From 6d0b8936c5046c634e11851220bdb25a1142b6c9 Mon Sep 17 00:00:00 2001 From: data Date: Sun, 1 Mar 2026 07:39:12 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 185 ++++ video-konverter/app/routes/library_api.py | 340 ++++++- video-konverter/app/routes/tv_api.py | 582 ++++++++++- video-konverter/app/server.py | 6 + video-konverter/app/services/auth.py | 757 +++++++++++++- video-konverter/app/services/i18n.py | 101 ++ video-konverter/app/services/queue.py | 44 +- video-konverter/app/services/tvdb.py | 16 +- video-konverter/app/static/tv/css/tv.css | 939 +++++++++++++++++- video-konverter/app/static/tv/i18n/de.json | 197 ++++ video-konverter/app/static/tv/i18n/en.json | 197 ++++ video-konverter/app/static/tv/js/player.js | 429 ++++++-- video-konverter/app/templates/tv/base.html | 21 +- video-konverter/app/templates/tv/login.html | 6 + .../app/templates/tv/movie_detail.html | 139 ++- video-konverter/app/templates/tv/movies.html | 162 ++- video-konverter/app/templates/tv/player.html | 64 +- .../app/templates/tv/profiles.html | 37 + video-konverter/app/templates/tv/search.html | 94 +- video-konverter/app/templates/tv/series.html | 163 ++- .../app/templates/tv/series_detail.html | 159 ++- .../app/templates/tv/settings.html | 176 ++++ .../app/templates/tv/watchlist.html | 56 ++ 23 files changed, 4590 insertions(+), 280 deletions(-) create mode 100644 video-konverter/app/services/i18n.py create mode 100644 video-konverter/app/static/tv/i18n/de.json create mode 100644 video-konverter/app/static/tv/i18n/en.json create mode 100644 video-konverter/app/templates/tv/profiles.html create mode 100644 video-konverter/app/templates/tv/settings.html create mode 100644 video-konverter/app/templates/tv/watchlist.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d714ef..341f915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,191 @@ 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 ``, 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 ### Import-System Neustrukturierung diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py index ef4e2e4..0d48ad0 100644 --- a/video-konverter/app/routes/library_api.py +++ b/video-konverter/app/routes/library_api.py @@ -1317,14 +1317,21 @@ def setup_library_routes(app: web.Application, config: Config, # === 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: - """GET /api/library/videos/{video_id}/stream?t=0 - Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC). - Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.). - Optional: ?t=120 fuer Seeking auf Sekunde 120.""" + """GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0 + Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur. + + 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 asyncio as _asyncio - import shlex video_id = int(request.match_info["video_id"]) @@ -1336,43 +1343,96 @@ def setup_library_routes(app: web.Application, config: Config, try: async with pool.acquire() as conn: - async with conn.cursor() as cur: + async with conn.cursor(aiomysql.DictCursor) as cur: 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,) ) - row = await cur.fetchone() - if not row: + 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 = row[0] + file_path = video["file_path"] if not os.path.isfile(file_path): return web.json_response( {"error": "Datei nicht gefunden"}, status=404 ) - # Seek-Position (Sekunden) aus Query-Parameter - seek_sec = float(request.query.get("t", "0")) + # Audio-Tracks parsen + 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 - # frag_keyframe: Fragment bei jedem Keyframe - # empty_moov: Leerer moov-Atom am Anfang (noetig fuer pipe) - # default_base_moof: Bessere Browser-Kompatibilitaet (Samsung TV etc.) - # frag_duration: Kleine Fragmente fuer schnellen Playback-Start - cmd = [ - "ffmpeg", "-hide_banner", "-loglevel", "error", - ] + # Parameter aus Query + quality = request.query.get("quality", "hd") + audio_idx = int(request.query.get("audio", "0")) + seek_sec = float(request.query.get("t", "0")) + sound_mode = request.query.get("sound", "stereo") + + # 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: - # -ss VOR -i fuer schnelles Seeking (Input-Seeking) 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 += [ - "-i", file_path, - "-c:v", "copy", - "-c:a", "aac", "-ac", "2", "-b:a", "192k", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-frag_duration", "1000000", "-f", "mp4", @@ -1405,7 +1465,6 @@ def setup_library_routes(app: web.Application, config: Config, try: await resp.write(chunk) except (ConnectionResetError, ConnectionAbortedError): - # Client hat Verbindung geschlossen break except Exception as e: @@ -1418,6 +1477,225 @@ def setup_library_routes(app: web.Application, config: Config, await resp.write_eof() 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 === 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", put_overwrite_mode, ) - # Video-Streaming + # Video-Streaming, Untertitel, Video-Info app.router.add_get( "/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) app.router.add_post( "/api/library/tvdb-auto-match", post_tvdb_auto_match diff --git a/video-konverter/app/routes/tv_api.py b/video-konverter/app/routes/tv_api.py index 5911b8b..db0712f 100644 --- a/video-konverter/app/routes/tv_api.py +++ b/video-konverter/app/routes/tv_api.py @@ -10,6 +10,7 @@ import aiomysql from app.config import Config from app.services.auth import AuthService 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, @@ -27,13 +28,16 @@ def setup_tv_routes(app: web.Application, config: Config, return await auth_service.validate_session(session_id) 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) async def wrapper(request): user = await get_tv_user(request) if not user: raise web.HTTPFound("/tv/login") request["tv_user"] = user + # i18n: Sprache des Users setzen + set_request_lang(request.app, user.get("ui_lang", "de")) return await handler(request) return wrapper @@ -50,10 +54,13 @@ def setup_tv_routes(app: web.Application, config: Config, ) 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() username = data.get("username", "").strip() password = data.get("password", "") + remember = data.get("remember", "") == "on" if not username or not password: return aiohttp_jinja2.render_template( @@ -68,14 +75,30 @@ def setup_tv_routes(app: web.Application, config: Config, {"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", "") - 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/") + # Session-Cookie + max_age = 10 * 365 * 24 * 3600 if remember else 30 * 24 * 3600 resp.set_cookie( "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, samesite="Lax", path="/", @@ -166,40 +189,112 @@ def setup_tv_routes(app: web.Application, config: Config, @require_auth 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"] if not user.get("can_view_series"): 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 = [] + sources = [] + all_genres = set() pool = library_service._db_pool if pool: async with pool.acquire() as conn: 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 = """ SELECT s.id, s.title, s.folder_name, s.poster_url, - s.genres, s.tvdb_id, s.overview, - COUNT(v.id) as episode_count + s.genres, s.tvdb_id, s.overview, s.status, + 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 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 = [] + + # Pfad-Berechtigung if user.get("allowed_paths"): - placeholders = ",".join( - ["%s"] * len(user["allowed_paths"])) - query += ( - f" WHERE s.library_path_id IN ({placeholders})" - ) - params = user["allowed_paths"] - query += " GROUP BY s.id ORDER BY s.title" + ph = ",".join(["%s"] * len(user["allowed_paths"])) + conditions.append( + f"s.library_path_id IN ({ph})") + params.extend(user["allowed_paths"]) + + # Quellen-Filter + 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) 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( "tv/series.html", request, { "user": user, "active": "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 seasons = {} + in_watchlist = False pool = library_service._db_pool if pool: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT id, title, folder_name, poster_url, - overview, genres, tvdb_id + overview, genres, tvdb_id, tvdb_score FROM library_series WHERE id = %s """, (series_id,)) series = await cur.fetchone() if series: + # Episoden mit TVDB-Beschreibung und Watch-Progress await cur.execute(""" - SELECT id, file_name, season_number, - episode_number, episode_title, - duration_sec, file_size, - width, height, video_codec, - container - FROM library_videos - WHERE series_id = %s - ORDER BY season_number, episode_number, file_name - """, (series_id,)) + SELECT v.id, v.file_name, v.season_number, + v.episode_number, v.episode_title, + v.duration_sec, v.file_size, + v.width, v.height, v.video_codec, + v.container, + tc.overview AS ep_overview, + tc.image_url AS ep_image_url, + wp.position_sec, wp.duration_sec AS wp_duration + FROM library_videos v + LEFT JOIN tvdb_episode_cache tc + 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() 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 if sn not in seasons: seasons[sn] = [] 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: raise web.HTTPFound("/tv/series") @@ -253,43 +379,114 @@ def setup_tv_routes(app: web.Application, config: Config, "active": "series", "series": series, "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 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"] if not user.get("can_view_movies"): 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 = [] + sources = [] + all_genres = set() pool = library_service._db_pool if pool: async with pool.acquire() as conn: 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 = """ 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 + LEFT JOIN tv_ratings r ON r.movie_id = m.id + AND r.rating > 0 """ + conditions = [] params = [] + if user.get("allowed_paths"): - placeholders = ",".join( - ["%s"] * len(user["allowed_paths"])) - query += ( - f" WHERE m.library_path_id IN ({placeholders})" - ) - params = user["allowed_paths"] - query += " ORDER BY m.title" + ph = ",".join(["%s"] * len(user["allowed_paths"])) + conditions.append( + f"m.library_path_id IN ({ph})") + params.extend(user["allowed_paths"]) + + if source_filter: + 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) 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( "tv/movies.html", request, { "user": user, "active": "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: await cur.execute(""" SELECT id, title, folder_name, poster_url, - year, overview, genres + year, overview, genres, tvdb_score FROM library_movies WHERE id = %s """, (movie_id,)) movie = await cur.fetchone() @@ -326,18 +523,32 @@ def setup_tv_routes(app: web.Application, config: Config, if not movie: 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( "tv/movie_detail.html", request, { "user": user, "active": "movies", "movie": movie, "videos": videos, + "in_watchlist": in_watchlist, + "user_rating": user_rating, + "avg_rating": avg_rating, + "tvdb_score": movie.get("tvdb_score"), } ) @require_auth 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"] video_id = int(request.query.get("v", 0)) if not video_id: @@ -349,14 +560,16 @@ def setup_tv_routes(app: web.Application, config: Config, if progress and not progress.get("completed"): start_pos = progress.get("position_sec", 0) - # Video-Info laden + # Video-Info + naechste Episode laden video = None + next_video = None pool = library_service._db_pool if pool: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT v.id, v.file_name, v.duration_sec, + v.series_id, s.title as series_title, v.season_number, v.episode_number, v.episode_title @@ -366,6 +579,24 @@ def setup_tv_routes(app: web.Application, config: Config, """, (video_id,)) 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: raise web.HTTPFound("/tv/") @@ -379,22 +610,42 @@ def setup_tv_routes(app: web.Application, config: Config, if 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( "tv/player.html", request, { "user": user, "video": video, "title": title, "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 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"] query = request.query.get("q", "").strip() results_series = [] results_movies = [] + history = [] if query and len(query) >= 2: pool = library_service._db_pool @@ -405,7 +656,8 @@ def setup_tv_routes(app: web.Application, config: Config, if user.get("can_view_series"): await cur.execute(""" - SELECT id, title, folder_name, poster_url, genres + SELECT id, title, folder_name, poster_url, + genres FROM library_series WHERE title LIKE %s OR folder_name LIKE %s ORDER BY title LIMIT 50 @@ -422,6 +674,12 @@ def setup_tv_routes(app: web.Application, config: Config, """, (search_term, search_term)) 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( "tv/search.html", request, { "user": user, @@ -429,6 +687,7 @@ def setup_tv_routes(app: web.Application, config: Config, "query": query, "series": results_series, "movies": results_movies, + "history": history, } ) @@ -566,12 +825,244 @@ def setup_tv_routes(app: web.Application, config: Config, return web.json_response( {"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 --- # TV-Seiten (mit Auth via Decorator) app.router.add_get("/tv/login", get_login) app.router.add_post("/tv/login", post_login) 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/series", get_series_list) 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/player", get_player) 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_get( "/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) app.router.add_get("/api/tv/qrcode", get_qrcode) diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py index b090943..acfb5b9 100644 --- a/video-konverter/app/server.py +++ b/video-konverter/app/server.py @@ -15,6 +15,7 @@ from app.services.tvdb import TVDBService from app.services.cleaner import CleanerService from app.services.importer import ImporterService from app.services.auth import AuthService +from app.services.i18n import load_translations, setup_jinja2_i18n from app.routes.api import setup_api_routes from app.routes.library_api import setup_library_routes from app.routes.pages import setup_page_routes @@ -70,6 +71,11 @@ class VideoKonverterServer: 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 ws_path = self.config.server_config.get("websocket_path", "/ws") self.app.router.add_get(ws_path, self.ws_manager.handle_websocket) diff --git a/video-konverter/app/services/auth.py b/video-konverter/app/services/auth.py index 067a24b..c68b697 100644 --- a/video-konverter/app/services/auth.py +++ b/video-konverter/app/services/auth.py @@ -16,7 +16,7 @@ class AuthService: self._get_pool = db_pool_getter 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() if not pool: logging.error("Auth: Kein DB-Pool verfuegbar") @@ -24,6 +24,7 @@ class AuthService: async with pool.acquire() as conn: async with conn.cursor() as cur: + # === Bestehende Tabellen === await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_users ( id INT AUTO_INCREMENT PRIMARY KEY, @@ -64,10 +65,171 @@ class AuthService: ) 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 await self._ensure_default_admin() 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: """Erstellt admin/admin falls keine User existieren""" pool = await self._get_pool() @@ -254,22 +416,41 @@ class AuthService: return user async def create_session(self, user_id: int, - user_agent: str = "") -> str: - """Erstellt Session, gibt Token zurueck""" + user_agent: str = "", + 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) pool = await self._get_pool() if not pool: 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 conn.cursor() as cur: - await cur.execute(""" - INSERT INTO tv_sessions (id, user_id, user_agent) - VALUES (%s, %s, %s) - """, (session_id, user_id, user_agent[:512] if user_agent else "")) + if persistent: + await cur.execute(""" + INSERT INTO tv_sessions + (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 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: return None pool = await self._get_pool() @@ -279,11 +460,18 @@ class AuthService: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" 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 JOIN tv_users u ON s.user_id = u.id 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,)) user = await cur.fetchone() @@ -311,7 +499,8 @@ class AuthService: ) 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() if not pool: return 0 @@ -319,10 +508,466 @@ class AuthService: async with conn.cursor() as cur: await cur.execute( "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 + # --- 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 --- async def save_progress(self, user_id: int, video_id: int, @@ -391,3 +1036,91 @@ class AuthService: row["updated_at"], "isoformat"): row["updated_at"] = str(row["updated_at"]) 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), + } diff --git a/video-konverter/app/services/i18n.py b/video-konverter/app/services/i18n.py new file mode 100644 index 0000000..8711a5c --- /dev/null +++ b/video-konverter/app/services/i18n.py @@ -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 diff --git a/video-konverter/app/services/queue.py b/video-konverter/app/services/queue.py index 14725c3..c3bb0fb 100644 --- a/video-konverter/app/services/queue.py +++ b/video-konverter/app/services/queue.py @@ -317,29 +317,55 @@ class QueueService: await self.ws_manager.broadcast_queue_update() 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 # 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: 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: try: 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: - 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 if cleanup_cfg.get("enabled", False): - deleted = self.scanner.cleanup_directory(job.media.source_dir) - if deleted: + source_dir = job.media.source_dir + 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( - 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]: """Naechster Job mit Status QUEUED (FIFO)""" diff --git a/video-konverter/app/services/tvdb.py b/video-konverter/app/services/tvdb.py index 4a1fc90..f513662 100644 --- a/video-konverter/app/services/tvdb.py +++ b/video-konverter/app/services/tvdb.py @@ -801,12 +801,21 @@ class TVDBService: ep_aired = getattr(ep, "aired", None) ep_runtime = getattr(ep, "runtime", None) 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({ "season_number": s_num, "episode_number": e_num, "episode_name": ep_name or "", "aired": ep_aired, "runtime": ep_runtime, + "overview": ep_overview or "", + "image_url": ep_image or "", }) page += 1 if page > 50: @@ -840,12 +849,15 @@ class TVDBService: await cur.execute( "INSERT INTO tvdb_episode_cache " "(series_tvdb_id, season_number, episode_number, " - "episode_name, aired, runtime) " - "VALUES (%s, %s, %s, %s, %s, %s)", + "episode_name, aired, runtime, overview, " + "image_url) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s)", ( tvdb_id, ep["season_number"], ep["episode_number"], ep["episode_name"], ep["aired"], ep["runtime"], + ep.get("overview", ""), + ep.get("image_url", ""), ) ) except Exception as e: diff --git a/video-konverter/app/static/tv/css/tv.css b/video-konverter/app/static/tv/css/tv.css index 1a10f28..a3bcc7c 100644 --- a/video-konverter/app/static/tv/css/tv.css +++ b/video-konverter/app/static/tv/css/tv.css @@ -3,21 +3,59 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -:root { +/* === Theme: Dark (Standard) === */ +:root, [data-theme="dark"] { --bg: #0f0f0f; --bg-card: #1a1a1a; --bg-hover: #252525; --bg-input: #1e1e1e; + --bg-nav: #111; --text: #e0e0e0; --text-muted: #888; --accent: #64b5f6; --accent-hover: #90caf9; --danger: #ef5350; --success: #66bb6a; + --border: #333; + --shadow: rgba(0,0,0,0.5); --radius: 8px; --focus-ring: 3px solid var(--accent); } +/* === Theme: Medium (Grau) === */ +[data-theme="medium"] { + --bg: #2a2d32; + --bg-card: #363a40; + --bg-hover: #42474e; + --bg-input: #31353b; + --bg-nav: #24272c; + --text: #e8e8e8; + --text-muted: #999; + --accent: #5c9ce6; + --accent-hover: #7db4f0; + --danger: #e05252; + --success: #5dba5d; + --border: #4a4f56; + --shadow: rgba(0,0,0,0.3); +} + +/* === Theme: Light (Hell) === */ +[data-theme="light"] { + --bg: #f0f2f5; + --bg-card: #ffffff; + --bg-hover: #e8eaed; + --bg-input: #ffffff; + --bg-nav: #ffffff; + --text: #1a1a1a; + --text-muted: #666; + --accent: #1a73e8; + --accent-hover: #1565c0; + --danger: #d32f2f; + --success: #388e3c; + --border: #dadce0; + --shadow: rgba(0,0,0,0.1); +} + html { font-size: clamp(14px, 1.2vw, 20px); } @@ -46,7 +84,7 @@ a { color: var(--accent); text-decoration: none; } position: sticky; top: 0; z-index: 100; - background: rgba(15, 15, 15, 0.95); + background: var(--bg-nav); backdrop-filter: blur(10px); display: flex; align-items: center; @@ -92,7 +130,7 @@ a { color: var(--accent); text-decoration: none; } } .tv-row::-webkit-scrollbar { height: 4px; } .tv-row::-webkit-scrollbar-track { background: transparent; } -.tv-row::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; } +.tv-row::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } .tv-row .tv-card { scroll-snap-align: start; flex-shrink: 0; @@ -163,7 +201,7 @@ a { color: var(--accent); text-decoration: none; } /* Wiedergabe-Fortschritt auf Karte */ .tv-card-progress { height: 3px; - background: #333; + background: var(--border); } .tv-card-progress-bar { height: 100%; @@ -229,7 +267,7 @@ a { color: var(--accent); text-decoration: none; } padding: 0.5rem 1.2rem; background: var(--bg-card); color: var(--text-muted); - border: 1px solid #333; + border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; font-size: 0.9rem; @@ -240,37 +278,400 @@ a { color: var(--accent); text-decoration: none; } .tv-tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; } /* === Episoden-Liste === */ -.tv-episode-list { display: flex; flex-direction: column; gap: 2px; } -.tv-episode { +.tv-episode-list { display: flex; flex-direction: column; gap: 0.5rem; } + +/* Episoden-Karte (Netflix-Style) */ +.tv-episode-card { display: flex; - align-items: center; - gap: 0.8rem; - padding: 0.8rem 1rem; + gap: 1rem; + padding: 0.6rem; background: var(--bg-card); border-radius: var(--radius); transition: background 0.2s; color: var(--text); + text-decoration: none; } -.tv-episode:hover, .tv-episode:focus { background: var(--bg-hover); } -.tv-episode:focus { outline: var(--focus-ring); outline-offset: -2px; } +.tv-episode-card:hover, .tv-episode-card:focus { background: var(--bg-hover); } +.tv-episode-card:focus { outline: var(--focus-ring); outline-offset: -2px; } -.tv-episode-num { color: var(--text-muted); font-weight: 600; min-width: 3rem; font-size: 0.9rem; } -.tv-episode-title { flex: 1; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.tv-episode-meta { color: var(--text-muted); font-size: 0.8rem; white-space: nowrap; } -.tv-episode-play { color: var(--accent); font-size: 1.2rem; } +/* Thumbnail-Bereich */ +.tv-ep-thumb { + position: relative; + flex-shrink: 0; + width: 200px; + aspect-ratio: 16 / 9; + border-radius: calc(var(--radius) - 2px); + overflow: hidden; + background: var(--bg-card); +} +.tv-ep-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.tv-ep-progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(255,255,255,0.2); +} +.tv-ep-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 0 2px 2px 0; +} +.tv-ep-watched { + position: absolute; + top: 4px; + right: 4px; + background: rgba(0,0,0,0.7); + color: var(--accent); + width: 24px; + height: 24px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: bold; +} +.tv-ep-duration { + position: absolute; + bottom: 6px; + right: 6px; + background: rgba(0,0,0,0.75); + color: #fff; + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 3px; +} + +/* Info-Bereich */ +.tv-ep-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; + padding: 0.2rem 0; +} +.tv-ep-header { + display: flex; + align-items: baseline; + gap: 0.5rem; +} +.tv-ep-num { + color: var(--text-muted); + font-weight: 600; + font-size: 0.85rem; + flex-shrink: 0; +} +.tv-ep-title { + font-size: 0.95rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tv-ep-desc { + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} +.tv-ep-meta { + color: var(--text-muted); + font-size: 0.75rem; + margin-top: auto; +} + +/* Serien-Detail Aktionen */ +.tv-detail-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.8rem; +} + +/* Legacy-Klassen fuer Rueckwaertskompatibilitaet */ +.tv-episode { display: none; } + +/* === Ansichts-Umschalter === */ +.tv-list-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} +.tv-list-header .tv-page-title { margin: 0; } +.tv-view-switch { + display: flex; + gap: 4px; + background: var(--bg-card); + border-radius: var(--radius); + padding: 3px; +} +.tv-view-btn { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px 8px; + border-radius: calc(var(--radius) - 2px); + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s, color 0.2s; +} +.tv-view-btn svg { fill: currentColor; } +.tv-view-btn:hover { color: var(--text); background: var(--bg-hover); } +.tv-view-btn.active { color: var(--accent); background: var(--bg-hover); } +.tv-view-btn:focus { outline: var(--focus-ring); outline-offset: -1px; } + +/* === Kompakte Liste === */ +.tv-list-compact { + display: flex; + flex-direction: column; + gap: 2px; +} +.tv-list-item { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.5rem 0.8rem; + background: var(--bg-card); + border-radius: var(--radius); + color: var(--text); + text-decoration: none; + transition: background 0.2s; +} +.tv-list-item:hover, .tv-list-item:focus { + background: var(--bg-hover); +} +.tv-list-item:focus { outline: var(--focus-ring); outline-offset: -2px; } +.tv-list-poster { + width: 40px; + height: 56px; + flex-shrink: 0; + border-radius: 3px; + overflow: hidden; + background: var(--bg-card); +} +.tv-list-poster img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.tv-list-title { + flex: 1; + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.tv-list-genre { + color: var(--text-muted); + font-size: 0.8rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} +.tv-list-count { + color: var(--text-muted); + font-size: 0.8rem; + white-space: nowrap; + min-width: 50px; + text-align: right; +} + +/* === Detail-Liste === */ +.tv-detail-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.tv-detail-item { + display: flex; + gap: 1rem; + padding: 0.6rem; + background: var(--bg-card); + border-radius: var(--radius); + color: var(--text); + text-decoration: none; + transition: background 0.2s; +} +.tv-detail-item:hover, .tv-detail-item:focus { + background: var(--bg-hover); +} +.tv-detail-item:focus { outline: var(--focus-ring); outline-offset: -2px; } +.tv-detail-thumb { + width: 80px; + height: 112px; + flex-shrink: 0; + border-radius: calc(var(--radius) - 2px); + overflow: hidden; + background: var(--bg-card); +} +.tv-detail-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.tv-detail-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; + padding: 0.2rem 0; +} +.tv-detail-title { + font-size: 1rem; + font-weight: 600; +} +.tv-detail-desc { + color: var(--text-muted); + font-size: 0.82rem; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin: 0; +} +.tv-detail-meta { + color: var(--text-muted); + font-size: 0.78rem; + margin-top: auto; +} + +/* === Filter-Leiste === */ +.tv-filter-bar { + display: flex; + align-items: center; + gap: 0.8rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.tv-genre-chips { + display: flex; + gap: 0.3rem; + flex-wrap: wrap; + flex: 1; +} +.tv-chip { + display: inline-block; + padding: 0.3rem 0.7rem; + background: var(--bg-card); + border: 1px solid transparent; + border-radius: 99px; + color: var(--text-muted); + font-size: 0.8rem; + text-decoration: none; + transition: background 0.2s, color 0.2s; + white-space: nowrap; +} +.tv-chip:hover { color: var(--text); background: var(--bg-hover); } +.tv-chip.active { color: var(--accent); border-color: var(--accent); font-weight: 600; } +.tv-chip:focus { outline: var(--focus-ring); outline-offset: -1px; } +.tv-sort-select { + padding: 0.4rem 0.8rem; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 0.8rem; + cursor: pointer; + flex-shrink: 0; +} +.tv-sort-select:focus { border-color: var(--accent); outline: none; } +.tv-source-tabs { margin-bottom: 0.5rem; } /* === Suche === */ .tv-search-form { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } +.tv-search-wrapper { flex: 1; position: relative; } .tv-search-input { - flex: 1; + width: 100%; padding: 0.8rem 1rem; background: var(--bg-input); - border: 1px solid #333; + border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 1rem; + box-sizing: border-box; } .tv-search-input:focus { border-color: var(--accent); outline: none; } + +/* Autocomplete-Dropdown */ +.tv-autocomplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-card); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + max-height: 300px; + overflow-y: auto; + z-index: 50; +} +.tv-ac-item { + display: block; + padding: 0.6rem 1rem; + color: var(--text); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.15s; +} +.tv-ac-item:hover { background: var(--bg-hover); } + +/* Such-History */ +.tv-search-history-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} +.tv-link-btn { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + font-size: 0.8rem; + padding: 0.3rem; +} +.tv-link-btn:hover { text-decoration: underline; } +.tv-search-history-list { + display: flex; + flex-direction: column; + gap: 2px; +} +.tv-search-history-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.8rem; + background: var(--bg-card); + border-radius: var(--radius); + color: var(--text); + text-decoration: none; + font-size: 0.9rem; + transition: background 0.2s; +} +.tv-search-history-item:hover { background: var(--bg-hover); } +.tv-search-history-icon { color: var(--text-muted); font-size: 0.8rem; } + .tv-search-btn { padding: 0.8rem 1.5rem; background: var(--accent); @@ -322,7 +723,7 @@ a { color: var(--accent); text-decoration: none; } width: 100%; padding: 0.8rem 1rem; background: var(--bg-input); - border: 1px solid #333; + border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 1rem; @@ -439,6 +840,122 @@ a { color: var(--accent); text-decoration: none; } pointer-events: none; } +/* === Player-Overlay (Einstellungen) === */ +.player-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + justify-content: flex-end; + z-index: 20; +} +.player-overlay-panel { + width: 320px; + max-width: 90vw; + height: 100%; + overflow-y: auto; + padding: 2rem 1.5rem; + background: rgba(20, 20, 20, 0.95); +} +.player-overlay-section { margin-bottom: 1.5rem; } +.player-overlay-section h3 { + font-size: 0.85rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} +.overlay-option { + display: block; + width: 100%; + text-align: left; + padding: 0.6rem 1rem; + background: transparent; + border: none; + color: var(--text); + font-size: 0.95rem; + cursor: pointer; + border-radius: var(--radius); + transition: background 0.2s; +} +.overlay-option:hover, .overlay-option:focus { + background: var(--bg-hover); + outline: none; +} +.overlay-option.active { + color: var(--accent); + font-weight: 600; +} +.overlay-option.active::before { + content: "\2713 "; +} + +/* === Naechste Episode Overlay === */ +.player-next-overlay, .player-still-watching { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 25; +} +.player-next-card { + background: var(--bg-card); + border-radius: 16px; + padding: 2.5rem; + text-align: center; + max-width: 400px; + width: 90%; +} +.player-next-title { + font-size: 1.2rem; + font-weight: 600; + margin-bottom: 0.5rem; +} +.player-next-name { + color: var(--text-muted); + font-size: 0.95rem; + margin-bottom: 1rem; +} +.player-next-countdown { + font-size: 2rem; + font-weight: 700; + color: var(--accent); + margin-bottom: 1.5rem; +} +.player-next-buttons { + display: flex; + gap: 1rem; + justify-content: center; +} +.player-btn-cancel { + padding: 0.8rem 2rem; + background: transparent; + border: 1px solid var(--text-muted); + color: var(--text); + border-radius: var(--radius); + cursor: pointer; + font-size: 1rem; +} +.player-btn-cancel:hover, .player-btn-cancel:focus { + border-color: var(--text); + outline: var(--focus-ring); +} + +/* Player-Overlay responsive: Handy als Bottom-Sheet */ +@media (max-width: 480px) { + .player-overlay { justify-content: center; align-items: flex-end; } + .player-overlay-panel { + width: 100%; + max-width: 100%; + height: auto; + max-height: 70vh; + border-radius: 16px 16px 0 0; + padding: 1.5rem 1rem; + } +} + /* === Responsive === */ @media (max-width: 768px) { .tv-nav { padding: 0.4rem 0.8rem; } @@ -451,6 +968,13 @@ a { color: var(--accent); text-decoration: none; } .tv-detail-poster { width: 150px; } .tv-page-title { font-size: 1.3rem; } .tv-nav-user { display: none; } + /* Episoden-Karten: Thumbnail kleiner */ + .tv-ep-thumb { width: 140px; } + .tv-ep-desc { -webkit-line-clamp: 1; } + /* Listen-Ansicht: Genre ausblenden */ + .tv-list-genre { display: none; } + /* Detail-Liste: Poster kleiner */ + .tv-detail-thumb { width: 60px; height: 84px; } } @media (max-width: 480px) { @@ -459,6 +983,18 @@ a { color: var(--accent); text-decoration: none; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } .tv-row .tv-card { width: 120px; } .tv-detail-poster { width: 120px; } + /* Episoden-Karten: kompakt auf Handy */ + .tv-ep-thumb { width: 100px; } + .tv-ep-title { font-size: 0.85rem; } + .tv-ep-desc { display: none; } + .tv-episode-card { gap: 0.6rem; padding: 0.4rem; } + /* Detail-Liste: Description ausblenden */ + .tv-detail-desc { display: none; } + .tv-detail-thumb { width: 50px; height: 70px; } + .tv-detail-item { gap: 0.6rem; } + /* Listen-Ansicht: kleiner */ + .tv-list-poster { width: 32px; height: 45px; } + .tv-list-item { padding: 0.4rem 0.6rem; gap: 0.5rem; } } /* TV/Desktop (grosse Bildschirme) */ @@ -466,6 +1002,369 @@ a { color: var(--accent); text-decoration: none; } .tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; } .tv-row .tv-card { width: 200px; } .tv-row .tv-card-wide { width: 300px; } - .tv-episode { padding: 1rem 1.5rem; } .tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; } + /* Episoden-Karten: groesser auf TV */ + .tv-ep-thumb { width: 260px; } + .tv-ep-desc { -webkit-line-clamp: 3; } + .tv-episode-card { padding: 0.8rem; gap: 1.2rem; } + /* Detail-Liste: groesser */ + .tv-detail-thumb { width: 100px; height: 140px; } + .tv-detail-desc { -webkit-line-clamp: 3; } + .tv-detail-title { font-size: 1.1rem; } + /* Listen-Ansicht */ + .tv-list-poster { width: 48px; height: 67px; } + .tv-list-item { padding: 0.6rem 1rem; } +} + +/* === Avatar (Profilfarbe) === */ +.tv-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: #000; + font-weight: 700; + font-size: 0.85rem; + flex-shrink: 0; +} +.tv-avatar-lg { + width: 80px; + height: 80px; + font-size: 2rem; +} +.tv-nav-profile { display: flex; align-items: center; } +.tv-nav-profile:focus .tv-avatar { outline: var(--focus-ring); outline-offset: 3px; } + +/* === Profilauswahl === */ +.profiles-container { + width: 100%; + max-width: 800px; + margin: 0 auto; + padding: 3rem 1rem; + text-align: center; +} +.profiles-title { + font-size: 2rem; + font-weight: 700; + margin-bottom: 2rem; + color: var(--text); +} +.profiles-grid { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1.5rem; +} +.profile-form { display: contents; } +.profile-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.8rem; + padding: 1.5rem; + background: transparent; + border: 2px solid transparent; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + color: var(--text); + text-decoration: none; + min-width: 120px; +} +.profile-card:hover, .profile-card:focus { + background: var(--bg-card); + border-color: var(--accent); + outline: none; +} +.profile-active { border-color: var(--accent); } +.profile-add-icon { background: var(--border) !important; color: var(--text-muted) !important; } +.profile-name { font-size: 0.95rem; font-weight: 500; } + +/* === Einstellungen === */ +.settings-form { max-width: 600px; } +.settings-group { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.2rem; + margin-bottom: 1.2rem; +} +.settings-group legend { + color: var(--accent); + font-weight: 600; + font-size: 0.95rem; + padding: 0 0.5rem; +} +.settings-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.8rem; + font-size: 0.9rem; + color: var(--text); +} +.settings-check { + justify-content: flex-start; + gap: 0.5rem; + cursor: pointer; +} +.settings-check input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); +} +.settings-input, .settings-select { + padding: 0.5rem 0.8rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 0.9rem; + min-width: 180px; +} +.settings-input:focus, .settings-select:focus { + border-color: var(--accent); + outline: none; +} +.settings-color { + width: 50px; + height: 36px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input); + cursor: pointer; + padding: 2px; +} +.settings-save { margin-top: 1rem; } +.settings-danger { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + display: flex; + gap: 1rem; + flex-wrap: wrap; +} +.settings-danger-btn { + padding: 0.6rem 1.2rem; + background: transparent; + border: 1px solid var(--danger); + color: var(--danger); + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + transition: background 0.2s; +} +.settings-danger-btn:hover, .settings-danger-btn:focus { + background: rgba(239, 83, 80, 0.15); + outline: var(--focus-ring); +} + +/* Erfolgs-Meldung */ +.tv-success-msg { + background: rgba(102, 187, 106, 0.15); + color: var(--success); + padding: 0.6rem 1rem; + border-radius: var(--radius); + margin-bottom: 1rem; + font-size: 0.9rem; +} + +/* Login: Angemeldet bleiben */ +.login-remember { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} +.login-remember label { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-muted); + font-size: 0.85rem; + cursor: pointer; +} +.login-remember input[type="checkbox"] { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +/* === Watchlist-Button === */ +.tv-watchlist-btn { + background: none; + border: 1px solid var(--text-muted); + color: var(--text-muted); + padding: 0.5rem 1rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} +.tv-watchlist-btn:hover, .tv-watchlist-btn:focus { + border-color: var(--accent); + color: var(--accent); + outline: var(--focus-ring); +} +.tv-watchlist-btn.active { + background: var(--accent); + color: #000; + border-color: var(--accent); +} + +/* === Watch-Status Badge === */ +.tv-status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; +} +.tv-status-watched { background: var(--success); color: #000; } +.tv-status-watching { background: var(--accent); color: #000; } +.tv-status-unwatched { background: #444; color: var(--text-muted); } + +/* === Bewertungssystem (Rating) === */ +.tv-rating-section { + display: flex; + flex-wrap: wrap; + gap: 0.8rem 1.5rem; + align-items: center; + margin: 0.8rem 0; +} +.tv-rating-user { + display: flex; + align-items: center; + gap: 0.5rem; +} +.tv-rating-label { + font-size: 0.85rem; + color: var(--text-muted); + white-space: nowrap; +} +.tv-stars-input { + display: flex; + align-items: center; + gap: 2px; +} +.tv-stars-input .tv-star { + font-size: 1.6rem; + color: #555; + cursor: pointer; + transition: color 0.15s, transform 0.15s; + user-select: none; + line-height: 1; +} +.tv-stars-input .tv-star:hover, +.tv-stars-input .tv-star:focus { + transform: scale(1.2); +} +.tv-stars-input .tv-star.active { + color: #f5c518; +} +.tv-stars-input .tv-star:hover ~ .tv-star { + color: #555 !important; +} +/* Hover-Effekt: alle Sterne bis zum gehovertem golden */ +.tv-stars-input:hover .tv-star { + color: #f5c518; +} +.tv-stars-input:hover .tv-star:hover ~ .tv-star { + color: #555; +} +.tv-rating-remove { + font-size: 0.9rem; + color: var(--text-muted); + cursor: pointer; + margin-left: 4px; + padding: 2px 6px; + border-radius: 3px; + transition: color 0.15s, background 0.15s; +} +.tv-rating-remove:hover, +.tv-rating-remove:focus { + color: #f44; + background: rgba(255, 68, 68, 0.1); +} + +/* Durchschnittsbewertung (nur Anzeige) */ +.tv-rating-avg { + display: flex; + align-items: center; + gap: 0.4rem; +} +.tv-stars-display { + display: flex; + gap: 1px; +} +.tv-stars-display .tv-star { + font-size: 1rem; + color: #555; + line-height: 1; +} +.tv-stars-display .tv-star.active { + color: #f5c518; +} +.tv-rating-text { + font-size: 0.85rem; + color: var(--text-muted); +} + +/* Externe Bewertung (TVDB Badge) */ +.tv-rating-external { + display: flex; + align-items: center; +} +.tv-rating-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; +} +.tv-rating-badge.tvdb { + background: #3b7; + color: #000; +} + +/* Mini-Sterne fuer Listen/Karten */ +.tv-star-sm { + font-size: 0.75rem; + color: #555; + line-height: 1; +} +.tv-star-sm.active { + color: #f5c518; +} +.tv-card-stars { + white-space: nowrap; +} +.tv-list-rating { + min-width: 70px; + white-space: nowrap; +} + +/* Rating-Filter Dropdown */ +.tv-rating-filter { + min-width: 100px; +} + +/* Responsive: Rating kleiner auf Handy */ +@media (max-width: 480px) { + .tv-rating-section { + gap: 0.5rem; + } + .tv-stars-input .tv-star { + font-size: 1.3rem; + } + .tv-rating-label { + font-size: 0.75rem; + } + .tv-list-rating { + display: none; + } } diff --git a/video-konverter/app/static/tv/i18n/de.json b/video-konverter/app/static/tv/i18n/de.json new file mode 100644 index 0000000..1e774c4 --- /dev/null +++ b/video-konverter/app/static/tv/i18n/de.json @@ -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" + } +} diff --git a/video-konverter/app/static/tv/i18n/en.json b/video-konverter/app/static/tv/i18n/en.json new file mode 100644 index 0000000..21e329f --- /dev/null +++ b/video-konverter/app/static/tv/i18n/en.json @@ -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" + } +} diff --git a/video-konverter/app/static/tv/js/player.js b/video-konverter/app/static/tv/js/player.js index 69d2deb..feeabea 100644 --- a/video-konverter/app/static/tv/js/player.js +++ b/video-konverter/app/static/tv/js/player.js @@ -1,28 +1,35 @@ /** - * VideoKonverter TV - Video-Player - * Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung - * Speichert Watch-Progress automatisch + * VideoKonverter TV - Video-Player v4.0 + * Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl, + * Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung. */ +// === State === let videoEl = null; -let videoId = 0; -let videoDuration = 0; +let cfg = {}; // Konfiguration aus initPlayer() +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 timeDisplay = null; let playBtn = null; let controlsTimer = null; let saveTimer = null; let controlsVisible = true; +let overlayOpen = false; +let nextCountdown = null; +let episodesWatched = 0; +let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming /** * Player initialisieren - * @param {number} id - Video-ID - * @param {number} startPos - Startposition in Sekunden - * @param {number} duration - Video-Dauer in Sekunden (Fallback) + * @param {Object} opts - Konfiguration */ -function initPlayer(id, startPos, duration) { - videoId = id; - videoDuration = duration; +function initPlayer(opts) { + cfg = opts; + currentQuality = opts.streamQuality || "hd"; videoEl = document.getElementById("player-video"); progressBar = document.getElementById("player-progress-bar"); @@ -31,10 +38,11 @@ function initPlayer(id, startPos, duration) { if (!videoEl) return; - // Stream-URL setzen (ffmpeg-Transcoding Endpoint) - const streamUrl = `/api/library/videos/${id}/stream` + - (startPos > 0 ? `?t=${Math.floor(startPos)}` : ""); - videoEl.src = streamUrl; + // Video-Info laden (Audio/Subtitle-Tracks) + loadVideoInfo().then(() => { + // Stream starten + setStreamUrl(opts.startPos || 0); + }); // Events videoEl.addEventListener("timeupdate", onTimeUpdate); @@ -43,78 +51,152 @@ function initPlayer(id, startPos, duration) { videoEl.addEventListener("ended", onEnded); videoEl.addEventListener("loadedmetadata", () => { if (videoEl.duration && isFinite(videoEl.duration)) { - videoDuration = videoEl.duration; + cfg.duration = videoEl.duration + seekOffset; } }); - - // Klick auf Video -> Play/Pause videoEl.addEventListener("click", togglePlay); // Controls UI playBtn.addEventListener("click", togglePlay); document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen); - - // Progress-Bar klickbar fuer Seeking 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 document.addEventListener("keydown", onKeyDown); - - // Maus/Touch-Bewegung -> Controls anzeigen document.addEventListener("mousemove", showControls); document.addEventListener("touchstart", showControls); - // Controls nach 4 Sekunden ausblenden scheduleHideControls(); - - // Watch-Progress alle 10 Sekunden speichern 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 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 === function togglePlay() { if (!videoEl) return; - if (videoEl.paused) { - videoEl.play(); - } else { - videoEl.pause(); - } + if (videoEl.paused) videoEl.play(); + else videoEl.pause(); } function onPlay() { - if (playBtn) playBtn.innerHTML = "❚❚"; // Pause-Symbol + if (playBtn) playBtn.innerHTML = "❚❚"; scheduleHideControls(); } function onPause() { - if (playBtn) playBtn.innerHTML = "▶"; // Play-Symbol + if (playBtn) playBtn.innerHTML = "▶"; showControls(); - // Sofort speichern bei Pause saveProgress(); } function onEnded() { - // Video fertig -> als "completed" speichern saveProgress(true); - // Zurueck navigieren nach 2 Sekunden - setTimeout(() => { - window.history.back(); - }, 2000); + episodesWatched++; + + // Schaust du noch? (wenn Max-Episoden erreicht) + 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 === function seekRelative(seconds) { if (!videoEl) return; - const newTime = Math.max(0, Math.min( - videoEl.currentTime + seconds, - videoEl.duration || videoDuration - )); - // Neue Stream-URL mit Zeitstempel - const wasPlaying = !videoEl.paused; - videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`; - if (wasPlaying) videoEl.play(); + const totalTime = seekOffset + videoEl.currentTime; + const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); + const newTime = Math.max(0, Math.min(totalTime + seconds, dur)); + setStreamUrl(newTime); showControls(); } @@ -122,29 +204,22 @@ function onProgressClick(e) { if (!videoEl) return; const rect = e.currentTarget.getBoundingClientRect(); const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const dur = videoEl.duration || videoDuration; + const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); if (!dur) return; - const newTime = 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(); + setStreamUrl(pct * dur); showControls(); } -// === Zeit-Anzeige und Progress === +// === Zeit-Anzeige === function onTimeUpdate() { if (!videoEl) return; - const current = videoEl.currentTime; - const dur = videoEl.duration || videoDuration; + const current = seekOffset + videoEl.currentTime; + const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); - // Progress-Bar if (progressBar && dur > 0) { progressBar.style.width = ((current / dur) * 100) + "%"; } - - // Zeit-Anzeige if (timeDisplay) { timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur); } @@ -155,9 +230,7 @@ function formatTime(sec) { const h = Math.floor(sec / 3600); const m = Math.floor((sec % 3600) / 60); const s = Math.floor(sec % 60); - if (h > 0) { - return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); - } + if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); return m + ":" + String(s).padStart(2, "0"); } @@ -171,7 +244,7 @@ function showControls() { } function hideControls() { - if (!videoEl || videoEl.paused) return; + if (!videoEl || videoEl.paused || overlayOpen) return; const wrapper = document.getElementById("player-wrapper"); if (wrapper) wrapper.classList.add("player-hide-controls"); 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 = "

Audio

"; + 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 += ``; + }); + audioEl.innerHTML = html; + } + + // Untertitel + const subsEl = document.getElementById("overlay-subs"); + if (subsEl && videoInfo) { + let html = "

Untertitel

"; + html += ``; + if (videoInfo.subtitle_tracks) { + videoInfo.subtitle_tracks.forEach((s, i) => { + const label = langName(s.lang) || `Spur ${i + 1}`; + const active = i === currentSub ? " active" : ""; + html += ``; + }); + } + 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 = "

Qualit\u00e4t

"; + qualities.forEach(([val, label]) => { + const active = val === currentQuality ? " active" : ""; + html += ``; + }); + 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 = "

Geschwindigkeit

"; + speeds.forEach(s => { + const active = s === currentSpeed ? " active" : ""; + html += ``; + }); + 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 === function onKeyDown(e) { // Samsung Tizen Remote Keys const keyMap = { - 10009: "Escape", - 10182: "Escape", - 415: "Play", - 19: "Pause", - 413: "Stop", - 417: "FastForward", - 412: "Rewind", + 10009: "Escape", 10182: "Escape", + 415: "Play", 19: "Pause", 413: "Stop", + 417: "FastForward", 412: "Rewind", }; const key = keyMap[e.keyCode] || e.key; + // Overlay offen? -> Navigation im Overlay + if (overlayOpen && (key === "Escape" || key === "Backspace")) { + toggleOverlay(); + e.preventDefault(); + return; + } + switch (key) { - case " ": - case "Enter": - case "Play": - case "Pause": - togglePlay(); - e.preventDefault(); - break; - case "ArrowLeft": - case "Rewind": - seekRelative(-10); - e.preventDefault(); - break; - case "ArrowRight": - case "FastForward": - seekRelative(10); - e.preventDefault(); - break; + case " ": case "Enter": case "Play": case "Pause": + togglePlay(); e.preventDefault(); break; + case "ArrowLeft": case "Rewind": + seekRelative(-10); e.preventDefault(); break; + case "ArrowRight": case "FastForward": + seekRelative(10); e.preventDefault(); break; case "ArrowUp": - // Lautstaerke hoch (falls vom Browser unterstuetzt) if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1); - showControls(); - e.preventDefault(); - break; + showControls(); e.preventDefault(); break; case "ArrowDown": - // Lautstaerke runter if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1); - showControls(); - e.preventDefault(); - break; - case "Escape": - case "Backspace": - case "Stop": - // Zurueck navigieren + showControls(); e.preventDefault(); break; + case "Escape": case "Backspace": case "Stop": saveProgress(); setTimeout(() => window.history.back(), 100); - e.preventDefault(); - break; + e.preventDefault(); break; case "f": - toggleFullscreen(); - e.preventDefault(); - break; + toggleFullscreen(); e.preventDefault(); break; + case "s": + toggleOverlay(); e.preventDefault(); break; + case "n": + if (cfg.nextVideoId) playNextEpisode(); + e.preventDefault(); break; } } // === Watch-Progress speichern === function saveProgress(completed) { - if (!videoId || !videoEl) return; - const pos = videoEl.currentTime || 0; - const dur = videoEl.duration || videoDuration || 0; - if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern + if (!cfg.videoId || !videoEl) return; + const pos = seekOffset + (videoEl.currentTime || 0); + const dur = cfg.duration || (seekOffset + (videoEl.duration || 0)); + if (pos < 5 && !completed) return; fetch("/tv/api/watch-progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - video_id: videoId, + video_id: cfg.videoId, position_sec: pos, duration_sec: dur, }), - }).catch(() => {}); // Fehler ignorieren (nicht kritisch) + }).catch(() => {}); } -// Beim Verlassen der Seite speichern 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 || ""; +} diff --git a/video-konverter/app/templates/tv/base.html b/video-konverter/app/templates/tv/base.html index a43cfd8..6fc4c37 100644 --- a/video-konverter/app/templates/tv/base.html +++ b/video-konverter/app/templates/tv/base.html @@ -1,5 +1,6 @@ - + @@ -17,18 +18,24 @@ {% if user is defined and user %} {% endif %} diff --git a/video-konverter/app/templates/tv/login.html b/video-konverter/app/templates/tv/login.html index 06a68ff..e866844 100644 --- a/video-konverter/app/templates/tv/login.html +++ b/video-konverter/app/templates/tv/login.html @@ -30,6 +30,12 @@ autocomplete="current-password" data-focusable required> + diff --git a/video-konverter/app/templates/tv/movie_detail.html b/video-konverter/app/templates/tv/movie_detail.html index f482591..56aa7bd 100644 --- a/video-konverter/app/templates/tv/movie_detail.html +++ b/video-konverter/app/templates/tv/movie_detail.html @@ -19,31 +19,140 @@

{{ movie.overview }}

{% endif %} - {% if videos %} -
- - ▶ Abspielen - + +
+
+ {{ t('rating.your_rating') }}: +
+ {% for i in range(1, 6) %} + + {% endfor %} + {% if user_rating > 0 %} + + {% endif %} +
+
+ {% if avg_rating.count > 0 %} +
+ + {% for i in range(1, 6) %} + + {% endfor %} + + {{ avg_rating.avg }} ({{ avg_rating.count }}) +
+ {% endif %} + {% if tvdb_score %} +
+ TVDB {{ "%.0f"|format(tvdb_score) }}% +
+ {% endif %} +
+ +
+ {% if videos %} + + ▶ {{ t('player.play') }} + + {% endif %} +
- {% endif %}
{% if videos|length > 1 %} -

Versionen

+

{{ t('movies.versions') }}

{% endif %} {% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/tv/movies.html b/video-konverter/app/templates/tv/movies.html index 8943dc4..f3a3b61 100644 --- a/video-konverter/app/templates/tv/movies.html +++ b/video-konverter/app/templates/tv/movies.html @@ -1,10 +1,81 @@ {% extends "tv/base.html" %} -{% block title %}Filme - VideoKonverter TV{% endblock %} +{% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %} {% block content %}
-

Filme

-
+
+

{{ t('movies.title') }}

+
+ + + +
+
+ + + {% if sources|length > 1 %} +
+ + {{ t('filter.all') }} + + {% for src in sources %} + + {{ src.name }} + + {% endfor %} +
+ {% endif %} + + +
+ {% if genres %} +
+ + {{ t('filter.all') }} + + {% for g in genres %} + + {{ g }} + + {% endfor %} +
+ {% endif %} + + + +
+ + + + + + + + + + {% if not movies %} -
Keine Filme vorhanden.
+
{{ t('movies.no_movies') }}
{% endif %}
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/tv/player.html b/video-konverter/app/templates/tv/player.html index d43ea6c..45b77af 100644 --- a/video-konverter/app/templates/tv/player.html +++ b/video-konverter/app/templates/tv/player.html @@ -11,14 +11,12 @@
- +
@@ -29,14 +27,70 @@ 0:00 / 0:00 + + {% if next_video %} + + {% endif %}
+ + + + + + {% if next_video %} + + {% endif %} + + + diff --git a/video-konverter/app/templates/tv/profiles.html b/video-konverter/app/templates/tv/profiles.html new file mode 100644 index 0000000..2de96d2 --- /dev/null +++ b/video-konverter/app/templates/tv/profiles.html @@ -0,0 +1,37 @@ + + + + + + + + {{ t('profiles.title') }} - VideoKonverter TV + + +
+

{{ t('profiles.title') }}

+ +
+ {% for p in profiles %} +
+ + +
+ {% endfor %} + + + + + + {{ t('profiles.add_user') }} + +
+
+ + + + diff --git a/video-konverter/app/templates/tv/search.html b/video-konverter/app/templates/tv/search.html index 3a4e640..e06415d 100644 --- a/video-konverter/app/templates/tv/search.html +++ b/video-konverter/app/templates/tv/search.html @@ -1,20 +1,23 @@ {% extends "tv/base.html" %} -{% block title %}Suche - VideoKonverter TV{% endblock %} +{% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %} {% block content %}
-

Suche

-
- - +

{{ t('search.title') }}

+ +
+ + +
+
{% if query %} {% if series %} -

Serien ({{ series|length }})

+

{{ t('search.results_series') }} ({{ series|length }})

{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/tv/series.html b/video-konverter/app/templates/tv/series.html index ba1ec25..085c76b 100644 --- a/video-konverter/app/templates/tv/series.html +++ b/video-konverter/app/templates/tv/series.html @@ -1,10 +1,81 @@ {% extends "tv/base.html" %} -{% block title %}Serien - VideoKonverter TV{% endblock %} +{% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %} {% block content %}
-

Serien

-
+
+

{{ t('series.title') }}

+
+ + + +
+
+ + + {% if sources|length > 1 %} +
+ + {{ t('filter.all') }} + + {% for src in sources %} + + {{ src.name }} + + {% endfor %} +
+ {% endif %} + + +
+ {% if genres %} +
+ + {{ t('filter.all') }} + + {% for g in genres %} + + {{ g }} + + {% endfor %} +
+ {% endif %} + + + +
+ + + + + + + + + + {% if not series %} -
Keine Serien vorhanden.
+
{{ t('series.no_series') }}
{% endif %}
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/tv/series_detail.html b/video-konverter/app/templates/tv/series_detail.html index c615ffd..5704739 100644 --- a/video-konverter/app/templates/tv/series_detail.html +++ b/video-konverter/app/templates/tv/series_detail.html @@ -16,6 +16,54 @@ {% if series.overview %}

{{ series.overview }}

{% endif %} + + +
+ +
+ {{ t('rating.your_rating') }}: +
+ {% for i in range(1, 6) %} + + {% endfor %} + {% if user_rating > 0 %} + + {% endif %} +
+
+ + {% if avg_rating.count > 0 %} +
+ + {% for i in range(1, 6) %} + + {% endfor %} + + {{ avg_rating.avg }} ({{ avg_rating.count }}) +
+ {% endif %} + + {% if tvdb_score %} +
+ TVDB {{ "%.0f"|format(tvdb_score) }}% +
+ {% endif %} +
+ +
+ +
@@ -26,7 +74,7 @@ {% endfor %} @@ -36,25 +84,51 @@ {% endfor %} {% else %} -
Keine Episoden vorhanden.
+
{{ t('series.no_episodes') }}
{% endif %} {% endblock %} @@ -72,5 +146,62 @@ function showSeason(sn) { // Tab aktivieren 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 = '♥'; + } else { + btn.classList.remove('active'); + btn.querySelector('.watchlist-icon').innerHTML = '♡'; + } + }) + .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 = '✕'; + 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(() => {}); +} {% endblock %} diff --git a/video-konverter/app/templates/tv/settings.html b/video-konverter/app/templates/tv/settings.html new file mode 100644 index 0000000..07350ba --- /dev/null +++ b/video-konverter/app/templates/tv/settings.html @@ -0,0 +1,176 @@ +{% extends "tv/base.html" %} +{% block title %}{{ t('settings.title') }} - VideoKonverter TV{% endblock %} + +{% block content %} +
+

{{ t('settings.title') }}

+ + {% if request.query.get('saved') %} +
{{ t('settings.saved') }}
+ {% endif %} + {% if request.query.get('reset') %} +
{{ t('status.reset_progress') }} ✓
+ {% endif %} + +
+ + +
+ {{ t('settings.profile') }} + + +
+ + +
+ {{ t('settings.language') }} + + + + +
+ + +
+ {{ t('settings.views') }} + + + +
+ + +
+ {{ t('settings.autoplay') }} + + + +
+ + + {% if client %} +
+ {{ t('settings.client_settings') }} + + + +
+ {% endif %} + + +
+ + +
+
+ +
+
+ +
+
+
+{% endblock %} diff --git a/video-konverter/app/templates/tv/watchlist.html b/video-konverter/app/templates/tv/watchlist.html new file mode 100644 index 0000000..0d632f5 --- /dev/null +++ b/video-konverter/app/templates/tv/watchlist.html @@ -0,0 +1,56 @@ +{% extends "tv/base.html" %} +{% block title %}{{ t('watchlist.title') }} - VideoKonverter TV{% endblock %} + +{% block content %} +
+

{{ t('watchlist.title') }}

+ + {% if not series and not movies %} +
{{ t('watchlist.empty') }}
+ {% endif %} + + {% if series %} + + {% endif %} + + {% if movies %} + + {% endif %} +
+{% endblock %}