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 ===
|
||||
|
||||
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 ===
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ class VideoKonverterServer:
|
|||
exc_info=True)
|
||||
raise
|
||||
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["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
_gpHideDelayed("thumbnails");
|
||||
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => clearInterval(interval));
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,24 @@
|
|||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gp-thumbnails" class="global-progress" style="display:none">
|
||||
<div class="global-progress-info">
|
||||
<span class="gp-label">Thumbnails</span>
|
||||
<span class="gp-detail text-muted"></span>
|
||||
</div>
|
||||
<div class="progress-container" style="height:4px">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gp-automatch" class="global-progress" style="display:none">
|
||||
<div class="global-progress-info">
|
||||
<span class="gp-label">Auto-Match</span>
|
||||
<span class="gp-detail text-muted"></span>
|
||||
</div>
|
||||
<div class="progress-container" style="height:4px">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
|
|
|
|||
|
|
@ -17,22 +17,6 @@
|
|||
</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 -->
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -95,11 +95,7 @@
|
|||
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
|
||||
<!-- Thumbnail -->
|
||||
<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">
|
||||
{% endif %}
|
||||
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
|
||||
<div class="tv-ep-progress">
|
||||
<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', {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,15 @@
|
|||
</label>
|
||||
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="force_transcode" id="force_transcode"
|
||||
{% if tv.force_transcode | default(false) %}checked{% endif %}>
|
||||
Immer transcodieren (kein Copy-Modus)
|
||||
</label>
|
||||
<span class="text-muted" style="font-size:0.8rem">Alle Videos werden zu H.264+AAC transcodiert. Langsamer, aber garantiert kompatibel fuer alle Clients (TV, Handy, Browser)</span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue