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