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.
## [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
### TV-App: FocusManager-Fix, Poster-Caching, Performance

View file

@ -1,10 +1,11 @@
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 \
ffmpeg \
python3 \
python3-pip \
gosu \
intel-opencl-icd \
intel-media-va-driver-non-free \
libva-drm2 \
@ -40,9 +41,9 @@ COPY video-konverter/app/ ./app/
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000)
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs
# Daten- und Log-Verzeichnisse + HLS-Streaming (beschreibbar fuer UID 1000)
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls \
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls
# Entrypoint (kopiert Defaults in gemountete Volumes)
COPY entrypoint.sh .

View file

@ -1,11 +1,17 @@
#!/bin/bash
# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis,
# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid).
# Entrypoint: PUID/PGID User-Switching + Default-Config kopieren
#
# 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"
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
filename=$(basename "$file")
if [ ! -f "$CFG_DIR/$filename" ]; then
@ -14,5 +20,32 @@ for file in "$DEFAULTS_DIR"/*; do
fi
done
# Anwendung starten
exec python3 __main__.py
# Pruefen ob wir als root laufen (Unraid Docker-UI Modus)
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)
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
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 logging
@ -38,6 +40,11 @@ _ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
"VK_TARGET_CONTAINER": (("files", "target_container"), 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
@ -96,6 +103,14 @@ _DEFAULT_SETTINGS: dict = {
"tvdb_language": "deu",
"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": {
"enabled": False,
"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():
raw = os.environ.get(env_key)
if raw is None:
continue
if raw is None or raw == "":
continue # Leere ENV-Variablen ueberschreiben YAML nicht
# Typ-Konvertierung
try:
@ -328,6 +343,10 @@ class Config:
def cleanup_config(self) -> dict:
return self.settings.get("cleanup", {})
@property
def tv_config(self) -> dict:
return self.settings.get("tv", {})
@property
def server_config(self) -> dict:
return self.settings.get("server", {})

View file

@ -45,6 +45,12 @@ def setup_page_routes(app: web.Application, config: Config,
"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")
async def library(request: web.Request) -> dict:
"""GET /library - Bibliothek"""
@ -132,6 +138,33 @@ def setup_page_routes(app: web.Application, config: Config,
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")
async def htmx_stats_table(request: web.Request) -> dict:
"""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("/library", library)
app.router.add_get("/admin", admin)
app.router.add_get("/tv-admin", tv_admin)
app.router.add_get("/statistics", statistics)
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)

View file

@ -10,12 +10,14 @@ import aiomysql
from app.config import Config
from app.services.auth import AuthService
from app.services.library import LibraryService
from app.services.hls import HLSSessionManager
from app.services.i18n import set_request_lang, get_all_translations
def setup_tv_routes(app: web.Application, config: Config,
auth_service: AuthService,
library_service: LibraryService) -> None:
library_service: LibraryService,
hls_manager: HLSSessionManager = None) -> None:
"""Registriert alle TV-App Routes"""
# --- Poster-URL Lokalisierung ---
@ -155,75 +157,193 @@ def setup_tv_routes(app: web.Application, config: Config,
@require_auth
async def get_home(request: web.Request) -> web.Response:
"""GET /tv/ - Startseite"""
"""GET /tv/ - Startseite mit konfigurierbaren Rubriken"""
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 = []
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
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Serien laden (mit Berechtigungspruefung)
# --- Serien ---
if user.get("can_view_series"):
series_query = """
SELECT s.id, s.title, s.folder_name, s.poster_url,
s.genres, s.tvdb_id,
path_sql, path_params = _path_filter(
"s", user.get("allowed_paths"))
# 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
FROM library_series s
LEFT JOIN library_videos v ON v.series_id = s.id
"""
params = []
if user.get("allowed_paths"):
placeholders = ",".join(
["%s"] * len(user["allowed_paths"]))
series_query += (
f" WHERE s.library_path_id IN ({placeholders})"
)
params = user["allowed_paths"]
series_query += (
" GROUP BY s.id ORDER BY s.title LIMIT 20"
)
await cur.execute(series_query, params)
LEFT JOIN library_videos v
ON v.series_id = s.id
{watched_join}
WHERE 1=1 {path_sql} {watched_where}
GROUP BY s.id
ORDER BY s.title
LIMIT 20
""", path_params)
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"):
movies_query = """
SELECT m.id, m.title, m.folder_name, m.poster_url,
m.year, m.genres
path_sql, path_params = _path_filter(
"m", user.get("allowed_paths"))
# 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
"""
params = []
if user.get("allowed_paths"):
placeholders = ",".join(
["%s"] * len(user["allowed_paths"]))
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)
{watched_join}
WHERE 1=1 {path_sql} {watched_where}
ORDER BY m.title
LIMIT 20
""", path_params)
movies = await cur.fetchall()
# Poster-URLs lokalisieren (kein TVDB-Laden)
_localize_posters(series, "series")
# Schon gesehen (Filme)
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
continue_watching = await auth_service.get_continue_watching(
user["id"]
)
if show_continue:
continue_watching = await auth_service.get_continue_watching(
uid)
return aiohttp_jinja2.render_template(
"tv/home.html", request, {
"user": user,
"active": "home",
"continue_watching": continue_watching,
"new_series": new_series,
"new_movies": new_movies,
"series": series,
"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
_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(
"tv/series_detail.html", request, {
"user": user,
@ -476,6 +599,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"user_rating": user_rating,
"avg_rating": avg_rating,
"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),
can_view_series=data.get("can_view_series", 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"),
)
@ -1052,10 +1177,22 @@ def setup_tv_routes(app: web.Application, config: Config,
"autoplay_enabled": lambda v: v == "on",
"autoplay_countdown_sec": 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():
if key in data:
user_kwargs[key] = transform(data[key])
elif not is_ajax and key in checkbox_fields:
user_kwargs[key] = False
if user_kwargs:
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")
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 ---
# 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_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)
app.router.add_get("/api/tv/qrcode", get_qrcode)
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_put("/api/tv/users/{id}", put_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"""
import asyncio
import logging
import time
from pathlib import Path
from aiohttp import web
import aiohttp_jinja2
@ -15,6 +16,7 @@ from app.services.tvdb import TVDBService
from app.services.cleaner import CleanerService
from app.services.importer import ImporterService
from app.services.auth import AuthService
from app.services.hls import HLSSessionManager
from app.services.i18n import load_translations, setup_jinja2_i18n
from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes
@ -86,6 +88,10 @@ class VideoKonverterServer:
load_translations(str(static_dir))
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
ws_path = self.config.server_config.get("websocket_path", "/ws")
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
@ -110,9 +116,13 @@ class VideoKonverterServer:
async def _lazy_pool():
return self.library_service._db_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(
self.app, self.config,
self.auth_service, self.library_service,
self.hls_manager,
)
# Statische Dateien
@ -171,12 +181,16 @@ class VideoKonverterServer:
if self.library_service._db_pool:
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")
port = self.config.server_config.get("port", 8080)
logging.info(f"Server bereit auf http://{host}:{port}")
async def _on_shutdown(self, app: web.Application) -> None:
"""Server-Stop: Queue und Library stoppen"""
await self.hls_manager.stop()
await self.queue_service.stop()
await self.library_service.stop()
logging.info("Server heruntergefahren")

View file

@ -222,6 +222,20 @@ class AuthService:
await add_column("tv_users", "theme",
"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)
await add_column("library_series", "tvdb_score",
"FLOAT DEFAULT NULL")
@ -253,6 +267,7 @@ class AuthService:
display_name: str = None, is_admin: bool = False,
can_view_series: bool = True,
can_view_movies: bool = True,
show_tech_info: bool = False,
allowed_paths: list = None) -> Optional[int]:
"""Erstellt neuen User, gibt ID zurueck"""
pw_hash = bcrypt.hashpw(
@ -269,10 +284,12 @@ class AuthService:
await cur.execute("""
INSERT INTO tv_users
(username, password_hash, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths)
VALUES (%s, %s, %s, %s, %s, %s, %s)
can_view_series, can_view_movies, show_tech_info,
allowed_paths)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (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
except Exception as e:
logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}")
@ -295,7 +312,8 @@ class AuthService:
values.append(pw_hash)
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:
updates.append(f"{field} = %s")
val = kwargs[field]
@ -349,8 +367,8 @@ class AuthService:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, username, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths,
last_login, created_at
can_view_series, can_view_movies, show_tech_info,
allowed_paths, last_login, created_at
FROM tv_users ORDER BY id
""")
rows = await cur.fetchall()
@ -467,6 +485,8 @@ class AuthService:
u.series_view, u.movies_view, u.avatar_color,
u.autoplay_enabled, u.autoplay_countdown_sec,
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
FROM tv_sessions s
JOIN tv_users u ON s.user_id = u.id
@ -624,6 +644,8 @@ class AuthService:
"series_view", "movies_view", "avatar_color",
"autoplay_enabled", "autoplay_countdown_sec",
"autoplay_max_episodes", "display_name", "theme",
"home_show_continue", "home_show_new",
"home_hide_watched", "home_show_watched",
}
updates = []
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.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.probe import ProbeService
@ -120,6 +121,7 @@ class ImporterService:
detected_series VARCHAR(256),
detected_season INT,
detected_episode INT,
detected_episode_end INT NULL,
tvdb_series_id INT NULL,
tvdb_series_name VARCHAR(256),
tvdb_episode_title VARCHAR(512),
@ -138,6 +140,20 @@ class ImporterService:
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
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:
logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}")
@ -316,6 +332,7 @@ class ImporterService:
series_name = info.get("series", "")
season = info.get("season")
episode = info.get("episode")
episode_end = info.get("episode_end")
# Status: pending_series wenn Serie erkannt, sonst pending
if series_name and season and episode:
@ -332,11 +349,13 @@ class ImporterService:
detected_series = %s,
detected_season = %s,
detected_episode = %s,
detected_episode_end = %s,
status = %s,
conflict_reason = %s
WHERE id = %s
""", (
series_name, season, episode, status,
series_name, season, episode, episode_end,
status,
None if status == "pending_series"
else "Serie/Staffel/Episode nicht erkannt",
item["id"],
@ -427,6 +446,7 @@ class ImporterService:
for item in items:
season = item["detected_season"]
episode = item["detected_episode"]
episode_end = item.get("detected_episode_end")
# Episodentitel von TVDB
tvdb_ep_title = ""
@ -443,7 +463,8 @@ class ImporterService:
tvdb_name, season, episode,
tvdb_ep_title, ext,
job["lib_path"],
pattern, season_pat
pattern, season_pat,
episode_end=episode_end
)
target_path = os.path.join(target_dir, target_file)
@ -574,6 +595,7 @@ class ImporterService:
"series": staffel_info["series"],
"season": staffel_info["season"],
"episode": info_file["episode"],
"episode_end": info_file.get("episode_end"),
}
# Dateiname hat S/E
@ -621,25 +643,31 @@ class ImporterService:
return None
def _parse_name(self, name: str) -> dict:
"""Extrahiert Serienname, Staffel, Episode aus einem Namen"""
result = {"series": "", "season": None, "episode": None}
"""Extrahiert Serienname, Staffel, Episode aus einem Namen.
Unterstuetzt Doppelfolgen: S09E19E20, S01E01-E02, 1x01-02"""
result = {"series": "", "season": None, "episode": None,
"episode_end": None}
name_no_ext = os.path.splitext(name)[0]
# S01E02 Format
m = RE_SXXEXX.search(name)
# S01E02 / Doppelfolge S01E01E02 Format
m = RE_SXXEXX_MULTI.search(name)
if m:
result["season"] = int(m.group(1))
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)
if sm:
result["series"] = self._clean_name(sm.group(1))
return result
# 1x02 Format
m = RE_XXxXX.search(name)
# 1x02 / Doppelfolge 1x01-02 Format
m = RE_XXxXX_MULTI.search(name)
if m:
result["season"] = int(m.group(1))
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)
if sm:
result["series"] = self._clean_name(sm.group(1))
@ -660,14 +688,21 @@ class ImporterService:
def _build_target(self, series: str, season: Optional[int],
episode: Optional[int], title: str, ext: str,
lib_path: str, pattern: str,
season_pattern: str) -> tuple[str, str]:
"""Baut Ziel-Ordner und Dateiname nach Pattern"""
season_pattern: str,
episode_end: Optional[int] = None) -> tuple[str, str]:
"""Baut Ziel-Ordner und Dateiname nach Pattern.
Unterstuetzt Doppelfolgen via episode_end."""
s = season or 1
e = episode or 0
# Season-Ordner
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
try:
if title:
@ -675,14 +710,18 @@ class ImporterService:
series=series, season=s, episode=e,
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:
# Ohne Titel: "Serie - S01E03.ext"
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
filename = f"{series} - {ep_str}.{ext}"
except (KeyError, ValueError):
if title:
filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}"
filename = f"{series} - {ep_str} - {title}.{ext}"
else:
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
filename = f"{series} - {ep_str}.{ext}"
# Ungueltige Zeichen entfernen
for ch in ['<', '>', ':', '"', '|', '?', '*']:
@ -1224,6 +1263,7 @@ class ImporterService:
return False
allowed = {
'detected_series', 'detected_season', 'detected_episode',
'detected_episode_end',
'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title',
'target_path', 'target_filename', 'status'
}
@ -1400,6 +1440,7 @@ class ImporterService:
for item in items:
season = item["detected_season"]
episode = item["detected_episode"]
ep_end = item.get("detected_episode_end")
# Episodentitel holen
tvdb_ep_title = ""
@ -1420,7 +1461,8 @@ class ImporterService:
tvdb_name, season, episode,
tvdb_ep_title, ext,
job["lib_path"],
pattern, season_pat
pattern, season_pat,
episode_end=ep_end
)
await cur.execute("""
@ -1462,7 +1504,8 @@ class ImporterService:
"conflict_reason = 'Serie uebersprungen' "
"WHERE import_job_id = %s "
"AND LOWER(detected_series) = LOWER(%s) "
"AND status IN ('pending', 'matched')",
"AND status IN ('pending', 'pending_series', "
"'matched')",
(job_id, detected_series)
)
skipped = cur.rowcount

View file

@ -3,6 +3,7 @@ import asyncio
import json
import logging
import os
import signal
import time
from collections import OrderedDict
from decimal import Decimal
@ -34,6 +35,7 @@ class QueueService:
self._active_count: int = 0
self._running: bool = False
self._paused: bool = False
self._encoding_suspended: bool = False
self._queue_task: Optional[asyncio.Task] = None
self._queue_file = str(config.data_path / "queue.json")
self._db_pool: Optional[aiomysql.Pool] = None
@ -195,6 +197,52 @@ class QueueService:
def is_paused(self) -> bool:
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:
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
job = self.jobs.get(job_id)
@ -226,7 +274,8 @@ class QueueService:
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
JobStatus.FAILED, JobStatus.CANCELLED):
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:
"""Aktive Jobs fuer WebSocket"""
@ -249,8 +298,8 @@ class QueueService:
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
while self._running:
try:
if (not self._paused and
self._active_count < self.config.max_parallel_jobs):
if (not self._paused and not self._encoding_suspended
and self._active_count < self.config.max_parallel_jobs):
next_job = self._get_next_queued()
if next_job:
asyncio.create_task(self._execute_job(next_job))
@ -315,6 +364,14 @@ class QueueService:
self._save_queue()
await self._save_stats(job)
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:
"""Cleanup nach erfolgreicher Konvertierung.

View file

@ -38,6 +38,7 @@ class TVDBService:
def __init__(self, config: Config):
self.config = config
self._client = None
self._client_api_key = "" # Key mit dem der Client erstellt wurde
self._db_pool: Optional[aiomysql.Pool] = None
@property
@ -64,12 +65,18 @@ class TVDBService:
self._db_pool = pool
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:
return None
if not self._api_key:
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:
try:
if self._pin:
@ -78,6 +85,7 @@ class TVDBService:
)
else:
self._client = tvdb_v4_official.TVDB(self._api_key)
self._client_api_key = self._api_key
logging.info("TVDB Client verbunden")
except Exception as e:
logging.error(f"TVDB Verbindung fehlgeschlagen: {e}")

View file

@ -3210,22 +3210,49 @@ async function generateThumbnails() {
showToast("Thumbnail-Generierung laeuft bereits", "info");
} else {
showToast("Thumbnail-Generierung gestartet", "success");
pollThumbnailStatus();
}
// Fortschrittsbalken anzeigen und Polling starten
showThumbnailProgress();
pollThumbnailStatus();
})
.catch(e => showToast("Fehler: " + e, "error"));
}
function showThumbnailProgress() {
const container = document.getElementById("thumbnail-progress");
if (container) container.style.display = "";
}
function hideThumbnailProgress() {
const container = document.getElementById("thumbnail-progress");
if (container) container.style.display = "none";
}
function updateThumbnailProgress(generated, total) {
const bar = document.getElementById("thumbnail-bar");
const status = document.getElementById("thumbnail-status");
if (!bar || !status) return;
const pct = total > 0 ? Math.round((generated / total) * 100) : 0;
bar.style.width = pct + "%";
status.textContent = "Thumbnails: " + generated + " / " + total + " (" + pct + "%)";
}
function pollThumbnailStatus() {
const interval = setInterval(() => {
fetch("/api/library/thumbnail-status")
.then(r => r.json())
.then(data => {
updateThumbnailProgress(data.generated, data.total);
if (!data.running) {
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));
}, 3000);
}, 2000);
}

View file

@ -12,8 +12,8 @@
--bg-nav: #111;
--text: #e0e0e0;
--text-muted: #888;
--accent: #64b5f6;
--accent-hover: #90caf9;
--accent: #e5a00d;
--accent-hover: #f0b830;
--danger: #ef5350;
--success: #66bb6a;
--border: #333;
@ -31,8 +31,8 @@
--bg-nav: #24272c;
--text: #e8e8e8;
--text-muted: #999;
--accent: #5c9ce6;
--accent-hover: #7db4f0;
--accent: #d4940c;
--accent-hover: #e5a825;
--danger: #e05252;
--success: #5dba5d;
--border: #4a4f56;
@ -48,8 +48,8 @@
--bg-nav: #ffffff;
--text: #1a1a1a;
--text-muted: #666;
--accent: #1a73e8;
--accent-hover: #1565c0;
--accent: #b8860b;
--accent-hover: #9a7209;
--danger: #d32f2f;
--success: #388e3c;
--border: #dadce0;
@ -125,7 +125,7 @@ a { color: var(--accent); text-decoration: none; }
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
padding: 8px 0;
padding: 4px 4px;
-webkit-overflow-scrolling: touch;
}
.tv-row::-webkit-scrollbar { height: 4px; }
@ -134,9 +134,9 @@ a { color: var(--accent); text-decoration: none; }
.tv-row .tv-card {
scroll-snap-align: start;
flex-shrink: 0;
width: 180px;
width: 90px;
}
.tv-row .tv-card-wide { width: 260px; }
.tv-row .tv-card-wide { width: 132px; }
/* === Poster-Grid === */
.tv-grid {
@ -158,18 +158,30 @@ a { color: var(--accent); text-decoration: none; }
/* === Poster-Karten === */
.tv-card {
position: relative;
display: block;
background: var(--bg-card);
border-radius: var(--radius);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
transition: filter 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.tv-card:hover, .tv-card:focus {
transform: scale(1.04);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
.tv-card:hover {
filter: brightness(1.2);
}
.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 {
width: 100%;
@ -190,10 +202,10 @@ a { color: var(--accent); text-decoration: none; }
text-align: center;
padding: 0.5rem;
}
.tv-card-info { padding: 0.5rem 0.6rem; }
.tv-card-info { padding: 0.3rem 0.4rem; }
.tv-card-title {
display: block;
font-size: 0.85rem;
font-size: 0.7rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@ -202,13 +214,33 @@ a { color: var(--accent); text-decoration: none; }
}
.tv-card-meta {
display: block;
font-size: 0.75rem;
font-size: 0.6rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
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 */
.tv-card-progress {
height: 3px;
@ -924,6 +956,30 @@ a { color: var(--accent); text-decoration: none; }
object-fit: contain;
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 {
position: absolute;
top: 0;
@ -988,11 +1044,17 @@ a { color: var(--accent); text-decoration: none; }
color: var(--text);
font-size: 1.4rem;
cursor: pointer;
padding: 0.4rem;
padding: 0;
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 svg { display: block; }
.player-btn svg { display: block; width: 20px; height: 20px; }
.player-btn-badge {
display: inline-block;
font-size: 0.7rem;
@ -1018,53 +1080,96 @@ a { color: var(--accent); text-decoration: none; }
pointer-events: none;
}
/* === Player-Overlay (Einstellungen) === */
.player-overlay {
/* === Player-Popup-Menue (kompakt, ersetzt das grosse Overlay-Panel) === */
.player-popup {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: flex-end;
z-index: 20;
}
.player-overlay-panel {
width: 320px;
right: 1rem;
bottom: 5rem;
width: 280px;
max-width: 90vw;
height: 100%;
max-height: 60vh;
overflow-y: auto;
padding: 2rem 1.5rem;
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-overlay-section h3 {
font-size: 0.85rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
.player-popup.popup-visible {
opacity: 1;
transform: translateY(0);
}
.overlay-option {
display: block;
/* Hauptmenue-Eintraege */
.popup-menu-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
text-align: left;
padding: 0.6rem 1rem;
padding: 0.7rem 1.2rem;
background: transparent;
border: none;
color: var(--text);
font-size: 0.95rem;
cursor: pointer;
border-radius: var(--radius);
transition: background 0.2s;
transition: background 0.15s;
text-align: left;
}
.overlay-option:hover, .overlay-option:focus {
.popup-menu-item:hover, .popup-menu-item:focus {
background: var(--bg-hover);
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);
font-weight: 600;
}
.overlay-option.active::before {
.popup-option.active::before {
content: "\2713 ";
}
@ -1121,16 +1226,15 @@ a { color: var(--accent); text-decoration: none; }
outline: var(--focus-ring);
}
/* Player-Overlay responsive: Handy als Bottom-Sheet */
/* Player-Popup responsive: Handy zentriert */
@media (max-width: 480px) {
.player-overlay { justify-content: center; align-items: flex-end; }
.player-overlay-panel {
width: 100%;
max-width: 100%;
height: auto;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding: 1.5rem 1rem;
.player-popup {
right: 50%;
transform: translateX(50%) translateY(8px);
width: 90vw;
}
.player-popup.popup-visible {
transform: translateX(50%) translateY(0);
}
}
@ -1140,8 +1244,8 @@ a { color: var(--accent); text-decoration: none; }
.tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; }
.tv-main { padding: 1rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.tv-row .tv-card { width: 140px; }
.tv-row .tv-card-wide { width: 200px; }
.tv-row .tv-card { width: 72px; }
.tv-row .tv-card-wide { width: 108px; }
.tv-detail-header { flex-direction: column; }
.tv-detail-poster { width: 150px; }
.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-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.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; }
/* Episoden-Karten: kompakt auf Handy */
.tv-ep-thumb { width: 100px; }
@ -1178,8 +1282,8 @@ a { color: var(--accent); text-decoration: none; }
/* TV/Desktop (grosse Bildschirme) */
@media (min-width: 1280px) {
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.tv-row .tv-card { width: 200px; }
.tv-row .tv-card-wide { width: 300px; }
.tv-row .tv-card { width: 102px; }
.tv-row .tv-card-wide { width: 156px; }
.tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; }
/* Episoden-Karten: groesser auf TV */
.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,
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
*/
@ -18,10 +19,17 @@ let playBtn = null;
let controlsTimer = null;
let saveTimer = null;
let controlsVisible = true;
let overlayOpen = false;
let popupOpen = false; // Popup-Menue offen?
let popupSection = null; // Aktive Popup-Sektion (null = Hauptmenue)
let nextCountdown = null;
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
@ -31,6 +39,10 @@ function initPlayer(opts) {
cfg = opts;
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");
progressBar = document.getElementById("player-progress-bar");
timeDisplay = document.getElementById("player-time");
@ -38,41 +50,38 @@ function initPlayer(opts) {
if (!videoEl) return;
// Video-Info laden (Audio/Subtitle-Tracks)
loadVideoInfo().then(() => {
// Stream starten
setStreamUrl(opts.startPos || 0);
updatePlayerButtons();
});
// Video-Info + HLS-Stream PARALLEL starten (nicht sequentiell warten!)
const infoReady = loadVideoInfo();
startHLSStream(opts.startPos || 0);
infoReady.then(() => updatePlayerButtons());
// Events
videoEl.addEventListener("timeupdate", onTimeUpdate);
videoEl.addEventListener("play", onPlay);
videoEl.addEventListener("pause", onPause);
videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("loadedmetadata", () => {
if (videoEl.duration && isFinite(videoEl.duration)) {
cfg.duration = videoEl.duration + seekOffset;
}
});
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
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);
// Einstellungen-Button
// Einstellungen-Button -> Popup-Hauptmenue
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");
if (btnAudio) btnAudio.addEventListener("click", () => openOverlaySection("audio"));
if (btnAudio) btnAudio.addEventListener("click", () => openPopupSection("audio"));
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");
if (btnQuality) btnQuality.addEventListener("click", () => openOverlaySection("quality"));
if (btnQuality) btnQuality.addEventListener("click", () => openPopupSection("quality"));
// Naechste-Episode-Button
const btnNext = document.getElementById("btn-next");
@ -102,10 +111,21 @@ function initPlayer(opts) {
document.addEventListener("mousemove", showControls);
document.addEventListener("touchstart", showControls);
// Fullscreen nur auf Desktop/Handy anzeigen (nicht auf Samsung TV)
if (btnFs && isTizenTV()) {
btnFs.style.display = "none";
}
scheduleHideControls();
saveTimer = setInterval(saveProgress, 10000);
}
// === Erkennung: Samsung Tizen TV ===
function isTizenTV() {
return typeof tizen !== "undefined" || /Tizen/i.test(navigator.userAgent);
}
// === Video-Info laden ===
async function loadVideoInfo() {
@ -138,7 +158,6 @@ async function loadVideoInfo() {
if (i === currentSub) track.default = true;
videoEl.appendChild(track);
});
// Aktiven Track setzen
updateSubtitleTrack();
}
} 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({
quality: currentQuality,
audio: currentAudio,
sound: cfg.soundMode || "stereo",
});
if (seekSec > 0) params.set("t", Math.floor(seekSec));
const wasPlaying = videoEl && !videoEl.paused;
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 ===
@ -202,29 +385,53 @@ function onEnded() {
function seekRelative(seconds) {
if (!videoEl) return;
const totalTime = seekOffset + videoEl.currentTime;
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
const newTime = Math.max(0, Math.min(totalTime + seconds, dur));
setStreamUrl(newTime);
showControls();
const dur = getDuration();
const cur = getCurrentTime();
const newTime = Math.max(0, Math.min(cur + seconds, dur));
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) {
if (!videoEl) return;
const rect = e.currentTarget.getBoundingClientRect();
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;
setStreamUrl(pct * dur);
// Absolute Seek-Position im Video
const seekTo = pct * dur;
// Immer neuen HLS-Stream starten (server-seitiger Seek)
startHLSStream(seekTo);
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() {
if (!videoEl) return;
const current = seekOffset + videoEl.currentTime;
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
const current = getCurrentTime();
const dur = getDuration();
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
@ -253,7 +460,7 @@ function showControls() {
}
function hideControls() {
if (!videoEl || videoEl.paused || overlayOpen) return;
if (!videoEl || videoEl.paused || popupOpen) return;
const wrapper = document.getElementById("player-wrapper");
if (wrapper) wrapper.classList.add("player-hide-controls");
controlsVisible = false;
@ -275,138 +482,189 @@ function toggleFullscreen() {
}
}
// === Einstellungen-Overlay ===
// === Popup-Menue (ersetzt das grosse Overlay-Panel) ===
function toggleOverlay() {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
overlayOpen = !overlayOpen;
overlay.style.display = overlayOpen ? "" : "none";
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 togglePopup() {
if (popupOpen) {
closePopup();
} else {
openPopupSection(null);
}
}
function openOverlaySection(section) {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
if (overlayOpen) {
// Bereits offen -> schliessen
overlayOpen = false;
overlay.style.display = "none";
// Alle Sektionen wieder sichtbar machen
overlay.querySelectorAll(".player-overlay-section").forEach(
s => s.style.display = "");
function openPopupSection(section) {
const popup = document.getElementById("player-popup");
if (!popup) return;
if (popupOpen && popupSection === section) {
// Gleiche Sektion nochmal -> schliessen
closePopup();
return;
}
overlayOpen = true;
overlay.style.display = "";
renderOverlay();
popupOpen = true;
popupSection = section;
popup.style.display = "";
popup.classList.add("popup-visible");
renderPopup(section);
showControls();
if (section) {
// Nur die gewaehlte Sektion anzeigen, andere verstecken
overlay.querySelectorAll(".player-overlay-section").forEach(s => {
s.style.display = s.id === "overlay-" + section ? "" : "none";
});
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();
}
}
// Focus auf ersten Button im Popup
requestAnimationFrame(() => {
const first = popup.querySelector("[data-focusable]");
if (first) first.focus();
});
}
function renderOverlay() {
// Audio-Spuren
const audioEl = document.getElementById("overlay-audio");
if (audioEl && videoInfo && videoInfo.audio_tracks) {
let html = "<h3>Audio</h3>";
function closePopup() {
const popup = document.getElementById("player-popup");
if (!popup) return;
popupOpen = false;
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) => {
const label = langName(a.lang) || `Spur ${i + 1}`;
const ch = a.channels > 2 ? ` (${a.channels}ch)` : "";
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) {
if (idx === currentAudio) return;
currentAudio = idx;
// Neuen Stream mit anderer Audio-Spur starten
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime);
renderOverlay();
// Neuen HLS-Stream mit anderer Audio-Spur starten
const currentTime = getCurrentTime();
startHLSStream(currentTime);
renderPopup(popupSection);
updatePlayerButtons();
}
function switchSub(idx) {
currentSub = idx;
updateSubtitleTrack();
renderOverlay();
renderPopup(popupSection);
updatePlayerButtons();
}
@ -420,16 +678,16 @@ function updateSubtitleTrack() {
function switchQuality(q) {
if (q === currentQuality) return;
currentQuality = q;
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime);
renderOverlay();
const currentTime = getCurrentTime();
startHLSStream(currentTime);
renderPopup(popupSection);
updatePlayerButtons();
}
function switchSpeed(s) {
currentSpeed = s;
if (videoEl) videoEl.playbackRate = s;
renderOverlay();
renderPopup(popupSection);
}
// === Naechste Episode ===
@ -454,6 +712,7 @@ function showNextEpisodeOverlay() {
function playNextEpisode() {
if (nextCountdown) clearInterval(nextCountdown);
cleanupHLS();
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
}
@ -466,14 +725,10 @@ function cancelNext() {
// === D-Pad Navigation fuer Fernbedienung ===
/**
* Fokussierbare Elemente im aktuellen Kontext finden.
* Im Overlay: nur Overlay-Buttons. Sonst: Player-Control-Buttons.
*/
function _getFocusables() {
if (overlayOpen) {
const overlay = document.getElementById("player-overlay");
return overlay ? Array.from(overlay.querySelectorAll("[data-focusable]")) : [];
if (popupOpen) {
const popup = document.getElementById("player-popup");
return popup ? Array.from(popup.querySelectorAll("[data-focusable]")) : [];
}
// "Naechste Episode" oder "Schaust du noch" Overlay?
const nextOv = document.getElementById("next-overlay");
@ -521,23 +776,31 @@ function onKeyDown(e) {
const buttonFocused = active && active.hasAttribute("data-focusable") &&
active.tagName === "BUTTON";
// --- Overlay offen: D-Pad navigiert im Overlay ---
if (overlayOpen) {
// --- Popup offen: D-Pad navigiert im Popup ---
if (popupOpen) {
switch (key) {
case "Escape": case "Backspace":
toggleOverlay();
// Focus zurueck auf Settings-Button
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.focus();
if (popupSection) {
// Zurueck zum Hauptmenue
openPopupSection(null);
} else {
closePopup();
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.focus();
}
e.preventDefault(); return;
case "ArrowUp":
_focusNext(-1); e.preventDefault(); return;
case "ArrowDown":
_focusNext(1); e.preventDefault(); return;
case "ArrowLeft":
_focusNext(-1); e.preventDefault(); return;
if (popupSection) {
openPopupSection(null);
} else {
closePopup();
}
e.preventDefault(); return;
case "ArrowRight":
_focusNext(1); e.preventDefault(); return;
case "Enter":
if (buttonFocused) active.click();
e.preventDefault(); return;
@ -568,7 +831,6 @@ function onKeyDown(e) {
case "ArrowRight":
_focusNext(1); showControls(); e.preventDefault(); return;
case "ArrowUp":
// Vom Button weg = Controls ausblenden, Video steuern
active.blur(); showControls(); e.preventDefault(); return;
case "ArrowDown":
active.blur(); showControls(); e.preventDefault(); return;
@ -582,7 +844,6 @@ function onKeyDown(e) {
case " ": case "Play": case "Pause":
togglePlay(); e.preventDefault(); break;
case "Enter":
// Kein Button fokussiert: Controls einblenden + Focus auf Play
if (!controlsVisible) {
showControls();
if (playBtn) playBtn.focus();
@ -595,12 +856,10 @@ function onKeyDown(e) {
case "ArrowRight": case "FastForward":
seekRelative(10); showControls(); e.preventDefault(); break;
case "ArrowUp":
// Erster Druck: Controls + Focus auf Buttons
if (!controlsVisible) {
showControls();
if (playBtn) playBtn.focus();
} else {
// Controls sichtbar aber kein Button fokussiert: Focus setzen
if (playBtn) playBtn.focus();
showControls();
}
@ -616,24 +875,25 @@ function onKeyDown(e) {
e.preventDefault(); break;
case "Escape": case "Backspace": case "Stop":
saveProgress();
cleanupHLS();
setTimeout(() => window.history.back(), 100);
e.preventDefault(); break;
case "f":
toggleFullscreen(); e.preventDefault(); break;
case "s":
toggleOverlay(); e.preventDefault(); break;
togglePopup(); e.preventDefault(); break;
case "n":
if (cfg.nextVideoId) playNextEpisode();
e.preventDefault(); break;
// Samsung Farbtasten: Direkt-Zugriff auf Overlay-Sektionen
// Samsung Farbtasten: Direkt-Zugriff auf Popup-Sektionen
case "ColorRed":
openOverlaySection("audio"); e.preventDefault(); break;
openPopupSection("audio"); e.preventDefault(); break;
case "ColorGreen":
openOverlaySection("subs"); e.preventDefault(); break;
openPopupSection("subs"); e.preventDefault(); break;
case "ColorYellow":
openOverlaySection("quality"); e.preventDefault(); break;
openPopupSection("quality"); e.preventDefault(); break;
case "ColorBlue":
openOverlaySection("speed"); e.preventDefault(); break;
openPopupSection("speed"); e.preventDefault(); break;
}
}
@ -641,13 +901,13 @@ function onKeyDown(e) {
function saveProgress(completed) {
if (!cfg.videoId || !videoEl) return;
const pos = seekOffset + (videoEl.currentTime || 0);
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
const pos = getCurrentTime();
const dur = getDuration();
if (pos < 5 && !completed) return;
fetch("/tv/api/watch-progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
video_id: cfg.videoId,
position_sec: pos,
@ -656,7 +916,10 @@ function saveProgress(completed) {
}).catch(() => {});
}
window.addEventListener("beforeunload", () => saveProgress());
window.addEventListener("beforeunload", () => {
saveProgress();
cleanupHLS();
});
// === Button-Status aktualisieren ===
@ -667,7 +930,7 @@ function updatePlayerButtons() {
// Quality-Badge: aktuellen Modus anzeigen
var badge = document.getElementById("quality-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";
}
// Audio-Button: aktuelle Sprache anzeigen (Tooltip)

View file

@ -170,7 +170,8 @@
<label for="tvdb_api_key">TVDB API Key</label>
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
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 class="form-group">
<label for="tvdb_pin">TVDB PIN</label>
@ -243,55 +244,6 @@
</div>
</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 -->
<section class="admin-section">
<h2>Encoding-Presets</h2>
@ -387,155 +339,9 @@ function scanPath(pathId) {
.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", () => {
loadLibraryPaths();
tvLoadUsers();
tvLoadUrl();
});
</script>
{% endblock %}

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VideoKonverter{% endblock %}</title>
<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>
{% block head %}{% endblock %}
</head>
@ -18,6 +18,7 @@
<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="/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>
</nav>
</header>

View file

@ -17,6 +17,14 @@
</div>
</div>
<!-- Thumbnail-Generierung Progress -->
<div id="thumbnail-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="thumbnail-bar"></div>
</div>
<span class="scan-status" id="thumbnail-status">Thumbnails werden generiert...</span>
</div>
<!-- Auto-Match Progress -->
<div id="auto-match-progress" class="scan-progress" style="display:none">
<div class="progress-container">
@ -64,7 +72,7 @@
<div class="filter-group">
<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 class="filter-group">
@ -527,5 +535,5 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/library.js"></script>
<script src="/static/js/library.js?v={{ v }}"></script>
{% endblock %}

View file

@ -11,7 +11,7 @@
<link rel="manifest" href="/static/tv/manifest.json">
<link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png">
<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>
</head>
<body>

View file

@ -28,6 +28,41 @@
</section>
{% 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 -->
{% if series %}
<section class="tv-section">
@ -78,7 +113,44 @@
</section>
{% 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">
<p>Noch keine Inhalte in der Bibliothek.</p>
<p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p>

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<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>
</head>
<body class="login-body">

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<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>
</head>
<body class="player-body">
@ -15,6 +15,12 @@
<span class="player-title">{{ title }}</span>
</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 id="player-video" autoplay playsinline></video>
@ -44,15 +50,8 @@
</div>
</div>
<!-- Einstellungen-Overlay -->
<div class="player-overlay" id="player-overlay" style="display:none">
<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>
<!-- Kompaktes Popup-Menue (ersetzt das grosse Overlay-Panel) -->
<div class="player-popup" id="player-popup" style="display:none"></div>
<!-- Naechste Episode Overlay -->
{% if next_video %}
@ -81,6 +80,8 @@
</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>
initPlayer({

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<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>
</head>
<body class="login-body">

View file

@ -90,7 +90,7 @@
</div>
<div class="tv-episode-list">
{% 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 }}">
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-link" data-focusable>
<!-- Thumbnail -->
@ -100,12 +100,12 @@
{% else %}
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% 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-bar" style="width: {{ ep.progress_pct }}%"></div>
</div>
{% endif %}
{% if ep.progress_pct >= 95 %}
{% if ep.progress_pct >= watched_threshold_pct|default(90) %}
<div class="tv-ep-watched">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
@ -127,17 +127,19 @@
{% endif %}
<div class="tv-ep-meta">
{% 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 %}
&middot; {{ ep.container|upper }}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
{% if ep.file_size %} &middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %}
{% endif %}
</div>
</div>
</a>
<!-- 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
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)">
&#10003;
</button>

View file

@ -107,6 +107,31 @@
</label>
</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 -->
<fieldset class="settings-group">
<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 %}