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.
|
||||
|
||||
## [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
|
||||
|
||||
### Import-System Neustrukturierung
|
||||
|
|
|
|||
|
|
@ -1317,14 +1317,21 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
|
||||
# === Video-Streaming ===
|
||||
|
||||
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
|
||||
_BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
|
||||
|
||||
async def get_stream_video(request: web.Request) -> web.StreamResponse:
|
||||
"""GET /api/library/videos/{video_id}/stream?t=0
|
||||
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC).
|
||||
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
|
||||
Optional: ?t=120 fuer Seeking auf Sekunde 120."""
|
||||
"""GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0
|
||||
Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur.
|
||||
|
||||
Parameter:
|
||||
quality: uhd|hd|sd|low (Default: hd)
|
||||
audio: Audio-Track-Index (Default: 0)
|
||||
t: Seek-Position in Sekunden (Default: 0)
|
||||
sound: stereo|surround|original (Default: stereo)
|
||||
"""
|
||||
import os
|
||||
import asyncio as _asyncio
|
||||
import shlex
|
||||
|
||||
video_id = int(request.match_info["video_id"])
|
||||
|
||||
|
|
@ -1336,43 +1343,96 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT file_path FROM library_videos WHERE id = %s",
|
||||
"SELECT file_path, width, height, video_codec, "
|
||||
"audio_tracks, container, file_size "
|
||||
"FROM library_videos WHERE id = %s",
|
||||
(video_id,)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
video = await cur.fetchone()
|
||||
if not video:
|
||||
return web.json_response(
|
||||
{"error": "Video nicht gefunden"}, status=404
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
file_path = row[0]
|
||||
file_path = video["file_path"]
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"error": "Datei nicht gefunden"}, status=404
|
||||
)
|
||||
|
||||
# Seek-Position (Sekunden) aus Query-Parameter
|
||||
seek_sec = float(request.query.get("t", "0"))
|
||||
# Audio-Tracks parsen
|
||||
audio_tracks = video.get("audio_tracks") or "[]"
|
||||
if isinstance(audio_tracks, str):
|
||||
audio_tracks = json.loads(audio_tracks)
|
||||
|
||||
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container
|
||||
# frag_keyframe: Fragment bei jedem Keyframe
|
||||
# empty_moov: Leerer moov-Atom am Anfang (noetig fuer pipe)
|
||||
# default_base_moof: Bessere Browser-Kompatibilitaet (Samsung TV etc.)
|
||||
# frag_duration: Kleine Fragmente fuer schnellen Playback-Start
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
]
|
||||
# Parameter aus Query
|
||||
quality = request.query.get("quality", "hd")
|
||||
audio_idx = int(request.query.get("audio", "0"))
|
||||
seek_sec = float(request.query.get("t", "0"))
|
||||
sound_mode = request.query.get("sound", "stereo")
|
||||
|
||||
# Audio-Track bestimmen
|
||||
if audio_idx >= len(audio_tracks):
|
||||
audio_idx = 0
|
||||
audio_info = audio_tracks[audio_idx] if audio_tracks else {}
|
||||
audio_codec = audio_info.get("codec", "unknown")
|
||||
audio_channels = audio_info.get("channels", 2)
|
||||
|
||||
# Ziel-Aufloesung bestimmen
|
||||
orig_h = video.get("height") or 1080
|
||||
quality_heights = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
|
||||
target_h = quality_heights.get(quality, 1080)
|
||||
needs_video_scale = orig_h > target_h and quality != "uhd"
|
||||
|
||||
# Audio-Transcoding: noetig wenn Codec nicht browser-kompatibel
|
||||
needs_audio_transcode = audio_codec not in _BROWSER_AUDIO_CODECS
|
||||
|
||||
# Sound-Modus: Kanalanzahl bestimmen
|
||||
if sound_mode == "stereo":
|
||||
out_channels = 2
|
||||
elif sound_mode == "surround":
|
||||
out_channels = min(audio_channels, 8)
|
||||
else: # original
|
||||
out_channels = audio_channels
|
||||
|
||||
# Wenn Kanalanzahl sich aendert -> Transcoding noetig
|
||||
if out_channels != audio_channels:
|
||||
needs_audio_transcode = True
|
||||
|
||||
# ffmpeg-Kommando zusammenbauen
|
||||
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
|
||||
if seek_sec > 0:
|
||||
# -ss VOR -i fuer schnelles Seeking (Input-Seeking)
|
||||
cmd += ["-ss", str(seek_sec)]
|
||||
cmd += ["-i", file_path]
|
||||
|
||||
# Video-Mapping und Codec
|
||||
cmd += ["-map", "0:v:0"]
|
||||
if needs_video_scale:
|
||||
crf = {"sd": "23", "low": "28"}.get(quality, "20")
|
||||
cmd += [
|
||||
"-c:v", "libx264", "-preset", "fast",
|
||||
"-crf", crf,
|
||||
"-vf", f"scale=-2:{target_h}",
|
||||
]
|
||||
else:
|
||||
cmd += ["-c:v", "copy"]
|
||||
|
||||
# Audio-Mapping und Codec
|
||||
cmd += ["-map", f"0:a:{audio_idx}"]
|
||||
if needs_audio_transcode:
|
||||
bitrate = {1: "96k", 2: "192k"}.get(
|
||||
out_channels, f"{out_channels * 64}k")
|
||||
cmd += ["-c:a", "aac", "-ac", str(out_channels),
|
||||
"-b:a", bitrate]
|
||||
else:
|
||||
cmd += ["-c:a", "copy"]
|
||||
|
||||
# Container: Fragmentiertes MP4 fuer Streaming
|
||||
cmd += [
|
||||
"-i", file_path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
|
||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
||||
"-frag_duration", "1000000",
|
||||
"-f", "mp4",
|
||||
|
|
@ -1405,7 +1465,6 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
try:
|
||||
await resp.write(chunk)
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
# Client hat Verbindung geschlossen
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1418,6 +1477,225 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
await resp.write_eof()
|
||||
return resp
|
||||
|
||||
# === Untertitel-Extraktion ===
|
||||
|
||||
async def get_subtitle_track(request: web.Request) -> web.Response:
|
||||
"""GET /api/library/videos/{video_id}/subtitles/{track_index}
|
||||
Extrahiert Untertitel als WebVTT per ffmpeg."""
|
||||
import os
|
||||
import asyncio as _asyncio
|
||||
|
||||
video_id = int(request.match_info["video_id"])
|
||||
track_idx = int(request.match_info["track_index"])
|
||||
|
||||
pool = await library_service._get_pool()
|
||||
if not pool:
|
||||
return web.json_response(
|
||||
{"error": "Keine DB-Verbindung"}, status=500)
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT file_path, subtitle_tracks "
|
||||
"FROM library_videos WHERE id = %s", (video_id,))
|
||||
video = await cur.fetchone()
|
||||
if not video:
|
||||
return web.json_response(
|
||||
{"error": "Video nicht gefunden"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
file_path = video["file_path"]
|
||||
if not os.path.isfile(file_path):
|
||||
return web.json_response(
|
||||
{"error": "Datei nicht gefunden"}, status=404)
|
||||
|
||||
sub_tracks = video.get("subtitle_tracks") or "[]"
|
||||
if isinstance(sub_tracks, str):
|
||||
sub_tracks = json.loads(sub_tracks)
|
||||
|
||||
if track_idx >= len(sub_tracks):
|
||||
return web.json_response(
|
||||
{"error": "Untertitel-Track nicht gefunden"}, status=404)
|
||||
|
||||
sub = sub_tracks[track_idx]
|
||||
if sub.get("codec") in ("hdmv_pgs_subtitle", "dvd_subtitle",
|
||||
"pgs", "vobsub"):
|
||||
return web.json_response(
|
||||
{"error": "Bild-basierte Untertitel nicht unterstuetzt"},
|
||||
status=400)
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-i", file_path,
|
||||
"-map", f"0:s:{track_idx}",
|
||||
"-f", "webvtt", "pipe:1",
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await _asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=_asyncio.subprocess.PIPE,
|
||||
stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
|
||||
if proc.returncode != 0:
|
||||
logging.error(
|
||||
f"Untertitel-Extraktion fehlgeschlagen: "
|
||||
f"{stderr.decode('utf-8', errors='replace')}")
|
||||
return web.json_response(
|
||||
{"error": "Extraktion fehlgeschlagen"}, status=500)
|
||||
|
||||
return web.Response(
|
||||
body=stdout,
|
||||
content_type="text/vtt",
|
||||
charset="utf-8",
|
||||
headers={"Cache-Control": "public, max-age=86400"},
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Untertitel-Fehler: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# === Video-Info API (fuer Player-UI) ===
|
||||
|
||||
async def get_video_info(request: web.Request) -> web.Response:
|
||||
"""GET /api/library/videos/{video_id}/info
|
||||
Audio-/Untertitel-Tracks und Video-Infos fuer Player-Overlay."""
|
||||
video_id = int(request.match_info["video_id"])
|
||||
|
||||
pool = await library_service._get_pool()
|
||||
if not pool:
|
||||
return web.json_response(
|
||||
{"error": "Keine DB-Verbindung"}, status=500)
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT id, file_name, width, height, video_codec,
|
||||
audio_tracks, subtitle_tracks, container,
|
||||
duration_sec, video_bitrate, is_10bit, hdr,
|
||||
series_id, season_number, episode_number
|
||||
FROM library_videos WHERE id = %s
|
||||
""", (video_id,))
|
||||
video = await cur.fetchone()
|
||||
if not video:
|
||||
return web.json_response(
|
||||
{"error": "Video nicht gefunden"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# JSON-Felder parsen
|
||||
for field in ("audio_tracks", "subtitle_tracks"):
|
||||
val = video.get(field)
|
||||
if isinstance(val, str):
|
||||
video[field] = json.loads(val)
|
||||
elif val is None:
|
||||
video[field] = []
|
||||
|
||||
# Bild-basierte Untertitel rausfiltern
|
||||
video["subtitle_tracks"] = [
|
||||
s for s in video["subtitle_tracks"]
|
||||
if s.get("codec") not in (
|
||||
"hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub"
|
||||
)
|
||||
]
|
||||
|
||||
return web.json_response(video)
|
||||
|
||||
# === Episoden-Thumbnails ===
|
||||
|
||||
async def get_video_thumbnail(request: web.Request) -> web.Response:
|
||||
"""GET /api/library/videos/{video_id}/thumbnail
|
||||
Gibt Thumbnail zurueck. Generiert per ffmpeg bei Erstaufruf."""
|
||||
import os
|
||||
import asyncio as _asyncio
|
||||
|
||||
video_id = int(request.match_info["video_id"])
|
||||
|
||||
pool = await library_service._get_pool()
|
||||
if not pool:
|
||||
return web.json_response(
|
||||
{"error": "Keine DB-Verbindung"}, status=500)
|
||||
|
||||
# Pruefen ob bereits generiert
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT thumbnail_path FROM tv_episode_thumbnails "
|
||||
"WHERE video_id = %s", (video_id,))
|
||||
cached = await cur.fetchone()
|
||||
|
||||
if cached and os.path.isfile(cached["thumbnail_path"]):
|
||||
return web.FileResponse(
|
||||
cached["thumbnail_path"],
|
||||
headers={"Cache-Control": "public, max-age=604800"})
|
||||
|
||||
# Video-Info laden
|
||||
await cur.execute(
|
||||
"SELECT file_path, duration_sec FROM library_videos "
|
||||
"WHERE id = %s", (video_id,))
|
||||
video = await cur.fetchone()
|
||||
if not video or not os.path.isfile(video["file_path"]):
|
||||
return web.json_response(
|
||||
{"error": "Video nicht gefunden"}, status=404)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# Thumbnail generieren per ffmpeg (Frame bei 25%)
|
||||
file_path = video["file_path"]
|
||||
duration = video.get("duration_sec") or 0
|
||||
seek_pos = duration * 0.25 if duration > 10 else 5
|
||||
|
||||
# Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei
|
||||
video_dir = os.path.dirname(file_path)
|
||||
thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails")
|
||||
os.makedirs(thumb_dir, exist_ok=True)
|
||||
thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||
"-ss", str(int(seek_pos)),
|
||||
"-i", file_path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "5",
|
||||
"-vf", "scale=480:-1",
|
||||
"-y", thumb_path,
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await _asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=_asyncio.subprocess.PIPE,
|
||||
stderr=_asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
if proc.returncode == 0 and os.path.isfile(thumb_path):
|
||||
# In DB cachen
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_episode_thumbnails
|
||||
(video_id, thumbnail_path, source)
|
||||
VALUES (%s, %s, 'ffmpeg')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
thumbnail_path = VALUES(thumbnail_path)
|
||||
""", (video_id, thumb_path))
|
||||
return web.FileResponse(
|
||||
thumb_path,
|
||||
headers={"Cache-Control": "public, max-age=604800"})
|
||||
else:
|
||||
return web.json_response(
|
||||
{"error": "Thumbnail-Generierung fehlgeschlagen"},
|
||||
status=500)
|
||||
except Exception as e:
|
||||
logging.error(f"Thumbnail-Fehler: {e}")
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
# === Import: Item zuordnen / ueberspringen ===
|
||||
|
||||
async def post_reassign_import_item(
|
||||
|
|
@ -1910,10 +2188,20 @@ def setup_library_routes(app: web.Application, config: Config,
|
|||
"/api/library/import/{job_id}/overwrite-mode",
|
||||
put_overwrite_mode,
|
||||
)
|
||||
# Video-Streaming
|
||||
# Video-Streaming, Untertitel, Video-Info
|
||||
app.router.add_get(
|
||||
"/api/library/videos/{video_id}/stream", get_stream_video
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/library/videos/{video_id}/subtitles/{track_index}",
|
||||
get_subtitle_track
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/library/videos/{video_id}/info", get_video_info
|
||||
)
|
||||
app.router.add_get(
|
||||
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
|
||||
)
|
||||
# TVDB Auto-Match (Review-Modus)
|
||||
app.router.add_post(
|
||||
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import aiomysql
|
|||
from app.config import Config
|
||||
from app.services.auth import AuthService
|
||||
from app.services.library import LibraryService
|
||||
from app.services.i18n import set_request_lang, get_all_translations
|
||||
|
||||
|
||||
def setup_tv_routes(app: web.Application, config: Config,
|
||||
|
|
@ -27,13 +28,16 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
return await auth_service.validate_session(session_id)
|
||||
|
||||
def require_auth(handler):
|
||||
"""Decorator: Leitet auf Login um wenn nicht eingeloggt"""
|
||||
"""Decorator: Leitet auf Login um wenn nicht eingeloggt.
|
||||
Setzt i18n-Sprache aus User-Einstellungen."""
|
||||
@wraps(handler)
|
||||
async def wrapper(request):
|
||||
user = await get_tv_user(request)
|
||||
if not user:
|
||||
raise web.HTTPFound("/tv/login")
|
||||
request["tv_user"] = user
|
||||
# i18n: Sprache des Users setzen
|
||||
set_request_lang(request.app, user.get("ui_lang", "de"))
|
||||
return await handler(request)
|
||||
return wrapper
|
||||
|
||||
|
|
@ -50,10 +54,13 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
)
|
||||
|
||||
async def post_login(request: web.Request) -> web.Response:
|
||||
"""POST /tv/login - Login verarbeiten"""
|
||||
"""POST /tv/login - Login verarbeiten.
|
||||
Unterstuetzt 'remember' Checkbox fuer permanente Sessions
|
||||
und Client-ID fuer Multi-User Quick-Switch."""
|
||||
data = await request.post()
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
remember = data.get("remember", "") == "on"
|
||||
|
||||
if not username or not password:
|
||||
return aiohttp_jinja2.render_template(
|
||||
|
|
@ -68,14 +75,30 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
{"error": "Falscher Benutzername oder Passwort"}
|
||||
)
|
||||
|
||||
# Session erstellen
|
||||
# Client-ID ermitteln/erstellen (fuer Multi-User pro Geraet)
|
||||
client_id = request.cookies.get("vk_client_id")
|
||||
client_id = await auth_service.get_or_create_client(client_id)
|
||||
|
||||
# Session erstellen (persistent wenn "Angemeldet bleiben")
|
||||
ua = request.headers.get("User-Agent", "")
|
||||
session_id = await auth_service.create_session(user["id"], ua)
|
||||
session_id = await auth_service.create_session(
|
||||
user["id"], ua, client_id=client_id, persistent=remember
|
||||
)
|
||||
|
||||
resp = web.HTTPFound("/tv/")
|
||||
# Session-Cookie
|
||||
max_age = 10 * 365 * 24 * 3600 if remember else 30 * 24 * 3600
|
||||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=30 * 24 * 3600, # 30 Tage
|
||||
max_age=max_age,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
path="/",
|
||||
)
|
||||
# Client-ID Cookie (immer permanent)
|
||||
resp.set_cookie(
|
||||
"vk_client_id", client_id,
|
||||
max_age=10 * 365 * 24 * 3600, # 10 Jahre
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
path="/",
|
||||
|
|
@ -166,40 +189,112 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
|
||||
@require_auth
|
||||
async def get_series_list(request: web.Request) -> web.Response:
|
||||
"""GET /tv/series - Alle Serien"""
|
||||
"""GET /tv/series?source=&genre=&sort=&rating= - Alle Serien mit Filtern"""
|
||||
user = request["tv_user"]
|
||||
if not user.get("can_view_series"):
|
||||
raise web.HTTPFound("/tv/")
|
||||
|
||||
# Filter-Parameter
|
||||
source_filter = request.query.get("source", "")
|
||||
genre_filter = request.query.get("genre", "")
|
||||
sort_by = request.query.get("sort", "title")
|
||||
rating_filter = request.query.get("rating", "")
|
||||
|
||||
series = []
|
||||
sources = []
|
||||
all_genres = set()
|
||||
pool = library_service._db_pool
|
||||
if pool:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
# Verfuegbare Quellen laden
|
||||
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'series'"
|
||||
src_params = []
|
||||
if user.get("allowed_paths"):
|
||||
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||
src_query += f" AND id IN ({ph})"
|
||||
src_params = user["allowed_paths"]
|
||||
await cur.execute(src_query, src_params)
|
||||
sources = await cur.fetchall()
|
||||
|
||||
# Serien-Query mit Filtern + Durchschnittsbewertung
|
||||
query = """
|
||||
SELECT s.id, s.title, s.folder_name, s.poster_url,
|
||||
s.genres, s.tvdb_id, s.overview,
|
||||
COUNT(v.id) as episode_count
|
||||
s.genres, s.tvdb_id, s.overview, s.status,
|
||||
s.library_path_id, s.tvdb_score,
|
||||
COUNT(DISTINCT v.id) as episode_count,
|
||||
COALESCE(AVG(r.rating), 0) as avg_rating,
|
||||
COUNT(DISTINCT r.id) as rating_count
|
||||
FROM library_series s
|
||||
LEFT JOIN library_videos v ON v.series_id = s.id
|
||||
LEFT JOIN tv_ratings r ON r.series_id = s.id
|
||||
AND r.rating > 0
|
||||
"""
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
# Pfad-Berechtigung
|
||||
if user.get("allowed_paths"):
|
||||
placeholders = ",".join(
|
||||
["%s"] * len(user["allowed_paths"]))
|
||||
query += (
|
||||
f" WHERE s.library_path_id IN ({placeholders})"
|
||||
)
|
||||
params = user["allowed_paths"]
|
||||
query += " GROUP BY s.id ORDER BY s.title"
|
||||
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||
conditions.append(
|
||||
f"s.library_path_id IN ({ph})")
|
||||
params.extend(user["allowed_paths"])
|
||||
|
||||
# Quellen-Filter
|
||||
if source_filter:
|
||||
conditions.append("s.library_path_id = %s")
|
||||
params.append(int(source_filter))
|
||||
|
||||
# Genre-Filter
|
||||
if genre_filter:
|
||||
conditions.append("s.genres LIKE %s")
|
||||
params.append(f"%{genre_filter}%")
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " GROUP BY s.id"
|
||||
|
||||
# Rating-Filter (nach GROUP BY mit HAVING)
|
||||
if rating_filter:
|
||||
min_stars = int(rating_filter)
|
||||
query += " HAVING avg_rating >= %s"
|
||||
params.append(min_stars)
|
||||
|
||||
# Sortierung
|
||||
sort_map = {
|
||||
"title": " ORDER BY s.title",
|
||||
"title_desc": " ORDER BY s.title DESC",
|
||||
"newest": " ORDER BY s.id DESC",
|
||||
"episodes": " ORDER BY episode_count DESC",
|
||||
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
|
||||
}
|
||||
query += sort_map.get(sort_by, " ORDER BY s.title")
|
||||
await cur.execute(query, params)
|
||||
series = await cur.fetchall()
|
||||
|
||||
# Alle verfuegbaren Genres extrahieren + Rating runden
|
||||
for s in series:
|
||||
s["avg_rating"] = round(
|
||||
float(s.get("avg_rating") or 0), 1)
|
||||
if s.get("genres"):
|
||||
for g in s["genres"].split(","):
|
||||
g = g.strip()
|
||||
if g:
|
||||
all_genres.add(g)
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/series.html", request, {
|
||||
"user": user,
|
||||
"active": "series",
|
||||
"series": series,
|
||||
"view": user.get("series_view") or "grid",
|
||||
"sources": sources,
|
||||
"genres": sorted(all_genres),
|
||||
"current_source": source_filter,
|
||||
"current_genre": genre_filter,
|
||||
"current_sort": sort_by,
|
||||
"current_rating": rating_filter,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -214,36 +309,67 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
|
||||
series = None
|
||||
seasons = {}
|
||||
in_watchlist = False
|
||||
pool = library_service._db_pool
|
||||
if pool:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT id, title, folder_name, poster_url,
|
||||
overview, genres, tvdb_id
|
||||
overview, genres, tvdb_id, tvdb_score
|
||||
FROM library_series WHERE id = %s
|
||||
""", (series_id,))
|
||||
series = await cur.fetchone()
|
||||
|
||||
if series:
|
||||
# Episoden mit TVDB-Beschreibung und Watch-Progress
|
||||
await cur.execute("""
|
||||
SELECT id, file_name, season_number,
|
||||
episode_number, episode_title,
|
||||
duration_sec, file_size,
|
||||
width, height, video_codec,
|
||||
container
|
||||
FROM library_videos
|
||||
WHERE series_id = %s
|
||||
ORDER BY season_number, episode_number, file_name
|
||||
""", (series_id,))
|
||||
SELECT v.id, v.file_name, v.season_number,
|
||||
v.episode_number, v.episode_title,
|
||||
v.duration_sec, v.file_size,
|
||||
v.width, v.height, v.video_codec,
|
||||
v.container,
|
||||
tc.overview AS ep_overview,
|
||||
tc.image_url AS ep_image_url,
|
||||
wp.position_sec, wp.duration_sec AS wp_duration
|
||||
FROM library_videos v
|
||||
LEFT JOIN tvdb_episode_cache tc
|
||||
ON tc.series_tvdb_id = %s
|
||||
AND tc.season_number = v.season_number
|
||||
AND tc.episode_number = v.episode_number
|
||||
LEFT JOIN tv_watch_progress wp
|
||||
ON wp.video_id = v.id
|
||||
AND wp.user_id = %s
|
||||
WHERE v.series_id = %s
|
||||
ORDER BY v.season_number, v.episode_number,
|
||||
v.file_name
|
||||
""", (series.get("tvdb_id") or 0,
|
||||
user["id"], series_id))
|
||||
episodes = await cur.fetchall()
|
||||
|
||||
for ep in episodes:
|
||||
# Fortschritt berechnen
|
||||
if ep.get("position_sec") and ep.get("wp_duration"):
|
||||
ep["progress_pct"] = min(100, int(
|
||||
ep["position_sec"] / ep["wp_duration"]
|
||||
* 100))
|
||||
else:
|
||||
ep["progress_pct"] = 0
|
||||
sn = ep.get("season_number") or 0
|
||||
if sn not in seasons:
|
||||
seasons[sn] = []
|
||||
seasons[sn].append(ep)
|
||||
|
||||
# Watchlist-Status pruefen
|
||||
in_watchlist = await auth_service.is_in_watchlist(
|
||||
user["id"], series_id=series_id)
|
||||
|
||||
# Bewertungen laden
|
||||
user_rating = await auth_service.get_rating(
|
||||
user["id"], series_id=series_id)
|
||||
avg_rating = await auth_service.get_avg_rating(
|
||||
series_id=series_id)
|
||||
|
||||
if not series:
|
||||
raise web.HTTPFound("/tv/series")
|
||||
|
||||
|
|
@ -253,43 +379,114 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
"active": "series",
|
||||
"series": series,
|
||||
"seasons": dict(sorted(seasons.items())),
|
||||
"in_watchlist": in_watchlist,
|
||||
"user_rating": user_rating,
|
||||
"avg_rating": avg_rating,
|
||||
"tvdb_score": series.get("tvdb_score"),
|
||||
}
|
||||
)
|
||||
|
||||
@require_auth
|
||||
async def get_movies_list(request: web.Request) -> web.Response:
|
||||
"""GET /tv/movies - Alle Filme"""
|
||||
"""GET /tv/movies?source=&genre=&sort=&rating= - Alle Filme mit Filtern"""
|
||||
user = request["tv_user"]
|
||||
if not user.get("can_view_movies"):
|
||||
raise web.HTTPFound("/tv/")
|
||||
|
||||
# Filter-Parameter
|
||||
source_filter = request.query.get("source", "")
|
||||
genre_filter = request.query.get("genre", "")
|
||||
sort_by = request.query.get("sort", "title")
|
||||
rating_filter = request.query.get("rating", "")
|
||||
|
||||
movies = []
|
||||
sources = []
|
||||
all_genres = set()
|
||||
pool = library_service._db_pool
|
||||
if pool:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
# Verfuegbare Quellen
|
||||
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'movie'"
|
||||
src_params = []
|
||||
if user.get("allowed_paths"):
|
||||
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||
src_query += f" AND id IN ({ph})"
|
||||
src_params = user["allowed_paths"]
|
||||
await cur.execute(src_query, src_params)
|
||||
sources = await cur.fetchall()
|
||||
|
||||
# Film-Query mit Filtern + Durchschnittsbewertung
|
||||
query = """
|
||||
SELECT m.id, m.title, m.folder_name, m.poster_url,
|
||||
m.year, m.genres, m.overview
|
||||
m.year, m.genres, m.overview,
|
||||
m.library_path_id, m.tvdb_score,
|
||||
COALESCE(AVG(r.rating), 0) as avg_rating,
|
||||
COUNT(DISTINCT r.id) as rating_count
|
||||
FROM library_movies m
|
||||
LEFT JOIN tv_ratings r ON r.movie_id = m.id
|
||||
AND r.rating > 0
|
||||
"""
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if user.get("allowed_paths"):
|
||||
placeholders = ",".join(
|
||||
["%s"] * len(user["allowed_paths"]))
|
||||
query += (
|
||||
f" WHERE m.library_path_id IN ({placeholders})"
|
||||
)
|
||||
params = user["allowed_paths"]
|
||||
query += " ORDER BY m.title"
|
||||
ph = ",".join(["%s"] * len(user["allowed_paths"]))
|
||||
conditions.append(
|
||||
f"m.library_path_id IN ({ph})")
|
||||
params.extend(user["allowed_paths"])
|
||||
|
||||
if source_filter:
|
||||
conditions.append("m.library_path_id = %s")
|
||||
params.append(int(source_filter))
|
||||
|
||||
if genre_filter:
|
||||
conditions.append("m.genres LIKE %s")
|
||||
params.append(f"%{genre_filter}%")
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " GROUP BY m.id"
|
||||
|
||||
# Rating-Filter (nach GROUP BY)
|
||||
if rating_filter:
|
||||
min_stars = int(rating_filter)
|
||||
query += " HAVING avg_rating >= %s"
|
||||
params.append(min_stars)
|
||||
|
||||
sort_map = {
|
||||
"title": " ORDER BY m.title",
|
||||
"title_desc": " ORDER BY m.title DESC",
|
||||
"newest": " ORDER BY m.id DESC",
|
||||
"year": " ORDER BY m.year DESC",
|
||||
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
|
||||
}
|
||||
query += sort_map.get(sort_by, " ORDER BY m.title")
|
||||
await cur.execute(query, params)
|
||||
movies = await cur.fetchall()
|
||||
|
||||
for m in movies:
|
||||
m["avg_rating"] = round(
|
||||
float(m.get("avg_rating") or 0), 1)
|
||||
if m.get("genres"):
|
||||
for g in m["genres"].split(","):
|
||||
g = g.strip()
|
||||
if g:
|
||||
all_genres.add(g)
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/movies.html", request, {
|
||||
"user": user,
|
||||
"active": "movies",
|
||||
"movies": movies,
|
||||
"view": user.get("movies_view") or "grid",
|
||||
"sources": sources,
|
||||
"genres": sorted(all_genres),
|
||||
"current_source": source_filter,
|
||||
"current_genre": genre_filter,
|
||||
"current_sort": sort_by,
|
||||
"current_rating": rating_filter,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -309,7 +506,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT id, title, folder_name, poster_url,
|
||||
year, overview, genres
|
||||
year, overview, genres, tvdb_score
|
||||
FROM library_movies WHERE id = %s
|
||||
""", (movie_id,))
|
||||
movie = await cur.fetchone()
|
||||
|
|
@ -326,18 +523,32 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
if not movie:
|
||||
raise web.HTTPFound("/tv/movies")
|
||||
|
||||
in_watchlist = await auth_service.is_in_watchlist(
|
||||
user["id"], movie_id=movie_id)
|
||||
|
||||
# Bewertungen laden
|
||||
user_rating = await auth_service.get_rating(
|
||||
user["id"], movie_id=movie_id)
|
||||
avg_rating = await auth_service.get_avg_rating(
|
||||
movie_id=movie_id)
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/movie_detail.html", request, {
|
||||
"user": user,
|
||||
"active": "movies",
|
||||
"movie": movie,
|
||||
"videos": videos,
|
||||
"in_watchlist": in_watchlist,
|
||||
"user_rating": user_rating,
|
||||
"avg_rating": avg_rating,
|
||||
"tvdb_score": movie.get("tvdb_score"),
|
||||
}
|
||||
)
|
||||
|
||||
@require_auth
|
||||
async def get_player(request: web.Request) -> web.Response:
|
||||
"""GET /tv/player?v={video_id} - Video-Player"""
|
||||
"""GET /tv/player?v={video_id} - Video-Player
|
||||
Laedt Video-Info, naechste Episode und Client-Einstellungen."""
|
||||
user = request["tv_user"]
|
||||
video_id = int(request.query.get("v", 0))
|
||||
if not video_id:
|
||||
|
|
@ -349,14 +560,16 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
if progress and not progress.get("completed"):
|
||||
start_pos = progress.get("position_sec", 0)
|
||||
|
||||
# Video-Info laden
|
||||
# Video-Info + naechste Episode laden
|
||||
video = None
|
||||
next_video = None
|
||||
pool = library_service._db_pool
|
||||
if pool:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT v.id, v.file_name, v.duration_sec,
|
||||
v.series_id,
|
||||
s.title as series_title,
|
||||
v.season_number, v.episode_number,
|
||||
v.episode_title
|
||||
|
|
@ -366,6 +579,24 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
""", (video_id,))
|
||||
video = await cur.fetchone()
|
||||
|
||||
# Naechste Episode ermitteln (gleiche Serie)
|
||||
if video and video.get("series_id"):
|
||||
await cur.execute("""
|
||||
SELECT id, season_number, episode_number,
|
||||
episode_title, file_name
|
||||
FROM library_videos
|
||||
WHERE series_id = %s
|
||||
AND (season_number > %s
|
||||
OR (season_number = %s
|
||||
AND episode_number > %s))
|
||||
ORDER BY season_number ASC, episode_number ASC
|
||||
LIMIT 1
|
||||
""", (video["series_id"],
|
||||
video.get("season_number", 0),
|
||||
video.get("season_number", 0),
|
||||
video.get("episode_number", 0)))
|
||||
next_video = await cur.fetchone()
|
||||
|
||||
if not video:
|
||||
raise web.HTTPFound("/tv/")
|
||||
|
||||
|
|
@ -379,22 +610,42 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
if ep_title:
|
||||
title += f" - {ep_title}"
|
||||
|
||||
# Naechste Episode Titel
|
||||
next_title = ""
|
||||
if next_video:
|
||||
sn2 = next_video.get("season_number", 0)
|
||||
en2 = next_video.get("episode_number", 0)
|
||||
next_title = f"S{sn2:02d}E{en2:02d}"
|
||||
if next_video.get("episode_title"):
|
||||
next_title += f" - {next_video['episode_title']}"
|
||||
|
||||
# Client-Einstellungen
|
||||
client_id = request.cookies.get("vk_client_id")
|
||||
client = None
|
||||
if client_id:
|
||||
client = await auth_service.get_client_settings(client_id)
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/player.html", request, {
|
||||
"user": user,
|
||||
"video": video,
|
||||
"title": title,
|
||||
"start_pos": start_pos,
|
||||
"next_video": next_video,
|
||||
"next_title": next_title,
|
||||
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
|
||||
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
|
||||
}
|
||||
)
|
||||
|
||||
@require_auth
|
||||
async def get_search(request: web.Request) -> web.Response:
|
||||
"""GET /tv/search?q=... - Suchseite"""
|
||||
"""GET /tv/search?q=... - Suchseite mit History/Autocomplete"""
|
||||
user = request["tv_user"]
|
||||
query = request.query.get("q", "").strip()
|
||||
results_series = []
|
||||
results_movies = []
|
||||
history = []
|
||||
|
||||
if query and len(query) >= 2:
|
||||
pool = library_service._db_pool
|
||||
|
|
@ -405,7 +656,8 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
|
||||
if user.get("can_view_series"):
|
||||
await cur.execute("""
|
||||
SELECT id, title, folder_name, poster_url, genres
|
||||
SELECT id, title, folder_name, poster_url,
|
||||
genres
|
||||
FROM library_series
|
||||
WHERE title LIKE %s OR folder_name LIKE %s
|
||||
ORDER BY title LIMIT 50
|
||||
|
|
@ -422,6 +674,12 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
""", (search_term, search_term))
|
||||
results_movies = await cur.fetchall()
|
||||
|
||||
# Such-History speichern
|
||||
await auth_service.save_search(user["id"], query)
|
||||
else:
|
||||
# Ohne Query: History anzeigen
|
||||
history = await auth_service.get_search_history(user["id"])
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/search.html", request, {
|
||||
"user": user,
|
||||
|
|
@ -429,6 +687,7 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
"query": query,
|
||||
"series": results_series,
|
||||
"movies": results_movies,
|
||||
"history": history,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -566,12 +825,244 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
return web.json_response(
|
||||
{"error": "User nicht gefunden"}, status=404)
|
||||
|
||||
# --- Profilauswahl (Multi-User Quick-Switch) ---
|
||||
|
||||
async def get_profiles(request: web.Request) -> web.Response:
|
||||
"""GET /tv/profiles - Profilauswahl (wer schaut?)"""
|
||||
client_id = request.cookies.get("vk_client_id")
|
||||
profiles = []
|
||||
if client_id:
|
||||
profiles = await auth_service.get_client_profiles(client_id)
|
||||
# Aktuelle Session herausfinden
|
||||
current_session = request.cookies.get("vk_session")
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/profiles.html", request, {
|
||||
"profiles": profiles,
|
||||
"current_session": current_session,
|
||||
}
|
||||
)
|
||||
|
||||
async def post_switch_profile(request: web.Request) -> web.Response:
|
||||
"""POST /tv/switch-profile - Profil wechseln (Session-ID)"""
|
||||
data = await request.post()
|
||||
session_id = data.get("session_id", "")
|
||||
if not session_id:
|
||||
raise web.HTTPFound("/tv/profiles")
|
||||
# Session validieren
|
||||
user = await auth_service.validate_session(session_id)
|
||||
if not user:
|
||||
raise web.HTTPFound("/tv/login")
|
||||
resp = web.HTTPFound("/tv/")
|
||||
resp.set_cookie(
|
||||
"vk_session", session_id,
|
||||
max_age=10 * 365 * 24 * 3600,
|
||||
httponly=True, samesite="Lax", path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
# --- User-Einstellungen ---
|
||||
|
||||
@require_auth
|
||||
async def get_settings(request: web.Request) -> web.Response:
|
||||
"""GET /tv/settings - Benutzer-Einstellungen"""
|
||||
user = request["tv_user"]
|
||||
client_id = request.cookies.get("vk_client_id")
|
||||
client = None
|
||||
if client_id:
|
||||
client = await auth_service.get_client_settings(client_id)
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/settings.html", request, {
|
||||
"user": user,
|
||||
"client": client,
|
||||
"active": "settings",
|
||||
}
|
||||
)
|
||||
|
||||
@require_auth
|
||||
async def post_settings(request: web.Request) -> web.Response:
|
||||
"""POST /tv/settings - Benutzer-Einstellungen speichern
|
||||
Unterstuetzt sowohl vollstaendige Form-Submits als auch
|
||||
einzelne AJAX-Updates (nur gesetzte Felder aendern)."""
|
||||
user = request["tv_user"]
|
||||
data = await request.post()
|
||||
is_ajax = "X-Requested-With" in request.headers or \
|
||||
len(data) <= 2
|
||||
|
||||
# Nur uebergebene Felder sammeln (kein Ueberschreiben)
|
||||
user_kwargs = {}
|
||||
field_map = {
|
||||
"display_name": lambda v: v,
|
||||
"preferred_audio_lang": lambda v: v,
|
||||
"preferred_subtitle_lang": lambda v: v or None,
|
||||
"subtitles_enabled": lambda v: v == "on",
|
||||
"ui_lang": lambda v: v,
|
||||
"series_view": lambda v: v,
|
||||
"movies_view": lambda v: v,
|
||||
"avatar_color": lambda v: v,
|
||||
"theme": lambda v: v,
|
||||
"autoplay_enabled": lambda v: v == "on",
|
||||
"autoplay_countdown_sec": lambda v: int(v),
|
||||
"autoplay_max_episodes": lambda v: int(v),
|
||||
}
|
||||
for key, transform in field_map.items():
|
||||
if key in data:
|
||||
user_kwargs[key] = transform(data[key])
|
||||
|
||||
if user_kwargs:
|
||||
await auth_service.update_user_settings(
|
||||
user["id"], **user_kwargs)
|
||||
|
||||
# Client-Einstellungen (nur wenn Felder vorhanden)
|
||||
client_id = request.cookies.get("vk_client_id")
|
||||
client_kwargs = {}
|
||||
if client_id:
|
||||
if "client_name" in data:
|
||||
client_kwargs["name"] = data["client_name"]
|
||||
if "sound_mode" in data:
|
||||
client_kwargs["sound_mode"] = data["sound_mode"]
|
||||
if "stream_quality" in data:
|
||||
client_kwargs["stream_quality"] = data["stream_quality"]
|
||||
if client_kwargs:
|
||||
await auth_service.update_client_settings(
|
||||
client_id, **client_kwargs)
|
||||
|
||||
# AJAX: JSON zurueckgeben, sonst Redirect
|
||||
if is_ajax:
|
||||
return web.json_response({"ok": True})
|
||||
raise web.HTTPFound("/tv/settings?saved=1")
|
||||
|
||||
@require_auth
|
||||
async def post_reset_progress(request: web.Request) -> web.Response:
|
||||
"""POST /tv/settings/reset - Alle Fortschritte zuruecksetzen"""
|
||||
user = request["tv_user"]
|
||||
await auth_service.reset_all_progress(user["id"])
|
||||
raise web.HTTPFound("/tv/settings?reset=1")
|
||||
|
||||
# --- Watchlist ---
|
||||
|
||||
@require_auth
|
||||
async def get_watchlist(request: web.Request) -> web.Response:
|
||||
"""GET /tv/watchlist - Merkliste anzeigen"""
|
||||
user = request["tv_user"]
|
||||
wl = await auth_service.get_watchlist(user["id"])
|
||||
return aiohttp_jinja2.render_template(
|
||||
"tv/watchlist.html", request, {
|
||||
"user": user,
|
||||
"active": "watchlist",
|
||||
"series": wl["series"],
|
||||
"movies": wl["movies"],
|
||||
}
|
||||
)
|
||||
|
||||
@require_auth
|
||||
async def post_watchlist_toggle(request: web.Request) -> web.Response:
|
||||
"""POST /tv/api/watchlist - Toggle Merkliste (JSON)"""
|
||||
user = request["tv_user"]
|
||||
data = await request.json()
|
||||
series_id = data.get("series_id")
|
||||
movie_id = data.get("movie_id")
|
||||
in_list = await auth_service.toggle_watchlist(
|
||||
user["id"],
|
||||
series_id=int(series_id) if series_id else None,
|
||||
movie_id=int(movie_id) if movie_id else None,
|
||||
)
|
||||
return web.json_response({"in_watchlist": in_list})
|
||||
|
||||
# --- Watch-Status ---
|
||||
|
||||
@require_auth
|
||||
async def post_watch_status(request: web.Request) -> web.Response:
|
||||
"""POST /tv/api/watch-status - Status setzen (JSON)"""
|
||||
user = request["tv_user"]
|
||||
data = await request.json()
|
||||
status = data.get("status", "unwatched")
|
||||
success = await auth_service.set_watch_status(
|
||||
user["id"], status,
|
||||
video_id=data.get("video_id"),
|
||||
series_id=data.get("series_id"),
|
||||
season_key=data.get("season_key"),
|
||||
)
|
||||
return web.json_response({"success": success})
|
||||
|
||||
# --- Such-API ---
|
||||
|
||||
@require_auth
|
||||
async def get_search_suggestions(request: web.Request) -> web.Response:
|
||||
"""GET /tv/api/search/suggest?q=... - Autocomplete-Vorschlaege"""
|
||||
user = request["tv_user"]
|
||||
prefix = request.query.get("q", "").strip()
|
||||
suggestions = await auth_service.get_search_suggestions(
|
||||
user["id"], prefix)
|
||||
return web.json_response({"suggestions": suggestions})
|
||||
|
||||
@require_auth
|
||||
async def get_search_history(request: web.Request) -> web.Response:
|
||||
"""GET /tv/api/search/history - Such-History"""
|
||||
user = request["tv_user"]
|
||||
history = await auth_service.get_search_history(user["id"])
|
||||
return web.json_response({"history": history})
|
||||
|
||||
@require_auth
|
||||
async def delete_search_history(request: web.Request) -> web.Response:
|
||||
"""DELETE /tv/api/search/history - Such-History loeschen"""
|
||||
user = request["tv_user"]
|
||||
await auth_service.clear_search_history(user["id"])
|
||||
return web.json_response({"success": True})
|
||||
|
||||
# --- Rating API ---
|
||||
|
||||
@require_auth
|
||||
async def post_rating(request: web.Request) -> web.Response:
|
||||
"""POST /tv/api/rating - Bewertung setzen/loeschen (JSON)
|
||||
Body: { series_id|movie_id: int, rating: 0-5 }"""
|
||||
user = request["tv_user"]
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
|
||||
|
||||
rating = int(data.get("rating", 0))
|
||||
series_id = data.get("series_id")
|
||||
movie_id = data.get("movie_id")
|
||||
|
||||
if not series_id and not movie_id:
|
||||
return web.json_response(
|
||||
{"error": "series_id oder movie_id noetig"}, status=400)
|
||||
|
||||
success = await auth_service.set_rating(
|
||||
user["id"], rating,
|
||||
series_id=int(series_id) if series_id else None,
|
||||
movie_id=int(movie_id) if movie_id else None,
|
||||
)
|
||||
|
||||
# Durchschnitt zurueckgeben
|
||||
avg = await auth_service.get_avg_rating(
|
||||
series_id=int(series_id) if series_id else None,
|
||||
movie_id=int(movie_id) if movie_id else None,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"success": success,
|
||||
"user_rating": rating,
|
||||
"avg_rating": avg["avg"],
|
||||
"rating_count": avg["count"],
|
||||
})
|
||||
|
||||
# --- i18n API (fuer JavaScript) ---
|
||||
|
||||
async def get_i18n(request: web.Request) -> web.Response:
|
||||
"""GET /tv/api/i18n?lang=de - Alle Uebersetzungen als JSON"""
|
||||
lang = request.query.get("lang", "de")
|
||||
return web.json_response(get_all_translations(lang))
|
||||
|
||||
# --- Routes registrieren ---
|
||||
|
||||
# TV-Seiten (mit Auth via Decorator)
|
||||
app.router.add_get("/tv/login", get_login)
|
||||
app.router.add_post("/tv/login", post_login)
|
||||
app.router.add_get("/tv/logout", get_logout)
|
||||
app.router.add_get("/tv/profiles", get_profiles)
|
||||
app.router.add_post("/tv/switch-profile", post_switch_profile)
|
||||
app.router.add_get("/tv/", get_home)
|
||||
app.router.add_get("/tv/series", get_series_list)
|
||||
app.router.add_get("/tv/series/{id}", get_series_detail)
|
||||
|
|
@ -579,11 +1070,22 @@ def setup_tv_routes(app: web.Application, config: Config,
|
|||
app.router.add_get("/tv/movies/{id}", get_movie_detail)
|
||||
app.router.add_get("/tv/player", get_player)
|
||||
app.router.add_get("/tv/search", get_search)
|
||||
app.router.add_get("/tv/watchlist", get_watchlist)
|
||||
app.router.add_get("/tv/settings", get_settings)
|
||||
app.router.add_post("/tv/settings", post_settings)
|
||||
app.router.add_post("/tv/settings/reset", post_reset_progress)
|
||||
|
||||
# TV-API (Watch-Progress)
|
||||
# TV-API (Watch-Progress, Watchlist, Status, Suche, i18n)
|
||||
app.router.add_post("/tv/api/watch-progress", post_watch_progress)
|
||||
app.router.add_get(
|
||||
"/tv/api/watch-progress/{video_id}", get_watch_progress)
|
||||
app.router.add_post("/tv/api/watchlist", post_watchlist_toggle)
|
||||
app.router.add_post("/tv/api/watch-status", post_watch_status)
|
||||
app.router.add_get("/tv/api/search/suggest", get_search_suggestions)
|
||||
app.router.add_get("/tv/api/search/history", get_search_history)
|
||||
app.router.add_delete("/tv/api/search/history", delete_search_history)
|
||||
app.router.add_get("/tv/api/i18n", get_i18n)
|
||||
app.router.add_post("/tv/api/rating", post_rating)
|
||||
|
||||
# Admin-API (QR-Code, User-Verwaltung)
|
||||
app.router.add_get("/api/tv/qrcode", get_qrcode)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.services.tvdb import TVDBService
|
|||
from app.services.cleaner import CleanerService
|
||||
from app.services.importer import ImporterService
|
||||
from app.services.auth import AuthService
|
||||
from app.services.i18n import load_translations, setup_jinja2_i18n
|
||||
from app.routes.api import setup_api_routes
|
||||
from app.routes.library_api import setup_library_routes
|
||||
from app.routes.pages import setup_page_routes
|
||||
|
|
@ -70,6 +71,11 @@ class VideoKonverterServer:
|
|||
context_processors=[aiohttp_jinja2.request_processor],
|
||||
)
|
||||
|
||||
# i18n: Uebersetzungen laden und Jinja2-Filter registrieren
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
load_translations(str(static_dir))
|
||||
setup_jinja2_i18n(self.app)
|
||||
|
||||
# WebSocket Route
|
||||
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
||||
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class AuthService:
|
|||
self._get_pool = db_pool_getter
|
||||
|
||||
async def init_db(self) -> None:
|
||||
"""Erstellt DB-Tabellen fuer TV-Auth"""
|
||||
"""Erstellt DB-Tabellen fuer TV-Auth und migriert bestehende"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
logging.error("Auth: Kein DB-Pool verfuegbar")
|
||||
|
|
@ -24,6 +24,7 @@ class AuthService:
|
|||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# === Bestehende Tabellen ===
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
|
@ -64,10 +65,171 @@ class AuthService:
|
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# === Neue Tabellen (v4.0) ===
|
||||
|
||||
# Client-Einstellungen (pro Geraet/Browser)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_clients (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(128) DEFAULT NULL,
|
||||
sound_mode ENUM('stereo','surround','original')
|
||||
DEFAULT 'stereo',
|
||||
stream_quality ENUM('uhd','hd','sd','low')
|
||||
DEFAULT 'hd',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Merkliste (Watchlist)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_watchlist (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
series_id INT NULL,
|
||||
movie_id INT NULL,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Manueller Watch-Status
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_watch_status (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
video_id INT NULL,
|
||||
series_id INT NULL,
|
||||
season_key VARCHAR(64) NULL,
|
||||
status ENUM('unwatched','watching','watched')
|
||||
DEFAULT 'unwatched',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
UNIQUE INDEX idx_user_video (user_id, video_id),
|
||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||
UNIQUE INDEX idx_user_season (user_id, season_key),
|
||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Episoden-Thumbnails Cache
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_episode_thumbnails (
|
||||
video_id INT PRIMARY KEY,
|
||||
thumbnail_path VARCHAR(1024) NOT NULL,
|
||||
source ENUM('tvdb','ffmpeg') DEFAULT 'ffmpeg',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Such-History (pro User)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_search_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
query VARCHAR(256) NOT NULL,
|
||||
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_query (query(64)),
|
||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Bewertungen (pro User, fuer Serien und Filme)
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tv_ratings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
series_id INT NULL,
|
||||
movie_id INT NULL,
|
||||
rating TINYINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
||||
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# === Migration: Neue Spalten zu bestehenden Tabellen ===
|
||||
await self._migrate_columns(cur)
|
||||
|
||||
# Standard-Admin erstellen falls keine User existieren
|
||||
await self._ensure_default_admin()
|
||||
logging.info("TV-Auth: DB-Tabellen initialisiert")
|
||||
|
||||
async def _migrate_columns(self, cur) -> None:
|
||||
"""Fuegt neue Spalten zu bestehenden Tabellen hinzu (idempotent)"""
|
||||
# Hilfsfunktion: Spalte hinzufuegen falls nicht vorhanden
|
||||
async def add_column(table: str, column: str, definition: str):
|
||||
await cur.execute(
|
||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
||||
"AND TABLE_NAME = %s AND COLUMN_NAME = %s",
|
||||
(table, column)
|
||||
)
|
||||
row = await cur.fetchone()
|
||||
if row[0] == 0:
|
||||
await cur.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
||||
)
|
||||
logging.info(f"TV-Auth: Spalte {table}.{column} hinzugefuegt")
|
||||
|
||||
# tv_users: User-Einstellungen
|
||||
await add_column("tv_users", "preferred_audio_lang",
|
||||
"VARCHAR(8) DEFAULT 'deu'")
|
||||
await add_column("tv_users", "preferred_subtitle_lang",
|
||||
"VARCHAR(8) DEFAULT NULL")
|
||||
await add_column("tv_users", "subtitles_enabled",
|
||||
"TINYINT DEFAULT 0")
|
||||
await add_column("tv_users", "ui_lang",
|
||||
"VARCHAR(8) DEFAULT 'de'")
|
||||
await add_column("tv_users", "series_view",
|
||||
"VARCHAR(16) DEFAULT 'grid'")
|
||||
await add_column("tv_users", "movies_view",
|
||||
"VARCHAR(16) DEFAULT 'grid'")
|
||||
await add_column("tv_users", "avatar_color",
|
||||
"VARCHAR(7) DEFAULT '#64b5f6'")
|
||||
# Auto-Play Einstellungen
|
||||
await add_column("tv_users", "autoplay_enabled",
|
||||
"TINYINT DEFAULT 1")
|
||||
await add_column("tv_users", "autoplay_countdown_sec",
|
||||
"INT DEFAULT 10")
|
||||
await add_column("tv_users", "autoplay_max_episodes",
|
||||
"INT DEFAULT 0")
|
||||
|
||||
# tv_sessions: Client-Referenz und permanente Sessions
|
||||
await add_column("tv_sessions", "client_id",
|
||||
"VARCHAR(64) DEFAULT NULL")
|
||||
await add_column("tv_sessions", "expires_at",
|
||||
"TIMESTAMP NULL DEFAULT NULL")
|
||||
|
||||
# tvdb_episode_cache: Beschreibung und Bild-URL
|
||||
await add_column("tvdb_episode_cache", "overview",
|
||||
"TEXT DEFAULT NULL")
|
||||
await add_column("tvdb_episode_cache", "image_url",
|
||||
"VARCHAR(1024) DEFAULT NULL")
|
||||
|
||||
# tv_users: Theme
|
||||
await add_column("tv_users", "theme",
|
||||
"VARCHAR(16) DEFAULT 'dark'")
|
||||
|
||||
# library_series: TVDB-Score (externe Bewertung 0-100)
|
||||
await add_column("library_series", "tvdb_score",
|
||||
"FLOAT DEFAULT NULL")
|
||||
|
||||
# library_movies: TVDB-Score (externe Bewertung 0-100)
|
||||
await add_column("library_movies", "tvdb_score",
|
||||
"FLOAT DEFAULT NULL")
|
||||
|
||||
async def _ensure_default_admin(self) -> None:
|
||||
"""Erstellt admin/admin falls keine User existieren"""
|
||||
pool = await self._get_pool()
|
||||
|
|
@ -254,22 +416,41 @@ class AuthService:
|
|||
return user
|
||||
|
||||
async def create_session(self, user_id: int,
|
||||
user_agent: str = "") -> str:
|
||||
"""Erstellt Session, gibt Token zurueck"""
|
||||
user_agent: str = "",
|
||||
client_id: str = "",
|
||||
persistent: bool = False) -> str:
|
||||
"""Erstellt Session, gibt Token zurueck.
|
||||
persistent=True -> Session laeuft nie ab (expires_at=NULL)"""
|
||||
session_id = secrets.token_urlsafe(48)
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return ""
|
||||
# Nicht-persistente Sessions laufen nach 30 Tagen ab
|
||||
expires = None if persistent else "DATE_ADD(NOW(), INTERVAL 30 DAY)"
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if persistent:
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_sessions (id, user_id, user_agent)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (session_id, user_id, user_agent[:512] if user_agent else ""))
|
||||
INSERT INTO tv_sessions
|
||||
(id, user_id, user_agent, client_id, expires_at)
|
||||
VALUES (%s, %s, %s, %s, NULL)
|
||||
""", (session_id, user_id,
|
||||
user_agent[:512] if user_agent else "",
|
||||
client_id or None))
|
||||
else:
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_sessions
|
||||
(id, user_id, user_agent, client_id, expires_at)
|
||||
VALUES (%s, %s, %s, %s,
|
||||
DATE_ADD(NOW(), INTERVAL 30 DAY))
|
||||
""", (session_id, user_id,
|
||||
user_agent[:512] if user_agent else "",
|
||||
client_id or None))
|
||||
return session_id
|
||||
|
||||
async def validate_session(self, session_id: str) -> Optional[dict]:
|
||||
"""Prueft Session, gibt User-Dict zurueck oder None"""
|
||||
"""Prueft Session, gibt User-Dict mit Einstellungen zurueck oder None.
|
||||
Beruecksichtigt expires_at (NULL = permanent, sonst Ablauf-Datum)."""
|
||||
if not session_id:
|
||||
return None
|
||||
pool = await self._get_pool()
|
||||
|
|
@ -279,11 +460,18 @@ class AuthService:
|
|||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT u.id, u.username, u.display_name, u.is_admin,
|
||||
u.can_view_series, u.can_view_movies, u.allowed_paths
|
||||
u.can_view_series, u.can_view_movies,
|
||||
u.allowed_paths,
|
||||
u.preferred_audio_lang, u.preferred_subtitle_lang,
|
||||
u.subtitles_enabled, u.ui_lang,
|
||||
u.series_view, u.movies_view, u.avatar_color,
|
||||
u.autoplay_enabled, u.autoplay_countdown_sec,
|
||||
u.autoplay_max_episodes, u.theme,
|
||||
s.client_id
|
||||
FROM tv_sessions s
|
||||
JOIN tv_users u ON s.user_id = u.id
|
||||
WHERE s.id = %s
|
||||
AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
||||
""", (session_id,))
|
||||
user = await cur.fetchone()
|
||||
|
||||
|
|
@ -311,7 +499,8 @@ class AuthService:
|
|||
)
|
||||
|
||||
async def cleanup_old_sessions(self) -> int:
|
||||
"""Loescht Sessions aelter als 30 Tage"""
|
||||
"""Loescht abgelaufene Sessions (expires_at abgelaufen).
|
||||
Persistente Sessions (expires_at IS NULL) werden nie geloescht."""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return 0
|
||||
|
|
@ -319,10 +508,466 @@ class AuthService:
|
|||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_sessions "
|
||||
"WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"
|
||||
"WHERE expires_at IS NOT NULL AND expires_at < NOW()"
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
# --- Client-Verwaltung (pro Geraet) ---
|
||||
|
||||
async def get_or_create_client(self, client_id: str = None) -> str:
|
||||
"""Gibt bestehende oder neue Client-ID zurueck"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return ""
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if client_id:
|
||||
await cur.execute(
|
||||
"SELECT id FROM tv_clients WHERE id = %s",
|
||||
(client_id,))
|
||||
if await cur.fetchone():
|
||||
await cur.execute(
|
||||
"UPDATE tv_clients SET last_active = NOW() "
|
||||
"WHERE id = %s", (client_id,))
|
||||
return client_id
|
||||
# Neuen Client erstellen
|
||||
new_id = secrets.token_urlsafe(32)
|
||||
await cur.execute(
|
||||
"INSERT INTO tv_clients (id) VALUES (%s)",
|
||||
(new_id,))
|
||||
return new_id
|
||||
|
||||
async def get_client_settings(self, client_id: str) -> Optional[dict]:
|
||||
"""Liest Client-Einstellungen"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM tv_clients WHERE id = %s",
|
||||
(client_id,))
|
||||
return await cur.fetchone()
|
||||
|
||||
async def update_client_settings(self, client_id: str,
|
||||
**kwargs) -> bool:
|
||||
"""Aktualisiert Client-Einstellungen (name, sound_mode, stream_quality)"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
allowed = {"name", "sound_mode", "stream_quality"}
|
||||
updates = []
|
||||
values = []
|
||||
for key, val in kwargs.items():
|
||||
if key in allowed and val is not None:
|
||||
updates.append(f"{key} = %s")
|
||||
values.append(val)
|
||||
if not updates:
|
||||
return False
|
||||
values.append(client_id)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"UPDATE tv_clients SET {', '.join(updates)} "
|
||||
"WHERE id = %s", tuple(values))
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Client-Settings fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
# --- Multi-User: Profile auf dem selben Geraet ---
|
||||
|
||||
async def get_client_profiles(self, client_id: str) -> list[dict]:
|
||||
"""Alle eingeloggten User auf einem Client (fuer Quick-Switch)"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return []
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute("""
|
||||
SELECT s.id as session_id, u.id as user_id,
|
||||
u.username, u.display_name, u.avatar_color
|
||||
FROM tv_sessions s
|
||||
JOIN tv_users u ON s.user_id = u.id
|
||||
WHERE s.client_id = %s
|
||||
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
||||
ORDER BY s.last_active DESC
|
||||
""", (client_id,))
|
||||
return await cur.fetchall()
|
||||
|
||||
# --- User-Einstellungen ---
|
||||
|
||||
async def update_user_settings(self, user_id: int,
|
||||
**kwargs) -> bool:
|
||||
"""Aktualisiert User-Einstellungen (Sprache, Ansichten, Auto-Play)"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
allowed = {
|
||||
"preferred_audio_lang", "preferred_subtitle_lang",
|
||||
"subtitles_enabled", "ui_lang",
|
||||
"series_view", "movies_view", "avatar_color",
|
||||
"autoplay_enabled", "autoplay_countdown_sec",
|
||||
"autoplay_max_episodes", "display_name", "theme",
|
||||
}
|
||||
updates = []
|
||||
values = []
|
||||
for key, val in kwargs.items():
|
||||
if key in allowed:
|
||||
updates.append(f"{key} = %s")
|
||||
if isinstance(val, bool):
|
||||
val = int(val)
|
||||
values.append(val)
|
||||
if not updates:
|
||||
return False
|
||||
values.append(user_id)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
f"UPDATE tv_users SET {', '.join(updates)} "
|
||||
"WHERE id = %s", tuple(values))
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Einstellungen fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
# --- Watchlist (Merkliste) ---
|
||||
|
||||
async def add_to_watchlist(self, user_id: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> bool:
|
||||
"""Fuegt Serie oder Film zur Merkliste hinzu"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT IGNORE INTO tv_watchlist
|
||||
(user_id, series_id, movie_id)
|
||||
VALUES (%s, %s, %s)
|
||||
""", (user_id, series_id, movie_id))
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Watchlist hinzufuegen fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
async def remove_from_watchlist(self, user_id: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> bool:
|
||||
"""Entfernt Serie oder Film von der Merkliste"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
elif movie_id:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND movie_id = %s",
|
||||
(user_id, movie_id))
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Watchlist entfernen fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
async def toggle_watchlist(self, user_id: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> bool:
|
||||
"""Toggle: Hinzufuegen wenn nicht vorhanden, entfernen wenn schon drin.
|
||||
Gibt True zurueck wenn jetzt in der Liste, False wenn entfernt."""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Pruefen ob schon in Liste
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"SELECT id FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
else:
|
||||
await cur.execute(
|
||||
"SELECT id FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND movie_id = %s",
|
||||
(user_id, movie_id))
|
||||
exists = await cur.fetchone()
|
||||
if exists:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_watchlist WHERE id = %s",
|
||||
(exists[0],))
|
||||
return False # Entfernt
|
||||
else:
|
||||
await cur.execute(
|
||||
"INSERT INTO tv_watchlist "
|
||||
"(user_id, series_id, movie_id) VALUES (%s, %s, %s)",
|
||||
(user_id, series_id, movie_id))
|
||||
return True # Hinzugefuegt
|
||||
|
||||
async def get_watchlist(self, user_id: int) -> dict:
|
||||
"""Gibt Merkliste zurueck (Serien + Filme)"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return {"series": [], "movies": []}
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
# Serien
|
||||
await cur.execute("""
|
||||
SELECT w.id as watchlist_id, w.added_at,
|
||||
s.id, s.title, s.folder_name, s.poster_url,
|
||||
s.genres, s.overview
|
||||
FROM tv_watchlist w
|
||||
JOIN library_series s ON w.series_id = s.id
|
||||
WHERE w.user_id = %s AND w.series_id IS NOT NULL
|
||||
ORDER BY w.added_at DESC
|
||||
""", (user_id,))
|
||||
series = await cur.fetchall()
|
||||
# Filme
|
||||
await cur.execute("""
|
||||
SELECT w.id as watchlist_id, w.added_at,
|
||||
m.id, m.title, m.folder_name, m.poster_url,
|
||||
m.year, m.genres, m.overview
|
||||
FROM tv_watchlist w
|
||||
JOIN library_movies m ON w.movie_id = m.id
|
||||
WHERE w.user_id = %s AND w.movie_id IS NOT NULL
|
||||
ORDER BY w.added_at DESC
|
||||
""", (user_id,))
|
||||
movies = await cur.fetchall()
|
||||
return {"series": series, "movies": movies}
|
||||
|
||||
async def is_in_watchlist(self, user_id: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> bool:
|
||||
"""Prueft ob Serie/Film in der Merkliste ist"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"SELECT 1 FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
else:
|
||||
await cur.execute(
|
||||
"SELECT 1 FROM tv_watchlist "
|
||||
"WHERE user_id = %s AND movie_id = %s",
|
||||
(user_id, movie_id))
|
||||
return await cur.fetchone() is not None
|
||||
|
||||
# --- Watch-Status (manuell gesehen/nicht gesehen) ---
|
||||
|
||||
async def set_watch_status(self, user_id: int, status: str,
|
||||
video_id: int = None,
|
||||
series_id: int = None,
|
||||
season_key: str = None) -> bool:
|
||||
"""Setzt manuellen Watch-Status (unwatched/watching/watched)"""
|
||||
if status not in ("unwatched", "watching", "watched"):
|
||||
return False
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_watch_status
|
||||
(user_id, video_id, series_id, season_key, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status)
|
||||
""", (user_id, video_id, series_id, season_key, status))
|
||||
|
||||
# Bei Staffel/Serie auch Einzel-Episoden aktualisieren
|
||||
if series_id and not video_id and not season_key:
|
||||
# Ganze Serie markieren
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_watch_status
|
||||
(user_id, video_id, status)
|
||||
SELECT %s, v.id, %s
|
||||
FROM library_videos v
|
||||
WHERE v.series_id = %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status)
|
||||
""", (user_id, status, series_id))
|
||||
elif season_key:
|
||||
# Ganze Staffel markieren (format: "series_id:season")
|
||||
parts = season_key.split(":")
|
||||
if len(parts) == 2:
|
||||
sid, sn = int(parts[0]), int(parts[1])
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_watch_status
|
||||
(user_id, video_id, status)
|
||||
SELECT %s, v.id, %s
|
||||
FROM library_videos v
|
||||
WHERE v.series_id = %s
|
||||
AND v.season_number = %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status)
|
||||
""", (user_id, status, sid, sn))
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Watch-Status fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
async def get_watch_status(self, user_id: int,
|
||||
video_id: int = None,
|
||||
series_id: int = None) -> Optional[str]:
|
||||
"""Gibt Watch-Status zurueck"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return None
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if video_id:
|
||||
await cur.execute(
|
||||
"SELECT status FROM tv_watch_status "
|
||||
"WHERE user_id = %s AND video_id = %s",
|
||||
(user_id, video_id))
|
||||
elif series_id:
|
||||
await cur.execute(
|
||||
"SELECT status FROM tv_watch_status "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
else:
|
||||
return None
|
||||
row = await cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
# --- Such-History ---
|
||||
|
||||
async def save_search(self, user_id: int, query: str) -> None:
|
||||
"""Speichert Suchanfrage in der History"""
|
||||
if not query or len(query) < 2:
|
||||
return
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Duplikate vermeiden: gleiche Query aktualisieren
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_search_history "
|
||||
"WHERE user_id = %s AND query = %s",
|
||||
(user_id, query))
|
||||
await cur.execute(
|
||||
"INSERT INTO tv_search_history (user_id, query) "
|
||||
"VALUES (%s, %s)", (user_id, query))
|
||||
# Max. 50 Eintraege behalten
|
||||
await cur.execute("""
|
||||
DELETE FROM tv_search_history
|
||||
WHERE user_id = %s AND id NOT IN (
|
||||
SELECT id FROM (
|
||||
SELECT id FROM tv_search_history
|
||||
WHERE user_id = %s
|
||||
ORDER BY searched_at DESC LIMIT 50
|
||||
) t
|
||||
)
|
||||
""", (user_id, user_id))
|
||||
|
||||
async def get_search_history(self, user_id: int,
|
||||
limit: int = 20) -> list[str]:
|
||||
"""Gibt letzte Suchanfragen zurueck"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return []
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"SELECT query FROM tv_search_history "
|
||||
"WHERE user_id = %s ORDER BY searched_at DESC LIMIT %s",
|
||||
(user_id, limit))
|
||||
rows = await cur.fetchall()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
async def get_search_suggestions(self, user_id: int,
|
||||
prefix: str,
|
||||
limit: int = 8) -> list[str]:
|
||||
"""Autocomplete: Vorschlaege aus History + Serien/Film-Titel"""
|
||||
if not prefix or len(prefix) < 1:
|
||||
return []
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return []
|
||||
suggestions = []
|
||||
search = f"{prefix}%"
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
# Aus Such-History
|
||||
await cur.execute(
|
||||
"SELECT DISTINCT query FROM tv_search_history "
|
||||
"WHERE user_id = %s AND query LIKE %s "
|
||||
"ORDER BY searched_at DESC LIMIT %s",
|
||||
(user_id, search, limit))
|
||||
rows = await cur.fetchall()
|
||||
suggestions.extend(r[0] for r in rows)
|
||||
# Aus Serien-Titeln
|
||||
remaining = limit - len(suggestions)
|
||||
if remaining > 0:
|
||||
await cur.execute(
|
||||
"SELECT title FROM library_series "
|
||||
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
||||
(search, remaining))
|
||||
rows = await cur.fetchall()
|
||||
for r in rows:
|
||||
if r[0] not in suggestions:
|
||||
suggestions.append(r[0])
|
||||
# Aus Film-Titeln
|
||||
remaining = limit - len(suggestions)
|
||||
if remaining > 0:
|
||||
await cur.execute(
|
||||
"SELECT title FROM library_movies "
|
||||
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
||||
(search, remaining))
|
||||
rows = await cur.fetchall()
|
||||
for r in rows:
|
||||
if r[0] not in suggestions:
|
||||
suggestions.append(r[0])
|
||||
return suggestions[:limit]
|
||||
|
||||
async def clear_search_history(self, user_id: int) -> bool:
|
||||
"""Loescht alle Suchanfragen eines Users"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_search_history WHERE user_id = %s",
|
||||
(user_id,))
|
||||
return True
|
||||
|
||||
# --- Fortschritt zuruecksetzen ---
|
||||
|
||||
async def reset_all_progress(self, user_id: int) -> bool:
|
||||
"""Setzt ALLE Fortschritte und Status eines Users zurueck"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_watch_progress WHERE user_id = %s",
|
||||
(user_id,))
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_watch_status WHERE user_id = %s",
|
||||
(user_id,))
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Reset fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
# --- Watch-Progress ---
|
||||
|
||||
async def save_progress(self, user_id: int, video_id: int,
|
||||
|
|
@ -391,3 +1036,91 @@ class AuthService:
|
|||
row["updated_at"], "isoformat"):
|
||||
row["updated_at"] = str(row["updated_at"])
|
||||
return rows
|
||||
|
||||
# --- Bewertungen (Ratings) ---
|
||||
|
||||
async def set_rating(self, user_id: int, rating: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> bool:
|
||||
"""Setzt User-Bewertung (1-5 Sterne). 0 = Bewertung loeschen."""
|
||||
if rating < 0 or rating > 5:
|
||||
return False
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return False
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if rating == 0:
|
||||
# Bewertung loeschen
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_ratings "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
elif movie_id:
|
||||
await cur.execute(
|
||||
"DELETE FROM tv_ratings "
|
||||
"WHERE user_id = %s AND movie_id = %s",
|
||||
(user_id, movie_id))
|
||||
else:
|
||||
await cur.execute("""
|
||||
INSERT INTO tv_ratings
|
||||
(user_id, series_id, movie_id, rating)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE rating = VALUES(rating)
|
||||
""", (user_id, series_id, movie_id, rating))
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"TV-Auth: Rating fehlgeschlagen: {e}")
|
||||
return False
|
||||
|
||||
async def get_rating(self, user_id: int,
|
||||
series_id: int = None,
|
||||
movie_id: int = None) -> int:
|
||||
"""Gibt User-Rating zurueck (0 = keine Bewertung)"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return 0
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"SELECT rating FROM tv_ratings "
|
||||
"WHERE user_id = %s AND series_id = %s",
|
||||
(user_id, series_id))
|
||||
elif movie_id:
|
||||
await cur.execute(
|
||||
"SELECT rating FROM tv_ratings "
|
||||
"WHERE user_id = %s AND movie_id = %s",
|
||||
(user_id, movie_id))
|
||||
else:
|
||||
return 0
|
||||
row = await cur.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
async def get_avg_rating(self, series_id: int = None,
|
||||
movie_id: int = None) -> dict:
|
||||
"""Gibt Durchschnittsbewertung + Anzahl zurueck"""
|
||||
pool = await self._get_pool()
|
||||
if not pool:
|
||||
return {"avg": 0, "count": 0}
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if series_id:
|
||||
await cur.execute(
|
||||
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
||||
"FROM tv_ratings WHERE series_id = %s AND rating > 0",
|
||||
(series_id,))
|
||||
elif movie_id:
|
||||
await cur.execute(
|
||||
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
||||
"FROM tv_ratings WHERE movie_id = %s AND rating > 0",
|
||||
(movie_id,))
|
||||
else:
|
||||
return {"avg": 0, "count": 0}
|
||||
row = await cur.fetchone()
|
||||
return {
|
||||
"avg": round(float(row[0] or 0), 1),
|
||||
"count": int(row[1] or 0),
|
||||
}
|
||||
|
|
|
|||
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,28 +317,54 @@ class QueueService:
|
|||
await self.ws_manager.broadcast_queue_update()
|
||||
|
||||
async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
|
||||
"""Cleanup nach erfolgreicher Konvertierung"""
|
||||
"""Cleanup nach erfolgreicher Konvertierung.
|
||||
WICHTIG: Nur die Quelldatei dieses Jobs loeschen, NICHT
|
||||
andere Dateien im Ordner die noch in der Queue warten!"""
|
||||
files_cfg = self.config.files_config
|
||||
|
||||
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
||||
should_delete = files_cfg.get("delete_source", False) or job.delete_source
|
||||
should_delete = files_cfg.get("delete_source", False) or \
|
||||
job.delete_source
|
||||
|
||||
if should_delete:
|
||||
target_exists = os.path.exists(job.target_path)
|
||||
target_size = os.path.getsize(job.target_path) if target_exists else 0
|
||||
target_size = (os.path.getsize(job.target_path)
|
||||
if target_exists else 0)
|
||||
if target_exists and target_size > 0:
|
||||
try:
|
||||
os.remove(job.media.source_path)
|
||||
logging.info(f"Quelldatei geloescht: {job.media.source_path}")
|
||||
logging.info(
|
||||
f"Quelldatei geloescht: {job.media.source_path}")
|
||||
except OSError as e:
|
||||
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}")
|
||||
logging.error(
|
||||
f"Quelldatei loeschen fehlgeschlagen: {e}")
|
||||
else:
|
||||
logging.warning(
|
||||
f"Quelldatei NICHT geloescht "
|
||||
f"(Zieldatei fehlt/leer): "
|
||||
f"{job.media.source_path}")
|
||||
|
||||
# SICHERHEIT: Ordner-Cleanup nur wenn KEINE weiteren
|
||||
# Jobs aus diesem Ordner in der Queue warten!
|
||||
cleanup_cfg = self.config.cleanup_config
|
||||
if cleanup_cfg.get("enabled", False):
|
||||
deleted = self.scanner.cleanup_directory(job.media.source_dir)
|
||||
source_dir = job.media.source_dir
|
||||
pending = [
|
||||
j for j in self.jobs.values()
|
||||
if j.media.source_dir == source_dir
|
||||
and j.status in (JobStatus.QUEUED, JobStatus.ACTIVE)
|
||||
and j.id != job.id
|
||||
]
|
||||
if pending:
|
||||
logging.info(
|
||||
f"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 in {job.media.source_dir}"
|
||||
f"{len(deleted)} Dateien bereinigt "
|
||||
f"in {source_dir}"
|
||||
)
|
||||
|
||||
def _get_next_queued(self) -> Optional[ConversionJob]:
|
||||
|
|
|
|||
|
|
@ -801,12 +801,21 @@ class TVDBService:
|
|||
ep_aired = getattr(ep, "aired", None)
|
||||
ep_runtime = getattr(ep, "runtime", None)
|
||||
if s_num and s_num > 0 and e_num and e_num > 0:
|
||||
# Beschreibung und Bild-URL
|
||||
if isinstance(ep, dict):
|
||||
ep_overview = ep.get("overview", "")
|
||||
ep_image = ep.get("image", "")
|
||||
else:
|
||||
ep_overview = getattr(ep, "overview", "")
|
||||
ep_image = getattr(ep, "image", "")
|
||||
episodes.append({
|
||||
"season_number": s_num,
|
||||
"episode_number": e_num,
|
||||
"episode_name": ep_name or "",
|
||||
"aired": ep_aired,
|
||||
"runtime": ep_runtime,
|
||||
"overview": ep_overview or "",
|
||||
"image_url": ep_image or "",
|
||||
})
|
||||
page += 1
|
||||
if page > 50:
|
||||
|
|
@ -840,12 +849,15 @@ class TVDBService:
|
|||
await cur.execute(
|
||||
"INSERT INTO tvdb_episode_cache "
|
||||
"(series_tvdb_id, season_number, episode_number, "
|
||||
"episode_name, aired, runtime) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||
"episode_name, aired, runtime, overview, "
|
||||
"image_url) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
|
||||
(
|
||||
tvdb_id, ep["season_number"],
|
||||
ep["episode_number"], ep["episode_name"],
|
||||
ep["aired"], ep["runtime"],
|
||||
ep.get("overview", ""),
|
||||
ep.get("image_url", ""),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
|
|||
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
|
||||
* Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung
|
||||
* Speichert Watch-Progress automatisch
|
||||
* VideoKonverter TV - Video-Player v4.0
|
||||
* Fullscreen-Player mit Audio/Untertitel/Qualitaets-Auswahl,
|
||||
* Naechste-Episode-Countdown und Tastatur/Fernbedienung-Steuerung.
|
||||
*/
|
||||
|
||||
// === State ===
|
||||
let videoEl = null;
|
||||
let videoId = 0;
|
||||
let videoDuration = 0;
|
||||
let cfg = {}; // Konfiguration aus initPlayer()
|
||||
let videoInfo = null; // Audio/Subtitle-Tracks vom Server
|
||||
let currentAudio = 0;
|
||||
let currentSub = -1; // -1 = aus
|
||||
let currentQuality = "hd";
|
||||
let currentSpeed = 1.0;
|
||||
let progressBar = null;
|
||||
let timeDisplay = null;
|
||||
let playBtn = null;
|
||||
let controlsTimer = null;
|
||||
let saveTimer = null;
|
||||
let controlsVisible = true;
|
||||
let overlayOpen = false;
|
||||
let nextCountdown = null;
|
||||
let episodesWatched = 0;
|
||||
let seekOffset = 0; // Korrektur fuer Seek-basiertes Streaming
|
||||
|
||||
/**
|
||||
* Player initialisieren
|
||||
* @param {number} id - Video-ID
|
||||
* @param {number} startPos - Startposition in Sekunden
|
||||
* @param {number} duration - Video-Dauer in Sekunden (Fallback)
|
||||
* @param {Object} opts - Konfiguration
|
||||
*/
|
||||
function initPlayer(id, startPos, duration) {
|
||||
videoId = id;
|
||||
videoDuration = duration;
|
||||
function initPlayer(opts) {
|
||||
cfg = opts;
|
||||
currentQuality = opts.streamQuality || "hd";
|
||||
|
||||
videoEl = document.getElementById("player-video");
|
||||
progressBar = document.getElementById("player-progress-bar");
|
||||
|
|
@ -31,10 +38,11 @@ function initPlayer(id, startPos, duration) {
|
|||
|
||||
if (!videoEl) return;
|
||||
|
||||
// Stream-URL setzen (ffmpeg-Transcoding Endpoint)
|
||||
const streamUrl = `/api/library/videos/${id}/stream` +
|
||||
(startPos > 0 ? `?t=${Math.floor(startPos)}` : "");
|
||||
videoEl.src = streamUrl;
|
||||
// Video-Info laden (Audio/Subtitle-Tracks)
|
||||
loadVideoInfo().then(() => {
|
||||
// Stream starten
|
||||
setStreamUrl(opts.startPos || 0);
|
||||
});
|
||||
|
||||
// Events
|
||||
videoEl.addEventListener("timeupdate", onTimeUpdate);
|
||||
|
|
@ -43,78 +51,152 @@ function initPlayer(id, startPos, duration) {
|
|||
videoEl.addEventListener("ended", onEnded);
|
||||
videoEl.addEventListener("loadedmetadata", () => {
|
||||
if (videoEl.duration && isFinite(videoEl.duration)) {
|
||||
videoDuration = videoEl.duration;
|
||||
cfg.duration = videoEl.duration + seekOffset;
|
||||
}
|
||||
});
|
||||
|
||||
// Klick auf Video -> Play/Pause
|
||||
videoEl.addEventListener("click", togglePlay);
|
||||
|
||||
// Controls UI
|
||||
playBtn.addEventListener("click", togglePlay);
|
||||
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
|
||||
|
||||
// Progress-Bar klickbar fuer Seeking
|
||||
document.getElementById("player-progress").addEventListener("click", onProgressClick);
|
||||
|
||||
// Einstellungen-Button
|
||||
const btnSettings = document.getElementById("btn-settings");
|
||||
if (btnSettings) btnSettings.addEventListener("click", toggleOverlay);
|
||||
|
||||
// Naechste-Episode-Button
|
||||
const btnNext = document.getElementById("btn-next");
|
||||
if (btnNext) btnNext.addEventListener("click", playNextEpisode);
|
||||
|
||||
// Naechste-Episode Overlay Buttons
|
||||
const btnNextPlay = document.getElementById("btn-next-play");
|
||||
if (btnNextPlay) btnNextPlay.addEventListener("click", playNextEpisode);
|
||||
const btnNextCancel = document.getElementById("btn-next-cancel");
|
||||
if (btnNextCancel) btnNextCancel.addEventListener("click", cancelNext);
|
||||
|
||||
// Schaust du noch?
|
||||
const btnStillYes = document.getElementById("btn-still-yes");
|
||||
if (btnStillYes) btnStillYes.addEventListener("click", () => {
|
||||
document.getElementById("still-watching-overlay").style.display = "none";
|
||||
episodesWatched = 0;
|
||||
videoEl.play();
|
||||
});
|
||||
const btnStillNo = document.getElementById("btn-still-no");
|
||||
if (btnStillNo) btnStillNo.addEventListener("click", () => {
|
||||
saveProgress();
|
||||
window.history.back();
|
||||
});
|
||||
|
||||
// Tastatur-Steuerung
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
|
||||
// Maus/Touch-Bewegung -> Controls anzeigen
|
||||
document.addEventListener("mousemove", showControls);
|
||||
document.addEventListener("touchstart", showControls);
|
||||
|
||||
// Controls nach 4 Sekunden ausblenden
|
||||
scheduleHideControls();
|
||||
|
||||
// Watch-Progress alle 10 Sekunden speichern
|
||||
saveTimer = setInterval(saveProgress, 10000);
|
||||
}
|
||||
|
||||
// === Video-Info laden ===
|
||||
|
||||
async function loadVideoInfo() {
|
||||
try {
|
||||
const resp = await fetch(`/api/library/videos/${cfg.videoId}/info`);
|
||||
videoInfo = await resp.json();
|
||||
|
||||
// Bevorzugte Audio-Spur finden
|
||||
if (videoInfo.audio_tracks) {
|
||||
const prefIdx = videoInfo.audio_tracks.findIndex(
|
||||
a => a.lang === cfg.preferredAudio);
|
||||
if (prefIdx >= 0) currentAudio = prefIdx;
|
||||
}
|
||||
|
||||
// Bevorzugte Untertitel-Spur finden
|
||||
if (cfg.subtitlesEnabled && cfg.preferredSub && videoInfo.subtitle_tracks) {
|
||||
const subIdx = videoInfo.subtitle_tracks.findIndex(
|
||||
s => s.lang === cfg.preferredSub);
|
||||
if (subIdx >= 0) currentSub = subIdx;
|
||||
}
|
||||
|
||||
// Untertitel-Tracks als <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 ===
|
||||
|
||||
function togglePlay() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) {
|
||||
videoEl.play();
|
||||
} else {
|
||||
videoEl.pause();
|
||||
}
|
||||
if (videoEl.paused) videoEl.play();
|
||||
else videoEl.pause();
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
if (playBtn) playBtn.innerHTML = "❚❚"; // Pause-Symbol
|
||||
if (playBtn) playBtn.innerHTML = "❚❚";
|
||||
scheduleHideControls();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
if (playBtn) playBtn.innerHTML = "▶"; // Play-Symbol
|
||||
if (playBtn) playBtn.innerHTML = "▶";
|
||||
showControls();
|
||||
// Sofort speichern bei Pause
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
// Video fertig -> als "completed" speichern
|
||||
saveProgress(true);
|
||||
// Zurueck navigieren nach 2 Sekunden
|
||||
setTimeout(() => {
|
||||
window.history.back();
|
||||
}, 2000);
|
||||
episodesWatched++;
|
||||
|
||||
// Schaust du noch? (wenn Max-Episoden erreicht)
|
||||
if (cfg.autoplayMax > 0 && episodesWatched >= cfg.autoplayMax) {
|
||||
document.getElementById("still-watching-overlay").style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Naechste Episode
|
||||
if (cfg.nextVideoId && cfg.autoplay) {
|
||||
showNextEpisodeOverlay();
|
||||
} else {
|
||||
setTimeout(() => window.history.back(), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// === Seeking ===
|
||||
|
||||
function seekRelative(seconds) {
|
||||
if (!videoEl) return;
|
||||
const newTime = Math.max(0, Math.min(
|
||||
videoEl.currentTime + seconds,
|
||||
videoEl.duration || videoDuration
|
||||
));
|
||||
// Neue Stream-URL mit Zeitstempel
|
||||
const wasPlaying = !videoEl.paused;
|
||||
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
||||
if (wasPlaying) videoEl.play();
|
||||
const totalTime = seekOffset + videoEl.currentTime;
|
||||
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||
const newTime = Math.max(0, Math.min(totalTime + seconds, dur));
|
||||
setStreamUrl(newTime);
|
||||
showControls();
|
||||
}
|
||||
|
||||
|
|
@ -122,29 +204,22 @@ function onProgressClick(e) {
|
|||
if (!videoEl) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const dur = videoEl.duration || videoDuration;
|
||||
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||
if (!dur) return;
|
||||
const newTime = pct * dur;
|
||||
// Neue Stream-URL mit Zeitstempel
|
||||
const wasPlaying = !videoEl.paused;
|
||||
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
|
||||
if (wasPlaying) videoEl.play();
|
||||
setStreamUrl(pct * dur);
|
||||
showControls();
|
||||
}
|
||||
|
||||
// === Zeit-Anzeige und Progress ===
|
||||
// === Zeit-Anzeige ===
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!videoEl) return;
|
||||
const current = videoEl.currentTime;
|
||||
const dur = videoEl.duration || videoDuration;
|
||||
const current = seekOffset + videoEl.currentTime;
|
||||
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||
|
||||
// Progress-Bar
|
||||
if (progressBar && dur > 0) {
|
||||
progressBar.style.width = ((current / dur) * 100) + "%";
|
||||
}
|
||||
|
||||
// Zeit-Anzeige
|
||||
if (timeDisplay) {
|
||||
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
|
||||
}
|
||||
|
|
@ -155,9 +230,7 @@ function formatTime(sec) {
|
|||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
if (h > 0) {
|
||||
return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
||||
}
|
||||
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
|
||||
return m + ":" + String(s).padStart(2, "0");
|
||||
}
|
||||
|
||||
|
|
@ -171,7 +244,7 @@ function showControls() {
|
|||
}
|
||||
|
||||
function hideControls() {
|
||||
if (!videoEl || videoEl.paused) return;
|
||||
if (!videoEl || videoEl.paused || overlayOpen) return;
|
||||
const wrapper = document.getElementById("player-wrapper");
|
||||
if (wrapper) wrapper.classList.add("player-hide-controls");
|
||||
controlsVisible = false;
|
||||
|
|
@ -193,84 +266,220 @@ function toggleFullscreen() {
|
|||
}
|
||||
}
|
||||
|
||||
// === Einstellungen-Overlay ===
|
||||
|
||||
function toggleOverlay() {
|
||||
const overlay = document.getElementById("player-overlay");
|
||||
if (!overlay) return;
|
||||
overlayOpen = !overlayOpen;
|
||||
overlay.style.display = overlayOpen ? "" : "none";
|
||||
if (overlayOpen) {
|
||||
renderOverlay();
|
||||
showControls();
|
||||
}
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
// Audio-Spuren
|
||||
const audioEl = document.getElementById("overlay-audio");
|
||||
if (audioEl && videoInfo && videoInfo.audio_tracks) {
|
||||
let html = "<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 ===
|
||||
|
||||
function onKeyDown(e) {
|
||||
// Samsung Tizen Remote Keys
|
||||
const keyMap = {
|
||||
10009: "Escape",
|
||||
10182: "Escape",
|
||||
415: "Play",
|
||||
19: "Pause",
|
||||
413: "Stop",
|
||||
417: "FastForward",
|
||||
412: "Rewind",
|
||||
10009: "Escape", 10182: "Escape",
|
||||
415: "Play", 19: "Pause", 413: "Stop",
|
||||
417: "FastForward", 412: "Rewind",
|
||||
};
|
||||
const key = keyMap[e.keyCode] || e.key;
|
||||
|
||||
// Overlay offen? -> Navigation im Overlay
|
||||
if (overlayOpen && (key === "Escape" || key === "Backspace")) {
|
||||
toggleOverlay();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case " ":
|
||||
case "Enter":
|
||||
case "Play":
|
||||
case "Pause":
|
||||
togglePlay();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "Rewind":
|
||||
seekRelative(-10);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
case "FastForward":
|
||||
seekRelative(10);
|
||||
e.preventDefault();
|
||||
break;
|
||||
case " ": case "Enter": case "Play": case "Pause":
|
||||
togglePlay(); e.preventDefault(); break;
|
||||
case "ArrowLeft": case "Rewind":
|
||||
seekRelative(-10); e.preventDefault(); break;
|
||||
case "ArrowRight": case "FastForward":
|
||||
seekRelative(10); e.preventDefault(); break;
|
||||
case "ArrowUp":
|
||||
// Lautstaerke hoch (falls vom Browser unterstuetzt)
|
||||
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
|
||||
showControls();
|
||||
e.preventDefault();
|
||||
break;
|
||||
showControls(); e.preventDefault(); break;
|
||||
case "ArrowDown":
|
||||
// Lautstaerke runter
|
||||
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
|
||||
showControls();
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "Escape":
|
||||
case "Backspace":
|
||||
case "Stop":
|
||||
// Zurueck navigieren
|
||||
showControls(); e.preventDefault(); break;
|
||||
case "Escape": case "Backspace": case "Stop":
|
||||
saveProgress();
|
||||
setTimeout(() => window.history.back(), 100);
|
||||
e.preventDefault();
|
||||
break;
|
||||
e.preventDefault(); break;
|
||||
case "f":
|
||||
toggleFullscreen();
|
||||
e.preventDefault();
|
||||
break;
|
||||
toggleFullscreen(); e.preventDefault(); break;
|
||||
case "s":
|
||||
toggleOverlay(); e.preventDefault(); break;
|
||||
case "n":
|
||||
if (cfg.nextVideoId) playNextEpisode();
|
||||
e.preventDefault(); break;
|
||||
}
|
||||
}
|
||||
|
||||
// === Watch-Progress speichern ===
|
||||
|
||||
function saveProgress(completed) {
|
||||
if (!videoId || !videoEl) return;
|
||||
const pos = videoEl.currentTime || 0;
|
||||
const dur = videoEl.duration || videoDuration || 0;
|
||||
if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern
|
||||
if (!cfg.videoId || !videoEl) return;
|
||||
const pos = seekOffset + (videoEl.currentTime || 0);
|
||||
const dur = cfg.duration || (seekOffset + (videoEl.duration || 0));
|
||||
if (pos < 5 && !completed) return;
|
||||
|
||||
fetch("/tv/api/watch-progress", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
video_id: videoId,
|
||||
video_id: cfg.videoId,
|
||||
position_sec: pos,
|
||||
duration_sec: dur,
|
||||
}),
|
||||
}).catch(() => {}); // Fehler ignorieren (nicht kritisch)
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Beim Verlassen der Seite speichern
|
||||
window.addEventListener("beforeunload", () => saveProgress());
|
||||
|
||||
// === Hilfsfunktionen ===
|
||||
|
||||
const LANG_NAMES = {
|
||||
deu: "Deutsch", eng: "English", fra: "Fran\u00e7ais",
|
||||
spa: "Espa\u00f1ol", ita: "Italiano", jpn: "\u65e5\u672c\u8a9e",
|
||||
kor: "\ud55c\uad6d\uc5b4", por: "Portugu\u00eas",
|
||||
rus: "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
zho: "\u4e2d\u6587", und: "Unbekannt",
|
||||
};
|
||||
|
||||
function langName(code) {
|
||||
return LANG_NAMES[code] || code || "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<!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>
|
||||
<meta charset="UTF-8">
|
||||
<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 %}
|
||||
<nav class="tv-nav" id="tv-nav">
|
||||
<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 %}
|
||||
<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 %}
|
||||
{% 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 %}
|
||||
<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 class="tv-nav-right">
|
||||
<span class="tv-nav-user">{{ user.display_name or user.username }}</span>
|
||||
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>Abmelden</a>
|
||||
<a href="/tv/profiles" class="tv-nav-profile" data-focusable title="{{ t('profiles.switch') }}">
|
||||
<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>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@
|
|||
autocomplete="current-password"
|
||||
data-focusable required>
|
||||
</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>
|
||||
Anmelden
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -19,31 +19,140 @@
|
|||
<p class="tv-detail-overview">{{ movie.overview }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if videos %}
|
||||
<div class="tv-detail-actions">
|
||||
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
|
||||
▶ Abspielen
|
||||
</a>
|
||||
<!-- Bewertungen -->
|
||||
<div class="tv-rating-section">
|
||||
<div class="tv-rating-user">
|
||||
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
{% for v in videos %}
|
||||
<a href="/tv/player?v={{ v.id }}" class="tv-episode" data-focusable>
|
||||
<span class="tv-episode-title">{{ v.file_name }}</span>
|
||||
<span class="tv-episode-meta">
|
||||
<a href="/tv/player?v={{ v.id }}" class="tv-episode-card" data-focusable>
|
||||
<div class="tv-ep-thumb">
|
||||
<img src="/api/library/videos/{{ v.id }}/thumbnail" alt="" loading="lazy">
|
||||
<div class="tv-ep-duration">
|
||||
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
|
||||
{% if v.width %} · {{ v.width }}x{{ v.height }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<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 }}
|
||||
</span>
|
||||
<span class="tv-episode-play">▶</span>
|
||||
{% if v.video_codec %} · {{ v.video_codec }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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" %}
|
||||
{% block title %}Filme - VideoKonverter TV{% endblock %}
|
||||
{% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="tv-section">
|
||||
<h1 class="tv-page-title">Filme</h1>
|
||||
<div class="tv-grid">
|
||||
<div class="tv-list-header">
|
||||
<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 %}
|
||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||
{% if m.poster_url %}
|
||||
|
|
@ -14,13 +85,94 @@
|
|||
{% endif %}
|
||||
<div class="tv-card-info">
|
||||
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
||||
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} · {{ m.genres }}{% endif %}</span>
|
||||
<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>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<div class="tv-empty">Keine Filme vorhanden.</div>
|
||||
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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">
|
||||
<!-- Header (ausblendbar) -->
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<video id="player-video" autoplay playsinline>
|
||||
Dein Browser unterstuetzt kein HTML5-Video.
|
||||
</video>
|
||||
<video id="player-video" autoplay playsinline></video>
|
||||
|
||||
<!-- Controls (ausblendbar) -->
|
||||
<div class="player-controls" id="player-controls">
|
||||
|
|
@ -29,14 +27,70 @@
|
|||
<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-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>
|
||||
</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>
|
||||
|
||||
<script src="/static/tv/js/player.js"></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>
|
||||
</body>
|
||||
</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" %}
|
||||
{% block title %}Suche - VideoKonverter TV{% endblock %}
|
||||
{% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="tv-section">
|
||||
<h1 class="tv-page-title">Suche</h1>
|
||||
<form action="/tv/search" method="GET" class="tv-search-form">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
placeholder="Serie oder Film suchen..."
|
||||
<h1 class="tv-page-title">{{ t('search.title') }}</h1>
|
||||
<form action="/tv/search" method="GET" class="tv-search-form" autocomplete="off">
|
||||
<div class="tv-search-wrapper">
|
||||
<input type="text" name="q" id="search-input" value="{{ query }}"
|
||||
placeholder="{{ t('search.placeholder') }}"
|
||||
class="tv-search-input" data-focusable autofocus>
|
||||
<button type="submit" class="tv-search-btn" data-focusable>Suchen</button>
|
||||
<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>
|
||||
|
||||
{% if query %}
|
||||
<!-- Serien-Ergebnisse -->
|
||||
{% 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">
|
||||
{% for s in series %}
|
||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||
|
|
@ -25,6 +28,9 @@
|
|||
{% 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 %}
|
||||
|
|
@ -33,7 +39,7 @@
|
|||
|
||||
<!-- Film-Ergebnisse -->
|
||||
{% 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">
|
||||
{% for m in movies %}
|
||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||
|
|
@ -52,8 +58,76 @@
|
|||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
</section>
|
||||
{% 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" %}
|
||||
{% block title %}Serien - VideoKonverter TV{% endblock %}
|
||||
{% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="tv-section">
|
||||
<h1 class="tv-page-title">Serien</h1>
|
||||
<div class="tv-grid">
|
||||
<div class="tv-list-header">
|
||||
<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 %}
|
||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||
{% if s.poster_url %}
|
||||
|
|
@ -14,13 +85,95 @@
|
|||
{% endif %}
|
||||
<div class="tv-card-info">
|
||||
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
||||
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden{% 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>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<div class="tv-empty">Keine Serien vorhanden.</div>
|
||||
<div class="tv-empty">{{ t('series.no_series') }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% 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 %}
|
||||
<p class="tv-detail-overview">{{ series.overview }}</p>
|
||||
{% 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>
|
||||
|
||||
|
|
@ -26,7 +74,7 @@
|
|||
<button class="tv-tab {% if loop.first %}active{% endif %}"
|
||||
data-focusable
|
||||
onclick="showSeason({{ sn }})">
|
||||
{% if sn == 0 %}Specials{% else %}Staffel {{ sn }}{% endif %}
|
||||
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -36,25 +84,51 @@
|
|||
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||
<div class="tv-episode-list">
|
||||
{% for ep in episodes %}
|
||||
<a href="/tv/player?v={{ ep.id }}" class="tv-episode" data-focusable>
|
||||
<span class="tv-episode-num">
|
||||
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% else %}-{% endif %}
|
||||
<a href="/tv/player?v={{ ep.id }}" class="tv-episode-card" data-focusable>
|
||||
<!-- Thumbnail -->
|
||||
<div class="tv-ep-thumb">
|
||||
{% if ep.ep_image_url %}
|
||||
<img src="{{ ep.ep_image_url }}" alt="" loading="lazy">
|
||||
{% else %}
|
||||
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
|
||||
{% endif %}
|
||||
{% if ep.progress_pct > 0 and ep.progress_pct < 95 %}
|
||||
<div class="tv-ep-progress">
|
||||
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
|
||||
</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-episode-title">
|
||||
<span class="tv-ep-title">
|
||||
{{ ep.episode_title or ep.file_name }}
|
||||
</span>
|
||||
<span class="tv-episode-meta">
|
||||
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
||||
{% if ep.width %} · {{ ep.width }}x{{ ep.height }}{% endif %}
|
||||
</span>
|
||||
<span class="tv-episode-play">▶</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="tv-empty">Keine Episoden vorhanden.</div>
|
||||
<div class="tv-empty">{{ t('series.no_episodes') }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -72,5 +146,62 @@ function showSeason(sn) {
|
|||
// Tab aktivieren
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
|
||||
function toggleWatchlist(btn) {
|
||||
const seriesId = btn.dataset.seriesId;
|
||||
fetch('/tv/api/watchlist', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ series_id: parseInt(seriesId) }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.in_watchlist) {
|
||||
btn.classList.add('active');
|
||||
btn.querySelector('.watchlist-icon').innerHTML = '♥';
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
btn.querySelector('.watchlist-icon').innerHTML = '♡';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function setRating(value) {
|
||||
const container = document.getElementById('user-stars');
|
||||
const seriesId = container.dataset.seriesId;
|
||||
fetch('/tv/api/rating', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
// Sterne aktualisieren
|
||||
container.dataset.rating = data.user_rating;
|
||||
container.querySelectorAll('.tv-star').forEach(star => {
|
||||
const v = parseInt(star.dataset.value);
|
||||
star.classList.toggle('active', v <= data.user_rating);
|
||||
});
|
||||
// Entfernen-Button anzeigen/verstecken
|
||||
let removeBtn = container.querySelector('.tv-rating-remove');
|
||||
if (data.user_rating > 0 && !removeBtn) {
|
||||
removeBtn = document.createElement('span');
|
||||
removeBtn.className = 'tv-rating-remove';
|
||||
removeBtn.setAttribute('data-focusable', '');
|
||||
removeBtn.innerHTML = '✕';
|
||||
removeBtn.onclick = () => setRating(0);
|
||||
container.appendChild(removeBtn);
|
||||
} else if (data.user_rating === 0 && removeBtn) {
|
||||
removeBtn.remove();
|
||||
}
|
||||
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
|
||||
if (data.avg_rating !== undefined) {
|
||||
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
|
||||
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
</script>
|
||||
{% 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