feat: VideoKonverter v4.0 - Streaming-Client Ausbau

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

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

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

View file

@ -2,6 +2,191 @@
Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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),
}

View file

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

View file

@ -317,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]:

View file

@ -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

View file

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

View file

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

View file

@ -1,28 +1,35 @@
/**
* VideoKonverter TV - Video-Player
* 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 = "&#10074;&#10074;"; // Pause-Symbol
if (playBtn) playBtn.innerHTML = "&#10074;&#10074;";
scheduleHideControls();
}
function onPause() {
if (playBtn) playBtn.innerHTML = "&#9654;"; // Play-Symbol
if (playBtn) playBtn.innerHTML = "&#9654;";
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 || "";
}

View file

@ -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>&#9881;</a>
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>{{ t('nav.logout') }}</a>
</div>
</nav>
{% endif %}

View file

@ -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>

View file

@ -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>
&#9654; 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 }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
{% if videos %}
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
&#9654; {{ t('player.play') }}
</a>
{% endif %}
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-movie-id="{{ movie.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('watchlist.title') }}</span>
</button>
</div>
</div>
</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 %} &middot; {{ 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 %}
&middot; {{ v.container|upper }}
</span>
<span class="tv-episode-play">&#9654;</span>
{% if v.video_codec %} &middot; {{ 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 = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const movieId = container.dataset.movieId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: parseInt(movieId), rating: value }),
})
.then(r => r.json())
.then(data => {
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
</script>
{% endblock %}

View file

@ -1,10 +1,81 @@
{% extends "tv/base.html" %}
{% 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) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>{{ t('filter.sort_newest') }} (Jahr)</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for m in movies %}
<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 %} &middot; {{ m.genres }}{% endif %}</span>
<span class="tv-card-meta">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div>
</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 %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ m.genres or '' }}</span>
<span class="tv-list-count">{{ m.year or '' }}</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable>
<div class="tv-detail-thumb">
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ m.title or m.folder_name }}</span>
{% if m.overview %}
<p class="tv-detail-desc">{{ m.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ m.avg_rating }}</span> &middot; {% endif %}
{% if m.year %}{{ m.year }}{% endif %}
{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
{% if not movies %}
<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 %}

View file

@ -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>&#10094; Zurueck</a>
<a href="javascript:history.back()" class="player-back" data-focusable>&#10094; {{ 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>&#9654;</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') }}">&#9881;</button>
{% if next_video %}
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">&#9197;</button>
{% endif %}
<button class="player-btn" id="btn-fullscreen" data-focusable>&#9974;</button>
</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>

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f0f0f">
<link rel="stylesheet" href="/static/tv/css/tv.css">
<title>{{ t('profiles.title') }} - VideoKonverter TV</title>
</head>
<body class="login-body">
<div class="profiles-container">
<h1 class="profiles-title">{{ t('profiles.title') }}</h1>
<div class="profiles-grid">
{% for p in profiles %}
<form method="post" action="/tv/switch-profile" class="profile-form">
<input type="hidden" name="session_id" value="{{ p.session_id }}">
<button type="submit" class="profile-card {% if p.session_id == current_session %}profile-active{% endif %}" data-focusable>
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
{{ (p.display_name or p.username)[:1]|upper }}
</span>
<span class="profile-name">{{ p.display_name or p.username }}</span>
</button>
</form>
{% endfor %}
<!-- Anderer Benutzer -->
<a href="/tv/login" class="profile-card profile-add" data-focusable>
<span class="tv-avatar tv-avatar-lg profile-add-icon">+</span>
<span class="profile-name">{{ t('profiles.add_user') }}</span>
</a>
</div>
</div>
<script src="/static/tv/js/tv.js"></script>
</body>
</html>

View file

@ -1,20 +1,23 @@
{% extends "tv/base.html" %}
{% 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 &laquo;{{ query }}&raquo;</div>
<div class="tv-empty">{{ t('search.no_results', query=query) }}</div>
{% endif %}
{% else %}
<!-- Such-History -->
{% if history %}
<div class="tv-search-history">
<div class="tv-search-history-header">
<h2 class="tv-section-title">{{ t('search.history') }}</h2>
<button class="tv-link-btn" onclick="clearHistory()" data-focusable>{{ t('search.clear_history') }}</button>
</div>
<div class="tv-search-history-list">
{% for h in history %}
<a href="/tv/search?q={{ h.query }}" class="tv-search-history-item" data-focusable>
<span class="tv-search-history-icon">&#128269;</span>
{{ h.query }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</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 %}

View file

@ -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) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="episodes" {% if current_sort == 'episodes' %}selected{% endif %}>{{ t('filter.sort_episodes') }}</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for s in series %}
<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 %} &middot; {{ s.genres }}{% endif %}</span>
<span class="tv-card-meta">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}{% if s.genres %} &middot; {{ s.genres }}{% endif %}
</span>
</div>
</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 %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ s.genres or '' }}</span>
<span class="tv-list-count">{{ s.episode_count or 0 }} Ep.</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable>
<div class="tv-detail-thumb">
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ s.title or s.folder_name }}</span>
{% if s.overview %}
<p class="tv-detail-desc">{{ s.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ s.avg_rating }}</span> &middot; {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
{% if s.genres %} &middot; {{ s.genres }}{% endif %}
{% if s.status %} &middot; {{ s.status }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
{% if not series %}
<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 %}

View file

@ -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 }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
<!-- Durchschnitt -->
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
<!-- TVDB-Score -->
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-series-id="{{ series.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
</button>
</div>
</div>
</div>
@ -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">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
</div>
</div>
<!-- Info -->
<div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-num">
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}
</span>
<span class="tv-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 %} &middot; {{ ep.width }}x{{ ep.height }}{% endif %}
</span>
<span class="tv-episode-play">&#9654;</span>
</div>
{% if ep.ep_overview %}
<p class="tv-ep-desc">{{ ep.ep_overview }}</p>
{% endif %}
<div class="tv-ep-meta">
{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %}
{% if ep.video_codec %} &middot; {{ ep.video_codec }}{% endif %}
</div>
</div>
</a>
{% 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 = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const seriesId = container.dataset.seriesId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
})
.then(r => r.json())
.then(data => {
// Sterne aktualisieren
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
// Entfernen-Button anzeigen/verstecken
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
</script>
{% endblock %}

View file

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

View file

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