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:
parent
75bb5d796d
commit
4f151de78c
27 changed files with 2325 additions and 541 deletions
113
CHANGELOG.md
113
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
469
video-konverter/app/services/hls.py
Normal file
469
video-konverter/app/services/hls.py
Normal 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()
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
2
video-konverter/app/static/tv/js/lib/hls.min.js
vendored
Normal file
2
video-konverter/app/static/tv/js/lib/hls.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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)">← 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)">← 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)">← 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)">← 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)
|
||||
|
|
|
|||
|
|
@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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ü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 %} · {{ 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">✓</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">✓</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 %} · {{ 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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">✓</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 %}
|
||||
· {{ ep.container|upper }}
|
||||
{% if ep.video_codec %} · {{ ep.video_codec }}{% endif %}
|
||||
{% if ep.file_size %} · {{ (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)">
|
||||
✓
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"Weiterschauen" 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>
|
||||
"Neu hinzugefügt" 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>
|
||||
"Schon gesehen"-Rubrik anzeigen
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<!-- Auto-Play -->
|
||||
<fieldset class="settings-group">
|
||||
<legend>{{ t('settings.autoplay') }}</legend>
|
||||
|
|
|
|||
360
video-konverter/app/templates/tv_admin.html
Normal file
360
video-konverter/app/templates/tv_admin.html
Normal 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
|
||||
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 %}
|
||||
Loading…
Reference in a new issue