"""Authentifizierung und User-Verwaltung fuer die TV-App""" import json import logging import secrets import time from typing import Optional import aiomysql import bcrypt class AuthService: """Verwaltet TV-User, Sessions und Berechtigungen""" def __init__(self, db_pool_getter): self._get_pool = db_pool_getter async def init_db(self) -> None: """Erstellt DB-Tabellen fuer TV-Auth und migriert bestehende""" pool = await self._get_pool() if not pool: logging.error("Auth: Kein DB-Pool verfuegbar") return async with pool.acquire() as conn: async with conn.cursor() as cur: # === Bestehende Tabellen === await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, display_name VARCHAR(128), is_admin TINYINT DEFAULT 0, can_view_series TINYINT DEFAULT 1, can_view_movies TINYINT DEFAULT 1, allowed_paths JSON DEFAULT NULL, last_login TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_sessions ( id VARCHAR(64) PRIMARY KEY, user_id INT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP, user_agent VARCHAR(512), FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE, INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_watch_progress ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, video_id INT NOT NULL, position_sec DOUBLE DEFAULT 0, duration_sec DOUBLE DEFAULT 0, completed TINYINT DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX idx_user_video (user_id, video_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # === Neue Tabellen (v4.0) === # Client-Einstellungen (pro Geraet/Browser) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_clients ( id VARCHAR(64) PRIMARY KEY, name VARCHAR(128) DEFAULT NULL, sound_mode ENUM('stereo','surround','original') DEFAULT 'stereo', stream_quality ENUM('uhd','hd','sd','low') DEFAULT 'hd', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # Merkliste (Watchlist) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_watchlist ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, series_id INT NULL, movie_id INT NULL, added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX idx_user_series (user_id, series_id), UNIQUE INDEX idx_user_movie (user_id, movie_id), FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # Manueller Watch-Status await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_watch_status ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, video_id INT NULL, series_id INT NULL, season_key VARCHAR(64) NULL, status ENUM('unwatched','watching','watched') DEFAULT 'unwatched', updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user (user_id), UNIQUE INDEX idx_user_video (user_id, video_id), UNIQUE INDEX idx_user_series (user_id, series_id), UNIQUE INDEX idx_user_season (user_id, season_key), FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # Episoden-Thumbnails Cache await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_episode_thumbnails ( video_id INT PRIMARY KEY, thumbnail_path VARCHAR(1024) NOT NULL, source ENUM('tvdb','ffmpeg') DEFAULT 'ffmpeg', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # Such-History (pro User) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_search_history ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, query VARCHAR(256) NOT NULL, searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_query (query(64)), FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # Bewertungen (pro User, fuer Serien und Filme) await cur.execute(""" CREATE TABLE IF NOT EXISTS tv_ratings ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, series_id INT NULL, movie_id INT NULL, rating TINYINT NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE INDEX idx_user_series (user_id, series_id), UNIQUE INDEX idx_user_movie (user_id, movie_id), FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) # === Migration: Neue Spalten zu bestehenden Tabellen === await self._migrate_columns(cur) # Standard-Admin erstellen falls keine User existieren await self._ensure_default_admin() logging.info("TV-Auth: DB-Tabellen initialisiert") async def _migrate_columns(self, cur) -> None: """Fuegt neue Spalten zu bestehenden Tabellen hinzu (idempotent)""" # Hilfsfunktion: Spalte hinzufuegen falls nicht vorhanden async def add_column(table: str, column: str, definition: str): await cur.execute( "SELECT COUNT(*) FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA = DATABASE() " "AND TABLE_NAME = %s AND COLUMN_NAME = %s", (table, column) ) row = await cur.fetchone() if row[0] == 0: await cur.execute( f"ALTER TABLE {table} ADD COLUMN {column} {definition}" ) logging.info(f"TV-Auth: Spalte {table}.{column} hinzugefuegt") # tv_users: User-Einstellungen await add_column("tv_users", "preferred_audio_lang", "VARCHAR(8) DEFAULT 'deu'") await add_column("tv_users", "preferred_subtitle_lang", "VARCHAR(8) DEFAULT NULL") await add_column("tv_users", "subtitles_enabled", "TINYINT DEFAULT 0") await add_column("tv_users", "ui_lang", "VARCHAR(8) DEFAULT 'de'") await add_column("tv_users", "series_view", "VARCHAR(16) DEFAULT 'grid'") await add_column("tv_users", "movies_view", "VARCHAR(16) DEFAULT 'grid'") await add_column("tv_users", "avatar_color", "VARCHAR(7) DEFAULT '#64b5f6'") # Auto-Play Einstellungen await add_column("tv_users", "autoplay_enabled", "TINYINT DEFAULT 1") await add_column("tv_users", "autoplay_countdown_sec", "INT DEFAULT 10") await add_column("tv_users", "autoplay_max_episodes", "INT DEFAULT 0") # tv_sessions: Client-Referenz und permanente Sessions await add_column("tv_sessions", "client_id", "VARCHAR(64) DEFAULT NULL") await add_column("tv_sessions", "expires_at", "TIMESTAMP NULL DEFAULT NULL") # tvdb_episode_cache: Beschreibung und Bild-URL await add_column("tvdb_episode_cache", "overview", "TEXT DEFAULT NULL") await add_column("tvdb_episode_cache", "image_url", "VARCHAR(1024) DEFAULT NULL") # tv_users: Theme await add_column("tv_users", "theme", "VARCHAR(16) DEFAULT 'dark'") # library_series: TVDB-Score (externe Bewertung 0-100) await add_column("library_series", "tvdb_score", "FLOAT DEFAULT NULL") # library_movies: TVDB-Score (externe Bewertung 0-100) await add_column("library_movies", "tvdb_score", "FLOAT DEFAULT NULL") async def _ensure_default_admin(self) -> None: """Erstellt admin/admin falls keine User existieren""" pool = await self._get_pool() if not pool: return async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute("SELECT COUNT(*) FROM tv_users") row = await cur.fetchone() if row[0] == 0: await self.create_user( "admin", "admin", display_name="Administrator", is_admin=True ) logging.info("TV-Auth: Standard-Admin erstellt (admin/admin)") # --- User-CRUD --- async def create_user(self, username: str, password: str, display_name: str = None, is_admin: bool = False, can_view_series: bool = True, can_view_movies: bool = True, allowed_paths: list = None) -> Optional[int]: """Erstellt neuen User, gibt ID zurueck""" pw_hash = bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") paths_json = json.dumps(allowed_paths) if allowed_paths else None pool = await self._get_pool() if not pool: return None try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT INTO tv_users (username, password_hash, display_name, is_admin, can_view_series, can_view_movies, allowed_paths) VALUES (%s, %s, %s, %s, %s, %s, %s) """, (username, pw_hash, display_name, int(is_admin), int(can_view_series), int(can_view_movies), paths_json)) return cur.lastrowid except Exception as e: logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}") return None async def update_user(self, user_id: int, **kwargs) -> bool: """Aktualisiert User-Felder (password, display_name, Rechte)""" pool = await self._get_pool() if not pool: return False updates = [] values = [] if "password" in kwargs and kwargs["password"]: pw_hash = bcrypt.hashpw( kwargs["password"].encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") updates.append("password_hash = %s") values.append(pw_hash) for field in ("display_name", "is_admin", "can_view_series", "can_view_movies"): if field in kwargs: updates.append(f"{field} = %s") val = kwargs[field] if isinstance(val, bool): val = int(val) values.append(val) if "allowed_paths" in kwargs: updates.append("allowed_paths = %s") ap = kwargs["allowed_paths"] values.append(json.dumps(ap) if ap else None) if not updates: return False values.append(user_id) try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( f"UPDATE tv_users SET {', '.join(updates)} WHERE id = %s", tuple(values) ) return True except Exception as e: logging.error(f"TV-Auth: User aktualisieren fehlgeschlagen: {e}") return False async def delete_user(self, user_id: int) -> bool: """Loescht User und alle Sessions""" pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM tv_users WHERE id = %s", (user_id,) ) return cur.rowcount > 0 except Exception as e: logging.error(f"TV-Auth: User loeschen fehlgeschlagen: {e}") return False async def list_users(self) -> list[dict]: """Gibt alle User zurueck (ohne Passwort-Hash)""" pool = await self._get_pool() if not pool: return [] async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT id, username, display_name, is_admin, can_view_series, can_view_movies, allowed_paths, last_login, created_at FROM tv_users ORDER BY id """) rows = await cur.fetchall() for row in rows: # JSON-Feld parsen if row.get("allowed_paths") and isinstance( row["allowed_paths"], str): row["allowed_paths"] = json.loads(row["allowed_paths"]) # Timestamps als String for k in ("last_login", "created_at"): if row.get(k) and hasattr(row[k], "isoformat"): row[k] = str(row[k]) return rows async def get_user(self, user_id: int) -> Optional[dict]: """Einzelnen User laden""" pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT id, username, display_name, is_admin, can_view_series, can_view_movies, allowed_paths, last_login, created_at FROM tv_users WHERE id = %s """, (user_id,)) row = await cur.fetchone() if row and row.get("allowed_paths") and isinstance( row["allowed_paths"], str): row["allowed_paths"] = json.loads(row["allowed_paths"]) return row # --- Login / Sessions --- async def verify_login(self, username: str, password: str) -> Optional[dict]: """Prueft Credentials, gibt User-Dict zurueck oder None""" pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute( "SELECT * FROM tv_users WHERE username = %s", (username,) ) user = await cur.fetchone() if not user: return None if not bcrypt.checkpw( password.encode("utf-8"), user["password_hash"].encode("utf-8") ): return None # last_login aktualisieren await cur.execute( "UPDATE tv_users SET last_login = NOW() WHERE id = %s", (user["id"],) ) del user["password_hash"] return user async def create_session(self, user_id: int, user_agent: str = "", client_id: str = "", persistent: bool = False) -> str: """Erstellt Session, gibt Token zurueck. persistent=True -> Session laeuft nie ab (expires_at=NULL)""" session_id = secrets.token_urlsafe(48) pool = await self._get_pool() if not pool: return "" # Nicht-persistente Sessions laufen nach 30 Tagen ab expires = None if persistent else "DATE_ADD(NOW(), INTERVAL 30 DAY)" async with pool.acquire() as conn: async with conn.cursor() as cur: if persistent: await cur.execute(""" INSERT INTO tv_sessions (id, user_id, user_agent, client_id, expires_at) VALUES (%s, %s, %s, %s, NULL) """, (session_id, user_id, user_agent[:512] if user_agent else "", client_id or None)) else: await cur.execute(""" INSERT INTO tv_sessions (id, user_id, user_agent, client_id, expires_at) VALUES (%s, %s, %s, %s, DATE_ADD(NOW(), INTERVAL 30 DAY)) """, (session_id, user_id, user_agent[:512] if user_agent else "", client_id or None)) return session_id async def validate_session(self, session_id: str) -> Optional[dict]: """Prueft Session, gibt User-Dict mit Einstellungen zurueck oder None. Beruecksichtigt expires_at (NULL = permanent, sonst Ablauf-Datum).""" if not session_id: return None pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT u.id, u.username, u.display_name, u.is_admin, u.can_view_series, u.can_view_movies, u.allowed_paths, u.preferred_audio_lang, u.preferred_subtitle_lang, u.subtitles_enabled, u.ui_lang, u.series_view, u.movies_view, u.avatar_color, u.autoplay_enabled, u.autoplay_countdown_sec, u.autoplay_max_episodes, u.theme, s.client_id FROM tv_sessions s JOIN tv_users u ON s.user_id = u.id WHERE s.id = %s AND (s.expires_at IS NULL OR s.expires_at > NOW()) """, (session_id,)) user = await cur.fetchone() if user: # Session-Aktivitaet aktualisieren await cur.execute( "UPDATE tv_sessions SET last_active = NOW() " "WHERE id = %s", (session_id,) ) if user.get("allowed_paths") and isinstance( user["allowed_paths"], str): user["allowed_paths"] = json.loads( user["allowed_paths"]) return user async def delete_session(self, session_id: str) -> None: """Logout: Session loeschen""" pool = await self._get_pool() if not pool: return async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM tv_sessions WHERE id = %s", (session_id,) ) async def cleanup_old_sessions(self) -> int: """Loescht abgelaufene Sessions (expires_at abgelaufen). Persistente Sessions (expires_at IS NULL) werden nie geloescht.""" pool = await self._get_pool() if not pool: return 0 async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM tv_sessions " "WHERE expires_at IS NOT NULL AND expires_at < NOW()" ) return cur.rowcount # --- Client-Verwaltung (pro Geraet) --- async def get_or_create_client(self, client_id: str = None) -> str: """Gibt bestehende oder neue Client-ID zurueck""" pool = await self._get_pool() if not pool: return "" async with pool.acquire() as conn: async with conn.cursor() as cur: if client_id: await cur.execute( "SELECT id FROM tv_clients WHERE id = %s", (client_id,)) if await cur.fetchone(): await cur.execute( "UPDATE tv_clients SET last_active = NOW() " "WHERE id = %s", (client_id,)) return client_id # Neuen Client erstellen new_id = secrets.token_urlsafe(32) await cur.execute( "INSERT INTO tv_clients (id) VALUES (%s)", (new_id,)) return new_id async def get_client_settings(self, client_id: str) -> Optional[dict]: """Liest Client-Einstellungen""" pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute( "SELECT * FROM tv_clients WHERE id = %s", (client_id,)) return await cur.fetchone() async def update_client_settings(self, client_id: str, **kwargs) -> bool: """Aktualisiert Client-Einstellungen (name, sound_mode, stream_quality)""" pool = await self._get_pool() if not pool: return False allowed = {"name", "sound_mode", "stream_quality"} updates = [] values = [] for key, val in kwargs.items(): if key in allowed and val is not None: updates.append(f"{key} = %s") values.append(val) if not updates: return False values.append(client_id) try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( f"UPDATE tv_clients SET {', '.join(updates)} " "WHERE id = %s", tuple(values)) return True except Exception as e: logging.error(f"TV-Auth: Client-Settings fehlgeschlagen: {e}") return False # --- Multi-User: Profile auf dem selben Geraet --- async def get_client_profiles(self, client_id: str) -> list[dict]: """Alle eingeloggten User auf einem Client (fuer Quick-Switch)""" pool = await self._get_pool() if not pool: return [] async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT s.id as session_id, u.id as user_id, u.username, u.display_name, u.avatar_color FROM tv_sessions s JOIN tv_users u ON s.user_id = u.id WHERE s.client_id = %s AND (s.expires_at IS NULL OR s.expires_at > NOW()) ORDER BY s.last_active DESC """, (client_id,)) return await cur.fetchall() # --- User-Einstellungen --- async def update_user_settings(self, user_id: int, **kwargs) -> bool: """Aktualisiert User-Einstellungen (Sprache, Ansichten, Auto-Play)""" pool = await self._get_pool() if not pool: return False allowed = { "preferred_audio_lang", "preferred_subtitle_lang", "subtitles_enabled", "ui_lang", "series_view", "movies_view", "avatar_color", "autoplay_enabled", "autoplay_countdown_sec", "autoplay_max_episodes", "display_name", "theme", } updates = [] values = [] for key, val in kwargs.items(): if key in allowed: updates.append(f"{key} = %s") if isinstance(val, bool): val = int(val) values.append(val) if not updates: return False values.append(user_id) try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( f"UPDATE tv_users SET {', '.join(updates)} " "WHERE id = %s", tuple(values)) return True except Exception as e: logging.error(f"TV-Auth: Einstellungen fehlgeschlagen: {e}") return False # --- Watchlist (Merkliste) --- async def add_to_watchlist(self, user_id: int, series_id: int = None, movie_id: int = None) -> bool: """Fuegt Serie oder Film zur Merkliste hinzu""" pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT IGNORE INTO tv_watchlist (user_id, series_id, movie_id) VALUES (%s, %s, %s) """, (user_id, series_id, movie_id)) return cur.rowcount > 0 except Exception as e: logging.error(f"TV-Auth: Watchlist hinzufuegen fehlgeschlagen: {e}") return False async def remove_from_watchlist(self, user_id: int, series_id: int = None, movie_id: int = None) -> bool: """Entfernt Serie oder Film von der Merkliste""" pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: if series_id: await cur.execute( "DELETE FROM tv_watchlist " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) elif movie_id: await cur.execute( "DELETE FROM tv_watchlist " "WHERE user_id = %s AND movie_id = %s", (user_id, movie_id)) return cur.rowcount > 0 except Exception as e: logging.error(f"TV-Auth: Watchlist entfernen fehlgeschlagen: {e}") return False async def toggle_watchlist(self, user_id: int, series_id: int = None, movie_id: int = None) -> bool: """Toggle: Hinzufuegen wenn nicht vorhanden, entfernen wenn schon drin. Gibt True zurueck wenn jetzt in der Liste, False wenn entfernt.""" pool = await self._get_pool() if not pool: return False async with pool.acquire() as conn: async with conn.cursor() as cur: # Pruefen ob schon in Liste if series_id: await cur.execute( "SELECT id FROM tv_watchlist " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) else: await cur.execute( "SELECT id FROM tv_watchlist " "WHERE user_id = %s AND movie_id = %s", (user_id, movie_id)) exists = await cur.fetchone() if exists: await cur.execute( "DELETE FROM tv_watchlist WHERE id = %s", (exists[0],)) return False # Entfernt else: await cur.execute( "INSERT INTO tv_watchlist " "(user_id, series_id, movie_id) VALUES (%s, %s, %s)", (user_id, series_id, movie_id)) return True # Hinzugefuegt async def get_watchlist(self, user_id: int) -> dict: """Gibt Merkliste zurueck (Serien + Filme)""" pool = await self._get_pool() if not pool: return {"series": [], "movies": []} async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: # Serien await cur.execute(""" SELECT w.id as watchlist_id, w.added_at, s.id, s.title, s.folder_name, s.poster_url, s.genres, s.overview FROM tv_watchlist w JOIN library_series s ON w.series_id = s.id WHERE w.user_id = %s AND w.series_id IS NOT NULL ORDER BY w.added_at DESC """, (user_id,)) series = await cur.fetchall() # Filme await cur.execute(""" SELECT w.id as watchlist_id, w.added_at, m.id, m.title, m.folder_name, m.poster_url, m.year, m.genres, m.overview FROM tv_watchlist w JOIN library_movies m ON w.movie_id = m.id WHERE w.user_id = %s AND w.movie_id IS NOT NULL ORDER BY w.added_at DESC """, (user_id,)) movies = await cur.fetchall() return {"series": series, "movies": movies} async def is_in_watchlist(self, user_id: int, series_id: int = None, movie_id: int = None) -> bool: """Prueft ob Serie/Film in der Merkliste ist""" pool = await self._get_pool() if not pool: return False async with pool.acquire() as conn: async with conn.cursor() as cur: if series_id: await cur.execute( "SELECT 1 FROM tv_watchlist " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) else: await cur.execute( "SELECT 1 FROM tv_watchlist " "WHERE user_id = %s AND movie_id = %s", (user_id, movie_id)) return await cur.fetchone() is not None # --- Watch-Status (manuell gesehen/nicht gesehen) --- async def set_watch_status(self, user_id: int, status: str, video_id: int = None, series_id: int = None, season_key: str = None) -> bool: """Setzt manuellen Watch-Status (unwatched/watching/watched)""" if status not in ("unwatched", "watching", "watched"): return False pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT INTO tv_watch_status (user_id, video_id, series_id, season_key, status) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE status = VALUES(status) """, (user_id, video_id, series_id, season_key, status)) # Bei Staffel/Serie auch Einzel-Episoden aktualisieren if series_id and not video_id and not season_key: # Ganze Serie markieren await cur.execute(""" INSERT INTO tv_watch_status (user_id, video_id, status) SELECT %s, v.id, %s FROM library_videos v WHERE v.series_id = %s ON DUPLICATE KEY UPDATE status = VALUES(status) """, (user_id, status, series_id)) elif season_key: # Ganze Staffel markieren (format: "series_id:season") parts = season_key.split(":") if len(parts) == 2: sid, sn = int(parts[0]), int(parts[1]) await cur.execute(""" INSERT INTO tv_watch_status (user_id, video_id, status) SELECT %s, v.id, %s FROM library_videos v WHERE v.series_id = %s AND v.season_number = %s ON DUPLICATE KEY UPDATE status = VALUES(status) """, (user_id, status, sid, sn)) return True except Exception as e: logging.error(f"TV-Auth: Watch-Status fehlgeschlagen: {e}") return False async def get_watch_status(self, user_id: int, video_id: int = None, series_id: int = None) -> Optional[str]: """Gibt Watch-Status zurueck""" pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor() as cur: if video_id: await cur.execute( "SELECT status FROM tv_watch_status " "WHERE user_id = %s AND video_id = %s", (user_id, video_id)) elif series_id: await cur.execute( "SELECT status FROM tv_watch_status " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) else: return None row = await cur.fetchone() return row[0] if row else None # --- Such-History --- async def save_search(self, user_id: int, query: str) -> None: """Speichert Suchanfrage in der History""" if not query or len(query) < 2: return pool = await self._get_pool() if not pool: return async with pool.acquire() as conn: async with conn.cursor() as cur: # Duplikate vermeiden: gleiche Query aktualisieren await cur.execute( "DELETE FROM tv_search_history " "WHERE user_id = %s AND query = %s", (user_id, query)) await cur.execute( "INSERT INTO tv_search_history (user_id, query) " "VALUES (%s, %s)", (user_id, query)) # Max. 50 Eintraege behalten await cur.execute(""" DELETE FROM tv_search_history WHERE user_id = %s AND id NOT IN ( SELECT id FROM ( SELECT id FROM tv_search_history WHERE user_id = %s ORDER BY searched_at DESC LIMIT 50 ) t ) """, (user_id, user_id)) async def get_search_history(self, user_id: int, limit: int = 20) -> list[str]: """Gibt letzte Suchanfragen zurueck""" pool = await self._get_pool() if not pool: return [] async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "SELECT query FROM tv_search_history " "WHERE user_id = %s ORDER BY searched_at DESC LIMIT %s", (user_id, limit)) rows = await cur.fetchall() return [r[0] for r in rows] async def get_search_suggestions(self, user_id: int, prefix: str, limit: int = 8) -> list[str]: """Autocomplete: Vorschlaege aus History + Serien/Film-Titel""" if not prefix or len(prefix) < 1: return [] pool = await self._get_pool() if not pool: return [] suggestions = [] search = f"{prefix}%" async with pool.acquire() as conn: async with conn.cursor() as cur: # Aus Such-History await cur.execute( "SELECT DISTINCT query FROM tv_search_history " "WHERE user_id = %s AND query LIKE %s " "ORDER BY searched_at DESC LIMIT %s", (user_id, search, limit)) rows = await cur.fetchall() suggestions.extend(r[0] for r in rows) # Aus Serien-Titeln remaining = limit - len(suggestions) if remaining > 0: await cur.execute( "SELECT title FROM library_series " "WHERE title LIKE %s ORDER BY title LIMIT %s", (search, remaining)) rows = await cur.fetchall() for r in rows: if r[0] not in suggestions: suggestions.append(r[0]) # Aus Film-Titeln remaining = limit - len(suggestions) if remaining > 0: await cur.execute( "SELECT title FROM library_movies " "WHERE title LIKE %s ORDER BY title LIMIT %s", (search, remaining)) rows = await cur.fetchall() for r in rows: if r[0] not in suggestions: suggestions.append(r[0]) return suggestions[:limit] async def clear_search_history(self, user_id: int) -> bool: """Loescht alle Suchanfragen eines Users""" pool = await self._get_pool() if not pool: return False async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM tv_search_history WHERE user_id = %s", (user_id,)) return True # --- Fortschritt zuruecksetzen --- async def reset_all_progress(self, user_id: int) -> bool: """Setzt ALLE Fortschritte und Status eines Users zurueck""" pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute( "DELETE FROM tv_watch_progress WHERE user_id = %s", (user_id,)) await cur.execute( "DELETE FROM tv_watch_status WHERE user_id = %s", (user_id,)) return True except Exception as e: logging.error(f"TV-Auth: Reset fehlgeschlagen: {e}") return False # --- Watch-Progress --- async def save_progress(self, user_id: int, video_id: int, position_sec: float, duration_sec: float = 0) -> None: """Speichert Wiedergabe-Position""" completed = 1 if (duration_sec > 0 and position_sec / duration_sec > 0.9) else 0 pool = await self._get_pool() if not pool: return async with pool.acquire() as conn: async with conn.cursor() as cur: await cur.execute(""" INSERT INTO tv_watch_progress (user_id, video_id, position_sec, duration_sec, completed) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE position_sec = VALUES(position_sec), duration_sec = VALUES(duration_sec), completed = VALUES(completed) """, (user_id, video_id, position_sec, duration_sec, completed)) async def get_progress(self, user_id: int, video_id: int) -> Optional[dict]: """Liest Wiedergabe-Position""" pool = await self._get_pool() if not pool: return None async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT position_sec, duration_sec, completed FROM tv_watch_progress WHERE user_id = %s AND video_id = %s """, (user_id, video_id)) return await cur.fetchone() async def get_continue_watching(self, user_id: int, limit: int = 20) -> list[dict]: """Gibt 'Weiterschauen' Liste zurueck (nicht fertig, zuletzt gesehen)""" pool = await self._get_pool() if not pool: return [] async with pool.acquire() as conn: async with conn.cursor(aiomysql.DictCursor) as cur: await cur.execute(""" SELECT wp.video_id, wp.position_sec, wp.duration_sec, wp.updated_at, v.file_name, v.file_path, v.duration_sec as video_duration, v.width, v.height, v.video_codec, s.id as series_id, s.title as series_title, s.poster_url as series_poster FROM tv_watch_progress wp JOIN library_videos v ON wp.video_id = v.id LEFT JOIN library_series s ON v.series_id = s.id WHERE wp.user_id = %s AND wp.completed = 0 AND wp.position_sec > 10 ORDER BY wp.updated_at DESC LIMIT %s """, (user_id, limit)) rows = await cur.fetchall() for row in rows: if row.get("updated_at") and hasattr( row["updated_at"], "isoformat"): row["updated_at"] = str(row["updated_at"]) return rows # --- Bewertungen (Ratings) --- async def set_rating(self, user_id: int, rating: int, series_id: int = None, movie_id: int = None) -> bool: """Setzt User-Bewertung (1-5 Sterne). 0 = Bewertung loeschen.""" if rating < 0 or rating > 5: return False pool = await self._get_pool() if not pool: return False try: async with pool.acquire() as conn: async with conn.cursor() as cur: if rating == 0: # Bewertung loeschen if series_id: await cur.execute( "DELETE FROM tv_ratings " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) elif movie_id: await cur.execute( "DELETE FROM tv_ratings " "WHERE user_id = %s AND movie_id = %s", (user_id, movie_id)) else: await cur.execute(""" INSERT INTO tv_ratings (user_id, series_id, movie_id, rating) VALUES (%s, %s, %s, %s) ON DUPLICATE KEY UPDATE rating = VALUES(rating) """, (user_id, series_id, movie_id, rating)) return True except Exception as e: logging.error(f"TV-Auth: Rating fehlgeschlagen: {e}") return False async def get_rating(self, user_id: int, series_id: int = None, movie_id: int = None) -> int: """Gibt User-Rating zurueck (0 = keine Bewertung)""" pool = await self._get_pool() if not pool: return 0 async with pool.acquire() as conn: async with conn.cursor() as cur: if series_id: await cur.execute( "SELECT rating FROM tv_ratings " "WHERE user_id = %s AND series_id = %s", (user_id, series_id)) elif movie_id: await cur.execute( "SELECT rating FROM tv_ratings " "WHERE user_id = %s AND movie_id = %s", (user_id, movie_id)) else: return 0 row = await cur.fetchone() return row[0] if row else 0 async def get_avg_rating(self, series_id: int = None, movie_id: int = None) -> dict: """Gibt Durchschnittsbewertung + Anzahl zurueck""" pool = await self._get_pool() if not pool: return {"avg": 0, "count": 0} async with pool.acquire() as conn: async with conn.cursor() as cur: if series_id: await cur.execute( "SELECT AVG(rating) as avg_r, COUNT(*) as cnt " "FROM tv_ratings WHERE series_id = %s AND rating > 0", (series_id,)) elif movie_id: await cur.execute( "SELECT AVG(rating) as avg_r, COUNT(*) as cnt " "FROM tv_ratings WHERE movie_id = %s AND rating > 0", (movie_id,)) else: return {"avg": 0, "count": 0} row = await cur.fetchone() return { "avg": round(float(row[0] or 0), 1), "count": int(row[1] or 0), }