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:
parent
0ebe600215
commit
99730f2f8f
29 changed files with 3141 additions and 2 deletions
|
|
@ -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
157
tizen-app/INSTALL.md
Normal 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
|
||||
BIN
tizen-app/VideoKonverter.wgt
Normal file
BIN
tizen-app/VideoKonverter.wgt
Normal file
Binary file not shown.
30
tizen-app/config.xml
Normal file
30
tizen-app/config.xml
Normal 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
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
136
tizen-app/index.html
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
594
video-konverter/app/routes/tv_api.py
Normal file
594
video-konverter/app/routes/tv_api.py
Normal 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)
|
||||
|
|
@ -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)"
|
||||
)
|
||||
|
|
|
|||
393
video-konverter/app/services/auth.py
Normal file
393
video-konverter/app/services/auth.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
471
video-konverter/app/static/tv/css/tv.css
Normal file
471
video-konverter/app/static/tv/css/tv.css
Normal 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; }
|
||||
}
|
||||
BIN
video-konverter/app/static/tv/icons/icon-192.png
Normal file
BIN
video-konverter/app/static/tv/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
video-konverter/app/static/tv/icons/icon-512.png
Normal file
BIN
video-konverter/app/static/tv/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
276
video-konverter/app/static/tv/js/player.js
Normal file
276
video-konverter/app/static/tv/js/player.js
Normal 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 = "❚❚"; // Pause-Symbol
|
||||
scheduleHideControls();
|
||||
}
|
||||
|
||||
function onPause() {
|
||||
if (playBtn) playBtn.innerHTML = "▶"; // 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());
|
||||
235
video-konverter/app/static/tv/js/tv.js
Normal file
235
video-konverter/app/static/tv/js/tv.js
Normal 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();
|
||||
});
|
||||
23
video-konverter/app/static/tv/manifest.json
Normal file
23
video-konverter/app/static/tv/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
68
video-konverter/app/static/tv/sw.js
Normal file
68
video-konverter/app/static/tv/sw.js
Normal 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))
|
||||
);
|
||||
});
|
||||
|
|
@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
}
|
||||
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 %}
|
||||
|
|
|
|||
51
video-konverter/app/templates/tv/base.html
Normal file
51
video-konverter/app/templates/tv/base.html
Normal 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>
|
||||
87
video-konverter/app/templates/tv/home.html
Normal file
87
video-konverter/app/templates/tv/home.html
Normal 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">▶</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 %} · {{ 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 %}
|
||||
40
video-konverter/app/templates/tv/login.html
Normal file
40
video-konverter/app/templates/tv/login.html
Normal 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>
|
||||
49
video-konverter/app/templates/tv/movie_detail.html
Normal file
49
video-konverter/app/templates/tv/movie_detail.html
Normal 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>
|
||||
▶ 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 %} · {{ v.width }}x{{ v.height }}{% endif %}
|
||||
· {{ v.container|upper }}
|
||||
</span>
|
||||
<span class="tv-episode-play">▶</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
26
video-konverter/app/templates/tv/movies.html
Normal file
26
video-konverter/app/templates/tv/movies.html
Normal 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 %} · {{ m.genres }}{% endif %}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not movies %}
|
||||
<div class="tv-empty">Keine Filme vorhanden.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
42
video-konverter/app/templates/tv/player.html
Normal file
42
video-konverter/app/templates/tv/player.html
Normal 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>❮ 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>▶</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>⛶</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>
|
||||
59
video-konverter/app/templates/tv/search.html
Normal file
59
video-konverter/app/templates/tv/search.html
Normal 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 «{{ query }}»</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
26
video-konverter/app/templates/tv/series.html
Normal file
26
video-konverter/app/templates/tv/series.html
Normal 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 %} · {{ s.genres }}{% endif %}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if not series %}
|
||||
<div class="tv-empty">Keine Serien vorhanden.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
76
video-konverter/app/templates/tv/series_detail.html
Normal file
76
video-konverter/app/templates/tv/series_detail.html
Normal 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 %} · {{ ep.width }}x{{ ep.height }}{% endif %}
|
||||
</span>
|
||||
<span class="tv-episode-play">▶</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 %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue