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 PyYAML>=6.0
aiomysql>=0.2.0 aiomysql>=0.2.0
tvdb-v4-official>=1.1.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) ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(ws_log_handler) 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 --- # --- Routes registrieren ---
app.router.add_get("/api/log", get_log)
app.router.add_get("/api/browse", get_browse) app.router.add_get("/api/browse", get_browse)
app.router.add_post("/api/upload", post_upload) app.router.add_post("/api/upload", post_upload)
app.router.add_post("/api/convert", post_convert) 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.tvdb import TVDBService
from app.services.cleaner import CleanerService from app.services.cleaner import CleanerService
from app.services.importer import ImporterService from app.services.importer import ImporterService
from app.services.auth import AuthService
from app.routes.api import setup_api_routes from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes from app.routes.library_api import setup_library_routes
from app.routes.pages import setup_page_routes from app.routes.pages import setup_page_routes
from app.routes.tv_api import setup_tv_routes
class VideoKonverterServer: class VideoKonverterServer:
@ -88,6 +90,9 @@ class VideoKonverterServer:
# Seiten Routes # Seiten Routes
setup_page_routes(self.app, self.config, self.queue_service) 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 # Statische Dateien
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.exists(): if static_dir.exists():
@ -140,6 +145,17 @@ class VideoKonverterServer:
await self.tvdb_service.init_db() await self.tvdb_service.init_db()
await self.importer_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") host = self.config.server_config.get("host", "0.0.0.0")
port = self.config.server_config.get("port", 8080) port = self.config.server_config.get("port", 8080)
logging.info(f"Server bereit auf http://{host}:{port}") logging.info(f"Server bereit auf http://{host}:{port}")
@ -166,6 +182,7 @@ class VideoKonverterServer:
f" Bibliothek: http://{host}:{port}/library\n" f" Bibliothek: http://{host}:{port}/library\n"
f" Admin: http://{host}:{port}/admin\n" f" Admin: http://{host}:{port}/admin\n"
f" Statistik: http://{host}:{port}/statistics\n" f" Statistik: http://{host}:{port}/statistics\n"
f" TV-App: http://{host}:{port}/tv/\n"
f" WebSocket: ws://{host}:{port}/ws\n" f" WebSocket: ws://{host}:{port}/ws\n"
f" API: http://{host}:{port}/api/convert (POST)" 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() { async function executeImport() {
if (!currentImportJobId) return; if (!currentImportJobId) return;
// Job-ID merken bevor resetImport() sie loescht
const jobId = currentImportJobId;
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken // Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
closeImportModal(); closeImportModal();
resetImport(); resetImport();
// Starte Import (non-blocking - Server antwortet sofort) // 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 // 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> </div>
</section> </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 --> <!-- Presets -->
<section class="admin-section"> <section class="admin-section">
<h2>Encoding-Presets</h2> <h2>Encoding-Presets</h2>
@ -338,6 +387,155 @@ function scanPath(pathId) {
.catch(e => showToast("Fehler: " + e, "error")); .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> </script>
{% endblock %} {% 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 PyYAML>=6.0
aiomysql>=0.2.0 aiomysql>=0.2.0
tvdb-v4-official>=1.1.0 tvdb-v4-official>=1.1.0
bcrypt>=4.0
qrcode[pil]>=7.0