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>
594 lines
22 KiB
Python
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)
|