Tizen TV: Transparenter iframe-Overlay statt opacity:0 - Player-Controls (Progress-Bar, Buttons, Popup-Menue) jetzt sichtbar ueber dem AVPlay-Video. CSS-Klasse "vknative-playing" macht Hintergruende transparent, AVPlay-Video scheint durch den iframe hindurch. Android App: Immersive Sticky Fullscreen mit WindowInsetsControllerCompat. Status- und Navigationsleiste komplett versteckt, per Swipe vom Rand temporaer einblendbar. Audio-Normalisierung (3 Stufen): - Server-seitig: EBU R128 loudnorm (I=-14 LUFS) im HLS-Transcoding - Server-seitig: dynaudnorm (dynamische Szenen-Anpassung) im HLS-Transcoding - Client-seitig: DynamicsCompressorNode im Browser-Player Alle Optionen konfigurierbar: loudnorm/dynaudnorm im TV Admin-Center, Audio-Kompressor pro Client in den Einstellungen. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1875 lines
76 KiB
Python
1875 lines
76 KiB
Python
"""TV-App Routes - Seiten und API fuer Streaming-Frontend"""
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import logging
|
|
import secrets
|
|
import time
|
|
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.hls import HLSSessionManager
|
|
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,
|
|
hls_manager: HLSSessionManager = None) -> None:
|
|
"""Registriert alle TV-App Routes"""
|
|
|
|
# --- Poster-URL Lokalisierung ---
|
|
# TVDB-URLs durch lokalen Endpunkt ersetzen (schnelleres Laden)
|
|
|
|
_POSTER_WIDTH = 300 # Max. Breite fuer Poster-Thumbnails
|
|
|
|
def _localize_posters(rows, content_type="series"):
|
|
"""poster_url durch lokalen Resize-Endpunkt ersetzen.
|
|
Vermeidet externe TVDB-Requests, Bilder werden lokal gecacht."""
|
|
for row in rows:
|
|
if content_type == "series":
|
|
row["poster_url"] = (
|
|
f"/api/library/metadata/{row['id']}"
|
|
f"/poster.jpg?w={_POSTER_WIDTH}"
|
|
)
|
|
# Filme: noch keine lokale Metadaten -> URL beibehalten
|
|
|
|
# --- Stream-Tokens (fuer AVPlay / Native Player ohne Cookie-Jar) ---
|
|
# Token -> {video_id, user_id, expires}
|
|
_stream_tokens: dict[str, dict] = {}
|
|
_TOKEN_LIFETIME = 3600 # 1 Stunde
|
|
|
|
def _cleanup_tokens():
|
|
"""Abgelaufene Tokens entfernen"""
|
|
now = time.time()
|
|
expired = [k for k, v in _stream_tokens.items() if v["expires"] < now]
|
|
for k in expired:
|
|
del _stream_tokens[k]
|
|
|
|
def _create_stream_token(video_id: int, user_id: int) -> str:
|
|
"""Temporaeren Stream-Token erstellen"""
|
|
_cleanup_tokens()
|
|
token = secrets.token_urlsafe(32)
|
|
_stream_tokens[token] = {
|
|
"video_id": video_id,
|
|
"user_id": user_id,
|
|
"expires": time.time() + _TOKEN_LIFETIME,
|
|
}
|
|
return token
|
|
|
|
def _validate_stream_token(token: str, video_id: int) -> bool:
|
|
"""Prueft ob Token gueltig ist fuer das angegebene Video"""
|
|
info = _stream_tokens.get(token)
|
|
if not info:
|
|
return False
|
|
if info["expires"] < time.time():
|
|
del _stream_tokens[token]
|
|
return False
|
|
if info["video_id"] != video_id:
|
|
return False
|
|
return True
|
|
|
|
# --- Auth-Hilfsfunktionen ---
|
|
|
|
def _cookie_params(request: web.Request) -> dict:
|
|
"""SameSite-Parameter je nach Protokoll und Client.
|
|
Tizen-WGT-App: iframe-Context -> SameSite weglassen (Lax blockiert Cookies in iframes).
|
|
HTTPS: SameSite=None+Secure. HTTP normal: Lax."""
|
|
ua = request.headers.get("User-Agent", "")
|
|
is_https = (request.secure
|
|
or request.headers.get("X-Forwarded-Proto") == "https")
|
|
if is_https:
|
|
return {"samesite": "None", "secure": True}
|
|
# Tizen-App laeuft im iframe -> SameSite weglassen damit Cookie gesetzt wird
|
|
if "Tizen" in ua:
|
|
return {}
|
|
return {"samesite": "Lax"}
|
|
|
|
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.
|
|
Wenn bereits eingeloggt -> weiter.
|
|
Wenn Profile auf dem Geraet -> Profilauswahl.
|
|
Wenn genau ein Profil -> direkt einloggen."""
|
|
user = await get_tv_user(request)
|
|
if user:
|
|
raise web.HTTPFound("/tv/")
|
|
# Pruefen ob Profile auf diesem Client existieren
|
|
client_id = request.cookies.get("vk_client_id")
|
|
if client_id:
|
|
profiles = await auth_service.get_client_profiles(client_id)
|
|
if len(profiles) == 1:
|
|
# Nur ein Profil -> direkt Session wechseln
|
|
session_id = profiles[0]["session_id"]
|
|
check_user = await auth_service.validate_session(session_id)
|
|
if check_user:
|
|
resp = web.HTTPFound("/tv/")
|
|
resp.set_cookie(
|
|
"vk_session", session_id,
|
|
max_age=10 * 365 * 24 * 3600,
|
|
httponly=True, path="/",
|
|
**_cookie_params(request),
|
|
)
|
|
return resp
|
|
elif len(profiles) > 1:
|
|
# Mehrere Profile -> Profilauswahl
|
|
raise web.HTTPFound("/tv/profiles")
|
|
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, path="/",
|
|
**_cookie_params(request),
|
|
)
|
|
# Client-ID Cookie (immer permanent)
|
|
resp.set_cookie(
|
|
"vk_client_id", client_id,
|
|
max_age=10 * 365 * 24 * 3600, # 10 Jahre
|
|
httponly=True, path="/",
|
|
**_cookie_params(request),
|
|
)
|
|
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 mit konfigurierbaren Rubriken"""
|
|
user = request["tv_user"]
|
|
uid = user["id"]
|
|
|
|
# User-Einstellungen fuer Startseite
|
|
show_continue = user.get("home_show_continue", 1)
|
|
show_new = user.get("home_show_new", 1)
|
|
hide_watched = user.get("home_hide_watched", 1)
|
|
show_watched = user.get("home_show_watched", 1)
|
|
|
|
# Daten-Container
|
|
continue_watching = []
|
|
new_series = []
|
|
new_movies = []
|
|
series = []
|
|
movies = []
|
|
watched_series = []
|
|
watched_movies = []
|
|
|
|
# Berechtigungs-WHERE fuer Pfad-Einschraenkung
|
|
def _path_filter(alias: str, paths: list):
|
|
if paths:
|
|
ph = ",".join(["%s"] * len(paths))
|
|
return f" AND {alias}.library_path_id IN ({ph})", list(paths)
|
|
return "", []
|
|
|
|
pool = library_service._db_pool
|
|
if pool:
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
|
|
# --- Serien ---
|
|
if user.get("can_view_series"):
|
|
path_sql, path_params = _path_filter(
|
|
"s", user.get("allowed_paths"))
|
|
|
|
# Neu hinzugefuegt (letzte 10, nach Scan-Datum)
|
|
if show_new:
|
|
await cur.execute(f"""
|
|
SELECT s.id, s.title, s.folder_name,
|
|
s.poster_url, s.last_updated,
|
|
COUNT(v.id) as episode_count
|
|
FROM library_series s
|
|
LEFT JOIN library_videos v
|
|
ON v.series_id = s.id
|
|
WHERE s.last_updated IS NOT NULL
|
|
{path_sql}
|
|
GROUP BY s.id
|
|
ORDER BY s.last_updated DESC
|
|
LIMIT 10
|
|
""", path_params)
|
|
new_series = await cur.fetchall()
|
|
|
|
# Serien (ohne gesehene, falls aktiviert)
|
|
watched_join = ""
|
|
watched_where = ""
|
|
if hide_watched:
|
|
watched_join = (
|
|
" LEFT JOIN tv_watch_status ws"
|
|
" ON ws.series_id = s.id"
|
|
f" AND ws.user_id = {int(uid)}"
|
|
" AND ws.status = 'watched'"
|
|
)
|
|
watched_where = " AND ws.id IS NULL"
|
|
|
|
await cur.execute(f"""
|
|
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
|
|
{watched_join}
|
|
WHERE 1=1 {path_sql} {watched_where}
|
|
GROUP BY s.id
|
|
ORDER BY s.title
|
|
LIMIT 20
|
|
""", path_params)
|
|
series = await cur.fetchall()
|
|
|
|
# Schon gesehen
|
|
if show_watched:
|
|
await cur.execute(f"""
|
|
SELECT s.id, s.title, s.folder_name,
|
|
s.poster_url,
|
|
COUNT(v.id) as episode_count
|
|
FROM library_series s
|
|
LEFT JOIN library_videos v
|
|
ON v.series_id = s.id
|
|
INNER JOIN tv_watch_status ws
|
|
ON ws.series_id = s.id
|
|
AND ws.user_id = %s
|
|
AND ws.status = 'watched'
|
|
WHERE 1=1 {path_sql}
|
|
GROUP BY s.id
|
|
ORDER BY ws.updated_at DESC
|
|
LIMIT 20
|
|
""", [uid] + path_params)
|
|
watched_series = await cur.fetchall()
|
|
|
|
# --- Filme ---
|
|
if user.get("can_view_movies"):
|
|
path_sql, path_params = _path_filter(
|
|
"m", user.get("allowed_paths"))
|
|
|
|
# Neu hinzugefuegt
|
|
if show_new:
|
|
await cur.execute(f"""
|
|
SELECT m.id, m.title, m.folder_name,
|
|
m.poster_url, m.year, m.genres,
|
|
m.last_updated
|
|
FROM library_movies m
|
|
WHERE m.last_updated IS NOT NULL
|
|
{path_sql}
|
|
ORDER BY m.last_updated DESC
|
|
LIMIT 10
|
|
""", path_params)
|
|
new_movies = await cur.fetchall()
|
|
|
|
# Filme (ohne gesehene, falls aktiviert)
|
|
# Filme gelten als gesehen wenn watch_progress
|
|
# completed = 1
|
|
watched_join = ""
|
|
watched_where = ""
|
|
if hide_watched:
|
|
watched_join = f"""
|
|
LEFT JOIN (
|
|
SELECT DISTINCT v2.movie_id
|
|
FROM tv_watch_progress wp
|
|
JOIN library_videos v2
|
|
ON wp.video_id = v2.id
|
|
WHERE wp.user_id = {int(uid)}
|
|
AND wp.completed = 1
|
|
AND v2.movie_id IS NOT NULL
|
|
) wm ON wm.movie_id = m.id
|
|
"""
|
|
watched_where = " AND wm.movie_id IS NULL"
|
|
|
|
await cur.execute(f"""
|
|
SELECT m.id, m.title, m.folder_name,
|
|
m.poster_url, m.year, m.genres
|
|
FROM library_movies m
|
|
{watched_join}
|
|
WHERE 1=1 {path_sql} {watched_where}
|
|
ORDER BY m.title
|
|
LIMIT 20
|
|
""", path_params)
|
|
movies = await cur.fetchall()
|
|
|
|
# Schon gesehen (Filme)
|
|
if show_watched:
|
|
await cur.execute(f"""
|
|
SELECT DISTINCT m.id, m.title,
|
|
m.folder_name, m.poster_url,
|
|
m.year, m.genres
|
|
FROM library_movies m
|
|
JOIN library_videos v ON v.movie_id = m.id
|
|
JOIN tv_watch_progress wp
|
|
ON wp.video_id = v.id
|
|
AND wp.user_id = %s
|
|
AND wp.completed = 1
|
|
WHERE 1=1 {path_sql}
|
|
ORDER BY wp.updated_at DESC
|
|
LIMIT 20
|
|
""", [uid] + path_params)
|
|
watched_movies = await cur.fetchall()
|
|
|
|
# Poster-URLs lokalisieren
|
|
for lst in (series, new_series, watched_series):
|
|
_localize_posters(lst, "series")
|
|
|
|
# Weiterschauen
|
|
if show_continue:
|
|
continue_watching = await auth_service.get_continue_watching(
|
|
uid)
|
|
|
|
return aiohttp_jinja2.render_template(
|
|
"tv/home.html", request, {
|
|
"user": user,
|
|
"active": "home",
|
|
"continue_watching": continue_watching,
|
|
"new_series": new_series,
|
|
"new_movies": new_movies,
|
|
"series": series,
|
|
"movies": movies,
|
|
"watched_series": watched_series,
|
|
"watched_movies": watched_movies,
|
|
}
|
|
)
|
|
|
|
@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)
|
|
|
|
# Poster-URLs lokalisieren (kein TVDB-Laden)
|
|
_localize_posters(series, "series")
|
|
|
|
# Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername)
|
|
folder_data = []
|
|
src_map = {str(src["id"]): src["name"] for src in sources}
|
|
if source_filter:
|
|
# Nur gefilterte Quelle
|
|
items = sorted(
|
|
[s for s in series
|
|
if str(s.get("library_path_id")) == source_filter],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
src_name = src_map.get(source_filter, "")
|
|
if items:
|
|
folder_data.append({"name": src_name, "entries": items})
|
|
else:
|
|
for src in sources:
|
|
items = sorted(
|
|
[s for s in series
|
|
if s.get("library_path_id") == src["id"]],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
if items:
|
|
folder_data.append({
|
|
"name": src["name"], "entries": items})
|
|
# Serien ohne Quelle (Fallback)
|
|
src_ids = {src["id"] for src in sources}
|
|
orphans = sorted(
|
|
[s for s in series
|
|
if s.get("library_path_id") not in src_ids],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
if orphans:
|
|
folder_data.append({"name": "Sonstige", "entries": orphans})
|
|
|
|
return aiohttp_jinja2.render_template(
|
|
"tv/series.html", request, {
|
|
"user": user,
|
|
"active": "series",
|
|
"series": series,
|
|
"view": user.get("series_view") or "grid",
|
|
"sources": sources,
|
|
"folder_data": folder_data,
|
|
"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,
|
|
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)
|
|
|
|
# Duplikat-Episoden markieren (gleiche Episodennummer)
|
|
for sn, eps in seasons.items():
|
|
ep_count = {}
|
|
for ep in eps:
|
|
en = ep.get("episode_number")
|
|
if en is not None:
|
|
ep_count[en] = ep_count.get(en, 0) + 1
|
|
for ep in eps:
|
|
en = ep.get("episode_number")
|
|
ep["is_duplicate"] = (
|
|
en is not None and ep_count.get(en, 0) > 1
|
|
)
|
|
|
|
# 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")
|
|
|
|
# Poster-URL lokalisieren
|
|
_localize_posters([series], "series")
|
|
|
|
# Watch-Threshold aus Config (Standard: 90%, wie Plex)
|
|
watched_threshold = config.tv_config.get("watched_threshold_pct", 90)
|
|
|
|
# Pro Staffel: Anzahl gesamt und gesehen berechnen
|
|
season_watched = {}
|
|
for sn, eps in seasons.items():
|
|
total = len(eps)
|
|
seen = sum(1 for ep in eps
|
|
if ep.get("progress_pct", 0) >= watched_threshold)
|
|
season_watched[sn] = {
|
|
"total": total, "seen": seen,
|
|
"all_seen": total > 0 and seen == total,
|
|
}
|
|
|
|
# Post-Play Parameter (vom Player nach Episoden-Ende)
|
|
post_play = request.query.get("post_play") == "1"
|
|
next_video_id = request.query.get("next_video", "")
|
|
countdown = int(request.query.get("countdown", "10") or "10")
|
|
last_watched_id = request.query.get("last_watched", "")
|
|
|
|
return aiohttp_jinja2.render_template(
|
|
"tv/series_detail.html", request, {
|
|
"user": user,
|
|
"active": "series",
|
|
"series": series,
|
|
"seasons": dict(sorted(seasons.items())),
|
|
"season_watched": season_watched,
|
|
"in_watchlist": in_watchlist,
|
|
"user_rating": user_rating,
|
|
"avg_rating": avg_rating,
|
|
"tvdb_score": series.get("tvdb_score"),
|
|
"watched_threshold_pct": watched_threshold,
|
|
"post_play": post_play,
|
|
"next_video_id": next_video_id,
|
|
"countdown": countdown,
|
|
"last_watched_id": last_watched_id,
|
|
}
|
|
)
|
|
|
|
@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)
|
|
|
|
# Ordner-Daten aufbereiten (gruppiert nach Quelle, sortiert nach Ordnername)
|
|
folder_data = []
|
|
src_map = {str(src["id"]): src["name"] for src in sources}
|
|
if source_filter:
|
|
items = sorted(
|
|
[m for m in movies
|
|
if str(m.get("library_path_id")) == source_filter],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
src_name = src_map.get(source_filter, "")
|
|
if items:
|
|
folder_data.append({"name": src_name, "entries": items})
|
|
else:
|
|
for src in sources:
|
|
items = sorted(
|
|
[m for m in movies
|
|
if m.get("library_path_id") == src["id"]],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
if items:
|
|
folder_data.append({
|
|
"name": src["name"], "entries": items})
|
|
# Filme ohne Quelle (Fallback)
|
|
src_ids = {src["id"] for src in sources}
|
|
orphans = sorted(
|
|
[m for m in movies
|
|
if m.get("library_path_id") not in src_ids],
|
|
key=lambda x: (x.get("folder_name") or "").lower()
|
|
)
|
|
if orphans:
|
|
folder_data.append({"name": "Sonstige", "entries": orphans})
|
|
|
|
return aiohttp_jinja2.render_template(
|
|
"tv/movies.html", request, {
|
|
"user": user,
|
|
"active": "movies",
|
|
"movies": movies,
|
|
"view": user.get("movies_view") or "grid",
|
|
"sources": sources,
|
|
"folder_data": folder_data,
|
|
"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)
|
|
|
|
# URL zur Seriendetail-Seite (fuer Post-Play Navigation)
|
|
series_detail_url = ""
|
|
if video.get("series_id"):
|
|
series_detail_url = f"/tv/series/{video['series_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,
|
|
"series_detail_url": series_detail_url,
|
|
"client_sound_mode": client.get("sound_mode", "stereo") if client else "stereo",
|
|
"client_stream_quality": client.get("stream_quality", "hd") if client else "hd",
|
|
"client_audio_compressor": client.get("audio_compressor", False) if client else False,
|
|
}
|
|
)
|
|
|
|
@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()
|
|
|
|
# Poster-URLs lokalisieren
|
|
_localize_posters(results_series, "series")
|
|
|
|
# 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)
|
|
completed = data.get("completed", False)
|
|
|
|
if not video_id:
|
|
return web.json_response(
|
|
{"error": "video_id fehlt"}, status=400)
|
|
|
|
# Bei explizitem completed-Flag: Position = Duration setzen
|
|
if completed and duration > 0:
|
|
position = duration
|
|
|
|
await auth_service.save_progress(
|
|
user["id"], video_id, position, duration
|
|
)
|
|
|
|
# Bei completed: Episode automatisch als "watched" markieren
|
|
if completed:
|
|
await auth_service.set_watch_status(
|
|
user["id"], "watched", video_id=video_id
|
|
)
|
|
|
|
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),
|
|
show_tech_info=data.get("show_tech_info", False),
|
|
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?)
|
|
Zeigt alle User an. Aktuelle Session wird hervorgehoben."""
|
|
client_id = request.cookies.get("vk_client_id")
|
|
current_session = request.cookies.get("vk_session")
|
|
current_user_id = None
|
|
if current_session:
|
|
user = await auth_service.validate_session(current_session)
|
|
if user:
|
|
current_user_id = user.get("id")
|
|
|
|
# Alle User laden (nicht nur die mit Sessions auf diesem Client)
|
|
all_users = await auth_service.get_all_users()
|
|
return aiohttp_jinja2.render_template(
|
|
"tv/profiles.html", request, {
|
|
"profiles": all_users,
|
|
"current_user_id": current_user_id,
|
|
}
|
|
)
|
|
|
|
async def post_switch_profile(request: web.Request) -> web.Response:
|
|
"""POST /tv/switch-profile - Auf anderen User wechseln.
|
|
Erstellt neue Session fuer den gewaehlten User."""
|
|
data = await request.post()
|
|
user_id = data.get("user_id", "")
|
|
if not user_id:
|
|
raise web.HTTPFound("/tv/profiles")
|
|
|
|
# Client-ID ermitteln/erstellen
|
|
client_id = request.cookies.get("vk_client_id")
|
|
client_id = await auth_service.get_or_create_client(client_id)
|
|
|
|
# Neue Session fuer den User erstellen
|
|
ua = request.headers.get("User-Agent", "")
|
|
session_id = await auth_service.create_session(
|
|
int(user_id), ua, client_id=client_id, persistent=True
|
|
)
|
|
if not session_id:
|
|
raise web.HTTPFound("/tv/login")
|
|
|
|
resp = web.HTTPFound("/tv/")
|
|
resp.set_cookie(
|
|
"vk_session", session_id,
|
|
max_age=10 * 365 * 24 * 3600,
|
|
httponly=True, path="/",
|
|
**_cookie_params(request),
|
|
)
|
|
resp.set_cookie(
|
|
"vk_client_id", client_id,
|
|
max_age=10 * 365 * 24 * 3600,
|
|
httponly=True, path="/",
|
|
**_cookie_params(request),
|
|
)
|
|
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),
|
|
"home_show_continue": lambda v: v == "on",
|
|
"home_show_new": lambda v: v == "on",
|
|
"home_hide_watched": lambda v: v == "on",
|
|
"home_show_watched": lambda v: v == "on",
|
|
}
|
|
# Checkbox-Felder: Wenn nicht in data -> False (bei Full-Form)
|
|
checkbox_fields = {
|
|
"subtitles_enabled", "autoplay_enabled",
|
|
"home_show_continue", "home_show_new",
|
|
"home_hide_watched", "home_show_watched",
|
|
}
|
|
for key, transform in field_map.items():
|
|
if key in data:
|
|
user_kwargs[key] = transform(data[key])
|
|
elif not is_ajax and key in checkbox_fields:
|
|
user_kwargs[key] = False
|
|
|
|
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"]
|
|
# audio_compressor: Checkbox-Handling
|
|
if "audio_compressor" in data:
|
|
client_kwargs["audio_compressor"] = (
|
|
data["audio_compressor"] == "on")
|
|
elif not is_ajax:
|
|
client_kwargs["audio_compressor"] = False
|
|
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"])
|
|
# Poster-URLs lokalisieren
|
|
_localize_posters(wl["series"], "series")
|
|
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))
|
|
|
|
# --- Direct-Stream API (fuer AVPlay / native Wiedergabe) ---
|
|
|
|
async def get_direct_stream(request: web.Request) -> web.Response:
|
|
"""GET /tv/api/direct-stream/{video_id}
|
|
Liefert Videodatei direkt als FileResponse (kein Transcoding).
|
|
Unterstuetzt HTTP Range Requests fuer Seeking.
|
|
Auth: Cookie ODER ?token=xxx (fuer AVPlay ohne Cookie-Jar)."""
|
|
video_id = int(request.match_info["video_id"])
|
|
|
|
# Auth pruefen: Cookie oder Stream-Token
|
|
user = await get_tv_user(request)
|
|
if not user:
|
|
# Fallback: Stream-Token pruefen
|
|
token = request.query.get("token")
|
|
if not token or not _validate_stream_token(token, video_id):
|
|
return web.json_response(
|
|
{"error": "Nicht autorisiert"}, status=401)
|
|
|
|
pool = await library_service._get_pool()
|
|
if not pool:
|
|
return web.json_response(
|
|
{"error": "Keine DB-Verbindung"}, status=500)
|
|
|
|
try:
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
await cur.execute(
|
|
"SELECT file_path FROM library_videos WHERE id = %s",
|
|
(video_id,))
|
|
row = await cur.fetchone()
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
if not row or not row.get("file_path"):
|
|
return web.json_response(
|
|
{"error": "Video nicht gefunden"}, status=404)
|
|
|
|
import os
|
|
file_path = row["file_path"]
|
|
if not os.path.isfile(file_path):
|
|
return web.json_response(
|
|
{"error": "Datei nicht gefunden"}, status=404)
|
|
|
|
return web.FileResponse(file_path)
|
|
|
|
@require_auth
|
|
async def post_stream_token(request: web.Request) -> web.Response:
|
|
"""POST /tv/api/stream-token
|
|
Erstellt temporaeren Token fuer Direct-Stream (AVPlay).
|
|
Body: { video_id: int }"""
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response(
|
|
{"error": "Ungueltiges JSON"}, status=400)
|
|
|
|
video_id = int(data.get("video_id", 0))
|
|
if not video_id:
|
|
return web.json_response(
|
|
{"error": "video_id fehlt"}, status=400)
|
|
|
|
user = request["tv_user"]
|
|
token = _create_stream_token(video_id, user["id"])
|
|
return web.json_response({"token": token})
|
|
|
|
@require_auth
|
|
async def get_video_info_tv(request: web.Request) -> web.Response:
|
|
"""GET /tv/api/video-info/{video_id}
|
|
Erweiterte Video-Infos inkl. Direct-Play-Kompatibilitaet."""
|
|
video_id = int(request.match_info["video_id"])
|
|
|
|
pool = await library_service._get_pool()
|
|
if not pool:
|
|
return web.json_response(
|
|
{"error": "Keine DB-Verbindung"}, status=500)
|
|
|
|
try:
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
await cur.execute("""
|
|
SELECT id, file_name, file_path, width, height,
|
|
video_codec, audio_tracks, subtitle_tracks,
|
|
container, duration_sec, video_bitrate,
|
|
is_10bit, hdr, series_id,
|
|
season_number, episode_number
|
|
FROM library_videos WHERE id = %s
|
|
""", (video_id,))
|
|
video = await cur.fetchone()
|
|
if not video:
|
|
return web.json_response(
|
|
{"error": "Video nicht gefunden"}, status=404)
|
|
except Exception as e:
|
|
return web.json_response({"error": str(e)}, status=500)
|
|
|
|
# JSON-Felder parsen
|
|
for field in ("audio_tracks", "subtitle_tracks"):
|
|
val = video.get(field)
|
|
if isinstance(val, str):
|
|
video[field] = json.loads(val)
|
|
elif val is None:
|
|
video[field] = []
|
|
|
|
# Bild-basierte Untertitel rausfiltern
|
|
video["subtitle_tracks"] = [
|
|
s for s in video["subtitle_tracks"]
|
|
if s.get("codec") not in (
|
|
"hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub"
|
|
)
|
|
]
|
|
|
|
# Direct-Play-Infos hinzufuegen
|
|
video["direct_play_url"] = f"/tv/api/direct-stream/{video_id}"
|
|
|
|
# Audio-Codecs extrahieren (fuer Client-seitige Kompatibilitaetspruefung)
|
|
audio_codecs = []
|
|
for track in video.get("audio_tracks", []):
|
|
codec = track.get("codec", "").lower()
|
|
if codec and codec not in audio_codecs:
|
|
audio_codecs.append(codec)
|
|
video["audio_codecs"] = audio_codecs
|
|
|
|
# Video-Codec normalisieren
|
|
vc = (video.get("video_codec") or "").lower()
|
|
codec_map = {
|
|
"h264": "h264", "avc": "h264", "avc1": "h264",
|
|
"hevc": "hevc", "h265": "hevc", "hev1": "hevc",
|
|
"av1": "av1", "av01": "av1",
|
|
"vp9": "vp9", "vp09": "vp9",
|
|
}
|
|
video["video_codec_normalized"] = codec_map.get(vc, vc)
|
|
|
|
# Dateigroesse fuer Puffer-Abschaetzung (ExoPlayer)
|
|
file_path = video.get("file_path")
|
|
try:
|
|
import os
|
|
video["file_size"] = os.path.getsize(file_path) if file_path and os.path.isfile(file_path) else 0
|
|
except Exception:
|
|
video["file_size"] = 0
|
|
|
|
# file_path nicht an den Client senden
|
|
video.pop("file_path", None)
|
|
|
|
return web.json_response(video)
|
|
|
|
# --- HLS Streaming API ---
|
|
|
|
@require_auth
|
|
async def post_hls_start(request: web.Request) -> web.Response:
|
|
"""POST /tv/api/hls/start - HLS-Session starten
|
|
Body: { video_id, quality?, audio?, sound?, t? }"""
|
|
if not hls_manager:
|
|
return web.json_response(
|
|
{"error": "HLS nicht verfuegbar"}, status=503)
|
|
|
|
try:
|
|
data = await request.json()
|
|
except Exception:
|
|
return web.json_response(
|
|
{"error": "Ungueltiges JSON"}, status=400)
|
|
|
|
video_id = int(data.get("video_id", 0))
|
|
if not video_id:
|
|
return web.json_response(
|
|
{"error": "video_id erforderlich"}, status=400)
|
|
|
|
quality = data.get("quality", "hd")
|
|
audio_idx = int(data.get("audio", 0))
|
|
sound_mode = data.get("sound", "stereo")
|
|
seek_sec = float(data.get("t", 0))
|
|
client_codecs = data.get("codecs") # z.B. ["h264", "av1", "vp9"]
|
|
|
|
session = await hls_manager.create_session(
|
|
video_id, quality, audio_idx, sound_mode, seek_sec,
|
|
client_codecs=client_codecs)
|
|
|
|
if not session:
|
|
return web.json_response(
|
|
{"error": "Session konnte nicht erstellt werden"}, status=500)
|
|
|
|
if session.error:
|
|
return web.json_response(
|
|
{"error": f"ffmpeg Fehler: {session.error[:200]}"}, status=500)
|
|
|
|
return web.json_response({
|
|
"session_id": session.session_id,
|
|
"playlist_url": f"/tv/api/hls/{session.session_id}/playlist.m3u8",
|
|
"ready": session.ready,
|
|
})
|
|
|
|
async def get_hls_playlist(request: web.Request) -> web.Response:
|
|
"""GET /tv/api/hls/{session_id}/playlist.m3u8 - HLS Manifest"""
|
|
if not hls_manager:
|
|
return web.Response(status=503)
|
|
|
|
session_id = request.match_info["session_id"]
|
|
session = hls_manager.get_session(session_id)
|
|
if not session:
|
|
return web.Response(status=404, text="Session nicht gefunden")
|
|
|
|
if not session.playlist_path.exists():
|
|
# Playlist noch nicht bereit - leere HLS-Playlist zurueckgeben
|
|
# hls.js und natives HLS laden sie automatisch erneut
|
|
return web.Response(
|
|
text="#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n",
|
|
content_type="application/vnd.apple.mpegurl",
|
|
headers={
|
|
"Cache-Control": "no-cache, no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
})
|
|
|
|
return web.FileResponse(
|
|
session.playlist_path,
|
|
headers={
|
|
"Content-Type": "application/vnd.apple.mpegurl",
|
|
"Cache-Control": "no-cache, no-store",
|
|
"Access-Control-Allow-Origin": "*",
|
|
})
|
|
|
|
async def get_hls_segment(request: web.Request) -> web.Response:
|
|
"""GET /tv/api/hls/{session_id}/{segment} - HLS Segment (.m4s/.mp4)"""
|
|
if not hls_manager:
|
|
return web.Response(status=503)
|
|
|
|
session_id = request.match_info["session_id"]
|
|
segment = request.match_info["segment"]
|
|
session = hls_manager.get_session(session_id)
|
|
if not session:
|
|
return web.Response(status=404, text="Session nicht gefunden")
|
|
|
|
# Path-Traversal verhindern (z.B. ../../etc/passwd)
|
|
seg_path = (session.dir / segment).resolve()
|
|
if not str(seg_path).startswith(str(session.dir.resolve())):
|
|
return web.Response(status=403, text="Zugriff verweigert")
|
|
|
|
# Warten bis Segment existiert und fertig geschrieben ist
|
|
# (ffmpeg schreibt Segmente inkrementell - bei AV1 copy bis 34 MB)
|
|
for _ in range(50): # max 5 Sekunden warten (AV1-Segmente sind gross)
|
|
if seg_path.exists() and seg_path.stat().st_size > 0:
|
|
# Kurz warten und nochmal pruefen ob Dateigroesse stabil
|
|
size1 = seg_path.stat().st_size
|
|
await asyncio.sleep(0.15)
|
|
if seg_path.exists() and seg_path.stat().st_size == size1:
|
|
break # Segment fertig geschrieben
|
|
await asyncio.sleep(0.1)
|
|
else:
|
|
if not seg_path.exists():
|
|
return web.Response(status=404,
|
|
text="Segment nicht verfuegbar")
|
|
|
|
# Content-Type je nach Dateiendung
|
|
if segment.endswith(".mp4") or segment.endswith(".m4s"):
|
|
content_type = "video/mp4"
|
|
elif segment.endswith(".ts"):
|
|
content_type = "video/mp2t"
|
|
else:
|
|
content_type = "application/octet-stream"
|
|
|
|
return web.FileResponse(
|
|
seg_path,
|
|
headers={
|
|
"Content-Type": content_type,
|
|
"Cache-Control": "public, max-age=86400, immutable",
|
|
"Access-Control-Allow-Origin": "*",
|
|
})
|
|
|
|
@require_auth
|
|
async def delete_hls_session(request: web.Request) -> web.Response:
|
|
"""DELETE /tv/api/hls/{session_id} - Session beenden"""
|
|
if not hls_manager:
|
|
return web.json_response({"error": "HLS nicht verfuegbar"},
|
|
status=503)
|
|
|
|
session_id = request.match_info["session_id"]
|
|
await hls_manager.destroy_session(session_id)
|
|
return web.json_response({"success": True})
|
|
|
|
async def post_hls_stop(request: web.Request) -> web.Response:
|
|
"""POST /tv/api/hls/{session_id}/stop - Session beenden (fuer sendBeacon)"""
|
|
if not hls_manager:
|
|
return web.Response(status=204)
|
|
session_id = request.match_info["session_id"]
|
|
await hls_manager.destroy_session(session_id)
|
|
return web.Response(status=204)
|
|
|
|
# --- HLS Admin-API (fuer TV Admin-Center) ---
|
|
|
|
async def get_hls_sessions(request: web.Request) -> web.Response:
|
|
"""GET /api/tv/hls-sessions - Aktive HLS-Sessions auflisten"""
|
|
if not hls_manager:
|
|
return web.json_response({"sessions": []})
|
|
sessions = hls_manager.get_all_sessions_info()
|
|
# Video-Namen aus DB nachladen fuer Anzeige
|
|
pool = await library_service._get_pool()
|
|
if pool:
|
|
try:
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
for s in sessions:
|
|
await cur.execute(
|
|
"SELECT file_name FROM library_videos "
|
|
"WHERE id = %s", (s["video_id"],))
|
|
row = await cur.fetchone()
|
|
s["video_name"] = row["file_name"] if row else ""
|
|
except Exception:
|
|
pass
|
|
return web.json_response({"sessions": sessions})
|
|
|
|
async def delete_hls_session_admin(request: web.Request) -> web.Response:
|
|
"""DELETE /api/tv/hls-sessions/{sid} - HLS-Session beenden (Admin)"""
|
|
if not hls_manager:
|
|
return web.json_response({"error": "HLS nicht verfuegbar"},
|
|
status=400)
|
|
sid = request.match_info["sid"]
|
|
await hls_manager.destroy_session(sid)
|
|
return web.json_response({"success": True})
|
|
|
|
# --- Tizen Debug-Log ---
|
|
|
|
async def post_tizen_log(request):
|
|
"""Empfaengt Log-Eintraege von der Tizen WGT-App und speichert in DB"""
|
|
try:
|
|
data = await request.json()
|
|
entries = data.get("entries", [])
|
|
ua = data.get("userAgent", "")[:500]
|
|
if not entries:
|
|
return web.json_response({"ok": True, "count": 0})
|
|
pool = library_service._db_pool
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor() as cur:
|
|
for e in entries[:50]: # Max 50 pro Request
|
|
await cur.execute(
|
|
"INSERT INTO tizen_logs (level, message, user_agent, client_ts) "
|
|
"VALUES (%s, %s, %s, %s)",
|
|
(e.get("l", "I"), e.get("m", "")[:2000],
|
|
ua, e.get("t", ""))
|
|
)
|
|
await conn.commit()
|
|
return web.json_response({"ok": True, "count": len(entries)})
|
|
except Exception as ex:
|
|
logging.warning(f"Tizen-Log Fehler: {ex}")
|
|
return web.json_response({"ok": False, "error": str(ex)}, status=400)
|
|
|
|
async def get_tizen_log(request):
|
|
"""Gibt die letzten Tizen-Logs zurueck (fuer Debugging)"""
|
|
limit = int(request.query.get("limit", "100"))
|
|
level = request.query.get("level", "")
|
|
pool = library_service._db_pool
|
|
async with pool.acquire() as conn:
|
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
if level:
|
|
await cur.execute(
|
|
"SELECT * FROM tizen_logs WHERE level = %s "
|
|
"ORDER BY id DESC LIMIT %s", (level, limit))
|
|
else:
|
|
await cur.execute(
|
|
"SELECT * FROM tizen_logs ORDER BY id DESC LIMIT %s",
|
|
(limit,))
|
|
rows = await cur.fetchall()
|
|
# Chronologisch (aelteste zuerst)
|
|
rows.reverse()
|
|
# Timestamps serialisierbar machen
|
|
for r in rows:
|
|
if r.get("created_at"):
|
|
r["created_at"] = str(r["created_at"])
|
|
return web.json_response(rows)
|
|
|
|
# --- 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", lambda r: web.HTTPFound("/tv/"))
|
|
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)
|
|
|
|
# Direct-Stream API (AVPlay)
|
|
app.router.add_get(
|
|
"/tv/api/direct-stream/{video_id}", get_direct_stream)
|
|
app.router.add_get(
|
|
"/tv/api/video-info/{video_id}", get_video_info_tv)
|
|
app.router.add_post(
|
|
"/tv/api/stream-token", post_stream_token)
|
|
|
|
# HLS Streaming API
|
|
app.router.add_post("/tv/api/hls/start", post_hls_start)
|
|
app.router.add_get(
|
|
"/tv/api/hls/{session_id}/playlist.m3u8", get_hls_playlist)
|
|
app.router.add_get(
|
|
"/tv/api/hls/{session_id}/{segment}", get_hls_segment)
|
|
app.router.add_delete(
|
|
"/tv/api/hls/{session_id}", delete_hls_session)
|
|
app.router.add_post(
|
|
"/tv/api/hls/{session_id}/stop", post_hls_stop)
|
|
|
|
# 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)
|
|
app.router.add_get("/api/tv/hls-sessions", get_hls_sessions)
|
|
app.router.add_delete("/api/tv/hls-sessions/{sid}", delete_hls_session_admin)
|
|
|
|
# Tizen Debug-Log API (kein Auth, Daten von WGT-App)
|
|
app.router.add_post("/api/tizen-log", post_tizen_log)
|
|
app.router.add_get("/api/tizen-log", get_tizen_log)
|