"""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""" 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: 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 """) # Standard-Admin erstellen falls keine User existieren await self._ensure_default_admin() logging.info("TV-Auth: DB-Tabellen initialisiert") 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 = "") -> str: """Erstellt Session, gibt Token zurueck""" session_id = secrets.token_urlsafe(48) 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_sessions (id, user_id, user_agent) VALUES (%s, %s, %s) """, (session_id, user_id, user_agent[:512] if user_agent else "")) return session_id async def validate_session(self, session_id: str) -> Optional[dict]: """Prueft Session, gibt User-Dict zurueck oder None""" 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 FROM tv_sessions s JOIN tv_users u ON s.user_id = u.id WHERE s.id = %s AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY) """, (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 Sessions aelter als 30 Tage""" 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 created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)" ) return cur.rowcount # --- 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