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:
parent
4f151de78c
commit
d61fd5bc04
11 changed files with 241 additions and 125 deletions
|
|
@ -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 ===
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,8 @@ class VideoKonverterServer:
|
||||||
exc_info=True)
|
exc_info=True)
|
||||||
raise
|
raise
|
||||||
if request.path.startswith("/api/"):
|
if request.path.startswith("/api/"):
|
||||||
|
# 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["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Expires"] = "0"
|
response.headers["Expires"] = "0"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
|
||||||
hideThumbnailProgress();
|
|
||||||
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
|
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
|
||||||
}, 2000);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => clearInterval(interval));
|
.catch(() => clearInterval(interval));
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue