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
|
PyYAML>=6.0
|
||||||
aiomysql>=0.2.0
|
aiomysql>=0.2.0
|
||||||
tvdb-v4-official>=1.1.0
|
tvdb-v4-official>=1.1.0
|
||||||
|
bcrypt>=4.0
|
||||||
|
qrcode[pil]>=7.0
|
||||||
|
|
|
||||||
157
tizen-app/INSTALL.md
Normal file
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)
|
ws_log_handler.setLevel(logging.INFO)
|
||||||
logging.getLogger().addHandler(ws_log_handler)
|
logging.getLogger().addHandler(ws_log_handler)
|
||||||
|
|
||||||
|
# --- Server-Log lesen ---
|
||||||
|
|
||||||
|
async def get_log(request: web.Request) -> web.Response:
|
||||||
|
"""
|
||||||
|
GET /api/log?lines=100&level=INFO
|
||||||
|
Gibt die letzten N Zeilen des Server-Logs zurueck.
|
||||||
|
"""
|
||||||
|
lines = int(request.query.get("lines", 100))
|
||||||
|
level_filter = request.query.get("level", "").upper()
|
||||||
|
lines = min(lines, 5000) # Max 5000 Zeilen
|
||||||
|
|
||||||
|
log_dir = Path(__file__).parent.parent.parent / "logs"
|
||||||
|
log_file = log_dir / "server.log"
|
||||||
|
|
||||||
|
# Fallback: Aus dem logging-Handler lesen
|
||||||
|
log_entries = []
|
||||||
|
|
||||||
|
if log_file.exists():
|
||||||
|
try:
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
all_lines = f.readlines()
|
||||||
|
# Letzte N Zeilen
|
||||||
|
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||||
|
for line in recent:
|
||||||
|
line = line.rstrip()
|
||||||
|
if level_filter and level_filter not in line:
|
||||||
|
continue
|
||||||
|
log_entries.append(line)
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Log lesen fehlgeschlagen: {e}"}, status=500
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Kein Log-File: aus dem MemoryHandler lesen (falls vorhanden)
|
||||||
|
for handler in logging.getLogger().handlers:
|
||||||
|
if isinstance(handler, _MemoryLogHandler):
|
||||||
|
entries = handler.get_entries(lines)
|
||||||
|
for entry in entries:
|
||||||
|
if level_filter and level_filter not in entry:
|
||||||
|
continue
|
||||||
|
log_entries.append(entry)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not log_entries:
|
||||||
|
log_entries.append("Keine Log-Datei gefunden unter: " + str(log_file))
|
||||||
|
|
||||||
|
return web.json_response({
|
||||||
|
"lines": log_entries,
|
||||||
|
"count": len(log_entries),
|
||||||
|
"source": str(log_file) if log_file.exists() else "memory",
|
||||||
|
})
|
||||||
|
|
||||||
|
# In-Memory Log-Handler (fuer Zugriff ohne Datei)
|
||||||
|
class _MemoryLogHandler(logging.Handler):
|
||||||
|
"""Speichert die letzten N Log-Eintraege im Speicher"""
|
||||||
|
def __init__(self, max_entries: int = 2000):
|
||||||
|
super().__init__()
|
||||||
|
self._entries = []
|
||||||
|
self._max = max_entries
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
msg = self.format(record)
|
||||||
|
self._entries.append(msg)
|
||||||
|
if len(self._entries) > self._max:
|
||||||
|
self._entries = self._entries[-self._max:]
|
||||||
|
|
||||||
|
def get_entries(self, n: int = 100) -> list[str]:
|
||||||
|
return self._entries[-n:]
|
||||||
|
|
||||||
|
# Memory-Handler installieren
|
||||||
|
_mem_handler = _MemoryLogHandler(2000)
|
||||||
|
_mem_handler.setLevel(logging.DEBUG)
|
||||||
|
_mem_handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s - %(levelname)s - %(message)s"
|
||||||
|
))
|
||||||
|
logging.getLogger().addHandler(_mem_handler)
|
||||||
|
|
||||||
# --- Routes registrieren ---
|
# --- Routes registrieren ---
|
||||||
|
app.router.add_get("/api/log", get_log)
|
||||||
app.router.add_get("/api/browse", get_browse)
|
app.router.add_get("/api/browse", get_browse)
|
||||||
app.router.add_post("/api/upload", post_upload)
|
app.router.add_post("/api/upload", post_upload)
|
||||||
app.router.add_post("/api/convert", post_convert)
|
app.router.add_post("/api/convert", post_convert)
|
||||||
|
|
|
||||||
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.tvdb import TVDBService
|
||||||
from app.services.cleaner import CleanerService
|
from app.services.cleaner import CleanerService
|
||||||
from app.services.importer import ImporterService
|
from app.services.importer import ImporterService
|
||||||
|
from app.services.auth import AuthService
|
||||||
from app.routes.api import setup_api_routes
|
from app.routes.api import setup_api_routes
|
||||||
from app.routes.library_api import setup_library_routes
|
from app.routes.library_api import setup_library_routes
|
||||||
from app.routes.pages import setup_page_routes
|
from app.routes.pages import setup_page_routes
|
||||||
|
from app.routes.tv_api import setup_tv_routes
|
||||||
|
|
||||||
|
|
||||||
class VideoKonverterServer:
|
class VideoKonverterServer:
|
||||||
|
|
@ -88,6 +90,9 @@ class VideoKonverterServer:
|
||||||
# Seiten Routes
|
# Seiten Routes
|
||||||
setup_page_routes(self.app, self.config, self.queue_service)
|
setup_page_routes(self.app, self.config, self.queue_service)
|
||||||
|
|
||||||
|
# TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert)
|
||||||
|
self.auth_service = None
|
||||||
|
|
||||||
# Statische Dateien
|
# Statische Dateien
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
|
|
@ -140,6 +145,17 @@ class VideoKonverterServer:
|
||||||
await self.tvdb_service.init_db()
|
await self.tvdb_service.init_db()
|
||||||
await self.importer_service.init_db()
|
await self.importer_service.init_db()
|
||||||
|
|
||||||
|
# TV-App Auth-Service initialisieren (braucht DB-Pool)
|
||||||
|
if self.library_service._db_pool:
|
||||||
|
async def _get_pool():
|
||||||
|
return self.library_service._db_pool
|
||||||
|
self.auth_service = AuthService(_get_pool)
|
||||||
|
await self.auth_service.init_db()
|
||||||
|
setup_tv_routes(
|
||||||
|
self.app, self.config,
|
||||||
|
self.auth_service, self.library_service,
|
||||||
|
)
|
||||||
|
|
||||||
host = self.config.server_config.get("host", "0.0.0.0")
|
host = self.config.server_config.get("host", "0.0.0.0")
|
||||||
port = self.config.server_config.get("port", 8080)
|
port = self.config.server_config.get("port", 8080)
|
||||||
logging.info(f"Server bereit auf http://{host}:{port}")
|
logging.info(f"Server bereit auf http://{host}:{port}")
|
||||||
|
|
@ -166,6 +182,7 @@ class VideoKonverterServer:
|
||||||
f" Bibliothek: http://{host}:{port}/library\n"
|
f" Bibliothek: http://{host}:{port}/library\n"
|
||||||
f" Admin: http://{host}:{port}/admin\n"
|
f" Admin: http://{host}:{port}/admin\n"
|
||||||
f" Statistik: http://{host}:{port}/statistics\n"
|
f" Statistik: http://{host}:{port}/statistics\n"
|
||||||
|
f" TV-App: http://{host}:{port}/tv/\n"
|
||||||
f" WebSocket: ws://{host}:{port}/ws\n"
|
f" WebSocket: ws://{host}:{port}/ws\n"
|
||||||
f" API: http://{host}:{port}/api/convert (POST)"
|
f" API: http://{host}:{port}/api/convert (POST)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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() {
|
async function executeImport() {
|
||||||
if (!currentImportJobId) return;
|
if (!currentImportJobId) return;
|
||||||
|
|
||||||
|
// Job-ID merken bevor resetImport() sie loescht
|
||||||
|
const jobId = currentImportJobId;
|
||||||
|
|
||||||
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
|
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
resetImport();
|
resetImport();
|
||||||
|
|
||||||
// Starte Import (non-blocking - Server antwortet sofort)
|
// Starte Import (non-blocking - Server antwortet sofort)
|
||||||
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
|
fetch(`/api/library/import/${jobId}/execute`, {method: "POST"});
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket-Handler fuer Import-Fortschritt
|
// WebSocket-Handler fuer Import-Fortschritt
|
||||||
|
|
|
||||||
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- TV-App / Streaming -->
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>TV-App / Streaming</h2>
|
||||||
|
<div style="display:flex;gap:2rem;flex-wrap:wrap">
|
||||||
|
<!-- QR-Code -->
|
||||||
|
<div style="text-align:center">
|
||||||
|
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
|
||||||
|
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
|
||||||
|
<div style="margin-top:0.3rem">
|
||||||
|
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- User-Verwaltung -->
|
||||||
|
<div style="flex:1;min-width:300px">
|
||||||
|
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
|
||||||
|
<div id="tv-users-list">
|
||||||
|
<div class="loading-msg">Lade Benutzer...</div>
|
||||||
|
</div>
|
||||||
|
<!-- Neuer User -->
|
||||||
|
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
|
||||||
|
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Anzeigename</label>
|
||||||
|
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" id="tv-new-password" placeholder="Passwort">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rechte</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.3rem">
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<h2>Encoding-Presets</h2>
|
<h2>Encoding-Presets</h2>
|
||||||
|
|
@ -338,6 +387,155 @@ function scanPath(pathId) {
|
||||||
.catch(e => showToast("Fehler: " + e, "error"));
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", loadLibraryPaths);
|
// === TV-App User-Verwaltung ===
|
||||||
|
|
||||||
|
function tvLoadUsers() {
|
||||||
|
fetch("/api/tv/users")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const container = document.getElementById("tv-users-list");
|
||||||
|
const users = data.users || [];
|
||||||
|
if (!users.length) {
|
||||||
|
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = users.map(u => `
|
||||||
|
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(u.display_name || u.username)}</strong>
|
||||||
|
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
|
||||||
|
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
|
||||||
|
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
|
||||||
|
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
|
||||||
|
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.3rem">
|
||||||
|
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
|
||||||
|
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById("tv-users-list").innerHTML =
|
||||||
|
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return str.replace(/&/g,"&").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>
|
</script>
|
||||||
{% endblock %}
|
{% 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
|
PyYAML>=6.0
|
||||||
aiomysql>=0.2.0
|
aiomysql>=0.2.0
|
||||||
tvdb-v4-official>=1.1.0
|
tvdb-v4-official>=1.1.0
|
||||||
|
bcrypt>=4.0
|
||||||
|
qrcode[pil]>=7.0
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue