"""TV-App Routes - Seiten und API fuer Streaming-Frontend""" import io import json import logging 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.i18n import set_request_lang, get_all_translations def setup_tv_routes(app: web.Application, config: Config, auth_service: AuthService, library_service: LibraryService) -> None: """Registriert alle TV-App Routes""" # --- Auth-Hilfsfunktionen --- 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""" # Bereits eingeloggt? -> Weiterleiten user = await get_tv_user(request) if user: raise web.HTTPFound("/tv/") 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, samesite="Lax", path="/", ) # Client-ID Cookie (immer permanent) resp.set_cookie( "vk_client_id", client_id, max_age=10 * 365 * 24 * 3600, # 10 Jahre httponly=True, samesite="Lax", path="/", ) 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""" user = request["tv_user"] # Daten laden series = [] movies = [] continue_watching = [] pool = library_service._db_pool if pool: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: # Serien laden (mit Berechtigungspruefung) if user.get("can_view_series"): series_query = """ 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 """ params = [] if user.get("allowed_paths"): placeholders = ",".join( ["%s"] * len(user["allowed_paths"])) series_query += ( f" WHERE s.library_path_id IN ({placeholders})" ) params = user["allowed_paths"] series_query += ( " GROUP BY s.id ORDER BY s.title LIMIT 20" ) await cur.execute(series_query, params) series = await cur.fetchall() # Filme laden if user.get("can_view_movies"): movies_query = """ SELECT m.id, m.title, m.folder_name, m.poster_url, m.year, m.genres FROM library_movies m """ params = [] if user.get("allowed_paths"): placeholders = ",".join( ["%s"] * len(user["allowed_paths"])) movies_query += ( f" WHERE m.library_path_id IN ({placeholders})" ) params = user["allowed_paths"] movies_query += " ORDER BY m.title LIMIT 20" await cur.execute(movies_query, params) movies = await cur.fetchall() # Weiterschauen continue_watching = await auth_service.get_continue_watching( user["id"] ) return aiohttp_jinja2.render_template( "tv/home.html", request, { "user": user, "active": "home", "series": series, "movies": movies, "continue_watching": continue_watching, } ) @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) return aiohttp_jinja2.render_template( "tv/series.html", request, { "user": user, "active": "series", "series": series, "view": user.get("series_view") or "grid", "sources": sources, "genres": sorted(all_genres), "current_source": source_filter, "current_genre": genre_filter, "current_sort": sort_by, "current_rating": rating_filter, } ) @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, tc.image_url AS ep_image_url, wp.position_sec, wp.duration_sec AS wp_duration FROM library_videos v LEFT JOIN tvdb_episode_cache tc ON tc.series_tvdb_id = %s AND tc.season_number = v.season_number AND tc.episode_number = v.episode_number LEFT JOIN tv_watch_progress wp ON wp.video_id = v.id AND wp.user_id = %s WHERE v.series_id = %s ORDER BY v.season_number, v.episode_number, v.file_name """, (series.get("tvdb_id") or 0, user["id"], series_id)) episodes = await cur.fetchall() for ep in episodes: # Fortschritt berechnen if ep.get("position_sec") and ep.get("wp_duration"): ep["progress_pct"] = min(100, int( ep["position_sec"] / ep["wp_duration"] * 100)) else: ep["progress_pct"] = 0 sn = ep.get("season_number") or 0 if sn not in seasons: seasons[sn] = [] seasons[sn].append(ep) # Watchlist-Status pruefen in_watchlist = await auth_service.is_in_watchlist( user["id"], series_id=series_id) # Bewertungen laden user_rating = await auth_service.get_rating( user["id"], series_id=series_id) avg_rating = await auth_service.get_avg_rating( series_id=series_id) if not series: raise web.HTTPFound("/tv/series") return aiohttp_jinja2.render_template( "tv/series_detail.html", request, { "user": user, "active": "series", "series": series, "seasons": dict(sorted(seasons.items())), "in_watchlist": in_watchlist, "user_rating": user_rating, "avg_rating": avg_rating, "tvdb_score": series.get("tvdb_score"), } ) @require_auth async def get_movies_list(request: web.Request) -> web.Response: """GET /tv/movies?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) return aiohttp_jinja2.render_template( "tv/movies.html", request, { "user": user, "active": "movies", "movies": movies, "view": user.get("movies_view") or "grid", "sources": sources, "genres": sorted(all_genres), "current_source": source_filter, "current_genre": genre_filter, "current_sort": sort_by, "current_rating": rating_filter, } ) @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) return aiohttp_jinja2.render_template( "tv/player.html", request, { "user": user, "video": video, "title": title, "start_pos": start_pos, "next_video": next_video, "next_title": next_title, "client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo", "client_stream_quality": client.get("stream_quality", "hd") if client else "hd", } ) @require_auth async def get_search(request: web.Request) -> web.Response: """GET /tv/search?q=... - Suchseite 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() # 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) if not video_id: return web.json_response( {"error": "video_id fehlt"}, status=400) await auth_service.save_progress( user["id"], video_id, position, duration ) 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), 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?)""" client_id = request.cookies.get("vk_client_id") profiles = [] if client_id: profiles = await auth_service.get_client_profiles(client_id) # Aktuelle Session herausfinden current_session = request.cookies.get("vk_session") return aiohttp_jinja2.render_template( "tv/profiles.html", request, { "profiles": profiles, "current_session": current_session, } ) async def post_switch_profile(request: web.Request) -> web.Response: """POST /tv/switch-profile - Profil wechseln (Session-ID)""" data = await request.post() session_id = data.get("session_id", "") if not session_id: raise web.HTTPFound("/tv/profiles") # Session validieren user = await auth_service.validate_session(session_id) if not user: raise web.HTTPFound("/tv/login") resp = web.HTTPFound("/tv/") resp.set_cookie( "vk_session", session_id, max_age=10 * 365 * 24 * 3600, httponly=True, samesite="Lax", path="/", ) return resp # --- User-Einstellungen --- @require_auth async def get_settings(request: web.Request) -> web.Response: """GET /tv/settings - Benutzer-Einstellungen""" user = request["tv_user"] client_id = request.cookies.get("vk_client_id") client = None if client_id: client = await auth_service.get_client_settings(client_id) return aiohttp_jinja2.render_template( "tv/settings.html", request, { "user": user, "client": client, "active": "settings", } ) @require_auth async def post_settings(request: web.Request) -> web.Response: """POST /tv/settings - Benutzer-Einstellungen speichern Unterstuetzt sowohl vollstaendige Form-Submits als auch einzelne AJAX-Updates (nur gesetzte Felder aendern).""" user = request["tv_user"] data = await request.post() is_ajax = "X-Requested-With" in request.headers or \ len(data) <= 2 # Nur uebergebene Felder sammeln (kein Ueberschreiben) user_kwargs = {} field_map = { "display_name": lambda v: v, "preferred_audio_lang": lambda v: v, "preferred_subtitle_lang": lambda v: v or None, "subtitles_enabled": lambda v: v == "on", "ui_lang": lambda v: v, "series_view": lambda v: v, "movies_view": lambda v: v, "avatar_color": lambda v: v, "theme": lambda v: v, "autoplay_enabled": lambda v: v == "on", "autoplay_countdown_sec": lambda v: int(v), "autoplay_max_episodes": lambda v: int(v), } for key, transform in field_map.items(): if key in data: user_kwargs[key] = transform(data[key]) if user_kwargs: await auth_service.update_user_settings( user["id"], **user_kwargs) # Client-Einstellungen (nur wenn Felder vorhanden) client_id = request.cookies.get("vk_client_id") client_kwargs = {} if client_id: if "client_name" in data: client_kwargs["name"] = data["client_name"] if "sound_mode" in data: client_kwargs["sound_mode"] = data["sound_mode"] if "stream_quality" in data: client_kwargs["stream_quality"] = data["stream_quality"] if client_kwargs: await auth_service.update_client_settings( client_id, **client_kwargs) # AJAX: JSON zurueckgeben, sonst Redirect if is_ajax: return web.json_response({"ok": True}) raise web.HTTPFound("/tv/settings?saved=1") @require_auth async def post_reset_progress(request: web.Request) -> web.Response: """POST /tv/settings/reset - Alle Fortschritte zuruecksetzen""" user = request["tv_user"] await auth_service.reset_all_progress(user["id"]) raise web.HTTPFound("/tv/settings?reset=1") # --- Watchlist --- @require_auth async def get_watchlist(request: web.Request) -> web.Response: """GET /tv/watchlist - Merkliste anzeigen""" user = request["tv_user"] wl = await auth_service.get_watchlist(user["id"]) return aiohttp_jinja2.render_template( "tv/watchlist.html", request, { "user": user, "active": "watchlist", "series": wl["series"], "movies": wl["movies"], } ) @require_auth async def post_watchlist_toggle(request: web.Request) -> web.Response: """POST /tv/api/watchlist - Toggle Merkliste (JSON)""" user = request["tv_user"] data = await request.json() series_id = data.get("series_id") movie_id = data.get("movie_id") in_list = await auth_service.toggle_watchlist( user["id"], series_id=int(series_id) if series_id else None, movie_id=int(movie_id) if movie_id else None, ) return web.json_response({"in_watchlist": in_list}) # --- Watch-Status --- @require_auth async def post_watch_status(request: web.Request) -> web.Response: """POST /tv/api/watch-status - Status setzen (JSON)""" user = request["tv_user"] data = await request.json() status = data.get("status", "unwatched") success = await auth_service.set_watch_status( user["id"], status, video_id=data.get("video_id"), series_id=data.get("series_id"), season_key=data.get("season_key"), ) return web.json_response({"success": success}) # --- Such-API --- @require_auth async def get_search_suggestions(request: web.Request) -> web.Response: """GET /tv/api/search/suggest?q=... - Autocomplete-Vorschlaege""" user = request["tv_user"] prefix = request.query.get("q", "").strip() suggestions = await auth_service.get_search_suggestions( user["id"], prefix) return web.json_response({"suggestions": suggestions}) @require_auth async def get_search_history(request: web.Request) -> web.Response: """GET /tv/api/search/history - Such-History""" user = request["tv_user"] history = await auth_service.get_search_history(user["id"]) return web.json_response({"history": history}) @require_auth async def delete_search_history(request: web.Request) -> web.Response: """DELETE /tv/api/search/history - Such-History loeschen""" user = request["tv_user"] await auth_service.clear_search_history(user["id"]) return web.json_response({"success": True}) # --- Rating API --- @require_auth async def post_rating(request: web.Request) -> web.Response: """POST /tv/api/rating - Bewertung setzen/loeschen (JSON) Body: { series_id|movie_id: int, rating: 0-5 }""" user = request["tv_user"] try: data = await request.json() except Exception: return web.json_response({"error": "Ungueltiges JSON"}, status=400) rating = int(data.get("rating", 0)) series_id = data.get("series_id") movie_id = data.get("movie_id") if not series_id and not movie_id: return web.json_response( {"error": "series_id oder movie_id noetig"}, status=400) success = await auth_service.set_rating( user["id"], rating, series_id=int(series_id) if series_id else None, movie_id=int(movie_id) if movie_id else None, ) # Durchschnitt zurueckgeben avg = await auth_service.get_avg_rating( series_id=int(series_id) if series_id else None, movie_id=int(movie_id) if movie_id else None, ) return web.json_response({ "success": success, "user_rating": rating, "avg_rating": avg["avg"], "rating_count": avg["count"], }) # --- i18n API (fuer JavaScript) --- async def get_i18n(request: web.Request) -> web.Response: """GET /tv/api/i18n?lang=de - Alle Uebersetzungen als JSON""" lang = request.query.get("lang", "de") return web.json_response(get_all_translations(lang)) # --- Routes registrieren --- # TV-Seiten (mit Auth via Decorator) app.router.add_get("/tv/login", get_login) app.router.add_post("/tv/login", post_login) app.router.add_get("/tv/logout", get_logout) app.router.add_get("/tv/profiles", get_profiles) app.router.add_post("/tv/switch-profile", post_switch_profile) app.router.add_get("/tv/", get_home) app.router.add_get("/tv/series", get_series_list) app.router.add_get("/tv/series/{id}", get_series_detail) 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) # 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)