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>
This commit is contained in:
Eduard Wisch 2026-02-28 09:26:19 +01:00
parent 0ebe600215
commit 99730f2f8f
29 changed files with 3141 additions and 2 deletions

View file

@ -4,3 +4,5 @@ jinja2>=3.1.0
PyYAML>=6.0
aiomysql>=0.2.0
tvdb-v4-official>=1.1.0
bcrypt>=4.0
qrcode[pil]>=7.0

157
tizen-app/INSTALL.md Normal file
View file

@ -0,0 +1,157 @@
# VideoKonverter - Samsung Tizen TV Installation
Die VideoKonverter TV-App auf einem Samsung Smart TV (Tizen) installieren.
## Voraussetzungen
- Samsung Smart TV mit Tizen OS (ab 2017)
- PC und TV im gleichen Netzwerk
- Samsung Developer Account (kostenlos): https://developer.samsung.com/
- Tizen Studio auf dem PC
## Schritt 1: Tizen Studio installieren
Download: https://developer.tizen.org/development/tizen-studio/download
### Linux (Manjaro/Arch)
```bash
# Installer herunterladen und ausfuehren
chmod +x web-ide_Tizen_Studio_*.bin
./web-ide_Tizen_Studio_*.bin
# Nach Installation: Tools liegen unter ~/tizen-studio/
# Package Manager oeffnen und installieren:
# - Tizen SDK Tools
# - Samsung TV Extensions (Extension SDK Tab)
# - Samsung Certificate Extension (Extension SDK Tab)
```
### Wichtige Pfade nach Installation
```
~/tizen-studio/tools/sdb # Smart Development Bridge (wie adb)
~/tizen-studio/tools/ide/bin/tizen # CLI-Tool
~/tizen-studio/ide/TizenStudio # IDE starten
```
## Schritt 2: Samsung Developer Zertifikat erstellen
Das Zertifikat signiert die App fuer deinen TV. Ohne Zertifikat verweigert der TV die Installation.
1. Tizen Studio IDE starten
2. **Tools > Certificate Manager** oeffnen
3. **"+" klicken** > **Samsung** waehlen (nicht Tizen!)
4. **TV** als Geraetetyp waehlen
5. Samsung Developer Account Daten eingeben
6. Zertifikat wird erstellt und gespeichert
**WICHTIG:** Zertifikat sichern! Bei App-Updates muss das gleiche Zertifikat verwendet werden.
## Schritt 3: TV vorbereiten (Developer Mode)
### Ueber TV-Menue
1. TV einschalten
2. **Apps** oeffnen (Home > Apps)
3. Im Apps-Bereich die Ziffern **12345** eingeben (bei neueren Fernbedienungen evtl. ueber das virtuelle Nummernfeld)
4. Developer Mode **ON** schalten
5. **Host PC IP** eingeben (IP des PCs mit Tizen Studio)
6. TV neustarten
### Alternative (neuere TVs ab 2024/Tizen 8)
Falls der 12345-Trick nicht funktioniert:
- **Einstellungen > Allgemein > System-Manager** nach Developer Mode suchen
- Oder direkt ueber Tizen Studio Device Manager verbinden (siehe Schritt 4)
## Schritt 4: TV verbinden
1. **TV-IP herausfinden:** TV > Einstellungen > Allgemein > Netzwerk > IP-Adresse
2. In Tizen Studio: **Tools > Device Manager** oeffnen
3. **Remote Device Manager** > TV-IP eingeben > Verbinden
4. TV sollte in der Geraete-Liste erscheinen
5. **Rechtsklick auf TV > "Permit to install applications"**
### Oder per Kommandozeile
```bash
# Verbinden
~/tizen-studio/tools/sdb connect <TV-IP>
# Pruefen
~/tizen-studio/tools/sdb devices
```
## Schritt 5: App installieren
### Option A: Ueber Tizen Studio IDE (empfohlen)
1. Device Manager: TV ist verbunden
2. **Rechtsklick auf TV > "Install app"**
3. `VideoKonverter.wgt` auswaehlen
4. Installation laeuft automatisch
### Option B: Per Kommandozeile
```bash
cd /pfad/zu/tizen-app/
~/tizen-studio/tools/ide/bin/tizen install -n VideoKonverter.wgt -t <TV-Name>
```
Der TV-Name wird mit `sdb devices` angezeigt.
### Option C: Docker (ohne Tizen Studio)
Falls Tizen Studio zu aufwaendig ist - das Georift Docker-Image hat alles drin:
```bash
# Generisches WGT installieren (ohne Tizen Studio auf dem PC)
docker run --rm -v $(pwd):/app georift/install-jellyfin-tizen \
<TV-IP> --wgt /app/VideoKonverter.wgt
```
Siehe: https://github.com/Georift/install-jellyfin-tizen
## Schritt 6: App starten
1. App erscheint als **"VideoKonverter"** im Apps-Menue des TVs
2. Beim **ersten Start**: Server-IP eingeben (z.B. `192.168.155.12:8080`)
3. Die IP wird gespeichert - beim naechsten Start verbindet die App automatisch
4. Login mit TV-App Benutzerdaten (erstellt in der Admin-Oberflaeche)
## Wie funktioniert die App?
Die Tizen-App ist nur ein **duenner Wrapper**. Sie macht nichts ausser:
1. Beim ersten Start die Server-Adresse abfragen
2. Weiterleiten auf `http://<Server-IP>/tv/`
3. Ab dann kommt alles vom Docker-Container
**Vorteil:** Bei Software-Updates muss nur der Docker-Container aktualisiert werden.
Die App auf dem TV muss NICHT neu installiert werden.
## Fehlerbehebung
### TV wird nicht gefunden
- Sind PC und TV im gleichen Netzwerk/VLAN?
- Ist Developer Mode auf dem TV aktiviert?
- Firewall auf dem PC deaktiviert/Port 26101 offen?
### Installation schlaegt fehl
- Zertifikat korrekt erstellt? (Samsung, nicht Tizen)
- "Permit to install applications" ausgefuehrt?
- Alte Version erst deinstallieren: `sdb shell 0 vd_appuninstall vkTVApp001.VideoKonverter`
### App startet nicht / weisser Bildschirm
- Server laeuft? `curl http://<Server-IP>:8080/tv/`
- Richtige IP eingegeben?
- Browser-Cache auf TV leeren: App deinstallieren und neu installieren
## Links
- Samsung Developer Portal: https://developer.samsung.com/smarttv/develop
- Tizen Studio Download: https://developer.tizen.org/development/tizen-studio/download
- Samsung TV Quick-Start Guide: https://developer.samsung.com/smarttv/develop/getting-started/quick-start-guide.html
- Jellyfin Tizen (aehnliches Projekt): https://github.com/jellyfin/jellyfin-tizen
- Samsung-Jellyfin-Installer (GUI): https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer

Binary file not shown.

30
tizen-app/config.xml Normal file
View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
id="http://data-it-solution.de/videokonverter" version="3.1.0" viewmodes="maximized">
<name>VideoKonverter</name>
<description>VideoKonverter TV-App - Serien und Filme streamen</description>
<author>data IT solution - Eduard Wisch</author>
<icon src="icon.png"/>
<content src="index.html"/>
<!-- Tizen TV App -->
<tizen:application id="vkTVApp001.VideoKonverter" package="vkTVApp001" required_version="3.0"/>
<tizen:profile name="tv-samsung"/>
<!-- Berechtigungen -->
<tizen:privilege name="http://tizen.org/privilege/internet"/>
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
<tizen:privilege name="http://developer.samsung.com/privilege/network.public"/>
<tizen:privilege name="http://developer.samsung.com/privilege/productinfo"/>
<!-- Netzwerk-Zugriff erlauben (lokales Netz) -->
<access origin="*" subdomains="true"/>
<!-- TV-spezifische Einstellungen -->
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable"
encryption="disable" install-location="auto" hwkey-event="enable"/>
</widget>

BIN
tizen-app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

136
tizen-app/index.html Normal file
View file

@ -0,0 +1,136 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VideoKonverter</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0f0f0f;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
overflow: hidden;
}
.setup {
text-align: center;
max-width: 600px;
padding: 2rem;
}
.setup h1 {
font-size: 2rem;
margin-bottom: 1rem;
color: #64b5f6;
}
.setup p {
font-size: 1.2rem;
color: #aaa;
margin-bottom: 2rem;
}
.setup input {
width: 100%;
padding: 1rem;
font-size: 1.5rem;
background: #1a1a1a;
border: 2px solid #333;
border-radius: 8px;
color: #fff;
text-align: center;
margin-bottom: 1rem;
}
.setup input:focus {
border-color: #64b5f6;
outline: none;
}
.setup button {
padding: 1rem 3rem;
font-size: 1.3rem;
background: #1976d2;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
}
.setup button:focus {
outline: 3px solid #64b5f6;
outline-offset: 4px;
}
.hint {
margin-top: 1.5rem;
font-size: 0.9rem;
color: #666;
}
</style>
</head>
<body>
<div class="setup" id="setup">
<h1>VideoKonverter TV</h1>
<p>Server-Adresse eingeben:</p>
<input type="text" id="serverUrl" placeholder="z.B. 192.168.155.12:8080"
data-focusable autofocus>
<br>
<button id="connectBtn" onclick="connect()" data-focusable>Verbinden</button>
<p class="hint">Die Adresse wird gespeichert und beim naechsten Start automatisch geladen.</p>
</div>
<script>
// Server-URL aus localStorage laden
var STORAGE_KEY = "vk_server_url";
var savedUrl = localStorage.getItem(STORAGE_KEY);
// Beim Start: Wenn URL gespeichert, direkt verbinden
if (savedUrl) {
connectTo(savedUrl);
}
function connect() {
var input = document.getElementById("serverUrl");
var url = input.value.trim();
if (!url) return;
// Protokoll ergaenzen falls noetig
if (url.indexOf("://") === -1) {
url = "http://" + url;
}
// Slash am Ende sicherstellen
if (!url.endsWith("/")) url += "/";
// TV-Pfad anhaengen
if (url.indexOf("/tv") === -1) {
url += "tv/";
}
localStorage.setItem(STORAGE_KEY, url);
connectTo(url);
}
function connectTo(url) {
// Vollbild-Redirect zum VideoKonverter TV-App
window.location.href = url;
}
// Enter-Taste zum Verbinden
document.getElementById("serverUrl").addEventListener("keydown", function(e) {
if (e.keyCode === 13) { // Enter
connect();
}
});
// Tizen: Zurueck-Taste abfangen (sonst schliesst die App sofort)
document.addEventListener("keydown", function(e) {
// Samsung Remote: Return/Back = 10009
if (e.keyCode === 10009) {
// Wenn auf Setup-Seite: App beenden
if (document.getElementById("setup").style.display !== "none") {
try { tizen.application.getCurrentApplication().exit(); } catch(ex) {}
}
}
});
</script>
</body>
</html>

View file

@ -384,7 +384,85 @@ def setup_api_routes(app: web.Application, config: Config,
ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(ws_log_handler)
# --- Server-Log lesen ---
async def get_log(request: web.Request) -> web.Response:
"""
GET /api/log?lines=100&level=INFO
Gibt die letzten N Zeilen des Server-Logs zurueck.
"""
lines = int(request.query.get("lines", 100))
level_filter = request.query.get("level", "").upper()
lines = min(lines, 5000) # Max 5000 Zeilen
log_dir = Path(__file__).parent.parent.parent / "logs"
log_file = log_dir / "server.log"
# Fallback: Aus dem logging-Handler lesen
log_entries = []
if log_file.exists():
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
# Letzte N Zeilen
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
for line in recent:
line = line.rstrip()
if level_filter and level_filter not in line:
continue
log_entries.append(line)
except Exception as e:
return web.json_response(
{"error": f"Log lesen fehlgeschlagen: {e}"}, status=500
)
else:
# Kein Log-File: aus dem MemoryHandler lesen (falls vorhanden)
for handler in logging.getLogger().handlers:
if isinstance(handler, _MemoryLogHandler):
entries = handler.get_entries(lines)
for entry in entries:
if level_filter and level_filter not in entry:
continue
log_entries.append(entry)
break
if not log_entries:
log_entries.append("Keine Log-Datei gefunden unter: " + str(log_file))
return web.json_response({
"lines": log_entries,
"count": len(log_entries),
"source": str(log_file) if log_file.exists() else "memory",
})
# In-Memory Log-Handler (fuer Zugriff ohne Datei)
class _MemoryLogHandler(logging.Handler):
"""Speichert die letzten N Log-Eintraege im Speicher"""
def __init__(self, max_entries: int = 2000):
super().__init__()
self._entries = []
self._max = max_entries
def emit(self, record):
msg = self.format(record)
self._entries.append(msg)
if len(self._entries) > self._max:
self._entries = self._entries[-self._max:]
def get_entries(self, n: int = 100) -> list[str]:
return self._entries[-n:]
# Memory-Handler installieren
_mem_handler = _MemoryLogHandler(2000)
_mem_handler.setLevel(logging.DEBUG)
_mem_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s"
))
logging.getLogger().addHandler(_mem_handler)
# --- Routes registrieren ---
app.router.add_get("/api/log", get_log)
app.router.add_get("/api/browse", get_browse)
app.router.add_post("/api/upload", post_upload)
app.router.add_post("/api/convert", post_convert)

View file

@ -0,0 +1,594 @@
"""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)

View file

@ -14,9 +14,11 @@ from app.services.library import LibraryService
from app.services.tvdb import TVDBService
from app.services.cleaner import CleanerService
from app.services.importer import ImporterService
from app.services.auth import AuthService
from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes
from app.routes.pages import setup_page_routes
from app.routes.tv_api import setup_tv_routes
class VideoKonverterServer:
@ -88,6 +90,9 @@ class VideoKonverterServer:
# Seiten Routes
setup_page_routes(self.app, self.config, self.queue_service)
# TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert)
self.auth_service = None
# Statische Dateien
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
@ -140,6 +145,17 @@ class VideoKonverterServer:
await self.tvdb_service.init_db()
await self.importer_service.init_db()
# TV-App Auth-Service initialisieren (braucht DB-Pool)
if self.library_service._db_pool:
async def _get_pool():
return self.library_service._db_pool
self.auth_service = AuthService(_get_pool)
await self.auth_service.init_db()
setup_tv_routes(
self.app, self.config,
self.auth_service, self.library_service,
)
host = self.config.server_config.get("host", "0.0.0.0")
port = self.config.server_config.get("port", 8080)
logging.info(f"Server bereit auf http://{host}:{port}")
@ -166,6 +182,7 @@ class VideoKonverterServer:
f" Bibliothek: http://{host}:{port}/library\n"
f" Admin: http://{host}:{port}/admin\n"
f" Statistik: http://{host}:{port}/statistics\n"
f" TV-App: http://{host}:{port}/tv/\n"
f" WebSocket: ws://{host}:{port}/ws\n"
f" API: http://{host}:{port}/api/convert (POST)"
)

View file

@ -0,0 +1,393 @@
"""Authentifizierung und User-Verwaltung fuer die TV-App"""
import json
import logging
import secrets
import time
from typing import Optional
import aiomysql
import bcrypt
class AuthService:
"""Verwaltet TV-User, Sessions und Berechtigungen"""
def __init__(self, db_pool_getter):
self._get_pool = db_pool_getter
async def init_db(self) -> None:
"""Erstellt DB-Tabellen fuer TV-Auth"""
pool = await self._get_pool()
if not pool:
logging.error("Auth: Kein DB-Pool verfuegbar")
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(128),
is_admin TINYINT DEFAULT 0,
can_view_series TINYINT DEFAULT 1,
can_view_movies TINYINT DEFAULT 1,
allowed_paths JSON DEFAULT NULL,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_sessions (
id VARCHAR(64) PRIMARY KEY,
user_id INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_agent VARCHAR(512),
FOREIGN KEY (user_id) REFERENCES tv_users(id) ON DELETE CASCADE,
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
await cur.execute("""
CREATE TABLE IF NOT EXISTS tv_watch_progress (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
video_id INT NOT NULL,
position_sec DOUBLE DEFAULT 0,
duration_sec DOUBLE DEFAULT 0,
completed TINYINT DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX idx_user_video (user_id, video_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Standard-Admin erstellen falls keine User existieren
await self._ensure_default_admin()
logging.info("TV-Auth: DB-Tabellen initialisiert")
async def _ensure_default_admin(self) -> None:
"""Erstellt admin/admin falls keine User existieren"""
pool = await self._get_pool()
if not pool:
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT COUNT(*) FROM tv_users")
row = await cur.fetchone()
if row[0] == 0:
await self.create_user(
"admin", "admin",
display_name="Administrator",
is_admin=True
)
logging.info("TV-Auth: Standard-Admin erstellt (admin/admin)")
# --- User-CRUD ---
async def create_user(self, username: str, password: str,
display_name: str = None, is_admin: bool = False,
can_view_series: bool = True,
can_view_movies: bool = True,
allowed_paths: list = None) -> Optional[int]:
"""Erstellt neuen User, gibt ID zurueck"""
pw_hash = bcrypt.hashpw(
password.encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
paths_json = json.dumps(allowed_paths) if allowed_paths else None
pool = await self._get_pool()
if not pool:
return None
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_users
(username, password_hash, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths)
VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (username, pw_hash, display_name, int(is_admin),
int(can_view_series), int(can_view_movies), paths_json))
return cur.lastrowid
except Exception as e:
logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}")
return None
async def update_user(self, user_id: int, **kwargs) -> bool:
"""Aktualisiert User-Felder (password, display_name, Rechte)"""
pool = await self._get_pool()
if not pool:
return False
updates = []
values = []
if "password" in kwargs and kwargs["password"]:
pw_hash = bcrypt.hashpw(
kwargs["password"].encode("utf-8"), bcrypt.gensalt()
).decode("utf-8")
updates.append("password_hash = %s")
values.append(pw_hash)
for field in ("display_name", "is_admin",
"can_view_series", "can_view_movies"):
if field in kwargs:
updates.append(f"{field} = %s")
val = kwargs[field]
if isinstance(val, bool):
val = int(val)
values.append(val)
if "allowed_paths" in kwargs:
updates.append("allowed_paths = %s")
ap = kwargs["allowed_paths"]
values.append(json.dumps(ap) if ap else None)
if not updates:
return False
values.append(user_id)
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
f"UPDATE tv_users SET {', '.join(updates)} WHERE id = %s",
tuple(values)
)
return True
except Exception as e:
logging.error(f"TV-Auth: User aktualisieren fehlgeschlagen: {e}")
return False
async def delete_user(self, user_id: int) -> bool:
"""Loescht User und alle Sessions"""
pool = await self._get_pool()
if not pool:
return False
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM tv_users WHERE id = %s", (user_id,)
)
return cur.rowcount > 0
except Exception as e:
logging.error(f"TV-Auth: User loeschen fehlgeschlagen: {e}")
return False
async def list_users(self) -> list[dict]:
"""Gibt alle User zurueck (ohne Passwort-Hash)"""
pool = await self._get_pool()
if not pool:
return []
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, username, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths,
last_login, created_at
FROM tv_users ORDER BY id
""")
rows = await cur.fetchall()
for row in rows:
# JSON-Feld parsen
if row.get("allowed_paths") and isinstance(
row["allowed_paths"], str):
row["allowed_paths"] = json.loads(row["allowed_paths"])
# Timestamps als String
for k in ("last_login", "created_at"):
if row.get(k) and hasattr(row[k], "isoformat"):
row[k] = str(row[k])
return rows
async def get_user(self, user_id: int) -> Optional[dict]:
"""Einzelnen User laden"""
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, username, display_name, is_admin,
can_view_series, can_view_movies, allowed_paths,
last_login, created_at
FROM tv_users WHERE id = %s
""", (user_id,))
row = await cur.fetchone()
if row and row.get("allowed_paths") and isinstance(
row["allowed_paths"], str):
row["allowed_paths"] = json.loads(row["allowed_paths"])
return row
# --- Login / Sessions ---
async def verify_login(self, username: str, password: str) -> Optional[dict]:
"""Prueft Credentials, gibt User-Dict zurueck oder None"""
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT * FROM tv_users WHERE username = %s",
(username,)
)
user = await cur.fetchone()
if not user:
return None
if not bcrypt.checkpw(
password.encode("utf-8"),
user["password_hash"].encode("utf-8")
):
return None
# last_login aktualisieren
await cur.execute(
"UPDATE tv_users SET last_login = NOW() WHERE id = %s",
(user["id"],)
)
del user["password_hash"]
return user
async def create_session(self, user_id: int,
user_agent: str = "") -> str:
"""Erstellt Session, gibt Token zurueck"""
session_id = secrets.token_urlsafe(48)
pool = await self._get_pool()
if not pool:
return ""
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_sessions (id, user_id, user_agent)
VALUES (%s, %s, %s)
""", (session_id, user_id, user_agent[:512] if user_agent else ""))
return session_id
async def validate_session(self, session_id: str) -> Optional[dict]:
"""Prueft Session, gibt User-Dict zurueck oder None"""
if not session_id:
return None
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT u.id, u.username, u.display_name, u.is_admin,
u.can_view_series, u.can_view_movies, u.allowed_paths
FROM tv_sessions s
JOIN tv_users u ON s.user_id = u.id
WHERE s.id = %s
AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
""", (session_id,))
user = await cur.fetchone()
if user:
# Session-Aktivitaet aktualisieren
await cur.execute(
"UPDATE tv_sessions SET last_active = NOW() "
"WHERE id = %s", (session_id,)
)
if user.get("allowed_paths") and isinstance(
user["allowed_paths"], str):
user["allowed_paths"] = json.loads(
user["allowed_paths"])
return user
async def delete_session(self, session_id: str) -> None:
"""Logout: Session loeschen"""
pool = await self._get_pool()
if not pool:
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM tv_sessions WHERE id = %s", (session_id,)
)
async def cleanup_old_sessions(self) -> int:
"""Loescht Sessions aelter als 30 Tage"""
pool = await self._get_pool()
if not pool:
return 0
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute(
"DELETE FROM tv_sessions "
"WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"
)
return cur.rowcount
# --- Watch-Progress ---
async def save_progress(self, user_id: int, video_id: int,
position_sec: float,
duration_sec: float = 0) -> None:
"""Speichert Wiedergabe-Position"""
completed = 1 if (duration_sec > 0 and
position_sec / duration_sec > 0.9) else 0
pool = await self._get_pool()
if not pool:
return
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_watch_progress
(user_id, video_id, position_sec, duration_sec, completed)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
position_sec = VALUES(position_sec),
duration_sec = VALUES(duration_sec),
completed = VALUES(completed)
""", (user_id, video_id, position_sec, duration_sec, completed))
async def get_progress(self, user_id: int,
video_id: int) -> Optional[dict]:
"""Liest Wiedergabe-Position"""
pool = await self._get_pool()
if not pool:
return None
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT position_sec, duration_sec, completed
FROM tv_watch_progress
WHERE user_id = %s AND video_id = %s
""", (user_id, video_id))
return await cur.fetchone()
async def get_continue_watching(self, user_id: int,
limit: int = 20) -> list[dict]:
"""Gibt 'Weiterschauen' Liste zurueck (nicht fertig, zuletzt gesehen)"""
pool = await self._get_pool()
if not pool:
return []
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT wp.video_id, wp.position_sec, wp.duration_sec,
wp.updated_at,
v.file_name, v.file_path,
v.duration_sec as video_duration,
v.width, v.height, v.video_codec,
s.id as series_id, s.title as series_title,
s.poster_url as series_poster
FROM tv_watch_progress wp
JOIN library_videos v ON wp.video_id = v.id
LEFT JOIN library_series s ON v.series_id = s.id
WHERE wp.user_id = %s AND wp.completed = 0
AND wp.position_sec > 10
ORDER BY wp.updated_at DESC
LIMIT %s
""", (user_id, limit))
rows = await cur.fetchall()
for row in rows:
if row.get("updated_at") and hasattr(
row["updated_at"], "isoformat"):
row["updated_at"] = str(row["updated_at"])
return rows

View file

@ -2831,12 +2831,15 @@ let _importWsActive = false; // WebSocket liefert Updates?
async function executeImport() {
if (!currentImportJobId) return;
// Job-ID merken bevor resetImport() sie loescht
const jobId = currentImportJobId;
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
closeImportModal();
resetImport();
// Starte Import (non-blocking - Server antwortet sofort)
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
fetch(`/api/library/import/${jobId}/execute`, {method: "POST"});
}
// WebSocket-Handler fuer Import-Fortschritt

View file

@ -0,0 +1,471 @@
/* VideoKonverter TV - Streaming-Frontend */
/* Optimiert fuer TV (Fernbedienung), Handy, Tablet */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f0f;
--bg-card: #1a1a1a;
--bg-hover: #252525;
--bg-input: #1e1e1e;
--text: #e0e0e0;
--text-muted: #888;
--accent: #64b5f6;
--accent-hover: #90caf9;
--danger: #ef5350;
--success: #66bb6a;
--radius: 8px;
--focus-ring: 3px solid var(--accent);
}
html {
font-size: clamp(14px, 1.2vw, 20px);
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.5;
min-height: 100vh;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
/* === Focus-Management fuer D-Pad === */
[data-focusable]:focus {
outline: var(--focus-ring);
outline-offset: 4px;
z-index: 10;
}
/* === Navigation === */
.tv-nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(15, 15, 15, 0.95);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.5rem;
border-bottom: 1px solid #222;
}
.tv-nav-links { display: flex; gap: 0.3rem; }
.tv-nav-right { display: flex; align-items: center; gap: 0.8rem; }
.tv-nav-item {
color: var(--text-muted);
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.95rem;
transition: background 0.2s, color 0.2s;
white-space: nowrap;
}
.tv-nav-item:hover, .tv-nav-item:focus { background: var(--bg-hover); color: var(--text); }
.tv-nav-item.active { color: var(--text); background: var(--bg-card); font-weight: 600; }
.tv-nav-user { color: var(--text-muted); font-size: 0.85rem; }
.tv-nav-logout { color: var(--danger); font-size: 0.85rem; }
/* === Main Content === */
.tv-main { padding: 1.5rem; max-width: 1600px; margin: 0 auto; }
/* === Sections === */
.tv-section { margin-bottom: 2rem; }
.tv-section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.8rem; }
.tv-section-title { font-size: 1.3rem; font-weight: 600; margin-bottom: 0.5rem; }
.tv-section-more { font-size: 0.85rem; color: var(--accent); padding: 0.3rem 0.6rem; border-radius: var(--radius); }
.tv-section-more:focus { outline: var(--focus-ring); }
.tv-page-title { font-size: 1.6rem; font-weight: 700; margin-bottom: 1rem; }
/* === Horizontale Scroll-Reihen (Netflix-Style) === */
.tv-row {
display: flex;
gap: 12px;
overflow-x: auto;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
padding: 8px 0;
-webkit-overflow-scrolling: touch;
}
.tv-row::-webkit-scrollbar { height: 4px; }
.tv-row::-webkit-scrollbar-track { background: transparent; }
.tv-row::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
.tv-row .tv-card {
scroll-snap-align: start;
flex-shrink: 0;
width: 180px;
}
.tv-row .tv-card-wide { width: 260px; }
/* === Poster-Grid === */
.tv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
/* === Poster-Karten === */
.tv-card {
display: block;
background: var(--bg-card);
border-radius: var(--radius);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.tv-card:hover, .tv-card:focus {
transform: scale(1.04);
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.tv-card:focus { outline: var(--focus-ring); outline-offset: 2px; }
.tv-card-img {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
display: block;
background: #222;
}
.tv-card-placeholder {
width: 100%;
aspect-ratio: 2/3;
display: flex;
align-items: center;
justify-content: center;
background: #1e1e1e;
color: var(--text-muted);
font-size: 0.85rem;
text-align: center;
padding: 0.5rem;
}
.tv-card-info { padding: 0.5rem 0.6rem; }
.tv-card-title {
display: block;
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.tv-card-meta {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Wiedergabe-Fortschritt auf Karte */
.tv-card-progress {
height: 3px;
background: #333;
}
.tv-card-progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
/* === Detail-Ansicht === */
.tv-detail-header {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.tv-detail-poster {
width: 200px;
border-radius: var(--radius);
flex-shrink: 0;
object-fit: cover;
}
.tv-detail-info { flex: 1; min-width: 0; }
.tv-detail-genres { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.5rem; }
.tv-detail-year { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 0.3rem; }
.tv-detail-overview {
font-size: 0.9rem;
line-height: 1.6;
color: #ccc;
max-height: 8rem;
overflow: hidden;
margin-bottom: 1rem;
}
.tv-detail-actions { margin-top: 1rem; }
/* Play-Button (gross, fuer TV) */
.tv-play-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.8rem 2rem;
background: var(--accent);
color: #000;
font-size: 1.1rem;
font-weight: 700;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.2s;
}
.tv-play-btn:hover, .tv-play-btn:focus {
background: var(--accent-hover);
outline: var(--focus-ring);
outline-offset: 4px;
}
/* === Staffel-Tabs === */
.tv-tabs {
display: flex;
gap: 0.3rem;
margin-bottom: 1rem;
overflow-x: auto;
padding-bottom: 4px;
}
.tv-tab {
padding: 0.5rem 1.2rem;
background: var(--bg-card);
color: var(--text-muted);
border: 1px solid #333;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
white-space: nowrap;
transition: background 0.2s, color 0.2s;
}
.tv-tab:hover, .tv-tab:focus { background: var(--bg-hover); color: var(--text); }
.tv-tab.active { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
/* === Episoden-Liste === */
.tv-episode-list { display: flex; flex-direction: column; gap: 2px; }
.tv-episode {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.8rem 1rem;
background: var(--bg-card);
border-radius: var(--radius);
transition: background 0.2s;
color: var(--text);
}
.tv-episode:hover, .tv-episode:focus { background: var(--bg-hover); }
.tv-episode:focus { outline: var(--focus-ring); outline-offset: -2px; }
.tv-episode-num { color: var(--text-muted); font-weight: 600; min-width: 3rem; font-size: 0.9rem; }
.tv-episode-title { flex: 1; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tv-episode-meta { color: var(--text-muted); font-size: 0.8rem; white-space: nowrap; }
.tv-episode-play { color: var(--accent); font-size: 1.2rem; }
/* === Suche === */
.tv-search-form { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.tv-search-input {
flex: 1;
padding: 0.8rem 1rem;
background: var(--bg-input);
border: 1px solid #333;
border-radius: var(--radius);
color: var(--text);
font-size: 1rem;
}
.tv-search-input:focus { border-color: var(--accent); outline: none; }
.tv-search-btn {
padding: 0.8rem 1.5rem;
background: var(--accent);
color: #000;
border: none;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
/* === Empty State === */
.tv-empty {
text-align: center;
color: var(--text-muted);
padding: 3rem 1rem;
font-size: 1rem;
}
/* === Login === */
.login-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-container { width: 100%; max-width: 400px; padding: 1rem; }
.login-card {
background: var(--bg-card);
border-radius: 12px;
padding: 2.5rem;
text-align: center;
}
.login-title { font-size: 1.8rem; font-weight: 700; margin-bottom: 0.2rem; }
.login-subtitle { color: var(--text-muted); margin-bottom: 1.5rem; font-size: 1rem; }
.login-error {
background: rgba(239, 83, 80, 0.15);
color: var(--danger);
padding: 0.6rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.login-form { text-align: left; }
.login-field { margin-bottom: 1rem; }
.login-field label { display: block; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.3rem; }
.login-field input {
width: 100%;
padding: 0.8rem 1rem;
background: var(--bg-input);
border: 1px solid #333;
border-radius: var(--radius);
color: var(--text);
font-size: 1rem;
}
.login-field input:focus { border-color: var(--accent); outline: none; }
.login-btn {
width: 100%;
padding: 0.9rem;
background: var(--accent);
color: #000;
border: none;
border-radius: var(--radius);
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
margin-top: 0.5rem;
}
.login-btn:hover, .login-btn:focus { background: var(--accent-hover); }
/* === Video-Player === */
.player-body {
background: #000;
overflow: hidden;
}
.player-wrapper {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background: #000;
}
.player-wrapper video {
flex: 1;
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.player-header {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: 1rem 1.5rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.8), transparent);
display: flex;
align-items: center;
gap: 1rem;
z-index: 10;
transition: opacity 0.3s;
}
.player-back {
color: var(--text);
font-size: 1rem;
padding: 0.4rem 0.8rem;
border-radius: var(--radius);
}
.player-back:focus { outline: var(--focus-ring); }
.player-title {
color: var(--text);
font-size: 1rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem 1.5rem 1rem;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
z-index: 10;
transition: opacity 0.3s;
}
.player-progress {
height: 6px;
background: rgba(255,255,255,0.2);
border-radius: 3px;
cursor: pointer;
margin-bottom: 0.5rem;
}
.player-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.2s;
pointer-events: none;
}
.player-buttons {
display: flex;
align-items: center;
gap: 0.8rem;
}
.player-btn {
background: none;
border: none;
color: var(--text);
font-size: 1.4rem;
cursor: pointer;
padding: 0.4rem;
border-radius: var(--radius);
}
.player-btn:focus { outline: var(--focus-ring); }
.player-time { color: var(--text-muted); font-size: 0.85rem; }
.player-spacer { flex: 1; }
/* Controls ausblenden nach Inaktivitaet */
.player-hide-controls .player-header,
.player-hide-controls .player-controls {
opacity: 0;
pointer-events: none;
}
/* === Responsive === */
@media (max-width: 768px) {
.tv-nav { padding: 0.4rem 0.8rem; }
.tv-nav-item { padding: 0.4rem 0.6rem; font-size: 0.85rem; }
.tv-main { padding: 1rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.tv-row .tv-card { width: 140px; }
.tv-row .tv-card-wide { width: 200px; }
.tv-detail-header { flex-direction: column; }
.tv-detail-poster { width: 150px; }
.tv-page-title { font-size: 1.3rem; }
.tv-nav-user { display: none; }
}
@media (max-width: 480px) {
.tv-nav-links { gap: 0; }
.tv-nav-item { padding: 0.3rem 0.5rem; font-size: 0.8rem; }
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
.tv-row .tv-card { width: 120px; }
.tv-detail-poster { width: 120px; }
}
/* TV/Desktop (grosse Bildschirme) */
@media (min-width: 1280px) {
.tv-grid { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.tv-row .tv-card { width: 200px; }
.tv-row .tv-card-wide { width: 300px; }
.tv-episode { padding: 1rem 1.5rem; }
.tv-play-btn { padding: 1rem 3rem; font-size: 1.3rem; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,276 @@
/**
* VideoKonverter TV - Video-Player
* Fullscreen-Player mit Tastatur/Fernbedienung-Steuerung
* Speichert Watch-Progress automatisch
*/
let videoEl = null;
let videoId = 0;
let videoDuration = 0;
let progressBar = null;
let timeDisplay = null;
let playBtn = null;
let controlsTimer = null;
let saveTimer = null;
let controlsVisible = true;
/**
* Player initialisieren
* @param {number} id - Video-ID
* @param {number} startPos - Startposition in Sekunden
* @param {number} duration - Video-Dauer in Sekunden (Fallback)
*/
function initPlayer(id, startPos, duration) {
videoId = id;
videoDuration = duration;
videoEl = document.getElementById("player-video");
progressBar = document.getElementById("player-progress-bar");
timeDisplay = document.getElementById("player-time");
playBtn = document.getElementById("btn-play");
if (!videoEl) return;
// Stream-URL setzen (ffmpeg-Transcoding Endpoint)
const streamUrl = `/api/library/videos/${id}/stream` +
(startPos > 0 ? `?t=${Math.floor(startPos)}` : "");
videoEl.src = streamUrl;
// Events
videoEl.addEventListener("timeupdate", onTimeUpdate);
videoEl.addEventListener("play", onPlay);
videoEl.addEventListener("pause", onPause);
videoEl.addEventListener("ended", onEnded);
videoEl.addEventListener("loadedmetadata", () => {
if (videoEl.duration && isFinite(videoEl.duration)) {
videoDuration = videoEl.duration;
}
});
// Klick auf Video -> Play/Pause
videoEl.addEventListener("click", togglePlay);
// Controls UI
playBtn.addEventListener("click", togglePlay);
document.getElementById("btn-fullscreen").addEventListener("click", toggleFullscreen);
// Progress-Bar klickbar fuer Seeking
document.getElementById("player-progress").addEventListener("click", onProgressClick);
// Tastatur-Steuerung
document.addEventListener("keydown", onKeyDown);
// Maus/Touch-Bewegung -> Controls anzeigen
document.addEventListener("mousemove", showControls);
document.addEventListener("touchstart", showControls);
// Controls nach 4 Sekunden ausblenden
scheduleHideControls();
// Watch-Progress alle 10 Sekunden speichern
saveTimer = setInterval(saveProgress, 10000);
}
// === Playback-Controls ===
function togglePlay() {
if (!videoEl) return;
if (videoEl.paused) {
videoEl.play();
} else {
videoEl.pause();
}
}
function onPlay() {
if (playBtn) playBtn.innerHTML = "&#10074;&#10074;"; // Pause-Symbol
scheduleHideControls();
}
function onPause() {
if (playBtn) playBtn.innerHTML = "&#9654;"; // Play-Symbol
showControls();
// Sofort speichern bei Pause
saveProgress();
}
function onEnded() {
// Video fertig -> als "completed" speichern
saveProgress(true);
// Zurueck navigieren nach 2 Sekunden
setTimeout(() => {
window.history.back();
}, 2000);
}
// === Seeking ===
function seekRelative(seconds) {
if (!videoEl) return;
const newTime = Math.max(0, Math.min(
videoEl.currentTime + seconds,
videoEl.duration || videoDuration
));
// Neue Stream-URL mit Zeitstempel
const wasPlaying = !videoEl.paused;
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
if (wasPlaying) videoEl.play();
showControls();
}
function onProgressClick(e) {
if (!videoEl) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const dur = videoEl.duration || videoDuration;
if (!dur) return;
const newTime = pct * dur;
// Neue Stream-URL mit Zeitstempel
const wasPlaying = !videoEl.paused;
videoEl.src = `/api/library/videos/${videoId}/stream?t=${Math.floor(newTime)}`;
if (wasPlaying) videoEl.play();
showControls();
}
// === Zeit-Anzeige und Progress ===
function onTimeUpdate() {
if (!videoEl) return;
const current = videoEl.currentTime;
const dur = videoEl.duration || videoDuration;
// Progress-Bar
if (progressBar && dur > 0) {
progressBar.style.width = ((current / dur) * 100) + "%";
}
// Zeit-Anzeige
if (timeDisplay) {
timeDisplay.textContent = formatTime(current) + " / " + formatTime(dur);
}
}
function formatTime(sec) {
if (!sec || !isFinite(sec)) return "0:00";
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (h > 0) {
return h + ":" + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");
}
return m + ":" + String(s).padStart(2, "0");
}
// === Controls Ein-/Ausblenden ===
function showControls() {
const wrapper = document.getElementById("player-wrapper");
if (wrapper) wrapper.classList.remove("player-hide-controls");
controlsVisible = true;
scheduleHideControls();
}
function hideControls() {
if (!videoEl || videoEl.paused) return;
const wrapper = document.getElementById("player-wrapper");
if (wrapper) wrapper.classList.add("player-hide-controls");
controlsVisible = false;
}
function scheduleHideControls() {
if (controlsTimer) clearTimeout(controlsTimer);
controlsTimer = setTimeout(hideControls, 4000);
}
// === Fullscreen ===
function toggleFullscreen() {
const wrapper = document.getElementById("player-wrapper");
if (!document.fullscreenElement) {
(wrapper || document.documentElement).requestFullscreen().catch(() => {});
} else {
document.exitFullscreen().catch(() => {});
}
}
// === Tastatur-Steuerung ===
function onKeyDown(e) {
// Samsung Tizen Remote Keys
const keyMap = {
10009: "Escape",
10182: "Escape",
415: "Play",
19: "Pause",
413: "Stop",
417: "FastForward",
412: "Rewind",
};
const key = keyMap[e.keyCode] || e.key;
switch (key) {
case " ":
case "Enter":
case "Play":
case "Pause":
togglePlay();
e.preventDefault();
break;
case "ArrowLeft":
case "Rewind":
seekRelative(-10);
e.preventDefault();
break;
case "ArrowRight":
case "FastForward":
seekRelative(10);
e.preventDefault();
break;
case "ArrowUp":
// Lautstaerke hoch (falls vom Browser unterstuetzt)
if (videoEl) videoEl.volume = Math.min(1, videoEl.volume + 0.1);
showControls();
e.preventDefault();
break;
case "ArrowDown":
// Lautstaerke runter
if (videoEl) videoEl.volume = Math.max(0, videoEl.volume - 0.1);
showControls();
e.preventDefault();
break;
case "Escape":
case "Backspace":
case "Stop":
// Zurueck navigieren
saveProgress();
setTimeout(() => window.history.back(), 100);
e.preventDefault();
break;
case "f":
toggleFullscreen();
e.preventDefault();
break;
}
}
// === Watch-Progress speichern ===
function saveProgress(completed) {
if (!videoId || !videoEl) return;
const pos = videoEl.currentTime || 0;
const dur = videoEl.duration || videoDuration || 0;
if (pos < 5 && !completed) return; // Erst ab 5 Sekunden speichern
fetch("/tv/api/watch-progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_id: videoId,
position_sec: pos,
duration_sec: dur,
}),
}).catch(() => {}); // Fehler ignorieren (nicht kritisch)
}
// Beim Verlassen der Seite speichern
window.addEventListener("beforeunload", () => saveProgress());

View file

@ -0,0 +1,235 @@
/**
* VideoKonverter TV - Focus-Manager und Navigation
* D-Pad Navigation fuer TV-Fernbedienungen (Samsung Tizen, Android TV)
* + Lazy-Loading fuer Poster-Bilder
*/
// === Focus-Manager ===
class FocusManager {
constructor() {
this._enabled = true;
this._currentFocus = null;
// Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e));
// Initiales Focus-Element setzen
requestAnimationFrame(() => this._initFocus());
}
_initFocus() {
// Erstes fokussierbares Element finden (nicht autofocus Inputs)
const autofocusEl = document.querySelector("[autofocus]");
if (autofocusEl) {
autofocusEl.focus();
return;
}
const first = document.querySelector("[data-focusable]");
if (first) first.focus();
}
_onKeyDown(e) {
if (!this._enabled) return;
// Samsung Tizen Remote Key-Codes mappen
const keyMap = {
37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown",
13: "Enter", 27: "Escape", 8: "Backspace",
// Samsung Tizen spezifisch
10009: "Escape", // RETURN-Taste
10182: "Escape", // EXIT-Taste
};
const key = keyMap[e.keyCode] || e.key;
switch (key) {
case "ArrowUp":
case "ArrowDown":
case "ArrowLeft":
case "ArrowRight":
this._navigate(key, e);
break;
case "Enter":
this._activate(e);
break;
case "Escape":
case "Backspace":
this._goBack(e);
break;
}
}
_navigate(direction, e) {
const active = document.activeElement;
// Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation)
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (direction === "ArrowLeft" || direction === "ArrowRight") return;
}
const focusables = this._getFocusableElements();
if (!focusables.length) return;
// Aktuelles Element
const currentIdx = focusables.indexOf(active);
if (currentIdx === -1) {
// Kein fokussiertes Element -> erstes waehlen
focusables[0].focus();
e.preventDefault();
return;
}
// Naechstes Element in Richtung finden (Nearest-Neighbor)
const current = active.getBoundingClientRect();
const cx = current.left + current.width / 2;
const cy = current.top + current.height / 2;
let bestEl = null;
let bestDist = Infinity;
for (const el of focusables) {
if (el === active) continue;
const rect = el.getBoundingClientRect();
// Element muss sichtbar sein
if (rect.width === 0 || rect.height === 0) continue;
const ex = rect.left + rect.width / 2;
const ey = rect.top + rect.height / 2;
// Pruefen ob Element in der richtigen Richtung liegt
const dx = ex - cx;
const dy = ey - cy;
let valid = false;
switch (direction) {
case "ArrowUp": valid = dy < -5; break;
case "ArrowDown": valid = dy > 5; break;
case "ArrowLeft": valid = dx < -5; break;
case "ArrowRight": valid = dx > 5; break;
}
if (!valid) continue;
// Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr)
let dist;
if (direction === "ArrowUp" || direction === "ArrowDown") {
dist = Math.abs(dy) + Math.abs(dx) * 3;
} else {
dist = Math.abs(dx) + Math.abs(dy) * 3;
}
if (dist < bestDist) {
bestDist = dist;
bestEl = el;
}
}
if (bestEl) {
bestEl.focus();
// Ins Sichtfeld scrollen
bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
e.preventDefault();
}
}
_activate(e) {
const active = document.activeElement;
if (!active || active === document.body) return;
// Links, Buttons -> Click ausfuehren
if (active.tagName === "A" || active.tagName === "BUTTON") {
// Natuerliches Enter-Verhalten beibehalten
return;
}
// Andere fokussierbare Elemente -> Click simulieren
if (active.hasAttribute("data-focusable")) {
active.click();
e.preventDefault();
}
}
_goBack(e) {
const active = document.activeElement;
// In Input-Feldern: Escape = Blur, Backspace = natuerlich
if (active && active.tagName === "INPUT") {
if (e.key === "Escape") {
active.blur();
e.preventDefault();
}
return;
}
// Zurueck navigieren
if (window.history.length > 1) {
window.history.back();
e.preventDefault();
}
}
_getFocusableElements() {
// Alle sichtbaren fokussierbaren Elemente
const elements = document.querySelectorAll("[data-focusable]");
return Array.from(elements).filter(el => {
if (el.offsetParent === null && el.style.position !== "fixed") return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
}
// === Horizontale Scroll-Reihen: Scroll per Pfeiltaste ===
function initRowScroll() {
document.querySelectorAll(".tv-row").forEach(row => {
// Maus-Rad horizontal scrollen
row.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
row.scrollLeft += e.deltaY;
}
}, { passive: false });
});
}
// === Lazy-Loading fuer Poster (IntersectionObserver) ===
function initLazyLoad() {
// Browser-natives loading="lazy" wird bereits verwendet
// Zusaetzlich: Placeholder-Klasse entfernen nach Laden
document.querySelectorAll("img.tv-card-img").forEach(img => {
if (img.complete) return;
img.style.opacity = "0";
img.style.transition = "opacity 0.3s";
img.addEventListener("load", () => {
img.style.opacity = "1";
}, { once: true });
img.addEventListener("error", () => {
// Fehlerhaftes Bild: Placeholder anzeigen
img.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "tv-card-placeholder";
placeholder.textContent = img.alt || "?";
img.parentNode.insertBefore(placeholder, img);
}, { once: true });
});
}
// === Navigation: Aktiven Tab highlighten ===
function initNavHighlight() {
const path = window.location.pathname;
document.querySelectorAll(".tv-nav-item").forEach(item => {
const href = item.getAttribute("href");
if (href === path || (href !== "/tv/" && path.startsWith(href))) {
item.classList.add("active");
}
});
}
// === Init ===
document.addEventListener("DOMContentLoaded", () => {
window.focusManager = new FocusManager();
initRowScroll();
initLazyLoad();
initNavHighlight();
});

View file

@ -0,0 +1,23 @@
{
"name": "VideoKonverter TV",
"short_name": "VK TV",
"description": "Video-Streaming aus deiner Bibliothek",
"start_url": "/tv/",
"scope": "/tv/",
"display": "standalone",
"orientation": "any",
"background_color": "#0f0f0f",
"theme_color": "#0f0f0f",
"icons": [
{
"src": "/static/tv/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/tv/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View file

@ -0,0 +1,68 @@
/**
* VideoKonverter TV - Service Worker (minimal)
* Ermoeglicht PWA-Installation auf Handys und Tablets
* Kein Offline-Caching noetig (Streaming braucht Netzwerk)
*/
const CACHE_NAME = "vk-tv-v1";
const STATIC_ASSETS = [
"/static/tv/css/tv.css",
"/static/tv/js/tv.js",
"/static/tv/js/player.js",
"/static/tv/icons/icon-192.png",
];
// Installation: Statische Assets cachen
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(STATIC_ASSETS))
.then(() => self.skipWaiting())
);
});
// Aktivierung: Alte Caches aufraemen
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(
keys.filter(k => k !== CACHE_NAME)
.map(k => caches.delete(k))
))
.then(() => self.clients.claim())
);
});
// Fetch: Network-First Strategie (Streaming braucht immer Netzwerk)
self.addEventListener("fetch", (event) => {
// Nur GET-Requests cachen
if (event.request.method !== "GET") return;
// Streaming/API nie cachen
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/") || url.pathname.includes("/stream")) {
return;
}
// Statische Assets: Cache-First
if (url.pathname.startsWith("/static/tv/")) {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, clone));
return response;
})
)
);
return;
}
// Alles andere: Network-First
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});

View file

@ -243,6 +243,55 @@
</div>
</section>
<!-- TV-App / Streaming -->
<section class="admin-section">
<h2>TV-App / Streaming</h2>
<div style="display:flex;gap:2rem;flex-wrap:wrap">
<!-- QR-Code -->
<div style="text-align:center">
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
<div style="margin-top:0.3rem">
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
</div>
</div>
<!-- User-Verwaltung -->
<div style="flex:1;min-width:300px">
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
<div id="tv-users-list">
<div class="loading-msg">Lade Benutzer...</div>
</div>
<!-- Neuer User -->
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
<div class="form-grid">
<div class="form-group">
<label>Benutzername</label>
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
</div>
<div class="form-group">
<label>Anzeigename</label>
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
</div>
<div class="form-group">
<label>Passwort</label>
<input type="password" id="tv-new-password" placeholder="Passwort">
</div>
<div class="form-group">
<label>Rechte</label>
<div style="display:flex;flex-direction:column;gap:0.3rem">
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
</div>
</div>
</div>
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
</div>
</div>
</div>
</section>
<!-- Presets -->
<section class="admin-section">
<h2>Encoding-Presets</h2>
@ -338,6 +387,155 @@ function scanPath(pathId) {
.catch(e => showToast("Fehler: " + e, "error"));
}
document.addEventListener("DOMContentLoaded", loadLibraryPaths);
// === TV-App User-Verwaltung ===
function tvLoadUsers() {
fetch("/api/tv/users")
.then(r => r.json())
.then(data => {
const container = document.getElementById("tv-users-list");
const users = data.users || [];
if (!users.length) {
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
return;
}
container.innerHTML = users.map(u => `
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div>
<strong>${escapeHtml(u.display_name || u.username)}</strong>
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
</div>
<div style="display:flex;gap:0.3rem">
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
</div>
</div>
`).join("");
})
.catch(() => {
document.getElementById("tv-users-list").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
});
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
}
function tvCreateUser() {
const username = document.getElementById("tv-new-username").value.trim();
const displayName = document.getElementById("tv-new-display").value.trim();
const password = document.getElementById("tv-new-password").value;
if (!username || !password) {
showToast("Benutzername und Passwort noetig", "error");
return;
}
fetch("/api/tv/users", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
username: username,
password: password,
display_name: displayName || username,
is_admin: document.getElementById("tv-new-admin").checked,
can_view_series: document.getElementById("tv-new-series").checked,
can_view_movies: document.getElementById("tv-new-movies").checked,
}),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
document.getElementById("tv-new-username").value = "";
document.getElementById("tv-new-display").value = "";
document.getElementById("tv-new-password").value = "";
showToast("Benutzer erstellt", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvDeleteUser(userId, username) {
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
fetch("/api/tv/users/" + userId, {method: "DELETE"})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer geloescht", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
async function tvEditUser(userId) {
// User-Daten laden, dann Edit-Dialog anzeigen
const resp = await fetch("/api/tv/users").then(r => r.json());
const user = (resp.users || []).find(u => u.id === userId);
if (!user) return;
const newPass = prompt("Neues Passwort (leer lassen um beizubehalten):");
if (newPass === null) return; // Abgebrochen
const updates = {};
if (newPass) updates.password = newPass;
const newSeries = confirm("Serien anzeigen?");
const newMovies = confirm("Filme anzeigen?");
const newAdmin = confirm("Admin-Rechte?");
updates.can_view_series = newSeries;
updates.can_view_movies = newMovies;
updates.is_admin = newAdmin;
fetch("/api/tv/users/" + userId, {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(updates),
})
.then(r => r.json())
.then(data => {
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Benutzer aktualisiert", "success");
tvLoadUsers();
}
})
.catch(e => showToast("Fehler: " + e, "error"));
}
// TV-URL laden
function tvLoadUrl() {
fetch("/api/tv/url")
.then(r => r.json())
.then(data => {
const link = document.getElementById("tv-link");
if (link && data.url) {
link.href = data.url;
link.textContent = data.url;
}
})
.catch(() => {});
}
document.addEventListener("DOMContentLoaded", () => {
loadLibraryPaths();
tvLoadUsers();
tvLoadUrl();
});
</script>
{% endblock %}

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0f0f0f">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/static/tv/manifest.json">
<link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png">
<link rel="icon" href="/static/icons/favicon.ico">
<link rel="stylesheet" href="/static/tv/css/tv.css">
<title>{% block title %}VideoKonverter TV{% endblock %}</title>
</head>
<body>
{% if user is defined and user %}
<nav class="tv-nav" id="tv-nav">
<div class="tv-nav-links">
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>Startseite</a>
{% if user.can_view_series %}
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>Serien</a>
{% endif %}
{% if user.can_view_movies %}
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>Filme</a>
{% endif %}
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>Suche</a>
</div>
<div class="tv-nav-right">
<span class="tv-nav-user">{{ user.display_name or user.username }}</span>
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>Abmelden</a>
</div>
</nav>
{% endif %}
<main class="tv-main">
{% block content %}{% endblock %}
</main>
<script src="/static/tv/js/tv.js"></script>
{% block scripts %}{% endblock %}
<script>
// PWA Service Worker registrieren
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/tv/sw.js', {scope: '/tv/'})
.catch(() => {});
}
</script>
</body>
</html>

View file

@ -0,0 +1,87 @@
{% extends "tv/base.html" %}
{% block title %}Startseite - VideoKonverter TV{% endblock %}
{% block content %}
<!-- Weiterschauen -->
{% if continue_watching %}
<section class="tv-section">
<h2 class="tv-section-title">Weiterschauen</h2>
<div class="tv-row">
{% for item in continue_watching %}
<a href="/tv/player?v={{ item.video_id }}" class="tv-card tv-card-wide" data-focusable>
{% if item.series_poster %}
<img src="{{ item.series_poster }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">&#9654;</div>
{% endif %}
<div class="tv-card-progress">
<div class="tv-card-progress-bar"
style="width:{{ ((item.position_sec / item.duration_sec) * 100) if item.duration_sec else 0 }}%"></div>
</div>
<div class="tv-card-info">
<span class="tv-card-title">{{ item.series_title or item.file_name }}</span>
<span class="tv-card-meta">{{ item.file_name }}</span>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Serien -->
{% if series %}
<section class="tv-section">
<div class="tv-section-header">
<h2 class="tv-section-title">Serien</h2>
<a href="/tv/series" class="tv-section-more" data-focusable>Alle anzeigen</a>
</div>
<div class="tv-row">
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden</span>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
<!-- Filme -->
{% if movies %}
<section class="tv-section">
<div class="tv-section-header">
<h2 class="tv-section-title">Filme</h2>
<a href="/tv/movies" class="tv-section-more" data-focusable>Alle anzeigen</a>
</div>
<div class="tv-row">
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}
{% if not series and not movies %}
<div class="tv-empty">
<p>Noch keine Inhalte in der Bibliothek.</p>
<p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0f0f0f">
<link rel="stylesheet" href="/static/tv/css/tv.css">
<title>Login - VideoKonverter TV</title>
</head>
<body class="login-body">
<div class="login-container">
<div class="login-card">
<h1 class="login-title">VideoKonverter</h1>
<p class="login-subtitle">TV-Streaming</p>
{% if error %}
<div class="login-error">{{ error }}</div>
{% endif %}
<form method="POST" action="/tv/login" class="login-form">
<div class="login-field">
<label for="username">Benutzername</label>
<input type="text" id="username" name="username"
autocomplete="username" autofocus
data-focusable required>
</div>
<div class="login-field">
<label for="password">Passwort</label>
<input type="password" id="password" name="password"
autocomplete="current-password"
data-focusable required>
</div>
<button type="submit" class="login-btn" data-focusable>
Anmelden
</button>
</form>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,49 @@
{% extends "tv/base.html" %}
{% block title %}{{ movie.title or movie.folder_name }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<div class="tv-detail-header">
{% if movie.poster_url %}
<img src="{{ movie.poster_url }}" alt="" class="tv-detail-poster">
{% endif %}
<div class="tv-detail-info">
<h1 class="tv-page-title">{{ movie.title or movie.folder_name }}</h1>
{% if movie.year %}
<p class="tv-detail-year">{{ movie.year }}</p>
{% endif %}
{% if movie.genres %}
<p class="tv-detail-genres">{{ movie.genres }}</p>
{% endif %}
{% if movie.overview %}
<p class="tv-detail-overview">{{ movie.overview }}</p>
{% endif %}
{% if videos %}
<div class="tv-detail-actions">
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
&#9654; Abspielen
</a>
</div>
{% endif %}
</div>
</div>
{% if videos|length > 1 %}
<h3 class="tv-section-title">Versionen</h3>
<div class="tv-episode-list">
{% for v in videos %}
<a href="/tv/player?v={{ v.id }}" class="tv-episode" data-focusable>
<span class="tv-episode-title">{{ v.file_name }}</span>
<span class="tv-episode-meta">
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
{% if v.width %} &middot; {{ v.width }}x{{ v.height }}{% endif %}
&middot; {{ v.container|upper }}
</span>
<span class="tv-episode-play">&#9654;</span>
</a>
{% endfor %}
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "tv/base.html" %}
{% block title %}Filme - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">Filme</h1>
<div class="tv-grid">
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
{% if not movies %}
<div class="tv-empty">Keine Filme vorhanden.</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#000000">
<link rel="stylesheet" href="/static/tv/css/tv.css">
<title>{{ title }} - VideoKonverter TV</title>
</head>
<body class="player-body">
<div class="player-wrapper" id="player-wrapper">
<!-- Header (ausblendbar) -->
<div class="player-header" id="player-header">
<a href="javascript:history.back()" class="player-back" data-focusable>&#10094; Zurueck</a>
<span class="player-title">{{ title }}</span>
</div>
<!-- Video -->
<video id="player-video" autoplay playsinline>
Dein Browser unterstuetzt kein HTML5-Video.
</video>
<!-- Controls (ausblendbar) -->
<div class="player-controls" id="player-controls">
<div class="player-progress" id="player-progress">
<div class="player-progress-bar" id="player-progress-bar"></div>
</div>
<div class="player-buttons">
<button class="player-btn" id="btn-play" data-focusable>&#9654;</button>
<span class="player-time" id="player-time">0:00 / 0:00</span>
<span class="player-spacer"></span>
<button class="player-btn" id="btn-fullscreen" data-focusable>&#9974;</button>
</div>
</div>
</div>
<script src="/static/tv/js/player.js"></script>
<script>
initPlayer({{ video.id }}, {{ start_pos }}, {{ video.duration_sec or 0 }});
</script>
</body>
</html>

View file

@ -0,0 +1,59 @@
{% extends "tv/base.html" %}
{% block title %}Suche - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">Suche</h1>
<form action="/tv/search" method="GET" class="tv-search-form">
<input type="text" name="q" value="{{ query }}"
placeholder="Serie oder Film suchen..."
class="tv-search-input" data-focusable autofocus>
<button type="submit" class="tv-search-btn" data-focusable>Suchen</button>
</form>
{% if query %}
<!-- Serien-Ergebnisse -->
{% if series %}
<h2 class="tv-section-title">Serien ({{ series|length }})</h2>
<div class="tv-grid">
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
<!-- Film-Ergebnisse -->
{% if movies %}
<h2 class="tv-section-title">Filme ({{ movies|length }})</h2>
<div class="tv-grid">
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ m.title or m.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
<span class="tv-card-meta">{{ m.year or "" }}</span>
</div>
</a>
{% endfor %}
</div>
{% endif %}
{% if not series and not movies %}
<div class="tv-empty">Keine Ergebnisse fuer &laquo;{{ query }}&raquo;</div>
{% endif %}
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends "tv/base.html" %}
{% block title %}Serien - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">Serien</h1>
<div class="tv-grid">
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
{% else %}
<div class="tv-card-placeholder">{{ s.title or s.folder_name }}</div>
{% endif %}
<div class="tv-card-info">
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden{% if s.genres %} &middot; {{ s.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
{% if not series %}
<div class="tv-empty">Keine Serien vorhanden.</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,76 @@
{% extends "tv/base.html" %}
{% block title %}{{ series.title or series.folder_name }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<!-- Serien-Header -->
<div class="tv-detail-header">
{% if series.poster_url %}
<img src="{{ series.poster_url }}" alt="" class="tv-detail-poster">
{% endif %}
<div class="tv-detail-info">
<h1 class="tv-page-title">{{ series.title or series.folder_name }}</h1>
{% if series.genres %}
<p class="tv-detail-genres">{{ series.genres }}</p>
{% endif %}
{% if series.overview %}
<p class="tv-detail-overview">{{ series.overview }}</p>
{% endif %}
</div>
</div>
<!-- Staffel-Tabs -->
{% if seasons %}
<div class="tv-tabs" id="season-tabs">
{% for sn in seasons.keys() %}
<button class="tv-tab {% if loop.first %}active{% endif %}"
data-focusable
onclick="showSeason({{ sn }})">
{% if sn == 0 %}Specials{% else %}Staffel {{ sn }}{% endif %}
</button>
{% endfor %}
</div>
<!-- Episoden pro Staffel -->
{% for sn, episodes in seasons.items() %}
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
<div class="tv-episode-list">
{% for ep in episodes %}
<a href="/tv/player?v={{ ep.id }}" class="tv-episode" data-focusable>
<span class="tv-episode-num">
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% else %}-{% endif %}
</span>
<span class="tv-episode-title">
{{ ep.episode_title or ep.file_name }}
</span>
<span class="tv-episode-meta">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
{% if ep.width %} &middot; {{ ep.width }}x{{ ep.height }}{% endif %}
</span>
<span class="tv-episode-play">&#9654;</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="tv-empty">Keine Episoden vorhanden.</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
function showSeason(sn) {
// Alle Staffeln verstecken
document.querySelectorAll('.tv-season').forEach(el => el.style.display = 'none');
// Alle Tabs deaktivieren
document.querySelectorAll('.tv-tab').forEach(el => el.classList.remove('active'));
// Gewaehlte Staffel anzeigen
const season = document.getElementById('season-' + sn);
if (season) season.style.display = '';
// Tab aktivieren
event.target.classList.add('active');
}
</script>
{% endblock %}

View file

@ -4,3 +4,5 @@ jinja2>=3.1.0
PyYAML>=6.0
aiomysql>=0.2.0
tvdb-v4-official>=1.1.0
bcrypt>=4.0
qrcode[pil]>=7.0