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>
393 lines
16 KiB
Python
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
|