docker.videokonverter/video-konverter/app/routes/tv_api.py
data 6d0b8936c5 feat: VideoKonverter v4.0 - Streaming-Client Ausbau
TV-App komplett ueberarbeitet: i18n (DE/EN), Multi-User Quick-Switch,
3 Themes (Dark/Medium/Light), 3 Ansichten (Grid/Liste/Detail),
Filter (Quellen/Genre/Rating/Sortierung), Merkliste, 5-Sterne-Bewertung,
Watch-Status, Player-Overlay (Audio/Untertitel/Qualitaet/Naechste Episode),
Episoden-Thumbnails, Suchverlauf, Queue-Bugfix (delete_source).

5 neue DB-Tabellen, 10+ neue API-Endpunkte, ~3800 neue Zeilen Code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:39:12 +01:00

1096 lines
44 KiB
Python

"""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
from app.services.i18n import set_request_lang, get_all_translations
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.
Setzt i18n-Sprache aus User-Einstellungen."""
@wraps(handler)
async def wrapper(request):
user = await get_tv_user(request)
if not user:
raise web.HTTPFound("/tv/login")
request["tv_user"] = user
# i18n: Sprache des Users setzen
set_request_lang(request.app, user.get("ui_lang", "de"))
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.
Unterstuetzt 'remember' Checkbox fuer permanente Sessions
und Client-ID fuer Multi-User Quick-Switch."""
data = await request.post()
username = data.get("username", "").strip()
password = data.get("password", "")
remember = data.get("remember", "") == "on"
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"}
)
# Client-ID ermitteln/erstellen (fuer Multi-User pro Geraet)
client_id = request.cookies.get("vk_client_id")
client_id = await auth_service.get_or_create_client(client_id)
# Session erstellen (persistent wenn "Angemeldet bleiben")
ua = request.headers.get("User-Agent", "")
session_id = await auth_service.create_session(
user["id"], ua, client_id=client_id, persistent=remember
)
resp = web.HTTPFound("/tv/")
# Session-Cookie
max_age = 10 * 365 * 24 * 3600 if remember else 30 * 24 * 3600
resp.set_cookie(
"vk_session", session_id,
max_age=max_age,
httponly=True,
samesite="Lax",
path="/",
)
# Client-ID Cookie (immer permanent)
resp.set_cookie(
"vk_client_id", client_id,
max_age=10 * 365 * 24 * 3600, # 10 Jahre
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?source=&genre=&sort=&rating= - Alle Serien mit Filtern"""
user = request["tv_user"]
if not user.get("can_view_series"):
raise web.HTTPFound("/tv/")
# Filter-Parameter
source_filter = request.query.get("source", "")
genre_filter = request.query.get("genre", "")
sort_by = request.query.get("sort", "title")
rating_filter = request.query.get("rating", "")
series = []
sources = []
all_genres = set()
pool = library_service._db_pool
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Verfuegbare Quellen laden
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'series'"
src_params = []
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
src_query += f" AND id IN ({ph})"
src_params = user["allowed_paths"]
await cur.execute(src_query, src_params)
sources = await cur.fetchall()
# Serien-Query mit Filtern + Durchschnittsbewertung
query = """
SELECT s.id, s.title, s.folder_name, s.poster_url,
s.genres, s.tvdb_id, s.overview, s.status,
s.library_path_id, s.tvdb_score,
COUNT(DISTINCT v.id) as episode_count,
COALESCE(AVG(r.rating), 0) as avg_rating,
COUNT(DISTINCT r.id) as rating_count
FROM library_series s
LEFT JOIN library_videos v ON v.series_id = s.id
LEFT JOIN tv_ratings r ON r.series_id = s.id
AND r.rating > 0
"""
conditions = []
params = []
# Pfad-Berechtigung
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
conditions.append(
f"s.library_path_id IN ({ph})")
params.extend(user["allowed_paths"])
# Quellen-Filter
if source_filter:
conditions.append("s.library_path_id = %s")
params.append(int(source_filter))
# Genre-Filter
if genre_filter:
conditions.append("s.genres LIKE %s")
params.append(f"%{genre_filter}%")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " GROUP BY s.id"
# Rating-Filter (nach GROUP BY mit HAVING)
if rating_filter:
min_stars = int(rating_filter)
query += " HAVING avg_rating >= %s"
params.append(min_stars)
# Sortierung
sort_map = {
"title": " ORDER BY s.title",
"title_desc": " ORDER BY s.title DESC",
"newest": " ORDER BY s.id DESC",
"episodes": " ORDER BY episode_count DESC",
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
}
query += sort_map.get(sort_by, " ORDER BY s.title")
await cur.execute(query, params)
series = await cur.fetchall()
# Alle verfuegbaren Genres extrahieren + Rating runden
for s in series:
s["avg_rating"] = round(
float(s.get("avg_rating") or 0), 1)
if s.get("genres"):
for g in s["genres"].split(","):
g = g.strip()
if g:
all_genres.add(g)
return aiohttp_jinja2.render_template(
"tv/series.html", request, {
"user": user,
"active": "series",
"series": series,
"view": user.get("series_view") or "grid",
"sources": sources,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
}
)
@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 = {}
in_watchlist = False
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, tvdb_score
FROM library_series WHERE id = %s
""", (series_id,))
series = await cur.fetchone()
if series:
# Episoden mit TVDB-Beschreibung und Watch-Progress
await cur.execute("""
SELECT v.id, v.file_name, v.season_number,
v.episode_number, v.episode_title,
v.duration_sec, v.file_size,
v.width, v.height, v.video_codec,
v.container,
tc.overview AS ep_overview,
tc.image_url AS ep_image_url,
wp.position_sec, wp.duration_sec AS wp_duration
FROM library_videos v
LEFT JOIN tvdb_episode_cache tc
ON tc.series_tvdb_id = %s
AND tc.season_number = v.season_number
AND tc.episode_number = v.episode_number
LEFT JOIN tv_watch_progress wp
ON wp.video_id = v.id
AND wp.user_id = %s
WHERE v.series_id = %s
ORDER BY v.season_number, v.episode_number,
v.file_name
""", (series.get("tvdb_id") or 0,
user["id"], series_id))
episodes = await cur.fetchall()
for ep in episodes:
# Fortschritt berechnen
if ep.get("position_sec") and ep.get("wp_duration"):
ep["progress_pct"] = min(100, int(
ep["position_sec"] / ep["wp_duration"]
* 100))
else:
ep["progress_pct"] = 0
sn = ep.get("season_number") or 0
if sn not in seasons:
seasons[sn] = []
seasons[sn].append(ep)
# Watchlist-Status pruefen
in_watchlist = await auth_service.is_in_watchlist(
user["id"], series_id=series_id)
# Bewertungen laden
user_rating = await auth_service.get_rating(
user["id"], series_id=series_id)
avg_rating = await auth_service.get_avg_rating(
series_id=series_id)
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())),
"in_watchlist": in_watchlist,
"user_rating": user_rating,
"avg_rating": avg_rating,
"tvdb_score": series.get("tvdb_score"),
}
)
@require_auth
async def get_movies_list(request: web.Request) -> web.Response:
"""GET /tv/movies?source=&genre=&sort=&rating= - Alle Filme mit Filtern"""
user = request["tv_user"]
if not user.get("can_view_movies"):
raise web.HTTPFound("/tv/")
# Filter-Parameter
source_filter = request.query.get("source", "")
genre_filter = request.query.get("genre", "")
sort_by = request.query.get("sort", "title")
rating_filter = request.query.get("rating", "")
movies = []
sources = []
all_genres = set()
pool = library_service._db_pool
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Verfuegbare Quellen
src_query = "SELECT id, name FROM library_paths WHERE media_type = 'movie'"
src_params = []
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
src_query += f" AND id IN ({ph})"
src_params = user["allowed_paths"]
await cur.execute(src_query, src_params)
sources = await cur.fetchall()
# Film-Query mit Filtern + Durchschnittsbewertung
query = """
SELECT m.id, m.title, m.folder_name, m.poster_url,
m.year, m.genres, m.overview,
m.library_path_id, m.tvdb_score,
COALESCE(AVG(r.rating), 0) as avg_rating,
COUNT(DISTINCT r.id) as rating_count
FROM library_movies m
LEFT JOIN tv_ratings r ON r.movie_id = m.id
AND r.rating > 0
"""
conditions = []
params = []
if user.get("allowed_paths"):
ph = ",".join(["%s"] * len(user["allowed_paths"]))
conditions.append(
f"m.library_path_id IN ({ph})")
params.extend(user["allowed_paths"])
if source_filter:
conditions.append("m.library_path_id = %s")
params.append(int(source_filter))
if genre_filter:
conditions.append("m.genres LIKE %s")
params.append(f"%{genre_filter}%")
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " GROUP BY m.id"
# Rating-Filter (nach GROUP BY)
if rating_filter:
min_stars = int(rating_filter)
query += " HAVING avg_rating >= %s"
params.append(min_stars)
sort_map = {
"title": " ORDER BY m.title",
"title_desc": " ORDER BY m.title DESC",
"newest": " ORDER BY m.id DESC",
"year": " ORDER BY m.year DESC",
"rating": " ORDER BY avg_rating DESC, rating_count DESC",
}
query += sort_map.get(sort_by, " ORDER BY m.title")
await cur.execute(query, params)
movies = await cur.fetchall()
for m in movies:
m["avg_rating"] = round(
float(m.get("avg_rating") or 0), 1)
if m.get("genres"):
for g in m["genres"].split(","):
g = g.strip()
if g:
all_genres.add(g)
return aiohttp_jinja2.render_template(
"tv/movies.html", request, {
"user": user,
"active": "movies",
"movies": movies,
"view": user.get("movies_view") or "grid",
"sources": sources,
"genres": sorted(all_genres),
"current_source": source_filter,
"current_genre": genre_filter,
"current_sort": sort_by,
"current_rating": rating_filter,
}
)
@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, tvdb_score
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")
in_watchlist = await auth_service.is_in_watchlist(
user["id"], movie_id=movie_id)
# Bewertungen laden
user_rating = await auth_service.get_rating(
user["id"], movie_id=movie_id)
avg_rating = await auth_service.get_avg_rating(
movie_id=movie_id)
return aiohttp_jinja2.render_template(
"tv/movie_detail.html", request, {
"user": user,
"active": "movies",
"movie": movie,
"videos": videos,
"in_watchlist": in_watchlist,
"user_rating": user_rating,
"avg_rating": avg_rating,
"tvdb_score": movie.get("tvdb_score"),
}
)
@require_auth
async def get_player(request: web.Request) -> web.Response:
"""GET /tv/player?v={video_id} - Video-Player
Laedt Video-Info, naechste Episode und Client-Einstellungen."""
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 + naechste Episode laden
video = None
next_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,
v.series_id,
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()
# Naechste Episode ermitteln (gleiche Serie)
if video and video.get("series_id"):
await cur.execute("""
SELECT id, season_number, episode_number,
episode_title, file_name
FROM library_videos
WHERE series_id = %s
AND (season_number > %s
OR (season_number = %s
AND episode_number > %s))
ORDER BY season_number ASC, episode_number ASC
LIMIT 1
""", (video["series_id"],
video.get("season_number", 0),
video.get("season_number", 0),
video.get("episode_number", 0)))
next_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}"
# Naechste Episode Titel
next_title = ""
if next_video:
sn2 = next_video.get("season_number", 0)
en2 = next_video.get("episode_number", 0)
next_title = f"S{sn2:02d}E{en2:02d}"
if next_video.get("episode_title"):
next_title += f" - {next_video['episode_title']}"
# Client-Einstellungen
client_id = request.cookies.get("vk_client_id")
client = None
if client_id:
client = await auth_service.get_client_settings(client_id)
return aiohttp_jinja2.render_template(
"tv/player.html", request, {
"user": user,
"video": video,
"title": title,
"start_pos": start_pos,
"next_video": next_video,
"next_title": next_title,
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
}
)
@require_auth
async def get_search(request: web.Request) -> web.Response:
"""GET /tv/search?q=... - Suchseite mit History/Autocomplete"""
user = request["tv_user"]
query = request.query.get("q", "").strip()
results_series = []
results_movies = []
history = []
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()
# Such-History speichern
await auth_service.save_search(user["id"], query)
else:
# Ohne Query: History anzeigen
history = await auth_service.get_search_history(user["id"])
return aiohttp_jinja2.render_template(
"tv/search.html", request, {
"user": user,
"active": "search",
"query": query,
"series": results_series,
"movies": results_movies,
"history": history,
}
)
# --- 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)
# --- Profilauswahl (Multi-User Quick-Switch) ---
async def get_profiles(request: web.Request) -> web.Response:
"""GET /tv/profiles - Profilauswahl (wer schaut?)"""
client_id = request.cookies.get("vk_client_id")
profiles = []
if client_id:
profiles = await auth_service.get_client_profiles(client_id)
# Aktuelle Session herausfinden
current_session = request.cookies.get("vk_session")
return aiohttp_jinja2.render_template(
"tv/profiles.html", request, {
"profiles": profiles,
"current_session": current_session,
}
)
async def post_switch_profile(request: web.Request) -> web.Response:
"""POST /tv/switch-profile - Profil wechseln (Session-ID)"""
data = await request.post()
session_id = data.get("session_id", "")
if not session_id:
raise web.HTTPFound("/tv/profiles")
# Session validieren
user = await auth_service.validate_session(session_id)
if not user:
raise web.HTTPFound("/tv/login")
resp = web.HTTPFound("/tv/")
resp.set_cookie(
"vk_session", session_id,
max_age=10 * 365 * 24 * 3600,
httponly=True, samesite="Lax", path="/",
)
return resp
# --- User-Einstellungen ---
@require_auth
async def get_settings(request: web.Request) -> web.Response:
"""GET /tv/settings - Benutzer-Einstellungen"""
user = request["tv_user"]
client_id = request.cookies.get("vk_client_id")
client = None
if client_id:
client = await auth_service.get_client_settings(client_id)
return aiohttp_jinja2.render_template(
"tv/settings.html", request, {
"user": user,
"client": client,
"active": "settings",
}
)
@require_auth
async def post_settings(request: web.Request) -> web.Response:
"""POST /tv/settings - Benutzer-Einstellungen speichern
Unterstuetzt sowohl vollstaendige Form-Submits als auch
einzelne AJAX-Updates (nur gesetzte Felder aendern)."""
user = request["tv_user"]
data = await request.post()
is_ajax = "X-Requested-With" in request.headers or \
len(data) <= 2
# Nur uebergebene Felder sammeln (kein Ueberschreiben)
user_kwargs = {}
field_map = {
"display_name": lambda v: v,
"preferred_audio_lang": lambda v: v,
"preferred_subtitle_lang": lambda v: v or None,
"subtitles_enabled": lambda v: v == "on",
"ui_lang": lambda v: v,
"series_view": lambda v: v,
"movies_view": lambda v: v,
"avatar_color": lambda v: v,
"theme": lambda v: v,
"autoplay_enabled": lambda v: v == "on",
"autoplay_countdown_sec": lambda v: int(v),
"autoplay_max_episodes": lambda v: int(v),
}
for key, transform in field_map.items():
if key in data:
user_kwargs[key] = transform(data[key])
if user_kwargs:
await auth_service.update_user_settings(
user["id"], **user_kwargs)
# Client-Einstellungen (nur wenn Felder vorhanden)
client_id = request.cookies.get("vk_client_id")
client_kwargs = {}
if client_id:
if "client_name" in data:
client_kwargs["name"] = data["client_name"]
if "sound_mode" in data:
client_kwargs["sound_mode"] = data["sound_mode"]
if "stream_quality" in data:
client_kwargs["stream_quality"] = data["stream_quality"]
if client_kwargs:
await auth_service.update_client_settings(
client_id, **client_kwargs)
# AJAX: JSON zurueckgeben, sonst Redirect
if is_ajax:
return web.json_response({"ok": True})
raise web.HTTPFound("/tv/settings?saved=1")
@require_auth
async def post_reset_progress(request: web.Request) -> web.Response:
"""POST /tv/settings/reset - Alle Fortschritte zuruecksetzen"""
user = request["tv_user"]
await auth_service.reset_all_progress(user["id"])
raise web.HTTPFound("/tv/settings?reset=1")
# --- Watchlist ---
@require_auth
async def get_watchlist(request: web.Request) -> web.Response:
"""GET /tv/watchlist - Merkliste anzeigen"""
user = request["tv_user"]
wl = await auth_service.get_watchlist(user["id"])
return aiohttp_jinja2.render_template(
"tv/watchlist.html", request, {
"user": user,
"active": "watchlist",
"series": wl["series"],
"movies": wl["movies"],
}
)
@require_auth
async def post_watchlist_toggle(request: web.Request) -> web.Response:
"""POST /tv/api/watchlist - Toggle Merkliste (JSON)"""
user = request["tv_user"]
data = await request.json()
series_id = data.get("series_id")
movie_id = data.get("movie_id")
in_list = await auth_service.toggle_watchlist(
user["id"],
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
return web.json_response({"in_watchlist": in_list})
# --- Watch-Status ---
@require_auth
async def post_watch_status(request: web.Request) -> web.Response:
"""POST /tv/api/watch-status - Status setzen (JSON)"""
user = request["tv_user"]
data = await request.json()
status = data.get("status", "unwatched")
success = await auth_service.set_watch_status(
user["id"], status,
video_id=data.get("video_id"),
series_id=data.get("series_id"),
season_key=data.get("season_key"),
)
return web.json_response({"success": success})
# --- Such-API ---
@require_auth
async def get_search_suggestions(request: web.Request) -> web.Response:
"""GET /tv/api/search/suggest?q=... - Autocomplete-Vorschlaege"""
user = request["tv_user"]
prefix = request.query.get("q", "").strip()
suggestions = await auth_service.get_search_suggestions(
user["id"], prefix)
return web.json_response({"suggestions": suggestions})
@require_auth
async def get_search_history(request: web.Request) -> web.Response:
"""GET /tv/api/search/history - Such-History"""
user = request["tv_user"]
history = await auth_service.get_search_history(user["id"])
return web.json_response({"history": history})
@require_auth
async def delete_search_history(request: web.Request) -> web.Response:
"""DELETE /tv/api/search/history - Such-History loeschen"""
user = request["tv_user"]
await auth_service.clear_search_history(user["id"])
return web.json_response({"success": True})
# --- Rating API ---
@require_auth
async def post_rating(request: web.Request) -> web.Response:
"""POST /tv/api/rating - Bewertung setzen/loeschen (JSON)
Body: { series_id|movie_id: int, rating: 0-5 }"""
user = request["tv_user"]
try:
data = await request.json()
except Exception:
return web.json_response({"error": "Ungueltiges JSON"}, status=400)
rating = int(data.get("rating", 0))
series_id = data.get("series_id")
movie_id = data.get("movie_id")
if not series_id and not movie_id:
return web.json_response(
{"error": "series_id oder movie_id noetig"}, status=400)
success = await auth_service.set_rating(
user["id"], rating,
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
# Durchschnitt zurueckgeben
avg = await auth_service.get_avg_rating(
series_id=int(series_id) if series_id else None,
movie_id=int(movie_id) if movie_id else None,
)
return web.json_response({
"success": success,
"user_rating": rating,
"avg_rating": avg["avg"],
"rating_count": avg["count"],
})
# --- i18n API (fuer JavaScript) ---
async def get_i18n(request: web.Request) -> web.Response:
"""GET /tv/api/i18n?lang=de - Alle Uebersetzungen als JSON"""
lang = request.query.get("lang", "de")
return web.json_response(get_all_translations(lang))
# --- 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/profiles", get_profiles)
app.router.add_post("/tv/switch-profile", post_switch_profile)
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)
app.router.add_get("/tv/watchlist", get_watchlist)
app.router.add_get("/tv/settings", get_settings)
app.router.add_post("/tv/settings", post_settings)
app.router.add_post("/tv/settings/reset", post_reset_progress)
# TV-API (Watch-Progress, Watchlist, Status, Suche, i18n)
app.router.add_post("/tv/api/watch-progress", post_watch_progress)
app.router.add_get(
"/tv/api/watch-progress/{video_id}", get_watch_progress)
app.router.add_post("/tv/api/watchlist", post_watchlist_toggle)
app.router.add_post("/tv/api/watch-status", post_watch_status)
app.router.add_get("/tv/api/search/suggest", get_search_suggestions)
app.router.add_get("/tv/api/search/history", get_search_history)
app.router.add_delete("/tv/api/search/history", delete_search_history)
app.router.add_get("/tv/api/i18n", get_i18n)
app.router.add_post("/tv/api/rating", post_rating)
# 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)