feat: VideoKonverter v4.2 - TV Admin-Center, HLS-Streaming, Startseiten-Rubriken

- TV Admin-Center (/tv-admin): HLS-Settings, Session-Monitoring, User-Verwaltung
- HLS-Streaming: ffmpeg .ts-Segmente, hls.js, GPU VAAPI, SIGSTOP/SIGCONT
- Startseite: Rubriken (Weiterschauen, Neu, Serien, Filme, Schon gesehen)
- User-Settings: Startseiten-Rubriken konfigurierbar, Watch-Threshold
- UI: Amber/Gold Accent-Farbe, Focus-Ring-Fix, Player-Buttons einheitlich
- Cache-Busting: ?v= Timestamp auf allen CSS/JS Includes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-02 17:57:48 +01:00
parent 75bb5d796d
commit 4f151de78c
27 changed files with 2325 additions and 541 deletions

View file

@ -2,6 +2,119 @@
Alle relevanten Aenderungen am VideoKonverter-Projekt. Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [4.2.0] - 2026-03-02
### TV Admin-Center, konfigurierbare Settings & Bugfixes
Neue Admin-Seite fuer TV-Backend-Verwaltung. Alle HLS-Streaming- und Watch-Status-Parameter
sind jetzt konfigurierbar statt hardcoded. HLS-Session-Monitoring mit Live-Uebersicht.
#### Neue Features
- **TV Admin-Center** (`/tv-admin`): Eigene Verwaltungsseite fuer TV-Backend, erreichbar ueber Navigation
- HLS-Streaming-Einstellungen: Segment-Dauer, Erstes-Segment-Dauer, Session-Timeout, Max-Sessions
- Batch-Pause-Toggle: Konvertierung bei Stream pausieren (SIGSTOP/SIGCONT)
- Watch-Status-Schwelle: Ab wieviel Prozent gilt eine Episode als gesehen (Default 90%, Plex-Standard)
- HTMX-Formular mit Live-Speichern
- **HLS-Session-Monitoring**: Tabelle mit aktiven Streaming-Sessions (Session-ID, Video, Qualitaet, Laufzeit, Inaktivitaet, Status)
- Sessions einzeln beenden (Admin-Button)
- Auto-Refresh alle 15 Sekunden
- **TV-User-Verwaltung verschoben**: QR-Code + CRUD von `/admin` nach `/tv-admin`
- **Konfigurierbare HLS-Konstanten**: Alle vorher hardcodierten Werte (Segment-Dauer 4s, Init-Dauer 1s, Timeout 5min, Max-Sessions 5) jetzt per Admin-UI und ENV-Variablen einstellbar
- **Max-Sessions-Limit**: HLS-Session-Erstellung wird abgelehnt wenn Limit erreicht
#### Bugfixes
- **Library Suchfeld**: Enter-Taste loest jetzt sofort den Filter aus (vorher: nur Input-Event)
- **Watch-Threshold**: Von hardcoded 95% auf konfigurierbaren Wert geaendert (Default 90%)
- **TV-Homepage Cover**: Kleinere Card-Groessen (180→150px Standard, 260→220px Wide) fuer bessere Uebersicht
- **Focus-Outline abgeschnitten**: `.tv-row` Padding/Margin-Fix damit Focus-Ring bei Karten vollstaendig sichtbar
#### Neue ENV-Variablen
- `VK_TV_WATCHED_THRESHOLD` - Gesehen-Schwelle in Prozent (Default: 90)
- `VK_TV_HLS_SEGMENT_SEC` - HLS-Segment-Dauer in Sekunden (Default: 4)
- `VK_TV_HLS_TIMEOUT_MIN` - HLS-Session-Timeout in Minuten (Default: 5)
- `VK_TV_HLS_MAX_SESSIONS` - Max. gleichzeitige HLS-Sessions (Default: 5)
- `VK_TV_PAUSE_BATCH` - Batch bei Stream pausieren (Default: true)
#### Neue API-Endpunkte
- `GET /api/tv/hls-sessions` - Aktive HLS-Sessions auflisten (mit Video-Name aus DB)
- `DELETE /api/tv/hls-sessions/{sid}` - HLS-Session beenden
#### Geaenderte Dateien (10 Dateien)
- `app/config.py` - `tv`-Sektion in Defaults, 5 ENV-Mappings, `tv_config` Property, Docstring
- `app/services/hls.py` - `_tv_setting()` Methode, Config statt Konstanten, Max-Sessions-Check, konfigurierbare Timeouts
- `app/server.py` - `config=self.config` an HLSSessionManager uebergeben
- `app/routes/pages.py` - `/tv-admin` Route + Handler, `POST /htmx/tv-settings` Save-Handler
- `app/routes/tv_api.py` - HLS-Sessions-API (GET/DELETE), `watched_threshold_pct` im Template-Context
- `app/templates/base.html` - Nav-Link "TV Admin" mit Active-State
- `app/templates/tv_admin.html` - **NEU**: Komplettes TV-Admin-Template (Settings, Sessions, Users)
- `app/templates/admin.html` - TV-Sektion + JS komplett entfernt
- `app/templates/tv/series_detail.html` - Threshold-Variable statt hardcoded 95
- `app/templates/library.html` - Enter-Key-Handler auf Suchfeld
- `app/static/tv/css/tv.css` - Card-Groessen reduziert, Focus-Outline-Fix
---
## [4.1.0] - 2026-03-02
### HLS-Streaming, GPU-VAAPI-Fix, Player-Umbau
Natives HLS-Streaming ersetzt die fragile ffmpeg-Pipe. Intel A380 GPU wird korrekt
fuer h264_vaapi-Transcoding genutzt. Player-UI komplett ueberarbeitet.
#### Neue Features
- **HLS-Streaming**: ffmpeg erzeugt .ts-Segmente + m3u8-Playlist in `/tmp/hls/{session_id}/`
- hls.js Library fuer Non-Safari/Tizen Browser
- API: `/tv/api/hls/start`, `/tv/api/hls/{sid}/playlist.m3u8`, `/tv/api/hls/{sid}/segment*.ts`
- Auto-Cleanup inaktiver Sessions (5 Min Timeout)
- **SIGSTOP/SIGCONT**: Laufende Batch-Konvertierung wird waehrend HLS-Streaming pausiert und danach fortgesetzt
- **Codec-Erkennung**: `detectSupportedCodecs()` prueft Browser-Faehigkeiten vor Stream-Start
- **Quality-Auswahl**: UHD/HD/SD/LD im Player waehlbar
- **Loading-Spinner**: Sichtbarer Ladeindikator waehrend Stream-Initialisierung
- **Kompakt-Popup**: Statt grossem Overlay-Panel jetzt kompaktes Popup fuer Audio/Sub/Quality
#### GPU-Fix
- **Intel A380 VAAPI**: Korrekter Pipeline-Aufbau `-vaapi_device /dev/dri/renderD129 -vf 'format=nv12,hwupload' -c:v h264_vaapi`
- **CPU-Fallback**: Automatischer Wechsel auf `libx264 -preset veryfast` wenn GPU fehlschlaegt (Exit < 1s)
#### Geaenderte Dateien
- `Dockerfile` - hls.js Library, VAAPI-Pakete
- `entrypoint.sh` - /tmp/hls Verzeichnis
- `app/services/hls.py` - **NEU**: HLSSessionManager (600+ Zeilen)
- `app/services/queue.py` - SIGSTOP/SIGCONT Integration
- `app/routes/tv_api.py` - HLS-Endpunkte
- `app/static/tv/js/lib/hls.min.js` - **NEU**: hls.js Library
- `app/static/tv/js/player.js` - HLS-Integration, Codec-Erkennung, Kompakt-Popup
- `app/static/tv/css/tv.css` - Loading-Spinner, Popup-Styles
- `app/templates/tv/player.html` - HLS-Player-Markup
- `app/templates/tv/series_detail.html` - Tech-Info-Anzeige
---
## [4.0.3] - 2026-03-01
### JSON-Import-Fix, Player D-Pad-Navigation, Overlay-Bugfix
#### Bugfixes
- **JSON-Import**: `importer.py` konnte bei fehlenden Keys in TVDB-Daten abstuerzen - robusteres `.get()` Handling
- **Player D-Pad-Navigation**: Fernbedienung-Navigation im Player-Overlay funktionierte nicht korrekt auf Samsung TV
- **Overlay-Bugfix**: Player-Overlay schloss sich nicht zuverlaessig beim Druecken der Zurueck-Taste
#### Geaenderte Dateien
- `app/services/importer.py` - Robusteres JSON-Parsing
- `app/static/tv/js/player.js` - D-Pad Keydown-Handler, Overlay-Close-Fix
---
## [4.0.2] - 2026-03-01 ## [4.0.2] - 2026-03-01
### TV-App: FocusManager-Fix, Poster-Caching, Performance ### TV-App: FocusManager-Fix, Poster-Caching, Performance

View file

@ -1,10 +1,11 @@
FROM ubuntu:24.04 FROM ubuntu:24.04
# Basis-Pakete + ffmpeg + Intel GPU Treiber # Basis-Pakete + ffmpeg + Intel GPU Treiber + gosu (fuer PUID/PGID User-Switching)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
python3 \ python3 \
python3-pip \ python3-pip \
gosu \
intel-opencl-icd \ intel-opencl-icd \
intel-media-va-driver-non-free \ intel-media-va-driver-non-free \
libva-drm2 \ libva-drm2 \
@ -40,9 +41,9 @@ COPY video-konverter/app/ ./app/
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert) # Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000) # Daten- und Log-Verzeichnisse + HLS-Streaming (beschreibbar fuer UID 1000)
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \ RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls \
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs && chmod 777 /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls
# Entrypoint (kopiert Defaults in gemountete Volumes) # Entrypoint (kopiert Defaults in gemountete Volumes)
COPY entrypoint.sh . COPY entrypoint.sh .

View file

@ -1,11 +1,17 @@
#!/bin/bash #!/bin/bash
# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis, # Entrypoint: PUID/PGID User-Switching + Default-Config kopieren
# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid). #
# Unterstuetzt zwei Betriebsarten:
# 1) docker-compose mit user: "${PUID:-99}:${PGID:-100}" → laeuft direkt als richtiger User
# 2) Unraid Docker-UI mit PUID/PGID als Container-Variablen → entrypoint wechselt den User
PUID=${PUID:-99}
PGID=${PGID:-100}
CFG_DIR="/opt/video-konverter/app/cfg" CFG_DIR="/opt/video-konverter/app/cfg"
DEFAULTS_DIR="/opt/video-konverter/cfg_defaults" DEFAULTS_DIR="/opt/video-konverter/cfg_defaults"
# Alle Default-Dateien kopieren, wenn nicht vorhanden # Default-Konfigdateien kopieren falls nicht vorhanden
for file in "$DEFAULTS_DIR"/*; do for file in "$DEFAULTS_DIR"/*; do
filename=$(basename "$file") filename=$(basename "$file")
if [ ! -f "$CFG_DIR/$filename" ]; then if [ ! -f "$CFG_DIR/$filename" ]; then
@ -14,5 +20,32 @@ for file in "$DEFAULTS_DIR"/*; do
fi fi
done done
# Anwendung starten # Pruefen ob wir als root laufen (Unraid Docker-UI Modus)
exec python3 __main__.py if [ "$(id -u)" = "0" ]; then
echo "Container laeuft als root - wechsle zu PUID=$PUID PGID=$PGID"
# Gruppe erstellen/aendern
if getent group vkuser > /dev/null 2>&1; then
groupmod -o -g "$PGID" vkuser
else
groupadd -o -g "$PGID" vkuser
fi
# User erstellen/aendern
if id vkuser > /dev/null 2>&1; then
usermod -o -u "$PUID" -g "$PGID" vkuser
else
useradd -o -u "$PUID" -g "$PGID" -M -s /bin/bash vkuser
fi
# Verzeichnis-Berechtigungen rekursiv setzen (inkl. vorhandener Dateien)
chown -R "$PUID:$PGID" /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls 2>/dev/null
chown -R "$PUID:$PGID" "$CFG_DIR" 2>/dev/null
# Als PUID:PGID User starten
exec gosu "$PUID:$PGID" python3 __main__.py
else
# Laeuft bereits als richtiger User (docker-compose user: Direktive)
echo "Container laeuft als UID=$(id -u) GID=$(id -g)"
exec python3 __main__.py
fi

View file

@ -10,6 +10,8 @@ Mapping (VK_ Prefix):
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false) Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4) Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR) Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR)
TV-App: VK_TV_WATCHED_THRESHOLD, VK_TV_HLS_SEGMENT_SEC, VK_TV_HLS_TIMEOUT_MIN,
VK_TV_HLS_MAX_SESSIONS, VK_TV_PAUSE_BATCH
""" """
import os import os
import logging import logging
@ -38,6 +40,11 @@ _ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool), "VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
"VK_TARGET_CONTAINER": (("files", "target_container"), str), "VK_TARGET_CONTAINER": (("files", "target_container"), str),
"VK_LOG_LEVEL": (("logging", "level"), str), "VK_LOG_LEVEL": (("logging", "level"), str),
"VK_TV_WATCHED_THRESHOLD": (("tv", "watched_threshold_pct"), int),
"VK_TV_HLS_SEGMENT_SEC": (("tv", "hls_segment_duration"), int),
"VK_TV_HLS_TIMEOUT_MIN": (("tv", "hls_session_timeout_min"), int),
"VK_TV_HLS_MAX_SESSIONS": (("tv", "hls_max_sessions"), int),
"VK_TV_PAUSE_BATCH": (("tv", "pause_batch_on_stream"), bool),
} }
# Rueckwaertskompatibilitaet # Rueckwaertskompatibilitaet
@ -96,6 +103,14 @@ _DEFAULT_SETTINGS: dict = {
"tvdb_language": "deu", "tvdb_language": "deu",
"tvdb_pin": "", "tvdb_pin": "",
}, },
"tv": {
"watched_threshold_pct": 90,
"hls_segment_duration": 4,
"hls_init_duration": 1,
"hls_session_timeout_min": 5,
"hls_max_sessions": 5,
"pause_batch_on_stream": True,
},
"cleanup": { "cleanup": {
"enabled": False, "enabled": False,
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"], "delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
@ -208,8 +223,8 @@ class Config:
for env_key, ((section, key), val_type) in _ENV_MAP.items(): for env_key, ((section, key), val_type) in _ENV_MAP.items():
raw = os.environ.get(env_key) raw = os.environ.get(env_key)
if raw is None: if raw is None or raw == "":
continue continue # Leere ENV-Variablen ueberschreiben YAML nicht
# Typ-Konvertierung # Typ-Konvertierung
try: try:
@ -328,6 +343,10 @@ class Config:
def cleanup_config(self) -> dict: def cleanup_config(self) -> dict:
return self.settings.get("cleanup", {}) return self.settings.get("cleanup", {})
@property
def tv_config(self) -> dict:
return self.settings.get("tv", {})
@property @property
def server_config(self) -> dict: def server_config(self) -> dict:
return self.settings.get("server", {}) return self.settings.get("server", {})

View file

@ -45,6 +45,12 @@ def setup_page_routes(app: web.Application, config: Config,
"gpu_devices": gpu_devices, "gpu_devices": gpu_devices,
} }
@aiohttp_jinja2.template("tv_admin.html")
async def tv_admin(request: web.Request) -> dict:
"""GET /tv-admin - TV Admin-Center"""
tv = config.settings.get("tv", {})
return {"tv": tv}
@aiohttp_jinja2.template("library.html") @aiohttp_jinja2.template("library.html")
async def library(request: web.Request) -> dict: async def library(request: web.Request) -> dict:
"""GET /library - Bibliothek""" """GET /library - Bibliothek"""
@ -132,6 +138,33 @@ def setup_page_routes(app: web.Application, config: Config,
content_type="text/html", content_type="text/html",
) )
async def htmx_save_tv_settings(request: web.Request) -> web.Response:
"""POST /htmx/tv-settings - TV-Settings via Formular speichern"""
data = await request.post()
settings = config.settings
settings.setdefault("tv", {})
settings["tv"]["hls_segment_duration"] = int(
data.get("hls_segment_duration", 4))
settings["tv"]["hls_init_duration"] = int(
data.get("hls_init_duration", 1))
settings["tv"]["hls_session_timeout_min"] = int(
data.get("hls_session_timeout_min", 5))
settings["tv"]["hls_max_sessions"] = int(
data.get("hls_max_sessions", 5))
settings["tv"]["pause_batch_on_stream"] = (
data.get("pause_batch_on_stream") == "on")
settings["tv"]["watched_threshold_pct"] = int(
data.get("watched_threshold_pct", 90))
config.save_settings()
logging.info("TV-Settings via Admin-UI gespeichert")
return web.Response(
text='<div class="toast success">TV-Settings gespeichert!</div>',
content_type="text/html",
)
@aiohttp_jinja2.template("partials/stats_table.html") @aiohttp_jinja2.template("partials/stats_table.html")
async def htmx_stats_table(request: web.Request) -> dict: async def htmx_stats_table(request: web.Request) -> dict:
"""GET /htmx/stats?page=1 - Paginierte Statistik""" """GET /htmx/stats?page=1 - Paginierte Statistik"""
@ -155,6 +188,8 @@ def setup_page_routes(app: web.Application, config: Config,
app.router.add_get("/dashboard", dashboard) app.router.add_get("/dashboard", dashboard)
app.router.add_get("/library", library) app.router.add_get("/library", library)
app.router.add_get("/admin", admin) app.router.add_get("/admin", admin)
app.router.add_get("/tv-admin", tv_admin)
app.router.add_get("/statistics", statistics) app.router.add_get("/statistics", statistics)
app.router.add_post("/htmx/settings", htmx_save_settings) app.router.add_post("/htmx/settings", htmx_save_settings)
app.router.add_post("/htmx/tv-settings", htmx_save_tv_settings)
app.router.add_get("/htmx/stats", htmx_stats_table) app.router.add_get("/htmx/stats", htmx_stats_table)

View file

@ -10,12 +10,14 @@ import aiomysql
from app.config import Config from app.config import Config
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.library import LibraryService from app.services.library import LibraryService
from app.services.hls import HLSSessionManager
from app.services.i18n import set_request_lang, get_all_translations from app.services.i18n import set_request_lang, get_all_translations
def setup_tv_routes(app: web.Application, config: Config, def setup_tv_routes(app: web.Application, config: Config,
auth_service: AuthService, auth_service: AuthService,
library_service: LibraryService) -> None: library_service: LibraryService,
hls_manager: HLSSessionManager = None) -> None:
"""Registriert alle TV-App Routes""" """Registriert alle TV-App Routes"""
# --- Poster-URL Lokalisierung --- # --- Poster-URL Lokalisierung ---
@ -155,75 +157,193 @@ def setup_tv_routes(app: web.Application, config: Config,
@require_auth @require_auth
async def get_home(request: web.Request) -> web.Response: async def get_home(request: web.Request) -> web.Response:
"""GET /tv/ - Startseite""" """GET /tv/ - Startseite mit konfigurierbaren Rubriken"""
user = request["tv_user"] user = request["tv_user"]
uid = user["id"]
# Daten laden # User-Einstellungen fuer Startseite
show_continue = user.get("home_show_continue", 1)
show_new = user.get("home_show_new", 1)
hide_watched = user.get("home_hide_watched", 1)
show_watched = user.get("home_show_watched", 1)
# Daten-Container
continue_watching = []
new_series = []
new_movies = []
series = [] series = []
movies = [] movies = []
continue_watching = [] watched_series = []
watched_movies = []
# Berechtigungs-WHERE fuer Pfad-Einschraenkung
def _path_filter(alias: str, paths: list):
if paths:
ph = ",".join(["%s"] * len(paths))
return f" AND {alias}.library_path_id IN ({ph})", list(paths)
return "", []
pool = library_service._db_pool pool = library_service._db_pool
if pool: if pool:
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:
# Serien laden (mit Berechtigungspruefung)
# --- Serien ---
if user.get("can_view_series"): if user.get("can_view_series"):
series_query = """ path_sql, path_params = _path_filter(
SELECT s.id, s.title, s.folder_name, s.poster_url, "s", user.get("allowed_paths"))
s.genres, s.tvdb_id,
# Neu hinzugefuegt (letzte 10, nach Scan-Datum)
if show_new:
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url, s.last_updated,
COUNT(v.id) as episode_count
FROM library_series s
LEFT JOIN library_videos v
ON v.series_id = s.id
WHERE s.last_updated IS NOT NULL
{path_sql}
GROUP BY s.id
ORDER BY s.last_updated DESC
LIMIT 10
""", path_params)
new_series = await cur.fetchall()
# Serien (ohne gesehene, falls aktiviert)
watched_join = ""
watched_where = ""
if hide_watched:
watched_join = (
" LEFT JOIN tv_watch_status ws"
" ON ws.series_id = s.id"
f" AND ws.user_id = {int(uid)}"
" AND ws.status = 'watched'"
)
watched_where = " AND ws.id IS NULL"
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url, s.genres, s.tvdb_id,
COUNT(v.id) as episode_count COUNT(v.id) as episode_count
FROM library_series s FROM library_series s
LEFT JOIN library_videos v ON v.series_id = s.id LEFT JOIN library_videos v
""" ON v.series_id = s.id
params = [] {watched_join}
if user.get("allowed_paths"): WHERE 1=1 {path_sql} {watched_where}
placeholders = ",".join( GROUP BY s.id
["%s"] * len(user["allowed_paths"])) ORDER BY s.title
series_query += ( LIMIT 20
f" WHERE s.library_path_id IN ({placeholders})" """, path_params)
)
params = user["allowed_paths"]
series_query += (
" GROUP BY s.id ORDER BY s.title LIMIT 20"
)
await cur.execute(series_query, params)
series = await cur.fetchall() series = await cur.fetchall()
# Filme laden # Schon gesehen
if show_watched:
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url,
COUNT(v.id) as episode_count
FROM library_series s
LEFT JOIN library_videos v
ON v.series_id = s.id
INNER JOIN tv_watch_status ws
ON ws.series_id = s.id
AND ws.user_id = %s
AND ws.status = 'watched'
WHERE 1=1 {path_sql}
GROUP BY s.id
ORDER BY ws.updated_at DESC
LIMIT 20
""", [uid] + path_params)
watched_series = await cur.fetchall()
# --- Filme ---
if user.get("can_view_movies"): if user.get("can_view_movies"):
movies_query = """ path_sql, path_params = _path_filter(
SELECT m.id, m.title, m.folder_name, m.poster_url, "m", user.get("allowed_paths"))
m.year, m.genres
# Neu hinzugefuegt
if show_new:
await cur.execute(f"""
SELECT m.id, m.title, m.folder_name,
m.poster_url, m.year, m.genres,
m.last_updated
FROM library_movies m
WHERE m.last_updated IS NOT NULL
{path_sql}
ORDER BY m.last_updated DESC
LIMIT 10
""", path_params)
new_movies = await cur.fetchall()
# Filme (ohne gesehene, falls aktiviert)
# Filme gelten als gesehen wenn watch_progress
# completed = 1
watched_join = ""
watched_where = ""
if hide_watched:
watched_join = f"""
LEFT JOIN (
SELECT DISTINCT v2.movie_id
FROM tv_watch_progress wp
JOIN library_videos v2
ON wp.video_id = v2.id
WHERE wp.user_id = {int(uid)}
AND wp.completed = 1
AND v2.movie_id IS NOT NULL
) wm ON wm.movie_id = m.id
"""
watched_where = " AND wm.movie_id IS NULL"
await cur.execute(f"""
SELECT m.id, m.title, m.folder_name,
m.poster_url, m.year, m.genres
FROM library_movies m FROM library_movies m
""" {watched_join}
params = [] WHERE 1=1 {path_sql} {watched_where}
if user.get("allowed_paths"): ORDER BY m.title
placeholders = ",".join( LIMIT 20
["%s"] * len(user["allowed_paths"])) """, path_params)
movies_query += (
f" WHERE m.library_path_id IN ({placeholders})"
)
params = user["allowed_paths"]
movies_query += " ORDER BY m.title LIMIT 20"
await cur.execute(movies_query, params)
movies = await cur.fetchall() movies = await cur.fetchall()
# Poster-URLs lokalisieren (kein TVDB-Laden) # Schon gesehen (Filme)
_localize_posters(series, "series") if show_watched:
await cur.execute(f"""
SELECT DISTINCT m.id, m.title,
m.folder_name, m.poster_url,
m.year, m.genres
FROM library_movies m
JOIN library_videos v ON v.movie_id = m.id
JOIN tv_watch_progress wp
ON wp.video_id = v.id
AND wp.user_id = %s
AND wp.completed = 1
WHERE 1=1 {path_sql}
ORDER BY wp.updated_at DESC
LIMIT 20
""", [uid] + path_params)
watched_movies = await cur.fetchall()
# Poster-URLs lokalisieren
for lst in (series, new_series, watched_series):
_localize_posters(lst, "series")
# Weiterschauen # Weiterschauen
continue_watching = await auth_service.get_continue_watching( if show_continue:
user["id"] continue_watching = await auth_service.get_continue_watching(
) uid)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/home.html", request, { "tv/home.html", request, {
"user": user, "user": user,
"active": "home", "active": "home",
"continue_watching": continue_watching,
"new_series": new_series,
"new_movies": new_movies,
"series": series, "series": series,
"movies": movies, "movies": movies,
"continue_watching": continue_watching, "watched_series": watched_series,
"watched_movies": watched_movies,
} }
) )
@ -466,6 +586,9 @@ def setup_tv_routes(app: web.Application, config: Config,
# Poster-URL lokalisieren # Poster-URL lokalisieren
_localize_posters([series], "series") _localize_posters([series], "series")
# Watch-Threshold aus Config (Standard: 90%, wie Plex)
watched_threshold = config.tv_config.get("watched_threshold_pct", 90)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/series_detail.html", request, { "tv/series_detail.html", request, {
"user": user, "user": user,
@ -476,6 +599,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"user_rating": user_rating, "user_rating": user_rating,
"avg_rating": avg_rating, "avg_rating": avg_rating,
"tvdb_score": series.get("tvdb_score"), "tvdb_score": series.get("tvdb_score"),
"watched_threshold_pct": watched_threshold,
} }
) )
@ -921,6 +1045,7 @@ def setup_tv_routes(app: web.Application, config: Config,
is_admin=data.get("is_admin", False), is_admin=data.get("is_admin", False),
can_view_series=data.get("can_view_series", True), can_view_series=data.get("can_view_series", True),
can_view_movies=data.get("can_view_movies", True), can_view_movies=data.get("can_view_movies", True),
show_tech_info=data.get("show_tech_info", False),
allowed_paths=data.get("allowed_paths"), allowed_paths=data.get("allowed_paths"),
) )
@ -1052,10 +1177,22 @@ def setup_tv_routes(app: web.Application, config: Config,
"autoplay_enabled": lambda v: v == "on", "autoplay_enabled": lambda v: v == "on",
"autoplay_countdown_sec": lambda v: int(v), "autoplay_countdown_sec": lambda v: int(v),
"autoplay_max_episodes": lambda v: int(v), "autoplay_max_episodes": lambda v: int(v),
"home_show_continue": lambda v: v == "on",
"home_show_new": lambda v: v == "on",
"home_hide_watched": lambda v: v == "on",
"home_show_watched": lambda v: v == "on",
}
# Checkbox-Felder: Wenn nicht in data -> False (bei Full-Form)
checkbox_fields = {
"subtitles_enabled", "autoplay_enabled",
"home_show_continue", "home_show_new",
"home_hide_watched", "home_show_watched",
} }
for key, transform in field_map.items(): for key, transform in field_map.items():
if key in data: if key in data:
user_kwargs[key] = transform(data[key]) user_kwargs[key] = transform(data[key])
elif not is_ajax and key in checkbox_fields:
user_kwargs[key] = False
if user_kwargs: if user_kwargs:
await auth_service.update_user_settings( await auth_service.update_user_settings(
@ -1206,6 +1343,157 @@ def setup_tv_routes(app: web.Application, config: Config,
lang = request.query.get("lang", "de") lang = request.query.get("lang", "de")
return web.json_response(get_all_translations(lang)) return web.json_response(get_all_translations(lang))
# --- HLS Streaming API ---
@require_auth
async def post_hls_start(request: web.Request) -> web.Response:
"""POST /tv/api/hls/start - HLS-Session starten
Body: { video_id, quality?, audio?, sound?, t? }"""
if not hls_manager:
return web.json_response(
{"error": "HLS nicht verfuegbar"}, status=503)
try:
data = await request.json()
except Exception:
return web.json_response(
{"error": "Ungueltiges JSON"}, status=400)
video_id = int(data.get("video_id", 0))
if not video_id:
return web.json_response(
{"error": "video_id erforderlich"}, status=400)
quality = data.get("quality", "hd")
audio_idx = int(data.get("audio", 0))
sound_mode = data.get("sound", "stereo")
seek_sec = float(data.get("t", 0))
client_codecs = data.get("codecs") # z.B. ["h264", "av1", "vp9"]
session = await hls_manager.create_session(
video_id, quality, audio_idx, sound_mode, seek_sec,
client_codecs=client_codecs)
if not session:
return web.json_response(
{"error": "Session konnte nicht erstellt werden"}, status=500)
if session.error:
return web.json_response(
{"error": f"ffmpeg Fehler: {session.error[:200]}"}, status=500)
return web.json_response({
"session_id": session.session_id,
"playlist_url": f"/tv/api/hls/{session.session_id}/playlist.m3u8",
"ready": session.ready,
})
async def get_hls_playlist(request: web.Request) -> web.Response:
"""GET /tv/api/hls/{session_id}/playlist.m3u8 - HLS Manifest"""
if not hls_manager:
return web.Response(status=503)
session_id = request.match_info["session_id"]
session = hls_manager.get_session(session_id)
if not session:
return web.Response(status=404, text="Session nicht gefunden")
if not session.playlist_path.exists():
# Playlist noch nicht bereit - leere HLS-Playlist zurueckgeben
# hls.js und natives HLS laden sie automatisch erneut
return web.Response(
text="#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n",
content_type="application/vnd.apple.mpegurl",
headers={
"Cache-Control": "no-cache, no-store",
"Access-Control-Allow-Origin": "*",
})
return web.FileResponse(
session.playlist_path,
headers={
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-cache, no-store",
"Access-Control-Allow-Origin": "*",
})
async def get_hls_segment(request: web.Request) -> web.Response:
"""GET /tv/api/hls/{session_id}/{segment} - HLS Segment (.m4s/.mp4)"""
if not hls_manager:
return web.Response(status=503)
session_id = request.match_info["session_id"]
segment = request.match_info["segment"]
session = hls_manager.get_session(session_id)
if not session:
return web.Response(status=404, text="Session nicht gefunden")
# Path-Traversal verhindern (z.B. ../../etc/passwd)
seg_path = (session.dir / segment).resolve()
if not str(seg_path).startswith(str(session.dir.resolve())):
return web.Response(status=403, text="Zugriff verweigert")
if not seg_path.exists():
return web.Response(status=404, text="Segment nicht gefunden")
# Content-Type je nach Dateiendung
if segment.endswith(".mp4") or segment.endswith(".m4s"):
content_type = "video/mp4"
elif segment.endswith(".ts"):
content_type = "video/mp2t"
else:
content_type = "application/octet-stream"
return web.FileResponse(
seg_path,
headers={
"Content-Type": content_type,
"Cache-Control": "public, max-age=3600",
"Access-Control-Allow-Origin": "*",
})
@require_auth
async def delete_hls_session(request: web.Request) -> web.Response:
"""DELETE /tv/api/hls/{session_id} - Session beenden"""
if not hls_manager:
return web.json_response({"error": "HLS nicht verfuegbar"},
status=503)
session_id = request.match_info["session_id"]
await hls_manager.destroy_session(session_id)
return web.json_response({"success": True})
# --- HLS Admin-API (fuer TV Admin-Center) ---
async def get_hls_sessions(request: web.Request) -> web.Response:
"""GET /api/tv/hls-sessions - Aktive HLS-Sessions auflisten"""
if not hls_manager:
return web.json_response({"sessions": []})
sessions = hls_manager.get_all_sessions_info()
# Video-Namen aus DB nachladen fuer Anzeige
pool = await library_service._get_pool()
if pool:
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
for s in sessions:
await cur.execute(
"SELECT file_name FROM library_videos "
"WHERE id = %s", (s["video_id"],))
row = await cur.fetchone()
s["video_name"] = row["file_name"] if row else ""
except Exception:
pass
return web.json_response({"sessions": sessions})
async def delete_hls_session_admin(request: web.Request) -> web.Response:
"""DELETE /api/tv/hls-sessions/{sid} - HLS-Session beenden (Admin)"""
if not hls_manager:
return web.json_response({"error": "HLS nicht verfuegbar"},
status=400)
sid = request.match_info["sid"]
await hls_manager.destroy_session(sid)
return web.json_response({"success": True})
# --- Routes registrieren --- # --- Routes registrieren ---
# TV-Seiten (mit Auth via Decorator) # TV-Seiten (mit Auth via Decorator)
@ -1238,6 +1526,15 @@ def setup_tv_routes(app: web.Application, config: Config,
app.router.add_get("/tv/api/i18n", get_i18n) app.router.add_get("/tv/api/i18n", get_i18n)
app.router.add_post("/tv/api/rating", post_rating) app.router.add_post("/tv/api/rating", post_rating)
# HLS Streaming API
app.router.add_post("/tv/api/hls/start", post_hls_start)
app.router.add_get(
"/tv/api/hls/{session_id}/playlist.m3u8", get_hls_playlist)
app.router.add_get(
"/tv/api/hls/{session_id}/{segment}", get_hls_segment)
app.router.add_delete(
"/tv/api/hls/{session_id}", delete_hls_session)
# Admin-API (QR-Code, User-Verwaltung) # Admin-API (QR-Code, User-Verwaltung)
app.router.add_get("/api/tv/qrcode", get_qrcode) app.router.add_get("/api/tv/qrcode", get_qrcode)
app.router.add_get("/api/tv/url", get_tv_url) app.router.add_get("/api/tv/url", get_tv_url)
@ -1245,3 +1542,5 @@ def setup_tv_routes(app: web.Application, config: Config,
app.router.add_post("/api/tv/users", post_user) app.router.add_post("/api/tv/users", post_user)
app.router.add_put("/api/tv/users/{id}", put_user) app.router.add_put("/api/tv/users/{id}", put_user)
app.router.add_delete("/api/tv/users/{id}", delete_user) app.router.add_delete("/api/tv/users/{id}", delete_user)
app.router.add_get("/api/tv/hls-sessions", get_hls_sessions)
app.router.add_delete("/api/tv/hls-sessions/{sid}", delete_hls_session_admin)

View file

@ -1,6 +1,7 @@
"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App""" """Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App"""
import asyncio import asyncio
import logging import logging
import time
from pathlib import Path from pathlib import Path
from aiohttp import web from aiohttp import web
import aiohttp_jinja2 import aiohttp_jinja2
@ -15,6 +16,7 @@ from app.services.tvdb import TVDBService
from app.services.cleaner import CleanerService from app.services.cleaner import CleanerService
from app.services.importer import ImporterService from app.services.importer import ImporterService
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.hls import HLSSessionManager
from app.services.i18n import load_translations, setup_jinja2_i18n from app.services.i18n import load_translations, setup_jinja2_i18n
from app.routes.api import setup_api_routes from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes from app.routes.library_api import setup_library_routes
@ -86,6 +88,10 @@ class VideoKonverterServer:
load_translations(str(static_dir)) load_translations(str(static_dir))
setup_jinja2_i18n(self.app) setup_jinja2_i18n(self.app)
# Cache-Busting: Versionsstempel fuer statische Dateien
env = self.app[aiohttp_jinja2.APP_KEY]
env.globals["v"] = str(int(time.time()))
# WebSocket Route # WebSocket Route
ws_path = self.config.server_config.get("websocket_path", "/ws") ws_path = self.config.server_config.get("websocket_path", "/ws")
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket) self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
@ -110,9 +116,13 @@ class VideoKonverterServer:
async def _lazy_pool(): async def _lazy_pool():
return self.library_service._db_pool return self.library_service._db_pool
self.auth_service = AuthService(_lazy_pool) self.auth_service = AuthService(_lazy_pool)
self.hls_manager = HLSSessionManager(
self.library_service, self.config.gpu_device,
queue_service=self.queue_service, config=self.config)
setup_tv_routes( setup_tv_routes(
self.app, self.config, self.app, self.config,
self.auth_service, self.library_service, self.auth_service, self.library_service,
self.hls_manager,
) )
# Statische Dateien # Statische Dateien
@ -171,12 +181,16 @@ class VideoKonverterServer:
if self.library_service._db_pool: if self.library_service._db_pool:
await self.auth_service.init_db() await self.auth_service.init_db()
# HLS Session Manager starten
await self.hls_manager.start()
host = self.config.server_config.get("host", "0.0.0.0") host = self.config.server_config.get("host", "0.0.0.0")
port = self.config.server_config.get("port", 8080) port = self.config.server_config.get("port", 8080)
logging.info(f"Server bereit auf http://{host}:{port}") logging.info(f"Server bereit auf http://{host}:{port}")
async def _on_shutdown(self, app: web.Application) -> None: async def _on_shutdown(self, app: web.Application) -> None:
"""Server-Stop: Queue und Library stoppen""" """Server-Stop: Queue und Library stoppen"""
await self.hls_manager.stop()
await self.queue_service.stop() await self.queue_service.stop()
await self.library_service.stop() await self.library_service.stop()
logging.info("Server heruntergefahren") logging.info("Server heruntergefahren")

View file

@ -222,6 +222,20 @@ class AuthService:
await add_column("tv_users", "theme", await add_column("tv_users", "theme",
"VARCHAR(16) DEFAULT 'dark'") "VARCHAR(16) DEFAULT 'dark'")
# tv_users: Technische Metadaten in Serien-Detail anzeigen
await add_column("tv_users", "show_tech_info",
"TINYINT DEFAULT 0")
# tv_users: Startseiten-Rubriken konfigurierbar
await add_column("tv_users", "home_show_continue",
"TINYINT DEFAULT 1")
await add_column("tv_users", "home_show_new",
"TINYINT DEFAULT 1")
await add_column("tv_users", "home_hide_watched",
"TINYINT DEFAULT 1")
await add_column("tv_users", "home_show_watched",
"TINYINT DEFAULT 1")
# library_series: TVDB-Score (externe Bewertung 0-100) # library_series: TVDB-Score (externe Bewertung 0-100)
await add_column("library_series", "tvdb_score", await add_column("library_series", "tvdb_score",
"FLOAT DEFAULT NULL") "FLOAT DEFAULT NULL")
@ -253,6 +267,7 @@ class AuthService:
display_name: str = None, is_admin: bool = False, display_name: str = None, is_admin: bool = False,
can_view_series: bool = True, can_view_series: bool = True,
can_view_movies: bool = True, can_view_movies: bool = True,
show_tech_info: bool = False,
allowed_paths: list = None) -> Optional[int]: allowed_paths: list = None) -> Optional[int]:
"""Erstellt neuen User, gibt ID zurueck""" """Erstellt neuen User, gibt ID zurueck"""
pw_hash = bcrypt.hashpw( pw_hash = bcrypt.hashpw(
@ -269,10 +284,12 @@ class AuthService:
await cur.execute(""" await cur.execute("""
INSERT INTO tv_users INSERT INTO tv_users
(username, password_hash, display_name, is_admin, (username, password_hash, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths) can_view_series, can_view_movies, show_tech_info,
VALUES (%s, %s, %s, %s, %s, %s, %s) allowed_paths)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (username, pw_hash, display_name, int(is_admin), """, (username, pw_hash, display_name, int(is_admin),
int(can_view_series), int(can_view_movies), paths_json)) int(can_view_series), int(can_view_movies),
int(show_tech_info), paths_json))
return cur.lastrowid return cur.lastrowid
except Exception as e: except Exception as e:
logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}") logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}")
@ -295,7 +312,8 @@ class AuthService:
values.append(pw_hash) values.append(pw_hash)
for field in ("display_name", "is_admin", for field in ("display_name", "is_admin",
"can_view_series", "can_view_movies"): "can_view_series", "can_view_movies",
"show_tech_info"):
if field in kwargs: if field in kwargs:
updates.append(f"{field} = %s") updates.append(f"{field} = %s")
val = kwargs[field] val = kwargs[field]
@ -349,8 +367,8 @@ class AuthService:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(""" await cur.execute("""
SELECT id, username, display_name, is_admin, SELECT id, username, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths, can_view_series, can_view_movies, show_tech_info,
last_login, created_at allowed_paths, last_login, created_at
FROM tv_users ORDER BY id FROM tv_users ORDER BY id
""") """)
rows = await cur.fetchall() rows = await cur.fetchall()
@ -467,6 +485,8 @@ class AuthService:
u.series_view, u.movies_view, u.avatar_color, u.series_view, u.movies_view, u.avatar_color,
u.autoplay_enabled, u.autoplay_countdown_sec, u.autoplay_enabled, u.autoplay_countdown_sec,
u.autoplay_max_episodes, u.theme, u.autoplay_max_episodes, u.theme,
u.home_show_continue, u.home_show_new,
u.home_hide_watched, u.home_show_watched,
s.client_id s.client_id
FROM tv_sessions s FROM tv_sessions s
JOIN tv_users u ON s.user_id = u.id JOIN tv_users u ON s.user_id = u.id
@ -624,6 +644,8 @@ class AuthService:
"series_view", "movies_view", "avatar_color", "series_view", "movies_view", "avatar_color",
"autoplay_enabled", "autoplay_countdown_sec", "autoplay_enabled", "autoplay_countdown_sec",
"autoplay_max_episodes", "display_name", "theme", "autoplay_max_episodes", "display_name", "theme",
"home_show_continue", "home_show_new",
"home_hide_watched", "home_show_watched",
} }
updates = [] updates = []
values = [] values = []

View file

@ -0,0 +1,469 @@
"""HLS Session Manager - HTTP Live Streaming per ffmpeg
Verwaltet HLS-Sessions: ffmpeg erzeugt m3u8 + fMP4-Segmente (.m4s),
die dann per HTTP ausgeliefert werden. Vorteile gegenueber Pipe-Streaming:
- Sofortiger Playback-Start (erstes Segment in ~1s verfuegbar)
- Natives Seeking ueber Segmente
- fMP4 statt mpegts (bessere Codec-Kompatibilitaet)
- Automatische Codec-Erkennung: Client meldet unterstuetzte Codecs,
Server entscheidet copy vs. H.264-Transcoding (CPU oder GPU/VAAPI)
- hls.js Polyfill fuer Browser ohne native HLS-Unterstuetzung
- Samsung Tizen hat native HLS-Unterstuetzung
"""
import asyncio
import json
import logging
import os
import shutil
import time
import uuid
from pathlib import Path
from typing import Optional
import aiomysql
from app.services.library import LibraryService
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
# Video-Codec-Normalisierung (DB-Werte -> einfache Namen fuer Client-Abgleich)
CODEC_NORMALIZE = {
"av1": "av1", "libaom-av1": "av1", "libsvtav1": "av1", "av1_vaapi": "av1",
"hevc": "hevc", "h265": "hevc", "libx265": "hevc", "hevc_vaapi": "hevc",
"h264": "h264", "avc": "h264", "libx264": "h264", "h264_vaapi": "h264",
"vp9": "vp9", "libvpx-vp9": "vp9", "vp8": "vp8",
"mpeg4": "mpeg4", "mpeg2video": "mpeg2",
}
# HLS Konfiguration (Defaults, werden von Config ueberschrieben)
HLS_BASE_DIR = Path("/tmp/hls")
# Qualitaets-Stufen (Ziel-Hoehe)
QUALITY_HEIGHTS = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
class HLSSession:
"""Einzelne HLS-Streaming-Session"""
def __init__(self, session_id: str, video_id: int, quality: str,
audio_idx: int, sound_mode: str, seek_sec: float):
self.session_id = session_id
self.video_id = video_id
self.quality = quality
self.audio_idx = audio_idx
self.sound_mode = sound_mode
self.seek_sec = seek_sec
self.process: Optional[asyncio.subprocess.Process] = None
self.created_at = time.time()
self.last_access = time.time()
self.ready = False
self.error: Optional[str] = None
@property
def dir(self) -> Path:
return HLS_BASE_DIR / self.session_id
@property
def playlist_path(self) -> Path:
return self.dir / "stream.m3u8"
def touch(self):
"""Letzten Zugriff aktualisieren"""
self.last_access = time.time()
def is_expired(self, timeout_sec: int = 300) -> bool:
return (time.time() - self.last_access) > timeout_sec
class HLSSessionManager:
"""Verwaltet alle HLS-Sessions mit Auto-Cleanup"""
def __init__(self, library_service: LibraryService,
gpu_device: str = "/dev/dri/renderD128",
queue_service=None, config=None):
self._library = library_service
self._sessions: dict[str, HLSSession] = {}
self._cleanup_task: Optional[asyncio.Task] = None
self._gpu_device = gpu_device
self._gpu_available = os.path.exists(gpu_device)
self._queue_service = queue_service
self._config = config
def _tv_setting(self, key: str, default):
"""TV-Einstellung aus Config lesen (mit Fallback)"""
if self._config:
return self._config.tv_config.get(key, default)
return default
async def start(self):
"""Cleanup-Task starten und Verzeichnis vorbereiten"""
HLS_BASE_DIR.mkdir(parents=True, exist_ok=True)
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
gpu_status = (f"GPU verfuegbar ({self._gpu_device})"
if self._gpu_available else "Nur CPU-Transcoding")
logging.info(f"HLS Session Manager gestartet - {gpu_status}")
async def stop(self):
"""Alle Sessions beenden und aufraumen"""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
for sid in list(self._sessions):
await self.destroy_session(sid)
logging.info("HLS Session Manager gestoppt")
async def create_session(self, video_id: int, quality: str = "hd",
audio_idx: int = 0, sound_mode: str = "stereo",
seek_sec: float = 0,
client_codecs: list[str] = None,
) -> Optional[HLSSession]:
"""Neue HLS-Session erstellen und ffmpeg starten
client_codecs: Liste der vom Client unterstuetzten Video-Codecs
(z.B. ["h264", "hevc", "av1"]). Wenn der Quell-Codec nicht drin ist,
wird automatisch zu H.264 transkodiert (GPU wenn verfuegbar).
"""
# Max. gleichzeitige Sessions pruefen
max_sessions = self._tv_setting("hls_max_sessions", 5)
if len(self._sessions) >= max_sessions:
logging.warning(f"HLS: Max. Sessions ({max_sessions}) erreicht - "
f"neue Session abgelehnt")
return None
pool = await self._library._get_pool()
if not pool:
return None
# Video-Info aus DB laden
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT file_path, width, height, video_codec, "
"audio_tracks, container, duration_sec "
"FROM library_videos WHERE id = %s",
(video_id,))
video = await cur.fetchone()
if not video:
return None
except Exception as e:
logging.error(f"HLS Session DB-Fehler: {e}")
return None
file_path = video["file_path"]
if not os.path.isfile(file_path):
logging.error(f"HLS: Datei nicht gefunden: {file_path}")
return None
# Session erstellen
session_id = uuid.uuid4().hex[:16]
session = HLSSession(session_id, video_id, quality,
audio_idx, sound_mode, seek_sec)
session.dir.mkdir(parents=True, exist_ok=True)
# Audio-Tracks parsen
audio_tracks = video.get("audio_tracks") or "[]"
if isinstance(audio_tracks, str):
audio_tracks = json.loads(audio_tracks)
if audio_idx >= len(audio_tracks):
audio_idx = 0
audio_info = audio_tracks[audio_idx] if audio_tracks else {}
audio_codec = audio_info.get("codec", "unknown")
audio_channels = audio_info.get("channels", 2)
# Video-Codec: Kann der Client das direkt abspielen?
video_codec = video.get("video_codec", "unknown")
codec_name = CODEC_NORMALIZE.get(video_codec, video_codec)
# Fallback: wenn keine Codecs gemeldet -> nur H.264 annehmen
supported = client_codecs or ["h264"]
client_can_play = codec_name in supported
# Ziel-Aufloesung
orig_h = video.get("height") or 1080
target_h = QUALITY_HEIGHTS.get(quality, 1080)
needs_video_scale = orig_h > target_h and quality != "uhd"
needs_video_transcode = not client_can_play
# Audio-Transcoding noetig?
needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS
# Sound-Modus
if sound_mode == "stereo":
out_channels = 2
elif sound_mode == "surround":
out_channels = min(audio_channels, 8)
else:
out_channels = audio_channels
if out_channels != audio_channels:
needs_audio_transcode = True
# ffmpeg starten (GPU-Versuch mit CPU-Fallback)
use_gpu = (self._gpu_available
and (needs_video_transcode or needs_video_scale))
cmd = self._build_ffmpeg_cmd(
file_path, session, seek_sec, audio_idx, quality,
needs_video_transcode, needs_video_scale, target_h,
needs_audio_transcode, out_channels, use_gpu)
vmode = "copy" if not (needs_video_transcode or needs_video_scale) else (
"h264_vaapi" if use_gpu else "libx264")
logging.info(f"HLS Session {session_id}: starte ffmpeg fuer "
f"Video {video_id} (q={quality}, v={vmode}, "
f"src={video_codec}, audio={audio_idx})")
logging.debug(f"HLS ffmpeg cmd: {' '.join(cmd)}")
try:
session.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logging.error(f"HLS ffmpeg Start fehlgeschlagen: {e}")
shutil.rmtree(session.dir, ignore_errors=True)
return None
# GPU-Fallback: Wenn ffmpeg sofort scheitert (z.B. h264_vaapi nicht
# unterstuetzt) -> automatisch mit CPU-Encoding neu starten
if use_gpu:
await asyncio.sleep(1.0)
if session.process.returncode is not None:
stderr = await session.process.stderr.read()
err_msg = stderr.decode("utf-8", errors="replace")[:300]
logging.warning(
f"HLS GPU-Encoding fehlgeschlagen (Code "
f"{session.process.returncode}): {err_msg}")
logging.info("HLS: Fallback auf CPU-Encoding (libx264)")
self._gpu_available = False # GPU fuer HLS deaktivieren
# Dateien aufraumen und neu starten
for f in session.dir.iterdir():
f.unlink(missing_ok=True)
cmd = self._build_ffmpeg_cmd(
file_path, session, seek_sec, audio_idx, quality,
needs_video_transcode, needs_video_scale, target_h,
needs_audio_transcode, out_channels, use_gpu=False)
logging.debug(f"HLS CPU-Fallback cmd: {' '.join(cmd)}")
try:
session.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logging.error(f"HLS CPU-Fallback Start fehlgeschlagen: {e}")
shutil.rmtree(session.dir, ignore_errors=True)
return None
self._sessions[session_id] = session
# Laufende Batch-Konvertierungen einfrieren (Ressourcen fuer Stream)
if self._queue_service and self._tv_setting("pause_batch_on_stream", True):
count = self._queue_service.suspend_encoding()
logging.info(f"HLS: {count} Konvertierung(en) eingefroren")
# Kurz warten ob erstes Segment schnell kommt (Copy-Modus: <1s)
# Bei Transcoding nicht lange blockieren - hls.js/native HLS
# haben eigene Retry-Logik fuer noch nicht verfuegbare Segmente
timeout = 3.0 if (needs_video_transcode or needs_video_scale) else 2.0
await self._wait_for_ready(session, timeout=timeout)
return session
def _build_ffmpeg_cmd(self, file_path: str, session: HLSSession,
seek_sec: float, audio_idx: int, quality: str,
needs_video_transcode: bool,
needs_video_scale: bool, target_h: int,
needs_audio_transcode: bool,
out_channels: int, use_gpu: bool) -> list[str]:
"""Baut das ffmpeg-Kommando fuer HLS-Streaming.
GPU-Modus: Software-Decode -> NV12 -> hwupload -> h264_vaapi
(zuverlaessiger als Full-HW-Pipeline, funktioniert mit allen Quell-Codecs)"""
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
# Schnellere Datei-Analyse
cmd += ["-analyzeduration", "3000000", # 3 Sekunden
"-probesize", "3000000"] # 3 MB
# VAAPI-Device (KEIN hwaccel - Software-Decode ist zuverlaessiger
# fuer beliebige Quell-Codecs wie AV1/VP9/HEVC)
if use_gpu:
cmd += ["-vaapi_device", self._gpu_device]
if seek_sec > 0:
cmd += ["-ss", str(seek_sec)]
cmd += ["-i", file_path]
# Video-Codec Entscheidung
cmd += ["-map", "0:v:0"]
if needs_video_scale or needs_video_transcode:
crf = {"sd": "23", "low": "28"}.get(quality, "20")
if use_gpu:
# VAAPI Hardware-Encoding (Intel A380):
# format=nv12 (CPU) -> hwupload (VAAPI) -> h264_vaapi
vf_parts = ["format=nv12"]
if needs_video_scale:
# CPU-seitig skalieren, dann hochladen
vf_parts.insert(0, f"scale=-2:{target_h}")
vf_parts.append("hwupload")
cmd += ["-vf", ",".join(vf_parts),
"-c:v", "h264_vaapi", "-qp", crf]
else:
# CPU Software-Encoding
vf_parts = []
if needs_video_scale:
vf_parts.append(f"scale=-2:{target_h}")
cmd += ["-c:v", "libx264", "-preset", "veryfast",
"-crf", crf]
if vf_parts:
cmd += ["-vf", ",".join(vf_parts)]
else:
cmd += ["-c:v", "copy"]
# Audio
cmd += ["-map", f"0:a:{audio_idx}"]
if needs_audio_transcode:
bitrate = {1: "96k", 2: "192k"}.get(
out_channels, f"{out_channels * 64}k")
cmd += ["-c:a", "aac", "-ac", str(out_channels),
"-b:a", bitrate]
else:
cmd += ["-c:a", "copy"]
# HLS Output - fMP4 Segmente
seg_dur = self._tv_setting("hls_segment_duration", 4)
init_dur = self._tv_setting("hls_init_duration", 1)
cmd += [
"-f", "hls",
"-hls_time", str(seg_dur),
"-hls_init_time", str(init_dur),
"-hls_list_size", "0",
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4",
"-hls_flags", "append_list+independent_segments",
"-hls_segment_filename", str(session.dir / "seg%05d.m4s"),
"-start_number", "0",
str(session.playlist_path),
]
return cmd
async def _wait_for_ready(self, session: HLSSession,
timeout: float = 15.0):
"""Warten bis Playlist und erstes Segment existieren"""
start = time.time()
while (time.time() - start) < timeout:
if session.playlist_path.exists():
# Pruefen ob Init-Segment + mindestens ein Media-Segment da ist
init_seg = session.dir / "init.mp4"
segments = list(session.dir.glob("seg*.m4s"))
if init_seg.exists() and segments:
session.ready = True
logging.info(
f"HLS Session {session.session_id}: bereit "
f"({len(segments)} Segmente, "
f"{time.time() - start:.1f}s)")
return
# ffmpeg beendet?
if session.process and session.process.returncode is not None:
stderr = await session.process.stderr.read()
session.error = stderr.decode("utf-8", errors="replace")
logging.error(
f"HLS ffmpeg beendet mit Code "
f"{session.process.returncode}: {session.error[:500]}")
return
await asyncio.sleep(0.3)
logging.warning(
f"HLS Session {session.session_id}: Timeout nach {timeout}s")
def get_session(self, session_id: str) -> Optional[HLSSession]:
"""Session anhand ID holen und Zugriff aktualisieren"""
session = self._sessions.get(session_id)
if session:
session.touch()
return session
async def destroy_session(self, session_id: str):
"""Session beenden: ffmpeg stoppen + Dateien loeschen"""
session = self._sessions.pop(session_id, None)
if not session:
return
# ffmpeg-Prozess beenden
if session.process and session.process.returncode is None:
session.process.terminate()
try:
await asyncio.wait_for(session.process.wait(), timeout=5)
except asyncio.TimeoutError:
session.process.kill()
await session.process.wait()
# Dateien aufraumen
if session.dir.exists():
shutil.rmtree(session.dir, ignore_errors=True)
logging.info(f"HLS Session {session_id} beendet")
# Wenn keine Sessions mehr aktiv -> Batch-Konvertierung fortsetzen
if (not self._sessions and self._queue_service
and self._tv_setting("pause_batch_on_stream", True)):
count = self._queue_service.resume_encoding()
logging.info(f"HLS: Alle Sessions beendet, "
f"{count} Konvertierung(en) fortgesetzt")
async def _cleanup_loop(self):
"""Periodisch abgelaufene Sessions entfernen"""
while True:
try:
await asyncio.sleep(30)
timeout_sec = self._tv_setting("hls_session_timeout_min", 5) * 60
expired = [
sid for sid, s in self._sessions.items()
if s.is_expired(timeout_sec)
]
for sid in expired:
logging.info(
f"HLS Session {sid} abgelaufen (Timeout)")
await self.destroy_session(sid)
# Verwaiste Verzeichnisse aufraumen
if HLS_BASE_DIR.exists():
for d in HLS_BASE_DIR.iterdir():
if d.is_dir() and d.name not in self._sessions:
shutil.rmtree(d, ignore_errors=True)
except asyncio.CancelledError:
raise
except Exception as e:
logging.error(f"HLS Cleanup Fehler: {e}")
@property
def active_sessions(self) -> int:
return len(self._sessions)
def get_all_sessions_info(self) -> list[dict]:
"""Alle Sessions als Info-Liste (fuer Debug/Admin)"""
return [
{
"session_id": s.session_id,
"video_id": s.video_id,
"quality": s.quality,
"ready": s.ready,
"age_sec": int(time.time() - s.created_at),
"idle_sec": int(time.time() - s.last_access),
}
for s in self._sessions.values()
]

View file

@ -12,7 +12,8 @@ import aiomysql
from app.config import Config from app.config import Config
from app.services.library import ( from app.services.library import (
LibraryService, VIDEO_EXTENSIONS, RE_SXXEXX, RE_XXxXX LibraryService, VIDEO_EXTENSIONS,
RE_SXXEXX_MULTI, RE_XXxXX_MULTI
) )
from app.services.tvdb import TVDBService from app.services.tvdb import TVDBService
from app.services.probe import ProbeService from app.services.probe import ProbeService
@ -120,6 +121,7 @@ class ImporterService:
detected_series VARCHAR(256), detected_series VARCHAR(256),
detected_season INT, detected_season INT,
detected_episode INT, detected_episode INT,
detected_episode_end INT NULL,
tvdb_series_id INT NULL, tvdb_series_id INT NULL,
tvdb_series_name VARCHAR(256), tvdb_series_name VARCHAR(256),
tvdb_episode_title VARCHAR(512), tvdb_episode_title VARCHAR(512),
@ -138,6 +140,20 @@ class ImporterService:
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""") """)
logging.info("Import-Tabellen initialisiert") logging.info("Import-Tabellen initialisiert")
# Migration: detected_episode_end Spalte hinzufuegen
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
try:
await cur.execute(
"ALTER TABLE import_items "
"ADD COLUMN detected_episode_end INT NULL "
"AFTER detected_episode"
)
logging.info("Import: Spalte detected_episode_end hinzugefuegt")
except Exception:
pass # Spalte existiert bereits
except Exception as e: except Exception as e:
logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}") logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}")
@ -316,6 +332,7 @@ class ImporterService:
series_name = info.get("series", "") series_name = info.get("series", "")
season = info.get("season") season = info.get("season")
episode = info.get("episode") episode = info.get("episode")
episode_end = info.get("episode_end")
# Status: pending_series wenn Serie erkannt, sonst pending # Status: pending_series wenn Serie erkannt, sonst pending
if series_name and season and episode: if series_name and season and episode:
@ -332,11 +349,13 @@ class ImporterService:
detected_series = %s, detected_series = %s,
detected_season = %s, detected_season = %s,
detected_episode = %s, detected_episode = %s,
detected_episode_end = %s,
status = %s, status = %s,
conflict_reason = %s conflict_reason = %s
WHERE id = %s WHERE id = %s
""", ( """, (
series_name, season, episode, status, series_name, season, episode, episode_end,
status,
None if status == "pending_series" None if status == "pending_series"
else "Serie/Staffel/Episode nicht erkannt", else "Serie/Staffel/Episode nicht erkannt",
item["id"], item["id"],
@ -427,6 +446,7 @@ class ImporterService:
for item in items: for item in items:
season = item["detected_season"] season = item["detected_season"]
episode = item["detected_episode"] episode = item["detected_episode"]
episode_end = item.get("detected_episode_end")
# Episodentitel von TVDB # Episodentitel von TVDB
tvdb_ep_title = "" tvdb_ep_title = ""
@ -443,7 +463,8 @@ class ImporterService:
tvdb_name, season, episode, tvdb_name, season, episode,
tvdb_ep_title, ext, tvdb_ep_title, ext,
job["lib_path"], job["lib_path"],
pattern, season_pat pattern, season_pat,
episode_end=episode_end
) )
target_path = os.path.join(target_dir, target_file) target_path = os.path.join(target_dir, target_file)
@ -574,6 +595,7 @@ class ImporterService:
"series": staffel_info["series"], "series": staffel_info["series"],
"season": staffel_info["season"], "season": staffel_info["season"],
"episode": info_file["episode"], "episode": info_file["episode"],
"episode_end": info_file.get("episode_end"),
} }
# Dateiname hat S/E # Dateiname hat S/E
@ -621,25 +643,31 @@ class ImporterService:
return None return None
def _parse_name(self, name: str) -> dict: def _parse_name(self, name: str) -> dict:
"""Extrahiert Serienname, Staffel, Episode aus einem Namen""" """Extrahiert Serienname, Staffel, Episode aus einem Namen.
result = {"series": "", "season": None, "episode": None} Unterstuetzt Doppelfolgen: S09E19E20, S01E01-E02, 1x01-02"""
result = {"series": "", "season": None, "episode": None,
"episode_end": None}
name_no_ext = os.path.splitext(name)[0] name_no_ext = os.path.splitext(name)[0]
# S01E02 Format # S01E02 / Doppelfolge S01E01E02 Format
m = RE_SXXEXX.search(name) m = RE_SXXEXX_MULTI.search(name)
if m: if m:
result["season"] = int(m.group(1)) result["season"] = int(m.group(1))
result["episode"] = int(m.group(2)) result["episode"] = int(m.group(2))
if m.group(3):
result["episode_end"] = int(m.group(3))
sm = RE_SERIES_FROM_NAME.match(name_no_ext) sm = RE_SERIES_FROM_NAME.match(name_no_ext)
if sm: if sm:
result["series"] = self._clean_name(sm.group(1)) result["series"] = self._clean_name(sm.group(1))
return result return result
# 1x02 Format # 1x02 / Doppelfolge 1x01-02 Format
m = RE_XXxXX.search(name) m = RE_XXxXX_MULTI.search(name)
if m: if m:
result["season"] = int(m.group(1)) result["season"] = int(m.group(1))
result["episode"] = int(m.group(2)) result["episode"] = int(m.group(2))
if m.group(3):
result["episode_end"] = int(m.group(3))
sm = RE_SERIES_FROM_XXx.match(name_no_ext) sm = RE_SERIES_FROM_XXx.match(name_no_ext)
if sm: if sm:
result["series"] = self._clean_name(sm.group(1)) result["series"] = self._clean_name(sm.group(1))
@ -660,14 +688,21 @@ class ImporterService:
def _build_target(self, series: str, season: Optional[int], def _build_target(self, series: str, season: Optional[int],
episode: Optional[int], title: str, ext: str, episode: Optional[int], title: str, ext: str,
lib_path: str, pattern: str, lib_path: str, pattern: str,
season_pattern: str) -> tuple[str, str]: season_pattern: str,
"""Baut Ziel-Ordner und Dateiname nach Pattern""" episode_end: Optional[int] = None) -> tuple[str, str]:
"""Baut Ziel-Ordner und Dateiname nach Pattern.
Unterstuetzt Doppelfolgen via episode_end."""
s = season or 1 s = season or 1
e = episode or 0 e = episode or 0
# Season-Ordner # Season-Ordner
season_dir = season_pattern.format(season=s) season_dir = season_pattern.format(season=s)
# Episode-Teil: S01E02 oder S01E02E03 bei Doppelfolgen
ep_str = f"S{s:02d}E{e:02d}"
if episode_end and episode_end != e:
ep_str += f"E{episode_end:02d}"
# Dateiname - kein Titel: ohne Titel-Teil, sonst mit # Dateiname - kein Titel: ohne Titel-Teil, sonst mit
try: try:
if title: if title:
@ -675,14 +710,18 @@ class ImporterService:
series=series, season=s, episode=e, series=series, season=s, episode=e,
title=title, ext=ext title=title, ext=ext
) )
# Doppelfolge: E-Teil im generierten Namen ersetzen
if episode_end and episode_end != e:
filename = filename.replace(
f"S{s:02d}E{e:02d}", ep_str, 1
)
else: else:
# Ohne Titel: "Serie - S01E03.ext" filename = f"{series} - {ep_str}.{ext}"
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
except (KeyError, ValueError): except (KeyError, ValueError):
if title: if title:
filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}" filename = f"{series} - {ep_str} - {title}.{ext}"
else: else:
filename = f"{series} - S{s:02d}E{e:02d}.{ext}" filename = f"{series} - {ep_str}.{ext}"
# Ungueltige Zeichen entfernen # Ungueltige Zeichen entfernen
for ch in ['<', '>', ':', '"', '|', '?', '*']: for ch in ['<', '>', ':', '"', '|', '?', '*']:
@ -1224,6 +1263,7 @@ class ImporterService:
return False return False
allowed = { allowed = {
'detected_series', 'detected_season', 'detected_episode', 'detected_series', 'detected_season', 'detected_episode',
'detected_episode_end',
'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title', 'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title',
'target_path', 'target_filename', 'status' 'target_path', 'target_filename', 'status'
} }
@ -1400,6 +1440,7 @@ class ImporterService:
for item in items: for item in items:
season = item["detected_season"] season = item["detected_season"]
episode = item["detected_episode"] episode = item["detected_episode"]
ep_end = item.get("detected_episode_end")
# Episodentitel holen # Episodentitel holen
tvdb_ep_title = "" tvdb_ep_title = ""
@ -1420,7 +1461,8 @@ class ImporterService:
tvdb_name, season, episode, tvdb_name, season, episode,
tvdb_ep_title, ext, tvdb_ep_title, ext,
job["lib_path"], job["lib_path"],
pattern, season_pat pattern, season_pat,
episode_end=ep_end
) )
await cur.execute(""" await cur.execute("""
@ -1462,7 +1504,8 @@ class ImporterService:
"conflict_reason = 'Serie uebersprungen' " "conflict_reason = 'Serie uebersprungen' "
"WHERE import_job_id = %s " "WHERE import_job_id = %s "
"AND LOWER(detected_series) = LOWER(%s) " "AND LOWER(detected_series) = LOWER(%s) "
"AND status IN ('pending', 'matched')", "AND status IN ('pending', 'pending_series', "
"'matched')",
(job_id, detected_series) (job_id, detected_series)
) )
skipped = cur.rowcount skipped = cur.rowcount

View file

@ -3,6 +3,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import signal
import time import time
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
@ -34,6 +35,7 @@ class QueueService:
self._active_count: int = 0 self._active_count: int = 0
self._running: bool = False self._running: bool = False
self._paused: bool = False self._paused: bool = False
self._encoding_suspended: bool = False
self._queue_task: Optional[asyncio.Task] = None self._queue_task: Optional[asyncio.Task] = None
self._queue_file = str(config.data_path / "queue.json") self._queue_file = str(config.data_path / "queue.json")
self._db_pool: Optional[aiomysql.Pool] = None self._db_pool: Optional[aiomysql.Pool] = None
@ -195,6 +197,52 @@ class QueueService:
def is_paused(self) -> bool: def is_paused(self) -> bool:
return self._paused return self._paused
@property
def encoding_suspended(self) -> bool:
"""Sind aktive ffmpeg-Prozesse gerade per SIGSTOP eingefroren?"""
return self._encoding_suspended
def suspend_encoding(self) -> int:
"""Friert alle aktiven ffmpeg-Konvertierungen per SIGSTOP ein.
Wird aufgerufen wenn ein HLS-Stream startet, damit der Server
volle Ressourcen fuers Streaming hat.
Gibt die Anzahl pausierter Prozesse zurueck."""
count = 0
for job in self.jobs.values():
if (job.status == JobStatus.ACTIVE and job.process
and job.process.returncode is None):
try:
os.kill(job.process.pid, signal.SIGSTOP)
count += 1
except (ProcessLookupError, PermissionError) as e:
logging.warning(f"SIGSTOP fehlgeschlagen fuer PID "
f"{job.process.pid}: {e}")
if count:
self._encoding_suspended = True
logging.info(f"Encoding pausiert: {count} ffmpeg-Prozess(e) "
f"per SIGSTOP eingefroren (HLS-Stream aktiv)")
return count
def resume_encoding(self) -> int:
"""Setzt alle eingefrorenen ffmpeg-Konvertierungen per SIGCONT fort.
Wird aufgerufen wenn der letzte HLS-Stream endet.
Gibt die Anzahl fortgesetzter Prozesse zurueck."""
count = 0
for job in self.jobs.values():
if (job.status == JobStatus.ACTIVE and job.process
and job.process.returncode is None):
try:
os.kill(job.process.pid, signal.SIGCONT)
count += 1
except (ProcessLookupError, PermissionError) as e:
logging.warning(f"SIGCONT fehlgeschlagen fuer PID "
f"{job.process.pid}: {e}")
if count:
logging.info(f"Encoding fortgesetzt: {count} ffmpeg-Prozess(e) "
f"per SIGCONT aufgeweckt")
self._encoding_suspended = False
return count
async def retry_job(self, job_id: int) -> bool: async def retry_job(self, job_id: int) -> bool:
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED""" """Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
job = self.jobs.get(job_id) job = self.jobs.get(job_id)
@ -226,7 +274,8 @@ class QueueService:
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE, if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
JobStatus.FAILED, JobStatus.CANCELLED): JobStatus.FAILED, JobStatus.CANCELLED):
queue[job_id] = job.to_dict_queue() queue[job_id] = job.to_dict_queue()
return {"data_queue": queue, "queue_paused": self._paused} return {"data_queue": queue, "queue_paused": self._paused,
"encoding_suspended": self._encoding_suspended}
def get_active_jobs(self) -> dict: def get_active_jobs(self) -> dict:
"""Aktive Jobs fuer WebSocket""" """Aktive Jobs fuer WebSocket"""
@ -249,8 +298,8 @@ class QueueService:
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei""" """Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
while self._running: while self._running:
try: try:
if (not self._paused and if (not self._paused and not self._encoding_suspended
self._active_count < self.config.max_parallel_jobs): and self._active_count < self.config.max_parallel_jobs):
next_job = self._get_next_queued() next_job = self._get_next_queued()
if next_job: if next_job:
asyncio.create_task(self._execute_job(next_job)) asyncio.create_task(self._execute_job(next_job))
@ -315,6 +364,14 @@ class QueueService:
self._save_queue() self._save_queue()
await self._save_stats(job) await self._save_stats(job)
await self.ws_manager.broadcast_queue_update() await self.ws_manager.broadcast_queue_update()
# Library-Seite zum Reload auffordern (Badges aktualisieren)
if job.status == JobStatus.FINISHED:
await self.ws_manager.broadcast({
"data_library_scan": {
"status": "idle", "current": "",
"total": 0, "done": 0
}
})
async def _post_conversion_cleanup(self, job: ConversionJob) -> None: async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
"""Cleanup nach erfolgreicher Konvertierung. """Cleanup nach erfolgreicher Konvertierung.

View file

@ -38,6 +38,7 @@ class TVDBService:
def __init__(self, config: Config): def __init__(self, config: Config):
self.config = config self.config = config
self._client = None self._client = None
self._client_api_key = "" # Key mit dem der Client erstellt wurde
self._db_pool: Optional[aiomysql.Pool] = None self._db_pool: Optional[aiomysql.Pool] = None
@property @property
@ -64,12 +65,18 @@ class TVDBService:
self._db_pool = pool self._db_pool = pool
def _get_client(self): def _get_client(self):
"""Erstellt oder gibt TVDB-Client zurueck""" """Erstellt oder gibt TVDB-Client zurueck.
Erstellt neuen Client wenn API Key geaendert wurde."""
if not TVDB_AVAILABLE: if not TVDB_AVAILABLE:
return None return None
if not self._api_key: if not self._api_key:
return None return None
# Client neu erstellen wenn API Key geaendert wurde
if self._client and self._client_api_key != self._api_key:
logging.info("TVDB API Key geaendert - Client wird neu erstellt")
self._client = None
if self._client is None: if self._client is None:
try: try:
if self._pin: if self._pin:
@ -78,6 +85,7 @@ class TVDBService:
) )
else: else:
self._client = tvdb_v4_official.TVDB(self._api_key) self._client = tvdb_v4_official.TVDB(self._api_key)
self._client_api_key = self._api_key
logging.info("TVDB Client verbunden") logging.info("TVDB Client verbunden")
except Exception as e: except Exception as e:
logging.error(f"TVDB Verbindung fehlgeschlagen: {e}") logging.error(f"TVDB Verbindung fehlgeschlagen: {e}")

View file

@ -3210,22 +3210,49 @@ async function generateThumbnails() {
showToast("Thumbnail-Generierung laeuft bereits", "info"); showToast("Thumbnail-Generierung laeuft bereits", "info");
} else { } else {
showToast("Thumbnail-Generierung gestartet", "success"); showToast("Thumbnail-Generierung gestartet", "success");
pollThumbnailStatus();
} }
// Fortschrittsbalken anzeigen und Polling starten
showThumbnailProgress();
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);
if (!data.running) { if (!data.running) {
clearInterval(interval); clearInterval(interval);
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success"); // Kurz anzeigen, dann ausblenden
setTimeout(() => {
hideThumbnailProgress();
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
}, 2000);
} }
}) })
.catch(() => clearInterval(interval)); .catch(() => clearInterval(interval));
}, 3000); }, 2000);
} }

View file

@ -12,8 +12,8 @@
--bg-nav: #111; --bg-nav: #111;
--text: #e0e0e0; --text: #e0e0e0;
--text-muted: #888; --text-muted: #888;
--accent: #64b5f6; --accent: #e5a00d;
--accent-hover: #90caf9; --accent-hover: #f0b830;
--danger: #ef5350; --danger: #ef5350;
--success: #66bb6a; --success: #66bb6a;
--border: #333; --border: #333;
@ -31,8 +31,8 @@
--bg-nav: #24272c; --bg-nav: #24272c;
--text: #e8e8e8; --text: #e8e8e8;
--text-muted: #999; --text-muted: #999;
--accent: #5c9ce6; --accent: #d4940c;
--accent-hover: #7db4f0; --accent-hover: #e5a825;
--danger: #e05252; --danger: #e05252;
--success: #5dba5d; --success: #5dba5d;
--border: #4a4f56; --border: #4a4f56;
@ -48,8 +48,8 @@
--bg-nav: #ffffff; --bg-nav: #ffffff;
--text: #1a1a1a; --text: #1a1a1a;
--text-muted: #666; --text-muted: #666;
--accent: #1a73e8; --accent: #b8860b;
--accent-hover: #1565c0; --accent-hover: #9a7209;
--danger: #d32f2f; --danger: #d32f2f;
--success: #388e3c; --success: #388e3c;
--border: #dadce0; --border: #dadce0;
@ -125,7 +125,7 @@ a { color: var(--accent); text-decoration: none; }
overflow-x: auto; overflow-x: auto;
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
padding: 8px 0; padding: 4px 4px;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
.tv-row::-webkit-scrollbar { height: 4px; } .tv-row::-webkit-scrollbar { height: 4px; }
@ -134,9 +134,9 @@ a { color: var(--accent); text-decoration: none; }
.tv-row .tv-card { .tv-row .tv-card {
scroll-snap-align: start; scroll-snap-align: start;
flex-shrink: 0; flex-shrink: 0;
width: 180px; width: 90px;
} }
.tv-row .tv-card-wide { width: 260px; } .tv-row .tv-card-wide { width: 132px; }
/* === Poster-Grid === */ /* === Poster-Grid === */
.tv-grid { .tv-grid {
@ -158,18 +158,30 @@ a { color: var(--accent); text-decoration: none; }
/* === Poster-Karten === */ /* === Poster-Karten === */
.tv-card { .tv-card {
position: relative;
display: block; display: block;
background: var(--bg-card); background: var(--bg-card);
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s; transition: filter 0.2s, box-shadow 0.2s;
cursor: pointer; cursor: pointer;
} }
.tv-card:hover, .tv-card:focus { .tv-card:hover {
transform: scale(1.04); filter: brightness(1.2);
box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
.tv-card[data-focusable]:focus {
outline: none !important;
outline-offset: 0 !important;
}
.tv-card:focus::after {
content: '';
position: absolute;
inset: 0;
border: 3px solid var(--accent);
border-radius: var(--radius);
pointer-events: none;
z-index: 10;
} }
.tv-card:focus { outline: var(--focus-ring); outline-offset: 2px; }
.tv-card-img { .tv-card-img {
width: 100%; width: 100%;
@ -190,10 +202,10 @@ a { color: var(--accent); text-decoration: none; }
text-align: center; text-align: center;
padding: 0.5rem; padding: 0.5rem;
} }
.tv-card-info { padding: 0.5rem 0.6rem; } .tv-card-info { padding: 0.3rem 0.4rem; }
.tv-card-title { .tv-card-title {
display: block; display: block;
font-size: 0.85rem; font-size: 0.7rem;
font-weight: 500; font-weight: 500;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -202,13 +214,33 @@ a { color: var(--accent); text-decoration: none; }
} }
.tv-card-meta { .tv-card-meta {
display: block; display: block;
font-size: 0.75rem; font-size: 0.6rem;
color: var(--text-muted); color: var(--text-muted);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
/* Schon-gesehen Badge */
.tv-card-watched { opacity: 0.65; }
.tv-card-watched:hover { opacity: 1; }
.tv-card-watched-badge {
position: absolute;
top: 4px;
right: 4px;
background: var(--success);
color: #fff;
width: 20px;
height: 20px;
border-radius: 50%;
font-size: 0.7rem;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.tv-title-muted { color: var(--text-muted); }
/* Wiedergabe-Fortschritt auf Karte */ /* Wiedergabe-Fortschritt auf Karte */
.tv-card-progress { .tv-card-progress {
height: 3px; height: 3px;
@ -924,6 +956,30 @@ a { color: var(--accent); text-decoration: none; }
object-fit: contain; object-fit: contain;
background: #000; background: #000;
} }
/* Loading-Spinner im Player */
.player-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 50;
background: #000;
gap: 1rem;
}
.player-loading-spinner {
width: 48px;
height: 48px;
border: 3px solid rgba(255,255,255,0.2);
border-top-color: #2196f3;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.player-loading-text {
color: rgba(255,255,255,0.7);
font-size: 0.9rem;
}
.player-header { .player-header {
position: absolute; position: absolute;
top: 0; top: 0;
@ -988,11 +1044,17 @@ a { color: var(--accent); text-decoration: none; }
color: var(--text); color: var(--text);
font-size: 1.4rem; font-size: 1.4rem;
cursor: pointer; cursor: pointer;
padding: 0.4rem; padding: 0;
border-radius: var(--radius); border-radius: var(--radius);
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.player-btn:focus { outline: var(--focus-ring); } .player-btn:focus { outline: var(--focus-ring); }
.player-btn svg { display: block; } .player-btn svg { display: block; width: 20px; height: 20px; }
.player-btn-badge { .player-btn-badge {
display: inline-block; display: inline-block;
font-size: 0.7rem; font-size: 0.7rem;
@ -1018,53 +1080,96 @@ a { color: var(--accent); text-decoration: none; }
pointer-events: none; pointer-events: none;
} }
/* === Player-Overlay (Einstellungen) === */ /* === Player-Popup-Menue (kompakt, ersetzt das grosse Overlay-Panel) === */
.player-overlay { .player-popup {
position: absolute; position: absolute;
inset: 0; right: 1rem;
background: rgba(0, 0, 0, 0.85); bottom: 5rem;
display: flex; width: 280px;
justify-content: flex-end;
z-index: 20;
}
.player-overlay-panel {
width: 320px;
max-width: 90vw; max-width: 90vw;
height: 100%; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding: 2rem 1.5rem;
background: rgba(20, 20, 20, 0.95); background: rgba(20, 20, 20, 0.95);
border-radius: 12px;
padding: 0.5rem 0;
z-index: 20;
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
} }
.player-overlay-section { margin-bottom: 1.5rem; } .player-popup.popup-visible {
.player-overlay-section h3 { opacity: 1;
font-size: 0.85rem; transform: translateY(0);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
} }
.overlay-option {
display: block; /* Hauptmenue-Eintraege */
.popup-menu-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%; width: 100%;
text-align: left; padding: 0.7rem 1.2rem;
padding: 0.6rem 1rem;
background: transparent; background: transparent;
border: none; border: none;
color: var(--text); color: var(--text);
font-size: 0.95rem; font-size: 0.95rem;
cursor: pointer; cursor: pointer;
border-radius: var(--radius); transition: background 0.15s;
transition: background 0.2s; text-align: left;
} }
.overlay-option:hover, .overlay-option:focus { .popup-menu-item:hover, .popup-menu-item:focus {
background: var(--bg-hover); background: var(--bg-hover);
outline: none; outline: none;
} }
.overlay-option.active { .popup-item-label { font-weight: 500; }
.popup-item-value {
color: var(--text-muted);
font-size: 0.85rem;
}
/* Zurueck-Button im Submenue */
.popup-back {
display: block;
width: 100%;
padding: 0.7rem 1.2rem;
background: transparent;
border: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
text-align: left;
margin-bottom: 0.3rem;
}
.popup-back:hover, .popup-back:focus {
background: var(--bg-hover);
outline: none;
}
/* Optionen im Submenue */
.popup-option {
display: block;
width: 100%;
text-align: left;
padding: 0.6rem 1.2rem;
background: transparent;
border: none;
color: var(--text);
font-size: 0.95rem;
cursor: pointer;
transition: background 0.15s;
}
.popup-option:hover, .popup-option:focus {
background: var(--bg-hover);
outline: none;
}
.popup-option.active {
color: var(--accent); color: var(--accent);
font-weight: 600; font-weight: 600;
} }
.overlay-option.active::before { .popup-option.active::before {
content: "\2713 "; content: "\2713 ";
} }
@ -1121,16 +1226,15 @@ a { color: var(--accent); text-decoration: none; }
outline: var(--focus-ring); outline: var(--focus-ring);
} }
/* Player-Overlay responsive: Handy als Bottom-Sheet */ /* Player-Popup responsive: Handy zentriert */
@media (max-width: 480px) { @media (max-width: 480px) {
.player-overlay { justify-content: center; align-items: flex-end; } .player-popup {
.player-overlay-panel { right: 50%;
width: 100%; transform: translateX(50%) translateY(8px);
max-width: 100%; width: 90vw;
height: auto; }
max-height: 70vh; .player-popup.popup-visible {
border-radius: 16px 16px 0 0; transform: translateX(50%) translateY(0);
padding: 1.5rem 1rem;
} }
} }
@ -1140,8 +1244,8 @@ a { color: var(--accent); text-decoration: none; }
.tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; } .tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; }
.tv-main { padding: 1rem; } .tv-main { padding: 1rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.tv-row .tv-card { width: 140px; } .tv-row .tv-card { width: 72px; }
.tv-row .tv-card-wide { width: 200px; } .tv-row .tv-card-wide { width: 108px; }
.tv-detail-header { flex-direction: column; } .tv-detail-header { flex-direction: column; }
.tv-detail-poster { width: 150px; } .tv-detail-poster { width: 150px; }
.tv-page-title { font-size: 1.3rem; } .tv-page-title { font-size: 1.3rem; }
@ -1159,7 +1263,7 @@ a { color: var(--accent); text-decoration: none; }
.tv-nav-links { gap: 0; } .tv-nav-links { gap: 0; }
.tv-nav-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; } .tv-nav-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
.tv-row .tv-card { width: 120px; } .tv-row .tv-card { width: 60px; }
.tv-detail-poster { width: 120px; } .tv-detail-poster { width: 120px; }
/* Episoden-Karten: kompakt auf Handy */ /* Episoden-Karten: kompakt auf Handy */
.tv-ep-thumb { width: 100px; } .tv-ep-thumb { width: 100px; }
@ -1178,8 +1282,8 @@ a { color: var(--accent); text-decoration: none; }
/* TV/Desktop (grosse Bildschirme) */ /* TV/Desktop (grosse Bildschirme) */
@media (min-width: 1280px) { @media (min-width: 1280px) {
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.tv-row .tv-card { width: 200px; } .tv-row .tv-card { width: 102px; }
.tv-row .tv-card-wide { width: 300px; } .tv-row .tv-card-wide { width: 156px; }
.tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; } .tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; }
/* Episoden-Karten: groesser auf TV */ /* Episoden-Karten: groesser auf TV */
.tv-ep-thumb { width: 260px; } .tv-ep-thumb { width: 260px; }

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,6 @@
/** /**
* VideoKonverter TV - Video-Player v4.0 * VideoKonverter TV - Video-Player v4.1
* HLS-Streaming mit hls.js, kompaktes Popup-Menue statt Panel-Overlay,
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl, * Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung. * Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
*/ */
@ -18,10 +19,17 @@ let playBtn = null;
let controlsTimer = null; let controlsTimer = null;
let saveTimer = null; let saveTimer = null;
let controlsVisible = true; let controlsVisible = true;
let overlayOpen = false; let popupOpen = false; // Popup-Menue offen?
let popupSection = null; // Aktive Popup-Sektion (null = Hauptmenue)
let nextCountdown = null; let nextCountdown = null;
let episodesWatched = 0; let episodesWatched = 0;
let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming
// HLS-State
let hlsInstance = null; // hls.js Instanz
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
/** /**
* Player initialisieren * Player initialisieren
@ -31,6 +39,10 @@ function initPlayer(opts) {
cfg = opts; cfg = opts;
currentQuality = opts.streamQuality || "hd"; currentQuality = opts.streamQuality || "hd";
// Client-Codec-Erkennung (welche Video-Codecs kann dieser Browser?)
clientCodecs = detectSupportedCodecs();
console.info("Client-Codecs:", clientCodecs.join(", "));
videoEl = document.getElementById("player-video"); videoEl = document.getElementById("player-video");
progressBar = document.getElementById("player-progress-bar"); progressBar = document.getElementById("player-progress-bar");
timeDisplay = document.getElementById("player-time"); timeDisplay = document.getElementById("player-time");
@ -38,41 +50,38 @@ function initPlayer(opts) {
if (!videoEl) return; if (!videoEl) return;
// Video-Info laden (Audio/Subtitle-Tracks) // Video-Info + HLS-Stream PARALLEL starten (nicht sequentiell warten!)
loadVideoInfo().then(() => { const infoReady = loadVideoInfo();
// Stream starten startHLSStream(opts.startPos || 0);
setStreamUrl(opts.startPos || 0); infoReady.then(() => updatePlayerButtons());
updatePlayerButtons();
});
// Events // Events
videoEl.addEventListener("timeupdate", onTimeUpdate); videoEl.addEventListener("timeupdate", onTimeUpdate);
videoEl.addEventListener("play", onPlay); videoEl.addEventListener("play", onPlay);
videoEl.addEventListener("pause", onPause); videoEl.addEventListener("pause", onPause);
videoEl.addEventListener("ended", onEnded); videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("loadedmetadata", () => {
if (videoEl.duration && isFinite(videoEl.duration)) {
cfg.duration = videoEl.duration + seekOffset;
}
});
videoEl.addEventListener("click", togglePlay); videoEl.addEventListener("click", togglePlay);
// Loading ausblenden sobald Video laeuft (mehrere Events als Sicherheit)
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.addEventListener("canplay", hideLoading, {once: true});
// Controls UI // Controls UI
playBtn.addEventListener("click", togglePlay); playBtn.addEventListener("click", togglePlay);
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen); const btnFs = document.getElementById("btn-fullscreen");
if (btnFs) btnFs.addEventListener("click", toggleFullscreen);
document.getElementById("player-progress").addEventListener("click", onProgressClick); document.getElementById("player-progress").addEventListener("click", onProgressClick);
// Einstellungen-Button // Einstellungen-Button -> Popup-Hauptmenue
const btnSettings = document.getElementById("btn-settings"); const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.addEventListener("click", () => openOverlaySection(null)); if (btnSettings) btnSettings.addEventListener("click", () => togglePopup());
// Separate Buttons: Audio, Untertitel, Qualitaet // Direkt-Buttons: Audio, Untertitel, Qualitaet
const btnAudio = document.getElementById("btn-audio"); const btnAudio = document.getElementById("btn-audio");
if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio")); if (btnAudio) btnAudio.addEventListener("click", () => openPopupSection("audio"));
const btnSubs = document.getElementById("btn-subs"); const btnSubs = document.getElementById("btn-subs");
if (btnSubs) btnSubs.addEventListener("click", () => openOverlaySection("subs")); if (btnSubs) btnSubs.addEventListener("click", () => openPopupSection("subs"));
const btnQuality = document.getElementById("btn-quality"); const btnQuality = document.getElementById("btn-quality");
if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality")); if (btnQuality) btnQuality.addEventListener("click", () => openPopupSection("quality"));
// Naechste-Episode-Button // Naechste-Episode-Button
const btnNext = document.getElementById("btn-next"); const btnNext = document.getElementById("btn-next");
@ -102,10 +111,21 @@ function initPlayer(opts) {
document.addEventListener("mousemove", showControls); document.addEventListener("mousemove", showControls);
document.addEventListener("touchstart", showControls); document.addEventListener("touchstart", showControls);
// Fullscreen nur auf Desktop/Handy anzeigen (nicht auf Samsung TV)
if (btnFs && isTizenTV()) {
btnFs.style.display = "none";
}
scheduleHideControls(); scheduleHideControls();
saveTimer = setInterval(saveProgress, 10000); saveTimer = setInterval(saveProgress, 10000);
} }
// === Erkennung: Samsung Tizen TV ===
function isTizenTV() {
return typeof tizen !== "undefined" || /Tizen/i.test(navigator.userAgent);
}
// === Video-Info laden === // === Video-Info laden ===
async function loadVideoInfo() { async function loadVideoInfo() {
@ -138,7 +158,6 @@ async function loadVideoInfo() {
if (i === currentSub) track.default = true; if (i === currentSub) track.default = true;
videoEl.appendChild(track); videoEl.appendChild(track);
}); });
// Aktiven Track setzen
updateSubtitleTrack(); updateSubtitleTrack();
} }
} catch (e) { } catch (e) {
@ -146,19 +165,183 @@ async function loadVideoInfo() {
} }
} }
// === Stream-URL === // === Codec-Erkennung ===
function setStreamUrl(seekSec) { /**
seekOffset = seekSec || 0; * Erkennt automatisch welche Video-Codecs der Browser/TV decodieren kann.
* Wird beim HLS-Start an den Server geschickt -> Server entscheidet copy vs transcode.
*
* WICHTIG: Unterscheidung zwischen nativem HLS (Tizen/Safari) und MSE (hls.js):
* - Natives HLS: canPlayType() meldet oft AV1/VP9, aber der native HLS-Player
* unterstuetzt diese Codecs NICHT zuverlaessig in fMP4-Segmenten.
* -> Konservativ: nur H.264 (+ evtl. HEVC)
* - MSE/hls.js: MediaSource.isTypeSupported() ist zuverlaessig
* -> Alle unterstuetzten Codecs melden
*/
function detectSupportedCodecs() {
const codecs = [];
const el = document.createElement("video");
const hasNativeHLS = !!el.canPlayType("application/vnd.apple.mpegurl");
const hasMSE = typeof MediaSource !== "undefined" && MediaSource.isTypeSupported;
if (!hasNativeHLS && hasMSE) {
// MSE-basiert (hls.js auf Chrome/Firefox/Edge): zuverlaessige Erkennung
if (MediaSource.isTypeSupported('video/mp4; codecs="avc1.640028"')) codecs.push("h264");
if (MediaSource.isTypeSupported('video/mp4; codecs="hev1.1.6.L93.B0"')) codecs.push("hevc");
if (MediaSource.isTypeSupported('video/mp4; codecs="av01.0.05M.08"')) codecs.push("av1");
if (MediaSource.isTypeSupported('video/mp4; codecs="vp09.00.10.08"')) codecs.push("vp9");
} else {
// Natives HLS (Samsung Tizen, Safari, iOS):
// Konservativ - nur H.264 melden, da AV1/VP9 in HLS-fMP4 nicht zuverlaessig
codecs.push("h264");
if (el.canPlayType('video/mp4; codecs="hev1.1.6.L93.B0"')
|| el.canPlayType('video/mp4; codecs="hvc1.1.6.L93.B0"')) {
codecs.push("hevc");
}
}
if (!codecs.length) codecs.push("h264");
return codecs;
}
// === Loading-Indikator ===
let loadingTimer = null;
function showLoading() {
var el = document.getElementById("player-loading");
if (el) { el.classList.remove("hidden"); el.style.display = ""; }
// Fallback: Loading nach 8 Sekunden ausblenden (falls Events nicht feuern)
clearTimeout(loadingTimer);
loadingTimer = setTimeout(hideLoading, 8000);
}
function hideLoading() {
clearTimeout(loadingTimer);
var el = document.getElementById("player-loading");
if (!el) return;
el.style.display = "none";
}
// === HLS Streaming ===
async function startHLSStream(seekSec) {
// Loading-Spinner anzeigen
showLoading();
// Vorherige Session beenden
await cleanupHLS();
// Seek-Offset merken (ffmpeg -ss schneidet serverseitig)
hlsSeekOffset = seekSec > 0 ? Math.floor(seekSec) : 0;
// Neue HLS-Session vom Server anfordern
try {
const resp = await fetch("/tv/api/hls/start", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
video_id: cfg.videoId,
quality: currentQuality,
audio: currentAudio,
sound: cfg.soundMode || "stereo",
t: hlsSeekOffset,
codecs: clientCodecs || ["h264"],
}),
});
if (!resp.ok) {
console.error("HLS Session Start fehlgeschlagen:", resp.status);
setStreamUrlLegacy(seekSec);
return;
}
const data = await resp.json();
hlsSessionId = data.session_id;
const playlistUrl = data.playlist_url;
// Retry-Zaehler fuer Netzwerkfehler
let networkRetries = 0;
const MAX_RETRIES = 3;
// HLS abspielen
if (videoEl.canPlayType("application/vnd.apple.mpegurl")) {
// Native HLS (Safari, Tizen)
videoEl.src = playlistUrl;
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
// hls.js Polyfill (Chrome, Firefox, Edge)
hlsInstance = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
});
hlsInstance.loadSource(playlistUrl);
hlsInstance.attachMedia(videoEl);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
hlsReady = true;
videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
});
hlsInstance.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error("HLS fataler Fehler:", data.type, data.details);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR
&& networkRetries < MAX_RETRIES) {
// Netzwerkfehler -> Retry mit Backoff
networkRetries++;
console.warn("HLS Netzwerkfehler, Retry " +
networkRetries + "/" + MAX_RETRIES);
setTimeout(() => hlsInstance.startLoad(),
1000 * networkRetries);
} else {
// Zu viele Retries oder anderer Fehler -> Fallback
cleanupHLS();
setStreamUrlLegacy(seekSec);
}
}
});
} else {
// Kein HLS moeglich -> Fallback
console.warn("Weder natives HLS noch hls.js verfuegbar");
setStreamUrlLegacy(seekSec);
}
} catch (e) {
console.error("HLS Start Fehler:", e);
hideLoading();
setStreamUrlLegacy(seekSec);
}
}
/** Fallback: Altes Pipe-Streaming (fMP4 ueber StreamResponse) */
function setStreamUrlLegacy(seekSec) {
const params = new URLSearchParams({ const params = new URLSearchParams({
quality: currentQuality, quality: currentQuality,
audio: currentAudio, audio: currentAudio,
sound: cfg.soundMode || "stereo", sound: cfg.soundMode || "stereo",
}); });
if (seekSec > 0) params.set("t", Math.floor(seekSec)); if (seekSec > 0) params.set("t", Math.floor(seekSec));
const wasPlaying = videoEl && !videoEl.paused;
videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`; videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`;
if (wasPlaying) videoEl.play(); videoEl.addEventListener("playing", hideLoading, {once: true});
videoEl.play().catch(() => {});
}
/** HLS aufraumen: hls.js + Server-Session beenden */
async function cleanupHLS() {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (hlsSessionId) {
// Server-Session loeschen (fire & forget)
fetch(`/tv/api/hls/${hlsSessionId}`, {method: "DELETE"}).catch(() => {});
hlsSessionId = null;
}
hlsReady = false;
hlsSeekOffset = 0;
} }
// === Playback-Controls === // === Playback-Controls ===
@ -202,29 +385,53 @@ function onEnded() {
function seekRelative(seconds) { function seekRelative(seconds) {
if (!videoEl) return; if (!videoEl) return;
const totalTime = seekOffset + videoEl.currentTime; const dur = getDuration();
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); const cur = getCurrentTime();
const newTime = Math.max(0, Math.min(totalTime + seconds, dur)); const newTime = Math.max(0, Math.min(cur + seconds, dur));
setStreamUrl(newTime);
showControls(); if (hlsSessionId) {
// HLS: nativen Seek verwenden (hls.js unterstuetzt das)
videoEl.currentTime = Math.max(0, Math.min(
videoEl.currentTime + seconds, videoEl.duration || Infinity));
showControls();
} else {
// Legacy: neuen Stream starten
startHLSStream(newTime);
showControls();
}
} }
function onProgressClick(e) { function onProgressClick(e) {
if (!videoEl) return; if (!videoEl) return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); const dur = getDuration();
if (!dur) return; if (!dur) return;
setStreamUrl(pct * dur);
// Absolute Seek-Position im Video
const seekTo = pct * dur;
// Immer neuen HLS-Stream starten (server-seitiger Seek)
startHLSStream(seekTo);
showControls(); showControls();
} }
// === Zeit-Anzeige === // === Zeit-Funktionen ===
function getCurrentTime() {
if (!videoEl) return 0;
// Bei HLS mit Server-Seek: videoEl.currentTime + Offset = echte Position
return hlsSeekOffset + (videoEl.currentTime || 0);
}
function getDuration() {
// Echte Gesamtdauer des Videos (nicht der HLS-Stream-Dauer)
return cfg.duration || 0;
}
function onTimeUpdate() { function onTimeUpdate() {
if (!videoEl) return; if (!videoEl) return;
const current = seekOffset + videoEl.currentTime; const current = getCurrentTime();
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); const dur = getDuration();
if (progressBar && dur > 0) { if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%"; progressBar.style.width = ((current / dur) * 100) + "%";
@ -253,7 +460,7 @@ function showControls() {
} }
function hideControls() { function hideControls() {
if (!videoEl || videoEl.paused || overlayOpen) return; if (!videoEl || videoEl.paused || popupOpen) return;
const wrapper = document.getElementById("player-wrapper"); const wrapper = document.getElementById("player-wrapper");
if (wrapper) wrapper.classList.add("player-hide-controls"); if (wrapper) wrapper.classList.add("player-hide-controls");
controlsVisible = false; controlsVisible = false;
@ -275,138 +482,189 @@ function toggleFullscreen() {
} }
} }
// === Einstellungen-Overlay === // === Popup-Menue (ersetzt das grosse Overlay-Panel) ===
function toggleOverlay() { function togglePopup() {
const overlay = document.getElementById("player-overlay"); if (popupOpen) {
if (!overlay) return; closePopup();
overlayOpen = !overlayOpen; } else {
overlay.style.display = overlayOpen ? "" : "none"; openPopupSection(null);
if (overlayOpen) {
renderOverlay();
showControls();
// Nur Geschwindigkeit (Audio/Subs/Qualitaet haben eigene Buttons)
overlay.querySelectorAll(".player-overlay-section").forEach(s => {
s.style.display = s.id === "overlay-speed" ? "" : "none";
});
var el = document.getElementById("overlay-speed");
if (el) {
var firstBtn = el.querySelector("[data-focusable]");
if (firstBtn) firstBtn.focus();
}
} }
} }
function openOverlaySection(section) { function openPopupSection(section) {
const overlay = document.getElementById("player-overlay"); const popup = document.getElementById("player-popup");
if (!overlay) return; if (!popup) return;
if (overlayOpen) {
// Bereits offen -> schliessen if (popupOpen && popupSection === section) {
overlayOpen = false; // Gleiche Sektion nochmal -> schliessen
overlay.style.display = "none"; closePopup();
// Alle Sektionen wieder sichtbar machen
overlay.querySelectorAll(".player-overlay-section").forEach(
s => s.style.display = "");
return; return;
} }
overlayOpen = true;
overlay.style.display = ""; popupOpen = true;
renderOverlay(); popupSection = section;
popup.style.display = "";
popup.classList.add("popup-visible");
renderPopup(section);
showControls(); showControls();
if (section) {
// Nur die gewaehlte Sektion anzeigen, andere verstecken // Focus auf ersten Button im Popup
overlay.querySelectorAll(".player-overlay-section").forEach(s => { requestAnimationFrame(() => {
s.style.display = s.id === "overlay-" + section ? "" : "none"; const first = popup.querySelector("[data-focusable]");
}); if (first) first.focus();
var el = document.getElementById("overlay-" + section); });
if (el) {
var firstBtn = el.querySelector("[data-focusable]");
if (firstBtn) firstBtn.focus();
}
} else {
// Settings-Button: nur Geschwindigkeit (Audio/Subs/Qualitaet haben eigene Buttons)
overlay.querySelectorAll(".player-overlay-section").forEach(s => {
s.style.display = s.id === "overlay-speed" ? "" : "none";
});
var el = document.getElementById("overlay-speed");
if (el) {
var firstBtn = el.querySelector("[data-focusable]");
if (firstBtn) firstBtn.focus();
}
}
} }
function renderOverlay() { function closePopup() {
// Audio-Spuren const popup = document.getElementById("player-popup");
const audioEl = document.getElementById("overlay-audio"); if (!popup) return;
if (audioEl && videoInfo && videoInfo.audio_tracks) { popupOpen = false;
let html = "<h3>Audio</h3>"; popupSection = null;
popup.classList.remove("popup-visible");
popup.style.display = "none";
}
function renderPopup(section) {
const popup = document.getElementById("player-popup");
if (!popup) return;
let html = "";
if (!section) {
// Hauptmenue: Liste aller Optionen
html = '<div class="popup-menu">';
// Audio
const audioLabel = _currentAudioLabel();
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('audio')">
<span class="popup-item-label">Audio</span>
<span class="popup-item-value">${audioLabel}</span>
</button>`;
// Untertitel
const subLabel = currentSub >= 0 ? _currentSubLabel() : "Aus";
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('subs')">
<span class="popup-item-label">Untertitel</span>
<span class="popup-item-value">${subLabel}</span>
</button>`;
// Qualitaet
const qualLabels = {uhd: "Ultra HD", hd: "HD", sd: "SD", low: "Niedrig"};
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('quality')">
<span class="popup-item-label">Qualit\u00e4t</span>
<span class="popup-item-value">${qualLabels[currentQuality] || "HD"}</span>
</button>`;
// Geschwindigkeit
html += `<button class="popup-menu-item" data-focusable onclick="openPopupSection('speed')">
<span class="popup-item-label">Geschwindigkeit</span>
<span class="popup-item-value">${currentSpeed}x</span>
</button>`;
html += "</div>";
} else if (section === "audio") {
html = _renderAudioOptions();
} else if (section === "subs") {
html = _renderSubOptions();
} else if (section === "quality") {
html = _renderQualityOptions();
} else if (section === "speed") {
html = _renderSpeedOptions();
}
popup.innerHTML = html;
}
function _currentAudioLabel() {
if (videoInfo && videoInfo.audio_tracks && videoInfo.audio_tracks[currentAudio]) {
const a = videoInfo.audio_tracks[currentAudio];
const ch = a.channels > 2 ? ` ${a.channels}ch` : "";
return langName(a.lang) + ch;
}
return "Spur 1";
}
function _currentSubLabel() {
if (videoInfo && videoInfo.subtitle_tracks && videoInfo.subtitle_tracks[currentSub]) {
return langName(videoInfo.subtitle_tracks[currentSub].lang);
}
return "Spur " + (currentSub + 1);
}
function _renderAudioOptions() {
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Audio</button>`;
if (videoInfo && videoInfo.audio_tracks) {
videoInfo.audio_tracks.forEach((a, i) => { videoInfo.audio_tracks.forEach((a, i) => {
const label = langName(a.lang) || `Spur ${i + 1}`; const label = langName(a.lang) || `Spur ${i + 1}`;
const ch = a.channels > 2 ? ` (${a.channels}ch)` : ""; const ch = a.channels > 2 ? ` (${a.channels}ch)` : "";
const active = i === currentAudio ? " active" : ""; const active = i === currentAudio ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchAudio(${i})">${label}${ch}</button>`; html += `<button class="popup-option${active}" data-focusable onclick="switchAudio(${i})">${label}${ch}</button>`;
}); });
audioEl.innerHTML = html;
}
// Untertitel
const subsEl = document.getElementById("overlay-subs");
if (subsEl && videoInfo) {
let html = "<h3>Untertitel</h3>";
html += `<button class="overlay-option${currentSub === -1 ? ' active' : ''}" data-focusable onclick="switchSub(-1)">Aus</button>`;
if (videoInfo.subtitle_tracks) {
videoInfo.subtitle_tracks.forEach((s, i) => {
const label = langName(s.lang) || `Spur ${i + 1}`;
const active = i === currentSub ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchSub(${i})">${label}</button>`;
});
}
subsEl.innerHTML = html;
}
// Qualitaet
const qualEl = document.getElementById("overlay-quality");
if (qualEl) {
const qualities = [
["uhd", "Ultra HD"], ["hd", "HD"],
["sd", "SD"], ["low", "Niedrig"]
];
let html = "<h3>Qualit\u00e4t</h3>";
qualities.forEach(([val, label]) => {
const active = val === currentQuality ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchQuality('${val}')">${label}</button>`;
});
qualEl.innerHTML = html;
}
// Geschwindigkeit
const speedEl = document.getElementById("overlay-speed");
if (speedEl) {
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
let html = "<h3>Geschwindigkeit</h3>";
speeds.forEach(s => {
const active = s === currentSpeed ? " active" : "";
html += `<button class="overlay-option${active}" data-focusable onclick="switchSpeed(${s})">${s}x</button>`;
});
speedEl.innerHTML = html;
} }
html += "</div>";
return html;
} }
function _renderSubOptions() {
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Untertitel</button>`;
html += `<button class="popup-option${currentSub === -1 ? ' active' : ''}" data-focusable onclick="switchSub(-1)">Aus</button>`;
if (videoInfo && videoInfo.subtitle_tracks) {
videoInfo.subtitle_tracks.forEach((s, i) => {
const label = langName(s.lang) || `Spur ${i + 1}`;
const active = i === currentSub ? " active" : "";
html += `<button class="popup-option${active}" data-focusable onclick="switchSub(${i})">${label}</button>`;
});
}
html += "</div>";
return html;
}
function _renderQualityOptions() {
const qualities = [
["uhd", "Ultra HD"], ["hd", "HD"],
["sd", "SD"], ["low", "Niedrig"]
];
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Qualit\u00e4t</button>`;
qualities.forEach(([val, label]) => {
const active = val === currentQuality ? " active" : "";
html += `<button class="popup-option${active}" data-focusable onclick="switchQuality('${val}')">${label}</button>`;
});
html += "</div>";
return html;
}
function _renderSpeedOptions() {
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
let html = '<div class="popup-submenu">';
html += `<button class="popup-back" data-focusable onclick="openPopupSection(null)">&larr; Geschwindigkeit</button>`;
speeds.forEach(s => {
const active = s === currentSpeed ? " active" : "";
html += `<button class="popup-option${active}" data-focusable onclick="switchSpeed(${s})">${s}x</button>`;
});
html += "</div>";
return html;
}
// === Audio/Sub/Quality/Speed wechseln ===
function switchAudio(idx) { function switchAudio(idx) {
if (idx === currentAudio) return; if (idx === currentAudio) return;
currentAudio = idx; currentAudio = idx;
// Neuen Stream mit anderer Audio-Spur starten // Neuen HLS-Stream mit anderer Audio-Spur starten
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); const currentTime = getCurrentTime();
setStreamUrl(currentTime); startHLSStream(currentTime);
renderOverlay(); renderPopup(popupSection);
updatePlayerButtons(); updatePlayerButtons();
} }
function switchSub(idx) { function switchSub(idx) {
currentSub = idx; currentSub = idx;
updateSubtitleTrack(); updateSubtitleTrack();
renderOverlay(); renderPopup(popupSection);
updatePlayerButtons(); updatePlayerButtons();
} }
@ -420,16 +678,16 @@ function updateSubtitleTrack() {
function switchQuality(q) { function switchQuality(q) {
if (q === currentQuality) return; if (q === currentQuality) return;
currentQuality = q; currentQuality = q;
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0); const currentTime = getCurrentTime();
setStreamUrl(currentTime); startHLSStream(currentTime);
renderOverlay(); renderPopup(popupSection);
updatePlayerButtons(); updatePlayerButtons();
} }
function switchSpeed(s) { function switchSpeed(s) {
currentSpeed = s; currentSpeed = s;
if (videoEl) videoEl.playbackRate = s; if (videoEl) videoEl.playbackRate = s;
renderOverlay(); renderPopup(popupSection);
} }
// === Naechste Episode === // === Naechste Episode ===
@ -454,6 +712,7 @@ function showNextEpisodeOverlay() {
function playNextEpisode() { function playNextEpisode() {
if (nextCountdown) clearInterval(nextCountdown); if (nextCountdown) clearInterval(nextCountdown);
cleanupHLS();
if (cfg.nextUrl) window.location.href = cfg.nextUrl; if (cfg.nextUrl) window.location.href = cfg.nextUrl;
} }
@ -466,14 +725,10 @@ function cancelNext() {
// === D-Pad Navigation fuer Fernbedienung === // === D-Pad Navigation fuer Fernbedienung ===
/**
* Fokussierbare Elemente im aktuellen Kontext finden.
* Im Overlay: nur Overlay-Buttons. Sonst: Player-Control-Buttons.
*/
function _getFocusables() { function _getFocusables() {
if (overlayOpen) { if (popupOpen) {
const overlay = document.getElementById("player-overlay"); const popup = document.getElementById("player-popup");
return overlay ? Array.from(overlay.querySelectorAll("[data-focusable]")) : []; return popup ? Array.from(popup.querySelectorAll("[data-focusable]")) : [];
} }
// "Naechste Episode" oder "Schaust du noch" Overlay? // "Naechste Episode" oder "Schaust du noch" Overlay?
const nextOv = document.getElementById("next-overlay"); const nextOv = document.getElementById("next-overlay");
@ -521,23 +776,31 @@ function onKeyDown(e) {
const buttonFocused = active && active.hasAttribute("data-focusable") && const buttonFocused = active && active.hasAttribute("data-focusable") &&
active.tagName === "BUTTON"; active.tagName === "BUTTON";
// --- Overlay offen: D-Pad navigiert im Overlay --- // --- Popup offen: D-Pad navigiert im Popup ---
if (overlayOpen) { if (popupOpen) {
switch (key) { switch (key) {
case "Escape": case "Backspace": case "Escape": case "Backspace":
toggleOverlay(); if (popupSection) {
// Focus zurueck auf Settings-Button // Zurueck zum Hauptmenue
const btnSettings = document.getElementById("btn-settings"); openPopupSection(null);
if (btnSettings) btnSettings.focus(); } else {
closePopup();
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.focus();
}
e.preventDefault(); return; e.preventDefault(); return;
case "ArrowUp": case "ArrowUp":
_focusNext(-1); e.preventDefault(); return; _focusNext(-1); e.preventDefault(); return;
case "ArrowDown": case "ArrowDown":
_focusNext(1); e.preventDefault(); return; _focusNext(1); e.preventDefault(); return;
case "ArrowLeft": case "ArrowLeft":
_focusNext(-1); e.preventDefault(); return; if (popupSection) {
openPopupSection(null);
} else {
closePopup();
}
e.preventDefault(); return;
case "ArrowRight": case "ArrowRight":
_focusNext(1); e.preventDefault(); return;
case "Enter": case "Enter":
if (buttonFocused) active.click(); if (buttonFocused) active.click();
e.preventDefault(); return; e.preventDefault(); return;
@ -568,7 +831,6 @@ function onKeyDown(e) {
case "ArrowRight": case "ArrowRight":
_focusNext(1); showControls(); e.preventDefault(); return; _focusNext(1); showControls(); e.preventDefault(); return;
case "ArrowUp": case "ArrowUp":
// Vom Button weg = Controls ausblenden, Video steuern
active.blur(); showControls(); e.preventDefault(); return; active.blur(); showControls(); e.preventDefault(); return;
case "ArrowDown": case "ArrowDown":
active.blur(); showControls(); e.preventDefault(); return; active.blur(); showControls(); e.preventDefault(); return;
@ -582,7 +844,6 @@ function onKeyDown(e) {
case " ": case "Play": case "Pause": case " ": case "Play": case "Pause":
togglePlay(); e.preventDefault(); break; togglePlay(); e.preventDefault(); break;
case "Enter": case "Enter":
// Kein Button fokussiert: Controls einblenden + Focus auf Play
if (!controlsVisible) { if (!controlsVisible) {
showControls(); showControls();
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
@ -595,12 +856,10 @@ function onKeyDown(e) {
case "ArrowRight": case "FastForward": case "ArrowRight": case "FastForward":
seekRelative(10); showControls(); e.preventDefault(); break; seekRelative(10); showControls(); e.preventDefault(); break;
case "ArrowUp": case "ArrowUp":
// Erster Druck: Controls + Focus auf Buttons
if (!controlsVisible) { if (!controlsVisible) {
showControls(); showControls();
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
} else { } else {
// Controls sichtbar aber kein Button fokussiert: Focus setzen
if (playBtn) playBtn.focus(); if (playBtn) playBtn.focus();
showControls(); showControls();
} }
@ -616,24 +875,25 @@ function onKeyDown(e) {
e.preventDefault(); break; e.preventDefault(); break;
case "Escape": case "Backspace": case "Stop": case "Escape": case "Backspace": case "Stop":
saveProgress(); saveProgress();
cleanupHLS();
setTimeout(() => window.history.back(), 100); setTimeout(() => window.history.back(), 100);
e.preventDefault(); break; e.preventDefault(); break;
case "f": case "f":
toggleFullscreen(); e.preventDefault(); break; toggleFullscreen(); e.preventDefault(); break;
case "s": case "s":
toggleOverlay(); e.preventDefault(); break; togglePopup(); e.preventDefault(); break;
case "n": case "n":
if (cfg.nextVideoId) playNextEpisode(); if (cfg.nextVideoId) playNextEpisode();
e.preventDefault(); break; e.preventDefault(); break;
// Samsung Farbtasten: Direkt-Zugriff auf Overlay-Sektionen // Samsung Farbtasten: Direkt-Zugriff auf Popup-Sektionen
case "ColorRed": case "ColorRed":
openOverlaySection("audio"); e.preventDefault(); break; openPopupSection("audio"); e.preventDefault(); break;
case "ColorGreen": case "ColorGreen":
openOverlaySection("subs"); e.preventDefault(); break; openPopupSection("subs"); e.preventDefault(); break;
case "ColorYellow": case "ColorYellow":
openOverlaySection("quality"); e.preventDefault(); break; openPopupSection("quality"); e.preventDefault(); break;
case "ColorBlue": case "ColorBlue":
openOverlaySection("speed"); e.preventDefault(); break; openPopupSection("speed"); e.preventDefault(); break;
} }
} }
@ -641,13 +901,13 @@ function onKeyDown(e) {
function saveProgress(completed) { function saveProgress(completed) {
if (!cfg.videoId || !videoEl) return; if (!cfg.videoId || !videoEl) return;
const pos = seekOffset + (videoEl.currentTime || 0); const pos = getCurrentTime();
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); const dur = getDuration();
if (pos < 5 && !completed) return; if (pos < 5 && !completed) return;
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({
video_id: cfg.videoId, video_id: cfg.videoId,
position_sec: pos, position_sec: pos,
@ -656,7 +916,10 @@ function saveProgress(completed) {
}).catch(() => {}); }).catch(() => {});
} }
window.addEventListener("beforeunload", () => saveProgress()); window.addEventListener("beforeunload", () => {
saveProgress();
cleanupHLS();
});
// === Button-Status aktualisieren === // === Button-Status aktualisieren ===
@ -667,7 +930,7 @@ function updatePlayerButtons() {
// Quality-Badge: aktuellen Modus anzeigen // Quality-Badge: aktuellen Modus anzeigen
var badge = document.getElementById("quality-badge"); var badge = document.getElementById("quality-badge");
if (badge) { if (badge) {
var labels = { uhd: "4K", hd: "HD", sd: "SD", low: "LD" }; var labels = {uhd: "4K", hd: "HD", sd: "SD", low: "LD"};
badge.textContent = labels[currentQuality] || "HD"; badge.textContent = labels[currentQuality] || "HD";
} }
// Audio-Button: aktuelle Sprache anzeigen (Tooltip) // Audio-Button: aktuelle Sprache anzeigen (Tooltip)

View file

@ -170,7 +170,8 @@
<label for="tvdb_api_key">TVDB API Key</label> <label for="tvdb_api_key">TVDB API Key</label>
<input type="text" name="tvdb_api_key" id="tvdb_api_key" <input type="text" name="tvdb_api_key" id="tvdb_api_key"
value="{{ settings.library.tvdb_api_key if settings.library else '' }}" value="{{ settings.library.tvdb_api_key if settings.library else '' }}"
placeholder="API Key von thetvdb.com"> placeholder="API Key von thetvdb.com"
autocomplete="off" spellcheck="false">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="tvdb_pin">TVDB PIN</label> <label for="tvdb_pin">TVDB PIN</label>
@ -243,55 +244,6 @@
</div> </div>
</section> </section>
<!-- TV-App / Streaming -->
<section class="admin-section">
<h2>TV-App / Streaming</h2>
<div style="display:flex;gap:2rem;flex-wrap:wrap">
<!-- QR-Code -->
<div style="text-align:center">
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
<div style="margin-top:0.3rem">
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
</div>
</div>
<!-- User-Verwaltung -->
<div style="flex:1;min-width:300px">
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
<div id="tv-users-list">
<div class="loading-msg">Lade Benutzer...</div>
</div>
<!-- Neuer User -->
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
<div class="form-grid">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
</div>
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="tv-new-password" placeholder="Passwort">
</div>
<div class="form-group">
<label>Rechte</label>
<div style="display:flex;flex-direction:column;gap:0.3rem">
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
</div>
</div>
</div>
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
</div>
</div>
</div>
</section>
<!-- Presets --> <!-- Presets -->
<section class="admin-section"> <section class="admin-section">
<h2>Encoding-Presets</h2> <h2>Encoding-Presets</h2>
@ -387,155 +339,9 @@ function scanPath(pathId) {
.catch(e => showToast("Fehler: " + e, "error")); .catch(e => showToast("Fehler: " + e, "error"));
} }
// === TV-App User-Verwaltung ===
function tvLoadUsers() {
fetch("/api/tv/users")
.then(r => r.json())
.then(data => {
const container = document.getElementById("tv-users-list");
const users = data.users || [];
if (!users.length) {
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
return;
}
container.innerHTML = users.map(u => `
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div>
<strong>${escapeHtml(u.display_name || u.username)}</strong>
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
</div>
<div style="display:flex;gap:0.3rem">
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
</div>
</div>
`).join("");
})
.catch(() => {
document.getElementById("tv-users-list").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
});
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
}
function tvCreateUser() {
const username = document.getElementById("tv-new-username").value.trim();
const displayName = document.getElementById("tv-new-display").value.trim();
const password = document.getElementById("tv-new-password").value;
if (!username || !password) {
showToast("Benutzername und Passwort noetig", "error");
return;
}
fetch("/api/tv/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
username: username,
password: password,
display_name: displayName || username,
is_admin: document.getElementById("tv-new-admin").checked,
can_view_series: document.getElementById("tv-new-series").checked,
can_view_movies: document.getElementById("tv-new-movies").checked,
}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
document.getElementById("tv-new-username").value = "";
document.getElementById("tv-new-display").value = "";
document.getElementById("tv-new-password").value = "";
showToast("Benutzer erstellt", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvDeleteUser(userId, username) {
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
fetch("/api/tv/users/" + userId, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer geloescht", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvEditUser(userId) {
// User-Daten laden, dann Edit-Dialog anzeigen
const resp = await fetch("/api/tv/users").then(r => r.json());
const user = (resp.users || []).find(u => u.id === userId);
if (!user) return;
const newPass = prompt("Neues Passwort (leer lassen um beizubehalten):");
if (newPass === null) return; // Abgebrochen
const updates = {};
if (newPass) updates.password = newPass;
const newSeries = confirm("Serien anzeigen?");
const newMovies = confirm("Filme anzeigen?");
const newAdmin = confirm("Admin-Rechte?");
updates.can_view_series = newSeries;
updates.can_view_movies = newMovies;
updates.is_admin = newAdmin;
fetch("/api/tv/users/" + userId, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(updates),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer aktualisiert", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
// TV-URL laden
function tvLoadUrl() {
fetch("/api/tv/url")
.then(r => r.json())
.then(data => {
const link = document.getElementById("tv-link");
if (link && data.url) {
link.href = data.url;
link.textContent = data.url;
}
})
.catch(() => {});
}
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
loadLibraryPaths(); loadLibraryPaths();
tvLoadUsers();
tvLoadUrl();
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VideoKonverter{% endblock %}</title> <title>{% block title %}VideoKonverter{% endblock %}</title>
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon"> <link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v={{ v }}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script> <script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
@ -18,6 +18,7 @@
<a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a> <a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a> <a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
<a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a> <a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a>
<a href="/tv-admin" class="nav-link {% if request.path == '/tv-admin' %}active{% endif %}">TV Admin</a>
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a> <a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
</nav> </nav>
</header> </header>

View file

@ -17,6 +17,14 @@
</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 --> <!-- Auto-Match Progress -->
<div id="auto-match-progress" class="scan-progress" style="display:none"> <div id="auto-match-progress" class="scan-progress" style="display:none">
<div class="progress-container"> <div class="progress-container">
@ -64,7 +72,7 @@
<div class="filter-group"> <div class="filter-group">
<label>Suche</label> <label>Suche</label>
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()"> <input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()" onkeydown="if(event.key==='Enter'){event.preventDefault();applyFilters()}">
</div> </div>
<div class="filter-group"> <div class="filter-group">
@ -527,5 +535,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/library.js"></script> <script src="/static/js/library.js?v={{ v }}"></script>
{% endblock %} {% endblock %}

View file

@ -11,7 +11,7 @@
<link rel="manifest" href="/static/tv/manifest.json"> <link rel="manifest" href="/static/tv/manifest.json">
<link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png"> <link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png">
<link rel="icon" href="/static/icons/favicon.ico"> <link rel="icon" href="/static/icons/favicon.ico">
<link rel="stylesheet" href="/static/tv/css/tv.css"> <link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
<title>{% block title %}VideoKonverter TV{% endblock %}</title> <title>{% block title %}VideoKonverter TV{% endblock %}</title>
</head> </head>
<body> <body>

View file

@ -28,6 +28,41 @@
</section> </section>
{% endif %} {% endif %}
<!-- Neu hinzugefuegt -->
{% if new_series or new_movies %}
<section class="tv-section">
<h2 class="tv-section-title">Neu hinzugef&uuml;gt</h2>
<div class="tv-row">
{% for s in new_series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden</span>
</div>
</a>
{% endfor %}
{% for m in new_movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Serien --> <!-- Serien -->
{% if series %} {% if series %}
<section class="tv-section"> <section class="tv-section">
@ -78,7 +113,44 @@
</section> </section>
{% endif %} {% endif %}
{% if not series and not movies %} <!-- Schon gesehen -->
{% if watched_series or watched_movies %}
<section class="tv-section">
<h2 class="tv-section-title tv-title-muted">Schon gesehen</h2>
<div class="tv-row">
{% for s in watched_series %}
<a href="/tv/series/{{ s.id }}" class="tv-card tv-card-watched" data-focusable>
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
{% endif %}
<div class="tv-card-watched-badge">&#10003;</div>
<div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden</span>
</div>
</a>
{% endfor %}
{% for m in watched_movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card tv-card-watched" data-focusable>
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
{% endif %}
<div class="tv-card-watched-badge">&#10003;</div>
<div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if not series and not movies and not new_series and not new_movies and not continue_watching %}
<div class="tv-empty"> <div class="tv-empty">
<p>Noch keine Inhalte in der Bibliothek.</p> <p>Noch keine Inhalte in der Bibliothek.</p>
<p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p> <p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p>

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f0f0f"> <meta name="theme-color" content="#0f0f0f">
<link rel="stylesheet" href="/static/tv/css/tv.css"> <link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
<title>Login - VideoKonverter TV</title> <title>Login - VideoKonverter TV</title>
</head> </head>
<body class="login-body"> <body class="login-body">

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#000000"> <meta name="theme-color" content="#000000">
<link rel="stylesheet" href="/static/tv/css/tv.css"> <link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
<title>{{ title }} - VideoKonverter TV</title> <title>{{ title }} - VideoKonverter TV</title>
</head> </head>
<body class="player-body"> <body class="player-body">
@ -15,6 +15,12 @@
<span class="player-title">{{ title }}</span> <span class="player-title">{{ title }}</span>
</div> </div>
<!-- Loading-Spinner (sichtbar bis Stream bereit) -->
<div class="player-loading" id="player-loading">
<div class="player-loading-spinner"></div>
<p class="player-loading-text">Stream wird geladen...</p>
</div>
<!-- Video --> <!-- Video -->
<video id="player-video" autoplay playsinline></video> <video id="player-video" autoplay playsinline></video>
@ -44,15 +50,8 @@
</div> </div>
</div> </div>
<!-- Einstellungen-Overlay --> <!-- Kompaktes Popup-Menue (ersetzt das grosse Overlay-Panel) -->
<div class="player-overlay" id="player-overlay" style="display:none"> <div class="player-popup" id="player-popup" style="display:none"></div>
<div class="player-overlay-panel">
<div class="player-overlay-section" id="overlay-audio"></div>
<div class="player-overlay-section" id="overlay-subs"></div>
<div class="player-overlay-section" id="overlay-quality"></div>
<div class="player-overlay-section" id="overlay-speed"></div>
</div>
</div>
<!-- Naechste Episode Overlay --> <!-- Naechste Episode Overlay -->
{% if next_video %} {% if next_video %}
@ -81,6 +80,8 @@
</div> </div>
</div> </div>
<!-- hls.js fuer Browser ohne native HLS-Unterstuetzung -->
<script src="/static/tv/js/lib/hls.min.js"></script>
<script src="/static/tv/js/player.js"></script> <script src="/static/tv/js/player.js"></script>
<script> <script>
initPlayer({ initPlayer({

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f0f0f"> <meta name="theme-color" content="#0f0f0f">
<link rel="stylesheet" href="/static/tv/css/tv.css"> <link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
<title>{{ t('profiles.title') }} - VideoKonverter TV</title> <title>{{ t('profiles.title') }} - VideoKonverter TV</title>
</head> </head>
<body class="login-body"> <body class="login-body">

View file

@ -90,7 +90,7 @@
</div> </div>
<div class="tv-episode-list"> <div class="tv-episode-list">
{% for ep in episodes %} {% for ep in episodes %}
<div class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= 95 %}tv-ep-seen{% endif %}" <div class="tv-episode-card {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}tv-ep-seen{% endif %}"
data-video-id="{{ ep.id }}"> data-video-id="{{ ep.id }}">
<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 -->
@ -100,12 +100,12 @@
{% else %} {% else %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy"> <img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% endif %} {% endif %}
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %} {% 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>
</div> </div>
{% endif %} {% endif %}
{% if ep.progress_pct >= 95 %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}
<div class="tv-ep-watched">&#10003;</div> <div class="tv-ep-watched">&#10003;</div>
{% endif %} {% endif %}
<div class="tv-ep-duration"> <div class="tv-ep-duration">
@ -127,17 +127,19 @@
{% endif %} {% endif %}
<div class="tv-ep-meta"> <div class="tv-ep-meta">
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %} {% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span> {% endif %}
{% if user.show_tech_info %}
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} {% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
&middot; {{ ep.container|upper }} &middot; {{ ep.container|upper }}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %} {% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
{% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %} {% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
{% endif %}
</div> </div>
</div> </div>
</a> </a>
<!-- Gesehen-Button --> <!-- Gesehen-Button -->
<button class="tv-ep-mark-btn {% if ep.progress_pct >= 95 %}active{% endif %}" <button class="tv-ep-mark-btn {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
data-focusable data-focusable
title="{% if ep.progress_pct >= 95 %}{{ t('status.mark_unwatched') }}{% else %}{{ t('status.mark_watched') }}{% endif %}" title="{% if ep.progress_pct >= watched_threshold_pct|default(90) %}{{ t('status.mark_unwatched') }}{% else %}{{ t('status.mark_watched') }}{% endif %}"
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)"> onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003; &#10003;
</button> </button>

View file

@ -107,6 +107,31 @@
</label> </label>
</fieldset> </fieldset>
<!-- Startseite -->
<fieldset class="settings-group">
<legend>Startseite</legend>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_continue"
{% if user.home_show_continue is not defined or user.home_show_continue %}checked{% endif %} data-focusable>
&quot;Weiterschauen&quot; anzeigen
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_new"
{% if user.home_show_new is not defined or user.home_show_new %}checked{% endif %} data-focusable>
&quot;Neu hinzugef&uuml;gt&quot; anzeigen
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_hide_watched"
{% if user.home_hide_watched is not defined or user.home_hide_watched %}checked{% endif %} data-focusable>
Gesehene Serien/Filme ausblenden
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_watched"
{% if user.home_show_watched is not defined or user.home_show_watched %}checked{% endif %} data-focusable>
&quot;Schon gesehen&quot;-Rubrik anzeigen
</label>
</fieldset>
<!-- Auto-Play --> <!-- Auto-Play -->
<fieldset class="settings-group"> <fieldset class="settings-group">
<legend>{{ t('settings.autoplay') }}</legend> <legend>{{ t('settings.autoplay') }}</legend>

View file

@ -0,0 +1,360 @@
{% extends "base.html" %}
{% block title %}TV Admin - VideoKonverter{% endblock %}
{% block content %}
<section class="admin-section">
<h2>TV Admin-Center</h2>
<form hx-post="/htmx/tv-settings" hx-target="#tv-save-result" hx-swap="innerHTML">
<!-- Streaming -->
<fieldset>
<legend>HLS Streaming</legend>
<div class="form-grid">
<div class="form-group">
<label for="hls_segment_duration">Segment-Dauer (Sekunden)</label>
<input type="number" name="hls_segment_duration" id="hls_segment_duration"
value="{{ tv.hls_segment_duration | default(4) }}" min="1" max="30">
<span class="text-muted" style="font-size:0.8rem">Laenge der einzelnen HLS-Segmente</span>
</div>
<div class="form-group">
<label for="hls_init_duration">Erstes Segment (Sekunden)</label>
<input type="number" name="hls_init_duration" id="hls_init_duration"
value="{{ tv.hls_init_duration | default(1) }}" min="1" max="10">
<span class="text-muted" style="font-size:0.8rem">Kuerzeres erstes Segment fuer schnelleren Playback-Start</span>
</div>
<div class="form-group">
<label for="hls_session_timeout_min">Session-Timeout (Minuten)</label>
<input type="number" name="hls_session_timeout_min" id="hls_session_timeout_min"
value="{{ tv.hls_session_timeout_min | default(5) }}" min="1" max="60">
<span class="text-muted" style="font-size:0.8rem">Inaktive Sessions werden nach dieser Zeit beendet</span>
</div>
<div class="form-group">
<label for="hls_max_sessions">Max. gleichzeitige Sessions</label>
<input type="number" name="hls_max_sessions" id="hls_max_sessions"
value="{{ tv.hls_max_sessions | default(5) }}" min="1" max="20">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="pause_batch_on_stream" id="pause_batch_on_stream"
{% if tv.pause_batch_on_stream | default(true) %}checked{% endif %}>
Batch-Konvertierung bei Stream pausieren
</label>
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
</div>
</div>
</fieldset>
<!-- Watch-Status -->
<fieldset>
<legend>Watch-Status</legend>
<div class="form-grid">
<div class="form-group">
<label for="watched_threshold_pct">Gesehen-Schwelle (%)</label>
<input type="number" name="watched_threshold_pct" id="watched_threshold_pct"
value="{{ tv.watched_threshold_pct | default(90) }}" min="50" max="100">
<span class="text-muted" style="font-size:0.8rem">Ab diesem Fortschritt gilt eine Episode als gesehen (Plex Standard: 90%)</span>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
<span id="tv-save-result"></span>
</div>
</form>
</section>
<!-- Aktive HLS-Sessions -->
<section class="admin-section">
<h2>Aktive HLS-Sessions</h2>
<div id="hls-sessions">
<div class="loading-msg">Lade Sessions...</div>
</div>
<button class="btn-secondary" onclick="loadHlsSessions()" style="margin-top:0.5rem">Aktualisieren</button>
</section>
<!-- TV-App / Streaming -->
<section class="admin-section">
<h2>TV-App</h2>
<div style="display:flex;gap:2rem;flex-wrap:wrap">
<!-- QR-Code -->
<div style="text-align:center">
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
<div style="margin-top:0.3rem">
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
</div>
</div>
<!-- User-Verwaltung -->
<div style="flex:1;min-width:300px">
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
<div id="tv-users-list">
<div class="loading-msg">Lade Benutzer...</div>
</div>
<!-- Neuer User -->
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
<div class="form-grid">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
</div>
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="tv-new-password" placeholder="Passwort">
</div>
<div class="form-group">
<label>Rechte</label>
<div style="display:flex;flex-direction:column;gap:0.3rem">
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-techinfo"> Technische Details</label>
</div>
</div>
</div>
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
// === HLS Sessions Monitoring ===
function loadHlsSessions() {
fetch("/api/tv/hls-sessions")
.then(r => r.json())
.then(data => {
const container = document.getElementById("hls-sessions");
const sessions = data.sessions || [];
if (!sessions.length) {
container.innerHTML = '<div class="loading-msg">Keine aktiven Sessions</div>';
return;
}
container.innerHTML = `
<table class="stats-table" style="width:100%">
<thead>
<tr>
<th>Session</th>
<th>Video</th>
<th>Qualitaet</th>
<th>Laufzeit</th>
<th>Inaktiv</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${sessions.map(s => `
<tr>
<td><code>${s.session_id.substring(0, 8)}...</code></td>
<td>${escapeHtml(s.video_name || 'ID ' + s.video_id)}</td>
<td><span class="tag">${s.quality.toUpperCase()}</span></td>
<td>${formatDuration(s.age_sec)}</td>
<td>${formatDuration(s.idle_sec)}</td>
<td>${s.ready ? '<span class="status-badge ok">Aktiv</span>' : '<span class="status-badge warn">Startet...</span>'}</td>
<td><button class="btn-small btn-danger" onclick="destroyHlsSession('${s.session_id}')">Beenden</button></td>
</tr>
`).join("")}
</tbody>
</table>
`;
})
.catch(() => {
document.getElementById("hls-sessions").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
});
}
async function destroyHlsSession(sid) {
if (!await showConfirm("HLS-Session beenden?", {title: "Session beenden", okText: "Beenden", icon: "warn"})) return;
fetch("/api/tv/hls-sessions/" + sid, {method: "DELETE"})
.then(r => r.json())
.then(() => { showToast("Session beendet", "success"); loadHlsSessions(); })
.catch(e => showToast("Fehler: " + e, "error"));
}
function formatDuration(sec) {
if (sec < 60) return sec + "s";
if (sec < 3600) return Math.floor(sec / 60) + "m " + (sec % 60) + "s";
return Math.floor(sec / 3600) + "h " + Math.floor((sec % 3600) / 60) + "m";
}
// === TV-App User-Verwaltung ===
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
}
function tvLoadUsers() {
fetch("/api/tv/users")
.then(r => r.json())
.then(data => {
const container = document.getElementById("tv-users-list");
const users = data.users || [];
if (!users.length) {
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
return;
}
container.innerHTML = users.map(u => `
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div>
<strong>${escapeHtml(u.display_name || u.username)}</strong>
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
${u.show_tech_info ? '<span class="tag">Tech-Info</span>' : ''}
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
</div>
<div style="display:flex;gap:0.3rem">
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
</div>
</div>
`).join("");
})
.catch(() => {
document.getElementById("tv-users-list").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
});
}
function tvCreateUser() {
const username = document.getElementById("tv-new-username").value.trim();
const displayName = document.getElementById("tv-new-display").value.trim();
const password = document.getElementById("tv-new-password").value;
if (!username || !password) {
showToast("Benutzername und Passwort noetig", "error");
return;
}
fetch("/api/tv/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
username: username,
password: password,
display_name: displayName || username,
is_admin: document.getElementById("tv-new-admin").checked,
can_view_series: document.getElementById("tv-new-series").checked,
can_view_movies: document.getElementById("tv-new-movies").checked,
show_tech_info: document.getElementById("tv-new-techinfo").checked,
}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
document.getElementById("tv-new-username").value = "";
document.getElementById("tv-new-display").value = "";
document.getElementById("tv-new-password").value = "";
showToast("Benutzer erstellt", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvDeleteUser(userId, username) {
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
fetch("/api/tv/users/" + userId, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer geloescht", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvEditUser(userId) {
// User-Daten laden, dann Edit-Dialog anzeigen
const resp = await fetch("/api/tv/users").then(r => r.json());
const user = (resp.users || []).find(u => u.id === userId);
if (!user) return;
const newPass = await showPrompt("Neues Passwort (leer lassen um beizubehalten):", {
title: "Benutzer bearbeiten: " + user.username,
placeholder: "Neues Passwort...",
okText: "Weiter"
});
if (newPass === null) return;
const updates = {};
if (newPass) updates.password = newPass;
const newSeries = confirm("Serien anzeigen?");
const newMovies = confirm("Filme anzeigen?");
const newAdmin = confirm("Admin-Rechte?");
const newTechInfo = confirm("Technische Details anzeigen?");
updates.can_view_series = newSeries;
updates.can_view_movies = newMovies;
updates.is_admin = newAdmin;
updates.show_tech_info = newTechInfo;
fetch("/api/tv/users/" + userId, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(updates),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer aktualisiert", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
// TV-URL laden
function tvLoadUrl() {
fetch("/api/tv/url")
.then(r => r.json())
.then(data => {
const link = document.getElementById("tv-link");
if (link && data.url) {
link.href = data.url;
link.textContent = data.url;
}
})
.catch(() => {});
}
// === Init ===
document.addEventListener("DOMContentLoaded", () => {
tvLoadUsers();
tvLoadUrl();
loadHlsSessions();
// HLS-Sessions alle 15 Sekunden aktualisieren
setInterval(loadHlsSessions, 15000);
});
</script>
{% endblock %}