PROBLEME BEHOBEN: - Schwarzes Bild beim Video-Abspielen (z-index & iframe-Overlap) - Login-Cookie wurde nicht gesetzt (Third-Party-Cookie-Blocking) ÄNDERUNGEN: Tizen-App (tizen-app/index.html): - z-index AVPlay von 0 auf 10 erhöht (über iframe) - iframe wird beim AVPlay-Start ausgeblendet (opacity: 0, pointerEvents: none) - iframe wird beim AVPlay-Stop wieder eingeblendet - Fix: <object id="avplayer"> nur im Parent, NICHT im iframe Player-Template (video-konverter/app/templates/tv/player.html): - <object id="avplayer"> entfernt (existiert nur im Parent-Frame) - AVPlay läuft ausschließlich im Tizen-App Parent-Frame Cookie-Fix (video-konverter/app/routes/tv_api.py): - SameSite=Lax → SameSite=None (4 Stellen) - Ermöglicht Session-Cookies im Cross-Origin-iframe - Login funktioniert jetzt in Tizen-App (tizen:// → http://) Neue Features: - VKNative Bridge (vknative-bridge.js): postMessage-Kommunikation iframe ↔ Parent - AVPlay Bridge (avplay-bridge.js): Legacy Direct-Play Support - Android-App Scaffolding (android-app/) TESTERGEBNIS: - ✅ Login erfolgreich (SameSite=None Cookie) - ✅ AVPlay Direct-Play funktioniert (samsung-agent/1.1) - ✅ Bildqualität gut (Hardware-Decoding) - ✅ Keine Stream-Unterbrechungen - ✅ Watch-Progress-Tracking funktioniert Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
513 lines
23 KiB
HTML
513 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Einstellungen - VideoKonverter{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="admin-section">
|
|
<h2>Einstellungen</h2>
|
|
|
|
<form hx-post="/htmx/settings" hx-target="#save-result" hx-swap="innerHTML">
|
|
|
|
<!-- Encoding -->
|
|
<fieldset>
|
|
<legend>Encoding</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="encoding_mode">Modus</label>
|
|
<select name="encoding_mode" id="encoding_mode">
|
|
<option value="cpu" {% if settings.encoding.mode == 'cpu' %}selected{% endif %}>CPU</option>
|
|
<option value="gpu" {% if settings.encoding.mode == 'gpu' %}selected{% endif %}>GPU (Intel VAAPI)</option>
|
|
<option value="auto" {% if settings.encoding.mode == 'auto' %}selected{% endif %}>Auto-Erkennung</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="gpu_device">GPU Device</label>
|
|
<select name="gpu_device" id="gpu_device">
|
|
{% for device in gpu_devices %}
|
|
<option value="{{ device }}" {% if device == settings.encoding.gpu_device %}selected{% endif %}>{{ device }}</option>
|
|
{% endfor %}
|
|
{% if not gpu_devices %}
|
|
<option value="/dev/dri/renderD128">Keine GPU erkannt</option>
|
|
{% endif %}
|
|
</select>
|
|
{% if gpu_available %}
|
|
<span class="status-badge ok">GPU verfuegbar</span>
|
|
{% else %}
|
|
<span class="status-badge warn">Keine GPU</span>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="default_preset">Standard-Preset</label>
|
|
<select name="default_preset" id="default_preset">
|
|
{% for key, preset in presets.items() %}
|
|
<option value="{{ key }}" {% if key == settings.encoding.default_preset %}selected{% endif %}>{{ preset.name }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="max_parallel_jobs">Max. parallele Jobs</label>
|
|
<input type="number" name="max_parallel_jobs" id="max_parallel_jobs"
|
|
value="{{ settings.encoding.max_parallel_jobs }}" min="1" max="8">
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Dateien -->
|
|
<fieldset>
|
|
<legend>Dateien</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="target_container">Ziel-Container</label>
|
|
<select name="target_container" id="target_container">
|
|
<option value="webm" {% if settings.files.target_container == 'webm' %}selected{% endif %}>WebM (AV1/Opus)</option>
|
|
<option value="mkv" {% if settings.files.target_container == 'mkv' %}selected{% endif %}>MKV (Matroska)</option>
|
|
<option value="mp4" {% if settings.files.target_container == 'mp4' %}selected{% endif %}>MP4</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="target_folder">Ziel-Ordner</label>
|
|
<input type="text" name="target_folder" id="target_folder"
|
|
value="{{ settings.files.target_folder }}"
|
|
placeholder="same = gleicher Ordner">
|
|
</div>
|
|
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" name="delete_source"
|
|
{% if settings.files.delete_source %}checked{% endif %}>
|
|
Quelldatei nach Konvertierung loeschen
|
|
</label>
|
|
</div>
|
|
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" name="recursive_scan"
|
|
{% if settings.files.recursive_scan %}checked{% endif %}>
|
|
Unterordner rekursiv scannen
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Cleanup -->
|
|
<fieldset>
|
|
<legend>Cleanup</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" name="cleanup_enabled"
|
|
{% if settings.cleanup.enabled %}checked{% endif %}>
|
|
Auto-Cleanup aktivieren
|
|
</label>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Zu loeschende Extensions</label>
|
|
<input type="text" name="cleanup_extensions"
|
|
value="{{ settings.cleanup.delete_extensions | join(', ') }}"
|
|
placeholder=".avi, .wmv, .nfo, .txt, .jpg">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Ausnahmen (Muster)</label>
|
|
<input type="text" name="cleanup_exclude"
|
|
value="{{ settings.cleanup.exclude_patterns | join(', ') }}"
|
|
placeholder="readme*, *.md">
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Audio -->
|
|
<fieldset>
|
|
<legend>Audio</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="audio_languages">Sprachen</label>
|
|
<input type="text" name="audio_languages" id="audio_languages"
|
|
value="{{ settings.audio.languages | join(', ') }}"
|
|
placeholder="ger, eng, und">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="audio_codec">Codec</label>
|
|
<select name="audio_codec" id="audio_codec">
|
|
<option value="libopus" {% if settings.audio.default_codec == 'libopus' %}selected{% endif %}>Opus</option>
|
|
<option value="aac" {% if settings.audio.default_codec == 'aac' %}selected{% endif %}>AAC</option>
|
|
<option value="copy" {% if settings.audio.default_codec == 'copy' %}selected{% endif %}>Stream Copy</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" name="keep_channels"
|
|
{% if settings.audio.keep_channels %}checked{% endif %}>
|
|
Kanalanzahl beibehalten (kein Downmix)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Untertitel -->
|
|
<fieldset>
|
|
<legend>Untertitel</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="subtitle_languages">Sprachen</label>
|
|
<input type="text" name="subtitle_languages" id="subtitle_languages"
|
|
value="{{ settings.subtitle.languages | join(', ') }}"
|
|
placeholder="ger, eng">
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- TVDB / Bibliothek -->
|
|
<fieldset>
|
|
<legend>Bibliothek / TVDB</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="tvdb_api_key">TVDB API Key</label>
|
|
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
|
|
value="{{ settings.library.tvdb_api_key if settings.library else '' }}"
|
|
placeholder="API Key von thetvdb.com"
|
|
autocomplete="off" spellcheck="false">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="tvdb_pin">TVDB PIN</label>
|
|
<input type="text" name="tvdb_pin" id="tvdb_pin"
|
|
value="{{ settings.library.tvdb_pin if settings.library else '' }}"
|
|
placeholder="Subscriber PIN (optional)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="tvdb_language">TVDB Sprache</label>
|
|
<select name="tvdb_language" id="tvdb_language">
|
|
{% set lang = settings.library.tvdb_language if settings.library and settings.library.tvdb_language else 'deu' %}
|
|
<option value="deu" {% if lang == 'deu' %}selected{% endif %}>Deutsch</option>
|
|
<option value="eng" {% if lang == 'eng' %}selected{% endif %}>English</option>
|
|
<option value="fra" {% if lang == 'fra' %}selected{% endif %}>Francais</option>
|
|
<option value="spa" {% if lang == 'spa' %}selected{% endif %}>Espanol</option>
|
|
<option value="ita" {% if lang == 'ita' %}selected{% endif %}>Italiano</option>
|
|
<option value="jpn" {% if lang == 'jpn' %}selected{% endif %}>Japanese</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Logging -->
|
|
<fieldset>
|
|
<legend>Logging</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="log_level">Log-Level</label>
|
|
<select name="log_level" id="log_level">
|
|
{% for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR'] %}
|
|
<option value="{{ level }}" {% if level == settings.logging.level %}selected{% endif %}>{{ level }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn-primary">Speichern</button>
|
|
</div>
|
|
<div id="save-result"></div>
|
|
</form>
|
|
</section>
|
|
|
|
<!-- Scan-Pfade -->
|
|
<section class="admin-section">
|
|
<h2>Bibliothek - Scan-Pfade</h2>
|
|
<div id="library-paths">
|
|
<div class="loading-msg">Lade Pfade...</div>
|
|
</div>
|
|
<div class="form-grid" style="margin-top:1rem">
|
|
<div class="form-group">
|
|
<label>Name</label>
|
|
<input type="text" id="new-path-name" placeholder="z.B. Serien">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Pfad</label>
|
|
<input type="text" id="new-path-path" placeholder="/mnt/30 - Media/Serien">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Typ</label>
|
|
<select id="new-path-type">
|
|
<option value="series">Serien</option>
|
|
<option value="movie">Filme</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group" style="justify-content:flex-end">
|
|
<button class="btn-primary" onclick="addLibraryPath()">Pfad hinzufuegen</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Presets -->
|
|
<section class="admin-section">
|
|
<h2>Encoding-Presets</h2>
|
|
<div class="preset-editor" id="preset-editor">
|
|
{% for key, preset in presets.items() %}
|
|
<div class="preset-edit-card" id="preset-{{ key }}">
|
|
<div class="preset-header" onclick="togglePresetEdit('{{ key }}')">
|
|
<div class="preset-header-left">
|
|
<h3>{{ preset.name }}</h3>
|
|
<div class="preset-details">
|
|
<span class="tag">{{ preset.video_codec }}</span>
|
|
<span class="tag">{{ preset.container }}</span>
|
|
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
|
|
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
|
|
{% if key == settings.encoding.default_preset %}<span class="tag default">Standard</span>{% endif %}
|
|
</div>
|
|
</div>
|
|
<span class="preset-toggle" id="toggle-{{ key }}">▼</span>
|
|
</div>
|
|
|
|
<div class="preset-body" id="preset-body-{{ key }}" style="display:none">
|
|
<form hx-post="/htmx/preset/{{ key }}" hx-target="#preset-result-{{ key }}" hx-swap="innerHTML">
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Anzeigename</label>
|
|
<input type="text" name="name" value="{{ preset.name }}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Video-Codec</label>
|
|
<select name="video_codec">
|
|
{% for codec, label in [
|
|
('av1_vaapi', 'GPU AV1 (VAAPI)'),
|
|
('hevc_vaapi', 'GPU HEVC (VAAPI)'),
|
|
('h264_vaapi', 'GPU H.264 (VAAPI)'),
|
|
('libsvtav1', 'CPU SVT-AV1'),
|
|
('libx265', 'CPU x265'),
|
|
('libx264', 'CPU x264'),
|
|
('libvpx-vp9', 'CPU VP9')
|
|
] %}
|
|
<option value="{{ codec }}" {% if preset.video_codec == codec %}selected{% endif %}>{{ label }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Container</label>
|
|
<select name="container">
|
|
<option value="webm" {% if preset.container == 'webm' %}selected{% endif %}>WebM</option>
|
|
<option value="mkv" {% if preset.container == 'mkv' %}selected{% endif %}>MKV</option>
|
|
<option value="mp4" {% if preset.container == 'mp4' %}selected{% endif %}>MP4</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Qualitaetsparameter</label>
|
|
<select name="quality_param">
|
|
<option value="crf" {% if preset.quality_param == 'crf' %}selected{% endif %}>CRF (Constant Rate Factor)</option>
|
|
<option value="qp" {% if preset.quality_param == 'qp' %}selected{% endif %}>QP (Quantization Parameter)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Qualitaetswert <small>(niedrig = besser)</small></label>
|
|
<input type="number" name="quality_value" value="{{ preset.quality_value }}" min="0" max="63">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>GOP-Groesse <small>(Keyframe-Intervall)</small></label>
|
|
<input type="number" name="gop_size" value="{{ preset.gop_size }}" min="1" max="1000">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Speed-Preset <small>(nur CPU)</small></label>
|
|
<input type="text" name="speed_preset"
|
|
value="{{ preset.speed_preset if preset.speed_preset is not none else '' }}"
|
|
placeholder="z.B. 5 (SVT-AV1) oder medium (x264/x265)">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Video-Filter</label>
|
|
<input type="text" name="video_filter" value="{{ preset.video_filter }}"
|
|
placeholder="z.B. format=nv12,hwupload">
|
|
</div>
|
|
<div class="form-group checkbox-group">
|
|
<label>
|
|
<input type="checkbox" name="hw_init" {% if preset.hw_init %}checked{% endif %}>
|
|
Hardware-Init (GPU VAAPI)
|
|
</label>
|
|
</div>
|
|
<div class="form-group" style="grid-column: 1 / -1">
|
|
<label>Extra-Parameter <small>(key=value, je Zeile)</small></label>
|
|
<textarea name="extra_params" rows="3"
|
|
placeholder="svtav1-params=tune=0:film-grain=8">{% for k, v in (preset.extra_params or {}).items() %}{{ k }}={{ v }}{% if not loop.last %}
|
|
{% endif %}{% endfor %}</textarea>
|
|
</div>
|
|
</div>
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn-primary">Speichern</button>
|
|
{% if key != settings.encoding.default_preset %}
|
|
<button type="button" class="btn-secondary" onclick="setDefaultPreset('{{ key }}')">
|
|
Als Standard setzen
|
|
</button>
|
|
<button type="button" class="btn-danger" onclick="deletePreset('{{ key }}')">
|
|
Loeschen
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
<div id="preset-result-{{ key }}"></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
<div style="margin-top:1rem">
|
|
<button class="btn-secondary" onclick="showNewPresetForm()">+ Neues Preset</button>
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// Scan-Pfade Verwaltung
|
|
function loadLibraryPaths() {
|
|
fetch("/api/library/paths")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const container = document.getElementById("library-paths");
|
|
const paths = data.paths || [];
|
|
if (!paths.length) {
|
|
container.innerHTML = '<div class="loading-msg">Keine Scan-Pfade konfiguriert</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = paths.map(p => `
|
|
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
|
<div>
|
|
<strong>${p.name}</strong>
|
|
<span class="tag">${p.media_type === 'series' ? 'Serien' : 'Filme'}</span>
|
|
<br><span style="font-size:0.8rem;color:#888">${p.path}</span>
|
|
${p.last_scan ? '<br><span style="font-size:0.75rem;color:#666">Letzter Scan: ' + p.last_scan + '</span>' : ''}
|
|
</div>
|
|
<div style="display:flex;gap:0.3rem">
|
|
<button class="btn-small btn-secondary" onclick="scanPath(${p.id})">Scannen</button>
|
|
<button class="btn-small btn-danger" onclick="deletePath(${p.id})">Loeschen</button>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("library-paths").innerHTML =
|
|
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
|
|
});
|
|
}
|
|
|
|
function addLibraryPath() {
|
|
const name = document.getElementById("new-path-name").value.trim();
|
|
const path = document.getElementById("new-path-path").value.trim();
|
|
const mediaType = document.getElementById("new-path-type").value;
|
|
if (!name || !path) {
|
|
showToast("Name und Pfad erforderlich", "error");
|
|
return;
|
|
}
|
|
fetch("/api/library/paths", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({name: name, path: path, media_type: mediaType}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
document.getElementById("new-path-name").value = "";
|
|
document.getElementById("new-path-path").value = "";
|
|
showToast("Pfad hinzugefuegt", "success");
|
|
loadLibraryPaths();
|
|
}
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function deletePath(pathId) {
|
|
if (!await showConfirm("Scan-Pfad und alle zugehoerigen Daten loeschen?", {title: "Pfad loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
fetch("/api/library/paths/" + pathId, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(() => { showToast("Pfad geloescht", "success"); loadLibraryPaths(); })
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function scanPath(pathId) {
|
|
fetch("/api/library/scan/" + pathId, {method: "POST"})
|
|
.then(r => r.json())
|
|
.then(data => showToast(data.message || "Scan gestartet", "success"))
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
|
|
// === Preset-Editor ===
|
|
|
|
function togglePresetEdit(key) {
|
|
const body = document.getElementById("preset-body-" + key);
|
|
const toggle = document.getElementById("toggle-" + key);
|
|
const open = body.style.display === "none";
|
|
body.style.display = open ? "" : "none";
|
|
toggle.innerHTML = open ? "▲" : "▼";
|
|
}
|
|
|
|
async function setDefaultPreset(key) {
|
|
const resp = await fetch("/api/settings", {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({encoding: {default_preset: key}})
|
|
});
|
|
if (resp.ok) {
|
|
showToast("Standard-Preset geaendert", "success");
|
|
location.reload();
|
|
} else {
|
|
showToast("Fehler beim Aendern", "error");
|
|
}
|
|
}
|
|
|
|
async function deletePreset(key) {
|
|
if (!await showConfirm('Preset "' + key + '" wirklich loeschen?',
|
|
{title: "Preset loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
|
|
const resp = await fetch("/api/presets/" + key, {method: "DELETE"});
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
showToast("Preset geloescht", "success");
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
async function showNewPresetForm() {
|
|
const key = prompt("Preset-Schluessel (z.B. gpu_vp9):");
|
|
if (!key) return;
|
|
if (!/^[a-z][a-z0-9_]*$/.test(key.trim())) {
|
|
showToast("Nur Kleinbuchstaben, Zahlen und Unterstriche erlaubt", "error");
|
|
return;
|
|
}
|
|
const resp = await fetch("/api/presets", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
key: key.trim(),
|
|
preset: {
|
|
name: key.trim(),
|
|
video_codec: "libx264",
|
|
container: "mp4",
|
|
quality_param: "crf",
|
|
quality_value: 23,
|
|
gop_size: 240,
|
|
video_filter: "",
|
|
hw_init: false,
|
|
extra_params: {}
|
|
}
|
|
})
|
|
});
|
|
const data = await resp.json();
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
showToast("Preset erstellt", "success");
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
loadLibraryPaths();
|
|
});
|
|
</script>
|
|
{% endblock %}
|