Compare commits

...

26 commits
v3.0 ... main

Author SHA1 Message Date
406ba57a2d feat(tv): Serie als gesehen markieren - Button im Header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:28:10 +01:00
764da40447 fix(tv): Gesehen-Button per D-Pad fokussierbar, auf TV sichtbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:26:41 +01:00
f78bfd293b fix(tv): Episoden-Cards vergrößert, Laufzeit-Badge vollständig sichtbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:25:45 +01:00
78dd9ebe03 fix(tv): Alphabet-Sidebar per D-Pad/Fernbedienung navigierbar
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:15:31 +01:00
bce5460fcf fix(tv): Alphabet-Sidebar zeigt nur verfügbare Buchstaben
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 11:07:55 +01:00
26adabe55c docs: Implementierungsplan für TV-App D-Pad Fixes
6 Tasks mit exakten Zeilennummern und Code-Snippets:
1. Alphabet-Sidebar nur verfügbare Buchstaben
2. FocusManager Sidebar-Navigation
3. Episoden-Cards vergrößern
4. Gesehen-Button fokussierbar
5. Serie-als-gesehen Button
6. Finaler Test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:49:25 +01:00
efbda20e42 docs: Spec-Korrekturen nach Review
- Jinja2-Variante entfernt (nur JS-Lösung)
- i18n: bestehenden Key status.mark_series verwenden
- Responsive Breakpoint 1200px berücksichtigt
- opacity-Fix nur für TV (hover:none Media-Query)
- Änderung 2/2b als atomar markiert
- focusin-Handler Sidebar-Ausschluss dokumentiert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:40:29 +01:00
205830f474 docs: Design-Spec für TV-App D-Pad & Usability Fixes
5 Probleme identifiziert und Lösungen spezifiziert:
- Alphabet-Sidebar nicht per Fernbedienung erreichbar
- Obere Buchstaben verdeckt (nur verfügbare rendern)
- Episoden-Cards zu klein / Laufzeit abgeschnitten
- Gesehen-Button nicht fokussierbar (tabindex/-1)
- Kein Serie-als-gesehen-Button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 10:36:06 +01:00
95df4d7a90 feat: VideoKonverter v5.8 - AVPlay-Overlay Fix, Debug-Stats, Focus-Ring Fix
- Tizen: Parent-Frame Transparenz + iframe z-index Fix fuer sichtbare Player-Controls ueber AVPlay
- Tizen: Farbtasten (Rot/Gruen/Gelb/Blau) werden bei aktivem AVPlay an iframe weitergeleitet
- Tizen: AVPlay Debug-Stats (State, Stream-Info, Codec, Bitrate) per postMessage abrufbar
- VKNative Bridge: requestStats() + vknative_stats Handler fuer AVPlay-Monitoring
- Player: Debug-Overlay zeigt AVPlay-spezifische Infos (Blaue Taste auf Fernbedienung)
- CSS: Episoden-Karten Focus-Ring von outline auf box-shadow umgestellt (kein Clipping mehr)
- CSS: Episode-Grid padding fuer Scale-Transform Platz
- SW Cache v16 -> v17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 20:20:01 +01:00
dc9ee15ec3 fix: Tizen config.xml - allow-navigation entfernt (CSP-Bug), v5.7.0
KRITISCHER FIX: <tizen:allow-navigation>*</tizen:allow-navigation> in
config.xml aendert die Content Security Policy und blockiert ALLE inline
<style> und <script> Bloecke. Symptom: App rendert ohne CSS/JS, nur
unstyled HTML-Elemente sichtbar.

Loesung: Tag komplett entfernt, <access origin="*" subdomains="true"/>
reicht fuer iframe/XHR-Zugriff. Warnung als Kommentar hinzugefuegt.

Tizen-App v5.7.0 mit allen v5.5/v5.6 Features:
- Debug-Panel (Gruene Taste), Remote-Logging an /api/tizen-log
- Connecting-Overlay mit Spinner und Timeout
- AVPlay Direct-Play + HLS-Fallback (Surround)
- Transparenter iframe (opacity bleibt 1 bei AVPlay)
- Media-Keys, D-Pad-Weiterleitung, Verbindungs-Reset

Android APK v1.1.0 hinzugefuegt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:12:15 +01:00
956b7b9ac8 feat: VideoKonverter v5.6 - Player-Overlay, Immersive Fullscreen, Audio-Normalisierung
Tizen TV: Transparenter iframe-Overlay statt opacity:0 - Player-Controls
(Progress-Bar, Buttons, Popup-Menue) jetzt sichtbar ueber dem AVPlay-Video.
CSS-Klasse "vknative-playing" macht Hintergruende transparent, AVPlay-Video
scheint durch den iframe hindurch.

Android App: Immersive Sticky Fullscreen mit WindowInsetsControllerCompat.
Status- und Navigationsleiste komplett versteckt, per Swipe vom Rand
temporaer einblendbar.

Audio-Normalisierung (3 Stufen):
- Server-seitig: EBU R128 loudnorm (I=-14 LUFS) im HLS-Transcoding
- Server-seitig: dynaudnorm (dynamische Szenen-Anpassung) im HLS-Transcoding
- Client-seitig: DynamicsCompressorNode im Browser-Player
Alle Optionen konfigurierbar: loudnorm/dynaudnorm im TV Admin-Center,
Audio-Kompressor pro Client in den Einstellungen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:07:04 +01:00
00d8f6b982 feat: VideoKonverter v5.5 - Tizen Remote-Logging, Login D-Pad, Cookie-Fix
Tizen-App v5.5.0:
- Remote-Logging: Console-Override + XHR an /api/tizen-log alle 3s
- Debug-Panel: Gruene Taste toggled scrollbares Log-Panel (unten)
- window.onerror Handler fuer uncaught Errors
- Alle v5.4.2 Features erhalten (Connecting-Overlay, Timeout, IME-Fixes)

Server (tv_api.py):
- POST/GET /api/tizen-log Endpunkte (DB-Tabelle tizen_logs)
- Cookie SameSite-Fix: Tizen iframe bekommt kein SameSite (Lax blockiert)

Login (login.html):
- D-Pad Navigation per postMessage (vknative_keyevent)
- ArrowUp/Down zwischen Feldern, Enter auf Button

Sonstiges:
- base.html: vk_app_loaded postMessage Signal
- sw.js: Cache v14 -> v15
- Altes Docker-Export entfernt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:05:22 +01:00
8302ff953a feat: VideoKonverter v5.3 - Android APK Fix, Tizen HLS-Surround, Native Player Verbesserungen
Android-App v1.2.0:
- Fix: 404-Fehler durch doppelten /tv/tv/ Pfad (URL-Bereinigung in SetupActivity)
- Fix: Kein Ton - AudioAttributes (AUDIO_CONTENT_TYPE_MOVIE + handleAudioFocus)
- Neu: ExoPlayer HLS-Support (playHLS) fuer DTS/TrueHD-Audio Fallback
- Neu: Back-Taste auf Root-Seite -> zurueck zum Setup (Server aendern)
- VKWebViewClient: playHLS in JS-Bridge exponiert

Tizen-App:
- Fix: Tonausfaelle bei Opus 6ch (Akte X) - canDirectPlay blockt Opus >2ch
- Neu: AVPlay HLS-Fallback (playHLS) mit AAC 5.1 Surround-Erhalt
- Neu: Buffer-Konfiguration (setBufferingParam) fuer stabilere Wiedergabe
- VKNative-Bridge v2.0: playHLS in beiden Modi (postMessage + Direct AVPlay)

Player:
- Native-HLS Default Sound auf "surround" (AVPlay/ExoPlayer koennen 5.1)
- PWA Direct-Play, Template-Fixes, UX-Verbesserungen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:18:07 +01:00
78368db582 fix: PWA Direct-Play statt HLS + Template-Fix series_detail
- Browser/PWA nutzt jetzt direkte MP4-Wiedergabe mit Range-Requests
- Codec-Pruefung (H.264/HEVC/AV1) mit automatischem HLS-Fallback
- direct_play_url zur Library Video-Info-Route hinzugefuegt
- Doppeltes endblock in series_detail.html entfernt (500er Fehler)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:19:00 +01:00
0d1619c6c9 feat: VideoKonverter v5.1 - TV-App UX-Verbesserungen, PWA-Fix, Library-Features
- PWA Cookie-Fix: SameSite/Secure je nach Protokoll (HTTP=Lax, HTTPS=None+Secure)
- Samsung Fernbedienung: Media-Key-Registrierung, Return/Back navigiert zurueck
- Post-Play Navigation: Countdown auf naechster Episode nach Wiedergabe-Ende
- Gelbe Staffel-Tabs: Gold-Farbe wenn alle Episoden gesehen
- Episoden Card-Grid: Plex-Style Thumbnail-Grid mit Detail-Panel bei Focus
- Weiche Uebergaenge: Fade-In/Out Animationen fuer Player und Seitenwechsel
- Codec-Badge: AV1/HEVC Badge in Videobibliothek bei komplett konvertierten Serien
- Separate Import-Fortschrittsbalken: Pro Import-Job eigener Balken
- Android APK signiert (v2+v3 Scheme)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:09:28 +01:00
93983cf6ee fix: Tizen-App iframe + Cookie-Fix für Cross-Origin
PROBLEME BEHOBEN:
- Schwarzes Bild beim Video-Abspielen (z-index & iframe-Overlap)
- Login-Cookie wurde nicht gesetzt (Third-Party-Cookie-Blocking)

ÄNDERUNGEN:

Tizen-App (tizen-app/index.html):
- z-index AVPlay von 0 auf 10 erhöht (über iframe)
- iframe wird beim AVPlay-Start ausgeblendet (opacity: 0, pointerEvents: none)
- iframe wird beim AVPlay-Stop wieder eingeblendet
- Fix: <object id="avplayer"> nur im Parent, NICHT im iframe

Player-Template (video-konverter/app/templates/tv/player.html):
- <object id="avplayer"> entfernt (existiert nur im Parent-Frame)
- AVPlay läuft ausschließlich im Tizen-App Parent-Frame

Cookie-Fix (video-konverter/app/routes/tv_api.py):
- SameSite=Lax → SameSite=None (4 Stellen)
- Ermöglicht Session-Cookies im Cross-Origin-iframe
- Login funktioniert jetzt in Tizen-App (tizen:// → http://)

Neue Features:
- VKNative Bridge (vknative-bridge.js): postMessage-Kommunikation iframe ↔ Parent
- AVPlay Bridge (avplay-bridge.js): Legacy Direct-Play Support
- Android-App Scaffolding (android-app/)

TESTERGEBNIS:
-  Login erfolgreich (SameSite=None Cookie)
-  AVPlay Direct-Play funktioniert (samsung-agent/1.1)
-  Bildqualität gut (Hardware-Decoding)
-  Keine Stream-Unterbrechungen
-  Watch-Progress-Tracking funktioniert

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-07 08:36:13 +01:00
e2bf70b280 perf: Performance-Optimierungen + TV-Cover vergroessert
- aiomysql Pool: minsize=2, maxsize=10, pool_recycle=300 (verhindert "gone away")
- Jinja2 Bytecode-Cache + auto_reload=False (3-5x schnelleres Rendering)
- HLS-Segmente: Cache-Header immutable (aggressives Browser-Caching)
- WebSocket: heartbeat=30s (erkennt tote Verbindungen automatisch)
- VAAPI: -low_power 1 fuer h264_vaapi (2-3x schnelleres GPU-Encoding)
- TV-Homepage: Cover um 40% vergroessert (alle Breakpoints)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 20:41:41 +01:00
d61fd5bc04 feat: VideoKonverter v4.3 - Thumbnails, Watch-Status, Transcoding-Settings
Thumbnails:
- Negative Zaehlung gefixt (-23 von 5789): INNER JOIN statt separate COUNT
- Verwaiste Thumbnail-Eintraege werden automatisch bereinigt
- TVDB-Bilder werden lokal heruntergeladen statt extern verlinkt
- Template nutzt nur noch lokale API, keine externen TVDB-URLs
- Cache-Control: Thumbnails werden 7 Tage gecacht (Middleware ueberschreibt nicht mehr)
- Fortschrittsbalken ins globale Progress-System verschoben (Thumbnails + Auto-Match)

Watch-Status:
- Feldnamen-Bug gefixt: position/duration -> position_sec/duration_sec
- saveProgress(completed) setzt Position=Duration bei Video-Ende
- Backend wertet completed-Flag aus

Player:
- Error-Recovery: Auto-Retry bei Video-Fehlern (2x)
- Toast-Benachrichtigungen bei Stream-Fehlern (HLS, Netzwerk, Fallback)
- onPlaying() Reset des Retry-Zaehlers

Transcoding:
- Neue Einstellung "Immer transcodieren" (force_transcode) im TV-Admin
- Erzwingt H.264+AAC Transcoding fuer maximale Client-Kompatibilitaet
- Kein Copy-Modus wenn aktiviert

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 06:30:39 +01:00
4f151de78c feat: VideoKonverter v4.2 - TV Admin-Center, HLS-Streaming, Startseiten-Rubriken
- TV Admin-Center (/tv-admin): HLS-Settings, Session-Monitoring, User-Verwaltung
- HLS-Streaming: ffmpeg .ts-Segmente, hls.js, GPU VAAPI, SIGSTOP/SIGCONT
- Startseite: Rubriken (Weiterschauen, Neu, Serien, Filme, Schon gesehen)
- User-Settings: Startseiten-Rubriken konfigurierbar, Watch-Threshold
- UI: Amber/Gold Accent-Farbe, Focus-Ring-Fix, Player-Buttons einheitlich
- Cache-Busting: ?v= Timestamp auf allen CSS/JS Includes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:57:48 +01:00
75bb5d796d fix: VideoKonverter v4.0.3 - JSON-Import-Fix, Player D-Pad-Navigation, Overlay-Bugfix
- import json in library_api.py ergänzt (fehlte, Video-Info-API crashte)
- Player: D-Pad-Navigation für Samsung TV Fernbedienung eingebaut
- Player: Samsung Farbtasten (Rot=Audio, Grün=Subs, Gelb=Qualität, Blau=Speed)
- Player: Overlay zeigt nur noch die zum Button passende Sektion
- Player: Auto-Fokus beim Öffnen von Overlays

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:20:30 +01:00
e8f2d49949 feat: VideoKonverter v4.0.2 - FocusManager-Fix, Poster-Caching, Performance
- FocusManager: Navigation von Nav-Leiste direkt zu Content-Karten
- Input/Select Editier-Modus: Erst Enter zum Bearbeiten, D-Pad navigiert weiter
- Poster lokal cachen + Pillow-Resize (233KB → 47KB, 80% kleiner)
- Content-Visibility fuer versteckte View-Container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:49:05 +01:00
c7151e8bd1 feat: VideoKonverter v4.0.1 - UX-Verbesserungen, Batch-Thumbnails, Bugfixes
- Alphabet-Seitenleiste (A-Z) auf Serien-/Filme-Seite
- Separate Player-Buttons fuer Audio/Untertitel/Qualitaet
- Batch-Thumbnail-Generierung per Button in der Bibliothek
- Redundante Dateien in Episoden-Tabelle orange markiert
- Gesehen-Markierung per Episode/Staffel
- Genre-Filter als Select-Element statt Chips
- Fix: tvdb_episode_cache fehlende Spalten (overview, image_url)
- Fix: Login Auto-Fill-Erkennung statt Flash
- Fix: Profil-Wechsel zeigt alle User

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:22:04 +01:00
61ca20bf8b fix: TV-App UX-Verbesserungen - Navigation, Ordner-Ansicht, Duplikate
- FocusManager: SELECT-Elemente, sequentielle Nav-Navigation, Zone-basiert
- Ordner-Ansicht (4. View) fuer Serien + Filme mit Quellen-Gruppierung
- Login-Flow: Lade-Spinner statt Form-Flash, Auto-Login bei 1 Profil
- Farbauswahl: Farbkreise statt input type=color (Samsung TV kompatibel)
- Duplikat-Episoden: Orange Markierung + Badge bei gleicher Episodennummer
- i18n: Neue Keys fuer Ordner-Ansicht und Duplikat-Markierung

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:57:57 +01:00
6d0b8936c5 feat: VideoKonverter v4.0 - Streaming-Client Ausbau
TV-App komplett ueberarbeitet: i18n (DE/EN), Multi-User Quick-Switch,
3 Themes (Dark/Medium/Light), 3 Ansichten (Grid/Liste/Detail),
Filter (Quellen/Genre/Rating/Sortierung), Merkliste, 5-Sterne-Bewertung,
Watch-Status, Player-Overlay (Audio/Untertitel/Qualitaet/Naechste Episode),
Episoden-Thumbnails, Suchverlauf, Queue-Bugfix (delete_source).

5 neue DB-Tabellen, 10+ neue API-Endpunkte, ~3800 neue Zeilen Code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 07:39:12 +01:00
a1be045a7d feat: Samsung TV Installation + Streaming-Fix
- Samsung-Zertifikate (author + distributor) fuer TV-App erstellt
- WGT mit Samsung-Signatur auf TV installiert und getestet
- Streaming movflags korrigiert: default_base_moof statt faststart (pipe)
- frag_duration=1s fuer schnelleren Playback-Start auf Samsung TV
- INSTALL.md komplett ueberarbeitet mit Manjaro/Arch-Anleitung
- .gitignore: Tizen Studio workspace/ Ordner ausgeschlossen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:25:45 +01:00
99730f2f8f 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>
2026-02-28 09:26:19 +01:00
90 changed files with 18648 additions and 218 deletions

10
.gitignore vendored
View file

@ -35,5 +35,15 @@ video-konverter/app/cfg/
.DS_Store
Thumbs.db
# Tizen Studio Workspace
workspace/
# Android
android-app/.gradle/
android-app/build/
android-app/app/build/
android-app/local.properties
android-app/*.apk.idsig
# Claude
.claude/

View file

@ -2,6 +2,380 @@
Alle relevanten Aenderungen am VideoKonverter-Projekt.
## [4.2.0] - 2026-03-02
### TV Admin-Center, konfigurierbare Settings & Bugfixes
Neue Admin-Seite fuer TV-Backend-Verwaltung. Alle HLS-Streaming- und Watch-Status-Parameter
sind jetzt konfigurierbar statt hardcoded. HLS-Session-Monitoring mit Live-Uebersicht.
#### Neue Features
- **TV Admin-Center** (`/tv-admin`): Eigene Verwaltungsseite fuer TV-Backend, erreichbar ueber Navigation
- HLS-Streaming-Einstellungen: Segment-Dauer, Erstes-Segment-Dauer, Session-Timeout, Max-Sessions
- Batch-Pause-Toggle: Konvertierung bei Stream pausieren (SIGSTOP/SIGCONT)
- Watch-Status-Schwelle: Ab wieviel Prozent gilt eine Episode als gesehen (Default 90%, Plex-Standard)
- HTMX-Formular mit Live-Speichern
- **HLS-Session-Monitoring**: Tabelle mit aktiven Streaming-Sessions (Session-ID, Video, Qualitaet, Laufzeit, Inaktivitaet, Status)
- Sessions einzeln beenden (Admin-Button)
- Auto-Refresh alle 15 Sekunden
- **TV-User-Verwaltung verschoben**: QR-Code + CRUD von `/admin` nach `/tv-admin`
- **Konfigurierbare HLS-Konstanten**: Alle vorher hardcodierten Werte (Segment-Dauer 4s, Init-Dauer 1s, Timeout 5min, Max-Sessions 5) jetzt per Admin-UI und ENV-Variablen einstellbar
- **Max-Sessions-Limit**: HLS-Session-Erstellung wird abgelehnt wenn Limit erreicht
#### Bugfixes
- **Library Suchfeld**: Enter-Taste loest jetzt sofort den Filter aus (vorher: nur Input-Event)
- **Watch-Threshold**: Von hardcoded 95% auf konfigurierbaren Wert geaendert (Default 90%)
- **TV-Homepage Cover**: Kleinere Card-Groessen (180→150px Standard, 260→220px Wide) fuer bessere Uebersicht
- **Focus-Outline abgeschnitten**: `.tv-row` Padding/Margin-Fix damit Focus-Ring bei Karten vollstaendig sichtbar
#### Neue ENV-Variablen
- `VK_TV_WATCHED_THRESHOLD` - Gesehen-Schwelle in Prozent (Default: 90)
- `VK_TV_HLS_SEGMENT_SEC` - HLS-Segment-Dauer in Sekunden (Default: 4)
- `VK_TV_HLS_TIMEOUT_MIN` - HLS-Session-Timeout in Minuten (Default: 5)
- `VK_TV_HLS_MAX_SESSIONS` - Max. gleichzeitige HLS-Sessions (Default: 5)
- `VK_TV_PAUSE_BATCH` - Batch bei Stream pausieren (Default: true)
#### Neue API-Endpunkte
- `GET /api/tv/hls-sessions` - Aktive HLS-Sessions auflisten (mit Video-Name aus DB)
- `DELETE /api/tv/hls-sessions/{sid}` - HLS-Session beenden
#### Geaenderte Dateien (10 Dateien)
- `app/config.py` - `tv`-Sektion in Defaults, 5 ENV-Mappings, `tv_config` Property, Docstring
- `app/services/hls.py` - `_tv_setting()` Methode, Config statt Konstanten, Max-Sessions-Check, konfigurierbare Timeouts
- `app/server.py` - `config=self.config` an HLSSessionManager uebergeben
- `app/routes/pages.py` - `/tv-admin` Route + Handler, `POST /htmx/tv-settings` Save-Handler
- `app/routes/tv_api.py` - HLS-Sessions-API (GET/DELETE), `watched_threshold_pct` im Template-Context
- `app/templates/base.html` - Nav-Link "TV Admin" mit Active-State
- `app/templates/tv_admin.html` - **NEU**: Komplettes TV-Admin-Template (Settings, Sessions, Users)
- `app/templates/admin.html` - TV-Sektion + JS komplett entfernt
- `app/templates/tv/series_detail.html` - Threshold-Variable statt hardcoded 95
- `app/templates/library.html` - Enter-Key-Handler auf Suchfeld
- `app/static/tv/css/tv.css` - Card-Groessen reduziert, Focus-Outline-Fix
---
## [4.1.0] - 2026-03-02
### HLS-Streaming, GPU-VAAPI-Fix, Player-Umbau
Natives HLS-Streaming ersetzt die fragile ffmpeg-Pipe. Intel A380 GPU wird korrekt
fuer h264_vaapi-Transcoding genutzt. Player-UI komplett ueberarbeitet.
#### Neue Features
- **HLS-Streaming**: ffmpeg erzeugt .ts-Segmente + m3u8-Playlist in `/tmp/hls/{session_id}/`
- hls.js Library fuer Non-Safari/Tizen Browser
- API: `/tv/api/hls/start`, `/tv/api/hls/{sid}/playlist.m3u8`, `/tv/api/hls/{sid}/segment*.ts`
- Auto-Cleanup inaktiver Sessions (5 Min Timeout)
- **SIGSTOP/SIGCONT**: Laufende Batch-Konvertierung wird waehrend HLS-Streaming pausiert und danach fortgesetzt
- **Codec-Erkennung**: `detectSupportedCodecs()` prueft Browser-Faehigkeiten vor Stream-Start
- **Quality-Auswahl**: UHD/HD/SD/LD im Player waehlbar
- **Loading-Spinner**: Sichtbarer Ladeindikator waehrend Stream-Initialisierung
- **Kompakt-Popup**: Statt grossem Overlay-Panel jetzt kompaktes Popup fuer Audio/Sub/Quality
#### GPU-Fix
- **Intel A380 VAAPI**: Korrekter Pipeline-Aufbau `-vaapi_device /dev/dri/renderD129 -vf 'format=nv12,hwupload' -c:v h264_vaapi`
- **CPU-Fallback**: Automatischer Wechsel auf `libx264 -preset veryfast` wenn GPU fehlschlaegt (Exit < 1s)
#### Geaenderte Dateien
- `Dockerfile` - hls.js Library, VAAPI-Pakete
- `entrypoint.sh` - /tmp/hls Verzeichnis
- `app/services/hls.py` - **NEU**: HLSSessionManager (600+ Zeilen)
- `app/services/queue.py` - SIGSTOP/SIGCONT Integration
- `app/routes/tv_api.py` - HLS-Endpunkte
- `app/static/tv/js/lib/hls.min.js` - **NEU**: hls.js Library
- `app/static/tv/js/player.js` - HLS-Integration, Codec-Erkennung, Kompakt-Popup
- `app/static/tv/css/tv.css` - Loading-Spinner, Popup-Styles
- `app/templates/tv/player.html` - HLS-Player-Markup
- `app/templates/tv/series_detail.html` - Tech-Info-Anzeige
---
## [4.0.3] - 2026-03-01
### JSON-Import-Fix, Player D-Pad-Navigation, Overlay-Bugfix
#### Bugfixes
- **JSON-Import**: `importer.py` konnte bei fehlenden Keys in TVDB-Daten abstuerzen - robusteres `.get()` Handling
- **Player D-Pad-Navigation**: Fernbedienung-Navigation im Player-Overlay funktionierte nicht korrekt auf Samsung TV
- **Overlay-Bugfix**: Player-Overlay schloss sich nicht zuverlaessig beim Druecken der Zurueck-Taste
#### Geaenderte Dateien
- `app/services/importer.py` - Robusteres JSON-Parsing
- `app/static/tv/js/player.js` - D-Pad Keydown-Handler, Overlay-Close-Fix
---
## [4.0.2] - 2026-03-01
### TV-App: FocusManager-Fix, Poster-Caching, Performance
#### Bugfixes
- **FocusManager Navigation**: Von der oberen Nav-Leiste nach unten navigieren springt jetzt direkt zu Content-Karten (Grid/Liste/Detail) statt bei Filter-Dropdowns und View-Switch-Buttons haengenzubleiben
- **Input/Select Editier-Modus**: Textfelder und Select-Dropdowns werden erst nach Enter-Bestaetigung editierbar - D-Pad Navigation kann jetzt ueber Formularfelder hinweg navigieren ohne haengenzubleiben
- **Content-Focus-Speicher**: `_lastContentFocus` merkt sich nur noch echte Content-Elemente (Karten, Listen-Eintraege), nicht mehr Filter/Controls
#### Performance
- **Poster-Caching mit Resize**: Poster-Bilder werden beim ersten Abruf lokal in `.metadata/` gespeichert und auf 300px Breite verkleinert (Pillow)
- 80% kleinere Bilder (233KB → 47KB pro Poster)
- Kein externer TVDB-Request mehr nach erstem Laden
- Cache-Hit: ~10ms statt ~80ms
- **Content-Visibility**: Versteckte View-Container nutzen `content-visibility: hidden` fuer bessere Render-Performance
#### Geaenderte Dateien (4 Dateien, +211/-21 Zeilen)
- `app/routes/library_api.py` - On-Demand Poster-Download + Pillow-Resize-Cache
- `app/routes/tv_api.py` - `_localize_posters()` Helper fuer lokale Poster-URLs
- `app/static/tv/css/tv.css` - Input-Editing-Style, Content-Visibility
- `app/static/tv/js/tv.js` - FocusManager: Input-Modus, Nav→Content Fix, initFocus Fix
---
## [4.0.1] - 2026-03-01
### TV-App: UX-Verbesserungen & Bugfixes
#### Neue Features
- **Alphabet-Seitenleiste**: Vertikale A-Z Sidebar auf Serien-/Filme-Seite zum Filtern nach Anfangsbuchstabe
- Buchstaben ohne Treffer automatisch abgedunkelt
- Wird in Ordner-Ansicht versteckt
- Responsive fuer Handy/Tablet
- **Genre-Select statt Chips**: Genre-Filter als Dropdown-Element (uebersichtlicher bei vielen Genres)
- **Player-Buttons**: Separate Symbole fuer Audio (Lautsprecher-SVG), Untertitel (CC-Badge), Qualitaet (HD-Badge)
- CC-Button leuchtet wenn Untertitel aktiv, Quality-Badge zeigt aktuellen Modus (4K/HD/SD/LD)
- Klick oeffnet Overlay direkt bei der entsprechenden Sektion
- **Gesehen-Markierung**: Buttons fuer "Episode gesehen" und "Staffel gesehen" in Serien-Detail
- **Batch-Thumbnails**: Neuer Button "Thumbnails" in der Bibliothek generiert alle fehlenden Episoden-Thumbnails im Hintergrund per ffmpeg
- **Redundanz-Markierung**: Duplikate in der Episoden-Tabelle werden jetzt orange markiert mit "REDUNDANT"-Badge
- Ranking: Neuerer Codec > kleinere Datei
- **Rating-Sortierung**: Serien/Filme nach Bewertung sortierbar + Min-Rating-Filter
#### Bugfixes
- **tvdb_episode_cache**: Fehlende Spalten `overview` und `image_url` hinzugefuegt (Episoden-Beschreibungen funktionierten nicht)
- **Login-Form Flash**: Auto-Fill-Erkennung statt hartem Timeout (prueft 5x alle 200ms ob Browser Felder ausgefuellt hat)
- **Profil-Wechsel**: Zeigt jetzt alle User an (nicht nur die mit aktiver Session)
- **Debug-Prints entfernt**: Bereinigung aus server.py und tv_api.py
- **Route-Registrierung**: TV-API-Routen in `_setup_app()` verschoben (verhinderte 500-Fehler)
#### Neue API-Endpunkte
- `POST /api/library/generate-thumbnails` - Batch-Thumbnail-Generierung starten
- `GET /api/library/thumbnail-status` - Thumbnail-Fortschritt abfragen
#### Geaenderte Dateien (19 Dateien, +821/-122 Zeilen)
- `app/routes/library_api.py` - Batch-Thumbnails + aiomysql Import
- `app/routes/tv_api.py` - Gesehen-Status, Rating-Filter, Genre-Select
- `app/server.py` - Route-Registrierung Fix
- `app/services/auth.py` - Watch-Status DB-Methoden
- `app/services/library.py` - tvdb_episode_cache Spalten-Fix + Migration
- `app/static/css/style.css` - Redundanz-Zeilen-Style
- `app/static/js/library.js` - Redundanz-Erkennung, Batch-Thumbnails
- `app/static/tv/css/tv.css` - Player-Badges, Alphabet-Sidebar, Rating-Styles
- `app/static/tv/i18n/de.json` + `en.json` - Rating-Uebersetzungen
- `app/static/tv/js/player.js` - Overlay-Sections, Button-Updates
- `app/static/tv/js/tv.js` - Gesehen-Buttons, Alphabet-Filter
- `app/templates/library.html` - Thumbnails-Button
- `app/templates/tv/login.html` - Auto-Fill-Erkennung
- `app/templates/tv/movies.html` - Alphabet-Sidebar, data-letter
- `app/templates/tv/player.html` - Audio/CC/Quality-Buttons
- `app/templates/tv/profiles.html` - Alle User anzeigen
- `app/templates/tv/series.html` - Alphabet-Sidebar, data-letter
- `app/templates/tv/series_detail.html` - Gesehen-Buttons, Episoden-Beschreibungen
---
## [4.0.0] - 2026-03-01
### TV-App: Vollwertiger Streaming-Client
Kompletter Ausbau der TV-App von einfachem Browser zu einem Netflix-aehnlichen Streaming-Client
mit Multi-User, Einstellungen, Bewertungen, Merkliste und Internationalisierung.
#### Internationalisierung (i18n)
- JSON-basiertes Uebersetzungssystem (`static/tv/i18n/de.json`, `en.json`)
- Jinja2-Template-Funktion `t('key.subkey')` fuer alle Texte
- Neuer Service `app/services/i18n.py` mit Sprach-Loader und Fallback (DE)
- Pro-User Spracheinstellung (`ui_lang` in tv_users)
- Alle Templates komplett auf i18n umgestellt
#### Multi-User & Profil-Wechsel
- Quick-Switch: Profilauswahl-Screen (`/tv/profiles`) ohne erneutes Passwort
- Mehrere User pro Geraet (Client), Sessions ueber `vk_client_id` Cookie
- Profilfarben (Avatar-Kreis) pro User konfigurierbar
- "Angemeldet bleiben" Option beim Login (permanente vs. 30-Tage-Sessions)
- Neue DB-Tabelle `tv_clients` fuer Geraete-Einstellungen
#### Benutzer-Einstellungen (`/tv/settings`)
- Menusprache (DE/EN), Audio-Sprache, Untertitel-Sprache
- Theme-Auswahl (Dunkel/Mittel/Hell) mit Live-Vorschau
- Serien- und Film-Ansicht (Raster/Liste/Detail)
- Autoplay: An/Aus, Countdown-Dauer, Max. Folgen am Stueck
- Suchverlauf loeschen, Fortschritte zuruecksetzen
#### Themes (Dark/Medium/Light)
- CSS Custom Properties (`--bg-primary`, `--text-primary`, etc.)
- `data-theme` Attribut auf `<html>`, gespeichert pro User
- Dunkel (Standard), Mittel (grau), Hell (weiss)
- Alle TV-Seiten, Player, Settings, Login unterstuetzen Themes
#### 3 Ansichten (Grid / Liste / Detail)
- **Grid**: Poster-Kacheln im responsiven Grid (wie bisher)
- **Liste**: Kompakte 1-Zeile pro Eintrag mit Mini-Poster
- **Detail**: Groesseres Poster + Beschreibung + Metadaten
- View-Switcher (3 Icons oben rechts) auf Serien- und Filme-Seite
- Einstellung wird pro User gespeichert (getrennt fuer Serien/Filme)
#### Episoden-Darstellung (verbessert)
- Episoden-Thumbnails: TVDB-Bilder oder ffmpeg-Fallback (Frame bei 25%)
- Episodenbeschreibung aus TVDB-Cache angezeigt
- Watch-Progress-Balken pro Episode
- Gesehen-Haekchen bei >= 95% Fortschritt
- Episodennummer, Titel, Dauer, Codec-Info
#### Filter & Quellen-Tabs
- Quellen-Tabs oben: `[Alle] [Quelle 1] [Quelle 2]` (aus library_paths)
- Genre-Chips als Filter unterhalb der Tabs
- Sortierung: Name (A-Z/Z-A), Neueste, Episoden-Anzahl, Bewertung
- Alle Filter als URL-Parameter (`?source=1&genre=Action&sort=title&rating=3`)
#### Merkliste (Watchlist)
- Herz-Button auf Serien-/Film-Detailseiten (Toggle)
- Eigene Seite `/tv/watchlist` mit allen gemerkten Inhalten
- Tabs fuer Serien/Filme auf der Merkliste-Seite
- Neue DB-Tabelle `tv_watchlist` (user_id + series_id/movie_id)
- Navigation: Merkliste als eigener Tab
#### Bewertungssystem (Rating)
- 5-Sterne-Bewertung pro User auf Serien-/Film-Detailseiten
- Klickbare Sterne mit Hover-Effekt + Entfernen-Button
- Durchschnittsbewertung aller User angezeigt
- TVDB-Score als externes Rating-Badge
- Mini-Sterne in allen 3 Listen-Ansichten (Grid/Liste/Detail)
- Rating-Filter (Min. Sterne) und Sortierung nach Bewertung
- Neue DB-Tabelle `tv_ratings`, neue Spalte `tvdb_score`
#### Manueller Watch-Status
- Pro Episode: Gesehen/Nicht gesehen Toggle
- Pro Staffel: "Ganze Staffel als gesehen markieren"
- Pro Serie: "Serie als gesehen markieren"
- In Einstellungen: "Alle Fortschritte zuruecksetzen"
- Neue DB-Tabelle `tv_watch_status`
#### Player-Verbesserungen
- Naechste Episode: Overlay-Countdown (konfigurierbar 5-30 Sek.)
- "Schaust du noch?" Dialog nach X Folgen (Netflix-Style)
- Player-Overlay-Menue: Audio-Spur, Untertitel, Qualitaet, Geschwindigkeit
- Audio-Spur-Auswahl aus verfuegbaren Tracks
- Untertitel-Extraktion (SRT/ASS -> WebVTT) per ffmpeg
- Fernbedienung-navigierbar (FocusManager)
#### Streaming-Qualitaeten
- 4 Modi: UHD (Original), HD (1080p), SD (720p), Low (480p)
- Video copy wenn Original <= Ziel-Aufloesung, sonst Re-Encoding
- Audio nach Client-Config (Stereo/Surround/Original)
#### Suchverlauf
- Letzte Suchen werden gespeichert und angezeigt
- Loeschbar ueber Einstellungen oder einzeln
#### Queue-Bugfix
- `delete_source`-Flag wird jetzt korrekt aus der DB geladen (war immer `False`)
- Fix in `queue.py`: `job['delete_source']` statt hartcodiertes `False`
### Neue Dateien
- `app/services/i18n.py` - Internationalisierungs-Service
- `app/static/tv/i18n/de.json` - Deutsche Uebersetzungen (~200 Keys)
- `app/static/tv/i18n/en.json` - Englische Uebersetzungen (~200 Keys)
- `app/templates/tv/profiles.html` - Profilauswahl (Quick-Switch)
- `app/templates/tv/settings.html` - Benutzer-Einstellungen
- `app/templates/tv/watchlist.html` - Merkliste
### Geaenderte Dateien
- `app/services/auth.py` - Multi-User, Watchlist, Status, Rating, Client-Settings, 8 neue DB-Tabellen/Spalten
- `app/services/tvdb.py` - Episoden-Bilder, tvdb_score Extraktion
- `app/services/queue.py` - delete_source Bugfix
- `app/routes/tv_api.py` - ~20 neue Endpunkte (Settings, Profiles, Watchlist, Rating, Status, Filter)
- `app/routes/library_api.py` - Thumbnail-Endpunkt, Subtitle-Extraktion
- `app/server.py` - i18n-Service Integration
- `app/templates/tv/base.html` - i18n, Theme-Support, Navigation erweitert
- `app/templates/tv/home.html` - Watchlist-Bereich, i18n
- `app/templates/tv/series.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
- `app/templates/tv/movies.html` - 3 Ansichten, Filter, Quellen-Tabs, Rating, i18n
- `app/templates/tv/series_detail.html` - Rating, Watchlist, Episoden-Thumbnails, i18n
- `app/templates/tv/movie_detail.html` - Rating, Watchlist, Versionen, i18n
- `app/templates/tv/player.html` - Overlay-Menue, Naechste Episode, Audio/Sub-Auswahl
- `app/templates/tv/search.html` - Suchverlauf, i18n
- `app/templates/tv/login.html` - "Angemeldet bleiben", i18n
- `app/static/tv/js/player.js` - Komplett ueberarbeitet (Overlay, Audio, Subs, Quality, Next)
- `app/static/tv/css/tv.css` - Themes, 3 Ansichten, Rating, Watchlist, Player-Overlay (~500 neue Zeilen)
### Neue DB-Tabellen
- `tv_clients` - Geraete-Einstellungen (Sound, Qualitaet)
- `tv_watchlist` - Merkliste pro User (Serien + Filme)
- `tv_watch_status` - Manueller Watch-Status (Episode/Staffel/Serie)
- `tv_ratings` - 5-Sterne-Bewertungen pro User
- `tv_episode_thumbnails` - Episoden-Bild-Cache
### Neue DB-Spalten (tv_users)
- `preferred_audio_lang`, `preferred_subtitle_lang`, `subtitles_enabled`
- `ui_lang`, `series_view`, `movies_view`, `avatar_color`, `theme`
- `autoplay_enabled`, `autoplay_countdown_sec`, `autoplay_max_episodes`
### Neue DB-Spalten (library_series/library_movies)
- `tvdb_score` (FLOAT) - Externe TVDB-Bewertung
### Neue API-Endpunkte
- `GET/POST /tv/settings` - Benutzer-Einstellungen
- `GET /tv/profiles` - Profilauswahl
- `POST /tv/switch-profile` - Profil wechseln
- `GET /tv/watchlist` - Merkliste anzeigen
- `POST /tv/api/watchlist` - Merkliste Toggle
- `POST /tv/api/rating` - Bewertung setzen/loeschen
- `POST /tv/api/watch-status` - Watch-Status aendern
- `DELETE /tv/api/search/history` - Suchverlauf loeschen
- `GET /api/library/videos/{id}/thumbnail` - Episoden-Thumbnail
- `GET /api/library/videos/{id}/subtitles/{index}` - Untertitel als WebVTT
---
## [3.1.1] - 2026-02-28
### Samsung TV Installation + Streaming-Fix
- Tizen-App erfolgreich auf Samsung GQ65Q7FAAUXZG installiert
- Streaming-Fix: `movflags=frag_keyframe+empty_moov+default_base_moof`
- Samsung-signierte Zertifikate (nicht Tizen-Standard)
---
## [3.1.0] - 2026-02-28
### TV-App komplett
- TV-App mit Login, Home, Serien, Filme, Player, Suche
- Auth-System: bcrypt, DB-Sessions (30 Tage Cookie)
- Pro-User Berechtigungen (Serien, Filme, Admin, erlaubte Pfade)
- PWA: manifest.json + Service Worker
- Tizen-App fuer Samsung Smart TVs
- Admin-Seite: QR-Code + User-Verwaltung
- Log-API: `GET /api/log?lines=100&level=INFO`
---
## [3.0.0] - 2026-02-28
### Bugfixes, Queue-Pause, Button-Audit
- Queue-Pause-Funktion
- Button-Audit aller UI-Elemente
- Diverse Bugfixes
---
## [2.9.0] - 2026-02-27
### Import-System Neustrukturierung

View file

@ -1,10 +1,11 @@
FROM ubuntu:24.04
# Basis-Pakete + ffmpeg + Intel GPU Treiber
# Basis-Pakete + ffmpeg + Intel GPU Treiber + gosu (fuer PUID/PGID User-Switching)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
python3 \
python3-pip \
gosu \
intel-opencl-icd \
intel-media-va-driver-non-free \
libva-drm2 \
@ -40,9 +41,9 @@ COPY video-konverter/app/ ./app/
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000)
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs
# Daten- und Log-Verzeichnisse + HLS-Streaming (beschreibbar fuer UID 1000)
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls /tmp/jinja2_cache \
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls /tmp/jinja2_cache
# Entrypoint (kopiert Defaults in gemountete Volumes)
COPY entrypoint.sh .

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,66 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "de.datait.videokonverter"
compileSdk = 35
defaultConfig {
applicationId = "de.datait.videokonverter"
minSdk = 24 // Android 7.0 (ExoPlayer Codec-Support)
targetSdk = 35
versionCode = 3
versionName = "1.2.0"
}
signingConfigs {
create("release") {
storeFile = file(System.getProperty("user.home") + "/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
release {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding = true
}
}
dependencies {
// Media3 ExoPlayer
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
// AndroidX
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.webkit:webkit:1.12.1")
implementation("androidx.preference:preference-ktx:1.2.1")
// Leanback (Android TV)
implementation("androidx.leanback:leanback:1.0.0")
}

10
android-app/app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,10 @@
# VideoKonverter Android App - ProGuard Regeln
# JavaScript Interface Methoden nicht entfernen
-keepclassmembers class de.datait.videokonverter.NativePlayerBridge {
@android.webkit.JavascriptInterface <methods>;
}
# ExoPlayer
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**

View file

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android TV (optional) -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.VideoKonverter"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<!-- Setup: Server-URL eingeben (erster Start) -->
<activity
android:name=".SetupActivity"
android:exported="true"
android:theme="@style/Theme.VideoKonverter">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Haupt-Activity: WebView -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="false" />
</application>
</manifest>

View file

@ -0,0 +1,128 @@
package de.datait.videokonverter
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.webkit.CookieManager
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.media3.ui.PlayerView
import androidx.preference.PreferenceManager
/**
* Haupt-Activity: WebView laedt die TV-App vom Server.
* ExoPlayer-Overlay fuer Direct-Play Video.
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
private lateinit var playerView: PlayerView
private lateinit var bridge: NativePlayerBridge
private var serverUrl: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Immersive Fullscreen: Status- und Navigationsleiste verstecken
WindowCompat.setDecorFitsSystemWindows(window, false)
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
setContentView(R.layout.activity_main)
// Server-URL aus Intent oder Preferences
serverUrl = intent.getStringExtra("server_url")
?: PreferenceManager.getDefaultSharedPreferences(this)
.getString("server_url", null)
?: run {
// Keine URL -> zurueck zum Setup
startActivity(Intent(this, SetupActivity::class.java))
finish()
return
}
webView = findViewById(R.id.webview)
playerView = findViewById(R.id.playerView)
// Cookies aktivieren (fuer Auth-Session)
CookieManager.getInstance().apply {
setAcceptCookie(true)
setAcceptThirdPartyCookies(webView, true)
}
// WebView konfigurieren
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// Cache-Einstellungen
cacheMode = WebSettings.LOAD_DEFAULT
databaseEnabled = true
}
// NativePlayerBridge registrieren
bridge = NativePlayerBridge(this, webView, playerView, serverUrl)
webView.addJavascriptInterface(bridge, "VKNativeAndroid")
// Custom WebViewClient: VKNative nach jedem Page-Load injizieren
webView.webViewClient = VKWebViewClient(serverUrl)
// Custom WebChromeClient: Fullscreen + Console-Logs
webView.webChromeClient = VKWebChromeClient()
// TV-App laden
webView.loadUrl("$serverUrl/tv/")
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_BACK) {
// ExoPlayer aktiv? Zuerst stoppen
if (playerView.visibility == View.VISIBLE) {
bridge.stop()
return true
}
// WebView zurueck-navigieren
if (webView.canGoBack()) {
webView.goBack()
return true
}
// Kein Zurueck moeglich -> zurueck zum Setup (Server aendern)
val intent = Intent(this, SetupActivity::class.java)
intent.putExtra("force_setup", true)
startActivity(intent)
finish()
return true
}
return super.onKeyDown(keyCode, event)
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
// Immersive Mode nach Focus-Wechsel neu setzen (Android setzt es zurueck)
if (hasFocus) {
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
override fun onPause() {
super.onPause()
// ExoPlayer pausieren wenn App in den Hintergrund geht
bridge.pauseIfPlaying()
}
override fun onDestroy() {
bridge.release()
webView.destroy()
super.onDestroy()
}
}

View file

@ -0,0 +1,386 @@
package de.datait.videokonverter
import android.app.Activity
import android.media.MediaCodecList
import android.os.Handler
import android.os.Looper
import android.view.View
import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
import org.json.JSONArray
import org.json.JSONObject
/**
* JavaScript Bridge: Verbindet window.VKNative mit ExoPlayer.
* Wird per @JavascriptInterface als VKNativeAndroid registriert,
* dann in VKWebViewClient zu window.VKNative gewrapped.
*/
@OptIn(UnstableApi::class)
class NativePlayerBridge(
private val activity: Activity,
private val webView: WebView,
private val playerView: PlayerView,
private val serverUrl: String
) : Player.Listener {
private var exoPlayer: ExoPlayer? = null
private var timeUpdateHandler: Handler? = null
private var timeUpdateRunnable: Runnable? = null
// Unterstuetzte Audio-Codecs (DTS blockiert)
private val unsupportedAudio = listOf("dts", "dca", "dts_hd", "dts-hd", "truehd")
// --- Codec-Abfrage ---
@JavascriptInterface
fun getSupportedVideoCodecs(): String {
val codecs = mutableSetOf<String>()
val codecList = MediaCodecList(MediaCodecList.ALL_CODECS)
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
val t = type.lowercase()
when {
t.contains("avc") -> codecs.add("h264")
t.contains("hevc") || t.contains("hev") -> codecs.add("hevc")
t.contains("av01") -> codecs.add("av1")
t.contains("vp9") -> codecs.add("vp9")
}
}
}
if (codecs.isEmpty()) codecs.add("h264")
return JSONArray(codecs.toList()).toString()
}
@JavascriptInterface
fun getSupportedAudioCodecs(): String {
// ExoPlayer unterstuetzt diese Codecs nativ
val codecs = listOf("aac", "opus", "mp3", "flac", "ac3", "eac3", "vorbis", "pcm")
return JSONArray(codecs).toString()
}
@JavascriptInterface
fun canDirectPlay(videoInfoJson: String): Boolean {
return try {
val info = JSONObject(videoInfoJson)
val videoCodec = info.optString("video_codec_normalized", "").lowercase()
// Video-Codec pruefen
val supportedVideo = JSONArray(getSupportedVideoCodecs())
val videoCodecs = (0 until supportedVideo.length()).map { supportedVideo.getString(it) }
if (videoCodec !in videoCodecs) return false
// Audio-Codecs pruefen (DTS blockieren)
val audioCodecs = info.optJSONArray("audio_codecs")
if (audioCodecs != null) {
for (i in 0 until audioCodecs.length()) {
val ac = audioCodecs.optString(i, "").lowercase()
if (ac in unsupportedAudio) return false
}
}
true
} catch (e: Exception) {
false
}
}
// --- Player-Steuerung ---
@JavascriptInterface
fun play(url: String, videoInfoJson: String, optsJson: String): Boolean {
val opts = try { JSONObject(optsJson) } catch (e: Exception) { JSONObject() }
val seekMs = opts.optLong("seekMs", 0)
// Relative URL -> Absolute URL
val fullUrl = if (url.startsWith("/")) "$serverUrl$url" else url
activity.runOnUiThread {
try {
// Vorherigen Player bereinigen
releasePlayer()
// ExoPlayer erstellen (mit Audio-Attributen fuer korrekten Ton)
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(activity)
.setAudioAttributes(audioAttributes, true)
.build()
// PlayerView konfigurieren
playerView.player = player
playerView.useController = false // Controls kommen von Web-UI
playerView.visibility = View.VISIBLE
// Cookie fuer Auth mitgeben
val cookie = CookieManager.getInstance().getCookie(serverUrl) ?: ""
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf("Cookie" to cookie))
val mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(fullUrl))
player.setMediaSource(mediaSource)
player.addListener(this)
player.prepare()
if (seekMs > 0) {
player.seekTo(seekMs)
}
player.playWhenReady = true
exoPlayer = player
// Periodische Zeit-Updates starten
startTimeUpdates()
} catch (e: Exception) {
callJs("if(window._vkOnError) window._vkOnError('${e.message}')")
}
}
return true
}
/**
* HLS-Stream ueber ExoPlayer abspielen (Fallback fuer DTS/TrueHD-Audio).
* Wird von player.js aufgerufen wenn canDirectPlay() false ist.
*/
@JavascriptInterface
fun playHLS(playlistUrl: String, optsJson: String): Boolean {
val opts = try { JSONObject(optsJson) } catch (e: Exception) { JSONObject() }
val seekMs = opts.optLong("seekMs", 0)
// Relative URL -> Absolute URL
val fullUrl = if (playlistUrl.startsWith("/")) "$serverUrl$playlistUrl" else playlistUrl
activity.runOnUiThread {
try {
releasePlayer()
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build()
val player = ExoPlayer.Builder(activity)
.setAudioAttributes(audioAttributes, true)
.build()
playerView.player = player
playerView.useController = false
playerView.visibility = View.VISIBLE
// Cookie fuer Auth
val cookie = CookieManager.getInstance().getCookie(serverUrl) ?: ""
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setDefaultRequestProperties(mapOf("Cookie" to cookie))
// HLS-MediaSource (nicht Progressive!)
val mediaSource = HlsMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(fullUrl))
player.setMediaSource(mediaSource)
player.addListener(this)
player.prepare()
if (seekMs > 0) {
player.seekTo(seekMs)
}
player.playWhenReady = true
exoPlayer = player
startTimeUpdates()
} catch (e: Exception) {
callJs("if(window._vkOnError) window._vkOnError('${e.message}')")
}
}
return true
}
@JavascriptInterface
fun togglePlay() {
activity.runOnUiThread {
exoPlayer?.let {
it.playWhenReady = !it.playWhenReady
}
}
}
@JavascriptInterface
fun pause() {
activity.runOnUiThread {
exoPlayer?.playWhenReady = false
}
}
@JavascriptInterface
fun resume() {
activity.runOnUiThread {
exoPlayer?.playWhenReady = true
}
}
@JavascriptInterface
fun seek(positionMs: Long) {
activity.runOnUiThread {
exoPlayer?.seekTo(positionMs.coerceAtLeast(0))
}
}
@JavascriptInterface
fun getCurrentTime(): Long {
return exoPlayer?.currentPosition ?: 0
}
@JavascriptInterface
fun getDuration(): Long {
return exoPlayer?.duration ?: 0
}
@JavascriptInterface
fun isPlaying(): Boolean {
return exoPlayer?.isPlaying ?: false
}
@JavascriptInterface
fun stop() {
activity.runOnUiThread {
releasePlayer()
playerView.visibility = View.GONE
}
}
@JavascriptInterface
fun setAudioTrack(index: Int): Boolean {
val player = exoPlayer ?: return false
return try {
activity.runOnUiThread {
val tracks = player.currentTracks.groups
var audioIdx = 0
for (group in tracks) {
if (group.type == C.TRACK_TYPE_AUDIO) {
if (audioIdx == index) {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, 0)
)
.build()
return@runOnUiThread
}
audioIdx++
}
}
}
true
} catch (e: Exception) {
false
}
}
@JavascriptInterface
fun setSubtitleTrack(index: Int): Boolean {
// Untertitel werden ueber Web-UI gehandelt
return false
}
@JavascriptInterface
fun setPlaybackSpeed(speed: Float): Boolean {
return try {
activity.runOnUiThread {
exoPlayer?.setPlaybackSpeed(speed)
}
true
} catch (e: Exception) {
false
}
}
// --- Player.Listener Callbacks ---
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_READY -> {
callJs("if(window._vkOnReady) window._vkOnReady()")
callJs("if(window._vkOnBuffering) window._vkOnBuffering(false)")
}
Player.STATE_ENDED -> {
callJs("if(window._vkOnComplete) window._vkOnComplete()")
}
Player.STATE_BUFFERING -> {
callJs("if(window._vkOnBuffering) window._vkOnBuffering(true)")
}
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
callJs("if(window._vkOnPlayStateChanged) window._vkOnPlayStateChanged($isPlaying)")
}
override fun onPlayerError(error: PlaybackException) {
val msg = error.message?.replace("'", "\\'") ?: "Wiedergabefehler"
callJs("if(window._vkOnError) window._vkOnError('$msg')")
}
// --- Hilfsfunktionen ---
/** App pausiert -> ExoPlayer pausieren */
fun pauseIfPlaying() {
exoPlayer?.playWhenReady = false
}
/** Ressourcen freigeben */
fun release() {
activity.runOnUiThread {
releasePlayer()
}
}
private fun releasePlayer() {
stopTimeUpdates()
exoPlayer?.release()
exoPlayer = null
}
private fun startTimeUpdates() {
stopTimeUpdates()
val handler = Handler(Looper.getMainLooper())
val runnable = object : Runnable {
override fun run() {
val pos = exoPlayer?.currentPosition ?: 0
callJs("if(window._vkOnTimeUpdate) window._vkOnTimeUpdate($pos)")
handler.postDelayed(this, 500)
}
}
timeUpdateHandler = handler
timeUpdateRunnable = runnable
handler.post(runnable)
}
private fun stopTimeUpdates() {
timeUpdateRunnable?.let { timeUpdateHandler?.removeCallbacks(it) }
timeUpdateHandler = null
timeUpdateRunnable = null
}
private fun callJs(script: String) {
activity.runOnUiThread {
webView.evaluateJavascript(script, null)
}
}
}

View file

@ -0,0 +1,108 @@
package de.datait.videokonverter
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.preference.PreferenceManager
/**
* Setup-Bildschirm: Server-URL eingeben (wird beim ersten Start angezeigt).
* Gespeicherte URL leitet direkt zur MainActivity weiter.
*/
class SetupActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// "reset" Extra: Setup erzwingen (von MainActivity gesendet)
val forceSetup = intent.getBooleanExtra("force_setup", false)
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
if (!forceSetup) {
// Gespeicherte URL? Bereinigen + weiter zur MainActivity
val savedUrl = prefs.getString("server_url", null)
if (!savedUrl.isNullOrBlank()) {
val cleanUrl = cleanServerUrl(savedUrl)
if (cleanUrl != savedUrl) {
// Kaputte URL korrigieren (z.B. mit /tv/ Pfad)
prefs.edit().putString("server_url", cleanUrl).apply()
}
startMainActivity(cleanUrl)
return
}
}
setContentView(R.layout.activity_setup)
val serverInput = findViewById<EditText>(R.id.serverUrl)
val btnConnect = findViewById<Button>(R.id.btnConnect)
val errorText = findViewById<TextView>(R.id.errorText)
// Enter-Taste -> Verbinden
serverInput.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
connectToServer(serverInput.text.toString(), errorText, prefs)
true
} else false
}
btnConnect.setOnClickListener {
connectToServer(serverInput.text.toString(), errorText, prefs)
}
}
private fun connectToServer(
input: String,
errorText: TextView,
prefs: android.content.SharedPreferences
) {
var url = input.trim()
if (url.isBlank()) {
errorText.text = "Bitte Server-Adresse eingeben"
errorText.visibility = View.VISIBLE
return
}
// Protokoll ergaenzen
if (!url.contains("://")) {
url = "http://$url"
}
// Port ergaenzen falls nicht vorhanden
val hasPort = url.substringAfter("://").contains(":")
if (!hasPort) {
url = "$url:8080"
}
// Nur Schema + Host + Port behalten (Pfad entfernen)
url = cleanServerUrl(url)
// URL speichern
prefs.edit().putString("server_url", url).apply()
startMainActivity(url)
}
/** Nur Schema + Host + Port aus einer URL extrahieren */
private fun cleanServerUrl(raw: String): String {
return try {
val uri = android.net.Uri.parse(raw)
"${uri.scheme}://${uri.host}${if (uri.port > 0) ":${uri.port}" else ""}"
} catch (e: Exception) {
raw.trimEnd('/')
}
}
private fun startMainActivity(serverUrl: String) {
val intent = Intent(this, MainActivity::class.java)
intent.putExtra("server_url", serverUrl)
startActivity(intent)
finish()
}
}

View file

@ -0,0 +1,22 @@
package de.datait.videokonverter
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.WebChromeClient
/**
* Custom WebChromeClient: Console-Logs weiterleiten.
*/
class VKWebChromeClient : WebChromeClient() {
override fun onConsoleMessage(msg: ConsoleMessage): Boolean {
val level = when (msg.messageLevel()) {
ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
ConsoleMessage.MessageLevel.WARNING -> Log.WARN
ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
else -> Log.INFO
}
Log.println(level, "VKWebView", "${msg.message()} [${msg.sourceId()}:${msg.lineNumber()}]")
return true
}
}

View file

@ -0,0 +1,79 @@
package de.datait.videokonverter
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
/**
* Custom WebViewClient: Injiziert VKNative Bridge nach jedem Page-Load.
* Haelt Navigation innerhalb der App (kein externer Browser).
*/
class VKWebViewClient(private val serverUrl: String) : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
// VKNative Bridge in JavaScript injizieren
// VKNativeAndroid ist per @JavascriptInterface registriert,
// muss aber zu window.VKNative gewrapped werden (korrekte API)
val js = """
(function() {
if (window.VKNative) return;
if (!window.VKNativeAndroid) return;
window.VKNative = {
platform: 'android',
version: '1.0.0',
getSupportedVideoCodecs: function() {
try { return JSON.parse(VKNativeAndroid.getSupportedVideoCodecs()); }
catch(e) { return ['h264']; }
},
getSupportedAudioCodecs: function() {
try { return JSON.parse(VKNativeAndroid.getSupportedAudioCodecs()); }
catch(e) { return ['aac']; }
},
canDirectPlay: function(videoInfo) {
try { return VKNativeAndroid.canDirectPlay(JSON.stringify(videoInfo)); }
catch(e) { return false; }
},
play: function(url, videoInfo, opts) {
try {
return VKNativeAndroid.play(url,
JSON.stringify(videoInfo || {}),
JSON.stringify(opts || {}));
} catch(e) { return false; }
},
togglePlay: function() { VKNativeAndroid.togglePlay(); },
pause: function() { VKNativeAndroid.pause(); },
resume: function() { VKNativeAndroid.resume(); },
seek: function(ms) { VKNativeAndroid.seek(ms); },
getCurrentTime: function() { return VKNativeAndroid.getCurrentTime(); },
getDuration: function() { return VKNativeAndroid.getDuration(); },
isPlaying: function() { return VKNativeAndroid.isPlaying(); },
stop: function() { VKNativeAndroid.stop(); },
playHLS: function(url, opts) {
try { return VKNativeAndroid.playHLS(url, JSON.stringify(opts || {})); }
catch(e) { return false; }
},
setAudioTrack: function(i) { return VKNativeAndroid.setAudioTrack(i); },
setSubtitleTrack: function(i) { return VKNativeAndroid.setSubtitleTrack(i); },
setPlaybackSpeed: function(s) { return VKNativeAndroid.setPlaybackSpeed(s); },
};
console.info('[VKNative] Android Bridge initialisiert');
})();
""".trimIndent()
view.evaluateJavascript(js, null)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url.toString()
// Nur Server-URLs in der WebView oeffnen
if (url.startsWith(serverUrl) || url.startsWith("http://") || url.startsWith("https://")) {
return false // WebView handelt die URL
}
return true // Andere URLs blockieren
}
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<!-- WebView: TV-App UI vom Server -->
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- ExoPlayer: Overlay fuer Direct-Play Video (initial versteckt) -->
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
android:background="@android:color/black" />
</FrameLayout>

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:background="@android:color/black">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="VideoKonverter"
android:textColor="@android:color/white"
android:textSize="28sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_title"
android:textColor="#AAAAAA"
android:textSize="16sp"
android:layout_marginBottom="32dp" />
<EditText
android:id="@+id/serverUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxWidth="400dp"
android:hint="@string/setup_hint"
android:inputType="textUri"
android:textColor="@android:color/white"
android:textColorHint="#888888"
android:backgroundTint="#4db8ff"
android:textSize="18sp"
android:padding="12dp"
android:imeOptions="actionDone" />
<Button
android:id="@+id/btnConnect"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/setup_connect"
android:layout_marginTop="16dp"
android:paddingHorizontal="32dp"
android:textSize="16sp" />
<TextView
android:id="@+id/errorText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FF5555"
android:textSize="14sp"
android:layout_marginTop="12dp"
android:visibility="gone" />
</LinearLayout>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">VideoKonverter</string>
<string name="setup_title">Server verbinden</string>
<string name="setup_hint">Server-Adresse (z.B. 192.168.155.12:8080)</string>
<string name="setup_connect">Verbinden</string>
<string name="setup_error">Server nicht erreichbar</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.VideoKonverter" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowFullscreen">true</item>
</style>
</resources>

View file

@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

View file

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
android-app/gradlew vendored Normal file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
android-app/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "VideoKonverter"
include(":app")

Binary file not shown.

View file

@ -0,0 +1,531 @@
# TV-App D-Pad & Usability Fixes - Implementierungsplan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 5 Fernbedienungs-/Usability-Probleme der TV-App auf Samsung Tizen beheben.
**Architecture:** Reine Frontend-Änderungen an 4 Dateien — JavaScript (FocusManager + Template-Scripts), CSS (Episode-Grid, Mark-Button, neuer Button), HTML (Templates). Kein Backend-Change. Bestehender i18n-Key `status.mark_series` wird wiederverwendet.
**Tech Stack:** Vanilla JavaScript, CSS3, Jinja2-Templates, aiohttp
**Spec:** `docs/superpowers/specs/2026-03-16-tv-dpad-fixes-design.md`
---
## Dateiübersicht
| Datei | Änderung |
|-------|----------|
| `video-konverter/app/static/tv/js/tv.js` | FocusManager: Sidebar-Navigation, focusin-Handler |
| `video-konverter/app/static/tv/css/tv.css` | Episode-Grid größer, Mark-Button sichtbar, neuer Button-Style |
| `video-konverter/app/templates/tv/series.html` | Alphabet-IIFE: entfernen statt dimmen |
| `video-konverter/app/templates/tv/series_detail.html` | Mark-Button fokussierbar, "Serie als gesehen"-Button + JS |
---
## Task 1: Alphabet-Sidebar — nur verfügbare Buchstaben anzeigen
**Files:**
- Modify: `video-konverter/app/templates/tv/series.html:243-253`
- [ ] **Step 1: IIFE ändern — `el.remove()` statt `el.classList.add('dimmed')`**
In `series.html`, Zeile 243-253, das bestehende IIFE anpassen:
```javascript
// Buchstaben ohne Treffer komplett entfernen (statt nur dimmen)
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.remove();
});
})();
```
Konkret: Zeile 251 ändern von:
```javascript
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
```
zu:
```javascript
if (!avail[el.dataset.letter]) el.remove();
```
- [ ] **Step 2: Manuell testen**
Browser öffnen → TV-Serien-Seite → prüfen dass nur Buchstaben angezeigt werden, die tatsächlich Serien haben.
- [ ] **Step 3: Commit**
```bash
git add video-konverter/app/templates/tv/series.html
git commit -m "fix(tv): Alphabet-Sidebar zeigt nur verfügbare Buchstaben"
```
---
## Task 2: FocusManager — Sidebar per D-Pad erreichbar
**Files:**
- Modify: `video-konverter/app/static/tv/js/tv.js:38-43` (focusin-Handler)
- Modify: `video-konverter/app/static/tv/js/tv.js:230` (nach ArrowUp-Nav-Block, vor Nearest-Neighbor)
- Modify: `video-konverter/app/static/tv/js/tv.js:240-243` (searchEls-Filter)
**WICHTIG:** Alle 3 Änderungen sind atomar — zusammen einfügen!
- [ ] **Step 1: focusin-Handler anpassen — Sidebar aus Content-Tracking ausschließen**
In `tv.js`, Zeile 38-43, die Bedingung erweitern. Aktuell:
```javascript
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav")) {
// Nur echte Content-Elemente merken (nicht Filter/Controls)
if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
this._lastContentFocus = e.target;
}
}
}
```
Ändern zu (`.tv-alpha-sidebar` aus der Content-Areas-Liste entfernen UND Sidebar explizit in äußerer Bedingung ausschließen):
```javascript
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav") && !e.target.closest(".tv-alpha-sidebar")) {
// Nur echte Content-Elemente merken (nicht Nav/Sidebar)
if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
this._lastContentFocus = e.target;
}
}
}
```
- [ ] **Step 2: Sidebar-Navigationslogik einfügen — nach ArrowUp-Block (Zeile 230)**
In `tv.js`, nach Zeile 230 (dem Ende des `if (!inNav && direction === "ArrowUp")` Blocks), VOR dem Nearest-Neighbor-Block (Zeile 232: `const currentRect = ...`), folgenden Code einfügen:
```javascript
// ===== Alphabet-Sidebar Navigation =====
const inSidebar = active.closest(".tv-alpha-sidebar");
if (inSidebar) {
if (direction === "ArrowLeft") {
// Zurueck zum Content
if (this._lastContentFocus && document.contains(this._lastContentFocus)) {
this._lastContentFocus.focus();
} else {
const firstCard = document.querySelector(".tv-grid [data-focusable], .tv-card[data-focusable]");
if (firstCard) firstCard.focus();
}
e.preventDefault();
return;
}
if (direction === "ArrowUp" || direction === "ArrowDown") {
// Sequentiell durch Buchstaben
const letters = Array.from(document.querySelectorAll(".tv-alpha-letter[data-focusable]"))
.filter(el => el.offsetHeight > 0);
const idx = letters.indexOf(active);
const next = direction === "ArrowDown" ? idx + 1 : idx - 1;
if (next >= 0 && next < letters.length) {
letters[next].focus();
letters[next].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
e.preventDefault();
return;
}
}
// ArrowRight am rechten Grid-Rand -> Sidebar
if (!inNav && !inSidebar && direction === "ArrowRight") {
const sidebar = document.getElementById("alpha-sidebar");
if (sidebar && sidebar.offsetHeight > 0) {
// Pruefen ob es noch ein Element rechts im Content gibt
const contentEls = focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
const currentRect_r = active.getBoundingClientRect();
const rightNeighbor = contentEls.some(el => {
const r = el.getBoundingClientRect();
return r.left > currentRect_r.right + 5 && Math.abs(r.top - currentRect_r.top) < 100;
});
if (!rightNeighbor) {
// Kein Content rechts -> zur Sidebar springen
const sidebarLetters = Array.from(sidebar.querySelectorAll("[data-focusable]"))
.filter(el => el.offsetHeight > 0);
if (sidebarLetters.length > 0) {
// Naechstgelegenen Buchstaben vertikal finden
const cy_r = currentRect_r.top + currentRect_r.height / 2;
let best = sidebarLetters[0];
let bestDist_r = Infinity;
sidebarLetters.forEach(l => {
const lr = l.getBoundingClientRect();
const d = Math.abs(lr.top + lr.height / 2 - cy_r);
if (d < bestDist_r) { bestDist_r = d; best = l; }
});
this._lastContentFocus = active;
best.focus();
e.preventDefault();
return;
}
}
}
}
```
- [ ] **Step 3: Nearest-Neighbor searchEls-Filter erweitern — Sidebar ausschließen**
In `tv.js`, die Zeilen mit dem `searchEls`-Filter (ca. Zeile 240-243, nach Einfügung verschoben) ändern von:
```javascript
const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav"));
```
zu:
```javascript
const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
```
- [ ] **Step 4: Manuell testen**
Browser/Tizen-Emulator → Serien-Seite:
1. D-Pad rechts am Grid-Rand → Sidebar bekommt Focus
2. D-Pad hoch/runter in Sidebar → sequentiell durch Buchstaben
3. D-Pad links in Sidebar → zurück zur letzten Card
4. Enter in Sidebar → filtert Serien nach Buchstabe
- [ ] **Step 5: Commit**
```bash
git add video-konverter/app/static/tv/js/tv.js
git commit -m "fix(tv): Alphabet-Sidebar per D-Pad/Fernbedienung navigierbar"
```
---
## Task 3: Episoden-Cards vergrößern + Laufzeit-Badge
**Files:**
- Modify: `video-konverter/app/static/tv/css/tv.css:1817-1821` (Episode-Grid Basis)
- Modify: `video-konverter/app/static/tv/css/tv.css:2019-2020` (1200px Breakpoint)
- [ ] **Step 1: Basis-Grid vergrößern**
In `tv.css`, Zeile 1819 ändern von:
```css
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
```
zu:
```css
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
```
Zeile 1820 ändern von:
```css
gap: 0.8rem;
```
zu:
```css
gap: 1rem;
```
- [ ] **Step 2: 1200px-Breakpoint anpassen**
In `tv.css`, Zeile 2020 ändern von:
```css
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
```
zu:
```css
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
```
- [ ] **Step 3: Laufzeit-Badge anpassen**
In `tv.css`, die `.tv-ep-duration`-Regel finden (Zeile 452-461) und `white-space: nowrap;` hinzufügen sowie Font/Padding anpassen:
Aktuell:
```css
.tv-ep-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0,0,0,0.75);
color: #fff;
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 3px;
}
```
Ändern zu:
```css
.tv-ep-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0,0,0,0.75);
color: #fff;
font-size: 0.8rem;
padding: 3px 8px;
border-radius: 3px;
white-space: nowrap;
}
```
- [ ] **Step 4: Manuell testen**
Browser → Serien-Detail-Seite → prüfen dass Episoden-Cards größer sind und die Laufzeit vollständig sichtbar ist.
- [ ] **Step 5: Commit**
```bash
git add video-konverter/app/static/tv/css/tv.css
git commit -m "fix(tv): Episoden-Cards vergrößert, Laufzeit-Badge vollständig sichtbar"
```
---
## Task 4: "Gesehen"-Button auf Episode-Cards fokussierbar machen
**Files:**
- Modify: `video-konverter/app/templates/tv/series_detail.html:129-132` (HTML)
- Modify: `video-konverter/app/static/tv/css/tv.css:1864-1899` (CSS)
- [ ] **Step 1: HTML — `tabindex="-1"` entfernen, `data-focusable` hinzufügen**
In `series_detail.html`, Zeile 129-132 ändern von:
```html
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
tabindex="-1"
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003;
</button>
```
zu:
```html
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
data-focusable
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003;
</button>
```
- [ ] **Step 2: CSS — Mark-Button auf TV sichtbar machen (hover:none Media-Query)**
In `tv.css`, nach der bestehenden `.tv-ep-tile-mark`-Regel (Zeile 1882, nach `z-index: 2;` und vor der hover/focus-Regel), folgende Media-Query einfügen:
```css
/* TV-Geraete (kein Hover): Mark-Button immer leicht sichtbar */
@media (hover: none) {
.tv-ep-tile-mark {
opacity: 0.5;
}
}
```
- [ ] **Step 3: CSS — Focus-Ring für Mark-Button**
Die bestehende Regel in `tv.css`, Zeile 1894-1899 ist bereits vorhanden:
```css
.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
border-color: var(--accent);
color: var(--accent);
outline: none;
opacity: 1;
}
```
Diese enthält `outline: none` — für D-Pad-Navigation brauchen wir stattdessen einen sichtbaren Focus-Ring. Ändern zu:
```css
.tv-ep-tile-mark:hover, .tv-ep-tile-mark:focus {
border-color: var(--accent);
color: var(--accent);
opacity: 1;
}
.tv-ep-tile-mark:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
```
- [ ] **Step 4: Manuell testen**
Browser → Serien-Detail → D-Pad auf Episode-Card → dann D-Pad hoch/links zum Mark-Button → Enter drückt "Gesehen". Prüfen:
1. Button ist leicht sichtbar (auf TV/touch)
2. Button bekommt Focus-Ring bei D-Pad-Navigation
3. Enter toggled gesehen/ungesehen
- [ ] **Step 5: Commit**
```bash
git add video-konverter/app/templates/tv/series_detail.html video-konverter/app/static/tv/css/tv.css
git commit -m "fix(tv): Gesehen-Button per D-Pad fokussierbar, auf TV sichtbar"
```
---
## Task 5: "Serie als gesehen markieren"-Button
**Files:**
- Modify: `video-konverter/app/templates/tv/series_detail.html:57-66` (HTML)
- Modify: `video-konverter/app/templates/tv/series_detail.html` (JS am Ende des Script-Blocks)
- Modify: `video-konverter/app/static/tv/css/tv.css` (neues Styling)
- [ ] **Step 1: HTML — Button im Header-Aktionsbereich einfügen**
In `series_detail.html`, nach dem Watchlist-Button (Zeile 65, vor `</div>` auf Zeile 66), einfügen:
```html
<!-- Serie als gesehen markieren -->
<button class="tv-mark-series-btn"
id="btn-mark-series"
data-focusable
data-series-id="{{ series.id }}"
onclick="markSeriesWatched(this)">
<span class="mark-series-icon">&#10003;</span>
<span class="mark-series-text">{{ t('status.mark_series') }}</span>
</button>
```
- [ ] **Step 2: JavaScript — `markSeriesWatched()` Funktion hinzufügen**
In `series_detail.html`, im `<script>`-Block nach der `toggleWatched()`-Funktion (nach Zeile 255), einfügen:
```javascript
function markSeriesWatched(btn) {
// Alle ungesehenen Episoden aus ALLEN Staffeln sammeln
const allCards = document.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
const ids = [];
allCards.forEach(function(card) {
var vid = card.dataset.videoId;
if (vid) ids.push(parseInt(vid));
});
if (ids.length === 0) return;
// Batch-Request an API (gleiche Methode wie markSeasonWatched)
Promise.all(ids.map(function(id) {
return fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
});
})).then(function() {
// Alle Episoden-Cards als gesehen markieren
document.querySelectorAll('.tv-episode-tile').forEach(function(card) {
card.classList.add('tv-ep-seen');
var markBtn = card.querySelector('.tv-ep-tile-mark');
if (markBtn) markBtn.classList.add('active');
var thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
var check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#10003;';
thumb.appendChild(check);
}
});
// Alle Staffel-Tabs als komplett markieren
document.querySelectorAll('.tv-tab').forEach(function(tab) {
if (!tab.classList.contains('tv-tab-complete')) {
tab.classList.add('tv-tab-complete');
if (!tab.querySelector('.tv-tab-check')) {
var check = document.createElement('span');
check.className = 'tv-tab-check';
check.innerHTML = ' &#10003;';
tab.appendChild(check);
}
}
});
// Button-Zustand aendern
btn.classList.add('active');
}).catch(function() {});
}
```
- [ ] **Step 3: CSS — Styling für `.tv-mark-series-btn`**
In `tv.css`, am Ende der Serien-Detail-Styles (vor den Episode-Grid-Styles, ca. Zeile 1815), einfügen:
```css
/* Serie als gesehen markieren - Button */
.tv-mark-series-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, border-color 0.2s;
}
.tv-mark-series-btn:hover,
.tv-mark-series-btn:focus {
background: var(--bg-hover);
border-color: var(--accent);
}
.tv-mark-series-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
```
- [ ] **Step 4: Manuell testen**
Browser → Serien-Detail-Seite:
1. Button "Serie als gesehen" ist im Header sichtbar
2. D-Pad kann den Button fokussieren
3. Enter markiert alle Episoden aller Staffeln als gesehen
4. Alle Staffel-Tabs zeigen Häkchen
5. Button ändert Farbe zu accent
- [ ] **Step 5: Commit**
```bash
git add video-konverter/app/templates/tv/series_detail.html video-konverter/app/static/tv/css/tv.css
git commit -m "feat(tv): Serie als gesehen markieren - Button im Header"
```
---
## Task 6: Finaler Test & Gesamtcommit
- [ ] **Step 1: Alle Änderungen zusammen testen**
Checkliste:
- [ ] Serien-Seite: Alphabet zeigt nur verfügbare Buchstaben
- [ ] Serien-Seite: D-Pad rechts → Sidebar, links → zurück, Enter → filtert
- [ ] Serien-Detail: Episoden-Cards größer, Laufzeit vollständig sichtbar
- [ ] Serien-Detail: D-Pad kann Gesehen-Button auf Cards fokussieren + Enter togglen
- [ ] Serien-Detail: "Serie als gesehen"-Button funktioniert
- [ ] **Step 2: Docker-Image bauen und taggen**
```bash
cd "/mnt/17 - Entwicklungen/20 - Projekte/VideoKonverter"
PUID=1000 PGID=1000 docker compose --profile cpu build
```

View file

@ -0,0 +1,353 @@
# TV-App D-Pad & Usability Fixes (Samsung Tizen)
**Datum:** 2026-03-16
**Zielgerät:** Samsung Tizen TV (1920x1080)
## Zusammenfassung
5 Probleme bei der Fernbedienungs-Navigation der TV-App beheben:
1. Alphabet-Sidebar nicht per D-Pad erreichbar
2. Obere Buchstaben verdeckt (zu viele Einträge)
3. Episoden-Cards zu klein / Laufzeit abgeschnitten
4. "Gesehen"-Button auf Episode-Cards nicht fokussierbar
5. Kein "Serie als gesehen"-Button (nur Staffel-Level)
## Betroffene Dateien
| Datei | Änderungen |
|-------|------------|
| `video-konverter/app/templates/tv/series.html` | Alphabet-Sidebar: nur verfügbare Buchstaben rendern |
| `video-konverter/app/templates/tv/series_detail.html` | Episode-Mark-Button fokussierbar machen, "Serie als gesehen"-Button |
| `video-konverter/app/static/tv/css/tv.css` | Episoden-Grid vergrößern, Sidebar anpassen, Mark-Button sichtbar |
| `video-konverter/app/static/tv/js/tv.js` | FocusManager: explizite Sidebar-Navigation |
## Änderung 1: Alphabet-Sidebar nur verfügbare Buchstaben
### Problem
Die Sidebar rendert alle 26 Buchstaben + `#` (27 Einträge × 36px = 972px). Buchstaben ohne Serien werden nur gedimmt, nicht entfernt. Auf 1080p-TVs ragt die Sidebar über den Bildschirm hinaus.
### Lösung
Im Template `series.html` (Zeile 161-167): Nur Buchstaben rendern, die tatsächlich Serien haben. Das Backend liefert bereits die Serien mit `data-letter` Attribut — wir sammeln die verfügbaren Buchstaben serverseitig oder per Jinja2-Filter.
**Ansatz:** Per JavaScript im bestehenden IIFE — Buchstaben ohne Treffer komplett entfernen statt dimmen. Das bestehende Script (series.html, Zeile 244-253) wird angepasst:
```javascript
// Buchstaben ohne Treffer komplett entfernen (statt nur dimmen)
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.remove(); // entfernen statt dimmen
});
})();
```
Kein Backend-Change nötig, kein Jinja2-Template-Change.
## Änderung 2: Alphabet-Sidebar per D-Pad erreichbar machen
### Problem
Der FocusManager (tv.js, Zeile 240-243) filtert bei der Nearest-Neighbor-Suche Nav-Elemente vs. Content-Elemente. Die Sidebar ist weder Nav noch "normaler" Content — sie ist `position: fixed` am rechten Rand. Der Nearest-Neighbor-Algorithmus findet sie nicht zuverlässig, weil:
- Die Sidebar bei `right: 12px` liegt, also außerhalb des Content-Grid-Flows
- Der Distanz-Bias (Zeile 266-267) horizontale Abstände 3× stärker gewichtet
### Lösung
Explizite Sidebar-Navigation im FocusManager einbauen:
1. **ArrowRight** am rechten Rand des Serien-Grids → Focus springt zum nächstgelegenen Buchstaben in der Sidebar
2. **ArrowLeft** in der Sidebar → Focus springt zurück zur letzten fokussierten Serien-Card
3. **ArrowUp/ArrowDown** in der Sidebar → sequentielle Navigation durch die Buchstaben
In `tv.js`, nach der Nav-Logik (ca. Zeile 173), neue Sidebar-Logik einfügen:
```javascript
// Alphabet-Sidebar Navigation
const inSidebar = active.closest(".tv-alpha-sidebar");
if (inSidebar) {
if (direction === "ArrowLeft") {
// Zurück zum Content
if (this._lastContentFocus && document.contains(this._lastContentFocus)) {
this._lastContentFocus.focus();
} else {
const firstCard = document.querySelector(".tv-grid [data-focusable], .tv-card[data-focusable]");
if (firstCard) firstCard.focus();
}
e.preventDefault();
return;
}
if (direction === "ArrowUp" || direction === "ArrowDown") {
// Sequentiell durch Buchstaben
const letters = Array.from(document.querySelectorAll(".tv-alpha-letter[data-focusable]"))
.filter(el => el.offsetHeight > 0);
const idx = letters.indexOf(active);
const next = direction === "ArrowDown" ? idx + 1 : idx - 1;
if (next >= 0 && next < letters.length) {
letters[next].focus();
letters[next].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
e.preventDefault();
return;
}
}
// ArrowRight am rechten Grid-Rand -> Sidebar
if (!inNav && !inSidebar && direction === "ArrowRight") {
const sidebar = document.getElementById("alpha-sidebar");
if (sidebar && sidebar.offsetHeight > 0) {
// Prüfen ob es noch ein Element rechts im Content gibt
const contentEls = focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
const currentRect_r = active.getBoundingClientRect();
const rightNeighbor = contentEls.some(el => {
const r = el.getBoundingClientRect();
return r.left > currentRect_r.right + 5 && Math.abs(r.top - currentRect_r.top) < 100;
});
if (!rightNeighbor) {
// Kein Content rechts -> zur Sidebar springen
const sidebarLetters = Array.from(sidebar.querySelectorAll("[data-focusable]"))
.filter(el => el.offsetHeight > 0);
if (sidebarLetters.length > 0) {
// Nächstgelegenen Buchstaben vertikal finden
const cy_r = currentRect_r.top + currentRect_r.height / 2;
let best = sidebarLetters[0];
let bestDist_r = Infinity;
sidebarLetters.forEach(l => {
const lr = l.getBoundingClientRect();
const d = Math.abs(lr.top + lr.height / 2 - cy_r);
if (d < bestDist_r) { bestDist_r = d; best = l; }
});
this._lastContentFocus = active;
best.focus();
e.preventDefault();
return;
}
}
}
}
```
**WICHTIG: Beide folgenden Änderungen sind atomar — sie müssen zusammen eingefügt werden!**
Zusätzlich: Die Sidebar-Elemente aus der normalen Nearest-Neighbor-Suche ausschließen (Zeile 240-243 erweitern):
```javascript
const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
```
Außerdem: Im `focusin`-Handler (tv.js) die Sidebar aus dem `_lastContentFocus`-Tracking ausschließen, damit der ArrowLeft-Rücksprung zuverlässig funktioniert:
```javascript
// Im focusin-Handler: Sidebar-Elemente nicht als Content-Focus speichern
if (!el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar")) {
this._lastContentFocus = el;
}
```
**Enter-Handling:** Wenn der Nutzer in der Sidebar Enter drückt, simuliert der FocusManager einen Click auf das `data-focusable`-Element, was `onclick="filterByLetter()"` auslöst. Kein zusätzlicher Code nötig.
## Änderung 3: Episoden-Cards vergrößern
### Problem
Das Episode-Grid nutzt `minmax(180px, 1fr)` (tv.css, Zeile 1821). Auf einem 1080p-TV sind die Cards zu klein und die Laufzeit-Badge wird abgeschnitten.
### Lösung
Grid-Spalten vergrößern und Laufzeit-Badge anpassen. **Achtung:** Es gibt einen `@media (min-width: 1200px)` Breakpoint (tv.css, Zeile 2019-2020) der auf `minmax(220px, 1fr)` setzt — dieser muss ebenfalls angepasst werden.
```css
/* Basis (tv.css, Zeile 1821) */
.tv-episode-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); /* war 180px */
gap: 1rem; /* war 0.8rem */
}
/* Responsive: 1200px+ Breakpoint (tv.css, Zeile 2019-2020) — anpassen */
@media (min-width: 1200px) {
.tv-episode-grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } /* war 220px */
}
.tv-ep-duration {
font-size: 0.8rem; /* war 0.7rem */
padding: 3px 8px; /* war 2px 6px */
white-space: nowrap;
}
```
## Änderung 4: "Gesehen"-Button auf Episode-Cards fokussierbar
### Problem
Der Mark-Button (series_detail.html, Zeile 129-132) hat `tabindex="-1"` und kein `data-focusable`. Dadurch kann er per D-Pad nie erreicht werden. Zusätzlich hat er `opacity: 0` und wird nur bei hover/focus-within sichtbar — auf TV ohne Maus problematisch.
### Lösung
**Template** (series_detail.html, Zeile 129-132):
```html
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
data-focusable
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003;
</button>
```
Änderungen:
- `tabindex="-1"` entfernen
- `data-focusable` hinzufügen
**CSS** (tv.css): Mark-Button auf TV immer sichtbar machen. Der Base-Wert `opacity: 0` bleibt für Desktop/Mobile erhalten — nur auf TV (kein Hover-Support) wird der Button dauerhaft sichtbar:
```css
/* TV-Geräte (kein Hover): Mark-Button immer leicht sichtbar */
@media (hover: none) {
.tv-ep-tile-mark {
opacity: 0.5;
}
}
.tv-episode-tile:hover .tv-ep-tile-mark,
.tv-episode-tile:focus-within .tv-ep-tile-mark,
.tv-ep-tile-mark:focus {
opacity: 1;
}
/* Focus-Ring für Mark-Button */
.tv-ep-tile-mark:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
```
## Änderung 5: "Serie als gesehen markieren"-Button
### Problem
Es gibt nur `markSeasonWatched()` pro Staffel (series_detail.html, Zeile 96-99). Bei Serien mit vielen Staffeln muss man jede einzeln durchgehen.
### Lösung
**Template** — Neuen Button im Header-Aktionsbereich (series_detail.html, Zeile 57-66):
```html
<div class="tv-detail-actions">
<button class="tv-watchlist-btn ..." ...>
...
</button>
<!-- NEU: Serie als gesehen markieren -->
<button class="tv-mark-series-btn"
id="btn-mark-series"
data-focusable
data-series-id="{{ series.id }}"
onclick="markSeriesWatched(this)">
<span class="mark-series-icon">&#10003;</span>
<span class="mark-series-text">{{ t('status.mark_series') }}</span>
</button>
</div>
```
**JavaScript** — Neue Funktion `markSeriesWatched()`:
```javascript
function markSeriesWatched(btn) {
const seriesId = btn.dataset.seriesId;
// Alle ungesehenen Episoden aus ALLEN Staffeln sammeln
const allCards = document.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
const ids = [];
allCards.forEach(card => {
const vid = card.dataset.videoId;
if (vid) ids.push(parseInt(vid));
});
if (ids.length === 0) return;
// Batch-Request an API
Promise.all(ids.map(id =>
fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
})
)).then(() => {
// Alle Episoden-Cards als gesehen markieren
document.querySelectorAll('.tv-episode-tile').forEach(card => {
card.classList.add('tv-ep-seen');
const markBtn = card.querySelector('.tv-ep-tile-mark');
if (markBtn) markBtn.classList.add('active');
const thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
const check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#10003;';
thumb.appendChild(check);
}
});
// Alle Staffel-Tabs als komplett markieren
document.querySelectorAll('.tv-tab').forEach(tab => {
if (!tab.classList.contains('tv-tab-complete')) {
tab.classList.add('tv-tab-complete');
if (!tab.querySelector('.tv-tab-check')) {
const check = document.createElement('span');
check.className = 'tv-tab-check';
check.innerHTML = ' &#10003;';
tab.appendChild(check);
}
}
});
// Button-Zustand ändern
btn.classList.add('active');
}).catch(() => {});
}
```
**CSS** — Styling für den neuen Button:
```css
.tv-mark-series-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.2s, border-color 0.2s;
}
.tv-mark-series-btn:hover,
.tv-mark-series-btn:focus {
background: var(--bg-hover);
border-color: var(--accent);
}
.tv-mark-series-btn.active {
background: var(--accent);
color: #000;
border-color: var(--accent);
}
```
**i18n** — Bestehender Schlüssel `status.mark_series` wird verwendet:
- `de.json`: `"mark_series": "Serie als gesehen"` (bereits vorhanden)
- `en.json`: `"mark_series": "Mark series as watched"` (bereits vorhanden)
Kein neuer i18n-Key nötig.
## Zusammenfassung der Änderungen
| # | Datei | Art | Beschreibung |
|---|-------|-----|-------------|
| 1 | `series.html` | JS ändern | Buchstaben ohne Serien per JS entfernen statt dimmen |
| 2 | `tv.js` | Code einfügen | **ATOMAR:** Explizite Sidebar-Navigation + Sidebar aus Nearest-Neighbor ausschließen + focusin-Handler anpassen |
| 3 | `tv.css` | CSS ändern | Episode-Grid: `minmax(240px, 1fr)`, Laufzeit-Badge größer |
| 4 | `series_detail.html` | HTML ändern | Mark-Button: `tabindex="-1"``data-focusable` |
| 4b | `tv.css` | CSS ändern | Mark-Button: `opacity: 0.5` nur auf TV (hover:none), Focus-Ring |
| 5 | `series_detail.html` | HTML+JS einfügen | "Serie als gesehen"-Button + `markSeriesWatched()` |
| 5b | `tv.css` | CSS einfügen | Styling für `.tv-mark-series-btn` |
| 5c | i18n | — | Bestehender Key `status.mark_series` wird verwendet (kein Change) |
## Risiken & Hinweise
- **Batch-Requests:** `markSeriesWatched()` sendet einen Request pro Episode. Bei Serien mit 200+ Episoden könnte das den Server belasten. Alternative: Batch-Endpoint im Backend. Für jetzt akzeptabel, da `markSeasonWatched()` das gleiche Pattern nutzt.
- **Sidebar-Navigation:** Die explizite Logik muss vor dem Nearest-Neighbor-Algorithmus greifen, sonst wird sie nie erreicht.
- **Episode-Mark-Button:** Mit `data-focusable` wird der Button ein eigener D-Pad-Stopp. Der Nutzer muss also einmal extra navigieren pro Card (Card → Button). Das ist gewollt, damit man zwischen "Abspielen" (Card-Link) und "Als gesehen markieren" (Button) wählen kann.

View file

@ -1,11 +1,17 @@
#!/bin/bash
# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis,
# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid).
# Entrypoint: PUID/PGID User-Switching + Default-Config kopieren
#
# Unterstuetzt zwei Betriebsarten:
# 1) docker-compose mit user: "${PUID:-99}:${PGID:-100}" → laeuft direkt als richtiger User
# 2) Unraid Docker-UI mit PUID/PGID als Container-Variablen → entrypoint wechselt den User
PUID=${PUID:-99}
PGID=${PGID:-100}
CFG_DIR="/opt/video-konverter/app/cfg"
DEFAULTS_DIR="/opt/video-konverter/cfg_defaults"
# Alle Default-Dateien kopieren, wenn nicht vorhanden
# Default-Konfigdateien kopieren falls nicht vorhanden
for file in "$DEFAULTS_DIR"/*; do
filename=$(basename "$file")
if [ ! -f "$CFG_DIR/$filename" ]; then
@ -14,5 +20,32 @@ for file in "$DEFAULTS_DIR"/*; do
fi
done
# Anwendung starten
exec python3 __main__.py
# Pruefen ob wir als root laufen (Unraid Docker-UI Modus)
if [ "$(id -u)" = "0" ]; then
echo "Container laeuft als root - wechsle zu PUID=$PUID PGID=$PGID"
# Gruppe erstellen/aendern
if getent group vkuser > /dev/null 2>&1; then
groupmod -o -g "$PGID" vkuser
else
groupadd -o -g "$PGID" vkuser
fi
# User erstellen/aendern
if id vkuser > /dev/null 2>&1; then
usermod -o -u "$PUID" -g "$PGID" vkuser
else
useradd -o -u "$PUID" -g "$PGID" -M -s /bin/bash vkuser
fi
# Verzeichnis-Berechtigungen rekursiv setzen (inkl. vorhandener Dateien)
chown -R "$PUID:$PGID" /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls 2>/dev/null
chown -R "$PUID:$PGID" "$CFG_DIR" 2>/dev/null
# Als PUID:PGID User starten
exec gosu "$PUID:$PGID" python3 __main__.py
else
# Laeuft bereits als richtiger User (docker-compose user: Direktive)
echo "Container laeuft als UID=$(id -u) GID=$(id -g)"
exec python3 __main__.py
fi

View file

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

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

@ -0,0 +1,195 @@
# 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, getestet: Tizen 9.0)
- 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: Package Manager CLI nutzen
~/tizen-studio/package-manager/package-manager-cli.bin install \
--accept-license cert-add-on TV-SAMSUNG-Public
# WICHTIG auf Manjaro/Arch: Fake-dpkg Wrapper anlegen
# (Tizen PM prueft dpkg Pakete die auf Arch nicht existieren)
mkdir -p /tmp/fake-dpkg
cat > /tmp/fake-dpkg/dpkg << 'EOF'
#!/bin/bash
if [[ "$*" == *"-l"* ]] || [[ "$*" == *"--list"* ]] || [[ "$*" == *"-s"* ]]; then
for pkg in "$@"; do
[[ "$pkg" != -* ]] && echo "ii $pkg 99.0 amd64 Fake"
done
fi
exit 0
EOF
chmod +x /tmp/fake-dpkg/dpkg
# Dann mit PATH="/tmp/fake-dpkg:$PATH" den Package Manager starten
```
### Wichtige Pfade nach Installation
```
~/tizen-studio/tools/sdb # Smart Development Bridge
~/tizen-studio/tools/ide/bin/tizen # CLI-Tool
~/tizen-studio/tools/certificate-manager/certificate-manager # Certificate Manager GUI
```
## Schritt 2: Samsung Developer Zertifikat erstellen
Das Zertifikat signiert die App fuer den TV. Samsung TVs akzeptieren NUR Samsung-signierte
Zertifikate (nicht Standard-Tizen!).
### Voraussetzung: Samsung Certificate Extension installieren
```bash
# MUSS installiert sein, sonst erscheint "Samsung" nicht als Option!
PATH="/tmp/fake-dpkg:$PATH" ~/tizen-studio/package-manager/package-manager-cli.bin \
install --accept-license cert-add-on
```
### Zertifikat erstellen (Certificate Manager GUI)
1. Certificate Manager starten: `~/tizen-studio/tools/certificate-manager/certificate-manager`
2. **"+" klicken** > **Samsung** waehlen (NICHT Tizen!)
3. **TV** als Geraetetyp waehlen
4. Author-Zertifikat: Key Filename + Author Name eingeben
5. Samsung Developer Account einloggen
6. Distributor-Zertifikat: DUID des TVs eintragen (siehe Schritt 3)
7. **Finish** - Zertifikat wird unter `~/SamsungCertificate/<Profilname>/` gespeichert
**WICHTIG:** Zertifikat sichern! Bei App-Updates muss das gleiche Zertifikat verwendet werden.
Backup liegt in `tizen-app/certs/`.
## Schritt 3: TV vorbereiten (Developer Mode)
1. TV einschalten
2. **Apps** oeffnen (Home > Apps)
3. Ziffern **12345** eingeben (virtuelles Nummernfeld bei neueren Fernbedienungen)
4. Developer Mode **ON** schalten
5. **Host PC IP** eingeben (IP des PCs mit Tizen Studio)
6. **TV neustarten** (wichtig!)
### DUID auslesen (fuer Zertifikat)
```bash
# Erst TV verbinden (Schritt 4), dann:
~/tizen-studio/tools/sdb shell 0 getduid
# Gibt z.B. zurueck: KLCDNTGIJS4OU
```
## Schritt 4: TV verbinden
```bash
# Verbinden (Port 26101 muss offen sein)
~/tizen-studio/tools/sdb connect <TV-IP>
# Pruefen
~/tizen-studio/tools/sdb devices
# Zeigt: <IP>:26101 device <TV-Modell>
```
Falls Verbindung fehlschlaegt:
- Developer Mode aktiviert? PC-IP korrekt eingegeben?
- TV nach Aenderung des Developer Mode neugestartet?
- Port 26101 in Firewall offen?
## Schritt 5: WGT bauen und installieren
### WGT mit Samsung-Zertifikat signieren
```bash
# Sauberes Build-Verzeichnis erstellen (nur App-Dateien!)
mkdir -p /tmp/tizen-build
cp tizen-app/config.xml tizen-app/index.html tizen-app/icon.png /tmp/tizen-build/
# WGT mit Samsung Security Profile signieren
cd /tmp/tizen-build
~/tizen-studio/tools/ide/bin/tizen package -t wgt -s <Profilname> -- /tmp/tizen-build/
```
### Auf TV installieren
```bash
# TV-Name mit sdb devices ermitteln
~/tizen-studio/tools/ide/bin/tizen install -n /tmp/tizen-build/VideoKonverter.wgt -t <TV-Name>
```
Erfolgreiche Ausgabe:
```
Tizen application is successfully installed.
```
### App deinstallieren (falls noetig)
```bash
~/tizen-studio/tools/sdb shell 0 vd_appuninstall vkTVApp001.VideoKonverter
```
## 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 unter `/admin`)
## 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
### "Invalid certificate chain" bei Installation
- **Haeufigster Fehler!** Samsung TVs akzeptieren NUR Samsung-signierte Zertifikate
- Im Certificate Manager: "Samsung" waehlen, NICHT "Tizen"
- Samsung Certificate Extension (`cert-add-on`) muss installiert sein
### TV wird nicht gefunden
- Sind PC und TV im gleichen Netzwerk/VLAN?
- Ist Developer Mode auf dem TV aktiviert + TV neugestartet?
- Firewall auf dem PC: Port 26101 offen?
### Video startet langsam / nicht
- Server laeuft? `curl http://<Server-IP>:8080/tv/`
- AV1-Videos brauchen einen TV mit AV1-Unterstuetzung (ab ~2020)
- Streaming nutzt fragmented MP4 (`frag_keyframe+empty_moov+default_base_moof`)
### App startet nicht / weisser Bildschirm
- Richtige Server-IP eingegeben?
- Browser-Cache leeren: App deinstallieren und neu installieren
## Getestete Konfiguration
| Komponente | Version |
|-----------|---------|
| Samsung TV | GQ65Q7FAAUXZG (Tizen 9.0) |
| Tizen Studio | 6.0+ |
| sdb | 4.2.36 |
| tizen CLI | 2.5.25 |
| Host OS | Manjaro Linux (KDE) |
## 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

Binary file not shown.

View file

@ -0,0 +1,27 @@
-----BEGIN CERTIFICATE-----
MIIEfTCCAmWgAwIBAgIIV7LKnb5BWxEwDQYJKoZIhvcNAQELBQAwgZIxCzAJBgNV
BAYTAktSMRowGAYDVQQIDBFSZXB1YmxpYyBvZiBLb3JlYTETMBEGA1UEBwwKU3V3
b24gQ2l0eTEmMCQGA1UECgwdU2Ftc3VuZyBFbGVjdHJvbmljcyBDby4sIEx0ZC4x
CzAJBgNVBAsMAlZEMR0wGwYDVQQDDBRTYW1zdW5nIFZEIEF1dGhvciBDQTAeFw0y
NjAyMjgyMTEwMDhaFw0yNzAyMjgyMTEwMDhaME4xCTAHBgNVBAYTADEJMAcGA1UE
CBMAMQkwBwYDVQQHEwAxCTAHBgNVBAoTADEJMAcGA1UECxMAMRUwEwYDVQQDEwxF
ZHVhcmQgV2lzY2gwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCPuf5J
A+94c9QTqtm8rF6zPOCRjXZGZ+CoZLaJ1NMWLEwyTasEf08qkbiwzlmH3EAlyh4e
EmEBTqb5n6wCaakUtxwBYOb79yFSpoUU254XhjNilRW6F4x0loY+l6SaDpfkRjMx
PSLUZn2wXOXsVIgH39EFK3V1098hS5dBU++eBKhGFabvXo6cwC8a/f7CaE6JFZxe
v+F2zZW4nYWEAl6QTrgBWrGrzZQHbGjID8ZOcx/I2xQP0yD2oNsxMxWppUxCJjZS
k97hRA+XN/j1AVIRokXJDcOfifsNMNp6y0HV029X+57KN/zlGzZBJIXaRqqQS5JW
gHH9Oyn210Cj9iylAgMBAAGjGjAYMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgeAMA0G
CSqGSIb3DQEBCwUAA4ICAQBfaijH6HNNoUEBxXgGX6cxhoyVwhRQHIdkjBvmSi3G
Yf9E3agFM2SFo/TiFgQzEssiQiXabHKiYohUlDxjGp88UqilMyVflq54Uzw7EpBM
XxHlxTWIMdnUWXTRBbbxSFnvcde2dcH2hFk5TeiddaXOwcvErl6ewDytkBgL/MVT
zqUoiN7ATMwx1R3D5Kp7sog14UwkiKHLOdven+leURhRkl6rxJ9dD5X1ETisXhF5
QFazYXm/Hm3cAMfzqZVZGAXn0YZg2cP+iwroFGXxXONDaJAYNleUXZvZqhfXIN81
zqeIVL2fotl8pUiLkTihbBjd1bvzK+aAHPHeJHKbBBjGfH6l5d5d5xj9RU7o92bv
CiUe+lwN4IBcox8w1zFPqGv/8MFwcK/hoNh001rsAOoU3PQPag/7cvReqaKHbNNr
b8wUAlieTZktyrs4Ey9bd0eSJer5YfyLuQi1CiiLRcth2h5O9Xj4UEZfFE1DoIiQ
S1d/HQ1+MyFd2RszNqLqORL0PfgT909GSN+g0FIO/4uOvJUikAnhNT1QD62p6DvS
XJtlDE2KeYEzwoAxpX7RDf8AXuvFe9VG2rrmGJjXu+iPv6ez3+TnrXnUovxeoJZw
avbvaMDa93lcnTqzxhzXmJAC9kN+6L0ocN+XZIanxEABXm7+cOLkjRNRjg6n9unB
jQ==
-----END CERTIFICATE-----

BIN
tizen-app/certs/author.p12 Normal file

Binary file not shown.

View file

@ -0,0 +1,28 @@
-----BEGIN CERTIFICATE-----
MIIEyjCCArKgAwIBAgIIHZzx6svX/q0wDQYJKoZIhvcNAQELBQAwgY8xCzAJBgNV
BAYTAktSMRQwEgYDVQQIDAtTb3V0aCBLb3JlYTEOMAwGA1UEBwwFU3V3b24xJjAk
BgNVBAoMHVNhbXN1bmcgRWxlY3Ryb25pY3MgQ28uLCBMdGQuMQswCQYDVQQLDAJW
RDElMCMGA1UEAwwcVkQgREVWRUxPUEVSIFB1YmxpYyBDQSBDbGFzczAeFw0yNjAy
MjgyMTExMzJaFw0yNzAyMjgyMTExMzJaMHMxETAPBgNVBAMMCFRpemVuU0RLMQkw
BwYDVQQLDAAxCTAHBgNVBAoMADEJMAcGA1UEBwwAMQkwBwYDVQQIDAAxCTAHBgNV
BAYTADEnMCUGCSqGSIb3DQEJARYYZGF0YUBkYXRhLWl0LXNvbHV0aW9uLmRlMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsIsx/nP0lNgrK5726AEUxgeo
Qjc4K/O6QvdGdquoGKUgy+cLVVF4COKNPM/PrcEFI36yXH8fAoSmd0+6QGRi8VB4
ZUhJDiuSEy8GD+VXEPB3sFKl4sR0AtD8K5nEaioegE6ldJmLYtROysgFAsFLWv9I
L00OkMSDR5+mxtbT9wf2XJG8q+GToGJ0/UBEp/AMkfZ6M8DdhUZRFlfsASfRuOfa
aQ2joKqDR7Lq4mZdU1esJfN2HsuCBdT4e104QZOtelMW10f8KarWV8nFIkDIsq+B
3Y1zbinH/gYNwOrzqwnH7KgiBvjRc+KCfuFd5BUaV/6igl7dEluFb1zWoFNKrQID
AQABo0UwQzBBBgNVHREEOjA4hhRVUk46dGl6ZW46cGFja2FnZWlkPYYgVVJOOnRp
emVuOmRldmljZWlkPUtMQ0ROVEdJSlM0T1UwDQYJKoZIhvcNAQELBQADggIBAA8D
l3Unyxt/a0xv6Rq1FPvBdMhj4KtFDm18Rd+b6966Dun6VTEwSzbmywewZqcM2rNt
qB91AHUegVoRg0bCEBLA8mwqIPCvnSf/BuX7xtUpvdFVrF4EzgbryAT3HOfAphZ3
lftMOD+NreXjpp2NGgZ7GeqYzwDZFK1kvhGzIix1lozA+q111+kNuZ87HGVgO1z2
0MPtR9AxwCCNWZV9t/mNNWSuvyrUwkCMy4hwSYzWp0Q09g5MZdgaqvv0GE8cB1hO
85EKEqY64RHZlAUTIKNlm2veGRWMoHIooBa3kdAjB+GK9Krv+delcOvzeb5Ys9Xq
C58ouFD5VPovt0CfLJ8E1MOcwrCjdm0GYw5pyi1i8yVqwjQa6F60DEhQoWtF7XPo
IRRsHcZXNQCzTwVm3Xa71VR3JQL7RM6QRoOiUdaQh2hs5HqwgdsT/gFqThp/bMFZ
fSM5RZ+TqLbdXO9FWK7XsnIz3dlJdBjw/RCRpO94gjTWL+Adb9bzcAFULv8dMjKF
+9eSE81uFyaEuuyRhkBlw7f2RsUbpDeywuoXbCNv0FJO8buWRcPTjSeHLbSfyajN
T0MKY5L/68joIKByjd6e1npM4OLdZ6mq+wL8HIeISCcpJv2yZxjDLN8tVn6mX9ZC
IDvpfiuZx37W72DPJViYIXkcroSK1m9w6wIIoyg8
-----END CERTIFICATE-----

Binary file not shown.

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

@ -0,0 +1,34 @@
<?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="5.8.0" viewmodes="maximized">
<name>VideoKonverter</name>
<description>VideoKonverter TV-App - Serien und Filme streamen mit AVPlay Direct-Play</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"/>
<tizen:privilege name="http://developer.samsung.com/privilege/avplay"/>
<!-- Netzwerk-Zugriff erlauben (lokales Netz) -->
<access origin="*" subdomains="true"/>
<!-- KEIN tizen:allow-navigation! Das aendert die CSP und blockiert inline CSS/JS! -->
<!-- <access origin="*"> reicht fuer iframe/XHR-Zugriff -->
<!-- TV-spezifische Einstellungen -->
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable"
encryption="disable" install-location="auto" hwkey-event="enable"/>
</widget>

BIN
tizen-app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

1034
tizen-app/index.html Normal file

File diff suppressed because it is too large Load diff

937
tools.yaml Normal file
View file

@ -0,0 +1,937 @@
---
version: v1.2
tools:
## Access Groups
## An access group is the equivalent of an Endpoint Group in Portainer.
## ------------------------------------------------------------
- name: listAccessGroups
description: List all available access groups
annotations:
title: List Access Groups
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: createAccessGroup
description: Create a new access group. Use access groups when you want to define
accesses on more than one environment. Otherwise, define the accesses on
the environment level.
parameters:
- name: name
description: The name of the access group
type: string
required: true
- name: environmentIds
description: "The IDs of the environments that are part of the access group.
Must include all the environment IDs that are part of the group - this
includes new environments and the existing environments that are
already associated with the group. Example: [1, 2, 3]"
type: array
items:
type: number
annotations:
title: Create Access Group
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: updateAccessGroupName
description: Update the name of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: name
description: The name of the access group
type: string
required: true
annotations:
title: Update Access Group Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateAccessGroupUserAccesses
description: Update the user accesses of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: userAccesses
description: "The user accesses that are associated with all the environments in
the access group. The ID is the user ID of the user in Portainer.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
access: 'standard_user'}]"
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the user
type: number
access:
description: The access level of the user. Can be environment_administrator,
helpdesk_user, standard_user, readonly_user or operator_user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Access Group User Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateAccessGroupTeamAccesses
description: Update the team accesses of an existing access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: teamAccesses
description: "The team accesses that are associated with all the environments in
the access group. The ID is the team ID of the team in Portainer.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2,
access: 'standard_user'}]"
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the team
type: number
access:
description: The access level of the team. Can be environment_administrator,
helpdesk_user, standard_user, readonly_user or operator_user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Access Group Team Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: addEnvironmentToAccessGroup
description: Add an environment to an access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: environmentId
description: The ID of the environment to add to the access group
type: number
required: true
annotations:
title: Add Environment To Access Group
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: removeEnvironmentFromAccessGroup
description: Remove an environment from an access group.
parameters:
- name: id
description: The ID of the access group to update
type: number
required: true
- name: environmentId
description: The ID of the environment to remove from the access group
type: number
required: true
annotations:
title: Remove Environment From Access Group
readOnlyHint: false
destructiveHint: true
idempotentHint: true
openWorldHint: false
## Environment
## ------------------------------------------------------------
- name: listEnvironments
description: List all available environments
annotations:
title: List Environments
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentTags
description: Update the tags associated with an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: tagIds
description: >-
The IDs of the tags that are associated with the environment.
Must include all the tag IDs that should be associated with the environment - this includes new tags and existing tags.
Providing an empty array will remove all tags.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Tags
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentUserAccesses
description: Update the user access policies of an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: userAccesses
description: >-
The user accesses that are associated with the environment.
The ID is the user ID of the user in Portainer.
Must include all the access policies for all users that should be associated with the environment.
Providing an empty array will remove all user accesses.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the user
type: number
access:
description: The access level of the user
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Environment User Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentTeamAccesses
description: Update the team access policies of an environment
parameters:
- name: id
description: The ID of the environment to update
type: number
required: true
- name: teamAccesses
description: >-
The team accesses that are associated with the environment.
The ID is the team ID of the team in Portainer.
Must include all the access policies for all teams that should be associated with the environment.
Providing an empty array will remove all team accesses.
Example: [{id: 1, access: 'environment_administrator'}, {id: 2, access: 'standard_user'}]
type: array
required: true
items:
type: object
properties:
id:
description: The ID of the team
type: number
access:
description: The access level of the team
type: string
enum:
- environment_administrator
- helpdesk_user
- standard_user
- readonly_user
- operator_user
annotations:
title: Update Environment Team Accesses
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Environment Groups
## An environment group is the equivalent of an Edge Group in Portainer.
## ------------------------------------------------------------
- name: createEnvironmentGroup
description: Create a new environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: name
description: The name of the environment group
type: string
required: true
- name: environmentIds
description: The IDs of the environments to add to the group
type: array
required: true
items:
type: number
annotations:
title: Create Environment Group
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listEnvironmentGroups
description: List all available environment groups. Environment groups are the equivalent of Edge Groups in Portainer.
annotations:
title: List Environment Groups
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupName
description: Update the name of an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: name
description: The new name for the environment group
type: string
required: true
annotations:
title: Update Environment Group Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupEnvironments
description: Update the environments associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: environmentIds
description: >-
The IDs of the environments that should be part of the group.
Must include all environment IDs that should be associated with the group.
Providing an empty array will remove all environments from the group.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Group Environments
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateEnvironmentGroupTags
description: Update the tags associated with an environment group. Environment groups are the equivalent of Edge Groups in Portainer.
parameters:
- name: id
description: The ID of the environment group to update
type: number
required: true
- name: tagIds
description: >-
The IDs of the tags that should be associated with the group.
Must include all tag IDs that should be associated with the group.
Providing an empty array will remove all tags from the group.
Example: [1, 2, 3]
type: array
required: true
items:
type: number
annotations:
title: Update Environment Group Tags
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Settings
## ------------------------------------------------------------
- name: getSettings
description: Get the settings of the Portainer instance
annotations:
title: Get Settings
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Stacks
## ------------------------------------------------------------
- name: listStacks
description: List all available stacks
annotations:
title: List Stacks
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: getStackFile
description: Get the compose file for a specific stack ID
parameters:
- name: id
description: The ID of the stack to get the compose file for
type: number
required: true
annotations:
title: Get Stack File
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: createStack
description: Create a new stack
parameters:
- name: name
description: Name of the stack. Stack name must only consist of lowercase alpha
characters, numbers, hyphens, or underscores as well as start with a
lowercase character or number
type: string
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: services:
web:
image:nginx
type: string
required: true
- name: environmentGroupIds
description: "The IDs of the environment groups that the stack belongs to. Must
include at least one environment group ID. Example: [1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Create Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: updateStack
description: Update an existing stack
parameters:
- name: id
description: The ID of the stack to update
type: number
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: version: 3
services:
web:
image:nginx
type: string
required: true
- name: environmentGroupIds
description: "The IDs of the environment groups that the stack belongs to. Must
include at least one environment group ID. Example: [1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Update Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Local Stacks (regular Docker Compose stacks, non-Edge)
## ------------------------------------------------------------
- name: listLocalStacks
description: >-
List all local (non-edge) stacks deployed on Portainer environments.
Returns stack ID, name, status, type, environment ID, creation date,
and environment variables for each stack.
annotations:
title: List Local Stacks
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: getLocalStackFile
description: >-
Get the docker-compose file content for a specific local stack by its ID.
Returns the raw compose file as text.
parameters:
- name: id
description: The ID of the local stack to get the compose file for
type: number
required: true
annotations:
title: Get Local Stack File
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: createLocalStack
description: >-
Create a new local standalone Docker Compose stack on a specific environment.
Requires the environment ID, a stack name, and the compose file content.
Optionally accepts environment variables.
parameters:
- name: environmentId
description: The ID of the environment to deploy the stack to
type: number
required: true
- name: name
description: >-
Name of the stack. Stack name must only consist of lowercase alpha
characters, numbers, hyphens, or underscores as well as start with a
lowercase character or number
type: string
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: services:
web:
image: nginx
type: string
required: true
- name: env
description: >-
Optional environment variables for the stack. Each variable must have
a 'name' and 'value' field.
Example: [{"name": "DB_HOST", "value": "localhost"}]
type: array
required: false
items:
type: object
properties:
name:
type: string
description: The name of the environment variable
value:
type: string
description: The value of the environment variable
annotations:
title: Create Local Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: updateLocalStack
description: >-
Update an existing local stack with new compose file content and/or
environment variables. Requires the stack ID and environment ID.
parameters:
- name: id
description: The ID of the local stack to update
type: number
required: true
- name: environmentId
description: The ID of the environment where the stack is deployed
type: number
required: true
- name: file
description: >-
Content of the stack file. The file must be a valid
docker-compose.yml file. example: services:
web:
image: nginx
type: string
required: true
- name: env
description: >-
Optional environment variables for the stack. Each variable must have
a 'name' and 'value' field.
Example: [{"name": "DB_HOST", "value": "localhost"}]
type: array
required: false
items:
type: object
properties:
name:
type: string
description: The name of the environment variable
value:
type: string
description: The value of the environment variable
- name: prune
description: >-
If true, services that are no longer in the compose file will be removed.
Default: false
type: boolean
required: false
- name: pullImage
description: >-
If true, images will be pulled before deploying. Default: false
type: boolean
required: false
annotations:
title: Update Local Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: startLocalStack
description: >-
Start a stopped local stack. Brings up all containers defined in the
stack's compose file.
parameters:
- name: id
description: The ID of the local stack to start
type: number
required: true
- name: environmentId
description: The ID of the environment where the stack is deployed
type: number
required: true
annotations:
title: Start Local Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: stopLocalStack
description: >-
Stop a running local stack. Stops all containers defined in the
stack's compose file.
parameters:
- name: id
description: The ID of the local stack to stop
type: number
required: true
- name: environmentId
description: The ID of the environment where the stack is deployed
type: number
required: true
annotations:
title: Stop Local Stack
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: deleteLocalStack
description: >-
Delete a local stack permanently. This removes the stack and all its
associated containers from the environment.
parameters:
- name: id
description: The ID of the local stack to delete
type: number
required: true
- name: environmentId
description: The ID of the environment where the stack is deployed
type: number
required: true
annotations:
title: Delete Local Stack
readOnlyHint: false
destructiveHint: true
idempotentHint: false
openWorldHint: false
## Tags
## ------------------------------------------------------------
- name: createEnvironmentTag
description: Create a new environment tag
parameters:
- name: name
description: The name of the tag
type: string
required: true
annotations:
title: Create Environment Tag
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listEnvironmentTags
description: List all available environment tags
annotations:
title: List Environment Tags
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Teams
## ------------------------------------------------------------
- name: createTeam
description: Create a new team
parameters:
- name: name
description: The name of the team
type: string
required: true
annotations:
title: Create Team
readOnlyHint: false
destructiveHint: false
idempotentHint: false
openWorldHint: false
- name: listTeams
description: List all available teams
annotations:
title: List Teams
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateTeamName
description: Update the name of an existing team
parameters:
- name: id
description: The ID of the team to update
type: number
required: true
- name: name
description: The new name of the team
type: string
required: true
annotations:
title: Update Team Name
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateTeamMembers
description: Update the members of an existing team
parameters:
- name: id
description: The ID of the team to update
type: number
required: true
- name: userIds
description: "The IDs of the users that are part of the team. Must include all
the user IDs that are part of the team - this includes new users and
the existing users that are already associated with the team. Example:
[1, 2, 3]"
type: array
required: true
items:
type: number
annotations:
title: Update Team Members
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Users
## ------------------------------------------------------------
- name: listUsers
description: List all available users
annotations:
title: List Users
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false
- name: updateUserRole
description: Update an existing user
parameters:
- name: id
description: The ID of the user to update
type: number
required: true
- name: role
description: The role of the user. Can be admin, user or edge_admin
type: string
required: true
enum:
- admin
- user
- edge_admin
annotations:
title: Update User Role
readOnlyHint: false
destructiveHint: false
idempotentHint: true
openWorldHint: false
## Docker Proxy
## ------------------------------------------------------------
- name: dockerProxy
description: Proxy Docker requests to a specific Portainer environment.
This tool can be used with any Docker API operation as documented in the Docker Engine API specification (https://docs.docker.com/reference/api/engine/version/v1.48/).
In read-only mode, only GET requests are allowed.
parameters:
- name: environmentId
description: The ID of the environment to proxy Docker requests to
type: number
required: true
- name: method
description: The HTTP method to use to proxy the Docker API operation
type: string
required: true
enum:
- GET
- POST
- PUT
- DELETE
- HEAD
- name: dockerAPIPath
description: "The route of the Docker API operation to proxy. Must include the leading slash. Example: /containers/json"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Docker API operation. Must be an array of key-value pairs.
Example: [{key: 'all', value: 'true'}, {key: 'filter', value: 'dangling'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Docker API operation. Must be an array of key-value pairs.
Example: [{key: 'Content-Type', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
- name: body
description: "The body of the Docker API operation to proxy. Must be a JSON string.
Example: {'Image': 'nginx:latest', 'Name': 'my-container'}"
type: string
required: false
annotations:
title: Docker Proxy
readOnlyHint: true
destructiveHint: true
idempotentHint: true
openWorldHint: false
## Kubernetes Proxy
## ------------------------------------------------------------
- name: kubernetesProxy
description: Proxy Kubernetes requests to a specific Portainer environment.
This tool can be used with any Kubernetes API operation as documented in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
In read-only mode, only GET requests are allowed.
parameters:
- name: environmentId
description: The ID of the environment to proxy Kubernetes requests to
type: number
required: true
- name: method
description: The HTTP method to use to proxy the Kubernetes API operation
type: string
required: true
enum:
- GET
- POST
- PUT
- DELETE
- HEAD
- name: kubernetesAPIPath
description: "The route of the Kubernetes API operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'Content-Type', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
- name: body
description: "The body of the Kubernetes API operation to proxy. Must be a JSON string.
Example: {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'my-pod'}}"
type: string
required: false
annotations:
title: Kubernetes Proxy
readOnlyHint: true
destructiveHint: true
idempotentHint: true
openWorldHint: false
- name: getKubernetesResourceStripped
description: >-
Proxy GET requests to a specific Portainer environment for Kubernetes resources,
and automatically strips verbose metadata fields (such as 'managedFields') from the API response
to reduce its size. This tool is intended for retrieving Kubernetes resource
information where a leaner payload is desired.
This tool can be used with any GET Kubernetes API operation as documented
in the Kubernetes API specification (https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/).
For other methods (POST, PUT, DELETE, HEAD), use the 'kubernetesProxy' tool.
parameters:
- name: environmentId
description: The ID of the environment to proxy Kubernetes GET requests to
type: number
required: true
- name: kubernetesAPIPath
description: "The route of the Kubernetes API GET operation to proxy. Must include the leading slash. Example: /api/v1/namespaces/default/pods"
type: string
required: true
- name: queryParams
description: "The query parameters to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'watch', value: 'true'}, {key: 'fieldSelector', value: 'metadata.name=my-pod'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the query parameter
value:
type: string
description: The value of the query parameter
- name: headers
description: "The headers to include in the Kubernetes API operation. Must be an array of key-value pairs.
Example: [{key: 'Accept', value: 'application/json'}]"
type: array
required: false
items:
type: object
properties:
key:
type: string
description: The key of the header
value:
type: string
description: The value of the header
annotations:
title: Get Kubernetes Resource (Stripped)
readOnlyHint: true
destructiveHint: false
idempotentHint: true
openWorldHint: false

View file

@ -10,6 +10,8 @@ Mapping (VK_ Prefix):
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR)
TV-App: VK_TV_WATCHED_THRESHOLD, VK_TV_HLS_SEGMENT_SEC, VK_TV_HLS_TIMEOUT_MIN,
VK_TV_HLS_MAX_SESSIONS, VK_TV_PAUSE_BATCH
"""
import os
import logging
@ -38,6 +40,11 @@ _ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
"VK_TARGET_CONTAINER": (("files", "target_container"), str),
"VK_LOG_LEVEL": (("logging", "level"), str),
"VK_TV_WATCHED_THRESHOLD": (("tv", "watched_threshold_pct"), int),
"VK_TV_HLS_SEGMENT_SEC": (("tv", "hls_segment_duration"), int),
"VK_TV_HLS_TIMEOUT_MIN": (("tv", "hls_session_timeout_min"), int),
"VK_TV_HLS_MAX_SESSIONS": (("tv", "hls_max_sessions"), int),
"VK_TV_PAUSE_BATCH": (("tv", "pause_batch_on_stream"), bool),
}
# Rueckwaertskompatibilitaet
@ -96,6 +103,14 @@ _DEFAULT_SETTINGS: dict = {
"tvdb_language": "deu",
"tvdb_pin": "",
},
"tv": {
"watched_threshold_pct": 90,
"hls_segment_duration": 4,
"hls_init_duration": 1,
"hls_session_timeout_min": 5,
"hls_max_sessions": 5,
"pause_batch_on_stream": True,
},
"cleanup": {
"enabled": False,
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
@ -208,8 +223,8 @@ class Config:
for env_key, ((section, key), val_type) in _ENV_MAP.items():
raw = os.environ.get(env_key)
if raw is None:
continue
if raw is None or raw == "":
continue # Leere ENV-Variablen ueberschreiben YAML nicht
# Typ-Konvertierung
try:
@ -328,6 +343,10 @@ class Config:
def cleanup_config(self) -> dict:
return self.settings.get("cleanup", {})
@property
def tv_config(self) -> dict:
return self.settings.get("tv", {})
@property
def server_config(self) -> dict:
return self.settings.get("server", {})

View file

@ -141,6 +141,61 @@ def setup_api_routes(app: web.Application, config: Config,
logging.info(f"Preset '{preset_name}' aktualisiert")
return web.json_response({"message": f"Preset '{preset_name}' gespeichert"})
async def post_preset(request: web.Request) -> web.Response:
"""POST /api/presets - Neues Preset erstellen"""
try:
data = await request.json()
except Exception:
return web.json_response(
{"error": "Ungueltiges JSON"}, status=400
)
key = data.get("key", "").strip()
if not key:
return web.json_response(
{"error": "Preset-Key fehlt"}, status=400
)
if key in config.presets:
return web.json_response(
{"error": f"Preset '{key}' existiert bereits"}, status=409
)
import re
if not re.match(r'^[a-z][a-z0-9_]*$', key):
return web.json_response(
{"error": "Key: nur Kleinbuchstaben, Zahlen, Unterstriche"},
status=400
)
preset = data.get("preset", {
"name": key, "video_codec": "libx264", "container": "mp4",
"quality_param": "crf", "quality_value": 23, "gop_size": 240,
"video_filter": "", "hw_init": False, "extra_params": {}
})
config.presets[key] = preset
config.save_presets()
logging.info(f"Neues Preset '{key}' erstellt")
return web.json_response({"message": f"Preset '{key}' erstellt"})
async def delete_preset(request: web.Request) -> web.Response:
"""DELETE /api/presets/{preset_name} - Preset loeschen"""
preset_name = request.match_info["preset_name"]
if preset_name == config.default_preset_name:
return web.json_response(
{"error": "Standard-Preset kann nicht geloescht werden"},
status=400
)
if preset_name not in config.presets:
return web.json_response(
{"error": f"Preset '{preset_name}' nicht gefunden"},
status=404
)
del config.presets[preset_name]
config.save_presets()
logging.info(f"Preset '{preset_name}' geloescht")
return web.json_response(
{"message": f"Preset '{preset_name}' geloescht"}
)
# --- Statistics ---
async def get_statistics(request: web.Request) -> web.Response:
@ -384,7 +439,85 @@ def setup_api_routes(app: web.Application, config: Config,
ws_log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(ws_log_handler)
# --- Server-Log lesen ---
async def get_log(request: web.Request) -> web.Response:
"""
GET /api/log?lines=100&level=INFO
Gibt die letzten N Zeilen des Server-Logs zurueck.
"""
lines = int(request.query.get("lines", 100))
level_filter = request.query.get("level", "").upper()
lines = min(lines, 5000) # Max 5000 Zeilen
log_dir = Path(__file__).parent.parent.parent / "logs"
log_file = log_dir / "server.log"
# Fallback: Aus dem logging-Handler lesen
log_entries = []
if log_file.exists():
try:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
all_lines = f.readlines()
# Letzte N Zeilen
recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
for line in recent:
line = line.rstrip()
if level_filter and level_filter not in line:
continue
log_entries.append(line)
except Exception as e:
return web.json_response(
{"error": f"Log lesen fehlgeschlagen: {e}"}, status=500
)
else:
# Kein Log-File: aus dem MemoryHandler lesen (falls vorhanden)
for handler in logging.getLogger().handlers:
if isinstance(handler, _MemoryLogHandler):
entries = handler.get_entries(lines)
for entry in entries:
if level_filter and level_filter not in entry:
continue
log_entries.append(entry)
break
if not log_entries:
log_entries.append("Keine Log-Datei gefunden unter: " + str(log_file))
return web.json_response({
"lines": log_entries,
"count": len(log_entries),
"source": str(log_file) if log_file.exists() else "memory",
})
# In-Memory Log-Handler (fuer Zugriff ohne Datei)
class _MemoryLogHandler(logging.Handler):
"""Speichert die letzten N Log-Eintraege im Speicher"""
def __init__(self, max_entries: int = 2000):
super().__init__()
self._entries = []
self._max = max_entries
def emit(self, record):
msg = self.format(record)
self._entries.append(msg)
if len(self._entries) > self._max:
self._entries = self._entries[-self._max:]
def get_entries(self, n: int = 100) -> list[str]:
return self._entries[-n:]
# Memory-Handler installieren
_mem_handler = _MemoryLogHandler(2000)
_mem_handler.setLevel(logging.DEBUG)
_mem_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(levelname)s - %(message)s"
))
logging.getLogger().addHandler(_mem_handler)
# --- Routes registrieren ---
app.router.add_get("/api/log", get_log)
app.router.add_get("/api/browse", get_browse)
app.router.add_post("/api/upload", post_upload)
app.router.add_post("/api/convert", post_convert)
@ -398,7 +531,9 @@ def setup_api_routes(app: web.Application, config: Config,
app.router.add_get("/api/settings", get_settings)
app.router.add_put("/api/settings", put_settings)
app.router.add_get("/api/presets", get_presets)
app.router.add_post("/api/presets", post_preset)
app.router.add_put("/api/presets/{preset_name}", put_preset)
app.router.add_delete("/api/presets/{preset_name}", delete_preset)
app.router.add_get("/api/statistics", get_statistics)
app.router.add_get("/api/system", get_system_info)
app.router.add_get("/api/ws-config", get_ws_config)

View file

@ -1,6 +1,8 @@
"""REST API Endpoints fuer die Video-Bibliothek"""
import asyncio
import json
import logging
import aiomysql
from aiohttp import web
from app.config import Config
from app.services.library import LibraryService
@ -149,12 +151,53 @@ def setup_library_routes(app: web.Application, config: Config,
# === Serien ===
# Mapping: Preset video_codec -> ffprobe-Codec-Name in der DB
_PRESET_TO_DB_CODEC = {
"av1_vaapi": "av1", "libsvtav1": "av1",
"hevc_vaapi": "hevc", "libx265": "hevc",
"h264_vaapi": "h264", "libx264": "h264",
}
async def get_series(request: web.Request) -> web.Response:
"""GET /api/library/series"""
"""GET /api/library/series - mit optionalem Codec-Badge"""
path_id = request.query.get("path_id")
if path_id:
path_id = int(path_id)
series = await library_service.get_series_list(path_id)
# Codec-Badge: Pruefen ob alle Videos einer Serie den Ziel-Codec haben
preset = config.default_preset
preset_codec = preset.get("video_codec", "")
target_codec = _PRESET_TO_DB_CODEC.get(preset_codec, "")
if target_codec and series:
pool = library_service._db_pool
if pool:
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT series_id,
COUNT(*) AS total,
SUM(CASE WHEN LOWER(video_codec) = %s
THEN 1 ELSE 0 END) AS matching
FROM library_videos
WHERE series_id IS NOT NULL
GROUP BY series_id
""", (target_codec,))
codec_stats = {
r["series_id"]: r
for r in await cur.fetchall()
}
# Badge zuweisen wenn alle Videos passen
for s in series:
stats = codec_stats.get(s.get("id"))
if (stats and stats["total"] > 0
and stats["total"] == stats["matching"]):
s["codec_badge"] = target_codec.upper()
except Exception as e:
logging.warning(f"Codec-Badge Query fehlgeschlagen: {e}")
return web.json_response({"series": series})
async def get_series_detail(request: web.Request) -> web.Response:
@ -322,22 +365,93 @@ def setup_library_routes(app: web.Application, config: Config,
return web.json_response(results)
async def get_metadata_image(request: web.Request) -> web.Response:
"""GET /api/library/metadata/{series_id}/{filename}"""
"""GET /api/library/metadata/{series_id}/{filename}?w=300
Laedt Bilder lokal aus .metadata/ oder downloaded on-demand von TVDB.
Optionaler Parameter w= verkleinert auf angegebene Breite (gecacht)."""
import os
import aiohttp as aiohttp_client
series_id = int(request.match_info["series_id"])
filename = request.match_info["filename"]
detail = await library_service.get_series_detail(series_id)
if not detail or not detail.get("folder_path"):
if not detail:
return web.json_response(
{"error": "Nicht gefunden"}, status=404
)
import os
file_path = os.path.join(
detail["folder_path"], ".metadata", filename
)
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
folder_path = detail.get("folder_path", "")
meta_dir = os.path.join(folder_path, ".metadata") if folder_path else ""
file_path = os.path.join(meta_dir, filename) if meta_dir else ""
# Lokale Datei nicht vorhanden? On-demand von TVDB downloaden
if not file_path or not os.path.isfile(file_path):
poster_url = detail.get("poster_url", "")
if filename.startswith("poster") and poster_url and folder_path:
os.makedirs(meta_dir, exist_ok=True)
try:
async with aiohttp_client.ClientSession() as session:
async with session.get(
poster_url,
timeout=aiohttp_client.ClientTimeout(total=15)
) as resp:
if resp.status == 200:
data = await resp.read()
with open(file_path, "wb") as f:
f.write(data)
logging.info(
f"Poster heruntergeladen: Serie {series_id}"
f" ({len(data)} Bytes)")
else:
# Download fehlgeschlagen -> Redirect
raise web.HTTPFound(poster_url)
except web.HTTPFound:
raise
except Exception as e:
logging.warning(
f"Poster-Download fehlgeschlagen fuer Serie "
f"{series_id}: {e}")
if poster_url:
raise web.HTTPFound(poster_url)
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
elif filename.startswith("poster") and poster_url:
# Kein Ordner -> Redirect zur externen URL
raise web.HTTPFound(poster_url)
else:
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
# Resize-Parameter: ?w=300 verkleinert auf max. 300px Breite
width_param = request.query.get("w")
if width_param:
try:
target_w = int(width_param)
if 50 <= target_w <= 1000:
base, _ = os.path.splitext(filename)
cache_name = f"{base}_w{target_w}.jpg"
cache_path = os.path.join(meta_dir, cache_name)
if not os.path.isfile(cache_path):
try:
from PIL import Image
with Image.open(file_path) as img:
if img.width > target_w:
ratio = target_w / img.width
new_h = int(img.height * ratio)
img = img.resize(
(target_w, new_h), Image.LANCZOS
)
img = img.convert("RGB")
img.save(cache_path, "JPEG", quality=80)
except Exception as e:
logging.warning(
f"Poster-Resize fehlgeschlagen: {e}")
return web.FileResponse(file_path)
return web.FileResponse(cache_path)
except ValueError:
pass
return web.FileResponse(file_path)
# === Filme ===
@ -1317,14 +1431,21 @@ def setup_library_routes(app: web.Application, config: Config,
# === Video-Streaming ===
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
_BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
async def get_stream_video(request: web.Request) -> web.StreamResponse:
"""GET /api/library/videos/{video_id}/stream?t=0
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC).
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
Optional: ?t=120 fuer Seeking auf Sekunde 120."""
"""GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0
Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur.
Parameter:
quality: uhd|hd|sd|low (Default: hd)
audio: Audio-Track-Index (Default: 0)
t: Seek-Position in Sekunden (Default: 0)
sound: stereo|surround|original (Default: stereo)
"""
import os
import asyncio as _asyncio
import shlex
video_id = int(request.match_info["video_id"])
@ -1336,39 +1457,98 @@ def setup_library_routes(app: web.Application, config: Config,
try:
async with pool.acquire() as conn:
async with conn.cursor() as cur:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT file_path FROM library_videos WHERE id = %s",
"SELECT file_path, width, height, video_codec, "
"audio_tracks, container, file_size "
"FROM library_videos WHERE id = %s",
(video_id,)
)
row = await cur.fetchone()
if not row:
video = await cur.fetchone()
if not video:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404
)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
file_path = row[0]
file_path = video["file_path"]
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404
)
# Seek-Position (Sekunden) aus Query-Parameter
seek_sec = float(request.query.get("t", "0"))
# Audio-Tracks parsen
audio_tracks = video.get("audio_tracks") or "[]"
if isinstance(audio_tracks, str):
audio_tracks = json.loads(audio_tracks)
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
]
# Parameter aus Query
quality = request.query.get("quality", "hd")
audio_idx = int(request.query.get("audio", "0"))
seek_sec = float(request.query.get("t", "0"))
sound_mode = request.query.get("sound", "stereo")
# Audio-Track bestimmen
if audio_idx >= len(audio_tracks):
audio_idx = 0
audio_info = audio_tracks[audio_idx] if audio_tracks else {}
audio_codec = audio_info.get("codec", "unknown")
audio_channels = audio_info.get("channels", 2)
# Ziel-Aufloesung bestimmen
orig_h = video.get("height") or 1080
quality_heights = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
target_h = quality_heights.get(quality, 1080)
needs_video_scale = orig_h > target_h and quality != "uhd"
# Audio-Transcoding: noetig wenn Codec nicht browser-kompatibel
needs_audio_transcode = audio_codec not in _BROWSER_AUDIO_CODECS
# Sound-Modus: Kanalanzahl bestimmen
if sound_mode == "stereo":
out_channels = 2
elif sound_mode == "surround":
out_channels = min(audio_channels, 8)
else: # original
out_channels = audio_channels
# Wenn Kanalanzahl sich aendert -> Transcoding noetig
if out_channels != audio_channels:
needs_audio_transcode = True
# ffmpeg-Kommando zusammenbauen
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
if seek_sec > 0:
cmd += ["-ss", str(seek_sec)]
cmd += ["-i", file_path]
# Video-Mapping und Codec
cmd += ["-map", "0:v:0"]
if needs_video_scale:
crf = {"sd": "23", "low": "28"}.get(quality, "20")
cmd += [
"-c:v", "libx264", "-preset", "fast",
"-crf", crf,
"-vf", f"scale=-2:{target_h}",
]
else:
cmd += ["-c:v", "copy"]
# Audio-Mapping und Codec
cmd += ["-map", f"0:a:{audio_idx}"]
if needs_audio_transcode:
bitrate = {1: "96k", 2: "192k"}.get(
out_channels, f"{out_channels * 64}k")
cmd += ["-c:a", "aac", "-ac", str(out_channels),
"-b:a", bitrate]
else:
cmd += ["-c:a", "copy"]
# Container: Fragmentiertes MP4 fuer Streaming
cmd += [
"-i", file_path,
"-c:v", "copy",
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
"-movflags", "frag_keyframe+empty_moov+faststart",
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
"-frag_duration", "1000000",
"-f", "mp4",
"pipe:1",
]
@ -1399,7 +1579,6 @@ def setup_library_routes(app: web.Application, config: Config,
try:
await resp.write(chunk)
except (ConnectionResetError, ConnectionAbortedError):
# Client hat Verbindung geschlossen
break
except Exception as e:
@ -1412,6 +1591,455 @@ def setup_library_routes(app: web.Application, config: Config,
await resp.write_eof()
return resp
# === Untertitel-Extraktion ===
async def get_subtitle_track(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/subtitles/{track_index}
Extrahiert Untertitel als WebVTT per ffmpeg."""
import os
import asyncio as _asyncio
video_id = int(request.match_info["video_id"])
track_idx = int(request.match_info["track_index"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT file_path, subtitle_tracks "
"FROM library_videos WHERE id = %s", (video_id,))
video = await cur.fetchone()
if not video:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
file_path = video["file_path"]
if not os.path.isfile(file_path):
return web.json_response(
{"error": "Datei nicht gefunden"}, status=404)
sub_tracks = video.get("subtitle_tracks") or "[]"
if isinstance(sub_tracks, str):
sub_tracks = json.loads(sub_tracks)
if track_idx >= len(sub_tracks):
return web.json_response(
{"error": "Untertitel-Track nicht gefunden"}, status=404)
sub = sub_tracks[track_idx]
if sub.get("codec") in ("hdmv_pgs_subtitle", "dvd_subtitle",
"pgs", "vobsub"):
return web.json_response(
{"error": "Bild-basierte Untertitel nicht unterstuetzt"},
status=400)
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-i", file_path,
"-map", f"0:s:{track_idx}",
"-f", "webvtt", "pipe:1",
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
logging.error(
f"Untertitel-Extraktion fehlgeschlagen: "
f"{stderr.decode('utf-8', errors='replace')}")
return web.json_response(
{"error": "Extraktion fehlgeschlagen"}, status=500)
return web.Response(
body=stdout,
content_type="text/vtt",
charset="utf-8",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception as e:
logging.error(f"Untertitel-Fehler: {e}")
return web.json_response({"error": str(e)}, status=500)
# === Video-Info API (fuer Player-UI) ===
async def get_video_info(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/info
Audio-/Untertitel-Tracks und Video-Infos fuer Player-Overlay."""
video_id = int(request.match_info["video_id"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute("""
SELECT id, file_name, width, height, video_codec,
audio_tracks, subtitle_tracks, container,
duration_sec, video_bitrate, is_10bit, hdr,
series_id, season_number, episode_number
FROM library_videos WHERE id = %s
""", (video_id,))
video = await cur.fetchone()
if not video:
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# JSON-Felder parsen
for field in ("audio_tracks", "subtitle_tracks"):
val = video.get(field)
if isinstance(val, str):
video[field] = json.loads(val)
elif val is None:
video[field] = []
# Bild-basierte Untertitel rausfiltern
video["subtitle_tracks"] = [
s for s in video["subtitle_tracks"]
if s.get("codec") not in (
"hdmv_pgs_subtitle", "dvd_subtitle", "pgs", "vobsub"
)
]
# Direct-Play URL fuer Browser/PWA (MP4 mit Range-Requests)
video["direct_play_url"] = f"/tv/api/direct-stream/{video_id}"
return web.json_response(video)
# === Episoden-Thumbnails ===
async def _save_thumbnail_to_db(pool, video_id, thumb_path, source):
"""Speichert Thumbnail-Pfad in der DB."""
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
INSERT INTO tv_episode_thumbnails
(video_id, thumbnail_path, source)
VALUES (%s, %s, %s)
ON DUPLICATE KEY UPDATE
thumbnail_path = VALUES(thumbnail_path),
source = VALUES(source)
""", (video_id, thumb_path, source))
async def _download_tvdb_image(url, thumb_path):
"""Laedt TVDB-Bild herunter und speichert es lokal."""
import aiohttp as _aiohttp
try:
async with _aiohttp.ClientSession() as session:
async with session.get(url, timeout=_aiohttp.ClientTimeout(total=15)) as resp:
if resp.status == 200:
data = await resp.read()
if len(data) > 100: # Kein leeres/fehlerhaftes Bild
with open(thumb_path, "wb") as f:
f.write(data)
return True
except Exception as e:
logging.debug(f"TVDB-Bild Download fehlgeschlagen: {e}")
return False
async def get_video_thumbnail(request: web.Request) -> web.Response:
"""GET /api/library/videos/{video_id}/thumbnail
Gibt Thumbnail zurueck. Prioritaet: Lokal > TVDB-Download > ffmpeg."""
import os
import asyncio as _asyncio
video_id = int(request.match_info["video_id"])
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
# Pruefen ob bereits lokal vorhanden
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT thumbnail_path FROM tv_episode_thumbnails "
"WHERE video_id = %s", (video_id,))
cached = await cur.fetchone()
if cached and os.path.isfile(cached["thumbnail_path"]):
return web.FileResponse(
cached["thumbnail_path"],
headers={"Cache-Control": "public, max-age=604800"})
# Video-Info + TVDB-Bild-URL laden
await cur.execute("""
SELECT v.file_path, v.duration_sec,
v.series_id, v.season_number, v.episode_number
FROM library_videos v
WHERE v.id = %s
""", (video_id,))
video = await cur.fetchone()
if not video or not os.path.isfile(video["file_path"]):
return web.json_response(
{"error": "Video nicht gefunden"}, status=404)
# TVDB-Bild-URL pruefen
tvdb_image_url = None
if video.get("series_id"):
await cur.execute(
"SELECT tvdb_id FROM library_series "
"WHERE id = %s", (video["series_id"],))
s = await cur.fetchone()
if s and s.get("tvdb_id"):
await cur.execute("""
SELECT image_url FROM tvdb_episode_cache
WHERE series_tvdb_id = %s
AND season_number = %s
AND episode_number = %s
""", (s["tvdb_id"],
video.get("season_number") or 0,
video.get("episode_number") or 0))
tc = await cur.fetchone()
if tc and tc.get("image_url"):
tvdb_image_url = tc["image_url"]
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
# Zielverzeichnis: .metadata/thumbnails/ neben der Videodatei
file_path = video["file_path"]
video_dir = os.path.dirname(file_path)
thumb_dir = os.path.join(video_dir, ".metadata", "thumbnails")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, f"{video_id}.jpg")
# Versuch 1: TVDB-Bild herunterladen
if tvdb_image_url:
if await _download_tvdb_image(tvdb_image_url, thumb_path):
await _save_thumbnail_to_db(pool, video_id, thumb_path, "tvdb")
return web.FileResponse(
thumb_path,
headers={"Cache-Control": "public, max-age=604800"})
# Versuch 2: Per ffmpeg generieren (Frame bei 25%)
duration = video.get("duration_sec") or 0
seek_pos = duration * 0.25 if duration > 10 else 5
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek_pos)),
"-i", file_path,
"-vframes", "1",
"-q:v", "5",
"-vf", "scale=480:-1",
"-y", thumb_path,
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode == 0 and os.path.isfile(thumb_path):
await _save_thumbnail_to_db(
pool, video_id, thumb_path, "ffmpeg")
return web.FileResponse(
thumb_path,
headers={"Cache-Control": "public, max-age=604800"})
else:
return web.json_response(
{"error": "Thumbnail-Generierung fehlgeschlagen"},
status=500)
except Exception as e:
logging.error(f"Thumbnail-Fehler: {e}")
return web.json_response({"error": str(e)}, status=500)
# === Batch-Thumbnail-Generierung ===
_thumbnail_task = None # Hintergrund-Task fuer Batch-Generierung
async def post_generate_thumbnails(request: web.Request) -> web.Response:
"""POST /api/library/generate-thumbnails
Generiert fehlende Thumbnails fuer alle Videos im Hintergrund.
Optional: ?series_id=123 fuer nur eine Serie."""
import os
import asyncio as _asyncio
nonlocal _thumbnail_task
# Laeuft bereits?
if _thumbnail_task and not _thumbnail_task.done():
return web.json_response({
"status": "running",
"message": "Thumbnail-Generierung laeuft bereits"
})
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
series_id = request.query.get("series_id")
async def _generate_batch():
"""Hintergrund-Task: Fehlende Thumbnails erzeugen."""
generated = 0
errors = 0
try:
# Verwaiste Thumbnail-Eintraege bereinigen
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("""
DELETE FROM tv_episode_thumbnails
WHERE video_id NOT IN (
SELECT id FROM library_videos
)
""")
orphaned = cur.rowcount
if orphaned:
logging.info(
f"Thumbnail-Batch: {orphaned} verwaiste "
f"Eintraege bereinigt"
)
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
# Videos ohne Thumbnail finden (mit TVDB-Bild-URL)
sql = """
SELECT v.id, v.file_path, v.duration_sec,
tc.image_url AS tvdb_image_url
FROM library_videos v
LEFT JOIN tv_episode_thumbnails t
ON v.id = t.video_id
LEFT JOIN library_series s
ON v.series_id = s.id
LEFT JOIN tvdb_episode_cache tc
ON tc.series_tvdb_id = s.tvdb_id
AND tc.season_number = v.season_number
AND tc.episode_number = v.episode_number
WHERE t.video_id IS NULL
"""
params = []
if series_id:
sql += " AND v.series_id = %s"
params.append(int(series_id))
sql += " ORDER BY v.id"
await cur.execute(sql, params)
videos = await cur.fetchall()
logging.info(
f"Thumbnail-Batch: {len(videos)} Videos ohne Thumbnail"
)
downloaded = 0
for video in videos:
vid = video["id"]
fp = video["file_path"]
dur = video.get("duration_sec") or 0
if not os.path.isfile(fp):
continue
vdir = os.path.dirname(fp)
tdir = os.path.join(vdir, ".metadata", "thumbnails")
os.makedirs(tdir, exist_ok=True)
tpath = os.path.join(tdir, f"{vid}.jpg")
# Prioritaet 1: TVDB-Bild herunterladen
tvdb_url = video.get("tvdb_image_url")
if tvdb_url:
if await _download_tvdb_image(tvdb_url, tpath):
await _save_thumbnail_to_db(
pool, vid, tpath, "tvdb")
generated += 1
downloaded += 1
continue
# Prioritaet 2: Per ffmpeg generieren
seek = dur * 0.25 if dur > 10 else 5
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-ss", str(int(seek)),
"-i", fp,
"-vframes", "1", "-q:v", "5",
"-vf", "scale=480:-1",
"-y", tpath,
]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
await proc.communicate()
if proc.returncode == 0 and os.path.isfile(tpath):
await _save_thumbnail_to_db(
pool, vid, tpath, "ffmpeg")
generated += 1
else:
errors += 1
except Exception as e:
logging.warning(f"Thumbnail-Fehler Video {vid}: {e}")
errors += 1
logging.info(
f"Thumbnail-Batch fertig: {generated} erzeugt "
f"({downloaded} TVDB, {generated - downloaded} ffmpeg), "
f"{errors} Fehler"
)
except Exception as e:
logging.error(f"Thumbnail-Batch Fehler: {e}")
import asyncio
_thumbnail_task = asyncio.ensure_future(_generate_batch())
return web.json_response({
"status": "started",
"message": "Thumbnail-Generierung gestartet"
})
async def get_thumbnail_status(request: web.Request) -> web.Response:
"""GET /api/library/thumbnail-status
Zeigt Fortschritt der Thumbnail-Generierung."""
pool = await library_service._get_pool()
if not pool:
return web.json_response(
{"error": "Keine DB-Verbindung"}, status=500)
running = bool(_thumbnail_task and not _thumbnail_task.done())
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT COUNT(*) AS cnt FROM library_videos")
total = (await cur.fetchone())["cnt"]
# Nur Thumbnails zaehlen die auch ein existierendes Video haben
await cur.execute("""
SELECT COUNT(*) AS cnt
FROM tv_episode_thumbnails t
INNER JOIN library_videos v ON t.video_id = v.id
""")
done = (await cur.fetchone())["cnt"]
missing = max(0, total - done)
return web.json_response({
"running": running,
"generated": done,
"total": total,
"missing": missing,
})
# === Import: Item zuordnen / ueberspringen ===
async def post_reassign_import_item(
@ -1904,10 +2532,27 @@ def setup_library_routes(app: web.Application, config: Config,
"/api/library/import/{job_id}/overwrite-mode",
put_overwrite_mode,
)
# Video-Streaming
# Video-Streaming, Untertitel, Video-Info
app.router.add_get(
"/api/library/videos/{video_id}/stream", get_stream_video
)
app.router.add_get(
"/api/library/videos/{video_id}/subtitles/{track_index}",
get_subtitle_track
)
app.router.add_get(
"/api/library/videos/{video_id}/info", get_video_info
)
app.router.add_get(
"/api/library/videos/{video_id}/thumbnail", get_video_thumbnail
)
# Batch-Thumbnails
app.router.add_post(
"/api/library/generate-thumbnails", post_generate_thumbnails
)
app.router.add_get(
"/api/library/thumbnail-status", get_thumbnail_status
)
# TVDB Auto-Match (Review-Modus)
app.router.add_post(
"/api/library/tvdb-auto-match", post_tvdb_auto_match

View file

@ -45,6 +45,12 @@ def setup_page_routes(app: web.Application, config: Config,
"gpu_devices": gpu_devices,
}
@aiohttp_jinja2.template("tv_admin.html")
async def tv_admin(request: web.Request) -> dict:
"""GET /tv-admin - TV Admin-Center"""
tv = config.settings.get("tv", {})
return {"tv": tv}
@aiohttp_jinja2.template("library.html")
async def library(request: web.Request) -> dict:
"""GET /library - Bibliothek"""
@ -132,6 +138,84 @@ def setup_page_routes(app: web.Application, config: Config,
content_type="text/html",
)
async def htmx_save_tv_settings(request: web.Request) -> web.Response:
"""POST /htmx/tv-settings - TV-Settings via Formular speichern"""
data = await request.post()
settings = config.settings
settings.setdefault("tv", {})
settings["tv"]["hls_segment_duration"] = int(
data.get("hls_segment_duration", 4))
settings["tv"]["hls_init_duration"] = int(
data.get("hls_init_duration", 1))
settings["tv"]["hls_session_timeout_min"] = int(
data.get("hls_session_timeout_min", 5))
settings["tv"]["hls_max_sessions"] = int(
data.get("hls_max_sessions", 5))
settings["tv"]["pause_batch_on_stream"] = (
data.get("pause_batch_on_stream") == "on")
settings["tv"]["force_transcode"] = (
data.get("force_transcode") == "on")
settings["tv"]["audio_loudnorm"] = (
data.get("audio_loudnorm") == "on")
settings["tv"]["audio_dynaudnorm"] = (
data.get("audio_dynaudnorm") == "on")
settings["tv"]["watched_threshold_pct"] = int(
data.get("watched_threshold_pct", 90))
config.save_settings()
logging.info("TV-Settings via Admin-UI gespeichert")
return web.Response(
text='<div class="toast success">TV-Settings gespeichert!</div>',
content_type="text/html",
)
async def htmx_save_preset(request: web.Request) -> web.Response:
"""POST /htmx/preset/{preset_name} - Preset via Formular speichern"""
preset_name = request.match_info["preset_name"]
data = await request.post()
# Extra-Params parsen (key=value pro Zeile)
extra_params = {}
raw_extra = data.get("extra_params", "").strip()
for line in raw_extra.splitlines():
line = line.strip()
if "=" in line:
k, v = line.split("=", 1)
extra_params[k.strip()] = v.strip()
# Speed-Preset: int oder string oder None
speed_raw = data.get("speed_preset", "").strip()
speed_preset = None
if speed_raw:
try:
speed_preset = int(speed_raw)
except ValueError:
speed_preset = speed_raw
preset = {
"name": data.get("name", preset_name),
"video_codec": data.get("video_codec", "libx264"),
"container": data.get("container", "mp4"),
"quality_param": data.get("quality_param", "crf"),
"quality_value": int(data.get("quality_value", 23)),
"gop_size": int(data.get("gop_size", 240)),
"speed_preset": speed_preset,
"video_filter": data.get("video_filter", ""),
"hw_init": data.get("hw_init") == "on",
"extra_params": extra_params,
}
config.presets[preset_name] = preset
config.save_presets()
logging.info(f"Preset '{preset_name}' via Admin-UI gespeichert")
return web.Response(
text='<div class="toast success">Preset gespeichert!</div>',
content_type="text/html",
)
@aiohttp_jinja2.template("partials/stats_table.html")
async def htmx_stats_table(request: web.Request) -> dict:
"""GET /htmx/stats?page=1 - Paginierte Statistik"""
@ -155,6 +239,9 @@ def setup_page_routes(app: web.Application, config: Config,
app.router.add_get("/dashboard", dashboard)
app.router.add_get("/library", library)
app.router.add_get("/admin", admin)
app.router.add_get("/tv-admin", tv_admin)
app.router.add_get("/statistics", statistics)
app.router.add_post("/htmx/settings", htmx_save_settings)
app.router.add_post("/htmx/tv-settings", htmx_save_tv_settings)
app.router.add_post("/htmx/preset/{preset_name}", htmx_save_preset)
app.router.add_get("/htmx/stats", htmx_stats_table)

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ class WebSocketManager:
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
"""WebSocket-Endpoint Handler"""
ws = web.WebSocketResponse()
ws = web.WebSocketResponse(heartbeat=30.0)
await ws.prepare(request)
self.clients.add(ws)

View file

@ -1,6 +1,8 @@
"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App"""
import asyncio
import logging
import os
import time
from pathlib import Path
from aiohttp import web
import aiohttp_jinja2
@ -14,9 +16,13 @@ from app.services.library import LibraryService
from app.services.tvdb import TVDBService
from app.services.cleaner import CleanerService
from app.services.importer import ImporterService
from app.services.auth import AuthService
from app.services.hls import HLSSessionManager
from app.services.i18n import load_translations, setup_jinja2_i18n
from app.routes.api import setup_api_routes
from app.routes.library_api import setup_library_routes
from app.routes.pages import setup_page_routes
from app.routes.tv_api import setup_tv_routes
class VideoKonverterServer:
@ -50,12 +56,24 @@ class VideoKonverterServer:
@web.middleware
async def _no_cache_middleware(self, request: web.Request,
handler) -> web.Response:
"""Verhindert Browser-Caching fuer API-Responses"""
response = await handler(request)
"""Verhindert Browser-Caching fuer API-Responses + Error-Logging"""
try:
response = await handler(request)
except web.HTTPException as he:
if he.status >= 500:
logging.error(f"HTTP {he.status} bei {request.method} {request.path}: {he.reason}",
exc_info=True)
raise
except Exception as e:
logging.error(f"Unbehandelte Ausnahme bei {request.method} {request.path}: {e}",
exc_info=True)
raise
if request.path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
# Thumbnail-Bilder sollen gecacht werden (Cache-Control vom Handler)
if "/thumbnail" not in request.path:
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response
def _setup_app(self) -> None:
@ -66,8 +84,22 @@ class VideoKonverterServer:
self.app,
loader=jinja2.FileSystemLoader(str(template_dir)),
context_processors=[aiohttp_jinja2.request_processor],
bytecode_cache=jinja2.FileSystemBytecodeCache(
directory="/tmp/jinja2_cache",
pattern="__jinja2_%s.cache",
),
auto_reload=os.environ.get("VK_DEV", "").lower() == "true",
)
# i18n: Uebersetzungen laden und Jinja2-Filter registrieren
static_dir = Path(__file__).parent / "static"
load_translations(str(static_dir))
setup_jinja2_i18n(self.app)
# Cache-Busting: Versionsstempel fuer statische Dateien
env = self.app[aiohttp_jinja2.APP_KEY]
env.globals["v"] = str(int(time.time()))
# WebSocket Route
ws_path = self.config.server_config.get("websocket_path", "/ws")
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
@ -88,6 +120,19 @@ class VideoKonverterServer:
# Seiten Routes
setup_page_routes(self.app, self.config, self.queue_service)
# TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt)
async def _lazy_pool():
return self.library_service._db_pool
self.auth_service = AuthService(_lazy_pool)
self.hls_manager = HLSSessionManager(
self.library_service, self.config.gpu_device,
queue_service=self.queue_service, config=self.config)
setup_tv_routes(
self.app, self.config,
self.auth_service, self.library_service,
self.hls_manager,
)
# Statische Dateien
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
@ -140,12 +185,20 @@ class VideoKonverterServer:
await self.tvdb_service.init_db()
await self.importer_service.init_db()
# TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter)
if self.library_service._db_pool:
await self.auth_service.init_db()
# HLS Session Manager starten
await self.hls_manager.start()
host = self.config.server_config.get("host", "0.0.0.0")
port = self.config.server_config.get("port", 8080)
logging.info(f"Server bereit auf http://{host}:{port}")
async def _on_shutdown(self, app: web.Application) -> None:
"""Server-Stop: Queue und Library stoppen"""
await self.hls_manager.stop()
await self.queue_service.stop()
await self.library_service.stop()
logging.info("Server heruntergefahren")
@ -166,6 +219,7 @@ class VideoKonverterServer:
f" Bibliothek: http://{host}:{port}/library\n"
f" Admin: http://{host}:{port}/admin\n"
f" Statistik: http://{host}:{port}/statistics\n"
f" TV-App: http://{host}:{port}/tv/\n"
f" WebSocket: ws://{host}:{port}/ws\n"
f" API: http://{host}:{port}/api/convert (POST)"
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,509 @@
"""HLS Session Manager - HTTP Live Streaming per ffmpeg
Verwaltet HLS-Sessions: ffmpeg erzeugt m3u8 + fMP4-Segmente (.m4s),
die dann per HTTP ausgeliefert werden. Vorteile gegenueber Pipe-Streaming:
- Sofortiger Playback-Start (erstes Segment in ~1s verfuegbar)
- Natives Seeking ueber Segmente
- fMP4 statt mpegts (bessere Codec-Kompatibilitaet)
- Automatische Codec-Erkennung: Client meldet unterstuetzte Codecs,
Server entscheidet copy vs. H.264-Transcoding (CPU oder GPU/VAAPI)
- hls.js Polyfill fuer Browser ohne native HLS-Unterstuetzung
- Samsung Tizen hat native HLS-Unterstuetzung
"""
import asyncio
import json
import logging
import os
import shutil
import time
import uuid
from pathlib import Path
from typing import Optional
import aiomysql
from app.services.library import LibraryService
# Browser-kompatible Audio-Codecs (kein Transcoding noetig)
BROWSER_AUDIO_CODECS = {"aac", "mp3", "opus", "vorbis", "flac"}
# Video-Codec-Normalisierung (DB-Werte -> einfache Namen fuer Client-Abgleich)
CODEC_NORMALIZE = {
"av1": "av1", "libaom-av1": "av1", "libsvtav1": "av1", "av1_vaapi": "av1",
"hevc": "hevc", "h265": "hevc", "libx265": "hevc", "hevc_vaapi": "hevc",
"h264": "h264", "avc": "h264", "libx264": "h264", "h264_vaapi": "h264",
"vp9": "vp9", "libvpx-vp9": "vp9", "vp8": "vp8",
"mpeg4": "mpeg4", "mpeg2video": "mpeg2",
}
# HLS Konfiguration (Defaults, werden von Config ueberschrieben)
HLS_BASE_DIR = Path("/tmp/hls")
# Qualitaets-Stufen (Ziel-Hoehe)
QUALITY_HEIGHTS = {"uhd": 2160, "hd": 1080, "sd": 720, "low": 480}
class HLSSession:
"""Einzelne HLS-Streaming-Session"""
def __init__(self, session_id: str, video_id: int, quality: str,
audio_idx: int, sound_mode: str, seek_sec: float):
self.session_id = session_id
self.video_id = video_id
self.quality = quality
self.audio_idx = audio_idx
self.sound_mode = sound_mode
self.seek_sec = seek_sec
self.process: Optional[asyncio.subprocess.Process] = None
self.created_at = time.time()
self.last_access = time.time()
self.ready = False
self.error: Optional[str] = None
@property
def dir(self) -> Path:
return HLS_BASE_DIR / self.session_id
@property
def playlist_path(self) -> Path:
return self.dir / "stream.m3u8"
def touch(self):
"""Letzten Zugriff aktualisieren"""
self.last_access = time.time()
def is_expired(self, timeout_sec: int = 300) -> bool:
return (time.time() - self.last_access) > timeout_sec
class HLSSessionManager:
"""Verwaltet alle HLS-Sessions mit Auto-Cleanup"""
def __init__(self, library_service: LibraryService,
gpu_device: str = "/dev/dri/renderD128",
queue_service=None, config=None):
self._library = library_service
self._sessions: dict[str, HLSSession] = {}
self._cleanup_task: Optional[asyncio.Task] = None
self._gpu_device = gpu_device
self._gpu_available = os.path.exists(gpu_device)
self._queue_service = queue_service
self._config = config
def _tv_setting(self, key: str, default):
"""TV-Einstellung aus Config lesen (mit Fallback)"""
if self._config:
return self._config.tv_config.get(key, default)
return default
async def start(self):
"""Cleanup-Task starten und Verzeichnis vorbereiten"""
HLS_BASE_DIR.mkdir(parents=True, exist_ok=True)
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
gpu_status = (f"GPU verfuegbar ({self._gpu_device})"
if self._gpu_available else "Nur CPU-Transcoding")
logging.info(f"HLS Session Manager gestartet - {gpu_status}")
async def stop(self):
"""Alle Sessions beenden und aufraumen"""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
for sid in list(self._sessions):
await self.destroy_session(sid)
logging.info("HLS Session Manager gestoppt")
async def create_session(self, video_id: int, quality: str = "hd",
audio_idx: int = 0, sound_mode: str = "stereo",
seek_sec: float = 0,
client_codecs: list[str] = None,
) -> Optional[HLSSession]:
"""Neue HLS-Session erstellen und ffmpeg starten
client_codecs: Liste der vom Client unterstuetzten Video-Codecs
(z.B. ["h264", "hevc", "av1"]). Wenn der Quell-Codec nicht drin ist,
wird automatisch zu H.264 transkodiert (GPU wenn verfuegbar).
"""
# Max. gleichzeitige Sessions pruefen
max_sessions = self._tv_setting("hls_max_sessions", 5)
if len(self._sessions) >= max_sessions:
logging.warning(f"HLS: Max. Sessions ({max_sessions}) erreicht - "
f"neue Session abgelehnt")
return None
pool = await self._library._get_pool()
if not pool:
return None
# Video-Info aus DB laden
try:
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cur:
await cur.execute(
"SELECT file_path, width, height, video_codec, "
"audio_tracks, container, duration_sec "
"FROM library_videos WHERE id = %s",
(video_id,))
video = await cur.fetchone()
if not video:
return None
except Exception as e:
logging.error(f"HLS Session DB-Fehler: {e}")
return None
file_path = video["file_path"]
if not os.path.isfile(file_path):
logging.error(f"HLS: Datei nicht gefunden: {file_path}")
return None
# Session erstellen
session_id = uuid.uuid4().hex[:16]
session = HLSSession(session_id, video_id, quality,
audio_idx, sound_mode, seek_sec)
session.dir.mkdir(parents=True, exist_ok=True)
# Audio-Tracks parsen
audio_tracks = video.get("audio_tracks") or "[]"
if isinstance(audio_tracks, str):
audio_tracks = json.loads(audio_tracks)
if audio_idx >= len(audio_tracks):
audio_idx = 0
audio_info = audio_tracks[audio_idx] if audio_tracks else {}
audio_codec = audio_info.get("codec", "unknown")
audio_channels = audio_info.get("channels", 2)
# Video-Codec: Kann der Client das direkt abspielen?
video_codec = video.get("video_codec", "unknown")
codec_name = CODEC_NORMALIZE.get(video_codec, video_codec)
# Fallback: wenn keine Codecs gemeldet -> nur H.264 annehmen
supported = client_codecs or ["h264"]
client_can_play = codec_name in supported
# Ziel-Aufloesung
orig_h = video.get("height") or 1080
target_h = QUALITY_HEIGHTS.get(quality, 1080)
needs_video_scale = orig_h > target_h and quality != "uhd"
needs_video_transcode = not client_can_play
# Audio-Transcoding noetig?
needs_audio_transcode = audio_codec not in BROWSER_AUDIO_CODECS
# Audio-Normalisierung erzwingt Transcoding
audio_loudnorm = self._tv_setting("audio_loudnorm", False)
audio_dynaudnorm = self._tv_setting("audio_dynaudnorm", False)
if audio_loudnorm or audio_dynaudnorm:
needs_audio_transcode = True
# Force-Transcode: Immer transcodieren fuer maximale Kompatibilitaet
if self._tv_setting("force_transcode", False):
needs_video_transcode = True
if audio_codec not in BROWSER_AUDIO_CODECS:
needs_audio_transcode = True
# Sound-Modus
if sound_mode == "stereo":
out_channels = 2
elif sound_mode == "surround":
out_channels = min(audio_channels, 8)
else:
out_channels = audio_channels
if out_channels != audio_channels:
needs_audio_transcode = True
# ffmpeg starten (GPU-Versuch mit CPU-Fallback)
use_gpu = (self._gpu_available
and (needs_video_transcode or needs_video_scale))
cmd = self._build_ffmpeg_cmd(
file_path, session, seek_sec, audio_idx, quality,
needs_video_transcode, needs_video_scale, target_h,
needs_audio_transcode, out_channels, use_gpu)
vmode = "copy" if not (needs_video_transcode or needs_video_scale) else (
"h264_vaapi" if use_gpu else "libx264")
logging.info(f"HLS Session {session_id}: starte ffmpeg fuer "
f"Video {video_id} (q={quality}, v={vmode}, "
f"src={video_codec}, audio={audio_idx})")
logging.debug(f"HLS ffmpeg cmd: {' '.join(cmd)}")
# Batch-Konvertierung VOR ffmpeg-Start einfrieren (verhindert GPU-Kollision)
if self._queue_service and self._tv_setting("pause_batch_on_stream", True):
count = self._queue_service.suspend_encoding()
if count > 0:
logging.info(
f"Encoding pausiert: {count} ffmpeg-Prozess(e) "
f"per SIGSTOP eingefroren (HLS-Stream aktiv)")
try:
session.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logging.error(f"HLS ffmpeg Start fehlgeschlagen: {e}")
shutil.rmtree(session.dir, ignore_errors=True)
return None
# GPU-Fallback: Wenn ffmpeg sofort scheitert (z.B. h264_vaapi nicht
# unterstuetzt) -> automatisch mit CPU-Encoding neu starten
if use_gpu:
await asyncio.sleep(1.0)
if session.process.returncode is not None:
stderr = await session.process.stderr.read()
err_msg = stderr.decode("utf-8", errors="replace")[:300]
logging.warning(
f"HLS GPU-Encoding fehlgeschlagen (Code "
f"{session.process.returncode}): {err_msg}")
logging.info("HLS: Fallback auf CPU-Encoding (libx264)")
self._gpu_available = False # GPU fuer HLS deaktivieren
# Dateien aufraumen und neu starten
for f in session.dir.iterdir():
f.unlink(missing_ok=True)
cmd = self._build_ffmpeg_cmd(
file_path, session, seek_sec, audio_idx, quality,
needs_video_transcode, needs_video_scale, target_h,
needs_audio_transcode, out_channels, use_gpu=False)
logging.debug(f"HLS CPU-Fallback cmd: {' '.join(cmd)}")
try:
session.process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except Exception as e:
logging.error(f"HLS CPU-Fallback Start fehlgeschlagen: {e}")
shutil.rmtree(session.dir, ignore_errors=True)
return None
self._sessions[session_id] = session
# Kurz warten ob erstes Segment schnell kommt (Copy-Modus: <1s)
# Bei Transcoding nicht lange blockieren - hls.js/native HLS
# haben eigene Retry-Logik fuer noch nicht verfuegbare Segmente
timeout = 3.0 if (needs_video_transcode or needs_video_scale) else 2.0
await self._wait_for_ready(session, timeout=timeout)
return session
def _build_ffmpeg_cmd(self, file_path: str, session: HLSSession,
seek_sec: float, audio_idx: int, quality: str,
needs_video_transcode: bool,
needs_video_scale: bool, target_h: int,
needs_audio_transcode: bool,
out_channels: int, use_gpu: bool) -> list[str]:
"""Baut das ffmpeg-Kommando fuer HLS-Streaming.
GPU-Modus: Software-Decode -> NV12 -> hwupload -> h264_vaapi
(zuverlaessiger als Full-HW-Pipeline, funktioniert mit allen Quell-Codecs)"""
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error"]
# Schnellere Datei-Analyse
cmd += ["-analyzeduration", "3000000", # 3 Sekunden
"-probesize", "3000000"] # 3 MB
# VAAPI-Device (KEIN hwaccel - Software-Decode ist zuverlaessiger
# fuer beliebige Quell-Codecs wie AV1/VP9/HEVC)
if use_gpu:
cmd += ["-vaapi_device", self._gpu_device]
if seek_sec > 0:
cmd += ["-ss", str(seek_sec)]
cmd += ["-i", file_path]
# Video-Codec Entscheidung
cmd += ["-map", "0:v:0"]
if needs_video_scale or needs_video_transcode:
crf = {"sd": "23", "low": "28"}.get(quality, "20")
if use_gpu:
# VAAPI Hardware-Encoding (Intel A380):
# format=nv12 (CPU) -> hwupload (VAAPI) -> h264_vaapi
vf_parts = ["format=nv12"]
if needs_video_scale:
# CPU-seitig skalieren, dann hochladen
vf_parts.insert(0, f"scale=-2:{target_h}")
vf_parts.append("hwupload")
cmd += ["-vf", ",".join(vf_parts),
"-c:v", "h264_vaapi", "-qp", crf,
"-low_power", "1"]
else:
# CPU Software-Encoding
vf_parts = []
if needs_video_scale:
vf_parts.append(f"scale=-2:{target_h}")
cmd += ["-c:v", "libx264", "-preset", "veryfast",
"-crf", crf]
if vf_parts:
cmd += ["-vf", ",".join(vf_parts)]
else:
cmd += ["-c:v", "copy"]
# Audio
cmd += ["-map", f"0:a:{audio_idx}"]
if needs_audio_transcode:
bitrate = {1: "96k", 2: "192k"}.get(
out_channels, f"{out_channels * 64}k")
# Audio-Filter aufbauen (loudnorm, dynaudnorm)
af_parts = []
audio_loudnorm = self._tv_setting("audio_loudnorm", False)
audio_dynaudnorm = self._tv_setting("audio_dynaudnorm", False)
if audio_loudnorm:
af_parts.append("loudnorm=I=-14:LRA=7:TP=-1")
if audio_dynaudnorm:
af_parts.append("dynaudnorm=f=250:g=15:p=0.95")
cmd += ["-c:a", "aac", "-ac", str(out_channels),
"-b:a", bitrate]
if af_parts:
cmd += ["-af", ",".join(af_parts)]
else:
cmd += ["-c:a", "copy"]
# HLS Output - fMP4 Segmente
seg_dur = self._tv_setting("hls_segment_duration", 4)
init_dur = self._tv_setting("hls_init_duration", 1)
cmd += [
"-f", "hls",
"-hls_time", str(seg_dur),
"-hls_init_time", str(init_dur),
"-hls_list_size", "0",
"-hls_segment_type", "fmp4",
"-hls_fmp4_init_filename", "init.mp4",
"-hls_flags", "append_list+independent_segments",
"-hls_segment_filename", str(session.dir / "seg%05d.m4s"),
"-start_number", "0",
str(session.playlist_path),
]
return cmd
async def _wait_for_ready(self, session: HLSSession,
timeout: float = 15.0):
"""Warten bis Playlist und erstes Segment existieren"""
start = time.time()
while (time.time() - start) < timeout:
if session.playlist_path.exists():
# Pruefen ob Init-Segment + mindestens ein Media-Segment da ist
init_seg = session.dir / "init.mp4"
segments = list(session.dir.glob("seg*.m4s"))
if init_seg.exists() and segments:
session.ready = True
logging.info(
f"HLS Session {session.session_id}: bereit "
f"({len(segments)} Segmente, "
f"{time.time() - start:.1f}s)")
return
# ffmpeg beendet?
if session.process and session.process.returncode is not None:
stderr = await session.process.stderr.read()
session.error = stderr.decode("utf-8", errors="replace")
logging.error(
f"HLS ffmpeg beendet mit Code "
f"{session.process.returncode}: {session.error[:500]}")
return
await asyncio.sleep(0.3)
logging.warning(
f"HLS Session {session.session_id}: Timeout nach {timeout}s")
def get_session(self, session_id: str) -> Optional[HLSSession]:
"""Session anhand ID holen und Zugriff aktualisieren"""
session = self._sessions.get(session_id)
if session:
session.touch()
return session
async def destroy_session(self, session_id: str):
"""Session beenden: ffmpeg stoppen + Dateien loeschen"""
session = self._sessions.pop(session_id, None)
if not session:
return
# ffmpeg-Prozess beenden
if session.process and session.process.returncode is None:
session.process.terminate()
try:
await asyncio.wait_for(session.process.wait(), timeout=5)
except asyncio.TimeoutError:
session.process.kill()
await session.process.wait()
# Dateien aufraumen
if session.dir.exists():
shutil.rmtree(session.dir, ignore_errors=True)
logging.info(f"HLS Session {session_id} beendet")
# Verzoegertes Resume: Nicht sofort SIGCONT senden, da moeglicherweise
# gerade eine neue Session gestartet wird (Race Condition mit GPU)
if (not self._sessions and self._queue_service
and self._tv_setting("pause_batch_on_stream", True)):
asyncio.create_task(self._delayed_resume())
async def _delayed_resume(self, delay: float = 2.0):
"""Batch-Konvertierung verzoegert fortsetzen.
Wartet kurz, damit eine neue HLS-Session die GPU reservieren kann,
bevor die Batch-Konvertierung per SIGCONT aufgeweckt wird."""
await asyncio.sleep(delay)
# Erneut pruefen: Wenn inzwischen eine neue Session laeuft -> nicht fortsetzen
if not self._sessions and self._queue_service:
count = self._queue_service.resume_encoding()
if count > 0:
logging.info(
f"Encoding fortgesetzt: {count} ffmpeg-Prozess(e) "
f"per SIGCONT aufgeweckt")
logging.info(f"HLS: Alle Sessions beendet, "
f"{count} Konvertierung(en) fortgesetzt")
async def _cleanup_loop(self):
"""Periodisch abgelaufene Sessions entfernen"""
while True:
try:
await asyncio.sleep(30)
timeout_sec = self._tv_setting("hls_session_timeout_min", 5) * 60
expired = [
sid for sid, s in self._sessions.items()
if s.is_expired(timeout_sec)
]
for sid in expired:
logging.info(
f"HLS Session {sid} abgelaufen (Timeout)")
await self.destroy_session(sid)
# Verwaiste Verzeichnisse aufraumen
if HLS_BASE_DIR.exists():
for d in HLS_BASE_DIR.iterdir():
if d.is_dir() and d.name not in self._sessions:
shutil.rmtree(d, ignore_errors=True)
except asyncio.CancelledError:
raise
except Exception as e:
logging.error(f"HLS Cleanup Fehler: {e}")
@property
def active_sessions(self) -> int:
return len(self._sessions)
def get_all_sessions_info(self) -> list[dict]:
"""Alle Sessions als Info-Liste (fuer Debug/Admin)"""
return [
{
"session_id": s.session_id,
"video_id": s.video_id,
"quality": s.quality,
"ready": s.ready,
"age_sec": int(time.time() - s.created_at),
"idle_sec": int(time.time() - s.last_access),
}
for s in self._sessions.values()
]

View file

@ -0,0 +1,101 @@
"""Internationalisierung (i18n) fuer die TV-App.
Laedt Uebersetzungen aus JSON-Dateien und stellt Jinja2-Filter bereit."""
import json
import logging
import os
from typing import Optional
# Verfuegbare Sprachen
SUPPORTED_LANGS = ("de", "en")
DEFAULT_LANG = "de"
# Cache fuer geladene Uebersetzungen
_translations: dict[str, dict] = {}
def load_translations(static_dir: str) -> None:
"""Laedt alle Uebersetzungsdateien aus static/tv/i18n/"""
i18n_dir = os.path.join(static_dir, "tv", "i18n")
for lang in SUPPORTED_LANGS:
filepath = os.path.join(i18n_dir, f"{lang}.json")
if os.path.isfile(filepath):
with open(filepath, "r", encoding="utf-8") as f:
_translations[lang] = json.load(f)
logging.info(f"i18n: Sprache '{lang}' geladen ({filepath})")
else:
logging.warning(f"i18n: Datei nicht gefunden: {filepath}")
if not _translations:
logging.error("i18n: Keine Uebersetzungen geladen!")
def get_text(key: str, lang: str = DEFAULT_LANG, **kwargs) -> str:
"""Gibt uebersetzten Text fuer einen Punkt-separierten Schluessel zurueck.
Beispiel: get_text('nav.home', 'de') -> 'Startseite'
Platzhalter: get_text('player.next_in', 'de', seconds=10)"""
translations = _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
parts = key.split(".")
value = translations
for part in parts:
if isinstance(value, dict):
value = value.get(part)
else:
value = None
break
if value is None:
# Fallback auf Default-Sprache
if lang != DEFAULT_LANG:
return get_text(key, DEFAULT_LANG, **kwargs)
# Key als Fallback zurueckgeben
return key
if not isinstance(value, str):
return key
# Platzhalter ersetzen
if kwargs:
for k, v in kwargs.items():
value = value.replace(f"{{{k}}}", str(v))
return value
def get_all_translations(lang: str = DEFAULT_LANG) -> dict:
"""Gibt alle Uebersetzungen fuer eine Sprache zurueck (fuer JS)"""
return _translations.get(lang, _translations.get(DEFAULT_LANG, {}))
def setup_jinja2_i18n(app) -> None:
"""Registriert i18n-Filter und Globals in Jinja2-Environment.
Muss NACH aiohttp_jinja2.setup() aufgerufen werden."""
import aiohttp_jinja2
env = aiohttp_jinja2.get_env(app)
# Filter: {{ 'nav.home'|t }} oder {{ 'nav.home'|t('en') }}
def t_filter(key: str, lang: str = None) -> str:
# Sprache wird pro Request gesetzt (siehe Middleware)
if lang is None:
lang = getattr(env, "_current_lang", DEFAULT_LANG)
return get_text(key, lang)
env.filters["t"] = t_filter
# Global-Funktion: {{ t('nav.home') }} oder {{ t('nav.home', seconds=10) }}
def t_func(key: str, lang: str = None, **kwargs) -> str:
if lang is None:
lang = getattr(env, "_current_lang", DEFAULT_LANG)
return get_text(key, lang, **kwargs)
env.globals["t"] = t_func
env.globals["SUPPORTED_LANGS"] = SUPPORTED_LANGS
logging.info("i18n: Jinja2-Filter und Globals registriert")
def set_request_lang(app, lang: str) -> None:
"""Setzt die Sprache fuer den aktuellen Request.
Wird vom TV-Auth-Middleware aufgerufen."""
import aiohttp_jinja2
env = aiohttp_jinja2.get_env(app)
env._current_lang = lang if lang in SUPPORTED_LANGS else DEFAULT_LANG

View file

@ -12,7 +12,8 @@ import aiomysql
from app.config import Config
from app.services.library import (
LibraryService, VIDEO_EXTENSIONS, RE_SXXEXX, RE_XXxXX
LibraryService, VIDEO_EXTENSIONS,
RE_SXXEXX_MULTI, RE_XXxXX_MULTI
)
from app.services.tvdb import TVDBService
from app.services.probe import ProbeService
@ -120,6 +121,7 @@ class ImporterService:
detected_series VARCHAR(256),
detected_season INT,
detected_episode INT,
detected_episode_end INT NULL,
tvdb_series_id INT NULL,
tvdb_series_name VARCHAR(256),
tvdb_episode_title VARCHAR(512),
@ -138,6 +140,20 @@ class ImporterService:
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
logging.info("Import-Tabellen initialisiert")
# Migration: detected_episode_end Spalte hinzufuegen
async with self._db_pool.acquire() as conn:
async with conn.cursor() as cur:
try:
await cur.execute(
"ALTER TABLE import_items "
"ADD COLUMN detected_episode_end INT NULL "
"AFTER detected_episode"
)
logging.info("Import: Spalte detected_episode_end hinzugefuegt")
except Exception:
pass # Spalte existiert bereits
except Exception as e:
logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}")
@ -316,6 +332,7 @@ class ImporterService:
series_name = info.get("series", "")
season = info.get("season")
episode = info.get("episode")
episode_end = info.get("episode_end")
# Status: pending_series wenn Serie erkannt, sonst pending
if series_name and season and episode:
@ -332,11 +349,13 @@ class ImporterService:
detected_series = %s,
detected_season = %s,
detected_episode = %s,
detected_episode_end = %s,
status = %s,
conflict_reason = %s
WHERE id = %s
""", (
series_name, season, episode, status,
series_name, season, episode, episode_end,
status,
None if status == "pending_series"
else "Serie/Staffel/Episode nicht erkannt",
item["id"],
@ -427,6 +446,7 @@ class ImporterService:
for item in items:
season = item["detected_season"]
episode = item["detected_episode"]
episode_end = item.get("detected_episode_end")
# Episodentitel von TVDB
tvdb_ep_title = ""
@ -443,7 +463,8 @@ class ImporterService:
tvdb_name, season, episode,
tvdb_ep_title, ext,
job["lib_path"],
pattern, season_pat
pattern, season_pat,
episode_end=episode_end
)
target_path = os.path.join(target_dir, target_file)
@ -574,6 +595,7 @@ class ImporterService:
"series": staffel_info["series"],
"season": staffel_info["season"],
"episode": info_file["episode"],
"episode_end": info_file.get("episode_end"),
}
# Dateiname hat S/E
@ -621,25 +643,31 @@ class ImporterService:
return None
def _parse_name(self, name: str) -> dict:
"""Extrahiert Serienname, Staffel, Episode aus einem Namen"""
result = {"series": "", "season": None, "episode": None}
"""Extrahiert Serienname, Staffel, Episode aus einem Namen.
Unterstuetzt Doppelfolgen: S09E19E20, S01E01-E02, 1x01-02"""
result = {"series": "", "season": None, "episode": None,
"episode_end": None}
name_no_ext = os.path.splitext(name)[0]
# S01E02 Format
m = RE_SXXEXX.search(name)
# S01E02 / Doppelfolge S01E01E02 Format
m = RE_SXXEXX_MULTI.search(name)
if m:
result["season"] = int(m.group(1))
result["episode"] = int(m.group(2))
if m.group(3):
result["episode_end"] = int(m.group(3))
sm = RE_SERIES_FROM_NAME.match(name_no_ext)
if sm:
result["series"] = self._clean_name(sm.group(1))
return result
# 1x02 Format
m = RE_XXxXX.search(name)
# 1x02 / Doppelfolge 1x01-02 Format
m = RE_XXxXX_MULTI.search(name)
if m:
result["season"] = int(m.group(1))
result["episode"] = int(m.group(2))
if m.group(3):
result["episode_end"] = int(m.group(3))
sm = RE_SERIES_FROM_XXx.match(name_no_ext)
if sm:
result["series"] = self._clean_name(sm.group(1))
@ -660,14 +688,21 @@ class ImporterService:
def _build_target(self, series: str, season: Optional[int],
episode: Optional[int], title: str, ext: str,
lib_path: str, pattern: str,
season_pattern: str) -> tuple[str, str]:
"""Baut Ziel-Ordner und Dateiname nach Pattern"""
season_pattern: str,
episode_end: Optional[int] = None) -> tuple[str, str]:
"""Baut Ziel-Ordner und Dateiname nach Pattern.
Unterstuetzt Doppelfolgen via episode_end."""
s = season or 1
e = episode or 0
# Season-Ordner
season_dir = season_pattern.format(season=s)
# Episode-Teil: S01E02 oder S01E02E03 bei Doppelfolgen
ep_str = f"S{s:02d}E{e:02d}"
if episode_end and episode_end != e:
ep_str += f"E{episode_end:02d}"
# Dateiname - kein Titel: ohne Titel-Teil, sonst mit
try:
if title:
@ -675,14 +710,18 @@ class ImporterService:
series=series, season=s, episode=e,
title=title, ext=ext
)
# Doppelfolge: E-Teil im generierten Namen ersetzen
if episode_end and episode_end != e:
filename = filename.replace(
f"S{s:02d}E{e:02d}", ep_str, 1
)
else:
# Ohne Titel: "Serie - S01E03.ext"
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
filename = f"{series} - {ep_str}.{ext}"
except (KeyError, ValueError):
if title:
filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}"
filename = f"{series} - {ep_str} - {title}.{ext}"
else:
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
filename = f"{series} - {ep_str}.{ext}"
# Ungueltige Zeichen entfernen
for ch in ['<', '>', ':', '"', '|', '?', '*']:
@ -1224,6 +1263,7 @@ class ImporterService:
return False
allowed = {
'detected_series', 'detected_season', 'detected_episode',
'detected_episode_end',
'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title',
'target_path', 'target_filename', 'status'
}
@ -1400,6 +1440,7 @@ class ImporterService:
for item in items:
season = item["detected_season"]
episode = item["detected_episode"]
ep_end = item.get("detected_episode_end")
# Episodentitel holen
tvdb_ep_title = ""
@ -1420,7 +1461,8 @@ class ImporterService:
tvdb_name, season, episode,
tvdb_ep_title, ext,
job["lib_path"],
pattern, season_pat
pattern, season_pat,
episode_end=ep_end
)
await cur.execute("""
@ -1462,7 +1504,8 @@ class ImporterService:
"conflict_reason = 'Serie uebersprungen' "
"WHERE import_job_id = %s "
"AND LOWER(detected_series) = LOWER(%s) "
"AND status IN ('pending', 'matched')",
"AND status IN ('pending', 'pending_series', "
"'matched')",
(job_id, detected_series)
)
skipped = cur.rowcount

View file

@ -81,9 +81,10 @@ class LibraryService:
db=db_cfg.get("database", "video_converter"),
charset="utf8mb4",
autocommit=True,
minsize=1,
maxsize=5,
minsize=2,
maxsize=10,
connect_timeout=10,
pool_recycle=300,
)
return self._db_pool
except Exception as e:
@ -220,6 +221,8 @@ class LibraryService:
episode_name VARCHAR(512),
aired DATE NULL,
runtime INT NULL,
overview TEXT NULL,
image_url VARCHAR(1024) NULL,
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_series (series_tvdb_id),
UNIQUE INDEX idx_episode (
@ -227,6 +230,18 @@ class LibraryService:
)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
# Spalten nachtraeglich hinzufuegen (bestehende DBs)
for col, coldef in [
("overview", "TEXT NULL"),
("image_url", "VARCHAR(1024) NULL"),
]:
try:
await cur.execute(
f"ALTER TABLE tvdb_episode_cache "
f"ADD COLUMN {col} {coldef}"
)
except Exception:
pass # Spalte existiert bereits
# movie_id Spalte in library_videos (falls noch nicht vorhanden)
try:

View file

@ -3,6 +3,7 @@ import asyncio
import json
import logging
import os
import signal
import time
from collections import OrderedDict
from decimal import Decimal
@ -34,6 +35,7 @@ class QueueService:
self._active_count: int = 0
self._running: bool = False
self._paused: bool = False
self._encoding_suspended: bool = False
self._queue_task: Optional[asyncio.Task] = None
self._queue_file = str(config.data_path / "queue.json")
self._db_pool: Optional[aiomysql.Pool] = None
@ -195,6 +197,52 @@ class QueueService:
def is_paused(self) -> bool:
return self._paused
@property
def encoding_suspended(self) -> bool:
"""Sind aktive ffmpeg-Prozesse gerade per SIGSTOP eingefroren?"""
return self._encoding_suspended
def suspend_encoding(self) -> int:
"""Friert alle aktiven ffmpeg-Konvertierungen per SIGSTOP ein.
Wird aufgerufen wenn ein HLS-Stream startet, damit der Server
volle Ressourcen fuers Streaming hat.
Gibt die Anzahl pausierter Prozesse zurueck."""
count = 0
for job in self.jobs.values():
if (job.status == JobStatus.ACTIVE and job.process
and job.process.returncode is None):
try:
os.kill(job.process.pid, signal.SIGSTOP)
count += 1
except (ProcessLookupError, PermissionError) as e:
logging.warning(f"SIGSTOP fehlgeschlagen fuer PID "
f"{job.process.pid}: {e}")
if count:
self._encoding_suspended = True
logging.info(f"Encoding pausiert: {count} ffmpeg-Prozess(e) "
f"per SIGSTOP eingefroren (HLS-Stream aktiv)")
return count
def resume_encoding(self) -> int:
"""Setzt alle eingefrorenen ffmpeg-Konvertierungen per SIGCONT fort.
Wird aufgerufen wenn der letzte HLS-Stream endet.
Gibt die Anzahl fortgesetzter Prozesse zurueck."""
count = 0
for job in self.jobs.values():
if (job.status == JobStatus.ACTIVE and job.process
and job.process.returncode is None):
try:
os.kill(job.process.pid, signal.SIGCONT)
count += 1
except (ProcessLookupError, PermissionError) as e:
logging.warning(f"SIGCONT fehlgeschlagen fuer PID "
f"{job.process.pid}: {e}")
if count:
logging.info(f"Encoding fortgesetzt: {count} ffmpeg-Prozess(e) "
f"per SIGCONT aufgeweckt")
self._encoding_suspended = False
return count
async def retry_job(self, job_id: int) -> bool:
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
job = self.jobs.get(job_id)
@ -226,7 +274,8 @@ class QueueService:
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
JobStatus.FAILED, JobStatus.CANCELLED):
queue[job_id] = job.to_dict_queue()
return {"data_queue": queue, "queue_paused": self._paused}
return {"data_queue": queue, "queue_paused": self._paused,
"encoding_suspended": self._encoding_suspended}
def get_active_jobs(self) -> dict:
"""Aktive Jobs fuer WebSocket"""
@ -249,8 +298,8 @@ class QueueService:
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
while self._running:
try:
if (not self._paused and
self._active_count < self.config.max_parallel_jobs):
if (not self._paused and not self._encoding_suspended
and self._active_count < self.config.max_parallel_jobs):
next_job = self._get_next_queued()
if next_job:
asyncio.create_task(self._execute_job(next_job))
@ -267,6 +316,17 @@ class QueueService:
await self.ws_manager.broadcast_queue_update()
# Berechtigungspruefung BEVOR ffmpeg gestartet wird
target_dir = os.path.dirname(job.target_path)
if not self._check_write_permission(target_dir, job):
job.status = JobStatus.FAILED
job.finished_at = time.time()
self._active_count = max(0, self._active_count - 1)
self._save_queue()
await self._save_stats(job)
await self.ws_manager.broadcast_queue_update()
return
command = self.encoder.build_command(job)
logging.info(
f"Starte Konvertierung: {job.media.source_filename}\n"
@ -297,10 +357,18 @@ class QueueService:
else:
job.status = JobStatus.FAILED
error_output = progress.get_error_output()
# Bei Permission-Fehlern zusaetzliche Diagnose
extra = ""
if "Permission denied" in error_output:
uid, gid = os.getuid(), os.getgid()
extra = (
f"\n -> Berechtigungsfehler! Container UID:GID = "
f"{uid}:{gid}, Ziel: {job.target_path}"
)
logging.error(
f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): "
f"{job.media.source_filename}\n"
f" ffmpeg stderr:\n{error_output}"
f" ffmpeg stderr:\n{error_output}{extra}"
)
except asyncio.CancelledError:
@ -315,31 +383,118 @@ class QueueService:
self._save_queue()
await self._save_stats(job)
await self.ws_manager.broadcast_queue_update()
# Library-Seite zum Reload auffordern (Badges aktualisieren)
if job.status == JobStatus.FINISHED:
await self.ws_manager.broadcast({
"data_library_scan": {
"status": "idle", "current": "",
"total": 0, "done": 0
}
})
async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
"""Cleanup nach erfolgreicher Konvertierung"""
"""Cleanup nach erfolgreicher Konvertierung.
WICHTIG: Nur die Quelldatei dieses Jobs loeschen, NICHT
andere Dateien im Ordner die noch in der Queue warten!"""
files_cfg = self.config.files_config
# Quelldatei loeschen: Global per Config ODER per Job-Option
should_delete = files_cfg.get("delete_source", False) or job.delete_source
should_delete = files_cfg.get("delete_source", False) or \
job.delete_source
if should_delete:
target_exists = os.path.exists(job.target_path)
target_size = os.path.getsize(job.target_path) if target_exists else 0
target_size = (os.path.getsize(job.target_path)
if target_exists else 0)
if target_exists and target_size > 0:
try:
os.remove(job.media.source_path)
logging.info(f"Quelldatei geloescht: {job.media.source_path}")
logging.info(
f"Quelldatei geloescht: {job.media.source_path}")
except OSError as e:
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}")
logging.error(
f"Quelldatei loeschen fehlgeschlagen: {e}")
else:
logging.warning(
f"Quelldatei NICHT geloescht "
f"(Zieldatei fehlt/leer): "
f"{job.media.source_path}")
# SICHERHEIT: Ordner-Cleanup nur wenn KEINE weiteren
# Jobs aus diesem Ordner in der Queue warten!
cleanup_cfg = self.config.cleanup_config
if cleanup_cfg.get("enabled", False):
deleted = self.scanner.cleanup_directory(job.media.source_dir)
if deleted:
source_dir = job.media.source_dir
pending = [
j for j in self.jobs.values()
if j.media.source_dir == source_dir
and j.status in (JobStatus.QUEUED, JobStatus.ACTIVE)
and j.id != job.id
]
if pending:
logging.info(
f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}"
)
f"Ordner-Cleanup uebersprungen "
f"({len(pending)} Jobs wartend): {source_dir}")
else:
deleted = self.scanner.cleanup_directory(source_dir)
if deleted:
logging.info(
f"{len(deleted)} Dateien bereinigt "
f"in {source_dir}"
)
def _check_write_permission(self, target_dir: str,
job: ConversionJob) -> bool:
"""Prueft Schreibzugriff auf das Zielverzeichnis.
Gibt True zurueck wenn OK, False bei Fehler (Job wird FAILED)."""
uid = os.getuid()
gid = os.getgid()
if not os.path.isdir(target_dir):
logging.error(
f"Zielverzeichnis existiert nicht: {target_dir}\n"
f" Datei: {job.media.source_filename}\n"
f" Container UID:GID = {uid}:{gid}"
)
return False
# Echten Schreibtest machen (os.access ist bei CIFS/NFS unzuverlaessig)
test_file = os.path.join(target_dir, f".vk_write_test_{uid}")
try:
with open(test_file, "w") as f:
f.write("test")
os.remove(test_file)
return True
except PermissionError:
# Verzeichnis-Info sammeln fuer hilfreiche Fehlermeldung
try:
stat = os.stat(target_dir)
dir_uid = stat.st_uid
dir_gid = stat.st_gid
dir_mode = oct(stat.st_mode)[-3:]
except OSError:
dir_uid = dir_gid = "?"
dir_mode = "???"
logging.error(
f"Kein Schreibzugriff auf Zielverzeichnis!\n"
f" Datei: {job.media.source_filename}\n"
f" Ziel: {job.target_path}\n"
f" Verzeichnis: {target_dir}\n"
f" Container laeuft als UID:GID = {uid}:{gid}\n"
f" Verzeichnis gehoert UID:GID = {dir_uid}:{dir_gid} "
f"(Modus: {dir_mode})\n"
f" Loesung: PUID/PGID im Container auf {dir_uid}:{dir_gid} "
f"setzen oder Verzeichnis-Berechtigungen anpassen"
)
return False
except OSError as e:
logging.error(
f"Schreibtest fehlgeschlagen: {e}\n"
f" Verzeichnis: {target_dir}\n"
f" Container UID:GID = {uid}:{gid}"
)
return False
def _get_next_queued(self) -> Optional[ConversionJob]:
"""Naechster Job mit Status QUEUED (FIFO)"""
@ -417,9 +572,10 @@ class QueueService:
db=db_cfg["db"],
charset="utf8mb4",
autocommit=True,
minsize=1,
maxsize=5,
minsize=2,
maxsize=10,
connect_timeout=10,
pool_recycle=300,
)
return self._db_pool

View file

@ -38,6 +38,7 @@ class TVDBService:
def __init__(self, config: Config):
self.config = config
self._client = None
self._client_api_key = "" # Key mit dem der Client erstellt wurde
self._db_pool: Optional[aiomysql.Pool] = None
@property
@ -64,12 +65,18 @@ class TVDBService:
self._db_pool = pool
def _get_client(self):
"""Erstellt oder gibt TVDB-Client zurueck"""
"""Erstellt oder gibt TVDB-Client zurueck.
Erstellt neuen Client wenn API Key geaendert wurde."""
if not TVDB_AVAILABLE:
return None
if not self._api_key:
return None
# Client neu erstellen wenn API Key geaendert wurde
if self._client and self._client_api_key != self._api_key:
logging.info("TVDB API Key geaendert - Client wird neu erstellt")
self._client = None
if self._client is None:
try:
if self._pin:
@ -78,6 +85,7 @@ class TVDBService:
)
else:
self._client = tvdb_v4_official.TVDB(self._api_key)
self._client_api_key = self._api_key
logging.info("TVDB Client verbunden")
except Exception as e:
logging.error(f"TVDB Verbindung fehlgeschlagen: {e}")
@ -801,12 +809,21 @@ class TVDBService:
ep_aired = getattr(ep, "aired", None)
ep_runtime = getattr(ep, "runtime", None)
if s_num and s_num > 0 and e_num and e_num > 0:
# Beschreibung und Bild-URL
if isinstance(ep, dict):
ep_overview = ep.get("overview", "")
ep_image = ep.get("image", "")
else:
ep_overview = getattr(ep, "overview", "")
ep_image = getattr(ep, "image", "")
episodes.append({
"season_number": s_num,
"episode_number": e_num,
"episode_name": ep_name or "",
"aired": ep_aired,
"runtime": ep_runtime,
"overview": ep_overview or "",
"image_url": ep_image or "",
})
page += 1
if page > 50:
@ -840,12 +857,15 @@ class TVDBService:
await cur.execute(
"INSERT INTO tvdb_episode_cache "
"(series_tvdb_id, season_number, episode_number, "
"episode_name, aired, runtime) "
"VALUES (%s, %s, %s, %s, %s, %s)",
"episode_name, aired, runtime, overview, "
"image_url) "
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
(
tvdb_id, ep["season_number"],
ep["episode_number"], ep["episode_name"],
ep["aired"], ep["runtime"],
ep.get("overview", ""),
ep.get("image_url", ""),
)
)
except Exception as e:

View file

@ -294,7 +294,55 @@ legend {
gap: 0.5rem;
}
/* === Presets Grid === */
/* === Presets Editor === */
.preset-editor { display: flex; flex-direction: column; gap: 0.5rem; }
.preset-edit-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 10px;
overflow: hidden;
}
.preset-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1rem;
cursor: pointer;
transition: background 0.15s;
}
.preset-header:hover { background: #222; }
.preset-header-left { display: flex; align-items: center; gap: 0.8rem; flex-wrap: wrap; }
.preset-header h3 { font-size: 0.85rem; margin: 0; color: #fff; white-space: nowrap; }
.preset-toggle { color: #666; font-size: 0.7rem; transition: transform 0.2s; }
.preset-body {
padding: 1rem;
border-top: 1px solid #2a2a2a;
background: #141414;
}
.preset-body textarea {
width: 100%;
font-family: monospace;
font-size: 0.8rem;
background: #1e1e1e;
color: #e0e0e0;
border: 1px solid #333;
border-radius: 6px;
padding: 0.5rem;
resize: vertical;
}
.preset-body textarea:focus {
border-color: #1976d2;
outline: none;
}
.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; }
/* Presets Grid (Legacy) */
.presets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
@ -314,8 +362,6 @@ legend {
color: #fff;
}
.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.tag {
display: inline-block;
padding: 0.1rem 0.4rem;
@ -327,6 +373,7 @@ legend {
}
.tag.gpu { background: #1b5e20; color: #81c784; border-color: #2e7d32; }
.tag.cpu { background: #0d47a1; color: #90caf9; border-color: #1565c0; }
.tag.default { background: #e65100; color: #ffcc80; border-color: #f57c00; }
/* === Statistics === */
.stats-summary {
@ -910,6 +957,10 @@ legend {
}
.series-card:hover { border-color: #444; }
.series-poster-wrap {
position: relative;
flex-shrink: 0;
}
.series-poster {
width: 60px;
height: 90px;
@ -917,6 +968,19 @@ legend {
border-radius: 4px;
flex-shrink: 0;
}
.series-codec-badge {
position: absolute;
bottom: 2px;
left: 2px;
background: #2e7d32;
color: #fff;
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
letter-spacing: 0.5px;
line-height: 1.3;
}
.series-poster-placeholder {
width: 60px;
height: 90px;
@ -1081,6 +1145,8 @@ legend {
.row-missing { opacity: 0.6; }
.row-missing td { color: #888; }
.row-redundant { background: rgba(255, 152, 0, 0.08); }
.row-redundant td { color: #b0a080; }
.text-warn { color: #ffb74d; }
.text-muted { color: #888; font-size: 0.8rem; }
@ -1897,3 +1963,37 @@ legend {
.artwork-gallery { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
.lib-section-header { flex-direction: column; align-items: flex-start; }
}
/* === Import: Separate Fortschrittsbalken pro Job === */
.import-job-progress {
padding: 0.6rem 0;
border-bottom: 1px solid #2a2a2a;
}
.import-job-progress:last-child { border-bottom: none; }
.import-job-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
font-size: 0.85rem;
}
.import-job-name {
font-weight: 600;
color: #eee;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.import-job-status {
font-size: 0.8rem;
color: #aaa;
font-weight: 500;
}
.import-job-progress .progress-container {
margin-bottom: 0.2rem;
}
.import-job-text {
font-size: 0.78rem;
display: block;
}

View file

@ -465,9 +465,14 @@ function renderSeriesGrid(series) {
const tvdbBtn = s.tvdb_id
? `<span class="tag ok">TVDB</span>`
: `<button class="btn-small btn-secondary" onclick="event.stopPropagation(); openTvdbModal(${s.id}, '${escapeAttr(s.folder_name)}')">TVDB zuordnen</button>`;
const codecBadge = s.codec_badge
? `<span class="series-codec-badge">${escapeHtml(s.codec_badge)}</span>` : "";
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
${poster}
<div class="series-poster-wrap">
${poster}
${codecBadge}
</div>
<div class="series-info">
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
${genres}
@ -638,6 +643,33 @@ function renderEpisodesTab(series) {
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
// Redundante Dateien erkennen: gleiche Episode-Nummer mehrfach vorhanden
// Die "beste" Datei behalten (kleinere Datei bei gleichem Codec, neueres Format bevorzugt)
const epGroups = {};
for (const ep of allEps) {
if (ep._type !== "local" || !ep.episode_number) continue;
const key = `${ep.season_number || 0}-${ep.episode_number}`;
if (!epGroups[key]) epGroups[key] = [];
epGroups[key].push(ep);
}
const redundantIds = new Set();
const codecRank = {av1: 4, hevc: 3, h265: 3, h264: 2, x264: 2, mpeg4: 1, mpeg2video: 0};
for (const key of Object.keys(epGroups)) {
const group = epGroups[key];
if (group.length <= 1) continue;
// Sortiere: neuerer Codec besser, bei gleichem Codec kleinere Datei besser
group.sort((a, b) => {
const ra = codecRank[(a.video_codec || "").toLowerCase()] || 0;
const rb = codecRank[(b.video_codec || "").toLowerCase()] || 0;
if (ra !== rb) return rb - ra;
return (a.file_size || 0) - (b.file_size || 0);
});
// Alle ausser dem ersten sind redundant
for (let i = 1; i < group.length; i++) {
redundantIds.add(group[i].id);
}
}
for (const ep of allEps) {
if (ep._type === "missing") {
html += `<tr class="row-missing">
@ -647,6 +679,7 @@ function renderEpisodesTab(series) {
<td><span class="status-badge error">FEHLT</span></td>
</tr>`;
} else {
const isRedundant = redundantIds.has(ep.id);
const audioInfo = (ep.audio_tracks || []).map(a => {
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
@ -654,9 +687,10 @@ function renderEpisodesTab(series) {
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
const epTitle = ep.episode_title || ep.file_name || "Episode";
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
html += `<tr data-video-id="${ep.id}">
const redundantBadge = isRedundant ? ' <span class="status-badge warn" title="Duplikat - kann geloescht werden">REDUNDANT</span>' : '';
html += `<tr data-video-id="${ep.id}" class="${isRedundant ? 'row-redundant' : ''}">
<td>${ep.episode_number || "-"}</td>
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
<td>${res}</td>
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
<td><span class="tag">${fileExt}</span></td>
@ -1385,67 +1419,57 @@ let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen
async function startAutoMatch() {
if (!await showConfirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?", {title: "Auto-Match starten", detail: "Das kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.", okText: "Starten", icon: "info"})) return;
const progress = document.getElementById("auto-match-progress");
progress.style.display = "block";
document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege...";
document.getElementById("auto-match-bar").style.width = "0%";
_gpShow("automatch", "Auto-Match", "Suche TVDB-Vorschlaege...", 0);
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
.then(r => r.json())
.then(data => {
if (data.error) {
document.getElementById("auto-match-status").textContent = "Fehler: " + data.error;
setTimeout(() => { progress.style.display = "none"; }, 3000);
_gpShow("automatch", "Auto-Match", "Fehler: " + data.error, 0);
_gpHideDelayed("automatch");
return;
}
pollAutoMatchStatus();
})
.catch(e => {
document.getElementById("auto-match-status").textContent = "Fehler: " + e;
_gpShow("automatch", "Auto-Match", "Fehler: " + e, 0);
_gpHideDelayed("automatch");
});
}
function pollAutoMatchStatus() {
const progress = document.getElementById("auto-match-progress");
const interval = setInterval(() => {
fetch("/api/library/tvdb-auto-match-status")
.then(r => r.json())
.then(data => {
const bar = document.getElementById("auto-match-bar");
const status = document.getElementById("auto-match-status");
if (data.phase === "done") {
clearInterval(interval);
bar.style.width = "100%";
const suggestions = data.suggestions || [];
// Nur Items mit mindestens einem Vorschlag anzeigen
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
const noResults = suggestions.length - withSuggestions.length;
status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`;
setTimeout(() => {
progress.style.display = "none";
}, 2000);
_gpShow("automatch", "Auto-Match",
withSuggestions.length + " Vorschlaege, " + noResults + " ohne Ergebnis", 100);
_gpHideDelayed("automatch");
if (withSuggestions.length > 0) {
openTvdbReviewModal(withSuggestions);
}
} else if (data.phase === "error") {
clearInterval(interval);
status.textContent = "Fehler beim Sammeln der Vorschlaege";
setTimeout(() => { progress.style.display = "none"; }, 3000);
_gpShow("automatch", "Auto-Match", "Fehler", 0);
_gpHideDelayed("automatch");
} else if (!data.active && data.phase !== "done") {
clearInterval(interval);
progress.style.display = "none";
_gpHide("automatch");
} else {
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
bar.style.width = pct + "%";
const phase = data.phase === "series" ? "Serien" : "Filme";
status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`;
_gpShow("automatch", "Auto-Match",
phase + ": " + (data.current || "") + " (" + data.done + "/" + data.total + ")", pct);
}
})
.catch(() => clearInterval(interval));
}, 5000); // 5s Fallback (WS liefert Live-Updates)
}, 5000);
}
// === TVDB Review-Modal ===
@ -2096,7 +2120,7 @@ function openImportModal() {
.then(data => {
const select = document.getElementById("import-target");
select.innerHTML = (data.paths || []).map(p =>
`<option value="${p.id}">${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
`<option value="${p.id}" ${activePathId === p.id ? 'selected' : ''}>${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
).join("");
})
.catch(() => {});
@ -2831,27 +2855,52 @@ let _importWsActive = false; // WebSocket liefert Updates?
async function executeImport() {
if (!currentImportJobId) return;
// Job-ID merken bevor resetImport() sie loescht
const jobId = currentImportJobId;
// Modal schliessen - Fortschritt laeuft ueber globalen Progress-Balken
closeImportModal();
resetImport();
// Starte Import (non-blocking - Server antwortet sofort)
fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"});
fetch(`/api/library/import/${jobId}/execute`, {method: "POST"});
}
// WebSocket-Handler fuer Import-Fortschritt
function handleImportWS(data) {
if (!data || !data.job_id) return;
// Nur Updates fuer aktuellen Job
if (data.job_id !== currentImportJobId) return;
_importWsActive = true;
// Polling abschalten wenn WS liefert
stopImportPolling();
// Import-Progress-Container sichtbar machen
const progressEl = document.getElementById("import-progress");
if (progressEl) progressEl.style.display = "";
// Pro Job ein eigenes Element erstellen/finden
const container = document.getElementById("import-jobs-container");
if (!container) return;
let jobEl = document.getElementById("import-job-" + data.job_id);
if (!jobEl) {
jobEl = document.createElement("div");
jobEl.id = "import-job-" + data.job_id;
jobEl.className = "import-job-progress";
const jobName = data.source_path || data.job_name || "Job #" + data.job_id;
const shortName = jobName.split("/").pop() || jobName;
jobEl.innerHTML =
'<div class="import-job-header">' +
'<span class="import-job-name" title="' + escapeHtml(jobName) + '">' + escapeHtml(shortName) + '</span>' +
'<span class="import-job-status"></span>' +
'</div>' +
'<div class="progress-container"><div class="progress-bar import-job-bar"></div></div>' +
'<span class="text-muted import-job-text">Starte...</span>';
container.appendChild(jobEl);
}
const bar = jobEl.querySelector(".import-job-bar");
const statusText = jobEl.querySelector(".import-job-text");
const statusBadge = jobEl.querySelector(".import-job-status");
const status = data.status || "";
const total = data.total || 1;
const processed = data.processed || 0;
@ -2865,34 +2914,35 @@ function handleImportWS(data) {
pct += (bytesDone / bytesTotal) * (100 / total);
}
pct = Math.min(Math.round(pct), 100);
const bar = document.getElementById("import-bar");
const statusText = document.getElementById("import-status-text");
if (bar) bar.style.width = pct + "%";
if (status === "analyzing") {
if (statusText) statusText.textContent =
`Analysiere: ${processed} / ${total} - ${curFile}`;
if (statusBadge) statusBadge.textContent = "Analyse";
} else if (status === "embedding") {
if (statusText) statusText.textContent =
`Metadaten schreiben: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
`Metadaten: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
if (statusBadge) statusBadge.textContent = "Metadaten";
} else if (status === "importing") {
let txt = `Importiere: ${processed} / ${total} Dateien`;
let txt = `${processed} / ${total} Dateien`;
if (curFile && bytesTotal > 0 && processed < total) {
const curPct = Math.round((bytesDone / bytesTotal) * 100);
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)} / ${formatSize(bytesTotal)}, ${curPct}%)`;
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)}/${formatSize(bytesTotal)})`;
} else {
txt += ` (${pct}%)`;
}
if (statusText) statusText.textContent = txt;
if (statusBadge) statusBadge.textContent = pct + "%";
} else if (status === "done" || status === "error") {
if (bar) bar.style.width = "100%";
if (statusText) statusText.textContent =
status === "done"
? `Import abgeschlossen (${processed} Dateien)`
: `Import mit Fehlern beendet`;
if (status === "done") {
if (statusBadge) { statusBadge.textContent = "Fertig"; statusBadge.style.color = "#4caf50"; }
} else {
if (statusBadge) { statusBadge.textContent = "Fehler"; statusBadge.style.color = "#f44336"; }
}
// Ergebnis per REST holen fuer Details
// Ergebnis per REST holen
fetch(`/api/library/import/${data.job_id}`)
.then(r => r.json())
.then(result => {
@ -2901,9 +2951,8 @@ function handleImportWS(data) {
const errors = items.filter(i => i.status === "error").length;
const skipped = items.filter(i => i.status === "skipped").length;
if (statusText) statusText.textContent =
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
`${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
// Ziel-Pfad scannen
const job = result.job;
if (job && job.target_library_id && imported > 0) {
fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"})
@ -2941,72 +2990,27 @@ function startImportPolling() {
if (data.error) {
stopImportPolling();
document.getElementById("import-status-text").textContent = "Fehler: " + data.error;
return;
}
const job = data.job;
if (!job) return;
const total = job.total_files || 1;
const done = job.processed_files || 0;
// handleImportWS wiederverwenden (rendert in den neuen Container)
handleImportWS({
job_id: currentImportJobId,
status: job.status,
total: job.total_files || 1,
processed: job.processed_files || 0,
current_file: job.current_file_name || "",
bytes_done: job.current_file_bytes || 0,
bytes_total: job.current_file_total || 0,
source_path: job.source_path || "",
});
// Byte-Fortschritt der aktuellen Datei
const curFile = job.current_file_name || "";
const curBytes = job.current_file_bytes || 0;
const curTotal = job.current_file_total || 0;
// Prozent: fertige Dateien + anteilig aktuelle Datei
let pct = (done / total) * 100;
if (curTotal > 0 && done < total) {
pct += (curBytes / curTotal) * (100 / total);
}
pct = Math.min(Math.round(pct), 100);
document.getElementById("import-bar").style.width = pct + "%";
// Status-Text mit Byte-Fortschritt
let statusText = `Importiere: ${done} / ${total} Dateien`;
if (curFile && curTotal > 0 && done < total) {
const curPct = Math.round((curBytes / curTotal) * 100);
statusText += ` - ${curFile.substring(0, 40)}... (${formatSize(curBytes)} / ${formatSize(curTotal)}, ${curPct}%)`;
} else {
statusText += ` (${pct}%)`;
}
document.getElementById("import-status-text").textContent = statusText;
// Fertig?
// Fertig? (handleImportWS uebernimmt Ergebnis-Laden und Scan)
if (job.status === "done" || job.status === "error") {
stopImportPolling();
document.getElementById("import-bar").style.width = "100%";
// Zaehle Ergebnisse
const items = data.items || [];
const imported = items.filter(i => i.status === "done").length;
const errors = items.filter(i => i.status === "error").length;
const skipped = items.filter(i => i.status === "skipped").length;
document.getElementById("import-status-text").textContent =
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
// Nur Ziel-Pfad scannen und neu laden (statt alles)
const targetPathId = job.target_library_id;
if (targetPathId && imported > 0) {
fetch(`/api/library/scan/${targetPathId}`, {method: "POST"})
.then(() => {
setTimeout(() => {
loadSectionData(targetPathId);
loadStats();
}, 2000);
})
.catch(() => {
reloadAllSections();
loadStats();
});
} else {
reloadAllSections();
loadStats();
}
}
} catch (e) {
console.error("Import-Polling Fehler:", e);
@ -3155,3 +3159,50 @@ async function deleteVideo(videoId, title, context) {
})
.catch(e => showToast("Fehler: " + e, "error"));
}
// === Batch-Thumbnail-Generierung ===
async function generateThumbnails() {
// Status pruefen
const status = await fetch("/api/library/thumbnail-status").then(r => r.json());
if (status.missing === 0) {
showToast("Alle " + status.total + " Videos haben bereits Thumbnails", "info");
return;
}
if (!await showConfirm(
status.missing + " von " + status.total + " Videos haben noch kein Thumbnail. Jetzt generieren?",
{title: "Thumbnails generieren", detail: "Die Generierung laeuft im Hintergrund per ffmpeg.", okText: "Starten", icon: "info"}
)) return;
fetch("/api/library/generate-thumbnails", {method: "POST"})
.then(r => r.json())
.then(data => {
if (data.status === "running") {
showToast("Thumbnail-Generierung laeuft bereits", "info");
} else {
showToast("Thumbnail-Generierung gestartet", "success");
}
// Globalen Fortschrittsbalken anzeigen und Polling starten
_gpShow("thumbnails", "Thumbnails", "Starte...", 0);
pollThumbnailStatus();
})
.catch(e => showToast("Fehler: " + e, "error"));
}
function pollThumbnailStatus() {
const interval = setInterval(() => {
fetch("/api/library/thumbnail-status")
.then(r => r.json())
.then(data => {
const pct = data.total > 0 ? Math.round((data.generated / data.total) * 100) : 0;
_gpShow("thumbnails", "Thumbnails", data.generated + " / " + data.total, pct);
if (!data.running) {
clearInterval(interval);
_gpHideDelayed("thumbnails");
showToast(data.generated + " / " + data.total + " Thumbnails vorhanden", "success");
}
})
.catch(() => clearInterval(interval));
}, 2000);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,201 @@
{
"nav": {
"home": "Startseite",
"series": "Serien",
"movies": "Filme",
"search": "Suche",
"watchlist": "Merkliste",
"settings": "Einstellungen",
"profiles": "Profile",
"logout": "Abmelden"
},
"home": {
"continue_watching": "Weiterschauen",
"my_series": "Meine Serien",
"my_movies": "Meine Filme",
"recently_added": "Neu hinzugefügt",
"watchlist": "Meine Merkliste"
},
"series": {
"title": "Serien",
"all": "Alle",
"episodes": "Episoden",
"season": "Staffel",
"specials": "Specials",
"no_episodes": "Keine Episoden vorhanden.",
"no_series": "Keine Serien vorhanden.",
"episode_short": "E",
"min": "Min",
"watchlist": "Merkliste",
"duplicate": "Duplikat"
},
"movies": {
"title": "Filme",
"all": "Alle",
"no_movies": "Keine Filme vorhanden.",
"versions": "Versionen",
"version": "Version"
},
"player": {
"back": "Zurück",
"play": "Abspielen",
"pause": "Pause",
"fullscreen": "Vollbild",
"next_episode": "Nächste Episode",
"next_in": "Nächste Episode in {seconds}s",
"skip": "Jetzt abspielen",
"cancel": "Abbrechen",
"still_watching": "Schaust du noch?",
"continue": "Weiter",
"stop": "Aufhören",
"audio": "Audio",
"subtitles": "Untertitel",
"subtitles_off": "Aus",
"quality": "Qualität",
"quality_uhd": "Ultra HD",
"quality_hd": "HD",
"quality_sd": "SD",
"quality_low": "Niedrig",
"speed": "Geschwindigkeit",
"settings": "Einstellungen"
},
"search": {
"title": "Suche",
"placeholder": "Serien oder Filme suchen...",
"button": "Suchen",
"no_results": "Keine Ergebnisse für \"{query}\".",
"min_chars": "Mindestens 2 Zeichen eingeben.",
"history": "Letzte Suchen",
"clear_history": "Verlauf löschen",
"results_series": "Serien",
"results_movies": "Filme"
},
"watchlist": {
"title": "Merkliste",
"empty": "Deine Merkliste ist leer.",
"add": "Zur Merkliste",
"remove": "Von Merkliste entfernen",
"added": "Gemerkt",
"series": "Serien",
"movies": "Filme"
},
"status": {
"unwatched": "Nicht gesehen",
"watching": "Angefangen",
"watched": "Gesehen",
"mark_watched": "Als gesehen markieren",
"mark_unwatched": "Als nicht gesehen markieren",
"mark_season": "Staffel als gesehen",
"mark_series": "Serie als gesehen",
"reset_progress": "Fortschritt zurücksetzen"
},
"settings": {
"title": "Einstellungen",
"user_settings": "Benutzer-Einstellungen",
"client_settings": "Geräte-Einstellungen",
"profile": "Profil",
"display_name": "Anzeigename",
"avatar_color": "Profilfarbe",
"language": "Sprache",
"menu_language": "Menüsprache",
"audio_language": "Audio-Sprache",
"subtitle_language": "Untertitel-Sprache",
"subtitles_enabled": "Untertitel aktiviert",
"theme": "Design",
"theme_dark": "Dunkel",
"theme_medium": "Mittel",
"theme_light": "Hell",
"views": "Ansichten & Design",
"series_view": "Serien-Ansicht",
"movies_view": "Film-Ansicht",
"view_grid": "Raster",
"view_list": "Liste",
"view_detail": "Detail",
"view_folder": "Ordner",
"autoplay": "Automatische Wiedergabe",
"autoplay_enabled": "Nächste Episode automatisch abspielen",
"autoplay_countdown": "Countdown-Dauer",
"autoplay_max": "Max. Folgen am Stück",
"autoplay_max_desc": "0 = unbegrenzt",
"seconds": "Sekunden",
"save": "Speichern",
"saved": "Gespeichert!",
"reset_all": "Alle Fortschritte zurücksetzen",
"reset_confirm": "Wirklich ALLE Fortschritte und Status zurücksetzen? Das kann nicht rückgängig gemacht werden!",
"clear_search": "Suchverlauf löschen",
"device_name": "Gerätename",
"sound_mode": "Sound-Modus",
"sound_stereo": "Stereo",
"sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original",
"stream_quality": "Standard-Qualität",
"audio_compressor": "Audio-Kompression (gleicht Lautstärke-Schwankungen aus)",
"on": "An",
"off": "Aus"
},
"profiles": {
"title": "Wer schaut?",
"switch": "Profil wechseln",
"add_user": "Anderer Benutzer",
"manage": "Profile verwalten"
},
"login": {
"title": "VideoKonverter",
"subtitle": "TV-App",
"username": "Benutzername",
"password": "Passwort",
"login": "Anmelden",
"remember": "Angemeldet bleiben",
"error": "Benutzername oder Passwort falsch."
},
"rating": {
"title": "Bewertung",
"your_rating": "Deine Bewertung",
"avg_rating": "Durchschnitt",
"tvdb_score": "TVDB-Score",
"rate": "Bewerten",
"remove": "Bewertung entfernen",
"stars": "{n} Sterne",
"ratings": "{n} Bewertungen",
"no_ratings": "Noch keine Bewertungen",
"filter_min": "Ab {n} Sterne",
"sort_rating": "Bewertung"
},
"filter": {
"all": "Alle",
"sort": "Sortierung",
"sort_title": "Name (A-Z)",
"sort_title_desc": "Name (Z-A)",
"sort_newest": "Neueste zuerst",
"sort_episodes": "Episoden-Anzahl",
"sort_last_watched": "Zuletzt angesehen",
"sort_rating": "Bewertung",
"all_genres": "Alle Genres",
"genres": "Genres",
"min_rating": "Min. Sterne"
},
"common": {
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schließen",
"loading": "Laden...",
"error": "Fehler",
"no_connection": "Keine Verbindung zum Server.",
"unknown": "Unbekannt"
},
"lang": {
"deu": "Deutsch",
"eng": "Englisch",
"fra": "Französisch",
"spa": "Spanisch",
"ita": "Italienisch",
"jpn": "Japanisch",
"kor": "Koreanisch",
"por": "Portugiesisch",
"rus": "Russisch",
"zho": "Chinesisch",
"und": "Unbekannt"
}
}

View file

@ -0,0 +1,201 @@
{
"nav": {
"home": "Home",
"series": "Series",
"movies": "Movies",
"search": "Search",
"watchlist": "Watchlist",
"settings": "Settings",
"profiles": "Profiles",
"logout": "Logout"
},
"home": {
"continue_watching": "Continue Watching",
"my_series": "My Series",
"my_movies": "My Movies",
"recently_added": "Recently Added",
"watchlist": "My Watchlist"
},
"series": {
"title": "Series",
"all": "All",
"episodes": "Episodes",
"season": "Season",
"specials": "Specials",
"no_episodes": "No episodes available.",
"no_series": "No series available.",
"episode_short": "E",
"min": "min",
"watchlist": "Watchlist",
"duplicate": "Duplicate"
},
"movies": {
"title": "Movies",
"all": "All",
"no_movies": "No movies available.",
"versions": "Versions",
"version": "Version"
},
"player": {
"back": "Back",
"play": "Play",
"pause": "Pause",
"fullscreen": "Fullscreen",
"next_episode": "Next Episode",
"next_in": "Next episode in {seconds}s",
"skip": "Play Now",
"cancel": "Cancel",
"still_watching": "Are you still watching?",
"continue": "Continue",
"stop": "Stop",
"audio": "Audio",
"subtitles": "Subtitles",
"subtitles_off": "Off",
"quality": "Quality",
"quality_uhd": "Ultra HD",
"quality_hd": "HD",
"quality_sd": "SD",
"quality_low": "Low",
"speed": "Speed",
"settings": "Settings"
},
"search": {
"title": "Search",
"placeholder": "Search series or movies...",
"button": "Search",
"no_results": "No results for \"{query}\".",
"min_chars": "Enter at least 2 characters.",
"history": "Recent Searches",
"clear_history": "Clear History",
"results_series": "Series",
"results_movies": "Movies"
},
"watchlist": {
"title": "Watchlist",
"empty": "Your watchlist is empty.",
"add": "Add to Watchlist",
"remove": "Remove from Watchlist",
"added": "Added",
"series": "Series",
"movies": "Movies"
},
"status": {
"unwatched": "Unwatched",
"watching": "Watching",
"watched": "Watched",
"mark_watched": "Mark as watched",
"mark_unwatched": "Mark as unwatched",
"mark_season": "Mark season as watched",
"mark_series": "Mark series as watched",
"reset_progress": "Reset progress"
},
"settings": {
"title": "Settings",
"user_settings": "User Settings",
"client_settings": "Device Settings",
"profile": "Profile",
"display_name": "Display Name",
"avatar_color": "Profile Color",
"language": "Language",
"menu_language": "Menu Language",
"audio_language": "Audio Language",
"subtitle_language": "Subtitle Language",
"subtitles_enabled": "Subtitles enabled",
"theme": "Theme",
"theme_dark": "Dark",
"theme_medium": "Medium",
"theme_light": "Light",
"views": "Views & Theme",
"series_view": "Series View",
"movies_view": "Movies View",
"view_grid": "Grid",
"view_list": "List",
"view_detail": "Detail",
"view_folder": "Folders",
"autoplay": "Autoplay",
"autoplay_enabled": "Auto-play next episode",
"autoplay_countdown": "Countdown Duration",
"autoplay_max": "Max. consecutive episodes",
"autoplay_max_desc": "0 = unlimited",
"seconds": "seconds",
"save": "Save",
"saved": "Saved!",
"reset_all": "Reset All Progress",
"reset_confirm": "Really reset ALL progress and watch status? This cannot be undone!",
"clear_search": "Clear search history",
"device_name": "Device Name",
"sound_mode": "Sound Mode",
"sound_stereo": "Stereo",
"sound_surround": "Surround (5.1/7.1)",
"sound_original": "Original",
"stream_quality": "Default Quality",
"audio_compressor": "Audio Compression (levels out volume differences)",
"on": "On",
"off": "Off"
},
"profiles": {
"title": "Who's watching?",
"switch": "Switch Profile",
"add_user": "Other User",
"manage": "Manage Profiles"
},
"login": {
"title": "VideoKonverter",
"subtitle": "TV App",
"username": "Username",
"password": "Password",
"login": "Sign In",
"remember": "Keep me signed in",
"error": "Invalid username or password."
},
"rating": {
"title": "Rating",
"your_rating": "Your Rating",
"avg_rating": "Average",
"tvdb_score": "TVDB Score",
"rate": "Rate",
"remove": "Remove Rating",
"stars": "{n} Stars",
"ratings": "{n} Ratings",
"no_ratings": "No ratings yet",
"filter_min": "Min. {n} Stars",
"sort_rating": "Rating"
},
"filter": {
"all": "All",
"sort": "Sort",
"sort_title": "Name (A-Z)",
"sort_title_desc": "Name (Z-A)",
"sort_newest": "Newest First",
"sort_episodes": "Episode Count",
"sort_last_watched": "Last Watched",
"sort_rating": "Rating",
"all_genres": "All Genres",
"genres": "Genres",
"min_rating": "Min. Stars"
},
"common": {
"yes": "Yes",
"no": "No",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"loading": "Loading...",
"error": "Error",
"no_connection": "No connection to server.",
"unknown": "Unknown"
},
"lang": {
"deu": "German",
"eng": "English",
"fra": "French",
"spa": "Spanish",
"ita": "Italian",
"jpn": "Japanese",
"kor": "Korean",
"por": "Portuguese",
"rus": "Russian",
"zho": "Chinese",
"und": "Unknown"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -0,0 +1,340 @@
/**
* VideoKonverter TV - AVPlay Bridge v1.0
* Abstraktionsschicht fuer Samsung AVPlay API (Tizen WebApps).
* Ermoeglicht Direct-Play von MKV/WebM/MP4 mit Hardware-Decodern.
*
* AVPlay kann: H.264, HEVC, AV1, VP9, EAC3, AC3, AAC, Opus
* AVPlay kann NICHT: DTS (seit Samsung 2022 entfernt)
*
* Wird nur geladen wenn Tizen-Umgebung erkannt wird.
*/
const AVPlayBridge = {
available: false,
_playing: false,
_duration: 0,
_listener: null,
_displayEl: null, // <object> Element fuer AVPlay-Rendering
_timeUpdateId: null, // Interval fuer periodische Zeit-Updates
/**
* Initialisierung: Prueft ob AVPlay API verfuegbar ist
* @returns {boolean} true wenn AVPlay nutzbar
*/
init() {
try {
this.available = typeof webapis !== "undefined"
&& typeof webapis.avplay !== "undefined";
} catch (e) {
this.available = false;
}
if (this.available) {
console.info("[AVPlay] API verfuegbar");
}
return this.available;
},
/**
* Prueft ob ein Video direkt abgespielt werden kann (ohne Transcoding)
* @param {Object} videoInfo - Video-Infos vom Server
* @returns {boolean} true wenn Direct-Play moeglich
*/
canPlay(videoInfo) {
if (!this.available) return false;
// Video-Codec pruefen
const vc = (videoInfo.video_codec_normalized || "").toLowerCase();
const supportedVideo = ["h264", "hevc", "av1", "vp9"];
if (!supportedVideo.includes(vc)) {
console.info(`[AVPlay] Video-Codec '${vc}' nicht unterstuetzt`);
return false;
}
// Container pruefen
const container = (videoInfo.container || "").toLowerCase();
const supportedContainers = ["mkv", "matroska", "mp4", "webm", "avi", "ts"];
if (container && !supportedContainers.some(c => container.includes(c))) {
console.info(`[AVPlay] Container '${container}' nicht unterstuetzt`);
return false;
}
// Audio-Codecs pruefen - DTS ist nicht unterstuetzt
const audioCodecs = videoInfo.audio_codecs || [];
const unsupported = ["dts", "dca", "dts_hd", "dts-hd", "truehd"];
const hasUnsupported = audioCodecs.some(
ac => unsupported.includes(ac.toLowerCase())
);
if (hasUnsupported) {
console.info("[AVPlay] DTS-Audio erkannt -> kein Direct-Play");
return false;
}
console.info(`[AVPlay] Direct-Play moeglich: ${vc}/${container}`);
return true;
},
/**
* Video abspielen via AVPlay
* @param {string} url - Direct-Stream-URL
* @param {Object} opts - {seekMs, onTimeUpdate, onComplete, onError, onBuffering}
*/
play(url, opts = {}) {
if (!this.available) return false;
try {
// Vorherige Session bereinigen
this.stop();
// Display-Element setzen
this._displayEl = document.getElementById("avplayer");
if (this._displayEl) {
this._displayEl.style.display = "block";
}
// AVPlay oeffnen
webapis.avplay.open(url);
// Display-Bereich setzen (Vollbild)
webapis.avplay.setDisplayRect(
0, 0, window.innerWidth, window.innerHeight
);
// Event-Listener registrieren
this._listener = opts;
webapis.avplay.setListener({
onbufferingstart: () => {
console.debug("[AVPlay] Buffering gestartet");
if (this._listener && this._listener.onBuffering) {
this._listener.onBuffering(true);
}
},
onbufferingcomplete: () => {
console.debug("[AVPlay] Buffering abgeschlossen");
if (this._listener && this._listener.onBuffering) {
this._listener.onBuffering(false);
}
},
oncurrentplaytime: (ms) => {
// Wird von AVPlay periodisch aufgerufen
if (this._listener && this._listener.onTimeUpdate) {
this._listener.onTimeUpdate(ms);
}
},
onstreamcompleted: () => {
console.info("[AVPlay] Wiedergabe abgeschlossen");
this._playing = false;
if (this._listener && this._listener.onComplete) {
this._listener.onComplete();
}
},
onerror: (eventType) => {
console.error("[AVPlay] Fehler:", eventType);
this._playing = false;
if (this._listener && this._listener.onError) {
this._listener.onError(eventType);
}
},
onevent: (eventType, eventData) => {
console.debug("[AVPlay] Event:", eventType, eventData);
},
onsubtitlechange: (duration, text, dataSize, jsonData) => {
// Untertitel-Events (optional)
console.debug("[AVPlay] Subtitle:", text);
},
});
// Async vorbereiten und starten
webapis.avplay.prepareAsync(
() => {
// Erfolg: Wiedergabe starten
this._duration = webapis.avplay.getDuration();
console.info(`[AVPlay] Bereit, Dauer: ${this._duration}ms`);
// Seeking vor dem Start
if (opts.seekMs && opts.seekMs > 0) {
webapis.avplay.seekTo(opts.seekMs,
() => {
console.info(`[AVPlay] Seek zu ${opts.seekMs}ms`);
webapis.avplay.play();
this._playing = true;
this._startTimeUpdates();
},
(e) => {
console.warn("[AVPlay] Seek fehlgeschlagen:", e);
webapis.avplay.play();
this._playing = true;
this._startTimeUpdates();
}
);
} else {
webapis.avplay.play();
this._playing = true;
this._startTimeUpdates();
}
// Buffering-Ende signalisieren
if (this._listener && this._listener.onBuffering) {
this._listener.onBuffering(false);
}
if (this._listener && this._listener.onReady) {
this._listener.onReady();
}
},
(error) => {
console.error("[AVPlay] Prepare fehlgeschlagen:", error);
if (this._listener && this._listener.onError) {
this._listener.onError(error);
}
}
);
return true;
} catch (e) {
console.error("[AVPlay] Fehler beim Starten:", e);
if (this._listener && this._listener.onError) {
this._listener.onError(e.message || e);
}
return false;
}
},
/**
* Pause/Resume umschalten
*/
togglePlay() {
if (!this.available) return;
try {
const state = webapis.avplay.getState();
if (state === "PLAYING") {
webapis.avplay.pause();
this._playing = false;
} else if (state === "PAUSED" || state === "READY") {
webapis.avplay.play();
this._playing = true;
}
} catch (e) {
console.error("[AVPlay] togglePlay Fehler:", e);
}
},
pause() {
if (!this.available) return;
try {
if (this._playing) {
webapis.avplay.pause();
this._playing = false;
}
} catch (e) {
console.error("[AVPlay] pause Fehler:", e);
}
},
resume() {
if (!this.available) return;
try {
webapis.avplay.play();
this._playing = true;
} catch (e) {
console.error("[AVPlay] resume Fehler:", e);
}
},
/**
* Seeking zu Position in Millisekunden
* @param {number} positionMs - Zielposition in ms
* @param {function} onSuccess - Callback bei Erfolg
* @param {function} onError - Callback bei Fehler
*/
seek(positionMs, onSuccess, onError) {
if (!this.available) return;
try {
webapis.avplay.seekTo(
Math.max(0, Math.floor(positionMs)),
() => {
console.debug(`[AVPlay] Seek zu ${positionMs}ms`);
if (onSuccess) onSuccess();
},
(e) => {
console.warn("[AVPlay] Seek Fehler:", e);
if (onError) onError(e);
}
);
} catch (e) {
console.error("[AVPlay] seek Fehler:", e);
if (onError) onError(e);
}
},
/**
* Wiedergabe stoppen und AVPlay bereinigen
*/
stop() {
this._stopTimeUpdates();
this._playing = false;
try {
const state = webapis.avplay.getState();
if (state !== "IDLE" && state !== "NONE") {
webapis.avplay.stop();
}
webapis.avplay.close();
} catch (e) {
// Ignorieren wenn bereits gestoppt
}
if (this._displayEl) {
this._displayEl.style.display = "none";
}
},
/**
* Aktuelle Wiedergabeposition in Millisekunden
* @returns {number} Position in ms
*/
getCurrentTime() {
if (!this.available) return 0;
try {
return webapis.avplay.getCurrentTime();
} catch (e) {
return 0;
}
},
/**
* Gesamtdauer in Millisekunden
* @returns {number} Dauer in ms
*/
getDuration() {
if (!this.available) return 0;
try {
return this._duration || webapis.avplay.getDuration();
} catch (e) {
return 0;
}
},
/**
* Prueft ob gerade abgespielt wird
* @returns {boolean}
*/
isPlaying() {
return this._playing;
},
/**
* Periodische Zeit-Updates starten (fuer Progress-Bar)
*/
_startTimeUpdates() {
this._stopTimeUpdates();
this._timeUpdateId = setInterval(() => {
if (this._playing && this._listener && this._listener.onTimeUpdate) {
this._listener.onTimeUpdate(this.getCurrentTime());
}
}, 500);
},
_stopTimeUpdates() {
if (this._timeUpdateId) {
clearInterval(this._timeUpdateId);
this._timeUpdateId = null;
}
},
};

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,528 @@
/**
* 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;
// Merkt sich das letzte fokussierte Element im Content-Bereich
this._lastContentFocus = null;
// SELECT-Editier-Modus: erst Enter druecken, dann Hoch/Runter aendert Werte
this._selectActive = false;
// INPUT/TEXTAREA Editier-Modus: erst Enter druecken, dann tippen
this._inputActive = false;
// Tastatur-Events abfangen
document.addEventListener("keydown", (e) => this._onKeyDown(e));
// Focus-Tracking: merken wo wir zuletzt waren
document.addEventListener("focusin", (e) => {
// SELECT-Editier-Modus beenden wenn Focus sich aendert
if (this._selectActive && e.target && e.target.tagName !== "SELECT") {
this._selectActive = false;
document.querySelectorAll(".select-editing").forEach(
el => el.classList.remove("select-editing"));
}
// INPUT-Editier-Modus beenden wenn Focus sich aendert
if (this._inputActive && e.target &&
e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
this._inputActive = false;
document.querySelectorAll(".input-editing").forEach(
el => el.classList.remove("input-editing"));
}
if (e.target && e.target.hasAttribute && e.target.hasAttribute("data-focusable")) {
if (!e.target.closest("#tv-nav") && !e.target.closest(".tv-alpha-sidebar")) {
// Nur echte Content-Elemente merken (nicht Nav/Sidebar)
if (e.target.closest(".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid")) {
this._lastContentFocus = e.target;
}
}
}
});
// 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;
}
// Erstes Element im sichtbaren Content-Bereich (Karten bevorzugen)
const contentAreas = document.querySelectorAll(
".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid"
);
for (const area of contentAreas) {
if (!area.offsetHeight) continue;
const firstEl = area.querySelector("[data-focusable]");
if (firstEl) {
firstEl.focus();
return;
}
}
// Fallback: erstes Content-Element
const contentFirst = document.querySelector(".tv-main [data-focusable]");
if (contentFirst) {
contentFirst.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: Nur im Editier-Modus Cursor-Navigation erlauben
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (active.type === "checkbox") {
// Checkbox: normal navigieren
} else if (this._inputActive) {
// Editier-Modus: Links/Rechts fuer Cursor, Hoch/Runter navigiert weg
if (direction === "ArrowLeft" || direction === "ArrowRight") return;
}
// Nicht aktiv: alle Richtungen navigieren weiter
}
// Select-Elemente: Nur wenn aktiviert (Enter gedrueckt) Hoch/Runter aendert Wert
if (active && active.tagName === "SELECT") {
if (this._selectActive) {
// Editier-Modus: Wert manuell aendern (synthetische Events aendern SELECT nicht)
if (direction === "ArrowUp" || direction === "ArrowDown") {
const idx = active.selectedIndex;
if (direction === "ArrowDown" && idx < active.options.length - 1) {
active.selectedIndex = idx + 1;
} else if (direction === "ArrowUp" && idx > 0) {
active.selectedIndex = idx - 1;
}
e.preventDefault();
return;
}
} else {
// Nicht im Editier-Modus: Navigation statt Wert-Aenderung
if (direction === "ArrowUp" || direction === "ArrowDown") {
e.preventDefault();
}
}
}
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;
}
// Navigation innerhalb der Nav-Bar: Links/Rechts = sequentiell
const inNav = active.closest("#tv-nav");
if (inNav && (direction === "ArrowLeft" || direction === "ArrowRight")) {
// Alle Nav-Elemente sequentiell (links + rechts zusammen)
const navEls = focusables.filter(el => el.closest("#tv-nav"));
const navIdx = navEls.indexOf(active);
if (navIdx !== -1) {
const nextIdx = direction === "ArrowRight" ? navIdx + 1 : navIdx - 1;
if (nextIdx >= 0 && nextIdx < navEls.length) {
navEls[nextIdx].focus();
e.preventDefault();
return;
}
}
}
// Von Nav nach unten -> direkt zu Content-Karten (Filter/View-Switch ueberspringen)
if (inNav && direction === "ArrowDown") {
// Gespeicherten Content-Focus bevorzugen (nur wenn noch sichtbar)
if (this._lastContentFocus && document.contains(this._lastContentFocus)
&& this._lastContentFocus.offsetHeight > 0) {
this._lastContentFocus.focus();
this._lastContentFocus.scrollIntoView({ block: "nearest", behavior: "smooth" });
e.preventDefault();
return;
}
// Direkt zum sichtbaren Content-Bereich (Karten/Listen-Eintraege)
const contentAreas = document.querySelectorAll(
".tv-grid, .tv-list-compact, .tv-detail-list, .tv-folder-view, .tv-row, .tv-episode-list, .tv-episode-grid, .tv-tabs, .tv-detail-actions, .tv-alpha-sidebar, .tv-view-switch, .tv-filter-bar, .tv-season-actions, .profiles-grid"
);
for (const area of contentAreas) {
if (!area.offsetHeight) continue;
const firstEl = area.querySelector("[data-focusable]");
if (firstEl) {
firstEl.focus();
firstEl.scrollIntoView({ block: "nearest", behavior: "smooth" });
e.preventDefault();
return;
}
}
// Fallback: erstes Content-Element
const contentFirst = document.querySelector(".tv-main [data-focusable]");
if (contentFirst) {
contentFirst.focus();
contentFirst.scrollIntoView({ block: "nearest", behavior: "smooth" });
e.preventDefault();
return;
}
}
// Vom Content nach oben zur Nav springen wenn am oberen Rand
if (!inNav && direction === "ArrowUp") {
const current = active.getBoundingClientRect();
// Nur wenn Element nah am oberen Rand ist (< 200px vom Viewport-Top)
if (current.top < 200) {
// Pruefen ob es noch ein Element darueber im Content gibt
const contentEls = focusables.filter(el => !el.closest("#tv-nav"));
const above = contentEls.filter(el => {
const r = el.getBoundingClientRect();
return r.top + r.height / 2 < current.top - 5;
});
if (above.length === 0) {
// Kein Element darueber -> zur Nav springen
const activeNavItem = document.querySelector(".tv-nav-item.active");
if (activeNavItem) {
activeNavItem.focus();
e.preventDefault();
return;
}
}
}
}
// ===== Alphabet-Sidebar Navigation =====
const inSidebar = active.closest(".tv-alpha-sidebar");
if (inSidebar) {
if (direction === "ArrowLeft") {
// Zurueck zum Content
if (this._lastContentFocus && document.contains(this._lastContentFocus)) {
this._lastContentFocus.focus();
} else {
const firstCard = document.querySelector(".tv-grid [data-focusable], .tv-card[data-focusable]");
if (firstCard) firstCard.focus();
}
e.preventDefault();
return;
}
if (direction === "ArrowUp" || direction === "ArrowDown") {
// Sequentiell durch Buchstaben
const letters = Array.from(document.querySelectorAll(".tv-alpha-letter[data-focusable]"))
.filter(el => el.offsetHeight > 0);
const idx = letters.indexOf(active);
const next = direction === "ArrowDown" ? idx + 1 : idx - 1;
if (next >= 0 && next < letters.length) {
letters[next].focus();
letters[next].scrollIntoView({ block: "nearest", behavior: "smooth" });
}
e.preventDefault();
return;
}
}
// ArrowRight am rechten Grid-Rand -> Sidebar
if (!inNav && !inSidebar && direction === "ArrowRight") {
const sidebar = document.getElementById("alpha-sidebar");
if (sidebar && sidebar.offsetHeight > 0) {
// Pruefen ob es noch ein Element rechts im Content gibt
const contentEls = focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
const currentRect_r = active.getBoundingClientRect();
const rightNeighbor = contentEls.some(el => {
const r = el.getBoundingClientRect();
return r.left > currentRect_r.right + 5 && Math.abs(r.top - currentRect_r.top) < 100;
});
if (!rightNeighbor) {
// Kein Content rechts -> zur Sidebar springen
const sidebarLetters = Array.from(sidebar.querySelectorAll("[data-focusable]"))
.filter(el => el.offsetHeight > 0);
if (sidebarLetters.length > 0) {
// Naechstgelegenen Buchstaben vertikal finden
const cy_r = currentRect_r.top + currentRect_r.height / 2;
let best = sidebarLetters[0];
let bestDist_r = Infinity;
sidebarLetters.forEach(l => {
const lr = l.getBoundingClientRect();
const d = Math.abs(lr.top + lr.height / 2 - cy_r);
if (d < bestDist_r) { bestDist_r = d; best = l; }
});
this._lastContentFocus = active;
best.focus();
e.preventDefault();
return;
}
}
}
}
// Naechstes Element in Richtung finden (Nearest-Neighbor)
const currentRect = active.getBoundingClientRect();
const cx = currentRect.left + currentRect.width / 2;
const cy = currentRect.top + currentRect.height / 2;
let bestEl = null;
let bestDist = Infinity;
// Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen
const searchEls = inNav
? focusables.filter(el => el.closest("#tv-nav"))
: focusables.filter(el => !el.closest("#tv-nav") && !el.closest(".tv-alpha-sidebar"));
for (const el of searchEls) {
if (el === active) continue;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
const ex = rect.left + rect.width / 2;
const ey = rect.top + rect.height / 2;
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;
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();
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 (natuerliches Enter-Verhalten)
if (active.tagName === "A" || active.tagName === "BUTTON") {
return;
}
// Select: Enter aktiviert/deaktiviert den Editier-Modus
if (active.tagName === "SELECT") {
if (this._selectActive) {
// Wert bestaetigen, Editier-Modus beenden
this._selectActive = false;
active.classList.remove("select-editing");
// onchange ausloesen falls sich Wert geaendert hat
active.dispatchEvent(new Event("change", { bubbles: true }));
} else {
// Editier-Modus starten
this._selectActive = true;
active.classList.add("select-editing");
}
e.preventDefault();
return;
}
// Input/Textarea: Enter aktiviert/deaktiviert Editier-Modus
if ((active.tagName === "INPUT" && active.type !== "checkbox") ||
active.tagName === "TEXTAREA") {
if (this._inputActive) {
// Editier-Modus beenden
this._inputActive = false;
active.classList.remove("input-editing");
} else {
// Editier-Modus starten
this._inputActive = true;
active.classList.add("input-editing");
}
e.preventDefault();
return;
}
// Checkbox: Toggle
if (active.tagName === "INPUT" && active.type === "checkbox") {
active.checked = !active.checked;
active.dispatchEvent(new Event("change", { bubbles: true }));
e.preventDefault();
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 = Editier-Modus beenden oder Blur
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
if (this._inputActive) {
// Editier-Modus beenden, Focus bleibt
this._inputActive = false;
active.classList.remove("input-editing");
} else {
// Nicht im Editier-Modus: Focus verlassen
active.blur();
}
e.preventDefault();
return;
}
// In Select-Feldern: Escape = Editier-Modus beenden oder Blur
if (active && active.tagName === "SELECT") {
if (this._selectActive) {
// Editier-Modus beenden (Wert nicht uebernehmen)
this._selectActive = false;
active.classList.remove("select-editing");
} else {
// Nicht im Editier-Modus -> Focus verlassen
active.blur();
}
e.preventDefault();
return;
}
// Wenn Focus in der Nav: nicht zurueck navigieren
if (active && active.closest && active.closest("#tv-nav")) {
// Focus zurueck zum Content verschieben
if (this._lastContentFocus && document.contains(this._lastContentFocus)) {
this._lastContentFocus.focus();
}
e.preventDefault();
return;
}
// Wenn ein Player-Overlay offen ist, zuerst das schliessen
const overlay = document.querySelector(".player-overlay.visible, .player-next-overlay.visible");
if (overlay) {
overlay.classList.remove("visible");
e.preventDefault();
return;
}
// Zurueck navigieren
if (window.history.length > 1) {
window.history.back();
e.preventDefault();
}
}
_getFocusableElements() {
const elements = document.querySelectorAll("[data-focusable]");
return Array.from(elements).filter(el => {
if (el.offsetParent === null && el.style.position !== "fixed") return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
});
}
}
// === Horizontale Scroll-Reihen: Scroll per Pfeiltaste ===
function initRowScroll() {
document.querySelectorAll(".tv-row").forEach(row => {
// Maus-Rad horizontal scrollen
row.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
e.preventDefault();
row.scrollLeft += e.deltaY;
}
}, { passive: false });
});
}
// === Lazy-Loading fuer Poster (IntersectionObserver) ===
function initLazyLoad() {
// Browser-natives loading="lazy" wird bereits verwendet
// Zusaetzlich: Placeholder-Klasse entfernen nach Laden
document.querySelectorAll("img.tv-card-img").forEach(img => {
if (img.complete) return;
img.style.opacity = "0";
img.style.transition = "opacity 0.3s";
img.addEventListener("load", () => {
img.style.opacity = "1";
}, { once: true });
img.addEventListener("error", () => {
// Fehlerhaftes Bild: Placeholder anzeigen
img.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "tv-card-placeholder";
placeholder.textContent = img.alt || "?";
img.parentNode.insertBefore(placeholder, img);
}, { once: true });
});
}
// === Navigation: Aktiven Tab highlighten ===
function initNavHighlight() {
const path = window.location.pathname;
document.querySelectorAll(".tv-nav-item").forEach(item => {
const href = item.getAttribute("href");
if (href === path || (href !== "/tv/" && path.startsWith(href))) {
item.classList.add("active");
}
});
}
// === Init ===
document.addEventListener("DOMContentLoaded", () => {
window.focusManager = new FocusManager();
initRowScroll();
initLazyLoad();
initNavHighlight();
});

View file

@ -0,0 +1,567 @@
/**
* VideoKonverter TV - VKNative Bridge v2.0
* Einheitliches Interface fuer native Video-Player auf allen Plattformen.
*
* Modus 1 - Tizen iframe (postMessage):
* Laeuft im iframe auf Tizen-TV. Kommuniziert per postMessage mit dem
* Parent-Frame (tizen-app/index.html), der AVPlay steuert.
* webapis.avplay ist im iframe NICHT verfuegbar.
*
* Modus 2 - Tizen direkt (legacy, falls webapis doch verfuegbar):
* Direkter Zugriff auf webapis.avplay (Fallback).
*
* Android: window.VKNative wird von der Kotlin-App per @JavascriptInterface injiziert.
* Diese Bridge erkennt das und ueberspringt sich selbst.
*
* Interface: window.VKNative
* Callbacks: window._vkOnReady, _vkOnTimeUpdate, _vkOnComplete,
* _vkOnError, _vkOnBuffering, _vkOnPlayStateChanged
*/
(function() {
"use strict";
// Bereits von Android injiziert? Dann nichts tun
if (window.VKNative) {
console.info("[VKNative] Bridge bereits vorhanden (platform: " + window.VKNative.platform + ")");
return;
}
// Tizen-Erkennung (User-Agent, auch im iframe gueltig)
var isTizen = /Tizen/i.test(navigator.userAgent);
if (!isTizen) {
// Kein Tizen -> Bridge nicht noetig (Desktop/Handy nutzt HLS)
return;
}
// Im iframe? (Parent-Frame hat AVPlay)
var inIframe = (window.parent !== window);
// AVPlay direkt verfuegbar? (nur im Parent-Frame oder bei altem Redirect-Ansatz)
var avplayDirect = false;
try {
avplayDirect = typeof webapis !== "undefined" && typeof webapis.avplay !== "undefined";
} catch (e) {
avplayDirect = false;
}
// === MODUS 1: iframe + postMessage ===
if (inIframe && !avplayDirect) {
console.info("[VKNative] Tizen iframe-Modus: postMessage Bridge wird initialisiert");
var _parentReady = false;
var _videoCodecs = ["h264", "hevc", "av1", "vp9"];
var _audioCodecs = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"];
var _playing = false;
var _currentTimeMs = 0;
var _durationMs = 0;
// Unterstuetzte/Nicht-unterstuetzte Codecs (fuer canDirectPlay)
var UNSUPPORTED_AUDIO = ["dts", "dca", "dts_hd", "dts-hd", "truehd"];
var SUPPORTED_CONTAINERS = ["mkv", "matroska", "mp4", "webm", "avi", "ts"];
// Events vom Parent empfangen
window.addEventListener("message", function(event) {
// Nur Nachrichten vom Parent akzeptieren
if (event.source !== window.parent) return;
var data = event.data;
if (!data || !data.type) return;
switch (data.type) {
case "vknative_ready":
// Parent bestaetigt: AVPlay ist verfuegbar
_parentReady = true;
if (data.videoCodecs) _videoCodecs = data.videoCodecs;
if (data.audioCodecs) _audioCodecs = data.audioCodecs;
console.info("[VKNative] Parent bereit, Codecs: " +
_videoCodecs.join(",") + " / " + _audioCodecs.join(","));
break;
case "vknative_event":
_handleParentEvent(data.event, data.detail || {});
break;
case "vknative_stats":
// AVPlay-Stats vom Parent (Antwort auf requestStats)
if (data.stats) window.VKNative._lastStats = data.stats;
break;
case "vknative_keyevent":
// Key-Event vom Parent weitergeleitet -> als KeyboardEvent dispatchen
if (data.keyCode) {
// keyCode -> key-Name Mapping (KeyboardEvent setzt key nicht automatisch)
var keyNameMap = {
13: "Enter", 37: "ArrowLeft", 38: "ArrowUp",
39: "ArrowRight", 40: "ArrowDown", 27: "Escape",
8: "Backspace", 32: " ",
// Samsung-spezifische keyCodes (Media + Farbtasten)
10009: "Escape", 10182: "Escape",
415: "Play", 19: "Pause", 413: "Stop",
417: "FastForward", 412: "Rewind", 10252: "Play",
403: "ColorRed", 404: "ColorGreen",
405: "ColorYellow", 406: "ColorBlue",
};
var keyEvt = new KeyboardEvent("keydown", {
keyCode: data.keyCode,
which: data.keyCode,
key: keyNameMap[data.keyCode] || "",
bubbles: true,
});
document.dispatchEvent(keyEvt);
}
break;
}
});
// Events vom Parent an die Callbacks weiterleiten
function _handleParentEvent(event, detail) {
switch (event) {
case "ready":
_playing = true;
if (window._vkOnReady) window._vkOnReady();
break;
case "timeupdate":
_currentTimeMs = detail.ms || 0;
if (window._vkOnTimeUpdate) window._vkOnTimeUpdate(_currentTimeMs);
break;
case "complete":
_playing = false;
if (window._vkOnComplete) window._vkOnComplete();
break;
case "error":
_playing = false;
if (window._vkOnError) window._vkOnError(detail.msg || "Unbekannter Fehler");
break;
case "buffering":
if (window._vkOnBuffering) window._vkOnBuffering(detail.buffering);
break;
case "playstatechanged":
_playing = !!detail.playing;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(_playing);
break;
case "stopped":
_playing = false;
_currentTimeMs = 0;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
// Zurueck-Navigation ausloesen (Return-Taste auf Fernbedienung)
if (window._vkOnStopped) window._vkOnStopped();
break;
case "duration":
_durationMs = detail.ms || 0;
break;
}
}
// Nachricht an Parent senden
function _callParent(method, args) {
window.parent.postMessage({
type: "vknative_call",
method: method,
args: args || [],
}, "*");
}
// Probe senden: "Bist du bereit?"
function _probeParent() {
window.parent.postMessage({ type: "vknative_probe" }, "*");
}
// === VKNative Interface (postMessage-Modus) ===
window.VKNative = {
platform: "tizen",
version: "2.0.0",
getSupportedVideoCodecs: function() {
return _videoCodecs.slice();
},
getSupportedAudioCodecs: function() {
return _audioCodecs.slice();
},
canDirectPlay: function(videoInfo) {
// Video-Codec pruefen
var vc = (videoInfo.video_codec_normalized || "").toLowerCase();
if (_videoCodecs.indexOf(vc) === -1) {
console.info("[VKNative] Video-Codec '" + vc + "' nicht unterstuetzt");
return false;
}
// Container pruefen
var container = (videoInfo.container || "").toLowerCase();
if (container) {
var containerOk = false;
for (var i = 0; i < SUPPORTED_CONTAINERS.length; i++) {
if (container.indexOf(SUPPORTED_CONTAINERS[i]) !== -1) {
containerOk = true;
break;
}
}
if (!containerOk) {
console.info("[VKNative] Container '" + container + "' nicht unterstuetzt");
return false;
}
}
// Audio-Codecs pruefen - DTS/TrueHD blockieren
var audioCodecs = videoInfo.audio_codecs || [];
for (var j = 0; j < audioCodecs.length; j++) {
var ac = audioCodecs[j].toLowerCase();
if (UNSUPPORTED_AUDIO.indexOf(ac) !== -1) {
console.info("[VKNative] Audio-Codec '" + ac + "' nicht unterstuetzt -> kein Direct-Play");
return false;
}
}
// Tizen AVPlay: Opus mit >2 Kanaelen hat Tonausfaelle -> HLS Fallback
var audioTracks = videoInfo.audio_tracks || [];
for (var k = 0; k < audioTracks.length; k++) {
var track = audioTracks[k];
var trackCodec = (track.codec || "").toLowerCase();
var trackChannels = track.channels || 2;
if (trackCodec === "opus" && trackChannels > 2) {
console.info("[VKNative] Opus " + trackChannels + "ch auf Tizen -> HLS Fallback (Tonausfaelle bei AVPlay)");
return false;
}
}
console.info("[VKNative] Direct-Play moeglich: " + vc + "/" + container);
return true;
},
play: function(url, videoInfo, opts) {
console.info("[VKNative] play() per postMessage: " + url);
_callParent("play", [url, videoInfo, opts]);
return true;
},
togglePlay: function() {
_callParent("togglePlay");
},
pause: function() {
_callParent("pause");
},
resume: function() {
_callParent("resume");
},
seek: function(positionMs) {
_callParent("seek", [positionMs]);
},
getCurrentTime: function() {
// Letzte bekannte Position (wird per timeupdate-Event aktualisiert)
return _currentTimeMs;
},
getDuration: function() {
return _durationMs;
},
isPlaying: function() {
return _playing;
},
stop: function() {
_playing = false;
_currentTimeMs = 0;
_callParent("stop");
},
/**
* HLS-Stream ueber AVPlay abspielen (Fallback fuer Opus-Surround etc.)
* AVPlay spielt HLS nativ inkl. AAC 5.1 Surround.
*/
playHLS: function(playlistUrl, opts) {
console.info("[VKNative] playHLS() per postMessage: " + playlistUrl);
_callParent("playHLS", [playlistUrl, opts]);
return true;
},
setAudioTrack: function(index) {
console.info("[VKNative] Audio-Track-Wechsel auf Tizen nicht moeglich");
return false;
},
setSubtitleTrack: function(index) {
return false;
},
setPlaybackSpeed: function(speed) {
_callParent("setPlaybackSpeed", [speed]);
return true;
},
/** AVPlay-Stats vom Parent abfragen (async, Ergebnis in _lastStats) */
requestStats: function() {
window.parent.postMessage({ type: "vknative_get_stats" }, "*");
},
_lastStats: null,
};
// Parent proben (wiederholt, falls Parent noch nicht bereit)
_probeParent();
var _probeRetries = 0;
var _probeInterval = setInterval(function() {
if (_parentReady || _probeRetries > 20) {
clearInterval(_probeInterval);
if (!_parentReady) {
console.warn("[VKNative] Parent hat nach 10s nicht geantwortet");
}
return;
}
_probeRetries++;
_probeParent();
}, 500);
console.info("[VKNative] Tizen postMessage Bridge bereit");
return;
}
// === MODUS 2: Direkter AVPlay-Zugriff (Legacy-Fallback) ===
if (avplayDirect) {
console.info("[VKNative] Tizen Direct-AVPlay Bridge wird initialisiert");
var _playing2 = false;
var _duration2 = 0;
var _displayEl2 = null;
var _timeUpdateId2 = null;
var SUPPORTED_VIDEO = ["h264", "hevc", "av1", "vp9"];
var SUPPORTED_AUDIO = ["aac", "opus", "ac3", "eac3", "flac", "mp3", "vorbis", "pcm"];
var UNSUPPORTED_AUDIO2 = ["dts", "dca", "dts_hd", "dts-hd", "truehd"];
var SUPPORTED_CONTAINERS2 = ["mkv", "matroska", "mp4", "webm", "avi", "ts"];
function _startTimeUpdates2() {
_stopTimeUpdates2();
_timeUpdateId2 = setInterval(function() {
if (_playing2 && window._vkOnTimeUpdate) {
try {
window._vkOnTimeUpdate(webapis.avplay.getCurrentTime());
} catch (e) {}
}
}, 500);
}
function _stopTimeUpdates2() {
if (_timeUpdateId2) {
clearInterval(_timeUpdateId2);
_timeUpdateId2 = null;
}
}
function _resolveUrl2(url) {
if (url.indexOf("://") !== -1) return url;
return window.location.origin + url;
}
window.VKNative = {
platform: "tizen",
version: "2.0.0",
getSupportedVideoCodecs: function() { return SUPPORTED_VIDEO.slice(); },
getSupportedAudioCodecs: function() { return SUPPORTED_AUDIO.slice(); },
canDirectPlay: function(videoInfo) {
var vc = (videoInfo.video_codec_normalized || "").toLowerCase();
if (SUPPORTED_VIDEO.indexOf(vc) === -1) return false;
var container = (videoInfo.container || "").toLowerCase();
if (container) {
var ok = false;
for (var i = 0; i < SUPPORTED_CONTAINERS2.length; i++) {
if (container.indexOf(SUPPORTED_CONTAINERS2[i]) !== -1) { ok = true; break; }
}
if (!ok) return false;
}
var ac2 = videoInfo.audio_codecs || [];
for (var j = 0; j < ac2.length; j++) {
if (UNSUPPORTED_AUDIO2.indexOf(ac2[j].toLowerCase()) !== -1) return false;
}
// Tizen AVPlay: Opus mit >2 Kanaelen hat Tonausfaelle -> HLS Fallback
var audioTracks = videoInfo.audio_tracks || [];
for (var k = 0; k < audioTracks.length; k++) {
var trk = audioTracks[k];
var trkCodec = (trk.codec || "").toLowerCase();
var trkCh = trk.channels || 2;
if (trkCodec === "opus" && trkCh > 2) {
console.info("[VKNative] Opus " + trkCh + "ch auf Tizen -> HLS Fallback");
return false;
}
}
return true;
},
play: function(url, videoInfo, opts) {
opts = opts || {};
var seekMs = opts.seekMs || 0;
var fullUrl = _resolveUrl2(url);
try {
this.stop();
_displayEl2 = document.getElementById("avplayer");
if (_displayEl2) _displayEl2.style.display = "block";
var videoEl = document.getElementById("player-video");
if (videoEl) videoEl.style.display = "none";
webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
webapis.avplay.setListener({
onbufferingstart: function() { if (window._vkOnBuffering) window._vkOnBuffering(true); },
onbufferingcomplete: function() { if (window._vkOnBuffering) window._vkOnBuffering(false); },
oncurrentplaytime: function(ms) { if (window._vkOnTimeUpdate) window._vkOnTimeUpdate(ms); },
onstreamcompleted: function() {
_playing2 = false;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
if (window._vkOnComplete) window._vkOnComplete();
},
onerror: function(evt) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(String(evt));
},
onevent: function() {},
onsubtitlechange: function() {},
});
function _start() {
try {
webapis.avplay.play();
_playing2 = true;
_startTimeUpdates2();
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true);
if (window._vkOnReady) window._vkOnReady();
} catch (e) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(e.message || String(e));
}
}
webapis.avplay.prepareAsync(
function() {
try { _duration2 = webapis.avplay.getDuration(); } catch (e) { _duration2 = 0; }
if (seekMs > 0) {
try {
webapis.avplay.seekTo(seekMs, function() { _start(); }, function() { _start(); });
} catch (e) { _start(); }
} else {
_start();
}
},
function(err) { if (window._vkOnError) window._vkOnError(String(err)); }
);
return true;
} catch (e) {
if (window._vkOnError) window._vkOnError(e.message || String(e));
return false;
}
},
togglePlay: function() {
try {
var state = webapis.avplay.getState();
if (state === "PLAYING") {
webapis.avplay.pause(); _playing2 = false;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
} else if (state === "PAUSED" || state === "READY") {
webapis.avplay.play(); _playing2 = true;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true);
}
} catch (e) {}
},
pause: function() {
try { if (_playing2) { webapis.avplay.pause(); _playing2 = false; if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false); } } catch (e) {}
},
resume: function() {
try { webapis.avplay.play(); _playing2 = true; if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true); } catch (e) {}
},
seek: function(ms) {
try { webapis.avplay.seekTo(Math.max(0, Math.floor(ms)), function(){}, function(){}); } catch (e) {}
},
getCurrentTime: function() { try { return webapis.avplay.getCurrentTime(); } catch (e) { return 0; } },
getDuration: function() { try { return _duration2 || webapis.avplay.getDuration(); } catch (e) { return 0; } },
isPlaying: function() { return _playing2; },
stop: function() {
_stopTimeUpdates2(); _playing2 = false;
try { var s = webapis.avplay.getState(); if (s !== "IDLE" && s !== "NONE") webapis.avplay.stop(); webapis.avplay.close(); } catch (e) {}
if (_displayEl2) { _displayEl2.style.display = "none"; _displayEl2 = null; }
var v = document.getElementById("player-video"); if (v) v.style.display = "";
},
/** HLS ueber AVPlay abspielen (Fallback fuer Opus-Surround) */
playHLS: function(playlistUrl, opts) {
opts = opts || {};
var seekMs = opts.seekMs || 0;
var fullUrl = _resolveUrl2(playlistUrl);
try {
this.stop();
_displayEl2 = document.getElementById("avplayer");
if (_displayEl2) _displayEl2.style.display = "block";
var videoEl = document.getElementById("player-video");
if (videoEl) videoEl.style.display = "none";
webapis.avplay.open(fullUrl);
webapis.avplay.setDisplayRect(0, 0, window.innerWidth, window.innerHeight);
webapis.avplay.setListener({
onbufferingstart: function() { if (window._vkOnBuffering) window._vkOnBuffering(true); },
onbufferingcomplete: function() { if (window._vkOnBuffering) window._vkOnBuffering(false); },
oncurrentplaytime: function(ms) { if (window._vkOnTimeUpdate) window._vkOnTimeUpdate(ms); },
onstreamcompleted: function() {
_playing2 = false;
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(false);
if (window._vkOnComplete) window._vkOnComplete();
},
onerror: function(evt) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(String(evt));
},
onevent: function() {},
onsubtitlechange: function() {},
});
webapis.avplay.prepareAsync(
function() {
try { _duration2 = webapis.avplay.getDuration(); } catch (e) { _duration2 = 0; }
try {
webapis.avplay.play();
_playing2 = true;
_startTimeUpdates2();
if (window._vkOnPlayStateChanged) window._vkOnPlayStateChanged(true);
if (window._vkOnReady) window._vkOnReady();
} catch (e) {
_playing2 = false;
if (window._vkOnError) window._vkOnError(e.message || String(e));
}
},
function(err) { if (window._vkOnError) window._vkOnError(String(err)); }
);
return true;
} catch (e) {
if (window._vkOnError) window._vkOnError(e.message || String(e));
return false;
}
},
setAudioTrack: function() { return false; },
setSubtitleTrack: function() { return false; },
setPlaybackSpeed: function(speed) {
try { webapis.avplay.setSpeed(speed); return true; } catch (e) { return false; }
},
};
console.info("[VKNative] Tizen Direct-AVPlay Bridge bereit");
return;
}
// Weder iframe noch AVPlay verfuegbar -> kein VKNative
console.warn("[VKNative] Tizen erkannt, aber weder iframe-Parent noch webapis.avplay verfuegbar");
})();

View file

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

View file

@ -0,0 +1,70 @@
/**
* VideoKonverter TV - Service Worker (minimal)
* Ermoeglicht PWA-Installation auf Handys und Tablets
* Kein Offline-Caching noetig (Streaming braucht Netzwerk)
*/
const CACHE_NAME = "vk-tv-v17";
const STATIC_ASSETS = [
"/static/tv/css/tv.css",
"/static/tv/js/tv.js",
"/static/tv/js/player.js",
"/static/tv/js/vknative-bridge.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;
// API-Requests und Streaming nie cachen
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/tv/api/")
|| url.pathname.includes("/stream") || url.pathname.includes("/direct-stream")) {
return;
}
// Statische Assets: Cache-First
if (url.pathname.startsWith("/static/tv/")) {
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request)
.then(response => {
const clone = response.clone();
caches.open(CACHE_NAME)
.then(cache => cache.put(event.request, clone));
return response;
})
)
);
return;
}
// Alles andere: Network-First
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
});

View file

@ -170,7 +170,8 @@
<label for="tvdb_api_key">TVDB API Key</label>
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
value="{{ settings.library.tvdb_api_key if settings.library else '' }}"
placeholder="API Key von thetvdb.com">
placeholder="API Key von thetvdb.com"
autocomplete="off" spellcheck="false">
</div>
<div class="form-group">
<label for="tvdb_pin">TVDB PIN</label>
@ -246,19 +247,113 @@
<!-- Presets -->
<section class="admin-section">
<h2>Encoding-Presets</h2>
<div class="presets-grid">
<div class="preset-editor" id="preset-editor">
{% for key, preset in presets.items() %}
<div class="preset-card">
<h3>{{ preset.name }}</h3>
<div class="preset-details">
<span class="tag">{{ preset.video_codec }}</span>
<span class="tag">{{ preset.container }}</span>
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
<div class="preset-edit-card" id="preset-{{ key }}">
<div class="preset-header" onclick="togglePresetEdit('{{ key }}')">
<div class="preset-header-left">
<h3>{{ preset.name }}</h3>
<div class="preset-details">
<span class="tag">{{ preset.video_codec }}</span>
<span class="tag">{{ preset.container }}</span>
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
{% if key == settings.encoding.default_preset %}<span class="tag default">Standard</span>{% endif %}
</div>
</div>
<span class="preset-toggle" id="toggle-{{ key }}">&#9660;</span>
</div>
<div class="preset-body" id="preset-body-{{ key }}" style="display:none">
<form hx-post="/htmx/preset/{{ key }}" hx-target="#preset-result-{{ key }}" hx-swap="innerHTML">
<div class="form-grid">
<div class="form-group">
<label>Anzeigename</label>
<input type="text" name="name" value="{{ preset.name }}">
</div>
<div class="form-group">
<label>Video-Codec</label>
<select name="video_codec">
{% for codec, label in [
('av1_vaapi', 'GPU AV1 (VAAPI)'),
('hevc_vaapi', 'GPU HEVC (VAAPI)'),
('h264_vaapi', 'GPU H.264 (VAAPI)'),
('libsvtav1', 'CPU SVT-AV1'),
('libx265', 'CPU x265'),
('libx264', 'CPU x264'),
('libvpx-vp9', 'CPU VP9')
] %}
<option value="{{ codec }}" {% if preset.video_codec == codec %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>Container</label>
<select name="container">
<option value="webm" {% if preset.container == 'webm' %}selected{% endif %}>WebM</option>
<option value="mkv" {% if preset.container == 'mkv' %}selected{% endif %}>MKV</option>
<option value="mp4" {% if preset.container == 'mp4' %}selected{% endif %}>MP4</option>
</select>
</div>
<div class="form-group">
<label>Qualitaetsparameter</label>
<select name="quality_param">
<option value="crf" {% if preset.quality_param == 'crf' %}selected{% endif %}>CRF (Constant Rate Factor)</option>
<option value="qp" {% if preset.quality_param == 'qp' %}selected{% endif %}>QP (Quantization Parameter)</option>
</select>
</div>
<div class="form-group">
<label>Qualitaetswert <small>(niedrig = besser)</small></label>
<input type="number" name="quality_value" value="{{ preset.quality_value }}" min="0" max="63">
</div>
<div class="form-group">
<label>GOP-Groesse <small>(Keyframe-Intervall)</small></label>
<input type="number" name="gop_size" value="{{ preset.gop_size }}" min="1" max="1000">
</div>
<div class="form-group">
<label>Speed-Preset <small>(nur CPU)</small></label>
<input type="text" name="speed_preset"
value="{{ preset.speed_preset if preset.speed_preset is not none else '' }}"
placeholder="z.B. 5 (SVT-AV1) oder medium (x264/x265)">
</div>
<div class="form-group">
<label>Video-Filter</label>
<input type="text" name="video_filter" value="{{ preset.video_filter }}"
placeholder="z.B. format=nv12,hwupload">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="hw_init" {% if preset.hw_init %}checked{% endif %}>
Hardware-Init (GPU VAAPI)
</label>
</div>
<div class="form-group" style="grid-column: 1 / -1">
<label>Extra-Parameter <small>(key=value, je Zeile)</small></label>
<textarea name="extra_params" rows="3"
placeholder="svtav1-params=tune=0:film-grain=8">{% for k, v in (preset.extra_params or {}).items() %}{{ k }}={{ v }}{% if not loop.last %}
{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
{% if key != settings.encoding.default_preset %}
<button type="button" class="btn-secondary" onclick="setDefaultPreset('{{ key }}')">
Als Standard setzen
</button>
<button type="button" class="btn-danger" onclick="deletePreset('{{ key }}')">
Loeschen
</button>
{% endif %}
</div>
<div id="preset-result-{{ key }}"></div>
</form>
</div>
</div>
{% endfor %}
</div>
<div style="margin-top:1rem">
<button class="btn-secondary" onclick="showNewPresetForm()">+ Neues Preset</button>
</div>
</section>
{% endblock %}
@ -338,6 +433,81 @@ function scanPath(pathId) {
.catch(e => showToast("Fehler: " + e, "error"));
}
document.addEventListener("DOMContentLoaded", loadLibraryPaths);
// === Preset-Editor ===
function togglePresetEdit(key) {
const body = document.getElementById("preset-body-" + key);
const toggle = document.getElementById("toggle-" + key);
const open = body.style.display === "none";
body.style.display = open ? "" : "none";
toggle.innerHTML = open ? "&#9650;" : "&#9660;";
}
async function setDefaultPreset(key) {
const resp = await fetch("/api/settings", {
method: "PUT",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({encoding: {default_preset: key}})
});
if (resp.ok) {
showToast("Standard-Preset geaendert", "success");
location.reload();
} else {
showToast("Fehler beim Aendern", "error");
}
}
async function deletePreset(key) {
if (!await showConfirm('Preset "' + key + '" wirklich loeschen?',
{title: "Preset loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
const resp = await fetch("/api/presets/" + key, {method: "DELETE"});
const data = await resp.json();
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Preset geloescht", "success");
location.reload();
}
}
async function showNewPresetForm() {
const key = prompt("Preset-Schluessel (z.B. gpu_vp9):");
if (!key) return;
if (!/^[a-z][a-z0-9_]*$/.test(key.trim())) {
showToast("Nur Kleinbuchstaben, Zahlen und Unterstriche erlaubt", "error");
return;
}
const resp = await fetch("/api/presets", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
key: key.trim(),
preset: {
name: key.trim(),
video_codec: "libx264",
container: "mp4",
quality_param: "crf",
quality_value: 23,
gop_size: 240,
video_filter: "",
hw_init: false,
extra_params: {}
}
})
});
const data = await resp.json();
if (data.error) {
showToast("Fehler: " + data.error, "error");
} else {
showToast("Preset erstellt", "success");
location.reload();
}
}
document.addEventListener("DOMContentLoaded", () => {
loadLibraryPaths();
});
</script>
{% endblock %}

View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VideoKonverter{% endblock %}</title>
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ v }}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %}
</head>
@ -18,6 +18,7 @@
<a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
<a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a>
<a href="/tv-admin" class="nav-link {% if request.path == '/tv-admin' %}active{% endif %}">TV Admin</a>
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
</nav>
</header>
@ -51,6 +52,24 @@
<div class="progress-bar"></div>
</div>
</div>
<div id="gp-thumbnails" class="global-progress" style="display:none">
<div class="global-progress-info">
<span class="gp-label">Thumbnails</span>
<span class="gp-detail text-muted"></span>
</div>
<div class="progress-container" style="height:4px">
<div class="progress-bar"></div>
</div>
</div>
<div id="gp-automatch" class="global-progress" style="display:none">
<div class="global-progress-info">
<span class="gp-label">Auto-Match</span>
<span class="gp-detail text-muted"></span>
</div>
<div class="progress-container" style="height:4px">
<div class="progress-bar"></div>
</div>
</div>
</div>
<main>

View file

@ -13,17 +13,10 @@
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
<button class="btn-secondary" onclick="generateThumbnails()">Thumbnails</button>
</div>
</div>
<!-- Auto-Match Progress -->
<div id="auto-match-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="auto-match-bar"></div>
</div>
<span class="scan-status" id="auto-match-status">TVDB Auto-Match...</span>
</div>
<!-- Statistik-Leiste -->
<div class="library-stats" id="library-stats">
<div class="lib-stat"><span class="lib-stat-value" id="stat-videos">-</span><span class="lib-stat-label">Videos</span></div>
@ -63,7 +56,7 @@
<div class="filter-group">
<label>Suche</label>
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()">
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()" onkeydown="if(event.key==='Enter'){event.preventDefault();applyFilters()}">
</div>
<div class="filter-group">
@ -393,12 +386,9 @@
</div>
<div id="import-items-list" class="import-items-list"></div>
</div>
<!-- Schritt 3: Fortschritt -->
<!-- Schritt 3: Fortschritt (mehrere Jobs gleichzeitig) -->
<div id="import-progress" style="display:none; padding:1rem;">
<div class="progress-container">
<div class="progress-bar" id="import-bar"></div>
</div>
<span class="text-muted" id="import-status-text">Importiere...</span>
<div id="import-jobs-container"></div>
</div>
</div>
</div>
@ -526,5 +516,5 @@
{% endblock %}
{% block scripts %}
<script src="/static/js/library.js"></script>
<script src="/static/js/library.js?v={{ v }}"></script>
{% endblock %}

View file

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="{{ user.ui_lang if user is defined and user else 'de' }}"
data-theme="{{ user.theme if user is defined and user and user.theme else 'dark' }}">
<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?v={{ v }}">
<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>{{ t('nav.home') }}</a>
{% if user.can_view_series %}
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>{{ t('nav.series') }}</a>
{% endif %}
{% if user.can_view_movies %}
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>{{ t('nav.movies') }}</a>
{% endif %}
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>{{ t('nav.search') }}</a>
<a href="/tv/watchlist" class="tv-nav-item {% if active == 'watchlist' %}active{% endif %}" data-focusable>{{ t('nav.watchlist') }}</a>
</div>
<div class="tv-nav-right">
<a href="/tv/profiles" class="tv-nav-profile" data-focusable title="{{ t('profiles.switch') }}">
<span class="tv-avatar" style="background:{{ user.avatar_color or '#64b5f6' }}">
{{ (user.display_name or user.username)[:1]|upper }}
</span>
</a>
<a href="/tv/settings" class="tv-nav-item {% if active == 'settings' %}active{% endif %}" data-focusable>&#9881;</a>
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>{{ t('nav.logout') }}</a>
</div>
</nav>
{% endif %}
<main class="tv-main">
{% block content %}{% endblock %}
</main>
<script src="/static/tv/js/tv.js?v={{ v }}"></script>
{% block scripts %}{% endblock %}
<script>
// Tizen iframe: Parent signalisieren dass die App geladen hat
if (window.parent !== window) {
try { window.parent.postMessage({type: "vk_app_loaded"}, "*"); } catch(e) {}
}
// PWA Service Worker registrieren
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/tv/sw.js', {scope: '/tv/'})
.catch(() => {});
}
</script>
</body>
</html>

View file

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

View file

@ -0,0 +1,111 @@
<!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?v={{ v }}">
<title>Login - VideoKonverter TV</title>
</head>
<body class="login-body">
<!-- Lade-Spinner (verhindert Flash des Login-Formulars) -->
<div class="login-loader" id="login-loader">
<div class="login-spinner"></div>
</div>
<div class="login-container" id="login-container" style="display:none">
<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"
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>
<div class="login-field login-remember">
<label class="settings-check">
<input type="checkbox" name="remember" data-focusable>
Angemeldet bleiben
</label>
</div>
<button type="submit" class="login-btn" data-focusable>
Anmelden
</button>
</form>
</div>
</div>
<script>
// Tizen iframe: Parent signalisieren dass die App geladen hat
if (window.parent !== window) {
try { window.parent.postMessage({type: "vk_app_loaded"}, "*"); } catch(e) {}
}
// === D-Pad Navigation fuer Login-Formular (Tizen/Samsung Fernbedienung) ===
(function() {
// postMessage-Events vom Tizen-Parent empfangen und als KeyboardEvent dispatchen
window.addEventListener("message", function(evt) {
var d = evt.data;
if (!d || d.type !== "vknative_keyevent" || !d.keyCode) return;
var keyMap = {13:"Enter",37:"ArrowLeft",38:"ArrowUp",39:"ArrowRight",40:"ArrowDown",27:"Escape",8:"Backspace",10009:"Escape"};
var ke = new KeyboardEvent("keydown", {keyCode:d.keyCode, which:d.keyCode, key:keyMap[d.keyCode]||"", bubbles:true});
document.dispatchEvent(ke);
});
// Focusable Elemente sammeln und D-Pad-Navigation
function getFocusables() {
return Array.prototype.slice.call(document.querySelectorAll("[data-focusable]"));
}
document.addEventListener("keydown", function(e) {
var els = getFocusables();
if (!els.length) return;
var idx = els.indexOf(document.activeElement);
if (e.key === "ArrowDown" || e.keyCode === 40) {
e.preventDefault();
els[idx < els.length - 1 ? idx + 1 : 0].focus();
} else if (e.key === "ArrowUp" || e.keyCode === 38) {
e.preventDefault();
els[idx > 0 ? idx - 1 : els.length - 1].focus();
} else if ((e.key === "Enter" || e.keyCode === 13) && document.activeElement.tagName === "BUTTON") {
document.activeElement.click();
}
});
})();
// Pruefen ob Browser Felder vorausgefuellt hat -> automatisch absenden
var _autoAttempts = 0;
var _autoInterval = setInterval(function() {
_autoAttempts++;
var u = document.getElementById('username');
var p = document.getElementById('password');
if (u && p && u.value && p.value) {
clearInterval(_autoInterval);
document.querySelector('.login-form').submit();
return;
}
if (_autoAttempts >= 5) {
clearInterval(_autoInterval);
// Kein Auto-Fill -> Formular anzeigen
document.getElementById('login-loader').style.display = 'none';
var container = document.getElementById('login-container');
container.style.display = '';
container.style.animation = 'fadeIn 0.3s ease';
if (u) u.focus();
}
}, 200);
</script>
</body>
</html>

View file

@ -0,0 +1,158 @@
{% 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 %}
<!-- Bewertungen -->
<div class="tv-rating-section">
<div class="tv-rating-user">
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
<div class="tv-stars-input" id="user-stars"
data-movie-id="{{ movie.id }}" data-rating="{{ user_rating }}">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
data-value="{{ i }}" data-focusable
onclick="setRating({{ i }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
{% if videos %}
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
&#9654; {{ t('player.play') }}
</a>
{% endif %}
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-movie-id="{{ movie.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('watchlist.title') }}</span>
</button>
</div>
</div>
</div>
{% if videos|length > 1 %}
<h3 class="tv-section-title">{{ t('movies.versions') }}</h3>
<div class="tv-episode-list">
{% for v in videos %}
<a href="/tv/player?v={{ v.id }}" class="tv-episode-card" data-focusable>
<div class="tv-ep-thumb">
<img src="/api/library/videos/{{ v.id }}/thumbnail" alt="" loading="lazy">
<div class="tv-ep-duration">
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
</div>
</div>
<div class="tv-ep-info">
<div class="tv-ep-header">
<span class="tv-ep-title">{{ v.file_name }}</span>
</div>
<div class="tv-ep-meta">
{% if v.width %}{{ v.width }}x{{ v.height }}{% endif %}
&middot; {{ v.container|upper }}
{% if v.video_codec %} &middot; {{ v.video_codec }}{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
function toggleWatchlist(btn) {
const movieId = btn.dataset.movieId;
fetch('/tv/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: parseInt(movieId) }),
})
.then(r => r.json())
.then(data => {
if (data.in_watchlist) {
btn.classList.add('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const movieId = container.dataset.movieId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: parseInt(movieId), rating: value }),
})
.then(r => r.json())
.then(data => {
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
</script>
{% endblock %}

View file

@ -0,0 +1,254 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<div class="tv-list-header">
<h1 class="tv-page-title">{{ t('movies.title') }}</h1>
<div class="tv-view-switch" id="view-switch">
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
data-focusable data-view="grid" onclick="switchView('grid')"
title="{{ t('settings.view_grid') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
data-focusable data-view="list" onclick="switchView('list')"
title="{{ t('settings.view_list') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
data-focusable data-view="detail" onclick="switchView('detail')"
title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button>
<button class="tv-view-btn {% if view == 'folder' %}active{% endif %}"
data-focusable data-view="folder" onclick="switchView('folder')"
title="{{ t('settings.view_folder') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 4h5l2 2h7v8a1 1 0 01-1 1H2a1 1 0 01-1-1V5a1 1 0 011-1z" fill="currentColor" opacity="0.7"/></svg>
</button>
</div>
</div>
<!-- Quellen-Tabs (immer sichtbar) -->
{% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs">
<a href="/tv/movies?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for src in sources %}
<a href="/tv/movies?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
{{ src.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %}
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
<option value="">{{ t('filter.all_genres') }}</option>
{% for g in genres %}
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
{% endif %}
<!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
<option value="">{{ t('filter.min_rating') }}</option>
{% for n in range(1, 6) %}
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
{% for s in range(n) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="year" {% if current_sort == 'year' %}selected{% endif %}>{{ t('filter.sort_newest') }} (Jahr)</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
{% 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">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ m.year or "" }}{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
<!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-list-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
<div class="tv-list-poster">
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<span class="tv-list-title">{{ m.title or m.folder_name }}</span>
<span class="tv-list-rating">{% if m.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ m.genres or '' }}</span>
<span class="tv-list-count">{{ m.year or '' }}</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for m in movies %}
<a href="/tv/movies/{{ m.id }}" class="tv-detail-item" data-focusable data-letter="{{ (m.title or m.folder_name)[:1]|upper }}">
<div class="tv-detail-thumb">
{% if m.poster_url %}
<img src="{{ m.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ m.title or m.folder_name }}</span>
{% if m.overview %}
<p class="tv-detail-desc">{{ m.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if m.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= m.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ m.avg_rating }}</span> &middot; {% endif %}
{% if m.year %}{{ m.year }}{% endif %}
{% if m.genres %} &middot; {{ m.genres }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
<!-- === Ordner-Ansicht === -->
<div class="tv-folder-view tv-view-folder" id="view-folder" {% if view != 'folder' %}style="display:none"{% endif %}>
{% for src in folder_data %}
<div class="tv-folder-source">
{% if folder_data|length > 1 %}
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %}
<div class="tv-folder-list">
{% for m in src.entries %}
<a href="/tv/movies/{{ m.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ m.folder_name }}</span>
<span class="tv-folder-meta">
{% if m.title and m.title != m.folder_name %}{{ m.title }}{% endif %}
{% if m.year %} ({{ m.year }}){% endif %}
</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Alphabet-Seitenleiste -->
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
{% endfor %}
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
</nav>
{% if not movies and view != 'folder' %}
<div class="tv-empty">{{ t('movies.no_movies') }}</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
function switchView(mode) {
document.querySelectorAll('[id^="view-"]').forEach(el => {
if (el.id !== 'view-switch') el.style.display = 'none';
});
const target = document.getElementById('view-' + mode);
if (target) target.style.display = '';
document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode);
});
// Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
const filterBar = document.getElementById('filter-bar');
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
var alphaSidebar = document.getElementById('alpha-sidebar');
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
fetch('/tv/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest' },
body: 'movies_view=' + mode,
}).catch(() => {});
}
function applySort(sort) {
const url = new URL(window.location);
url.searchParams.set('sort', sort);
window.location.href = url.toString();
}
function applyGenre(genre) {
const url = new URL(window.location);
if (genre) {
url.searchParams.set('genre', genre);
} else {
url.searchParams.delete('genre');
}
window.location.href = url.toString();
}
function applyRating(rating) {
const url = new URL(window.location);
if (rating) {
url.searchParams.set('rating', rating);
} else {
url.searchParams.delete('rating');
}
window.location.href = url.toString();
}
// Alphabet-Filter
var _currentLetter = null;
function filterByLetter(letter) {
_currentLetter = (_currentLetter === letter) ? null : letter;
['grid', 'list', 'detail'].forEach(function(v) {
var c = document.getElementById('view-' + v);
if (!c) return;
c.querySelectorAll('[data-letter]').forEach(function(item) {
if (!_currentLetter) { item.style.display = ''; return; }
var raw = item.dataset.letter;
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
item.style.display = (norm === _currentLetter) ? '' : 'none';
});
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
el.classList.toggle('active', el.dataset.letter === _currentLetter);
});
}
// Buchstaben ohne Treffer abdunkeln
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.classList.add('dimmed');
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,114 @@
<!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?v={{ v }}">
<title>{{ title }} - VideoKonverter TV</title>
</head>
<body class="player-body">
<div class="player-wrapper" id="player-wrapper">
<!-- Header (ausblendbar) -->
<div class="player-header" id="player-header">
<a href="javascript:history.back()" class="player-back" data-focusable>&#10094; {{ t('player.back') }}</a>
<span class="player-title">{{ title }}</span>
</div>
<!-- Loading-Spinner (sichtbar bis Stream bereit) -->
<div class="player-loading" id="player-loading">
<div class="player-loading-spinner"></div>
<p class="player-loading-text">Stream wird geladen...</p>
</div>
<!-- Video (HTML5 fuer HLS, versteckt bei AVPlay Direct-Play) -->
<video id="player-video" autoplay playsinline></video>
<!-- Controls (ausblendbar) -->
<div class="player-controls" id="player-controls">
<div class="player-progress" id="player-progress">
<div class="player-progress-bar" id="player-progress-bar"></div>
</div>
<div class="player-buttons">
<button class="player-btn" id="btn-play" data-focusable>&#9654;</button>
<span class="player-time" id="player-time">0:00 / 0:00</span>
<span class="player-spacer"></span>
<button class="player-btn" id="btn-audio" data-focusable title="{{ t('player.audio') }}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 010 7"/></svg>
</button>
<button class="player-btn" id="btn-subs" data-focusable title="{{ t('player.subtitles') }}">
<span class="player-btn-badge">CC</span>
</button>
<button class="player-btn" id="btn-quality" data-focusable title="{{ t('player.quality') }}">
<span class="player-btn-badge" id="quality-badge">HD</span>
</button>
<button class="player-btn" id="btn-settings" data-focusable title="{{ t('player.settings') }}">&#9881;</button>
{% if next_video %}
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">&#9197;</button>
{% endif %}
<button class="player-btn" id="btn-fullscreen" data-focusable>&#9974;</button>
</div>
</div>
<!-- Kompaktes Popup-Menue (ersetzt das grosse Overlay-Panel) -->
<div class="player-popup" id="player-popup" style="display:none"></div>
<!-- Debug-Info-Overlay (Toggle mit "i"-Taste oder Blau-Taste) -->
<div class="player-debug" id="player-debug" style="display:none"></div>
<!-- Naechste Episode Overlay -->
{% if next_video %}
<div class="player-next-overlay" id="next-overlay" style="display:none">
<div class="player-next-card">
<p class="player-next-title" id="next-title">{{ t('player.next_episode') }}</p>
<p class="player-next-name">{{ next_title }}</p>
<p class="player-next-countdown" id="next-countdown"></p>
<div class="player-next-buttons">
<button class="tv-play-btn" id="btn-next-play" data-focusable>{{ t('player.skip') }}</button>
<button class="player-btn-cancel" id="btn-next-cancel" data-focusable>{{ t('player.cancel') }}</button>
</div>
</div>
</div>
{% endif %}
<!-- Schaust du noch? Overlay -->
<div class="player-still-watching" id="still-watching-overlay" style="display:none">
<div class="player-next-card">
<p class="player-next-title">{{ t('player.still_watching') }}</p>
<div class="player-next-buttons">
<button class="tv-play-btn" id="btn-still-yes" data-focusable>{{ t('player.continue') }}</button>
<button class="player-btn-cancel" id="btn-still-no" data-focusable>{{ t('player.stop') }}</button>
</div>
</div>
</div>
</div>
<!-- VKNative Bridge: Tizen AVPlay (auf Nicht-Tizen macht das Script nichts) -->
<!-- Android injiziert VKNative per @JavascriptInterface, Bridge erkennt das und ueberspringt -->
<script src="/static/tv/js/vknative-bridge.js?v={{ v }}"></script>
<!-- hls.js als Fallback fuer HLS-Streaming -->
<script src="/static/tv/js/lib/hls.min.js?v={{ v }}"></script>
<script src="/static/tv/js/player.js?v={{ v }}"></script>
<script>
initPlayer({
videoId: {{ video.id }},
startPos: {{ start_pos }},
duration: {{ video.duration_sec or 0 }},
{% if next_video %}
nextVideoId: {{ next_video.id }},
nextUrl: "/tv/player?v={{ next_video.id }}",
{% endif %}
autoplay: {{ 'true' if user.autoplay_enabled else 'false' }},
autoplayCountdown: {{ user.autoplay_countdown_sec or 10 }},
autoplayMax: {{ user.autoplay_max_episodes or 0 }},
preferredAudio: "{{ user.preferred_audio_lang or 'deu' }}",
preferredSub: "{{ user.preferred_subtitle_lang or '' }}",
subtitlesEnabled: {{ 'true' if user.subtitles_enabled else 'false' }},
soundMode: "{{ client_sound_mode or 'stereo' }}",
streamQuality: "{{ client_stream_quality or 'hd' }}",
audioCompressor: {{ 'true' if client_audio_compressor else 'false' }},
seriesDetailUrl: "{{ series_detail_url or '' }}",
});
</script>
</body>
</html>

View file

@ -0,0 +1,37 @@
<!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?v={{ v }}">
<title>{{ t('profiles.title') }} - VideoKonverter TV</title>
</head>
<body class="login-body">
<div class="profiles-container">
<h1 class="profiles-title">{{ t('profiles.title') }}</h1>
<div class="profiles-grid">
{% for p in profiles %}
<form method="post" action="/tv/switch-profile" class="profile-form">
<input type="hidden" name="user_id" value="{{ p.id }}">
<button type="submit" class="profile-card {% if p.id == current_user_id %}profile-active{% endif %}" data-focusable>
<span class="tv-avatar tv-avatar-lg" style="background:{{ p.avatar_color or '#64b5f6' }}">
{{ (p.display_name or p.username)[:1]|upper }}
</span>
<span class="profile-name">{{ p.display_name or p.username }}</span>
</button>
</form>
{% endfor %}
<!-- Anderer Benutzer -->
<a href="/tv/login" class="profile-card profile-add" data-focusable>
<span class="tv-avatar tv-avatar-lg profile-add-icon">+</span>
<span class="profile-name">{{ t('profiles.add_user') }}</span>
</a>
</div>
</div>
<script src="/static/tv/js/tv.js?v={{ v }}"></script>
</body>
</html>

View file

@ -0,0 +1,133 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">{{ t('search.title') }}</h1>
<form action="/tv/search" method="GET" class="tv-search-form" autocomplete="off">
<div class="tv-search-wrapper">
<input type="text" name="q" id="search-input" value="{{ query }}"
placeholder="{{ t('search.placeholder') }}"
class="tv-search-input" data-focusable autofocus>
<div class="tv-autocomplete" id="autocomplete" style="display:none"></div>
</div>
<button type="submit" class="tv-search-btn" data-focusable>{{ t('search.button') }}</button>
</form>
{% if query %}
<!-- Serien-Ergebnisse -->
{% if series %}
<h2 class="tv-section-title">{{ t('search.results_series') }} ({{ 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>
{% if s.genres %}
<span class="tv-card-meta">{{ s.genres }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% endif %}
<!-- Film-Ergebnisse -->
{% if movies %}
<h2 class="tv-section-title">{{ t('search.results_movies') }} ({{ 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">{{ t('search.no_results', query=query) }}</div>
{% endif %}
{% else %}
<!-- Such-History -->
{% if history %}
<div class="tv-search-history">
<div class="tv-search-history-header">
<h2 class="tv-section-title">{{ t('search.history') }}</h2>
<button class="tv-link-btn" onclick="clearHistory()" data-focusable>{{ t('search.clear_history') }}</button>
</div>
<div class="tv-search-history-list">
{% for h in history %}
<a href="/tv/search?q={{ h.query }}" class="tv-search-history-item" data-focusable>
<span class="tv-search-history-icon">&#128269;</span>
{{ h.query }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
// Autocomplete
const input = document.getElementById('search-input');
const acBox = document.getElementById('autocomplete');
let acTimer = null;
if (input) {
input.addEventListener('input', () => {
clearTimeout(acTimer);
const q = input.value.trim();
if (q.length < 2) { acBox.style.display = 'none'; return; }
acTimer = setTimeout(() => fetchSuggestions(q), 300);
});
input.addEventListener('blur', () => {
setTimeout(() => { acBox.style.display = 'none'; }, 200);
});
input.addEventListener('focus', () => {
if (acBox.children.length > 0) acBox.style.display = '';
});
}
function fetchSuggestions(q) {
fetch('/tv/api/search/suggest?q=' + encodeURIComponent(q))
.then(r => r.json())
.then(data => {
if (!data.suggestions || data.suggestions.length === 0) {
acBox.style.display = 'none';
return;
}
acBox.innerHTML = data.suggestions.map(s =>
`<a href="/tv/search?q=${encodeURIComponent(s)}" class="tv-ac-item">${s}</a>`
).join('');
acBox.style.display = '';
})
.catch(() => { acBox.style.display = 'none'; });
}
function clearHistory() {
fetch('/tv/api/search/history', { method: 'DELETE' })
.then(() => {
const hist = document.querySelector('.tv-search-history');
if (hist) hist.remove();
})
.catch(() => {});
}
</script>
{% endblock %}

View file

@ -0,0 +1,255 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<div class="tv-list-header">
<h1 class="tv-page-title">{{ t('series.title') }}</h1>
<div class="tv-view-switch" id="view-switch">
<button class="tv-view-btn {% if view == 'grid' %}active{% endif %}"
data-focusable data-view="grid" onclick="switchView('grid')"
title="{{ t('settings.view_grid') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1" width="7" height="7" rx="1"/><rect x="10" y="1" width="7" height="7" rx="1"/><rect x="1" y="10" width="7" height="7" rx="1"/><rect x="10" y="10" width="7" height="7" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'list' %}active{% endif %}"
data-focusable data-view="list" onclick="switchView('list')"
title="{{ t('settings.view_list') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="2" width="16" height="3" rx="1"/><rect x="1" y="7.5" width="16" height="3" rx="1"/><rect x="1" y="13" width="16" height="3" rx="1"/></svg>
</button>
<button class="tv-view-btn {% if view == 'detail' %}active{% endif %}"
data-focusable data-view="detail" onclick="switchView('detail')"
title="{{ t('settings.view_detail') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><rect x="1" y="1.5" width="5" height="6" rx="1"/><rect x="8" y="2" width="9" height="2" rx="0.5"/><rect x="8" y="5" width="6" height="1.5" rx="0.5"/><rect x="1" y="10.5" width="5" height="6" rx="1"/><rect x="8" y="11" width="9" height="2" rx="0.5"/><rect x="8" y="14" width="6" height="1.5" rx="0.5"/></svg>
</button>
<button class="tv-view-btn {% if view == 'folder' %}active{% endif %}"
data-focusable data-view="folder" onclick="switchView('folder')"
title="{{ t('settings.view_folder') }}">
<svg width="18" height="18" viewBox="0 0 18 18"><path d="M2 4h5l2 2h7v8a1 1 0 01-1 1H2a1 1 0 01-1-1V5a1 1 0 011-1z" fill="currentColor" opacity="0.7"/></svg>
</button>
</div>
</div>
<!-- Quellen-Tabs (immer sichtbar) -->
{% if sources|length > 1 %}
<div class="tv-tabs tv-source-tabs">
<a href="/tv/series?sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if not current_source %}active{% endif %}" data-focusable>
{{ t('filter.all') }}
</a>
{% for src in sources %}
<a href="/tv/series?source={{ src.id }}&sort={{ current_sort }}{% if current_genre %}&genre={{ current_genre }}{% endif %}"
class="tv-tab {% if current_source == src.id|string %}active{% endif %}" data-focusable>
{{ src.name }}
</a>
{% endfor %}
</div>
{% endif %}
<!-- Filter-Leiste (nicht in Ordner-Ansicht) -->
<div class="tv-filter-bar" id="filter-bar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% if genres %}
<select class="tv-sort-select tv-genre-filter" data-focusable onchange="applyGenre(this.value)">
<option value="">{{ t('filter.all_genres') }}</option>
{% for g in genres %}
<option value="{{ g }}" {% if current_genre == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
{% endif %}
<!-- Rating-Filter -->
<select class="tv-sort-select tv-rating-filter" data-focusable onchange="applyRating(this.value)">
<option value="">{{ t('filter.min_rating') }}</option>
{% for n in range(1, 6) %}
<option value="{{ n }}" {% if current_rating == n|string %}selected{% endif %}>
{% for s in range(n) %}&#9733;{% endfor %}{% for s in range(5 - n) %}&#9734;{% endfor %} {{ n }}+
</option>
{% endfor %}
</select>
<select class="tv-sort-select" data-focusable onchange="applySort(this.value)">
<option value="title" {% if current_sort == 'title' %}selected{% endif %}>{{ t('filter.sort_title') }}</option>
<option value="title_desc" {% if current_sort == 'title_desc' %}selected{% endif %}>{{ t('filter.sort_title_desc') }}</option>
<option value="newest" {% if current_sort == 'newest' %}selected{% endif %}>{{ t('filter.sort_newest') }}</option>
<option value="episodes" {% if current_sort == 'episodes' %}selected{% endif %}>{{ t('filter.sort_episodes') }}</option>
<option value="rating" {% if current_sort == 'rating' %}selected{% endif %}>{{ t('filter.sort_rating') }}</option>
</select>
</div>
<!-- === Grid-Ansicht === -->
<div class="tv-grid tv-view-grid" id="view-grid" {% if view != 'grid' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
{% 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">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}</span> {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}{% if s.genres %} &middot; {{ s.genres }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
<!-- === Liste (kompakt) === -->
<div class="tv-list-compact tv-view-list" id="view-list" {% if view != 'list' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-list-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
<div class="tv-list-poster">
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<span class="tv-list-title">{{ s.title or s.folder_name }}</span>
<span class="tv-list-rating">{% if s.avg_rating > 0 %}{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %}{% endif %}</span>
<span class="tv-list-genre">{{ s.genres or '' }}</span>
<span class="tv-list-count">{{ s.episode_count or 0 }} Ep.</span>
</a>
{% endfor %}
</div>
<!-- === Detail-Liste === -->
<div class="tv-detail-list tv-view-detail" id="view-detail" {% if view != 'detail' %}style="display:none"{% endif %}>
{% for s in series %}
<a href="/tv/series/{{ s.id }}" class="tv-detail-item" data-focusable data-letter="{{ (s.title or s.folder_name)[:1]|upper }}">
<div class="tv-detail-thumb">
{% if s.poster_url %}
<img src="{{ s.poster_url }}" alt="" loading="lazy">
{% endif %}
</div>
<div class="tv-detail-content">
<span class="tv-detail-title">{{ s.title or s.folder_name }}</span>
{% if s.overview %}
<p class="tv-detail-desc">{{ s.overview }}</p>
{% endif %}
<span class="tv-detail-meta">
{% if s.avg_rating > 0 %}<span class="tv-card-stars">{% for i in range(1, 6) %}<span class="tv-star-sm {% if i <= s.avg_rating|round|int %}active{% endif %}">&#9733;</span>{% endfor %} {{ s.avg_rating }}</span> &middot; {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
{% if s.genres %} &middot; {{ s.genres }}{% endif %}
{% if s.status %} &middot; {{ s.status }}{% endif %}
</span>
</div>
</a>
{% endfor %}
</div>
<!-- === Ordner-Ansicht === -->
<div class="tv-folder-view tv-view-folder" id="view-folder" {% if view != 'folder' %}style="display:none"{% endif %}>
{% for src in folder_data %}
<div class="tv-folder-source">
{% if folder_data|length > 1 %}
<h3 class="tv-folder-source-title">{{ src.name }}</h3>
{% endif %}
<div class="tv-folder-list">
{% for s in src.entries %}
<a href="/tv/series/{{ s.id }}" class="tv-folder-item" data-focusable>
<span class="tv-folder-icon">&#128193;</span>
<span class="tv-folder-name">{{ s.folder_name }}</span>
<span class="tv-folder-meta">
{% if s.title and s.title != s.folder_name %}{{ s.title }} &middot; {% endif %}
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
</span>
</a>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Alphabet-Seitenleiste -->
<nav class="tv-alpha-sidebar" id="alpha-sidebar" {% if view == 'folder' %}style="display:none"{% endif %}>
{% for letter in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' %}
<span class="tv-alpha-letter" data-letter="{{ letter }}" onclick="filterByLetter('{{ letter }}')" data-focusable>{{ letter }}</span>
{% endfor %}
<span class="tv-alpha-letter" data-letter="#" onclick="filterByLetter('#')" data-focusable>#</span>
</nav>
{% if not series and view != 'folder' %}
<div class="tv-empty">{{ t('series.no_series') }}</div>
{% endif %}
</section>
{% endblock %}
{% block scripts %}
<script>
function switchView(mode) {
document.querySelectorAll('[id^="view-"]').forEach(el => {
if (el.id !== 'view-switch') el.style.display = 'none';
});
const target = document.getElementById('view-' + mode);
if (target) target.style.display = '';
document.querySelectorAll('.tv-view-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.view === mode);
});
// Filter-Leiste und Alphabet in Ordner-Ansicht verstecken
const filterBar = document.getElementById('filter-bar');
if (filterBar) filterBar.style.display = mode === 'folder' ? 'none' : '';
var alphaSidebar = document.getElementById('alpha-sidebar');
if (alphaSidebar) alphaSidebar.style.display = mode === 'folder' ? 'none' : '';
fetch('/tv/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest' },
body: 'series_view=' + mode,
}).catch(() => {});
}
function applySort(sort) {
const url = new URL(window.location);
url.searchParams.set('sort', sort);
window.location.href = url.toString();
}
function applyGenre(genre) {
const url = new URL(window.location);
if (genre) {
url.searchParams.set('genre', genre);
} else {
url.searchParams.delete('genre');
}
window.location.href = url.toString();
}
function applyRating(rating) {
const url = new URL(window.location);
if (rating) {
url.searchParams.set('rating', rating);
} else {
url.searchParams.delete('rating');
}
window.location.href = url.toString();
}
// Alphabet-Filter
var _currentLetter = null;
function filterByLetter(letter) {
_currentLetter = (_currentLetter === letter) ? null : letter;
['grid', 'list', 'detail'].forEach(function(v) {
var c = document.getElementById('view-' + v);
if (!c) return;
c.querySelectorAll('[data-letter]').forEach(function(item) {
if (!_currentLetter) { item.style.display = ''; return; }
var raw = item.dataset.letter;
var norm = /^[A-Z]$/.test(raw) ? raw : '#';
item.style.display = (norm === _currentLetter) ? '' : 'none';
});
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
el.classList.toggle('active', el.dataset.letter === _currentLetter);
});
}
// Buchstaben ohne Treffer abdunkeln
(function() {
var avail = {};
document.querySelectorAll('.tv-view-grid [data-letter], .tv-view-list [data-letter], .tv-view-detail [data-letter]').forEach(function(item) {
var raw = item.dataset.letter;
avail[/^[A-Z]$/.test(raw) ? raw : '#'] = true;
});
document.querySelectorAll('.tv-alpha-letter').forEach(function(el) {
if (!avail[el.dataset.letter]) el.remove();
});
})();
</script>
{% endblock %}

View file

@ -0,0 +1,455 @@
{% 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 %}
<!-- Bewertungen -->
<div class="tv-rating-section">
<!-- Eigene Bewertung (klickbare Sterne) -->
<div class="tv-rating-user">
<span class="tv-rating-label">{{ t('rating.your_rating') }}:</span>
<div class="tv-stars-input" id="user-stars"
data-series-id="{{ series.id }}" data-rating="{{ user_rating }}">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= user_rating %}active{% endif %}"
data-value="{{ i }}" data-focusable
onclick="setRating({{ i }})">&#9733;</span>
{% endfor %}
{% if user_rating > 0 %}
<span class="tv-rating-remove" onclick="setRating(0)"
data-focusable title="{{ t('rating.remove') }}">&#10005;</span>
{% endif %}
</div>
</div>
<!-- Durchschnitt -->
{% if avg_rating.count > 0 %}
<div class="tv-rating-avg">
<span class="tv-stars-display">
{% for i in range(1, 6) %}
<span class="tv-star {% if i <= avg_rating.avg|round|int %}active{% endif %}">&#9733;</span>
{% endfor %}
</span>
<span class="tv-rating-text">{{ avg_rating.avg }} ({{ avg_rating.count }})</span>
</div>
{% endif %}
<!-- TVDB-Score -->
{% if tvdb_score %}
<div class="tv-rating-external">
<span class="tv-rating-badge tvdb">TVDB {{ "%.0f"|format(tvdb_score) }}%</span>
</div>
{% endif %}
</div>
<div class="tv-detail-actions">
<button class="tv-watchlist-btn {% if in_watchlist %}active{% endif %}"
id="btn-watchlist"
data-focusable
data-series-id="{{ series.id }}"
onclick="toggleWatchlist(this)">
<span class="watchlist-icon">{% if in_watchlist %}&#9829;{% else %}&#9825;{% endif %}</span>
<span class="watchlist-text">{{ t('series.watchlist') }}</span>
</button>
<!-- Serie als gesehen markieren -->
<button class="tv-mark-series-btn"
id="btn-mark-series"
data-focusable
data-series-id="{{ series.id }}"
onclick="markSeriesWatched(this)">
<span class="mark-series-icon">&#10003;</span>
<span class="mark-series-text">{{ t('status.mark_series') }}</span>
</button>
</div>
</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 %} {% if season_watched.get(sn, {}).get('all_seen') %}tv-tab-complete{% endif %}"
data-focusable data-season="{{ sn }}"
onclick="showSeason({{ sn }})">
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
{% if season_watched.get(sn, {}).get('all_seen') %}<span class="tv-tab-check">&#10003;</span>{% endif %}
</button>
{% endfor %}
</div>
<!-- Episoden-Detail-Panel (wird per JS bei Focus befuellt) -->
<div class="tv-ep-detail-panel" id="ep-detail-panel">
<div class="tv-ep-detail-inner">
<h3 class="tv-ep-detail-title" id="ep-detail-title"></h3>
<p class="tv-ep-detail-desc" id="ep-detail-desc"></p>
<p class="tv-ep-detail-meta" id="ep-detail-meta"></p>
</div>
</div>
<!-- Episoden pro Staffel (Card-Grid) -->
{% for sn, episodes in seasons.items() %}
<div class="tv-season" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
<div class="tv-season-actions">
<button class="tv-season-mark-btn" data-focusable
onclick="markSeasonWatched({{ series.id }}, {{ sn }})">
&#10003; {{ t('status.mark_season') }}
</button>
</div>
<div class="tv-episode-grid">
{% for ep in episodes %}
<div class="tv-episode-tile {% if ep.is_duplicate %}tv-ep-duplicate{% endif %} {% if ep.progress_pct >= watched_threshold_pct|default(90) %}tv-ep-seen{% endif %}"
data-video-id="{{ ep.id }}"
data-ep-title="{{ ep.episode_title or ep.file_name }}"
data-ep-desc="{{ ep.ep_overview|default('', true)|e }}"
data-ep-meta="{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} &middot; {{ ep.container|upper|default('') }} {% if ep.video_codec %}&middot; {{ ep.video_codec }}{% endif %} {% if ep.file_size %}&middot; {{ (ep.file_size / 1048576)|round|int }} MB{% endif %} {% if ep.duration_sec %}&middot; {{ (ep.duration_sec / 60)|round|int }} Min{% endif %}">
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-tile-link" data-focusable>
<div class="tv-ep-thumb">
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
<div class="tv-ep-progress">
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
</div>
{% endif %}
{% if ep.progress_pct >= watched_threshold_pct|default(90) %}
<div class="tv-ep-watched">&#10003;</div>
{% endif %}
<div class="tv-ep-duration">
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
</div>
</div>
<div class="tv-ep-tile-label">
<span class="tv-ep-num">{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% endif %}</span>
<span class="tv-ep-tile-title">{{ ep.episode_title or '' }}</span>
{% if ep.is_duplicate %}<span class="tv-ep-dup-badge">{{ t('series.duplicate') }}</span>{% endif %}
</div>
</a>
<button class="tv-ep-tile-mark {% if ep.progress_pct >= watched_threshold_pct|default(90) %}active{% endif %}"
data-focusable
onclick="event.stopPropagation(); toggleWatched({{ ep.id }}, this)">
&#10003;
</button>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="tv-empty">{{ t('series.no_episodes') }}</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');
}
function toggleWatchlist(btn) {
const seriesId = btn.dataset.seriesId;
fetch('/tv/api/watchlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId) }),
})
.then(r => r.json())
.then(data => {
if (data.in_watchlist) {
btn.classList.add('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9829;';
} else {
btn.classList.remove('active');
btn.querySelector('.watchlist-icon').innerHTML = '&#9825;';
}
})
.catch(() => {});
}
function setRating(value) {
const container = document.getElementById('user-stars');
const seriesId = container.dataset.seriesId;
fetch('/tv/api/rating', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ series_id: parseInt(seriesId), rating: value }),
})
.then(r => r.json())
.then(data => {
// Sterne aktualisieren
container.dataset.rating = data.user_rating;
container.querySelectorAll('.tv-star').forEach(star => {
const v = parseInt(star.dataset.value);
star.classList.toggle('active', v <= data.user_rating);
});
// Entfernen-Button anzeigen/verstecken
let removeBtn = container.querySelector('.tv-rating-remove');
if (data.user_rating > 0 && !removeBtn) {
removeBtn = document.createElement('span');
removeBtn.className = 'tv-rating-remove';
removeBtn.setAttribute('data-focusable', '');
removeBtn.innerHTML = '&#10005;';
removeBtn.onclick = () => setRating(0);
container.appendChild(removeBtn);
} else if (data.user_rating === 0 && removeBtn) {
removeBtn.remove();
}
// Durchschnitt aktualisieren (Seite neu laden fuer Einfachheit)
if (data.avg_rating !== undefined) {
const avgEl = document.querySelector('.tv-rating-avg .tv-rating-text');
if (avgEl) avgEl.textContent = data.avg_rating + ' (' + data.rating_count + ')';
}
})
.catch(() => {});
}
function toggleWatched(videoId, btn) {
// Aktuellen Status pruefen und togglen
const card = btn.closest('.tv-episode-tile');
const isSeen = card.classList.contains('tv-ep-seen');
const newPct = isSeen ? 0 : 100;
fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: videoId, position_sec: newPct, duration_sec: 100 }),
})
.then(r => r.json())
.then(() => {
if (isSeen) {
// Als ungesehen markieren
card.classList.remove('tv-ep-seen');
btn.classList.remove('active');
const watchedEl = card.querySelector('.tv-ep-watched');
if (watchedEl) watchedEl.remove();
const progressEl = card.querySelector('.tv-ep-progress');
if (progressEl) progressEl.remove();
} else {
// Als gesehen markieren
card.classList.add('tv-ep-seen');
btn.classList.add('active');
// Haekchen-Symbol hinzufuegen
const thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
const check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#10003;';
thumb.appendChild(check);
}
// Fortschrittsbalken entfernen
const progressEl = card.querySelector('.tv-ep-progress');
if (progressEl) progressEl.remove();
}
})
.catch(() => {});
}
function markSeriesWatched(btn) {
// Alle ungesehenen Episoden aus ALLEN Staffeln sammeln
const allCards = document.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
const ids = [];
allCards.forEach(function(card) {
var vid = card.dataset.videoId;
if (vid) ids.push(parseInt(vid));
});
if (ids.length === 0) return;
// Batch-Request an API (gleiche Methode wie markSeasonWatched)
Promise.all(ids.map(function(id) {
return fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
});
})).then(function() {
// Alle Episoden-Cards als gesehen markieren
document.querySelectorAll('.tv-episode-tile').forEach(function(card) {
card.classList.add('tv-ep-seen');
var markBtn = card.querySelector('.tv-ep-tile-mark');
if (markBtn) markBtn.classList.add('active');
var thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
var check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#10003;';
thumb.appendChild(check);
}
});
// Alle Staffel-Tabs als komplett markieren
document.querySelectorAll('.tv-tab').forEach(function(tab) {
if (!tab.classList.contains('tv-tab-complete')) {
tab.classList.add('tv-tab-complete');
if (!tab.querySelector('.tv-tab-check')) {
var check = document.createElement('span');
check.className = 'tv-tab-check';
check.innerHTML = ' &#10003;';
tab.appendChild(check);
}
}
});
// Button-Zustand aendern
btn.classList.add('active');
}).catch(function() {});
}
function markSeasonWatched(seriesId, seasonNum) {
const season = document.getElementById('season-' + seasonNum);
if (!season) return;
const cards = season.querySelectorAll('.tv-episode-tile:not(.tv-ep-seen)');
const ids = [];
cards.forEach(card => {
const vid = card.dataset.videoId;
if (vid) ids.push(parseInt(vid));
});
if (ids.length === 0) return;
Promise.all(ids.map(id =>
fetch('/tv/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ video_id: id, position_sec: 100, duration_sec: 100 }),
})
)).then(() => {
season.querySelectorAll('.tv-episode-tile').forEach(card => {
card.classList.add('tv-ep-seen');
const btn = card.querySelector('.tv-ep-tile-mark');
if (btn) btn.classList.add('active');
const thumb = card.querySelector('.tv-ep-thumb');
if (thumb && !thumb.querySelector('.tv-ep-watched')) {
const check = document.createElement('div');
check.className = 'tv-ep-watched';
check.innerHTML = '&#10003;';
thumb.appendChild(check);
}
});
// Staffel-Tab als komplett markieren
const tab = document.querySelector('.tv-tab[data-season="' + seasonNum + '"]');
if (tab && !tab.classList.contains('tv-tab-complete')) {
tab.classList.add('tv-tab-complete');
if (!tab.querySelector('.tv-tab-check')) {
const check = document.createElement('span');
check.className = 'tv-tab-check';
check.innerHTML = ' &#10003;';
tab.appendChild(check);
}
}
}).catch(() => {});
}
// === Episode Detail-Panel bei Focus ===
document.addEventListener('focusin', function(e) {
const tile = e.target.closest('.tv-episode-tile');
const panel = document.getElementById('ep-detail-panel');
if (!panel) return;
if (tile) {
document.getElementById('ep-detail-title').textContent = tile.dataset.epTitle || '';
document.getElementById('ep-detail-desc').textContent = tile.dataset.epDesc || '';
document.getElementById('ep-detail-meta').innerHTML = tile.dataset.epMeta || '';
panel.classList.add('visible');
}
});
// === Post-Play Navigation (von Player nach Episoden-Ende) ===
{% if post_play and next_video_id %}
(function() {
var nextCard = document.querySelector('[data-video-id="{{ next_video_id }}"]');
if (!nextCard) return;
// Zur richtigen Staffel wechseln
var season = nextCard.closest('.tv-season');
if (season && season.style.display === 'none') {
var sn = season.id.replace('season-', '');
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
season.style.display = '';
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
if (tab) tab.classList.add('active');
}
// Karte hervorheben und hineinsccrollen
nextCard.classList.add('tv-ep-next-loading');
nextCard.scrollIntoView({block: 'center', behavior: 'smooth'});
// Countdown-Overlay auf der Karte
var countdownEl = document.createElement('div');
countdownEl.className = 'tv-ep-countdown-overlay';
var remaining = {{ countdown }};
countdownEl.innerHTML = '<span class="tv-ep-countdown-num">' + remaining + '</span><span class="tv-ep-countdown-label">{{ t("player.next_episode") }}</span>';
nextCard.querySelector('.tv-ep-thumb').appendChild(countdownEl);
var timer = setInterval(function() {
remaining--;
var numEl = countdownEl.querySelector('.tv-ep-countdown-num');
if (numEl) numEl.textContent = remaining;
if (remaining <= 0) {
clearInterval(timer);
window.location.href = '/tv/player?v={{ next_video_id }}';
}
}, 1000);
// Enter = sofort abspielen, Escape/Return = abbrechen
function handleAutoplayKey(e) {
var key = e.key || '';
var kc = e.keyCode || 0;
// Enter: Sofort naechste Episode starten
if (key === 'Enter' || kc === 13) {
clearInterval(timer);
document.removeEventListener('keydown', handleAutoplayKey);
e.preventDefault();
window.location.href = '/tv/player?v={{ next_video_id }}';
return;
}
// Escape/Return/Backspace: Countdown abbrechen, auf Seite bleiben
if (kc === 10009 || kc === 27 || key === 'Escape' || key === 'Backspace') {
clearInterval(timer);
nextCard.classList.remove('tv-ep-next-loading');
countdownEl.remove();
document.removeEventListener('keydown', handleAutoplayKey);
e.preventDefault();
}
}
document.addEventListener('keydown', handleAutoplayKey);
})();
{% elif last_watched_id %}
// Zur letzten geschauten Episode scrollen
(function() {
var lastCard = document.querySelector('[data-video-id="{{ last_watched_id }}"]');
if (!lastCard) return;
// Zur richtigen Staffel wechseln
var season = lastCard.closest('.tv-season');
if (season && season.style.display === 'none') {
var sn = season.id.replace('season-', '');
document.querySelectorAll('.tv-season').forEach(function(el) { el.style.display = 'none'; });
document.querySelectorAll('.tv-tab').forEach(function(el) { el.classList.remove('active'); });
season.style.display = '';
var tab = document.querySelector('.tv-tab[data-season="' + sn + '"]');
if (tab) tab.classList.add('active');
}
lastCard.scrollIntoView({block: 'center', behavior: 'smooth'});
var focusEl = lastCard.querySelector('[data-focusable]');
if (focusEl) setTimeout(function() { focusEl.focus(); }, 300);
})();
{% endif %}
</script>
{% endblock %}

View file

@ -0,0 +1,231 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('settings.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">{{ t('settings.title') }}</h1>
{% if request.query.get('saved') %}
<div class="tv-success-msg">{{ t('settings.saved') }}</div>
{% endif %}
{% if request.query.get('reset') %}
<div class="tv-success-msg">{{ t('status.reset_progress') }} &#10003;</div>
{% endif %}
<form method="post" action="/tv/settings" class="settings-form">
<!-- Profil -->
<fieldset class="settings-group">
<legend>{{ t('settings.profile') }}</legend>
<label class="settings-label">
{{ t('settings.display_name') }}
<input type="text" name="display_name" value="{{ user.display_name or '' }}"
class="settings-input" data-focusable>
</label>
<div class="settings-label">
{{ t('settings.avatar_color') }}
<input type="hidden" name="avatar_color" id="avatar-color-input"
value="{{ user.avatar_color or '#64b5f6' }}">
<div class="color-picker-grid">
{% set colors = ['#64b5f6', '#42a5f5', '#5c6bc0', '#7e57c2', '#ab47bc',
'#ec407a', '#ef5350', '#ff7043', '#ffa726', '#ffca28',
'#66bb6a', '#26a69a', '#26c6da', '#78909c', '#8d6e63'] %}
{% for c in colors %}
<button type="button" class="color-swatch {% if (user.avatar_color or '#64b5f6') == c %}active{% endif %}"
style="background:{{ c }}" data-color="{{ c }}" data-focusable
onclick="selectColor(this, '{{ c }}')"></button>
{% endfor %}
</div>
</div>
</fieldset>
<!-- Sprache -->
<fieldset class="settings-group">
<legend>{{ t('settings.language') }}</legend>
<label class="settings-label">
{{ t('settings.menu_language') }}
<select name="ui_lang" class="settings-select" data-focusable>
<option value="de" {% if user.ui_lang == 'de' %}selected{% endif %}>Deutsch</option>
<option value="en" {% if user.ui_lang == 'en' %}selected{% endif %}>English</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.audio_language') }}
<select name="preferred_audio_lang" class="settings-select" data-focusable>
<option value="deu" {% if user.preferred_audio_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
<option value="eng" {% if user.preferred_audio_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
<option value="fra" {% if user.preferred_audio_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
<option value="spa" {% if user.preferred_audio_lang == 'spa' %}selected{% endif %}>{{ t('lang.spa') }}</option>
<option value="jpn" {% if user.preferred_audio_lang == 'jpn' %}selected{% endif %}>{{ t('lang.jpn') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.subtitle_language') }}
<select name="preferred_subtitle_lang" class="settings-select" data-focusable>
<option value="" {% if not user.preferred_subtitle_lang %}selected{% endif %}>{{ t('player.subtitles_off') }}</option>
<option value="deu" {% if user.preferred_subtitle_lang == 'deu' %}selected{% endif %}>{{ t('lang.deu') }}</option>
<option value="eng" {% if user.preferred_subtitle_lang == 'eng' %}selected{% endif %}>{{ t('lang.eng') }}</option>
<option value="fra" {% if user.preferred_subtitle_lang == 'fra' %}selected{% endif %}>{{ t('lang.fra') }}</option>
</select>
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="subtitles_enabled"
{% if user.subtitles_enabled %}checked{% endif %} data-focusable>
{{ t('settings.subtitles_enabled') }}
</label>
</fieldset>
<!-- Ansichten & Theme -->
<fieldset class="settings-group">
<legend>{{ t('settings.views') }}</legend>
<label class="settings-label">
{{ t('settings.theme') }}
<select name="theme" class="settings-select" data-focusable
onchange="document.documentElement.setAttribute('data-theme', this.value)">
<option value="dark" {% if user.theme == 'dark' or not user.theme %}selected{% endif %}>{{ t('settings.theme_dark') }}</option>
<option value="medium" {% if user.theme == 'medium' %}selected{% endif %}>{{ t('settings.theme_medium') }}</option>
<option value="light" {% if user.theme == 'light' %}selected{% endif %}>{{ t('settings.theme_light') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.series_view') }}
<select name="series_view" class="settings-select" data-focusable>
<option value="grid" {% if user.series_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.series_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.series_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
<option value="folder" {% if user.series_view == 'folder' %}selected{% endif %}>{{ t('settings.view_folder') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.movies_view') }}
<select name="movies_view" class="settings-select" data-focusable>
<option value="grid" {% if user.movies_view == 'grid' %}selected{% endif %}>{{ t('settings.view_grid') }}</option>
<option value="list" {% if user.movies_view == 'list' %}selected{% endif %}>{{ t('settings.view_list') }}</option>
<option value="detail" {% if user.movies_view == 'detail' %}selected{% endif %}>{{ t('settings.view_detail') }}</option>
<option value="folder" {% if user.movies_view == 'folder' %}selected{% endif %}>{{ t('settings.view_folder') }}</option>
</select>
</label>
</fieldset>
<!-- Startseite -->
<fieldset class="settings-group">
<legend>Startseite</legend>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_continue"
{% if user.home_show_continue is not defined or user.home_show_continue %}checked{% endif %} data-focusable>
&quot;Weiterschauen&quot; anzeigen
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_new"
{% if user.home_show_new is not defined or user.home_show_new %}checked{% endif %} data-focusable>
&quot;Neu hinzugef&uuml;gt&quot; anzeigen
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_hide_watched"
{% if user.home_hide_watched is not defined or user.home_hide_watched %}checked{% endif %} data-focusable>
Gesehene Serien/Filme ausblenden
</label>
<label class="settings-label settings-check">
<input type="checkbox" name="home_show_watched"
{% if user.home_show_watched is not defined or user.home_show_watched %}checked{% endif %} data-focusable>
&quot;Schon gesehen&quot;-Rubrik anzeigen
</label>
</fieldset>
<!-- Auto-Play -->
<fieldset class="settings-group">
<legend>{{ t('settings.autoplay') }}</legend>
<label class="settings-label settings-check">
<input type="checkbox" name="autoplay_enabled"
{% if user.autoplay_enabled %}checked{% endif %} data-focusable>
{{ t('settings.autoplay_enabled') }}
</label>
<label class="settings-label">
{{ t('settings.autoplay_countdown') }}
<select name="autoplay_countdown_sec" class="settings-select" data-focusable>
{% for s in [5, 10, 15, 20, 30] %}
<option value="{{ s }}" {% if user.autoplay_countdown_sec == s %}selected{% endif %}>{{ s }} {{ t('settings.seconds') }}</option>
{% endfor %}
</select>
</label>
<label class="settings-label">
{{ t('settings.autoplay_max') }}
<select name="autoplay_max_episodes" class="settings-select" data-focusable>
<option value="0" {% if user.autoplay_max_episodes == 0 %}selected{% endif %}>{{ t('settings.autoplay_max_desc') }}</option>
{% for n in [3, 5, 8, 10, 15, 20] %}
<option value="{{ n }}" {% if user.autoplay_max_episodes == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<!-- Geraete-Einstellungen -->
{% if client %}
<fieldset class="settings-group">
<legend>{{ t('settings.client_settings') }}</legend>
<label class="settings-label">
{{ t('settings.device_name') }}
<input type="text" name="client_name" value="{{ client.name or '' }}"
placeholder="z.B. Samsung TV Wohnzimmer"
class="settings-input" data-focusable>
</label>
<label class="settings-label">
{{ t('settings.sound_mode') }}
<select name="sound_mode" class="settings-select" data-focusable>
<option value="stereo" {% if client.sound_mode == 'stereo' %}selected{% endif %}>{{ t('settings.sound_stereo') }}</option>
<option value="surround" {% if client.sound_mode == 'surround' %}selected{% endif %}>{{ t('settings.sound_surround') }}</option>
<option value="original" {% if client.sound_mode == 'original' %}selected{% endif %}>{{ t('settings.sound_original') }}</option>
</select>
</label>
<label class="settings-label">
{{ t('settings.stream_quality') }}
<select name="stream_quality" class="settings-select" data-focusable>
<option value="uhd" {% if client.stream_quality == 'uhd' %}selected{% endif %}>{{ t('player.quality_uhd') }}</option>
<option value="hd" {% if client.stream_quality == 'hd' %}selected{% endif %}>{{ t('player.quality_hd') }}</option>
<option value="sd" {% if client.stream_quality == 'sd' %}selected{% endif %}>{{ t('player.quality_sd') }}</option>
<option value="low" {% if client.stream_quality == 'low' %}selected{% endif %}>{{ t('player.quality_low') }}</option>
</select>
</label>
<label class="settings-check">
<input type="checkbox" name="audio_compressor"
{% if client.audio_compressor %}checked{% endif %}
data-focusable>
{{ t('settings.audio_compressor') }}
</label>
</fieldset>
{% endif %}
<button type="submit" class="tv-play-btn settings-save" data-focusable>
{{ t('settings.save') }}
</button>
</form>
<!-- Gefahrenzone -->
<div class="settings-danger">
<form method="post" action="/tv/settings/reset"
onsubmit="return confirm('{{ t('settings.reset_confirm') }}')">
<button type="submit" class="settings-danger-btn" data-focusable>
{{ t('settings.reset_all') }}
</button>
</form>
<form method="post" action="/tv/api/search/history"
onsubmit="fetch('/tv/api/search/history',{method:'DELETE'});this.querySelector('button').textContent='✓';return false;">
<button type="button" class="settings-danger-btn" data-focusable>
{{ t('settings.clear_search') }}
</button>
</form>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
function selectColor(btn, color) {
// Aktive Markierung wechseln
document.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
btn.classList.add('active');
// Hidden-Input aktualisieren
document.getElementById('avatar-color-input').value = color;
}
</script>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "tv/base.html" %}
{% block title %}{{ t('watchlist.title') }} - VideoKonverter TV{% endblock %}
{% block content %}
<section class="tv-section">
<h1 class="tv-page-title">{{ t('watchlist.title') }}</h1>
{% if not series and not movies %}
<div class="tv-empty">{{ t('watchlist.empty') }}</div>
{% endif %}
{% if series %}
<div class="tv-section">
<h2 class="tv-section-title">{{ t('watchlist.series') }}</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 data-src="{{ s.poster_url }}" alt="" class="tv-card-img tv-lazy" 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>
{% if s.genres %}
<span class="tv-card-meta">{{ s.genres }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if movies %}
<div class="tv-section">
<h2 class="tv-section-title">{{ t('watchlist.movies') }}</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 data-src="{{ m.poster_url }}" alt="" class="tv-card-img tv-lazy" 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">{% if m.year %}{{ m.year }}{% endif %} {% if m.genres %}{{ m.genres }}{% endif %}</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</section>
{% endblock %}

View file

@ -0,0 +1,393 @@
{% extends "base.html" %}
{% block title %}TV Admin - VideoKonverter{% endblock %}
{% block content %}
<section class="admin-section">
<h2>TV Admin-Center</h2>
<form hx-post="/htmx/tv-settings" hx-target="#tv-save-result" hx-swap="innerHTML">
<!-- Streaming -->
<fieldset>
<legend>HLS Streaming</legend>
<div class="form-grid">
<div class="form-group">
<label for="hls_segment_duration">Segment-Dauer (Sekunden)</label>
<input type="number" name="hls_segment_duration" id="hls_segment_duration"
value="{{ tv.hls_segment_duration | default(4) }}" min="1" max="30">
<span class="text-muted" style="font-size:0.8rem">Laenge der einzelnen HLS-Segmente</span>
</div>
<div class="form-group">
<label for="hls_init_duration">Erstes Segment (Sekunden)</label>
<input type="number" name="hls_init_duration" id="hls_init_duration"
value="{{ tv.hls_init_duration | default(1) }}" min="1" max="10">
<span class="text-muted" style="font-size:0.8rem">Kuerzeres erstes Segment fuer schnelleren Playback-Start</span>
</div>
<div class="form-group">
<label for="hls_session_timeout_min">Session-Timeout (Minuten)</label>
<input type="number" name="hls_session_timeout_min" id="hls_session_timeout_min"
value="{{ tv.hls_session_timeout_min | default(5) }}" min="1" max="60">
<span class="text-muted" style="font-size:0.8rem">Inaktive Sessions werden nach dieser Zeit beendet</span>
</div>
<div class="form-group">
<label for="hls_max_sessions">Max. gleichzeitige Sessions</label>
<input type="number" name="hls_max_sessions" id="hls_max_sessions"
value="{{ tv.hls_max_sessions | default(5) }}" min="1" max="20">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="pause_batch_on_stream" id="pause_batch_on_stream"
{% if tv.pause_batch_on_stream | default(true) %}checked{% endif %}>
Batch-Konvertierung bei Stream pausieren
</label>
<span class="text-muted" style="font-size:0.8rem">Friert laufende Konvertierungen per SIGSTOP ein, solange ein Stream aktiv ist</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="force_transcode" id="force_transcode"
{% if tv.force_transcode | default(false) %}checked{% endif %}>
Immer transcodieren (kein Copy-Modus)
</label>
<span class="text-muted" style="font-size:0.8rem">Alle Videos werden zu H.264+AAC transcodiert. Langsamer, aber garantiert kompatibel fuer alle Clients (TV, Handy, Browser)</span>
</div>
</div>
</fieldset>
<!-- Audio-Normalisierung -->
<fieldset>
<legend>Audio-Normalisierung</legend>
<div class="form-grid">
<div class="form-group">
<label>
<input type="checkbox" name="audio_loudnorm" id="audio_loudnorm"
{% if tv.audio_loudnorm | default(false) %}checked{% endif %}>
EBU R128 Loudnorm (Lautstaerke-Normalisierung)
</label>
<span class="text-muted" style="font-size:0.8rem">Normalisiert Audio auf -14 LUFS (Broadcast-Standard). Alle Filme/Serien haben die gleiche Grundlautstaerke. Nur bei HLS-Streaming aktiv.</span>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="audio_dynaudnorm" id="audio_dynaudnorm"
{% if tv.audio_dynaudnorm | default(false) %}checked{% endif %}>
Dynamische Audio-Normalisierung (dynaudnorm)
</label>
<span class="text-muted" style="font-size:0.8rem">Passt Lautstaerke Szene fuer Szene an. Leise Dialoge werden lauter, Explosionen leiser. Ergaenzt Loudnorm. Nur bei HLS-Streaming aktiv.</span>
</div>
</div>
</fieldset>
<!-- Watch-Status -->
<fieldset>
<legend>Watch-Status</legend>
<div class="form-grid">
<div class="form-group">
<label for="watched_threshold_pct">Gesehen-Schwelle (%)</label>
<input type="number" name="watched_threshold_pct" id="watched_threshold_pct"
value="{{ tv.watched_threshold_pct | default(90) }}" min="50" max="100">
<span class="text-muted" style="font-size:0.8rem">Ab diesem Fortschritt gilt eine Episode als gesehen (Plex Standard: 90%)</span>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
<span id="tv-save-result"></span>
</div>
</form>
</section>
<!-- Aktive HLS-Sessions -->
<section class="admin-section">
<h2>Aktive HLS-Sessions</h2>
<div id="hls-sessions">
<div class="loading-msg">Lade Sessions...</div>
</div>
<button class="btn-secondary" onclick="loadHlsSessions()" style="margin-top:0.5rem">Aktualisieren</button>
</section>
<!-- TV-App / Streaming -->
<section class="admin-section">
<h2>TV-App</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>
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-techinfo"> Technische Details</label>
</div>
</div>
</div>
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
</div>
</div>
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
// === HLS Sessions Monitoring ===
function loadHlsSessions() {
fetch("/api/tv/hls-sessions")
.then(r => r.json())
.then(data => {
const container = document.getElementById("hls-sessions");
const sessions = data.sessions || [];
if (!sessions.length) {
container.innerHTML = '<div class="loading-msg">Keine aktiven Sessions</div>';
return;
}
container.innerHTML = `
<table class="stats-table" style="width:100%">
<thead>
<tr>
<th>Session</th>
<th>Video</th>
<th>Qualitaet</th>
<th>Laufzeit</th>
<th>Inaktiv</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
${sessions.map(s => `
<tr>
<td><code>${s.session_id.substring(0, 8)}...</code></td>
<td>${escapeHtml(s.video_name || 'ID ' + s.video_id)}</td>
<td><span class="tag">${s.quality.toUpperCase()}</span></td>
<td>${formatDuration(s.age_sec)}</td>
<td>${formatDuration(s.idle_sec)}</td>
<td>${s.ready ? '<span class="status-badge ok">Aktiv</span>' : '<span class="status-badge warn">Startet...</span>'}</td>
<td><button class="btn-small btn-danger" onclick="destroyHlsSession('${s.session_id}')">Beenden</button></td>
</tr>
`).join("")}
</tbody>
</table>
`;
})
.catch(() => {
document.getElementById("hls-sessions").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
});
}
async function destroyHlsSession(sid) {
if (!await showConfirm("HLS-Session beenden?", {title: "Session beenden", okText: "Beenden", icon: "warn"})) return;
fetch("/api/tv/hls-sessions/" + sid, {method: "DELETE"})
.then(r => r.json())
.then(() => { showToast("Session beendet", "success"); loadHlsSessions(); })
.catch(e => showToast("Fehler: " + e, "error"));
}
function formatDuration(sec) {
if (sec < 60) return sec + "s";
if (sec < 3600) return Math.floor(sec / 60) + "m " + (sec % 60) + "s";
return Math.floor(sec / 3600) + "h " + Math.floor((sec % 3600) / 60) + "m";
}
// === TV-App User-Verwaltung ===
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
}
function 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.show_tech_info ? '<span class="tag">Tech-Info</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 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,
show_tech_info: document.getElementById("tv-new-techinfo").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 = await showPrompt("Neues Passwort (leer lassen um beizubehalten):", {
title: "Benutzer bearbeiten: " + user.username,
placeholder: "Neues Passwort...",
okText: "Weiter"
});
if (newPass === null) return;
const updates = {};
if (newPass) updates.password = newPass;
const newSeries = confirm("Serien anzeigen?");
const newMovies = confirm("Filme anzeigen?");
const newAdmin = confirm("Admin-Rechte?");
const newTechInfo = confirm("Technische Details anzeigen?");
updates.can_view_series = newSeries;
updates.can_view_movies = newMovies;
updates.is_admin = newAdmin;
updates.show_tech_info = newTechInfo;
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(() => {});
}
// === Init ===
document.addEventListener("DOMContentLoaded", () => {
tvLoadUsers();
tvLoadUrl();
loadHlsSessions();
// HLS-Sessions alle 15 Sekunden aktualisieren
setInterval(loadHlsSessions, 15000);
});
</script>
{% endblock %}

View file

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