docker.videokonverter/video-konverter/app/routes/tv_api.py
data 0d1619c6c9 feat: VideoKonverter v5.1 - TV-App UX-Verbesserungen, PWA-Fix, Library-Features
- PWA Cookie-Fix: SameSite/Secure je nach Protokoll (HTTP=Lax, HTTPS=None+Secure)
- Samsung Fernbedienung: Media-Key-Registrierung, Return/Back navigiert zurueck
- Post-Play Navigation: Countdown auf naechster Episode nach Wiedergabe-Ende
- Gelbe Staffel-Tabs: Gold-Farbe wenn alle Episoden gesehen
- Episoden Card-Grid: Plex-Style Thumbnail-Grid mit Detail-Panel bei Focus
- Weiche Uebergaenge: Fade-In/Out Animationen fuer Player und Seitenwechsel
- Codec-Badge: AV1/HEVC Badge in Videobibliothek bei komplett konvertierten Serien
- Separate Import-Fortschrittsbalken: Pro Import-Job eigener Balken
- Android APK signiert (v2+v3 Scheme)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:09:28 +01:00

1808 lines
73 KiB
Python

"""TV-App Routes - Seiten und API fuer Streaming-Frontend"""
import asyncio
import io
import json
import logging
import secrets
import time
from functools import wraps
from aiohttp import web
import aiohttp_jinja2
import aiomysql
from app.config import Config
from app.services.auth import AuthService
from app.services.library import LibraryService
from app.services.hls import HLSSessionManager
from app.services.i18n import set_request_lang, get_all_translations
def setup_tv_routes(app: web.Application, config: Config,
auth_service: AuthService,
library_service: LibraryService,
hls_manager: HLSSessionManager = None) -> None:
"""Registriert alle TV-App Routes"""
# --- Poster-URL Lokalisierung ---
# TVDB-URLs durch lokalen Endpunkt ersetzen (schnelleres Laden)
_POSTER_WIDTH = 300 # Max. Breite fuer Poster-Thumbnails
def _localize_posters(rows, content_type="series"):
"""poster_url durch lokalen Resize-Endpunkt ersetzen.
Vermeidet externe TVDB-Requests, Bilder werden lokal gecacht."""
for row in rows:
if content_type == "series":
row["poster_url"] = (
f"/api/library/metadata/{row['id']}"
f"/poster.jpg?w={_POSTER_WIDTH}"
)
# Filme: noch keine lokale Metadaten -> URL beibehalten
# --- Stream-Tokens (fuer AVPlay / Native Player ohne Cookie-Jar) ---
# Token -> {video_id, user_id, expires}
_stream_tokens: dict[str, dict] = {}
_TOKEN_LIFETIME = 3600 # 1 Stunde
def _cleanup_tokens():
"""Abgelaufene Tokens entfernen"""
now = time.time()
expired = [k for k, v in _stream_tokens.items() if v["expires"] < now]
for k in expired:
del _stream_tokens[k]
def _create_stream_token(video_id: int, user_id: int) -> str:
"""Temporaeren Stream-Token erstellen"""
_cleanup_tokens()
token = secrets.token_urlsafe(32)
_stream_tokens[token] = {
"video_id": video_id,
"user_id": user_id,
"expires": time.time() + _TOKEN_LIFETIME,
}
return token
def _validate_stream_token(token: str, video_id: int) -> bool:
"""Prueft ob Token gueltig ist fuer das angegebene Video"""
info = _stream_tokens.get(token)
if not info:
return False
if info["expires"] < time.time():
del _stream_tokens[token]
return False
if info["video_id"] != video_id:
return False
return True
# --- Auth-Hilfsfunktionen ---
def _cookie_params(request: web.Request) -> dict:
"""SameSite-Parameter je nach Protokoll: None+Secure bei HTTPS, Lax bei HTTP.
Browser verwerfen SameSite=None ohne Secure-Flag stillschweigend."""
is_https = (request.secure
or request.headers.get("X-Forwarded-Proto") == "https")
if is_https:
return {"samesite": "None", "secure": True}
return {"samesite": "Lax"}
async def get_tv_user(request: web.Request) -> dict | None:
"""Prueft Session-Cookie, gibt User zurueck oder None"""
session_id = request.cookies.get("vk_session")
if not session_id:
return None
return await auth_service.validate_session(session_id)
def require_auth(handler):
"""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
# --- Login / Logout ---
async def get_login(request: web.Request) -> web.Response:
"""GET /tv/login - Login-Seite.
Wenn bereits eingeloggt -> weiter.
Wenn Profile auf dem Geraet -> Profilauswahl.
Wenn genau ein Profil -> direkt einloggen."""
user = await get_tv_user(request)
if user:
raise web.HTTPFound("/tv/")
# Pruefen ob Profile auf diesem Client existieren
client_id = request.cookies.get("vk_client_id")
if client_id:
profiles = await auth_service.get_client_profiles(client_id)
if len(profiles) == 1:
# Nur ein Profil -> direkt Session wechseln
session_id = profiles[0]["session_id"]
check_user = await auth_service.validate_session(session_id)
if check_user:
resp = web.HTTPFound("/tv/")
resp.set_cookie(
"vk_session", session_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, path="/",
**_cookie_params(request),
)
return resp
elif len(profiles) > 1:
# Mehrere Profile -> Profilauswahl
raise web.HTTPFound("/tv/profiles")
return aiohttp_jinja2.render_template(
"tv/login.html", request, {"error": None}
)
async def post_login(request: web.Request) -> web.Response:
"""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(
"tv/login.html", request,
{"error": "Benutzername und Passwort eingeben"}
)
user = await auth_service.verify_login(username, password)
if not user:
return aiohttp_jinja2.render_template(
"tv/login.html", request,
{"error": "Falscher Benutzername oder Passwort"}
)
# 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, 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=max_age,
httponly=True, path="/",
**_cookie_params(request),
)
# Client-ID Cookie (immer permanent)
resp.set_cookie(
"vk_client_id", client_id,
max_age=10 * 365 * 24 * 3600, # 10 Jahre
httponly=True, path="/",
**_cookie_params(request),
)
return resp
async def get_logout(request: web.Request) -> web.Response:
"""GET /tv/logout - Session loeschen"""
session_id = request.cookies.get("vk_session")
if session_id:
await auth_service.delete_session(session_id)
resp = web.HTTPFound("/tv/login")
resp.del_cookie("vk_session", path="/")
return resp
# --- TV-Seiten ---
@require_auth
async def get_home(request: web.Request) -> web.Response:
"""GET /tv/ - Startseite mit konfigurierbaren Rubriken"""
user = request["tv_user"]
uid = user["id"]
# User-Einstellungen fuer Startseite
show_continue = user.get("home_show_continue", 1)
show_new = user.get("home_show_new", 1)
hide_watched = user.get("home_hide_watched", 1)
show_watched = user.get("home_show_watched", 1)
# Daten-Container
continue_watching = []
new_series = []
new_movies = []
series = []
movies = []
watched_series = []
watched_movies = []
# Berechtigungs-WHERE fuer Pfad-Einschraenkung
def _path_filter(alias: str, paths: list):
if paths:
ph = ",".join(["%s"] * len(paths))
return f" AND {alias}.library_path_id IN ({ph})", list(paths)
return "", []
pool = library_service._db_pool
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# --- Serien ---
if user.get("can_view_series"):
path_sql, path_params = _path_filter(
"s", user.get("allowed_paths"))
# Neu hinzugefuegt (letzte 10, nach Scan-Datum)
if show_new:
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url, s.last_updated,
COUNT(v.id) as episode_count
FROM library_series s
LEFT JOIN library_videos v
ON v.series_id = s.id
WHERE s.last_updated IS NOT NULL
{path_sql}
GROUP BY s.id
ORDER BY s.last_updated DESC
LIMIT 10
""", path_params)
new_series = await cur.fetchall()
# Serien (ohne gesehene, falls aktiviert)
watched_join = ""
watched_where = ""
if hide_watched:
watched_join = (
" LEFT JOIN tv_watch_status ws"
" ON ws.series_id = s.id"
f" AND ws.user_id = {int(uid)}"
" AND ws.status = 'watched'"
)
watched_where = " AND ws.id IS NULL"
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url, s.genres, s.tvdb_id,
COUNT(v.id) as episode_count
FROM library_series s
LEFT JOIN library_videos v
ON v.series_id = s.id
{watched_join}
WHERE 1=1 {path_sql} {watched_where}
GROUP BY s.id
ORDER BY s.title
LIMIT 20
""", path_params)
series = await cur.fetchall()
# Schon gesehen
if show_watched:
await cur.execute(f"""
SELECT s.id, s.title, s.folder_name,
s.poster_url,
COUNT(v.id) as episode_count
FROM library_series s
LEFT JOIN library_videos v
ON v.series_id = s.id
INNER JOIN tv_watch_status ws
ON ws.series_id = s.id
AND ws.user_id = %s
AND ws.status = 'watched'
WHERE 1=1 {path_sql}
GROUP BY s.id
ORDER BY ws.updated_at DESC
LIMIT 20
""", [uid] + path_params)
watched_series = await cur.fetchall()
# --- Filme ---
if user.get("can_view_movies"):
path_sql, path_params = _path_filter(
"m", user.get("allowed_paths"))
# Neu hinzugefuegt
if show_new:
await cur.execute(f"""
SELECT m.id, m.title, m.folder_name,
m.poster_url, m.year, m.genres,
m.last_updated
FROM library_movies m
WHERE m.last_updated IS NOT NULL
{path_sql}
ORDER BY m.last_updated DESC
LIMIT 10
""", path_params)
new_movies = await cur.fetchall()
# Filme (ohne gesehene, falls aktiviert)
# Filme gelten als gesehen wenn watch_progress
# completed = 1
watched_join = ""
watched_where = ""
if hide_watched:
watched_join = f"""
LEFT JOIN (
SELECT DISTINCT v2.movie_id
FROM tv_watch_progress wp
JOIN library_videos v2
ON wp.video_id = v2.id
WHERE wp.user_id = {int(uid)}
AND wp.completed = 1
AND v2.movie_id IS NOT NULL
) wm ON wm.movie_id = m.id
"""
watched_where = " AND wm.movie_id IS NULL"
await cur.execute(f"""
SELECT m.id, m.title, m.folder_name,
m.poster_url, m.year, m.genres
FROM library_movies m
{watched_join}
WHERE 1=1 {path_sql} {watched_where}
ORDER BY m.title
LIMIT 20
""", path_params)
movies = await cur.fetchall()
# Schon gesehen (Filme)
if show_watched:
await cur.execute(f"""
SELECT DISTINCT m.id, m.title,
m.folder_name, m.poster_url,
m.year, m.genres
FROM library_movies m
JOIN library_videos v ON v.movie_id = m.id
JOIN tv_watch_progress wp
ON wp.video_id = v.id
AND wp.user_id = %s
AND wp.completed = 1
WHERE 1=1 {path_sql}
ORDER BY wp.updated_at DESC
LIMIT 20
""", [uid] + path_params)
watched_movies = await cur.fetchall()
# Poster-URLs lokalisieren
for lst in (series, new_series, watched_series):
_localize_posters(lst, "series")
# Weiterschauen
if show_continue:
continue_watching = await auth_service.get_continue_watching(
uid)
return aiohttp_jinja2.render_template(
"tv/home.html", request, {
"user": user,
"active": "home",
"continue_watching": continue_watching,
"new_series": new_series,
"new_movies": new_movies,
"series": series,
"movies": movies,
"watched_series": watched_series,
"watched_movies": watched_movies,
}
)
@require_auth
async def get_series_list(request: web.Request) -> web.Response:
"""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, 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"):
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)
# Poster-URLs lokalisieren (kein TVDB-Laden)
_localize_posters(series, "series")
# Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername)
folder_data = []
src_map = {str(src["id"]): src["name"] for src in sources}
if source_filter:
# Nur gefilterte Quelle
items = sorted(
[s for s in series
if str(s.get("library_path_id")) == source_filter],
key=lambda x: (x.get("folder_name") or "").lower()
)
src_name = src_map.get(source_filter, "")
if items:
folder_data.append({"name": src_name, "entries": items})
else:
for src in sources:
items = sorted(
[s for s in series
if s.get("library_path_id") == src["id"]],
key=lambda x: (x.get("folder_name") or "").lower()
)
if items:
folder_data.append({
"name": src["name"], "entries": items})
# Serien ohne Quelle (Fallback)
src_ids = {src["id"] for src in sources}
orphans = sorted(
[s for s in series
if s.get("library_path_id") not in src_ids],
key=lambda x: (x.get("folder_name") or "").lower()
)
if orphans:
folder_data.append({"name": "Sonstige", "entries": orphans})
return aiohttp_jinja2.render_template(
"tv/series.html", request, {
"user": user,
"active": "series",
"series": series,
"view": user.get("series_view") or "grid",
"sources": sources,
"folder_data": folder_data,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
}
)
@require_auth
async def get_series_detail(request: web.Request) -> web.Response:
"""GET /tv/series/{id} - Serien-Detail mit Staffeln"""
user = request["tv_user"]
if not user.get("can_view_series"):
raise web.HTTPFound("/tv/")
series_id = int(request.match_info["id"])
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, 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 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,
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)
# Duplikat-Episoden markieren (gleiche Episodennummer)
for sn, eps in seasons.items():
ep_count = {}
for ep in eps:
en = ep.get("episode_number")
if en is not None:
ep_count[en] = ep_count.get(en, 0) + 1
for ep in eps:
en = ep.get("episode_number")
ep["is_duplicate"] = (
en is not None and ep_count.get(en, 0) > 1
)
# 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")
# Poster-URL lokalisieren
_localize_posters([series], "series")
# Watch-Threshold aus Config (Standard: 90%, wie Plex)
watched_threshold = config.tv_config.get("watched_threshold_pct", 90)
# Pro Staffel: Anzahl gesamt und gesehen berechnen
season_watched = {}
for sn, eps in seasons.items():
total = len(eps)
seen = sum(1 for ep in eps
if ep.get("progress_pct", 0) >= watched_threshold)
season_watched[sn] = {
"total": total, "seen": seen,
"all_seen": total > 0 and seen == total,
}
# Post-Play Parameter (vom Player nach Episoden-Ende)
post_play = request.query.get("post_play") == "1"
next_video_id = request.query.get("next_video", "")
countdown = int(request.query.get("countdown", "10") or "10")
last_watched_id = request.query.get("last_watched", "")
return aiohttp_jinja2.render_template(
"tv/series_detail.html", request, {
"user": user,
"active": "series",
"series": series,
"seasons": dict(sorted(seasons.items())),
"season_watched": season_watched,
"in_watchlist": in_watchlist,
"user_rating": user_rating,
"avg_rating": avg_rating,
"tvdb_score": series.get("tvdb_score"),
"watched_threshold_pct": watched_threshold,
"post_play": post_play,
"next_video_id": next_video_id,
"countdown": countdown,
"last_watched_id": last_watched_id,
}
)
@require_auth
async def get_movies_list(request: web.Request) -> web.Response:
"""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.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"):
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)
# Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername)
folder_data = []
src_map = {str(src["id"]): src["name"] for src in sources}
if source_filter:
items = sorted(
[m for m in movies
if str(m.get("library_path_id")) == source_filter],
key=lambda x: (x.get("folder_name") or "").lower()
)
src_name = src_map.get(source_filter, "")
if items:
folder_data.append({"name": src_name, "entries": items})
else:
for src in sources:
items = sorted(
[m for m in movies
if m.get("library_path_id") == src["id"]],
key=lambda x: (x.get("folder_name") or "").lower()
)
if items:
folder_data.append({
"name": src["name"], "entries": items})
# Filme ohne Quelle (Fallback)
src_ids = {src["id"] for src in sources}
orphans = sorted(
[m for m in movies
if m.get("library_path_id") not in src_ids],
key=lambda x: (x.get("folder_name") or "").lower()
)
if orphans:
folder_data.append({"name": "Sonstige", "entries": orphans})
return aiohttp_jinja2.render_template(
"tv/movies.html", request, {
"user": user,
"active": "movies",
"movies": movies,
"view": user.get("movies_view") or "grid",
"sources": sources,
"folder_data": folder_data,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
}
)
@require_auth
async def get_movie_detail(request: web.Request) -> web.Response:
"""GET /tv/movies/{id} - Film-Detail"""
user = request["tv_user"]
if not user.get("can_view_movies"):
raise web.HTTPFound("/tv/")
movie_id = int(request.match_info["id"])
movie = None
videos = []
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,
year, overview, genres, tvdb_score
FROM library_movies WHERE id = %s
""", (movie_id,))
movie = await cur.fetchone()
if movie:
await cur.execute("""
SELECT id, file_name, duration_sec, file_size,
width, height, video_codec,
container
FROM library_videos WHERE movie_id = %s
""", (movie_id,))
videos = await cur.fetchall()
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
Laedt Video-Info, naechste Episode und Client-Einstellungen."""
user = request["tv_user"]
video_id = int(request.query.get("v", 0))
if not video_id:
raise web.HTTPFound("/tv/")
# Wiedergabe-Position laden
progress = await auth_service.get_progress(user["id"], video_id)
start_pos = 0
if progress and not progress.get("completed"):
start_pos = progress.get("position_sec", 0)
# 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
FROM library_videos v
LEFT JOIN library_series s ON v.series_id = s.id
WHERE v.id = %s
""", (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/")
# Titel zusammenbauen
title = video.get("file_name", "Video")
if video.get("series_title"):
sn = video.get("season_number", 0)
en = video.get("episode_number", 0)
ep_title = video.get("episode_title", "")
title = f"{video['series_title']} - S{sn:02d}E{en:02d}"
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)
# URL zur Seriendetail-Seite (fuer Post-Play Navigation)
series_detail_url = ""
if video.get("series_id"):
series_detail_url = f"/tv/series/{video['series_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,
"series_detail_url": series_detail_url,
"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 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
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
search_term = f"%{query}%"
if user.get("can_view_series"):
await cur.execute("""
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
""", (search_term, search_term))
results_series = await cur.fetchall()
if user.get("can_view_movies"):
await cur.execute("""
SELECT id, title, folder_name, poster_url,
year, genres
FROM library_movies
WHERE title LIKE %s OR folder_name LIKE %s
ORDER BY title LIMIT 50
""", (search_term, search_term))
results_movies = await cur.fetchall()
# Poster-URLs lokalisieren
_localize_posters(results_series, "series")
# 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,
"active": "search",
"query": query,
"series": results_series,
"movies": results_movies,
"history": history,
}
)
# --- TV-API Endpoints ---
@require_auth
async def post_watch_progress(request: web.Request) -> web.Response:
"""POST /tv/api/watch-progress - Position speichern"""
user = request["tv_user"]
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
video_id = data.get("video_id")
position = data.get("position_sec", 0)
duration = data.get("duration_sec", 0)
completed = data.get("completed", False)
if not video_id:
return web.json_response(
{"error": "video_id fehlt"}, status=400)
# Bei explizitem completed-Flag: Position = Duration setzen
if completed and duration > 0:
position = duration
await auth_service.save_progress(
user["id"], video_id, position, duration
)
# Bei completed: Episode automatisch als "watched" markieren
if completed:
await auth_service.set_watch_status(
user["id"], "watched", video_id=video_id
)
return web.json_response({"ok": True})
@require_auth
async def get_watch_progress(request: web.Request) -> web.Response:
"""GET /tv/api/watch-progress/{video_id}"""
user = request["tv_user"]
video_id = int(request.match_info["video_id"])
progress = await auth_service.get_progress(user["id"], video_id)
return web.json_response(progress or {"position_sec": 0})
# --- QR-Code ---
async def get_qrcode(request: web.Request) -> web.Response:
"""GET /api/tv/qrcode - QR-Code als PNG"""
try:
import qrcode
except ImportError:
return web.json_response(
{"error": "qrcode nicht installiert"}, status=500)
# URL ermitteln
srv = config.server_config
ext_url = srv.get("external_url", "")
if ext_url:
proto = "https" if srv.get("use_https") else "http"
base = f"{proto}://{ext_url}"
else:
base = f"http://{request.host}"
tv_url = f"{base}/tv/"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(tv_url)
qr.make(fit=True)
img = qr.make_image(fill_color="white", back_color="#0f0f0f")
buf = io.BytesIO()
img.save(buf, format="PNG")
return web.Response(
body=buf.getvalue(), content_type="image/png"
)
async def get_tv_url(request: web.Request) -> web.Response:
"""GET /api/tv/url - TV-App URL als JSON"""
srv = config.server_config
ext_url = srv.get("external_url", "")
if ext_url:
proto = "https" if srv.get("use_https") else "http"
base = f"{proto}://{ext_url}"
else:
base = f"http://{request.host}"
return web.json_response({"url": f"{base}/tv/"})
# --- User-Verwaltung (fuer Admin-UI) ---
async def get_users(request: web.Request) -> web.Response:
"""GET /api/tv/users - Alle User auflisten"""
users = await auth_service.list_users()
return web.json_response({"users": users})
async def post_user(request: web.Request) -> web.Response:
"""POST /api/tv/users - Neuen User erstellen"""
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return web.json_response(
{"error": "Username und Passwort noetig"}, status=400)
user_id = await auth_service.create_user(
username=username,
password=password,
display_name=data.get("display_name", ""),
is_admin=data.get("is_admin", False),
can_view_series=data.get("can_view_series", True),
can_view_movies=data.get("can_view_movies", True),
show_tech_info=data.get("show_tech_info", False),
allowed_paths=data.get("allowed_paths"),
)
if not user_id:
return web.json_response(
{"error": "User konnte nicht erstellt werden "
"(Name bereits vergeben?)"}, status=400)
return web.json_response({"id": user_id, "message": "User erstellt"})
async def put_user(request: web.Request) -> web.Response:
"""PUT /api/tv/users/{id} - User aendern"""
user_id = int(request.match_info["id"])
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
success = await auth_service.update_user(user_id, **data)
if success:
return web.json_response({"message": "User aktualisiert"})
return web.json_response(
{"error": "Aktualisierung fehlgeschlagen"}, status=400)
async def delete_user(request: web.Request) -> web.Response:
"""DELETE /api/tv/users/{id} - User loeschen"""
user_id = int(request.match_info["id"])
success = await auth_service.delete_user(user_id)
if success:
return web.json_response({"message": "User geloescht"})
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?)
Zeigt alle User an. Aktuelle Session wird hervorgehoben."""
client_id = request.cookies.get("vk_client_id")
current_session = request.cookies.get("vk_session")
current_user_id = None
if current_session:
user = await auth_service.validate_session(current_session)
if user:
current_user_id = user.get("id")
# Alle User laden (nicht nur die mit Sessions auf diesem Client)
all_users = await auth_service.get_all_users()
return aiohttp_jinja2.render_template(
"tv/profiles.html", request, {
"profiles": all_users,
"current_user_id": current_user_id,
}
)
async def post_switch_profile(request: web.Request) -> web.Response:
"""POST /tv/switch-profile - Auf anderen User wechseln.
Erstellt neue Session fuer den gewaehlten User."""
data = await request.post()
user_id = data.get("user_id", "")
if not user_id:
raise web.HTTPFound("/tv/profiles")
# Client-ID ermitteln/erstellen
client_id = request.cookies.get("vk_client_id")
client_id = await auth_service.get_or_create_client(client_id)
# Neue Session fuer den User erstellen
ua = request.headers.get("User-Agent", "")
session_id = await auth_service.create_session(
int(user_id), ua, client_id=client_id, persistent=True
)
if not session_id:
raise web.HTTPFound("/tv/login")
resp = web.HTTPFound("/tv/")
resp.set_cookie(
"vk_session", session_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, path="/",
**_cookie_params(request),
)
resp.set_cookie(
"vk_client_id", client_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, path="/",
**_cookie_params(request),
)
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),
"home_show_continue": lambda v: v == "on",
"home_show_new": lambda v: v == "on",
"home_hide_watched": lambda v: v == "on",
"home_show_watched": lambda v: v == "on",
}
# Checkbox-Felder: Wenn nicht in data -> False (bei Full-Form)
checkbox_fields = {
"subtitles_enabled", "autoplay_enabled",
"home_show_continue", "home_show_new",
"home_hide_watched", "home_show_watched",
}
for key, transform in field_map.items():
if key in data:
user_kwargs[key] = transform(data[key])
elif not is_ajax and key in checkbox_fields:
user_kwargs[key] = False
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"])
# Poster-URLs lokalisieren
_localize_posters(wl["series"], "series")
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))
# --- Direct-Stream API (fuer AVPlay / native Wiedergabe) ---
async def get_direct_stream(request: web.Request) -> web.Response:
"""GET /tv/api/direct-stream/{video_id}
Liefert Videodatei direkt als FileResponse (kein Transcoding).
Unterstuetzt HTTP Range Requests fuer Seeking.
Auth: Cookie ODER ?token=xxx (fuer AVPlay ohne Cookie-Jar)."""
video_id = int(request.match_info["video_id"])
# Auth pruefen: Cookie oder Stream-Token
user = await get_tv_user(request)
if not user:
# Fallback: Stream-Token pruefen
token = request.query.get("token")
if not token or not _validate_stream_token(token, video_id):
return web.json_response(
{"error": "Nicht autorisiert"}, status=401)
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 FROM library_videos WHERE id = %s",
(video_id,))
row = await cur.fetchone()
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
if not row or not row.get("file_path"):
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
import os
file_path = row["file_path"]
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404)
return web.FileResponse(file_path)
@require_auth
async def post_stream_token(request: web.Request) -> web.Response:
"""POST /tv/api/stream-token
Erstellt temporaeren Token fuer Direct-Stream (AVPlay).
Body: { video_id: int }"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"error": "Ungueltiges JSON"}, status=400)
video_id = int(data.get("video_id", 0))
if not video_id:
return web.json_response(
{"error": "video_id fehlt"}, status=400)
user = request["tv_user"]
token = _create_stream_token(video_id, user["id"])
return web.json_response({"token": token})
@require_auth
async def get_video_info_tv(request: web.Request) -> web.Response:
"""GET /tv/api/video-info/{video_id}
Erweiterte Video-Infos inkl. Direct-Play-Kompatibilitaet."""
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, file_path, 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"
)
]
# Direct-Play-Infos hinzufuegen
video["direct_play_url"] = f"/tv/api/direct-stream/{video_id}"
# Audio-Codecs extrahieren (fuer Client-seitige Kompatibilitaetspruefung)
audio_codecs = []
for track in video.get("audio_tracks", []):
codec = track.get("codec", "").lower()
if codec and codec not in audio_codecs:
audio_codecs.append(codec)
video["audio_codecs"] = audio_codecs
# Video-Codec normalisieren
vc = (video.get("video_codec") or "").lower()
codec_map = {
"h264": "h264", "avc": "h264", "avc1": "h264",
"hevc": "hevc", "h265": "hevc", "hev1": "hevc",
"av1": "av1", "av01": "av1",
"vp9": "vp9", "vp09": "vp9",
}
video["video_codec_normalized"] = codec_map.get(vc, vc)
# Dateigroesse fuer Puffer-Abschaetzung (ExoPlayer)
file_path = video.get("file_path")
try:
import os
video["file_size"] = os.path.getsize(file_path) if file_path and os.path.isfile(file_path) else 0
except Exception:
video["file_size"] = 0
# file_path nicht an den Client senden
video.pop("file_path", None)
return web.json_response(video)
# --- HLS Streaming API ---
@require_auth
async def post_hls_start(request: web.Request) -> web.Response:
"""POST /tv/api/hls/start - HLS-Session starten
Body: { video_id, quality?, audio?, sound?, t? }"""
if not hls_manager:
return web.json_response(
{"error": "HLS nicht verfuegbar"}, status=503)
try:
data = await request.json()
except Exception:
return web.json_response(
{"error": "Ungueltiges JSON"}, status=400)
video_id = int(data.get("video_id", 0))
if not video_id:
return web.json_response(
{"error": "video_id erforderlich"}, status=400)
quality = data.get("quality", "hd")
audio_idx = int(data.get("audio", 0))
sound_mode = data.get("sound", "stereo")
seek_sec = float(data.get("t", 0))
client_codecs = data.get("codecs") # z.B. ["h264", "av1", "vp9"]
session = await hls_manager.create_session(
video_id, quality, audio_idx, sound_mode, seek_sec,
client_codecs=client_codecs)
if not session:
return web.json_response(
{"error": "Session konnte nicht erstellt werden"}, status=500)
if session.error:
return web.json_response(
{"error": f"ffmpeg Fehler: {session.error[:200]}"}, status=500)
return web.json_response({
"session_id": session.session_id,
"playlist_url": f"/tv/api/hls/{session.session_id}/playlist.m3u8",
"ready": session.ready,
})
async def get_hls_playlist(request: web.Request) -> web.Response:
"""GET /tv/api/hls/{session_id}/playlist.m3u8 - HLS Manifest"""
if not hls_manager:
return web.Response(status=503)
session_id = request.match_info["session_id"]
session = hls_manager.get_session(session_id)
if not session:
return web.Response(status=404, text="Session nicht gefunden")
if not session.playlist_path.exists():
# Playlist noch nicht bereit - leere HLS-Playlist zurueckgeben
# hls.js und natives HLS laden sie automatisch erneut
return web.Response(
text="#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n",
content_type="application/vnd.apple.mpegurl",
headers={
"Cache-Control": "no-cache, no-store",
"Access-Control-Allow-Origin": "*",
})
return web.FileResponse(
session.playlist_path,
headers={
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-cache, no-store",
"Access-Control-Allow-Origin": "*",
})
async def get_hls_segment(request: web.Request) -> web.Response:
"""GET /tv/api/hls/{session_id}/{segment} - HLS Segment (.m4s/.mp4)"""
if not hls_manager:
return web.Response(status=503)
session_id = request.match_info["session_id"]
segment = request.match_info["segment"]
session = hls_manager.get_session(session_id)
if not session:
return web.Response(status=404, text="Session nicht gefunden")
# Path-Traversal verhindern (z.B. ../../etc/passwd)
seg_path = (session.dir / segment).resolve()
if not str(seg_path).startswith(str(session.dir.resolve())):
return web.Response(status=403, text="Zugriff verweigert")
# Warten bis Segment existiert und fertig geschrieben ist
# (ffmpeg schreibt Segmente inkrementell - bei AV1 copy bis 34 MB)
for _ in range(50): # max 5 Sekunden warten (AV1-Segmente sind gross)
if seg_path.exists() and seg_path.stat().st_size > 0:
# Kurz warten und nochmal pruefen ob Dateigroesse stabil
size1 = seg_path.stat().st_size
await asyncio.sleep(0.15)
if seg_path.exists() and seg_path.stat().st_size == size1:
break # Segment fertig geschrieben
await asyncio.sleep(0.1)
else:
if not seg_path.exists():
return web.Response(status=404,
text="Segment nicht verfuegbar")
# Content-Type je nach Dateiendung
if segment.endswith(".mp4") or segment.endswith(".m4s"):
content_type = "video/mp4"
elif segment.endswith(".ts"):
content_type = "video/mp2t"
else:
content_type = "application/octet-stream"
return web.FileResponse(
seg_path,
headers={
"Content-Type": content_type,
"Cache-Control": "public, max-age=86400, immutable",
"Access-Control-Allow-Origin": "*",
})
@require_auth
async def delete_hls_session(request: web.Request) -> web.Response:
"""DELETE /tv/api/hls/{session_id} - Session beenden"""
if not hls_manager:
return web.json_response({"error": "HLS nicht verfuegbar"},
status=503)
session_id = request.match_info["session_id"]
await hls_manager.destroy_session(session_id)
return web.json_response({"success": True})
async def post_hls_stop(request: web.Request) -> web.Response:
"""POST /tv/api/hls/{session_id}/stop - Session beenden (fuer sendBeacon)"""
if not hls_manager:
return web.Response(status=204)
session_id = request.match_info["session_id"]
await hls_manager.destroy_session(session_id)
return web.Response(status=204)
# --- HLS Admin-API (fuer TV Admin-Center) ---
async def get_hls_sessions(request: web.Request) -> web.Response:
"""GET /api/tv/hls-sessions - Aktive HLS-Sessions auflisten"""
if not hls_manager:
return web.json_response({"sessions": []})
sessions = hls_manager.get_all_sessions_info()
# Video-Namen aus DB nachladen fuer Anzeige
pool = await library_service._get_pool()
if pool:
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
for s in sessions:
await cur.execute(
"SELECT file_name FROM library_videos "
"WHERE id = %s", (s["video_id"],))
row = await cur.fetchone()
s["video_name"] = row["file_name"] if row else ""
except Exception:
pass
return web.json_response({"sessions": sessions})
async def delete_hls_session_admin(request: web.Request) -> web.Response:
"""DELETE /api/tv/hls-sessions/{sid} - HLS-Session beenden (Admin)"""
if not hls_manager:
return web.json_response({"error": "HLS nicht verfuegbar"},
status=400)
sid = request.match_info["sid"]
await hls_manager.destroy_session(sid)
return web.json_response({"success": True})
# --- 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)
app.router.add_get("/tv/movies", get_movies_list)
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, 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)
# Direct-Stream API (AVPlay)
app.router.add_get(
"/tv/api/direct-stream/{video_id}", get_direct_stream)
app.router.add_get(
"/tv/api/video-info/{video_id}", get_video_info_tv)
app.router.add_post(
"/tv/api/stream-token", post_stream_token)
# HLS Streaming API
app.router.add_post("/tv/api/hls/start", post_hls_start)
app.router.add_get(
"/tv/api/hls/{session_id}/playlist.m3u8", get_hls_playlist)
app.router.add_get(
"/tv/api/hls/{session_id}/{segment}", get_hls_segment)
app.router.add_delete(
"/tv/api/hls/{session_id}", delete_hls_session)
app.router.add_post(
"/tv/api/hls/{session_id}/stop", post_hls_stop)
# Admin-API (QR-Code, User-Verwaltung)
app.router.add_get("/api/tv/qrcode", get_qrcode)
app.router.add_get("/api/tv/url", get_tv_url)
app.router.add_get("/api/tv/users", get_users)
app.router.add_post("/api/tv/users", post_user)
app.router.add_put("/api/tv/users/{id}", put_user)
app.router.add_delete("/api/tv/users/{id}", delete_user)
app.router.add_get("/api/tv/hls-sessions", get_hls_sessions)
app.router.add_delete("/api/tv/hls-sessions/{sid}", delete_hls_session_admin)