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 ===
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 ===

View file

@ -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))

View file

@ -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
)

View file

@ -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"

View file

@ -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

View file

@ -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));

View file

@ -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(() => {});
}

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>