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 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-04 06:30:39 +01:00
parent 4f151de78c
commit d61fd5bc04
11 changed files with 241 additions and 125 deletions

View file

@ -1680,9 +1680,38 @@ def setup_library_routes(app: web.Application, config: Config,
# === Episoden-Thumbnails === # === 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: async def get_video_thumbnail(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/thumbnail """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 os
import asyncio as _asyncio import asyncio as _asyncio
@ -1693,7 +1722,7 @@ def setup_library_routes(app: web.Application, config: Config,
return web.json_response( return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500) {"error": "Keine DB-Verbindung"}, status=500)
# Pruefen ob bereits generiert # Pruefen ob bereits lokal vorhanden
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
@ -1707,28 +1736,59 @@ def setup_library_routes(app: web.Application, config: Config,
cached["thumbnail_path"], cached["thumbnail_path"],
headers={"Cache-Control": "public, max-age=604800"}) headers={"Cache-Control": "public, max-age=604800"})
# Video-Info laden # Video-Info + TVDB-Bild-URL laden
await cur.execute( await cur.execute("""
"SELECT file_path, duration_sec FROM library_videos " SELECT v.file_path, v.duration_sec,
"WHERE id = %s", (video_id,)) v.series_id, v.season_number, v.episode_number
FROM library_videos v
WHERE v.id = %s
""", (video_id,))
video = await cur.fetchone() video = await cur.fetchone()
if not video or not os.path.isfile(video["file_path"]): if not video or not os.path.isfile(video["file_path"]):
return web.json_response( return web.json_response(
{"error": "Video nicht gefunden"}, status=404) {"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: except Exception as e:
return web.json_response({"error": str(e)}, status=500) 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 # Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei
file_path = video["file_path"]
video_dir = os.path.dirname(file_path) video_dir = os.path.dirname(file_path)
thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails") thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails")
os.makedirs(thumb_dir, exist_ok=True) os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg") 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 = [ cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek_pos)), "-ss", str(int(seek_pos)),
@ -1748,16 +1808,8 @@ def setup_library_routes(app: web.Application, config: Config,
await proc.communicate() await proc.communicate()
if proc.returncode == 0 and os.path.isfile(thumb_path): if proc.returncode == 0 and os.path.isfile(thumb_path):
# In DB cachen await _save_thumbnail_to_db(
async with pool.acquire() as conn: pool, video_id, thumb_path, "ffmpeg")
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))
return web.FileResponse( return web.FileResponse(
thumb_path, thumb_path,
headers={"Cache-Control": "public, max-age=604800"}) headers={"Cache-Control": "public, max-age=604800"})
@ -1800,14 +1852,37 @@ def setup_library_routes(app: web.Application, config: Config,
generated = 0 generated = 0
errors = 0 errors = 0
try: 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 pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
# Videos ohne Thumbnail finden # Videos ohne Thumbnail finden (mit TVDB-Bild-URL)
sql = """ 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 FROM library_videos v
LEFT JOIN tv_episode_thumbnails t LEFT JOIN tv_episode_thumbnails t
ON v.id = t.video_id 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 WHERE t.video_id IS NULL
""" """
params = [] params = []
@ -1821,6 +1896,7 @@ def setup_library_routes(app: web.Application, config: Config,
logging.info( logging.info(
f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail" f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail"
) )
downloaded = 0
for video in videos: for video in videos:
vid = video["id"] vid = video["id"]
@ -1830,12 +1906,23 @@ def setup_library_routes(app: web.Application, config: Config,
if not os.path.isfile(fp): if not os.path.isfile(fp):
continue continue
seek = dur * 0.25 if dur > 10 else 5
vdir = os.path.dirname(fp) vdir = os.path.dirname(fp)
tdir = os.path.join(vdir, ".metadata", "thumbnails") tdir = os.path.join(vdir, ".metadata", "thumbnails")
os.makedirs(tdir, exist_ok=True) os.makedirs(tdir, exist_ok=True)
tpath = os.path.join(tdir, f"{vid}.jpg") 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 = [ cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek)), "-ss", str(int(seek)),
@ -1853,15 +1940,8 @@ def setup_library_routes(app: web.Application, config: Config,
await proc.communicate() await proc.communicate()
if proc.returncode == 0 and os.path.isfile(tpath): if proc.returncode == 0 and os.path.isfile(tpath):
async with pool.acquire() as conn2: await _save_thumbnail_to_db(
async with conn2.cursor() as cur2: pool, vid, tpath, "ffmpeg")
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))
generated += 1 generated += 1
else: else:
errors += 1 errors += 1
@ -1870,7 +1950,8 @@ def setup_library_routes(app: web.Application, config: Config,
errors += 1 errors += 1
logging.info( logging.info(
f"Thumbnail-Batch fertig: {generated} erzeugt, " f"Thumbnail-Batch fertig: {generated} erzeugt "
f"({downloaded} TVDB, {generated - downloaded} ffmpeg), "
f"{errors} Fehler" f"{errors} Fehler"
) )
except Exception as e: 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 pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: 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( await cur.execute(
"SELECT COUNT(*) AS cnt FROM library_videos") "SELECT COUNT(*) AS cnt FROM library_videos")
total = (await cur.fetchone())["cnt"] 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({ return web.json_response({
"running": running, "running": running,
"generated": done, "generated": done,
"total": total, "total": total,
"missing": total - done, "missing": missing,
}) })
# === Import: Item zuordnen / ueberspringen === # === Import: Item zuordnen / ueberspringen ===

View file

@ -154,6 +154,8 @@ def setup_page_routes(app: web.Application, config: Config,
data.get("hls_max_sessions", 5)) data.get("hls_max_sessions", 5))
settings["tv"]["pause_batch_on_stream"] = ( settings["tv"]["pause_batch_on_stream"] = (
data.get("pause_batch_on_stream") == "on") data.get("pause_batch_on_stream") == "on")
settings["tv"]["force_transcode"] = (
data.get("force_transcode") == "on")
settings["tv"]["watched_threshold_pct"] = int( settings["tv"]["watched_threshold_pct"] = int(
data.get("watched_threshold_pct", 90)) data.get("watched_threshold_pct", 90))

View file

@ -527,7 +527,6 @@ def setup_tv_routes(app: web.Application, config: Config,
v.width, v.height, v.video_codec, v.width, v.height, v.video_codec,
v.container, v.container,
tc.overview AS ep_overview, tc.overview AS ep_overview,
tc.image_url AS ep_image_url,
wp.position_sec, wp.duration_sec AS wp_duration wp.position_sec, wp.duration_sec AS wp_duration
FROM library_videos v FROM library_videos v
LEFT JOIN tvdb_episode_cache tc 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") video_id = data.get("video_id")
position = data.get("position_sec", 0) position = data.get("position_sec", 0)
duration = data.get("duration_sec", 0) duration = data.get("duration_sec", 0)
completed = data.get("completed", False)
if not video_id: if not video_id:
return web.json_response( return web.json_response(
{"error": "video_id fehlt"}, status=400) {"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( await auth_service.save_progress(
user["id"], video_id, position, duration user["id"], video_id, position, duration
) )

View file

@ -68,9 +68,11 @@ class VideoKonverterServer:
exc_info=True) exc_info=True)
raise raise
if request.path.startswith("/api/"): if request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" # Thumbnail-Bilder sollen gecacht werden (Cache-Control vom Handler)
response.headers["Pragma"] = "no-cache" if "/thumbnail" not in request.path:
response.headers["Expires"] = "0" response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response return response
def _setup_app(self) -> None: def _setup_app(self) -> None:

View file

@ -194,6 +194,12 @@ class HLSSessionManager:
# Audio-Transcoding noetig? # Audio-Transcoding noetig?
needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS 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 # Sound-Modus
if sound_mode == "stereo": if sound_mode == "stereo":
out_channels = 2 out_channels = 2

View file

@ -1414,67 +1414,57 @@ let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen
async function startAutoMatch() { 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; 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"); _gpShow("automatch", "Auto-Match", "Suche TVDB-Vorschlaege...", 0);
progress.style.display = "block";
document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege...";
document.getElementById("auto-match-bar").style.width = "0%";
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"}) fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
document.getElementById("auto-match-status").textContent = "Fehler: " + data.error; _gpShow("automatch", "Auto-Match", "Fehler: " + data.error, 0);
setTimeout(() => { progress.style.display = "none"; }, 3000); _gpHideDelayed("automatch");
return; return;
} }
pollAutoMatchStatus(); pollAutoMatchStatus();
}) })
.catch(e => { .catch(e => {
document.getElementById("auto-match-status").textContent = "Fehler: " + e; _gpShow("automatch", "Auto-Match", "Fehler: " + e, 0);
_gpHideDelayed("automatch");
}); });
} }
function pollAutoMatchStatus() { function pollAutoMatchStatus() {
const progress = document.getElementById("auto-match-progress");
const interval = setInterval(() => { const interval = setInterval(() => {
fetch("/api/library/tvdb-auto-match-status") fetch("/api/library/tvdb-auto-match-status")
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
const bar = document.getElementById("auto-match-bar");
const status = document.getElementById("auto-match-status");
if (data.phase === "done") { if (data.phase === "done") {
clearInterval(interval); clearInterval(interval);
bar.style.width = "100%";
const suggestions = data.suggestions || []; const suggestions = data.suggestions || [];
// Nur Items mit mindestens einem Vorschlag anzeigen
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0); const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
const noResults = suggestions.length - withSuggestions.length; const noResults = suggestions.length - withSuggestions.length;
status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`; _gpShow("automatch", "Auto-Match",
withSuggestions.length + " Vorschlaege, " + noResults + " ohne Ergebnis", 100);
setTimeout(() => { _gpHideDelayed("automatch");
progress.style.display = "none";
}, 2000);
if (withSuggestions.length > 0) { if (withSuggestions.length > 0) {
openTvdbReviewModal(withSuggestions); openTvdbReviewModal(withSuggestions);
} }
} else if (data.phase === "error") { } else if (data.phase === "error") {
clearInterval(interval); clearInterval(interval);
status.textContent = "Fehler beim Sammeln der Vorschlaege"; _gpShow("automatch", "Auto-Match", "Fehler", 0);
setTimeout(() => { progress.style.display = "none"; }, 3000); _gpHideDelayed("automatch");
} else if (!data.active && data.phase !== "done") { } else if (!data.active && data.phase !== "done") {
clearInterval(interval); clearInterval(interval);
progress.style.display = "none"; _gpHide("automatch");
} else { } else {
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0; const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + "%";
const phase = data.phase === "series" ? "Serien" : "Filme"; 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)); .catch(() => clearInterval(interval));
}, 5000); // 5s Fallback (WS liefert Live-Updates) }, 5000);
} }
// === TVDB Review-Modal === // === TVDB Review-Modal ===
@ -3211,46 +3201,24 @@ async function generateThumbnails() {
} else { } else {
showToast("Thumbnail-Generierung gestartet", "success"); showToast("Thumbnail-Generierung gestartet", "success");
} }
// Fortschrittsbalken anzeigen und Polling starten // Globalen Fortschrittsbalken anzeigen und Polling starten
showThumbnailProgress(); _gpShow("thumbnails", "Thumbnails", "Starte...", 0);
pollThumbnailStatus(); pollThumbnailStatus();
}) })
.catch(e => showToast("Fehler: " + e, "error")); .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() { function pollThumbnailStatus() {
const interval = setInterval(() => { const interval = setInterval(() => {
fetch("/api/library/thumbnail-status") fetch("/api/library/thumbnail-status")
.then(r => r.json()) .then(r => r.json())
.then(data => { .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) { if (!data.running) {
clearInterval(interval); clearInterval(interval);
// Kurz anzeigen, dann ausblenden _gpHideDelayed("thumbnails");
setTimeout(() => { showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
hideThumbnailProgress();
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
}, 2000);
} }
}) })
.catch(() => clearInterval(interval)); .catch(() => clearInterval(interval));

View file

@ -30,6 +30,8 @@ let hlsSessionId = null; // Aktive HLS-Session-ID
let hlsReady = false; // HLS-Playback bereit? let hlsReady = false; // HLS-Playback bereit?
let hlsSeekOffset = 0; // Server-seitiger Seek: echte Position im Video let hlsSeekOffset = 0; // Server-seitiger Seek: echte Position im Video
let clientCodecs = null; // Vom Client unterstuetzte Video-Codecs 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 * Player initialisieren
@ -62,8 +64,10 @@ function initPlayer(opts) {
videoEl.addEventListener("ended", onEnded); videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("click", togglePlay); videoEl.addEventListener("click", togglePlay);
// Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit) // 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}); videoEl.addEventListener("canplay", hideLoading, {once: true});
// Video-Error: Automatisch Retry mit Fallback
videoEl.addEventListener("error", onVideoError);
// Controls UI // Controls UI
playBtn.addEventListener("click", togglePlay); playBtn.addEventListener("click", togglePlay);
@ -217,11 +221,37 @@ function showLoading() {
} }
function hideLoading() { function hideLoading() {
clearTimeout(loadingTimer); clearTimeout(loadingTimer);
clearTimeout(loadingTimeout);
loadingTimeout = null;
var el = document.getElementById("player-loading"); var el = document.getElementById("player-loading");
if (!el) return; if (!el) return;
el.style.display = "none"; 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 === // === HLS Streaming ===
async function startHLSStream(seekSec) { async function startHLSStream(seekSec) {
@ -251,6 +281,8 @@ async function startHLSStream(seekSec) {
if (!resp.ok) { if (!resp.ok) {
console.error("HLS Session Start fehlgeschlagen:", resp.status); console.error("HLS Session Start fehlgeschlagen:", resp.status);
if (typeof showToast === "function")
showToast("HLS-Start fehlgeschlagen (HTTP " + resp.status + ")", "error");
setStreamUrlLegacy(seekSec); setStreamUrlLegacy(seekSec);
return; return;
} }
@ -293,12 +325,15 @@ async function startHLSStream(seekSec) {
&& networkRetries < MAX_RETRIES) { && networkRetries < MAX_RETRIES) {
// Netzwerkfehler -> Retry mit Backoff // Netzwerkfehler -> Retry mit Backoff
networkRetries++; networkRetries++;
console.warn("HLS Netzwerkfehler, Retry " + if (typeof showToast === "function")
networkRetries + "/" + MAX_RETRIES); showToast("Netzwerkfehler, Retry " +
networkRetries + "/" + MAX_RETRIES + "...", "error");
setTimeout(() => hlsInstance.startLoad(), setTimeout(() => hlsInstance.startLoad(),
1000 * networkRetries); 1000 * networkRetries);
} else { } else {
// Zu viele Retries oder anderer Fehler -> Fallback // Zu viele Retries oder anderer Fehler -> Fallback
if (typeof showToast === "function")
showToast("Stream-Fehler: " + data.details, "error");
cleanupHLS(); cleanupHLS();
setStreamUrlLegacy(seekSec); setStreamUrlLegacy(seekSec);
} }
@ -311,6 +346,8 @@ async function startHLSStream(seekSec) {
} }
} catch (e) { } catch (e) {
console.error("HLS Start Fehler:", e); console.error("HLS Start Fehler:", e);
if (typeof showToast === "function")
showToast("Stream-Start fehlgeschlagen: " + e.message, "error");
hideLoading(); hideLoading();
setStreamUrlLegacy(seekSec); setStreamUrlLegacy(seekSec);
} }
@ -901,18 +938,22 @@ function onKeyDown(e) {
function saveProgress(completed) { function saveProgress(completed) {
if (!cfg.videoId || !videoEl) return; if (!cfg.videoId || !videoEl) return;
const pos = getCurrentTime();
const dur = getDuration(); const dur = getDuration();
// Bei completed: Position = Duration (garantiert ueber Schwelle)
const pos = completed ? dur : getCurrentTime();
if (pos < 5 && !completed) return; 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", { fetch("/tv/api/watch-progress", {
method: "POST", method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ body: JSON.stringify(payload),
video_id: cfg.videoId,
position_sec: pos,
duration_sec: dur,
}),
}).catch(() => {}); }).catch(() => {});
} }

View file

@ -52,6 +52,24 @@
<div class="progress-bar"></div> <div class="progress-bar"></div>
</div> </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> </div>
<main> <main>

View file

@ -17,22 +17,6 @@
</div> </div>
</div> </div>
<!-- Thumbnail-Generierung Progress -->
<div id="thumbnail-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="thumbnail-bar"></div>
</div>
<span class="scan-status" id="thumbnail-status">Thumbnails werden generiert...</span>
</div>
<!-- Auto-Match Progress -->
<div id="auto-match-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="auto-match-bar"></div>
</div>
<span class="scan-status" id="auto-match-status">TVDB Auto-Match...</span>
</div>
<!-- Statistik-Leiste --> <!-- Statistik-Leiste -->
<div class="library-stats" id="library-stats"> <div class="library-stats" id="library-stats">
<div class="lib-stat"><span class="lib-stat-value" id="stat-videos">-</span><span class="lib-stat-label">Videos</span></div> <div class="lib-stat"><span class="lib-stat-value" id="stat-videos">-</span><span class="lib-stat-label">Videos</span></div>

View file

@ -95,11 +95,7 @@
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable> <a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
<!-- Thumbnail --> <!-- Thumbnail -->
<div class="tv-ep-thumb"> <div class="tv-ep-thumb">
{% if ep.ep_image_url %}
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
{% else %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy"> <img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% endif %}
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %} {% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
<div class="tv-ep-progress"> <div class="tv-ep-progress">
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div> <div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
@ -234,7 +230,7 @@ function toggleWatched(videoId, btn) {
fetch('/tv/api/watch-progress', { fetch('/tv/api/watch-progress', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(r => r.json())
.then(() => { .then(() => {
@ -283,7 +279,7 @@ function markSeasonWatched(seriesId, seasonNum) {
fetch('/tv/api/watch-progress', { fetch('/tv/api/watch-progress', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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(() => { )).then(() => {
// UI aktualisieren // UI aktualisieren

View file

@ -47,6 +47,15 @@
</label> </label>
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span> <span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
</div> </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> </div>
</fieldset> </fieldset>