"""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 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""" @wraps(handler) async def wrapper(request): user = await get_tv_user(request) if not user: raise web.HTTPFound("/tv/login") request["tv_user"] = user 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""" data = await request.post() username = data.get("username", "").strip() password = data.get("password", "") 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"} ) # Session erstellen ua = request.headers.get("User-Agent", "") session_id = await auth_service.create_session(user["id"], ua) resp = web.HTTPFound("/tv/") resp.set_cookie( "vk_session", session_id, max_age=30 * 24 * 3600, # 30 Tage 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 - Alle Serien""" user = request["tv_user"] if not user.get("can_view_series"): raise web.HTTPFound("/tv/") series = [] pool = library_service._db_pool if pool: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: query = """ SELECT s.id, s.title, s.folder_name, s.poster_url, s.genres, s.tvdb_id, s.overview, COUNT(v.id) as episode_count 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"])) query += ( f" WHERE s.library_path_id IN ({placeholders})" ) params = user["allowed_paths"] query += " GROUP BY s.id ORDER BY s.title" await cur.execute(query, params) series = await cur.fetchall() return aiohttp_jinja2.render_template( "tv/series.html", request, { "user": user, "active": "series", "series": series, } ) @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 = {} 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 FROM library_series WHERE id = %s """, (series_id,)) series = await cur.fetchone() if series: await cur.execute(""" SELECT id, file_name, season_number, episode_number, episode_title, duration_sec, file_size, width, height, video_codec, container FROM library_videos WHERE series_id = %s ORDER BY season_number, episode_number, file_name """, (series_id,)) episodes = await cur.fetchall() for ep in episodes: sn = ep.get("season_number") or 0 if sn not in seasons: seasons[sn] = [] seasons[sn].append(ep) 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())), } ) @require_auth async def get_movies_list(request: web.Request) -> web.Response: """GET /tv/movies - Alle Filme""" user = request["tv_user"] if not user.get("can_view_movies"): raise web.HTTPFound("/tv/") movies = [] pool = library_service._db_pool if pool: async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: query = """ SELECT m.id, m.title, m.folder_name, m.poster_url, m.year, m.genres, m.overview FROM library_movies m """ params = [] if user.get("allowed_paths"): placeholders = ",".join( ["%s"] * len(user["allowed_paths"])) query += ( f" WHERE m.library_path_id IN ({placeholders})" ) params = user["allowed_paths"] query += " ORDER BY m.title" await cur.execute(query, params) movies = await cur.fetchall() return aiohttp_jinja2.render_template( "tv/movies.html", request, { "user": user, "active": "movies", "movies": movies, } ) @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 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") return aiohttp_jinja2.render_template( "tv/movie_detail.html", request, { "user": user, "active": "movies", "movie": movie, "videos": videos, } ) @require_auth async def get_player(request: web.Request) -> web.Response: """GET /tv/player?v={video_id} - Video-Player""" 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 laden 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, 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() 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}" return aiohttp_jinja2.render_template( "tv/player.html", request, { "user": user, "video": video, "title": title, "start_pos": start_pos, } ) @require_auth async def get_search(request: web.Request) -> web.Response: """GET /tv/search?q=... - Suchseite""" user = request["tv_user"] query = request.query.get("q", "").strip() results_series = [] results_movies = [] 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() return aiohttp_jinja2.render_template( "tv/search.html", request, { "user": user, "active": "search", "query": query, "series": results_series, "movies": results_movies, } ) # --- 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) # --- 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/", 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) # TV-API (Watch-Progress) app.router.add_post("/tv/api/watch-progress", post_watch_progress) app.router.add_get( "/tv/api/watch-progress/{video_id}", get_watch_progress) # 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)