docker.videokonverter/video-konverter/app/services/auth.py
data 99730f2f8f feat: VideoKonverter v3.1 - TV-App, Auth, Tizen, Log-API
TV-App (/tv/):
- Login mit bcrypt-Passwort-Hashing und DB-Sessions (30 Tage)
- Home (Weiterschauen, Serien, Filme), Serien-Detail mit Staffeln
- Film-Uebersicht und Detail, Fullscreen Video-Player
- Suche mit Live-Ergebnissen, Watch-Progress (alle 10s gespeichert)
- D-Pad/Fernbedienung-Navigation (FocusManager, Samsung Tizen Keys)
- PWA: manifest.json, Service Worker, Icons fuer Handy/Tablet
- Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade)

Admin-Erweiterungen:
- QR-Code fuer TV-App URL
- User-Verwaltung (CRUD) mit Rechte-Konfiguration
- Log-API: GET /api/log?lines=100&level=INFO

Tizen-App (tizen-app/):
- Wrapper-App fuer Samsung Smart TVs (.wgt Paket)
- Einmalige Server-IP Eingabe, danach automatische Verbindung
- Installationsanleitung (INSTALL.md)

Bug-Fixes:
- executeImport: Job-ID vor resetImport() gesichert
- cursor(aiomysql.DictCursor) statt cursor(dict)
- DB-Spalten width/height statt video_width/video_height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:26:19 +01:00

393 lines
16 KiB
Python

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