From d61fd5bc04a762d0a7667a429c81cf1ce64efc3f Mon Sep 17 00:00:00 2001 From: data Date: Wed, 4 Mar 2026 06:30:39 +0100 Subject: [PATCH] feat: VideoKonverter v4.3 - Thumbnails, Watch-Status, Transcoding-Settings 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 --- video-konverter/app/routes/library_api.py | 162 ++++++++++++++---- video-konverter/app/routes/pages.py | 2 + video-konverter/app/routes/tv_api.py | 6 +- video-konverter/app/server.py | 8 +- video-konverter/app/services/hls.py | 6 + video-konverter/app/static/js/library.js | 72 +++----- video-konverter/app/static/tv/js/player.js | 59 ++++++- video-konverter/app/templates/base.html | 18 ++ video-konverter/app/templates/library.html | 16 -- .../app/templates/tv/series_detail.html | 8 +- video-konverter/app/templates/tv_admin.html | 9 + 11 files changed, 241 insertions(+), 125 deletions(-) diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py index 1a595f4..eb7647a 100644 --- a/video-konverter/app/routes/library_api.py +++ b/video-konverter/app/routes/library_api.py @@ -1680,9 +1680,38 @@ def setup_library_routes(app: web.Application, config: Config, # === Episoden-Thumbnails === + async def _save_thumbnail_to_db(pool, video_id, thumb_path, source): + """Speichert Thumbnail-Pfad in der DB.""" + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO tv_episode_thumbnails + (video_id, thumbnail_path, source) + VALUES (%s, %s, %s) + ON DUPLICATE KEY UPDATE + thumbnail_path = VALUES(thumbnail_path), + source = VALUES(source) + """, (video_id, thumb_path, source)) + + async def _download_tvdb_image(url, thumb_path): + """Laedt TVDB-Bild herunter und speichert es lokal.""" + import aiohttp as _aiohttp + try: + async with _aiohttp.ClientSession() as session: + async with session.get(url, timeout=_aiohttp.ClientTimeout(total=15)) as resp: + if resp.status == 200: + data = await resp.read() + if len(data) > 100: # Kein leeres/fehlerhaftes Bild + with open(thumb_path, "wb") as f: + f.write(data) + return True + except Exception as e: + logging.debug(f"TVDB-Bild Download fehlgeschlagen: {e}") + return False + async def get_video_thumbnail(request: web.Request) -> web.Response: """GET /api/library/videos/{video_id}/thumbnail - Gibt Thumbnail zurueck. Generiert per ffmpeg bei Erstaufruf.""" + Gibt Thumbnail zurueck. Prioritaet: Lokal > TVDB-Download > ffmpeg.""" import os import asyncio as _asyncio @@ -1693,7 +1722,7 @@ def setup_library_routes(app: web.Application, config: Config, return web.json_response( {"error": "Keine DB-Verbindung"}, status=500) - # Pruefen ob bereits generiert + # Pruefen ob bereits lokal vorhanden try: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: @@ -1707,28 +1736,59 @@ def setup_library_routes(app: web.Application, config: Config, cached["thumbnail_path"], headers={"Cache-Control": "public, max-age=604800"}) - # Video-Info laden - await cur.execute( - "SELECT file_path, duration_sec FROM library_videos " - "WHERE id = %s", (video_id,)) + # Video-Info + TVDB-Bild-URL laden + await cur.execute(""" + SELECT v.file_path, v.duration_sec, + v.series_id, v.season_number, v.episode_number + FROM library_videos v + WHERE v.id = %s + """, (video_id,)) video = await cur.fetchone() if not video or not os.path.isfile(video["file_path"]): return web.json_response( {"error": "Video nicht gefunden"}, status=404) + + # TVDB-Bild-URL pruefen + tvdb_image_url = None + if video.get("series_id"): + await cur.execute( + "SELECT tvdb_id FROM library_series " + "WHERE id = %s", (video["series_id"],)) + s = await cur.fetchone() + if s and s.get("tvdb_id"): + await cur.execute(""" + SELECT image_url FROM tvdb_episode_cache + WHERE series_tvdb_id = %s + AND season_number = %s + AND episode_number = %s + """, (s["tvdb_id"], + video.get("season_number") or 0, + video.get("episode_number") or 0)) + tc = await cur.fetchone() + if tc and tc.get("image_url"): + tvdb_image_url = tc["image_url"] except Exception as e: return web.json_response({"error": str(e)}, status=500) - # Thumbnail generieren per ffmpeg (Frame bei 25%) - file_path = video["file_path"] - duration = video.get("duration_sec") or 0 - seek_pos = duration * 0.25 if duration > 10 else 5 - # Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei + file_path = video["file_path"] video_dir = os.path.dirname(file_path) thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails") os.makedirs(thumb_dir, exist_ok=True) thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg") + # Versuch 1: TVDB-Bild herunterladen + if tvdb_image_url: + if await _download_tvdb_image(tvdb_image_url, thumb_path): + await _save_thumbnail_to_db(pool, video_id, thumb_path, "tvdb") + return web.FileResponse( + thumb_path, + headers={"Cache-Control": "public, max-age=604800"}) + + # Versuch 2: Per ffmpeg generieren (Frame bei 25%) + duration = video.get("duration_sec") or 0 + seek_pos = duration * 0.25 if duration > 10 else 5 + cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", str(int(seek_pos)), @@ -1748,16 +1808,8 @@ def setup_library_routes(app: web.Application, config: Config, await proc.communicate() if proc.returncode == 0 and os.path.isfile(thumb_path): - # In DB cachen - async with pool.acquire() as conn: - async with conn.cursor() as cur: - await cur.execute(""" - INSERT INTO tv_episode_thumbnails - (video_id, thumbnail_path, source) - VALUES (%s, %s, 'ffmpeg') - ON DUPLICATE KEY UPDATE - thumbnail_path = VALUES(thumbnail_path) - """, (video_id, thumb_path)) + await _save_thumbnail_to_db( + pool, video_id, thumb_path, "ffmpeg") return web.FileResponse( thumb_path, headers={"Cache-Control": "public, max-age=604800"}) @@ -1800,14 +1852,37 @@ def setup_library_routes(app: web.Application, config: Config, generated = 0 errors = 0 try: + # Verwaiste Thumbnail-Eintraege bereinigen + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + DELETE FROM tv_episode_thumbnails + WHERE video_id NOT IN ( + SELECT id FROM library_videos + ) + """) + orphaned = cur.rowcount + if orphaned: + logging.info( + f"Thumbnail-Batch: {orphaned} verwaiste " + f"Eintraege bereinigt" + ) + async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: - # Videos ohne Thumbnail finden + # Videos ohne Thumbnail finden (mit TVDB-Bild-URL) sql = """ - SELECT v.id, v.file_path, v.duration_sec + SELECT v.id, v.file_path, v.duration_sec, + tc.image_url AS tvdb_image_url FROM library_videos v LEFT JOIN tv_episode_thumbnails t ON v.id = t.video_id + LEFT JOIN library_series s + ON v.series_id = s.id + LEFT JOIN tvdb_episode_cache tc + ON tc.series_tvdb_id = s.tvdb_id + AND tc.season_number = v.season_number + AND tc.episode_number = v.episode_number WHERE t.video_id IS NULL """ params = [] @@ -1821,6 +1896,7 @@ def setup_library_routes(app: web.Application, config: Config, logging.info( f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail" ) + downloaded = 0 for video in videos: vid = video["id"] @@ -1830,12 +1906,23 @@ def setup_library_routes(app: web.Application, config: Config, if not os.path.isfile(fp): continue - seek = dur * 0.25 if dur > 10 else 5 vdir = os.path.dirname(fp) tdir = os.path.join(vdir, ".metadata", "thumbnails") os.makedirs(tdir, exist_ok=True) tpath = os.path.join(tdir, f"{vid}.jpg") + # Prioritaet 1: TVDB-Bild herunterladen + tvdb_url = video.get("tvdb_image_url") + if tvdb_url: + if await _download_tvdb_image(tvdb_url, tpath): + await _save_thumbnail_to_db( + pool, vid, tpath, "tvdb") + generated += 1 + downloaded += 1 + continue + + # Prioritaet 2: Per ffmpeg generieren + seek = dur * 0.25 if dur > 10 else 5 cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", str(int(seek)), @@ -1853,15 +1940,8 @@ def setup_library_routes(app: web.Application, config: Config, await proc.communicate() if proc.returncode == 0 and os.path.isfile(tpath): - async with pool.acquire() as conn2: - async with conn2.cursor() as cur2: - await cur2.execute(""" - INSERT INTO tv_episode_thumbnails - (video_id, thumbnail_path, source) - VALUES (%s, %s, 'ffmpeg') - ON DUPLICATE KEY UPDATE - thumbnail_path = VALUES(thumbnail_path) - """, (vid, tpath)) + await _save_thumbnail_to_db( + pool, vid, tpath, "ffmpeg") generated += 1 else: errors += 1 @@ -1870,7 +1950,8 @@ def setup_library_routes(app: web.Application, config: Config, errors += 1 logging.info( - f"Thumbnail-Batch fertig: {generated} erzeugt, " + f"Thumbnail-Batch fertig: {generated} erzeugt " + f"({downloaded} TVDB, {generated - downloaded} ffmpeg), " f"{errors} Fehler" ) except Exception as e: @@ -1896,18 +1977,23 @@ def setup_library_routes(app: web.Application, config: Config, async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: - await cur.execute( - "SELECT COUNT(*) AS cnt FROM tv_episode_thumbnails") - done = (await cur.fetchone())["cnt"] await cur.execute( "SELECT COUNT(*) AS cnt FROM library_videos") total = (await cur.fetchone())["cnt"] + # Nur Thumbnails zaehlen die auch ein existierendes Video haben + await cur.execute(""" + SELECT COUNT(*) AS cnt + FROM tv_episode_thumbnails t + INNER JOIN library_videos v ON t.video_id = v.id + """) + done = (await cur.fetchone())["cnt"] + missing = max(0, total - done) return web.json_response({ "running": running, "generated": done, "total": total, - "missing": total - done, + "missing": missing, }) # === Import: Item zuordnen / ueberspringen === diff --git a/video-konverter/app/routes/pages.py b/video-konverter/app/routes/pages.py index 8a66c79..80f66f1 100644 --- a/video-konverter/app/routes/pages.py +++ b/video-konverter/app/routes/pages.py @@ -154,6 +154,8 @@ def setup_page_routes(app: web.Application, config: Config, data.get("hls_max_sessions", 5)) settings["tv"]["pause_batch_on_stream"] = ( data.get("pause_batch_on_stream") == "on") + settings["tv"]["force_transcode"] = ( + data.get("force_transcode") == "on") settings["tv"]["watched_threshold_pct"] = int( data.get("watched_threshold_pct", 90)) diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index c849bd3..162897a 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -527,7 +527,6 @@ def setup_tv_routes(app: web.Application, config: Config, v.width, v.height, v.video_codec, v.container, tc.overview AS ep_overview, - tc.image_url AS ep_image_url, wp.position_sec, wp.duration_sec AS wp_duration FROM library_videos v LEFT JOIN tvdb_episode_cache tc @@ -958,11 +957,16 @@ def setup_tv_routes(app: web.Application, config: Config, video_id = data.get("video_id") position = data.get("position_sec", 0) duration = data.get("duration_sec", 0) + completed = data.get("completed", False) if not video_id: return web.json_response( {"error": "video_id fehlt"}, status=400) + # Bei explizitem completed-Flag: Position = Duration setzen + if completed and duration > 0: + position = duration + await auth_service.save_progress( user["id"], video_id, position, duration ) diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index aa65e7c..b095522 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -68,9 +68,11 @@ class VideoKonverterServer: exc_info=True) raise if request.path.startswith("/api/"): - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" + # Thumbnail-Bilder sollen gecacht werden (Cache-Control vom Handler) + if "/thumbnail" not in request.path: + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response def _setup_app(self) -> None: diff --git a/video-konverter/app/services/hls.py b/video-konverter/app/services/hls.py index 38f9d03..a41a65a 100644 --- a/video-konverter/app/services/hls.py +++ b/video-konverter/app/services/hls.py @@ -194,6 +194,12 @@ class HLSSessionManager: # Audio-Transcoding noetig? needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS + # Force-Transcode: Immer transcodieren fuer maximale Kompatibilitaet + if self._tv_setting("force_transcode", False): + needs_video_transcode = True + if audio_codec not in BROWSER_AUDIO_CODECS: + needs_audio_transcode = True + # Sound-Modus if sound_mode == "stereo": out_channels = 2 diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js index ecdcbbf..3b63c02 100644 --- a/video-konverter/app/static/js/library.js +++ b/video-konverter/app/static/js/library.js @@ -1414,67 +1414,57 @@ let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen async function startAutoMatch() { if (!await showConfirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?", {title: "Auto-Match starten", detail: "Das kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.", okText: "Starten", icon: "info"})) return; - const progress = document.getElementById("auto-match-progress"); - progress.style.display = "block"; - document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege..."; - document.getElementById("auto-match-bar").style.width = "0%"; + _gpShow("automatch", "Auto-Match", "Suche TVDB-Vorschlaege...", 0); fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"}) .then(r => r.json()) .then(data => { if (data.error) { - document.getElementById("auto-match-status").textContent = "Fehler: " + data.error; - setTimeout(() => { progress.style.display = "none"; }, 3000); + _gpShow("automatch", "Auto-Match", "Fehler: " + data.error, 0); + _gpHideDelayed("automatch"); return; } pollAutoMatchStatus(); }) .catch(e => { - document.getElementById("auto-match-status").textContent = "Fehler: " + e; + _gpShow("automatch", "Auto-Match", "Fehler: " + e, 0); + _gpHideDelayed("automatch"); }); } function pollAutoMatchStatus() { - const progress = document.getElementById("auto-match-progress"); const interval = setInterval(() => { fetch("/api/library/tvdb-auto-match-status") .then(r => r.json()) .then(data => { - const bar = document.getElementById("auto-match-bar"); - const status = document.getElementById("auto-match-status"); - if (data.phase === "done") { clearInterval(interval); - bar.style.width = "100%"; const suggestions = data.suggestions || []; - // Nur Items mit mindestens einem Vorschlag anzeigen const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0); const noResults = suggestions.length - withSuggestions.length; - status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`; - - setTimeout(() => { - progress.style.display = "none"; - }, 2000); + _gpShow("automatch", "Auto-Match", + withSuggestions.length + " Vorschlaege, " + noResults + " ohne Ergebnis", 100); + _gpHideDelayed("automatch"); if (withSuggestions.length > 0) { openTvdbReviewModal(withSuggestions); } } else if (data.phase === "error") { clearInterval(interval); - status.textContent = "Fehler beim Sammeln der Vorschlaege"; - setTimeout(() => { progress.style.display = "none"; }, 3000); + _gpShow("automatch", "Auto-Match", "Fehler", 0); + _gpHideDelayed("automatch"); } else if (!data.active && data.phase !== "done") { clearInterval(interval); - progress.style.display = "none"; + _gpHide("automatch"); } else { const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0; - bar.style.width = pct + "%"; const phase = data.phase === "series" ? "Serien" : "Filme"; - status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`; + _gpShow("automatch", "Auto-Match", + phase + ": " + (data.current || "") + " (" + data.done + "/" + data.total + ")", pct); } }) .catch(() => clearInterval(interval)); - }, 5000); // 5s Fallback (WS liefert Live-Updates) + }, 5000); } // === TVDB Review-Modal === @@ -3211,46 +3201,24 @@ async function generateThumbnails() { } else { showToast("Thumbnail-Generierung gestartet", "success"); } - // Fortschrittsbalken anzeigen und Polling starten - showThumbnailProgress(); + // Globalen Fortschrittsbalken anzeigen und Polling starten + _gpShow("thumbnails", "Thumbnails", "Starte...", 0); pollThumbnailStatus(); }) .catch(e => showToast("Fehler: " + e, "error")); } -function showThumbnailProgress() { - const container = document.getElementById("thumbnail-progress"); - if (container) container.style.display = ""; -} - -function hideThumbnailProgress() { - const container = document.getElementById("thumbnail-progress"); - if (container) container.style.display = "none"; -} - -function updateThumbnailProgress(generated, total) { - const bar = document.getElementById("thumbnail-bar"); - const status = document.getElementById("thumbnail-status"); - if (!bar || !status) return; - - const pct = total > 0 ? Math.round((generated / total) * 100) : 0; - bar.style.width = pct + "%"; - status.textContent = "Thumbnails: " + generated + " / " + total + " (" + pct + "%)"; -} - function pollThumbnailStatus() { const interval = setInterval(() => { fetch("/api/library/thumbnail-status") .then(r => r.json()) .then(data => { - updateThumbnailProgress(data.generated, data.total); + const pct = data.total > 0 ? Math.round((data.generated / data.total) * 100) : 0; + _gpShow("thumbnails", "Thumbnails", data.generated + " / " + data.total, pct); if (!data.running) { clearInterval(interval); - // Kurz anzeigen, dann ausblenden - setTimeout(() => { - hideThumbnailProgress(); - showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success"); - }, 2000); + _gpHideDelayed("thumbnails"); + showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success"); } }) .catch(() => clearInterval(interval)); diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js index 5cd3692..c0787ba 100644 --- a/video-konverter/app/static/tv/js/player.js +++ b/video-konverter/app/static/tv/js/player.js @@ -30,6 +30,8 @@ let hlsSessionId = null; // Aktive HLS-Session-ID let hlsReady = false; // HLS-Playback bereit? let hlsSeekOffset = 0; // Server-seitiger Seek: echte Position im Video let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs +let hlsRetryCount = 0; // Retry-Zaehler fuer gesamten Stream-Start +let loadingTimeout = null; // Timeout fuer Loading-Spinner /** * Player initialisieren @@ -62,8 +64,10 @@ function initPlayer(opts) { videoEl.addEventListener("ended", onEnded); videoEl.addEventListener("click", togglePlay); // Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit) - videoEl.addEventListener("playing", hideLoading, {once: true}); + videoEl.addEventListener("playing", onPlaying); videoEl.addEventListener("canplay", hideLoading, {once: true}); + // Video-Error: Automatisch Retry mit Fallback + videoEl.addEventListener("error", onVideoError); // Controls UI playBtn.addEventListener("click", togglePlay); @@ -217,11 +221,37 @@ function showLoading() { } function hideLoading() { clearTimeout(loadingTimer); + clearTimeout(loadingTimeout); + loadingTimeout = null; var el = document.getElementById("player-loading"); if (!el) return; el.style.display = "none"; } +function onPlaying() { + hideLoading(); + hlsRetryCount = 0; // Reset bei erfolgreichem Start +} + +function onVideoError() { + // Video-Element Fehler: Retry oder Fallback + var err = videoEl.error; + var msg = err ? "Video-Fehler: Code " + err.code : "Video-Fehler"; + console.error(msg, err); + + if (hlsRetryCount < 2) { + hlsRetryCount++; + if (typeof showToast === "function") + showToast("Stream-Fehler, Retry " + hlsRetryCount + "/2...", "error"); + var seekPos = getCurrentTime(); + setTimeout(function() { startHLSStream(seekPos); }, 1000 * hlsRetryCount); + } else { + if (typeof showToast === "function") + showToast("Video konnte nicht gestartet werden", "error"); + hideLoading(); + } +} + // === HLS Streaming === async function startHLSStream(seekSec) { @@ -251,6 +281,8 @@ async function startHLSStream(seekSec) { if (!resp.ok) { console.error("HLS Session Start fehlgeschlagen:", resp.status); + if (typeof showToast === "function") + showToast("HLS-Start fehlgeschlagen (HTTP " + resp.status + ")", "error"); setStreamUrlLegacy(seekSec); return; } @@ -293,12 +325,15 @@ async function startHLSStream(seekSec) { && networkRetries < MAX_RETRIES) { // Netzwerkfehler -> Retry mit Backoff networkRetries++; - console.warn("HLS Netzwerkfehler, Retry " + - networkRetries + "/" + MAX_RETRIES); + if (typeof showToast === "function") + showToast("Netzwerkfehler, Retry " + + networkRetries + "/" + MAX_RETRIES + "...", "error"); setTimeout(() => hlsInstance.startLoad(), 1000 * networkRetries); } else { // Zu viele Retries oder anderer Fehler -> Fallback + if (typeof showToast === "function") + showToast("Stream-Fehler: " + data.details, "error"); cleanupHLS(); setStreamUrlLegacy(seekSec); } @@ -311,6 +346,8 @@ async function startHLSStream(seekSec) { } } catch (e) { console.error("HLS Start Fehler:", e); + if (typeof showToast === "function") + showToast("Stream-Start fehlgeschlagen: " + e.message, "error"); hideLoading(); setStreamUrlLegacy(seekSec); } @@ -901,18 +938,22 @@ function onKeyDown(e) { function saveProgress(completed) { if (!cfg.videoId || !videoEl) return; - const pos = getCurrentTime(); const dur = getDuration(); + // Bei completed: Position = Duration (garantiert ueber Schwelle) + const pos = completed ? dur : getCurrentTime(); if (pos < 5 && !completed) return; + const payload = { + video_id: cfg.videoId, + position_sec: pos, + duration_sec: dur, + }; + if (completed) payload.completed = true; + fetch("/tv/api/watch-progress", { method: "POST", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({ - video_id: cfg.videoId, - position_sec: pos, - duration_sec: dur, - }), + body: JSON.stringify(payload), }).catch(() => {}); } diff --git a/video-konverter/app/templates/base.html b/video-konverter/app/templates/base.html index d7056e0..e3c2d63 100644 --- a/video-konverter/app/templates/base.html +++ b/video-konverter/app/templates/base.html @@ -52,6 +52,24 @@
+ +
diff --git a/video-konverter/app/templates/library.html b/video-konverter/app/templates/library.html index 85e40e4..4f55819 100644 --- a/video-konverter/app/templates/library.html +++ b/video-konverter/app/templates/library.html @@ -17,22 +17,6 @@ - - - - - -
-Videos
diff --git a/video-konverter/app/templates/tv/series_detail.html b/video-konverter/app/templates/tv/series_detail.html index f7c4d3a..21f4b0d 100644 --- a/video-konverter/app/templates/tv/series_detail.html +++ b/video-konverter/app/templates/tv/series_detail.html @@ -95,11 +95,7 @@
- {% if ep.ep_image_url %} - - {% else %} - {% endif %} {% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
@@ -234,7 +230,7 @@ function toggleWatched(videoId, btn) { fetch('/tv/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ video_id: videoId, position: newPct, duration: 100 }), + body: JSON.stringify({ video_id: videoId, position_sec: newPct, duration_sec: 100 }), }) .then(r => r.json()) .then(() => { @@ -283,7 +279,7 @@ function markSeasonWatched(seriesId, seasonNum) { fetch('/tv/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ video_id: id, position: 100, duration: 100 }), + body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }), }) )).then(() => { // UI aktualisieren diff --git a/video-konverter/app/templates/tv_admin.html b/video-konverter/app/templates/tv_admin.html index 2f7a041..0c6f15a 100644 --- a/video-konverter/app/templates/tv_admin.html +++ b/video-konverter/app/templates/tv_admin.html @@ -47,6 +47,15 @@ Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist
+ +
+ + Alle Videos werden zu H.264+AAC transcodiert. Langsamer, aber garantiert kompatibel fuer alle Clients (TV, Handy, Browser) +