Thumbnails: - Negative Zaehlung gefixt (-23 von 5789): INNER JOIN statt separate COUNT - Verwaiste Thumbnail-Eintraege werden automatisch bereinigt - TVDB-Bilder werden lokal heruntergeladen statt extern verlinkt - Template nutzt nur noch lokale API, keine externen TVDB-URLs - Cache-Control: Thumbnails werden 7 Tage gecacht (Middleware ueberschreibt nicht mehr) - Fortschrittsbalken ins globale Progress-System verschoben (Thumbnails + Auto-Match) Watch-Status: - Feldnamen-Bug gefixt: position/duration -> position_sec/duration_sec - saveProgress(completed) setzt Position=Duration bei Video-Ende - Backend wertet completed-Flag aus Player: - Error-Recovery: Auto-Retry bei Video-Fehlern (2x) - Toast-Benachrichtigungen bei Stream-Fehlern (HLS, Netzwerk, Fallback) - onPlaying() Reset des Retry-Zaehlers Transcoding: - Neue Einstellung "Immer transcodieren" (force_transcode) im TV-Admin - Erzwingt H.264+AAC Transcoding fuer maximale Client-Kompatibilitaet - Kein Copy-Modus wenn aktiviert Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
424 lines
17 KiB
HTML
424 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{% block title %}VideoKonverter{% endblock %}</title>
|
|
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
|
|
<link rel="stylesheet" href="/static/css/style.css?v={{ v }}">
|
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
|
{% block head %}{% endblock %}
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="header-left">
|
|
<h1>VideoKonverter</h1>
|
|
</div>
|
|
<nav>
|
|
<a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
|
|
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
|
|
<a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a>
|
|
<a href="/tv-admin" class="nav-link {% if request.path == '/tv-admin' %}active{% endif %}">TV Admin</a>
|
|
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
|
|
</nav>
|
|
</header>
|
|
|
|
<!-- Globale Progress-Balken (mehrere gleichzeitig moeglich) -->
|
|
<div id="global-progress-container" class="global-progress-container">
|
|
<div id="gp-scan" class="global-progress" style="display:none">
|
|
<div class="global-progress-info">
|
|
<span class="gp-label">Scan</span>
|
|
<span class="gp-detail text-muted"></span>
|
|
</div>
|
|
<div class="progress-container" style="height:4px">
|
|
<div class="progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
<div id="gp-import" class="global-progress" style="display:none">
|
|
<div class="global-progress-info">
|
|
<span class="gp-label">Import</span>
|
|
<span class="gp-detail text-muted"></span>
|
|
</div>
|
|
<div class="progress-container" style="height:4px">
|
|
<div class="progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
<div id="gp-convert" class="global-progress" style="display:none">
|
|
<div class="global-progress-info">
|
|
<span class="gp-label">Konvertierung</span>
|
|
<span class="gp-detail text-muted"></span>
|
|
</div>
|
|
<div class="progress-container" style="height:4px">
|
|
<div class="progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
<div id="gp-thumbnails" class="global-progress" style="display:none">
|
|
<div class="global-progress-info">
|
|
<span class="gp-label">Thumbnails</span>
|
|
<span class="gp-detail text-muted"></span>
|
|
</div>
|
|
<div class="progress-container" style="height:4px">
|
|
<div class="progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
<div id="gp-automatch" class="global-progress" style="display:none">
|
|
<div class="global-progress-info">
|
|
<span class="gp-label">Auto-Match</span>
|
|
<span class="gp-detail text-muted"></span>
|
|
</div>
|
|
<div class="progress-container" style="height:4px">
|
|
<div class="progress-bar"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
|
|
<!-- Globaler Bestaetigungs-Dialog (ersetzt browser confirm()) -->
|
|
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
|
<div class="modal modal-small">
|
|
<div class="modal-header">
|
|
<h2 id="confirm-title">Bestaetigung</h2>
|
|
<button class="btn-close" onclick="closeConfirmModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding:1.2rem">
|
|
<div id="confirm-icon" style="text-align:center; font-size:3rem; margin-bottom:0.8rem">⚠</div>
|
|
<div id="confirm-message" style="text-align:center; margin-bottom:1rem"></div>
|
|
<div id="confirm-detail" style="text-align:center; font-size:0.85rem; color:#888; margin-bottom:1.2rem"></div>
|
|
<div class="form-actions" style="justify-content:center">
|
|
<button class="btn-danger" id="confirm-btn-ok" onclick="confirmAction()">Loeschen</button>
|
|
<button class="btn-secondary" onclick="closeConfirmModal()">Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Globaler Eingabe-Dialog (ersetzt browser prompt()) -->
|
|
<div id="prompt-modal" class="modal-overlay" style="display:none">
|
|
<div class="modal modal-small">
|
|
<div class="modal-header">
|
|
<h2 id="prompt-title">Eingabe</h2>
|
|
<button class="btn-close" onclick="closePromptModal()">×</button>
|
|
</div>
|
|
<div class="modal-body" style="padding:1.2rem">
|
|
<div id="prompt-message" style="margin-bottom:0.8rem"></div>
|
|
<input type="text" id="prompt-input" class="form-control" style="width:100%;margin-bottom:1rem"
|
|
onkeydown="if(event.key==='Enter')submitPrompt()">
|
|
<div class="form-actions" style="justify-content:center">
|
|
<button class="btn-primary" id="prompt-btn-ok" onclick="submitPrompt()">OK</button>
|
|
<button class="btn-secondary" onclick="closePromptModal()">Abbrechen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast-container"></div>
|
|
|
|
<!-- Benachrichtigungs-Glocke -->
|
|
<div id="notification-bell" class="notification-bell" onclick="toggleNotificationPanel()">
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
|
</svg>
|
|
<span id="notification-badge" class="notification-badge" style="display:none">0</span>
|
|
</div>
|
|
|
|
<!-- Log-Panel -->
|
|
<div id="notification-panel" class="notification-panel" style="display:none">
|
|
<div class="notification-header">
|
|
<span>Server-Log</span>
|
|
<div>
|
|
<button class="btn-small btn-secondary" onclick="clearNotifications()">Alle loeschen</button>
|
|
<button class="btn-close" onclick="toggleNotificationPanel()">×</button>
|
|
</div>
|
|
</div>
|
|
<div id="notification-list" class="notification-list">
|
|
<div class="notification-empty">Keine Nachrichten</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// === Benachrichtigungs-System ===
|
|
const notifications = [];
|
|
let unreadErrors = 0;
|
|
|
|
function toggleNotificationPanel() {
|
|
const panel = document.getElementById("notification-panel");
|
|
const isOpen = panel.style.display !== "none";
|
|
panel.style.display = isOpen ? "none" : "flex";
|
|
|
|
if (!isOpen) {
|
|
// Panel geoeffnet - Fehler als gelesen markieren
|
|
unreadErrors = 0;
|
|
updateBadge();
|
|
}
|
|
}
|
|
|
|
function updateBadge() {
|
|
const badge = document.getElementById("notification-badge");
|
|
const bell = document.getElementById("notification-bell");
|
|
|
|
if (unreadErrors > 0) {
|
|
badge.textContent = unreadErrors > 99 ? "99+" : unreadErrors;
|
|
badge.style.display = "";
|
|
bell.classList.add("has-error");
|
|
} else {
|
|
badge.style.display = "none";
|
|
bell.classList.remove("has-error");
|
|
}
|
|
}
|
|
|
|
function addNotification(msg, level = "info") {
|
|
const time = new Date().toLocaleTimeString("de-DE", {hour: "2-digit", minute: "2-digit", second: "2-digit"});
|
|
|
|
notifications.unshift({msg, level, time});
|
|
if (notifications.length > 100) notifications.pop();
|
|
|
|
if (level === "error" || level === "ERROR") {
|
|
unreadErrors++;
|
|
updateBadge();
|
|
}
|
|
|
|
renderNotifications();
|
|
}
|
|
|
|
function renderNotifications() {
|
|
const list = document.getElementById("notification-list");
|
|
if (!notifications.length) {
|
|
list.innerHTML = '<div class="notification-empty">Keine Nachrichten</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = notifications.map(n => {
|
|
const cls = n.level.toLowerCase() === "error" ? "notification-item error" :
|
|
n.level.toLowerCase() === "warning" ? "notification-item warning" :
|
|
"notification-item";
|
|
return `<div class="${cls}">
|
|
<span class="notification-time">${n.time}</span>
|
|
<span class="notification-msg">${escapeHtmlSimple(n.msg)}</span>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
function clearNotifications() {
|
|
notifications.length = 0;
|
|
unreadErrors = 0;
|
|
updateBadge();
|
|
renderNotifications();
|
|
}
|
|
|
|
function escapeHtmlSimple(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
// Log-Empfang per WebSocket (kein Polling mehr)
|
|
// WebSocket sendet {data_log: {level, message}} - wird in websocket.js
|
|
// oder hier abgefangen, je nachdem welche Seite geladen ist.
|
|
let _logWs = null;
|
|
|
|
function connectLogWebSocket() {
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const url = `${proto}//${location.host}/ws`;
|
|
_logWs = new WebSocket(url);
|
|
|
|
_logWs.onmessage = function(event) {
|
|
try {
|
|
const packet = JSON.parse(event.data);
|
|
if (packet.data_log) {
|
|
addNotification(packet.data_log.message, packet.data_log.level);
|
|
}
|
|
// Globaler Progress-Balken
|
|
_updateGlobalProgress(packet);
|
|
} catch (e) {
|
|
// JSON-Parse-Fehler ignorieren
|
|
}
|
|
};
|
|
|
|
_logWs.onclose = function() {
|
|
setTimeout(connectLogWebSocket, 5000);
|
|
};
|
|
}
|
|
|
|
// === Globale Progress-Balken (mehrere gleichzeitig) ===
|
|
let _gpHideTimers = {};
|
|
|
|
function _gpShow(id, labelText, detailText, pct) {
|
|
const el = document.getElementById("gp-" + id);
|
|
if (!el) return;
|
|
el.style.display = "";
|
|
el.querySelector(".gp-label").textContent = labelText;
|
|
el.querySelector(".gp-detail").textContent = detailText;
|
|
el.querySelector(".progress-bar").style.width = pct + "%";
|
|
_gpCancelHide(id);
|
|
}
|
|
|
|
function _gpHide(id) {
|
|
const el = document.getElementById("gp-" + id);
|
|
if (el) el.style.display = "none";
|
|
}
|
|
|
|
function _gpHideDelayed(id) {
|
|
const el = document.getElementById("gp-" + id);
|
|
if (el) el.querySelector(".progress-bar").style.width = "100%";
|
|
_gpHideTimers[id] = setTimeout(() => _gpHide(id), 3000);
|
|
}
|
|
|
|
function _gpCancelHide(id) {
|
|
if (_gpHideTimers[id]) {
|
|
clearTimeout(_gpHideTimers[id]);
|
|
delete _gpHideTimers[id];
|
|
}
|
|
}
|
|
|
|
function _updateGlobalProgress(packet) {
|
|
// Scan-Fortschritt
|
|
if (packet.data_library_scan) {
|
|
const d = packet.data_library_scan;
|
|
if (d.status === "idle") {
|
|
_gpHideDelayed("scan");
|
|
return;
|
|
}
|
|
const pct = d.total > 0 ? Math.round((d.done / d.total) * 100) : 0;
|
|
_gpShow("scan", "Scan", `${d.current || ""} (${d.done || 0}/${d.total || 0})`, pct);
|
|
}
|
|
|
|
// Import-Fortschritt
|
|
if (packet.data_import) {
|
|
const d = packet.data_import;
|
|
if (d.status === "done" || d.status === "error") {
|
|
_gpHideDelayed("import");
|
|
return;
|
|
}
|
|
const total = d.total || 1;
|
|
const processed = d.processed || 0;
|
|
let pct = (processed / total) * 100;
|
|
if (d.bytes_total > 0 && processed < total) {
|
|
pct += ((d.bytes_done || 0) / d.bytes_total) * (100 / total);
|
|
}
|
|
pct = Math.min(Math.round(pct), 100);
|
|
let labelText = "Import";
|
|
if (d.status === "analyzing") labelText = "Import-Analyse";
|
|
else if (d.status === "embedding") labelText = "Metadaten";
|
|
const detailText = d.current_file
|
|
? `${d.current_file} (${processed}/${total})`
|
|
: `${processed}/${total} Dateien`;
|
|
_gpShow("import", labelText, detailText, pct);
|
|
}
|
|
|
|
// Konvertierungs-Fortschritt
|
|
if (packet.data_flow) {
|
|
const d = packet.data_flow;
|
|
const pct = d.loading || 0;
|
|
const detailText = d.time_remaining ? `Verbleibend: ${d.time_remaining}` : `${pct.toFixed(1)}%`;
|
|
_gpShow("convert", "Konvertierung", detailText, pct);
|
|
}
|
|
|
|
// Konvertierung abgeschlossen (leere data_convert = nichts aktiv)
|
|
if (packet.data_convert !== undefined && Object.keys(packet.data_convert).length === 0) {
|
|
_gpHideDelayed("convert");
|
|
}
|
|
}
|
|
|
|
// === Globale Dialog-Funktionen (auf allen Seiten verfuegbar) ===
|
|
|
|
let _confirmResolve = null;
|
|
let _promptResolve = null;
|
|
let pendingConfirmAction = null;
|
|
|
|
/**
|
|
* Zeigt einen Bestaetigungs-Dialog (ersetzt confirm()).
|
|
* Gibt ein Promise zurueck das mit true/false aufgeloest wird.
|
|
*/
|
|
function showConfirm(message, {title = "Bestaetigung", detail = "", okText = "OK", icon = "warn", danger = false} = {}) {
|
|
return new Promise(resolve => {
|
|
_confirmResolve = resolve;
|
|
document.getElementById("confirm-title").textContent = title;
|
|
const iconEl = document.getElementById("confirm-icon");
|
|
if (icon === "warn") {
|
|
iconEl.innerHTML = '<span style="color:#f0ad4e">⚠</span>';
|
|
} else if (icon === "danger" || icon === "delete") {
|
|
iconEl.innerHTML = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="1.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
|
} else if (icon === "info") {
|
|
iconEl.innerHTML = '<span style="color:#4caf50">ℹ</span>';
|
|
} else {
|
|
iconEl.innerHTML = '<span>' + icon + '</span>';
|
|
}
|
|
document.getElementById("confirm-message").innerHTML = message;
|
|
document.getElementById("confirm-detail").innerHTML = detail;
|
|
const okBtn = document.getElementById("confirm-btn-ok");
|
|
okBtn.textContent = okText;
|
|
okBtn.className = danger ? "btn-danger" : "btn-primary";
|
|
document.getElementById("confirm-modal").style.display = "flex";
|
|
});
|
|
}
|
|
|
|
function closeConfirmModal() {
|
|
document.getElementById("confirm-modal").style.display = "none";
|
|
if (_confirmResolve) { _confirmResolve(false); _confirmResolve = null; }
|
|
pendingConfirmAction = null;
|
|
}
|
|
|
|
function confirmAction() {
|
|
if (_confirmResolve) { _confirmResolve(true); _confirmResolve = null; }
|
|
if (pendingConfirmAction) { pendingConfirmAction(); pendingConfirmAction = null; }
|
|
document.getElementById("confirm-modal").style.display = "none";
|
|
}
|
|
|
|
/**
|
|
* Zeigt einen Eingabe-Dialog (ersetzt prompt()).
|
|
* Gibt ein Promise zurueck das mit dem eingegebenen String oder null aufgeloest wird.
|
|
*/
|
|
function showPrompt(message, {title = "Eingabe", defaultValue = "", placeholder = "", okText = "OK"} = {}) {
|
|
return new Promise(resolve => {
|
|
_promptResolve = resolve;
|
|
document.getElementById("prompt-title").textContent = title;
|
|
document.getElementById("prompt-message").textContent = message;
|
|
const input = document.getElementById("prompt-input");
|
|
input.value = defaultValue;
|
|
input.placeholder = placeholder;
|
|
document.getElementById("prompt-btn-ok").textContent = okText;
|
|
document.getElementById("prompt-modal").style.display = "flex";
|
|
setTimeout(() => input.focus(), 100);
|
|
});
|
|
}
|
|
|
|
function closePromptModal() {
|
|
document.getElementById("prompt-modal").style.display = "none";
|
|
if (_promptResolve) { _promptResolve(null); _promptResolve = null; }
|
|
}
|
|
|
|
function submitPrompt() {
|
|
const val = document.getElementById("prompt-input").value.trim();
|
|
document.getElementById("prompt-modal").style.display = "none";
|
|
if (_promptResolve) { _promptResolve(val || null); _promptResolve = null; }
|
|
}
|
|
|
|
// === Globale Toast-Funktion (auf allen Seiten verfuegbar) ===
|
|
function showToast(message, type = "info") {
|
|
const container = document.getElementById("toast-container");
|
|
if (!container) return;
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast toast-${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => toast.classList.add("show"), 10);
|
|
setTimeout(() => {
|
|
toast.classList.remove("show");
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
|
|
// Nur Log-WebSocket starten wenn kein globaler WS existiert (Dashboard hat eigenen)
|
|
if (!window.WS_URL) {
|
|
connectLogWebSocket();
|
|
}
|
|
</script>
|
|
|
|
{% block scripts %}{% endblock %}
|
|
</body>
|
|
</html>
|