docker.videokonverter/video-konverter/app/routes/tv_api.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

594 lines
22 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
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"""
@wraps(handler)
async def wrapper(request):
user = await get_tv_user(request)
if not user:
raise web.HTTPFound("/tv/login")
request["tv_user"] = user
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"""
data = await request.post()
username = data.get("username", "").strip()
password = data.get("password", "")
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"}
)
# Session erstellen
ua = request.headers.get("User-Agent", "")
session_id = await auth_service.create_session(user["id"], ua)
resp = web.HTTPFound("/tv/")
resp.set_cookie(
"vk_session", session_id,
max_age=30 * 24 * 3600, # 30 Tage
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 - Alle Serien"""
user = request["tv_user"]
if not user.get("can_view_series"):
raise web.HTTPFound("/tv/")
series = []
pool = library_service._db_pool
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
query = """
SELECT s.id, s.title, s.folder_name, s.poster_url,
s.genres, s.tvdb_id, s.overview,
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"]))
query += (
f" WHERE s.library_path_id IN ({placeholders})"
)
params = user["allowed_paths"]
query += " GROUP BY s.id ORDER BY s.title"
await cur.execute(query, params)
series = await cur.fetchall()
return aiohttp_jinja2.render_template(
"tv/series.html", request, {
"user": user,
"active": "series",
"series": series,
}
)
@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 = {}
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
FROM library_series WHERE id = %s
""", (series_id,))
series = await cur.fetchone()
if series:
await cur.execute("""
SELECT id, file_name, season_number,
episode_number, episode_title,
duration_sec, file_size,
width, height, video_codec,
container
FROM library_videos
WHERE series_id = %s
ORDER BY season_number, episode_number, file_name
""", (series_id,))
episodes = await cur.fetchall()
for ep in episodes:
sn = ep.get("season_number") or 0
if sn not in seasons:
seasons[sn] = []
seasons[sn].append(ep)
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())),
}
)
@require_auth
async def get_movies_list(request: web.Request) -> web.Response:
"""GET /tv/movies - Alle Filme"""
user = request["tv_user"]
if not user.get("can_view_movies"):
raise web.HTTPFound("/tv/")
movies = []
pool = library_service._db_pool
if pool:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
query = """
SELECT m.id, m.title, m.folder_name, m.poster_url,
m.year, m.genres, m.overview
FROM library_movies m
"""
params = []
if user.get("allowed_paths"):
placeholders = ",".join(
["%s"] * len(user["allowed_paths"]))
query += (
f" WHERE m.library_path_id IN ({placeholders})"
)
params = user["allowed_paths"]
query += " ORDER BY m.title"
await cur.execute(query, params)
movies = await cur.fetchall()
return aiohttp_jinja2.render_template(
"tv/movies.html", request, {
"user": user,
"active": "movies",
"movies": movies,
}
)
@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
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")
return aiohttp_jinja2.render_template(
"tv/movie_detail.html", request, {
"user": user,
"active": "movies",
"movie": movie,
"videos": videos,
}
)
@require_auth
async def get_player(request: web.Request) -> web.Response:
"""GET /tv/player?v={video_id} - Video-Player"""
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 laden
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,
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()
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}"
return aiohttp_jinja2.render_template(
"tv/player.html", request, {
"user": user,
"video": video,
"title": title,
"start_pos": start_pos,
}
)
@require_auth
async def get_search(request: web.Request) -> web.Response:
"""GET /tv/search?q=... - Suchseite"""
user = request["tv_user"]
query = request.query.get("q", "").strip()
results_series = []
results_movies = []
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()
return aiohttp_jinja2.render_template(
"tv/search.html", request, {
"user": user,
"active": "search",
"query": query,
"series": results_series,
"movies": results_movies,
}
)
# --- 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)
# --- 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/", 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)
# TV-API (Watch-Progress)
app.router.add_post("/tv/api/watch-progress", post_watch_progress)
app.router.add_get(
"/tv/api/watch-progress/{video_id}", get_watch_progress)
# 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)