feat: VideoKonverter v4.0 - Streaming-Client Ausbau
TV-App komplett ueberarbeitet: i18n (DE/EN), Multi-User Quick-Switch, 3 Themes (Dark/Medium/Light), 3 Ansichten (Grid/Liste/Detail), Filter (Quellen/Genre/Rating/Sortierung), Merkliste, 5-Sterne-Bewertung, Watch-Status, Player-Overlay (Audio/Untertitel/Qualitaet/Naechste Episode), Episoden-Thumbnails, Suchverlauf, Queue-Bugfix (delete_source). 5 neue DB-Tabellen, 10+ neue API-Endpunkte, ~3800 neue Zeilen Code. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a1be045a7d
commit
6d0b8936c5
23 changed files with 4590 additions and 280 deletions
185
CHANGELOG.md
185
CHANGELOG.md
|
|
@ -2,6 +2,191 @@
|
||||||
|
|
||||||
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
||||||
|
|
||||||
|
## [4.0.0] - 2026-03-01
|
||||||
|
|
||||||
|
### TV-App: Vollwertiger Streaming-Client
|
||||||
|
|
||||||
|
Kompletter Ausbau der TV-App von einfachem Browser zu einem Netflix-aehnlichen Streaming-Client
|
||||||
|
mit Multi-User, Einstellungen, Bewertungen, Merkliste und Internationalisierung.
|
||||||
|
|
||||||
|
#### Internationalisierung (i18n)
|
||||||
|
- JSON-basiertes Uebersetzungssystem (`static/tv/i18n/de.json`, `en.json`)
|
||||||
|
- Jinja2-Template-Funktion `t('key.subkey')` fuer alle Texte
|
||||||
|
- Neuer Service `app/services/i18n.py` mit Sprach-Loader und Fallback (DE)
|
||||||
|
- Pro-User Spracheinstellung (`ui_lang` in tv_users)
|
||||||
|
- Alle Templates komplett auf i18n umgestellt
|
||||||
|
|
||||||
|
#### Multi-User & Profil-Wechsel
|
||||||
|
- Quick-Switch: Profilauswahl-Screen (`/tv/profiles`) ohne erneutes Passwort
|
||||||
|
- Mehrere User pro Geraet (Client), Sessions ueber `vk_client_id` Cookie
|
||||||
|
- Profilfarben (Avatar-Kreis) pro User konfigurierbar
|
||||||
|
- "Angemeldet bleiben" Option beim Login (permanente vs. 30-Tage-Sessions)
|
||||||
|
- Neue DB-Tabelle `tv_clients` fuer Geraete-Einstellungen
|
||||||
|
|
||||||
|
#### Benutzer-Einstellungen (`/tv/settings`)
|
||||||
|
- Menusprache (DE/EN), Audio-Sprache, Untertitel-Sprache
|
||||||
|
- Theme-Auswahl (Dunkel/Mittel/Hell) mit Live-Vorschau
|
||||||
|
- Serien- und Film-Ansicht (Raster/Liste/Detail)
|
||||||
|
- Autoplay: An/Aus, Countdown-Dauer, Max. Folgen am Stueck
|
||||||
|
- Suchverlauf loeschen, Fortschritte zuruecksetzen
|
||||||
|
|
||||||
|
#### Themes (Dark/Medium/Light)
|
||||||
|
- CSS Custom Properties (`--bg-primary`, `--text-primary`, etc.)
|
||||||
|
- `data-theme` Attribut auf `<html>`, gespeichert pro User
|
||||||
|
- Dunkel (Standard), Mittel (grau), Hell (weiss)
|
||||||
|
- Alle TV-Seiten, Player, Settings, Login unterstuetzen Themes
|
||||||
|
|
||||||
|
#### 3 Ansichten (Grid / Liste / Detail)
|
||||||
|
- **Grid**: Poster-Kacheln im responsiven Grid (wie bisher)
|
||||||
|
- **Liste**: Kompakte 1-Zeile pro Eintrag mit Mini-Poster
|
||||||
|
- **Detail**: Groesseres Poster + Beschreibung + Metadaten
|
||||||
|
- View-Switcher (3 Icons oben rechts) auf Serien- und Filme-Seite
|
||||||
|
- Einstellung wird pro User gespeichert (getrennt fuer Serien/Filme)
|
||||||
|
|
||||||
|
#### Episoden-Darstellung (verbessert)
|
||||||
|
- Episoden-Thumbnails: TVDB-Bilder oder ffmpeg-Fallback (Frame bei 25%)
|
||||||
|
- Episodenbeschreibung aus TVDB-Cache angezeigt
|
||||||
|
- Watch-Progress-Balken pro Episode
|
||||||
|
- Gesehen-Haekchen bei >= 95% Fortschritt
|
||||||
|
- Episodennummer, Titel, Dauer, Codec-Info
|
||||||
|
|
||||||
|
#### Filter & Quellen-Tabs
|
||||||
|
- Quellen-Tabs oben: `[Alle] [Quelle 1] [Quelle 2]` (aus library_paths)
|
||||||
|
- Genre-Chips als Filter unterhalb der Tabs
|
||||||
|
- Sortierung: Name (A-Z/Z-A), Neueste, Episoden-Anzahl, Bewertung
|
||||||
|
- Alle Filter als URL-Parameter (`?source=1&genre=Action&sort=title&rating=3`)
|
||||||
|
|
||||||
|
#### Merkliste (Watchlist)
|
||||||
|
- Herz-Button auf Serien-/Film-Detailseiten (Toggle)
|
||||||
|
- Eigene Seite `/tv/watchlist` mit allen gemerkten Inhalten
|
||||||
|
- Tabs fuer Serien/Filme auf der Merkliste-Seite
|
||||||
|
- Neue DB-Tabelle `tv_watchlist` (user_id + series_id/movie_id)
|
||||||
|
- Navigation: Merkliste als eigener Tab
|
||||||
|
|
||||||
|
#### Bewertungssystem (Rating)
|
||||||
|
- 5-Sterne-Bewertung pro User auf Serien-/Film-Detailseiten
|
||||||
|
- Klickbare Sterne mit Hover-Effekt + Entfernen-Button
|
||||||
|
- Durchschnittsbewertung aller User angezeigt
|
||||||
|
- TVDB-Score als externes Rating-Badge
|
||||||
|
- Mini-Sterne in allen 3 Listen-Ansichten (Grid/Liste/Detail)
|
||||||
|
- Rating-Filter (Min. Sterne) und Sortierung nach Bewertung
|
||||||
|
- Neue DB-Tabelle `tv_ratings`, neue Spalte `tvdb_score`
|
||||||
|
|
||||||
|
#### Manueller Watch-Status
|
||||||
|
- Pro Episode: Gesehen/Nicht gesehen Toggle
|
||||||
|
- Pro Staffel: "Ganze Staffel als gesehen markieren"
|
||||||
|
- Pro Serie: "Serie als gesehen markieren"
|
||||||
|
- In Einstellungen: "Alle Fortschritte zuruecksetzen"
|
||||||
|
- Neue DB-Tabelle `tv_watch_status`
|
||||||
|
|
||||||
|
#### Player-Verbesserungen
|
||||||
|
- Naechste Episode: Overlay-Countdown (konfigurierbar 5-30 Sek.)
|
||||||
|
- "Schaust du noch?" Dialog nach X Folgen (Netflix-Style)
|
||||||
|
- Player-Overlay-Menue: Audio-Spur, Untertitel, Qualitaet, Geschwindigkeit
|
||||||
|
- Audio-Spur-Auswahl aus verfuegbaren Tracks
|
||||||
|
- Untertitel-Extraktion (SRT/ASS -> WebVTT) per ffmpeg
|
||||||
|
- Fernbedienung-navigierbar (FocusManager)
|
||||||
|
|
||||||
|
#### Streaming-Qualitaeten
|
||||||
|
- 4 Modi: UHD (Original), HD (1080p), SD (720p), Low (480p)
|
||||||
|
- Video copy wenn Original <= Ziel-Aufloesung, sonst Re-Encoding
|
||||||
|
- Audio nach Client-Config (Stereo/Surround/Original)
|
||||||
|
|
||||||
|
#### Suchverlauf
|
||||||
|
- Letzte Suchen werden gespeichert und angezeigt
|
||||||
|
- Loeschbar ueber Einstellungen oder einzeln
|
||||||
|
|
||||||
|
#### Queue-Bugfix
|
||||||
|
- `delete_source`-Flag wird jetzt korrekt aus der DB geladen (war immer `False`)
|
||||||
|
- Fix in `queue.py`: `job['delete_source']` statt hartcodiertes `False`
|
||||||
|
|
||||||
|
### Neue Dateien
|
||||||
|
- `app/services/i18n.py` - Internationalisierungs-Service
|
||||||
|
- `app/static/tv/i18n/de.json` - Deutsche Uebersetzungen (~200 Keys)
|
||||||
|
- `app/static/tv/i18n/en.json` - Englische Uebersetzungen (~200 Keys)
|
||||||
|
- `app/templates/tv/profiles.html` - Profilauswahl (Quick-Switch)
|
||||||
|
- `app/templates/tv/settings.html` - Benutzer-Einstellungen
|
||||||
|
- `app/templates/tv/watchlist.html` - Merkliste
|
||||||
|
|
||||||
|
### Geaenderte Dateien
|
||||||
|
- `app/services/auth.py` - Multi-User, Watchlist, Status, Rating, Client-Settings, 8 neue DB-Tabellen/Spalten
|
||||||
|
- `app/services/tvdb.py` - Episoden-Bilder, tvdb_score Extraktion
|
||||||
|
- `app/services/queue.py` - delete_source Bugfix
|
||||||
|
- `app/routes/tv_api.py` - ~20 neue Endpunkte (Settings, Profiles, Watchlist, Rating, Status, Filter)
|
||||||
|
- `app/routes/library_api.py` - Thumbnail-Endpunkt, Subtitle-Extraktion
|
||||||
|
- `app/server.py` - i18n-Service Integration
|
||||||
|
- `app/templates/tv/base.html` - i18n, Theme-Support, Navigation erweitert
|
||||||
|
- `app/templates/tv/home.html` - Watchlist-Bereich, i18n
|
||||||
|
- `app/templates/tv/series.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
|
||||||
|
- `app/templates/tv/movies.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
|
||||||
|
- `app/templates/tv/series_detail.html` - Rating, Watchlist, Episoden-Thumbnails, i18n
|
||||||
|
- `app/templates/tv/movie_detail.html` - Rating, Watchlist, Versionen, i18n
|
||||||
|
- `app/templates/tv/player.html` - Overlay-Menue, Naechste Episode, Audio/Sub-Auswahl
|
||||||
|
- `app/templates/tv/search.html` - Suchverlauf, i18n
|
||||||
|
- `app/templates/tv/login.html` - "Angemeldet bleiben", i18n
|
||||||
|
- `app/static/tv/js/player.js` - Komplett ueberarbeitet (Overlay, Audio, Subs, Quality, Next)
|
||||||
|
- `app/static/tv/css/tv.css` - Themes, 3 Ansichten, Rating, Watchlist, Player-Overlay (~500 neue Zeilen)
|
||||||
|
|
||||||
|
### Neue DB-Tabellen
|
||||||
|
- `tv_clients` - Geraete-Einstellungen (Sound, Qualitaet)
|
||||||
|
- `tv_watchlist` - Merkliste pro User (Serien + Filme)
|
||||||
|
- `tv_watch_status` - Manueller Watch-Status (Episode/Staffel/Serie)
|
||||||
|
- `tv_ratings` - 5-Sterne-Bewertungen pro User
|
||||||
|
- `tv_episode_thumbnails` - Episoden-Bild-Cache
|
||||||
|
|
||||||
|
### Neue DB-Spalten (tv_users)
|
||||||
|
- `preferred_audio_lang`, `preferred_subtitle_lang`, `subtitles_enabled`
|
||||||
|
- `ui_lang`, `series_view`, `movies_view`, `avatar_color`, `theme`
|
||||||
|
- `autoplay_enabled`, `autoplay_countdown_sec`, `autoplay_max_episodes`
|
||||||
|
|
||||||
|
### Neue DB-Spalten (library_series/library_movies)
|
||||||
|
- `tvdb_score` (FLOAT) - Externe TVDB-Bewertung
|
||||||
|
|
||||||
|
### Neue API-Endpunkte
|
||||||
|
- `GET/POST /tv/settings` - Benutzer-Einstellungen
|
||||||
|
- `GET /tv/profiles` - Profilauswahl
|
||||||
|
- `POST /tv/switch-profile` - Profil wechseln
|
||||||
|
- `GET /tv/watchlist` - Merkliste anzeigen
|
||||||
|
- `POST /tv/api/watchlist` - Merkliste Toggle
|
||||||
|
- `POST /tv/api/rating` - Bewertung setzen/loeschen
|
||||||
|
- `POST /tv/api/watch-status` - Watch-Status aendern
|
||||||
|
- `DELETE /tv/api/search/history` - Suchverlauf loeschen
|
||||||
|
- `GET /api/library/videos/{id}/thumbnail` - Episoden-Thumbnail
|
||||||
|
- `GET /api/library/videos/{id}/subtitles/{index}` - Untertitel als WebVTT
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.1] - 2026-02-28
|
||||||
|
|
||||||
|
### Samsung TV Installation + Streaming-Fix
|
||||||
|
- Tizen-App erfolgreich auf Samsung GQ65Q7FAAUXZG installiert
|
||||||
|
- Streaming-Fix: `movflags=frag_keyframe+empty_moov+default_base_moof`
|
||||||
|
- Samsung-signierte Zertifikate (nicht Tizen-Standard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-02-28
|
||||||
|
|
||||||
|
### TV-App komplett
|
||||||
|
- TV-App mit Login, Home, Serien, Filme, Player, Suche
|
||||||
|
- Auth-System: bcrypt, DB-Sessions (30 Tage Cookie)
|
||||||
|
- Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade)
|
||||||
|
- PWA: manifest.json + Service Worker
|
||||||
|
- Tizen-App fuer Samsung Smart TVs
|
||||||
|
- Admin-Seite: QR-Code + User-Verwaltung
|
||||||
|
- Log-API: `GET /api/log?lines=100&level=INFO`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.0.0] - 2026-02-28
|
||||||
|
|
||||||
|
### Bugfixes, Queue-Pause, Button-Audit
|
||||||
|
- Queue-Pause-Funktion
|
||||||
|
- Button-Audit aller UI-Elemente
|
||||||
|
- Diverse Bugfixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.9.0] - 2026-02-27
|
## [2.9.0] - 2026-02-27
|
||||||
|
|
||||||
### Import-System Neustrukturierung
|
### Import-System Neustrukturierung
|
||||||
|
|
|
||||||
|
|
@ -1317,14 +1317,21 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
# === Video-Streaming ===
|
# === Video-Streaming ===
|
||||||
|
|
||||||
|
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
|
||||||
|
_BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
|
||||||
|
|
||||||
async def get_stream_video(request: web.Request) -> web.StreamResponse:
|
async def get_stream_video(request: web.Request) -> web.StreamResponse:
|
||||||
"""GET /api/library/videos/{video_id}/stream?t=0
|
"""GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0
|
||||||
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC).
|
Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur.
|
||||||
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
|
|
||||||
Optional: ?t=120 fuer Seeking auf Sekunde 120."""
|
Parameter:
|
||||||
|
quality: uhd|hd|sd|low (Default: hd)
|
||||||
|
audio: Audio-Track-Index (Default: 0)
|
||||||
|
t: Seek-Position in Sekunden (Default: 0)
|
||||||
|
sound: stereo|surround|original (Default: stereo)
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
import shlex
|
|
||||||
|
|
||||||
video_id = int(request.match_info["video_id"])
|
video_id = int(request.match_info["video_id"])
|
||||||
|
|
||||||
|
|
@ -1336,43 +1343,96 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"SELECT file_path FROM library_videos WHERE id = %s",
|
"SELECT file_path, width, height, video_codec, "
|
||||||
|
"audio_tracks, container, file_size "
|
||||||
|
"FROM library_videos WHERE id = %s",
|
||||||
(video_id,)
|
(video_id,)
|
||||||
)
|
)
|
||||||
row = await cur.fetchone()
|
video = await cur.fetchone()
|
||||||
if not row:
|
if not video:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Video nicht gefunden"}, status=404
|
{"error": "Video nicht gefunden"}, status=404
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
file_path = row[0]
|
file_path = video["file_path"]
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Datei nicht gefunden"}, status=404
|
{"error": "Datei nicht gefunden"}, status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
# Seek-Position (Sekunden) aus Query-Parameter
|
# Audio-Tracks parsen
|
||||||
seek_sec = float(request.query.get("t", "0"))
|
audio_tracks = video.get("audio_tracks") or "[]"
|
||||||
|
if isinstance(audio_tracks, str):
|
||||||
|
audio_tracks = json.loads(audio_tracks)
|
||||||
|
|
||||||
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container
|
# Parameter aus Query
|
||||||
# frag_keyframe: Fragment bei jedem Keyframe
|
quality = request.query.get("quality", "hd")
|
||||||
# empty_moov: Leerer moov-Atom am Anfang (noetig fuer pipe)
|
audio_idx = int(request.query.get("audio", "0"))
|
||||||
# default_base_moof: Bessere Browser-Kompatibilitaet (Samsung TV etc.)
|
seek_sec = float(request.query.get("t", "0"))
|
||||||
# frag_duration: Kleine Fragmente fuer schnellen Playback-Start
|
sound_mode = request.query.get("sound", "stereo")
|
||||||
cmd = [
|
|
||||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
# Audio-Track bestimmen
|
||||||
]
|
if audio_idx >= len(audio_tracks):
|
||||||
|
audio_idx = 0
|
||||||
|
audio_info = audio_tracks[audio_idx] if audio_tracks else {}
|
||||||
|
audio_codec = audio_info.get("codec", "unknown")
|
||||||
|
audio_channels = audio_info.get("channels", 2)
|
||||||
|
|
||||||
|
# Ziel-Aufloesung bestimmen
|
||||||
|
orig_h = video.get("height") or 1080
|
||||||
|
quality_heights = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
|
||||||
|
target_h = quality_heights.get(quality, 1080)
|
||||||
|
needs_video_scale = orig_h > target_h and quality != "uhd"
|
||||||
|
|
||||||
|
# Audio-Transcoding: noetig wenn Codec nicht browser-kompatibel
|
||||||
|
needs_audio_transcode = audio_codec not in _BROWSER_AUDIO_CODECS
|
||||||
|
|
||||||
|
# Sound-Modus: Kanalanzahl bestimmen
|
||||||
|
if sound_mode == "stereo":
|
||||||
|
out_channels = 2
|
||||||
|
elif sound_mode == "surround":
|
||||||
|
out_channels = min(audio_channels, 8)
|
||||||
|
else: # original
|
||||||
|
out_channels = audio_channels
|
||||||
|
|
||||||
|
# Wenn Kanalanzahl sich aendert -> Transcoding noetig
|
||||||
|
if out_channels != audio_channels:
|
||||||
|
needs_audio_transcode = True
|
||||||
|
|
||||||
|
# ffmpeg-Kommando zusammenbauen
|
||||||
|
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
|
||||||
if seek_sec > 0:
|
if seek_sec > 0:
|
||||||
# -ss VOR -i fuer schnelles Seeking (Input-Seeking)
|
|
||||||
cmd += ["-ss", str(seek_sec)]
|
cmd += ["-ss", str(seek_sec)]
|
||||||
|
cmd += ["-i", file_path]
|
||||||
|
|
||||||
|
# Video-Mapping und Codec
|
||||||
|
cmd += ["-map", "0:v:0"]
|
||||||
|
if needs_video_scale:
|
||||||
|
crf = {"sd": "23", "low": "28"}.get(quality, "20")
|
||||||
|
cmd += [
|
||||||
|
"-c:v", "libx264", "-preset", "fast",
|
||||||
|
"-crf", crf,
|
||||||
|
"-vf", f"scale=-2:{target_h}",
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:v", "copy"]
|
||||||
|
|
||||||
|
# Audio-Mapping und Codec
|
||||||
|
cmd += ["-map", f"0:a:{audio_idx}"]
|
||||||
|
if needs_audio_transcode:
|
||||||
|
bitrate = {1: "96k", 2: "192k"}.get(
|
||||||
|
out_channels, f"{out_channels * 64}k")
|
||||||
|
cmd += ["-c:a", "aac", "-ac", str(out_channels),
|
||||||
|
"-b:a", bitrate]
|
||||||
|
else:
|
||||||
|
cmd += ["-c:a", "copy"]
|
||||||
|
|
||||||
|
# Container: Fragmentiertes MP4 fuer Streaming
|
||||||
cmd += [
|
cmd += [
|
||||||
"-i", file_path,
|
|
||||||
"-c:v", "copy",
|
|
||||||
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
|
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||||
"-frag_duration", "1000000",
|
"-frag_duration", "1000000",
|
||||||
"-f", "mp4",
|
"-f", "mp4",
|
||||||
|
|
@ -1405,7 +1465,6 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
try:
|
try:
|
||||||
await resp.write(chunk)
|
await resp.write(chunk)
|
||||||
except (ConnectionResetError, ConnectionAbortedError):
|
except (ConnectionResetError, ConnectionAbortedError):
|
||||||
# Client hat Verbindung geschlossen
|
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1418,6 +1477,225 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
await resp.write_eof()
|
await resp.write_eof()
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
# === Untertitel-Extraktion ===
|
||||||
|
|
||||||
|
async def get_subtitle_track(request: web.Request) -> web.Response:
|
||||||
|
"""GET /api/library/videos/{video_id}/subtitles/{track_index}
|
||||||
|
Extrahiert Untertitel als WebVTT per ffmpeg."""
|
||||||
|
import os
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
video_id = int(request.match_info["video_id"])
|
||||||
|
track_idx = int(request.match_info["track_index"])
|
||||||
|
|
||||||
|
pool = await library_service._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Keine DB-Verbindung"}, status=500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT file_path, subtitle_tracks "
|
||||||
|
"FROM library_videos WHERE id = %s", (video_id,))
|
||||||
|
video = await cur.fetchone()
|
||||||
|
if not video:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Video nicht gefunden"}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
file_path = video["file_path"]
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Datei nicht gefunden"}, status=404)
|
||||||
|
|
||||||
|
sub_tracks = video.get("subtitle_tracks") or "[]"
|
||||||
|
if isinstance(sub_tracks, str):
|
||||||
|
sub_tracks = json.loads(sub_tracks)
|
||||||
|
|
||||||
|
if track_idx >= len(sub_tracks):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Untertitel-Track nicht gefunden"}, status=404)
|
||||||
|
|
||||||
|
sub = sub_tracks[track_idx]
|
||||||
|
if sub.get("codec") in ("hdmv_pgs_subtitle", "dvd_subtitle",
|
||||||
|
"pgs", "vobsub"):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Bild-basierte Untertitel nicht unterstuetzt"},
|
||||||
|
status=400)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-i", file_path,
|
||||||
|
"-map", f"0:s:{track_idx}",
|
||||||
|
"-f", "webvtt", "pipe:1",
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await _asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=_asyncio.subprocess.PIPE,
|
||||||
|
stderr=_asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logging.error(
|
||||||
|
f"Untertitel-Extraktion fehlgeschlagen: "
|
||||||
|
f"{stderr.decode('utf-8', errors='replace')}")
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Extraktion fehlgeschlagen"}, status=500)
|
||||||
|
|
||||||
|
return web.Response(
|
||||||
|
body=stdout,
|
||||||
|
content_type="text/vtt",
|
||||||
|
charset="utf-8",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Untertitel-Fehler: {e}")
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
# === Video-Info API (fuer Player-UI) ===
|
||||||
|
|
||||||
|
async def get_video_info(request: web.Request) -> web.Response:
|
||||||
|
"""GET /api/library/videos/{video_id}/info
|
||||||
|
Audio-/Untertitel-Tracks und Video-Infos fuer Player-Overlay."""
|
||||||
|
video_id = int(request.match_info["video_id"])
|
||||||
|
|
||||||
|
pool = await library_service._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Keine DB-Verbindung"}, status=500)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute("""
|
||||||
|
SELECT id, file_name, width, height, video_codec,
|
||||||
|
audio_tracks, subtitle_tracks, container,
|
||||||
|
duration_sec, video_bitrate, is_10bit, hdr,
|
||||||
|
series_id, season_number, episode_number
|
||||||
|
FROM library_videos WHERE id = %s
|
||||||
|
""", (video_id,))
|
||||||
|
video = await cur.fetchone()
|
||||||
|
if not video:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Video nicht gefunden"}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
# JSON-Felder parsen
|
||||||
|
for field in ("audio_tracks", "subtitle_tracks"):
|
||||||
|
val = video.get(field)
|
||||||
|
if isinstance(val, str):
|
||||||
|
video[field] = json.loads(val)
|
||||||
|
elif val is None:
|
||||||
|
video[field] = []
|
||||||
|
|
||||||
|
# Bild-basierte Untertitel rausfiltern
|
||||||
|
video["subtitle_tracks"] = [
|
||||||
|
s for s in video["subtitle_tracks"]
|
||||||
|
if s.get("codec") not in (
|
||||||
|
"hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return web.json_response(video)
|
||||||
|
|
||||||
|
# === Episoden-Thumbnails ===
|
||||||
|
|
||||||
|
async def get_video_thumbnail(request: web.Request) -> web.Response:
|
||||||
|
"""GET /api/library/videos/{video_id}/thumbnail
|
||||||
|
Gibt Thumbnail zurueck. Generiert per ffmpeg bei Erstaufruf."""
|
||||||
|
import os
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
video_id = int(request.match_info["video_id"])
|
||||||
|
|
||||||
|
pool = await library_service._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Keine DB-Verbindung"}, status=500)
|
||||||
|
|
||||||
|
# Pruefen ob bereits generiert
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT thumbnail_path FROM tv_episode_thumbnails "
|
||||||
|
"WHERE video_id = %s", (video_id,))
|
||||||
|
cached = await cur.fetchone()
|
||||||
|
|
||||||
|
if cached and os.path.isfile(cached["thumbnail_path"]):
|
||||||
|
return web.FileResponse(
|
||||||
|
cached["thumbnail_path"],
|
||||||
|
headers={"Cache-Control": "public, max-age=604800"})
|
||||||
|
|
||||||
|
# Video-Info laden
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT file_path, duration_sec FROM library_videos "
|
||||||
|
"WHERE id = %s", (video_id,))
|
||||||
|
video = await cur.fetchone()
|
||||||
|
if not video or not os.path.isfile(video["file_path"]):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Video nicht gefunden"}, status=404)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
# Thumbnail generieren per ffmpeg (Frame bei 25%)
|
||||||
|
file_path = video["file_path"]
|
||||||
|
duration = video.get("duration_sec") or 0
|
||||||
|
seek_pos = duration * 0.25 if duration > 10 else 5
|
||||||
|
|
||||||
|
# Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei
|
||||||
|
video_dir = os.path.dirname(file_path)
|
||||||
|
thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails")
|
||||||
|
os.makedirs(thumb_dir, exist_ok=True)
|
||||||
|
thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
|
"-ss", str(int(seek_pos)),
|
||||||
|
"-i", file_path,
|
||||||
|
"-vframes", "1",
|
||||||
|
"-q:v", "5",
|
||||||
|
"-vf", "scale=480:-1",
|
||||||
|
"-y", thumb_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await _asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=_asyncio.subprocess.PIPE,
|
||||||
|
stderr=_asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
await proc.communicate()
|
||||||
|
|
||||||
|
if proc.returncode == 0 and os.path.isfile(thumb_path):
|
||||||
|
# In DB cachen
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_episode_thumbnails
|
||||||
|
(video_id, thumbnail_path, source)
|
||||||
|
VALUES (%s, %s, 'ffmpeg')
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
thumbnail_path = VALUES(thumbnail_path)
|
||||||
|
""", (video_id, thumb_path))
|
||||||
|
return web.FileResponse(
|
||||||
|
thumb_path,
|
||||||
|
headers={"Cache-Control": "public, max-age=604800"})
|
||||||
|
else:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Thumbnail-Generierung fehlgeschlagen"},
|
||||||
|
status=500)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Thumbnail-Fehler: {e}")
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
# === Import: Item zuordnen / ueberspringen ===
|
# === Import: Item zuordnen / ueberspringen ===
|
||||||
|
|
||||||
async def post_reassign_import_item(
|
async def post_reassign_import_item(
|
||||||
|
|
@ -1910,10 +2188,20 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
"/api/library/import/{job_id}/overwrite-mode",
|
"/api/library/import/{job_id}/overwrite-mode",
|
||||||
put_overwrite_mode,
|
put_overwrite_mode,
|
||||||
)
|
)
|
||||||
# Video-Streaming
|
# Video-Streaming, Untertitel, Video-Info
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
"/api/library/videos/{video_id}/stream", get_stream_video
|
"/api/library/videos/{video_id}/stream", get_stream_video
|
||||||
)
|
)
|
||||||
|
app.router.add_get(
|
||||||
|
"/api/library/videos/{video_id}/subtitles/{track_index}",
|
||||||
|
get_subtitle_track
|
||||||
|
)
|
||||||
|
app.router.add_get(
|
||||||
|
"/api/library/videos/{video_id}/info", get_video_info
|
||||||
|
)
|
||||||
|
app.router.add_get(
|
||||||
|
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
|
||||||
|
)
|
||||||
# TVDB Auto-Match (Review-Modus)
|
# TVDB Auto-Match (Review-Modus)
|
||||||
app.router.add_post(
|
app.router.add_post(
|
||||||
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import aiomysql
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
from app.services.library import LibraryService
|
from app.services.library import LibraryService
|
||||||
|
from app.services.i18n import set_request_lang, get_all_translations
|
||||||
|
|
||||||
|
|
||||||
def setup_tv_routes(app: web.Application, config: Config,
|
def setup_tv_routes(app: web.Application, config: Config,
|
||||||
|
|
@ -27,13 +28,16 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
return await auth_service.validate_session(session_id)
|
return await auth_service.validate_session(session_id)
|
||||||
|
|
||||||
def require_auth(handler):
|
def require_auth(handler):
|
||||||
"""Decorator: Leitet auf Login um wenn nicht eingeloggt"""
|
"""Decorator: Leitet auf Login um wenn nicht eingeloggt.
|
||||||
|
Setzt i18n-Sprache aus User-Einstellungen."""
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request):
|
async def wrapper(request):
|
||||||
user = await get_tv_user(request)
|
user = await get_tv_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
raise web.HTTPFound("/tv/login")
|
raise web.HTTPFound("/tv/login")
|
||||||
request["tv_user"] = user
|
request["tv_user"] = user
|
||||||
|
# i18n: Sprache des Users setzen
|
||||||
|
set_request_lang(request.app, user.get("ui_lang", "de"))
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
@ -50,10 +54,13 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def post_login(request: web.Request) -> web.Response:
|
async def post_login(request: web.Request) -> web.Response:
|
||||||
"""POST /tv/login - Login verarbeiten"""
|
"""POST /tv/login - Login verarbeiten.
|
||||||
|
Unterstuetzt 'remember' Checkbox fuer permanente Sessions
|
||||||
|
und Client-ID fuer Multi-User Quick-Switch."""
|
||||||
data = await request.post()
|
data = await request.post()
|
||||||
username = data.get("username", "").strip()
|
username = data.get("username", "").strip()
|
||||||
password = data.get("password", "")
|
password = data.get("password", "")
|
||||||
|
remember = data.get("remember", "") == "on"
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
|
|
@ -68,14 +75,30 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
{"error": "Falscher Benutzername oder Passwort"}
|
{"error": "Falscher Benutzername oder Passwort"}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Session erstellen
|
# Client-ID ermitteln/erstellen (fuer Multi-User pro Geraet)
|
||||||
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
client_id = await auth_service.get_or_create_client(client_id)
|
||||||
|
|
||||||
|
# Session erstellen (persistent wenn "Angemeldet bleiben")
|
||||||
ua = request.headers.get("User-Agent", "")
|
ua = request.headers.get("User-Agent", "")
|
||||||
session_id = await auth_service.create_session(user["id"], ua)
|
session_id = await auth_service.create_session(
|
||||||
|
user["id"], ua, client_id=client_id, persistent=remember
|
||||||
|
)
|
||||||
|
|
||||||
resp = web.HTTPFound("/tv/")
|
resp = web.HTTPFound("/tv/")
|
||||||
|
# Session-Cookie
|
||||||
|
max_age = 10 * 365 * 24 * 3600 if remember else 30 * 24 * 3600
|
||||||
resp.set_cookie(
|
resp.set_cookie(
|
||||||
"vk_session", session_id,
|
"vk_session", session_id,
|
||||||
max_age=30 * 24 * 3600, # 30 Tage
|
max_age=max_age,
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
# Client-ID Cookie (immer permanent)
|
||||||
|
resp.set_cookie(
|
||||||
|
"vk_client_id", client_id,
|
||||||
|
max_age=10 * 365 * 24 * 3600, # 10 Jahre
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="Lax",
|
samesite="Lax",
|
||||||
path="/",
|
path="/",
|
||||||
|
|
@ -166,40 +189,112 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def get_series_list(request: web.Request) -> web.Response:
|
async def get_series_list(request: web.Request) -> web.Response:
|
||||||
"""GET /tv/series - Alle Serien"""
|
"""GET /tv/series?source=&genre=&sort=&rating= - Alle Serien mit Filtern"""
|
||||||
user = request["tv_user"]
|
user = request["tv_user"]
|
||||||
if not user.get("can_view_series"):
|
if not user.get("can_view_series"):
|
||||||
raise web.HTTPFound("/tv/")
|
raise web.HTTPFound("/tv/")
|
||||||
|
|
||||||
|
# Filter-Parameter
|
||||||
|
source_filter = request.query.get("source", "")
|
||||||
|
genre_filter = request.query.get("genre", "")
|
||||||
|
sort_by = request.query.get("sort", "title")
|
||||||
|
rating_filter = request.query.get("rating", "")
|
||||||
|
|
||||||
series = []
|
series = []
|
||||||
|
sources = []
|
||||||
|
all_genres = set()
|
||||||
pool = library_service._db_pool
|
pool = library_service._db_pool
|
||||||
if pool:
|
if pool:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
# Verfuegbare Quellen laden
|
||||||
|
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'series'"
|
||||||
|
src_params = []
|
||||||
|
if user.get("allowed_paths"):
|
||||||
|
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||||
|
src_query += f" AND id IN ({ph})"
|
||||||
|
src_params = user["allowed_paths"]
|
||||||
|
await cur.execute(src_query, src_params)
|
||||||
|
sources = await cur.fetchall()
|
||||||
|
|
||||||
|
# Serien-Query mit Filtern + Durchschnittsbewertung
|
||||||
query = """
|
query = """
|
||||||
SELECT s.id, s.title, s.folder_name, s.poster_url,
|
SELECT s.id, s.title, s.folder_name, s.poster_url,
|
||||||
s.genres, s.tvdb_id, s.overview,
|
s.genres, s.tvdb_id, s.overview, s.status,
|
||||||
COUNT(v.id) as episode_count
|
s.library_path_id, s.tvdb_score,
|
||||||
|
COUNT(DISTINCT v.id) as episode_count,
|
||||||
|
COALESCE(AVG(r.rating), 0) as avg_rating,
|
||||||
|
COUNT(DISTINCT r.id) as rating_count
|
||||||
FROM library_series s
|
FROM library_series s
|
||||||
LEFT JOIN library_videos v ON v.series_id = s.id
|
LEFT JOIN library_videos v ON v.series_id = s.id
|
||||||
|
LEFT JOIN tv_ratings r ON r.series_id = s.id
|
||||||
|
AND r.rating > 0
|
||||||
"""
|
"""
|
||||||
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
# Pfad-Berechtigung
|
||||||
if user.get("allowed_paths"):
|
if user.get("allowed_paths"):
|
||||||
placeholders = ",".join(
|
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||||
["%s"] * len(user["allowed_paths"]))
|
conditions.append(
|
||||||
query += (
|
f"s.library_path_id IN ({ph})")
|
||||||
f" WHERE s.library_path_id IN ({placeholders})"
|
params.extend(user["allowed_paths"])
|
||||||
)
|
|
||||||
params = user["allowed_paths"]
|
# Quellen-Filter
|
||||||
query += " GROUP BY s.id ORDER BY s.title"
|
if source_filter:
|
||||||
|
conditions.append("s.library_path_id = %s")
|
||||||
|
params.append(int(source_filter))
|
||||||
|
|
||||||
|
# Genre-Filter
|
||||||
|
if genre_filter:
|
||||||
|
conditions.append("s.genres LIKE %s")
|
||||||
|
params.append(f"%{genre_filter}%")
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
query += " GROUP BY s.id"
|
||||||
|
|
||||||
|
# Rating-Filter (nach GROUP BY mit HAVING)
|
||||||
|
if rating_filter:
|
||||||
|
min_stars = int(rating_filter)
|
||||||
|
query += " HAVING avg_rating >= %s"
|
||||||
|
params.append(min_stars)
|
||||||
|
|
||||||
|
# Sortierung
|
||||||
|
sort_map = {
|
||||||
|
"title": " ORDER BY s.title",
|
||||||
|
"title_desc": " ORDER BY s.title DESC",
|
||||||
|
"newest": " ORDER BY s.id DESC",
|
||||||
|
"episodes": " ORDER BY episode_count DESC",
|
||||||
|
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
|
||||||
|
}
|
||||||
|
query += sort_map.get(sort_by, " ORDER BY s.title")
|
||||||
await cur.execute(query, params)
|
await cur.execute(query, params)
|
||||||
series = await cur.fetchall()
|
series = await cur.fetchall()
|
||||||
|
|
||||||
|
# Alle verfuegbaren Genres extrahieren + Rating runden
|
||||||
|
for s in series:
|
||||||
|
s["avg_rating"] = round(
|
||||||
|
float(s.get("avg_rating") or 0), 1)
|
||||||
|
if s.get("genres"):
|
||||||
|
for g in s["genres"].split(","):
|
||||||
|
g = g.strip()
|
||||||
|
if g:
|
||||||
|
all_genres.add(g)
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/series.html", request, {
|
"tv/series.html", request, {
|
||||||
"user": user,
|
"user": user,
|
||||||
"active": "series",
|
"active": "series",
|
||||||
"series": series,
|
"series": series,
|
||||||
|
"view": user.get("series_view") or "grid",
|
||||||
|
"sources": sources,
|
||||||
|
"genres": sorted(all_genres),
|
||||||
|
"current_source": source_filter,
|
||||||
|
"current_genre": genre_filter,
|
||||||
|
"current_sort": sort_by,
|
||||||
|
"current_rating": rating_filter,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -214,36 +309,67 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
series = None
|
series = None
|
||||||
seasons = {}
|
seasons = {}
|
||||||
|
in_watchlist = False
|
||||||
pool = library_service._db_pool
|
pool = library_service._db_pool
|
||||||
if pool:
|
if pool:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT id, title, folder_name, poster_url,
|
SELECT id, title, folder_name, poster_url,
|
||||||
overview, genres, tvdb_id
|
overview, genres, tvdb_id, tvdb_score
|
||||||
FROM library_series WHERE id = %s
|
FROM library_series WHERE id = %s
|
||||||
""", (series_id,))
|
""", (series_id,))
|
||||||
series = await cur.fetchone()
|
series = await cur.fetchone()
|
||||||
|
|
||||||
if series:
|
if series:
|
||||||
|
# Episoden mit TVDB-Beschreibung und Watch-Progress
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT id, file_name, season_number,
|
SELECT v.id, v.file_name, v.season_number,
|
||||||
episode_number, episode_title,
|
v.episode_number, v.episode_title,
|
||||||
duration_sec, file_size,
|
v.duration_sec, v.file_size,
|
||||||
width, height, video_codec,
|
v.width, v.height, v.video_codec,
|
||||||
container
|
v.container,
|
||||||
FROM library_videos
|
tc.overview AS ep_overview,
|
||||||
WHERE series_id = %s
|
tc.image_url AS ep_image_url,
|
||||||
ORDER BY season_number, episode_number, file_name
|
wp.position_sec, wp.duration_sec AS wp_duration
|
||||||
""", (series_id,))
|
FROM library_videos v
|
||||||
|
LEFT JOIN tvdb_episode_cache tc
|
||||||
|
ON tc.series_tvdb_id = %s
|
||||||
|
AND tc.season_number = v.season_number
|
||||||
|
AND tc.episode_number = v.episode_number
|
||||||
|
LEFT JOIN tv_watch_progress wp
|
||||||
|
ON wp.video_id = v.id
|
||||||
|
AND wp.user_id = %s
|
||||||
|
WHERE v.series_id = %s
|
||||||
|
ORDER BY v.season_number, v.episode_number,
|
||||||
|
v.file_name
|
||||||
|
""", (series.get("tvdb_id") or 0,
|
||||||
|
user["id"], series_id))
|
||||||
episodes = await cur.fetchall()
|
episodes = await cur.fetchall()
|
||||||
|
|
||||||
for ep in episodes:
|
for ep in episodes:
|
||||||
|
# Fortschritt berechnen
|
||||||
|
if ep.get("position_sec") and ep.get("wp_duration"):
|
||||||
|
ep["progress_pct"] = min(100, int(
|
||||||
|
ep["position_sec"] / ep["wp_duration"]
|
||||||
|
* 100))
|
||||||
|
else:
|
||||||
|
ep["progress_pct"] = 0
|
||||||
sn = ep.get("season_number") or 0
|
sn = ep.get("season_number") or 0
|
||||||
if sn not in seasons:
|
if sn not in seasons:
|
||||||
seasons[sn] = []
|
seasons[sn] = []
|
||||||
seasons[sn].append(ep)
|
seasons[sn].append(ep)
|
||||||
|
|
||||||
|
# Watchlist-Status pruefen
|
||||||
|
in_watchlist = await auth_service.is_in_watchlist(
|
||||||
|
user["id"], series_id=series_id)
|
||||||
|
|
||||||
|
# Bewertungen laden
|
||||||
|
user_rating = await auth_service.get_rating(
|
||||||
|
user["id"], series_id=series_id)
|
||||||
|
avg_rating = await auth_service.get_avg_rating(
|
||||||
|
series_id=series_id)
|
||||||
|
|
||||||
if not series:
|
if not series:
|
||||||
raise web.HTTPFound("/tv/series")
|
raise web.HTTPFound("/tv/series")
|
||||||
|
|
||||||
|
|
@ -253,43 +379,114 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
"active": "series",
|
"active": "series",
|
||||||
"series": series,
|
"series": series,
|
||||||
"seasons": dict(sorted(seasons.items())),
|
"seasons": dict(sorted(seasons.items())),
|
||||||
|
"in_watchlist": in_watchlist,
|
||||||
|
"user_rating": user_rating,
|
||||||
|
"avg_rating": avg_rating,
|
||||||
|
"tvdb_score": series.get("tvdb_score"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def get_movies_list(request: web.Request) -> web.Response:
|
async def get_movies_list(request: web.Request) -> web.Response:
|
||||||
"""GET /tv/movies - Alle Filme"""
|
"""GET /tv/movies?source=&genre=&sort=&rating= - Alle Filme mit Filtern"""
|
||||||
user = request["tv_user"]
|
user = request["tv_user"]
|
||||||
if not user.get("can_view_movies"):
|
if not user.get("can_view_movies"):
|
||||||
raise web.HTTPFound("/tv/")
|
raise web.HTTPFound("/tv/")
|
||||||
|
|
||||||
|
# Filter-Parameter
|
||||||
|
source_filter = request.query.get("source", "")
|
||||||
|
genre_filter = request.query.get("genre", "")
|
||||||
|
sort_by = request.query.get("sort", "title")
|
||||||
|
rating_filter = request.query.get("rating", "")
|
||||||
|
|
||||||
movies = []
|
movies = []
|
||||||
|
sources = []
|
||||||
|
all_genres = set()
|
||||||
pool = library_service._db_pool
|
pool = library_service._db_pool
|
||||||
if pool:
|
if pool:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
# Verfuegbare Quellen
|
||||||
|
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'movie'"
|
||||||
|
src_params = []
|
||||||
|
if user.get("allowed_paths"):
|
||||||
|
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||||
|
src_query += f" AND id IN ({ph})"
|
||||||
|
src_params = user["allowed_paths"]
|
||||||
|
await cur.execute(src_query, src_params)
|
||||||
|
sources = await cur.fetchall()
|
||||||
|
|
||||||
|
# Film-Query mit Filtern + Durchschnittsbewertung
|
||||||
query = """
|
query = """
|
||||||
SELECT m.id, m.title, m.folder_name, m.poster_url,
|
SELECT m.id, m.title, m.folder_name, m.poster_url,
|
||||||
m.year, m.genres, m.overview
|
m.year, m.genres, m.overview,
|
||||||
|
m.library_path_id, m.tvdb_score,
|
||||||
|
COALESCE(AVG(r.rating), 0) as avg_rating,
|
||||||
|
COUNT(DISTINCT r.id) as rating_count
|
||||||
FROM library_movies m
|
FROM library_movies m
|
||||||
|
LEFT JOIN tv_ratings r ON r.movie_id = m.id
|
||||||
|
AND r.rating > 0
|
||||||
"""
|
"""
|
||||||
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if user.get("allowed_paths"):
|
if user.get("allowed_paths"):
|
||||||
placeholders = ",".join(
|
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||||
["%s"] * len(user["allowed_paths"]))
|
conditions.append(
|
||||||
query += (
|
f"m.library_path_id IN ({ph})")
|
||||||
f" WHERE m.library_path_id IN ({placeholders})"
|
params.extend(user["allowed_paths"])
|
||||||
)
|
|
||||||
params = user["allowed_paths"]
|
if source_filter:
|
||||||
query += " ORDER BY m.title"
|
conditions.append("m.library_path_id = %s")
|
||||||
|
params.append(int(source_filter))
|
||||||
|
|
||||||
|
if genre_filter:
|
||||||
|
conditions.append("m.genres LIKE %s")
|
||||||
|
params.append(f"%{genre_filter}%")
|
||||||
|
|
||||||
|
if conditions:
|
||||||
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
|
query += " GROUP BY m.id"
|
||||||
|
|
||||||
|
# Rating-Filter (nach GROUP BY)
|
||||||
|
if rating_filter:
|
||||||
|
min_stars = int(rating_filter)
|
||||||
|
query += " HAVING avg_rating >= %s"
|
||||||
|
params.append(min_stars)
|
||||||
|
|
||||||
|
sort_map = {
|
||||||
|
"title": " ORDER BY m.title",
|
||||||
|
"title_desc": " ORDER BY m.title DESC",
|
||||||
|
"newest": " ORDER BY m.id DESC",
|
||||||
|
"year": " ORDER BY m.year DESC",
|
||||||
|
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
|
||||||
|
}
|
||||||
|
query += sort_map.get(sort_by, " ORDER BY m.title")
|
||||||
await cur.execute(query, params)
|
await cur.execute(query, params)
|
||||||
movies = await cur.fetchall()
|
movies = await cur.fetchall()
|
||||||
|
|
||||||
|
for m in movies:
|
||||||
|
m["avg_rating"] = round(
|
||||||
|
float(m.get("avg_rating") or 0), 1)
|
||||||
|
if m.get("genres"):
|
||||||
|
for g in m["genres"].split(","):
|
||||||
|
g = g.strip()
|
||||||
|
if g:
|
||||||
|
all_genres.add(g)
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/movies.html", request, {
|
"tv/movies.html", request, {
|
||||||
"user": user,
|
"user": user,
|
||||||
"active": "movies",
|
"active": "movies",
|
||||||
"movies": movies,
|
"movies": movies,
|
||||||
|
"view": user.get("movies_view") or "grid",
|
||||||
|
"sources": sources,
|
||||||
|
"genres": sorted(all_genres),
|
||||||
|
"current_source": source_filter,
|
||||||
|
"current_genre": genre_filter,
|
||||||
|
"current_sort": sort_by,
|
||||||
|
"current_rating": rating_filter,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -309,7 +506,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT id, title, folder_name, poster_url,
|
SELECT id, title, folder_name, poster_url,
|
||||||
year, overview, genres
|
year, overview, genres, tvdb_score
|
||||||
FROM library_movies WHERE id = %s
|
FROM library_movies WHERE id = %s
|
||||||
""", (movie_id,))
|
""", (movie_id,))
|
||||||
movie = await cur.fetchone()
|
movie = await cur.fetchone()
|
||||||
|
|
@ -326,18 +523,32 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
if not movie:
|
if not movie:
|
||||||
raise web.HTTPFound("/tv/movies")
|
raise web.HTTPFound("/tv/movies")
|
||||||
|
|
||||||
|
in_watchlist = await auth_service.is_in_watchlist(
|
||||||
|
user["id"], movie_id=movie_id)
|
||||||
|
|
||||||
|
# Bewertungen laden
|
||||||
|
user_rating = await auth_service.get_rating(
|
||||||
|
user["id"], movie_id=movie_id)
|
||||||
|
avg_rating = await auth_service.get_avg_rating(
|
||||||
|
movie_id=movie_id)
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/movie_detail.html", request, {
|
"tv/movie_detail.html", request, {
|
||||||
"user": user,
|
"user": user,
|
||||||
"active": "movies",
|
"active": "movies",
|
||||||
"movie": movie,
|
"movie": movie,
|
||||||
"videos": videos,
|
"videos": videos,
|
||||||
|
"in_watchlist": in_watchlist,
|
||||||
|
"user_rating": user_rating,
|
||||||
|
"avg_rating": avg_rating,
|
||||||
|
"tvdb_score": movie.get("tvdb_score"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def get_player(request: web.Request) -> web.Response:
|
async def get_player(request: web.Request) -> web.Response:
|
||||||
"""GET /tv/player?v={video_id} - Video-Player"""
|
"""GET /tv/player?v={video_id} - Video-Player
|
||||||
|
Laedt Video-Info, naechste Episode und Client-Einstellungen."""
|
||||||
user = request["tv_user"]
|
user = request["tv_user"]
|
||||||
video_id = int(request.query.get("v", 0))
|
video_id = int(request.query.get("v", 0))
|
||||||
if not video_id:
|
if not video_id:
|
||||||
|
|
@ -349,14 +560,16 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
if progress and not progress.get("completed"):
|
if progress and not progress.get("completed"):
|
||||||
start_pos = progress.get("position_sec", 0)
|
start_pos = progress.get("position_sec", 0)
|
||||||
|
|
||||||
# Video-Info laden
|
# Video-Info + naechste Episode laden
|
||||||
video = None
|
video = None
|
||||||
|
next_video = None
|
||||||
pool = library_service._db_pool
|
pool = library_service._db_pool
|
||||||
if pool:
|
if pool:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT v.id, v.file_name, v.duration_sec,
|
SELECT v.id, v.file_name, v.duration_sec,
|
||||||
|
v.series_id,
|
||||||
s.title as series_title,
|
s.title as series_title,
|
||||||
v.season_number, v.episode_number,
|
v.season_number, v.episode_number,
|
||||||
v.episode_title
|
v.episode_title
|
||||||
|
|
@ -366,6 +579,24 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
""", (video_id,))
|
""", (video_id,))
|
||||||
video = await cur.fetchone()
|
video = await cur.fetchone()
|
||||||
|
|
||||||
|
# Naechste Episode ermitteln (gleiche Serie)
|
||||||
|
if video and video.get("series_id"):
|
||||||
|
await cur.execute("""
|
||||||
|
SELECT id, season_number, episode_number,
|
||||||
|
episode_title, file_name
|
||||||
|
FROM library_videos
|
||||||
|
WHERE series_id = %s
|
||||||
|
AND (season_number > %s
|
||||||
|
OR (season_number = %s
|
||||||
|
AND episode_number > %s))
|
||||||
|
ORDER BY season_number ASC, episode_number ASC
|
||||||
|
LIMIT 1
|
||||||
|
""", (video["series_id"],
|
||||||
|
video.get("season_number", 0),
|
||||||
|
video.get("season_number", 0),
|
||||||
|
video.get("episode_number", 0)))
|
||||||
|
next_video = await cur.fetchone()
|
||||||
|
|
||||||
if not video:
|
if not video:
|
||||||
raise web.HTTPFound("/tv/")
|
raise web.HTTPFound("/tv/")
|
||||||
|
|
||||||
|
|
@ -379,22 +610,42 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
if ep_title:
|
if ep_title:
|
||||||
title += f" - {ep_title}"
|
title += f" - {ep_title}"
|
||||||
|
|
||||||
|
# Naechste Episode Titel
|
||||||
|
next_title = ""
|
||||||
|
if next_video:
|
||||||
|
sn2 = next_video.get("season_number", 0)
|
||||||
|
en2 = next_video.get("episode_number", 0)
|
||||||
|
next_title = f"S{sn2:02d}E{en2:02d}"
|
||||||
|
if next_video.get("episode_title"):
|
||||||
|
next_title += f" - {next_video['episode_title']}"
|
||||||
|
|
||||||
|
# Client-Einstellungen
|
||||||
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
client = None
|
||||||
|
if client_id:
|
||||||
|
client = await auth_service.get_client_settings(client_id)
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/player.html", request, {
|
"tv/player.html", request, {
|
||||||
"user": user,
|
"user": user,
|
||||||
"video": video,
|
"video": video,
|
||||||
"title": title,
|
"title": title,
|
||||||
"start_pos": start_pos,
|
"start_pos": start_pos,
|
||||||
|
"next_video": next_video,
|
||||||
|
"next_title": next_title,
|
||||||
|
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
|
||||||
|
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@require_auth
|
@require_auth
|
||||||
async def get_search(request: web.Request) -> web.Response:
|
async def get_search(request: web.Request) -> web.Response:
|
||||||
"""GET /tv/search?q=... - Suchseite"""
|
"""GET /tv/search?q=... - Suchseite mit History/Autocomplete"""
|
||||||
user = request["tv_user"]
|
user = request["tv_user"]
|
||||||
query = request.query.get("q", "").strip()
|
query = request.query.get("q", "").strip()
|
||||||
results_series = []
|
results_series = []
|
||||||
results_movies = []
|
results_movies = []
|
||||||
|
history = []
|
||||||
|
|
||||||
if query and len(query) >= 2:
|
if query and len(query) >= 2:
|
||||||
pool = library_service._db_pool
|
pool = library_service._db_pool
|
||||||
|
|
@ -405,7 +656,8 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
if user.get("can_view_series"):
|
if user.get("can_view_series"):
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT id, title, folder_name, poster_url, genres
|
SELECT id, title, folder_name, poster_url,
|
||||||
|
genres
|
||||||
FROM library_series
|
FROM library_series
|
||||||
WHERE title LIKE %s OR folder_name LIKE %s
|
WHERE title LIKE %s OR folder_name LIKE %s
|
||||||
ORDER BY title LIMIT 50
|
ORDER BY title LIMIT 50
|
||||||
|
|
@ -422,6 +674,12 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
""", (search_term, search_term))
|
""", (search_term, search_term))
|
||||||
results_movies = await cur.fetchall()
|
results_movies = await cur.fetchall()
|
||||||
|
|
||||||
|
# Such-History speichern
|
||||||
|
await auth_service.save_search(user["id"], query)
|
||||||
|
else:
|
||||||
|
# Ohne Query: History anzeigen
|
||||||
|
history = await auth_service.get_search_history(user["id"])
|
||||||
|
|
||||||
return aiohttp_jinja2.render_template(
|
return aiohttp_jinja2.render_template(
|
||||||
"tv/search.html", request, {
|
"tv/search.html", request, {
|
||||||
"user": user,
|
"user": user,
|
||||||
|
|
@ -429,6 +687,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
"query": query,
|
"query": query,
|
||||||
"series": results_series,
|
"series": results_series,
|
||||||
"movies": results_movies,
|
"movies": results_movies,
|
||||||
|
"history": history,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -566,12 +825,244 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "User nicht gefunden"}, status=404)
|
{"error": "User nicht gefunden"}, status=404)
|
||||||
|
|
||||||
|
# --- Profilauswahl (Multi-User Quick-Switch) ---
|
||||||
|
|
||||||
|
async def get_profiles(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/profiles - Profilauswahl (wer schaut?)"""
|
||||||
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
profiles = []
|
||||||
|
if client_id:
|
||||||
|
profiles = await auth_service.get_client_profiles(client_id)
|
||||||
|
# Aktuelle Session herausfinden
|
||||||
|
current_session = request.cookies.get("vk_session")
|
||||||
|
return aiohttp_jinja2.render_template(
|
||||||
|
"tv/profiles.html", request, {
|
||||||
|
"profiles": profiles,
|
||||||
|
"current_session": current_session,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def post_switch_profile(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/switch-profile - Profil wechseln (Session-ID)"""
|
||||||
|
data = await request.post()
|
||||||
|
session_id = data.get("session_id", "")
|
||||||
|
if not session_id:
|
||||||
|
raise web.HTTPFound("/tv/profiles")
|
||||||
|
# Session validieren
|
||||||
|
user = await auth_service.validate_session(session_id)
|
||||||
|
if not user:
|
||||||
|
raise web.HTTPFound("/tv/login")
|
||||||
|
resp = web.HTTPFound("/tv/")
|
||||||
|
resp.set_cookie(
|
||||||
|
"vk_session", session_id,
|
||||||
|
max_age=10 * 365 * 24 * 3600,
|
||||||
|
httponly=True, samesite="Lax", path="/",
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# --- User-Einstellungen ---
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_settings(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/settings - Benutzer-Einstellungen"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
client = None
|
||||||
|
if client_id:
|
||||||
|
client = await auth_service.get_client_settings(client_id)
|
||||||
|
return aiohttp_jinja2.render_template(
|
||||||
|
"tv/settings.html", request, {
|
||||||
|
"user": user,
|
||||||
|
"client": client,
|
||||||
|
"active": "settings",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def post_settings(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/settings - Benutzer-Einstellungen speichern
|
||||||
|
Unterstuetzt sowohl vollstaendige Form-Submits als auch
|
||||||
|
einzelne AJAX-Updates (nur gesetzte Felder aendern)."""
|
||||||
|
user = request["tv_user"]
|
||||||
|
data = await request.post()
|
||||||
|
is_ajax = "X-Requested-With" in request.headers or \
|
||||||
|
len(data) <= 2
|
||||||
|
|
||||||
|
# Nur uebergebene Felder sammeln (kein Ueberschreiben)
|
||||||
|
user_kwargs = {}
|
||||||
|
field_map = {
|
||||||
|
"display_name": lambda v: v,
|
||||||
|
"preferred_audio_lang": lambda v: v,
|
||||||
|
"preferred_subtitle_lang": lambda v: v or None,
|
||||||
|
"subtitles_enabled": lambda v: v == "on",
|
||||||
|
"ui_lang": lambda v: v,
|
||||||
|
"series_view": lambda v: v,
|
||||||
|
"movies_view": lambda v: v,
|
||||||
|
"avatar_color": lambda v: v,
|
||||||
|
"theme": lambda v: v,
|
||||||
|
"autoplay_enabled": lambda v: v == "on",
|
||||||
|
"autoplay_countdown_sec": lambda v: int(v),
|
||||||
|
"autoplay_max_episodes": lambda v: int(v),
|
||||||
|
}
|
||||||
|
for key, transform in field_map.items():
|
||||||
|
if key in data:
|
||||||
|
user_kwargs[key] = transform(data[key])
|
||||||
|
|
||||||
|
if user_kwargs:
|
||||||
|
await auth_service.update_user_settings(
|
||||||
|
user["id"], **user_kwargs)
|
||||||
|
|
||||||
|
# Client-Einstellungen (nur wenn Felder vorhanden)
|
||||||
|
client_id = request.cookies.get("vk_client_id")
|
||||||
|
client_kwargs = {}
|
||||||
|
if client_id:
|
||||||
|
if "client_name" in data:
|
||||||
|
client_kwargs["name"] = data["client_name"]
|
||||||
|
if "sound_mode" in data:
|
||||||
|
client_kwargs["sound_mode"] = data["sound_mode"]
|
||||||
|
if "stream_quality" in data:
|
||||||
|
client_kwargs["stream_quality"] = data["stream_quality"]
|
||||||
|
if client_kwargs:
|
||||||
|
await auth_service.update_client_settings(
|
||||||
|
client_id, **client_kwargs)
|
||||||
|
|
||||||
|
# AJAX: JSON zurueckgeben, sonst Redirect
|
||||||
|
if is_ajax:
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
raise web.HTTPFound("/tv/settings?saved=1")
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def post_reset_progress(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/settings/reset - Alle Fortschritte zuruecksetzen"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
await auth_service.reset_all_progress(user["id"])
|
||||||
|
raise web.HTTPFound("/tv/settings?reset=1")
|
||||||
|
|
||||||
|
# --- Watchlist ---
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_watchlist(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/watchlist - Merkliste anzeigen"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
wl = await auth_service.get_watchlist(user["id"])
|
||||||
|
return aiohttp_jinja2.render_template(
|
||||||
|
"tv/watchlist.html", request, {
|
||||||
|
"user": user,
|
||||||
|
"active": "watchlist",
|
||||||
|
"series": wl["series"],
|
||||||
|
"movies": wl["movies"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def post_watchlist_toggle(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/api/watchlist - Toggle Merkliste (JSON)"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
data = await request.json()
|
||||||
|
series_id = data.get("series_id")
|
||||||
|
movie_id = data.get("movie_id")
|
||||||
|
in_list = await auth_service.toggle_watchlist(
|
||||||
|
user["id"],
|
||||||
|
series_id=int(series_id) if series_id else None,
|
||||||
|
movie_id=int(movie_id) if movie_id else None,
|
||||||
|
)
|
||||||
|
return web.json_response({"in_watchlist": in_list})
|
||||||
|
|
||||||
|
# --- Watch-Status ---
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def post_watch_status(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/api/watch-status - Status setzen (JSON)"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
data = await request.json()
|
||||||
|
status = data.get("status", "unwatched")
|
||||||
|
success = await auth_service.set_watch_status(
|
||||||
|
user["id"], status,
|
||||||
|
video_id=data.get("video_id"),
|
||||||
|
series_id=data.get("series_id"),
|
||||||
|
season_key=data.get("season_key"),
|
||||||
|
)
|
||||||
|
return web.json_response({"success": success})
|
||||||
|
|
||||||
|
# --- Such-API ---
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_search_suggestions(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/api/search/suggest?q=... - Autocomplete-Vorschlaege"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
prefix = request.query.get("q", "").strip()
|
||||||
|
suggestions = await auth_service.get_search_suggestions(
|
||||||
|
user["id"], prefix)
|
||||||
|
return web.json_response({"suggestions": suggestions})
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def get_search_history(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/api/search/history - Such-History"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
history = await auth_service.get_search_history(user["id"])
|
||||||
|
return web.json_response({"history": history})
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def delete_search_history(request: web.Request) -> web.Response:
|
||||||
|
"""DELETE /tv/api/search/history - Such-History loeschen"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
await auth_service.clear_search_history(user["id"])
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
|
||||||
|
# --- Rating API ---
|
||||||
|
|
||||||
|
@require_auth
|
||||||
|
async def post_rating(request: web.Request) -> web.Response:
|
||||||
|
"""POST /tv/api/rating - Bewertung setzen/loeschen (JSON)
|
||||||
|
Body: { series_id|movie_id: int, rating: 0-5 }"""
|
||||||
|
user = request["tv_user"]
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
|
||||||
|
|
||||||
|
rating = int(data.get("rating", 0))
|
||||||
|
series_id = data.get("series_id")
|
||||||
|
movie_id = data.get("movie_id")
|
||||||
|
|
||||||
|
if not series_id and not movie_id:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "series_id oder movie_id noetig"}, status=400)
|
||||||
|
|
||||||
|
success = await auth_service.set_rating(
|
||||||
|
user["id"], rating,
|
||||||
|
series_id=int(series_id) if series_id else None,
|
||||||
|
movie_id=int(movie_id) if movie_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Durchschnitt zurueckgeben
|
||||||
|
avg = await auth_service.get_avg_rating(
|
||||||
|
series_id=int(series_id) if series_id else None,
|
||||||
|
movie_id=int(movie_id) if movie_id else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"success": success,
|
||||||
|
"user_rating": rating,
|
||||||
|
"avg_rating": avg["avg"],
|
||||||
|
"rating_count": avg["count"],
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- i18n API (fuer JavaScript) ---
|
||||||
|
|
||||||
|
async def get_i18n(request: web.Request) -> web.Response:
|
||||||
|
"""GET /tv/api/i18n?lang=de - Alle Uebersetzungen als JSON"""
|
||||||
|
lang = request.query.get("lang", "de")
|
||||||
|
return web.json_response(get_all_translations(lang))
|
||||||
|
|
||||||
# --- Routes registrieren ---
|
# --- Routes registrieren ---
|
||||||
|
|
||||||
# TV-Seiten (mit Auth via Decorator)
|
# TV-Seiten (mit Auth via Decorator)
|
||||||
app.router.add_get("/tv/login", get_login)
|
app.router.add_get("/tv/login", get_login)
|
||||||
app.router.add_post("/tv/login", post_login)
|
app.router.add_post("/tv/login", post_login)
|
||||||
app.router.add_get("/tv/logout", get_logout)
|
app.router.add_get("/tv/logout", get_logout)
|
||||||
|
app.router.add_get("/tv/profiles", get_profiles)
|
||||||
|
app.router.add_post("/tv/switch-profile", post_switch_profile)
|
||||||
app.router.add_get("/tv/", get_home)
|
app.router.add_get("/tv/", get_home)
|
||||||
app.router.add_get("/tv/series", get_series_list)
|
app.router.add_get("/tv/series", get_series_list)
|
||||||
app.router.add_get("/tv/series/{id}", get_series_detail)
|
app.router.add_get("/tv/series/{id}", get_series_detail)
|
||||||
|
|
@ -579,11 +1070,22 @@ def setup_tv_routes(app: web.Application, config: Config,
|
||||||
app.router.add_get("/tv/movies/{id}", get_movie_detail)
|
app.router.add_get("/tv/movies/{id}", get_movie_detail)
|
||||||
app.router.add_get("/tv/player", get_player)
|
app.router.add_get("/tv/player", get_player)
|
||||||
app.router.add_get("/tv/search", get_search)
|
app.router.add_get("/tv/search", get_search)
|
||||||
|
app.router.add_get("/tv/watchlist", get_watchlist)
|
||||||
|
app.router.add_get("/tv/settings", get_settings)
|
||||||
|
app.router.add_post("/tv/settings", post_settings)
|
||||||
|
app.router.add_post("/tv/settings/reset", post_reset_progress)
|
||||||
|
|
||||||
# TV-API (Watch-Progress)
|
# TV-API (Watch-Progress, Watchlist, Status, Suche, i18n)
|
||||||
app.router.add_post("/tv/api/watch-progress", post_watch_progress)
|
app.router.add_post("/tv/api/watch-progress", post_watch_progress)
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
"/tv/api/watch-progress/{video_id}", get_watch_progress)
|
"/tv/api/watch-progress/{video_id}", get_watch_progress)
|
||||||
|
app.router.add_post("/tv/api/watchlist", post_watchlist_toggle)
|
||||||
|
app.router.add_post("/tv/api/watch-status", post_watch_status)
|
||||||
|
app.router.add_get("/tv/api/search/suggest", get_search_suggestions)
|
||||||
|
app.router.add_get("/tv/api/search/history", get_search_history)
|
||||||
|
app.router.add_delete("/tv/api/search/history", delete_search_history)
|
||||||
|
app.router.add_get("/tv/api/i18n", get_i18n)
|
||||||
|
app.router.add_post("/tv/api/rating", post_rating)
|
||||||
|
|
||||||
# Admin-API (QR-Code, User-Verwaltung)
|
# Admin-API (QR-Code, User-Verwaltung)
|
||||||
app.router.add_get("/api/tv/qrcode", get_qrcode)
|
app.router.add_get("/api/tv/qrcode", get_qrcode)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.services.tvdb import TVDBService
|
||||||
from app.services.cleaner import CleanerService
|
from app.services.cleaner import CleanerService
|
||||||
from app.services.importer import ImporterService
|
from app.services.importer import ImporterService
|
||||||
from app.services.auth import AuthService
|
from app.services.auth import AuthService
|
||||||
|
from app.services.i18n import load_translations, setup_jinja2_i18n
|
||||||
from app.routes.api import setup_api_routes
|
from app.routes.api import setup_api_routes
|
||||||
from app.routes.library_api import setup_library_routes
|
from app.routes.library_api import setup_library_routes
|
||||||
from app.routes.pages import setup_page_routes
|
from app.routes.pages import setup_page_routes
|
||||||
|
|
@ -70,6 +71,11 @@ class VideoKonverterServer:
|
||||||
context_processors=[aiohttp_jinja2.request_processor],
|
context_processors=[aiohttp_jinja2.request_processor],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# i18n: Uebersetzungen laden und Jinja2-Filter registrieren
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
load_translations(str(static_dir))
|
||||||
|
setup_jinja2_i18n(self.app)
|
||||||
|
|
||||||
# WebSocket Route
|
# WebSocket Route
|
||||||
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
||||||
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class AuthService:
|
||||||
self._get_pool = db_pool_getter
|
self._get_pool = db_pool_getter
|
||||||
|
|
||||||
async def init_db(self) -> None:
|
async def init_db(self) -> None:
|
||||||
"""Erstellt DB-Tabellen fuer TV-Auth"""
|
"""Erstellt DB-Tabellen fuer TV-Auth und migriert bestehende"""
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
logging.error("Auth: Kein DB-Pool verfuegbar")
|
logging.error("Auth: Kein DB-Pool verfuegbar")
|
||||||
|
|
@ -24,6 +24,7 @@ class AuthService:
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
|
# === Bestehende Tabellen ===
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS tv_users (
|
CREATE TABLE IF NOT EXISTS tv_users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
@ -64,10 +65,171 @@ class AuthService:
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# === Neue Tabellen (v4.0) ===
|
||||||
|
|
||||||
|
# Client-Einstellungen (pro Geraet/Browser)
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_clients (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
name VARCHAR(128) DEFAULT NULL,
|
||||||
|
sound_mode ENUM('stereo','surround','original')
|
||||||
|
DEFAULT 'stereo',
|
||||||
|
stream_quality ENUM('uhd','hd','sd','low')
|
||||||
|
DEFAULT 'hd',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Merkliste (Watchlist)
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_watchlist (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
series_id INT NULL,
|
||||||
|
movie_id INT NULL,
|
||||||
|
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||||
|
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Manueller Watch-Status
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_watch_status (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
video_id INT NULL,
|
||||||
|
series_id INT NULL,
|
||||||
|
season_key VARCHAR(64) NULL,
|
||||||
|
status ENUM('unwatched','watching','watched')
|
||||||
|
DEFAULT 'unwatched',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
UNIQUE INDEX idx_user_video (user_id, video_id),
|
||||||
|
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||||
|
UNIQUE INDEX idx_user_season (user_id, season_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Episoden-Thumbnails Cache
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_episode_thumbnails (
|
||||||
|
video_id INT PRIMARY KEY,
|
||||||
|
thumbnail_path VARCHAR(1024) NOT NULL,
|
||||||
|
source ENUM('tvdb','ffmpeg') DEFAULT 'ffmpeg',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Such-History (pro User)
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_search_history (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
query VARCHAR(256) NOT NULL,
|
||||||
|
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_query (query(64)),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Bewertungen (pro User, fuer Serien und Filme)
|
||||||
|
await cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS tv_ratings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
series_id INT NULL,
|
||||||
|
movie_id INT NULL,
|
||||||
|
rating TINYINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||||
|
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
""")
|
||||||
|
|
||||||
|
# === Migration: Neue Spalten zu bestehenden Tabellen ===
|
||||||
|
await self._migrate_columns(cur)
|
||||||
|
|
||||||
# Standard-Admin erstellen falls keine User existieren
|
# Standard-Admin erstellen falls keine User existieren
|
||||||
await self._ensure_default_admin()
|
await self._ensure_default_admin()
|
||||||
logging.info("TV-Auth: DB-Tabellen initialisiert")
|
logging.info("TV-Auth: DB-Tabellen initialisiert")
|
||||||
|
|
||||||
|
async def _migrate_columns(self, cur) -> None:
|
||||||
|
"""Fuegt neue Spalten zu bestehenden Tabellen hinzu (idempotent)"""
|
||||||
|
# Hilfsfunktion: Spalte hinzufuegen falls nicht vorhanden
|
||||||
|
async def add_column(table: str, column: str, definition: str):
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||||
|
"AND TABLE_NAME = %s AND COLUMN_NAME = %s",
|
||||||
|
(table, column)
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if row[0] == 0:
|
||||||
|
await cur.execute(
|
||||||
|
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
||||||
|
)
|
||||||
|
logging.info(f"TV-Auth: Spalte {table}.{column} hinzugefuegt")
|
||||||
|
|
||||||
|
# tv_users: User-Einstellungen
|
||||||
|
await add_column("tv_users", "preferred_audio_lang",
|
||||||
|
"VARCHAR(8) DEFAULT 'deu'")
|
||||||
|
await add_column("tv_users", "preferred_subtitle_lang",
|
||||||
|
"VARCHAR(8) DEFAULT NULL")
|
||||||
|
await add_column("tv_users", "subtitles_enabled",
|
||||||
|
"TINYINT DEFAULT 0")
|
||||||
|
await add_column("tv_users", "ui_lang",
|
||||||
|
"VARCHAR(8) DEFAULT 'de'")
|
||||||
|
await add_column("tv_users", "series_view",
|
||||||
|
"VARCHAR(16) DEFAULT 'grid'")
|
||||||
|
await add_column("tv_users", "movies_view",
|
||||||
|
"VARCHAR(16) DEFAULT 'grid'")
|
||||||
|
await add_column("tv_users", "avatar_color",
|
||||||
|
"VARCHAR(7) DEFAULT '#64b5f6'")
|
||||||
|
# Auto-Play Einstellungen
|
||||||
|
await add_column("tv_users", "autoplay_enabled",
|
||||||
|
"TINYINT DEFAULT 1")
|
||||||
|
await add_column("tv_users", "autoplay_countdown_sec",
|
||||||
|
"INT DEFAULT 10")
|
||||||
|
await add_column("tv_users", "autoplay_max_episodes",
|
||||||
|
"INT DEFAULT 0")
|
||||||
|
|
||||||
|
# tv_sessions: Client-Referenz und permanente Sessions
|
||||||
|
await add_column("tv_sessions", "client_id",
|
||||||
|
"VARCHAR(64) DEFAULT NULL")
|
||||||
|
await add_column("tv_sessions", "expires_at",
|
||||||
|
"TIMESTAMP NULL DEFAULT NULL")
|
||||||
|
|
||||||
|
# tvdb_episode_cache: Beschreibung und Bild-URL
|
||||||
|
await add_column("tvdb_episode_cache", "overview",
|
||||||
|
"TEXT DEFAULT NULL")
|
||||||
|
await add_column("tvdb_episode_cache", "image_url",
|
||||||
|
"VARCHAR(1024) DEFAULT NULL")
|
||||||
|
|
||||||
|
# tv_users: Theme
|
||||||
|
await add_column("tv_users", "theme",
|
||||||
|
"VARCHAR(16) DEFAULT 'dark'")
|
||||||
|
|
||||||
|
# library_series: TVDB-Score (externe Bewertung 0-100)
|
||||||
|
await add_column("library_series", "tvdb_score",
|
||||||
|
"FLOAT DEFAULT NULL")
|
||||||
|
|
||||||
|
# library_movies: TVDB-Score (externe Bewertung 0-100)
|
||||||
|
await add_column("library_movies", "tvdb_score",
|
||||||
|
"FLOAT DEFAULT NULL")
|
||||||
|
|
||||||
async def _ensure_default_admin(self) -> None:
|
async def _ensure_default_admin(self) -> None:
|
||||||
"""Erstellt admin/admin falls keine User existieren"""
|
"""Erstellt admin/admin falls keine User existieren"""
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
|
|
@ -254,22 +416,41 @@ class AuthService:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def create_session(self, user_id: int,
|
async def create_session(self, user_id: int,
|
||||||
user_agent: str = "") -> str:
|
user_agent: str = "",
|
||||||
"""Erstellt Session, gibt Token zurueck"""
|
client_id: str = "",
|
||||||
|
persistent: bool = False) -> str:
|
||||||
|
"""Erstellt Session, gibt Token zurueck.
|
||||||
|
persistent=True -> Session laeuft nie ab (expires_at=NULL)"""
|
||||||
session_id = secrets.token_urlsafe(48)
|
session_id = secrets.token_urlsafe(48)
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return ""
|
return ""
|
||||||
|
# Nicht-persistente Sessions laufen nach 30 Tagen ab
|
||||||
|
expires = None if persistent else "DATE_ADD(NOW(), INTERVAL 30 DAY)"
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute("""
|
if persistent:
|
||||||
INSERT INTO tv_sessions (id, user_id, user_agent)
|
await cur.execute("""
|
||||||
VALUES (%s, %s, %s)
|
INSERT INTO tv_sessions
|
||||||
""", (session_id, user_id, user_agent[:512] if user_agent else ""))
|
(id, user_id, user_agent, client_id, expires_at)
|
||||||
|
VALUES (%s, %s, %s, %s, NULL)
|
||||||
|
""", (session_id, user_id,
|
||||||
|
user_agent[:512] if user_agent else "",
|
||||||
|
client_id or None))
|
||||||
|
else:
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_sessions
|
||||||
|
(id, user_id, user_agent, client_id, expires_at)
|
||||||
|
VALUES (%s, %s, %s, %s,
|
||||||
|
DATE_ADD(NOW(), INTERVAL 30 DAY))
|
||||||
|
""", (session_id, user_id,
|
||||||
|
user_agent[:512] if user_agent else "",
|
||||||
|
client_id or None))
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
async def validate_session(self, session_id: str) -> Optional[dict]:
|
async def validate_session(self, session_id: str) -> Optional[dict]:
|
||||||
"""Prueft Session, gibt User-Dict zurueck oder None"""
|
"""Prueft Session, gibt User-Dict mit Einstellungen zurueck oder None.
|
||||||
|
Beruecksichtigt expires_at (NULL = permanent, sonst Ablauf-Datum)."""
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
|
|
@ -279,11 +460,18 @@ class AuthService:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT u.id, u.username, u.display_name, u.is_admin,
|
SELECT u.id, u.username, u.display_name, u.is_admin,
|
||||||
u.can_view_series, u.can_view_movies, u.allowed_paths
|
u.can_view_series, u.can_view_movies,
|
||||||
|
u.allowed_paths,
|
||||||
|
u.preferred_audio_lang, u.preferred_subtitle_lang,
|
||||||
|
u.subtitles_enabled, u.ui_lang,
|
||||||
|
u.series_view, u.movies_view, u.avatar_color,
|
||||||
|
u.autoplay_enabled, u.autoplay_countdown_sec,
|
||||||
|
u.autoplay_max_episodes, u.theme,
|
||||||
|
s.client_id
|
||||||
FROM tv_sessions s
|
FROM tv_sessions s
|
||||||
JOIN tv_users u ON s.user_id = u.id
|
JOIN tv_users u ON s.user_id = u.id
|
||||||
WHERE s.id = %s
|
WHERE s.id = %s
|
||||||
AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
|
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
||||||
""", (session_id,))
|
""", (session_id,))
|
||||||
user = await cur.fetchone()
|
user = await cur.fetchone()
|
||||||
|
|
||||||
|
|
@ -311,7 +499,8 @@ class AuthService:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cleanup_old_sessions(self) -> int:
|
async def cleanup_old_sessions(self) -> int:
|
||||||
"""Loescht Sessions aelter als 30 Tage"""
|
"""Loescht abgelaufene Sessions (expires_at abgelaufen).
|
||||||
|
Persistente Sessions (expires_at IS NULL) werden nie geloescht."""
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -319,10 +508,466 @@ class AuthService:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"DELETE FROM tv_sessions "
|
"DELETE FROM tv_sessions "
|
||||||
"WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"
|
"WHERE expires_at IS NOT NULL AND expires_at < NOW()"
|
||||||
)
|
)
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
|
# --- Client-Verwaltung (pro Geraet) ---
|
||||||
|
|
||||||
|
async def get_or_create_client(self, client_id: str = None) -> str:
|
||||||
|
"""Gibt bestehende oder neue Client-ID zurueck"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return ""
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if client_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT id FROM tv_clients WHERE id = %s",
|
||||||
|
(client_id,))
|
||||||
|
if await cur.fetchone():
|
||||||
|
await cur.execute(
|
||||||
|
"UPDATE tv_clients SET last_active = NOW() "
|
||||||
|
"WHERE id = %s", (client_id,))
|
||||||
|
return client_id
|
||||||
|
# Neuen Client erstellen
|
||||||
|
new_id = secrets.token_urlsafe(32)
|
||||||
|
await cur.execute(
|
||||||
|
"INSERT INTO tv_clients (id) VALUES (%s)",
|
||||||
|
(new_id,))
|
||||||
|
return new_id
|
||||||
|
|
||||||
|
async def get_client_settings(self, client_id: str) -> Optional[dict]:
|
||||||
|
"""Liest Client-Einstellungen"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT * FROM tv_clients WHERE id = %s",
|
||||||
|
(client_id,))
|
||||||
|
return await cur.fetchone()
|
||||||
|
|
||||||
|
async def update_client_settings(self, client_id: str,
|
||||||
|
**kwargs) -> bool:
|
||||||
|
"""Aktualisiert Client-Einstellungen (name, sound_mode, stream_quality)"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
allowed = {"name", "sound_mode", "stream_quality"}
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
for key, val in kwargs.items():
|
||||||
|
if key in allowed and val is not None:
|
||||||
|
updates.append(f"{key} = %s")
|
||||||
|
values.append(val)
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
values.append(client_id)
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
f"UPDATE tv_clients SET {', '.join(updates)} "
|
||||||
|
"WHERE id = %s", tuple(values))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Client-Settings fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Multi-User: Profile auf dem selben Geraet ---
|
||||||
|
|
||||||
|
async def get_client_profiles(self, client_id: str) -> list[dict]:
|
||||||
|
"""Alle eingeloggten User auf einem Client (fuer Quick-Switch)"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
await cur.execute("""
|
||||||
|
SELECT s.id as session_id, u.id as user_id,
|
||||||
|
u.username, u.display_name, u.avatar_color
|
||||||
|
FROM tv_sessions s
|
||||||
|
JOIN tv_users u ON s.user_id = u.id
|
||||||
|
WHERE s.client_id = %s
|
||||||
|
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
||||||
|
ORDER BY s.last_active DESC
|
||||||
|
""", (client_id,))
|
||||||
|
return await cur.fetchall()
|
||||||
|
|
||||||
|
# --- User-Einstellungen ---
|
||||||
|
|
||||||
|
async def update_user_settings(self, user_id: int,
|
||||||
|
**kwargs) -> bool:
|
||||||
|
"""Aktualisiert User-Einstellungen (Sprache, Ansichten, Auto-Play)"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
allowed = {
|
||||||
|
"preferred_audio_lang", "preferred_subtitle_lang",
|
||||||
|
"subtitles_enabled", "ui_lang",
|
||||||
|
"series_view", "movies_view", "avatar_color",
|
||||||
|
"autoplay_enabled", "autoplay_countdown_sec",
|
||||||
|
"autoplay_max_episodes", "display_name", "theme",
|
||||||
|
}
|
||||||
|
updates = []
|
||||||
|
values = []
|
||||||
|
for key, val in kwargs.items():
|
||||||
|
if key in allowed:
|
||||||
|
updates.append(f"{key} = %s")
|
||||||
|
if isinstance(val, bool):
|
||||||
|
val = int(val)
|
||||||
|
values.append(val)
|
||||||
|
if not updates:
|
||||||
|
return False
|
||||||
|
values.append(user_id)
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
f"UPDATE tv_users SET {', '.join(updates)} "
|
||||||
|
"WHERE id = %s", tuple(values))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Einstellungen fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# --- Watchlist (Merkliste) ---
|
||||||
|
|
||||||
|
async def add_to_watchlist(self, user_id: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> bool:
|
||||||
|
"""Fuegt Serie oder Film zur Merkliste hinzu"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT IGNORE INTO tv_watchlist
|
||||||
|
(user_id, series_id, movie_id)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
""", (user_id, series_id, movie_id))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Watchlist hinzufuegen fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def remove_from_watchlist(self, user_id: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> bool:
|
||||||
|
"""Entfernt Serie oder Film von der Merkliste"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
elif movie_id:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND movie_id = %s",
|
||||||
|
(user_id, movie_id))
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Watchlist entfernen fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def toggle_watchlist(self, user_id: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> bool:
|
||||||
|
"""Toggle: Hinzufuegen wenn nicht vorhanden, entfernen wenn schon drin.
|
||||||
|
Gibt True zurueck wenn jetzt in der Liste, False wenn entfernt."""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
# Pruefen ob schon in Liste
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT id FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
else:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT id FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND movie_id = %s",
|
||||||
|
(user_id, movie_id))
|
||||||
|
exists = await cur.fetchone()
|
||||||
|
if exists:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_watchlist WHERE id = %s",
|
||||||
|
(exists[0],))
|
||||||
|
return False # Entfernt
|
||||||
|
else:
|
||||||
|
await cur.execute(
|
||||||
|
"INSERT INTO tv_watchlist "
|
||||||
|
"(user_id, series_id, movie_id) VALUES (%s, %s, %s)",
|
||||||
|
(user_id, series_id, movie_id))
|
||||||
|
return True # Hinzugefuegt
|
||||||
|
|
||||||
|
async def get_watchlist(self, user_id: int) -> dict:
|
||||||
|
"""Gibt Merkliste zurueck (Serien + Filme)"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return {"series": [], "movies": []}
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
|
# Serien
|
||||||
|
await cur.execute("""
|
||||||
|
SELECT w.id as watchlist_id, w.added_at,
|
||||||
|
s.id, s.title, s.folder_name, s.poster_url,
|
||||||
|
s.genres, s.overview
|
||||||
|
FROM tv_watchlist w
|
||||||
|
JOIN library_series s ON w.series_id = s.id
|
||||||
|
WHERE w.user_id = %s AND w.series_id IS NOT NULL
|
||||||
|
ORDER BY w.added_at DESC
|
||||||
|
""", (user_id,))
|
||||||
|
series = await cur.fetchall()
|
||||||
|
# Filme
|
||||||
|
await cur.execute("""
|
||||||
|
SELECT w.id as watchlist_id, w.added_at,
|
||||||
|
m.id, m.title, m.folder_name, m.poster_url,
|
||||||
|
m.year, m.genres, m.overview
|
||||||
|
FROM tv_watchlist w
|
||||||
|
JOIN library_movies m ON w.movie_id = m.id
|
||||||
|
WHERE w.user_id = %s AND w.movie_id IS NOT NULL
|
||||||
|
ORDER BY w.added_at DESC
|
||||||
|
""", (user_id,))
|
||||||
|
movies = await cur.fetchall()
|
||||||
|
return {"series": series, "movies": movies}
|
||||||
|
|
||||||
|
async def is_in_watchlist(self, user_id: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> bool:
|
||||||
|
"""Prueft ob Serie/Film in der Merkliste ist"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT 1 FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
else:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT 1 FROM tv_watchlist "
|
||||||
|
"WHERE user_id = %s AND movie_id = %s",
|
||||||
|
(user_id, movie_id))
|
||||||
|
return await cur.fetchone() is not None
|
||||||
|
|
||||||
|
# --- Watch-Status (manuell gesehen/nicht gesehen) ---
|
||||||
|
|
||||||
|
async def set_watch_status(self, user_id: int, status: str,
|
||||||
|
video_id: int = None,
|
||||||
|
series_id: int = None,
|
||||||
|
season_key: str = None) -> bool:
|
||||||
|
"""Setzt manuellen Watch-Status (unwatched/watching/watched)"""
|
||||||
|
if status not in ("unwatched", "watching", "watched"):
|
||||||
|
return False
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_watch_status
|
||||||
|
(user_id, video_id, series_id, season_key, status)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE status = VALUES(status)
|
||||||
|
""", (user_id, video_id, series_id, season_key, status))
|
||||||
|
|
||||||
|
# Bei Staffel/Serie auch Einzel-Episoden aktualisieren
|
||||||
|
if series_id and not video_id and not season_key:
|
||||||
|
# Ganze Serie markieren
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_watch_status
|
||||||
|
(user_id, video_id, status)
|
||||||
|
SELECT %s, v.id, %s
|
||||||
|
FROM library_videos v
|
||||||
|
WHERE v.series_id = %s
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status)
|
||||||
|
""", (user_id, status, series_id))
|
||||||
|
elif season_key:
|
||||||
|
# Ganze Staffel markieren (format: "series_id:season")
|
||||||
|
parts = season_key.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
sid, sn = int(parts[0]), int(parts[1])
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_watch_status
|
||||||
|
(user_id, video_id, status)
|
||||||
|
SELECT %s, v.id, %s
|
||||||
|
FROM library_videos v
|
||||||
|
WHERE v.series_id = %s
|
||||||
|
AND v.season_number = %s
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status)
|
||||||
|
""", (user_id, status, sid, sn))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Watch-Status fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_watch_status(self, user_id: int,
|
||||||
|
video_id: int = None,
|
||||||
|
series_id: int = None) -> Optional[str]:
|
||||||
|
"""Gibt Watch-Status zurueck"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return None
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if video_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT status FROM tv_watch_status "
|
||||||
|
"WHERE user_id = %s AND video_id = %s",
|
||||||
|
(user_id, video_id))
|
||||||
|
elif series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT status FROM tv_watch_status "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
# --- Such-History ---
|
||||||
|
|
||||||
|
async def save_search(self, user_id: int, query: str) -> None:
|
||||||
|
"""Speichert Suchanfrage in der History"""
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
# Duplikate vermeiden: gleiche Query aktualisieren
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_search_history "
|
||||||
|
"WHERE user_id = %s AND query = %s",
|
||||||
|
(user_id, query))
|
||||||
|
await cur.execute(
|
||||||
|
"INSERT INTO tv_search_history (user_id, query) "
|
||||||
|
"VALUES (%s, %s)", (user_id, query))
|
||||||
|
# Max. 50 Eintraege behalten
|
||||||
|
await cur.execute("""
|
||||||
|
DELETE FROM tv_search_history
|
||||||
|
WHERE user_id = %s AND id NOT IN (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id FROM tv_search_history
|
||||||
|
WHERE user_id = %s
|
||||||
|
ORDER BY searched_at DESC LIMIT 50
|
||||||
|
) t
|
||||||
|
)
|
||||||
|
""", (user_id, user_id))
|
||||||
|
|
||||||
|
async def get_search_history(self, user_id: int,
|
||||||
|
limit: int = 20) -> list[str]:
|
||||||
|
"""Gibt letzte Suchanfragen zurueck"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT query FROM tv_search_history "
|
||||||
|
"WHERE user_id = %s ORDER BY searched_at DESC LIMIT %s",
|
||||||
|
(user_id, limit))
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
async def get_search_suggestions(self, user_id: int,
|
||||||
|
prefix: str,
|
||||||
|
limit: int = 8) -> list[str]:
|
||||||
|
"""Autocomplete: Vorschlaege aus History + Serien/Film-Titel"""
|
||||||
|
if not prefix or len(prefix) < 1:
|
||||||
|
return []
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
suggestions = []
|
||||||
|
search = f"{prefix}%"
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
# Aus Such-History
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT DISTINCT query FROM tv_search_history "
|
||||||
|
"WHERE user_id = %s AND query LIKE %s "
|
||||||
|
"ORDER BY searched_at DESC LIMIT %s",
|
||||||
|
(user_id, search, limit))
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
suggestions.extend(r[0] for r in rows)
|
||||||
|
# Aus Serien-Titeln
|
||||||
|
remaining = limit - len(suggestions)
|
||||||
|
if remaining > 0:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT title FROM library_series "
|
||||||
|
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
||||||
|
(search, remaining))
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
for r in rows:
|
||||||
|
if r[0] not in suggestions:
|
||||||
|
suggestions.append(r[0])
|
||||||
|
# Aus Film-Titeln
|
||||||
|
remaining = limit - len(suggestions)
|
||||||
|
if remaining > 0:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT title FROM library_movies "
|
||||||
|
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
||||||
|
(search, remaining))
|
||||||
|
rows = await cur.fetchall()
|
||||||
|
for r in rows:
|
||||||
|
if r[0] not in suggestions:
|
||||||
|
suggestions.append(r[0])
|
||||||
|
return suggestions[:limit]
|
||||||
|
|
||||||
|
async def clear_search_history(self, user_id: int) -> bool:
|
||||||
|
"""Loescht alle Suchanfragen eines Users"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_search_history WHERE user_id = %s",
|
||||||
|
(user_id,))
|
||||||
|
return True
|
||||||
|
|
||||||
|
# --- Fortschritt zuruecksetzen ---
|
||||||
|
|
||||||
|
async def reset_all_progress(self, user_id: int) -> bool:
|
||||||
|
"""Setzt ALLE Fortschritte und Status eines Users zurueck"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_watch_progress WHERE user_id = %s",
|
||||||
|
(user_id,))
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_watch_status WHERE user_id = %s",
|
||||||
|
(user_id,))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Reset fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# --- Watch-Progress ---
|
# --- Watch-Progress ---
|
||||||
|
|
||||||
async def save_progress(self, user_id: int, video_id: int,
|
async def save_progress(self, user_id: int, video_id: int,
|
||||||
|
|
@ -391,3 +1036,91 @@ class AuthService:
|
||||||
row["updated_at"], "isoformat"):
|
row["updated_at"], "isoformat"):
|
||||||
row["updated_at"] = str(row["updated_at"])
|
row["updated_at"] = str(row["updated_at"])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
# --- Bewertungen (Ratings) ---
|
||||||
|
|
||||||
|
async def set_rating(self, user_id: int, rating: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> bool:
|
||||||
|
"""Setzt User-Bewertung (1-5 Sterne). 0 = Bewertung loeschen."""
|
||||||
|
if rating < 0 or rating > 5:
|
||||||
|
return False
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if rating == 0:
|
||||||
|
# Bewertung loeschen
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_ratings "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
elif movie_id:
|
||||||
|
await cur.execute(
|
||||||
|
"DELETE FROM tv_ratings "
|
||||||
|
"WHERE user_id = %s AND movie_id = %s",
|
||||||
|
(user_id, movie_id))
|
||||||
|
else:
|
||||||
|
await cur.execute("""
|
||||||
|
INSERT INTO tv_ratings
|
||||||
|
(user_id, series_id, movie_id, rating)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON DUPLICATE KEY UPDATE rating = VALUES(rating)
|
||||||
|
""", (user_id, series_id, movie_id, rating))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"TV-Auth: Rating fehlgeschlagen: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def get_rating(self, user_id: int,
|
||||||
|
series_id: int = None,
|
||||||
|
movie_id: int = None) -> int:
|
||||||
|
"""Gibt User-Rating zurueck (0 = keine Bewertung)"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return 0
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT rating FROM tv_ratings "
|
||||||
|
"WHERE user_id = %s AND series_id = %s",
|
||||||
|
(user_id, series_id))
|
||||||
|
elif movie_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT rating FROM tv_ratings "
|
||||||
|
"WHERE user_id = %s AND movie_id = %s",
|
||||||
|
(user_id, movie_id))
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
async def get_avg_rating(self, series_id: int = None,
|
||||||
|
movie_id: int = None) -> dict:
|
||||||
|
"""Gibt Durchschnittsbewertung + Anzahl zurueck"""
|
||||||
|
pool = await self._get_pool()
|
||||||
|
if not pool:
|
||||||
|
return {"avg": 0, "count": 0}
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.cursor() as cur:
|
||||||
|
if series_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
||||||
|
"FROM tv_ratings WHERE series_id = %s AND rating > 0",
|
||||||
|
(series_id,))
|
||||||
|
elif movie_id:
|
||||||
|
await cur.execute(
|
||||||
|
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
||||||
|
"FROM tv_ratings WHERE movie_id = %s AND rating > 0",
|
||||||
|
(movie_id,))
|
||||||
|
else:
|
||||||
|
return {"avg": 0, "count": 0}
|
||||||
|
row = await cur.fetchone()
|
||||||
|
return {
|
||||||
|
"avg": round(float(row[0] or 0), 1),
|
||||||
|
"count": int(row[1] or 0),
|
||||||
|
}
|
||||||
|
|
|
||||||
101
video-konverter/app/services/i18n.py
Normal file
101
video-konverter/app/services/i18n.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Internationalisierung (i18n) fuer die TV-App.
|
||||||
|
Laedt Uebersetzungen aus JSON-Dateien und stellt Jinja2-Filter bereit."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Verfuegbare Sprachen
|
||||||
|
SUPPORTED_LANGS = ("de", "en")
|
||||||
|
DEFAULT_LANG = "de"
|
||||||
|
|
||||||
|
# Cache fuer geladene Uebersetzungen
|
||||||
|
_translations: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_translations(static_dir: str) -> None:
|
||||||
|
"""Laedt alle Uebersetzungsdateien aus static/tv/i18n/"""
|
||||||
|
i18n_dir = os.path.join(static_dir, "tv", "i18n")
|
||||||
|
for lang in SUPPORTED_LANGS:
|
||||||
|
filepath = os.path.join(i18n_dir, f"{lang}.json")
|
||||||
|
if os.path.isfile(filepath):
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
_translations[lang] = json.load(f)
|
||||||
|
logging.info(f"i18n: Sprache '{lang}' geladen ({filepath})")
|
||||||
|
else:
|
||||||
|
logging.warning(f"i18n: Datei nicht gefunden: {filepath}")
|
||||||
|
if not _translations:
|
||||||
|
logging.error("i18n: Keine Uebersetzungen geladen!")
|
||||||
|
|
||||||
|
|
||||||
|
def get_text(key: str, lang: str = DEFAULT_LANG, **kwargs) -> str:
|
||||||
|
"""Gibt uebersetzten Text fuer einen Punkt-separierten Schluessel zurueck.
|
||||||
|
Beispiel: get_text('nav.home', 'de') -> 'Startseite'
|
||||||
|
Platzhalter: get_text('player.next_in', 'de', seconds=10)"""
|
||||||
|
translations = _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
|
||||||
|
parts = key.split(".")
|
||||||
|
value = translations
|
||||||
|
for part in parts:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(part)
|
||||||
|
else:
|
||||||
|
value = None
|
||||||
|
break
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
# Fallback auf Default-Sprache
|
||||||
|
if lang != DEFAULT_LANG:
|
||||||
|
return get_text(key, DEFAULT_LANG, **kwargs)
|
||||||
|
# Key als Fallback zurueckgeben
|
||||||
|
return key
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Platzhalter ersetzen
|
||||||
|
if kwargs:
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
value = value.replace(f"{{{k}}}", str(v))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_translations(lang: str = DEFAULT_LANG) -> dict:
|
||||||
|
"""Gibt alle Uebersetzungen fuer eine Sprache zurueck (fuer JS)"""
|
||||||
|
return _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
|
||||||
|
|
||||||
|
|
||||||
|
def setup_jinja2_i18n(app) -> None:
|
||||||
|
"""Registriert i18n-Filter und Globals in Jinja2-Environment.
|
||||||
|
Muss NACH aiohttp_jinja2.setup() aufgerufen werden."""
|
||||||
|
import aiohttp_jinja2
|
||||||
|
|
||||||
|
env = aiohttp_jinja2.get_env(app)
|
||||||
|
|
||||||
|
# Filter: {{ 'nav.home'|t }} oder {{ 'nav.home'|t('en') }}
|
||||||
|
def t_filter(key: str, lang: str = None) -> str:
|
||||||
|
# Sprache wird pro Request gesetzt (siehe Middleware)
|
||||||
|
if lang is None:
|
||||||
|
lang = getattr(env, "_current_lang", DEFAULT_LANG)
|
||||||
|
return get_text(key, lang)
|
||||||
|
|
||||||
|
env.filters["t"] = t_filter
|
||||||
|
|
||||||
|
# Global-Funktion: {{ t('nav.home') }} oder {{ t('nav.home', seconds=10) }}
|
||||||
|
def t_func(key: str, lang: str = None, **kwargs) -> str:
|
||||||
|
if lang is None:
|
||||||
|
lang = getattr(env, "_current_lang", DEFAULT_LANG)
|
||||||
|
return get_text(key, lang, **kwargs)
|
||||||
|
|
||||||
|
env.globals["t"] = t_func
|
||||||
|
env.globals["SUPPORTED_LANGS"] = SUPPORTED_LANGS
|
||||||
|
|
||||||
|
logging.info("i18n: Jinja2-Filter und Globals registriert")
|
||||||
|
|
||||||
|
|
||||||
|
def set_request_lang(app, lang: str) -> None:
|
||||||
|
"""Setzt die Sprache fuer den aktuellen Request.
|
||||||
|
Wird vom TV-Auth-Middleware aufgerufen."""
|
||||||
|
import aiohttp_jinja2
|
||||||
|
env = aiohttp_jinja2.get_env(app)
|
||||||
|
env._current_lang = lang if lang in SUPPORTED_LANGS else DEFAULT_LANG
|
||||||
|
|
@ -317,29 +317,55 @@ class QueueService:
|
||||||
await self.ws_manager.broadcast_queue_update()
|
await self.ws_manager.broadcast_queue_update()
|
||||||
|
|
||||||
async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
|
async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
|
||||||
"""Cleanup nach erfolgreicher Konvertierung"""
|
"""Cleanup nach erfolgreicher Konvertierung.
|
||||||
|
WICHTIG: Nur die Quelldatei dieses Jobs loeschen, NICHT
|
||||||
|
andere Dateien im Ordner die noch in der Queue warten!"""
|
||||||
files_cfg = self.config.files_config
|
files_cfg = self.config.files_config
|
||||||
|
|
||||||
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
||||||
should_delete = files_cfg.get("delete_source", False) or job.delete_source
|
should_delete = files_cfg.get("delete_source", False) or \
|
||||||
|
job.delete_source
|
||||||
|
|
||||||
if should_delete:
|
if should_delete:
|
||||||
target_exists = os.path.exists(job.target_path)
|
target_exists = os.path.exists(job.target_path)
|
||||||
target_size = os.path.getsize(job.target_path) if target_exists else 0
|
target_size = (os.path.getsize(job.target_path)
|
||||||
|
if target_exists else 0)
|
||||||
if target_exists and target_size > 0:
|
if target_exists and target_size > 0:
|
||||||
try:
|
try:
|
||||||
os.remove(job.media.source_path)
|
os.remove(job.media.source_path)
|
||||||
logging.info(f"Quelldatei geloescht: {job.media.source_path}")
|
logging.info(
|
||||||
|
f"Quelldatei geloescht: {job.media.source_path}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}")
|
logging.error(
|
||||||
|
f"Quelldatei loeschen fehlgeschlagen: {e}")
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
f"Quelldatei NICHT geloescht "
|
||||||
|
f"(Zieldatei fehlt/leer): "
|
||||||
|
f"{job.media.source_path}")
|
||||||
|
|
||||||
|
# SICHERHEIT: Ordner-Cleanup nur wenn KEINE weiteren
|
||||||
|
# Jobs aus diesem Ordner in der Queue warten!
|
||||||
cleanup_cfg = self.config.cleanup_config
|
cleanup_cfg = self.config.cleanup_config
|
||||||
if cleanup_cfg.get("enabled", False):
|
if cleanup_cfg.get("enabled", False):
|
||||||
deleted = self.scanner.cleanup_directory(job.media.source_dir)
|
source_dir = job.media.source_dir
|
||||||
if deleted:
|
pending = [
|
||||||
|
j for j in self.jobs.values()
|
||||||
|
if j.media.source_dir == source_dir
|
||||||
|
and j.status in (JobStatus.QUEUED, JobStatus.ACTIVE)
|
||||||
|
and j.id != job.id
|
||||||
|
]
|
||||||
|
if pending:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}"
|
f"Ordner-Cleanup uebersprungen "
|
||||||
)
|
f"({len(pending)} Jobs wartend): {source_dir}")
|
||||||
|
else:
|
||||||
|
deleted = self.scanner.cleanup_directory(source_dir)
|
||||||
|
if deleted:
|
||||||
|
logging.info(
|
||||||
|
f"{len(deleted)} Dateien bereinigt "
|
||||||
|
f"in {source_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
def _get_next_queued(self) -> Optional[ConversionJob]:
|
def _get_next_queued(self) -> Optional[ConversionJob]:
|
||||||
"""Naechster Job mit Status QUEUED (FIFO)"""
|
"""Naechster Job mit Status QUEUED (FIFO)"""
|
||||||
|
|
|
||||||
|
|
@ -801,12 +801,21 @@ class TVDBService:
|
||||||
ep_aired = getattr(ep, "aired", None)
|
ep_aired = getattr(ep, "aired", None)
|
||||||
ep_runtime = getattr(ep, "runtime", None)
|
ep_runtime = getattr(ep, "runtime", None)
|
||||||
if s_num and s_num > 0 and e_num and e_num > 0:
|
if s_num and s_num > 0 and e_num and e_num > 0:
|
||||||
|
# Beschreibung und Bild-URL
|
||||||
|
if isinstance(ep, dict):
|
||||||
|
ep_overview = ep.get("overview", "")
|
||||||
|
ep_image = ep.get("image", "")
|
||||||
|
else:
|
||||||
|
ep_overview = getattr(ep, "overview", "")
|
||||||
|
ep_image = getattr(ep, "image", "")
|
||||||
episodes.append({
|
episodes.append({
|
||||||
"season_number": s_num,
|
"season_number": s_num,
|
||||||
"episode_number": e_num,
|
"episode_number": e_num,
|
||||||
"episode_name": ep_name or "",
|
"episode_name": ep_name or "",
|
||||||
"aired": ep_aired,
|
"aired": ep_aired,
|
||||||
"runtime": ep_runtime,
|
"runtime": ep_runtime,
|
||||||
|
"overview": ep_overview or "",
|
||||||
|
"image_url": ep_image or "",
|
||||||
})
|
})
|
||||||
page += 1
|
page += 1
|
||||||
if page > 50:
|
if page > 50:
|
||||||
|
|
@ -840,12 +849,15 @@ class TVDBService:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"INSERT INTO tvdb_episode_cache "
|
"INSERT INTO tvdb_episode_cache "
|
||||||
"(series_tvdb_id, season_number, episode_number, "
|
"(series_tvdb_id, season_number, episode_number, "
|
||||||
"episode_name, aired, runtime) "
|
"episode_name, aired, runtime, overview, "
|
||||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
"image_url) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
|
||||||
(
|
(
|
||||||
tvdb_id, ep["season_number"],
|
tvdb_id, ep["season_number"],
|
||||||
ep["episode_number"], ep["episode_name"],
|
ep["episode_number"], ep["episode_name"],
|
||||||
ep["aired"], ep["runtime"],
|
ep["aired"], ep["runtime"],
|
||||||
|
ep.get("overview", ""),
|
||||||
|
ep.get("image_url", ""),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
197
video-konverter/app/static/tv/i18n/de.json
Normal file
197
video-konverter/app/static/tv/i18n/de.json
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Startseite",
|
||||||
|
"series": "Serien",
|
||||||
|
"movies": "Filme",
|
||||||
|
"search": "Suche",
|
||||||
|
"watchlist": "Merkliste",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"profiles": "Profile",
|
||||||
|
"logout": "Abmelden"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"continue_watching": "Weiterschauen",
|
||||||
|
"my_series": "Meine Serien",
|
||||||
|
"my_movies": "Meine Filme",
|
||||||
|
"recently_added": "Neu hinzugefügt",
|
||||||
|
"watchlist": "Meine Merkliste"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"title": "Serien",
|
||||||
|
"all": "Alle",
|
||||||
|
"episodes": "Episoden",
|
||||||
|
"season": "Staffel",
|
||||||
|
"specials": "Specials",
|
||||||
|
"no_episodes": "Keine Episoden vorhanden.",
|
||||||
|
"no_series": "Keine Serien vorhanden.",
|
||||||
|
"episode_short": "E",
|
||||||
|
"min": "Min",
|
||||||
|
"watchlist": "Merkliste"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"title": "Filme",
|
||||||
|
"all": "Alle",
|
||||||
|
"no_movies": "Keine Filme vorhanden.",
|
||||||
|
"versions": "Versionen",
|
||||||
|
"version": "Version"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"back": "Zurück",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"fullscreen": "Vollbild",
|
||||||
|
"next_episode": "Nächste Episode",
|
||||||
|
"next_in": "Nächste Episode in {seconds}s",
|
||||||
|
"skip": "Jetzt abspielen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"still_watching": "Schaust du noch?",
|
||||||
|
"continue": "Weiter",
|
||||||
|
"stop": "Aufhören",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Untertitel",
|
||||||
|
"subtitles_off": "Aus",
|
||||||
|
"quality": "Qualität",
|
||||||
|
"quality_uhd": "Ultra HD",
|
||||||
|
"quality_hd": "HD",
|
||||||
|
"quality_sd": "SD",
|
||||||
|
"quality_low": "Niedrig",
|
||||||
|
"speed": "Geschwindigkeit",
|
||||||
|
"settings": "Einstellungen"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Suche",
|
||||||
|
"placeholder": "Serien oder Filme suchen...",
|
||||||
|
"button": "Suchen",
|
||||||
|
"no_results": "Keine Ergebnisse für \"{query}\".",
|
||||||
|
"min_chars": "Mindestens 2 Zeichen eingeben.",
|
||||||
|
"history": "Letzte Suchen",
|
||||||
|
"clear_history": "Verlauf löschen",
|
||||||
|
"results_series": "Serien",
|
||||||
|
"results_movies": "Filme"
|
||||||
|
},
|
||||||
|
"watchlist": {
|
||||||
|
"title": "Merkliste",
|
||||||
|
"empty": "Deine Merkliste ist leer.",
|
||||||
|
"add": "Zur Merkliste",
|
||||||
|
"remove": "Von Merkliste entfernen",
|
||||||
|
"added": "Gemerkt",
|
||||||
|
"series": "Serien",
|
||||||
|
"movies": "Filme"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"unwatched": "Nicht gesehen",
|
||||||
|
"watching": "Angefangen",
|
||||||
|
"watched": "Gesehen",
|
||||||
|
"mark_watched": "Als gesehen markieren",
|
||||||
|
"mark_unwatched": "Als nicht gesehen markieren",
|
||||||
|
"mark_season": "Staffel als gesehen",
|
||||||
|
"mark_series": "Serie als gesehen",
|
||||||
|
"reset_progress": "Fortschritt zurücksetzen"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"user_settings": "Benutzer-Einstellungen",
|
||||||
|
"client_settings": "Geräte-Einstellungen",
|
||||||
|
"profile": "Profil",
|
||||||
|
"display_name": "Anzeigename",
|
||||||
|
"avatar_color": "Profilfarbe",
|
||||||
|
"language": "Sprache",
|
||||||
|
"menu_language": "Menüsprache",
|
||||||
|
"audio_language": "Audio-Sprache",
|
||||||
|
"subtitle_language": "Untertitel-Sprache",
|
||||||
|
"subtitles_enabled": "Untertitel aktiviert",
|
||||||
|
"theme": "Design",
|
||||||
|
"theme_dark": "Dunkel",
|
||||||
|
"theme_medium": "Mittel",
|
||||||
|
"theme_light": "Hell",
|
||||||
|
"views": "Ansichten & Design",
|
||||||
|
"series_view": "Serien-Ansicht",
|
||||||
|
"movies_view": "Film-Ansicht",
|
||||||
|
"view_grid": "Raster",
|
||||||
|
"view_list": "Liste",
|
||||||
|
"view_detail": "Detail",
|
||||||
|
"autoplay": "Automatische Wiedergabe",
|
||||||
|
"autoplay_enabled": "Nächste Episode automatisch abspielen",
|
||||||
|
"autoplay_countdown": "Countdown-Dauer",
|
||||||
|
"autoplay_max": "Max. Folgen am Stück",
|
||||||
|
"autoplay_max_desc": "0 = unbegrenzt",
|
||||||
|
"seconds": "Sekunden",
|
||||||
|
"save": "Speichern",
|
||||||
|
"saved": "Gespeichert!",
|
||||||
|
"reset_all": "Alle Fortschritte zurücksetzen",
|
||||||
|
"reset_confirm": "Wirklich ALLE Fortschritte und Status zurücksetzen? Das kann nicht rückgängig gemacht werden!",
|
||||||
|
"clear_search": "Suchverlauf löschen",
|
||||||
|
"device_name": "Gerätename",
|
||||||
|
"sound_mode": "Sound-Modus",
|
||||||
|
"sound_stereo": "Stereo",
|
||||||
|
"sound_surround": "Surround (5.1/7.1)",
|
||||||
|
"sound_original": "Original",
|
||||||
|
"stream_quality": "Standard-Qualität",
|
||||||
|
"on": "An",
|
||||||
|
"off": "Aus"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Wer schaut?",
|
||||||
|
"switch": "Profil wechseln",
|
||||||
|
"add_user": "Anderer Benutzer",
|
||||||
|
"manage": "Profile verwalten"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "VideoKonverter",
|
||||||
|
"subtitle": "TV-App",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"password": "Passwort",
|
||||||
|
"login": "Anmelden",
|
||||||
|
"remember": "Angemeldet bleiben",
|
||||||
|
"error": "Benutzername oder Passwort falsch."
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"title": "Bewertung",
|
||||||
|
"your_rating": "Deine Bewertung",
|
||||||
|
"avg_rating": "Durchschnitt",
|
||||||
|
"tvdb_score": "TVDB-Score",
|
||||||
|
"rate": "Bewerten",
|
||||||
|
"remove": "Bewertung entfernen",
|
||||||
|
"stars": "{n} Sterne",
|
||||||
|
"ratings": "{n} Bewertungen",
|
||||||
|
"no_ratings": "Noch keine Bewertungen",
|
||||||
|
"filter_min": "Ab {n} Sterne",
|
||||||
|
"sort_rating": "Bewertung"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "Alle",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"sort_title": "Name (A-Z)",
|
||||||
|
"sort_title_desc": "Name (Z-A)",
|
||||||
|
"sort_newest": "Neueste zuerst",
|
||||||
|
"sort_episodes": "Episoden-Anzahl",
|
||||||
|
"sort_last_watched": "Zuletzt angesehen",
|
||||||
|
"sort_rating": "Bewertung",
|
||||||
|
"genres": "Genres",
|
||||||
|
"min_rating": "Min. Sterne"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nein",
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"loading": "Laden...",
|
||||||
|
"error": "Fehler",
|
||||||
|
"no_connection": "Keine Verbindung zum Server.",
|
||||||
|
"unknown": "Unbekannt"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"deu": "Deutsch",
|
||||||
|
"eng": "Englisch",
|
||||||
|
"fra": "Französisch",
|
||||||
|
"spa": "Spanisch",
|
||||||
|
"ita": "Italienisch",
|
||||||
|
"jpn": "Japanisch",
|
||||||
|
"kor": "Koreanisch",
|
||||||
|
"por": "Portugiesisch",
|
||||||
|
"rus": "Russisch",
|
||||||
|
"zho": "Chinesisch",
|
||||||
|
"und": "Unbekannt"
|
||||||
|
}
|
||||||
|
}
|
||||||
197
video-konverter/app/static/tv/i18n/en.json
Normal file
197
video-konverter/app/static/tv/i18n/en.json
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"series": "Series",
|
||||||
|
"movies": "Movies",
|
||||||
|
"search": "Search",
|
||||||
|
"watchlist": "Watchlist",
|
||||||
|
"settings": "Settings",
|
||||||
|
"profiles": "Profiles",
|
||||||
|
"logout": "Logout"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"continue_watching": "Continue Watching",
|
||||||
|
"my_series": "My Series",
|
||||||
|
"my_movies": "My Movies",
|
||||||
|
"recently_added": "Recently Added",
|
||||||
|
"watchlist": "My Watchlist"
|
||||||
|
},
|
||||||
|
"series": {
|
||||||
|
"title": "Series",
|
||||||
|
"all": "All",
|
||||||
|
"episodes": "Episodes",
|
||||||
|
"season": "Season",
|
||||||
|
"specials": "Specials",
|
||||||
|
"no_episodes": "No episodes available.",
|
||||||
|
"no_series": "No series available.",
|
||||||
|
"episode_short": "E",
|
||||||
|
"min": "min",
|
||||||
|
"watchlist": "Watchlist"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"title": "Movies",
|
||||||
|
"all": "All",
|
||||||
|
"no_movies": "No movies available.",
|
||||||
|
"versions": "Versions",
|
||||||
|
"version": "Version"
|
||||||
|
},
|
||||||
|
"player": {
|
||||||
|
"back": "Back",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"fullscreen": "Fullscreen",
|
||||||
|
"next_episode": "Next Episode",
|
||||||
|
"next_in": "Next episode in {seconds}s",
|
||||||
|
"skip": "Play Now",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"still_watching": "Are you still watching?",
|
||||||
|
"continue": "Continue",
|
||||||
|
"stop": "Stop",
|
||||||
|
"audio": "Audio",
|
||||||
|
"subtitles": "Subtitles",
|
||||||
|
"subtitles_off": "Off",
|
||||||
|
"quality": "Quality",
|
||||||
|
"quality_uhd": "Ultra HD",
|
||||||
|
"quality_hd": "HD",
|
||||||
|
"quality_sd": "SD",
|
||||||
|
"quality_low": "Low",
|
||||||
|
"speed": "Speed",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"title": "Search",
|
||||||
|
"placeholder": "Search series or movies...",
|
||||||
|
"button": "Search",
|
||||||
|
"no_results": "No results for \"{query}\".",
|
||||||
|
"min_chars": "Enter at least 2 characters.",
|
||||||
|
"history": "Recent Searches",
|
||||||
|
"clear_history": "Clear History",
|
||||||
|
"results_series": "Series",
|
||||||
|
"results_movies": "Movies"
|
||||||
|
},
|
||||||
|
"watchlist": {
|
||||||
|
"title": "Watchlist",
|
||||||
|
"empty": "Your watchlist is empty.",
|
||||||
|
"add": "Add to Watchlist",
|
||||||
|
"remove": "Remove from Watchlist",
|
||||||
|
"added": "Added",
|
||||||
|
"series": "Series",
|
||||||
|
"movies": "Movies"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"unwatched": "Unwatched",
|
||||||
|
"watching": "Watching",
|
||||||
|
"watched": "Watched",
|
||||||
|
"mark_watched": "Mark as watched",
|
||||||
|
"mark_unwatched": "Mark as unwatched",
|
||||||
|
"mark_season": "Mark season as watched",
|
||||||
|
"mark_series": "Mark series as watched",
|
||||||
|
"reset_progress": "Reset progress"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"user_settings": "User Settings",
|
||||||
|
"client_settings": "Device Settings",
|
||||||
|
"profile": "Profile",
|
||||||
|
"display_name": "Display Name",
|
||||||
|
"avatar_color": "Profile Color",
|
||||||
|
"language": "Language",
|
||||||
|
"menu_language": "Menu Language",
|
||||||
|
"audio_language": "Audio Language",
|
||||||
|
"subtitle_language": "Subtitle Language",
|
||||||
|
"subtitles_enabled": "Subtitles enabled",
|
||||||
|
"theme": "Theme",
|
||||||
|
"theme_dark": "Dark",
|
||||||
|
"theme_medium": "Medium",
|
||||||
|
"theme_light": "Light",
|
||||||
|
"views": "Views & Theme",
|
||||||
|
"series_view": "Series View",
|
||||||
|
"movies_view": "Movies View",
|
||||||
|
"view_grid": "Grid",
|
||||||
|
"view_list": "List",
|
||||||
|
"view_detail": "Detail",
|
||||||
|
"autoplay": "Autoplay",
|
||||||
|
"autoplay_enabled": "Auto-play next episode",
|
||||||
|
"autoplay_countdown": "Countdown Duration",
|
||||||
|
"autoplay_max": "Max. consecutive episodes",
|
||||||
|
"autoplay_max_desc": "0 = unlimited",
|
||||||
|
"seconds": "seconds",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved!",
|
||||||
|
"reset_all": "Reset All Progress",
|
||||||
|
"reset_confirm": "Really reset ALL progress and watch status? This cannot be undone!",
|
||||||
|
"clear_search": "Clear search history",
|
||||||
|
"device_name": "Device Name",
|
||||||
|
"sound_mode": "Sound Mode",
|
||||||
|
"sound_stereo": "Stereo",
|
||||||
|
"sound_surround": "Surround (5.1/7.1)",
|
||||||
|
"sound_original": "Original",
|
||||||
|
"stream_quality": "Default Quality",
|
||||||
|
"on": "On",
|
||||||
|
"off": "Off"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"title": "Who's watching?",
|
||||||
|
"switch": "Switch Profile",
|
||||||
|
"add_user": "Other User",
|
||||||
|
"manage": "Manage Profiles"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "VideoKonverter",
|
||||||
|
"subtitle": "TV App",
|
||||||
|
"username": "Username",
|
||||||
|
"password": "Password",
|
||||||
|
"login": "Sign In",
|
||||||
|
"remember": "Keep me signed in",
|
||||||
|
"error": "Invalid username or password."
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"title": "Rating",
|
||||||
|
"your_rating": "Your Rating",
|
||||||
|
"avg_rating": "Average",
|
||||||
|
"tvdb_score": "TVDB Score",
|
||||||
|
"rate": "Rate",
|
||||||
|
"remove": "Remove Rating",
|
||||||
|
"stars": "{n} Stars",
|
||||||
|
"ratings": "{n} Ratings",
|
||||||
|
"no_ratings": "No ratings yet",
|
||||||
|
"filter_min": "Min. {n} Stars",
|
||||||
|
"sort_rating": "Rating"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"all": "All",
|
||||||
|
"sort": "Sort",
|
||||||
|
"sort_title": "Name (A-Z)",
|
||||||
|
"sort_title_desc": "Name (Z-A)",
|
||||||
|
"sort_newest": "Newest First",
|
||||||
|
"sort_episodes": "Episode Count",
|
||||||
|
"sort_last_watched": "Last Watched",
|
||||||
|
"sort_rating": "Rating",
|
||||||
|
"genres": "Genres",
|
||||||
|
"min_rating": "Min. Stars"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"close": "Close",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error",
|
||||||
|
"no_connection": "No connection to server.",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"deu": "German",
|
||||||
|
"eng": "English",
|
||||||
|
"fra": "French",
|
||||||
|
"spa": "Spanish",
|
||||||
|
"ita": "Italian",
|
||||||
|
"jpn": "Japanese",
|
||||||
|
"kor": "Korean",
|
||||||
|
"por": "Portuguese",
|
||||||
|
"rus": "Russian",
|
||||||
|
"zho": "Chinese",
|
||||||
|
"und": "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,35 @@
|
||||||
/**
|
/**
|
||||||
* VideoKonverter TV - Video-Player
|
* VideoKonverter TV - Video-Player v4.0
|
||||||
* Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung
|
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
|
||||||
* Speichert Watch-Progress automatisch
|
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// === State ===
|
||||||
let videoEl = null;
|
let videoEl = null;
|
||||||
let videoId = 0;
|
let cfg = {}; // Konfiguration aus initPlayer()
|
||||||
let videoDuration = 0;
|
let videoInfo = null; // Audio/Subtitle-Tracks vom Server
|
||||||
|
let currentAudio = 0;
|
||||||
|
let currentSub = -1; // -1 = aus
|
||||||
|
let currentQuality = "hd";
|
||||||
|
let currentSpeed = 1.0;
|
||||||
let progressBar = null;
|
let progressBar = null;
|
||||||
let timeDisplay = null;
|
let timeDisplay = null;
|
||||||
let playBtn = null;
|
let playBtn = null;
|
||||||
let controlsTimer = null;
|
let controlsTimer = null;
|
||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let controlsVisible = true;
|
let controlsVisible = true;
|
||||||
|
let overlayOpen = false;
|
||||||
|
let nextCountdown = null;
|
||||||
|
let episodesWatched = 0;
|
||||||
|
let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Player initialisieren
|
* Player initialisieren
|
||||||
* @param {number} id - Video-ID
|
* @param {Object} opts - Konfiguration
|
||||||
* @param {number} startPos - Startposition in Sekunden
|
|
||||||
* @param {number} duration - Video-Dauer in Sekunden (Fallback)
|
|
||||||
*/
|
*/
|
||||||
function initPlayer(id, startPos, duration) {
|
function initPlayer(opts) {
|
||||||
videoId = id;
|
cfg = opts;
|
||||||
videoDuration = duration;
|
currentQuality = opts.streamQuality || "hd";
|
||||||
|
|
||||||
videoEl = document.getElementById("player-video");
|
videoEl = document.getElementById("player-video");
|
||||||
progressBar = document.getElementById("player-progress-bar");
|
progressBar = document.getElementById("player-progress-bar");
|
||||||
|
|
@ -31,10 +38,11 @@ function initPlayer(id, startPos, duration) {
|
||||||
|
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
|
|
||||||
// Stream-URL setzen (ffmpeg-Transcoding Endpoint)
|
// Video-Info laden (Audio/Subtitle-Tracks)
|
||||||
const streamUrl = `/api/library/videos/${id}/stream` +
|
loadVideoInfo().then(() => {
|
||||||
(startPos > 0 ? `?t=${Math.floor(startPos)}` : "");
|
// Stream starten
|
||||||
videoEl.src = streamUrl;
|
setStreamUrl(opts.startPos || 0);
|
||||||
|
});
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
||||||
|
|
@ -43,78 +51,152 @@ function initPlayer(id, startPos, duration) {
|
||||||
videoEl.addEventListener("ended", onEnded);
|
videoEl.addEventListener("ended", onEnded);
|
||||||
videoEl.addEventListener("loadedmetadata", () => {
|
videoEl.addEventListener("loadedmetadata", () => {
|
||||||
if (videoEl.duration && isFinite(videoEl.duration)) {
|
if (videoEl.duration && isFinite(videoEl.duration)) {
|
||||||
videoDuration = videoEl.duration;
|
cfg.duration = videoEl.duration + seekOffset;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Klick auf Video -> Play/Pause
|
|
||||||
videoEl.addEventListener("click", togglePlay);
|
videoEl.addEventListener("click", togglePlay);
|
||||||
|
|
||||||
// Controls UI
|
// Controls UI
|
||||||
playBtn.addEventListener("click", togglePlay);
|
playBtn.addEventListener("click", togglePlay);
|
||||||
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
|
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
|
||||||
|
|
||||||
// Progress-Bar klickbar fuer Seeking
|
|
||||||
document.getElementById("player-progress").addEventListener("click", onProgressClick);
|
document.getElementById("player-progress").addEventListener("click", onProgressClick);
|
||||||
|
|
||||||
|
// Einstellungen-Button
|
||||||
|
const btnSettings = document.getElementById("btn-settings");
|
||||||
|
if (btnSettings) btnSettings.addEventListener("click", toggleOverlay);
|
||||||
|
|
||||||
|
// Naechste-Episode-Button
|
||||||
|
const btnNext = document.getElementById("btn-next");
|
||||||
|
if (btnNext) btnNext.addEventListener("click", playNextEpisode);
|
||||||
|
|
||||||
|
// Naechste-Episode Overlay Buttons
|
||||||
|
const btnNextPlay = document.getElementById("btn-next-play");
|
||||||
|
if (btnNextPlay) btnNextPlay.addEventListener("click", playNextEpisode);
|
||||||
|
const btnNextCancel = document.getElementById("btn-next-cancel");
|
||||||
|
if (btnNextCancel) btnNextCancel.addEventListener("click", cancelNext);
|
||||||
|
|
||||||
|
// Schaust du noch?
|
||||||
|
const btnStillYes = document.getElementById("btn-still-yes");
|
||||||
|
if (btnStillYes) btnStillYes.addEventListener("click", () => {
|
||||||
|
document.getElementById("still-watching-overlay").style.display = "none";
|
||||||
|
episodesWatched = 0;
|
||||||
|
videoEl.play();
|
||||||
|
});
|
||||||
|
const btnStillNo = document.getElementById("btn-still-no");
|
||||||
|
if (btnStillNo) btnStillNo.addEventListener("click", () => {
|
||||||
|
saveProgress();
|
||||||
|
window.history.back();
|
||||||
|
});
|
||||||
|
|
||||||
// Tastatur-Steuerung
|
// Tastatur-Steuerung
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
|
||||||
// Maus/Touch-Bewegung -> Controls anzeigen
|
|
||||||
document.addEventListener("mousemove", showControls);
|
document.addEventListener("mousemove", showControls);
|
||||||
document.addEventListener("touchstart", showControls);
|
document.addEventListener("touchstart", showControls);
|
||||||
|
|
||||||
// Controls nach 4 Sekunden ausblenden
|
|
||||||
scheduleHideControls();
|
scheduleHideControls();
|
||||||
|
|
||||||
// Watch-Progress alle 10 Sekunden speichern
|
|
||||||
saveTimer = setInterval(saveProgress, 10000);
|
saveTimer = setInterval(saveProgress, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Video-Info laden ===
|
||||||
|
|
||||||
|
async function loadVideoInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/library/videos/${cfg.videoId}/info`);
|
||||||
|
videoInfo = await resp.json();
|
||||||
|
|
||||||
|
// Bevorzugte Audio-Spur finden
|
||||||
|
if (videoInfo.audio_tracks) {
|
||||||
|
const prefIdx = videoInfo.audio_tracks.findIndex(
|
||||||
|
a => a.lang === cfg.preferredAudio);
|
||||||
|
if (prefIdx >= 0) currentAudio = prefIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bevorzugte Untertitel-Spur finden
|
||||||
|
if (cfg.subtitlesEnabled && cfg.preferredSub && videoInfo.subtitle_tracks) {
|
||||||
|
const subIdx = videoInfo.subtitle_tracks.findIndex(
|
||||||
|
s => s.lang === cfg.preferredSub);
|
||||||
|
if (subIdx >= 0) currentSub = subIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untertitel-Tracks als <track> hinzufuegen
|
||||||
|
if (videoInfo.subtitle_tracks) {
|
||||||
|
videoInfo.subtitle_tracks.forEach((sub, i) => {
|
||||||
|
const track = document.createElement("track");
|
||||||
|
track.kind = "subtitles";
|
||||||
|
track.src = `/api/library/videos/${cfg.videoId}/subtitles/${i}`;
|
||||||
|
track.srclang = sub.lang || "und";
|
||||||
|
track.label = langName(sub.lang) || `Spur ${i + 1}`;
|
||||||
|
if (i === currentSub) track.default = true;
|
||||||
|
videoEl.appendChild(track);
|
||||||
|
});
|
||||||
|
// Aktiven Track setzen
|
||||||
|
updateSubtitleTrack();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Video-Info laden fehlgeschlagen:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Stream-URL ===
|
||||||
|
|
||||||
|
function setStreamUrl(seekSec) {
|
||||||
|
seekOffset = seekSec || 0;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
quality: currentQuality,
|
||||||
|
audio: currentAudio,
|
||||||
|
sound: cfg.soundMode || "stereo",
|
||||||
|
});
|
||||||
|
if (seekSec > 0) params.set("t", Math.floor(seekSec));
|
||||||
|
const wasPlaying = videoEl && !videoEl.paused;
|
||||||
|
videoEl.src = `/api/library/videos/${cfg.videoId}/stream?${params}`;
|
||||||
|
if (wasPlaying) videoEl.play();
|
||||||
|
}
|
||||||
|
|
||||||
// === Playback-Controls ===
|
// === Playback-Controls ===
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
if (videoEl.paused) {
|
if (videoEl.paused) videoEl.play();
|
||||||
videoEl.play();
|
else videoEl.pause();
|
||||||
} else {
|
|
||||||
videoEl.pause();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPlay() {
|
function onPlay() {
|
||||||
if (playBtn) playBtn.innerHTML = "❚❚"; // Pause-Symbol
|
if (playBtn) playBtn.innerHTML = "❚❚";
|
||||||
scheduleHideControls();
|
scheduleHideControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPause() {
|
function onPause() {
|
||||||
if (playBtn) playBtn.innerHTML = "▶"; // Play-Symbol
|
if (playBtn) playBtn.innerHTML = "▶";
|
||||||
showControls();
|
showControls();
|
||||||
// Sofort speichern bei Pause
|
|
||||||
saveProgress();
|
saveProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEnded() {
|
function onEnded() {
|
||||||
// Video fertig -> als "completed" speichern
|
|
||||||
saveProgress(true);
|
saveProgress(true);
|
||||||
// Zurueck navigieren nach 2 Sekunden
|
episodesWatched++;
|
||||||
setTimeout(() => {
|
|
||||||
window.history.back();
|
// Schaust du noch? (wenn Max-Episoden erreicht)
|
||||||
}, 2000);
|
if (cfg.autoplayMax > 0 && episodesWatched >= cfg.autoplayMax) {
|
||||||
|
document.getElementById("still-watching-overlay").style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naechste Episode
|
||||||
|
if (cfg.nextVideoId && cfg.autoplay) {
|
||||||
|
showNextEpisodeOverlay();
|
||||||
|
} else {
|
||||||
|
setTimeout(() => window.history.back(), 2000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Seeking ===
|
// === Seeking ===
|
||||||
|
|
||||||
function seekRelative(seconds) {
|
function seekRelative(seconds) {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
const newTime = Math.max(0, Math.min(
|
const totalTime = seekOffset + videoEl.currentTime;
|
||||||
videoEl.currentTime + seconds,
|
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||||
videoEl.duration || videoDuration
|
const newTime = Math.max(0, Math.min(totalTime + seconds, dur));
|
||||||
));
|
setStreamUrl(newTime);
|
||||||
// Neue Stream-URL mit Zeitstempel
|
|
||||||
const wasPlaying = !videoEl.paused;
|
|
||||||
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
|
||||||
if (wasPlaying) videoEl.play();
|
|
||||||
showControls();
|
showControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,29 +204,22 @@ function onProgressClick(e) {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
const dur = videoEl.duration || videoDuration;
|
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||||
if (!dur) return;
|
if (!dur) return;
|
||||||
const newTime = pct * dur;
|
setStreamUrl(pct * dur);
|
||||||
// Neue Stream-URL mit Zeitstempel
|
|
||||||
const wasPlaying = !videoEl.paused;
|
|
||||||
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
|
||||||
if (wasPlaying) videoEl.play();
|
|
||||||
showControls();
|
showControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Zeit-Anzeige und Progress ===
|
// === Zeit-Anzeige ===
|
||||||
|
|
||||||
function onTimeUpdate() {
|
function onTimeUpdate() {
|
||||||
if (!videoEl) return;
|
if (!videoEl) return;
|
||||||
const current = videoEl.currentTime;
|
const current = seekOffset + videoEl.currentTime;
|
||||||
const dur = videoEl.duration || videoDuration;
|
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||||
|
|
||||||
// Progress-Bar
|
|
||||||
if (progressBar && dur > 0) {
|
if (progressBar && dur > 0) {
|
||||||
progressBar.style.width = ((current / dur) * 100) + "%";
|
progressBar.style.width = ((current / dur) * 100) + "%";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeit-Anzeige
|
|
||||||
if (timeDisplay) {
|
if (timeDisplay) {
|
||||||
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
||||||
}
|
}
|
||||||
|
|
@ -155,9 +230,7 @@ function formatTime(sec) {
|
||||||
const h = Math.floor(sec / 3600);
|
const h = Math.floor(sec / 3600);
|
||||||
const m = Math.floor((sec % 3600) / 60);
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
const s = Math.floor(sec % 60);
|
const s = Math.floor(sec % 60);
|
||||||
if (h > 0) {
|
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
||||||
return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return m + ":" + String(s).padStart(2, "0");
|
return m + ":" + String(s).padStart(2, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -171,7 +244,7 @@ function showControls() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideControls() {
|
function hideControls() {
|
||||||
if (!videoEl || videoEl.paused) return;
|
if (!videoEl || videoEl.paused || overlayOpen) return;
|
||||||
const wrapper = document.getElementById("player-wrapper");
|
const wrapper = document.getElementById("player-wrapper");
|
||||||
if (wrapper) wrapper.classList.add("player-hide-controls");
|
if (wrapper) wrapper.classList.add("player-hide-controls");
|
||||||
controlsVisible = false;
|
controlsVisible = false;
|
||||||
|
|
@ -193,84 +266,220 @@ function toggleFullscreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Einstellungen-Overlay ===
|
||||||
|
|
||||||
|
function toggleOverlay() {
|
||||||
|
const overlay = document.getElementById("player-overlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
overlayOpen = !overlayOpen;
|
||||||
|
overlay.style.display = overlayOpen ? "" : "none";
|
||||||
|
if (overlayOpen) {
|
||||||
|
renderOverlay();
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverlay() {
|
||||||
|
// Audio-Spuren
|
||||||
|
const audioEl = document.getElementById("overlay-audio");
|
||||||
|
if (audioEl && videoInfo && videoInfo.audio_tracks) {
|
||||||
|
let html = "<h3>Audio</h3>";
|
||||||
|
videoInfo.audio_tracks.forEach((a, i) => {
|
||||||
|
const label = langName(a.lang) || `Spur ${i + 1}`;
|
||||||
|
const ch = a.channels > 2 ? ` (${a.channels}ch)` : "";
|
||||||
|
const active = i === currentAudio ? " active" : "";
|
||||||
|
html += `<button class="overlay-option${active}" data-focusable onclick="switchAudio(${i})">${label}${ch}</button>`;
|
||||||
|
});
|
||||||
|
audioEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Untertitel
|
||||||
|
const subsEl = document.getElementById("overlay-subs");
|
||||||
|
if (subsEl && videoInfo) {
|
||||||
|
let html = "<h3>Untertitel</h3>";
|
||||||
|
html += `<button class="overlay-option${currentSub === -1 ? ' active' : ''}" data-focusable onclick="switchSub(-1)">Aus</button>`;
|
||||||
|
if (videoInfo.subtitle_tracks) {
|
||||||
|
videoInfo.subtitle_tracks.forEach((s, i) => {
|
||||||
|
const label = langName(s.lang) || `Spur ${i + 1}`;
|
||||||
|
const active = i === currentSub ? " active" : "";
|
||||||
|
html += `<button class="overlay-option${active}" data-focusable onclick="switchSub(${i})">${label}</button>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subsEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Qualitaet
|
||||||
|
const qualEl = document.getElementById("overlay-quality");
|
||||||
|
if (qualEl) {
|
||||||
|
const qualities = [
|
||||||
|
["uhd", "Ultra HD"], ["hd", "HD"],
|
||||||
|
["sd", "SD"], ["low", "Niedrig"]
|
||||||
|
];
|
||||||
|
let html = "<h3>Qualit\u00e4t</h3>";
|
||||||
|
qualities.forEach(([val, label]) => {
|
||||||
|
const active = val === currentQuality ? " active" : "";
|
||||||
|
html += `<button class="overlay-option${active}" data-focusable onclick="switchQuality('${val}')">${label}</button>`;
|
||||||
|
});
|
||||||
|
qualEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geschwindigkeit
|
||||||
|
const speedEl = document.getElementById("overlay-speed");
|
||||||
|
if (speedEl) {
|
||||||
|
const speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
let html = "<h3>Geschwindigkeit</h3>";
|
||||||
|
speeds.forEach(s => {
|
||||||
|
const active = s === currentSpeed ? " active" : "";
|
||||||
|
html += `<button class="overlay-option${active}" data-focusable onclick="switchSpeed(${s})">${s}x</button>`;
|
||||||
|
});
|
||||||
|
speedEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchAudio(idx) {
|
||||||
|
if (idx === currentAudio) return;
|
||||||
|
currentAudio = idx;
|
||||||
|
// Neuen Stream mit anderer Audio-Spur starten
|
||||||
|
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||||
|
setStreamUrl(currentTime);
|
||||||
|
renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSub(idx) {
|
||||||
|
currentSub = idx;
|
||||||
|
updateSubtitleTrack();
|
||||||
|
renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSubtitleTrack() {
|
||||||
|
if (!videoEl || !videoEl.textTracks) return;
|
||||||
|
for (let i = 0; i < videoEl.textTracks.length; i++) {
|
||||||
|
videoEl.textTracks[i].mode = (i === currentSub) ? "showing" : "hidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchQuality(q) {
|
||||||
|
if (q === currentQuality) return;
|
||||||
|
currentQuality = q;
|
||||||
|
const currentTime = seekOffset + (videoEl ? videoEl.currentTime : 0);
|
||||||
|
setStreamUrl(currentTime);
|
||||||
|
renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchSpeed(s) {
|
||||||
|
currentSpeed = s;
|
||||||
|
if (videoEl) videoEl.playbackRate = s;
|
||||||
|
renderOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Naechste Episode ===
|
||||||
|
|
||||||
|
function showNextEpisodeOverlay() {
|
||||||
|
const overlay = document.getElementById("next-overlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.style.display = "";
|
||||||
|
let remaining = cfg.autoplayCountdown || 10;
|
||||||
|
const countdownEl = document.getElementById("next-countdown");
|
||||||
|
|
||||||
|
nextCountdown = setInterval(() => {
|
||||||
|
remaining--;
|
||||||
|
if (countdownEl) countdownEl.textContent = remaining + "s";
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(nextCountdown);
|
||||||
|
playNextEpisode();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
if (countdownEl) countdownEl.textContent = remaining + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function playNextEpisode() {
|
||||||
|
if (nextCountdown) clearInterval(nextCountdown);
|
||||||
|
if (cfg.nextUrl) window.location.href = cfg.nextUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelNext() {
|
||||||
|
if (nextCountdown) clearInterval(nextCountdown);
|
||||||
|
const overlay = document.getElementById("next-overlay");
|
||||||
|
if (overlay) overlay.style.display = "none";
|
||||||
|
setTimeout(() => window.history.back(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
// === Tastatur-Steuerung ===
|
// === Tastatur-Steuerung ===
|
||||||
|
|
||||||
function onKeyDown(e) {
|
function onKeyDown(e) {
|
||||||
// Samsung Tizen Remote Keys
|
// Samsung Tizen Remote Keys
|
||||||
const keyMap = {
|
const keyMap = {
|
||||||
10009: "Escape",
|
10009: "Escape", 10182: "Escape",
|
||||||
10182: "Escape",
|
415: "Play", 19: "Pause", 413: "Stop",
|
||||||
415: "Play",
|
417: "FastForward", 412: "Rewind",
|
||||||
19: "Pause",
|
|
||||||
413: "Stop",
|
|
||||||
417: "FastForward",
|
|
||||||
412: "Rewind",
|
|
||||||
};
|
};
|
||||||
const key = keyMap[e.keyCode] || e.key;
|
const key = keyMap[e.keyCode] || e.key;
|
||||||
|
|
||||||
|
// Overlay offen? -> Navigation im Overlay
|
||||||
|
if (overlayOpen && (key === "Escape" || key === "Backspace")) {
|
||||||
|
toggleOverlay();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case " ":
|
case " ": case "Enter": case "Play": case "Pause":
|
||||||
case "Enter":
|
togglePlay(); e.preventDefault(); break;
|
||||||
case "Play":
|
case "ArrowLeft": case "Rewind":
|
||||||
case "Pause":
|
seekRelative(-10); e.preventDefault(); break;
|
||||||
togglePlay();
|
case "ArrowRight": case "FastForward":
|
||||||
e.preventDefault();
|
seekRelative(10); e.preventDefault(); break;
|
||||||
break;
|
|
||||||
case "ArrowLeft":
|
|
||||||
case "Rewind":
|
|
||||||
seekRelative(-10);
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
case "ArrowRight":
|
|
||||||
case "FastForward":
|
|
||||||
seekRelative(10);
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
case "ArrowUp":
|
||||||
// Lautstaerke hoch (falls vom Browser unterstuetzt)
|
|
||||||
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
|
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
|
||||||
showControls();
|
showControls(); e.preventDefault(); break;
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
case "ArrowDown":
|
case "ArrowDown":
|
||||||
// Lautstaerke runter
|
|
||||||
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
|
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
|
||||||
showControls();
|
showControls(); e.preventDefault(); break;
|
||||||
e.preventDefault();
|
case "Escape": case "Backspace": case "Stop":
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
case "Backspace":
|
|
||||||
case "Stop":
|
|
||||||
// Zurueck navigieren
|
|
||||||
saveProgress();
|
saveProgress();
|
||||||
setTimeout(() => window.history.back(), 100);
|
setTimeout(() => window.history.back(), 100);
|
||||||
e.preventDefault();
|
e.preventDefault(); break;
|
||||||
break;
|
|
||||||
case "f":
|
case "f":
|
||||||
toggleFullscreen();
|
toggleFullscreen(); e.preventDefault(); break;
|
||||||
e.preventDefault();
|
case "s":
|
||||||
break;
|
toggleOverlay(); e.preventDefault(); break;
|
||||||
|
case "n":
|
||||||
|
if (cfg.nextVideoId) playNextEpisode();
|
||||||
|
e.preventDefault(); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Watch-Progress speichern ===
|
// === Watch-Progress speichern ===
|
||||||
|
|
||||||
function saveProgress(completed) {
|
function saveProgress(completed) {
|
||||||
if (!videoId || !videoEl) return;
|
if (!cfg.videoId || !videoEl) return;
|
||||||
const pos = videoEl.currentTime || 0;
|
const pos = seekOffset + (videoEl.currentTime || 0);
|
||||||
const dur = videoEl.duration || videoDuration || 0;
|
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||||
if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern
|
if (pos < 5 && !completed) return;
|
||||||
|
|
||||||
fetch("/tv/api/watch-progress", {
|
fetch("/tv/api/watch-progress", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
video_id: videoId,
|
video_id: cfg.videoId,
|
||||||
position_sec: pos,
|
position_sec: pos,
|
||||||
duration_sec: dur,
|
duration_sec: dur,
|
||||||
}),
|
}),
|
||||||
}).catch(() => {}); // Fehler ignorieren (nicht kritisch)
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Beim Verlassen der Seite speichern
|
|
||||||
window.addEventListener("beforeunload", () => saveProgress());
|
window.addEventListener("beforeunload", () => saveProgress());
|
||||||
|
|
||||||
|
// === Hilfsfunktionen ===
|
||||||
|
|
||||||
|
const LANG_NAMES = {
|
||||||
|
deu: "Deutsch", eng: "English", fra: "Fran\u00e7ais",
|
||||||
|
spa: "Espa\u00f1ol", ita: "Italiano", jpn: "\u65e5\u672c\u8a9e",
|
||||||
|
kor: "\ud55c\uad6d\uc5b4", por: "Portugu\u00eas",
|
||||||
|
rus: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||||
|
zho: "\u4e2d\u6587", und: "Unbekannt",
|
||||||
|
};
|
||||||
|
|
||||||
|
function langName(code) {
|
||||||
|
return LANG_NAMES[code] || code || "";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="{{ user.ui_lang if user is defined and user else 'de' }}"
|
||||||
|
data-theme="{{ user.theme if user is defined and user and user.theme else 'dark' }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
|
@ -17,18 +18,24 @@
|
||||||
{% if user is defined and user %}
|
{% if user is defined and user %}
|
||||||
<nav class="tv-nav" id="tv-nav">
|
<nav class="tv-nav" id="tv-nav">
|
||||||
<div class="tv-nav-links">
|
<div class="tv-nav-links">
|
||||||
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>Startseite</a>
|
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>{{ t('nav.home') }}</a>
|
||||||
{% if user.can_view_series %}
|
{% if user.can_view_series %}
|
||||||
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>Serien</a>
|
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>{{ t('nav.series') }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.can_view_movies %}
|
{% if user.can_view_movies %}
|
||||||
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>Filme</a>
|
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>{{ t('nav.movies') }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>Suche</a>
|
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>{{ t('nav.search') }}</a>
|
||||||
|
<a href="/tv/watchlist" class="tv-nav-item {% if active == 'watchlist' %}active{% endif %}" data-focusable>{{ t('nav.watchlist') }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="tv-nav-right">
|
<div class="tv-nav-right">
|
||||||
<span class="tv-nav-user">{{ user.display_name or user.username }}</span>
|
<a href="/tv/profiles" class="tv-nav-profile" data-focusable title="{{ t('profiles.switch') }}">
|
||||||
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>Abmelden</a>
|
<span class="tv-avatar" style="background:{{ user.avatar_color or '#64b5f6' }}">
|
||||||
|
{{ (user.display_name or user.username)[:1]|upper }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/tv/settings" class="tv-nav-item {% if active == 'settings' %}active{% endif %}" data-focusable>⚙</a>
|
||||||
|
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>{{ t('nav.logout') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
data-focusable required>
|
data-focusable required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="login-field login-remember">
|
||||||
|
<label class="settings-check">
|
||||||
|
<input type="checkbox" name="remember" data-focusable>
|
||||||
|
Angemeldet bleiben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<button type="submit" class="login-btn" data-focusable>
|
<button type="submit" class="login-btn" data-focusable>
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -19,31 +19,140 @@
|
||||||
<p class="tv-detail-overview">{{ movie.overview }}</p>
|
<p class="tv-detail-overview">{{ movie.overview }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if videos %}
|
<!-- Bewertungen -->
|
||||||
<div class="tv-detail-actions">
|
<div class="tv-rating-section">
|
||||||
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
|
<div class="tv-rating-user">
|
||||||
▶ Abspielen
|
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
|
||||||
</a>
|
<div class="tv-stars-input" id="user-stars"
|
||||||
|
data-movie-id="{{ movie.id }}" data-rating="{{ user_rating }}">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
|
||||||
|
data-value="{{ i }}" data-focusable
|
||||||
|
onclick="setRating({{ i }})">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if user_rating > 0 %}
|
||||||
|
<span class="tv-rating-remove" onclick="setRating(0)"
|
||||||
|
data-focusable title="{{ t('rating.remove') }}">✕</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if avg_rating.count > 0 %}
|
||||||
|
<div class="tv-rating-avg">
|
||||||
|
<span class="tv-stars-display">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if tvdb_score %}
|
||||||
|
<div class="tv-rating-external">
|
||||||
|
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tv-detail-actions">
|
||||||
|
{% if videos %}
|
||||||
|
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
|
||||||
|
▶ {{ t('player.play') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
|
||||||
|
id="btn-watchlist"
|
||||||
|
data-focusable
|
||||||
|
data-movie-id="{{ movie.id }}"
|
||||||
|
onclick="toggleWatchlist(this)">
|
||||||
|
<span class="watchlist-icon">{% if in_watchlist %}♥{% else %}♡{% endif %}</span>
|
||||||
|
<span class="watchlist-text">{{ t('watchlist.title') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if videos|length > 1 %}
|
{% if videos|length > 1 %}
|
||||||
<h3 class="tv-section-title">Versionen</h3>
|
<h3 class="tv-section-title">{{ t('movies.versions') }}</h3>
|
||||||
<div class="tv-episode-list">
|
<div class="tv-episode-list">
|
||||||
{% for v in videos %}
|
{% for v in videos %}
|
||||||
<a href="/tv/player?v={{ v.id }}" class="tv-episode" data-focusable>
|
<a href="/tv/player?v={{ v.id }}" class="tv-episode-card" data-focusable>
|
||||||
<span class="tv-episode-title">{{ v.file_name }}</span>
|
<div class="tv-ep-thumb">
|
||||||
<span class="tv-episode-meta">
|
<img src="/api/library/videos/{{ v.id }}/thumbnail" alt="" loading="lazy">
|
||||||
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
|
<div class="tv-ep-duration">
|
||||||
{% if v.width %} · {{ v.width }}x{{ v.height }}{% endif %}
|
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
|
||||||
· {{ v.container|upper }}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<span class="tv-episode-play">▶</span>
|
<div class="tv-ep-info">
|
||||||
|
<div class="tv-ep-header">
|
||||||
|
<span class="tv-ep-title">{{ v.file_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tv-ep-meta">
|
||||||
|
{% if v.width %}{{ v.width }}x{{ v.height }}{% endif %}
|
||||||
|
· {{ v.container|upper }}
|
||||||
|
{% if v.video_codec %} · {{ v.video_codec }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function toggleWatchlist(btn) {
|
||||||
|
const movieId = btn.dataset.movieId;
|
||||||
|
fetch('/tv/api/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ movie_id: parseInt(movieId) }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.in_watchlist) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.querySelector('.watchlist-icon').innerHTML = '♥';
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
btn.querySelector('.watchlist-icon').innerHTML = '♡';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRating(value) {
|
||||||
|
const container = document.getElementById('user-stars');
|
||||||
|
const movieId = container.dataset.movieId;
|
||||||
|
fetch('/tv/api/rating', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ movie_id: parseInt(movieId), rating: value }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
container.dataset.rating = data.user_rating;
|
||||||
|
container.querySelectorAll('.tv-star').forEach(star => {
|
||||||
|
const v = parseInt(star.dataset.value);
|
||||||
|
star.classList.toggle('active', v <= data.user_rating);
|
||||||
|
});
|
||||||
|
let removeBtn = container.querySelector('.tv-rating-remove');
|
||||||
|
if (data.user_rating > 0 && !removeBtn) {
|
||||||
|
removeBtn = document.createElement('span');
|
||||||
|
removeBtn.className = 'tv-rating-remove';
|
||||||
|
removeBtn.setAttribute('data-focusable', '');
|
||||||
|
removeBtn.innerHTML = '✕';
|
||||||
|
removeBtn.onclick = () => setRating(0);
|
||||||
|
container.appendChild(removeBtn);
|
||||||
|
} else if (data.user_rating === 0 && removeBtn) {
|
||||||
|
removeBtn.remove();
|
||||||
|
}
|
||||||
|
if (data.avg_rating !== undefined) {
|
||||||
|
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
|
||||||
|
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,81 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}Filme - VideoKonverter TV{% endblock %}
|
{% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<h1 class="tv-page-title">Filme</h1>
|
<div class="tv-list-header">
|
||||||
<div class="tv-grid">
|
<h1 class="tv-page-title">{{ t('movies.title') }}</h1>
|
||||||
|
<div class="tv-view-switch" id="view-switch">
|
||||||
|
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
|
||||||
|
data-focusable data-view="grid" onclick="switchView('grid')"
|
||||||
|
title="{{ t('settings.view_grid') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
|
||||||
|
data-focusable data-view="list" onclick="switchView('list')"
|
||||||
|
title="{{ t('settings.view_list') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
|
||||||
|
data-focusable data-view="detail" onclick="switchView('detail')"
|
||||||
|
title="{{ t('settings.view_detail') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quellen-Tabs -->
|
||||||
|
{% if sources|length > 1 %}
|
||||||
|
<div class="tv-tabs tv-source-tabs">
|
||||||
|
<a href="/tv/movies?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
|
||||||
|
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
|
||||||
|
{{ t('filter.all') }}
|
||||||
|
</a>
|
||||||
|
{% for src in sources %}
|
||||||
|
<a href="/tv/movies?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
|
||||||
|
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
|
||||||
|
{{ src.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filter-Leiste -->
|
||||||
|
<div class="tv-filter-bar">
|
||||||
|
{% if genres %}
|
||||||
|
<div class="tv-genre-chips">
|
||||||
|
<a href="/tv/movies?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||||
|
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
||||||
|
{{ t('filter.all') }}
|
||||||
|
</a>
|
||||||
|
{% for g in genres %}
|
||||||
|
<a href="/tv/movies?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||||
|
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
||||||
|
{{ g }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Rating-Filter -->
|
||||||
|
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||||
|
<option value="">{{ t('filter.min_rating') }}</option>
|
||||||
|
{% for n in range(1, 6) %}
|
||||||
|
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
|
||||||
|
{% for s in range(n) %}★{% endfor %}{% for s in range(5 - n) %}☆{% endfor %} {{ n }}+
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
|
||||||
|
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
|
||||||
|
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
|
||||||
|
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
|
||||||
|
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>{{ t('filter.sort_newest') }} (Jahr)</option>
|
||||||
|
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === Grid-Ansicht === -->
|
||||||
|
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||||
{% if m.poster_url %}
|
{% if m.poster_url %}
|
||||||
|
|
@ -14,13 +85,94 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
||||||
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} · {{ m.genres }}{% endif %}</span>
|
<span class="tv-card-meta">
|
||||||
|
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %}</span> {% endif %}
|
||||||
|
{{ m.year or "" }}{% if m.genres %} · {{ m.genres }}{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- === Liste (kompakt) === -->
|
||||||
|
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||||
|
{% for m in movies %}
|
||||||
|
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable>
|
||||||
|
<div class="tv-list-poster">
|
||||||
|
{% if m.poster_url %}
|
||||||
|
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="tv-list-title">{{ m.title or m.folder_name }}</span>
|
||||||
|
<span class="tv-list-rating">{% if m.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %}{% endif %}</span>
|
||||||
|
<span class="tv-list-genre">{{ m.genres or '' }}</span>
|
||||||
|
<span class="tv-list-count">{{ m.year or '' }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === Detail-Liste === -->
|
||||||
|
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||||
|
{% for m in movies %}
|
||||||
|
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable>
|
||||||
|
<div class="tv-detail-thumb">
|
||||||
|
{% if m.poster_url %}
|
||||||
|
<img src="{{ m.poster_url }}" alt="" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tv-detail-content">
|
||||||
|
<span class="tv-detail-title">{{ m.title or m.folder_name }}</span>
|
||||||
|
{% if m.overview %}
|
||||||
|
<p class="tv-detail-desc">{{ m.overview }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tv-detail-meta">
|
||||||
|
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %} {{ m.avg_rating }}</span> · {% endif %}
|
||||||
|
{% if m.year %}{{ m.year }}{% endif %}
|
||||||
|
{% if m.genres %} · {{ m.genres }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not movies %}
|
{% if not movies %}
|
||||||
<div class="tv-empty">Keine Filme vorhanden.</div>
|
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function switchView(mode) {
|
||||||
|
document.querySelectorAll('[id^="view-"]').forEach(el => {
|
||||||
|
if (el.id !== 'view-switch') el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const target = document.getElementById('view-' + mode);
|
||||||
|
if (target) target.style.display = '';
|
||||||
|
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||||
|
});
|
||||||
|
fetch('/tv/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'movies_view=' + mode,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort(sort) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('sort', sort);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRating(rating) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (rating) {
|
||||||
|
url.searchParams.set('rating', rating);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('rating');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@
|
||||||
<div class="player-wrapper" id="player-wrapper">
|
<div class="player-wrapper" id="player-wrapper">
|
||||||
<!-- Header (ausblendbar) -->
|
<!-- Header (ausblendbar) -->
|
||||||
<div class="player-header" id="player-header">
|
<div class="player-header" id="player-header">
|
||||||
<a href="javascript:history.back()" class="player-back" data-focusable>❮ Zurueck</a>
|
<a href="javascript:history.back()" class="player-back" data-focusable>❮ {{ t('player.back') }}</a>
|
||||||
<span class="player-title">{{ title }}</span>
|
<span class="player-title">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video -->
|
<!-- Video -->
|
||||||
<video id="player-video" autoplay playsinline>
|
<video id="player-video" autoplay playsinline></video>
|
||||||
Dein Browser unterstuetzt kein HTML5-Video.
|
|
||||||
</video>
|
|
||||||
|
|
||||||
<!-- Controls (ausblendbar) -->
|
<!-- Controls (ausblendbar) -->
|
||||||
<div class="player-controls" id="player-controls">
|
<div class="player-controls" id="player-controls">
|
||||||
|
|
@ -29,14 +27,70 @@
|
||||||
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
<span class="player-spacer"></span>
|
<span class="player-spacer"></span>
|
||||||
|
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">⚙</button>
|
||||||
|
{% if next_video %}
|
||||||
|
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">⏭</button>
|
||||||
|
{% endif %}
|
||||||
<button class="player-btn" id="btn-fullscreen" data-focusable>⛶</button>
|
<button class="player-btn" id="btn-fullscreen" data-focusable>⛶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Einstellungen-Overlay -->
|
||||||
|
<div class="player-overlay" id="player-overlay" style="display:none">
|
||||||
|
<div class="player-overlay-panel">
|
||||||
|
<div class="player-overlay-section" id="overlay-audio"></div>
|
||||||
|
<div class="player-overlay-section" id="overlay-subs"></div>
|
||||||
|
<div class="player-overlay-section" id="overlay-quality"></div>
|
||||||
|
<div class="player-overlay-section" id="overlay-speed"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Naechste Episode Overlay -->
|
||||||
|
{% if next_video %}
|
||||||
|
<div class="player-next-overlay" id="next-overlay" style="display:none">
|
||||||
|
<div class="player-next-card">
|
||||||
|
<p class="player-next-title" id="next-title">{{ t('player.next_episode') }}</p>
|
||||||
|
<p class="player-next-name">{{ next_title }}</p>
|
||||||
|
<p class="player-next-countdown" id="next-countdown"></p>
|
||||||
|
<div class="player-next-buttons">
|
||||||
|
<button class="tv-play-btn" id="btn-next-play" data-focusable>{{ t('player.skip') }}</button>
|
||||||
|
<button class="player-btn-cancel" id="btn-next-cancel" data-focusable>{{ t('player.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Schaust du noch? Overlay -->
|
||||||
|
<div class="player-still-watching" id="still-watching-overlay" style="display:none">
|
||||||
|
<div class="player-next-card">
|
||||||
|
<p class="player-next-title">{{ t('player.still_watching') }}</p>
|
||||||
|
<div class="player-next-buttons">
|
||||||
|
<button class="tv-play-btn" id="btn-still-yes" data-focusable>{{ t('player.continue') }}</button>
|
||||||
|
<button class="player-btn-cancel" id="btn-still-no" data-focusable>{{ t('player.stop') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/tv/js/player.js"></script>
|
<script src="/static/tv/js/player.js"></script>
|
||||||
<script>
|
<script>
|
||||||
initPlayer({{ video.id }}, {{ start_pos }}, {{ video.duration_sec or 0 }});
|
initPlayer({
|
||||||
|
videoId: {{ video.id }},
|
||||||
|
startPos: {{ start_pos }},
|
||||||
|
duration: {{ video.duration_sec or 0 }},
|
||||||
|
{% if next_video %}
|
||||||
|
nextVideoId: {{ next_video.id }},
|
||||||
|
nextUrl: "/tv/player?v={{ next_video.id }}",
|
||||||
|
{% endif %}
|
||||||
|
autoplay: {{ 'true' if user.autoplay_enabled else 'false' }},
|
||||||
|
autoplayCountdown: {{ user.autoplay_countdown_sec or 10 }},
|
||||||
|
autoplayMax: {{ user.autoplay_max_episodes or 0 }},
|
||||||
|
preferredAudio: "{{ user.preferred_audio_lang or 'deu' }}",
|
||||||
|
preferredSub: "{{ user.preferred_subtitle_lang or '' }}",
|
||||||
|
subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }},
|
||||||
|
soundMode: "{{ client_sound_mode or 'stereo' }}",
|
||||||
|
streamQuality: "{{ client_stream_quality or 'hd' }}",
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
37
video-konverter/app/templates/tv/profiles.html
Normal file
37
video-konverter/app/templates/tv/profiles.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#0f0f0f">
|
||||||
|
<link rel="stylesheet" href="/static/tv/css/tv.css">
|
||||||
|
<title>{{ t('profiles.title') }} - VideoKonverter TV</title>
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<div class="profiles-container">
|
||||||
|
<h1 class="profiles-title">{{ t('profiles.title') }}</h1>
|
||||||
|
|
||||||
|
<div class="profiles-grid">
|
||||||
|
{% for p in profiles %}
|
||||||
|
<form method="post" action="/tv/switch-profile" class="profile-form">
|
||||||
|
<input type="hidden" name="session_id" value="{{ p.session_id }}">
|
||||||
|
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable>
|
||||||
|
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
|
||||||
|
{{ (p.display_name or p.username)[:1]|upper }}
|
||||||
|
</span>
|
||||||
|
<span class="profile-name">{{ p.display_name or p.username }}</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Anderer Benutzer -->
|
||||||
|
<a href="/tv/login" class="profile-card profile-add" data-focusable>
|
||||||
|
<span class="tv-avatar tv-avatar-lg profile-add-icon">+</span>
|
||||||
|
<span class="profile-name">{{ t('profiles.add_user') }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/tv/js/tv.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}Suche - VideoKonverter TV{% endblock %}
|
{% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<h1 class="tv-page-title">Suche</h1>
|
<h1 class="tv-page-title">{{ t('search.title') }}</h1>
|
||||||
<form action="/tv/search" method="GET" class="tv-search-form">
|
<form action="/tv/search" method="GET" class="tv-search-form" autocomplete="off">
|
||||||
<input type="text" name="q" value="{{ query }}"
|
<div class="tv-search-wrapper">
|
||||||
placeholder="Serie oder Film suchen..."
|
<input type="text" name="q" id="search-input" value="{{ query }}"
|
||||||
class="tv-search-input" data-focusable autofocus>
|
placeholder="{{ t('search.placeholder') }}"
|
||||||
<button type="submit" class="tv-search-btn" data-focusable>Suchen</button>
|
class="tv-search-input" data-focusable autofocus>
|
||||||
|
<div class="tv-autocomplete" id="autocomplete" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="tv-search-btn" data-focusable>{{ t('search.button') }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if query %}
|
{% if query %}
|
||||||
<!-- Serien-Ergebnisse -->
|
<!-- Serien-Ergebnisse -->
|
||||||
{% if series %}
|
{% if series %}
|
||||||
<h2 class="tv-section-title">Serien ({{ series|length }})</h2>
|
<h2 class="tv-section-title">{{ t('search.results_series') }} ({{ series|length }})</h2>
|
||||||
<div class="tv-grid">
|
<div class="tv-grid">
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||||
|
|
@ -25,6 +28,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
||||||
|
{% if s.genres %}
|
||||||
|
<span class="tv-card-meta">{{ s.genres }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -33,7 +39,7 @@
|
||||||
|
|
||||||
<!-- Film-Ergebnisse -->
|
<!-- Film-Ergebnisse -->
|
||||||
{% if movies %}
|
{% if movies %}
|
||||||
<h2 class="tv-section-title">Filme ({{ movies|length }})</h2>
|
<h2 class="tv-section-title">{{ t('search.results_movies') }} ({{ movies|length }})</h2>
|
||||||
<div class="tv-grid">
|
<div class="tv-grid">
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||||
|
|
@ -52,8 +58,76 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not series and not movies %}
|
{% if not series and not movies %}
|
||||||
<div class="tv-empty">Keine Ergebnisse fuer «{{ query }}»</div>
|
<div class="tv-empty">{{ t('search.no_results', query=query) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Such-History -->
|
||||||
|
{% if history %}
|
||||||
|
<div class="tv-search-history">
|
||||||
|
<div class="tv-search-history-header">
|
||||||
|
<h2 class="tv-section-title">{{ t('search.history') }}</h2>
|
||||||
|
<button class="tv-link-btn" onclick="clearHistory()" data-focusable>{{ t('search.clear_history') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="tv-search-history-list">
|
||||||
|
{% for h in history %}
|
||||||
|
<a href="/tv/search?q={{ h.query }}" class="tv-search-history-item" data-focusable>
|
||||||
|
<span class="tv-search-history-icon">🔍</span>
|
||||||
|
{{ h.query }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Autocomplete
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
const acBox = document.getElementById('autocomplete');
|
||||||
|
let acTimer = null;
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(acTimer);
|
||||||
|
const q = input.value.trim();
|
||||||
|
if (q.length < 2) { acBox.style.display = 'none'; return; }
|
||||||
|
acTimer = setTimeout(() => fetchSuggestions(q), 300);
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', () => {
|
||||||
|
setTimeout(() => { acBox.style.display = 'none'; }, 200);
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
if (acBox.children.length > 0) acBox.style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSuggestions(q) {
|
||||||
|
fetch('/tv/api/search/suggest?q=' + encodeURIComponent(q))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.suggestions || data.suggestions.length === 0) {
|
||||||
|
acBox.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
acBox.innerHTML = data.suggestions.map(s =>
|
||||||
|
`<a href="/tv/search?q=${encodeURIComponent(s)}" class="tv-ac-item">${s}</a>`
|
||||||
|
).join('');
|
||||||
|
acBox.style.display = '';
|
||||||
|
})
|
||||||
|
.catch(() => { acBox.style.display = 'none'; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHistory() {
|
||||||
|
fetch('/tv/api/search/history', { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
const hist = document.querySelector('.tv-search-history');
|
||||||
|
if (hist) hist.remove();
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,81 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}Serien - VideoKonverter TV{% endblock %}
|
{% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<h1 class="tv-page-title">Serien</h1>
|
<div class="tv-list-header">
|
||||||
<div class="tv-grid">
|
<h1 class="tv-page-title">{{ t('series.title') }}</h1>
|
||||||
|
<div class="tv-view-switch" id="view-switch">
|
||||||
|
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
|
||||||
|
data-focusable data-view="grid" onclick="switchView('grid')"
|
||||||
|
title="{{ t('settings.view_grid') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
|
||||||
|
data-focusable data-view="list" onclick="switchView('list')"
|
||||||
|
title="{{ t('settings.view_list') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
|
||||||
|
data-focusable data-view="detail" onclick="switchView('detail')"
|
||||||
|
title="{{ t('settings.view_detail') }}">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quellen-Tabs -->
|
||||||
|
{% if sources|length > 1 %}
|
||||||
|
<div class="tv-tabs tv-source-tabs">
|
||||||
|
<a href="/tv/series?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
|
||||||
|
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
|
||||||
|
{{ t('filter.all') }}
|
||||||
|
</a>
|
||||||
|
{% for src in sources %}
|
||||||
|
<a href="/tv/series?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
|
||||||
|
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
|
||||||
|
{{ src.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Filter-Leiste -->
|
||||||
|
<div class="tv-filter-bar">
|
||||||
|
{% if genres %}
|
||||||
|
<div class="tv-genre-chips">
|
||||||
|
<a href="/tv/series?sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||||
|
class="tv-chip {% if not current_genre %}active{% endif %}" data-focusable>
|
||||||
|
{{ t('filter.all') }}
|
||||||
|
</a>
|
||||||
|
{% for g in genres %}
|
||||||
|
<a href="/tv/series?genre={{ g }}&sort={{ current_sort }}{% if current_source %}&source={{ current_source }}{% endif %}"
|
||||||
|
class="tv-chip {% if current_genre == g %}active{% endif %}" data-focusable>
|
||||||
|
{{ g }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Rating-Filter -->
|
||||||
|
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
|
||||||
|
<option value="">{{ t('filter.min_rating') }}</option>
|
||||||
|
{% for n in range(1, 6) %}
|
||||||
|
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
|
||||||
|
{% for s in range(n) %}★{% endfor %}{% for s in range(5 - n) %}☆{% endfor %} {{ n }}+
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
|
||||||
|
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
|
||||||
|
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
|
||||||
|
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
|
||||||
|
<option value="episodes" {% if current_sort == 'episodes' %}selected{% endif %}>{{ t('filter.sort_episodes') }}</option>
|
||||||
|
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === Grid-Ansicht === -->
|
||||||
|
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||||
{% if s.poster_url %}
|
{% if s.poster_url %}
|
||||||
|
|
@ -14,13 +85,95 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
||||||
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden{% if s.genres %} · {{ s.genres }}{% endif %}</span>
|
<span class="tv-card-meta">
|
||||||
|
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %}</span> {% endif %}
|
||||||
|
{{ s.episode_count or 0 }} {{ t('series.episodes') }}{% if s.genres %} · {{ s.genres }}{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- === Liste (kompakt) === -->
|
||||||
|
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
|
||||||
|
{% for s in series %}
|
||||||
|
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable>
|
||||||
|
<div class="tv-list-poster">
|
||||||
|
{% if s.poster_url %}
|
||||||
|
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="tv-list-title">{{ s.title or s.folder_name }}</span>
|
||||||
|
<span class="tv-list-rating">{% if s.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %}{% endif %}</span>
|
||||||
|
<span class="tv-list-genre">{{ s.genres or '' }}</span>
|
||||||
|
<span class="tv-list-count">{{ s.episode_count or 0 }} Ep.</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- === Detail-Liste === -->
|
||||||
|
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
|
||||||
|
{% for s in series %}
|
||||||
|
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable>
|
||||||
|
<div class="tv-detail-thumb">
|
||||||
|
{% if s.poster_url %}
|
||||||
|
<img src="{{ s.poster_url }}" alt="" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tv-detail-content">
|
||||||
|
<span class="tv-detail-title">{{ s.title or s.folder_name }}</span>
|
||||||
|
{% if s.overview %}
|
||||||
|
<p class="tv-detail-desc">{{ s.overview }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<span class="tv-detail-meta">
|
||||||
|
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">★</span>{% endfor %} {{ s.avg_rating }}</span> · {% endif %}
|
||||||
|
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
|
||||||
|
{% if s.genres %} · {{ s.genres }}{% endif %}
|
||||||
|
{% if s.status %} · {{ s.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if not series %}
|
{% if not series %}
|
||||||
<div class="tv-empty">Keine Serien vorhanden.</div>
|
<div class="tv-empty">{{ t('series.no_series') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
function switchView(mode) {
|
||||||
|
document.querySelectorAll('[id^="view-"]').forEach(el => {
|
||||||
|
if (el.id !== 'view-switch') el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const target = document.getElementById('view-' + mode);
|
||||||
|
if (target) target.style.display = '';
|
||||||
|
document.querySelectorAll('.tv-view-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||||
|
});
|
||||||
|
fetch('/tv/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: 'series_view=' + mode,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySort(sort) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('sort', sort);
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRating(rating) {
|
||||||
|
const url = new URL(window.location);
|
||||||
|
if (rating) {
|
||||||
|
url.searchParams.set('rating', rating);
|
||||||
|
} else {
|
||||||
|
url.searchParams.delete('rating');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,54 @@
|
||||||
{% if series.overview %}
|
{% if series.overview %}
|
||||||
<p class="tv-detail-overview">{{ series.overview }}</p>
|
<p class="tv-detail-overview">{{ series.overview }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Bewertungen -->
|
||||||
|
<div class="tv-rating-section">
|
||||||
|
<!-- Eigene Bewertung (klickbare Sterne) -->
|
||||||
|
<div class="tv-rating-user">
|
||||||
|
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
|
||||||
|
<div class="tv-stars-input" id="user-stars"
|
||||||
|
data-series-id="{{ series.id }}" data-rating="{{ user_rating }}">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
|
||||||
|
data-value="{{ i }}" data-focusable
|
||||||
|
onclick="setRating({{ i }})">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if user_rating > 0 %}
|
||||||
|
<span class="tv-rating-remove" onclick="setRating(0)"
|
||||||
|
data-focusable title="{{ t('rating.remove') }}">✕</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Durchschnitt -->
|
||||||
|
{% if avg_rating.count > 0 %}
|
||||||
|
<div class="tv-rating-avg">
|
||||||
|
<span class="tv-stars-display">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">★</span>
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- TVDB-Score -->
|
||||||
|
{% if tvdb_score %}
|
||||||
|
<div class="tv-rating-external">
|
||||||
|
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tv-detail-actions">
|
||||||
|
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
|
||||||
|
id="btn-watchlist"
|
||||||
|
data-focusable
|
||||||
|
data-series-id="{{ series.id }}"
|
||||||
|
onclick="toggleWatchlist(this)">
|
||||||
|
<span class="watchlist-icon">{% if in_watchlist %}♥{% else %}♡{% endif %}</span>
|
||||||
|
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -26,7 +74,7 @@
|
||||||
<button class="tv-tab {% if loop.first %}active{% endif %}"
|
<button class="tv-tab {% if loop.first %}active{% endif %}"
|
||||||
data-focusable
|
data-focusable
|
||||||
onclick="showSeason({{ sn }})">
|
onclick="showSeason({{ sn }})">
|
||||||
{% if sn == 0 %}Specials{% else %}Staffel {{ sn }}{% endif %}
|
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -36,25 +84,51 @@
|
||||||
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||||
<div class="tv-episode-list">
|
<div class="tv-episode-list">
|
||||||
{% for ep in episodes %}
|
{% for ep in episodes %}
|
||||||
<a href="/tv/player?v={{ ep.id }}" class="tv-episode" data-focusable>
|
<a href="/tv/player?v={{ ep.id }}" class="tv-episode-card" data-focusable>
|
||||||
<span class="tv-episode-num">
|
<!-- Thumbnail -->
|
||||||
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% else %}-{% endif %}
|
<div class="tv-ep-thumb">
|
||||||
</span>
|
{% if ep.ep_image_url %}
|
||||||
<span class="tv-episode-title">
|
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
|
||||||
{{ ep.episode_title or ep.file_name }}
|
{% else %}
|
||||||
</span>
|
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
|
||||||
<span class="tv-episode-meta">
|
{% endif %}
|
||||||
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %}
|
||||||
{% if ep.width %} · {{ ep.width }}x{{ ep.height }}{% endif %}
|
<div class="tv-ep-progress">
|
||||||
</span>
|
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
|
||||||
<span class="tv-episode-play">▶</span>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ep.progress_pct >= 95 %}
|
||||||
|
<div class="tv-ep-watched">✓</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tv-ep-duration">
|
||||||
|
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="tv-ep-info">
|
||||||
|
<div class="tv-ep-header">
|
||||||
|
<span class="tv-ep-num">
|
||||||
|
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="tv-ep-title">
|
||||||
|
{{ ep.episode_title or ep.file_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if ep.ep_overview %}
|
||||||
|
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tv-ep-meta">
|
||||||
|
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
|
||||||
|
{% if ep.video_codec %} · {{ ep.video_codec }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="tv-empty">Keine Episoden vorhanden.</div>
|
<div class="tv-empty">{{ t('series.no_episodes') }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -72,5 +146,62 @@ function showSeason(sn) {
|
||||||
// Tab aktivieren
|
// Tab aktivieren
|
||||||
event.target.classList.add('active');
|
event.target.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleWatchlist(btn) {
|
||||||
|
const seriesId = btn.dataset.seriesId;
|
||||||
|
fetch('/tv/api/watchlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ series_id: parseInt(seriesId) }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.in_watchlist) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
btn.querySelector('.watchlist-icon').innerHTML = '♥';
|
||||||
|
} 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(() => {});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
176
video-konverter/app/templates/tv/settings.html
Normal file
176
video-konverter/app/templates/tv/settings.html
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
{% extends "tv/base.html" %}
|
||||||
|
{% block title %}{{ t('settings.title') }} - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="tv-section">
|
||||||
|
<h1 class="tv-page-title">{{ t('settings.title') }}</h1>
|
||||||
|
|
||||||
|
{% if request.query.get('saved') %}
|
||||||
|
<div class="tv-success-msg">{{ t('settings.saved') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if request.query.get('reset') %}
|
||||||
|
<div class="tv-success-msg">{{ t('status.reset_progress') }} ✓</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/tv/settings" class="settings-form">
|
||||||
|
|
||||||
|
<!-- Profil -->
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend>{{ t('settings.profile') }}</legend>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.display_name') }}
|
||||||
|
<input type="text" name="display_name" value="{{ user.display_name or '' }}"
|
||||||
|
class="settings-input" data-focusable>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.avatar_color') }}
|
||||||
|
<input type="color" name="avatar_color" value="{{ user.avatar_color or '#64b5f6' }}"
|
||||||
|
class="settings-color" data-focusable>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Sprache -->
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend>{{ t('settings.language') }}</legend>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.menu_language') }}
|
||||||
|
<select name="ui_lang" class="settings-select" data-focusable>
|
||||||
|
<option value="de" {% if user.ui_lang == 'de' %}selected{% endif %}>Deutsch</option>
|
||||||
|
<option value="en" {% if user.ui_lang == 'en' %}selected{% endif %}>English</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.audio_language') }}
|
||||||
|
<select name="preferred_audio_lang" class="settings-select" data-focusable>
|
||||||
|
<option value="deu" {% if user.preferred_audio_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
|
||||||
|
<option value="eng" {% if user.preferred_audio_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
|
||||||
|
<option value="fra" {% if user.preferred_audio_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
|
||||||
|
<option value="spa" {% if user.preferred_audio_lang == 'spa' %}selected{% endif %}>{{ t('lang.spa') }}</option>
|
||||||
|
<option value="jpn" {% if user.preferred_audio_lang == 'jpn' %}selected{% endif %}>{{ t('lang.jpn') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.subtitle_language') }}
|
||||||
|
<select name="preferred_subtitle_lang" class="settings-select" data-focusable>
|
||||||
|
<option value="" {% if not user.preferred_subtitle_lang %}selected{% endif %}>{{ t('player.subtitles_off') }}</option>
|
||||||
|
<option value="deu" {% if user.preferred_subtitle_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
|
||||||
|
<option value="eng" {% if user.preferred_subtitle_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
|
||||||
|
<option value="fra" {% if user.preferred_subtitle_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label settings-check">
|
||||||
|
<input type="checkbox" name="subtitles_enabled"
|
||||||
|
{% if user.subtitles_enabled %}checked{% endif %} data-focusable>
|
||||||
|
{{ t('settings.subtitles_enabled') }}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Ansichten & Theme -->
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend>{{ t('settings.views') }}</legend>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.theme') }}
|
||||||
|
<select name="theme" class="settings-select" data-focusable
|
||||||
|
onchange="document.documentElement.setAttribute('data-theme', this.value)">
|
||||||
|
<option value="dark" {% if user.theme == 'dark' or not user.theme %}selected{% endif %}>{{ t('settings.theme_dark') }}</option>
|
||||||
|
<option value="medium" {% if user.theme == 'medium' %}selected{% endif %}>{{ t('settings.theme_medium') }}</option>
|
||||||
|
<option value="light" {% if user.theme == 'light' %}selected{% endif %}>{{ t('settings.theme_light') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.series_view') }}
|
||||||
|
<select name="series_view" class="settings-select" data-focusable>
|
||||||
|
<option value="grid" {% if user.series_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
|
||||||
|
<option value="list" {% if user.series_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
|
||||||
|
<option value="detail" {% if user.series_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.movies_view') }}
|
||||||
|
<select name="movies_view" class="settings-select" data-focusable>
|
||||||
|
<option value="grid" {% if user.movies_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
|
||||||
|
<option value="list" {% if user.movies_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
|
||||||
|
<option value="detail" {% if user.movies_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Auto-Play -->
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend>{{ t('settings.autoplay') }}</legend>
|
||||||
|
<label class="settings-label settings-check">
|
||||||
|
<input type="checkbox" name="autoplay_enabled"
|
||||||
|
{% if user.autoplay_enabled %}checked{% endif %} data-focusable>
|
||||||
|
{{ t('settings.autoplay_enabled') }}
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.autoplay_countdown') }}
|
||||||
|
<select name="autoplay_countdown_sec" class="settings-select" data-focusable>
|
||||||
|
{% for s in [5, 10, 15, 20, 30] %}
|
||||||
|
<option value="{{ s }}" {% if user.autoplay_countdown_sec == s %}selected{% endif %}>{{ s }} {{ t('settings.seconds') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.autoplay_max') }}
|
||||||
|
<select name="autoplay_max_episodes" class="settings-select" data-focusable>
|
||||||
|
<option value="0" {% if user.autoplay_max_episodes == 0 %}selected{% endif %}>{{ t('settings.autoplay_max_desc') }}</option>
|
||||||
|
{% for n in [3, 5, 8, 10, 15, 20] %}
|
||||||
|
<option value="{{ n }}" {% if user.autoplay_max_episodes == n %}selected{% endif %}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Geraete-Einstellungen -->
|
||||||
|
{% if client %}
|
||||||
|
<fieldset class="settings-group">
|
||||||
|
<legend>{{ t('settings.client_settings') }}</legend>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.device_name') }}
|
||||||
|
<input type="text" name="client_name" value="{{ client.name or '' }}"
|
||||||
|
placeholder="z.B. Samsung TV Wohnzimmer"
|
||||||
|
class="settings-input" data-focusable>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.sound_mode') }}
|
||||||
|
<select name="sound_mode" class="settings-select" data-focusable>
|
||||||
|
<option value="stereo" {% if client.sound_mode == 'stereo' %}selected{% endif %}>{{ t('settings.sound_stereo') }}</option>
|
||||||
|
<option value="surround" {% if client.sound_mode == 'surround' %}selected{% endif %}>{{ t('settings.sound_surround') }}</option>
|
||||||
|
<option value="original" {% if client.sound_mode == 'original' %}selected{% endif %}>{{ t('settings.sound_original') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="settings-label">
|
||||||
|
{{ t('settings.stream_quality') }}
|
||||||
|
<select name="stream_quality" class="settings-select" data-focusable>
|
||||||
|
<option value="uhd" {% if client.stream_quality == 'uhd' %}selected{% endif %}>{{ t('player.quality_uhd') }}</option>
|
||||||
|
<option value="hd" {% if client.stream_quality == 'hd' %}selected{% endif %}>{{ t('player.quality_hd') }}</option>
|
||||||
|
<option value="sd" {% if client.stream_quality == 'sd' %}selected{% endif %}>{{ t('player.quality_sd') }}</option>
|
||||||
|
<option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="submit" class="tv-play-btn settings-save" data-focusable>
|
||||||
|
{{ t('settings.save') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Gefahrenzone -->
|
||||||
|
<div class="settings-danger">
|
||||||
|
<form method="post" action="/tv/settings/reset"
|
||||||
|
onsubmit="return confirm('{{ t('settings.reset_confirm') }}')">
|
||||||
|
<button type="submit" class="settings-danger-btn" data-focusable>
|
||||||
|
{{ t('settings.reset_all') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/tv/api/search/history"
|
||||||
|
onsubmit="fetch('/tv/api/search/history',{method:'DELETE'});this.querySelector('button').textContent='✓';return false;">
|
||||||
|
<button type="button" class="settings-danger-btn" data-focusable>
|
||||||
|
{{ t('settings.clear_search') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
56
video-konverter/app/templates/tv/watchlist.html
Normal file
56
video-konverter/app/templates/tv/watchlist.html
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "tv/base.html" %}
|
||||||
|
{% block title %}{{ t('watchlist.title') }} - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="tv-section">
|
||||||
|
<h1 class="tv-page-title">{{ t('watchlist.title') }}</h1>
|
||||||
|
|
||||||
|
{% if not series and not movies %}
|
||||||
|
<div class="tv-empty">{{ t('watchlist.empty') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if series %}
|
||||||
|
<div class="tv-section">
|
||||||
|
<h2 class="tv-section-title">{{ t('watchlist.series') }}</h2>
|
||||||
|
<div class="tv-grid">
|
||||||
|
{% for s in series %}
|
||||||
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||||
|
{% if s.poster_url %}
|
||||||
|
<img data-src="{{ s.poster_url }}" alt="" class="tv-card-img tv-lazy" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tv-card-info">
|
||||||
|
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
||||||
|
{% if s.genres %}
|
||||||
|
<span class="tv-card-meta">{{ s.genres }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if movies %}
|
||||||
|
<div class="tv-section">
|
||||||
|
<h2 class="tv-section-title">{{ t('watchlist.movies') }}</h2>
|
||||||
|
<div class="tv-grid">
|
||||||
|
{% for m in movies %}
|
||||||
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||||
|
{% if m.poster_url %}
|
||||||
|
<img data-src="{{ m.poster_url }}" alt="" class="tv-card-img tv-lazy" loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="tv-card-info">
|
||||||
|
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
||||||
|
<span class="tv-card-meta">{% if m.year %}{{ m.year }}{% endif %} {% if m.genres %}{{ m.genres }}{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in a new issue