Compare commits
No commits in common. "main" and "v3.1.0" have entirely different histories.
84 changed files with 922 additions and 16213 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -35,15 +35,5 @@ video-konverter/app/cfg/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
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
|
||||||
.claude/
|
.claude/
|
||||||
|
|
|
||||||
374
CHANGELOG.md
374
CHANGELOG.md
|
|
@ -2,380 +2,6 @@
|
||||||
|
|
||||||
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
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
|
## [2.9.0] - 2026-02-27
|
||||||
|
|
||||||
### Import-System Neustrukturierung
|
### Import-System Neustrukturierung
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
FROM ubuntu:24.04
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
# Basis-Pakete + ffmpeg + Intel GPU Treiber + gosu (fuer PUID/PGID User-Switching)
|
# Basis-Pakete + ffmpeg + Intel GPU Treiber
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
python3 \
|
python3 \
|
||||||
python3-pip \
|
python3-pip \
|
||||||
gosu \
|
|
||||||
intel-opencl-icd \
|
intel-opencl-icd \
|
||||||
intel-media-va-driver-non-free \
|
intel-media-va-driver-non-free \
|
||||||
libva-drm2 \
|
libva-drm2 \
|
||||||
|
|
@ -41,9 +40,9 @@ COPY video-konverter/app/ ./app/
|
||||||
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
|
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
|
||||||
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
|
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
|
||||||
|
|
||||||
# Daten- und Log-Verzeichnisse + HLS-Streaming (beschreibbar fuer UID 1000)
|
# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000)
|
||||||
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls /tmp/jinja2_cache \
|
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \
|
||||||
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs /tmp/hls /tmp/jinja2_cache
|
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs
|
||||||
|
|
||||||
# Entrypoint (kopiert Defaults in gemountete Volumes)
|
# Entrypoint (kopiert Defaults in gemountete Volumes)
|
||||||
COPY entrypoint.sh .
|
COPY entrypoint.sh .
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,66 +0,0 @@
|
||||||
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
10
android-app/app/proguard-rules.pro
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
# 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.**
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,386 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<?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.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 899 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 816 B |
|
|
@ -1,8 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
||||||
android.useAndroidX=true
|
|
||||||
kotlin.code.style=official
|
|
||||||
android.nonTransitiveRClass=true
|
|
||||||
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android-app/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
||||||
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
248
android-app/gradlew
vendored
|
|
@ -1,248 +0,0 @@
|
||||||
#!/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
93
android-app/gradlew.bat
vendored
|
|
@ -1,93 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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.
|
|
@ -1,531 +0,0 @@
|
||||||
# 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)">
|
|
||||||
✓
|
|
||||||
</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)">
|
|
||||||
✓
|
|
||||||
</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">✓</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 = '✓';
|
|
||||||
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 = ' ✓';
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
@ -1,353 +0,0 @@
|
||||||
# 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)">
|
|
||||||
✓
|
|
||||||
</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">✓</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 = '✓';
|
|
||||||
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 = ' ✓';
|
|
||||||
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.
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Entrypoint: PUID/PGID User-Switching + Default-Config kopieren
|
# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis,
|
||||||
#
|
# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid).
|
||||||
# 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"
|
CFG_DIR="/opt/video-konverter/app/cfg"
|
||||||
DEFAULTS_DIR="/opt/video-konverter/cfg_defaults"
|
DEFAULTS_DIR="/opt/video-konverter/cfg_defaults"
|
||||||
|
|
||||||
# Default-Konfigdateien kopieren falls nicht vorhanden
|
# Alle Default-Dateien kopieren, wenn nicht vorhanden
|
||||||
for file in "$DEFAULTS_DIR"/*; do
|
for file in "$DEFAULTS_DIR"/*; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
if [ ! -f "$CFG_DIR/$filename" ]; then
|
if [ ! -f "$CFG_DIR/$filename" ]; then
|
||||||
|
|
@ -20,32 +14,5 @@ for file in "$DEFAULTS_DIR"/*; do
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Pruefen ob wir als root laufen (Unraid Docker-UI Modus)
|
# Anwendung starten
|
||||||
if [ "$(id -u)" = "0" ]; then
|
exec python3 __main__.py
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Die VideoKonverter TV-App auf einem Samsung Smart TV (Tizen) installieren.
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
- Samsung Smart TV mit Tizen OS (ab 2017, getestet: Tizen 9.0)
|
- Samsung Smart TV mit Tizen OS (ab 2017)
|
||||||
- PC und TV im gleichen Netzwerk
|
- PC und TV im gleichen Netzwerk
|
||||||
- Samsung Developer Account (kostenlos): https://developer.samsung.com/
|
- Samsung Developer Account (kostenlos): https://developer.samsung.com/
|
||||||
- Tizen Studio auf dem PC
|
- Tizen Studio auf dem PC
|
||||||
|
|
@ -20,131 +20,105 @@ Download: https://developer.tizen.org/development/tizen-studio/download
|
||||||
chmod +x web-ide_Tizen_Studio_*.bin
|
chmod +x web-ide_Tizen_Studio_*.bin
|
||||||
./web-ide_Tizen_Studio_*.bin
|
./web-ide_Tizen_Studio_*.bin
|
||||||
|
|
||||||
# Nach Installation: Package Manager CLI nutzen
|
# Nach Installation: Tools liegen unter ~/tizen-studio/
|
||||||
~/tizen-studio/package-manager/package-manager-cli.bin install \
|
# Package Manager oeffnen und installieren:
|
||||||
--accept-license cert-add-on TV-SAMSUNG-Public
|
# - Tizen SDK Tools
|
||||||
|
# - Samsung TV Extensions (Extension SDK Tab)
|
||||||
# WICHTIG auf Manjaro/Arch: Fake-dpkg Wrapper anlegen
|
# - Samsung Certificate Extension (Extension SDK Tab)
|
||||||
# (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
|
### Wichtige Pfade nach Installation
|
||||||
|
|
||||||
```
|
```
|
||||||
~/tizen-studio/tools/sdb # Smart Development Bridge
|
~/tizen-studio/tools/sdb # Smart Development Bridge (wie adb)
|
||||||
~/tizen-studio/tools/ide/bin/tizen # CLI-Tool
|
~/tizen-studio/tools/ide/bin/tizen # CLI-Tool
|
||||||
~/tizen-studio/tools/certificate-manager/certificate-manager # Certificate Manager GUI
|
~/tizen-studio/ide/TizenStudio # IDE starten
|
||||||
```
|
```
|
||||||
|
|
||||||
## Schritt 2: Samsung Developer Zertifikat erstellen
|
## Schritt 2: Samsung Developer Zertifikat erstellen
|
||||||
|
|
||||||
Das Zertifikat signiert die App fuer den TV. Samsung TVs akzeptieren NUR Samsung-signierte
|
Das Zertifikat signiert die App fuer deinen TV. Ohne Zertifikat verweigert der TV die Installation.
|
||||||
Zertifikate (nicht Standard-Tizen!).
|
|
||||||
|
|
||||||
### Voraussetzung: Samsung Certificate Extension installieren
|
1. Tizen Studio IDE starten
|
||||||
|
2. **Tools > Certificate Manager** oeffnen
|
||||||
```bash
|
3. **"+" klicken** > **Samsung** waehlen (nicht Tizen!)
|
||||||
# MUSS installiert sein, sonst erscheint "Samsung" nicht als Option!
|
4. **TV** als Geraetetyp waehlen
|
||||||
PATH="/tmp/fake-dpkg:$PATH" ~/tizen-studio/package-manager/package-manager-cli.bin \
|
5. Samsung Developer Account Daten eingeben
|
||||||
install --accept-license cert-add-on
|
6. Zertifikat wird erstellt und gespeichert
|
||||||
```
|
|
||||||
|
|
||||||
### 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.
|
**WICHTIG:** Zertifikat sichern! Bei App-Updates muss das gleiche Zertifikat verwendet werden.
|
||||||
Backup liegt in `tizen-app/certs/`.
|
|
||||||
|
|
||||||
## Schritt 3: TV vorbereiten (Developer Mode)
|
## Schritt 3: TV vorbereiten (Developer Mode)
|
||||||
|
|
||||||
|
### Ueber TV-Menue
|
||||||
|
|
||||||
1. TV einschalten
|
1. TV einschalten
|
||||||
2. **Apps** oeffnen (Home > Apps)
|
2. **Apps** oeffnen (Home > Apps)
|
||||||
3. Ziffern **12345** eingeben (virtuelles Nummernfeld bei neueren Fernbedienungen)
|
3. Im Apps-Bereich die Ziffern **12345** eingeben (bei neueren Fernbedienungen evtl. ueber das virtuelle Nummernfeld)
|
||||||
4. Developer Mode **ON** schalten
|
4. Developer Mode **ON** schalten
|
||||||
5. **Host PC IP** eingeben (IP des PCs mit Tizen Studio)
|
5. **Host PC IP** eingeben (IP des PCs mit Tizen Studio)
|
||||||
6. **TV neustarten** (wichtig!)
|
6. TV neustarten
|
||||||
|
|
||||||
### DUID auslesen (fuer Zertifikat)
|
### Alternative (neuere TVs ab 2024/Tizen 8)
|
||||||
|
|
||||||
```bash
|
Falls der 12345-Trick nicht funktioniert:
|
||||||
# Erst TV verbinden (Schritt 4), dann:
|
- **Einstellungen > Allgemein > System-Manager** nach Developer Mode suchen
|
||||||
~/tizen-studio/tools/sdb shell 0 getduid
|
- Oder direkt ueber Tizen Studio Device Manager verbinden (siehe Schritt 4)
|
||||||
# Gibt z.B. zurueck: KLCDNTGIJS4OU
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schritt 4: TV verbinden
|
## Schritt 4: TV verbinden
|
||||||
|
|
||||||
|
1. **TV-IP herausfinden:** TV > Einstellungen > Allgemein > Netzwerk > IP-Adresse
|
||||||
|
2. In Tizen Studio: **Tools > Device Manager** oeffnen
|
||||||
|
3. **Remote Device Manager** > TV-IP eingeben > Verbinden
|
||||||
|
4. TV sollte in der Geraete-Liste erscheinen
|
||||||
|
5. **Rechtsklick auf TV > "Permit to install applications"**
|
||||||
|
|
||||||
|
### Oder per Kommandozeile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Verbinden (Port 26101 muss offen sein)
|
# Verbinden
|
||||||
~/tizen-studio/tools/sdb connect <TV-IP>
|
~/tizen-studio/tools/sdb connect <TV-IP>
|
||||||
|
|
||||||
# Pruefen
|
# Pruefen
|
||||||
~/tizen-studio/tools/sdb devices
|
~/tizen-studio/tools/sdb devices
|
||||||
# Zeigt: <IP>:26101 device <TV-Modell>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Falls Verbindung fehlschlaegt:
|
## Schritt 5: App installieren
|
||||||
- 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
|
### Option A: Ueber Tizen Studio IDE (empfohlen)
|
||||||
|
|
||||||
### WGT mit Samsung-Zertifikat signieren
|
1. Device Manager: TV ist verbunden
|
||||||
|
2. **Rechtsklick auf TV > "Install app"**
|
||||||
|
3. `VideoKonverter.wgt` auswaehlen
|
||||||
|
4. Installation laeuft automatisch
|
||||||
|
|
||||||
|
### Option B: Per Kommandozeile
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sauberes Build-Verzeichnis erstellen (nur App-Dateien!)
|
cd /pfad/zu/tizen-app/
|
||||||
mkdir -p /tmp/tizen-build
|
~/tizen-studio/tools/ide/bin/tizen install -n VideoKonverter.wgt -t <TV-Name>
|
||||||
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
|
Der TV-Name wird mit `sdb devices` angezeigt.
|
||||||
|
|
||||||
|
### Option C: Docker (ohne Tizen Studio)
|
||||||
|
|
||||||
|
Falls Tizen Studio zu aufwaendig ist - das Georift Docker-Image hat alles drin:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# TV-Name mit sdb devices ermitteln
|
# Generisches WGT installieren (ohne Tizen Studio auf dem PC)
|
||||||
~/tizen-studio/tools/ide/bin/tizen install -n /tmp/tizen-build/VideoKonverter.wgt -t <TV-Name>
|
docker run --rm -v $(pwd):/app georift/install-jellyfin-tizen \
|
||||||
|
<TV-IP> --wgt /app/VideoKonverter.wgt
|
||||||
```
|
```
|
||||||
|
|
||||||
Erfolgreiche Ausgabe:
|
Siehe: https://github.com/Georift/install-jellyfin-tizen
|
||||||
```
|
|
||||||
Tizen application is successfully installed.
|
|
||||||
```
|
|
||||||
|
|
||||||
### App deinstallieren (falls noetig)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/tizen-studio/tools/sdb shell 0 vd_appuninstall vkTVApp001.VideoKonverter
|
|
||||||
```
|
|
||||||
|
|
||||||
## Schritt 6: App starten
|
## Schritt 6: App starten
|
||||||
|
|
||||||
1. App erscheint als **"VideoKonverter"** im Apps-Menue des TVs
|
1. App erscheint als **"VideoKonverter"** im Apps-Menue des TVs
|
||||||
2. Beim **ersten Start**: Server-IP eingeben (z.B. `192.168.155.12:8080`)
|
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
|
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`)
|
4. Login mit TV-App Benutzerdaten (erstellt in der Admin-Oberflaeche)
|
||||||
|
|
||||||
## Wie funktioniert die App?
|
## Wie funktioniert die App?
|
||||||
|
|
||||||
|
|
@ -159,37 +133,25 @@ Die App auf dem TV muss NICHT neu installiert werden.
|
||||||
|
|
||||||
## Fehlerbehebung
|
## 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
|
### TV wird nicht gefunden
|
||||||
- Sind PC und TV im gleichen Netzwerk/VLAN?
|
- Sind PC und TV im gleichen Netzwerk/VLAN?
|
||||||
- Ist Developer Mode auf dem TV aktiviert + TV neugestartet?
|
- Ist Developer Mode auf dem TV aktiviert?
|
||||||
- Firewall auf dem PC: Port 26101 offen?
|
- Firewall auf dem PC deaktiviert/Port 26101 offen?
|
||||||
|
|
||||||
### Video startet langsam / nicht
|
### Installation schlaegt fehl
|
||||||
- Server laeuft? `curl http://<Server-IP>:8080/tv/`
|
- Zertifikat korrekt erstellt? (Samsung, nicht Tizen)
|
||||||
- AV1-Videos brauchen einen TV mit AV1-Unterstuetzung (ab ~2020)
|
- "Permit to install applications" ausgefuehrt?
|
||||||
- Streaming nutzt fragmented MP4 (`frag_keyframe+empty_moov+default_base_moof`)
|
- Alte Version erst deinstallieren: `sdb shell 0 vd_appuninstall vkTVApp001.VideoKonverter`
|
||||||
|
|
||||||
### App startet nicht / weisser Bildschirm
|
### App startet nicht / weisser Bildschirm
|
||||||
- Richtige Server-IP eingegeben?
|
- Server laeuft? `curl http://<Server-IP>:8080/tv/`
|
||||||
- Browser-Cache leeren: App deinstallieren und neu installieren
|
- Richtige IP eingegeben?
|
||||||
|
- Browser-Cache auf TV 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
|
## Links
|
||||||
|
|
||||||
- Samsung Developer Portal: https://developer.samsung.com/smarttv/develop
|
- Samsung Developer Portal: https://developer.samsung.com/smarttv/develop
|
||||||
- Tizen Studio Download: https://developer.tizen.org/development/tizen-studio/download
|
- 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
|
- Samsung TV Quick-Start Guide: https://developer.samsung.com/smarttv/develop/getting-started/quick-start-guide.html
|
||||||
|
- Jellyfin Tizen (aehnliches Projekt): https://github.com/jellyfin/jellyfin-tizen
|
||||||
|
- Samsung-Jellyfin-Installer (GUI): https://github.com/Jellyfin2Samsung/Samsung-Jellyfin-Installer
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,27 +0,0 @@
|
||||||
-----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-----
|
|
||||||
Binary file not shown.
|
|
@ -1,28 +0,0 @@
|
||||||
-----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.
|
|
@ -1,9 +1,9 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<widget xmlns="http://www.w3.org/ns/widgets" xmlns:tizen="http://tizen.org/ns/widgets"
|
<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">
|
id="http://data-it-solution.de/videokonverter" version="3.1.0" viewmodes="maximized">
|
||||||
|
|
||||||
<name>VideoKonverter</name>
|
<name>VideoKonverter</name>
|
||||||
<description>VideoKonverter TV-App - Serien und Filme streamen mit AVPlay Direct-Play</description>
|
<description>VideoKonverter TV-App - Serien und Filme streamen</description>
|
||||||
|
|
||||||
<author>data IT solution - Eduard Wisch</author>
|
<author>data IT solution - Eduard Wisch</author>
|
||||||
|
|
||||||
|
|
@ -19,14 +19,10 @@
|
||||||
<tizen:privilege name="http://tizen.org/privilege/tv.inputdevice"/>
|
<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/network.public"/>
|
||||||
<tizen:privilege name="http://developer.samsung.com/privilege/productinfo"/>
|
<tizen:privilege name="http://developer.samsung.com/privilege/productinfo"/>
|
||||||
<tizen:privilege name="http://developer.samsung.com/privilege/avplay"/>
|
|
||||||
|
|
||||||
<!-- Netzwerk-Zugriff erlauben (lokales Netz) -->
|
<!-- Netzwerk-Zugriff erlauben (lokales Netz) -->
|
||||||
<access origin="*" subdomains="true"/>
|
<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 -->
|
<!-- TV-spezifische Einstellungen -->
|
||||||
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable"
|
<tizen:setting screen-orientation="landscape" context-menu="enable" background-support="disable"
|
||||||
encryption="disable" install-location="auto" hwkey-event="enable"/>
|
encryption="disable" install-location="auto" hwkey-event="enable"/>
|
||||||
|
|
|
||||||
1050
tizen-app/index.html
1050
tizen-app/index.html
File diff suppressed because it is too large
Load diff
937
tools.yaml
937
tools.yaml
|
|
@ -1,937 +0,0 @@
|
||||||
---
|
|
||||||
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
|
|
||||||
|
|
@ -10,8 +10,6 @@ Mapping (VK_ Prefix):
|
||||||
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
|
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
|
||||||
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
|
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
|
||||||
Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR)
|
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 os
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -40,11 +38,6 @@ _ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
|
||||||
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
|
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
|
||||||
"VK_TARGET_CONTAINER": (("files", "target_container"), str),
|
"VK_TARGET_CONTAINER": (("files", "target_container"), str),
|
||||||
"VK_LOG_LEVEL": (("logging", "level"), 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
|
# Rueckwaertskompatibilitaet
|
||||||
|
|
@ -103,14 +96,6 @@ _DEFAULT_SETTINGS: dict = {
|
||||||
"tvdb_language": "deu",
|
"tvdb_language": "deu",
|
||||||
"tvdb_pin": "",
|
"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": {
|
"cleanup": {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
|
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
|
||||||
|
|
@ -223,8 +208,8 @@ class Config:
|
||||||
|
|
||||||
for env_key, ((section, key), val_type) in _ENV_MAP.items():
|
for env_key, ((section, key), val_type) in _ENV_MAP.items():
|
||||||
raw = os.environ.get(env_key)
|
raw = os.environ.get(env_key)
|
||||||
if raw is None or raw == "":
|
if raw is None:
|
||||||
continue # Leere ENV-Variablen ueberschreiben YAML nicht
|
continue
|
||||||
|
|
||||||
# Typ-Konvertierung
|
# Typ-Konvertierung
|
||||||
try:
|
try:
|
||||||
|
|
@ -343,10 +328,6 @@ class Config:
|
||||||
def cleanup_config(self) -> dict:
|
def cleanup_config(self) -> dict:
|
||||||
return self.settings.get("cleanup", {})
|
return self.settings.get("cleanup", {})
|
||||||
|
|
||||||
@property
|
|
||||||
def tv_config(self) -> dict:
|
|
||||||
return self.settings.get("tv", {})
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def server_config(self) -> dict:
|
def server_config(self) -> dict:
|
||||||
return self.settings.get("server", {})
|
return self.settings.get("server", {})
|
||||||
|
|
|
||||||
|
|
@ -141,61 +141,6 @@ def setup_api_routes(app: web.Application, config: Config,
|
||||||
logging.info(f"Preset '{preset_name}' aktualisiert")
|
logging.info(f"Preset '{preset_name}' aktualisiert")
|
||||||
return web.json_response({"message": f"Preset '{preset_name}' gespeichert"})
|
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 ---
|
# --- Statistics ---
|
||||||
|
|
||||||
async def get_statistics(request: web.Request) -> web.Response:
|
async def get_statistics(request: web.Request) -> web.Response:
|
||||||
|
|
@ -531,9 +476,7 @@ def setup_api_routes(app: web.Application, config: Config,
|
||||||
app.router.add_get("/api/settings", get_settings)
|
app.router.add_get("/api/settings", get_settings)
|
||||||
app.router.add_put("/api/settings", put_settings)
|
app.router.add_put("/api/settings", put_settings)
|
||||||
app.router.add_get("/api/presets", get_presets)
|
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_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/statistics", get_statistics)
|
||||||
app.router.add_get("/api/system", get_system_info)
|
app.router.add_get("/api/system", get_system_info)
|
||||||
app.router.add_get("/api/ws-config", get_ws_config)
|
app.router.add_get("/api/ws-config", get_ws_config)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""REST API Endpoints fuer die Video-Bibliothek"""
|
"""REST API Endpoints fuer die Video-Bibliothek"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import aiomysql
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
from app.services.library import LibraryService
|
from app.services.library import LibraryService
|
||||||
|
|
@ -151,53 +149,12 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
# === Serien ===
|
# === 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:
|
async def get_series(request: web.Request) -> web.Response:
|
||||||
"""GET /api/library/series - mit optionalem Codec-Badge"""
|
"""GET /api/library/series"""
|
||||||
path_id = request.query.get("path_id")
|
path_id = request.query.get("path_id")
|
||||||
if path_id:
|
if path_id:
|
||||||
path_id = int(path_id)
|
path_id = int(path_id)
|
||||||
series = await library_service.get_series_list(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})
|
return web.json_response({"series": series})
|
||||||
|
|
||||||
async def get_series_detail(request: web.Request) -> web.Response:
|
async def get_series_detail(request: web.Request) -> web.Response:
|
||||||
|
|
@ -365,93 +322,22 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
return web.json_response(results)
|
return web.json_response(results)
|
||||||
|
|
||||||
async def get_metadata_image(request: web.Request) -> web.Response:
|
async def get_metadata_image(request: web.Request) -> web.Response:
|
||||||
"""GET /api/library/metadata/{series_id}/{filename}?w=300
|
"""GET /api/library/metadata/{series_id}/{filename}"""
|
||||||
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"])
|
series_id = int(request.match_info["series_id"])
|
||||||
filename = request.match_info["filename"]
|
filename = request.match_info["filename"]
|
||||||
detail = await library_service.get_series_detail(series_id)
|
detail = await library_service.get_series_detail(series_id)
|
||||||
if not detail:
|
if not detail or not detail.get("folder_path"):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Nicht gefunden"}, status=404
|
{"error": "Nicht gefunden"}, status=404
|
||||||
)
|
)
|
||||||
|
import os
|
||||||
folder_path = detail.get("folder_path", "")
|
file_path = os.path.join(
|
||||||
meta_dir = os.path.join(folder_path, ".metadata") if folder_path else ""
|
detail["folder_path"], ".metadata", filename
|
||||||
file_path = os.path.join(meta_dir, filename) if meta_dir else ""
|
)
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
# Lokale Datei nicht vorhanden? On-demand von TVDB downloaden
|
return web.json_response(
|
||||||
if not file_path or not os.path.isfile(file_path):
|
{"error": "Datei nicht gefunden"}, status=404
|
||||||
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)
|
return web.FileResponse(file_path)
|
||||||
|
|
||||||
# === Filme ===
|
# === Filme ===
|
||||||
|
|
@ -1431,21 +1317,14 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
# === Video-Streaming ===
|
# === 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:
|
async def get_stream_video(request: web.Request) -> web.StreamResponse:
|
||||||
"""GET /api/library/videos/{video_id}/stream?quality=hd&audio=0&t=0
|
"""GET /api/library/videos/{video_id}/stream?t=0
|
||||||
Streamt Video mit konfigurierbarer Qualitaet und Audio-Spur.
|
Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC).
|
||||||
|
Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.).
|
||||||
Parameter:
|
Optional: ?t=120 fuer Seeking auf Sekunde 120."""
|
||||||
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 os
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
|
import shlex
|
||||||
|
|
||||||
video_id = int(request.match_info["video_id"])
|
video_id = int(request.match_info["video_id"])
|
||||||
|
|
||||||
|
|
@ -1457,98 +1336,39 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"SELECT file_path, width, height, video_codec, "
|
"SELECT file_path FROM library_videos WHERE id = %s",
|
||||||
"audio_tracks, container, file_size "
|
|
||||||
"FROM library_videos WHERE id = %s",
|
|
||||||
(video_id,)
|
(video_id,)
|
||||||
)
|
)
|
||||||
video = await cur.fetchone()
|
row = await cur.fetchone()
|
||||||
if not video:
|
if not row:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Video nicht gefunden"}, status=404
|
{"error": "Video nicht gefunden"}, status=404
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return web.json_response({"error": str(e)}, status=500)
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
file_path = video["file_path"]
|
file_path = row[0]
|
||||||
if not os.path.isfile(file_path):
|
if not os.path.isfile(file_path):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Datei nicht gefunden"}, status=404
|
{"error": "Datei nicht gefunden"}, status=404
|
||||||
)
|
)
|
||||||
|
|
||||||
# Audio-Tracks parsen
|
# Seek-Position (Sekunden) aus Query-Parameter
|
||||||
audio_tracks = video.get("audio_tracks") or "[]"
|
|
||||||
if isinstance(audio_tracks, str):
|
|
||||||
audio_tracks = json.loads(audio_tracks)
|
|
||||||
|
|
||||||
# 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"))
|
seek_sec = float(request.query.get("t", "0"))
|
||||||
sound_mode = request.query.get("sound", "stereo")
|
|
||||||
|
|
||||||
# Audio-Track bestimmen
|
# ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container
|
||||||
if audio_idx >= len(audio_tracks):
|
cmd = [
|
||||||
audio_idx = 0
|
"ffmpeg", "-hide_banner", "-loglevel", "error",
|
||||||
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:
|
if seek_sec > 0:
|
||||||
cmd += ["-ss", str(seek_sec)]
|
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 += [
|
cmd += [
|
||||||
"-movflags", "frag_keyframe+empty_moov+default_base_moof",
|
"-i", file_path,
|
||||||
"-frag_duration", "1000000",
|
"-c:v", "copy",
|
||||||
|
"-c:a", "aac", "-ac", "2", "-b:a", "192k",
|
||||||
|
"-movflags", "frag_keyframe+empty_moov+faststart",
|
||||||
"-f", "mp4",
|
"-f", "mp4",
|
||||||
"pipe:1",
|
"pipe:1",
|
||||||
]
|
]
|
||||||
|
|
@ -1579,6 +1399,7 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
try:
|
try:
|
||||||
await resp.write(chunk)
|
await resp.write(chunk)
|
||||||
except (ConnectionResetError, ConnectionAbortedError):
|
except (ConnectionResetError, ConnectionAbortedError):
|
||||||
|
# Client hat Verbindung geschlossen
|
||||||
break
|
break
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1591,455 +1412,6 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
await resp.write_eof()
|
await resp.write_eof()
|
||||||
return resp
|
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 ===
|
# === Import: Item zuordnen / ueberspringen ===
|
||||||
|
|
||||||
async def post_reassign_import_item(
|
async def post_reassign_import_item(
|
||||||
|
|
@ -2532,27 +1904,10 @@ def setup_library_routes(app: web.Application, config: Config,
|
||||||
"/api/library/import/{job_id}/overwrite-mode",
|
"/api/library/import/{job_id}/overwrite-mode",
|
||||||
put_overwrite_mode,
|
put_overwrite_mode,
|
||||||
)
|
)
|
||||||
# Video-Streaming, Untertitel, Video-Info
|
# Video-Streaming
|
||||||
app.router.add_get(
|
app.router.add_get(
|
||||||
"/api/library/videos/{video_id}/stream", get_stream_video
|
"/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)
|
# TVDB Auto-Match (Review-Modus)
|
||||||
app.router.add_post(
|
app.router.add_post(
|
||||||
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
"/api/library/tvdb-auto-match", post_tvdb_auto_match
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,6 @@ def setup_page_routes(app: web.Application, config: Config,
|
||||||
"gpu_devices": gpu_devices,
|
"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")
|
@aiohttp_jinja2.template("library.html")
|
||||||
async def library(request: web.Request) -> dict:
|
async def library(request: web.Request) -> dict:
|
||||||
"""GET /library - Bibliothek"""
|
"""GET /library - Bibliothek"""
|
||||||
|
|
@ -138,84 +132,6 @@ def setup_page_routes(app: web.Application, config: Config,
|
||||||
content_type="text/html",
|
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")
|
@aiohttp_jinja2.template("partials/stats_table.html")
|
||||||
async def htmx_stats_table(request: web.Request) -> dict:
|
async def htmx_stats_table(request: web.Request) -> dict:
|
||||||
"""GET /htmx/stats?page=1 - Paginierte Statistik"""
|
"""GET /htmx/stats?page=1 - Paginierte Statistik"""
|
||||||
|
|
@ -239,9 +155,6 @@ def setup_page_routes(app: web.Application, config: Config,
|
||||||
app.router.add_get("/dashboard", dashboard)
|
app.router.add_get("/dashboard", dashboard)
|
||||||
app.router.add_get("/library", library)
|
app.router.add_get("/library", library)
|
||||||
app.router.add_get("/admin", admin)
|
app.router.add_get("/admin", admin)
|
||||||
app.router.add_get("/tv-admin", tv_admin)
|
|
||||||
app.router.add_get("/statistics", statistics)
|
app.router.add_get("/statistics", statistics)
|
||||||
app.router.add_post("/htmx/settings", htmx_save_settings)
|
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)
|
app.router.add_get("/htmx/stats", htmx_stats_table)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,7 @@ class WebSocketManager:
|
||||||
|
|
||||||
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
"""WebSocket-Endpoint Handler"""
|
"""WebSocket-Endpoint Handler"""
|
||||||
ws = web.WebSocketResponse(heartbeat=30.0)
|
ws = web.WebSocketResponse()
|
||||||
await ws.prepare(request)
|
await ws.prepare(request)
|
||||||
self.clients.add(ws)
|
self.clients.add(ws)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App"""
|
"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
import aiohttp_jinja2
|
import aiohttp_jinja2
|
||||||
|
|
@ -17,8 +15,6 @@ from app.services.tvdb import TVDBService
|
||||||
from app.services.cleaner import CleanerService
|
from app.services.cleaner import CleanerService
|
||||||
from app.services.importer import ImporterService
|
from app.services.importer import ImporterService
|
||||||
from app.services.auth import AuthService
|
from app.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.api import setup_api_routes
|
||||||
from app.routes.library_api import setup_library_routes
|
from app.routes.library_api import setup_library_routes
|
||||||
from app.routes.pages import setup_page_routes
|
from app.routes.pages import setup_page_routes
|
||||||
|
|
@ -56,24 +52,12 @@ class VideoKonverterServer:
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def _no_cache_middleware(self, request: web.Request,
|
async def _no_cache_middleware(self, request: web.Request,
|
||||||
handler) -> web.Response:
|
handler) -> web.Response:
|
||||||
"""Verhindert Browser-Caching fuer API-Responses + Error-Logging"""
|
"""Verhindert Browser-Caching fuer API-Responses"""
|
||||||
try:
|
response = await handler(request)
|
||||||
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/"):
|
if request.path.startswith("/api/"):
|
||||||
# Thumbnail-Bilder sollen gecacht werden (Cache-Control vom Handler)
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||||
if "/thumbnail" not in request.path:
|
response.headers["Pragma"] = "no-cache"
|
||||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
response.headers["Expires"] = "0"
|
||||||
response.headers["Pragma"] = "no-cache"
|
|
||||||
response.headers["Expires"] = "0"
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _setup_app(self) -> None:
|
def _setup_app(self) -> None:
|
||||||
|
|
@ -84,22 +68,8 @@ class VideoKonverterServer:
|
||||||
self.app,
|
self.app,
|
||||||
loader=jinja2.FileSystemLoader(str(template_dir)),
|
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||||
context_processors=[aiohttp_jinja2.request_processor],
|
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
|
# WebSocket Route
|
||||||
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
||||||
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
||||||
|
|
@ -120,18 +90,8 @@ class VideoKonverterServer:
|
||||||
# Seiten Routes
|
# Seiten Routes
|
||||||
setup_page_routes(self.app, self.config, self.queue_service)
|
setup_page_routes(self.app, self.config, self.queue_service)
|
||||||
|
|
||||||
# TV-App Routes (Auth-Service, DB-Pool wird in on_startup gesetzt)
|
# TV-App Routes (Auth-Service wird spaeter mit DB-Pool initialisiert)
|
||||||
async def _lazy_pool():
|
self.auth_service = None
|
||||||
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
|
# Statische Dateien
|
||||||
static_dir = Path(__file__).parent / "static"
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
|
@ -185,12 +145,16 @@ class VideoKonverterServer:
|
||||||
await self.tvdb_service.init_db()
|
await self.tvdb_service.init_db()
|
||||||
await self.importer_service.init_db()
|
await self.importer_service.init_db()
|
||||||
|
|
||||||
# TV-App Auth-Service: DB-Tabellen initialisieren (Pool kommt ueber lazy getter)
|
# TV-App Auth-Service initialisieren (braucht DB-Pool)
|
||||||
if self.library_service._db_pool:
|
if self.library_service._db_pool:
|
||||||
|
async def _get_pool():
|
||||||
|
return self.library_service._db_pool
|
||||||
|
self.auth_service = AuthService(_get_pool)
|
||||||
await self.auth_service.init_db()
|
await self.auth_service.init_db()
|
||||||
|
setup_tv_routes(
|
||||||
# HLS Session Manager starten
|
self.app, self.config,
|
||||||
await self.hls_manager.start()
|
self.auth_service, self.library_service,
|
||||||
|
)
|
||||||
|
|
||||||
host = self.config.server_config.get("host", "0.0.0.0")
|
host = self.config.server_config.get("host", "0.0.0.0")
|
||||||
port = self.config.server_config.get("port", 8080)
|
port = self.config.server_config.get("port", 8080)
|
||||||
|
|
@ -198,7 +162,6 @@ class VideoKonverterServer:
|
||||||
|
|
||||||
async def _on_shutdown(self, app: web.Application) -> None:
|
async def _on_shutdown(self, app: web.Application) -> None:
|
||||||
"""Server-Stop: Queue und Library stoppen"""
|
"""Server-Stop: Queue und Library stoppen"""
|
||||||
await self.hls_manager.stop()
|
|
||||||
await self.queue_service.stop()
|
await self.queue_service.stop()
|
||||||
await self.library_service.stop()
|
await self.library_service.stop()
|
||||||
logging.info("Server heruntergefahren")
|
logging.info("Server heruntergefahren")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ class AuthService:
|
||||||
self._get_pool = db_pool_getter
|
self._get_pool = db_pool_getter
|
||||||
|
|
||||||
async def init_db(self) -> None:
|
async def init_db(self) -> None:
|
||||||
"""Erstellt DB-Tabellen fuer TV-Auth und migriert bestehende"""
|
"""Erstellt DB-Tabellen fuer TV-Auth"""
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
logging.error("Auth: Kein DB-Pool verfuegbar")
|
logging.error("Auth: Kein DB-Pool verfuegbar")
|
||||||
|
|
@ -24,7 +24,6 @@ class AuthService:
|
||||||
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
# === Bestehende Tabellen ===
|
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS tv_users (
|
CREATE TABLE IF NOT EXISTS tv_users (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
|
@ -65,192 +64,10 @@ class AuthService:
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# === Neue Tabellen (v4.0) ===
|
|
||||||
|
|
||||||
# Client-Einstellungen (pro Geraet/Browser)
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_clients (
|
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
|
||||||
name VARCHAR(128) DEFAULT NULL,
|
|
||||||
sound_mode ENUM('stereo','surround','original')
|
|
||||||
DEFAULT 'stereo',
|
|
||||||
stream_quality ENUM('uhd','hd','sd','low')
|
|
||||||
DEFAULT 'hd',
|
|
||||||
audio_compressor BOOLEAN DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Spalte audio_compressor hinzufuegen (Migration fuer bestehende DBs)
|
|
||||||
await cur.execute("""
|
|
||||||
ALTER TABLE tv_clients
|
|
||||||
ADD COLUMN IF NOT EXISTS audio_compressor BOOLEAN DEFAULT FALSE
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Merkliste (Watchlist)
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_watchlist (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
series_id INT NULL,
|
|
||||||
movie_id INT NULL,
|
|
||||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
|
||||||
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Manueller Watch-Status
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_watch_status (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
video_id INT NULL,
|
|
||||||
series_id INT NULL,
|
|
||||||
season_key VARCHAR(64) NULL,
|
|
||||||
status ENUM('unwatched','watching','watched')
|
|
||||||
DEFAULT 'unwatched',
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_user (user_id),
|
|
||||||
UNIQUE INDEX idx_user_video (user_id, video_id),
|
|
||||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
|
||||||
UNIQUE INDEX idx_user_season (user_id, season_key),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Episoden-Thumbnails Cache
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_episode_thumbnails (
|
|
||||||
video_id INT PRIMARY KEY,
|
|
||||||
thumbnail_path VARCHAR(1024) NOT NULL,
|
|
||||||
source ENUM('tvdb','ffmpeg') DEFAULT 'ffmpeg',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Such-History (pro User)
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_search_history (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
query VARCHAR(256) NOT NULL,
|
|
||||||
searched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_user (user_id),
|
|
||||||
INDEX idx_query (query(64)),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Bewertungen (pro User, fuer Serien und Filme)
|
|
||||||
await cur.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS tv_ratings (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
series_id INT NULL,
|
|
||||||
movie_id INT NULL,
|
|
||||||
rating TINYINT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
UNIQUE INDEX idx_user_series (user_id, series_id),
|
|
||||||
UNIQUE INDEX idx_user_movie (user_id, movie_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES tv_users(id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
|
||||||
""")
|
|
||||||
|
|
||||||
# === Migration: Neue Spalten zu bestehenden Tabellen ===
|
|
||||||
await self._migrate_columns(cur)
|
|
||||||
|
|
||||||
# Standard-Admin erstellen falls keine User existieren
|
# Standard-Admin erstellen falls keine User existieren
|
||||||
await self._ensure_default_admin()
|
await self._ensure_default_admin()
|
||||||
logging.info("TV-Auth: DB-Tabellen initialisiert")
|
logging.info("TV-Auth: DB-Tabellen initialisiert")
|
||||||
|
|
||||||
async def _migrate_columns(self, cur) -> None:
|
|
||||||
"""Fuegt neue Spalten zu bestehenden Tabellen hinzu (idempotent)"""
|
|
||||||
# Hilfsfunktion: Spalte hinzufuegen falls nicht vorhanden
|
|
||||||
async def add_column(table: str, column: str, definition: str):
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT COUNT(*) FROM information_schema.COLUMNS "
|
|
||||||
"WHERE TABLE_SCHEMA = DATABASE() "
|
|
||||||
"AND TABLE_NAME = %s AND COLUMN_NAME = %s",
|
|
||||||
(table, column)
|
|
||||||
)
|
|
||||||
row = await cur.fetchone()
|
|
||||||
if row[0] == 0:
|
|
||||||
await cur.execute(
|
|
||||||
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
|
||||||
)
|
|
||||||
logging.info(f"TV-Auth: Spalte {table}.{column} hinzugefuegt")
|
|
||||||
|
|
||||||
# tv_users: User-Einstellungen
|
|
||||||
await add_column("tv_users", "preferred_audio_lang",
|
|
||||||
"VARCHAR(8) DEFAULT 'deu'")
|
|
||||||
await add_column("tv_users", "preferred_subtitle_lang",
|
|
||||||
"VARCHAR(8) DEFAULT NULL")
|
|
||||||
await add_column("tv_users", "subtitles_enabled",
|
|
||||||
"TINYINT DEFAULT 0")
|
|
||||||
await add_column("tv_users", "ui_lang",
|
|
||||||
"VARCHAR(8) DEFAULT 'de'")
|
|
||||||
await add_column("tv_users", "series_view",
|
|
||||||
"VARCHAR(16) DEFAULT 'grid'")
|
|
||||||
await add_column("tv_users", "movies_view",
|
|
||||||
"VARCHAR(16) DEFAULT 'grid'")
|
|
||||||
await add_column("tv_users", "avatar_color",
|
|
||||||
"VARCHAR(7) DEFAULT '#64b5f6'")
|
|
||||||
# Auto-Play Einstellungen
|
|
||||||
await add_column("tv_users", "autoplay_enabled",
|
|
||||||
"TINYINT DEFAULT 1")
|
|
||||||
await add_column("tv_users", "autoplay_countdown_sec",
|
|
||||||
"INT DEFAULT 10")
|
|
||||||
await add_column("tv_users", "autoplay_max_episodes",
|
|
||||||
"INT DEFAULT 0")
|
|
||||||
|
|
||||||
# tv_sessions: Client-Referenz und permanente Sessions
|
|
||||||
await add_column("tv_sessions", "client_id",
|
|
||||||
"VARCHAR(64) DEFAULT NULL")
|
|
||||||
await add_column("tv_sessions", "expires_at",
|
|
||||||
"TIMESTAMP NULL DEFAULT NULL")
|
|
||||||
|
|
||||||
# tvdb_episode_cache: Beschreibung und Bild-URL
|
|
||||||
await add_column("tvdb_episode_cache", "overview",
|
|
||||||
"TEXT DEFAULT NULL")
|
|
||||||
await add_column("tvdb_episode_cache", "image_url",
|
|
||||||
"VARCHAR(1024) DEFAULT NULL")
|
|
||||||
|
|
||||||
# tv_users: Theme
|
|
||||||
await add_column("tv_users", "theme",
|
|
||||||
"VARCHAR(16) DEFAULT 'dark'")
|
|
||||||
|
|
||||||
# tv_users: Technische Metadaten in Serien-Detail anzeigen
|
|
||||||
await add_column("tv_users", "show_tech_info",
|
|
||||||
"TINYINT DEFAULT 0")
|
|
||||||
|
|
||||||
# tv_users: Startseiten-Rubriken konfigurierbar
|
|
||||||
await add_column("tv_users", "home_show_continue",
|
|
||||||
"TINYINT DEFAULT 1")
|
|
||||||
await add_column("tv_users", "home_show_new",
|
|
||||||
"TINYINT DEFAULT 1")
|
|
||||||
await add_column("tv_users", "home_hide_watched",
|
|
||||||
"TINYINT DEFAULT 1")
|
|
||||||
await add_column("tv_users", "home_show_watched",
|
|
||||||
"TINYINT DEFAULT 1")
|
|
||||||
|
|
||||||
# library_series: TVDB-Score (externe Bewertung 0-100)
|
|
||||||
await add_column("library_series", "tvdb_score",
|
|
||||||
"FLOAT DEFAULT NULL")
|
|
||||||
|
|
||||||
# library_movies: TVDB-Score (externe Bewertung 0-100)
|
|
||||||
await add_column("library_movies", "tvdb_score",
|
|
||||||
"FLOAT DEFAULT NULL")
|
|
||||||
|
|
||||||
async def _ensure_default_admin(self) -> None:
|
async def _ensure_default_admin(self) -> None:
|
||||||
"""Erstellt admin/admin falls keine User existieren"""
|
"""Erstellt admin/admin falls keine User existieren"""
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
|
|
@ -274,7 +91,6 @@ class AuthService:
|
||||||
display_name: str = None, is_admin: bool = False,
|
display_name: str = None, is_admin: bool = False,
|
||||||
can_view_series: bool = True,
|
can_view_series: bool = True,
|
||||||
can_view_movies: bool = True,
|
can_view_movies: bool = True,
|
||||||
show_tech_info: bool = False,
|
|
||||||
allowed_paths: list = None) -> Optional[int]:
|
allowed_paths: list = None) -> Optional[int]:
|
||||||
"""Erstellt neuen User, gibt ID zurueck"""
|
"""Erstellt neuen User, gibt ID zurueck"""
|
||||||
pw_hash = bcrypt.hashpw(
|
pw_hash = bcrypt.hashpw(
|
||||||
|
|
@ -291,12 +107,10 @@ class AuthService:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
INSERT INTO tv_users
|
INSERT INTO tv_users
|
||||||
(username, password_hash, display_name, is_admin,
|
(username, password_hash, display_name, is_admin,
|
||||||
can_view_series, can_view_movies, show_tech_info,
|
can_view_series, can_view_movies, allowed_paths)
|
||||||
allowed_paths)
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
||||||
""", (username, pw_hash, display_name, int(is_admin),
|
""", (username, pw_hash, display_name, int(is_admin),
|
||||||
int(can_view_series), int(can_view_movies),
|
int(can_view_series), int(can_view_movies), paths_json))
|
||||||
int(show_tech_info), paths_json))
|
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}")
|
logging.error(f"TV-Auth: User erstellen fehlgeschlagen: {e}")
|
||||||
|
|
@ -319,8 +133,7 @@ class AuthService:
|
||||||
values.append(pw_hash)
|
values.append(pw_hash)
|
||||||
|
|
||||||
for field in ("display_name", "is_admin",
|
for field in ("display_name", "is_admin",
|
||||||
"can_view_series", "can_view_movies",
|
"can_view_series", "can_view_movies"):
|
||||||
"show_tech_info"):
|
|
||||||
if field in kwargs:
|
if field in kwargs:
|
||||||
updates.append(f"{field} = %s")
|
updates.append(f"{field} = %s")
|
||||||
val = kwargs[field]
|
val = kwargs[field]
|
||||||
|
|
@ -374,8 +187,8 @@ class AuthService:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT id, username, display_name, is_admin,
|
SELECT id, username, display_name, is_admin,
|
||||||
can_view_series, can_view_movies, show_tech_info,
|
can_view_series, can_view_movies, allowed_paths,
|
||||||
allowed_paths, last_login, created_at
|
last_login, created_at
|
||||||
FROM tv_users ORDER BY id
|
FROM tv_users ORDER BY id
|
||||||
""")
|
""")
|
||||||
rows = await cur.fetchall()
|
rows = await cur.fetchall()
|
||||||
|
|
@ -441,41 +254,22 @@ class AuthService:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def create_session(self, user_id: int,
|
async def create_session(self, user_id: int,
|
||||||
user_agent: str = "",
|
user_agent: str = "") -> str:
|
||||||
client_id: str = "",
|
"""Erstellt Session, gibt Token zurueck"""
|
||||||
persistent: bool = False) -> str:
|
|
||||||
"""Erstellt Session, gibt Token zurueck.
|
|
||||||
persistent=True -> Session laeuft nie ab (expires_at=NULL)"""
|
|
||||||
session_id = secrets.token_urlsafe(48)
|
session_id = secrets.token_urlsafe(48)
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return ""
|
return ""
|
||||||
# Nicht-persistente Sessions laufen nach 30 Tagen ab
|
|
||||||
expires = None if persistent else "DATE_ADD(NOW(), INTERVAL 30 DAY)"
|
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
if persistent:
|
await cur.execute("""
|
||||||
await cur.execute("""
|
INSERT INTO tv_sessions (id, user_id, user_agent)
|
||||||
INSERT INTO tv_sessions
|
VALUES (%s, %s, %s)
|
||||||
(id, user_id, user_agent, client_id, expires_at)
|
""", (session_id, user_id, user_agent[:512] if user_agent else ""))
|
||||||
VALUES (%s, %s, %s, %s, NULL)
|
|
||||||
""", (session_id, user_id,
|
|
||||||
user_agent[:512] if user_agent else "",
|
|
||||||
client_id or None))
|
|
||||||
else:
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT INTO tv_sessions
|
|
||||||
(id, user_id, user_agent, client_id, expires_at)
|
|
||||||
VALUES (%s, %s, %s, %s,
|
|
||||||
DATE_ADD(NOW(), INTERVAL 30 DAY))
|
|
||||||
""", (session_id, user_id,
|
|
||||||
user_agent[:512] if user_agent else "",
|
|
||||||
client_id or None))
|
|
||||||
return session_id
|
return session_id
|
||||||
|
|
||||||
async def validate_session(self, session_id: str) -> Optional[dict]:
|
async def validate_session(self, session_id: str) -> Optional[dict]:
|
||||||
"""Prueft Session, gibt User-Dict mit Einstellungen zurueck oder None.
|
"""Prueft Session, gibt User-Dict zurueck oder None"""
|
||||||
Beruecksichtigt expires_at (NULL = permanent, sonst Ablauf-Datum)."""
|
|
||||||
if not session_id:
|
if not session_id:
|
||||||
return None
|
return None
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
|
|
@ -485,20 +279,11 @@ class AuthService:
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
SELECT u.id, u.username, u.display_name, u.is_admin,
|
SELECT u.id, u.username, u.display_name, u.is_admin,
|
||||||
u.can_view_series, u.can_view_movies,
|
u.can_view_series, u.can_view_movies, u.allowed_paths
|
||||||
u.allowed_paths,
|
|
||||||
u.preferred_audio_lang, u.preferred_subtitle_lang,
|
|
||||||
u.subtitles_enabled, u.ui_lang,
|
|
||||||
u.series_view, u.movies_view, u.avatar_color,
|
|
||||||
u.autoplay_enabled, u.autoplay_countdown_sec,
|
|
||||||
u.autoplay_max_episodes, u.theme,
|
|
||||||
u.home_show_continue, u.home_show_new,
|
|
||||||
u.home_hide_watched, u.home_show_watched,
|
|
||||||
s.client_id
|
|
||||||
FROM tv_sessions s
|
FROM tv_sessions s
|
||||||
JOIN tv_users u ON s.user_id = u.id
|
JOIN tv_users u ON s.user_id = u.id
|
||||||
WHERE s.id = %s
|
WHERE s.id = %s
|
||||||
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
AND s.created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
|
||||||
""", (session_id,))
|
""", (session_id,))
|
||||||
user = await cur.fetchone()
|
user = await cur.fetchone()
|
||||||
|
|
||||||
|
|
@ -526,8 +311,7 @@ class AuthService:
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cleanup_old_sessions(self) -> int:
|
async def cleanup_old_sessions(self) -> int:
|
||||||
"""Loescht abgelaufene Sessions (expires_at abgelaufen).
|
"""Loescht Sessions aelter als 30 Tage"""
|
||||||
Persistente Sessions (expires_at IS NULL) werden nie geloescht."""
|
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -535,494 +319,18 @@ class AuthService:
|
||||||
async with conn.cursor() as cur:
|
async with conn.cursor() as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"DELETE FROM tv_sessions "
|
"DELETE FROM tv_sessions "
|
||||||
"WHERE expires_at IS NOT NULL AND expires_at < NOW()"
|
"WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)"
|
||||||
)
|
)
|
||||||
return cur.rowcount
|
return cur.rowcount
|
||||||
|
|
||||||
# --- Client-Verwaltung (pro Geraet) ---
|
|
||||||
|
|
||||||
async def get_or_create_client(self, client_id: str = None) -> str:
|
|
||||||
"""Gibt bestehende oder neue Client-ID zurueck"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return ""
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if client_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT id FROM tv_clients WHERE id = %s",
|
|
||||||
(client_id,))
|
|
||||||
if await cur.fetchone():
|
|
||||||
await cur.execute(
|
|
||||||
"UPDATE tv_clients SET last_active = NOW() "
|
|
||||||
"WHERE id = %s", (client_id,))
|
|
||||||
return client_id
|
|
||||||
# Neuen Client erstellen
|
|
||||||
new_id = secrets.token_urlsafe(32)
|
|
||||||
await cur.execute(
|
|
||||||
"INSERT INTO tv_clients (id) VALUES (%s)",
|
|
||||||
(new_id,))
|
|
||||||
return new_id
|
|
||||||
|
|
||||||
async def get_client_settings(self, client_id: str) -> Optional[dict]:
|
|
||||||
"""Liest Client-Einstellungen"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return None
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT * FROM tv_clients WHERE id = %s",
|
|
||||||
(client_id,))
|
|
||||||
return await cur.fetchone()
|
|
||||||
|
|
||||||
async def update_client_settings(self, client_id: str,
|
|
||||||
**kwargs) -> bool:
|
|
||||||
"""Aktualisiert Client-Einstellungen (name, sound_mode, stream_quality)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
allowed = {"name", "sound_mode", "stream_quality", "audio_compressor"}
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
for key, val in kwargs.items():
|
|
||||||
if key in allowed and val is not None:
|
|
||||||
updates.append(f"{key} = %s")
|
|
||||||
values.append(val)
|
|
||||||
if not updates:
|
|
||||||
return False
|
|
||||||
values.append(client_id)
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute(
|
|
||||||
f"UPDATE tv_clients SET {', '.join(updates)} "
|
|
||||||
"WHERE id = %s", tuple(values))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Client-Settings fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- Multi-User: Profile auf dem selben Geraet ---
|
|
||||||
|
|
||||||
async def get_client_profiles(self, client_id: str) -> list[dict]:
|
|
||||||
"""Alle eingeloggten User auf einem Client (fuer Quick-Switch)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return []
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
||||||
await cur.execute("""
|
|
||||||
SELECT s.id as session_id, u.id as user_id,
|
|
||||||
u.username, u.display_name, u.avatar_color
|
|
||||||
FROM tv_sessions s
|
|
||||||
JOIN tv_users u ON s.user_id = u.id
|
|
||||||
WHERE s.client_id = %s
|
|
||||||
AND (s.expires_at IS NULL OR s.expires_at > NOW())
|
|
||||||
ORDER BY s.last_active DESC
|
|
||||||
""", (client_id,))
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
async def get_all_users(self) -> list[dict]:
|
|
||||||
"""Alle User laden (fuer Profilauswahl)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return []
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
||||||
await cur.execute("""
|
|
||||||
SELECT id, username, display_name, avatar_color
|
|
||||||
FROM tv_users
|
|
||||||
ORDER BY id
|
|
||||||
""")
|
|
||||||
return await cur.fetchall()
|
|
||||||
|
|
||||||
# --- User-Einstellungen ---
|
|
||||||
|
|
||||||
async def update_user_settings(self, user_id: int,
|
|
||||||
**kwargs) -> bool:
|
|
||||||
"""Aktualisiert User-Einstellungen (Sprache, Ansichten, Auto-Play)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
allowed = {
|
|
||||||
"preferred_audio_lang", "preferred_subtitle_lang",
|
|
||||||
"subtitles_enabled", "ui_lang",
|
|
||||||
"series_view", "movies_view", "avatar_color",
|
|
||||||
"autoplay_enabled", "autoplay_countdown_sec",
|
|
||||||
"autoplay_max_episodes", "display_name", "theme",
|
|
||||||
"home_show_continue", "home_show_new",
|
|
||||||
"home_hide_watched", "home_show_watched",
|
|
||||||
}
|
|
||||||
updates = []
|
|
||||||
values = []
|
|
||||||
for key, val in kwargs.items():
|
|
||||||
if key in allowed:
|
|
||||||
updates.append(f"{key} = %s")
|
|
||||||
if isinstance(val, bool):
|
|
||||||
val = int(val)
|
|
||||||
values.append(val)
|
|
||||||
if not updates:
|
|
||||||
return False
|
|
||||||
values.append(user_id)
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute(
|
|
||||||
f"UPDATE tv_users SET {', '.join(updates)} "
|
|
||||||
"WHERE id = %s", tuple(values))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Einstellungen fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- Watchlist (Merkliste) ---
|
|
||||||
|
|
||||||
async def add_to_watchlist(self, user_id: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> bool:
|
|
||||||
"""Fuegt Serie oder Film zur Merkliste hinzu"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT IGNORE INTO tv_watchlist
|
|
||||||
(user_id, series_id, movie_id)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
""", (user_id, series_id, movie_id))
|
|
||||||
return cur.rowcount > 0
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Watchlist hinzufuegen fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def remove_from_watchlist(self, user_id: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> bool:
|
|
||||||
"""Entfernt Serie oder Film von der Merkliste"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
elif movie_id:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND movie_id = %s",
|
|
||||||
(user_id, movie_id))
|
|
||||||
return cur.rowcount > 0
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Watchlist entfernen fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def toggle_watchlist(self, user_id: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> bool:
|
|
||||||
"""Toggle: Hinzufuegen wenn nicht vorhanden, entfernen wenn schon drin.
|
|
||||||
Gibt True zurueck wenn jetzt in der Liste, False wenn entfernt."""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
# Pruefen ob schon in Liste
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT id FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
else:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT id FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND movie_id = %s",
|
|
||||||
(user_id, movie_id))
|
|
||||||
exists = await cur.fetchone()
|
|
||||||
if exists:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_watchlist WHERE id = %s",
|
|
||||||
(exists[0],))
|
|
||||||
return False # Entfernt
|
|
||||||
else:
|
|
||||||
await cur.execute(
|
|
||||||
"INSERT INTO tv_watchlist "
|
|
||||||
"(user_id, series_id, movie_id) VALUES (%s, %s, %s)",
|
|
||||||
(user_id, series_id, movie_id))
|
|
||||||
return True # Hinzugefuegt
|
|
||||||
|
|
||||||
async def get_watchlist(self, user_id: int) -> dict:
|
|
||||||
"""Gibt Merkliste zurueck (Serien + Filme)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return {"series": [], "movies": []}
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
|
||||||
# Serien
|
|
||||||
await cur.execute("""
|
|
||||||
SELECT w.id as watchlist_id, w.added_at,
|
|
||||||
s.id, s.title, s.folder_name, s.poster_url,
|
|
||||||
s.genres, s.overview
|
|
||||||
FROM tv_watchlist w
|
|
||||||
JOIN library_series s ON w.series_id = s.id
|
|
||||||
WHERE w.user_id = %s AND w.series_id IS NOT NULL
|
|
||||||
ORDER BY w.added_at DESC
|
|
||||||
""", (user_id,))
|
|
||||||
series = await cur.fetchall()
|
|
||||||
# Filme
|
|
||||||
await cur.execute("""
|
|
||||||
SELECT w.id as watchlist_id, w.added_at,
|
|
||||||
m.id, m.title, m.folder_name, m.poster_url,
|
|
||||||
m.year, m.genres, m.overview
|
|
||||||
FROM tv_watchlist w
|
|
||||||
JOIN library_movies m ON w.movie_id = m.id
|
|
||||||
WHERE w.user_id = %s AND w.movie_id IS NOT NULL
|
|
||||||
ORDER BY w.added_at DESC
|
|
||||||
""", (user_id,))
|
|
||||||
movies = await cur.fetchall()
|
|
||||||
return {"series": series, "movies": movies}
|
|
||||||
|
|
||||||
async def is_in_watchlist(self, user_id: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> bool:
|
|
||||||
"""Prueft ob Serie/Film in der Merkliste ist"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT 1 FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
else:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT 1 FROM tv_watchlist "
|
|
||||||
"WHERE user_id = %s AND movie_id = %s",
|
|
||||||
(user_id, movie_id))
|
|
||||||
return await cur.fetchone() is not None
|
|
||||||
|
|
||||||
# --- Watch-Status (manuell gesehen/nicht gesehen) ---
|
|
||||||
|
|
||||||
async def set_watch_status(self, user_id: int, status: str,
|
|
||||||
video_id: int = None,
|
|
||||||
series_id: int = None,
|
|
||||||
season_key: str = None) -> bool:
|
|
||||||
"""Setzt manuellen Watch-Status (unwatched/watching/watched)"""
|
|
||||||
if status not in ("unwatched", "watching", "watched"):
|
|
||||||
return False
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT INTO tv_watch_status
|
|
||||||
(user_id, video_id, series_id, season_key, status)
|
|
||||||
VALUES (%s, %s, %s, %s, %s)
|
|
||||||
ON DUPLICATE KEY UPDATE status = VALUES(status)
|
|
||||||
""", (user_id, video_id, series_id, season_key, status))
|
|
||||||
|
|
||||||
# Bei Staffel/Serie auch Einzel-Episoden aktualisieren
|
|
||||||
if series_id and not video_id and not season_key:
|
|
||||||
# Ganze Serie markieren
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT INTO tv_watch_status
|
|
||||||
(user_id, video_id, status)
|
|
||||||
SELECT %s, v.id, %s
|
|
||||||
FROM library_videos v
|
|
||||||
WHERE v.series_id = %s
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
status = VALUES(status)
|
|
||||||
""", (user_id, status, series_id))
|
|
||||||
elif season_key:
|
|
||||||
# Ganze Staffel markieren (format: "series_id:season")
|
|
||||||
parts = season_key.split(":")
|
|
||||||
if len(parts) == 2:
|
|
||||||
sid, sn = int(parts[0]), int(parts[1])
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT INTO tv_watch_status
|
|
||||||
(user_id, video_id, status)
|
|
||||||
SELECT %s, v.id, %s
|
|
||||||
FROM library_videos v
|
|
||||||
WHERE v.series_id = %s
|
|
||||||
AND v.season_number = %s
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
status = VALUES(status)
|
|
||||||
""", (user_id, status, sid, sn))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Watch-Status fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_watch_status(self, user_id: int,
|
|
||||||
video_id: int = None,
|
|
||||||
series_id: int = None) -> Optional[str]:
|
|
||||||
"""Gibt Watch-Status zurueck"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return None
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if video_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT status FROM tv_watch_status "
|
|
||||||
"WHERE user_id = %s AND video_id = %s",
|
|
||||||
(user_id, video_id))
|
|
||||||
elif series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT status FROM tv_watch_status "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
row = await cur.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
# --- Such-History ---
|
|
||||||
|
|
||||||
async def save_search(self, user_id: int, query: str) -> None:
|
|
||||||
"""Speichert Suchanfrage in der History"""
|
|
||||||
if not query or len(query) < 2:
|
|
||||||
return
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
# Duplikate vermeiden: gleiche Query aktualisieren
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_search_history "
|
|
||||||
"WHERE user_id = %s AND query = %s",
|
|
||||||
(user_id, query))
|
|
||||||
await cur.execute(
|
|
||||||
"INSERT INTO tv_search_history (user_id, query) "
|
|
||||||
"VALUES (%s, %s)", (user_id, query))
|
|
||||||
# Max. 50 Eintraege behalten
|
|
||||||
await cur.execute("""
|
|
||||||
DELETE FROM tv_search_history
|
|
||||||
WHERE user_id = %s AND id NOT IN (
|
|
||||||
SELECT id FROM (
|
|
||||||
SELECT id FROM tv_search_history
|
|
||||||
WHERE user_id = %s
|
|
||||||
ORDER BY searched_at DESC LIMIT 50
|
|
||||||
) t
|
|
||||||
)
|
|
||||||
""", (user_id, user_id))
|
|
||||||
|
|
||||||
async def get_search_history(self, user_id: int,
|
|
||||||
limit: int = 20) -> list[str]:
|
|
||||||
"""Gibt letzte Suchanfragen zurueck"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return []
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT query FROM tv_search_history "
|
|
||||||
"WHERE user_id = %s ORDER BY searched_at DESC LIMIT %s",
|
|
||||||
(user_id, limit))
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
return [r[0] for r in rows]
|
|
||||||
|
|
||||||
async def get_search_suggestions(self, user_id: int,
|
|
||||||
prefix: str,
|
|
||||||
limit: int = 8) -> list[str]:
|
|
||||||
"""Autocomplete: Vorschlaege aus History + Serien/Film-Titel"""
|
|
||||||
if not prefix or len(prefix) < 1:
|
|
||||||
return []
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return []
|
|
||||||
suggestions = []
|
|
||||||
search = f"{prefix}%"
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
# Aus Such-History
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT DISTINCT query FROM tv_search_history "
|
|
||||||
"WHERE user_id = %s AND query LIKE %s "
|
|
||||||
"ORDER BY searched_at DESC LIMIT %s",
|
|
||||||
(user_id, search, limit))
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
suggestions.extend(r[0] for r in rows)
|
|
||||||
# Aus Serien-Titeln
|
|
||||||
remaining = limit - len(suggestions)
|
|
||||||
if remaining > 0:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT title FROM library_series "
|
|
||||||
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
|
||||||
(search, remaining))
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
for r in rows:
|
|
||||||
if r[0] not in suggestions:
|
|
||||||
suggestions.append(r[0])
|
|
||||||
# Aus Film-Titeln
|
|
||||||
remaining = limit - len(suggestions)
|
|
||||||
if remaining > 0:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT title FROM library_movies "
|
|
||||||
"WHERE title LIKE %s ORDER BY title LIMIT %s",
|
|
||||||
(search, remaining))
|
|
||||||
rows = await cur.fetchall()
|
|
||||||
for r in rows:
|
|
||||||
if r[0] not in suggestions:
|
|
||||||
suggestions.append(r[0])
|
|
||||||
return suggestions[:limit]
|
|
||||||
|
|
||||||
async def clear_search_history(self, user_id: int) -> bool:
|
|
||||||
"""Loescht alle Suchanfragen eines Users"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_search_history WHERE user_id = %s",
|
|
||||||
(user_id,))
|
|
||||||
return True
|
|
||||||
|
|
||||||
# --- Fortschritt zuruecksetzen ---
|
|
||||||
|
|
||||||
async def reset_all_progress(self, user_id: int) -> bool:
|
|
||||||
"""Setzt ALLE Fortschritte und Status eines Users zurueck"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_watch_progress WHERE user_id = %s",
|
|
||||||
(user_id,))
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_watch_status WHERE user_id = %s",
|
|
||||||
(user_id,))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Reset fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# --- Watch-Progress ---
|
# --- Watch-Progress ---
|
||||||
|
|
||||||
async def save_progress(self, user_id: int, video_id: int,
|
async def save_progress(self, user_id: int, video_id: int,
|
||||||
position_sec: float,
|
position_sec: float,
|
||||||
duration_sec: float = 0) -> None:
|
duration_sec: float = 0) -> None:
|
||||||
"""Speichert Wiedergabe-Position"""
|
"""Speichert Wiedergabe-Position"""
|
||||||
if duration_sec > 0 and position_sec / duration_sec > 0.9:
|
completed = 1 if (duration_sec > 0 and
|
||||||
completed = 1
|
position_sec / duration_sec > 0.9) else 0
|
||||||
elif duration_sec > 0 and position_sec >= duration_sec:
|
|
||||||
completed = 1
|
|
||||||
else:
|
|
||||||
completed = 0
|
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
if not pool:
|
if not pool:
|
||||||
return
|
return
|
||||||
|
|
@ -1083,91 +391,3 @@ class AuthService:
|
||||||
row["updated_at"], "isoformat"):
|
row["updated_at"], "isoformat"):
|
||||||
row["updated_at"] = str(row["updated_at"])
|
row["updated_at"] = str(row["updated_at"])
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
# --- Bewertungen (Ratings) ---
|
|
||||||
|
|
||||||
async def set_rating(self, user_id: int, rating: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> bool:
|
|
||||||
"""Setzt User-Bewertung (1-5 Sterne). 0 = Bewertung loeschen."""
|
|
||||||
if rating < 0 or rating > 5:
|
|
||||||
return False
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if rating == 0:
|
|
||||||
# Bewertung loeschen
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_ratings "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
elif movie_id:
|
|
||||||
await cur.execute(
|
|
||||||
"DELETE FROM tv_ratings "
|
|
||||||
"WHERE user_id = %s AND movie_id = %s",
|
|
||||||
(user_id, movie_id))
|
|
||||||
else:
|
|
||||||
await cur.execute("""
|
|
||||||
INSERT INTO tv_ratings
|
|
||||||
(user_id, series_id, movie_id, rating)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
ON DUPLICATE KEY UPDATE rating = VALUES(rating)
|
|
||||||
""", (user_id, series_id, movie_id, rating))
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"TV-Auth: Rating fehlgeschlagen: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def get_rating(self, user_id: int,
|
|
||||||
series_id: int = None,
|
|
||||||
movie_id: int = None) -> int:
|
|
||||||
"""Gibt User-Rating zurueck (0 = keine Bewertung)"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return 0
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT rating FROM tv_ratings "
|
|
||||||
"WHERE user_id = %s AND series_id = %s",
|
|
||||||
(user_id, series_id))
|
|
||||||
elif movie_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT rating FROM tv_ratings "
|
|
||||||
"WHERE user_id = %s AND movie_id = %s",
|
|
||||||
(user_id, movie_id))
|
|
||||||
else:
|
|
||||||
return 0
|
|
||||||
row = await cur.fetchone()
|
|
||||||
return row[0] if row else 0
|
|
||||||
|
|
||||||
async def get_avg_rating(self, series_id: int = None,
|
|
||||||
movie_id: int = None) -> dict:
|
|
||||||
"""Gibt Durchschnittsbewertung + Anzahl zurueck"""
|
|
||||||
pool = await self._get_pool()
|
|
||||||
if not pool:
|
|
||||||
return {"avg": 0, "count": 0}
|
|
||||||
async with pool.acquire() as conn:
|
|
||||||
async with conn.cursor() as cur:
|
|
||||||
if series_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
|
||||||
"FROM tv_ratings WHERE series_id = %s AND rating > 0",
|
|
||||||
(series_id,))
|
|
||||||
elif movie_id:
|
|
||||||
await cur.execute(
|
|
||||||
"SELECT AVG(rating) as avg_r, COUNT(*) as cnt "
|
|
||||||
"FROM tv_ratings WHERE movie_id = %s AND rating > 0",
|
|
||||||
(movie_id,))
|
|
||||||
else:
|
|
||||||
return {"avg": 0, "count": 0}
|
|
||||||
row = await cur.fetchone()
|
|
||||||
return {
|
|
||||||
"avg": round(float(row[0] or 0), 1),
|
|
||||||
"count": int(row[1] or 0),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,509 +0,0 @@
|
||||||
"""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()
|
|
||||||
]
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
"""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
|
|
||||||
|
|
@ -12,8 +12,7 @@ import aiomysql
|
||||||
|
|
||||||
from app.config import Config
|
from app.config import Config
|
||||||
from app.services.library import (
|
from app.services.library import (
|
||||||
LibraryService, VIDEO_EXTENSIONS,
|
LibraryService, VIDEO_EXTENSIONS, RE_SXXEXX, RE_XXxXX
|
||||||
RE_SXXEXX_MULTI, RE_XXxXX_MULTI
|
|
||||||
)
|
)
|
||||||
from app.services.tvdb import TVDBService
|
from app.services.tvdb import TVDBService
|
||||||
from app.services.probe import ProbeService
|
from app.services.probe import ProbeService
|
||||||
|
|
@ -121,7 +120,6 @@ class ImporterService:
|
||||||
detected_series VARCHAR(256),
|
detected_series VARCHAR(256),
|
||||||
detected_season INT,
|
detected_season INT,
|
||||||
detected_episode INT,
|
detected_episode INT,
|
||||||
detected_episode_end INT NULL,
|
|
||||||
tvdb_series_id INT NULL,
|
tvdb_series_id INT NULL,
|
||||||
tvdb_series_name VARCHAR(256),
|
tvdb_series_name VARCHAR(256),
|
||||||
tvdb_episode_title VARCHAR(512),
|
tvdb_episode_title VARCHAR(512),
|
||||||
|
|
@ -140,20 +138,6 @@ class ImporterService:
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
""")
|
""")
|
||||||
logging.info("Import-Tabellen initialisiert")
|
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:
|
except Exception as e:
|
||||||
logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}")
|
logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}")
|
||||||
|
|
||||||
|
|
@ -332,7 +316,6 @@ class ImporterService:
|
||||||
series_name = info.get("series", "")
|
series_name = info.get("series", "")
|
||||||
season = info.get("season")
|
season = info.get("season")
|
||||||
episode = info.get("episode")
|
episode = info.get("episode")
|
||||||
episode_end = info.get("episode_end")
|
|
||||||
|
|
||||||
# Status: pending_series wenn Serie erkannt, sonst pending
|
# Status: pending_series wenn Serie erkannt, sonst pending
|
||||||
if series_name and season and episode:
|
if series_name and season and episode:
|
||||||
|
|
@ -349,13 +332,11 @@ class ImporterService:
|
||||||
detected_series = %s,
|
detected_series = %s,
|
||||||
detected_season = %s,
|
detected_season = %s,
|
||||||
detected_episode = %s,
|
detected_episode = %s,
|
||||||
detected_episode_end = %s,
|
|
||||||
status = %s,
|
status = %s,
|
||||||
conflict_reason = %s
|
conflict_reason = %s
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""", (
|
""", (
|
||||||
series_name, season, episode, episode_end,
|
series_name, season, episode, status,
|
||||||
status,
|
|
||||||
None if status == "pending_series"
|
None if status == "pending_series"
|
||||||
else "Serie/Staffel/Episode nicht erkannt",
|
else "Serie/Staffel/Episode nicht erkannt",
|
||||||
item["id"],
|
item["id"],
|
||||||
|
|
@ -446,7 +427,6 @@ class ImporterService:
|
||||||
for item in items:
|
for item in items:
|
||||||
season = item["detected_season"]
|
season = item["detected_season"]
|
||||||
episode = item["detected_episode"]
|
episode = item["detected_episode"]
|
||||||
episode_end = item.get("detected_episode_end")
|
|
||||||
|
|
||||||
# Episodentitel von TVDB
|
# Episodentitel von TVDB
|
||||||
tvdb_ep_title = ""
|
tvdb_ep_title = ""
|
||||||
|
|
@ -463,8 +443,7 @@ class ImporterService:
|
||||||
tvdb_name, season, episode,
|
tvdb_name, season, episode,
|
||||||
tvdb_ep_title, ext,
|
tvdb_ep_title, ext,
|
||||||
job["lib_path"],
|
job["lib_path"],
|
||||||
pattern, season_pat,
|
pattern, season_pat
|
||||||
episode_end=episode_end
|
|
||||||
)
|
)
|
||||||
target_path = os.path.join(target_dir, target_file)
|
target_path = os.path.join(target_dir, target_file)
|
||||||
|
|
||||||
|
|
@ -595,7 +574,6 @@ class ImporterService:
|
||||||
"series": staffel_info["series"],
|
"series": staffel_info["series"],
|
||||||
"season": staffel_info["season"],
|
"season": staffel_info["season"],
|
||||||
"episode": info_file["episode"],
|
"episode": info_file["episode"],
|
||||||
"episode_end": info_file.get("episode_end"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Dateiname hat S/E
|
# Dateiname hat S/E
|
||||||
|
|
@ -643,31 +621,25 @@ class ImporterService:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _parse_name(self, name: str) -> dict:
|
def _parse_name(self, name: str) -> dict:
|
||||||
"""Extrahiert Serienname, Staffel, Episode aus einem Namen.
|
"""Extrahiert Serienname, Staffel, Episode aus einem Namen"""
|
||||||
Unterstuetzt Doppelfolgen: S09E19E20, S01E01-E02, 1x01-02"""
|
result = {"series": "", "season": None, "episode": None}
|
||||||
result = {"series": "", "season": None, "episode": None,
|
|
||||||
"episode_end": None}
|
|
||||||
name_no_ext = os.path.splitext(name)[0]
|
name_no_ext = os.path.splitext(name)[0]
|
||||||
|
|
||||||
# S01E02 / Doppelfolge S01E01E02 Format
|
# S01E02 Format
|
||||||
m = RE_SXXEXX_MULTI.search(name)
|
m = RE_SXXEXX.search(name)
|
||||||
if m:
|
if m:
|
||||||
result["season"] = int(m.group(1))
|
result["season"] = int(m.group(1))
|
||||||
result["episode"] = int(m.group(2))
|
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)
|
sm = RE_SERIES_FROM_NAME.match(name_no_ext)
|
||||||
if sm:
|
if sm:
|
||||||
result["series"] = self._clean_name(sm.group(1))
|
result["series"] = self._clean_name(sm.group(1))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 1x02 / Doppelfolge 1x01-02 Format
|
# 1x02 Format
|
||||||
m = RE_XXxXX_MULTI.search(name)
|
m = RE_XXxXX.search(name)
|
||||||
if m:
|
if m:
|
||||||
result["season"] = int(m.group(1))
|
result["season"] = int(m.group(1))
|
||||||
result["episode"] = int(m.group(2))
|
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)
|
sm = RE_SERIES_FROM_XXx.match(name_no_ext)
|
||||||
if sm:
|
if sm:
|
||||||
result["series"] = self._clean_name(sm.group(1))
|
result["series"] = self._clean_name(sm.group(1))
|
||||||
|
|
@ -688,21 +660,14 @@ class ImporterService:
|
||||||
def _build_target(self, series: str, season: Optional[int],
|
def _build_target(self, series: str, season: Optional[int],
|
||||||
episode: Optional[int], title: str, ext: str,
|
episode: Optional[int], title: str, ext: str,
|
||||||
lib_path: str, pattern: str,
|
lib_path: str, pattern: str,
|
||||||
season_pattern: str,
|
season_pattern: str) -> tuple[str, str]:
|
||||||
episode_end: Optional[int] = None) -> tuple[str, str]:
|
"""Baut Ziel-Ordner und Dateiname nach Pattern"""
|
||||||
"""Baut Ziel-Ordner und Dateiname nach Pattern.
|
|
||||||
Unterstuetzt Doppelfolgen via episode_end."""
|
|
||||||
s = season or 1
|
s = season or 1
|
||||||
e = episode or 0
|
e = episode or 0
|
||||||
|
|
||||||
# Season-Ordner
|
# Season-Ordner
|
||||||
season_dir = season_pattern.format(season=s)
|
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
|
# Dateiname - kein Titel: ohne Titel-Teil, sonst mit
|
||||||
try:
|
try:
|
||||||
if title:
|
if title:
|
||||||
|
|
@ -710,18 +675,14 @@ class ImporterService:
|
||||||
series=series, season=s, episode=e,
|
series=series, season=s, episode=e,
|
||||||
title=title, ext=ext
|
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:
|
else:
|
||||||
filename = f"{series} - {ep_str}.{ext}"
|
# Ohne Titel: "Serie - S01E03.ext"
|
||||||
|
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
if title:
|
if title:
|
||||||
filename = f"{series} - {ep_str} - {title}.{ext}"
|
filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}"
|
||||||
else:
|
else:
|
||||||
filename = f"{series} - {ep_str}.{ext}"
|
filename = f"{series} - S{s:02d}E{e:02d}.{ext}"
|
||||||
|
|
||||||
# Ungueltige Zeichen entfernen
|
# Ungueltige Zeichen entfernen
|
||||||
for ch in ['<', '>', ':', '"', '|', '?', '*']:
|
for ch in ['<', '>', ':', '"', '|', '?', '*']:
|
||||||
|
|
@ -1263,7 +1224,6 @@ class ImporterService:
|
||||||
return False
|
return False
|
||||||
allowed = {
|
allowed = {
|
||||||
'detected_series', 'detected_season', 'detected_episode',
|
'detected_series', 'detected_season', 'detected_episode',
|
||||||
'detected_episode_end',
|
|
||||||
'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title',
|
'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title',
|
||||||
'target_path', 'target_filename', 'status'
|
'target_path', 'target_filename', 'status'
|
||||||
}
|
}
|
||||||
|
|
@ -1440,7 +1400,6 @@ class ImporterService:
|
||||||
for item in items:
|
for item in items:
|
||||||
season = item["detected_season"]
|
season = item["detected_season"]
|
||||||
episode = item["detected_episode"]
|
episode = item["detected_episode"]
|
||||||
ep_end = item.get("detected_episode_end")
|
|
||||||
|
|
||||||
# Episodentitel holen
|
# Episodentitel holen
|
||||||
tvdb_ep_title = ""
|
tvdb_ep_title = ""
|
||||||
|
|
@ -1461,8 +1420,7 @@ class ImporterService:
|
||||||
tvdb_name, season, episode,
|
tvdb_name, season, episode,
|
||||||
tvdb_ep_title, ext,
|
tvdb_ep_title, ext,
|
||||||
job["lib_path"],
|
job["lib_path"],
|
||||||
pattern, season_pat,
|
pattern, season_pat
|
||||||
episode_end=ep_end
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await cur.execute("""
|
await cur.execute("""
|
||||||
|
|
@ -1504,8 +1462,7 @@ class ImporterService:
|
||||||
"conflict_reason = 'Serie uebersprungen' "
|
"conflict_reason = 'Serie uebersprungen' "
|
||||||
"WHERE import_job_id = %s "
|
"WHERE import_job_id = %s "
|
||||||
"AND LOWER(detected_series) = LOWER(%s) "
|
"AND LOWER(detected_series) = LOWER(%s) "
|
||||||
"AND status IN ('pending', 'pending_series', "
|
"AND status IN ('pending', 'matched')",
|
||||||
"'matched')",
|
|
||||||
(job_id, detected_series)
|
(job_id, detected_series)
|
||||||
)
|
)
|
||||||
skipped = cur.rowcount
|
skipped = cur.rowcount
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,9 @@ class LibraryService:
|
||||||
db=db_cfg.get("database", "video_converter"),
|
db=db_cfg.get("database", "video_converter"),
|
||||||
charset="utf8mb4",
|
charset="utf8mb4",
|
||||||
autocommit=True,
|
autocommit=True,
|
||||||
minsize=2,
|
minsize=1,
|
||||||
maxsize=10,
|
maxsize=5,
|
||||||
connect_timeout=10,
|
connect_timeout=10,
|
||||||
pool_recycle=300,
|
|
||||||
)
|
)
|
||||||
return self._db_pool
|
return self._db_pool
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -221,8 +220,6 @@ class LibraryService:
|
||||||
episode_name VARCHAR(512),
|
episode_name VARCHAR(512),
|
||||||
aired DATE NULL,
|
aired DATE NULL,
|
||||||
runtime INT NULL,
|
runtime INT NULL,
|
||||||
overview TEXT NULL,
|
|
||||||
image_url VARCHAR(1024) NULL,
|
|
||||||
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_series (series_tvdb_id),
|
INDEX idx_series (series_tvdb_id),
|
||||||
UNIQUE INDEX idx_episode (
|
UNIQUE INDEX idx_episode (
|
||||||
|
|
@ -230,18 +227,6 @@ class LibraryService:
|
||||||
)
|
)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
) 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)
|
# movie_id Spalte in library_videos (falls noch nicht vorhanden)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
@ -35,7 +34,6 @@ class QueueService:
|
||||||
self._active_count: int = 0
|
self._active_count: int = 0
|
||||||
self._running: bool = False
|
self._running: bool = False
|
||||||
self._paused: bool = False
|
self._paused: bool = False
|
||||||
self._encoding_suspended: bool = False
|
|
||||||
self._queue_task: Optional[asyncio.Task] = None
|
self._queue_task: Optional[asyncio.Task] = None
|
||||||
self._queue_file = str(config.data_path / "queue.json")
|
self._queue_file = str(config.data_path / "queue.json")
|
||||||
self._db_pool: Optional[aiomysql.Pool] = None
|
self._db_pool: Optional[aiomysql.Pool] = None
|
||||||
|
|
@ -197,52 +195,6 @@ class QueueService:
|
||||||
def is_paused(self) -> bool:
|
def is_paused(self) -> bool:
|
||||||
return self._paused
|
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:
|
async def retry_job(self, job_id: int) -> bool:
|
||||||
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
|
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
|
||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
|
|
@ -274,8 +226,7 @@ class QueueService:
|
||||||
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
|
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
|
||||||
JobStatus.FAILED, JobStatus.CANCELLED):
|
JobStatus.FAILED, JobStatus.CANCELLED):
|
||||||
queue[job_id] = job.to_dict_queue()
|
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:
|
def get_active_jobs(self) -> dict:
|
||||||
"""Aktive Jobs fuer WebSocket"""
|
"""Aktive Jobs fuer WebSocket"""
|
||||||
|
|
@ -298,8 +249,8 @@ class QueueService:
|
||||||
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
|
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
|
||||||
while self._running:
|
while self._running:
|
||||||
try:
|
try:
|
||||||
if (not self._paused and not self._encoding_suspended
|
if (not self._paused and
|
||||||
and self._active_count < self.config.max_parallel_jobs):
|
self._active_count < self.config.max_parallel_jobs):
|
||||||
next_job = self._get_next_queued()
|
next_job = self._get_next_queued()
|
||||||
if next_job:
|
if next_job:
|
||||||
asyncio.create_task(self._execute_job(next_job))
|
asyncio.create_task(self._execute_job(next_job))
|
||||||
|
|
@ -316,17 +267,6 @@ class QueueService:
|
||||||
|
|
||||||
await self.ws_manager.broadcast_queue_update()
|
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)
|
command = self.encoder.build_command(job)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Starte Konvertierung: {job.media.source_filename}\n"
|
f"Starte Konvertierung: {job.media.source_filename}\n"
|
||||||
|
|
@ -357,18 +297,10 @@ class QueueService:
|
||||||
else:
|
else:
|
||||||
job.status = JobStatus.FAILED
|
job.status = JobStatus.FAILED
|
||||||
error_output = progress.get_error_output()
|
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(
|
logging.error(
|
||||||
f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): "
|
f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): "
|
||||||
f"{job.media.source_filename}\n"
|
f"{job.media.source_filename}\n"
|
||||||
f" ffmpeg stderr:\n{error_output}{extra}"
|
f" ffmpeg stderr:\n{error_output}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
|
@ -383,118 +315,31 @@ class QueueService:
|
||||||
self._save_queue()
|
self._save_queue()
|
||||||
await self._save_stats(job)
|
await self._save_stats(job)
|
||||||
await self.ws_manager.broadcast_queue_update()
|
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:
|
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
|
files_cfg = self.config.files_config
|
||||||
|
|
||||||
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
||||||
should_delete = files_cfg.get("delete_source", False) or \
|
should_delete = files_cfg.get("delete_source", False) or job.delete_source
|
||||||
job.delete_source
|
|
||||||
|
|
||||||
if should_delete:
|
if should_delete:
|
||||||
target_exists = os.path.exists(job.target_path)
|
target_exists = os.path.exists(job.target_path)
|
||||||
target_size = (os.path.getsize(job.target_path)
|
target_size = os.path.getsize(job.target_path) if target_exists else 0
|
||||||
if target_exists else 0)
|
|
||||||
if target_exists and target_size > 0:
|
if target_exists and target_size > 0:
|
||||||
try:
|
try:
|
||||||
os.remove(job.media.source_path)
|
os.remove(job.media.source_path)
|
||||||
logging.info(
|
logging.info(f"Quelldatei geloescht: {job.media.source_path}")
|
||||||
f"Quelldatei geloescht: {job.media.source_path}")
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logging.error(
|
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}")
|
||||||
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
|
cleanup_cfg = self.config.cleanup_config
|
||||||
if cleanup_cfg.get("enabled", False):
|
if cleanup_cfg.get("enabled", False):
|
||||||
source_dir = job.media.source_dir
|
deleted = self.scanner.cleanup_directory(job.media.source_dir)
|
||||||
pending = [
|
if deleted:
|
||||||
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(
|
logging.info(
|
||||||
f"Ordner-Cleanup uebersprungen "
|
f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}"
|
||||||
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]:
|
def _get_next_queued(self) -> Optional[ConversionJob]:
|
||||||
"""Naechster Job mit Status QUEUED (FIFO)"""
|
"""Naechster Job mit Status QUEUED (FIFO)"""
|
||||||
|
|
@ -572,10 +417,9 @@ class QueueService:
|
||||||
db=db_cfg["db"],
|
db=db_cfg["db"],
|
||||||
charset="utf8mb4",
|
charset="utf8mb4",
|
||||||
autocommit=True,
|
autocommit=True,
|
||||||
minsize=2,
|
minsize=1,
|
||||||
maxsize=10,
|
maxsize=5,
|
||||||
connect_timeout=10,
|
connect_timeout=10,
|
||||||
pool_recycle=300,
|
|
||||||
)
|
)
|
||||||
return self._db_pool
|
return self._db_pool
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,6 @@ class TVDBService:
|
||||||
def __init__(self, config: Config):
|
def __init__(self, config: Config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self._client = None
|
self._client = None
|
||||||
self._client_api_key = "" # Key mit dem der Client erstellt wurde
|
|
||||||
self._db_pool: Optional[aiomysql.Pool] = None
|
self._db_pool: Optional[aiomysql.Pool] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -65,18 +64,12 @@ class TVDBService:
|
||||||
self._db_pool = pool
|
self._db_pool = pool
|
||||||
|
|
||||||
def _get_client(self):
|
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:
|
if not TVDB_AVAILABLE:
|
||||||
return None
|
return None
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
return None
|
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:
|
if self._client is None:
|
||||||
try:
|
try:
|
||||||
if self._pin:
|
if self._pin:
|
||||||
|
|
@ -85,7 +78,6 @@ class TVDBService:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._client = tvdb_v4_official.TVDB(self._api_key)
|
self._client = tvdb_v4_official.TVDB(self._api_key)
|
||||||
self._client_api_key = self._api_key
|
|
||||||
logging.info("TVDB Client verbunden")
|
logging.info("TVDB Client verbunden")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"TVDB Verbindung fehlgeschlagen: {e}")
|
logging.error(f"TVDB Verbindung fehlgeschlagen: {e}")
|
||||||
|
|
@ -809,21 +801,12 @@ class TVDBService:
|
||||||
ep_aired = getattr(ep, "aired", None)
|
ep_aired = getattr(ep, "aired", None)
|
||||||
ep_runtime = getattr(ep, "runtime", None)
|
ep_runtime = getattr(ep, "runtime", None)
|
||||||
if s_num and s_num > 0 and e_num and e_num > 0:
|
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({
|
episodes.append({
|
||||||
"season_number": s_num,
|
"season_number": s_num,
|
||||||
"episode_number": e_num,
|
"episode_number": e_num,
|
||||||
"episode_name": ep_name or "",
|
"episode_name": ep_name or "",
|
||||||
"aired": ep_aired,
|
"aired": ep_aired,
|
||||||
"runtime": ep_runtime,
|
"runtime": ep_runtime,
|
||||||
"overview": ep_overview or "",
|
|
||||||
"image_url": ep_image or "",
|
|
||||||
})
|
})
|
||||||
page += 1
|
page += 1
|
||||||
if page > 50:
|
if page > 50:
|
||||||
|
|
@ -857,15 +840,12 @@ class TVDBService:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
"INSERT INTO tvdb_episode_cache "
|
"INSERT INTO tvdb_episode_cache "
|
||||||
"(series_tvdb_id, season_number, episode_number, "
|
"(series_tvdb_id, season_number, episode_number, "
|
||||||
"episode_name, aired, runtime, overview, "
|
"episode_name, aired, runtime) "
|
||||||
"image_url) "
|
"VALUES (%s, %s, %s, %s, %s, %s)",
|
||||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s)",
|
|
||||||
(
|
(
|
||||||
tvdb_id, ep["season_number"],
|
tvdb_id, ep["season_number"],
|
||||||
ep["episode_number"], ep["episode_name"],
|
ep["episode_number"], ep["episode_name"],
|
||||||
ep["aired"], ep["runtime"],
|
ep["aired"], ep["runtime"],
|
||||||
ep.get("overview", ""),
|
|
||||||
ep.get("image_url", ""),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -294,55 +294,7 @@ legend {
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Presets Editor === */
|
/* === Presets Grid === */
|
||||||
.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 {
|
.presets-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
|
@ -362,6 +314,8 @@ legend {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
|
|
@ -373,7 +327,6 @@ legend {
|
||||||
}
|
}
|
||||||
.tag.gpu { background: #1b5e20; color: #81c784; border-color: #2e7d32; }
|
.tag.gpu { background: #1b5e20; color: #81c784; border-color: #2e7d32; }
|
||||||
.tag.cpu { background: #0d47a1; color: #90caf9; border-color: #1565c0; }
|
.tag.cpu { background: #0d47a1; color: #90caf9; border-color: #1565c0; }
|
||||||
.tag.default { background: #e65100; color: #ffcc80; border-color: #f57c00; }
|
|
||||||
|
|
||||||
/* === Statistics === */
|
/* === Statistics === */
|
||||||
.stats-summary {
|
.stats-summary {
|
||||||
|
|
@ -957,10 +910,6 @@ legend {
|
||||||
}
|
}
|
||||||
.series-card:hover { border-color: #444; }
|
.series-card:hover { border-color: #444; }
|
||||||
|
|
||||||
.series-poster-wrap {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.series-poster {
|
.series-poster {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
|
|
@ -968,19 +917,6 @@ legend {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
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 {
|
.series-poster-placeholder {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
|
|
@ -1145,8 +1081,6 @@ legend {
|
||||||
|
|
||||||
.row-missing { opacity: 0.6; }
|
.row-missing { opacity: 0.6; }
|
||||||
.row-missing td { color: #888; }
|
.row-missing td { color: #888; }
|
||||||
.row-redundant { background: rgba(255, 152, 0, 0.08); }
|
|
||||||
.row-redundant td { color: #b0a080; }
|
|
||||||
.text-warn { color: #ffb74d; }
|
.text-warn { color: #ffb74d; }
|
||||||
.text-muted { color: #888; font-size: 0.8rem; }
|
.text-muted { color: #888; font-size: 0.8rem; }
|
||||||
|
|
||||||
|
|
@ -1963,37 +1897,3 @@ legend {
|
||||||
.artwork-gallery { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
|
.artwork-gallery { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
|
||||||
.lib-section-header { flex-direction: column; align-items: flex-start; }
|
.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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -465,14 +465,9 @@ function renderSeriesGrid(series) {
|
||||||
const tvdbBtn = s.tvdb_id
|
const tvdbBtn = s.tvdb_id
|
||||||
? `<span class="tag ok">TVDB</span>`
|
? `<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>`;
|
: `<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})">
|
html += `<div class="series-card" onclick="openSeriesDetail(${s.id})">
|
||||||
<div class="series-poster-wrap">
|
${poster}
|
||||||
${poster}
|
|
||||||
${codecBadge}
|
|
||||||
</div>
|
|
||||||
<div class="series-info">
|
<div class="series-info">
|
||||||
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
|
<h4 title="${escapeHtml(s.folder_path || '')}">${escapeHtml(s.title || s.folder_name)}</h4>
|
||||||
${genres}
|
${genres}
|
||||||
|
|
@ -643,33 +638,6 @@ function renderEpisodesTab(series) {
|
||||||
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
|
for (const ep of sData.missing) allEps.push({...ep, _type: "missing"});
|
||||||
allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0));
|
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) {
|
for (const ep of allEps) {
|
||||||
if (ep._type === "missing") {
|
if (ep._type === "missing") {
|
||||||
html += `<tr class="row-missing">
|
html += `<tr class="row-missing">
|
||||||
|
|
@ -679,7 +647,6 @@ function renderEpisodesTab(series) {
|
||||||
<td><span class="status-badge error">FEHLT</span></td>
|
<td><span class="status-badge error">FEHLT</span></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
} else {
|
} else {
|
||||||
const isRedundant = redundantIds.has(ep.id);
|
|
||||||
const audioInfo = (ep.audio_tracks || []).map(a => {
|
const audioInfo = (ep.audio_tracks || []).map(a => {
|
||||||
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
const lang = (a.lang || "?").toUpperCase().substring(0, 3);
|
||||||
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
return `<span class="tag">${lang} ${channelLayout(a.channels)}</span>`;
|
||||||
|
|
@ -687,10 +654,9 @@ function renderEpisodesTab(series) {
|
||||||
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
|
const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-";
|
||||||
const epTitle = ep.episode_title || ep.file_name || "Episode";
|
const epTitle = ep.episode_title || ep.file_name || "Episode";
|
||||||
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
|
const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-";
|
||||||
const redundantBadge = isRedundant ? ' <span class="status-badge warn" title="Duplikat - kann geloescht werden">REDUNDANT</span>' : '';
|
html += `<tr data-video-id="${ep.id}">
|
||||||
html += `<tr data-video-id="${ep.id}" class="${isRedundant ? 'row-redundant' : ''}">
|
|
||||||
<td>${ep.episode_number || "-"}</td>
|
<td>${ep.episode_number || "-"}</td>
|
||||||
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}${redundantBadge}</td>
|
<td title="${escapeHtml(ep.file_name || '')}">${escapeHtml(epTitle)}</td>
|
||||||
<td>${res}</td>
|
<td>${res}</td>
|
||||||
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
<td><span class="tag codec">${ep.video_codec || "-"}</span></td>
|
||||||
<td><span class="tag">${fileExt}</span></td>
|
<td><span class="tag">${fileExt}</span></td>
|
||||||
|
|
@ -1419,57 +1385,67 @@ let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen
|
||||||
async function startAutoMatch() {
|
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;
|
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;
|
||||||
|
|
||||||
_gpShow("automatch", "Auto-Match", "Suche TVDB-Vorschlaege...", 0);
|
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%";
|
||||||
|
|
||||||
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
|
fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
_gpShow("automatch", "Auto-Match", "Fehler: " + data.error, 0);
|
document.getElementById("auto-match-status").textContent = "Fehler: " + data.error;
|
||||||
_gpHideDelayed("automatch");
|
setTimeout(() => { progress.style.display = "none"; }, 3000);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pollAutoMatchStatus();
|
pollAutoMatchStatus();
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
_gpShow("automatch", "Auto-Match", "Fehler: " + e, 0);
|
document.getElementById("auto-match-status").textContent = "Fehler: " + e;
|
||||||
_gpHideDelayed("automatch");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function pollAutoMatchStatus() {
|
function pollAutoMatchStatus() {
|
||||||
|
const progress = document.getElementById("auto-match-progress");
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetch("/api/library/tvdb-auto-match-status")
|
fetch("/api/library/tvdb-auto-match-status")
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
const bar = document.getElementById("auto-match-bar");
|
||||||
|
const status = document.getElementById("auto-match-status");
|
||||||
|
|
||||||
if (data.phase === "done") {
|
if (data.phase === "done") {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
|
bar.style.width = "100%";
|
||||||
const suggestions = data.suggestions || [];
|
const suggestions = data.suggestions || [];
|
||||||
|
// Nur Items mit mindestens einem Vorschlag anzeigen
|
||||||
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
|
const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0);
|
||||||
const noResults = suggestions.length - withSuggestions.length;
|
const noResults = suggestions.length - withSuggestions.length;
|
||||||
_gpShow("automatch", "Auto-Match",
|
status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`;
|
||||||
withSuggestions.length + " Vorschlaege, " + noResults + " ohne Ergebnis", 100);
|
|
||||||
_gpHideDelayed("automatch");
|
setTimeout(() => {
|
||||||
|
progress.style.display = "none";
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
if (withSuggestions.length > 0) {
|
if (withSuggestions.length > 0) {
|
||||||
openTvdbReviewModal(withSuggestions);
|
openTvdbReviewModal(withSuggestions);
|
||||||
}
|
}
|
||||||
} else if (data.phase === "error") {
|
} else if (data.phase === "error") {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
_gpShow("automatch", "Auto-Match", "Fehler", 0);
|
status.textContent = "Fehler beim Sammeln der Vorschlaege";
|
||||||
_gpHideDelayed("automatch");
|
setTimeout(() => { progress.style.display = "none"; }, 3000);
|
||||||
} else if (!data.active && data.phase !== "done") {
|
} else if (!data.active && data.phase !== "done") {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
_gpHide("automatch");
|
progress.style.display = "none";
|
||||||
} else {
|
} else {
|
||||||
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
|
||||||
|
bar.style.width = pct + "%";
|
||||||
const phase = data.phase === "series" ? "Serien" : "Filme";
|
const phase = data.phase === "series" ? "Serien" : "Filme";
|
||||||
_gpShow("automatch", "Auto-Match",
|
status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`;
|
||||||
phase + ": " + (data.current || "") + " (" + data.done + "/" + data.total + ")", pct);
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => clearInterval(interval));
|
.catch(() => clearInterval(interval));
|
||||||
}, 5000);
|
}, 5000); // 5s Fallback (WS liefert Live-Updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// === TVDB Review-Modal ===
|
// === TVDB Review-Modal ===
|
||||||
|
|
@ -2120,7 +2096,7 @@ function openImportModal() {
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const select = document.getElementById("import-target");
|
const select = document.getElementById("import-target");
|
||||||
select.innerHTML = (data.paths || []).map(p =>
|
select.innerHTML = (data.paths || []).map(p =>
|
||||||
`<option value="${p.id}" ${activePathId === p.id ? 'selected' : ''}>${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
|
`<option value="${p.id}">${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'})</option>`
|
||||||
).join("");
|
).join("");
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
@ -2869,38 +2845,16 @@ async function executeImport() {
|
||||||
// WebSocket-Handler fuer Import-Fortschritt
|
// WebSocket-Handler fuer Import-Fortschritt
|
||||||
function handleImportWS(data) {
|
function handleImportWS(data) {
|
||||||
if (!data || !data.job_id) return;
|
if (!data || !data.job_id) return;
|
||||||
|
// Nur Updates fuer aktuellen Job
|
||||||
|
if (data.job_id !== currentImportJobId) return;
|
||||||
|
|
||||||
_importWsActive = true;
|
_importWsActive = true;
|
||||||
|
// Polling abschalten wenn WS liefert
|
||||||
stopImportPolling();
|
stopImportPolling();
|
||||||
|
|
||||||
// Import-Progress-Container sichtbar machen
|
|
||||||
const progressEl = document.getElementById("import-progress");
|
const progressEl = document.getElementById("import-progress");
|
||||||
if (progressEl) progressEl.style.display = "";
|
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 status = data.status || "";
|
||||||
const total = data.total || 1;
|
const total = data.total || 1;
|
||||||
const processed = data.processed || 0;
|
const processed = data.processed || 0;
|
||||||
|
|
@ -2914,35 +2868,34 @@ function handleImportWS(data) {
|
||||||
pct += (bytesDone / bytesTotal) * (100 / total);
|
pct += (bytesDone / bytesTotal) * (100 / total);
|
||||||
}
|
}
|
||||||
pct = Math.min(Math.round(pct), 100);
|
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 (bar) bar.style.width = pct + "%";
|
||||||
|
|
||||||
if (status === "analyzing") {
|
if (status === "analyzing") {
|
||||||
if (statusText) statusText.textContent =
|
if (statusText) statusText.textContent =
|
||||||
`Analysiere: ${processed} / ${total} - ${curFile}`;
|
`Analysiere: ${processed} / ${total} - ${curFile}`;
|
||||||
if (statusBadge) statusBadge.textContent = "Analyse";
|
|
||||||
} else if (status === "embedding") {
|
} else if (status === "embedding") {
|
||||||
if (statusText) statusText.textContent =
|
if (statusText) statusText.textContent =
|
||||||
`Metadaten: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
|
`Metadaten schreiben: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`;
|
||||||
if (statusBadge) statusBadge.textContent = "Metadaten";
|
|
||||||
} else if (status === "importing") {
|
} else if (status === "importing") {
|
||||||
let txt = `${processed} / ${total} Dateien`;
|
let txt = `Importiere: ${processed} / ${total} Dateien`;
|
||||||
if (curFile && bytesTotal > 0 && processed < total) {
|
if (curFile && bytesTotal > 0 && processed < total) {
|
||||||
const curPct = Math.round((bytesDone / bytesTotal) * 100);
|
const curPct = Math.round((bytesDone / bytesTotal) * 100);
|
||||||
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)}/${formatSize(bytesTotal)})`;
|
txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)} / ${formatSize(bytesTotal)}, ${curPct}%)`;
|
||||||
} else {
|
} else {
|
||||||
txt += ` (${pct}%)`;
|
txt += ` (${pct}%)`;
|
||||||
}
|
}
|
||||||
if (statusText) statusText.textContent = txt;
|
if (statusText) statusText.textContent = txt;
|
||||||
if (statusBadge) statusBadge.textContent = pct + "%";
|
|
||||||
} else if (status === "done" || status === "error") {
|
} else if (status === "done" || status === "error") {
|
||||||
if (bar) bar.style.width = "100%";
|
if (bar) bar.style.width = "100%";
|
||||||
if (status === "done") {
|
if (statusText) statusText.textContent =
|
||||||
if (statusBadge) { statusBadge.textContent = "Fertig"; statusBadge.style.color = "#4caf50"; }
|
status === "done"
|
||||||
} else {
|
? `Import abgeschlossen (${processed} Dateien)`
|
||||||
if (statusBadge) { statusBadge.textContent = "Fehler"; statusBadge.style.color = "#f44336"; }
|
: `Import mit Fehlern beendet`;
|
||||||
}
|
|
||||||
|
|
||||||
// Ergebnis per REST holen
|
// Ergebnis per REST holen fuer Details
|
||||||
fetch(`/api/library/import/${data.job_id}`)
|
fetch(`/api/library/import/${data.job_id}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
|
|
@ -2951,8 +2904,9 @@ function handleImportWS(data) {
|
||||||
const errors = items.filter(i => i.status === "error").length;
|
const errors = items.filter(i => i.status === "error").length;
|
||||||
const skipped = items.filter(i => i.status === "skipped").length;
|
const skipped = items.filter(i => i.status === "skipped").length;
|
||||||
if (statusText) statusText.textContent =
|
if (statusText) statusText.textContent =
|
||||||
`${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
`Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`;
|
||||||
|
|
||||||
|
// Ziel-Pfad scannen
|
||||||
const job = result.job;
|
const job = result.job;
|
||||||
if (job && job.target_library_id && imported > 0) {
|
if (job && job.target_library_id && imported > 0) {
|
||||||
fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"})
|
fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"})
|
||||||
|
|
@ -2990,27 +2944,72 @@ function startImportPolling() {
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
stopImportPolling();
|
stopImportPolling();
|
||||||
|
document.getElementById("import-status-text").textContent = "Fehler: " + data.error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const job = data.job;
|
const job = data.job;
|
||||||
if (!job) return;
|
if (!job) return;
|
||||||
|
|
||||||
// handleImportWS wiederverwenden (rendert in den neuen Container)
|
const total = job.total_files || 1;
|
||||||
handleImportWS({
|
const done = job.processed_files || 0;
|
||||||
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 || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fertig? (handleImportWS uebernimmt Ergebnis-Laden und Scan)
|
// 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?
|
||||||
if (job.status === "done" || job.status === "error") {
|
if (job.status === "done" || job.status === "error") {
|
||||||
stopImportPolling();
|
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) {
|
} catch (e) {
|
||||||
console.error("Import-Polling Fehler:", e);
|
console.error("Import-Polling Fehler:", e);
|
||||||
|
|
@ -3159,50 +3158,3 @@ async function deleteVideo(videoId, title, context) {
|
||||||
})
|
})
|
||||||
.catch(e => showToast("Fehler: " + e, "error"));
|
.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
|
|
@ -1,201 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
/**
|
|
||||||
* 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
|
|
@ -10,41 +10,10 @@ class FocusManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._enabled = true;
|
this._enabled = true;
|
||||||
this._currentFocus = null;
|
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
|
// Tastatur-Events abfangen
|
||||||
document.addEventListener("keydown", (e) => this._onKeyDown(e));
|
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
|
// Initiales Focus-Element setzen
|
||||||
requestAnimationFrame(() => this._initFocus());
|
requestAnimationFrame(() => this._initFocus());
|
||||||
}
|
}
|
||||||
|
|
@ -56,24 +25,6 @@ class FocusManager {
|
||||||
autofocusEl.focus();
|
autofocusEl.focus();
|
||||||
return;
|
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]");
|
const first = document.querySelector("[data-focusable]");
|
||||||
if (first) first.focus();
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
|
|
@ -110,38 +61,9 @@ class FocusManager {
|
||||||
|
|
||||||
_navigate(direction, e) {
|
_navigate(direction, e) {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
|
// Input-Felder: Links/Rechts nicht abfangen (Cursor-Navigation)
|
||||||
// Input-Felder: Nur im Editier-Modus Cursor-Navigation erlauben
|
|
||||||
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
|
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
|
||||||
if (active.type === "checkbox") {
|
if (direction === "ArrowLeft" || direction === "ArrowRight") return;
|
||||||
// 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();
|
const focusables = this._getFocusableElements();
|
||||||
|
|
@ -156,164 +78,24 @@ class FocusManager {
|
||||||
return;
|
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)
|
// Naechstes Element in Richtung finden (Nearest-Neighbor)
|
||||||
const currentRect = active.getBoundingClientRect();
|
const current = active.getBoundingClientRect();
|
||||||
const cx = currentRect.left + currentRect.width / 2;
|
const cx = current.left + current.width / 2;
|
||||||
const cy = currentRect.top + currentRect.height / 2;
|
const cy = current.top + current.height / 2;
|
||||||
|
|
||||||
let bestEl = null;
|
let bestEl = null;
|
||||||
let bestDist = Infinity;
|
let bestDist = Infinity;
|
||||||
|
|
||||||
// Nur Elemente im gleichen Bereich (Nav oder Content) bevorzugen
|
for (const el of focusables) {
|
||||||
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;
|
if (el === active) continue;
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
|
// Element muss sichtbar sein
|
||||||
if (rect.width === 0 || rect.height === 0) continue;
|
if (rect.width === 0 || rect.height === 0) continue;
|
||||||
|
|
||||||
const ex = rect.left + rect.width / 2;
|
const ex = rect.left + rect.width / 2;
|
||||||
const ey = rect.top + rect.height / 2;
|
const ey = rect.top + rect.height / 2;
|
||||||
|
|
||||||
|
// Pruefen ob Element in der richtigen Richtung liegt
|
||||||
const dx = ex - cx;
|
const dx = ex - cx;
|
||||||
const dy = ey - cy;
|
const dy = ey - cy;
|
||||||
|
|
||||||
|
|
@ -326,6 +108,7 @@ class FocusManager {
|
||||||
}
|
}
|
||||||
if (!valid) continue;
|
if (!valid) continue;
|
||||||
|
|
||||||
|
// Distanz berechnen (gewichtet: Hauptrichtung weniger, Querrichtung mehr)
|
||||||
let dist;
|
let dist;
|
||||||
if (direction === "ArrowUp" || direction === "ArrowDown") {
|
if (direction === "ArrowUp" || direction === "ArrowDown") {
|
||||||
dist = Math.abs(dy) + Math.abs(dx) * 3;
|
dist = Math.abs(dy) + Math.abs(dx) * 3;
|
||||||
|
|
@ -341,6 +124,7 @@ class FocusManager {
|
||||||
|
|
||||||
if (bestEl) {
|
if (bestEl) {
|
||||||
bestEl.focus();
|
bestEl.focus();
|
||||||
|
// Ins Sichtfeld scrollen
|
||||||
bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
bestEl.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" });
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
@ -350,49 +134,9 @@ class FocusManager {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
if (!active || active === document.body) return;
|
if (!active || active === document.body) return;
|
||||||
|
|
||||||
// Links, Buttons -> Click ausfuehren (natuerliches Enter-Verhalten)
|
// Links, Buttons -> Click ausfuehren
|
||||||
if (active.tagName === "A" || active.tagName === "BUTTON") {
|
if (active.tagName === "A" || active.tagName === "BUTTON") {
|
||||||
return;
|
// Natuerliches Enter-Verhalten beibehalten
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -405,50 +149,12 @@ class FocusManager {
|
||||||
|
|
||||||
_goBack(e) {
|
_goBack(e) {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
|
// In Input-Feldern: Escape = Blur, Backspace = natuerlich
|
||||||
// In Input-Feldern: Escape = Editier-Modus beenden oder Blur
|
if (active && active.tagName === "INPUT") {
|
||||||
if (active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
|
if (e.key === "Escape") {
|
||||||
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();
|
active.blur();
|
||||||
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -460,6 +166,7 @@ class FocusManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
_getFocusableElements() {
|
_getFocusableElements() {
|
||||||
|
// Alle sichtbaren fokussierbaren Elemente
|
||||||
const elements = document.querySelectorAll("[data-focusable]");
|
const elements = document.querySelectorAll("[data-focusable]");
|
||||||
return Array.from(elements).filter(el => {
|
return Array.from(elements).filter(el => {
|
||||||
if (el.offsetParent === null && el.style.position !== "fixed") return false;
|
if (el.offsetParent === null && el.style.position !== "fixed") return false;
|
||||||
|
|
|
||||||
|
|
@ -1,567 +0,0 @@
|
||||||
/**
|
|
||||||
* 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");
|
|
||||||
})();
|
|
||||||
|
|
@ -4,12 +4,11 @@
|
||||||
* Kein Offline-Caching noetig (Streaming braucht Netzwerk)
|
* Kein Offline-Caching noetig (Streaming braucht Netzwerk)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const CACHE_NAME = "vk-tv-v17";
|
const CACHE_NAME = "vk-tv-v1";
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
"/static/tv/css/tv.css",
|
"/static/tv/css/tv.css",
|
||||||
"/static/tv/js/tv.js",
|
"/static/tv/js/tv.js",
|
||||||
"/static/tv/js/player.js",
|
"/static/tv/js/player.js",
|
||||||
"/static/tv/js/vknative-bridge.js",
|
|
||||||
"/static/tv/icons/icon-192.png",
|
"/static/tv/icons/icon-192.png",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -39,10 +38,9 @@ self.addEventListener("fetch", (event) => {
|
||||||
// Nur GET-Requests cachen
|
// Nur GET-Requests cachen
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
|
|
||||||
// API-Requests und Streaming nie cachen
|
// Streaming/API nie cachen
|
||||||
const url = new URL(event.request.url);
|
const url = new URL(event.request.url);
|
||||||
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/tv/api/")
|
if (url.pathname.startsWith("/api/") || url.pathname.includes("/stream")) {
|
||||||
|| url.pathname.includes("/stream") || url.pathname.includes("/direct-stream")) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,7 @@
|
||||||
<label for="tvdb_api_key">TVDB API Key</label>
|
<label for="tvdb_api_key">TVDB API Key</label>
|
||||||
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
|
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
|
||||||
value="{{ settings.library.tvdb_api_key if settings.library else '' }}"
|
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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tvdb_pin">TVDB PIN</label>
|
<label for="tvdb_pin">TVDB PIN</label>
|
||||||
|
|
@ -244,116 +243,71 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- TV-App / Streaming -->
|
||||||
|
<section class="admin-section">
|
||||||
|
<h2>TV-App / Streaming</h2>
|
||||||
|
<div style="display:flex;gap:2rem;flex-wrap:wrap">
|
||||||
|
<!-- QR-Code -->
|
||||||
|
<div style="text-align:center">
|
||||||
|
<img id="tv-qrcode" src="/api/tv/qrcode" alt="QR-Code" style="width:200px;height:200px;border-radius:8px;background:#1a1a1a">
|
||||||
|
<p style="margin-top:0.5rem;font-size:0.85rem;color:#888">QR-Code scannen oder Link oeffnen</p>
|
||||||
|
<div style="margin-top:0.3rem">
|
||||||
|
<a id="tv-link" href="/tv/" target="_blank" style="font-size:0.9rem">/tv/</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- User-Verwaltung -->
|
||||||
|
<div style="flex:1;min-width:300px">
|
||||||
|
<h3 style="margin-bottom:0.8rem">Benutzer</h3>
|
||||||
|
<div id="tv-users-list">
|
||||||
|
<div class="loading-msg">Lade Benutzer...</div>
|
||||||
|
</div>
|
||||||
|
<!-- Neuer User -->
|
||||||
|
<div style="margin-top:1rem;padding:1rem;background:#1a1a1a;border-radius:8px">
|
||||||
|
<h4 style="margin-bottom:0.5rem">Neuer Benutzer</h4>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Benutzername</label>
|
||||||
|
<input type="text" id="tv-new-username" placeholder="z.B. eddy">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Anzeigename</label>
|
||||||
|
<input type="text" id="tv-new-display" placeholder="z.B. Eddy">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Passwort</label>
|
||||||
|
<input type="password" id="tv-new-password" placeholder="Passwort">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Rechte</label>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:0.3rem">
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-series" checked> Serien</label>
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-movies" checked> Filme</label>
|
||||||
|
<label style="font-size:0.85rem"><input type="checkbox" id="tv-new-admin"> Admin</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="tvCreateUser()" style="margin-top:0.5rem">Benutzer erstellen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<h2>Encoding-Presets</h2>
|
<h2>Encoding-Presets</h2>
|
||||||
<div class="preset-editor" id="preset-editor">
|
<div class="presets-grid">
|
||||||
{% for key, preset in presets.items() %}
|
{% for key, preset in presets.items() %}
|
||||||
<div class="preset-edit-card" id="preset-{{ key }}">
|
<div class="preset-card">
|
||||||
<div class="preset-header" onclick="togglePresetEdit('{{ key }}')">
|
<h3>{{ preset.name }}</h3>
|
||||||
<div class="preset-header-left">
|
<div class="preset-details">
|
||||||
<h3>{{ preset.name }}</h3>
|
<span class="tag">{{ preset.video_codec }}</span>
|
||||||
<div class="preset-details">
|
<span class="tag">{{ preset.container }}</span>
|
||||||
<span class="tag">{{ preset.video_codec }}</span>
|
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
|
||||||
<span class="tag">{{ preset.container }}</span>
|
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
|
||||||
<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 }}">▼</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>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:1rem">
|
|
||||||
<button class="btn-secondary" onclick="showNewPresetForm()">+ Neues Preset</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -433,81 +387,155 @@ function scanPath(pathId) {
|
||||||
.catch(e => showToast("Fehler: " + e, "error"));
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === TV-App User-Verwaltung ===
|
||||||
|
|
||||||
// === Preset-Editor ===
|
function tvLoadUsers() {
|
||||||
|
fetch("/api/tv/users")
|
||||||
function togglePresetEdit(key) {
|
.then(r => r.json())
|
||||||
const body = document.getElementById("preset-body-" + key);
|
.then(data => {
|
||||||
const toggle = document.getElementById("toggle-" + key);
|
const container = document.getElementById("tv-users-list");
|
||||||
const open = body.style.display === "none";
|
const users = data.users || [];
|
||||||
body.style.display = open ? "" : "none";
|
if (!users.length) {
|
||||||
toggle.innerHTML = open ? "▲" : "▼";
|
container.innerHTML = '<div class="loading-msg">Keine Benutzer vorhanden</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = users.map(u => `
|
||||||
|
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(u.display_name || u.username)}</strong>
|
||||||
|
<span style="color:#888;font-size:0.85rem">@${escapeHtml(u.username)}</span>
|
||||||
|
${u.is_admin ? '<span class="tag gpu">Admin</span>' : ''}
|
||||||
|
${u.can_view_series ? '<span class="tag">Serien</span>' : ''}
|
||||||
|
${u.can_view_movies ? '<span class="tag">Filme</span>' : ''}
|
||||||
|
${u.last_login ? '<br><span style="font-size:0.75rem;color:#666">Letzter Login: ' + u.last_login + '</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.3rem">
|
||||||
|
<button class="btn-small btn-secondary" onclick="tvEditUser(${u.id})">Bearbeiten</button>
|
||||||
|
<button class="btn-small btn-danger" onclick="tvDeleteUser(${u.id}, '${escapeAttr(u.username)}')">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById("tv-users-list").innerHTML =
|
||||||
|
'<div style="text-align:center;color:#666;padding:1rem">TV-App nicht verfuegbar (DB-Verbindung fehlt?)</div>';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setDefaultPreset(key) {
|
function escapeHtml(str) {
|
||||||
const resp = await fetch("/api/settings", {
|
if (!str) return "";
|
||||||
method: "PUT",
|
return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||||
headers: {"Content-Type": "application/json"},
|
}
|
||||||
body: JSON.stringify({encoding: {default_preset: key}})
|
function escapeAttr(str) {
|
||||||
});
|
if (!str) return "";
|
||||||
if (resp.ok) {
|
return str.replace(/\\/g,"\\\\").replace(/'/g,"\\'").replace(/"/g,'\\"');
|
||||||
showToast("Standard-Preset geaendert", "success");
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
showToast("Fehler beim Aendern", "error");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePreset(key) {
|
function tvCreateUser() {
|
||||||
if (!await showConfirm('Preset "' + key + '" wirklich loeschen?',
|
const username = document.getElementById("tv-new-username").value.trim();
|
||||||
{title: "Preset loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
const displayName = document.getElementById("tv-new-display").value.trim();
|
||||||
|
const password = document.getElementById("tv-new-password").value;
|
||||||
const resp = await fetch("/api/presets/" + key, {method: "DELETE"});
|
if (!username || !password) {
|
||||||
const data = await resp.json();
|
showToast("Benutzername und Passwort noetig", "error");
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch("/api/presets", {
|
fetch("/api/tv/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: {"Content-Type": "application/json"},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
key: key.trim(),
|
username: username,
|
||||||
preset: {
|
password: password,
|
||||||
name: key.trim(),
|
display_name: displayName || username,
|
||||||
video_codec: "libx264",
|
is_admin: document.getElementById("tv-new-admin").checked,
|
||||||
container: "mp4",
|
can_view_series: document.getElementById("tv-new-series").checked,
|
||||||
quality_param: "crf",
|
can_view_movies: document.getElementById("tv-new-movies").checked,
|
||||||
quality_value: 23,
|
}),
|
||||||
gop_size: 240,
|
})
|
||||||
video_filter: "",
|
.then(r => r.json())
|
||||||
hw_init: false,
|
.then(data => {
|
||||||
extra_params: {}
|
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"));
|
||||||
const data = await resp.json();
|
}
|
||||||
if (data.error) {
|
|
||||||
showToast("Fehler: " + data.error, "error");
|
async function tvDeleteUser(userId, username) {
|
||||||
} else {
|
if (!await showConfirm(`Benutzer "${username}" wirklich loeschen?`, {title: "Benutzer loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
||||||
showToast("Preset erstellt", "success");
|
fetch("/api/tv/users/" + userId, {method: "DELETE"})
|
||||||
location.reload();
|
.then(r => r.json())
|
||||||
}
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast("Fehler: " + data.error, "error");
|
||||||
|
} else {
|
||||||
|
showToast("Benutzer geloescht", "success");
|
||||||
|
tvLoadUsers();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tvEditUser(userId) {
|
||||||
|
// User-Daten laden, dann Edit-Dialog anzeigen
|
||||||
|
const resp = await fetch("/api/tv/users").then(r => r.json());
|
||||||
|
const user = (resp.users || []).find(u => u.id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const newPass = prompt("Neues Passwort (leer lassen um beizubehalten):");
|
||||||
|
if (newPass === null) return; // Abgebrochen
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (newPass) updates.password = newPass;
|
||||||
|
|
||||||
|
const newSeries = confirm("Serien anzeigen?");
|
||||||
|
const newMovies = confirm("Filme anzeigen?");
|
||||||
|
const newAdmin = confirm("Admin-Rechte?");
|
||||||
|
|
||||||
|
updates.can_view_series = newSeries;
|
||||||
|
updates.can_view_movies = newMovies;
|
||||||
|
updates.is_admin = newAdmin;
|
||||||
|
|
||||||
|
fetch("/api/tv/users/" + userId, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast("Fehler: " + data.error, "error");
|
||||||
|
} else {
|
||||||
|
showToast("Benutzer aktualisiert", "success");
|
||||||
|
tvLoadUsers();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(e => showToast("Fehler: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TV-URL laden
|
||||||
|
function tvLoadUrl() {
|
||||||
|
fetch("/api/tv/url")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const link = document.getElementById("tv-link");
|
||||||
|
if (link && data.url) {
|
||||||
|
link.href = data.url;
|
||||||
|
link.textContent = data.url;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
loadLibraryPaths();
|
loadLibraryPaths();
|
||||||
|
tvLoadUsers();
|
||||||
|
tvLoadUrl();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}VideoKonverter{% endblock %}</title>
|
<title>{% block title %}VideoKonverter{% endblock %}</title>
|
||||||
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/static/icons/favicon.ico" type="image/x-icon">
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v={{ v }}">
|
<link rel="stylesheet" href="/static/css/style.css">
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -18,7 +18,6 @@
|
||||||
<a href="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
|
<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="/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="/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>
|
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -52,24 +51,6 @@
|
||||||
<div class="progress-bar"></div>
|
<div class="progress-bar"></div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,17 @@
|
||||||
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
|
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
|
||||||
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
|
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
|
||||||
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
|
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
|
||||||
<button class="btn-secondary" onclick="generateThumbnails()">Thumbnails</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Statistik-Leiste -->
|
||||||
<div class="library-stats" id="library-stats">
|
<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>
|
<div class="lib-stat"><span class="lib-stat-value" id="stat-videos">-</span><span class="lib-stat-label">Videos</span></div>
|
||||||
|
|
@ -56,7 +63,7 @@
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<label>Suche</label>
|
<label>Suche</label>
|
||||||
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()" onkeydown="if(event.key==='Enter'){event.preventDefault();applyFilters()}">
|
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
|
|
@ -386,9 +393,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="import-items-list" class="import-items-list"></div>
|
<div id="import-items-list" class="import-items-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Schritt 3: Fortschritt (mehrere Jobs gleichzeitig) -->
|
<!-- Schritt 3: Fortschritt -->
|
||||||
<div id="import-progress" style="display:none; padding:1rem;">
|
<div id="import-progress" style="display:none; padding:1rem;">
|
||||||
<div id="import-jobs-container"></div>
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar" id="import-bar"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted" id="import-status-text">Importiere...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -516,5 +526,5 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/library.js?v={{ v }}"></script>
|
<script src="/static/js/library.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ user.ui_lang if user is defined and user else 'de' }}"
|
<html lang="de">
|
||||||
data-theme="{{ user.theme if user is defined and user and user.theme else 'dark' }}">
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
|
@ -11,31 +10,25 @@
|
||||||
<link rel="manifest" href="/static/tv/manifest.json">
|
<link rel="manifest" href="/static/tv/manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/tv/icons/icon-192.png">
|
||||||
<link rel="icon" href="/static/icons/favicon.ico">
|
<link rel="icon" href="/static/icons/favicon.ico">
|
||||||
<link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
|
<link rel="stylesheet" href="/static/tv/css/tv.css">
|
||||||
<title>{% block title %}VideoKonverter TV{% endblock %}</title>
|
<title>{% block title %}VideoKonverter TV{% endblock %}</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% if user is defined and user %}
|
{% if user is defined and user %}
|
||||||
<nav class="tv-nav" id="tv-nav">
|
<nav class="tv-nav" id="tv-nav">
|
||||||
<div class="tv-nav-links">
|
<div class="tv-nav-links">
|
||||||
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>{{ t('nav.home') }}</a>
|
<a href="/tv/" class="tv-nav-item {% if active == 'home' %}active{% endif %}" data-focusable>Startseite</a>
|
||||||
{% if user.can_view_series %}
|
{% if user.can_view_series %}
|
||||||
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>{{ t('nav.series') }}</a>
|
<a href="/tv/series" class="tv-nav-item {% if active == 'series' %}active{% endif %}" data-focusable>Serien</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if user.can_view_movies %}
|
{% if user.can_view_movies %}
|
||||||
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>{{ t('nav.movies') }}</a>
|
<a href="/tv/movies" class="tv-nav-item {% if active == 'movies' %}active{% endif %}" data-focusable>Filme</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>{{ t('nav.search') }}</a>
|
<a href="/tv/search" class="tv-nav-item {% if active == 'search' %}active{% endif %}" data-focusable>Suche</a>
|
||||||
<a href="/tv/watchlist" class="tv-nav-item {% if active == 'watchlist' %}active{% endif %}" data-focusable>{{ t('nav.watchlist') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tv-nav-right">
|
<div class="tv-nav-right">
|
||||||
<a href="/tv/profiles" class="tv-nav-profile" data-focusable title="{{ t('profiles.switch') }}">
|
<span class="tv-nav-user">{{ user.display_name or user.username }}</span>
|
||||||
<span class="tv-avatar" style="background:{{ user.avatar_color or '#64b5f6' }}">
|
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>Abmelden</a>
|
||||||
{{ (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>⚙</a>
|
|
||||||
<a href="/tv/logout" class="tv-nav-item tv-nav-logout" data-focusable>{{ t('nav.logout') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -44,15 +37,10 @@
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="/static/tv/js/tv.js?v={{ v }}"></script>
|
<script src="/static/tv/js/tv.js"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
||||||
<script>
|
<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
|
// PWA Service Worker registrieren
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/static/tv/sw.js', {scope: '/tv/'})
|
navigator.serviceWorker.register('/static/tv/sw.js', {scope: '/tv/'})
|
||||||
|
|
|
||||||
|
|
@ -28,41 +28,6 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Neu hinzugefuegt -->
|
|
||||||
{% if new_series or new_movies %}
|
|
||||||
<section class="tv-section">
|
|
||||||
<h2 class="tv-section-title">Neu hinzugefü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 %} · {{ m.genres }}{% endif %}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Serien -->
|
<!-- Serien -->
|
||||||
{% if series %}
|
{% if series %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
|
|
@ -113,44 +78,7 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Schon gesehen -->
|
{% if not series and not movies %}
|
||||||
{% 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">✓</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">✓</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 %} · {{ 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">
|
<div class="tv-empty">
|
||||||
<p>Noch keine Inhalte in der Bibliothek.</p>
|
<p>Noch keine Inhalte in der Bibliothek.</p>
|
||||||
<p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p>
|
<p>Fuege Serien oder Filme ueber die Admin-Oberflaeche hinzu.</p>
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,11 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="theme-color" content="#0f0f0f">
|
<meta name="theme-color" content="#0f0f0f">
|
||||||
<link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
|
<link rel="stylesheet" href="/static/tv/css/tv.css">
|
||||||
<title>Login - VideoKonverter TV</title>
|
<title>Login - VideoKonverter TV</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="login-body">
|
<body class="login-body">
|
||||||
<!-- Lade-Spinner (verhindert Flash des Login-Formulars) -->
|
<div class="login-container">
|
||||||
<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">
|
<div class="login-card">
|
||||||
<h1 class="login-title">VideoKonverter</h1>
|
<h1 class="login-title">VideoKonverter</h1>
|
||||||
<p class="login-subtitle">TV-Streaming</p>
|
<p class="login-subtitle">TV-Streaming</p>
|
||||||
|
|
@ -26,7 +21,7 @@
|
||||||
<div class="login-field">
|
<div class="login-field">
|
||||||
<label for="username">Benutzername</label>
|
<label for="username">Benutzername</label>
|
||||||
<input type="text" id="username" name="username"
|
<input type="text" id="username" name="username"
|
||||||
autocomplete="username"
|
autocomplete="username" autofocus
|
||||||
data-focusable required>
|
data-focusable required>
|
||||||
</div>
|
</div>
|
||||||
<div class="login-field">
|
<div class="login-field">
|
||||||
|
|
@ -35,77 +30,11 @@
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
data-focusable required>
|
data-focusable required>
|
||||||
</div>
|
</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>
|
<button type="submit" class="login-btn" data-focusable>
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -19,140 +19,31 @@
|
||||||
<p class="tv-detail-overview">{{ movie.overview }}</p>
|
<p class="tv-detail-overview">{{ movie.overview }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bewertungen -->
|
{% if videos %}
|
||||||
<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 }})">★</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if user_rating > 0 %}
|
|
||||||
<span class="tv-rating-remove" onclick="setRating(0)"
|
|
||||||
data-focusable title="{{ t('rating.remove') }}">✕</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 %}">★</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">
|
<div class="tv-detail-actions">
|
||||||
{% if videos %}
|
|
||||||
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
|
<a href="/tv/player?v={{ videos[0].id }}" class="tv-play-btn" data-focusable>
|
||||||
▶ {{ t('player.play') }}
|
▶ Abspielen
|
||||||
</a>
|
</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 %}♥{% else %}♡{% endif %}</span>
|
|
||||||
<span class="watchlist-text">{{ t('watchlist.title') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if videos|length > 1 %}
|
{% if videos|length > 1 %}
|
||||||
<h3 class="tv-section-title">{{ t('movies.versions') }}</h3>
|
<h3 class="tv-section-title">Versionen</h3>
|
||||||
<div class="tv-episode-list">
|
<div class="tv-episode-list">
|
||||||
{% for v in videos %}
|
{% for v in videos %}
|
||||||
<a href="/tv/player?v={{ v.id }}" class="tv-episode-card" data-focusable>
|
<a href="/tv/player?v={{ v.id }}" class="tv-episode" data-focusable>
|
||||||
<div class="tv-ep-thumb">
|
<span class="tv-episode-title">{{ v.file_name }}</span>
|
||||||
<img src="/api/library/videos/{{ v.id }}/thumbnail" alt="" loading="lazy">
|
<span class="tv-episode-meta">
|
||||||
<div class="tv-ep-duration">
|
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
|
||||||
{% if v.duration_sec %}{{ (v.duration_sec / 60)|round|int }} Min{% endif %}
|
{% if v.width %} · {{ v.width }}x{{ v.height }}{% endif %}
|
||||||
</div>
|
· {{ v.container|upper }}
|
||||||
</div>
|
</span>
|
||||||
<div class="tv-ep-info">
|
<span class="tv-episode-play">▶</span>
|
||||||
<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 %}
|
|
||||||
· {{ v.container|upper }}
|
|
||||||
{% if v.video_codec %} · {{ v.video_codec }}{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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 = '♥';
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.querySelector('.watchlist-icon').innerHTML = '♡';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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 = '✕';
|
|
||||||
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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,12 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}{{ t('movies.title') }} - VideoKonverter TV{% endblock %}
|
{% block title %}Filme - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<div class="tv-list-header">
|
<h1 class="tv-page-title">Filme</h1>
|
||||||
<h1 class="tv-page-title">{{ t('movies.title') }}</h1>
|
<div class="tv-grid">
|
||||||
<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) %}★{% endfor %}{% for s in range(5 - n) %}☆{% 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 %}
|
{% 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 }}">
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||||
{% if m.poster_url %}
|
{% if m.poster_url %}
|
||||||
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
<img src="{{ m.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -84,171 +14,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
<span class="tv-card-title">{{ m.title or m.folder_name }}</span>
|
||||||
<span class="tv-card-meta">
|
<span class="tv-card-meta">{{ m.year or "" }}{% if m.genres %} · {{ m.genres }}{% endif %}</span>
|
||||||
{% 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 %}">★</span>{% endfor %}</span> {% endif %}
|
|
||||||
{{ m.year or "" }}{% if m.genres %} · {{ m.genres }}{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if not movies %}
|
||||||
<!-- === Liste (kompakt) === -->
|
<div class="tv-empty">Keine Filme vorhanden.</div>
|
||||||
<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 %}">★</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 %}">★</span>{% endfor %} {{ m.avg_rating }}</span> · {% endif %}
|
|
||||||
{% if m.year %}{{ m.year }}{% endif %}
|
|
||||||
{% if m.genres %} · {{ 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">📁</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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,21 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="theme-color" content="#000000">
|
<meta name="theme-color" content="#000000">
|
||||||
<link rel="stylesheet" href="/static/tv/css/tv.css?v={{ v }}">
|
<link rel="stylesheet" href="/static/tv/css/tv.css">
|
||||||
<title>{{ title }} - VideoKonverter TV</title>
|
<title>{{ title }} - VideoKonverter TV</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="player-body">
|
<body class="player-body">
|
||||||
<div class="player-wrapper" id="player-wrapper">
|
<div class="player-wrapper" id="player-wrapper">
|
||||||
<!-- Header (ausblendbar) -->
|
<!-- Header (ausblendbar) -->
|
||||||
<div class="player-header" id="player-header">
|
<div class="player-header" id="player-header">
|
||||||
<a href="javascript:history.back()" class="player-back" data-focusable>❮ {{ t('player.back') }}</a>
|
<a href="javascript:history.back()" class="player-back" data-focusable>❮ Zurueck</a>
|
||||||
<span class="player-title">{{ title }}</span>
|
<span class="player-title">{{ title }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading-Spinner (sichtbar bis Stream bereit) -->
|
<!-- Video -->
|
||||||
<div class="player-loading" id="player-loading">
|
<video id="player-video" autoplay playsinline>
|
||||||
<div class="player-loading-spinner"></div>
|
Dein Browser unterstuetzt kein HTML5-Video.
|
||||||
<p class="player-loading-text">Stream wird geladen...</p>
|
</video>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video (HTML5 fuer HLS, versteckt bei AVPlay Direct-Play) -->
|
|
||||||
<video id="player-video" autoplay playsinline></video>
|
|
||||||
|
|
||||||
<!-- Controls (ausblendbar) -->
|
<!-- Controls (ausblendbar) -->
|
||||||
<div class="player-controls" id="player-controls">
|
<div class="player-controls" id="player-controls">
|
||||||
|
|
@ -33,82 +29,14 @@
|
||||||
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
<button class="player-btn" id="btn-play" data-focusable>▶</button>
|
||||||
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
<span class="player-time" id="player-time">0:00 / 0:00</span>
|
||||||
<span class="player-spacer"></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') }}">⚙</button>
|
|
||||||
{% if next_video %}
|
|
||||||
<button class="player-btn" id="btn-next" data-focusable title="{{ t('player.next_episode') }}">⏭</button>
|
|
||||||
{% endif %}
|
|
||||||
<button class="player-btn" id="btn-fullscreen" data-focusable>⛶</button>
|
<button class="player-btn" id="btn-fullscreen" data-focusable>⛶</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- VKNative Bridge: Tizen AVPlay (auf Nicht-Tizen macht das Script nichts) -->
|
<script src="/static/tv/js/player.js"></script>
|
||||||
<!-- 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>
|
<script>
|
||||||
initPlayer({
|
initPlayer({{ video.id }}, {{ start_pos }}, {{ video.duration_sec or 0 }});
|
||||||
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
<!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>
|
|
||||||
|
|
@ -1,23 +1,20 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}{{ t('search.title') }} - VideoKonverter TV{% endblock %}
|
{% block title %}Suche - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<h1 class="tv-page-title">{{ t('search.title') }}</h1>
|
<h1 class="tv-page-title">Suche</h1>
|
||||||
<form action="/tv/search" method="GET" class="tv-search-form" autocomplete="off">
|
<form action="/tv/search" method="GET" class="tv-search-form">
|
||||||
<div class="tv-search-wrapper">
|
<input type="text" name="q" value="{{ query }}"
|
||||||
<input type="text" name="q" id="search-input" value="{{ query }}"
|
placeholder="Serie oder Film suchen..."
|
||||||
placeholder="{{ t('search.placeholder') }}"
|
class="tv-search-input" data-focusable autofocus>
|
||||||
class="tv-search-input" data-focusable autofocus>
|
<button type="submit" class="tv-search-btn" data-focusable>Suchen</button>
|
||||||
<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>
|
</form>
|
||||||
|
|
||||||
{% if query %}
|
{% if query %}
|
||||||
<!-- Serien-Ergebnisse -->
|
<!-- Serien-Ergebnisse -->
|
||||||
{% if series %}
|
{% if series %}
|
||||||
<h2 class="tv-section-title">{{ t('search.results_series') }} ({{ series|length }})</h2>
|
<h2 class="tv-section-title">Serien ({{ series|length }})</h2>
|
||||||
<div class="tv-grid">
|
<div class="tv-grid">
|
||||||
{% for s in series %}
|
{% for s in series %}
|
||||||
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||||
|
|
@ -28,9 +25,6 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -39,7 +33,7 @@
|
||||||
|
|
||||||
<!-- Film-Ergebnisse -->
|
<!-- Film-Ergebnisse -->
|
||||||
{% if movies %}
|
{% if movies %}
|
||||||
<h2 class="tv-section-title">{{ t('search.results_movies') }} ({{ movies|length }})</h2>
|
<h2 class="tv-section-title">Filme ({{ movies|length }})</h2>
|
||||||
<div class="tv-grid">
|
<div class="tv-grid">
|
||||||
{% for m in movies %}
|
{% for m in movies %}
|
||||||
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
<a href="/tv/movies/{{ m.id }}" class="tv-card" data-focusable>
|
||||||
|
|
@ -58,76 +52,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not series and not movies %}
|
{% if not series and not movies %}
|
||||||
<div class="tv-empty">{{ t('search.no_results', query=query) }}</div>
|
<div class="tv-empty">Keine Ergebnisse fuer «{{ 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">🔍</span>
|
|
||||||
{{ h.query }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,12 @@
|
||||||
{% extends "tv/base.html" %}
|
{% extends "tv/base.html" %}
|
||||||
{% block title %}{{ t('series.title') }} - VideoKonverter TV{% endblock %}
|
{% block title %}Serien - VideoKonverter TV{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="tv-section">
|
<section class="tv-section">
|
||||||
<div class="tv-list-header">
|
<h1 class="tv-page-title">Serien</h1>
|
||||||
<h1 class="tv-page-title">{{ t('series.title') }}</h1>
|
<div class="tv-grid">
|
||||||
<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) %}★{% endfor %}{% for s in range(5 - n) %}☆{% 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 %}
|
{% 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 }}">
|
<a href="/tv/series/{{ s.id }}" class="tv-card" data-focusable>
|
||||||
{% if s.poster_url %}
|
{% if s.poster_url %}
|
||||||
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
<img src="{{ s.poster_url }}" alt="" class="tv-card-img" loading="lazy">
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -84,172 +14,13 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="tv-card-info">
|
<div class="tv-card-info">
|
||||||
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
<span class="tv-card-title">{{ s.title or s.folder_name }}</span>
|
||||||
<span class="tv-card-meta">
|
<span class="tv-card-meta">{{ s.episode_count or 0 }} Episoden{% if s.genres %} · {{ s.genres }}{% endif %}</span>
|
||||||
{% 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 %}">★</span>{% endfor %}</span> {% endif %}
|
|
||||||
{{ s.episode_count or 0 }} {{ t('series.episodes') }}{% if s.genres %} · {{ s.genres }}{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if not series %}
|
||||||
<!-- === Liste (kompakt) === -->
|
<div class="tv-empty">Keine Serien vorhanden.</div>
|
||||||
<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 %}">★</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 %}">★</span>{% endfor %} {{ s.avg_rating }}</span> · {% endif %}
|
|
||||||
{{ s.episode_count or 0 }} {{ t('series.episodes') }}
|
|
||||||
{% if s.genres %} · {{ s.genres }}{% endif %}
|
|
||||||
{% if s.status %} · {{ 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">📁</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 }} · {% 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 %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% 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 %}
|
|
||||||
|
|
|
||||||
|
|
@ -16,63 +16,6 @@
|
||||||
{% if series.overview %}
|
{% if series.overview %}
|
||||||
<p class="tv-detail-overview">{{ series.overview }}</p>
|
<p class="tv-detail-overview">{{ series.overview }}</p>
|
||||||
{% endif %}
|
{% 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 }})">★</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if user_rating > 0 %}
|
|
||||||
<span class="tv-rating-remove" onclick="setRating(0)"
|
|
||||||
data-focusable title="{{ t('rating.remove') }}">✕</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 %}">★</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 %}♥{% else %}♡{% 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">✓</span>
|
|
||||||
<span class="mark-series-text">{{ t('status.mark_series') }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -80,73 +23,38 @@
|
||||||
{% if seasons %}
|
{% if seasons %}
|
||||||
<div class="tv-tabs" id="season-tabs">
|
<div class="tv-tabs" id="season-tabs">
|
||||||
{% for sn in seasons.keys() %}
|
{% 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 %}"
|
<button class="tv-tab {% if loop.first %}active{% endif %}"
|
||||||
data-focusable data-season="{{ sn }}"
|
data-focusable
|
||||||
onclick="showSeason({{ sn }})">
|
onclick="showSeason({{ sn }})">
|
||||||
{% if sn == 0 %}{{ t('series.specials') }}{% else %}{{ t('series.season') }} {{ sn }}{% endif %}
|
{% if sn == 0 %}Specials{% else %}Staffel {{ sn }}{% endif %}
|
||||||
{% if season_watched.get(sn, {}).get('all_seen') %}<span class="tv-tab-check">✓</span>{% endif %}
|
|
||||||
</button>
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Episoden-Detail-Panel (wird per JS bei Focus befuellt) -->
|
<!-- Episoden pro Staffel -->
|
||||||
<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() %}
|
{% 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" id="season-{{ sn }}" {% if not loop.first %}style="display:none"{% endif %}>
|
||||||
<div class="tv-season-actions">
|
<div class="tv-episode-list">
|
||||||
<button class="tv-season-mark-btn" data-focusable
|
|
||||||
onclick="markSeasonWatched({{ series.id }}, {{ sn }})">
|
|
||||||
✓ {{ t('status.mark_season') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tv-episode-grid">
|
|
||||||
{% for ep in episodes %}
|
{% 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 %}"
|
<a href="/tv/player?v={{ ep.id }}" class="tv-episode" data-focusable>
|
||||||
data-video-id="{{ ep.id }}"
|
<span class="tv-episode-num">
|
||||||
data-ep-title="{{ ep.episode_title or ep.file_name }}"
|
{% if ep.episode_number %}E{{ "%02d"|format(ep.episode_number) }}{% else %}-{% endif %}
|
||||||
data-ep-desc="{{ ep.ep_overview|default('', true)|e }}"
|
</span>
|
||||||
data-ep-meta="{% if ep.width %}{{ ep.width }}x{{ ep.height }}{% endif %} · {{ ep.container|upper|default('') }} {% if ep.video_codec %}· {{ ep.video_codec }}{% endif %} {% if ep.file_size %}· {{ (ep.file_size / 1048576)|round|int }} MB{% endif %} {% if ep.duration_sec %}· {{ (ep.duration_sec / 60)|round|int }} Min{% endif %}">
|
<span class="tv-episode-title">
|
||||||
<a href="/tv/player?v={{ ep.id }}" class="tv-ep-tile-link" data-focusable>
|
{{ ep.episode_title or ep.file_name }}
|
||||||
<div class="tv-ep-thumb">
|
</span>
|
||||||
<img src="/api/library/videos/{{ ep.id }}/thumbnail" alt="" loading="lazy">
|
<span class="tv-episode-meta">
|
||||||
{% if ep.progress_pct > 0 and ep.progress_pct < watched_threshold_pct|default(90) %}
|
{% if ep.duration_sec %}{{ (ep.duration_sec / 60)|round|int }} Min{% endif %}
|
||||||
<div class="tv-ep-progress">
|
{% if ep.width %} · {{ ep.width }}x{{ ep.height }}{% endif %}
|
||||||
<div class="tv-ep-progress-bar" style="width: {{ ep.progress_pct }}%"></div>
|
</span>
|
||||||
</div>
|
<span class="tv-episode-play">▶</span>
|
||||||
{% endif %}
|
</a>
|
||||||
{% if ep.progress_pct >= watched_threshold_pct|default(90) %}
|
|
||||||
<div class="tv-ep-watched">✓</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)">
|
|
||||||
✓
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="tv-empty">{{ t('series.no_episodes') }}</div>
|
<div class="tv-empty">Keine Episoden vorhanden.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
@ -164,292 +72,5 @@ function showSeason(sn) {
|
||||||
// Tab aktivieren
|
// Tab aktivieren
|
||||||
event.target.classList.add('active');
|
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 = '♥';
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
btn.querySelector('.watchlist-icon').innerHTML = '♡';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.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 = '✕';
|
|
||||||
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 = '✓';
|
|
||||||
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 = '✓';
|
|
||||||
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 = ' ✓';
|
|
||||||
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 = '✓';
|
|
||||||
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 = ' ✓';
|
|
||||||
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,231 +0,0 @@
|
||||||
{% 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') }} ✓</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>
|
|
||||||
"Weiterschauen" 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>
|
|
||||||
"Neu hinzugefügt" 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>
|
|
||||||
"Schon gesehen"-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 %}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
{% 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 %}
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
{% 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
||||||
}
|
|
||||||
|
|
||||||
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 %}
|
|
||||||
Loading…
Reference in a new issue