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>
369 lines
16 KiB
HTML
369 lines
16 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}TV Admin - VideoKonverter{% endblock %}
|
|
|
|
{% block content %}
|
|
<section class="admin-section">
|
|
<h2>TV Admin-Center</h2>
|
|
|
|
<form hx-post="/htmx/tv-settings" hx-target="#tv-save-result" hx-swap="innerHTML">
|
|
|
|
<!-- Streaming -->
|
|
<fieldset>
|
|
<legend>HLS Streaming</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="hls_segment_duration">Segment-Dauer (Sekunden)</label>
|
|
<input type="number" name="hls_segment_duration" id="hls_segment_duration"
|
|
value="{{ tv.hls_segment_duration | default(4) }}" min="1" max="30">
|
|
<span class="text-muted" style="font-size:0.8rem">Laenge der einzelnen HLS-Segmente</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="hls_init_duration">Erstes Segment (Sekunden)</label>
|
|
<input type="number" name="hls_init_duration" id="hls_init_duration"
|
|
value="{{ tv.hls_init_duration | default(1) }}" min="1" max="10">
|
|
<span class="text-muted" style="font-size:0.8rem">Kuerzeres erstes Segment fuer schnelleren Playback-Start</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="hls_session_timeout_min">Session-Timeout (Minuten)</label>
|
|
<input type="number" name="hls_session_timeout_min" id="hls_session_timeout_min"
|
|
value="{{ tv.hls_session_timeout_min | default(5) }}" min="1" max="60">
|
|
<span class="text-muted" style="font-size:0.8rem">Inaktive Sessions werden nach dieser Zeit beendet</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="hls_max_sessions">Max. gleichzeitige Sessions</label>
|
|
<input type="number" name="hls_max_sessions" id="hls_max_sessions"
|
|
value="{{ tv.hls_max_sessions | default(5) }}" min="1" max="20">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" name="pause_batch_on_stream" id="pause_batch_on_stream"
|
|
{% if tv.pause_batch_on_stream | default(true) %}checked{% endif %}>
|
|
Batch-Konvertierung bei Stream pausieren
|
|
</label>
|
|
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>
|
|
<input type="checkbox" name="force_transcode" id="force_transcode"
|
|
{% if tv.force_transcode | default(false) %}checked{% endif %}>
|
|
Immer transcodieren (kein Copy-Modus)
|
|
</label>
|
|
<span class="text-muted" style="font-size:0.8rem">Alle Videos werden zu H.264+AAC transcodiert. Langsamer, aber garantiert kompatibel fuer alle Clients (TV, Handy, Browser)</span>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<!-- Watch-Status -->
|
|
<fieldset>
|
|
<legend>Watch-Status</legend>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label for="watched_threshold_pct">Gesehen-Schwelle (%)</label>
|
|
<input type="number" name="watched_threshold_pct" id="watched_threshold_pct"
|
|
value="{{ tv.watched_threshold_pct | default(90) }}" min="50" max="100">
|
|
<span class="text-muted" style="font-size:0.8rem">Ab diesem Fortschritt gilt eine Episode als gesehen (Plex Standard: 90%)</span>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="btn-primary">Speichern</button>
|
|
<span id="tv-save-result"></span>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<!-- Aktive HLS-Sessions -->
|
|
<section class="admin-section">
|
|
<h2>Aktive HLS-Sessions</h2>
|
|
<div id="hls-sessions">
|
|
<div class="loading-msg">Lade Sessions...</div>
|
|
</div>
|
|
<button class="btn-secondary" onclick="loadHlsSessions()" style="margin-top:0.5rem">Aktualisieren</button>
|
|
</section>
|
|
|
|
<!-- TV-App / Streaming -->
|
|
<section class="admin-section">
|
|
<h2>TV-App</h2>
|
|
<div style="display:flex;gap:2rem;flex-wrap:wrap">
|
|
<!-- QR-Code -->
|
|
<div style="text-align:center">
|
|
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
|
|
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
|
|
<div style="margin-top:0.3rem">
|
|
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
|
|
</div>
|
|
</div>
|
|
<!-- User-Verwaltung -->
|
|
<div style="flex:1;min-width:300px">
|
|
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
|
|
<div id="tv-users-list">
|
|
<div class="loading-msg">Lade Benutzer...</div>
|
|
</div>
|
|
<!-- Neuer User -->
|
|
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
|
|
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
|
|
<div class="form-grid">
|
|
<div class="form-group">
|
|
<label>Benutzername</label>
|
|
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Anzeigename</label>
|
|
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Passwort</label>
|
|
<input type="password" id="tv-new-password" placeholder="Passwort">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Rechte</label>
|
|
<div style="display:flex;flex-direction:column;gap:0.3rem">
|
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
|
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
|
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
|
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-techinfo"> Technische Details</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// === HLS Sessions Monitoring ===
|
|
|
|
function loadHlsSessions() {
|
|
fetch("/api/tv/hls-sessions")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const container = document.getElementById("hls-sessions");
|
|
const sessions = data.sessions || [];
|
|
if (!sessions.length) {
|
|
container.innerHTML = '<div class="loading-msg">Keine aktiven Sessions</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = `
|
|
<table class="stats-table" style="width:100%">
|
|
<thead>
|
|
<tr>
|
|
<th>Session</th>
|
|
<th>Video</th>
|
|
<th>Qualitaet</th>
|
|
<th>Laufzeit</th>
|
|
<th>Inaktiv</th>
|
|
<th>Status</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${sessions.map(s => `
|
|
<tr>
|
|
<td><code>${s.session_id.substring(0, 8)}...</code></td>
|
|
<td>${escapeHtml(s.video_name || 'ID ' + s.video_id)}</td>
|
|
<td><span class="tag">${s.quality.toUpperCase()}</span></td>
|
|
<td>${formatDuration(s.age_sec)}</td>
|
|
<td>${formatDuration(s.idle_sec)}</td>
|
|
<td>${s.ready ? '<span class="status-badge ok">Aktiv</span>' : '<span class="status-badge warn">Startet...</span>'}</td>
|
|
<td><button class="btn-small btn-danger" onclick="destroyHlsSession('${s.session_id}')">Beenden</button></td>
|
|
</tr>
|
|
`).join("")}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("hls-sessions").innerHTML =
|
|
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
|
|
});
|
|
}
|
|
|
|
async function destroyHlsSession(sid) {
|
|
if (!await showConfirm("HLS-Session beenden?", {title: "Session beenden", okText: "Beenden", icon: "warn"})) return;
|
|
fetch("/api/tv/hls-sessions/" + sid, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(() => { showToast("Session beendet", "success"); loadHlsSessions(); })
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
function formatDuration(sec) {
|
|
if (sec < 60) return sec + "s";
|
|
if (sec < 3600) return Math.floor(sec / 60) + "m " + (sec % 60) + "s";
|
|
return Math.floor(sec / 3600) + "h " + Math.floor((sec % 3600) / 60) + "m";
|
|
}
|
|
|
|
// === TV-App User-Verwaltung ===
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return "";
|
|
return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
if (!str) return "";
|
|
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
|
|
}
|
|
|
|
function tvLoadUsers() {
|
|
fetch("/api/tv/users")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const container = document.getElementById("tv-users-list");
|
|
const users = data.users || [];
|
|
if (!users.length) {
|
|
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = users.map(u => `
|
|
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
|
<div>
|
|
<strong>${escapeHtml(u.display_name || u.username)}</strong>
|
|
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
|
|
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
|
|
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
|
|
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
|
|
${u.show_tech_info ? '<span class="tag">Tech-Info</span>' : ''}
|
|
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
|
|
</div>
|
|
<div style="display:flex;gap:0.3rem">
|
|
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
|
|
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
|
|
</div>
|
|
</div>
|
|
`).join("");
|
|
})
|
|
.catch(() => {
|
|
document.getElementById("tv-users-list").innerHTML =
|
|
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
|
|
});
|
|
}
|
|
|
|
function tvCreateUser() {
|
|
const username = document.getElementById("tv-new-username").value.trim();
|
|
const displayName = document.getElementById("tv-new-display").value.trim();
|
|
const password = document.getElementById("tv-new-password").value;
|
|
if (!username || !password) {
|
|
showToast("Benutzername und Passwort noetig", "error");
|
|
return;
|
|
}
|
|
fetch("/api/tv/users", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({
|
|
username: username,
|
|
password: password,
|
|
display_name: displayName || username,
|
|
is_admin: document.getElementById("tv-new-admin").checked,
|
|
can_view_series: document.getElementById("tv-new-series").checked,
|
|
can_view_movies: document.getElementById("tv-new-movies").checked,
|
|
show_tech_info: document.getElementById("tv-new-techinfo").checked,
|
|
}),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
document.getElementById("tv-new-username").value = "";
|
|
document.getElementById("tv-new-display").value = "";
|
|
document.getElementById("tv-new-password").value = "";
|
|
showToast("Benutzer erstellt", "success");
|
|
tvLoadUsers();
|
|
}
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function tvDeleteUser(userId, username) {
|
|
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
|
fetch("/api/tv/users/" + userId, {method: "DELETE"})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
showToast("Benutzer geloescht", "success");
|
|
tvLoadUsers();
|
|
}
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
async function tvEditUser(userId) {
|
|
// User-Daten laden, dann Edit-Dialog anzeigen
|
|
const resp = await fetch("/api/tv/users").then(r => r.json());
|
|
const user = (resp.users || []).find(u => u.id === userId);
|
|
if (!user) return;
|
|
|
|
const newPass = await showPrompt("Neues Passwort (leer lassen um beizubehalten):", {
|
|
title: "Benutzer bearbeiten: " + user.username,
|
|
placeholder: "Neues Passwort...",
|
|
okText: "Weiter"
|
|
});
|
|
if (newPass === null) return;
|
|
|
|
const updates = {};
|
|
if (newPass) updates.password = newPass;
|
|
|
|
const newSeries = confirm("Serien anzeigen?");
|
|
const newMovies = confirm("Filme anzeigen?");
|
|
const newAdmin = confirm("Admin-Rechte?");
|
|
const newTechInfo = confirm("Technische Details anzeigen?");
|
|
|
|
updates.can_view_series = newSeries;
|
|
updates.can_view_movies = newMovies;
|
|
updates.is_admin = newAdmin;
|
|
updates.show_tech_info = newTechInfo;
|
|
|
|
fetch("/api/tv/users/" + userId, {
|
|
method: "PUT",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify(updates),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
showToast("Fehler: " + data.error, "error");
|
|
} else {
|
|
showToast("Benutzer aktualisiert", "success");
|
|
tvLoadUsers();
|
|
}
|
|
})
|
|
.catch(e => showToast("Fehler: " + e, "error"));
|
|
}
|
|
|
|
// TV-URL laden
|
|
function tvLoadUrl() {
|
|
fetch("/api/tv/url")
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const link = document.getElementById("tv-link");
|
|
if (link && data.url) {
|
|
link.href = data.url;
|
|
link.textContent = data.url;
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// === Init ===
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
tvLoadUsers();
|
|
tvLoadUrl();
|
|
loadHlsSessions();
|
|
|
|
// HLS-Sessions alle 15 Sekunden aktualisieren
|
|
setInterval(loadHlsSessions, 15000);
|
|
});
|
|
</script>
|
|
{% endblock %}
|