feat: VideoKonverter v4.0 - Streaming-Client Ausbau

TV-App komplett ueberarbeitet: i18n (DE/EN), Multi-User Quick-Switch,
3 Themes (Dark/Medium/Light), 3 Ansichten (Grid/Liste/Detail),
Filter (Quellen/Genre/Rating/Sortierung), Merkliste, 5-Sterne-Bewertung,
Watch-Status, Player-Overlay (Audio/Untertitel/Qualitaet/Naechste Episode),
Episoden-Thumbnails, Suchverlauf, Queue-Bugfix (delete_source).

5 neue DB-Tabellen, 10+ neue API-Endpunkte, ~3800 neue Zeilen Code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-03-01 07:39:12 +01:00
parent a1be045a7d
commit 6d0b8936c5
23 changed files with 4590 additions and 280 deletions

View file

@ -2,6 +2,191 @@
Alle relevanten Aenderungen am VideoKonverter-Projekt. Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [4.0.0] - 2026-03-01
### TV-App: Vollwertiger Streaming-Client
Kompletter Ausbau der TV-App von einfachem Browser zu einem Netflix-aehnlichen Streaming-Client
mit Multi-User, Einstellungen, Bewertungen, Merkliste und Internationalisierung.
#### Internationalisierung (i18n)
- JSON-basiertes Uebersetzungssystem (`static/tv/i18n/de.json`, `en.json`)
- Jinja2-Template-Funktion `t('key.subkey')` fuer alle Texte
- Neuer Service `app/services/i18n.py` mit Sprach-Loader und Fallback (DE)
- Pro-User Spracheinstellung (`ui_lang` in tv_users)
- Alle Templates komplett auf i18n umgestellt
#### Multi-User & Profil-Wechsel
- Quick-Switch: Profilauswahl-Screen (`/tv/profiles`) ohne erneutes Passwort
- Mehrere User pro Geraet (Client), Sessions ueber `vk_client_id` Cookie
- Profilfarben (Avatar-Kreis) pro User konfigurierbar
- "Angemeldet bleiben" Option beim Login (permanente vs. 30-Tage-Sessions)
- Neue DB-Tabelle `tv_clients` fuer Geraete-Einstellungen
#### Benutzer-Einstellungen (`/tv/settings`)
- Menusprache (DE/EN), Audio-Sprache, Untertitel-Sprache
- Theme-Auswahl (Dunkel/Mittel/Hell) mit Live-Vorschau
- Serien- und Film-Ansicht (Raster/Liste/Detail)
- Autoplay: An/Aus, Countdown-Dauer, Max. Folgen am Stueck
- Suchverlauf loeschen, Fortschritte zuruecksetzen
#### Themes (Dark/Medium/Light)
- CSS Custom Properties (`--bg-primary`, `--text-primary`, etc.)
- `data-theme` Attribut auf `<html>`, gespeichert pro User
- Dunkel (Standard), Mittel (grau), Hell (weiss)
- Alle TV-Seiten, Player, Settings, Login unterstuetzen Themes
#### 3 Ansichten (Grid / Liste / Detail)
- **Grid**: Poster-Kacheln im responsiven Grid (wie bisher)
- **Liste**: Kompakte 1-Zeile pro Eintrag mit Mini-Poster
- **Detail**: Groesseres Poster + Beschreibung + Metadaten
- View-Switcher (3 Icons oben rechts) auf Serien- und Filme-Seite
- Einstellung wird pro User gespeichert (getrennt fuer Serien/Filme)
#### Episoden-Darstellung (verbessert)
- Episoden-Thumbnails: TVDB-Bilder oder ffmpeg-Fallback (Frame bei 25%)
- Episodenbeschreibung aus TVDB-Cache angezeigt
- Watch-Progress-Balken pro Episode
- Gesehen-Haekchen bei >= 95% Fortschritt
- Episodennummer, Titel, Dauer, Codec-Info
#### Filter & Quellen-Tabs
- Quellen-Tabs oben: `[Alle] [Quelle 1] [Quelle 2]` (aus library_paths)
- Genre-Chips als Filter unterhalb der Tabs
- Sortierung: Name (A-Z/Z-A), Neueste, Episoden-Anzahl, Bewertung
- Alle Filter als URL-Parameter (`?source=1&genre=Action&sort=title&rating=3`)
#### Merkliste (Watchlist)
- Herz-Button auf Serien-/Film-Detailseiten (Toggle)
- Eigene Seite `/tv/watchlist` mit allen gemerkten Inhalten
- Tabs fuer Serien/Filme auf der Merkliste-Seite
- Neue DB-Tabelle `tv_watchlist` (user_id + series_id/movie_id)
- Navigation: Merkliste als eigener Tab
#### Bewertungssystem (Rating)
- 5-Sterne-Bewertung pro User auf Serien-/Film-Detailseiten
- Klickbare Sterne mit Hover-Effekt + Entfernen-Button
- Durchschnittsbewertung aller User angezeigt
- TVDB-Score als externes Rating-Badge
- Mini-Sterne in allen 3 Listen-Ansichten (Grid/Liste/Detail)
- Rating-Filter (Min. Sterne) und Sortierung nach Bewertung
- Neue DB-Tabelle `tv_ratings`, neue Spalte `tvdb_score`
#### Manueller Watch-Status
- Pro Episode: Gesehen/Nicht gesehen Toggle
- Pro Staffel: "Ganze Staffel als gesehen markieren"
- Pro Serie: "Serie als gesehen markieren"
- In Einstellungen: "Alle Fortschritte zuruecksetzen"
- Neue DB-Tabelle `tv_watch_status`
#### Player-Verbesserungen
- Naechste Episode: Overlay-Countdown (konfigurierbar 5-30 Sek.)
- "Schaust du noch?" Dialog nach X Folgen (Netflix-Style)
- Player-Overlay-Menue: Audio-Spur, Untertitel, Qualitaet, Geschwindigkeit
- Audio-Spur-Auswahl aus verfuegbaren Tracks
- Untertitel-Extraktion (SRT/ASS -> WebVTT) per ffmpeg
- Fernbedienung-navigierbar (FocusManager)
#### Streaming-Qualitaeten
- 4 Modi: UHD (Original), HD (1080p), SD (720p), Low (480p)
- Video copy wenn Original <= Ziel-Aufloesung, sonst Re-Encoding
- Audio nach Client-Config (Stereo/Surround/Original)
#### Suchverlauf
- Letzte Suchen werden gespeichert und angezeigt
- Loeschbar ueber Einstellungen oder einzeln
#### Queue-Bugfix
- `delete_source`-Flag wird jetzt korrekt aus der DB geladen (war immer `False`)
- Fix in `queue.py`: `job['delete_source']` statt hartcodiertes `False`
### Neue Dateien
- `app/services/i18n.py` - Internationalisierungs-Service
- `app/static/tv/i18n/de.json` - Deutsche Uebersetzungen (~200 Keys)
- `app/static/tv/i18n/en.json` - Englische Uebersetzungen (~200 Keys)
- `app/templates/tv/profiles.html` - Profilauswahl (Quick-Switch)
- `app/templates/tv/settings.html` - Benutzer-Einstellungen
- `app/templates/tv/watchlist.html` - Merkliste
### Geaenderte Dateien
- `app/services/auth.py` - Multi-User, Watchlist, Status, Rating, Client-Settings, 8 neue DB-Tabellen/Spalten
- `app/services/tvdb.py` - Episoden-Bilder, tvdb_score Extraktion
- `app/services/queue.py` - delete_source Bugfix
- `app/routes/tv_api.py` - ~20 neue Endpunkte (Settings, Profiles, Watchlist, Rating, Status, Filter)
- `app/routes/library_api.py` - Thumbnail-Endpunkt, Subtitle-Extraktion
- `app/server.py` - i18n-Service Integration
- `app/templates/tv/base.html` - i18n, Theme-Support, Navigation erweitert
- `app/templates/tv/home.html` - Watchlist-Bereich, i18n
- `app/templates/tv/series.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
- `app/templates/tv/movies.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
- `app/templates/tv/series_detail.html` - Rating, Watchlist, Episoden-Thumbnails, i18n
- `app/templates/tv/movie_detail.html` - Rating, Watchlist, Versionen, i18n
- `app/templates/tv/player.html` - Overlay-Menue, Naechste Episode, Audio/Sub-Auswahl
- `app/templates/tv/search.html` - Suchverlauf, i18n
- `app/templates/tv/login.html` - "Angemeldet bleiben", i18n
- `app/static/tv/js/player.js` - Komplett ueberarbeitet (Overlay, Audio, Subs, Quality, Next)
- `app/static/tv/css/tv.css` - Themes, 3 Ansichten, Rating, Watchlist, Player-Overlay (~500 neue Zeilen)
### Neue DB-Tabellen
- `tv_clients` - Geraete-Einstellungen (Sound, Qualitaet)
- `tv_watchlist` - Merkliste pro User (Serien + Filme)
- `tv_watch_status` - Manueller Watch-Status (Episode/Staffel/Serie)
- `tv_ratings` - 5-Sterne-Bewertungen pro User
- `tv_episode_thumbnails` - Episoden-Bild-Cache
### Neue DB-Spalten (tv_users)
- `preferred_audio_lang`, `preferred_subtitle_lang`, `subtitles_enabled`
- `ui_lang`, `series_view`, `movies_view`, `avatar_color`, `theme`
- `autoplay_enabled`, `autoplay_countdown_sec`, `autoplay_max_episodes`
### Neue DB-Spalten (library_series/library_movies)
- `tvdb_score` (FLOAT) - Externe TVDB-Bewertung
### Neue API-Endpunkte
- `GET/POST /tv/settings` - Benutzer-Einstellungen
- `GET /tv/profiles` - Profilauswahl
- `POST /tv/switch-profile` - Profil wechseln
- `GET /tv/watchlist` - Merkliste anzeigen
- `POST /tv/api/watchlist` - Merkliste Toggle
- `POST /tv/api/rating` - Bewertung setzen/loeschen
- `POST /tv/api/watch-status` - Watch-Status aendern
- `DELETE /tv/api/search/history` - Suchverlauf loeschen
- `GET /api/library/videos/{id}/thumbnail` - Episoden-Thumbnail
- `GET /api/library/videos/{id}/subtitles/{index}` - Untertitel als WebVTT
---
## [3.1.1] - 2026-02-28
### Samsung TV Installation + Streaming-Fix
- Tizen-App erfolgreich auf Samsung GQ65Q7FAAUXZG installiert
- Streaming-Fix: `movflags=frag_keyframe+empty_moov+default_base_moof`
- Samsung-signierte Zertifikate (nicht Tizen-Standard)
---
## [3.1.0] - 2026-02-28
### TV-App komplett
- TV-App mit Login, Home, Serien, Filme, Player, Suche
- Auth-System: bcrypt, DB-Sessions (30 Tage Cookie)
- Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade)
- PWA: manifest.json + Service Worker
- Tizen-App fuer Samsung Smart TVs
- Admin-Seite: QR-Code + User-Verwaltung
- Log-API: `GET /api/log?lines=100&level=INFO`
---
## [3.0.0] - 2026-02-28
### Bugfixes, Queue-Pause, Button-Audit
- Queue-Pause-Funktion
- Button-Audit aller UI-Elemente
- Diverse Bugfixes
---
## [2.9.0] - 2026-02-27 ## [2.9.0] - 2026-02-27
### Import-System Neustrukturierung ### Import-System Neustrukturierung

View file

@ -1317,14 +1317,21 @@ def setup_library_routes(app: web.Application, config: Config,
# === Video-Streaming === # === Video-Streaming ===
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
_BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
async def get_stream_video(request: web.Request) -> web.StreamResponse: async def get_stream_video(request: web.Request) -> web.StreamResponse:
"""GET /api/library/videos/{video_id}/stream?t=0 """GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC). Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur.
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
Optional: ?t=120 fuer Seeking auf Sekunde 120.""" Parameter:
quality: uhd|hd|sd|low (Default: hd)
audio: Audio-Track-Index (Default: 0)
t: Seek-Position in Sekunden (Default: 0)
sound: stereo|surround|original (Default: stereo)
"""
import os import os
import asyncio as _asyncio import asyncio as _asyncio
import shlex
video_id = int(request.match_info["video_id"]) video_id = int(request.match_info["video_id"])
@ -1336,43 +1343,96 @@ def setup_library_routes(app: web.Application, config: Config,
try: try:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute( await cur.execute(
"SELECT file_path FROM library_videos WHERE id = %s", "SELECT file_path, width, height, video_codec, "
"audio_tracks, container, file_size "
"FROM library_videos WHERE id = %s",
(video_id,) (video_id,)
) )
row = await cur.fetchone() video = await cur.fetchone()
if not row: if not video:
return web.json_response( return web.json_response(
{"error": "Video nicht gefunden"}, status=404 {"error": "Video nicht gefunden"}, status=404
) )
except Exception as e: except Exception as e:
return web.json_response({"error": str(e)}, status=500) return web.json_response({"error": str(e)}, status=500)
file_path = row[0] file_path = video["file_path"]
if not os.path.isfile(file_path): if not os.path.isfile(file_path):
return web.json_response( return web.json_response(
{"error": "Datei nicht gefunden"}, status=404 {"error": "Datei nicht gefunden"}, status=404
) )
# Seek-Position (Sekunden) aus Query-Parameter # Audio-Tracks parsen
seek_sec = float(request.query.get("t", "0")) audio_tracks = video.get("audio_tracks") or "[]"
if isinstance(audio_tracks, str):
audio_tracks = json.loads(audio_tracks)
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container # Parameter aus Query
# frag_keyframe: Fragment bei jedem Keyframe quality = request.query.get("quality", "hd")
# empty_moov: Leerer moov-Atom am Anfang (noetig fuer pipe) audio_idx = int(request.query.get("audio", "0"))
# default_base_moof: Bessere Browser-Kompatibilitaet (Samsung TV etc.) seek_sec = float(request.query.get("t", "0"))
# frag_duration: Kleine Fragmente fuer schnellen Playback-Start sound_mode = request.query.get("sound", "stereo")
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error", # Audio-Track bestimmen
] 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)
# Ziel-Aufloesung bestimmen
orig_h = video.get("height") or 1080
quality_heights = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
target_h = quality_heights.get(quality, 1080)
needs_video_scale = orig_h > target_h and quality != "uhd"
# Audio-Transcoding: noetig wenn Codec nicht browser-kompatibel
needs_audio_transcode = audio_codec not in _BROWSER_AUDIO_CODECS
# Sound-Modus: Kanalanzahl bestimmen
if sound_mode == "stereo":
out_channels = 2
elif sound_mode == "surround":
out_channels = min(audio_channels, 8)
else: # original
out_channels = audio_channels
# Wenn Kanalanzahl sich aendert -> Transcoding noetig
if out_channels != audio_channels:
needs_audio_transcode = True
# ffmpeg-Kommando zusammenbauen
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
if seek_sec > 0: if seek_sec > 0:
# -ss VOR -i fuer schnelles Seeking (Input-Seeking)
cmd += ["-ss", str(seek_sec)] cmd += ["-ss", str(seek_sec)]
cmd += ["-i", file_path]
# Video-Mapping und Codec
cmd += ["-map", "0:v:0"]
if needs_video_scale:
crf = {"sd": "23", "low": "28"}.get(quality, "20")
cmd += [
"-c:v", "libx264", "-preset", "fast",
"-crf", crf,
"-vf", f"scale=-2:{target_h}",
]
else:
cmd += ["-c:v", "copy"]
# Audio-Mapping und Codec
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"]
# Container: Fragmentiertes MP4 fuer Streaming
cmd += [ cmd += [
"-i", file_path,
"-c:v", "copy",
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
"-movflags", "frag_keyframe+empty_moov+default_base_moof", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-frag_duration", "1000000", "-frag_duration", "1000000",
"-f", "mp4", "-f", "mp4",
@ -1405,7 +1465,6 @@ def setup_library_routes(app: web.Application, config: Config,
try: try:
await resp.write(chunk) await resp.write(chunk)
except (ConnectionResetError, ConnectionAbortedError): except (ConnectionResetError, ConnectionAbortedError):
# Client hat Verbindung geschlossen
break break
except Exception as e: except Exception as e:
@ -1418,6 +1477,225 @@ def setup_library_routes(app: web.Application, config: Config,
await resp.write_eof() await resp.write_eof()
return resp return resp
# === Untertitel-Extraktion ===
async def get_subtitle_track(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/subtitles/{track_index}
Extrahiert Untertitel als WebVTT per ffmpeg."""
import os
import asyncio as _asyncio
video_id = int(request.match_info["video_id"])
track_idx = int(request.match_info["track_index"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT file_path, subtitle_tracks "
"FROM library_videos WHERE id = %s", (video_id,))
video = await cur.fetchone()
if not video:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
file_path = video["file_path"]
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404)
sub_tracks = video.get("subtitle_tracks") or "[]"
if isinstance(sub_tracks, str):
sub_tracks = json.loads(sub_tracks)
if track_idx >= len(sub_tracks):
return web.json_response(
{"error": "Untertitel-Track nicht gefunden"}, status=404)
sub = sub_tracks[track_idx]
if sub.get("codec") in ("hdmv_pgs_subtitle", "dvd_subtitle",
"pgs", "vobsub"):
return web.json_response(
{"error": "Bild-basierte Untertitel nicht unterstuetzt"},
status=400)
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", file_path,
"-map", f"0:s:{track_idx}",
"-f", "webvtt", "pipe:1",
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
logging.error(
f"Untertitel-Extraktion fehlgeschlagen: "
f"{stderr.decode('utf-8', errors='replace')}")
return web.json_response(
{"error": "Extraktion fehlgeschlagen"}, status=500)
return web.Response(
body=stdout,
content_type="text/vtt",
charset="utf-8",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception as e:
logging.error(f"Untertitel-Fehler: {e}")
return web.json_response({"error": str(e)}, status=500)
# === Video-Info API (fuer Player-UI) ===
async def get_video_info(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/info
Audio-/Untertitel-Tracks und Video-Infos fuer Player-Overlay."""
video_id = int(request.match_info["video_id"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, file_name, width, height, video_codec,
audio_tracks, subtitle_tracks, container,
duration_sec, video_bitrate, is_10bit, hdr,
series_id, season_number, episode_number
FROM library_videos WHERE id = %s
""", (video_id,))
video = await cur.fetchone()
if not video:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# JSON-Felder parsen
for field in ("audio_tracks", "subtitle_tracks"):
val = video.get(field)
if isinstance(val, str):
video[field] = json.loads(val)
elif val is None:
video[field] = []
# Bild-basierte Untertitel rausfiltern
video["subtitle_tracks"] = [
s for s in video["subtitle_tracks"]
if s.get("codec") not in (
"hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub"
)
]
return web.json_response(video)
# === Episoden-Thumbnails ===
async def get_video_thumbnail(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/thumbnail
Gibt Thumbnail zurueck. Generiert per ffmpeg bei Erstaufruf."""
import os
import asyncio as _asyncio
video_id = int(request.match_info["video_id"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
# Pruefen ob bereits generiert
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT thumbnail_path FROM tv_episode_thumbnails "
"WHERE video_id = %s", (video_id,))
cached = await cur.fetchone()
if cached and os.path.isfile(cached["thumbnail_path"]):
return web.FileResponse(
cached["thumbnail_path"],
headers={"Cache-Control": "public, max-age=604800"})
# Video-Info laden
await cur.execute(
"SELECT file_path, duration_sec FROM library_videos "
"WHERE id = %s", (video_id,))
video = await cur.fetchone()
if not video or not os.path.isfile(video["file_path"]):
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Thumbnail generieren per ffmpeg (Frame bei 25%)
file_path = video["file_path"]
duration = video.get("duration_sec") or 0
seek_pos = duration * 0.25 if duration > 10 else 5
# Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei
video_dir = os.path.dirname(file_path)
thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg")
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek_pos)),
"-i", file_path,
"-vframes", "1",
"-q:v", "5",
"-vf", "scale=480:-1",
"-y", thumb_path,
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode == 0 and os.path.isfile(thumb_path):
# In DB cachen
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_episode_thumbnails
(video_id, thumbnail_path, source)
VALUES (%s, %s, 'ffmpeg')
ON DUPLICATE KEY UPDATE
thumbnail_path = VALUES(thumbnail_path)
""", (video_id, thumb_path))
return web.FileResponse(
thumb_path,
headers={"Cache-Control": "public, max-age=604800"})
else:
return web.json_response(
{"error": "Thumbnail-Generierung fehlgeschlagen"},
status=500)
except Exception as e:
logging.error(f"Thumbnail-Fehler: {e}")
return web.json_response({"error": str(e)}, status=500)
# === Import: Item zuordnen / ueberspringen === # === Import: Item zuordnen / ueberspringen ===
async def post_reassign_import_item( async def post_reassign_import_item(
@ -1910,10 +2188,20 @@ def setup_library_routes(app: web.Application, config: Config,
"/api/library/import/{job_id}/overwrite-mode", "/api/library/import/{job_id}/overwrite-mode",
put_overwrite_mode, put_overwrite_mode,
) )
# Video-Streaming # Video-Streaming, Untertitel, Video-Info
app.router.add_get( app.router.add_get(
"/api/library/videos/{video_id}/stream", get_stream_video "/api/library/videos/{video_id}/stream", get_stream_video
) )
app.router.add_get(
"/api/library/videos/{video_id}/subtitles/{track_index}",
get_subtitle_track
)
app.router.add_get(
"/api/library/videos/{video_id}/info", get_video_info
)
app.router.add_get(
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
)
# TVDB Auto-Match (Review-Modus) # TVDB Auto-Match (Review-Modus)
app.router.add_post( app.router.add_post(
"/api/library/tvdb-auto-match", post_tvdb_auto_match "/api/library/tvdb-auto-match", post_tvdb_auto_match

View file

@ -10,6 +10,7 @@ import aiomysql
from app.config import Config from app.config import Config
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.library import LibraryService from app.services.library import LibraryService
from app.services.i18n import set_request_lang, get_all_translations
def setup_tv_routes(app: web.Application, config: Config, def setup_tv_routes(app: web.Application, config: Config,
@ -27,13 +28,16 @@ def setup_tv_routes(app: web.Application, config: Config,
return await auth_service.validate_session(session_id) return await auth_service.validate_session(session_id)
def require_auth(handler): def require_auth(handler):
"""Decorator: Leitet auf Login um wenn nicht eingeloggt""" """Decorator: Leitet auf Login um wenn nicht eingeloggt.
Setzt i18n-Sprache aus User-Einstellungen."""
@wraps(handler) @wraps(handler)
async def wrapper(request): async def wrapper(request):
user = await get_tv_user(request) user = await get_tv_user(request)
if not user: if not user:
raise web.HTTPFound("/tv/login") raise web.HTTPFound("/tv/login")
request["tv_user"] = user request["tv_user"] = user
# i18n: Sprache des Users setzen
set_request_lang(request.app, user.get("ui_lang", "de"))
return await handler(request) return await handler(request)
return wrapper return wrapper
@ -50,10 +54,13 @@ def setup_tv_routes(app: web.Application, config: Config,
) )
async def post_login(request: web.Request) -> web.Response: async def post_login(request: web.Request) -> web.Response:
"""POST /tv/login - Login verarbeiten""" """POST /tv/login - Login verarbeiten.
Unterstuetzt 'remember' Checkbox fuer permanente Sessions
und Client-ID fuer Multi-User Quick-Switch."""
data = await request.post() data = await request.post()
username = data.get("username", "").strip() username = data.get("username", "").strip()
password = data.get("password", "") password = data.get("password", "")
remember = data.get("remember", "") == "on"
if not username or not password: if not username or not password:
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
@ -68,14 +75,30 @@ def setup_tv_routes(app: web.Application, config: Config,
{"error": "Falscher Benutzername oder Passwort"} {"error": "Falscher Benutzername oder Passwort"}
) )
# Session erstellen # Client-ID ermitteln/erstellen (fuer Multi-User pro Geraet)
client_id = request.cookies.get("vk_client_id")
client_id = await auth_service.get_or_create_client(client_id)
# Session erstellen (persistent wenn "Angemeldet bleiben")
ua = request.headers.get("User-Agent", "") ua = request.headers.get("User-Agent", "")
session_id = await auth_service.create_session(user["id"], ua) session_id = await auth_service.create_session(
user["id"], ua, client_id=client_id, persistent=remember
)
resp = web.HTTPFound("/tv/") resp = web.HTTPFound("/tv/")
# Session-Cookie
max_age = 10 * 365 * 24 * 3600 if remember else 30 * 24 * 3600
resp.set_cookie( resp.set_cookie(
"vk_session", session_id, "vk_session", session_id,
max_age=30 * 24 * 3600, # 30 Tage max_age=max_age,
httponly=True,
samesite="Lax",
path="/",
)
# Client-ID Cookie (immer permanent)
resp.set_cookie(
"vk_client_id", client_id,
max_age=10 * 365 * 24 * 3600, # 10 Jahre
httponly=True, httponly=True,
samesite="Lax", samesite="Lax",
path="/", path="/",
@ -166,40 +189,112 @@ def setup_tv_routes(app: web.Application, config: Config,
@require_auth @require_auth
async def get_series_list(request: web.Request) -> web.Response: async def get_series_list(request: web.Request) -> web.Response:
"""GET /tv/series - Alle Serien""" """GET /tv/series?source=&genre=&sort=&rating= - Alle Serien mit Filtern"""
user = request["tv_user"] user = request["tv_user"]
if not user.get("can_view_series"): if not user.get("can_view_series"):
raise web.HTTPFound("/tv/") raise web.HTTPFound("/tv/")
# Filter-Parameter
source_filter = request.query.get("source", "")
genre_filter = request.query.get("genre", "")
sort_by = request.query.get("sort", "title")
rating_filter = request.query.get("rating", "")
series = [] series = []
sources = []
all_genres = set()
pool = library_service._db_pool pool = library_service._db_pool
if pool: if pool:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
# Verfuegbare Quellen laden
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'series'"
src_params = []
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
src_query += f" AND id IN ({ph})"
src_params = user["allowed_paths"]
await cur.execute(src_query, src_params)
sources = await cur.fetchall()
# Serien-Query mit Filtern + Durchschnittsbewertung
query = """ query = """
SELECT s.id, s.title, s.folder_name, s.poster_url, SELECT s.id, s.title, s.folder_name, s.poster_url,
s.genres, s.tvdb_id, s.overview, s.genres, s.tvdb_id, s.overview, s.status,
COUNT(v.id) as episode_count s.library_path_id, s.tvdb_score,
COUNT(DISTINCT v.id) as episode_count,
COALESCE(AVG(r.rating), 0) as avg_rating,
COUNT(DISTINCT r.id) as rating_count
FROM library_series s FROM library_series s
LEFT JOIN library_videos v ON v.series_id = s.id LEFT JOIN library_videos v ON v.series_id = s.id
LEFT JOIN tv_ratings r ON r.series_id = s.id
AND r.rating > 0
""" """
conditions = []
params = [] params = []
# Pfad-Berechtigung
if user.get("allowed_paths"): if user.get("allowed_paths"):
placeholders = ",".join( ph = ",".join(["%s"] * len(user["allowed_paths"]))
["%s"] * len(user["allowed_paths"])) conditions.append(
query += ( f"s.library_path_id IN ({ph})")
f" WHERE s.library_path_id IN ({placeholders})" params.extend(user["allowed_paths"])
)
params = user["allowed_paths"] # Quellen-Filter
query += " GROUP BY s.id ORDER BY s.title" if source_filter:
conditions.append("s.library_path_id = %s")
params.append(int(source_filter))
# Genre-Filter
if genre_filter:
conditions.append("s.genres LIKE %s")
params.append(f"%{genre_filter}%")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " GROUP BY s.id"
# Rating-Filter (nach GROUP BY mit HAVING)
if rating_filter:
min_stars = int(rating_filter)
query += " HAVING avg_rating >= %s"
params.append(min_stars)
# Sortierung
sort_map = {
"title": " ORDER BY s.title",
"title_desc": " ORDER BY s.title DESC",
"newest": " ORDER BY s.id DESC",
"episodes": " ORDER BY episode_count DESC",
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
}
query += sort_map.get(sort_by, " ORDER BY s.title")
await cur.execute(query, params) await cur.execute(query, params)
series = await cur.fetchall() series = await cur.fetchall()
# Alle verfuegbaren Genres extrahieren + Rating runden
for s in series:
s["avg_rating"] = round(
float(s.get("avg_rating") or 0), 1)
if s.get("genres"):
for g in s["genres"].split(","):
g = g.strip()
if g:
all_genres.add(g)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/series.html", request, { "tv/series.html", request, {
"user": user, "user": user,
"active": "series", "active": "series",
"series": series, "series": series,
"view": user.get("series_view") or "grid",
"sources": sources,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
} }
) )
@ -214,36 +309,67 @@ def setup_tv_routes(app: web.Application, config: Config,
series = None series = None
seasons = {} seasons = {}
in_watchlist = False
pool = library_service._db_pool pool = library_service._db_pool
if pool: if pool:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(""" await cur.execute("""
SELECT id, title, folder_name, poster_url, SELECT id, title, folder_name, poster_url,
overview, genres, tvdb_id overview, genres, tvdb_id, tvdb_score
FROM library_series WHERE id = %s FROM library_series WHERE id = %s
""", (series_id,)) """, (series_id,))
series = await cur.fetchone() series = await cur.fetchone()
if series: if series:
# Episoden mit TVDB-Beschreibung und Watch-Progress
await cur.execute(""" await cur.execute("""
SELECT id, file_name, season_number, SELECT v.id, v.file_name, v.season_number,
episode_number, episode_title, v.episode_number, v.episode_title,
duration_sec, file_size, v.duration_sec, v.file_size,
width, height, video_codec, v.width, v.height, v.video_codec,
container v.container,
FROM library_videos tc.overview AS ep_overview,
WHERE series_id = %s tc.image_url AS ep_image_url,
ORDER BY season_number, episode_number, file_name wp.position_sec, wp.duration_sec AS wp_duration
""", (series_id,)) FROM library_videos v
LEFT JOIN tvdb_episode_cache tc
ON tc.series_tvdb_id = %s
AND tc.season_number = v.season_number
AND tc.episode_number = v.episode_number
LEFT JOIN tv_watch_progress wp
ON wp.video_id = v.id
AND wp.user_id = %s
WHERE v.series_id = %s
ORDER BY v.season_number, v.episode_number,
v.file_name
""", (series.get("tvdb_id") or 0,
user["id"], series_id))
episodes = await cur.fetchall() episodes = await cur.fetchall()
for ep in episodes: for ep in episodes:
# Fortschritt berechnen
if ep.get("position_sec") and ep.get("wp_duration"):
ep["progress_pct"] = min(100, int(
ep["position_sec"] / ep["wp_duration"]
* 100))
else:
ep["progress_pct"] = 0
sn = ep.get("season_number") or 0 sn = ep.get("season_number") or 0
if sn not in seasons: if sn not in seasons:
seasons[sn] = [] seasons[sn] = []
seasons[sn].append(ep) seasons[sn].append(ep)
# Watchlist-Status pruefen
in_watchlist = await auth_service.is_in_watchlist(
user["id"], series_id=series_id)
# Bewertungen laden
user_rating = await auth_service.get_rating(
user["id"], series_id=series_id)
avg_rating = await auth_service.get_avg_rating(
series_id=series_id)
if not series: if not series:
raise web.HTTPFound("/tv/series") raise web.HTTPFound("/tv/series")
@ -253,43 +379,114 @@ def setup_tv_routes(app: web.Application, config: Config,
"active": "series", "active": "series",
"series": series, "series": series,
"seasons": dict(sorted(seasons.items())), "seasons": dict(sorted(seasons.items())),
"in_watchlist": in_watchlist,
"user_rating": user_rating,
"avg_rating": avg_rating,
"tvdb_score": series.get("tvdb_score"),
} }
) )
@require_auth @require_auth
async def get_movies_list(request: web.Request) -> web.Response: async def get_movies_list(request: web.Request) -> web.Response:
"""GET /tv/movies - Alle Filme""" """GET /tv/movies?source=&genre=&sort=&rating= - Alle Filme mit Filtern"""
user = request["tv_user"] user = request["tv_user"]
if not user.get("can_view_movies"): if not user.get("can_view_movies"):
raise web.HTTPFound("/tv/") raise web.HTTPFound("/tv/")
# Filter-Parameter
source_filter = request.query.get("source", "")
genre_filter = request.query.get("genre", "")
sort_by = request.query.get("sort", "title")
rating_filter = request.query.get("rating", "")
movies = [] movies = []
sources = []
all_genres = set()
pool = library_service._db_pool pool = library_service._db_pool
if pool: if pool:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
# Verfuegbare Quellen
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'movie'"
src_params = []
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
src_query += f" AND id IN ({ph})"
src_params = user["allowed_paths"]
await cur.execute(src_query, src_params)
sources = await cur.fetchall()
# Film-Query mit Filtern + Durchschnittsbewertung
query = """ query = """
SELECT m.id, m.title, m.folder_name, m.poster_url, SELECT m.id, m.title, m.folder_name, m.poster_url,
m.year, m.genres, m.overview m.year, m.genres, m.overview,
m.library_path_id, m.tvdb_score,
COALESCE(AVG(r.rating), 0) as avg_rating,
COUNT(DISTINCT r.id) as rating_count
FROM library_movies m FROM library_movies m
LEFT JOIN tv_ratings r ON r.movie_id = m.id
AND r.rating > 0
""" """
conditions = []
params = [] params = []
if user.get("allowed_paths"): if user.get("allowed_paths"):
placeholders = ",".join( ph = ",".join(["%s"] * len(user["allowed_paths"]))
["%s"] * len(user["allowed_paths"])) conditions.append(
query += ( f"m.library_path_id IN ({ph})")
f" WHERE m.library_path_id IN ({placeholders})" params.extend(user["allowed_paths"])
)
params = user["allowed_paths"] if source_filter:
query += " ORDER BY m.title" conditions.append("m.library_path_id = %s")
params.append(int(source_filter))
if genre_filter:
conditions.append("m.genres LIKE %s")
params.append(f"%{genre_filter}%")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " GROUP BY m.id"
# Rating-Filter (nach GROUP BY)
if rating_filter:
min_stars = int(rating_filter)
query += " HAVING avg_rating >= %s"
params.append(min_stars)
sort_map = {
"title": " ORDER BY m.title",
"title_desc": " ORDER BY m.title DESC",
"newest": " ORDER BY m.id DESC",
"year": " ORDER BY m.year DESC",
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
}
query += sort_map.get(sort_by, " ORDER BY m.title")
await cur.execute(query, params) await cur.execute(query, params)
movies = await cur.fetchall() movies = await cur.fetchall()
for m in movies:
m["avg_rating"] = round(
float(m.get("avg_rating") or 0), 1)
if m.get("genres"):
for g in m["genres"].split(","):
g = g.strip()
if g:
all_genres.add(g)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/movies.html", request, { "tv/movies.html", request, {
"user": user, "user": user,
"active": "movies", "active": "movies",
"movies": movies, "movies": movies,
"view": user.get("movies_view") or "grid",
"sources": sources,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
} }
) )
@ -309,7 +506,7 @@ def setup_tv_routes(app: web.Application, config: Config,
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(""" await cur.execute("""
SELECT id, title, folder_name, poster_url, SELECT id, title, folder_name, poster_url,
year, overview, genres year, overview, genres, tvdb_score
FROM library_movies WHERE id = %s FROM library_movies WHERE id = %s
""", (movie_id,)) """, (movie_id,))
movie = await cur.fetchone() movie = await cur.fetchone()
@ -326,18 +523,32 @@ def setup_tv_routes(app: web.Application, config: Config,
if not movie: if not movie:
raise web.HTTPFound("/tv/movies") raise web.HTTPFound("/tv/movies")
in_watchlist = await auth_service.is_in_watchlist(
user["id"], movie_id=movie_id)
# Bewertungen laden
user_rating = await auth_service.get_rating(
user["id"], movie_id=movie_id)
avg_rating = await auth_service.get_avg_rating(
movie_id=movie_id)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/movie_detail.html", request, { "tv/movie_detail.html", request, {
"user": user, "user": user,
"active": "movies", "active": "movies",
"movie": movie, "movie": movie,
"videos": videos, "videos": videos,
"in_watchlist": in_watchlist,
"user_rating": user_rating,
"avg_rating": avg_rating,
"tvdb_score": movie.get("tvdb_score"),
} }
) )
@require_auth @require_auth
async def get_player(request: web.Request) -> web.Response: async def get_player(request: web.Request) -> web.Response:
"""GET /tv/player?v={video_id} - Video-Player""" """GET /tv/player?v={video_id} - Video-Player
Laedt Video-Info, naechste Episode und Client-Einstellungen."""
user = request["tv_user"] user = request["tv_user"]
video_id = int(request.query.get("v", 0)) video_id = int(request.query.get("v", 0))
if not video_id: if not video_id:
@ -349,14 +560,16 @@ def setup_tv_routes(app: web.Application, config: Config,
if progress and not progress.get("completed"): if progress and not progress.get("completed"):
start_pos = progress.get("position_sec", 0) start_pos = progress.get("position_sec", 0)
# Video-Info laden # Video-Info + naechste Episode laden
video = None video = None
next_video = None
pool = library_service._db_pool pool = library_service._db_pool
if pool: if pool:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(""" await cur.execute("""
SELECT v.id, v.file_name, v.duration_sec, SELECT v.id, v.file_name, v.duration_sec,
v.series_id,
s.title as series_title, s.title as series_title,
v.season_number, v.episode_number, v.season_number, v.episode_number,
v.episode_title v.episode_title
@ -366,6 +579,24 @@ def setup_tv_routes(app: web.Application, config: Config,
""", (video_id,)) """, (video_id,))
video = await cur.fetchone() video = await cur.fetchone()
# Naechste Episode ermitteln (gleiche Serie)
if video and video.get("series_id"):
await cur.execute("""
SELECT id, season_number, episode_number,
episode_title, file_name
FROM library_videos
WHERE series_id = %s
AND (season_number > %s
OR (season_number = %s
AND episode_number > %s))
ORDER BY season_number ASC, episode_number ASC
LIMIT 1
""", (video["series_id"],
video.get("season_number", 0),
video.get("season_number", 0),
video.get("episode_number", 0)))
next_video = await cur.fetchone()
if not video: if not video:
raise web.HTTPFound("/tv/") raise web.HTTPFound("/tv/")
@ -379,22 +610,42 @@ def setup_tv_routes(app: web.Application, config: Config,
if ep_title: if ep_title:
title += f" - {ep_title}" title += f" - {ep_title}"
# Naechste Episode Titel
next_title = ""
if next_video:
sn2 = next_video.get("season_number", 0)
en2 = next_video.get("episode_number", 0)
next_title = f"S{sn2:02d}E{en2:02d}"
if next_video.get("episode_title"):
next_title += f" - {next_video['episode_title']}"
# Client-Einstellungen
client_id = request.cookies.get("vk_client_id")
client = None
if client_id:
client = await auth_service.get_client_settings(client_id)
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/player.html", request, { "tv/player.html", request, {
"user": user, "user": user,
"video": video, "video": video,
"title": title, "title": title,
"start_pos": start_pos, "start_pos": start_pos,
"next_video": next_video,
"next_title": next_title,
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
} }
) )
@require_auth @require_auth
async def get_search(request: web.Request) -> web.Response: async def get_search(request: web.Request) -> web.Response:
"""GET /tv/search?q=... - Suchseite""" """GET /tv/search?q=... - Suchseite mit History/Autocomplete"""
user = request["tv_user"] user = request["tv_user"]
query = request.query.get("q", "").strip() query = request.query.get("q", "").strip()
results_series = [] results_series = []
results_movies = [] results_movies = []
history = []
if query and len(query) >= 2: if query and len(query) >= 2:
pool = library_service._db_pool pool = library_service._db_pool
@ -405,7 +656,8 @@ def setup_tv_routes(app: web.Application, config: Config,
if user.get("can_view_series"): if user.get("can_view_series"):
await cur.execute(""" await cur.execute("""
SELECT id, title, folder_name, poster_url, genres SELECT id, title, folder_name, poster_url,
genres
FROM library_series FROM library_series
WHERE title LIKE %s OR folder_name LIKE %s WHERE title LIKE %s OR folder_name LIKE %s
ORDER BY title LIMIT 50 ORDER BY title LIMIT 50
@ -422,6 +674,12 @@ def setup_tv_routes(app: web.Application, config: Config,
""", (search_term, search_term)) """, (search_term, search_term))
results_movies = await cur.fetchall() results_movies = await cur.fetchall()
# Such-History speichern
await auth_service.save_search(user["id"], query)
else:
# Ohne Query: History anzeigen
history = await auth_service.get_search_history(user["id"])
return aiohttp_jinja2.render_template( return aiohttp_jinja2.render_template(
"tv/search.html", request, { "tv/search.html", request, {
"user": user, "user": user,
@ -429,6 +687,7 @@ def setup_tv_routes(app: web.Application, config: Config,
"query": query, "query": query,
"series": results_series, "series": results_series,
"movies": results_movies, "movies": results_movies,
"history": history,
} }
) )
@ -566,12 +825,244 @@ def setup_tv_routes(app: web.Application, config: Config,
return web.json_response( return web.json_response(
{"error": "User nicht gefunden"}, status=404) {"error": "User nicht gefunden"}, status=404)
# --- Profilauswahl (Multi-User Quick-Switch) ---
async def get_profiles(request: web.Request) -> web.Response:
"""GET /tv/profiles - Profilauswahl (wer schaut?)"""
client_id = request.cookies.get("vk_client_id")
profiles = []
if client_id:
profiles = await auth_service.get_client_profiles(client_id)
# Aktuelle Session herausfinden
current_session = request.cookies.get("vk_session")
return aiohttp_jinja2.render_template(
"tv/profiles.html", request, {
"profiles": profiles,
"current_session": current_session,
}
)
async def post_switch_profile(request: web.Request) -> web.Response:
"""POST /tv/switch-profile - Profil wechseln (Session-ID)"""
data = await request.post()
session_id = data.get("session_id", "")
if not session_id:
raise web.HTTPFound("/tv/profiles")
# Session validieren
user = await auth_service.validate_session(session_id)
if not user:
raise web.HTTPFound("/tv/login")
resp = web.HTTPFound("/tv/")
resp.set_cookie(
"vk_session", session_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, samesite="Lax", path="/",
)
return resp
# --- User-Einstellungen ---
@require_auth
async def get_settings(request: web.Request) -> web.Response:
"""GET /tv/settings - Benutzer-Einstellungen"""
user = request["tv_user"]
client_id = request.cookies.get("vk_client_id")
client = None
if client_id:
client = await auth_service.get_client_settings(client_id)
return aiohttp_jinja2.render_template(
"tv/settings.html", request, {
"user": user,
"client": client,
"active": "settings",
}
)
@require_auth
async def post_settings(request: web.Request) -> web.Response:
"""POST /tv/settings - Benutzer-Einstellungen speichern
Unterstuetzt sowohl vollstaendige Form-Submits als auch
einzelne AJAX-Updates (nur gesetzte Felder aendern)."""
user = request["tv_user"]
data = await request.post()
is_ajax = "X-Requested-With" in request.headers or \
len(data) <= 2
# Nur uebergebene Felder sammeln (kein Ueberschreiben)
user_kwargs = {}
field_map = {
"display_name": lambda v: v,
"preferred_audio_lang": lambda v: v,
"preferred_subtitle_lang": lambda v: v or None,
"subtitles_enabled": lambda v: v == "on",
"ui_lang": lambda v: v,
"series_view": lambda v: v,
"movies_view": lambda v: v,
"avatar_color": lambda v: v,
"theme": lambda v: v,
"autoplay_enabled": lambda v: v == "on",
"autoplay_countdown_sec": lambda v: int(v),
"autoplay_max_episodes": lambda v: int(v),
}
for key, transform in field_map.items():
if key in data:
user_kwargs[key] = transform(data[key])
if user_kwargs:
await auth_service.update_user_settings(
user["id"], **user_kwargs)
# Client-Einstellungen (nur wenn Felder vorhanden)
client_id = request.cookies.get("vk_client_id")
client_kwargs = {}
if client_id:
if "client_name" in data:
client_kwargs["name"] = data["client_name"]
if "sound_mode" in data:
client_kwargs["sound_mode"] = data["sound_mode"]
if "stream_quality" in data:
client_kwargs["stream_quality"] = data["stream_quality"]
if client_kwargs:
await auth_service.update_client_settings(
client_id, **client_kwargs)
# AJAX: JSON zurueckgeben, sonst Redirect
if is_ajax:
return web.json_response({"ok": True})
raise web.HTTPFound("/tv/settings?saved=1")
@require_auth
async def post_reset_progress(request: web.Request) -> web.Response:
"""POST /tv/settings/reset - Alle Fortschritte zuruecksetzen"""
user = request["tv_user"]
await auth_service.reset_all_progress(user["id"])
raise web.HTTPFound("/tv/settings?reset=1")
# --- Watchlist ---
@require_auth
async def get_watchlist(request: web.Request) -> web.Response:
"""GET /tv/watchlist - Merkliste anzeigen"""
user = request["tv_user"]
wl = await auth_service.get_watchlist(user["id"])
return aiohttp_jinja2.render_template(
"tv/watchlist.html", request, {
"user": user,
"active": "watchlist",
"series": wl["series"],
"movies": wl["movies"],
}
)
@require_auth
async def post_watchlist_toggle(request: web.Request) -> web.Response:
"""POST /tv/api/watchlist - Toggle Merkliste (JSON)"""
user = request["tv_user"]
data = await request.json()
series_id = data.get("series_id")
movie_id = data.get("movie_id")
in_list = await auth_service.toggle_watchlist(
user["id"],
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
return web.json_response({"in_watchlist": in_list})
# --- Watch-Status ---
@require_auth
async def post_watch_status(request: web.Request) -> web.Response:
"""POST /tv/api/watch-status - Status setzen (JSON)"""
user = request["tv_user"]
data = await request.json()
status = data.get("status", "unwatched")
success = await auth_service.set_watch_status(
user["id"], status,
video_id=data.get("video_id"),
series_id=data.get("series_id"),
season_key=data.get("season_key"),
)
return web.json_response({"success": success})
# --- Such-API ---
@require_auth
async def get_search_suggestions(request: web.Request) -> web.Response:
"""GET /tv/api/search/suggest?q=... - Autocomplete-Vorschlaege"""
user = request["tv_user"]
prefix = request.query.get("q", "").strip()
suggestions = await auth_service.get_search_suggestions(
user["id"], prefix)
return web.json_response({"suggestions": suggestions})
@require_auth
async def get_search_history(request: web.Request) -> web.Response:
"""GET /tv/api/search/history - Such-History"""
user = request["tv_user"]
history = await auth_service.get_search_history(user["id"])
return web.json_response({"history": history})
@require_auth
async def delete_search_history(request: web.Request) -> web.Response:
"""DELETE /tv/api/search/history - Such-History loeschen"""
user = request["tv_user"]
await auth_service.clear_search_history(user["id"])
return web.json_response({"success": True})
# --- Rating API ---
@require_auth
async def post_rating(request: web.Request) -> web.Response:
"""POST /tv/api/rating - Bewertung setzen/loeschen (JSON)
Body: { series_id|movie_id: int, rating: 0-5 }"""
user = request["tv_user"]
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
rating = int(data.get("rating", 0))
series_id = data.get("series_id")
movie_id = data.get("movie_id")
if not series_id and not movie_id:
return web.json_response(
{"error": "series_id oder movie_id noetig"}, status=400)
success = await auth_service.set_rating(
user["id"], rating,
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
# Durchschnitt zurueckgeben
avg = await auth_service.get_avg_rating(
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
return web.json_response({
"success": success,
"user_rating": rating,
"avg_rating": avg["avg"],
"rating_count": avg["count"],
})
# --- i18n API (fuer JavaScript) ---
async def get_i18n(request: web.Request) -> web.Response:
"""GET /tv/api/i18n?lang=de - Alle Uebersetzungen als JSON"""
lang = request.query.get("lang", "de")
return web.json_response(get_all_translations(lang))
# --- Routes registrieren --- # --- Routes registrieren ---
# TV-Seiten (mit Auth via Decorator) # TV-Seiten (mit Auth via Decorator)
app.router.add_get("/tv/login", get_login) app.router.add_get("/tv/login", get_login)
app.router.add_post("/tv/login", post_login) app.router.add_post("/tv/login", post_login)
app.router.add_get("/tv/logout", get_logout) app.router.add_get("/tv/logout", get_logout)
app.router.add_get("/tv/profiles", get_profiles)
app.router.add_post("/tv/switch-profile", post_switch_profile)
app.router.add_get("/tv/", get_home) app.router.add_get("/tv/", get_home)
app.router.add_get("/tv/series", get_series_list) app.router.add_get("/tv/series", get_series_list)
app.router.add_get("/tv/series/{id}", get_series_detail) app.router.add_get("/tv/series/{id}", get_series_detail)
@ -579,11 +1070,22 @@ def setup_tv_routes(app: web.Application, config: Config,
app.router.add_get("/tv/movies/{id}", get_movie_detail) app.router.add_get("/tv/movies/{id}", get_movie_detail)
app.router.add_get("/tv/player", get_player) app.router.add_get("/tv/player", get_player)
app.router.add_get("/tv/search", get_search) app.router.add_get("/tv/search", get_search)
app.router.add_get("/tv/watchlist", get_watchlist)
app.router.add_get("/tv/settings", get_settings)
app.router.add_post("/tv/settings", post_settings)
app.router.add_post("/tv/settings/reset", post_reset_progress)
# TV-API (Watch-Progress) # TV-API (Watch-Progress, Watchlist, Status, Suche, i18n)
app.router.add_post("/tv/api/watch-progress", post_watch_progress) app.router.add_post("/tv/api/watch-progress", post_watch_progress)
app.router.add_get( app.router.add_get(
"/tv/api/watch-progress/{video_id}", get_watch_progress) "/tv/api/watch-progress/{video_id}", get_watch_progress)
app.router.add_post("/tv/api/watchlist", post_watchlist_toggle)
app.router.add_post("/tv/api/watch-status", post_watch_status)
app.router.add_get("/tv/api/search/suggest", get_search_suggestions)
app.router.add_get("/tv/api/search/history", get_search_history)
app.router.add_delete("/tv/api/search/history", delete_search_history)
app.router.add_get("/tv/api/i18n", get_i18n)
app.router.add_post("/tv/api/rating", post_rating)
# Admin-API (QR-Code, User-Verwaltung) # Admin-API (QR-Code, User-Verwaltung)
app.router.add_get("/api/tv/qrcode", get_qrcode) app.router.add_get("/api/tv/qrcode", get_qrcode)

View file

@ -15,6 +15,7 @@ from app.services.tvdb import TVDBService
from app.services.cleaner import CleanerService from app.services.cleaner import CleanerService
from app.services.importer import ImporterService from app.services.importer import ImporterService
from app.services.auth import AuthService from app.services.auth import AuthService
from app.services.i18n import load_translations, setup_jinja2_i18n
from app.routes.api import setup_api_routes from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes from app.routes.library_api import setup_library_routes
from app.routes.pages import setup_page_routes from app.routes.pages import setup_page_routes
@ -70,6 +71,11 @@ class VideoKonverterServer:
context_processors=[aiohttp_jinja2.request_processor], context_processors=[aiohttp_jinja2.request_processor],
) )
# i18n: Uebersetzungen laden und Jinja2-Filter registrieren
static_dir = Path(__file__).parent / "static"
load_translations(str(static_dir))
setup_jinja2_i18n(self.app)
# WebSocket Route # WebSocket Route
ws_path = self.config.server_config.get("websocket_path", "/ws") ws_path = self.config.server_config.get("websocket_path", "/ws")
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket) self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)

View file

@ -16,7 +16,7 @@ class AuthService:
self._get_pool = db_pool_getter self._get_pool = db_pool_getter
async def init_db(self) -> None: async def init_db(self) -> None:
"""Erstellt DB-Tabellen fuer TV-Auth""" """Erstellt DB-Tabellen fuer TV-Auth und migriert bestehende"""
pool = await self._get_pool() pool = await self._get_pool()
if not pool: if not pool:
logging.error("Auth: Kein DB-Pool verfuegbar") logging.error("Auth: Kein DB-Pool verfuegbar")
@ -24,6 +24,7 @@ class AuthService:
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
# === Bestehende Tabellen ===
await cur.execute(""" await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_users ( CREATE TABLE IF NOT EXISTS tv_users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@ -64,10 +65,171 @@ class AuthService:
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""") """)
# === Neue Tabellen (v4.0) ===
# Client-Einstellungen (pro Geraet/Browser)
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_clients (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(128) DEFAULT NULL,
sound_mode ENUM('stereo','surround','original')
DEFAULT 'stereo',
stream_quality ENUM('uhd','hd','sd','low')
DEFAULT 'hd',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Merkliste (Watchlist)
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_watchlist (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
series_id INT NULL,
movie_id INT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_series (user_id, series_id),
UNIQUE INDEX idx_user_movie (user_id, movie_id),
FOREIGN KEY (user_id) REFERENCES tv_users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Manueller Watch-Status
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_watch_status (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
video_id INT NULL,
series_id INT NULL,
season_key VARCHAR(64) NULL,
status ENUM('unwatched','watching','watched')
DEFAULT 'unwatched',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
UNIQUE INDEX idx_user_video (user_id, video_id),
UNIQUE INDEX idx_user_series (user_id, series_id),
UNIQUE INDEX idx_user_season (user_id, season_key),
FOREIGN KEY (user_id) REFERENCES tv_users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Episoden-Thumbnails Cache
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_episode_thumbnails (
video_id INT PRIMARY KEY,
thumbnail_path VARCHAR(1024) NOT NULL,
source ENUM('tvdb','ffmpeg') DEFAULT 'ffmpeg',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Such-History (pro User)
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_search_history (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
query VARCHAR(256) NOT NULL,
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_query (query(64)),
FOREIGN KEY (user_id) REFERENCES tv_users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Bewertungen (pro User, fuer Serien und Filme)
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_ratings (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
series_id INT NULL,
movie_id INT NULL,
rating TINYINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_series (user_id, series_id),
UNIQUE INDEX idx_user_movie (user_id, movie_id),
FOREIGN KEY (user_id) REFERENCES tv_users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# === Migration: Neue Spalten zu bestehenden Tabellen ===
await self._migrate_columns(cur)
# Standard-Admin erstellen falls keine User existieren # Standard-Admin erstellen falls keine User existieren
await self._ensure_default_admin() await self._ensure_default_admin()
logging.info("TV-Auth: DB-Tabellen initialisiert") logging.info("TV-Auth: DB-Tabellen initialisiert")
async def _migrate_columns(self, cur) -> None:
"""Fuegt neue Spalten zu bestehenden Tabellen hinzu (idempotent)"""
# Hilfsfunktion: Spalte hinzufuegen falls nicht vorhanden
async def add_column(table: str, column: str, definition: str):
await cur.execute(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = %s AND COLUMN_NAME = %s",
(table, column)
)
row = await cur.fetchone()
if row[0] == 0:
await cur.execute(
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
)
logging.info(f"TV-Auth: Spalte {table}.{column} hinzugefuegt")
# tv_users: User-Einstellungen
await add_column("tv_users", "preferred_audio_lang",
"VARCHAR(8) DEFAULT 'deu'")
await add_column("tv_users", "preferred_subtitle_lang",
"VARCHAR(8) DEFAULT NULL")
await add_column("tv_users", "subtitles_enabled",
"TINYINT DEFAULT 0")
await add_column("tv_users", "ui_lang",
"VARCHAR(8) DEFAULT 'de'")
await add_column("tv_users", "series_view",
"VARCHAR(16) DEFAULT 'grid'")
await add_column("tv_users", "movies_view",
"VARCHAR(16) DEFAULT 'grid'")
await add_column("tv_users", "avatar_color",
"VARCHAR(7) DEFAULT '#64b5f6'")
# Auto-Play Einstellungen
await add_column("tv_users", "autoplay_enabled",
"TINYINT DEFAULT 1")
await add_column("tv_users", "autoplay_countdown_sec",
"INT DEFAULT 10")
await add_column("tv_users", "autoplay_max_episodes",
"INT DEFAULT 0")
# tv_sessions: Client-Referenz und permanente Sessions
await add_column("tv_sessions", "client_id",
"VARCHAR(64) DEFAULT NULL")
await add_column("tv_sessions", "expires_at",
"TIMESTAMP NULL DEFAULT NULL")
# tvdb_episode_cache: Beschreibung und Bild-URL
await add_column("tvdb_episode_cache", "overview",
"TEXT DEFAULT NULL")
await add_column("tvdb_episode_cache", "image_url",
"VARCHAR(1024) DEFAULT NULL")
# tv_users: Theme
await add_column("tv_users", "theme",
"VARCHAR(16) DEFAULT 'dark'")
# library_series: TVDB-Score (externe Bewertung 0-100)
await add_column("library_series", "tvdb_score",
"FLOAT DEFAULT NULL")
# library_movies: TVDB-Score (externe Bewertung 0-100)
await add_column("library_movies", "tvdb_score",
"FLOAT DEFAULT NULL")
async def _ensure_default_admin(self) -> None: async def _ensure_default_admin(self) -> None:
"""Erstellt admin/admin falls keine User existieren""" """Erstellt admin/admin falls keine User existieren"""
pool = await self._get_pool() pool = await self._get_pool()
@ -254,22 +416,41 @@ class AuthService:
return user return user
async def create_session(self, user_id: int, async def create_session(self, user_id: int,
user_agent: str = "") -> str: user_agent: str = "",
"""Erstellt Session, gibt Token zurueck""" client_id: str = "",
persistent: bool = False) -> str:
"""Erstellt Session, gibt Token zurueck.
persistent=True -> Session laeuft nie ab (expires_at=NULL)"""
session_id = secrets.token_urlsafe(48) session_id = secrets.token_urlsafe(48)
pool = await self._get_pool() pool = await self._get_pool()
if not pool: if not pool:
return "" return ""
# Nicht-persistente Sessions laufen nach 30 Tagen ab
expires = None if persistent else "DATE_ADD(NOW(), INTERVAL 30 DAY)"
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute(""" if persistent:
INSERT INTO tv_sessions (id, user_id, user_agent) await cur.execute("""
VALUES (%s, %s, %s) INSERT INTO tv_sessions
""", (session_id, user_id, user_agent[:512] if user_agent else "")) (id, user_id, user_agent, client_id, expires_at)
VALUES (%s, %s, %s, %s, NULL)
""", (session_id, user_id,
user_agent[:512] if user_agent else "",
client_id or None))
else:
await cur.execute("""
INSERT INTO tv_sessions
(id, user_id, user_agent, client_id, expires_at)
VALUES (%s, %s, %s, %s,
DATE_ADD(NOW(), INTERVAL 30 DAY))
""", (session_id, user_id,
user_agent[:512] if user_agent else "",
client_id or None))
return session_id return session_id
async def validate_session(self, session_id: str) -> Optional[dict]: async def validate_session(self, session_id: str) -> Optional[dict]:
"""Prueft Session, gibt User-Dict zurueck oder None""" """Prueft Session, gibt User-Dict mit Einstellungen zurueck oder None.
Beruecksichtigt expires_at (NULL = permanent, sonst Ablauf-Datum)."""
if not session_id: if not session_id:
return None return None
pool = await self._get_pool() pool = await self._get_pool()
@ -279,11 +460,18 @@ class AuthService:
async with conn.cursor(aiomysql.DictCursor) as cur: async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(""" await cur.execute("""
SELECT u.id, u.username, u.display_name, u.is_admin, SELECT u.id, u.username, u.display_name, u.is_admin,
u.can_view_series, u.can_view_movies, u.allowed_paths u.can_view_series, u.can_view_movies,
u.allowed_paths,
u.preferred_audio_lang, u.preferred_subtitle_lang,
u.subtitles_enabled, u.ui_lang,
u.series_view, u.movies_view, u.avatar_color,
u.autoplay_enabled, u.autoplay_countdown_sec,
u.autoplay_max_episodes, u.theme,
s.client_id
FROM tv_sessions s FROM tv_sessions s
JOIN tv_users u ON s.user_id = u.id JOIN tv_users u ON s.user_id = u.id
WHERE s.id = %s WHERE s.id = %s
AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY) AND (s.expires_at IS NULL OR s.expires_at > NOW())
""", (session_id,)) """, (session_id,))
user = await cur.fetchone() user = await cur.fetchone()
@ -311,7 +499,8 @@ class AuthService:
) )
async def cleanup_old_sessions(self) -> int: async def cleanup_old_sessions(self) -> int:
"""Loescht Sessions aelter als 30 Tage""" """Loescht abgelaufene Sessions (expires_at abgelaufen).
Persistente Sessions (expires_at IS NULL) werden nie geloescht."""
pool = await self._get_pool() pool = await self._get_pool()
if not pool: if not pool:
return 0 return 0
@ -319,10 +508,466 @@ class AuthService:
async with conn.cursor() as cur: async with conn.cursor() as cur:
await cur.execute( await cur.execute(
"DELETE FROM tv_sessions " "DELETE FROM tv_sessions "
"WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)" "WHERE expires_at IS NOT NULL AND expires_at < NOW()"
) )
return cur.rowcount return cur.rowcount
# --- Client-Verwaltung (pro Geraet) ---
async def get_or_create_client(self, client_id: str = None) -> str:
"""Gibt bestehende oder neue Client-ID zurueck"""
pool = await self._get_pool()
if not pool:
return ""
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if client_id:
await cur.execute(
"SELECT id FROM tv_clients WHERE id = %s",
(client_id,))
if await cur.fetchone():
await cur.execute(
"UPDATE tv_clients SET last_active = NOW() "
"WHERE id = %s", (client_id,))
return client_id
# Neuen Client erstellen
new_id = secrets.token_urlsafe(32)
await cur.execute(
"INSERT INTO tv_clients (id) VALUES (%s)",
(new_id,))
return new_id
async def get_client_settings(self, client_id: str) -> Optional[dict]:
"""Liest Client-Einstellungen"""
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT * FROM tv_clients WHERE id = %s",
(client_id,))
return await cur.fetchone()
async def update_client_settings(self, client_id: str,
**kwargs) -> bool:
"""Aktualisiert Client-Einstellungen (name, sound_mode, stream_quality)"""
pool = await self._get_pool()
if not pool:
return False
allowed = {"name", "sound_mode", "stream_quality"}
updates = []
values = []
for key, val in kwargs.items():
if key in allowed and val is not None:
updates.append(f"{key} = %s")
values.append(val)
if not updates:
return False
values.append(client_id)
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"UPDATE tv_clients SET {', '.join(updates)} "
"WHERE id = %s", tuple(values))
return True
except Exception as e:
logging.error(f"TV-Auth: Client-Settings fehlgeschlagen: {e}")
return False
# --- Multi-User: Profile auf dem selben Geraet ---
async def get_client_profiles(self, client_id: str) -> list[dict]:
"""Alle eingeloggten User auf einem Client (fuer Quick-Switch)"""
pool = await self._get_pool()
if not pool:
return []
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT s.id as session_id, u.id as user_id,
u.username, u.display_name, u.avatar_color
FROM tv_sessions s
JOIN tv_users u ON s.user_id = u.id
WHERE s.client_id = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW())
ORDER BY s.last_active DESC
""", (client_id,))
return await cur.fetchall()
# --- User-Einstellungen ---
async def update_user_settings(self, user_id: int,
**kwargs) -> bool:
"""Aktualisiert User-Einstellungen (Sprache, Ansichten, Auto-Play)"""
pool = await self._get_pool()
if not pool:
return False
allowed = {
"preferred_audio_lang", "preferred_subtitle_lang",
"subtitles_enabled", "ui_lang",
"series_view", "movies_view", "avatar_color",
"autoplay_enabled", "autoplay_countdown_sec",
"autoplay_max_episodes", "display_name", "theme",
}
updates = []
values = []
for key, val in kwargs.items():
if key in allowed:
updates.append(f"{key} = %s")
if isinstance(val, bool):
val = int(val)
values.append(val)
if not updates:
return False
values.append(user_id)
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"UPDATE tv_users SET {', '.join(updates)} "
"WHERE id = %s", tuple(values))
return True
except Exception as e:
logging.error(f"TV-Auth: Einstellungen fehlgeschlagen: {e}")
return False
# --- Watchlist (Merkliste) ---
async def add_to_watchlist(self, user_id: int,
series_id: int = None,
movie_id: int = None) -> bool:
"""Fuegt Serie oder Film zur Merkliste hinzu"""
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT IGNORE INTO tv_watchlist
(user_id, series_id, movie_id)
VALUES (%s, %s, %s)
""", (user_id, series_id, movie_id))
return cur.rowcount > 0
except Exception as e:
logging.error(f"TV-Auth: Watchlist hinzufuegen fehlgeschlagen: {e}")
return False
async def remove_from_watchlist(self, user_id: int,
series_id: int = None,
movie_id: int = None) -> bool:
"""Entfernt Serie oder Film von der Merkliste"""
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if series_id:
await cur.execute(
"DELETE FROM tv_watchlist "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
elif movie_id:
await cur.execute(
"DELETE FROM tv_watchlist "
"WHERE user_id = %s AND movie_id = %s",
(user_id, movie_id))
return cur.rowcount > 0
except Exception as e:
logging.error(f"TV-Auth: Watchlist entfernen fehlgeschlagen: {e}")
return False
async def toggle_watchlist(self, user_id: int,
series_id: int = None,
movie_id: int = None) -> bool:
"""Toggle: Hinzufuegen wenn nicht vorhanden, entfernen wenn schon drin.
Gibt True zurueck wenn jetzt in der Liste, False wenn entfernt."""
pool = await self._get_pool()
if not pool:
return False
async with pool.acquire() as conn:
async with conn.cursor() as cur:
# Pruefen ob schon in Liste
if series_id:
await cur.execute(
"SELECT id FROM tv_watchlist "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
else:
await cur.execute(
"SELECT id FROM tv_watchlist "
"WHERE user_id = %s AND movie_id = %s",
(user_id, movie_id))
exists = await cur.fetchone()
if exists:
await cur.execute(
"DELETE FROM tv_watchlist WHERE id = %s",
(exists[0],))
return False # Entfernt
else:
await cur.execute(
"INSERT INTO tv_watchlist "
"(user_id, series_id, movie_id) VALUES (%s, %s, %s)",
(user_id, series_id, movie_id))
return True # Hinzugefuegt
async def get_watchlist(self, user_id: int) -> dict:
"""Gibt Merkliste zurueck (Serien + Filme)"""
pool = await self._get_pool()
if not pool:
return {"series": [], "movies": []}
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Serien
await cur.execute("""
SELECT w.id as watchlist_id, w.added_at,
s.id, s.title, s.folder_name, s.poster_url,
s.genres, s.overview
FROM tv_watchlist w
JOIN library_series s ON w.series_id = s.id
WHERE w.user_id = %s AND w.series_id IS NOT NULL
ORDER BY w.added_at DESC
""", (user_id,))
series = await cur.fetchall()
# Filme
await cur.execute("""
SELECT w.id as watchlist_id, w.added_at,
m.id, m.title, m.folder_name, m.poster_url,
m.year, m.genres, m.overview
FROM tv_watchlist w
JOIN library_movies m ON w.movie_id = m.id
WHERE w.user_id = %s AND w.movie_id IS NOT NULL
ORDER BY w.added_at DESC
""", (user_id,))
movies = await cur.fetchall()
return {"series": series, "movies": movies}
async def is_in_watchlist(self, user_id: int,
series_id: int = None,
movie_id: int = None) -> bool:
"""Prueft ob Serie/Film in der Merkliste ist"""
pool = await self._get_pool()
if not pool:
return False
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if series_id:
await cur.execute(
"SELECT 1 FROM tv_watchlist "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
else:
await cur.execute(
"SELECT 1 FROM tv_watchlist "
"WHERE user_id = %s AND movie_id = %s",
(user_id, movie_id))
return await cur.fetchone() is not None
# --- Watch-Status (manuell gesehen/nicht gesehen) ---
async def set_watch_status(self, user_id: int, status: str,
video_id: int = None,
series_id: int = None,
season_key: str = None) -> bool:
"""Setzt manuellen Watch-Status (unwatched/watching/watched)"""
if status not in ("unwatched", "watching", "watched"):
return False
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_watch_status
(user_id, video_id, series_id, season_key, status)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE status = VALUES(status)
""", (user_id, video_id, series_id, season_key, status))
# Bei Staffel/Serie auch Einzel-Episoden aktualisieren
if series_id and not video_id and not season_key:
# Ganze Serie markieren
await cur.execute("""
INSERT INTO tv_watch_status
(user_id, video_id, status)
SELECT %s, v.id, %s
FROM library_videos v
WHERE v.series_id = %s
ON DUPLICATE KEY UPDATE
status = VALUES(status)
""", (user_id, status, series_id))
elif season_key:
# Ganze Staffel markieren (format: "series_id:season")
parts = season_key.split(":")
if len(parts) == 2:
sid, sn = int(parts[0]), int(parts[1])
await cur.execute("""
INSERT INTO tv_watch_status
(user_id, video_id, status)
SELECT %s, v.id, %s
FROM library_videos v
WHERE v.series_id = %s
AND v.season_number = %s
ON DUPLICATE KEY UPDATE
status = VALUES(status)
""", (user_id, status, sid, sn))
return True
except Exception as e:
logging.error(f"TV-Auth: Watch-Status fehlgeschlagen: {e}")
return False
async def get_watch_status(self, user_id: int,
video_id: int = None,
series_id: int = None) -> Optional[str]:
"""Gibt Watch-Status zurueck"""
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if video_id:
await cur.execute(
"SELECT status FROM tv_watch_status "
"WHERE user_id = %s AND video_id = %s",
(user_id, video_id))
elif series_id:
await cur.execute(
"SELECT status FROM tv_watch_status "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
else:
return None
row = await cur.fetchone()
return row[0] if row else None
# --- Such-History ---
async def save_search(self, user_id: int, query: str) -> None:
"""Speichert Suchanfrage in der History"""
if not query or len(query) < 2:
return
pool = await self._get_pool()
if not pool:
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
# Duplikate vermeiden: gleiche Query aktualisieren
await cur.execute(
"DELETE FROM tv_search_history "
"WHERE user_id = %s AND query = %s",
(user_id, query))
await cur.execute(
"INSERT INTO tv_search_history (user_id, query) "
"VALUES (%s, %s)", (user_id, query))
# Max. 50 Eintraege behalten
await cur.execute("""
DELETE FROM tv_search_history
WHERE user_id = %s AND id NOT IN (
SELECT id FROM (
SELECT id FROM tv_search_history
WHERE user_id = %s
ORDER BY searched_at DESC LIMIT 50
) t
)
""", (user_id, user_id))
async def get_search_history(self, user_id: int,
limit: int = 20) -> list[str]:
"""Gibt letzte Suchanfragen zurueck"""
pool = await self._get_pool()
if not pool:
return []
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"SELECT query FROM tv_search_history "
"WHERE user_id = %s ORDER BY searched_at DESC LIMIT %s",
(user_id, limit))
rows = await cur.fetchall()
return [r[0] for r in rows]
async def get_search_suggestions(self, user_id: int,
prefix: str,
limit: int = 8) -> list[str]:
"""Autocomplete: Vorschlaege aus History + Serien/Film-Titel"""
if not prefix or len(prefix) < 1:
return []
pool = await self._get_pool()
if not pool:
return []
suggestions = []
search = f"{prefix}%"
async with pool.acquire() as conn:
async with conn.cursor() as cur:
# Aus Such-History
await cur.execute(
"SELECT DISTINCT query FROM tv_search_history "
"WHERE user_id = %s AND query LIKE %s "
"ORDER BY searched_at DESC LIMIT %s",
(user_id, search, limit))
rows = await cur.fetchall()
suggestions.extend(r[0] for r in rows)
# Aus Serien-Titeln
remaining = limit - len(suggestions)
if remaining > 0:
await cur.execute(
"SELECT title FROM library_series "
"WHERE title LIKE %s ORDER BY title LIMIT %s",
(search, remaining))
rows = await cur.fetchall()
for r in rows:
if r[0] not in suggestions:
suggestions.append(r[0])
# Aus Film-Titeln
remaining = limit - len(suggestions)
if remaining > 0:
await cur.execute(
"SELECT title FROM library_movies "
"WHERE title LIKE %s ORDER BY title LIMIT %s",
(search, remaining))
rows = await cur.fetchall()
for r in rows:
if r[0] not in suggestions:
suggestions.append(r[0])
return suggestions[:limit]
async def clear_search_history(self, user_id: int) -> bool:
"""Loescht alle Suchanfragen eines Users"""
pool = await self._get_pool()
if not pool:
return False
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM tv_search_history WHERE user_id = %s",
(user_id,))
return True
# --- Fortschritt zuruecksetzen ---
async def reset_all_progress(self, user_id: int) -> bool:
"""Setzt ALLE Fortschritte und Status eines Users zurueck"""
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM tv_watch_progress WHERE user_id = %s",
(user_id,))
await cur.execute(
"DELETE FROM tv_watch_status WHERE user_id = %s",
(user_id,))
return True
except Exception as e:
logging.error(f"TV-Auth: Reset fehlgeschlagen: {e}")
return False
# --- Watch-Progress --- # --- Watch-Progress ---
async def save_progress(self, user_id: int, video_id: int, async def save_progress(self, user_id: int, video_id: int,
@ -391,3 +1036,91 @@ class AuthService:
row["updated_at"], "isoformat"): row["updated_at"], "isoformat"):
row["updated_at"] = str(row["updated_at"]) row["updated_at"] = str(row["updated_at"])
return rows return rows
# --- Bewertungen (Ratings) ---
async def set_rating(self, user_id: int, rating: int,
series_id: int = None,
movie_id: int = None) -> bool:
"""Setzt User-Bewertung (1-5 Sterne). 0 = Bewertung loeschen."""
if rating < 0 or rating > 5:
return False
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if rating == 0:
# Bewertung loeschen
if series_id:
await cur.execute(
"DELETE FROM tv_ratings "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
elif movie_id:
await cur.execute(
"DELETE FROM tv_ratings "
"WHERE user_id = %s AND movie_id = %s",
(user_id, movie_id))
else:
await cur.execute("""
INSERT INTO tv_ratings
(user_id, series_id, movie_id, rating)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE rating = VALUES(rating)
""", (user_id, series_id, movie_id, rating))
return True
except Exception as e:
logging.error(f"TV-Auth: Rating fehlgeschlagen: {e}")
return False
async def get_rating(self, user_id: int,
series_id: int = None,
movie_id: int = None) -> int:
"""Gibt User-Rating zurueck (0 = keine Bewertung)"""
pool = await self._get_pool()
if not pool:
return 0
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if series_id:
await cur.execute(
"SELECT rating FROM tv_ratings "
"WHERE user_id = %s AND series_id = %s",
(user_id, series_id))
elif movie_id:
await cur.execute(
"SELECT rating FROM tv_ratings "
"WHERE user_id = %s AND movie_id = %s",
(user_id, movie_id))
else:
return 0
row = await cur.fetchone()
return row[0] if row else 0
async def get_avg_rating(self, series_id: int = None,
movie_id: int = None) -> dict:
"""Gibt Durchschnittsbewertung + Anzahl zurueck"""
pool = await self._get_pool()
if not pool:
return {"avg": 0, "count": 0}
async with pool.acquire() as conn:
async with conn.cursor() as cur:
if series_id:
await cur.execute(
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
"FROM tv_ratings WHERE series_id = %s AND rating > 0",
(series_id,))
elif movie_id:
await cur.execute(
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
"FROM tv_ratings WHERE movie_id = %s AND rating > 0",
(movie_id,))
else:
return {"avg": 0, "count": 0}
row = await cur.fetchone()
return {
"avg": round(float(row[0] or 0), 1),
"count": int(row[1] or 0),
}

View file

@ -0,0 +1,101 @@
"""Internationalisierung (i18n) fuer die TV-App.
Laedt Uebersetzungen aus JSON-Dateien und stellt Jinja2-Filter bereit."""
import json
import logging
import os
from typing import Optional
# Verfuegbare Sprachen
SUPPORTED_LANGS = ("de", "en")
DEFAULT_LANG = "de"
# Cache fuer geladene Uebersetzungen
_translations: dict[str, dict] = {}
def load_translations(static_dir: str) -> None:
"""Laedt alle Uebersetzungsdateien aus static/tv/i18n/"""
i18n_dir = os.path.join(static_dir, "tv", "i18n")
for lang in SUPPORTED_LANGS:
filepath = os.path.join(i18n_dir, f"{lang}.json")
if os.path.isfile(filepath):
with open(filepath, "r", encoding="utf-8") as f:
_translations[lang] = json.load(f)
logging.info(f"i18n: Sprache '{lang}' geladen ({filepath})")
else:
logging.warning(f"i18n: Datei nicht gefunden: {filepath}")
if not _translations:
logging.error("i18n: Keine Uebersetzungen geladen!")
def get_text(key: str, lang: str = DEFAULT_LANG, **kwargs) -> str:
"""Gibt uebersetzten Text fuer einen Punkt-separierten Schluessel zurueck.
Beispiel: get_text('nav.home', 'de') -> 'Startseite'
Platzhalter: get_text('player.next_in', 'de', seconds=10)"""
translations = _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
parts = key.split(".")
value = translations
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
value = None
break
if value is None:
# Fallback auf Default-Sprache
if lang != DEFAULT_LANG:
return get_text(key, DEFAULT_LANG, **kwargs)
# Key als Fallback zurueckgeben
return key
if not isinstance(value, str):
return key
# Platzhalter ersetzen
if kwargs:
for k, v in kwargs.items():
value = value.replace(f"{{{k}}}", str(v))
return value
def get_all_translations(lang: str = DEFAULT_LANG) -> dict:
"""Gibt alle Uebersetzungen fuer eine Sprache zurueck (fuer JS)"""
return _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
def setup_jinja2_i18n(app) -> None:
"""Registriert i18n-Filter und Globals in Jinja2-Environment.
Muss NACH aiohttp_jinja2.setup() aufgerufen werden."""
import aiohttp_jinja2
env = aiohttp_jinja2.get_env(app)
# Filter: {{ 'nav.home'|t }} oder {{ 'nav.home'|t('en') }}
def t_filter(key: str, lang: str = None) -> str:
# Sprache wird pro Request gesetzt (siehe Middleware)
if lang is None:
lang = getattr(env, "_current_lang", DEFAULT_LANG)
return get_text(key, lang)
env.filters["t"] = t_filter
# Global-Funktion: {{ t('nav.home') }} oder {{ t('nav.home', seconds=10) }}
def t_func(key: str, lang: str = None, **kwargs) -> str:
if lang is None:
lang = getattr(env, "_current_lang", DEFAULT_LANG)
return get_text(key, lang, **kwargs)
env.globals["t"] = t_func
env.globals["SUPPORTED_LANGS"] = SUPPORTED_LANGS
logging.info("i18n: Jinja2-Filter und Globals registriert")
def set_request_lang(app, lang: str) -> None:
"""Setzt die Sprache fuer den aktuellen Request.
Wird vom TV-Auth-Middleware aufgerufen."""
import aiohttp_jinja2
env = aiohttp_jinja2.get_env(app)
env._current_lang = lang if lang in SUPPORTED_LANGS else DEFAULT_LANG

View file

@ -317,29 +317,55 @@ class QueueService:
await self.ws_manager.broadcast_queue_update() await self.ws_manager.broadcast_queue_update()
async def _post_conversion_cleanup(self, job: ConversionJob) -> None: async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
"""Cleanup nach erfolgreicher Konvertierung""" """Cleanup nach erfolgreicher Konvertierung.
WICHTIG: Nur die Quelldatei dieses Jobs loeschen, NICHT
andere Dateien im Ordner die noch in der Queue warten!"""
files_cfg = self.config.files_config files_cfg = self.config.files_config
# Quelldatei loeschen: Global per Config ODER per Job-Option # Quelldatei loeschen: Global per Config ODER per Job-Option
should_delete = files_cfg.get("delete_source", False) or job.delete_source should_delete = files_cfg.get("delete_source", False) or \
job.delete_source
if should_delete: if should_delete:
target_exists = os.path.exists(job.target_path) target_exists = os.path.exists(job.target_path)
target_size = os.path.getsize(job.target_path) if target_exists else 0 target_size = (os.path.getsize(job.target_path)
if target_exists else 0)
if target_exists and target_size > 0: if target_exists and target_size > 0:
try: try:
os.remove(job.media.source_path) os.remove(job.media.source_path)
logging.info(f"Quelldatei geloescht: {job.media.source_path}") logging.info(
f"Quelldatei geloescht: {job.media.source_path}")
except OSError as e: except OSError as e:
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}") logging.error(
f"Quelldatei loeschen fehlgeschlagen: {e}")
else:
logging.warning(
f"Quelldatei NICHT geloescht "
f"(Zieldatei fehlt/leer): "
f"{job.media.source_path}")
# SICHERHEIT: Ordner-Cleanup nur wenn KEINE weiteren
# Jobs aus diesem Ordner in der Queue warten!
cleanup_cfg = self.config.cleanup_config cleanup_cfg = self.config.cleanup_config
if cleanup_cfg.get("enabled", False): if cleanup_cfg.get("enabled", False):
deleted = self.scanner.cleanup_directory(job.media.source_dir) source_dir = job.media.source_dir
if deleted: pending = [
j for j in self.jobs.values()
if j.media.source_dir == source_dir
and j.status in (JobStatus.QUEUED, JobStatus.ACTIVE)
and j.id != job.id
]
if pending:
logging.info( logging.info(
f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}" f"Ordner-Cleanup uebersprungen "
) f"({len(pending)} Jobs wartend): {source_dir}")
else:
deleted = self.scanner.cleanup_directory(source_dir)
if deleted:
logging.info(
f"{len(deleted)} Dateien bereinigt "
f"in {source_dir}"
)
def _get_next_queued(self) -> Optional[ConversionJob]: def _get_next_queued(self) -> Optional[ConversionJob]:
"""Naechster Job mit Status QUEUED (FIFO)""" """Naechster Job mit Status QUEUED (FIFO)"""

View file

@ -801,12 +801,21 @@ class TVDBService:
ep_aired = getattr(ep, "aired", None) ep_aired = getattr(ep, "aired", None)
ep_runtime = getattr(ep, "runtime", None) ep_runtime = getattr(ep, "runtime", None)
if s_num and s_num > 0 and e_num and e_num > 0: if s_num and s_num > 0 and e_num and e_num > 0:
# Beschreibung und Bild-URL
if isinstance(ep, dict):
ep_overview = ep.get("overview", "")
ep_image = ep.get("image", "")
else:
ep_overview = getattr(ep, "overview", "")
ep_image = getattr(ep, "image", "")
episodes.append({ episodes.append({
"season_number": s_num, "season_number": s_num,
"episode_number": e_num, "episode_number": e_num,
"episode_name": ep_name or "", "episode_name": ep_name or "",
"aired": ep_aired, "aired": ep_aired,
"runtime": ep_runtime, "runtime": ep_runtime,
"overview": ep_overview or "",
"image_url": ep_image or "",
}) })
page += 1 page += 1
if page > 50: if page > 50:
@ -840,12 +849,15 @@ class TVDBService:
await cur.execute( await cur.execute(
"INSERT INTO tvdb_episode_cache " "INSERT INTO tvdb_episode_cache "
"(series_tvdb_id, season_number, episode_number, " "(series_tvdb_id, season_number, episode_number, "
"episode_name, aired, runtime) " "episode_name, aired, runtime, overview, "
"VALUES (%s, %s, %s, %s, %s, %s)", "image_url) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
( (
tvdb_id, ep["season_number"], tvdb_id, ep["season_number"],
ep["episode_number"], ep["episode_name"], ep["episode_number"], ep["episode_name"],
ep["aired"], ep["runtime"], ep["aired"], ep["runtime"],
ep.get("overview", ""),
ep.get("image_url", ""),
) )
) )
except Exception as e: except Exception as e:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
{
"nav": {
"home": "Startseite",
"series": "Serien",
"movies": "Filme",
"search": "Suche",
"watchlist": "Merkliste",
"settings": "Einstellungen",
"profiles": "Profile",
"logout": "Abmelden"
},
"home": {
"continue_watching": "Weiterschauen",
"my_series": "Meine Serien",
"my_movies": "Meine Filme",
"recently_added": "Neu hinzugefügt",
"watchlist": "Meine Merkliste"
},
"series": {
"title": "Serien",
"all": "Alle",
"episodes": "Episoden",
"season": "Staffel",
"specials": "Specials",
"no_episodes": "Keine Episoden vorhanden.",
"no_series": "Keine Serien vorhanden.",
"episode_short": "E",
"min": "Min",
"watchlist": "Merkliste"
},
"movies": {
"title": "Filme",
"all": "Alle",
"no_movies": "Keine Filme vorhanden.",
"versions": "Versionen",
"version": "Version"
},
"player": {
"back": "Zurück",
"play": "Abspielen",
"pause": "Pause",
"fullscreen": "Vollbild",
"next_episode": "Nächste Episode",
"next_in": "Nächste Episode in {seconds}s",
"skip": "Jetzt abspielen",
"cancel": "Abbrechen",
"still_watching": "Schaust du noch?",
"continue": "Weiter",
"stop": "Aufhören",
"audio": "Audio",
"subtitles": "Untertitel",
"subtitles_off": "Aus",
"quality": "Qualität",
"quality_uhd": "Ultra HD",
"quality_hd": "HD",
"quality_sd": "SD",
"quality_low": "Niedrig",
"speed": "Geschwindigkeit",
"settings": "Einstellungen"
},
"search": {
"title": "Suche",
"placeholder": "Serien oder Filme suchen...",
"button": "Suchen",
"no_results": "Keine Ergebnisse für \"{query}\".",
"min_chars": "Mindestens 2 Zeichen eingeben.",
"history": "Letzte Suchen",
"clear_history": "Verlauf löschen",
"results_series": "Serien",
"results_movies": "Filme"
},
"watchlist": {
"title": "Merkliste",
"empty": "Deine Merkliste ist leer.",
"add": "Zur Merkliste",
"remove": "Von Merkliste entfernen",
"added": "Gemerkt",
"series": "Serien",
"movies": "Filme"
},
"status": {
"unwatched": "Nicht gesehen",
"watching": "Angefangen",
"watched": "Gesehen",
"mark_watched": "Als gesehen markieren",
"mark_unwatched": "Als nicht gesehen markieren",
"mark_season": "Staffel als gesehen",
"mark_series": "Serie als gesehen",
"reset_progress": "Fortschritt zurücksetzen"
},
"settings": {
"title": "Einstellungen",
"user_settings": "Benutzer-Einstellungen",
"client_settings": "Geräte-Einstellungen",
"profile": "Profil",
"display_name": "Anzeigename",
"avatar_color": "Profilfarbe",
"language": "Sprache",
"menu_language": "Menüsprache",
"audio_language": "Audio-Sprache",
"subtitle_language": "Untertitel-Sprache",
"subtitles_enabled": "Untertitel aktiviert",
"theme": "Design",
"theme_dark": "Dunkel",
"theme_medium": "Mittel",
"theme_light": "Hell",
"views": "Ansichten & Design",
"series_view": "Serien-Ansicht",
"movies_view": "Film-Ansicht",
"view_grid": "Raster",
"view_list": "Liste",
"view_detail": "Detail",
"autoplay": "Automatische Wiedergabe",
"autoplay_enabled": "Nächste Episode automatisch abspielen",
"autoplay_countdown": "Countdown-Dauer",
"autoplay_max": "Max. Folgen am Stück",
"autoplay_max_desc": "0 = unbegrenzt",
"seconds": "Sekunden",
"save": "Speichern",
"saved": "Gespeichert!",
"reset_all": "Alle Fortschritte zurücksetzen",
"reset_confirm": "Wirklich ALLE Fortschritte und Status zurücksetzen? Das kann nicht rückgängig gemacht werden!",
"clear_search": "Suchverlauf löschen",
"device_name": "Gerätename",
"sound_mode": "Sound-Modus",
"sound_stereo": "Stereo",
"sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original",
"stream_quality": "Standard-Qualität",
"on": "An",
"off": "Aus"
},
"profiles": {
"title": "Wer schaut?",
"switch": "Profil wechseln",
"add_user": "Anderer Benutzer",
"manage": "Profile verwalten"
},
"login": {
"title": "VideoKonverter",
"subtitle": "TV-App",
"username": "Benutzername",
"password": "Passwort",
"login": "Anmelden",
"remember": "Angemeldet bleiben",
"error": "Benutzername oder Passwort falsch."
},
"rating": {
"title": "Bewertung",
"your_rating": "Deine Bewertung",
"avg_rating": "Durchschnitt",
"tvdb_score": "TVDB-Score",
"rate": "Bewerten",
"remove": "Bewertung entfernen",
"stars": "{n} Sterne",
"ratings": "{n} Bewertungen",
"no_ratings": "Noch keine Bewertungen",
"filter_min": "Ab {n} Sterne",
"sort_rating": "Bewertung"
},
"filter": {
"all": "Alle",
"sort": "Sortierung",
"sort_title": "Name (A-Z)",
"sort_title_desc": "Name (Z-A)",
"sort_newest": "Neueste zuerst",
"sort_episodes": "Episoden-Anzahl",
"sort_last_watched": "Zuletzt angesehen",
"sort_rating": "Bewertung",
"genres": "Genres",
"min_rating": "Min. Sterne"
},
"common": {
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schließen",
"loading": "Laden...",
"error": "Fehler",
"no_connection": "Keine Verbindung zum Server.",
"unknown": "Unbekannt"
},
"lang": {
"deu": "Deutsch",
"eng": "Englisch",
"fra": "Französisch",
"spa": "Spanisch",
"ita": "Italienisch",
"jpn": "Japanisch",
"kor": "Koreanisch",
"por": "Portugiesisch",
"rus": "Russisch",
"zho": "Chinesisch",
"und": "Unbekannt"
}
}

View file

@ -0,0 +1,197 @@
{
"nav": {
"home": "Home",
"series": "Series",
"movies": "Movies",
"search": "Search",
"watchlist": "Watchlist",
"settings": "Settings",
"profiles": "Profiles",
"logout": "Logout"
},
"home": {
"continue_watching": "Continue Watching",
"my_series": "My Series",
"my_movies": "My Movies",
"recently_added": "Recently Added",
"watchlist": "My Watchlist"
},
"series": {
"title": "Series",
"all": "All",
"episodes": "Episodes",
"season": "Season",
"specials": "Specials",
"no_episodes": "No episodes available.",
"no_series": "No series available.",
"episode_short": "E",
"min": "min",
"watchlist": "Watchlist"
},
"movies": {
"title": "Movies",
"all": "All",
"no_movies": "No movies available.",
"versions": "Versions",
"version": "Version"
},
"player": {
"back": "Back",
"play": "Play",
"pause": "Pause",
"fullscreen": "Fullscreen",
"next_episode": "Next Episode",
"next_in": "Next episode in {seconds}s",
"skip": "Play Now",
"cancel": "Cancel",
"still_watching": "Are you still watching?",
"continue": "Continue",
"stop": "Stop",
"audio": "Audio",
"subtitles": "Subtitles",
"subtitles_off": "Off",
"quality": "Quality",
"quality_uhd": "Ultra HD",
"quality_hd": "HD",
"quality_sd": "SD",
"quality_low": "Low",
"speed": "Speed",
"settings": "Settings"
},
"search": {
"title": "Search",
"placeholder": "Search series or movies...",
"button": "Search",
"no_results": "No results for \"{query}\".",
"min_chars": "Enter at least 2 characters.",
"history": "Recent Searches",
"clear_history": "Clear History",
"results_series": "Series",
"results_movies": "Movies"
},
"watchlist": {
"title": "Watchlist",
"empty": "Your watchlist is empty.",
"add": "Add to Watchlist",
"remove": "Remove from Watchlist",
"added": "Added",
"series": "Series",
"movies": "Movies"
},
"status": {
"unwatched": "Unwatched",
"watching": "Watching",
"watched": "Watched",
"mark_watched": "Mark as watched",
"mark_unwatched": "Mark as unwatched",
"mark_season": "Mark season as watched",
"mark_series": "Mark series as watched",
"reset_progress": "Reset progress"
},
"settings": {
"title": "Settings",
"user_settings": "User Settings",
"client_settings": "Device Settings",
"profile": "Profile",
"display_name": "Display Name",
"avatar_color": "Profile Color",
"language": "Language",
"menu_language": "Menu Language",
"audio_language": "Audio Language",
"subtitle_language": "Subtitle Language",
"subtitles_enabled": "Subtitles enabled",
"theme": "Theme",
"theme_dark": "Dark",
"theme_medium": "Medium",
"theme_light": "Light",
"views": "Views & Theme",
"series_view": "Series View",
"movies_view": "Movies View",
"view_grid": "Grid",
"view_list": "List",
"view_detail": "Detail",
"autoplay": "Autoplay",
"autoplay_enabled": "Auto-play next episode",
"autoplay_countdown": "Countdown Duration",
"autoplay_max": "Max. consecutive episodes",
"autoplay_max_desc": "0 = unlimited",
"seconds": "seconds",
"save": "Save",
"saved": "Saved!",
"reset_all": "Reset All Progress",
"reset_confirm": "Really reset ALL progress and watch status? This cannot be undone!",
"clear_search": "Clear search history",
"device_name": "Device Name",
"sound_mode": "Sound Mode",
"sound_stereo": "Stereo",
"sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original",
"stream_quality": "Default Quality",
"on": "On",
"off": "Off"
},
"profiles": {
"title": "Who's watching?",
"switch": "Switch Profile",
"add_user": "Other User",
"manage": "Manage Profiles"
},
"login": {
"title": "VideoKonverter",
"subtitle": "TV App",
"username": "Username",
"password": "Password",
"login": "Sign In",
"remember": "Keep me signed in",
"error": "Invalid username or password."
},
"rating": {
"title": "Rating",
"your_rating": "Your Rating",
"avg_rating": "Average",
"tvdb_score": "TVDB Score",
"rate": "Rate",
"remove": "Remove Rating",
"stars": "{n} Stars",
"ratings": "{n} Ratings",
"no_ratings": "No ratings yet",
"filter_min": "Min. {n} Stars",
"sort_rating": "Rating"
},
"filter": {
"all": "All",
"sort": "Sort",
"sort_title": "Name (A-Z)",
"sort_title_desc": "Name (Z-A)",
"sort_newest": "Newest First",
"sort_episodes": "Episode Count",
"sort_last_watched": "Last Watched",
"sort_rating": "Rating",
"genres": "Genres",
"min_rating": "Min. Stars"
},
"common": {
"yes": "Yes",
"no": "No",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"loading": "Loading...",
"error": "Error",
"no_connection": "No connection to server.",
"unknown": "Unknown"
},
"lang": {
"deu": "German",
"eng": "English",
"fra": "French",
"spa": "Spanish",
"ita": "Italian",
"jpn": "Japanese",
"kor": "Korean",
"por": "Portuguese",
"rus": "Russian",
"zho": "Chinese",
"und": "Unknown"
}
}

View file

@ -1,28 +1,35 @@
/** /**
* VideoKonverter TV - Video-Player * VideoKonverter TV - Video-Player v4.0
* Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung * Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
* Speichert Watch-Progress automatisch * Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
*/ */
// === State ===
let videoEl = null; let videoEl = null;
let videoId = 0; let cfg = {}; // Konfiguration aus initPlayer()
let videoDuration = 0; let videoInfo = null; // Audio/Subtitle-Tracks vom Server
let currentAudio = 0;
let currentSub = -1; // -1 = aus
let currentQuality = "hd";
let currentSpeed = 1.0;
let progressBar = null; let progressBar = null;
let timeDisplay = null; let timeDisplay = null;
let playBtn = null; let playBtn = null;
let controlsTimer = null; let controlsTimer = null;
let saveTimer = null; let saveTimer = null;
let controlsVisible = true; let controlsVisible = true;
let overlayOpen = false;
let nextCountdown = null;
let episodesWatched = 0;
let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming
/** /**
* Player initialisieren * Player initialisieren
* @param {number} id - Video-ID * @param {Object} opts - Konfiguration
* @param {number} startPos - Startposition in Sekunden
* @param {number} duration - Video-Dauer in Sekunden (Fallback)
*/ */
function initPlayer(id, startPos, duration) { function initPlayer(opts) {
videoId = id; cfg = opts;
videoDuration = duration; currentQuality = opts.streamQuality || "hd";
videoEl = document.getElementById("player-video"); videoEl = document.getElementById("player-video");
progressBar = document.getElementById("player-progress-bar"); progressBar = document.getElementById("player-progress-bar");
@ -31,10 +38,11 @@ function initPlayer(id, startPos, duration) {
if (!videoEl) return; if (!videoEl) return;
// Stream-URL setzen (ffmpeg-Transcoding Endpoint) // Video-Info laden (Audio/Subtitle-Tracks)
const streamUrl = `/api/library/videos/${id}/stream` + loadVideoInfo().then(() => {
(startPos > 0 ? `?t=${Math.floor(startPos)}` : ""); // Stream starten
videoEl.src = streamUrl; setStreamUrl(opts.startPos || 0);
});
// Events // Events
videoEl.addEventListener("timeupdate", onTimeUpdate); videoEl.addEventListener("timeupdate", onTimeUpdate);
@ -43,78 +51,152 @@ function initPlayer(id, startPos, duration) {
videoEl.addEventListener("ended", onEnded); videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("loadedmetadata", () => { videoEl.addEventListener("loadedmetadata", () => {
if (videoEl.duration && isFinite(videoEl.duration)) { if (videoEl.duration && isFinite(videoEl.duration)) {
videoDuration = videoEl.duration; cfg.duration = videoEl.duration + seekOffset;
} }
}); });
// Klick auf Video -> Play/Pause
videoEl.addEventListener("click", togglePlay); videoEl.addEventListener("click", togglePlay);
// Controls UI // Controls UI
playBtn.addEventListener("click", togglePlay); playBtn.addEventListener("click", togglePlay);
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen); document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
// Progress-Bar klickbar fuer Seeking
document.getElementById("player-progress").addEventListener("click", onProgressClick); document.getElementById("player-progress").addEventListener("click", onProgressClick);
// Einstellungen-Button
const btnSettings = document.getElementById("btn-settings");
if (btnSettings) btnSettings.addEventListener("click", toggleOverlay);
// Naechste-Episode-Button
const btnNext = document.getElementById("btn-next");
if (btnNext) btnNext.addEventListener("click", playNextEpisode);
// Naechste-Episode Overlay Buttons
const btnNextPlay = document.getElementById("btn-next-play");
if (btnNextPlay) btnNextPlay.addEventListener("click", playNextEpisode);
const btnNextCancel = document.getElementById("btn-next-cancel");
if (btnNextCancel) btnNextCancel.addEventListener("click", cancelNext);
// Schaust du noch?
const btnStillYes = document.getElementById("btn-still-yes");
if (btnStillYes) btnStillYes.addEventListener("click", () => {
document.getElementById("still-watching-overlay").style.display = "none";
episodesWatched = 0;
videoEl.play();
});
const btnStillNo = document.getElementById("btn-still-no");
if (btnStillNo) btnStillNo.addEventListener("click", () => {
saveProgress();
window.history.back();
});
// Tastatur-Steuerung // Tastatur-Steuerung
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
// Maus/Touch-Bewegung -> Controls anzeigen
document.addEventListener("mousemove", showControls); document.addEventListener("mousemove", showControls);
document.addEventListener("touchstart", showControls); document.addEventListener("touchstart", showControls);
// Controls nach 4 Sekunden ausblenden
scheduleHideControls(); scheduleHideControls();
// Watch-Progress alle 10 Sekunden speichern
saveTimer = setInterval(saveProgress, 10000); saveTimer = setInterval(saveProgress, 10000);
} }
// === Video-Info laden ===
async function loadVideoInfo() {
try {
const resp = await fetch(`/api/library/videos/${cfg.videoId}/info`);
videoInfo = await resp.json();
// Bevorzugte Audio-Spur finden
if (videoInfo.audio_tracks) {
const prefIdx = videoInfo.audio_tracks.findIndex(
a => a.lang === cfg.preferredAudio);
if (prefIdx >= 0) currentAudio = prefIdx;
}
// Bevorzugte Untertitel-Spur finden
if (cfg.subtitlesEnabled && cfg.preferredSub && videoInfo.subtitle_tracks) {
const subIdx = videoInfo.subtitle_tracks.findIndex(
s => s.lang === cfg.preferredSub);
if (subIdx >= 0) currentSub = subIdx;
}
// Untertitel-Tracks als <track> hinzufuegen
if (videoInfo.subtitle_tracks) {
videoInfo.subtitle_tracks.forEach((sub, i) => {
const track = document.createElement("track");
track.kind = "subtitles";
track.src = `/api/library/videos/${cfg.videoId}/subtitles/${i}`;
track.srclang = sub.lang || "und";
track.label = langName(sub.lang) || `Spur ${i + 1}`;
if (i === currentSub) track.default = true;
videoEl.appendChild(track);
});
// Aktiven Track setzen
updateSubtitleTrack();
}
} catch (e) {
console.warn("Video-Info laden fehlgeschlagen:", e);
}
}
// === Stream-URL ===
function setStreamUrl(seekSec) {
seekOffset = seekSec || 0;
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();
}
// === Playback-Controls === // === Playback-Controls ===
function togglePlay() { function togglePlay() {
if (!videoEl) return; if (!videoEl) return;
if (videoEl.paused) { if (videoEl.paused) videoEl.play();
videoEl.play(); else videoEl.pause();
} else {
videoEl.pause();
}
} }
function onPlay() { function onPlay() {
if (playBtn) playBtn.innerHTML = "&#10074;&#10074;"; // Pause-Symbol if (playBtn) playBtn.innerHTML = "&#10074;&#10074;";
scheduleHideControls(); scheduleHideControls();
} }
function onPause() { function onPause() {
if (playBtn) playBtn.innerHTML = "&#9654;"; // Play-Symbol if (playBtn) playBtn.innerHTML = "&#9654;";
showControls(); showControls();
// Sofort speichern bei Pause
saveProgress(); saveProgress();
} }
function onEnded() { function onEnded() {
// Video fertig -> als "completed" speichern
saveProgress(true); saveProgress(true);
// Zurueck navigieren nach 2 Sekunden episodesWatched++;
setTimeout(() => {
window.history.back(); // Schaust du noch? (wenn Max-Episoden erreicht)
}, 2000); if (cfg.autoplayMax > 0 && episodesWatched >= cfg.autoplayMax) {
document.getElementById("still-watching-overlay").style.display = "";
return;
}
// Naechste Episode
if (cfg.nextVideoId && cfg.autoplay) {
showNextEpisodeOverlay();
} else {
setTimeout(() => window.history.back(), 2000);
}
} }
// === Seeking === // === Seeking ===
function seekRelative(seconds) { function seekRelative(seconds) {
if (!videoEl) return; if (!videoEl) return;
const newTime = Math.max(0, Math.min( const totalTime = seekOffset + videoEl.currentTime;
videoEl.currentTime + seconds, const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
videoEl.duration || videoDuration const newTime = Math.max(0, Math.min(totalTime + seconds, dur));
)); setStreamUrl(newTime);
// Neue Stream-URL mit Zeitstempel
const wasPlaying = !videoEl.paused;
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
if (wasPlaying) videoEl.play();
showControls(); showControls();
} }
@ -122,29 +204,22 @@ function onProgressClick(e) {
if (!videoEl) return; if (!videoEl) return;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const dur = videoEl.duration || videoDuration; const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
if (!dur) return; if (!dur) return;
const newTime = pct * dur; setStreamUrl(pct * dur);
// Neue Stream-URL mit Zeitstempel
const wasPlaying = !videoEl.paused;
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
if (wasPlaying) videoEl.play();
showControls(); showControls();
} }
// === Zeit-Anzeige und Progress === // === Zeit-Anzeige ===
function onTimeUpdate() { function onTimeUpdate() {
if (!videoEl) return; if (!videoEl) return;
const current = videoEl.currentTime; const current = seekOffset + videoEl.currentTime;
const dur = videoEl.duration || videoDuration; const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
// Progress-Bar
if (progressBar && dur > 0) { if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%"; progressBar.style.width = ((current / dur) * 100) + "%";
} }
// Zeit-Anzeige
if (timeDisplay) { if (timeDisplay) {
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur); timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
} }
@ -155,9 +230,7 @@ function formatTime(sec) {
const h = Math.floor(sec / 3600); const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60); const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60); const s = Math.floor(sec % 60);
if (h > 0) { if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
return m + ":" + String(s).padStart(2, "0"); return m + ":" + String(s).padStart(2, "0");
} }
@ -171,7 +244,7 @@ function showControls() {
} }
function hideControls() { function hideControls() {
if (!videoEl || videoEl.paused) return; if (!videoEl || videoEl.paused || overlayOpen) return;
const wrapper = document.getElementById("player-wrapper"); const wrapper = document.getElementById("player-wrapper");
if (wrapper) wrapper.classList.add("player-hide-controls"); if (wrapper) wrapper.classList.add("player-hide-controls");
controlsVisible = false; controlsVisible = false;
@ -193,84 +266,220 @@ function toggleFullscreen() {
} }
} }
// === Einstellungen-Overlay ===
function toggleOverlay() {
const overlay = document.getElementById("player-overlay");
if (!overlay) return;
overlayOpen = !overlayOpen;
overlay.style.display = overlayOpen ? "" : "none";
if (overlayOpen) {
renderOverlay();
showControls();
}
}
function renderOverlay() {
// Audio-Spuren
const audioEl = document.getElementById("overlay-audio");
if (audioEl && videoInfo && videoInfo.audio_tracks) {
let html = "<h3>Audio</h3>";
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>`;
});
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;
}
}
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();
}
function switchSub(idx) {
currentSub = idx;
updateSubtitleTrack();
renderOverlay();
}
function updateSubtitleTrack() {
if (!videoEl || !videoEl.textTracks) return;
for (let i = 0; i < videoEl.textTracks.length; i++) {
videoEl.textTracks[i].mode = (i === currentSub) ? "showing" : "hidden";
}
}
function switchQuality(q) {
if (q === currentQuality) return;
currentQuality = q;
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
setStreamUrl(currentTime);
renderOverlay();
}
function switchSpeed(s) {
currentSpeed = s;
if (videoEl) videoEl.playbackRate = s;
renderOverlay();
}
// === Naechste Episode ===
function showNextEpisodeOverlay() {
const overlay = document.getElementById("next-overlay");
if (!overlay) return;
overlay.style.display = "";
let remaining = cfg.autoplayCountdown || 10;
const countdownEl = document.getElementById("next-countdown");
nextCountdown = setInterval(() => {
remaining--;
if (countdownEl) countdownEl.textContent = remaining + "s";
if (remaining <= 0) {
clearInterval(nextCountdown);
playNextEpisode();
}
}, 1000);
if (countdownEl) countdownEl.textContent = remaining + "s";
}
function playNextEpisode() {
if (nextCountdown) clearInterval(nextCountdown);
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
}
function cancelNext() {
if (nextCountdown) clearInterval(nextCountdown);
const overlay = document.getElementById("next-overlay");
if (overlay) overlay.style.display = "none";
setTimeout(() => window.history.back(), 500);
}
// === Tastatur-Steuerung === // === Tastatur-Steuerung ===
function onKeyDown(e) { function onKeyDown(e) {
// Samsung Tizen Remote Keys // Samsung Tizen Remote Keys
const keyMap = { const keyMap = {
10009: "Escape", 10009: "Escape", 10182: "Escape",
10182: "Escape", 415: "Play", 19: "Pause", 413: "Stop",
415: "Play", 417: "FastForward", 412: "Rewind",
19: "Pause",
413: "Stop",
417: "FastForward",
412: "Rewind",
}; };
const key = keyMap[e.keyCode] || e.key; const key = keyMap[e.keyCode] || e.key;
// Overlay offen? -> Navigation im Overlay
if (overlayOpen && (key === "Escape" || key === "Backspace")) {
toggleOverlay();
e.preventDefault();
return;
}
switch (key) { switch (key) {
case " ": case " ": case "Enter": case "Play": case "Pause":
case "Enter": togglePlay(); e.preventDefault(); break;
case "Play": case "ArrowLeft": case "Rewind":
case "Pause": seekRelative(-10); e.preventDefault(); break;
togglePlay(); case "ArrowRight": case "FastForward":
e.preventDefault(); seekRelative(10); e.preventDefault(); break;
break;
case "ArrowLeft":
case "Rewind":
seekRelative(-10);
e.preventDefault();
break;
case "ArrowRight":
case "FastForward":
seekRelative(10);
e.preventDefault();
break;
case "ArrowUp": case "ArrowUp":
// Lautstaerke hoch (falls vom Browser unterstuetzt)
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1); if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
showControls(); showControls(); e.preventDefault(); break;
e.preventDefault();
break;
case "ArrowDown": case "ArrowDown":
// Lautstaerke runter
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1); if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
showControls(); showControls(); e.preventDefault(); break;
e.preventDefault(); case "Escape": case "Backspace": case "Stop":
break;
case "Escape":
case "Backspace":
case "Stop":
// Zurueck navigieren
saveProgress(); saveProgress();
setTimeout(() => window.history.back(), 100); setTimeout(() => window.history.back(), 100);
e.preventDefault(); e.preventDefault(); break;
break;
case "f": case "f":
toggleFullscreen(); toggleFullscreen(); e.preventDefault(); break;
e.preventDefault(); case "s":
break; toggleOverlay(); e.preventDefault(); break;
case "n":
if (cfg.nextVideoId) playNextEpisode();
e.preventDefault(); break;
} }
} }
// === Watch-Progress speichern === // === Watch-Progress speichern ===
function saveProgress(completed) { function saveProgress(completed) {
if (!videoId || !videoEl) return; if (!cfg.videoId || !videoEl) return;
const pos = videoEl.currentTime || 0; const pos = seekOffset + (videoEl.currentTime || 0);
const dur = videoEl.duration || videoDuration || 0; const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern if (pos < 5 && !completed) return;
fetch("/tv/api/watch-progress", { fetch("/tv/api/watch-progress", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
video_id: videoId, video_id: cfg.videoId,
position_sec: pos, position_sec: pos,
duration_sec: dur, duration_sec: dur,
}), }),
}).catch(() => {}); // Fehler ignorieren (nicht kritisch) }).catch(() => {});
} }
// Beim Verlassen der Seite speichern
window.addEventListener("beforeunload", () => saveProgress()); window.addEventListener("beforeunload", () => saveProgress());
// === Hilfsfunktionen ===
const LANG_NAMES = {
deu: "Deutsch", eng: "English", fra: "Fran\u00e7ais",
spa: "Espa\u00f1ol", ita: "Italiano", jpn: "\u65e5\u672c\u8a9e",
kor: "\ud55c\uad6d\uc5b4", por: "Portugu\u00eas",
rus: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
zho: "\u4e2d\u6587", und: "Unbekannt",
};
function langName(code) {
return LANG_NAMES[code] || code || "";
}

View file

@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="{{ user.ui_lang if user is defined and user else 'de' }}"
data-theme="{{ user.theme if user is defined and user and user.theme else 'dark' }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
@ -17,18 +18,24 @@
{% if user is defined and user %} {% if user is defined and user %}
<nav class="tv-nav" id="tv-nav"> <nav class="tv-nav" id="tv-nav">
<div class="tv-nav-links"> <div class="tv-nav-links">
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>Startseite</a> <a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>{{ t('nav.home') }}</a>
{% if user.can_view_series %} {% if user.can_view_series %}
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>Serien</a> <a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>{{ t('nav.series') }}</a>
{% endif %} {% endif %}
{% if user.can_view_movies %} {% if user.can_view_movies %}
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>Filme</a> <a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>{{ t('nav.movies') }}</a>
{% endif %} {% endif %}
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>Suche</a> <a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>{{ t('nav.search') }}</a>
<a href="/tv/watchlist" class="tv-nav-item {% if active == 'watchlist' %}active{% endif %}" data-focusable>{{ t('nav.watchlist') }}</a>
</div> </div>
<div class="tv-nav-right"> <div class="tv-nav-right">
<span class="tv-nav-user">{{ user.display_name or user.username }}</span> <a href="/tv/profiles" class="tv-nav-profile" data-focusable title="{{ t('profiles.switch') }}">
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>Abmelden</a> <span class="tv-avatar" style="background:{{ user.avatar_color or '#64b5f6' }}">
{{ (user.display_name or user.username)[:1]|upper }}
</span>
</a>
<a href="/tv/settings" class="tv-nav-item {% if active == 'settings' %}active{% endif %}" data-focusable>&#9881;</a>
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>{{ t('nav.logout') }}</a>
</div> </div>
</nav> </nav>
{% endif %} {% endif %}

View file

@ -30,6 +30,12 @@
autocomplete="current-password" autocomplete="current-password"
data-focusable required> data-focusable required>
</div> </div>
<div class="login-field login-remember">
<label class="settings-check">
<input type="checkbox" name="remember" data-focusable>
Angemeldet bleiben
</label>
</div>
<button type="submit" class="login-btn" data-focusable> <button type="submit" class="login-btn" data-focusable>
Anmelden Anmelden
</button> </button>

View file

@ -19,31 +19,140 @@
<p class="tv-detail-overview">{{ movie.overview }}</p> <p class="tv-detail-overview">{{ movie.overview }}</p>
{% endif %} {% endif %}
{% if videos %} <!-- Bewertungen -->
<div class="tv-detail-actions"> <div class="tv-rating-section">
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable> <div class="tv-rating-user">
&#9654; Abspielen <span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
</a> <div class="tv-stars-input" id="user-stars"
data-movie-id="{{ movie.id }}" data-rating="{{ user_rating }}">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
data-value="{{ i }}" data-focusable
onclick="setRating({{ i }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
{% if videos %}
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
&#9654; {{ t('player.play') }}
</a>
{% endif %}
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-movie-id="{{ movie.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('watchlist.title') }}</span>
</button>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% if videos|length > 1 %} {% if videos|length > 1 %}
<h3 class="tv-section-title">Versionen</h3> <h3 class="tv-section-title">{{ t('movies.versions') }}</h3>
<div class="tv-episode-list"> <div class="tv-episode-list">
{% for v in videos %} {% for v in videos %}
<a href="/tv/player?v={{ v.id }}" class="tv-episode" data-focusable> <a href="/tv/player?v={{ v.id }}" class="tv-episode-card" data-focusable>
<span class="tv-episode-title">{{ v.file_name }}</span> <div class="tv-ep-thumb">
<span class="tv-episode-meta"> <img src="/api/library/videos/{{ v.id }}/thumbnail" alt="" loading="lazy">
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %} <div class="tv-ep-duration">
{% if v.width %} &middot; {{ v.width }}x{{ v.height }}{% endif %} {% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
&middot; {{ v.container|upper }} </div>
</span> </div>
<span class="tv-episode-play">&#9654;</span> <div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-title">{{ v.file_name }}</span>
</div>
<div class="tv-ep-meta">
{% if v.width %}{{ v.width }}x{{ v.height }}{% endif %}
&middot; {{ v.container|upper }}
{% if v.video_codec %} &middot; {{ v.video_codec }}{% endif %}
</div>
</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function toggleWatchlist(btn) {
const movieId = btn.dataset.movieId;
fetch('/tv/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: parseInt(movieId) }),
})
.then(r => r.json())
.then(data => {
if (data.in_watchlist) {
btn.classList.add('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const movieId = container.dataset.movieId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: parseInt(movieId), rating: value }),
})
.then(r => r.json())
.then(data => {
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
</script>
{% endblock %}

View file

@ -1,10 +1,81 @@
{% extends "tv/base.html" %} {% extends "tv/base.html" %}
{% block title %}Filme - VideoKonverter TV{% endblock %} {% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %}
{% block content %} {% block content %}
<section class="tv-section"> <section class="tv-section">
<h1 class="tv-page-title">Filme</h1> <div class="tv-list-header">
<div class="tv-grid"> <h1 class="tv-page-title">{{ t('movies.title') }}</h1>
<div class="tv-view-switch" id="view-switch">
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
data-focusable data-view="grid" onclick="switchView('grid')"
title="{{ t('settings.view_grid') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
data-focusable data-view="list" onclick="switchView('list')"
title="{{ t('settings.view_list') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
data-focusable data-view="detail" onclick="switchView('detail')"
title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button>
</div>
</div>
<!-- Quellen-Tabs -->
{% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs">
<a href="/tv/movies?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for src in sources %}
<a href="/tv/movies?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
{{ src.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Filter-Leiste -->
<div class="tv-filter-bar">
{% if genres %}
<div class="tv-genre-chips">
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for g in genres %}
<a href="/tv/movies?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
{{ g }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
<option value="">{{ t('filter.min_rating') }}</option>
{% for n in range(1, 6) %}
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
{% for s in range(n) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>{{ t('filter.sort_newest') }} (Jahr)</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for m in movies %} {% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %} {% if m.poster_url %}
@ -14,13 +85,94 @@
{% endif %} {% endif %}
<div class="tv-card-info"> <div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span> <span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}</span> <span class="tv-card-meta">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
<!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable>
<div class="tv-list-poster">
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<span class="tv-list-title">{{ m.title or m.folder_name }}</span>
<span class="tv-list-rating">{% if m.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ m.genres or '' }}</span>
<span class="tv-list-count">{{ m.year or '' }}</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable>
<div class="tv-detail-thumb">
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ m.title or m.folder_name }}</span>
{% if m.overview %}
<p class="tv-detail-desc">{{ m.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ m.avg_rating }}</span> &middot; {% endif %}
{% if m.year %}{{ m.year }}{% endif %}
{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
{% if not movies %} {% if not movies %}
<div class="tv-empty">Keine Filme vorhanden.</div> <div class="tv-empty">{{ t('movies.no_movies') }}</div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function switchView(mode) {
document.querySelectorAll('[id^="view-"]').forEach(el => {
if (el.id !== 'view-switch') el.style.display = 'none';
});
const target = document.getElementById('view-' + mode);
if (target) target.style.display = '';
document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode);
});
fetch('/tv/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'movies_view=' + mode,
}).catch(() => {});
}
function applySort(sort) {
const url = new URL(window.location);
url.searchParams.set('sort', sort);
window.location.href = url.toString();
}
function applyRating(rating) {
const url = new URL(window.location);
if (rating) {
url.searchParams.set('rating', rating);
} else {
url.searchParams.delete('rating');
}
window.location.href = url.toString();
}
</script>
{% endblock %}

View file

@ -11,14 +11,12 @@
<div class="player-wrapper" id="player-wrapper"> <div class="player-wrapper" id="player-wrapper">
<!-- Header (ausblendbar) --> <!-- Header (ausblendbar) -->
<div class="player-header" id="player-header"> <div class="player-header" id="player-header">
<a href="javascript:history.back()" class="player-back" data-focusable>&#10094; Zurueck</a> <a href="javascript:history.back()" class="player-back" data-focusable>&#10094; {{ t('player.back') }}</a>
<span class="player-title">{{ title }}</span> <span class="player-title">{{ title }}</span>
</div> </div>
<!-- Video --> <!-- Video -->
<video id="player-video" autoplay playsinline> <video id="player-video" autoplay playsinline></video>
Dein Browser unterstuetzt kein HTML5-Video.
</video>
<!-- Controls (ausblendbar) --> <!-- Controls (ausblendbar) -->
<div class="player-controls" id="player-controls"> <div class="player-controls" id="player-controls">
@ -29,14 +27,70 @@
<button class="player-btn" id="btn-play" data-focusable>&#9654;</button> <button class="player-btn" id="btn-play" data-focusable>&#9654;</button>
<span class="player-time" id="player-time">0:00 / 0:00</span> <span class="player-time" id="player-time">0:00 / 0:00</span>
<span class="player-spacer"></span> <span class="player-spacer"></span>
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">&#9881;</button>
{% if next_video %}
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">&#9197;</button>
{% endif %}
<button class="player-btn" id="btn-fullscreen" data-focusable>&#9974;</button> <button class="player-btn" id="btn-fullscreen" data-focusable>&#9974;</button>
</div> </div>
</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>
<!-- Naechste Episode Overlay -->
{% if next_video %}
<div class="player-next-overlay" id="next-overlay" style="display:none">
<div class="player-next-card">
<p class="player-next-title" id="next-title">{{ t('player.next_episode') }}</p>
<p class="player-next-name">{{ next_title }}</p>
<p class="player-next-countdown" id="next-countdown"></p>
<div class="player-next-buttons">
<button class="tv-play-btn" id="btn-next-play" data-focusable>{{ t('player.skip') }}</button>
<button class="player-btn-cancel" id="btn-next-cancel" data-focusable>{{ t('player.cancel') }}</button>
</div>
</div>
</div>
{% endif %}
<!-- Schaust du noch? Overlay -->
<div class="player-still-watching" id="still-watching-overlay" style="display:none">
<div class="player-next-card">
<p class="player-next-title">{{ t('player.still_watching') }}</p>
<div class="player-next-buttons">
<button class="tv-play-btn" id="btn-still-yes" data-focusable>{{ t('player.continue') }}</button>
<button class="player-btn-cancel" id="btn-still-no" data-focusable>{{ t('player.stop') }}</button>
</div>
</div>
</div>
</div> </div>
<script src="/static/tv/js/player.js"></script> <script src="/static/tv/js/player.js"></script>
<script> <script>
initPlayer({{ video.id }}, {{ start_pos }}, {{ video.duration_sec or 0 }}); initPlayer({
videoId: {{ video.id }},
startPos: {{ start_pos }},
duration: {{ video.duration_sec or 0 }},
{% if next_video %}
nextVideoId: {{ next_video.id }},
nextUrl: "/tv/player?v={{ next_video.id }}",
{% endif %}
autoplay: {{ 'true' if user.autoplay_enabled else 'false' }},
autoplayCountdown: {{ user.autoplay_countdown_sec or 10 }},
autoplayMax: {{ user.autoplay_max_episodes or 0 }},
preferredAudio: "{{ user.preferred_audio_lang or 'deu' }}",
preferredSub: "{{ user.preferred_subtitle_lang or '' }}",
subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }},
soundMode: "{{ client_sound_mode or 'stereo' }}",
streamQuality: "{{ client_stream_quality or 'hd' }}",
});
</script> </script>
</body> </body>
</html> </html>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de">
<head>
<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">
<title>{{ t('profiles.title') }} - VideoKonverter TV</title>
</head>
<body class="login-body">
<div class="profiles-container">
<h1 class="profiles-title">{{ t('profiles.title') }}</h1>
<div class="profiles-grid">
{% for p in profiles %}
<form method="post" action="/tv/switch-profile" class="profile-form">
<input type="hidden" name="session_id" value="{{ p.session_id }}">
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable>
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
{{ (p.display_name or p.username)[:1]|upper }}
</span>
<span class="profile-name">{{ p.display_name or p.username }}</span>
</button>
</form>
{% endfor %}
<!-- Anderer Benutzer -->
<a href="/tv/login" class="profile-card profile-add" data-focusable>
<span class="tv-avatar tv-avatar-lg profile-add-icon">+</span>
<span class="profile-name">{{ t('profiles.add_user') }}</span>
</a>
</div>
</div>
<script src="/static/tv/js/tv.js"></script>
</body>
</html>

View file

@ -1,20 +1,23 @@
{% extends "tv/base.html" %} {% extends "tv/base.html" %}
{% block title %}Suche - VideoKonverter TV{% endblock %} {% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %}
{% block content %} {% block content %}
<section class="tv-section"> <section class="tv-section">
<h1 class="tv-page-title">Suche</h1> <h1 class="tv-page-title">{{ t('search.title') }}</h1>
<form action="/tv/search" method="GET" class="tv-search-form"> <form action="/tv/search" method="GET" class="tv-search-form" autocomplete="off">
<input type="text" name="q" value="{{ query }}" <div class="tv-search-wrapper">
placeholder="Serie oder Film suchen..." <input type="text" name="q" id="search-input" value="{{ query }}"
class="tv-search-input" data-focusable autofocus> placeholder="{{ t('search.placeholder') }}"
<button type="submit" class="tv-search-btn" data-focusable>Suchen</button> class="tv-search-input" data-focusable autofocus>
<div class="tv-autocomplete" id="autocomplete" style="display:none"></div>
</div>
<button type="submit" class="tv-search-btn" data-focusable>{{ t('search.button') }}</button>
</form> </form>
{% if query %} {% if query %}
<!-- Serien-Ergebnisse --> <!-- Serien-Ergebnisse -->
{% if series %} {% if series %}
<h2 class="tv-section-title">Serien ({{ series|length }})</h2> <h2 class="tv-section-title">{{ t('search.results_series') }} ({{ series|length }})</h2>
<div class="tv-grid"> <div class="tv-grid">
{% for s in series %} {% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
@ -25,6 +28,9 @@
{% endif %} {% endif %}
<div class="tv-card-info"> <div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span> <span class="tv-card-title">{{ s.title or s.folder_name }}</span>
{% if s.genres %}
<span class="tv-card-meta">{{ s.genres }}</span>
{% endif %}
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
@ -33,7 +39,7 @@
<!-- Film-Ergebnisse --> <!-- Film-Ergebnisse -->
{% if movies %} {% if movies %}
<h2 class="tv-section-title">Filme ({{ movies|length }})</h2> <h2 class="tv-section-title">{{ t('search.results_movies') }} ({{ movies|length }})</h2>
<div class="tv-grid"> <div class="tv-grid">
{% for m in movies %} {% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable> <a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
@ -52,8 +58,76 @@
{% endif %} {% endif %}
{% if not series and not movies %} {% if not series and not movies %}
<div class="tv-empty">Keine Ergebnisse fuer &laquo;{{ query }}&raquo;</div> <div class="tv-empty">{{ t('search.no_results', query=query) }}</div>
{% endif %}
{% else %}
<!-- Such-History -->
{% if history %}
<div class="tv-search-history">
<div class="tv-search-history-header">
<h2 class="tv-section-title">{{ t('search.history') }}</h2>
<button class="tv-link-btn" onclick="clearHistory()" data-focusable>{{ t('search.clear_history') }}</button>
</div>
<div class="tv-search-history-list">
{% for h in history %}
<a href="/tv/search?q={{ h.query }}" class="tv-search-history-item" data-focusable>
<span class="tv-search-history-icon">&#128269;</span>
{{ h.query }}
</a>
{% endfor %}
</div>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
// Autocomplete
const input = document.getElementById('search-input');
const acBox = document.getElementById('autocomplete');
let acTimer = null;
if (input) {
input.addEventListener('input', () => {
clearTimeout(acTimer);
const q = input.value.trim();
if (q.length < 2) { acBox.style.display = 'none'; return; }
acTimer = setTimeout(() => fetchSuggestions(q), 300);
});
input.addEventListener('blur', () => {
setTimeout(() => { acBox.style.display = 'none'; }, 200);
});
input.addEventListener('focus', () => {
if (acBox.children.length > 0) acBox.style.display = '';
});
}
function fetchSuggestions(q) {
fetch('/tv/api/search/suggest?q=' + encodeURIComponent(q))
.then(r => r.json())
.then(data => {
if (!data.suggestions || data.suggestions.length === 0) {
acBox.style.display = 'none';
return;
}
acBox.innerHTML = data.suggestions.map(s =>
`<a href="/tv/search?q=${encodeURIComponent(s)}" class="tv-ac-item">${s}</a>`
).join('');
acBox.style.display = '';
})
.catch(() => { acBox.style.display = 'none'; });
}
function clearHistory() {
fetch('/tv/api/search/history', { method: 'DELETE' })
.then(() => {
const hist = document.querySelector('.tv-search-history');
if (hist) hist.remove();
})
.catch(() => {});
}
</script>
{% endblock %}

View file

@ -1,10 +1,81 @@
{% extends "tv/base.html" %} {% extends "tv/base.html" %}
{% block title %}Serien - VideoKonverter TV{% endblock %} {% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %}
{% block content %} {% block content %}
<section class="tv-section"> <section class="tv-section">
<h1 class="tv-page-title">Serien</h1> <div class="tv-list-header">
<div class="tv-grid"> <h1 class="tv-page-title">{{ t('series.title') }}</h1>
<div class="tv-view-switch" id="view-switch">
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
data-focusable data-view="grid" onclick="switchView('grid')"
title="{{ t('settings.view_grid') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
data-focusable data-view="list" onclick="switchView('list')"
title="{{ t('settings.view_list') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
data-focusable data-view="detail" onclick="switchView('detail')"
title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button>
</div>
</div>
<!-- Quellen-Tabs -->
{% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs">
<a href="/tv/series?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for src in sources %}
<a href="/tv/series?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
{{ src.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Filter-Leiste -->
<div class="tv-filter-bar">
{% if genres %}
<div class="tv-genre-chips">
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for g in genres %}
<a href="/tv/series?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
{{ g }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
<option value="">{{ t('filter.min_rating') }}</option>
{% for n in range(1, 6) %}
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
{% for s in range(n) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="episodes" {% if current_sort == 'episodes' %}selected{% endif %}>{{ t('filter.sort_episodes') }}</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for s in series %} {% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable> <a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %} {% if s.poster_url %}
@ -14,13 +85,95 @@
{% endif %} {% endif %}
<div class="tv-card-info"> <div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span> <span class="tv-card-title">{{ s.title or s.folder_name }}</span>
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden{% if s.genres %} &middot; {{ s.genres }}{% endif %}</span> <span class="tv-card-meta">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}{% if s.genres %} &middot; {{ s.genres }}{% endif %}
</span>
</div> </div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
<!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable>
<div class="tv-list-poster">
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<span class="tv-list-title">{{ s.title or s.folder_name }}</span>
<span class="tv-list-rating">{% if s.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ s.genres or '' }}</span>
<span class="tv-list-count">{{ s.episode_count or 0 }} Ep.</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable>
<div class="tv-detail-thumb">
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ s.title or s.folder_name }}</span>
{% if s.overview %}
<p class="tv-detail-desc">{{ s.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ s.avg_rating }}</span> &middot; {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
{% if s.genres %} &middot; {{ s.genres }}{% endif %}
{% if s.status %} &middot; {{ s.status }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
{% if not series %} {% if not series %}
<div class="tv-empty">Keine Serien vorhanden.</div> <div class="tv-empty">{{ t('series.no_series') }}</div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
function switchView(mode) {
document.querySelectorAll('[id^="view-"]').forEach(el => {
if (el.id !== 'view-switch') el.style.display = 'none';
});
const target = document.getElementById('view-' + mode);
if (target) target.style.display = '';
document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode);
});
fetch('/tv/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'series_view=' + mode,
}).catch(() => {});
}
function applySort(sort) {
const url = new URL(window.location);
url.searchParams.set('sort', sort);
window.location.href = url.toString();
}
function applyRating(rating) {
const url = new URL(window.location);
if (rating) {
url.searchParams.set('rating', rating);
} else {
url.searchParams.delete('rating');
}
window.location.href = url.toString();
}
</script>
{% endblock %}

View file

@ -16,6 +16,54 @@
{% if series.overview %} {% if series.overview %}
<p class="tv-detail-overview">{{ series.overview }}</p> <p class="tv-detail-overview">{{ series.overview }}</p>
{% endif %} {% endif %}
<!-- Bewertungen -->
<div class="tv-rating-section">
<!-- Eigene Bewertung (klickbare Sterne) -->
<div class="tv-rating-user">
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
<div class="tv-stars-input" id="user-stars"
data-series-id="{{ series.id }}" data-rating="{{ user_rating }}">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
data-value="{{ i }}" data-focusable
onclick="setRating({{ i }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
<!-- Durchschnitt -->
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
<!-- TVDB-Score -->
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-series-id="{{ series.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
</button>
</div>
</div> </div>
</div> </div>
@ -26,7 +74,7 @@
<button class="tv-tab {% if loop.first %}active{% endif %}" <button class="tv-tab {% if loop.first %}active{% endif %}"
data-focusable data-focusable
onclick="showSeason({{ sn }})"> onclick="showSeason({{ sn }})">
{% if sn == 0 %}Specials{% else %}Staffel {{ sn }}{% endif %} {% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
</button> </button>
{% endfor %} {% endfor %}
</div> </div>
@ -36,25 +84,51 @@
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}> <div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
<div class="tv-episode-list"> <div class="tv-episode-list">
{% for ep in episodes %} {% for ep in episodes %}
<a href="/tv/player?v={{ ep.id }}" class="tv-episode" data-focusable> <a href="/tv/player?v={{ ep.id }}" class="tv-episode-card" data-focusable>
<span class="tv-episode-num"> <!-- Thumbnail -->
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% else %}-{% endif %} <div class="tv-ep-thumb">
</span> {% if ep.ep_image_url %}
<span class="tv-episode-title"> <img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
{{ ep.episode_title or ep.file_name }} {% else %}
</span> <img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
<span class="tv-episode-meta"> {% endif %}
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %} {% if ep.progress_pct > 0 and ep.progress_pct < 95 %}
{% if ep.width %} &middot; {{ ep.width }}x{{ ep.height }}{% endif %} <div class="tv-ep-progress">
</span> <div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
<span class="tv-episode-play">&#9654;</span> </div>
{% endif %}
{% if ep.progress_pct >= 95 %}
<div class="tv-ep-watched">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
</div>
</div>
<!-- Info -->
<div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-num">
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
</span>
<span class="tv-ep-title">
{{ ep.episode_title or ep.file_name }}
</span>
</div>
{% if ep.ep_overview %}
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
{% endif %}
<div class="tv-ep-meta">
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
</div>
</div>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<div class="tv-empty">Keine Episoden vorhanden.</div> <div class="tv-empty">{{ t('series.no_episodes') }}</div>
{% endif %} {% endif %}
</section> </section>
{% endblock %} {% endblock %}
@ -72,5 +146,62 @@ function showSeason(sn) {
// Tab aktivieren // Tab aktivieren
event.target.classList.add('active'); event.target.classList.add('active');
} }
function toggleWatchlist(btn) {
const seriesId = btn.dataset.seriesId;
fetch('/tv/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId) }),
})
.then(r => r.json())
.then(data => {
if (data.in_watchlist) {
btn.classList.add('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const seriesId = container.dataset.seriesId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
})
.then(r => r.json())
.then(data => {
// Sterne aktualisieren
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
// Entfernen-Button anzeigen/verstecken
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
</script> </script>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,176 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('settings.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">{{ t('settings.title') }}</h1>
{% if request.query.get('saved') %}
<div class="tv-success-msg">{{ t('settings.saved') }}</div>
{% endif %}
{% if request.query.get('reset') %}
<div class="tv-success-msg">{{ t('status.reset_progress') }} &#10003;</div>
{% endif %}
<form method="post" action="/tv/settings" class="settings-form">
<!-- Profil -->
<fieldset class="settings-group">
<legend>{{ t('settings.profile') }}</legend>
<label class="settings-label">
{{ t('settings.display_name') }}
<input type="text" name="display_name" value="{{ user.display_name or '' }}"
class="settings-input" data-focusable>
</label>
<label class="settings-label">
{{ t('settings.avatar_color') }}
<input type="color" name="avatar_color" value="{{ user.avatar_color or '#64b5f6' }}"
class="settings-color" data-focusable>
</label>
</fieldset>
<!-- Sprache -->
<fieldset class="settings-group">
<legend>{{ t('settings.language') }}</legend>
<label class="settings-label">
{{ t('settings.menu_language') }}
<select name="ui_lang" class="settings-select" data-focusable>
<option value="de" {% if user.ui_lang == 'de' %}selected{% endif %}>Deutsch</option>
<option value="en" {% if user.ui_lang == 'en' %}selected{% endif %}>English</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.audio_language') }}
<select name="preferred_audio_lang" class="settings-select" data-focusable>
<option value="deu" {% if user.preferred_audio_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
<option value="eng" {% if user.preferred_audio_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
<option value="fra" {% if user.preferred_audio_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
<option value="spa" {% if user.preferred_audio_lang == 'spa' %}selected{% endif %}>{{ t('lang.spa') }}</option>
<option value="jpn" {% if user.preferred_audio_lang == 'jpn' %}selected{% endif %}>{{ t('lang.jpn') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.subtitle_language') }}
<select name="preferred_subtitle_lang" class="settings-select" data-focusable>
<option value="" {% if not user.preferred_subtitle_lang %}selected{% endif %}>{{ t('player.subtitles_off') }}</option>
<option value="deu" {% if user.preferred_subtitle_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
<option value="eng" {% if user.preferred_subtitle_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
<option value="fra" {% if user.preferred_subtitle_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
</select>
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="subtitles_enabled"
{% if user.subtitles_enabled %}checked{% endif %} data-focusable>
{{ t('settings.subtitles_enabled') }}
</label>
</fieldset>
<!-- Ansichten & Theme -->
<fieldset class="settings-group">
<legend>{{ t('settings.views') }}</legend>
<label class="settings-label">
{{ t('settings.theme') }}
<select name="theme" class="settings-select" data-focusable
onchange="document.documentElement.setAttribute('data-theme', this.value)">
<option value="dark" {% if user.theme == 'dark' or not user.theme %}selected{% endif %}>{{ t('settings.theme_dark') }}</option>
<option value="medium" {% if user.theme == 'medium' %}selected{% endif %}>{{ t('settings.theme_medium') }}</option>
<option value="light" {% if user.theme == 'light' %}selected{% endif %}>{{ t('settings.theme_light') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.series_view') }}
<select name="series_view" class="settings-select" data-focusable>
<option value="grid" {% if user.series_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.series_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.series_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.movies_view') }}
<select name="movies_view" class="settings-select" data-focusable>
<option value="grid" {% if user.movies_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.movies_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.movies_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
</select>
</label>
</fieldset>
<!-- Auto-Play -->
<fieldset class="settings-group">
<legend>{{ t('settings.autoplay') }}</legend>
<label class="settings-label settings-check">
<input type="checkbox" name="autoplay_enabled"
{% if user.autoplay_enabled %}checked{% endif %} data-focusable>
{{ t('settings.autoplay_enabled') }}
</label>
<label class="settings-label">
{{ t('settings.autoplay_countdown') }}
<select name="autoplay_countdown_sec" class="settings-select" data-focusable>
{% for s in [5, 10, 15, 20, 30] %}
<option value="{{ s }}" {% if user.autoplay_countdown_sec == s %}selected{% endif %}>{{ s }} {{ t('settings.seconds') }}</option>
{% endfor %}
</select>
</label>
<label class="settings-label">
{{ t('settings.autoplay_max') }}
<select name="autoplay_max_episodes" class="settings-select" data-focusable>
<option value="0" {% if user.autoplay_max_episodes == 0 %}selected{% endif %}>{{ t('settings.autoplay_max_desc') }}</option>
{% for n in [3, 5, 8, 10, 15, 20] %}
<option value="{{ n }}" {% if user.autoplay_max_episodes == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<!-- Geraete-Einstellungen -->
{% if client %}
<fieldset class="settings-group">
<legend>{{ t('settings.client_settings') }}</legend>
<label class="settings-label">
{{ t('settings.device_name') }}
<input type="text" name="client_name" value="{{ client.name or '' }}"
placeholder="z.B. Samsung TV Wohnzimmer"
class="settings-input" data-focusable>
</label>
<label class="settings-label">
{{ t('settings.sound_mode') }}
<select name="sound_mode" class="settings-select" data-focusable>
<option value="stereo" {% if client.sound_mode == 'stereo' %}selected{% endif %}>{{ t('settings.sound_stereo') }}</option>
<option value="surround" {% if client.sound_mode == 'surround' %}selected{% endif %}>{{ t('settings.sound_surround') }}</option>
<option value="original" {% if client.sound_mode == 'original' %}selected{% endif %}>{{ t('settings.sound_original') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.stream_quality') }}
<select name="stream_quality" class="settings-select" data-focusable>
<option value="uhd" {% if client.stream_quality == 'uhd' %}selected{% endif %}>{{ t('player.quality_uhd') }}</option>
<option value="hd" {% if client.stream_quality == 'hd' %}selected{% endif %}>{{ t('player.quality_hd') }}</option>
<option value="sd" {% if client.stream_quality == 'sd' %}selected{% endif %}>{{ t('player.quality_sd') }}</option>
<option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option>
</select>
</label>
</fieldset>
{% endif %}
<button type="submit" class="tv-play-btn settings-save" data-focusable>
{{ t('settings.save') }}
</button>
</form>
<!-- Gefahrenzone -->
<div class="settings-danger">
<form method="post" action="/tv/settings/reset"
onsubmit="return confirm('{{ t('settings.reset_confirm') }}')">
<button type="submit" class="settings-danger-btn" data-focusable>
{{ t('settings.reset_all') }}
</button>
</form>
<form method="post" action="/tv/api/search/history"
onsubmit="fetch('/tv/api/search/history',{method:'DELETE'});this.querySelector('button').textContent='✓';return false;">
<button type="button" class="settings-danger-btn" data-focusable>
{{ t('settings.clear_search') }}
</button>
</form>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('watchlist.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">{{ t('watchlist.title') }}</h1>
{% if not series and not movies %}
<div class="tv-empty">{{ t('watchlist.empty') }}</div>
{% endif %}
{% if series %}
<div class="tv-section">
<h2 class="tv-section-title">{{ t('watchlist.series') }}</h2>
<div class="tv-grid">
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %}
<img data-src="{{ s.poster_url }}" alt="" class="tv-card-img tv-lazy" 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>
{% if s.genres %}
<span class="tv-card-meta">{{ s.genres }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if movies %}
<div class="tv-section">
<h2 class="tv-section-title">{{ t('watchlist.movies') }}</h2>
<div class="tv-grid">
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %}
<img data-src="{{ m.poster_url }}" alt="" class="tv-card-img tv-lazy" 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">{% if m.year %}{{ m.year }}{% endif %} {% if m.genres %}{{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{% endblock %}