docker.videokonverter/video-konverter/app/templates/admin.html
data 93983cf6ee fix: Tizen-App iframe + Cookie-Fix für Cross-Origin
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>
2026-03-07 08:36:13 +01:00

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 }}">&#9660;</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 ? "&#9650;" : "&#9660;";
}
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 %}