commit 37dff4de69f3f818ea2f5c034f9dcbbfe708d392 Author: data Date: Fri Feb 27 11:41:48 2026 +0100 feat: VideoKonverter v2.9 - Projekt-Reset aus Docker-Image Projekt aus Docker-Image videoconverter:2.9 extrahiert. Enthält zweiphasigen Import-Workflow mit Serien-Zuordnung. Co-Authored-By: Claude Opus 4.5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5a13804 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +# Tar-Archive nicht in den Build-Kontext einschliessen +*.tar +*.tar.gz + +# Alte Projektverzeichnisse +AV1 Encoder* +kio/ +python.data-tv-file-v2/ + +# Git und IDE +.git/ +.claude/ +.vscode/ +*.pyc +__pycache__/ + +# Runtime-Daten +data/ +logs/ +video-konverter/app/cfg/settings.yaml +video-konverter/app/cfg/*.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a6a7f3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +.venv/ +*.egg-info/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Logs +logs/ +*.log + +# Data +data/ + +# Docker +*.tar + +# Alt-Backups +alt/ +_extract/ + +# Config (wird zur Laufzeit gemountet) +video-konverter/app/cfg/ + +# OS +.DS_Store +Thumbs.db + +# Claude +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3d714ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,448 @@ +# Changelog + +Alle relevanten Aenderungen am VideoKonverter-Projekt. + +## [2.9.0] - 2026-02-27 + +### Import-System Neustrukturierung + +**Zweiphasiger Import-Workflow** +- Phase 1 (Analyse): Nur Dateiname-Erkennung (Serie/Staffel/Episode) +- Phase 2 (Serien-Zuordnung): User ordnet erkannte Serien TVDB-Serien zu +- Erst nach Zuordnung: Zielpfade berechnen, TVDB-Episodentitel holen, Konflikte pruefen +- Status-Flow: `pending` -> `analyzing` -> `pending_assignment` -> `ready` -> `importing` -> `done` + +**Serien-Zuordnungs-UI** +- Neue Ansicht nach Analyse zeigt alle erkannten Serien +- Pro Serie: Anzahl Dateien, Staffel-Range +- TVDB-Suche und -Zuordnung pro Serie +- Erst wenn alle Serien zugeordnet: Import freigeben + +**Backend-Aenderungen** +- `analyze_job()` in importer.py: Nur Dateinamen-Parsing, kein TVDB +- `get_pending_series()`: Gruppierte Liste aller zuzuordnenden Serien +- `assign_series_mapping()`: TVDB-Zuordnung + Zielpfad-Berechnung +- Neuer Item-Status `pending_series` fuer noch nicht zugeordnete Dateien + +**Mindestgroesse fuer Episoden** +- Dateien < 100 MiB werden als Sample/Trailer markiert und uebersprungen +- Verhindert Import von NFO-begleitenden Samples + +### Geaenderte Dateien +- `app/services/importer.py` - Zweiphasiger Workflow, MIN_EPISODE_SIZE, assign_series_mapping() +- `app/routes/library_api.py` - Neue Endpoints fuer Serien-Zuordnung +- `app/static/js/library.js` - Serien-Zuordnungs-UI +- `app/templates/library.html` - import-series-assign Container + +### Neue API-Endpoints +- `GET /api/library/import/{id}/pending-series` - Zuzuordnende Serien auflisten +- `POST /api/library/import/{id}/assign-series` - Serie TVDB zuordnen + +--- + +## [2.5.0] - 2026-02-24 + +### GPU & Deployment + +**Intel A380 GPU-Konfiguration** +- GPU-Device auf renderD129 (Intel A380) korrigiert - renderD128 war AMD auf Eddys Unraid +- GPU-Erkennung per `/sys/class/drm/renderD*/device/uevent` statt `vainfo` +- `VK_GPU_DEVICE` Umgebungsvariable statt hardcoded Device-Mapping +- AV1 10-Bit (`gpu_av1_10bit`) als Standard-Preset (bessere Qualitaet bei gleichem Speed) + +**Docker Entrypoint** +- Neues `entrypoint.sh`: Kopiert Default-Configs (`presets.yaml`, `settings.yaml`) automatisch ins gemountete cfg-Volume bei Erstinstallation +- `cfg_defaults/` Verzeichnis im Image als Backup der Default-Konfigdateien +- `Config._load_presets()` mit Fallback auf `cfg_defaults` falls presets.yaml fehlt +- Behebt leeres Preset-Dropdown auf Unraid bei frisch gemounteten Volumes + +**Unraid Docker-Template** +- Neue `unraid/my-VideoKonverter.xml` fuer Unraid WebGUI-Installation +- Alle ENV-Variablen mit Defaults vorkonfiguriert +- GPU-Device, Pfad-Mappings, DB-Konfiguration voreingestellt +- ExtraParams fuer `--group-add video --device=/dev/dri:/dev/dri` + +**Startseite** +- Redirect von `/` auf `/library` statt Dashboard + +### Geaenderte Dateien +- `Dockerfile` - cfg_defaults Sicherung, entrypoint.sh statt CMD +- `entrypoint.sh` - NEU: Default-Config-Kopie bei Erststart +- `docker-compose.yml` - VK_GPU_DEVICE renderD129, /dev/dri komplett, gpu_av1_10bit Default +- `app/config.py` - _load_presets() mit cfg_defaults Fallback +- `app/routes/pages.py` - Startseite Redirect auf /library +- `unraid/my-VideoKonverter.xml` - NEU: Unraid Docker-Template + +--- + +## [2.4.0] - 2026-02-24 + +### Video-Player & Streaming + +**Browser-Streaming** +- Video-Player mit ffmpeg-Transcoding-Stream (Video copy + Audio->AAC Stereo) +- Unterstuetzt Formate die Browser nicht nativ abspielen (EAC3, DTS, AC3, TrueHD) +- Fragmented MP4 mit `frag_keyframe+empty_moov+faststart`, chunked Transfer-Encoding +- Endpoint: `GET /api/library/videos/{id}/stream?t=0` + +**Play-/Delete-Buttons** +- Play-Button bei jedem Video in Serien-, Film- und Ordner-Ansichten +- Delete-Button zum Loeschen einzelner Videos (DB-Eintrag + Datei auf Disk) +- Bestaetigungs-Dialog vor dem Loeschen + +### Import-System + +**Nicht-erkannte Dateien zuordnen** +- Modal fuer nicht-erkannte Dateien: Serie/Staffel/Episode manuell zuweisen oder ueberspringen +- Import-Start blockiert solange ungeloeste Items vorhanden +- Duplikat-Erkennung bei erneutem Scan nach Import + +### Technische Aenderungen + +**ENV-Variablen Refactoring** +- Alle Umgebungsvariablen mit `VK_*` Prefix (VK_DB_HOST, VK_MODE, VK_PORT etc.) +- `_ENV_MAP` in config.py: Zentrales Mapping ENV -> Settings-Pfad mit Typ-Konvertierung +- Rueckwaertskompatibilitaet per `_ENV_ALIASES` + +**WebSocket Server-Log Push** +- Server-Logs werden per WebSocket an alle Clients gepusht (statt HTTP-Polling) +- `WebLogHandler` in api.py schreibt Log-Eintraege in den WebSocket-Manager + +**Audio-Fix** +- `channelmap=channel_layout=5.1` Filter fuer libopus bei EAC3/AC3 mit `5.1(side)` Layout +- Behebt Encoder-Fehler bei Surround-Audio-Transcoding + +**Ordner-Loeschen Fix** +- Filebrowser: Ordner-Loeschen funktioniert jetzt korrekt + +### Geaenderte Dateien +- `app/config.py` - ENV-Mapping, VK_* Prefix, Typ-Konvertierung (+180 Z.) +- `app/routes/library_api.py` - Video-Stream, Video-Delete, Import-Zuordnung (+200 Z.) +- `app/services/importer.py` - Zuordnungs-Modal, Duplikat-Erkennung (+160 Z.) +- `app/services/encoder.py` - channelmap 5.1(side) Fix +- `app/services/library.py` - Video-Delete, Rescan-Logik (+70 Z.) +- `app/static/js/library.js` - Player, Play/Delete-Buttons, Import-Modal (+270 Z.) +- `app/static/css/style.css` - Player-Styles, Button-Styles (+50 Z.) +- `app/templates/library.html` - Player-Modal, Import-Zuordnungs-Modal (+60 Z.) +- `app/templates/base.html` - WebSocket-Log-Push Integration +- `app/routes/ws.py` - Server-Log WebSocket-Broadcast +- `docker-compose.yml` - VK_* ENV-Variablen + +### Neue API-Endpoints +- `GET /api/library/videos/{id}/stream` - Video-Transcoding-Stream +- `DELETE /api/library/videos/{id}` - Video loeschen (DB + Datei) +- `POST /api/library/import/{id}/assign` - Import-Item manuell zuordnen +- `POST /api/library/import/{id}/skip` - Import-Item ueberspringen + +--- + +## [2.3.0] - 2026-02-24 + +### Import-System Verbesserungen + +**Bestehende Import-Jobs laden** +- Neue `GET /api/library/import` API liefert alle Import-Jobs +- Import-Modal zeigt jetzt offene Jobs oben an (Buttons mit Status) +- Klick auf Job laedt und zeigt Vorschau zum Fortsetzen +- Verhindert doppelte Importe der gleichen Quelle + +**Import-Fortschritt mit Byte-Level** +- Neue DB-Felder: `current_file_name`, `current_file_bytes`, `current_file_total` +- Kopieren in 64MB-Chunks mit Progress-Updates alle 50MB +- UI zeigt aktuelle Datei und Byte-Fortschritt + +**Gezielter Rescan nach Import** +- Nach Import wird nur der Ziel-Library-Pfad gescannt +- `imported_series` Liste im Job-Status fuer betroffene Ordner +- Statt `reloadAllSections()` nur `loadSectionData(targetPathId)` + +### Ordner-Verwaltung + +**Ordner-Loeschen Button** +- Neuer Muelleimer-Button (SVG-Icon) oben rechts bei Ordnern +- Erscheint nur bei Hover, rot bei Mouse-Over +- Schoener Bestaetigungs-Dialog statt Browser-confirm() +- Toast-Benachrichtigung statt alert() + +**Delete-Folder API** +- `POST /api/library/delete-folder` mit Sicherheitspruefung +- Prueft ob Pfad unter einem Library-Pfad liegt +- Loescht Ordner + alle DB-Eintraege (library_videos) +- Gibt geloeschte Dateien/Ordner/DB-Eintraege zurueck + +### Serie konvertieren + +**Batch-Konvertierung fuer Serien** +- Neuer Button "Serie konvertieren" im Serien-Modal +- Modal mit Codec-Auswahl (AV1/HEVC/H.264) +- Option: Alle neu konvertieren (auch bereits passende) +- Option: Quelldateien nach Konvertierung loeschen +- `POST /api/library/series/{id}/convert` API +- `GET /api/library/series/{id}/convert-status` fuer Codec-Statistik + +**Cleanup-Funktion fuer Serien** +- "Alte Dateien loeschen" Button im Serien-Modal +- Loescht alles ausser: registrierte Videos, .metadata, .nfo, Bilder +- `POST /api/library/series/{id}/cleanup` API + +### Server-Log System + +**Benachrichtigungs-Glocke** +- Glocken-Icon unten rechts auf allen Seiten +- Badge zeigt ungelesene Fehler-Anzahl (rot) +- Log-Panel mit allen Server-Meldungen +- Fehler/Warnings farblich hervorgehoben + +**Log-API** +- `GET /api/logs?since=ID` liefert neue Log-Eintraege +- In-Memory-Buffer (max 200 Eintraege) +- Polling alle 2 Sekunden + +### UI-Verbesserungen + +**Toast-Benachrichtigungen** +- Verbesserte Styling mit Slide-Animation +- Farbige linke Border (success/error/info) +- 4 Sekunden Anzeigedauer + +**TVDB-Suche** +- Checkbox "Englische Titel durchsuchen" im TVDB-Modal +- Ermoeglicht Suche nach englischen Originaltiteln + +### Technische Aenderungen + +**Neue/Geaenderte Dateien** +- `app/routes/library_api.py` - 6 neue Endpoints (+200 Z.) +- `app/services/importer.py` - get_all_jobs(), Progress-Tracking (+100 Z.) +- `app/services/queue.py` - delete_source Option bei add_paths() +- `app/static/js/library.js` - Dialog-System, Toast, Import-Jobs (+150 Z.) +- `app/static/css/style.css` - Toast, Delete-Button, Dialog-Styles (+50 Z.) +- `app/templates/library.html` - Confirm-Modal, Convert-Modal (+50 Z.) +- `app/templates/base.html` - Benachrichtigungs-Glocke + Log-Panel (+100 Z.) +- `app/routes/api.py` - /api/logs Endpoint, WebLogHandler (+40 Z.) +- `app/models/job.py` - delete_source Flag + +**Neue API-Endpoints** +- `GET /api/library/import` - Liste aller Import-Jobs +- `POST /api/library/delete-folder` - Ordner loeschen +- `POST /api/library/series/{id}/convert` - Serie konvertieren +- `GET /api/library/series/{id}/convert-status` - Codec-Status +- `POST /api/library/series/{id}/cleanup` - Alte Dateien loeschen +- `GET /api/logs` - Server-Log abrufen + +--- + +## [2.2.0] - 2026-02-21 + +### Bugfixes + +**TVDB Review-Modal nicht klickbar** +- `JSON.stringify()` erzeugte doppelte Anfuehrungszeichen in HTML onclick-Attributen +- Neue `escapeAttr()`-Funktion ersetzt `"` durch `"` fuer sichere HTML-Attribute +- 4 Stellen in `library.js` korrigiert (Serien, Filme, Review-Liste, manuelle Suche) + +**Film-TVDB-Suche liefert keine Ergebnisse** +- Filmtitel mit fuehrenden Nummern (z.B. "10 Logan The Wolverine") fanden nichts auf TVDB +- Neue `cleanSearchTitle()`-Funktion entfernt fuehrende Nummern und Aufloesungs-Suffixe +- Angewendet in `openMovieTvdbModal()` und `openTvdbModal()` + +**Auto-Match Progress-Variable nicht im Scope** +- `pollAutoMatchStatus()` referenzierte `progress` aus `startAutoMatch()`-Scope +- Variable wird jetzt lokal in `pollAutoMatchStatus()` definiert + +**TVDB-Sprache wurde nicht gespeichert** +- `pages.py` HTMX-Save-Handler fehlte `tvdb_language` Feld +- Hinzugefuegt: `settings["library"]["tvdb_language"] = data.get("tvdb_language", "deu")` + +### Geaenderte Dateien +- `app/static/js/library.js` - +escapeAttr(), +cleanSearchTitle(), 4x Attribut-Escaping, Progress-Fix +- `app/routes/pages.py` - tvdb_language in Settings-Save + +--- + +## [2.1.0] - 2026-02-21 + +### TVDB-Sprachkonfiguration + +Alle TVDB-Metadaten (Serien-Titel, Beschreibungen, Episoden-Namen) werden jetzt +in der konfigurierten Sprache abgerufen statt immer Englisch. + +#### Neue Features +- **Sprach-Dropdown** in Admin-UI (Deutsch, Englisch, Franzoesisch, Spanisch, Italienisch, Japanisch) +- **API-Endpoints**: `GET/PUT /api/tvdb/language` zum Lesen/Aendern der Sprache +- **Episoden-Refresh**: `POST /api/library/tvdb-refresh-episodes` aktualisiert alle gecachten Episoden +- Alle TVDB-API-Aufrufe nutzen `self._language` Property + +#### TVDB Review-Modal (Neues Feature) +- Statt blindem Auto-Match werden jetzt **Vorschlaege gesammelt** und zur Pruefung angezeigt +- `collect_suggestions()` in tvdb.py: Top 3 TVDB-Treffer pro ungematchter Serie/Film +- Review-Modal: Poster-Vorschau, Beschreibung, Jahr, Einzelbestaetigung +- Manuelle TVDB-Suche falls Vorschlaege nicht passen +- Polling-basierter Fortschritt waehrend der Analyse + +#### Film-Scanning (Neues Feature) +- Bibliothek unterstuetzt jetzt **Filme** neben Serien +- Neue DB-Tabelle `library_movies` fuer Film-Metadaten +- Film-Erkennung: Ein Video pro Ordner = Film, Ordnername = Filmtitel +- Film-Grid in der Bibliothek-UI mit Poster, Titel, Jahr +- TVDB-Zuordnung fuer Filme (Suche + manuelle Zuordnung) + +#### Import- und Clean-Service (Grundgeruest) +- `app/services/importer.py` (734 Z.) - Import-Logik mit Serien-Erkennung + TVDB-Lookup +- `app/services/cleaner.py` (155 Z.) - Junk-Scan + Loeschen von Nicht-Video-Dateien + +#### Geaenderte Dateien +- `app/services/tvdb.py` - _language Property, lokalisierte Suche, collect_suggestions() (298 -> 1005 Z.) +- `app/services/library.py` - Film-Scan, _ensure_movie, _add_video_to_db (1082 -> 1747 Z.) +- `app/routes/library_api.py` - TVDB-Language-Endpoints, Confirm-Endpoint, Film-Endpoints (260 -> 998 Z.) +- `app/static/js/library.js` - Review-Modal, Film-Grid, Auto-Match-Polling (587 -> 1912 Z.) +- `app/static/css/style.css` - Review-Modal CSS, Film-Grid CSS (889 -> 1554 Z.) +- `app/templates/library.html` - Review-Modal HTML, Film-TVDB-Modal (330 -> 392 Z.) +- `app/templates/admin.html` - TVDB-Sprach-Dropdown (330 -> 342 Z.) +- `app/server.py` - CleanerService + ImporterService Integration +- `app/cfg/settings.yaml` - tvdb_language, import-Settings, cleanup keep_extensions + +#### Neue API-Endpoints +- `GET /api/tvdb/language` - Aktuelle TVDB-Sprache +- `PUT /api/tvdb/language` - TVDB-Sprache aendern +- `POST /api/library/tvdb-refresh-episodes` - Alle Episoden-Caches aktualisieren +- `POST /api/library/tvdb-auto-match` - Review-Vorschlaege sammeln +- `POST /api/library/tvdb-confirm` - Einzelnen TVDB-Match bestaetigen +- `GET /api/library/movies` - Filme auflisten +- `POST /api/library/movies/{id}/tvdb-match` - Film-TVDB-Zuordnung + +--- + +## [2.0.0] - 2026-02-20 + +### Video-Bibliothek (Neues Feature) + +Komplette Video-Bibliotheksverwaltung mit Serien-Erkennung, ffprobe-Analyse, +TVDB-Integration und umfangreichen Filterfunktionen. + +#### Neue Dateien +- `app/services/library.py` - LibraryService (Scan, DB, Filter, Duplikate) +- `app/services/tvdb.py` - TVDBService (Auth, Suche, Episoden-Abgleich) +- `app/routes/library_api.py` - REST API Endpoints fuer Bibliothek +- `app/templates/library.html` - Bibliothek-Hauptseite mit Filter-Sidebar +- `app/static/js/library.js` - Bibliothek-Frontend (Filter, TVDB, Scan-Progress) + +#### Geaenderte Dateien +- `app/server.py` - LibraryService + TVDBService Integration +- `app/routes/pages.py` - Route /library + TVDB-Settings speichern +- `app/templates/base.html` - Nav-Link "Bibliothek" hinzugefuegt +- `app/templates/admin.html` - TVDB-Settings + Scan-Pfad-Verwaltung +- `app/static/css/style.css` - Library-Styles (~250 Zeilen ergaenzt) +- `app/cfg/settings.yaml` - library-Sektion (enabled, tvdb_api_key, tvdb_pin) +- `requirements.txt` - tvdb-v4-official>=1.1.0 hinzugefuegt + +#### Datenbank +Vier neue Tabellen (werden automatisch beim Start erstellt): +- `library_paths` - Konfigurierbare Scan-Pfade (Serien/Filme) +- `library_series` - Erkannte Serien mit TVDB-Verknuepfung +- `library_videos` - Videos mit vollstaendigen ffprobe-Metadaten +- `tvdb_episode_cache` - TVDB-Episoden-Cache + +#### Features im Detail + +**Ordner-Scan** +- Konfigurierbare Scan-Pfade fuer Serien und Filme getrennt +- Serien-Erkennung via Ordnerstruktur (S01E01, 1x02, Season/Staffel XX) +- ffprobe-Analyse: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR +- Versteckte Ordner (.Trash-*) werden automatisch uebersprungen +- UPSERT-Logik: unveraenderte Dateien werden nicht erneut analysiert +- Scan-Fortschritt via WebSocket + Polling im UI +- Verwaiste DB-Eintraege werden nach Scan automatisch bereinigt + +**Filter-System** +- Video-Codec: AV1, HEVC, H.264, MPEG-4 +- Aufloesung: 4K, 1080p, 720p, SD +- Container: MKV, MP4, AVI, WebM, TS, WMV +- Audio: Sprache (Deutsch, Englisch), Kanaele (Stereo, 5.1, 7.1) +- 10-Bit Filter +- Freitext-Suche im Dateinamen +- Sortierung nach Name, Groesse, Aufloesung, Dauer, Codec, Datum +- Pagination (50 Videos pro Seite) + +**TVDB-Integration** +- Authentifizierung via API Key + optionalem PIN +- Serien-Suche mit Ergebnis-Vorschau (Poster, Beschreibung, Jahr) +- Episoden-Abgleich: Soll (TVDB) vs. Ist (lokal) = fehlende Episoden +- Poster-URLs, Beschreibung, Status (Continuing/Ended) +- Episoden-Cache in DB (reduziert API-Aufrufe) + +**Duplikat-Finder** +- Erkennt gleiche Episoden in verschiedenen Formaten (z.B. AVI + WebM) +- Vergleich ueber Serie + Staffel + Episode +- Anzeige mit Codec, Aufloesung, Groesse fuer beide Versionen + +**Direkt-Konvertierung** +- "Conv"-Button bei jedem Video in der Bibliothek +- Sendet Video direkt an die bestehende Konvertierungs-Queue +- Optionale Preset-Auswahl + +**Admin-UI Erweiterung** +- TVDB API Key + PIN Eingabefelder +- Scan-Pfad-Verwaltung (hinzufuegen, loeschen, einzeln scannen) +- Letzter Scan-Zeitpunkt pro Pfad angezeigt + +**Statistik-Leiste** +- Gesamt-Videos, Serien-Anzahl, Speicherbedarf, Gesamtspielzeit +- Codec-Verteilung, Aufloesungs-Verteilung + +#### API Endpoints (17 neue) +- `GET/POST/DELETE /api/library/paths` - Scan-Pfade CRUD +- `POST /api/library/scan` - Komplett-Scan +- `POST /api/library/scan/{id}` - Einzel-Scan +- `GET /api/library/scan-status` - Scan-Fortschritt +- `GET /api/library/videos` - Videos mit Filtern +- `GET /api/library/series` - Alle Serien +- `GET /api/library/series/{id}` - Serien-Detail mit Episoden +- `GET /api/library/series/{id}/missing` - Fehlende Episoden +- `POST /api/library/series/{id}/tvdb-match` - TVDB zuordnen +- `GET /api/library/duplicates` - Duplikate finden +- `POST /api/library/videos/{id}/convert` - Direkt konvertieren +- `GET /api/library/stats` - Bibliotheks-Statistiken +- `GET /api/tvdb/search?q=` - TVDB-Suche + +#### Erster Scan-Lauf +- 80 Serien erkannt, 5.260 Videos analysiert +- ~3.7 TiB Gesamtgroesse, ~150 Tage Spielzeit +- Codecs: H.264 (2.942), MPEG-4 (2.094), AV1 (199), HEVC (24) +- Aufloesungen: SD (4.256), 720p (651), 1080p (300), 4K (52) +- 9 Duplikat-Paare gefunden + +--- + +## [1.0.0] - 2026-02-20 + +### Komplett-Neubau + +Vollstaendiger Neubau des VideoKonverter-Servers als moderne +Python/aiohttp-Anwendung mit Web-UI. + +#### Kern-Features +- **aiohttp-Server** mit Jinja2-Templating und HTMX +- **WebSocket** fuer Echtzeit-Fortschritts-Updates +- **Queue-System** mit MariaDB-Persistierung und parallelen Jobs +- **FFmpeg-Encoding** mit GPU (Intel VAAPI) und CPU Support +- **7 Encoding-Presets**: GPU AV1/HEVC/H.264 + CPU SVT-AV1/x265/x264 +- **Dashboard** mit aktiven Jobs und Queue-Uebersicht +- **Admin-UI** fuer Einstellungen, Presets, Encoding-Modus +- **Statistik-Seite** mit Konvertierungs-Historie +- **File-Browser** zum Auswaehlen von Dateien/Ordnern +- **Docker-Support** mit GPU- und CPU-Profilen + +#### Audio-Handling +- Alle Spuren behalten (kein Downmix) +- Konfigurierbare Sprach-Filter (DE, EN, Undefiniert) +- Opus-Transcoding mit bitrate-basiertem Kanalmanagement +- Surround-Kanaele (5.1, 7.1) bleiben erhalten + +#### Technische Details +- Async/Await durchgaengig (aiohttp, aiomysql, asyncio.subprocess) +- MariaDB statt SQLite (Lock-Probleme in Docker behoben) +- WebSocket-Broadcast fuer alle verbundenen Clients +- Automatische GPU-Erkennung (VAAPI, vainfo) +- Konfigurierbare Settings via YAML +- Log-Rotation (7 Tage, 10 MiB pro Datei) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6d77241 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +FROM ubuntu:24.04 + +# Basis-Pakete + ffmpeg + Intel GPU Treiber +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + python3 \ + python3-pip \ + intel-opencl-icd \ + intel-media-va-driver-non-free \ + libva-drm2 \ + libva2 \ + libmfx1 \ + vainfo \ + && rm -rf /var/lib/apt/lists/* + +# Umgebungsvariablen fuer Intel GPU +ENV LIBVA_DRIVER_NAME=iHD +ENV LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri + +# VideoKonverter Defaults (ueberschreibbar per docker run -e / Unraid UI) +ENV VK_DB_HOST=localhost +ENV VK_DB_PORT=3306 +ENV VK_DB_USER=video +ENV VK_DB_PASSWORD="" +ENV VK_DB_NAME=video_converter +ENV VK_MODE=cpu +ENV VK_PORT=8080 +ENV VK_LOG_LEVEL=INFO + +WORKDIR /opt/video-konverter + +# Python-Abhaengigkeiten +COPY requirements.txt . +RUN pip install --no-cache-dir --break-system-packages -r requirements.txt + +# Anwendung kopieren (aus video-konverter Unterverzeichnis) +COPY video-konverter/__main__.py . +COPY video-konverter/app/ ./app/ + +# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert) +RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults + +# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000) +RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \ + && chmod 777 /opt/video-konverter/data /opt/video-konverter/logs + +# Entrypoint (kopiert Defaults in gemountete Volumes) +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh + +# Konfiguration und Daten als Volumes +VOLUME ["/opt/video-konverter/app/cfg", "/opt/video-konverter/data", "/opt/video-konverter/logs"] + +EXPOSE 8080 + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5294fc9 --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +# VideoKonverter + +Web-basierter Video-Konverter mit GPU-Beschleunigung (Intel VAAPI), Video-Bibliotheksverwaltung und TVDB-Integration. Laeuft als Docker-Container auf Unraid oder lokal. + +## Features + +### Video-Konvertierung +- **GPU-Encoding**: Intel VAAPI (AV1, AV1 10-Bit, HEVC, H.264) ueber Intel A380 +- **CPU-Encoding**: SVT-AV1, x265, x264 als Fallback +- **AV1 10-Bit Standard**: Bessere Qualitaet bei gleichem Speed (p010 Pixel-Format) +- **Konfigurierbare Presets**: 7 Presets (4x GPU + 3x CPU) +- **Parallele Jobs**: Mehrere Videos gleichzeitig konvertieren +- **Audio-Handling**: Alle Spuren behalten (DE+EN), kein Downmix, Opus-Transcoding +- **Live-Fortschritt**: WebSocket-basierte Echtzeit-Updates im Dashboard +- **Queue-Management**: Drag-and-Drop, Pause, Abbruch, Prioritaeten + +### Video-Player +- **Browser-Streaming**: Direktes Abspielen mit ffmpeg-Transcoding (EAC3/DTS/AC3 -> AAC) +- **Play-Buttons**: In Serien-, Film- und Ordner-Ansichten +- **Delete-Buttons**: Einzelne Videos loeschen (DB + Datei) + +### Video-Bibliothek +- **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme +- **Serien-Erkennung**: Automatisch via Ordnerstruktur (`S01E01`, `1x02`, `Staffel/Season XX`) +- **Doppel-Episoden**: Erkennung von Multi-Episoden-Dateien (`S01E01E02`, `S01E01-E02`, `1x01-02`) +- **Episoden-Titel**: Automatische Extraktion aus Dateinamen (ohne Qualitaets-Tags) +- **Film-Erkennung**: Ein Video pro Ordner = Film, Ordnername als Filmtitel +- **ffprobe-Analyse**: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR/10-Bit +- **TVDB-Integration**: Serien + Filme, Poster, Episoden-Titel, fehlende Episoden +- **TVDB Review-Modal**: Vorschlaege pruefen statt blindem Auto-Match, manuelle Suche +- **TVDB-Sprachkonfiguration**: Metadaten in Deutsch, Englisch oder anderen Sprachen +- **Schnellfilter**: Vordefinierte Filter (Nicht konvertiert, Alte Formate, Fehlende Episoden) +- **Filter-Presets**: Eigene Filter speichern und als Standard setzen +- **Fehlende Episoden**: Uebersicht aller fehlenden Episoden ueber alle Serien +- **Filter**: Video-Codec, Aufloesung, Container, Audio-Sprache, Kanaele, 10-Bit +- **Duplikat-Finder**: Gleiche Episode in verschiedenen Formaten erkennen +- **Import-Service**: Videos einsortieren mit Serien-Erkennung und TVDB-Lookup +- **Clean-Service**: Nicht-Video-Dateien (NFO, JPG, SRT etc.) finden und entfernen +- **Direkt-Konvertierung**: Videos aus der Bibliothek direkt in die Queue senden + +### Administration +- **Web-UI**: Responsive Dashboard, Bibliothek, Einstellungen, Statistik +- **Settings**: Encoding-Modus, Ziel-Container, Audio-/Untertitel-Sprachen, Cleanup +- **TVDB-Verwaltung**: API-Key/PIN konfigurieren, Sprache waehlen, Serien + Filme zuordnen +- **Scan-Pfad-Management**: Pfade hinzufuegen/loeschen/scannen ueber Admin-UI +- **Statistik**: Konvertierungs-Historie mit Groessen-Ersparnis, Dauer, Codec-Verteilung + + +## Architektur + +``` +┌────────────────────────────────────────────────────┐ +│ Browser (Dashboard / Bibliothek / Admin / Stats) │ +├──────────────┬──────────────┬──────────────────────┤ +│ HTMX/JS │ WebSocket │ REST API │ +├──────────────┴──────────────┴──────────────────────┤ +│ aiohttp Server │ +│ ┌──────────┐ ┌────────────┐ ┌──────────────────┐ │ +│ │ Queue │ │ Encoder │ │ LibraryService │ │ +│ │ Service │ │ Service │ │ (Scan, Filter, │ │ +│ │ │ │ (ffmpeg) │ │ Serien + Filme) │ │ +│ ├──────────┤ ├────────────┤ ├──────────────────┤ │ +│ │ Scanner │ │ Probe │ │ TVDBService │ │ +│ │ Service │ │ Service │ │ (API v4, i18n) │ │ +│ ├──────────┤ │ (ffprobe) │ ├──────────────────┤ │ +│ │ Importer │ └────────────┘ │ CleanerService │ │ +│ │ Service │ │ (Junk-Scan) │ │ +│ └──────────┘ └──────────────────┘ │ +├─────────────────────────────────────────────────────┤ +│ MariaDB (statistics, library_*, tvdb_episode_cache)│ +└─────────────────────────────────────────────────────┘ +``` + +### Technologie-Stack + +| Komponente | Technologie | +|------------|-------------| +| Backend | Python 3.12, aiohttp (async) | +| Templates | Jinja2 + HTMX | +| Datenbank | MariaDB (aiomysql) | +| Video | FFmpeg + FFprobe | +| GPU | Intel VAAPI (iHD-Treiber) | +| Metadaten | TVDB API v4 (tvdb-v4-official) | +| Container | Docker (Ubuntu 24.04) | +| Echtzeit | WebSocket | + + +## Projektstruktur + +``` +video-konverter/ +├── __main__.py # Einstiegspunkt +├── Dockerfile # Ubuntu 24.04 + ffmpeg + Intel GPU +├── entrypoint.sh # Default-Configs in Volumes kopieren +├── docker-compose.yml # GPU + CPU Profile +├── requirements.txt # Python-Abhaengigkeiten +├── unraid/ +│ └── my-VideoKonverter.xml # Unraid Docker-Template +├── app/ +│ ├── server.py # Haupt-Server (aiohttp Application) +│ ├── config.py # Settings + Presets laden +│ ├── cfg/ +│ │ ├── settings.yaml # Laufzeit-Einstellungen +│ │ └── presets.yaml # Encoding-Presets (7 Stueck) +│ ├── models/ +│ │ ├── media.py # MediaFile, VideoStream, AudioStream +│ │ └── job.py # ConvertJob, JobStatus +│ ├── services/ +│ │ ├── library.py # Bibliothek: Scan, Filter, Duplikate (1747 Z.) +│ │ ├── tvdb.py # TVDB: Auth, Suche, Episoden, Sprache (1005 Z.) +│ │ ├── importer.py # Import: Erkennung, TVDB-Lookup, Kopieren (734 Z.) +│ │ ├── cleaner.py # Clean: Junk-Scan, Nicht-Video-Dateien (155 Z.) +│ │ ├── queue.py # Job-Queue mit MariaDB-Persistierung (541 Z.) +│ │ ├── encoder.py # FFmpeg-Wrapper (GPU + CPU) +│ │ ├── probe.py # FFprobe-Analyse +│ │ ├── scanner.py # Dateisystem-Scanner +│ │ └── progress.py # Encoding-Fortschritt parsen +│ ├── routes/ +│ │ ├── api.py # REST API (Queue, Jobs, Convert) +│ │ ├── library_api.py # REST API (Bibliothek, TVDB, Scan) (998 Z.) +│ │ ├── pages.py # HTML-Seiten (Dashboard, Admin, etc.) +│ │ └── ws.py # WebSocket-Manager +│ ├── templates/ +│ │ ├── base.html # Basis-Layout mit Navigation +│ │ ├── dashboard.html # Queue + aktive Jobs +│ │ ├── library.html # Bibliothek mit Filtern +│ │ ├── admin.html # Einstellungen + TVDB + Scan-Pfade +│ │ ├── statistics.html # Konvertierungs-Statistik +│ │ └── partials/ +│ │ └── stats_table.html +│ └── static/ +│ ├── css/style.css # Komplettes Styling (1554 Z.) +│ └── js/ +│ ├── library.js # Bibliothek-UI (1912 Z.) +│ ├── websocket.js # WebSocket-Client +│ └── filebrowser.js # Datei-Browser +├── data/ # Queue-Persistierung +├── logs/ # Server-Logs +└── testmedia/ # Test-Dateien +``` + + +## Installation + +### Voraussetzungen +- Docker + Docker Compose +- MariaDB-Server (extern, z.B. auf Unraid) +- Optional: Intel GPU fuer Hardware-Encoding (Intel A380 empfohlen) + +### MariaDB einrichten +```sql +CREATE DATABASE video_converter CHARACTER SET utf8mb4; +CREATE USER 'video'@'%' IDENTIFIED BY 'dein_passwort'; +GRANT ALL PRIVILEGES ON video_converter.* TO 'video'@'%'; +FLUSH PRIVILEGES; +``` +Die Tabellen werden automatisch beim ersten Start erstellt. + +### Unraid-Installation + +1. Docker-Image laden (Unraid Web-Terminal): +```bash +docker load -i /mnt/user/downloads/videokonverter-cpu.tar +``` + +2. XML-Template kopieren: +```bash +cp /mnt/user/downloads/my-VideoKonverter.xml /boot/config/plugins/dockerMan/templates-user/ +``` + +3. In der Unraid Docker-WebGUI: "Container hinzufuegen" → Template waehlen → Werte anpassen → Starten + +Das Template enthält alle Variablen mit korrekten Defaults. + +### Konfiguration + +Alle Einstellungen sind per **Umgebungsvariablen** (VK_*) konfigurierbar. +ENV-Variablen ueberschreiben immer die `settings.yaml`. + +| Variable | Default | Beschreibung | +|----------|---------|-------------| +| `VK_DB_HOST` | `192.168.155.11` | MariaDB Host | +| `VK_DB_PORT` | `3306` | MariaDB Port | +| `VK_DB_USER` | `video` | MariaDB Benutzer | +| `VK_DB_PASSWORD` | - | MariaDB Passwort | +| `VK_DB_NAME` | `video_converter` | Datenbank-Name | +| `VK_MODE` | `cpu` | Encoding-Modus: `gpu`, `cpu`, `auto` | +| `VK_GPU_DEVICE` | `/dev/dri/renderD129` | GPU Render-Device | +| `VK_DEFAULT_PRESET` | `gpu_av1_10bit` | Standard Encoding-Preset | +| `VK_MAX_JOBS` | `1` | Max. parallele Jobs | +| `VK_TVDB_API_KEY` | - | TVDB API Key | +| `VK_TVDB_LANGUAGE` | `deu` | TVDB Sprache | +| `VK_LOG_LEVEL` | `INFO` | Log-Level | + +Alternativ kann `app/cfg/settings.yaml` direkt bearbeitet werden. +Bei Erstinstallation werden Default-Konfigdateien automatisch ins cfg-Volume kopiert. + +### GPU-Device ermitteln + +Auf dem Host pruefen welches renderD* die Intel GPU ist: +```bash +cat /sys/class/drm/renderD*/device/uevent | grep -B1 DRIVER +``` +Beispiel: `renderD128` = AMD, `renderD129` = Intel → `VK_GPU_DEVICE=/dev/dri/renderD129` + +### Starten + +**GPU-Modus** (Produktion auf Unraid): +```bash +docker compose --profile gpu up --build -d +``` + +**CPU-Modus** (lokal testen): +```bash +PUID=1000 PGID=1000 docker compose --profile cpu up --build -d +``` + +Web-UI: http://localhost:8080 + + +## Encoding-Presets + +| Preset | Codec | Container | Qualitaet | Modus | +|--------|-------|-----------|-----------|-------| +| GPU AV1 | av1_vaapi | WebM | QP 30 | GPU | +| GPU AV1 10-Bit | av1_vaapi | WebM | QP 30 | GPU | +| GPU HEVC | hevc_vaapi | MKV | QP 28 | GPU | +| GPU H.264 | h264_vaapi | MP4 | QP 23 | GPU | +| CPU AV1/SVT | libsvtav1 | WebM | CRF 30 | CPU | +| CPU HEVC/x265 | libx265 | MKV | CRF 28 | CPU | +| CPU H.264/x264 | libx264 | MP4 | CRF 23 | CPU | + + +## API Referenz + +### Konvertierung +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| POST | `/api/convert` | Dateien/Ordner zur Queue hinzufuegen | +| GET | `/api/queue` | Queue-Status abrufen | +| DELETE | `/api/jobs/{id}` | Job entfernen/abbrechen | + +### Bibliothek +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| GET | `/api/library/paths` | Scan-Pfade auflisten | +| POST | `/api/library/paths` | Scan-Pfad hinzufuegen | +| DELETE | `/api/library/paths/{id}` | Scan-Pfad loeschen | +| POST | `/api/library/scan` | Alle Pfade scannen | +| POST | `/api/library/scan/{id}` | Einzelnen Pfad scannen | +| GET | `/api/library/scan-status` | Scan-Fortschritt | +| GET | `/api/library/videos` | Videos filtern (siehe Filter-Params) | +| GET | `/api/library/series` | Alle Serien | +| GET | `/api/library/series/{id}` | Serie mit Episoden | +| GET | `/api/library/series/{id}/missing` | Fehlende Episoden einer Serie | +| GET | `/api/library/missing-episodes` | Alle fehlenden Episoden (paginated) | +| GET | `/api/library/filter-presets` | Filter-Presets laden | +| POST | `/api/library/filter-presets` | Neues Preset speichern | +| PUT | `/api/library/filter-presets` | Presets aktualisieren | +| DELETE | `/api/library/filter-presets/{id}` | Preset loeschen | +| PUT | `/api/library/default-view` | Standard-Ansicht setzen | +| POST | `/api/library/series/{id}/tvdb-match` | TVDB-ID zuordnen | +| POST | `/api/library/series/{id}/convert` | Alle Episoden konvertieren | +| GET | `/api/library/series/{id}/convert-status` | Codec-Status der Serie | +| POST | `/api/library/series/{id}/cleanup` | Alte Dateien loeschen | +| GET | `/api/library/duplicates` | Duplikate finden | +| POST | `/api/library/videos/{id}/convert` | Direkt konvertieren | +| POST | `/api/library/delete-folder` | Ordner komplett loeschen | +| GET | `/api/library/stats` | Bibliotheks-Statistiken | +| GET | `/api/library/movies` | Filme auflisten | +| POST | `/api/library/movies/{id}/tvdb-match` | Film-TVDB-Zuordnung | +| POST | `/api/library/tvdb-auto-match` | Review-Vorschlaege sammeln | +| POST | `/api/library/tvdb-confirm` | TVDB-Match bestaetigen | +| POST | `/api/library/tvdb-refresh-episodes` | Episoden-Cache aktualisieren | +| GET | `/api/tvdb/search?q=` | TVDB-Suche | +| GET | `/api/tvdb/language` | TVDB-Sprache lesen | +| PUT | `/api/tvdb/language` | TVDB-Sprache aendern | + +### Streaming +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| GET | `/api/library/videos/{id}/stream` | Video-Transcoding-Stream (ffmpeg pipe) | +| DELETE | `/api/library/videos/{id}` | Video loeschen (DB + Datei) | + +### Import +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| GET | `/api/library/import` | Alle Import-Jobs auflisten | +| POST | `/api/library/import` | Neuen Import-Job erstellen | +| GET | `/api/library/import/{id}` | Import-Job Status mit Items | +| POST | `/api/library/import/{id}/analyze` | Import analysieren | +| POST | `/api/library/import/{id}/execute` | Import ausfuehren | +| POST | `/api/library/import/{id}/assign` | Item manuell zuordnen | +| POST | `/api/library/import/{id}/skip` | Item ueberspringen | + +### System +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| GET | `/api/logs` | Server-Logs abrufen | +| GET | `/api/system` | System-Info (GPU, Jobs) | + +### Video-Filter (`/api/library/videos`) +``` +?video_codec=hevc # h264, hevc, av1, mpeg4 +&min_width=1920 # Mindest-Aufloesung +&container=mkv # mkv, mp4, avi, webm +&audio_lang=ger # Audio-Sprache +&audio_channels=6 # Kanal-Anzahl (2=Stereo, 6=5.1) +&is_10bit=1 # Nur 10-Bit +¬_converted=1 # Nicht im Zielformat (Container + Codec) +&exclude_codec=av1 # Codec ausschliessen +&exclude_container=webm # Container ausschliessen +&search=breaking # Dateiname-Suche +&sort=file_size # Sortierung +&order=desc # asc | desc +&page=1&limit=50 # Pagination +``` + + +## Datenbank-Schema + +### library_paths +Konfigurierte Scan-Pfade fuer Serien- und Film-Ordner. + +### library_series +Erkannte Serien mit optionaler TVDB-Verknuepfung (Poster, Beschreibung, Episoden-Zaehler). + +### library_movies +Erkannte Filme mit optionaler TVDB-Verknuepfung (Poster, Beschreibung, Jahr). + +### library_videos +Jedes Video mit vollstaendigen ffprobe-Metadaten: +- Video: Codec, Aufloesung, Framerate, Bitrate, 10-Bit, HDR +- Audio: JSON-Array mit Spuren (`[{"codec":"eac3","lang":"ger","channels":6,"bitrate":256000}]`) +- Untertitel: JSON-Array (`[{"codec":"subrip","lang":"ger"}]`) +- Serien: Staffel/Episode-Nummer, episode_end (fuer Doppel-Episoden), Episoden-Titel + +### tvdb_episode_cache +Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung). + + +## Docker Volumes + +| Volume (Host) | Container-Pfad | Beschreibung | +|----------------|---------------|-------------| +| `./app/cfg` (lokal) / `/mnt/user/appdata/videokonverter/cfg` (Unraid) | `/opt/video-konverter/app/cfg` | Konfiguration (persistent, Defaults werden automatisch kopiert) | +| `./data` (lokal) / `/mnt/user/appdata/videokonverter/data` (Unraid) | `/opt/video-konverter/data` | Queue-Persistierung | +| `./logs` (lokal) / `/mnt/user/appdata/videokonverter/logs` (Unraid) | `/opt/video-konverter/logs` | Server-Logs | +| `/mnt` (lokal) / `/mnt/user` (Unraid) | `/mnt` | Medien-Pfade | +| `/dev/dri` | `/dev/dri` | GPU-Devices (fuer VAAPI) | + + +## Lizenz + +Privates Projekt von Eddy (Eduard Wisch). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0cbf659 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,89 @@ +services: + # === GPU-Modus (Produktion auf Unraid) === + # Starten mit: docker compose --profile gpu up --build + # Unraid: nobody:users = 99:100 + video-konverter: + build: + context: . + dockerfile: Dockerfile + container_name: video-konverter + restart: unless-stopped + user: "${PUID:-99}:${PGID:-100}" + ports: + - "${VK_PORT:-8080}:8080" + volumes: + # Konfiguration (persistent) + - ./video-konverter/app/cfg:/opt/video-konverter/app/cfg + # Daten (Queue-Persistierung) + - ./data:/opt/video-konverter/data + # Logs + - ./logs:/opt/video-konverter/logs + # /mnt 1:1 durchreichen - Pfade von Dolphin stimmen dann im Container + - /mnt:/mnt:rw + devices: + # Alle GPUs durchreichen - VK_GPU_DEVICE waehlt die richtige + - /dev/dri:/dev/dri + group_add: + - "video" + environment: + # GPU-Treiber + - LIBVA_DRIVER_NAME=iHD + - LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri + # === VideoKonverter Konfiguration (VK_*) === + # Alle Werte ueberschreiben die settings.yaml + # Datenbank + - VK_DB_HOST=${VK_DB_HOST:-192.168.155.11} + - VK_DB_PORT=${VK_DB_PORT:-3306} + - VK_DB_USER=${VK_DB_USER:-video} + - VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715} + - VK_DB_NAME=${VK_DB_NAME:-video_converter} + # Encoding + - VK_MODE=gpu + - VK_GPU_DEVICE=${VK_GPU_DEVICE:-/dev/dri/renderD129} + - VK_MAX_JOBS=${VK_MAX_JOBS:-1} + - VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-gpu_av1_10bit} + # Library / TVDB + - VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-} + - VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu} + # Logging + - VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO} + profiles: + - gpu + + # === CPU-Modus (lokales Testen ohne GPU) === + # Starten mit: docker compose --profile cpu up --build + # Lokal: CIFS-Mount nutzt UID 1000, daher PUID/PGID ueberschreiben: + # PUID=1000 PGID=1000 docker compose --profile cpu up --build + video-konverter-cpu: + build: + context: . + dockerfile: Dockerfile + container_name: video-konverter-cpu + user: "${PUID:-99}:${PGID:-100}" + ports: + - "${VK_PORT:-8080}:8080" + volumes: + - ./video-konverter/app/cfg:/opt/video-konverter/app/cfg + - ./data:/opt/video-konverter/data + - ./logs:/opt/video-konverter/logs + # /mnt 1:1 durchreichen - Pfade identisch zum Host + - /mnt:/mnt:rw + environment: + # === VideoKonverter Konfiguration (VK_*) === + # Datenbank + - VK_DB_HOST=${VK_DB_HOST:-192.168.155.11} + - VK_DB_PORT=${VK_DB_PORT:-3306} + - VK_DB_USER=${VK_DB_USER:-video} + - VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715} + - VK_DB_NAME=${VK_DB_NAME:-video_converter} + # Encoding + - VK_MODE=cpu + - VK_MAX_JOBS=${VK_MAX_JOBS:-1} + - VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-cpu_av1} + # Library / TVDB + - VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-} + - VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu} + # Logging + - VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO} + profiles: + - cpu diff --git a/video-konverter/__main__.py b/video-konverter/__main__.py new file mode 100644 index 0000000..371b5b0 --- /dev/null +++ b/video-konverter/__main__.py @@ -0,0 +1,14 @@ +"""Einstiegspunkt fuer den VideoKonverter Server""" +import asyncio +import logging +from app.server import VideoKonverterServer + + +if __name__ == "__main__": + server = VideoKonverterServer() + try: + asyncio.run(server.run()) + except KeyboardInterrupt: + logging.warning("Server wurde manuell beendet") + except Exception as e: + logging.critical(f"Kritischer Fehler: {e}", exc_info=True) diff --git a/video-konverter/app/__init__.py b/video-konverter/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video-konverter/app/config.py b/video-konverter/app/config.py new file mode 100644 index 0000000..c1e18a2 --- /dev/null +++ b/video-konverter/app/config.py @@ -0,0 +1,333 @@ +"""Konfigurationsmanagement - Singleton fuer Settings und Presets + +Alle wichtigen Settings koennen per Umgebungsvariable ueberschrieben werden. +ENV-Variablen haben IMMER Vorrang vor settings.yaml. + +Mapping (VK_ Prefix): + Datenbank: VK_DB_HOST, VK_DB_PORT, VK_DB_USER, VK_DB_PASSWORD, VK_DB_NAME + Encoding: VK_MODE (cpu/gpu/auto), VK_GPU_DEVICE, VK_MAX_JOBS, VK_DEFAULT_PRESET + Server: VK_PORT, VK_HOST, VK_EXTERNAL_URL + Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false) + Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4) + Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR) +""" +import os +import logging +import yaml +from pathlib import Path +from typing import Optional +from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler + +# Mapping: ENV-Variable -> (settings-pfad, typ) +# Pfad als Tuple: ("section", "key") +_ENV_MAP: dict[str, tuple[tuple[str, str], type]] = { + "VK_DB_HOST": (("database", "host"), str), + "VK_DB_PORT": (("database", "port"), int), + "VK_DB_USER": (("database", "user"), str), + "VK_DB_PASSWORD": (("database", "password"), str), + "VK_DB_NAME": (("database", "database"), str), + "VK_MODE": (("encoding", "mode"), str), + "VK_GPU_DEVICE": (("encoding", "gpu_device"), str), + "VK_MAX_JOBS": (("encoding", "max_parallel_jobs"), int), + "VK_DEFAULT_PRESET": (("encoding", "default_preset"), str), + "VK_PORT": (("server", "port"), int), + "VK_HOST": (("server", "host"), str), + "VK_EXTERNAL_URL": (("server", "external_url"), str), + "VK_TVDB_API_KEY": (("library", "tvdb_api_key"), str), + "VK_TVDB_LANGUAGE": (("library", "tvdb_language"), str), + "VK_LIBRARY_ENABLED": (("library", "enabled"), bool), + "VK_TARGET_CONTAINER": (("files", "target_container"), str), + "VK_LOG_LEVEL": (("logging", "level"), str), +} + +# Rueckwaertskompatibilitaet +_ENV_ALIASES: dict[str, str] = { + "VIDEO_KONVERTER_MODE": "VK_MODE", +} + +# Default-Settings wenn keine settings.yaml existiert +_DEFAULT_SETTINGS: dict = { + "database": { + "host": "localhost", + "port": 3306, + "user": "video", + "password": "", + "database": "video_converter", + }, + "encoding": { + "mode": "cpu", + "default_preset": "cpu_av1", + "gpu_device": "/dev/dri/renderD128", + "gpu_driver": "iHD", + "max_parallel_jobs": 1, + }, + "server": { + "host": "0.0.0.0", + "port": 8080, + "external_url": "", + "use_https": False, + "websocket_path": "/ws", + }, + "files": { + "delete_source": False, + "recursive_scan": True, + "scan_extensions": [".mkv", ".mp4", ".avi", ".wmv", ".vob", ".ts", ".m4v", ".flv", ".mov"], + "target_container": "webm", + "target_folder": "same", + }, + "audio": { + "bitrate_map": {2: "128k", 6: "320k", 8: "450k"}, + "default_bitrate": "192k", + "default_codec": "libopus", + "keep_channels": True, + "languages": ["ger", "eng", "und"], + }, + "subtitle": { + "codec_blacklist": ["hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"], + "languages": ["ger", "eng"], + }, + "library": { + "enabled": True, + "import_default_mode": "copy", + "import_naming_pattern": "{series} - S{season:02d}E{episode:02d} - {title}.{ext}", + "import_season_pattern": "Season {season:02d}", + "scan_interval_hours": 0, + "tvdb_api_key": "", + "tvdb_language": "deu", + "tvdb_pin": "", + }, + "cleanup": { + "enabled": False, + "delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"], + "keep_extensions": [".srt"], + "exclude_patterns": ["readme*", "*.md"], + }, + "logging": { + "level": "INFO", + "file": "server.log", + "rotation": "time", + "backup_count": 7, + "max_size_mb": 10, + }, + "statistics": { + "cleanup_days": 365, + "max_entries": 5000, + }, +} + + +class Config: + """Laedt und verwaltet settings.yaml und presets.yaml. + ENV-Variablen (VK_*) ueberschreiben YAML-Werte.""" + _instance: Optional['Config'] = None + + def __new__(cls) -> 'Config': + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if self._initialized: + return + self._initialized = True + + self._base_path = Path(__file__).parent + self._cfg_path = self._base_path / "cfg" + self._log_path = self._base_path.parent / "logs" + self._data_path = self._base_path.parent / "data" + + # Verzeichnisse sicherstellen + self._cfg_path.mkdir(parents=True, exist_ok=True) + self._log_path.mkdir(parents=True, exist_ok=True) + self._data_path.mkdir(parents=True, exist_ok=True) + + self.settings: dict = {} + self.presets: dict = {} + self._load_settings() + self._load_presets() + self._apply_env_overrides() + + @property + def log_file_path(self) -> str: + """Pfad zur aktiven Log-Datei""" + log_file = self.settings.get("logging", {}).get("file", "server.log") + return str(self._log_path / log_file) + + def _load_settings(self) -> None: + """Laedt settings.yaml oder erzeugt Defaults""" + import copy + settings_file = self._cfg_path / "settings.yaml" + if settings_file.exists(): + try: + with open(settings_file, "r", encoding="utf-8") as f: + self.settings = yaml.safe_load(f) or {} + logging.info(f"Settings geladen: {settings_file}") + except Exception as e: + logging.error(f"Settings lesen fehlgeschlagen: {e}") + self.settings = copy.deepcopy(_DEFAULT_SETTINGS) + else: + # Keine settings.yaml -> Defaults verwenden und speichern + logging.info("Keine settings.yaml gefunden - erzeuge Defaults") + self.settings = copy.deepcopy(_DEFAULT_SETTINGS) + self._save_yaml(settings_file, self.settings) + + def _load_presets(self) -> None: + """Laedt presets.yaml. Falls nicht vorhanden, aus cfg_defaults kopieren.""" + presets_file = self._cfg_path / "presets.yaml" + if not presets_file.exists(): + # Versuche Default-Presets aus cfg_defaults zu kopieren + defaults_file = Path(__file__).parent.parent / "cfg_defaults" / "presets.yaml" + if defaults_file.exists(): + import shutil + shutil.copy2(defaults_file, presets_file) + logging.info(f"Default-Presets kopiert: {defaults_file} -> {presets_file}") + else: + logging.warning("Keine presets.yaml und keine Defaults gefunden") + + if presets_file.exists(): + try: + with open(presets_file, "r", encoding="utf-8") as f: + self.presets = yaml.safe_load(f) or {} + logging.info(f"Presets geladen: {presets_file}") + except Exception as e: + logging.error(f"Presets lesen fehlgeschlagen: {e}") + self.presets = {} + else: + self.presets = {} + + def _apply_env_overrides(self) -> None: + """Umgebungsvariablen (VK_*) ueberschreiben Settings. + Unterstuetzt auch alte Variablennamen per Alias-Mapping.""" + applied = [] + + # Aliase aufloesen (z.B. VIDEO_KONVERTER_MODE -> VK_MODE) + for old_name, new_name in _ENV_ALIASES.items(): + if old_name in os.environ and new_name not in os.environ: + os.environ[new_name] = os.environ[old_name] + + for env_key, ((section, key), val_type) in _ENV_MAP.items(): + raw = os.environ.get(env_key) + if raw is None: + continue + + # Typ-Konvertierung + try: + if val_type is bool: + value = raw.lower() in ("true", "1", "yes", "on") + elif val_type is int: + value = int(raw) + else: + value = raw + except (ValueError, TypeError): + logging.warning(f"ENV {env_key}={raw!r} - ungueliger Wert, uebersprungen") + continue + + self.settings.setdefault(section, {})[key] = value + applied.append(f"{env_key}={value}") + + if applied: + logging.info(f"ENV-Overrides angewendet: {', '.join(applied)}") + + @staticmethod + def _save_yaml(path: Path, data: dict) -> None: + """Schreibt dict als YAML in Datei""" + try: + with open(path, "w", encoding="utf-8") as f: + yaml.dump(data, f, default_flow_style=False, + indent=2, allow_unicode=True) + logging.info(f"YAML gespeichert: {path}") + except Exception as e: + logging.error(f"YAML speichern fehlgeschlagen ({path}): {e}") + + def save_settings(self) -> None: + """Schreibt aktuelle Settings zurueck in settings.yaml""" + self._save_yaml(self._cfg_path / "settings.yaml", self.settings) + + def save_presets(self) -> None: + """Schreibt Presets zurueck in presets.yaml""" + self._save_yaml(self._cfg_path / "presets.yaml", self.presets) + + def setup_logging(self) -> None: + """Konfiguriert Logging mit Rotation""" + log_cfg = self.settings.get("logging", {}) + log_level = log_cfg.get("level", "INFO") + log_file = log_cfg.get("file", "server.log") + log_mode = log_cfg.get("rotation", "time") + backup_count = log_cfg.get("backup_count", 7) + + log_path = self._log_path / log_file + handlers = [logging.StreamHandler()] + + if log_mode == "time": + file_handler = TimedRotatingFileHandler( + str(log_path), when="midnight", interval=1, + backupCount=backup_count, encoding="utf-8" + ) + else: + max_bytes = log_cfg.get("max_size_mb", 10) * 1024 * 1024 + file_handler = RotatingFileHandler( + str(log_path), maxBytes=max_bytes, + backupCount=backup_count, encoding="utf-8" + ) + + handlers.append(file_handler) + + # force=True weil Config.__init__ logging aufruft bevor setup_logging() + logging.basicConfig( + level=getattr(logging, log_level, logging.INFO), + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=handlers, + force=True, + ) + + # --- Properties fuer haeufig benoetigte Werte --- + + @property + def encoding_mode(self) -> str: + return self.settings.get("encoding", {}).get("mode", "cpu") + + @property + def gpu_device(self) -> str: + return self.settings.get("encoding", {}).get("gpu_device", "/dev/dri/renderD128") + + @property + def max_parallel_jobs(self) -> int: + return self.settings.get("encoding", {}).get("max_parallel_jobs", 1) + + @property + def target_container(self) -> str: + return self.settings.get("files", {}).get("target_container", "webm") + + @property + def default_preset_name(self) -> str: + return self.settings.get("encoding", {}).get("default_preset", "cpu_av1") + + @property + def default_preset(self) -> dict: + name = self.default_preset_name + return self.presets.get(name, {}) + + @property + def data_path(self) -> Path: + return self._data_path + + @property + def audio_config(self) -> dict: + return self.settings.get("audio", {}) + + @property + def subtitle_config(self) -> dict: + return self.settings.get("subtitle", {}) + + @property + def files_config(self) -> dict: + return self.settings.get("files", {}) + + @property + def cleanup_config(self) -> dict: + return self.settings.get("cleanup", {}) + + @property + def server_config(self) -> dict: + return self.settings.get("server", {}) diff --git a/video-konverter/app/models/__init__.py b/video-konverter/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video-konverter/app/models/job.py b/video-konverter/app/models/job.py new file mode 100644 index 0000000..d432708 --- /dev/null +++ b/video-konverter/app/models/job.py @@ -0,0 +1,205 @@ +"""Konvertierungs-Job-Modell mit Status-Management""" +import time +import asyncio +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Optional +from app.models.media import MediaFile + +# Globaler Zaehler fuer eindeutige IDs +_id_counter = 0 + + +class JobStatus(IntEnum): + QUEUED = 0 + ACTIVE = 1 + FINISHED = 2 + FAILED = 3 + CANCELLED = 4 + + +@dataclass +class ConversionJob: + """Einzelner Konvertierungs-Auftrag""" + media: MediaFile + preset_name: str = "" + + # Wird in __post_init__ gesetzt + id: int = field(init=False) + status: JobStatus = field(default=JobStatus.QUEUED) + + # Ziel-Informationen + target_path: str = "" + target_filename: str = "" + target_container: str = "webm" + + # Optionen + delete_source: bool = False # Quelldatei nach Konvertierung loeschen + + # ffmpeg Prozess + ffmpeg_cmd: list[str] = field(default_factory=list) + process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False) + task: Optional[asyncio.Task] = field(default=None, repr=False) + + # Fortschritt + progress_percent: float = 0.0 + progress_fps: float = 0.0 + progress_speed: float = 0.0 + progress_bitrate: int = 0 + progress_size_bytes: int = 0 + progress_time_sec: float = 0.0 + progress_frames: int = 0 + progress_eta_sec: float = 0.0 + + # Zeitstempel + created_at: float = field(default_factory=time.time) + started_at: Optional[float] = None + finished_at: Optional[float] = None + + # Statistik-Akkumulation (Summe, Anzahl) + _stat_fps: list = field(default_factory=lambda: [0.0, 0]) + _stat_speed: list = field(default_factory=lambda: [0.0, 0]) + _stat_bitrate: list = field(default_factory=lambda: [0, 0]) + + def __post_init__(self) -> None: + global _id_counter + self.id = int(time.time() * 1000) * 1000 + _id_counter + _id_counter += 1 + + def build_target_path(self, config) -> None: + """Baut Ziel-Pfad basierend auf Config""" + files_cfg = config.files_config + container = files_cfg.get("target_container", "webm") + self.target_container = container + + # Dateiname ohne Extension + neue Extension + base_name = self.media.source_filename.rsplit(".", 1)[0] + self.target_filename = f"{base_name}.{container}" + + # Ziel-Ordner + target_folder = files_cfg.get("target_folder", "same") + if target_folder == "same": + target_dir = self.media.source_dir + else: + target_dir = target_folder + + self.target_path = f"{target_dir}/{self.target_filename}" + + # Konfliktvermeidung: Wenn Ziel = Quelle (gleiche Extension) + if self.target_path == self.media.source_path: + base = self.target_path.rsplit(".", 1)[0] + self.target_path = f"{base}_converted.{container}" + self.target_filename = f"{base_name}_converted.{container}" + + def update_stats(self, fps: float, speed: float, bitrate: int) -> None: + """Akkumuliert Statistik-Werte fuer Durchschnittsberechnung""" + if fps > 0: + self._stat_fps[0] += fps + self._stat_fps[1] += 1 + if speed > 0: + self._stat_speed[0] += speed + self._stat_speed[1] += 1 + if bitrate > 0: + self._stat_bitrate[0] += bitrate + self._stat_bitrate[1] += 1 + + @property + def avg_fps(self) -> float: + if self._stat_fps[1] > 0: + return self._stat_fps[0] / self._stat_fps[1] + return 0.0 + + @property + def avg_speed(self) -> float: + if self._stat_speed[1] > 0: + return self._stat_speed[0] / self._stat_speed[1] + return 0.0 + + @property + def avg_bitrate(self) -> int: + if self._stat_bitrate[1] > 0: + return int(self._stat_bitrate[0] / self._stat_bitrate[1]) + return 0 + + @property + def duration_sec(self) -> float: + """Konvertierungsdauer in Sekunden""" + if self.started_at and self.finished_at: + return self.finished_at - self.started_at + return 0.0 + + def to_dict_active(self) -> dict: + """Fuer WebSocket data_convert Nachricht""" + size = MediaFile.format_size(self.media.source_size_bytes) + return { + "source_file_name": self.media.source_filename, + "source_file": self.media.source_path, + "source_path": self.media.source_dir, + "source_duration": self.media.source_duration_sec, + "source_size": [size[0], size[1]], + "source_frame_rate": self.media.frame_rate, + "source_frames_total": self.media.total_frames, + "target_file_name": self.target_filename, + "target_file": self.target_path, + "status": self.status.value, + "preset": self.preset_name, + } + + def to_dict_queue(self) -> dict: + """Fuer WebSocket data_queue Nachricht""" + return { + "source_file_name": self.media.source_filename, + "source_file": self.media.source_path, + "source_path": self.media.source_dir, + "status": self.status.value, + "preset": self.preset_name, + } + + def to_dict_progress(self) -> dict: + """Fuer WebSocket data_flow Nachricht""" + target_size = MediaFile.format_size(self.progress_size_bytes) + return { + "id": self.id, + "frames": self.progress_frames, + "fps": self.progress_fps, + "speed": self.progress_speed, + "quantizer": 0, + "size": [target_size[0], target_size[1]], + "time": MediaFile.format_time(self.progress_time_sec), + "time_remaining": MediaFile.format_time(self.progress_eta_sec), + "loading": round(self.progress_percent, 1), + "bitrate": [self.progress_bitrate, "kbits/s"], + } + + def to_dict_stats(self) -> dict: + """Fuer Statistik-Datenbank""" + return { + "source_path": self.media.source_path, + "source_filename": self.media.source_filename, + "source_size_bytes": self.media.source_size_bytes, + "source_duration_sec": self.media.source_duration_sec, + "source_frame_rate": self.media.frame_rate, + "source_frames_total": self.media.total_frames, + "target_path": self.target_path, + "target_filename": self.target_filename, + "target_size_bytes": self.progress_size_bytes, + "target_container": self.target_container, + "preset_name": self.preset_name, + "status": self.status.value, + "started_at": self.started_at, + "finished_at": self.finished_at, + "duration_sec": self.duration_sec, + "avg_fps": self.avg_fps, + "avg_speed": self.avg_speed, + "avg_bitrate": self.avg_bitrate, + } + + def to_json(self) -> dict: + """Fuer Queue-Persistierung""" + return { + "source_path": self.media.source_path, + "preset_name": self.preset_name, + "status": self.status.value, + "created_at": self.created_at, + "delete_source": self.delete_source, + } diff --git a/video-konverter/app/models/media.py b/video-konverter/app/models/media.py new file mode 100644 index 0000000..c364d90 --- /dev/null +++ b/video-konverter/app/models/media.py @@ -0,0 +1,166 @@ +"""Media-Datei-Modell mit Stream-Informationen""" +import os +import math +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class VideoStream: + """Einzelner Video-Stream""" + index: int + codec_name: str + width: int = 0 + height: int = 0 + pix_fmt: str = "" + frame_rate: float = 0.0 + level: Optional[int] = None + bit_rate: Optional[int] = None + + @property + def is_10bit(self) -> bool: + """Erkennt ob der Stream 10-Bit ist""" + return "10" in self.pix_fmt or "p010" in self.pix_fmt + + @property + def resolution(self) -> str: + return f"{self.width}x{self.height}" + + +@dataclass +class AudioStream: + """Einzelner Audio-Stream""" + index: int + codec_name: str + channels: int = 2 + sample_rate: int = 48000 + language: Optional[str] = None + bit_rate: Optional[int] = None + + @property + def channel_layout(self) -> str: + """Menschenlesbares Kanal-Layout""" + layouts = {1: "Mono", 2: "Stereo", 3: "2.1", 6: "5.1", 8: "7.1"} + return layouts.get(self.channels, f"{self.channels}ch") + + +@dataclass +class SubtitleStream: + """Einzelner Untertitel-Stream""" + index: int + codec_name: str + language: Optional[str] = None + + +@dataclass +class MediaFile: + """Analysierte Mediendatei mit allen Stream-Informationen""" + source_path: str + source_dir: str = field(init=False) + source_filename: str = field(init=False) + source_extension: str = field(init=False) + source_size_bytes: int = 0 + source_duration_sec: float = 0.0 + source_bitrate: int = 0 + + video_streams: list[VideoStream] = field(default_factory=list) + audio_streams: list[AudioStream] = field(default_factory=list) + subtitle_streams: list[SubtitleStream] = field(default_factory=list) + + def __post_init__(self) -> None: + self.source_dir = os.path.dirname(self.source_path) + self.source_filename = os.path.basename(self.source_path) + self.source_extension = os.path.splitext(self.source_filename)[1].lower() + + @property + def frame_rate(self) -> float: + """Framerate des ersten Video-Streams""" + if self.video_streams: + return self.video_streams[0].frame_rate + return 0.0 + + @property + def total_frames(self) -> int: + """Gesamtanzahl Frames""" + return int(self.frame_rate * self.source_duration_sec) + + @property + def is_10bit(self) -> bool: + """Prueft ob der erste Video-Stream 10-Bit ist""" + if self.video_streams: + return self.video_streams[0].is_10bit + return False + + @property + def source_size_human(self) -> tuple[float, str]: + """Menschenlesbare Groesse""" + return self.format_size(self.source_size_bytes) + + def to_dict(self) -> dict: + """Serialisiert fuer WebSocket/API""" + size = self.source_size_human + return { + "source_path": self.source_path, + "source_dir": self.source_dir, + "source_filename": self.source_filename, + "source_extension": self.source_extension, + "source_size": [size[0], size[1]], + "source_duration": self.source_duration_sec, + "source_duration_human": self.format_time(self.source_duration_sec), + "source_frame_rate": self.frame_rate, + "source_frames_total": self.total_frames, + "video_streams": len(self.video_streams), + "audio_streams": [ + {"index": a.index, "codec": a.codec_name, + "channels": a.channels, "layout": a.channel_layout, + "language": a.language} + for a in self.audio_streams + ], + "subtitle_streams": [ + {"index": s.index, "codec": s.codec_name, + "language": s.language} + for s in self.subtitle_streams + ], + } + + @staticmethod + def format_size(size_bytes: int) -> tuple[float, str]: + """Konvertiert Bytes in menschenlesbare Groesse""" + units = ["B", "KiB", "MiB", "GiB", "TiB"] + size = float(size_bytes) + unit_idx = 0 + while size >= 1024.0 and unit_idx < len(units) - 1: + size /= 1024.0 + unit_idx += 1 + return round(size, 1), units[unit_idx] + + @staticmethod + def format_time(seconds: float) -> str: + """Formatiert Sekunden in lesbares Format""" + if seconds <= 0: + return "0 Min" + days = int(seconds // 86400) + seconds %= 86400 + hours = int(seconds // 3600) + seconds %= 3600 + minutes = math.ceil(seconds / 60) + + parts = [] + if days: + parts.append(f"{days} Tage") + if hours: + parts.append(f"{hours} Std") + if minutes: + parts.append(f"{minutes} Min") + return " ".join(parts) if parts else "< 1 Min" + + @staticmethod + def time_to_seconds(time_str: str) -> float: + """Konvertiert HH:MM:SS oder Sekunden-String in float""" + parts = time_str.split(":") + if len(parts) == 1: + return float(parts[0]) + if len(parts) == 3: + h, m, s = map(float, parts) + return h * 3600 + m * 60 + s + return 0.0 diff --git a/video-konverter/app/routes/__init__.py b/video-konverter/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video-konverter/app/routes/api.py b/video-konverter/app/routes/api.py new file mode 100644 index 0000000..2e3e468 --- /dev/null +++ b/video-konverter/app/routes/api.py @@ -0,0 +1,390 @@ +"""REST API Endpoints""" +import asyncio +import logging +import os +from pathlib import Path +from aiohttp import web +from app.config import Config +from app.services.queue import QueueService +from app.services.scanner import ScannerService +from app.services.encoder import EncoderService +from app.routes.ws import WebSocketManager + + +def setup_api_routes(app: web.Application, config: Config, + queue_service: QueueService, + scanner: ScannerService, + ws_manager: WebSocketManager = None) -> None: + """Registriert alle API-Routes""" + + # --- Job-Management --- + + async def post_convert(request: web.Request) -> web.Response: + """ + POST /api/convert + Body: {"files": ["/pfad/datei.mkv", "/pfad/ordner/"]} + Optional: {"files": [...], "preset": "gpu_av1", "recursive": true} + Hauptendpoint fuer KDE Dolphin Integration. + """ + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + files = data.get("files", []) + if not files: + return web.json_response( + {"error": "Keine Dateien angegeben"}, status=400 + ) + + preset = data.get("preset") + recursive = data.get("recursive") + + logging.info(f"POST /api/convert: {len(files)} Pfade empfangen") + + jobs = await queue_service.add_paths(files, preset, recursive) + + return web.json_response({ + "message": f"{len(jobs)} Jobs erstellt", + "jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs], + }) + + async def get_jobs(request: web.Request) -> web.Response: + """GET /api/jobs - Alle Jobs mit Status""" + return web.json_response({"jobs": queue_service.get_all_jobs()}) + + async def delete_job(request: web.Request) -> web.Response: + """DELETE /api/jobs/{job_id}""" + job_id = int(request.match_info["job_id"]) + success = await queue_service.remove_job(job_id) + if success: + return web.json_response({"message": "Job geloescht"}) + return web.json_response({"error": "Job nicht gefunden"}, status=404) + + async def post_cancel(request: web.Request) -> web.Response: + """POST /api/jobs/{job_id}/cancel""" + job_id = int(request.match_info["job_id"]) + success = await queue_service.cancel_job(job_id) + if success: + return web.json_response({"message": "Job abgebrochen"}) + return web.json_response({"error": "Job nicht aktiv"}, status=400) + + async def post_retry(request: web.Request) -> web.Response: + """POST /api/jobs/{job_id}/retry""" + job_id = int(request.match_info["job_id"]) + success = await queue_service.retry_job(job_id) + if success: + return web.json_response({"message": "Job wiederholt"}) + return web.json_response({"error": "Job nicht fehlgeschlagen"}, status=400) + + # --- Settings --- + + async def get_settings(request: web.Request) -> web.Response: + """GET /api/settings""" + return web.json_response(config.settings) + + async def put_settings(request: web.Request) -> web.Response: + """PUT /api/settings - Settings aktualisieren""" + try: + new_settings = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + # Settings zusammenfuehren (deep merge) + _deep_merge(config.settings, new_settings) + config.save_settings() + logging.info("Settings aktualisiert via API") + return web.json_response({"message": "Settings gespeichert"}) + + # --- Presets --- + + async def get_presets(request: web.Request) -> web.Response: + """GET /api/presets""" + return web.json_response(config.presets) + + async def put_preset(request: web.Request) -> web.Response: + """PUT /api/presets/{preset_name}""" + preset_name = request.match_info["preset_name"] + try: + preset_data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + config.presets[preset_name] = preset_data + config.save_presets() + logging.info(f"Preset '{preset_name}' aktualisiert") + return web.json_response({"message": f"Preset '{preset_name}' gespeichert"}) + + # --- Statistics --- + + async def get_statistics(request: web.Request) -> web.Response: + """GET /api/statistics?limit=50&offset=0""" + limit = int(request.query.get("limit", 50)) + offset = int(request.query.get("offset", 0)) + stats = await queue_service.get_statistics(limit, offset) + summary = await queue_service.get_statistics_summary() + return web.json_response({"entries": stats, "summary": summary}) + + # --- System --- + + async def get_system_info(request: web.Request) -> web.Response: + """GET /api/system - GPU-Status, verfuegbare Devices""" + gpu_available = EncoderService.detect_gpu_available() + devices = EncoderService.get_available_render_devices() + return web.json_response({ + "encoding_mode": config.encoding_mode, + "gpu_available": gpu_available, + "gpu_devices": devices, + "gpu_device_configured": config.gpu_device, + "default_preset": config.default_preset_name, + "max_parallel_jobs": config.max_parallel_jobs, + "active_jobs": len([ + j for j in queue_service.jobs.values() + if j.status.value == 1 + ]), + "queued_jobs": len([ + j for j in queue_service.jobs.values() + if j.status.value == 0 + ]), + }) + + async def get_ws_config(request: web.Request) -> web.Response: + """GET /api/ws-config - WebSocket-URL fuer Client""" + srv = config.server_config + ext_url = srv.get("external_url", "") + use_https = srv.get("use_https", False) + port = srv.get("port", 8080) + + if ext_url: + ws_url = ext_url + else: + ws_url = f"{request.host}" + + return web.json_response({ + "websocket_url": ws_url, + "websocket_path": srv.get("websocket_path", "/ws"), + "use_https": use_https, + "port": port, + }) + + # --- Filebrowser --- + + # Erlaubte Basispfade (Sicherheit: nur unter /mnt navigierbar) + _BROWSE_ROOTS = ["/mnt"] + + def _is_path_allowed(path: str) -> bool: + """Prueft ob Pfad unter einem erlaubten Root liegt""" + real = os.path.realpath(path) + return any(real.startswith(root) for root in _BROWSE_ROOTS) + + async def get_browse(request: web.Request) -> web.Response: + """ + GET /api/browse?path=/mnt + Gibt Ordner und Videodateien im Verzeichnis zurueck. + """ + path = request.query.get("path", "/mnt") + + if not _is_path_allowed(path): + return web.json_response( + {"error": "Zugriff verweigert"}, status=403 + ) + + if not os.path.isdir(path): + return web.json_response( + {"error": "Verzeichnis nicht gefunden"}, status=404 + ) + + scan_ext = set(config.files_config.get("scan_extensions", [])) + dirs = [] + files = [] + + try: + for entry in sorted(os.listdir(path)): + # Versteckte Dateien ueberspringen + if entry.startswith("."): + continue + + full = os.path.join(path, entry) + + if os.path.isdir(full): + # Anzahl Videodateien im Unterordner zaehlen + video_count = 0 + try: + for f in os.listdir(full): + if os.path.splitext(f)[1].lower() in scan_ext: + video_count += 1 + except PermissionError: + pass + dirs.append({ + "name": entry, + "path": full, + "video_count": video_count, + }) + + elif os.path.isfile(full): + ext = os.path.splitext(entry)[1].lower() + if ext in scan_ext: + size = os.path.getsize(full) + files.append({ + "name": entry, + "path": full, + "size": size, + "size_human": _format_size(size), + }) + + except PermissionError: + return web.json_response( + {"error": "Keine Leseberechtigung"}, status=403 + ) + + # Eltern-Pfad (zum Navigieren nach oben) + parent = os.path.dirname(path) + if not _is_path_allowed(parent): + parent = None + + return web.json_response({ + "path": path, + "parent": parent, + "dirs": dirs, + "files": files, + "total_files": len(files), + }) + + def _format_size(size_bytes: int) -> str: + """Kompakte Groessenangabe""" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.0f} KiB" + if size_bytes < 1024 * 1024 * 1024: + return f"{size_bytes / (1024 * 1024):.1f} MiB" + return f"{size_bytes / (1024 * 1024 * 1024):.2f} GiB" + + # --- Upload --- + + # Upload-Verzeichnis + _UPLOAD_DIR = "/mnt/uploads" + + async def post_upload(request: web.Request) -> web.Response: + """ + POST /api/upload (multipart/form-data) + Laedt eine Videodatei hoch und startet die Konvertierung. + """ + os.makedirs(_UPLOAD_DIR, exist_ok=True) + + reader = await request.multipart() + preset = None + saved_files = [] + + while True: + part = await reader.next() + if part is None: + break + + if part.name == "preset": + preset = (await part.text()).strip() or None + continue + + if part.name == "files": + filename = part.filename + if not filename: + continue + + # Sicherheit: Nur Dateiname ohne Pfad + filename = os.path.basename(filename) + ext = os.path.splitext(filename)[1].lower() + scan_ext = set(config.files_config.get("scan_extensions", [])) + if ext not in scan_ext: + logging.warning(f"Upload abgelehnt (Extension {ext}): {filename}") + continue + + # Datei speichern + dest = os.path.join(_UPLOAD_DIR, filename) + # Konfliktvermeidung + if os.path.exists(dest): + base, extension = os.path.splitext(filename) + counter = 1 + while os.path.exists(dest): + dest = os.path.join( + _UPLOAD_DIR, f"{base}_{counter}{extension}" + ) + counter += 1 + + size = 0 + with open(dest, "wb") as f: + while True: + chunk = await part.read_chunk() + if not chunk: + break + f.write(chunk) + size += len(chunk) + + saved_files.append(dest) + logging.info(f"Upload: {filename} ({_format_size(size)})") + + if not saved_files: + return web.json_response( + {"error": "Keine gueltigen Videodateien hochgeladen"}, status=400 + ) + + jobs = await queue_service.add_paths(saved_files, preset) + + return web.json_response({ + "message": f"{len(saved_files)} Datei(en) hochgeladen, {len(jobs)} Jobs erstellt", + "jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs], + }) + + # --- Logs via WebSocket --- + + class WebSocketLogHandler(logging.Handler): + """Pusht Logs direkt per WebSocket an alle Clients""" + def __init__(self, ws_mgr: WebSocketManager): + super().__init__() + self._ws_manager = ws_mgr + + def emit(self, record): + if not self._ws_manager or not self._ws_manager.clients: + return + try: + loop = asyncio.get_running_loop() + loop.create_task( + self._ws_manager.broadcast_log( + record.levelname, record.getMessage() + ) + ) + except RuntimeError: + pass + + if ws_manager: + ws_log_handler = WebSocketLogHandler(ws_manager) + ws_log_handler.setLevel(logging.INFO) + logging.getLogger().addHandler(ws_log_handler) + + # --- Routes registrieren --- + app.router.add_get("/api/browse", get_browse) + app.router.add_post("/api/upload", post_upload) + app.router.add_post("/api/convert", post_convert) + app.router.add_get("/api/jobs", get_jobs) + app.router.add_delete("/api/jobs/{job_id}", delete_job) + app.router.add_post("/api/jobs/{job_id}/cancel", post_cancel) + app.router.add_post("/api/jobs/{job_id}/retry", post_retry) + app.router.add_get("/api/settings", get_settings) + app.router.add_put("/api/settings", put_settings) + app.router.add_get("/api/presets", get_presets) + app.router.add_put("/api/presets/{preset_name}", put_preset) + app.router.add_get("/api/statistics", get_statistics) + app.router.add_get("/api/system", get_system_info) + app.router.add_get("/api/ws-config", get_ws_config) + + +def _deep_merge(base: dict, override: dict) -> None: + """Rekursives Zusammenfuehren zweier Dicts""" + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + _deep_merge(base[key], value) + else: + base[key] = value diff --git a/video-konverter/app/routes/library_api.py b/video-konverter/app/routes/library_api.py new file mode 100644 index 0000000..292573e --- /dev/null +++ b/video-konverter/app/routes/library_api.py @@ -0,0 +1,1929 @@ +"""REST API Endpoints fuer die Video-Bibliothek""" +import asyncio +import logging +from aiohttp import web +from app.config import Config +from app.services.library import LibraryService +from app.services.tvdb import TVDBService +from app.services.queue import QueueService +from app.services.cleaner import CleanerService +from app.services.importer import ImporterService + + +def setup_library_routes(app: web.Application, config: Config, + library_service: LibraryService, + tvdb_service: TVDBService, + queue_service: QueueService, + cleaner_service: CleanerService = None, + importer_service: ImporterService = None + ) -> None: + """Registriert Bibliotheks-API-Routes""" + + # === Scan-Pfade === + + async def get_paths(request: web.Request) -> web.Response: + """GET /api/library/paths""" + paths = await library_service.get_paths() + return web.json_response({"paths": paths}) + + async def post_path(request: web.Request) -> web.Response: + """POST /api/library/paths""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + name = data.get("name", "").strip() + path = data.get("path", "").strip() + media_type = data.get("media_type", "").strip() + + if not name or not path: + return web.json_response( + {"error": "Name und Pfad erforderlich"}, status=400 + ) + if media_type not in ("series", "movie"): + return web.json_response( + {"error": "media_type muss 'series' oder 'movie' sein"}, + status=400, + ) + + path_id = await library_service.add_path(name, path, media_type) + if path_id: + return web.json_response( + {"message": "Pfad hinzugefuegt", "id": path_id} + ) + return web.json_response( + {"error": "Pfad konnte nicht hinzugefuegt werden"}, status=500 + ) + + async def put_path(request: web.Request) -> web.Response: + """PUT /api/library/paths/{path_id}""" + path_id = int(request.match_info["path_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + success = await library_service.update_path( + path_id, + name=data.get("name"), + path=data.get("path"), + media_type=data.get("media_type"), + enabled=data.get("enabled"), + ) + if success: + return web.json_response({"message": "Pfad aktualisiert"}) + return web.json_response( + {"error": "Pfad nicht gefunden"}, status=404 + ) + + async def delete_path(request: web.Request) -> web.Response: + """DELETE /api/library/paths/{path_id}""" + path_id = int(request.match_info["path_id"]) + success = await library_service.remove_path(path_id) + if success: + return web.json_response({"message": "Pfad entfernt"}) + return web.json_response( + {"error": "Pfad nicht gefunden"}, status=404 + ) + + # === Scanning === + + async def post_scan_all(request: web.Request) -> web.Response: + """POST /api/library/scan - Alle Pfade scannen""" + asyncio.create_task(_run_scan_all()) + return web.json_response({"message": "Scan gestartet"}) + + async def _run_scan_all(): + result = await library_service.scan_all() + logging.info(f"Komplett-Scan Ergebnis: {result}") + + async def post_scan_single(request: web.Request) -> web.Response: + """POST /api/library/scan/{path_id}""" + path_id = int(request.match_info["path_id"]) + asyncio.create_task(_run_scan_single(path_id)) + return web.json_response({"message": "Scan gestartet"}) + + async def _run_scan_single(path_id: int): + result = await library_service.scan_single_path(path_id) + logging.info(f"Einzel-Scan Ergebnis: {result}") + + # === Videos abfragen === + + async def get_videos(request: web.Request) -> web.Response: + """GET /api/library/videos?filter-params...""" + filters = {} + for key in ("library_path_id", "media_type", "series_id", + "video_codec", "min_width", "max_width", + "container", "audio_lang", "audio_channels", + "has_subtitle", "is_10bit", "sort", "order", + "search", "not_converted", "exclude_container", + "exclude_codec"): + val = request.query.get(key) + if val: + filters[key] = val + + page = int(request.query.get("page", 1)) + limit = int(request.query.get("limit", 50)) + + result = await library_service.get_videos(filters, page, limit) + return web.json_response(result) + + async def get_movies(request: web.Request) -> web.Response: + """GET /api/library/movies - Nur Filme (keine Serien)""" + filters = {} + for key in ("video_codec", "min_width", "max_width", + "container", "audio_lang", "audio_channels", + "is_10bit", "sort", "order", "search"): + val = request.query.get(key) + if val: + filters[key] = val + + page = int(request.query.get("page", 1)) + limit = int(request.query.get("limit", 50)) + + result = await library_service.get_movies(filters, page, limit) + return web.json_response(result) + + # === Serien === + + async def get_series(request: web.Request) -> web.Response: + """GET /api/library/series""" + path_id = request.query.get("path_id") + if path_id: + path_id = int(path_id) + series = await library_service.get_series_list(path_id) + return web.json_response({"series": series}) + + async def get_series_detail(request: web.Request) -> web.Response: + """GET /api/library/series/{series_id}""" + series_id = int(request.match_info["series_id"]) + detail = await library_service.get_series_detail(series_id) + if detail: + return web.json_response(detail) + return web.json_response( + {"error": "Serie nicht gefunden"}, status=404 + ) + + async def delete_series(request: web.Request) -> web.Response: + """DELETE /api/library/series/{series_id}?delete_files=1""" + series_id = int(request.match_info["series_id"]) + delete_files = request.query.get("delete_files") == "1" + result = await library_service.delete_series( + series_id, delete_files=delete_files + ) + if result.get("error"): + return web.json_response(result, status=404) + return web.json_response(result) + + async def get_missing_episodes(request: web.Request) -> web.Response: + """GET /api/library/series/{series_id}/missing""" + series_id = int(request.match_info["series_id"]) + missing = await library_service.get_missing_episodes(series_id) + return web.json_response({"missing": missing}) + + async def get_all_missing_episodes(request: web.Request) -> web.Response: + """GET /api/library/missing-episodes?path_id=&page=&limit= + Alle fehlenden Episoden aller Serien (fuer Filter-Ansicht).""" + path_id = request.query.get("path_id") + page = int(request.query.get("page", 1)) + limit = int(request.query.get("limit", 50)) + + result = await library_service.get_all_missing_episodes( + int(path_id) if path_id else None, + page, + limit + ) + return web.json_response(result) + + # === TVDB === + + async def post_tvdb_match(request: web.Request) -> web.Response: + """POST /api/library/series/{series_id}/tvdb-match""" + series_id = int(request.match_info["series_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + tvdb_id = data.get("tvdb_id") + if not tvdb_id: + return web.json_response( + {"error": "tvdb_id erforderlich"}, status=400 + ) + + result = await tvdb_service.match_and_update_series( + series_id, int(tvdb_id), library_service + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def delete_tvdb_link(request: web.Request) -> web.Response: + """DELETE /api/library/series/{series_id}/tvdb""" + series_id = int(request.match_info["series_id"]) + success = await library_service.unlink_tvdb(series_id) + if success: + return web.json_response({"message": "TVDB-Zuordnung geloest"}) + return web.json_response( + {"error": "Serie nicht gefunden"}, status=404 + ) + + async def post_tvdb_refresh(request: web.Request) -> web.Response: + """POST /api/library/series/{series_id}/tvdb-refresh""" + series_id = int(request.match_info["series_id"]) + # TVDB-ID aus DB holen + detail = await library_service.get_series_detail(series_id) + if not detail or not detail.get("tvdb_id"): + return web.json_response( + {"error": "Keine TVDB-Zuordnung vorhanden"}, status=400 + ) + result = await tvdb_service.match_and_update_series( + series_id, detail["tvdb_id"], library_service + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def get_tvdb_search(request: web.Request) -> web.Response: + """GET /api/tvdb/search?q=Breaking+Bad&lang=eng + lang: Sprache fuer Ergebnisse (deu, eng, etc.) + Standard: konfigurierte Sprache + """ + query = request.query.get("q", "").strip() + lang = request.query.get("lang", "").strip() or None + if not query: + return web.json_response( + {"error": "Suchbegriff erforderlich"}, status=400 + ) + if not tvdb_service.is_configured: + return web.json_response( + {"error": "TVDB nicht konfiguriert (API Key fehlt)"}, + status=400, + ) + results = await tvdb_service.search_series(query, language=lang) + return web.json_response({"results": results}) + + # === TVDB Metadaten === + + async def get_series_cast(request: web.Request) -> web.Response: + """GET /api/library/series/{series_id}/cast""" + series_id = int(request.match_info["series_id"]) + detail = await library_service.get_series_detail(series_id) + if not detail or not detail.get("tvdb_id"): + return web.json_response({"cast": []}) + cast = await tvdb_service.get_series_characters(detail["tvdb_id"]) + return web.json_response({"cast": cast}) + + async def get_series_artworks(request: web.Request) -> web.Response: + """GET /api/library/series/{series_id}/artworks""" + series_id = int(request.match_info["series_id"]) + detail = await library_service.get_series_detail(series_id) + if not detail or not detail.get("tvdb_id"): + return web.json_response({"artworks": []}) + artworks = await tvdb_service.get_series_artworks(detail["tvdb_id"]) + return web.json_response({"artworks": artworks}) + + async def post_metadata_download(request: web.Request) -> web.Response: + """POST /api/library/series/{series_id}/metadata-download""" + series_id = int(request.match_info["series_id"]) + detail = await library_service.get_series_detail(series_id) + if not detail: + return web.json_response( + {"error": "Serie nicht gefunden"}, status=404 + ) + if not detail.get("tvdb_id"): + return web.json_response( + {"error": "Keine TVDB-Zuordnung"}, status=400 + ) + result = await tvdb_service.download_metadata( + series_id, detail["tvdb_id"], detail.get("folder_path", "") + ) + return web.json_response(result) + + async def post_metadata_download_all(request: web.Request) -> web.Response: + """POST /api/library/metadata-download-all""" + series_list = await library_service.get_series_list() + results = {"success": 0, "skipped": 0, "errors": 0} + for s in series_list: + if not s.get("tvdb_id"): + results["skipped"] += 1 + continue + try: + await tvdb_service.download_metadata( + s["id"], s["tvdb_id"], s.get("folder_path", "") + ) + results["success"] += 1 + except Exception: + results["errors"] += 1 + return web.json_response(results) + + async def get_metadata_image(request: web.Request) -> web.Response: + """GET /api/library/metadata/{series_id}/{filename}""" + series_id = int(request.match_info["series_id"]) + filename = request.match_info["filename"] + detail = await library_service.get_series_detail(series_id) + if not detail or not detail.get("folder_path"): + return web.json_response( + {"error": "Nicht gefunden"}, status=404 + ) + import os + file_path = os.path.join( + detail["folder_path"], ".metadata", filename + ) + if not os.path.isfile(file_path): + return web.json_response( + {"error": "Datei nicht gefunden"}, status=404 + ) + return web.FileResponse(file_path) + + # === Filme === + + async def get_movies_list(request: web.Request) -> web.Response: + """GET /api/library/movies-list?path_id=X""" + path_id = request.query.get("path_id") + if path_id: + path_id = int(path_id) + movies = await library_service.get_movie_list(path_id) + return web.json_response({"movies": movies}) + + async def get_movie_detail(request: web.Request) -> web.Response: + """GET /api/library/movies/{movie_id}""" + movie_id = int(request.match_info["movie_id"]) + detail = await library_service.get_movie_detail(movie_id) + if detail: + return web.json_response(detail) + return web.json_response( + {"error": "Film nicht gefunden"}, status=404 + ) + + async def delete_movie(request: web.Request) -> web.Response: + """DELETE /api/library/movies/{movie_id}?delete_files=1""" + movie_id = int(request.match_info["movie_id"]) + delete_files = request.query.get("delete_files") == "1" + result = await library_service.delete_movie( + movie_id, delete_files=delete_files + ) + if result.get("error"): + return web.json_response(result, status=404) + return web.json_response(result) + + async def post_movie_tvdb_match(request: web.Request) -> web.Response: + """POST /api/library/movies/{movie_id}/tvdb-match""" + movie_id = int(request.match_info["movie_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + tvdb_id = data.get("tvdb_id") + if not tvdb_id: + return web.json_response( + {"error": "tvdb_id erforderlich"}, status=400 + ) + result = await tvdb_service.match_and_update_movie( + movie_id, int(tvdb_id), library_service + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def delete_movie_tvdb_link(request: web.Request) -> web.Response: + """DELETE /api/library/movies/{movie_id}/tvdb""" + movie_id = int(request.match_info["movie_id"]) + success = await library_service.unlink_movie_tvdb(movie_id) + if success: + return web.json_response({"message": "TVDB-Zuordnung geloest"}) + return web.json_response( + {"error": "Film nicht gefunden"}, status=404 + ) + + async def get_tvdb_movie_search(request: web.Request) -> web.Response: + """GET /api/tvdb/search-movies?q=Inception""" + query = request.query.get("q", "").strip() + if not query: + return web.json_response( + {"error": "Suchbegriff erforderlich"}, status=400 + ) + if not tvdb_service.is_configured: + return web.json_response( + {"error": "TVDB nicht konfiguriert"}, status=400 + ) + results = await tvdb_service.search_movies(query) + return web.json_response({"results": results}) + + # === TVDB Auto-Match (Review-Modus) === + + _auto_match_state = { + "active": False, + "phase": "", + "done": 0, + "total": 0, + "current": "", + "suggestions": None, + } + + async def post_tvdb_auto_match(request: web.Request) -> web.Response: + """POST /api/library/tvdb-auto-match?type=series|movies|all + Sammelt TVDB-Vorschlaege (matched NICHT automatisch).""" + if _auto_match_state["active"]: + return web.json_response( + {"error": "Suche laeuft bereits"}, status=409 + ) + if not tvdb_service.is_configured: + return web.json_response( + {"error": "TVDB nicht konfiguriert"}, status=400 + ) + + match_type = request.query.get("type", "all") + _auto_match_state.update({ + "active": True, + "phase": "starting", + "done": 0, "total": 0, + "current": "", + "suggestions": None, + }) + + async def run_collect(): + try: + async def progress_cb(done, total, name, count): + _auto_match_state.update({ + "done": done, + "total": total, + "current": name, + }) + + all_suggestions = [] + + if match_type in ("series", "all"): + _auto_match_state["phase"] = "series" + _auto_match_state["done"] = 0 + s = await tvdb_service.collect_suggestions( + "series", progress_cb + ) + all_suggestions.extend(s) + + if match_type in ("movies", "all"): + _auto_match_state["phase"] = "movies" + _auto_match_state["done"] = 0 + s = await tvdb_service.collect_suggestions( + "movies", progress_cb + ) + all_suggestions.extend(s) + + _auto_match_state["suggestions"] = all_suggestions + _auto_match_state["phase"] = "done" + except Exception as e: + logging.error(f"TVDB Vorschlaege sammeln fehlgeschlagen: {e}") + _auto_match_state["phase"] = "error" + _auto_match_state["suggestions"] = [] + finally: + _auto_match_state["active"] = False + + asyncio.create_task(run_collect()) + return web.json_response({"message": "TVDB-Suche gestartet"}) + + async def get_tvdb_auto_match_status( + request: web.Request + ) -> web.Response: + """GET /api/library/tvdb-auto-match-status""" + # Vorschlaege nur bei "done" mitschicken + result = { + "active": _auto_match_state["active"], + "phase": _auto_match_state["phase"], + "done": _auto_match_state["done"], + "total": _auto_match_state["total"], + "current": _auto_match_state["current"], + } + if _auto_match_state["phase"] == "done": + result["suggestions"] = _auto_match_state["suggestions"] + return web.json_response(result) + + async def post_tvdb_confirm(request: web.Request) -> web.Response: + """POST /api/library/tvdb-confirm - Einzelnen Vorschlag bestaetigen. + Body: {id, type: 'series'|'movies', tvdb_id}""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + item_id = data.get("id") + media_type = data.get("type") + tvdb_id = data.get("tvdb_id") + + if not item_id or not media_type or not tvdb_id: + return web.json_response( + {"error": "id, type und tvdb_id erforderlich"}, status=400 + ) + + if media_type == "series": + result = await tvdb_service.match_and_update_series( + int(item_id), int(tvdb_id), library_service + ) + elif media_type == "movies": + result = await tvdb_service.match_and_update_movie( + int(item_id), int(tvdb_id), library_service + ) + else: + return web.json_response( + {"error": "type muss 'series' oder 'movies' sein"}, + status=400, + ) + + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + # === TVDB Sprache === + + async def get_tvdb_language(request: web.Request) -> web.Response: + """GET /api/tvdb/language""" + lang = config.settings.get("library", {}).get( + "tvdb_language", "deu" + ) + return web.json_response({"language": lang}) + + async def put_tvdb_language(request: web.Request) -> web.Response: + """PUT /api/tvdb/language - TVDB-Sprache aendern""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + lang = data.get("language", "").strip() + if not lang or len(lang) != 3: + return web.json_response( + {"error": "Sprache muss 3-Buchstaben-Code sein (z.B. deu)"}, + status=400, + ) + # In Config speichern + if "library" not in config.settings: + config.settings["library"] = {} + config.settings["library"]["tvdb_language"] = lang + config.save_settings() + return web.json_response( + {"message": f"TVDB-Sprache auf '{lang}' gesetzt"} + ) + + async def post_tvdb_refresh_all_episodes( + request: web.Request, + ) -> web.Response: + """POST /api/library/tvdb-refresh-episodes + Laedt alle Episoden-Caches neu (z.B. nach Sprachswitch).""" + if not tvdb_service.is_configured: + return web.json_response( + {"error": "TVDB nicht konfiguriert"}, status=400 + ) + series_list = await library_service.get_series_list() + refreshed = 0 + for s in series_list: + if not s.get("tvdb_id"): + continue + try: + await tvdb_service.fetch_episodes(s["tvdb_id"]) + await tvdb_service._update_episode_titles( + s["id"], s["tvdb_id"] + ) + refreshed += 1 + except Exception: + pass + return web.json_response({ + "message": f"{refreshed} Serien-Episoden aktualisiert" + }) + + # === Ordner-Ansicht === + + async def get_browse(request: web.Request) -> web.Response: + """GET /api/library/browse?path=...""" + path = request.query.get("path") + result = await library_service.browse_path(path or None) + return web.json_response(result) + + # === Duplikate === + + async def get_duplicates(request: web.Request) -> web.Response: + """GET /api/library/duplicates""" + dupes = await library_service.find_duplicates() + return web.json_response({"duplicates": dupes}) + + # === Video loeschen === + + async def delete_video(request: web.Request) -> web.Response: + """DELETE /api/library/videos/{video_id}?delete_file=1""" + video_id = int(request.match_info["video_id"]) + delete_file = request.query.get("delete_file") == "1" + result = await library_service.delete_video( + video_id, delete_file=delete_file + ) + if result.get("error"): + return web.json_response(result, status=404) + return web.json_response(result) + + # === Konvertierung aus Bibliothek === + + async def post_convert_video(request: web.Request) -> web.Response: + """POST /api/library/videos/{video_id}/convert""" + 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() as cur: + await cur.execute( + "SELECT file_path FROM library_videos WHERE id = %s", + (video_id,) + ) + row = await cur.fetchone() + if not row: + return web.json_response( + {"error": "Video nicht gefunden"}, status=404 + ) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + file_path = row[0] + preset = None + try: + data = await request.json() + preset = data.get("preset") + except Exception: + pass + + # Pruefen ob Datei existiert + import os + if not os.path.exists(file_path): + return web.json_response( + {"error": f"Datei nicht gefunden: {file_path}"}, status=404 + ) + + jobs = await queue_service.add_paths([file_path], preset) + if jobs: + return web.json_response({ + "message": "Konvertierung gestartet", + "job_id": jobs[0].id, + }) + return web.json_response( + {"error": f"Job konnte nicht erstellt werden fuer: {file_path}"}, status=500 + ) + + # === Batch-Konvertierung Serie === + + async def post_convert_series(request: web.Request) -> web.Response: + """POST /api/library/series/{series_id}/convert + Konvertiert alle Episoden einer Serie die nicht im Zielformat sind. + Body: {preset, target_codec, force_all, delete_old} + - preset: Encoding-Preset (optional, nimmt default) + - target_codec: Ziel-Codec zum Vergleich (z.B. 'av1', 'hevc') + - force_all: true = alle konvertieren, false = nur nicht-Zielformat + - delete_old: true = alte Quelldateien nach Konvertierung loeschen + """ + import os + series_id = int(request.match_info["series_id"]) + + try: + data = await request.json() + except Exception: + data = {} + + preset = data.get("preset") + target_codec = data.get("target_codec", "av1").lower() + force_all = data.get("force_all", False) + delete_old = data.get("delete_old", False) + + 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() as cur: + # Alle Videos der Serie laden + await cur.execute( + "SELECT id, file_path, video_codec " + "FROM library_videos WHERE series_id = %s", + (series_id,) + ) + videos = await cur.fetchall() + + # Serien-Ordner fuer Cleanup + await cur.execute( + "SELECT folder_path FROM library_series WHERE id = %s", + (series_id,) + ) + series_row = await cur.fetchone() + series_folder = series_row[0] if series_row else None + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + if not videos: + return web.json_response( + {"error": "Keine Videos gefunden"}, status=404 + ) + + # Codec-Mapping fuer Vergleich + codec_aliases = { + "av1": ["av1", "libaom-av1", "libsvtav1", "av1_vaapi"], + "hevc": ["hevc", "h265", "libx265", "hevc_vaapi"], + "h264": ["h264", "avc", "libx264", "h264_vaapi"], + } + target_codecs = codec_aliases.get(target_codec, [target_codec]) + + to_convert = [] + already_done = 0 + + for vid_id, file_path, current_codec in videos: + current = (current_codec or "").lower() + is_target = any(tc in current for tc in target_codecs) + + if force_all or not is_target: + to_convert.append(file_path) + else: + already_done += 1 + + if not to_convert: + return web.json_response({ + "message": "Alle Episoden sind bereits im Zielformat", + "already_done": already_done, + "queued": 0, + }) + + # Jobs erstellen mit delete_source Option + jobs = await queue_service.add_paths( + to_convert, preset, delete_source=delete_old + ) + + return web.json_response({ + "message": f"{len(jobs)} Episoden zur Konvertierung hinzugefuegt", + "queued": len(jobs), + "already_done": already_done, + "skipped": len(videos) - len(jobs) - already_done, + "delete_old": delete_old, + }) + + async def post_cleanup_series_folder(request: web.Request) -> web.Response: + """POST /api/library/series/{series_id}/cleanup + Loescht alle Dateien im Serien-Ordner AUSSER: + - Videos die in der Bibliothek sind + - .metadata Verzeichnis und dessen Inhalt + - .nfo Dateien + """ + import os + series_id = int(request.match_info["series_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() as cur: + # Serien-Ordner + await cur.execute( + "SELECT folder_path FROM library_series WHERE id = %s", + (series_id,) + ) + row = await cur.fetchone() + if not row: + return web.json_response( + {"error": "Serie nicht gefunden"}, status=404 + ) + series_folder = row[0] + + # Alle Videos der Serie (diese behalten) + await cur.execute( + "SELECT file_path FROM library_videos WHERE series_id = %s", + (series_id,) + ) + keep_files = {r[0] for r in await cur.fetchall()} + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + if not series_folder or not os.path.isdir(series_folder): + return web.json_response( + {"error": "Serien-Ordner nicht gefunden"}, status=404 + ) + + # Geschuetzte Pfade/Dateien + protected_dirs = {".metadata", "@eaDir", ".AppleDouble"} + protected_extensions = {".nfo", ".jpg", ".jpeg", ".png", ".xml"} + + deleted = 0 + errors = [] + + for root, dirs, files in os.walk(series_folder, topdown=True): + # Geschuetzte Verzeichnisse ueberspringen + dirs[:] = [d for d in dirs if d not in protected_dirs] + + for f in files: + file_path = os.path.join(root, f) + ext = os.path.splitext(f)[1].lower() + + # Behalten wenn: + # - In der Bibliothek registriert + # - Geschuetzte Extension + # - Versteckte Datei + if file_path in keep_files: + continue + if ext in protected_extensions: + continue + if f.startswith("."): + continue + + # Loeschen + try: + os.remove(file_path) + deleted += 1 + logging.info(f"Cleanup geloescht: {file_path}") + except Exception as e: + errors.append(f"{f}: {e}") + + return web.json_response({ + "deleted": deleted, + "errors": len(errors), + "error_details": errors[:10], # Max 10 Fehler anzeigen + }) + + async def post_delete_folder(request: web.Request) -> web.Response: + """POST /api/library/delete-folder + Loescht einen kompletten Ordner (Season-Ordner etc.) inkl. DB-Eintraege. + Body: {folder_path: "/mnt/.../Season 01"} + ACHTUNG: Unwiderruflich! + """ + import os + import shutil + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + folder_path = data.get("folder_path", "").strip() + if not folder_path: + return web.json_response( + {"error": "folder_path erforderlich"}, status=400 + ) + + # Sicherheitspruefung: Muss unter einem Library-Pfad liegen + pool = await library_service._get_pool() + if not pool: + return web.json_response( + {"error": "Keine DB-Verbindung"}, status=500 + ) + + allowed = False + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT path FROM library_paths WHERE enabled = 1" + ) + paths = await cur.fetchall() + for (lib_path,) in paths: + if folder_path.startswith(lib_path): + allowed = True + break + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + if not allowed: + return web.json_response( + {"error": "Ordner liegt nicht in einem Bibliothekspfad"}, + status=403 + ) + + if not os.path.isdir(folder_path): + return web.json_response( + {"error": "Ordner nicht gefunden"}, status=404 + ) + + # Zaehlen was geloescht wird + deleted_files = 0 + deleted_dirs = 0 + errors = [] + + # Zuerst alle Dateien zaehlen + for root, dirs, files in os.walk(folder_path): + deleted_files += len(files) + deleted_dirs += len(dirs) + + # DB-Eintraege loeschen (Videos in diesem Ordner) + db_removed = 0 + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + # Videos loeschen deren file_path mit folder_path beginnt + await cur.execute( + "DELETE FROM library_videos " + "WHERE file_path LIKE %s", + (folder_path + "%",) + ) + db_removed = cur.rowcount + except Exception as e: + errors.append(f"DB-Fehler: {e}") + + # Ordner loeschen (onerror fuer SMB/CIFS Permission-Probleme) + def _rm_error(func, path, exc_info): + """Bei Permission-Fehler: Schreibrechte setzen und nochmal versuchen""" + import stat + try: + os.chmod(path, stat.S_IRWXU) + func(path) + except Exception as e2: + errors.append(f"{path}: {e2}") + + try: + shutil.rmtree(folder_path, onerror=_rm_error) + if os.path.exists(folder_path): + # Ordner existiert noch -> nicht alles geloescht + logging.warning( + f"Ordner teilweise geloescht: {folder_path} " + f"({len(errors)} Fehler)" + ) + else: + logging.info(f"Ordner geloescht: {folder_path}") + except Exception as e: + logging.error(f"Ordner loeschen fehlgeschlagen: {e}") + return web.json_response( + {"error": f"Loeschen fehlgeschlagen: {e}"}, status=500 + ) + + return web.json_response({ + "deleted_files": deleted_files, + "deleted_dirs": deleted_dirs, + "db_removed": db_removed, + "errors": errors, + }) + + async def get_series_convert_status(request: web.Request) -> web.Response: + """GET /api/library/series/{series_id}/convert-status + Zeigt Codec-Status aller Episoden einer Serie.""" + series_id = int(request.match_info["series_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() as cur: + await cur.execute( + "SELECT id, file_name, video_codec, season_number, " + "episode_number FROM library_videos " + "WHERE series_id = %s ORDER BY season_number, episode_number", + (series_id,) + ) + videos = await cur.fetchall() + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + # Codec-Statistik + codec_counts = {} + episodes = [] + for vid_id, name, codec, season, episode in videos: + codec_lower = (codec or "unknown").lower() + codec_counts[codec_lower] = codec_counts.get(codec_lower, 0) + 1 + episodes.append({ + "id": vid_id, + "name": name, + "codec": codec, + "season": season, + "episode": episode, + }) + + return web.json_response({ + "total": len(videos), + "codec_counts": codec_counts, + "episodes": episodes, + }) + + # === Statistiken === + + async def get_library_stats(request: web.Request) -> web.Response: + """GET /api/library/stats""" + stats = await library_service.get_stats() + return web.json_response(stats) + + # === Scan-Status === + + async def get_scan_status(request: web.Request) -> web.Response: + """GET /api/library/scan-status""" + return web.json_response(library_service._scan_progress) + + # === Clean-Funktion === + + async def get_clean_scan(request: web.Request) -> web.Response: + """GET /api/library/clean/scan?path_id=""" + if not cleaner_service: + return web.json_response( + {"error": "Clean-Service nicht verfuegbar"}, status=500 + ) + path_id = request.query.get("path_id") + result = await cleaner_service.scan_for_junk( + int(path_id) if path_id else None + ) + return web.json_response(result) + + async def post_clean_delete(request: web.Request) -> web.Response: + """POST /api/library/clean/delete""" + if not cleaner_service: + return web.json_response( + {"error": "Clean-Service nicht verfuegbar"}, status=500 + ) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + files = data.get("files", []) + if not files: + return web.json_response( + {"error": "Keine Dateien angegeben"}, status=400 + ) + result = await cleaner_service.delete_files(files) + return web.json_response(result) + + async def post_clean_empty_dirs(request: web.Request) -> web.Response: + """POST /api/library/clean/empty-dirs""" + if not cleaner_service: + return web.json_response( + {"error": "Clean-Service nicht verfuegbar"}, status=500 + ) + try: + data = await request.json() + except Exception: + data = {} + path_id = data.get("path_id") + count = await cleaner_service.delete_empty_dirs( + int(path_id) if path_id else None + ) + return web.json_response({"deleted_dirs": count}) + + # === Filesystem-Browser (fuer Import) === + + async def get_browse_fs(request: web.Request) -> web.Response: + """GET /api/library/browse-fs?path=... - Echten Filesystem-Browser""" + import os + from app.services.library import VIDEO_EXTENSIONS + + path = request.query.get("path", "/mnt") + + # Sicherheits-Check: Nur unter /mnt erlauben + real = os.path.realpath(path) + if not real.startswith("/mnt"): + return web.json_response( + {"error": "Zugriff nur auf /mnt erlaubt"}, status=403 + ) + + if not os.path.isdir(real): + return web.json_response( + {"error": "Ordner nicht gefunden"}, status=404 + ) + + folders = [] + video_count = 0 + video_size = 0 + + try: + entries = sorted(os.scandir(real), key=lambda e: e.name.lower()) + for entry in entries: + if entry.name.startswith("."): + continue + if entry.is_dir(follow_symlinks=True): + # Schnelle Zaehlung: Videos im Unterordner + sub_vids = 0 + try: + for sub in os.scandir(entry.path): + if sub.is_file(): + ext = os.path.splitext(sub.name)[1].lower() + if ext in VIDEO_EXTENSIONS: + sub_vids += 1 + except PermissionError: + pass + folders.append({ + "name": entry.name, + "path": entry.path, + "video_count": sub_vids, + }) + elif entry.is_file(): + ext = os.path.splitext(entry.name)[1].lower() + if ext in VIDEO_EXTENSIONS: + video_count += 1 + try: + video_size += entry.stat().st_size + except OSError: + pass + except PermissionError: + return web.json_response( + {"error": "Keine Berechtigung"}, status=403 + ) + + # Breadcrumb + parts = real.split("/") + breadcrumb = [] + for i in range(1, len(parts)): + crumb_path = "/".join(parts[:i + 1]) or "/" + breadcrumb.append({ + "name": parts[i], + "path": crumb_path, + }) + + return web.json_response({ + "current_path": real, + "folders": folders, + "video_count": video_count, + "video_size": video_size, + "breadcrumb": breadcrumb, + }) + + # === Import-Funktion === + + async def post_create_import(request: web.Request) -> web.Response: + """POST /api/library/import""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + source = data.get("source_path", "").strip() + target_id = data.get("target_library_id") + mode = data.get("mode", "copy") + + if not source or not target_id: + return web.json_response( + {"error": "source_path und target_library_id erforderlich"}, + status=400, + ) + + job_id = await importer_service.create_job( + source, int(target_id), mode + ) + if job_id: + return web.json_response( + {"message": "Import-Job erstellt", "job_id": job_id} + ) + return web.json_response( + {"error": "Keine Videos gefunden oder Fehler"}, status=400 + ) + + async def post_analyze_import(request: web.Request) -> web.Response: + """POST /api/library/import/{job_id}/analyze""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + try: + job_id = int(request.match_info["job_id"]) + except (ValueError, KeyError): + return web.json_response( + {"error": "Ungueltige Job-ID"}, status=400 + ) + result = await importer_service.analyze_job(job_id) + return web.json_response(result) + + async def get_import_jobs(request: web.Request) -> web.Response: + """GET /api/library/import - Liste aller Import-Jobs""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + jobs = await importer_service.get_all_jobs() + return web.json_response({"jobs": jobs}) + + async def get_import_status(request: web.Request) -> web.Response: + """GET /api/library/import/{job_id}""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + try: + job_id = int(request.match_info["job_id"]) + except (ValueError, KeyError): + return web.json_response( + {"error": "Ungueltige Job-ID"}, status=400 + ) + result = await importer_service.get_job_status(job_id) + return web.json_response(result) + + async def delete_import_job(request: web.Request) -> web.Response: + """DELETE /api/library/import/{job_id}""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + try: + job_id = int(request.match_info["job_id"]) + except (ValueError, KeyError): + return web.json_response( + {"error": "Ungueltige Job-ID"}, status=400 + ) + result = await importer_service.delete_job(job_id) + if "error" in result: + return web.json_response(result, status=400) + return web.json_response(result) + + # Task-Referenzen gegen GC schuetzen + _background_tasks: set = set() + + async def post_execute_import(request: web.Request) -> web.Response: + """POST /api/library/import/{job_id}/execute + Startet Import async im Hintergrund, antwortet sofort.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + try: + job_id = int(request.match_info["job_id"]) + except (ValueError, KeyError): + return web.json_response( + {"error": "Ungueltige Job-ID"}, status=400 + ) + + # Import im Hintergrund starten (blockiert nicht den Response) + async def _run_import(): + try: + await importer_service.execute_import(job_id) + except Exception as e: + logging.error(f"Hintergrund-Import {job_id} fehlgeschlagen: {e}") + + task = asyncio.create_task(_run_import()) + _background_tasks.add(task) + task.add_done_callback(_background_tasks.discard) + return web.json_response({"ok": True, "job_id": job_id}) + + async def put_import_item(request: web.Request) -> web.Response: + """PUT /api/library/import/items/{item_id}""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + item_id = int(request.match_info["item_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + success = await importer_service.update_item(item_id, **data) + if success: + return web.json_response({"message": "Item aktualisiert"}) + return web.json_response( + {"error": "Aktualisierung fehlgeschlagen"}, status=400 + ) + + async def put_resolve_conflict(request: web.Request) -> web.Response: + """PUT /api/library/import/items/{item_id}/resolve""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + item_id = int(request.match_info["item_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + action = data.get("action", "") + success = await importer_service.resolve_conflict(item_id, action) + if success: + return web.json_response({"message": "Konflikt geloest"}) + return web.json_response( + {"error": "Ungueltige Aktion"}, status=400 + ) + + # === Video-Streaming === + + async def get_stream_video(request: web.Request) -> web.StreamResponse: + """GET /api/library/videos/{video_id}/stream?t=0 + Streamt Video per ffmpeg-Transcoding (Video copy, Audio->AAC). + Browser-kompatibel fuer alle Codecs (EAC3, DTS, AC3 etc.). + Optional: ?t=120 fuer Seeking auf Sekunde 120.""" + import os + import asyncio as _asyncio + import shlex + + 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() as cur: + await cur.execute( + "SELECT file_path FROM library_videos WHERE id = %s", + (video_id,) + ) + row = await cur.fetchone() + if not row: + return web.json_response( + {"error": "Video nicht gefunden"}, status=404 + ) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + file_path = row[0] + if not os.path.isfile(file_path): + return web.json_response( + {"error": "Datei nicht gefunden"}, status=404 + ) + + # Seek-Position (Sekunden) aus Query-Parameter + seek_sec = float(request.query.get("t", "0")) + + # ffmpeg-Kommando: Video copy, Audio -> AAC Stereo, MP4-Container + cmd = [ + "ffmpeg", "-hide_banner", "-loglevel", "error", + ] + if seek_sec > 0: + cmd += ["-ss", str(seek_sec)] + cmd += [ + "-i", file_path, + "-c:v", "copy", + "-c:a", "aac", "-ac", "2", "-b:a", "192k", + "-movflags", "frag_keyframe+empty_moov+faststart", + "-f", "mp4", + "pipe:1", + ] + + resp = web.StreamResponse( + status=200, + headers={ + "Content-Type": "video/mp4", + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + }, + ) + await resp.prepare(request) + + proc = None + try: + proc = await _asyncio.create_subprocess_exec( + *cmd, + stdout=_asyncio.subprocess.PIPE, + stderr=_asyncio.subprocess.PIPE, + ) + + chunk_size = 256 * 1024 # 256 KB + while True: + chunk = await proc.stdout.read(chunk_size) + if not chunk: + break + try: + await resp.write(chunk) + except (ConnectionResetError, ConnectionAbortedError): + # Client hat Verbindung geschlossen + break + + except Exception as e: + logging.error(f"Stream-Fehler: {e}") + finally: + if proc and proc.returncode is None: + proc.kill() + await proc.wait() + + await resp.write_eof() + return resp + + # === Import: Item zuordnen / ueberspringen === + + async def post_reassign_import_item( + request: web.Request, + ) -> web.Response: + """POST /api/library/import/items/{item_id}/reassign + Weist einem nicht-erkannten Item eine Serie zu.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + item_id = int(request.match_info["item_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + series_name = data.get("series_name", "").strip() + season = data.get("season") + episode = data.get("episode") + tvdb_id = data.get("tvdb_id") + + if not series_name or season is None or episode is None: + return web.json_response( + {"error": "series_name, season und episode erforderlich"}, + status=400, + ) + + result = await importer_service.reassign_item( + item_id, series_name, + int(season), int(episode), + int(tvdb_id) if tvdb_id else None + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def post_skip_import_item( + request: web.Request, + ) -> web.Response: + """POST /api/library/import/items/{item_id}/skip""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + item_id = int(request.match_info["item_id"]) + success = await importer_service.skip_item(item_id) + if success: + return web.json_response({"message": "Item uebersprungen"}) + return web.json_response( + {"error": "Fehlgeschlagen"}, status=400 + ) + + # === Serie-Level-Zuordnung (neuer Workflow) === + + async def get_pending_series(request: web.Request) -> web.Response: + """GET /api/library/import/{job_id}/pending-series + Gibt alle erkannten Serien zurueck, die noch zugeordnet werden muessen.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + result = await importer_service.get_pending_series(job_id) + return web.json_response(result) + + async def post_assign_series(request: web.Request) -> web.Response: + """POST /api/library/import/{job_id}/assign-series + Ordnet eine erkannte Serie einer TVDB-Serie zu und berechnet Zielpfade.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + detected_series = data.get("detected_series", "").strip() + tvdb_id = data.get("tvdb_id") + tvdb_name = data.get("tvdb_name", "").strip() + + if not detected_series or not tvdb_name: + return web.json_response( + {"error": "detected_series und tvdb_name erforderlich"}, status=400 + ) + + result = await importer_service.assign_series_mapping( + job_id, detected_series, + int(tvdb_id) if tvdb_id else None, + tvdb_name + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def post_resolve_all_conflicts(request: web.Request) -> web.Response: + """POST /api/library/import/{job_id}/resolve-all-conflicts + Loest alle Konflikte auf einmal (overwrite oder skip).""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + action = data.get("action", "").strip() + if action not in ("overwrite", "skip"): + return web.json_response( + {"error": "action muss 'overwrite' oder 'skip' sein"}, status=400 + ) + + result = await importer_service.resolve_all_conflicts(job_id, action) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def put_overwrite_mode(request: web.Request) -> web.Response: + """PUT /api/library/import/{job_id}/overwrite-mode + Setzt ob alle Konflikte automatisch ueberschrieben werden sollen.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + overwrite = data.get("overwrite", False) + result = await importer_service.set_overwrite_mode(job_id, bool(overwrite)) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + # === Alte Serien-Zuordnung (Kompatibilitaet) === + + async def post_reassign_import_series( + request: web.Request, + ) -> web.Response: + """POST /api/library/import/{job_id}/reassign-series + Ordnet alle Items mit gleichem detected_series zu.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + detected_series = data.get("detected_series", "").strip() + tvdb_id = data.get("tvdb_id") + series_name = data.get("series_name", "").strip() + + if not detected_series: + return web.json_response( + {"error": "detected_series erforderlich"}, status=400 + ) + + # Neuen Workflow nutzen + result = await importer_service.assign_series_mapping( + job_id, detected_series, + int(tvdb_id) if tvdb_id else None, + series_name or detected_series + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + async def post_skip_import_series( + request: web.Request, + ) -> web.Response: + """POST /api/library/import/{job_id}/skip-series + Ueberspringt alle Items einer Serie.""" + if not importer_service: + return web.json_response( + {"error": "Import-Service nicht verfuegbar"}, status=500 + ) + job_id = int(request.match_info["job_id"]) + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + + detected_series = data.get("detected_series", "").strip() + if not detected_series: + return web.json_response( + {"error": "detected_series erforderlich"}, status=400 + ) + + result = await importer_service.skip_series( + job_id, detected_series + ) + if result.get("error"): + return web.json_response(result, status=400) + return web.json_response(result) + + # === Filter-Presets === + + async def get_filter_presets(request: web.Request) -> web.Response: + """GET /api/library/filter-presets""" + lib_cfg = config.settings.get("library", {}) + presets = lib_cfg.get("filter_presets", {}) + default_view = lib_cfg.get("default_view", "all") + return web.json_response({ + "presets": presets, + "default_view": default_view, + }) + + async def put_filter_presets(request: web.Request) -> web.Response: + """PUT /api/library/filter-presets - Presets speichern""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + presets = data.get("presets", {}) + default_view = data.get("default_view") + + if "library" not in config.settings: + config.settings["library"] = {} + if presets: + config.settings["library"]["filter_presets"] = presets + if default_view: + config.settings["library"]["default_view"] = default_view + config.save_settings() + return web.json_response({"message": "Filter-Presets gespeichert"}) + + async def post_filter_preset(request: web.Request) -> web.Response: + """POST /api/library/filter-presets - Neues Preset hinzufuegen""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + preset_id = data.get("id", "").strip() + preset_name = data.get("name", "").strip() + filters = data.get("filters", {}) + + if not preset_id or not preset_name: + return web.json_response( + {"error": "id und name erforderlich"}, status=400 + ) + + if "library" not in config.settings: + config.settings["library"] = {} + if "filter_presets" not in config.settings["library"]: + config.settings["library"]["filter_presets"] = {} + + config.settings["library"]["filter_presets"][preset_id] = { + "name": preset_name, + **filters, + } + config.save_settings() + return web.json_response({"message": f"Preset '{preset_name}' gespeichert"}) + + async def delete_filter_preset(request: web.Request) -> web.Response: + """DELETE /api/library/filter-presets/{preset_id}""" + preset_id = request.match_info["preset_id"] + presets = config.settings.get("library", {}).get("filter_presets", {}) + if preset_id in presets: + del config.settings["library"]["filter_presets"][preset_id] + config.save_settings() + return web.json_response({"message": "Preset geloescht"}) + return web.json_response({"error": "Preset nicht gefunden"}, status=404) + + async def put_default_view(request: web.Request) -> web.Response: + """PUT /api/library/default-view - Standard-Ansicht setzen""" + try: + data = await request.json() + except Exception: + return web.json_response( + {"error": "Ungueltiges JSON"}, status=400 + ) + default_view = data.get("default_view", "all") + if "library" not in config.settings: + config.settings["library"] = {} + config.settings["library"]["default_view"] = default_view + config.save_settings() + return web.json_response({ + "message": f"Standard-Ansicht auf '{default_view}' gesetzt" + }) + + # === Server-Logs === + + async def get_server_logs(request: web.Request) -> web.Response: + """GET /api/logs?lines=100 - Letzte N Zeilen aus server.log""" + import os + from collections import deque + lines_count = int(request.query.get("lines", "100")) + lines_count = min(lines_count, 1000) + + log_file = config.log_file_path + if not os.path.isfile(log_file): + return web.json_response( + {"lines": [], "error": "Log-Datei nicht gefunden"} + ) + + try: + # deque mit maxlen liest nur die letzten N Zeilen (speicherschonend) + with open(log_file, "r", encoding="utf-8", + errors="replace") as f: + result_lines = deque(f, maxlen=lines_count) + + return web.json_response({ + "lines": [line.rstrip("\n") for line in result_lines], + "total": len(result_lines), + }) + except Exception as e: + return web.json_response( + {"error": str(e)}, status=500 + ) + + # === Routes registrieren === + # Filter-Presets + app.router.add_get("/api/library/filter-presets", get_filter_presets) + app.router.add_put("/api/library/filter-presets", put_filter_presets) + app.router.add_post("/api/library/filter-presets", post_filter_preset) + app.router.add_delete( + "/api/library/filter-presets/{preset_id}", delete_filter_preset + ) + app.router.add_put("/api/library/default-view", put_default_view) + # Pfade + app.router.add_get("/api/library/paths", get_paths) + app.router.add_post("/api/library/paths", post_path) + app.router.add_put("/api/library/paths/{path_id}", put_path) + app.router.add_delete("/api/library/paths/{path_id}", delete_path) + # Scanning + app.router.add_post("/api/library/scan", post_scan_all) + app.router.add_post("/api/library/scan/{path_id}", post_scan_single) + app.router.add_get("/api/library/scan-status", get_scan_status) + # Videos / Filme + app.router.add_get("/api/library/videos", get_videos) + app.router.add_get("/api/library/movies", get_movies) + app.router.add_delete( + "/api/library/videos/{video_id}", delete_video + ) + # Serien + app.router.add_get("/api/library/series", get_series) + app.router.add_get("/api/library/series/{series_id}", get_series_detail) + app.router.add_delete( + "/api/library/series/{series_id}", delete_series + ) + app.router.add_get( + "/api/library/series/{series_id}/missing", get_missing_episodes + ) + app.router.add_get( + "/api/library/missing-episodes", get_all_missing_episodes + ) + # TVDB + app.router.add_post( + "/api/library/series/{series_id}/tvdb-match", post_tvdb_match + ) + app.router.add_delete( + "/api/library/series/{series_id}/tvdb", delete_tvdb_link + ) + app.router.add_post( + "/api/library/series/{series_id}/tvdb-refresh", post_tvdb_refresh + ) + app.router.add_get("/api/tvdb/search", get_tvdb_search) + # TVDB Metadaten + app.router.add_get( + "/api/library/series/{series_id}/cast", get_series_cast + ) + app.router.add_get( + "/api/library/series/{series_id}/artworks", get_series_artworks + ) + app.router.add_post( + "/api/library/series/{series_id}/metadata-download", + post_metadata_download, + ) + app.router.add_post( + "/api/library/metadata-download-all", post_metadata_download_all + ) + app.router.add_get( + "/api/library/metadata/{series_id}/{filename}", get_metadata_image + ) + # Filme + app.router.add_get("/api/library/movies-list", get_movies_list) + app.router.add_get("/api/library/movies/{movie_id}", get_movie_detail) + app.router.add_delete("/api/library/movies/{movie_id}", delete_movie) + app.router.add_post( + "/api/library/movies/{movie_id}/tvdb-match", post_movie_tvdb_match + ) + app.router.add_delete( + "/api/library/movies/{movie_id}/tvdb", delete_movie_tvdb_link + ) + app.router.add_get("/api/tvdb/search-movies", get_tvdb_movie_search) + # Browse / Duplikate + app.router.add_get("/api/library/browse", get_browse) + app.router.add_get("/api/library/duplicates", get_duplicates) + # Konvertierung + app.router.add_post( + "/api/library/videos/{video_id}/convert", post_convert_video + ) + app.router.add_post( + "/api/library/series/{series_id}/convert", post_convert_series + ) + app.router.add_get( + "/api/library/series/{series_id}/convert-status", + get_series_convert_status + ) + app.router.add_post( + "/api/library/series/{series_id}/cleanup", + post_cleanup_series_folder + ) + app.router.add_post("/api/library/delete-folder", post_delete_folder) + # Statistiken + app.router.add_get("/api/library/stats", get_library_stats) + # Clean + app.router.add_get("/api/library/clean/scan", get_clean_scan) + app.router.add_post("/api/library/clean/delete", post_clean_delete) + app.router.add_post( + "/api/library/clean/empty-dirs", post_clean_empty_dirs + ) + # Filesystem-Browser + app.router.add_get("/api/library/browse-fs", get_browse_fs) + # Import + app.router.add_get("/api/library/import", get_import_jobs) + app.router.add_post("/api/library/import", post_create_import) + app.router.add_delete( + "/api/library/import/{job_id}", delete_import_job + ) + app.router.add_post( + "/api/library/import/{job_id}/analyze", post_analyze_import + ) + app.router.add_get( + "/api/library/import/{job_id}", get_import_status + ) + app.router.add_post( + "/api/library/import/{job_id}/execute", post_execute_import + ) + app.router.add_put( + "/api/library/import/items/{item_id}", put_import_item + ) + app.router.add_put( + "/api/library/import/items/{item_id}/resolve", put_resolve_conflict + ) + app.router.add_post( + "/api/library/import/items/{item_id}/reassign", + post_reassign_import_item, + ) + app.router.add_post( + "/api/library/import/items/{item_id}/skip", + post_skip_import_item, + ) + app.router.add_post( + "/api/library/import/{job_id}/reassign-series", + post_reassign_import_series, + ) + app.router.add_post( + "/api/library/import/{job_id}/skip-series", + post_skip_import_series, + ) + # Neuer Workflow: Serien-Zuordnung VOR Konflikt-Check + app.router.add_get( + "/api/library/import/{job_id}/pending-series", + get_pending_series, + ) + app.router.add_post( + "/api/library/import/{job_id}/assign-series", + post_assign_series, + ) + app.router.add_post( + "/api/library/import/{job_id}/resolve-all-conflicts", + post_resolve_all_conflicts, + ) + app.router.add_put( + "/api/library/import/{job_id}/overwrite-mode", + put_overwrite_mode, + ) + # Video-Streaming + app.router.add_get( + "/api/library/videos/{video_id}/stream", get_stream_video + ) + # TVDB Auto-Match (Review-Modus) + app.router.add_post( + "/api/library/tvdb-auto-match", post_tvdb_auto_match + ) + app.router.add_get( + "/api/library/tvdb-auto-match-status", get_tvdb_auto_match_status + ) + app.router.add_post( + "/api/library/tvdb-confirm", post_tvdb_confirm + ) + # TVDB Sprache + app.router.add_get("/api/tvdb/language", get_tvdb_language) + app.router.add_put("/api/tvdb/language", put_tvdb_language) + app.router.add_post( + "/api/library/tvdb-refresh-episodes", + post_tvdb_refresh_all_episodes, + ) + # Server-Logs + app.router.add_get("/api/logs", get_server_logs) diff --git a/video-konverter/app/routes/pages.py b/video-konverter/app/routes/pages.py new file mode 100644 index 0000000..6a39e84 --- /dev/null +++ b/video-konverter/app/routes/pages.py @@ -0,0 +1,160 @@ +"""Server-gerenderte Seiten mit Jinja2 + HTMX""" +import logging +from aiohttp import web +import aiohttp_jinja2 +from app.config import Config +from app.services.queue import QueueService +from app.services.encoder import EncoderService +from app.models.media import MediaFile + + +def setup_page_routes(app: web.Application, config: Config, + queue_service: QueueService) -> None: + """Registriert Seiten-Routes""" + + def _build_ws_url(request) -> str: + """Baut WebSocket-URL fuer den Client""" + srv = config.server_config + ext_url = srv.get("external_url", "") + use_https = srv.get("use_https", False) + ws_path = srv.get("websocket_path", "/ws") + protocol = "wss" if use_https else "ws" + + if ext_url: + return f"{protocol}://{ext_url}{ws_path}" + return f"{protocol}://{request.host}{ws_path}" + + @aiohttp_jinja2.template("dashboard.html") + async def dashboard(request: web.Request) -> dict: + """GET / - Dashboard""" + return { + "ws_url": _build_ws_url(request), + "active_jobs": queue_service.get_active_jobs().get("data_convert", {}), + "queue": queue_service.get_queue_state().get("data_queue", {}), + } + + @aiohttp_jinja2.template("admin.html") + async def admin(request: web.Request) -> dict: + """GET /admin - Einstellungsseite""" + gpu_available = EncoderService.detect_gpu_available() + gpu_devices = EncoderService.get_available_render_devices() + return { + "settings": config.settings, + "presets": config.presets, + "gpu_available": gpu_available, + "gpu_devices": gpu_devices, + } + + @aiohttp_jinja2.template("library.html") + async def library(request: web.Request) -> dict: + """GET /library - Bibliothek""" + return {} + + @aiohttp_jinja2.template("statistics.html") + async def statistics(request: web.Request) -> dict: + """GET /statistics - Statistik-Seite""" + entries = await queue_service.get_statistics(limit=50) + summary = await queue_service.get_statistics_summary() + return { + "entries": entries, + "summary": summary, + "format_size": MediaFile.format_size, + "format_time": MediaFile.format_time, + } + + # --- HTMX Partials --- + + async def htmx_save_settings(request: web.Request) -> web.Response: + """POST /htmx/settings - Settings via Formular speichern""" + data = await request.post() + + # Formular-Daten in Settings-Struktur konvertieren + settings = config.settings + + # Encoding + settings["encoding"]["mode"] = data.get("encoding_mode", "cpu") + settings["encoding"]["gpu_device"] = data.get("gpu_device", + "/dev/dri/renderD128") + settings["encoding"]["default_preset"] = data.get("default_preset", + "cpu_av1") + settings["encoding"]["max_parallel_jobs"] = int( + data.get("max_parallel_jobs", 1) + ) + + # Files + settings["files"]["target_container"] = data.get("target_container", "webm") + settings["files"]["target_folder"] = data.get("target_folder", "same") + settings["files"]["delete_source"] = data.get("delete_source") == "on" + settings["files"]["recursive_scan"] = data.get("recursive_scan") == "on" + + # Cleanup + settings["cleanup"]["enabled"] = data.get("cleanup_enabled") == "on" + cleanup_ext = data.get("cleanup_extensions", "") + if cleanup_ext: + settings["cleanup"]["delete_extensions"] = [ + e.strip() for e in cleanup_ext.split(",") if e.strip() + ] + exclude_pat = data.get("cleanup_exclude", "") + if exclude_pat: + settings["cleanup"]["exclude_patterns"] = [ + p.strip() for p in exclude_pat.split(",") if p.strip() + ] + + # Audio + audio_langs = data.get("audio_languages", "ger,eng,und") + settings["audio"]["languages"] = [ + l.strip() for l in audio_langs.split(",") if l.strip() + ] + settings["audio"]["default_codec"] = data.get("audio_codec", "libopus") + settings["audio"]["keep_channels"] = data.get("keep_channels") == "on" + + # Subtitle + sub_langs = data.get("subtitle_languages", "ger,eng") + settings["subtitle"]["languages"] = [ + l.strip() for l in sub_langs.split(",") if l.strip() + ] + + # Logging + settings["logging"]["level"] = data.get("log_level", "INFO") + + # Bibliothek / TVDB + settings.setdefault("library", {}) + settings["library"]["tvdb_api_key"] = data.get("tvdb_api_key", "") + settings["library"]["tvdb_pin"] = data.get("tvdb_pin", "") + settings["library"]["tvdb_language"] = data.get("tvdb_language", "deu") + + config.save_settings() + logging.info("Settings via Admin-UI gespeichert") + + # Erfolgs-HTML zurueckgeben (HTMX swap) + return web.Response( + text='
Settings gespeichert!
', + content_type="text/html", + ) + + @aiohttp_jinja2.template("partials/stats_table.html") + async def htmx_stats_table(request: web.Request) -> dict: + """GET /htmx/stats?page=1 - Paginierte Statistik""" + page = int(request.query.get("page", 1)) + limit = 25 + offset = (page - 1) * limit + entries = await queue_service.get_statistics(limit, offset) + return { + "entries": entries, + "page": page, + "format_size": MediaFile.format_size, + "format_time": MediaFile.format_time, + } + + async def redirect_to_library(request: web.Request): + """GET / -> Weiterleitung zur Bibliothek""" + raise web.HTTPFound("/library") + + # Routes registrieren + app.router.add_get("/", redirect_to_library) + app.router.add_get("/dashboard", dashboard) + app.router.add_get("/library", library) + app.router.add_get("/admin", admin) + app.router.add_get("/statistics", statistics) + app.router.add_post("/htmx/settings", htmx_save_settings) + app.router.add_get("/htmx/stats", htmx_stats_table) diff --git a/video-konverter/app/routes/ws.py b/video-konverter/app/routes/ws.py new file mode 100644 index 0000000..9852003 --- /dev/null +++ b/video-konverter/app/routes/ws.py @@ -0,0 +1,127 @@ +"""WebSocket Handler fuer Echtzeit-Updates""" +import json +import logging +from typing import Optional, Set, TYPE_CHECKING +from aiohttp import web + +if TYPE_CHECKING: + from app.services.queue import QueueService + + +class WebSocketManager: + """Verwaltet WebSocket-Verbindungen und Broadcasts""" + + def __init__(self): + self.clients: Set[web.WebSocketResponse] = set() + self.queue_service: Optional['QueueService'] = None + + def set_queue_service(self, queue_service: 'QueueService') -> None: + """Setzt Referenz auf QueueService""" + self.queue_service = queue_service + + async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse: + """WebSocket-Endpoint Handler""" + ws = web.WebSocketResponse() + await ws.prepare(request) + self.clients.add(ws) + + client_ip = request.remote + logging.info(f"WebSocket Client verbunden: {client_ip} " + f"({len(self.clients)} aktiv)") + + # Initialen Status senden + if self.queue_service: + try: + await ws.send_json(self.queue_service.get_active_jobs()) + await ws.send_json(self.queue_service.get_queue_state()) + except Exception: + pass + + try: + async for msg in ws: + if msg.type == web.WSMsgType.TEXT: + try: + data = json.loads(msg.data) + await self._handle_message(data) + except json.JSONDecodeError: + logging.warning(f"Ungueltige JSON-Nachricht: {msg.data[:100]}") + elif msg.type == web.WSMsgType.ERROR: + logging.error(f"WebSocket Fehler: {ws.exception()}") + except Exception as e: + logging.error(f"WebSocket Handler Fehler: {e}") + finally: + self.clients.discard(ws) + logging.info(f"WebSocket Client getrennt: {client_ip} " + f"({len(self.clients)} aktiv)") + + return ws + + async def broadcast(self, message: dict) -> None: + """Sendet Nachricht an alle verbundenen Clients""" + if not self.clients: + return + + msg_json = json.dumps(message) + dead_clients = set() + + for client in self.clients.copy(): + try: + await client.send_str(msg_json) + except Exception: + dead_clients.add(client) + + self.clients -= dead_clients + + async def broadcast_queue_update(self) -> None: + """Sendet aktuelle Queue an alle Clients""" + if not self.queue_service: + return + await self.broadcast(self.queue_service.get_active_jobs()) + await self.broadcast(self.queue_service.get_queue_state()) + + async def broadcast_progress(self, job) -> None: + """Sendet Fortschritts-Update fuer einen Job""" + await self.broadcast({"data_flow": job.to_dict_progress()}) + + async def broadcast_log(self, level: str, message: str) -> None: + """Sendet Log-Nachricht an alle Clients""" + await self.broadcast({ + "data_log": {"level": level, "message": message} + }) + + async def _handle_message(self, data: dict) -> None: + """Verarbeitet eingehende WebSocket-Nachrichten""" + if not self.queue_service: + return + + if "data_path" in data: + # Pfade empfangen (Legacy-Kompatibilitaet + neues Format) + paths = data["data_path"] + if isinstance(paths, str): + # Einzelner Pfad als String + paths = [paths] + elif isinstance(paths, dict) and "paths" in paths: + # Altes Format: {"paths": [...]} + paths = paths["paths"] + elif not isinstance(paths, list): + paths = [str(paths)] + + await self.queue_service.add_paths(paths) + + elif "data_command" in data: + cmd = data["data_command"] + cmd_type = cmd.get("cmd", "") + job_id = cmd.get("id") + + if not job_id: + return + + if cmd_type == "delete": + await self.queue_service.remove_job(int(job_id)) + elif cmd_type == "cancel": + await self.queue_service.cancel_job(int(job_id)) + elif cmd_type == "retry": + await self.queue_service.retry_job(int(job_id)) + + elif "data_message" in data: + logging.info(f"Client-Nachricht: {data['data_message']}") diff --git a/video-konverter/app/server.py b/video-konverter/app/server.py new file mode 100644 index 0000000..e4ec55a --- /dev/null +++ b/video-konverter/app/server.py @@ -0,0 +1,174 @@ +"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App""" +import asyncio +import logging +from pathlib import Path +from aiohttp import web +import aiohttp_jinja2 +import jinja2 +from app.config import Config +from app.services.queue import QueueService +from app.services.scanner import ScannerService +from app.services.encoder import EncoderService +from app.routes.ws import WebSocketManager +from app.services.library import LibraryService +from app.services.tvdb import TVDBService +from app.services.cleaner import CleanerService +from app.services.importer import ImporterService +from app.routes.api import setup_api_routes +from app.routes.library_api import setup_library_routes +from app.routes.pages import setup_page_routes + + +class VideoKonverterServer: + """Haupt-Server - ein Port fuer HTTP, WebSocket und Admin-UI""" + + def __init__(self): + self.config = Config() + self.config.setup_logging() + + # Services + self.ws_manager = WebSocketManager() + self.scanner = ScannerService(self.config) + self.queue_service = QueueService(self.config, self.ws_manager) + self.ws_manager.set_queue_service(self.queue_service) + + # Bibliothek-Services + self.library_service = LibraryService(self.config, self.ws_manager) + self.tvdb_service = TVDBService(self.config) + self.cleaner_service = CleanerService(self.config, self.library_service) + self.importer_service = ImporterService( + self.config, self.library_service, self.tvdb_service + ) + + # aiohttp App (50 GiB Upload-Limit fuer grosse Videodateien) + self.app = web.Application( + client_max_size=50 * 1024 * 1024 * 1024, + middlewares=[self._no_cache_middleware] + ) + self._setup_app() + + @web.middleware + async def _no_cache_middleware(self, request: web.Request, + handler) -> web.Response: + """Verhindert Browser-Caching fuer API-Responses""" + response = await handler(request) + if request.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + def _setup_app(self) -> None: + """Konfiguriert die aiohttp-Application""" + # Jinja2 Templates (request_processor macht request in Templates verfuegbar) + template_dir = Path(__file__).parent / "templates" + aiohttp_jinja2.setup( + self.app, + loader=jinja2.FileSystemLoader(str(template_dir)), + context_processors=[aiohttp_jinja2.request_processor], + ) + + # WebSocket Route + ws_path = self.config.server_config.get("websocket_path", "/ws") + self.app.router.add_get(ws_path, self.ws_manager.handle_websocket) + + # API Routes + setup_api_routes( + self.app, self.config, self.queue_service, self.scanner, + self.ws_manager + ) + + # Bibliothek API Routes + setup_library_routes( + self.app, self.config, self.library_service, + self.tvdb_service, self.queue_service, + self.cleaner_service, self.importer_service, + ) + + # Seiten Routes + setup_page_routes(self.app, self.config, self.queue_service) + + # Statische Dateien + static_dir = Path(__file__).parent / "static" + if static_dir.exists(): + self.app.router.add_static( + "/static/", path=str(static_dir), name="static" + ) + + # Startup/Shutdown Hooks + self.app.on_startup.append(self._on_startup) + self.app.on_shutdown.append(self._on_shutdown) + + async def _on_startup(self, app: web.Application) -> None: + """Server-Start: GPU pruefen, Queue starten""" + mode = self.config.encoding_mode + + # Auto-Detection + if mode == "auto": + gpu_ok = EncoderService.detect_gpu_available() + if gpu_ok: + gpu_ok = await EncoderService.test_gpu_encoding( + self.config.gpu_device + ) + if gpu_ok: + self.config.settings["encoding"]["mode"] = "gpu" + self.config.settings["encoding"]["default_preset"] = "gpu_av1" + logging.info(f"GPU erkannt ({self.config.gpu_device}), " + f"verwende GPU-Encoding") + else: + self.config.settings["encoding"]["mode"] = "cpu" + self.config.settings["encoding"]["default_preset"] = "cpu_av1" + logging.info("Keine GPU erkannt, verwende CPU-Encoding") + else: + logging.info(f"Encoding-Modus: {mode}") + + # Queue starten + await self.queue_service.start() + + # Bibliothek starten + await self.library_service.start() + + # DB-Pool mit anderen Services teilen + if self.library_service._db_pool: + self.tvdb_service.set_db_pool(self.library_service._db_pool) + self.importer_service.set_db_pool(self.library_service._db_pool) + + # WebSocket-Manager an ImporterService fuer Live-Updates + self.importer_service.set_ws_manager(self.ws_manager) + + # Zusaetzliche DB-Tabellen erstellen + await self.tvdb_service.init_db() + await self.importer_service.init_db() + + host = self.config.server_config.get("host", "0.0.0.0") + port = self.config.server_config.get("port", 8080) + logging.info(f"Server bereit auf http://{host}:{port}") + + async def _on_shutdown(self, app: web.Application) -> None: + """Server-Stop: Queue und Library stoppen""" + await self.queue_service.stop() + await self.library_service.stop() + logging.info("Server heruntergefahren") + + async def run(self) -> None: + """Startet den Server""" + host = self.config.server_config.get("host", "0.0.0.0") + port = self.config.server_config.get("port", 8080) + + runner = web.AppRunner(self.app) + await runner.setup() + site = web.TCPSite(runner, host, port) + await site.start() + + logging.info( + f"VideoKonverter Server laeuft auf http://{host}:{port}\n" + f" Dashboard: http://{host}:{port}/\n" + f" Bibliothek: http://{host}:{port}/library\n" + f" Admin: http://{host}:{port}/admin\n" + f" Statistik: http://{host}:{port}/statistics\n" + f" WebSocket: ws://{host}:{port}/ws\n" + f" API: http://{host}:{port}/api/convert (POST)" + ) + + # Endlos laufen bis Interrupt + await asyncio.Event().wait() diff --git a/video-konverter/app/services/__init__.py b/video-konverter/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/video-konverter/app/services/cleaner.py b/video-konverter/app/services/cleaner.py new file mode 100644 index 0000000..343b679 --- /dev/null +++ b/video-konverter/app/services/cleaner.py @@ -0,0 +1,155 @@ +"""Clean-Service: Findet und entfernt Nicht-Video-Dateien aus der Bibliothek""" +import logging +import os +from pathlib import Path +from typing import Optional + +from app.config import Config +from app.services.library import LibraryService, VIDEO_EXTENSIONS + + +class CleanerService: + """Scannt Library-Ordner nach Nicht-Video-Dateien und bietet Cleanup an""" + + def __init__(self, config: Config, library_service: LibraryService): + self.config = config + self.library = library_service + + @property + def _cleanup_config(self) -> dict: + return self.config.settings.get("cleanup", {}) + + @property + def _keep_extensions(self) -> set: + """Extensions die behalten werden sollen""" + exts = self._cleanup_config.get("keep_extensions", []) + return {e.lower() for e in exts} + + async def scan_for_junk(self, library_path_id: int = None) -> dict: + """Scannt Library-Ordner nach Nicht-Video-Dateien. + Gibt zurueck: files, total_size, total_count""" + paths = await self.library.get_paths() + if library_path_id: + paths = [p for p in paths if p["id"] == library_path_id] + + keep_exts = self._keep_extensions + junk_files = [] + total_size = 0 + + for lib_path in paths: + if not lib_path.get("enabled"): + continue + base = lib_path["path"] + if not os.path.isdir(base): + continue + + for root, dirs, files in os.walk(base): + # Versteckte Ordner ueberspringen + dirs[:] = [d for d in dirs if not d.startswith(".")] + for f in files: + ext = os.path.splitext(f)[1].lower() + # Video-Dateien und Keep-Extensions ueberspringen + if ext in VIDEO_EXTENSIONS: + continue + if ext in keep_exts: + continue + + fp = os.path.join(root, f) + try: + size = os.path.getsize(fp) + except OSError: + size = 0 + + # Relativen Pfad berechnen + rel = os.path.relpath(fp, base) + # Serien-Ordner aus erstem Pfadteil + parts = rel.replace("\\", "/").split("/") + parent_series = parts[0] if len(parts) > 1 else "" + + junk_files.append({ + "path": fp, + "name": f, + "size": size, + "extension": ext, + "parent_series": parent_series, + "library_name": lib_path["name"], + }) + total_size += size + + return { + "files": junk_files, + "total_size": total_size, + "total_count": len(junk_files), + } + + async def delete_files(self, file_paths: list[str]) -> dict: + """Loescht die angegebenen Dateien""" + deleted = 0 + failed = 0 + freed = 0 + errors = [] + + # Sicherheitscheck: Nur Dateien in Library-Pfaden loeschen + paths = await self.library.get_paths() + allowed_prefixes = [p["path"] for p in paths] + + for fp in file_paths: + # Pruefen ob Datei in erlaubtem Pfad liegt + is_allowed = any( + fp.startswith(prefix + "/") or fp == prefix + for prefix in allowed_prefixes + ) + if not is_allowed: + errors.append(f"Nicht erlaubt: {fp}") + failed += 1 + continue + + try: + size = os.path.getsize(fp) + os.remove(fp) + deleted += 1 + freed += size + logging.info(f"Clean: Geloescht: {fp}") + except OSError as e: + errors.append(f"{fp}: {e}") + failed += 1 + + return { + "deleted": deleted, + "failed": failed, + "freed_bytes": freed, + "errors": errors, + } + + async def delete_empty_dirs(self, + library_path_id: int = None) -> int: + """Leere Unterordner loeschen (bottom-up)""" + paths = await self.library.get_paths() + if library_path_id: + paths = [p for p in paths if p["id"] == library_path_id] + + removed = 0 + for lib_path in paths: + if not lib_path.get("enabled"): + continue + base = lib_path["path"] + if not os.path.isdir(base): + continue + + # Bottom-up: Tiefste Ordner zuerst + for root, dirs, files in os.walk(base, topdown=False): + # Nicht den Basis-Ordner selbst loeschen + if root == base: + continue + # Versteckte Ordner ueberspringen + if os.path.basename(root).startswith("."): + continue + try: + if not os.listdir(root): + os.rmdir(root) + removed += 1 + logging.info(f"Clean: Leerer Ordner entfernt: {root}") + except OSError: + pass + + return removed diff --git a/video-konverter/app/services/encoder.py b/video-konverter/app/services/encoder.py new file mode 100644 index 0000000..a203773 --- /dev/null +++ b/video-konverter/app/services/encoder.py @@ -0,0 +1,234 @@ +"""ffmpeg Command Builder - GPU und CPU Encoding""" +import os +import logging +import asyncio +from app.config import Config +from app.models.job import ConversionJob + + +class EncoderService: + """Baut ffmpeg-Befehle basierend auf Preset und Media-Analyse""" + + def __init__(self, config: Config): + self.config = config + + def build_command(self, job: ConversionJob) -> list[str]: + """ + Baut den vollstaendigen ffmpeg-Befehl. + Beruecksichtigt GPU/CPU, Preset, Audio/Subtitle-Filter. + """ + preset = self.config.presets.get(job.preset_name, {}) + if not preset: + logging.error(f"Preset '{job.preset_name}' nicht gefunden") + preset = self.config.default_preset + + cmd = ["ffmpeg", "-y"] + + # GPU-Initialisierung + cmd.extend(self._build_hw_init(preset)) + + # Input + cmd.extend(["-i", job.media.source_path]) + + # Video-Stream (erster Video-Stream) + cmd.extend(self._build_video_params(job, preset)) + + # Audio-Streams (alle passenden) + cmd.extend(self._build_audio_params(job)) + + # Subtitle-Streams (alle passenden) + cmd.extend(self._build_subtitle_params(job)) + + # Output + cmd.append(job.target_path) + + job.ffmpeg_cmd = cmd + return cmd + + def _build_hw_init(self, preset: dict) -> list[str]: + """GPU-Initialisierung fuer VAAPI""" + if not preset.get("hw_init", False): + return [] + + device = self.config.gpu_device + return [ + "-init_hw_device", f"vaapi=intel:{device}", + "-hwaccel", "vaapi", + "-hwaccel_device", "intel", + ] + + def _build_video_params(self, job: ConversionJob, preset: dict) -> list[str]: + """Video-Parameter: Codec, Quality, Filter""" + cmd = ["-map", "0:v:0"] # Erster Video-Stream + + # Video-Codec + codec = preset.get("video_codec", "libsvtav1") + cmd.extend(["-c:v", codec]) + + # Quality (CRF oder QP je nach Encoder) + quality_param = preset.get("quality_param", "crf") + quality_value = preset.get("quality_value", 30) + cmd.extend([f"-{quality_param}", str(quality_value)]) + + # GOP-Groesse + gop = preset.get("gop_size") + if gop: + cmd.extend(["-g", str(gop)]) + + # Speed-Preset (nur CPU-Encoder) + speed = preset.get("speed_preset") + if speed is not None: + cmd.extend(["-preset", str(speed)]) + + # Video-Filter + vf = preset.get("video_filter", "") + if not vf and preset.get("hw_init"): + # Auto-Detect Pixel-Format fuer GPU + vf = self._detect_gpu_filter(job) + if vf: + cmd.extend(["-vf", vf]) + + # Extra-Parameter + for key, value in preset.get("extra_params", {}).items(): + cmd.extend([f"-{key}", str(value)]) + + return cmd + + def _build_audio_params(self, job: ConversionJob) -> list[str]: + """ + Audio-Streams: Filtert nach Sprache, setzt Codec/Bitrate. + WICHTIG: Kanalanzahl wird beibehalten (kein Downmix)! + Surround (5.1/7.1) und Stereo (2.0/2.1) bleiben erhalten. + """ + audio_cfg = self.config.audio_config + languages = audio_cfg.get("languages", ["ger", "eng", "und"]) + codec = audio_cfg.get("default_codec", "libopus") + bitrate_map = audio_cfg.get("bitrate_map", {2: "128k", 6: "320k", 8: "450k"}) + default_bitrate = audio_cfg.get("default_bitrate", "192k") + keep_channels = audio_cfg.get("keep_channels", True) + + cmd = [] + audio_idx = 0 + + for stream in job.media.audio_streams: + # Sprachfilter: Wenn Sprache gesetzt, muss sie in der Liste sein + lang = stream.language + if lang and lang not in languages: + continue + + cmd.extend(["-map", f"0:{stream.index}"]) + + if codec == "copy": + cmd.extend([f"-c:a:{audio_idx}", "copy"]) + else: + cmd.extend([f"-c:a:{audio_idx}", codec]) + + # Bitrate nach Kanalanzahl (Surround bekommt mehr) + # Konvertiere bitrate_map Keys zu int (YAML laedt sie als int) + channels = stream.channels + bitrate = str(bitrate_map.get(channels, default_bitrate)) + cmd.extend([f"-b:a:{audio_idx}", bitrate]) + + # Kanalanzahl beibehalten + if keep_channels: + cmd.extend([f"-ac:{audio_idx}", str(channels)]) + + # Channel-Layout normalisieren fuer libopus + # EAC3/AC3 mit 5.1(side) Layout fuehrt zu Encoder-Fehler + if codec == "libopus" and channels == 6: + cmd.extend([ + f"-filter:a:{audio_idx}", + "channelmap=channel_layout=5.1", + ]) + + audio_idx += 1 + + return cmd + + def _build_subtitle_params(self, job: ConversionJob) -> list[str]: + """Subtitle-Streams: Filtert nach Sprache und Blacklist""" + sub_cfg = self.config.subtitle_config + languages = sub_cfg.get("languages", ["ger", "eng"]) + blacklist = sub_cfg.get("codec_blacklist", []) + + cmd = [] + for stream in job.media.subtitle_streams: + # Codec-Blacklist (Bild-basierte Untertitel) + if stream.codec_name in blacklist: + continue + + # Sprachfilter + lang = stream.language + if lang and lang not in languages: + continue + + cmd.extend(["-map", f"0:{stream.index}"]) + + # Subtitle-Codec: Bei WebM nur webvtt moeglich + if job.target_container == "webm" and cmd: + cmd.extend(["-c:s", "webvtt"]) + elif cmd: + cmd.extend(["-c:s", "copy"]) + + return cmd + + def _detect_gpu_filter(self, job: ConversionJob) -> str: + """Erkennt Pixel-Format fuer GPU-Encoding""" + if job.media.is_10bit: + return "format=p010,hwupload" + return "format=nv12,hwupload" + + @staticmethod + def detect_gpu_available() -> bool: + """Prueft ob GPU/VAAPI verfuegbar ist""" + # Pruefe ob /dev/dri existiert + if not os.path.exists("/dev/dri"): + return False + + # Pruefe ob renderD* Devices vorhanden + devices = EncoderService.get_available_render_devices() + if not devices: + return False + + return True + + @staticmethod + def get_available_render_devices() -> list[str]: + """Listet verfuegbare /dev/dri/renderD* Geraete""" + dri_path = "/dev/dri" + if not os.path.exists(dri_path): + return [] + + devices = [] + try: + for entry in os.listdir(dri_path): + if entry.startswith("renderD"): + devices.append(f"{dri_path}/{entry}") + except PermissionError: + logging.warning("Kein Zugriff auf /dev/dri") + + return sorted(devices) + + @staticmethod + async def test_gpu_encoding(device: str = "/dev/dri/renderD128") -> bool: + """Testet ob GPU-Encoding tatsaechlich funktioniert""" + cmd = [ + "ffmpeg", "-y", + "-init_hw_device", f"vaapi=test:{device}", + "-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1", + "-vf", "format=nv12,hwupload", + "-c:v", "h264_vaapi", + "-frames:v", "1", + "-f", "null", "-", + ] + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for(process.communicate(), timeout=10) + return process.returncode == 0 + except Exception as e: + logging.warning(f"GPU-Test fehlgeschlagen: {e}") + return False diff --git a/video-konverter/app/services/importer.py b/video-konverter/app/services/importer.py new file mode 100644 index 0000000..a51f90a --- /dev/null +++ b/video-konverter/app/services/importer.py @@ -0,0 +1,1568 @@ +"""Import-Service: Videos erkennen, TVDB-Match, umbenennen, einsortieren""" +import asyncio +import json +import logging +import os +import re +import shutil +from pathlib import Path +from typing import Optional + +import aiomysql + +from app.config import Config +from app.services.library import ( + LibraryService, VIDEO_EXTENSIONS, RE_SXXEXX, RE_XXxXX +) +from app.services.tvdb import TVDBService +from app.services.probe import ProbeService + +# Serienname aus Dateiname extrahieren (alles vor SxxExx) +RE_SERIES_FROM_NAME = re.compile( + r'^(.+?)[\s._-]+[Ss]\d{1,2}[Ee]\d{1,3}', re.IGNORECASE +) +RE_SERIES_FROM_XXx = re.compile( + r'^(.+?)[\s._-]+\d{1,2}x\d{2,3}', re.IGNORECASE +) +# "Serienname - Staffel X" oder "Serienname Season X" in Ordnernamen +RE_STAFFEL_DIR = re.compile( + r'^(.+?)[\s._-]+(?:Staffel|Season)\s*(\d{1,2})\s*$', re.IGNORECASE +) + + +class ImporterService: + """Video-Import: Erkennung, TVDB-Matching, Umbenennung, Kopieren/Verschieben""" + + def __init__(self, config: Config, library_service: LibraryService, + tvdb_service: TVDBService): + self.config = config + self.library = library_service + self.tvdb = tvdb_service + self._db_pool: Optional[aiomysql.Pool] = None + self.ws_manager = None + + def set_db_pool(self, pool: aiomysql.Pool) -> None: + self._db_pool = pool + + def set_ws_manager(self, ws_manager) -> None: + """WebSocket-Manager fuer Live-Updates""" + self.ws_manager = ws_manager + + async def _broadcast_import(self, job_id: int, status: str, + processed: int = 0, total: int = 0, + current_file: str = "", + bytes_done: int = 0, + bytes_total: int = 0) -> None: + """Sendet Import-Fortschritt per WebSocket""" + if not self.ws_manager: + return + await self.ws_manager.broadcast({ + "data_import": { + "job_id": job_id, + "status": status, + "processed": processed, + "total": total, + "current_file": current_file, + "bytes_done": bytes_done, + "bytes_total": bytes_total, + } + }) + + @property + def _naming_pattern(self) -> str: + return self.config.settings.get("library", {}).get( + "import_naming_pattern", + "{series} - S{season:02d}E{episode:02d} - {title}.{ext}" + ) + + @property + def _season_pattern(self) -> str: + return self.config.settings.get("library", {}).get( + "import_season_pattern", "Season {season:02d}" + ) + + # === DB-Tabellen erstellen === + + async def init_db(self) -> None: + """Import-Tabellen erstellen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS import_jobs ( + id INT AUTO_INCREMENT PRIMARY KEY, + source_path VARCHAR(1024) NOT NULL, + target_library_id INT NOT NULL, + status ENUM('pending','analyzing','pending_assignment', + 'ready','importing','done','error') + DEFAULT 'pending', + mode ENUM('copy','move') DEFAULT 'copy', + naming_pattern VARCHAR(256), + season_pattern VARCHAR(256), + total_files INT DEFAULT 0, + processed_files INT DEFAULT 0, + overwrite_all TINYINT(1) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (target_library_id) + REFERENCES library_paths(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + await cur.execute(""" + CREATE TABLE IF NOT EXISTS import_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + import_job_id INT NOT NULL, + source_file VARCHAR(1024) NOT NULL, + source_size BIGINT NOT NULL DEFAULT 0, + source_duration DOUBLE NULL, + detected_series VARCHAR(256), + detected_season INT, + detected_episode INT, + tvdb_series_id INT NULL, + tvdb_series_name VARCHAR(256), + tvdb_episode_title VARCHAR(512), + target_path VARCHAR(1024), + target_filename VARCHAR(512), + status ENUM('pending','pending_series','matched', + 'conflict','skipped','done','error') + DEFAULT 'pending', + conflict_reason VARCHAR(512) NULL, + existing_file_path VARCHAR(1024) NULL, + existing_file_size BIGINT NULL, + user_action ENUM('overwrite','skip','rename') NULL, + FOREIGN KEY (import_job_id) + REFERENCES import_jobs(id) ON DELETE CASCADE, + INDEX idx_job (import_job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + logging.info("Import-Tabellen initialisiert") + except Exception as e: + logging.error(f"Import-Tabellen erstellen fehlgeschlagen: {e}") + + # === Job-Verwaltung === + + async def create_job(self, source_path: str, + target_library_id: int, + mode: str = 'copy') -> Optional[int]: + """Erstellt einen Import-Job und sucht Video-Dateien im Quellordner""" + if not self._db_pool: + return None + if not os.path.isdir(source_path): + return None + if mode not in ('copy', 'move'): + mode = 'copy' + + # Video-Dateien im Quellordner finden + videos = [] + for root, dirs, files in os.walk(source_path): + dirs[:] = [d for d in dirs if not d.startswith(".")] + for f in sorted(files): + ext = os.path.splitext(f)[1].lower() + if ext in VIDEO_EXTENSIONS: + videos.append(os.path.join(root, f)) + + if not videos: + return None + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO import_jobs " + "(source_path, target_library_id, status, mode, " + "naming_pattern, season_pattern, total_files) " + "VALUES (%s, %s, 'pending', %s, %s, %s, %s)", + (source_path, target_library_id, mode, + self._naming_pattern, self._season_pattern, + len(videos)) + ) + job_id = cur.lastrowid + + # Items einfuegen + for vf in videos: + try: + size = os.path.getsize(vf) + except OSError: + size = 0 + await cur.execute( + "INSERT INTO import_items " + "(import_job_id, source_file, source_size) " + "VALUES (%s, %s, %s)", + (job_id, vf, size) + ) + + logging.info( + f"Import-Job erstellt: {job_id} " + f"({len(videos)} Videos aus {source_path})" + ) + return job_id + except Exception as e: + logging.error(f"Import-Job erstellen fehlgeschlagen: {e}") + return None + + async def delete_job(self, job_id: int) -> dict: + """Loescht einen Import-Job (nur wenn nicht gerade importiert wird)""" + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT status FROM import_jobs WHERE id = %s", + (job_id,) + ) + row = await cur.fetchone() + if not row: + return {"error": "Job nicht gefunden"} + if row[0] == 'importing': + return {"error": "Job laeuft gerade, kann nicht geloescht werden"} + # Items werden per ON DELETE CASCADE mitgeloescht + await cur.execute( + "DELETE FROM import_jobs WHERE id = %s", (job_id,) + ) + logging.info(f"Import-Job {job_id} geloescht") + return {"message": f"Import-Job {job_id} geloescht"} + except Exception as e: + logging.error(f"Import-Job {job_id} loeschen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def analyze_job(self, job_id: int) -> dict: + """Phase 1: Nur Serie/Staffel/Episode aus Dateinamen extrahieren. + + KEIN TVDB-Lookup, KEINE Zielpfade, KEINE Konflikt-Pruefung. + Das passiert erst nach der Serien-Zuordnung durch den User. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Job laden + await cur.execute( + "SELECT * FROM import_jobs WHERE id = %s", (job_id,) + ) + job = await cur.fetchone() + if not job: + return {"error": "Job nicht gefunden"} + + # Status auf analyzing + await cur.execute( + "UPDATE import_jobs SET status = 'analyzing' " + "WHERE id = %s", (job_id,) + ) + + # Items laden + await cur.execute( + "SELECT * FROM import_items " + "WHERE import_job_id = %s ORDER BY source_file", + (job_id,) + ) + items = await cur.fetchall() + + # Jedes Item analysieren (NUR Dateiname-Erkennung) + total = len(items) + for idx, item in enumerate(items): + await self._analyze_item_basic(item) + await self._broadcast_import( + job_id, "analyzing", + processed=idx + 1, total=total, + current_file=os.path.basename( + item.get("source_file", "") + ), + ) + + # Status auf 'pending_assignment' - wartet auf Serien-Zuordnung + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET status = 'pending_assignment' " + "WHERE id = %s", (job_id,) + ) + + return await self.get_job_status(job_id) + + except Exception as e: + logging.error(f"Import-Analyse fehlgeschlagen: {e}") + return {"error": str(e)} + + async def _analyze_item_basic(self, item: dict) -> None: + """Phase 1: Nur Serie/Staffel/Episode aus Dateinamen extrahieren.""" + filename = os.path.basename(item["source_file"]) + + # Groessen-Check: Zu kleine Dateien als Sample/Trailer markieren + if item["source_size"] < self.MIN_EPISODE_SIZE: + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE import_items SET + status = 'skipped', + conflict_reason = %s + WHERE id = %s + """, ( + f"Vermutlich Sample/Trailer " + f"({self._fmt_size(item['source_size'])})", + item["id"], + )) + except Exception: + pass + return + + # Serie/Staffel/Episode erkennen (Dateiname + Ordnername) + info = self._detect_series_info(item["source_file"]) + series_name = info.get("series", "") + season = info.get("season") + episode = info.get("episode") + + # Status: pending_series wenn Serie erkannt, sonst pending + if series_name and season and episode: + status = "pending_series" # Wartet auf Serien-Zuordnung + else: + status = "pending" # Nicht erkannt + + # In DB aktualisieren (NUR erkannte Werte, KEIN Zielpfad) + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE import_items SET + detected_series = %s, + detected_season = %s, + detected_episode = %s, + status = %s, + conflict_reason = %s + WHERE id = %s + """, ( + series_name, season, episode, status, + None if status == "pending_series" + else "Serie/Staffel/Episode nicht erkannt", + item["id"], + )) + except Exception as e: + logging.error(f"Import-Item analysieren fehlgeschlagen: {e}") + + async def get_pending_series(self, job_id: int) -> dict: + """Gibt alle erkannten Serien zurueck, die noch zugeordnet werden muessen. + + Gruppiert nach detected_series mit Anzahl der Dateien. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute(""" + SELECT detected_series, COUNT(*) as count, + MIN(detected_season) as min_season, + MAX(detected_season) as max_season + FROM import_items + WHERE import_job_id = %s + AND status = 'pending_series' + AND detected_series IS NOT NULL + GROUP BY detected_series + ORDER BY detected_series + """, (job_id,)) + rows = await cur.fetchall() + + series_list = [] + for row in rows: + series_list.append({ + "detected_name": row["detected_series"], + "count": row["count"], + "seasons": f"{row['min_season']}-{row['max_season']}" + if row["min_season"] != row["max_season"] + else str(row["min_season"]), + }) + + return {"series": series_list} + + except Exception as e: + logging.error(f"Pending Series laden fehlgeschlagen: {e}") + return {"error": str(e)} + + async def assign_series_mapping(self, job_id: int, detected_series: str, + tvdb_id: int, tvdb_name: str) -> dict: + """Ordnet eine erkannte Serie einer TVDB-Serie zu. + + Berechnet Zielpfade und prueft Konflikte fuer alle Dateien dieser Serie. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Job + Library-Pfad laden + await cur.execute( + "SELECT j.*, lp.path as lib_path " + "FROM import_jobs j " + "JOIN library_paths lp ON lp.id = j.target_library_id " + "WHERE j.id = %s", (job_id,) + ) + job = await cur.fetchone() + if not job: + return {"error": "Job nicht gefunden"} + + # Alle Items mit diesem detected_series + await cur.execute( + "SELECT * FROM import_items " + "WHERE import_job_id = %s " + "AND LOWER(detected_series) = LOWER(%s) " + "AND status = 'pending_series'", + (job_id, detected_series) + ) + items = await cur.fetchall() + + if not items: + return {"error": f"Keine Items fuer '{detected_series}'"} + + # Jedes Item: Zielpfad berechnen, Episodentitel holen, Konflikte pruefen + updated = 0 + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + for item in items: + season = item["detected_season"] + episode = item["detected_episode"] + + # Episodentitel von TVDB + tvdb_ep_title = "" + if tvdb_id and season and episode and self.tvdb.is_configured: + tvdb_ep_title = await self._get_episode_title( + int(tvdb_id), season, episode + ) + + # Zielpfad berechnen + ext = os.path.splitext(item["source_file"])[1].lstrip(".") + pattern = job.get("naming_pattern") or self._naming_pattern + season_pat = job.get("season_pattern") or self._season_pattern + target_dir, target_file = self._build_target( + tvdb_name, season, episode, + tvdb_ep_title, ext, + job["lib_path"], + pattern, season_pat + ) + target_path = os.path.join(target_dir, target_file) + + # Konflikt pruefen + status = "matched" + conflict = None + existing_path = None + existing_size = None + + if os.path.exists(target_path): + existing_path = target_path + existing_size = os.path.getsize(target_path) + source_size = item["source_size"] + + if source_size and existing_size: + diff_pct = abs(source_size - existing_size) / max( + existing_size, 1 + ) * 100 + if diff_pct > 20: + conflict = ( + f"Datei existiert " + f"(Quelle: {self._fmt_size(source_size)}, " + f"Ziel: {self._fmt_size(existing_size)}, " + f"Diff: {diff_pct:.0f}%)" + ) + else: + conflict = "Datei existiert (aehnliche Groesse)" + else: + conflict = "Datei existiert bereits" + status = "conflict" + + await cur.execute(""" + UPDATE import_items SET + tvdb_series_id = %s, + tvdb_series_name = %s, + tvdb_episode_title = %s, + target_path = %s, + target_filename = %s, + status = %s, + conflict_reason = %s, + existing_file_path = %s, + existing_file_size = %s + WHERE id = %s + """, ( + tvdb_id, tvdb_name, tvdb_ep_title, + target_dir, target_file, status, + conflict, existing_path, existing_size, + item["id"], + )) + updated += 1 + + # Pruefen ob noch pending_series Items uebrig sind + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT COUNT(*) FROM import_items " + "WHERE import_job_id = %s AND status = 'pending_series'", + (job_id,) + ) + remaining = (await cur.fetchone())[0] + + # Wenn alle Serien zugeordnet -> Status auf ready + if remaining == 0: + await cur.execute( + "UPDATE import_jobs SET status = 'ready' " + "WHERE id = %s", (job_id,) + ) + + logging.info( + f"Serien-Zuordnung: {updated} Items fuer " + f"'{detected_series}' -> '{tvdb_name}' (TVDB: {tvdb_id})" + ) + return { + "ok": True, + "updated": updated, + "tvdb_name": tvdb_name, + "remaining_series": remaining, + } + + except Exception as e: + logging.error(f"Serien-Zuordnung fehlgeschlagen: {e}") + return {"error": str(e)} + + # Mindestgroesse fuer "echte" Episoden (darunter = Sample/Trailer) + MIN_EPISODE_SIZE = 100 * 1024 * 1024 # 100 MiB + + def _detect_series_info(self, file_path: str) -> dict: + """Extrahiert Serienname, Staffel, Episode. + + Versucht zuerst den Dateinamen, dann den uebergeordneten + Ordnernamen als Fallback. Der Ordnername ist oft zuverlaessiger + bei Release-Gruppen-Prefixes (z.B. 'tlr-24.s07e01.mkv' vs + Ordner '24.S07E01.German.DL.1080p.Bluray.x264-TLR'). + Erkennt auch 'Serienname - Staffel X'-Ordner (haeufig bei DE-Medien). + """ + filename = os.path.basename(file_path) + parent_dir = os.path.basename(os.path.dirname(file_path)) + grandparent_dir = os.path.basename( + os.path.dirname(os.path.dirname(file_path)) + ) + + # Beide Quellen versuchen + info_file = self._parse_name(filename) + info_dir = self._parse_name(parent_dir) + + # Strategie: Ordnername bevorzugen bei Scene-Releases. + # Scene-Ordner: "24.S07E01.German.DL.1080p-TLR" -> Serie="24" + # Scene-Datei: "tlr-24.s07e01.1080p.mkv" -> Serie="tlr-24" + # Ordnername hat Serienname vorne, Dateiname oft Release-Tag vorne + + # Ordnername hat S/E -> bevorzugen (hat meist korrekten Seriennamen) + if info_dir.get("season") and info_dir.get("episode"): + if info_dir.get("series"): + return info_dir + # Ordner hat S/E aber keinen Namen -> Dateiname nehmen + if info_file.get("series"): + info_dir["series"] = info_file["series"] + return info_dir + + # "Staffel X" / "Season X" Pattern im Ordnernamen + # z.B. "24 - Staffel 6" -> Serie="24", Staffel=6 + # Episode kommt dann aus dem Dateinamen + staffel_info = self._parse_staffel_dir(parent_dir) + if not staffel_info: + staffel_info = self._parse_staffel_dir(grandparent_dir) + if staffel_info and info_file.get("episode"): + return { + "series": staffel_info["series"], + "season": staffel_info["season"], + "episode": info_file["episode"], + } + + # Dateiname hat S/E + if info_file.get("season") and info_file.get("episode"): + # Ordner-Serienname als Fallback wenn Datei keinen hat + if not info_file.get("series") and info_dir.get("series"): + info_file["series"] = info_dir["series"] + return info_file + + return info_file + + async def _get_cached_tvdb_match(self, series_name: str) -> Optional[dict]: + """Prueft ob diese Serie schon in library_series mit TVDB-ID existiert.""" + if not self._db_pool: + return None + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT tvdb_id, title FROM library_series " + "WHERE tvdb_id IS NOT NULL " + "AND (LOWER(folder_name) = LOWER(%s) " + " OR LOWER(title) = LOWER(%s))", + (series_name, series_name) + ) + row = await cur.fetchone() + if row: + return { + "tvdb_id": row["tvdb_id"], + "name": row["title"], + } + except Exception: + pass + return None + + @staticmethod + def _parse_staffel_dir(dir_name: str) -> Optional[dict]: + """Erkennt 'Serienname - Staffel X' Pattern in Ordnernamen""" + m = RE_STAFFEL_DIR.match(dir_name) + if m: + series = m.group(1).replace(".", " ").replace("_", " ").strip() + series = re.sub(r'\s+', ' ', series).rstrip(" -") + if series: + return {"series": series, "season": int(m.group(2))} + return None + + def _parse_name(self, name: str) -> dict: + """Extrahiert Serienname, Staffel, Episode aus einem Namen""" + result = {"series": "", "season": None, "episode": None} + name_no_ext = os.path.splitext(name)[0] + + # S01E02 Format + m = RE_SXXEXX.search(name) + if m: + result["season"] = int(m.group(1)) + result["episode"] = int(m.group(2)) + sm = RE_SERIES_FROM_NAME.match(name_no_ext) + if sm: + result["series"] = self._clean_name(sm.group(1)) + return result + + # 1x02 Format + m = RE_XXxXX.search(name) + if m: + result["season"] = int(m.group(1)) + result["episode"] = int(m.group(2)) + sm = RE_SERIES_FROM_XXx.match(name_no_ext) + if sm: + result["series"] = self._clean_name(sm.group(1)) + return result + + return result + + @staticmethod + def _clean_name(name: str) -> str: + """Bereinigt Seriennamen: Punkte/Underscores durch Leerzeichen""" + name = name.replace(".", " ").replace("_", " ") + # Mehrfach-Leerzeichen reduzieren + name = re.sub(r'\s+', ' ', name).strip() + # Trailing Bindestriche entfernen + name = name.rstrip(" -") + return name + + def _build_target(self, series: str, season: Optional[int], + episode: Optional[int], title: str, ext: str, + lib_path: str, pattern: str, + season_pattern: str) -> tuple[str, str]: + """Baut Ziel-Ordner und Dateiname nach Pattern""" + s = season or 1 + e = episode or 0 + + # Season-Ordner + season_dir = season_pattern.format(season=s) + + # Dateiname - kein Titel: ohne Titel-Teil, sonst mit + try: + if title: + filename = pattern.format( + series=series, season=s, episode=e, + title=title, ext=ext + ) + else: + # Ohne Titel: "Serie - S01E03.ext" + filename = f"{series} - S{s:02d}E{e:02d}.{ext}" + except (KeyError, ValueError): + if title: + filename = f"{series} - S{s:02d}E{e:02d} - {title}.{ext}" + else: + filename = f"{series} - S{s:02d}E{e:02d}.{ext}" + + # Ungueltige Zeichen entfernen + for ch in ['<', '>', ':', '"', '|', '?', '*']: + filename = filename.replace(ch, '') + series = series.replace(ch, '') + + target_dir = os.path.join(lib_path, series, season_dir) + return target_dir, filename + + async def _get_episode_title(self, tvdb_id: int, + season: int, episode: int) -> str: + """Episodentitel aus TVDB-Cache oder API holen""" + if not self._db_pool: + return "" + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + # Zuerst Cache pruefen + await cur.execute( + "SELECT episode_name FROM tvdb_episode_cache " + "WHERE series_tvdb_id = %s " + "AND season_number = %s " + "AND episode_number = %s", + (tvdb_id, season, episode) + ) + row = await cur.fetchone() + if row and row[0]: + return row[0] + except Exception: + pass + + # Cache leer -> Episoden von TVDB laden + episodes = await self.tvdb.fetch_episodes(tvdb_id) + for ep in episodes: + if ep["season_number"] == season and ep["episode_number"] == episode: + return ep.get("episode_name", "") + return "" + + async def execute_import(self, job_id: int) -> dict: + """Fuehrt den Import aus (Kopieren/Verschieben + TVDB-Link)""" + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM import_jobs WHERE id = %s", (job_id,) + ) + job = await cur.fetchone() + if not job: + return {"error": "Job nicht gefunden"} + + await cur.execute( + "UPDATE import_jobs SET status = 'importing' " + "WHERE id = %s", (job_id,) + ) + + # Nur Items mit status matched oder conflict+overwrite + await cur.execute( + "SELECT * FROM import_items " + "WHERE import_job_id = %s " + "AND (status = 'matched' " + " OR (status = 'conflict' " + " AND user_action = 'overwrite'))", + (job_id,) + ) + items = await cur.fetchall() + + done = 0 + errors = 0 + mode = job.get("mode", "copy") + + # TVDB-IDs sammeln fuer spaetere Verknuepfung + tvdb_links = {} # series_name -> tvdb_id + + total_items = len(items) + for item in items: + # WS-Broadcast VOR dem Kopieren + await self._broadcast_import( + job_id, "importing", + processed=done + errors, total=total_items, + current_file=os.path.basename( + item.get("source_file", "") + ), + bytes_total=item.get("source_size", 0), + ) + + ok = await self._process_item( + item, mode, job_id, done + errors, total_items + ) + if ok: + done += 1 + # TVDB-Link merken + if item.get("tvdb_series_id") and item.get("tvdb_series_name"): + tvdb_links[item["tvdb_series_name"]] = item["tvdb_series_id"] + else: + errors += 1 + + # Progress updaten + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET processed_files = %s " + "WHERE id = %s", (done + errors, job_id) + ) + + # WS-Broadcast NACH dem Kopieren + await self._broadcast_import( + job_id, "importing", + processed=done + errors, total=total_items, + ) + + # Job abschliessen + final_status = "done" if errors == 0 else "error" + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET status = %s " + "WHERE id = %s", (final_status, job_id) + ) + + # TVDB-Zuordnungen in library_series uebernehmen + linked_series = 0 + if tvdb_links: + linked_series = await self._link_tvdb_to_series(tvdb_links) + + # Finaler WS-Broadcast + await self._broadcast_import( + job_id, final_status, + processed=done + errors, total=total_items, + ) + + # Auto-Scan: Ziel-Bibliothek aktualisieren + target_lib_id = job.get("target_library_id") + if target_lib_id and self.library: + logging.info( + f"Import abgeschlossen - starte Auto-Scan " + f"fuer Bibliothek {target_lib_id}" + ) + await self.library.scan_single_path(target_lib_id) + + return { + "done": done, + "errors": errors, + "tvdb_linked": linked_series, + } + + except Exception as e: + logging.error(f"Import ausfuehren fehlgeschlagen: {e}") + return {"error": str(e)} + + async def _link_tvdb_to_series(self, tvdb_links: dict) -> int: + """Verknuepft importierte Serien mit TVDB in library_series""" + if not self._db_pool or not self.tvdb: + return 0 + + linked = 0 + for series_name, tvdb_id in tvdb_links.items(): + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + # Serie in library_series finden (nach Namen) + await cur.execute( + "SELECT id, tvdb_id FROM library_series " + "WHERE (folder_name = %s OR title = %s) " + "AND tvdb_id IS NULL " + "LIMIT 1", + (series_name, series_name) + ) + row = await cur.fetchone() + if row: + series_id = row[0] + # TVDB-Daten laden und verknuepfen + result = await self.tvdb.match_and_update_series( + series_id, int(tvdb_id), self.library + ) + if not result.get("error"): + linked += 1 + logging.info( + f"Import: TVDB verknuepft - " + f"{series_name} -> {tvdb_id}" + ) + except Exception as e: + logging.warning( + f"TVDB-Link fehlgeschlagen fuer {series_name}: {e}" + ) + + return linked + + async def _process_item(self, item: dict, mode: str, + job_id: int = 0, + item_processed: int = 0, + item_total: int = 0) -> bool: + """Einzelnes Item importieren (kopieren/verschieben + Metadaten)""" + src = item["source_file"] + target_dir = item["target_path"] + target_file = item["target_filename"] + + if not target_dir or not target_file: + await self._update_item_status(item["id"], "error") + return False + + target = os.path.join(target_dir, target_file) + src_size = item.get("source_size", 0) or os.path.getsize(src) + + try: + # Zielordner erstellen + os.makedirs(target_dir, exist_ok=True) + + # Alte Dateien fuer dieselbe Episode aufraeumen + # (z.B. "S01E03 - Unbekannt.mkv" wenn jetzt "S01E03 - Willkür.mkv" kommt) + season = item.get("detected_season") + episode = item.get("detected_episode") + if season is not None and episode is not None and os.path.isdir(target_dir): + ep_pattern = f"S{season:02d}E{episode:02d}" + for existing in os.listdir(target_dir): + existing_path = os.path.join(target_dir, existing) + if (existing != target_file + and ep_pattern in existing + and os.path.isfile(existing_path)): + logging.info( + f"Import: Alte Episode-Datei entfernt: {existing}" + ) + os.remove(existing_path) + # Auch aus library_videos loeschen + if self._db_pool: + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM library_videos " + "WHERE file_path = %s", + (existing_path,) + ) + except Exception: + pass + + # Fortschritt-Tracking in DB setzen + if job_id and self._db_pool: + await self._update_file_progress( + job_id, target_file, 0, src_size + ) + + if mode == "move": + shutil.move(src, target) + # Bei Move sofort fertig + if job_id and self._db_pool: + await self._update_file_progress( + job_id, target_file, src_size, src_size + ) + else: + # Kopieren mit Fortschritt + await self._copy_with_progress( + src, target, job_id, target_file, src_size, + item_processed, item_total + ) + + logging.info( + f"Import: {os.path.basename(src)} -> {target}" + ) + + # Metadaten in Datei einbetten (falls TVDB-Infos vorhanden) + if item.get("tvdb_series_name") or item.get("detected_series"): + # WS-Broadcast: Metadaten-Phase anzeigen + await self._broadcast_import( + job_id, "embedding", + processed=item_processed, total=item_total, + current_file=target_file, + ) + await self._embed_metadata(target, item) + + await self._update_item_status(item["id"], "done") + return True + + except Exception as e: + logging.error(f"Import fehlgeschlagen: {src}: {e}") + await self._update_item_status(item["id"], "error") + return False + + async def _embed_metadata(self, file_path: str, item: dict) -> bool: + """Bettet Metadaten mit ffmpeg in die Datei ein""" + import asyncio + import tempfile + + series_name = item.get("tvdb_series_name") or item.get("detected_series") or "" + season = item.get("detected_season") or 0 + episode = item.get("detected_episode") or 0 + episode_title = item.get("tvdb_episode_title") or "" + + if not series_name: + return False + + # Temporaere Ausgabedatei + base, ext = os.path.splitext(file_path) + temp_file = f"{base}_temp{ext}" + + # ffmpeg Metadaten-Befehl + cmd = [ + "ffmpeg", "-y", "-i", file_path, + "-map", "0", + "-c", "copy", + "-metadata", f"title={episode_title}" if episode_title else f"S{season:02d}E{episode:02d}", + "-metadata", f"show={series_name}", + "-metadata", f"season_number={season}", + "-metadata", f"episode_sort={episode}", + "-metadata", f"episode_id=S{season:02d}E{episode:02d}", + ] + + # Fuer MKV zusaetzliche Tags + if file_path.lower().endswith(".mkv"): + cmd.extend([ + "-metadata:s:v:0", f"title={series_name} - S{season:02d}E{episode:02d}", + ]) + + cmd.append(temp_file) + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await asyncio.wait_for( + process.communicate(), timeout=600 # 10 Min fuer grosse Dateien + ) + + if process.returncode == 0: + # Temporaere Datei ueber Original verschieben + os.replace(temp_file, file_path) + logging.info(f"Metadaten eingebettet: {os.path.basename(file_path)}") + return True + else: + logging.warning( + f"Metadaten einbetten fehlgeschlagen: " + f"{stderr.decode()[:200]}" + ) + # Temp-Datei loeschen falls vorhanden + if os.path.exists(temp_file): + os.remove(temp_file) + return False + + except asyncio.TimeoutError: + logging.warning(f"Metadaten einbetten Timeout: {file_path}") + if os.path.exists(temp_file): + os.remove(temp_file) + return False + except Exception as e: + logging.warning(f"Metadaten einbetten Fehler: {e}") + if os.path.exists(temp_file): + os.remove(temp_file) + return False + + async def _update_item_status(self, item_id: int, + status: str) -> None: + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_items SET status = %s " + "WHERE id = %s", (status, item_id) + ) + except Exception: + pass + + async def _update_file_progress(self, job_id: int, filename: str, + bytes_done: int, bytes_total: int) -> None: + """Aktualisiert Byte-Fortschritt fuer aktuelle Datei""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET " + "current_file_name = %s, " + "current_file_bytes = %s, " + "current_file_total = %s " + "WHERE id = %s", + (filename, bytes_done, bytes_total, job_id) + ) + except Exception: + pass + + async def _copy_with_progress(self, src: str, dst: str, + job_id: int, filename: str, + total_size: int, + item_processed: int = 0, + item_total: int = 0) -> None: + """Kopiert Datei mit Fortschritts-Updates in DB + WebSocket""" + chunk_size = 64 * 1024 * 1024 # 64 MB Chunks + bytes_copied = 0 + last_update = 0 + + loop = asyncio.get_event_loop() + + with open(src, 'rb') as fsrc, open(dst, 'wb') as fdst: + while True: + # Chunk lesen (in Thread um nicht zu blockieren) + chunk = await loop.run_in_executor( + None, fsrc.read, chunk_size + ) + if not chunk: + break + + # Chunk schreiben + await loop.run_in_executor(None, fdst.write, chunk) + bytes_copied += len(chunk) + + # Progress alle 50 MB updaten (DB + WebSocket) + if bytes_copied - last_update >= 50 * 1024 * 1024: + await self._update_file_progress( + job_id, filename, bytes_copied, total_size + ) + await self._broadcast_import( + job_id, "importing", + processed=item_processed, + total=item_total, + current_file=filename, + bytes_done=bytes_copied, + bytes_total=total_size, + ) + last_update = bytes_copied + + # Finales Update + await self._update_file_progress( + job_id, filename, total_size, total_size + ) + + # Metadaten kopieren (Zeitstempel etc.) + shutil.copystat(src, dst) + + async def resolve_conflict(self, item_id: int, + action: str) -> bool: + """Konflikt loesen: overwrite, skip, rename""" + if not self._db_pool or action not in ('overwrite', 'skip', 'rename'): + return False + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + if action == 'skip': + await cur.execute( + "UPDATE import_items SET status = 'skipped', " + "user_action = 'skip' WHERE id = %s", + (item_id,) + ) + elif action == 'rename': + # Dateiname mit Suffix versehen + await cur.execute( + "SELECT target_filename FROM import_items " + "WHERE id = %s", (item_id,) + ) + row = await cur.fetchone() + if row and row[0]: + name, ext = os.path.splitext(row[0]) + new_name = f"{name}_neu{ext}" + await cur.execute( + "UPDATE import_items SET " + "target_filename = %s, " + "status = 'matched', " + "user_action = 'rename' " + "WHERE id = %s", + (new_name, item_id) + ) + else: # overwrite + await cur.execute( + "UPDATE import_items SET user_action = 'overwrite' " + "WHERE id = %s", (item_id,) + ) + return True + except Exception as e: + logging.error(f"Konflikt loesen fehlgeschlagen: {e}") + return False + + async def resolve_all_conflicts(self, job_id: int, action: str) -> dict: + """Loest ALLE Konflikte eines Jobs auf einmal. + + action: 'overwrite' oder 'skip' + """ + if not self._db_pool or action not in ('overwrite', 'skip'): + return {"error": "Ungueltige Aktion"} + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + if action == 'skip': + await cur.execute( + "UPDATE import_items SET status = 'skipped', " + "user_action = 'skip' " + "WHERE import_job_id = %s AND status = 'conflict' " + "AND user_action IS NULL", + (job_id,) + ) + else: # overwrite + await cur.execute( + "UPDATE import_items SET user_action = 'overwrite' " + "WHERE import_job_id = %s AND status = 'conflict' " + "AND user_action IS NULL", + (job_id,) + ) + updated = cur.rowcount + logging.info(f"Alle Konflikte geloest: {updated} Items -> {action}") + return {"ok": True, "updated": updated} + except Exception as e: + logging.error(f"Alle Konflikte loesen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def set_overwrite_mode(self, job_id: int, overwrite: bool) -> dict: + """Setzt den Ueberschreiben-Modus fuer den ganzen Job. + + Wenn overwrite=True, werden alle Konflikte automatisch ueberschrieben. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET overwrite_all = %s " + "WHERE id = %s", (1 if overwrite else 0, job_id) + ) + # Wenn aktiviert: Alle bestehenden Konflikte auf overwrite setzen + if overwrite: + await cur.execute( + "UPDATE import_items SET user_action = 'overwrite' " + "WHERE import_job_id = %s AND status = 'conflict' " + "AND user_action IS NULL", + (job_id,) + ) + return {"ok": True, "overwrite_all": overwrite} + except Exception as e: + logging.error(f"Overwrite-Modus setzen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def update_item(self, item_id: int, **kwargs) -> bool: + """Manuelle Korrektur eines Items""" + if not self._db_pool: + return False + allowed = { + 'detected_series', 'detected_season', 'detected_episode', + 'tvdb_series_id', 'tvdb_series_name', 'tvdb_episode_title', + 'target_path', 'target_filename', 'status' + } + updates = [] + params = [] + for k, v in kwargs.items(): + if k in allowed: + updates.append(f"{k} = %s") + params.append(v) + if not updates: + return False + params.append(item_id) + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + f"UPDATE import_items SET {', '.join(updates)} " + f"WHERE id = %s", params + ) + return True + except Exception as e: + logging.error(f"Import-Item aktualisieren fehlgeschlagen: {e}") + return False + + async def reassign_item(self, item_id: int, + series_name: str, + season: int, episode: int, + tvdb_id: int = None) -> dict: + """Weist einem pending-Item eine Serie/Staffel/Episode zu. + + Berechnet automatisch den Zielpfad und holt ggf. TVDB-Episodentitel. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Item laden + await cur.execute( + "SELECT i.*, j.target_library_id, j.naming_pattern, " + "j.season_pattern FROM import_items i " + "JOIN import_jobs j ON j.id = i.import_job_id " + "WHERE i.id = %s", (item_id,) + ) + item = await cur.fetchone() + if not item: + return {"error": "Item nicht gefunden"} + + # Library-Pfad laden + await cur.execute( + "SELECT * FROM library_paths WHERE id = %s", + (item["target_library_id"],) + ) + lib_path = await cur.fetchone() + if not lib_path: + return {"error": "Ziel-Library nicht gefunden"} + + # TVDB-Name und Episodentitel holen + tvdb_name = series_name + tvdb_ep_title = "" + if tvdb_id and self.tvdb.is_configured: + # Serien-Info von TVDB holen + try: + info = await self.tvdb.get_series_info(tvdb_id) + if info and info.get("name"): + tvdb_name = info["name"] + except Exception: + pass + # Episodentitel holen + tvdb_ep_title = await self._get_episode_title( + tvdb_id, season, episode + ) + + # Zielpfad berechnen + ext = os.path.splitext(item["source_file"])[1].lstrip(".") + pattern = item.get("naming_pattern") or self._naming_pattern + season_pattern = item.get("season_pattern") or self._season_pattern + target_dir, target_file = self._build_target( + tvdb_name or series_name, + season, episode, + tvdb_ep_title or "", + ext, + lib_path["path"], + pattern, season_pattern + ) + + # In DB aktualisieren + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE import_items SET + detected_series = %s, + detected_season = %s, + detected_episode = %s, + tvdb_series_id = %s, + tvdb_series_name = %s, + tvdb_episode_title = %s, + target_path = %s, + target_filename = %s, + status = 'matched' + WHERE id = %s + """, ( + series_name, season, episode, + tvdb_id, tvdb_name, tvdb_ep_title, + target_dir, target_file, item_id, + )) + + return { + "ok": True, + "target_dir": target_dir, + "target_file": target_file, + "tvdb_name": tvdb_name, + "tvdb_ep_title": tvdb_ep_title, + } + + except Exception as e: + logging.error(f"Import-Item zuordnen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def reassign_series(self, job_id: int, detected_series: str, + tvdb_id: int = None, + series_name: str = None) -> dict: + """Ordnet alle Items mit gleichem detected_series einer Serie zu. + + Setzt TVDB-ID + Episodentitel + Zielpfade fuer alle passenden Items. + """ + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Job + Library-Pfad laden + await cur.execute( + "SELECT j.*, lp.path as lib_path " + "FROM import_jobs j " + "JOIN library_paths lp ON lp.id = j.target_library_id " + "WHERE j.id = %s", (job_id,) + ) + job = await cur.fetchone() + if not job: + return {"error": "Job nicht gefunden"} + + # Alle Items mit gleichem detected_series + await cur.execute( + "SELECT * FROM import_items " + "WHERE import_job_id = %s " + "AND LOWER(detected_series) = LOWER(%s) " + "AND status IN ('pending', 'matched') " + "AND detected_season IS NOT NULL " + "AND detected_episode IS NOT NULL", + (job_id, detected_series) + ) + items = await cur.fetchall() + + if not items: + return {"error": f"Keine Items fuer '{detected_series}'"} + + # TVDB-Name holen + tvdb_name = series_name or detected_series + if tvdb_id and self.tvdb.is_configured: + try: + info = await self.tvdb.get_series_info(tvdb_id) + if info and info.get("name"): + tvdb_name = info["name"] + except Exception: + pass + + # Jedes Item aktualisieren (eine Connection fuer alle) + updated = 0 + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + for item in items: + season = item["detected_season"] + episode = item["detected_episode"] + + # Episodentitel holen + tvdb_ep_title = "" + if tvdb_id and season and episode: + tvdb_ep_title = await self._get_episode_title( + int(tvdb_id), season, episode + ) + + # Zielpfad berechnen + ext = os.path.splitext( + item["source_file"] + )[1].lstrip(".") + pattern = (job.get("naming_pattern") + or self._naming_pattern) + season_pat = (job.get("season_pattern") + or self._season_pattern) + target_dir, target_file = self._build_target( + tvdb_name, season, episode, + tvdb_ep_title, ext, + job["lib_path"], + pattern, season_pat + ) + + await cur.execute(""" + UPDATE import_items SET + tvdb_series_id = %s, + tvdb_series_name = %s, + tvdb_episode_title = %s, + target_path = %s, + target_filename = %s, + status = 'matched', + conflict_reason = NULL + WHERE id = %s + """, ( + tvdb_id, tvdb_name, tvdb_ep_title, + target_dir, target_file, item["id"], + )) + updated += 1 + + logging.info( + f"Serien-Zuordnung: {updated} Items fuer " + f"'{detected_series}' -> '{tvdb_name}'" + ) + return {"ok": True, "updated": updated, "tvdb_name": tvdb_name} + + except Exception as e: + logging.error(f"Serien-Zuordnung fehlgeschlagen: {e}") + return {"error": str(e)} + + async def skip_series(self, job_id: int, + detected_series: str) -> dict: + """Ueberspringt alle Items mit gleichem detected_series.""" + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_items SET status = 'skipped', " + "conflict_reason = 'Serie uebersprungen' " + "WHERE import_job_id = %s " + "AND LOWER(detected_series) = LOWER(%s) " + "AND status IN ('pending', 'matched')", + (job_id, detected_series) + ) + skipped = cur.rowcount + logging.info( + f"Serie uebersprungen: {skipped} Items " + f"fuer '{detected_series}'" + ) + return {"ok": True, "skipped": skipped} + except Exception as e: + logging.error(f"Serie ueberspringen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def skip_item(self, item_id: int) -> bool: + """Markiert ein Item als uebersprungen""" + if not self._db_pool: + return False + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_items SET status = 'skipped', " + "conflict_reason = 'Manuell uebersprungen' " + "WHERE id = %s", (item_id,) + ) + return True + except Exception: + return False + + async def get_all_jobs(self) -> list: + """Liste aller Import-Jobs (neueste zuerst)""" + if not self._db_pool: + return [] + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT id, source_path, status, total_files, " + "processed_files, created_at FROM import_jobs " + "ORDER BY id DESC LIMIT 20" + ) + jobs = await cur.fetchall() + return [self._serialize(j) for j in jobs] + except Exception as e: + logging.error(f"Import-Jobs laden fehlgeschlagen: {e}") + return [] + + async def get_job_status(self, job_id: int) -> dict: + """Status eines Import-Jobs mit allen Items""" + if not self._db_pool: + return {"error": "Keine DB-Verbindung"} + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM import_jobs WHERE id = %s", (job_id,) + ) + job = await cur.fetchone() + if not job: + return {"error": "Job nicht gefunden"} + + await cur.execute( + "SELECT * FROM import_items " + "WHERE import_job_id = %s ORDER BY source_file", + (job_id,) + ) + items = await cur.fetchall() + + # Bei abgeschlossenen Jobs: Importierte Serien-Ordner sammeln + imported_series = [] + if job.get("status") in ("done", "error"): + series_folders = set() + for item in items: + if item.get("status") == "done" and item.get("target_path"): + series_folders.add(item["target_path"]) + imported_series = list(series_folders) + + return { + "job": self._serialize(job), + "items": [self._serialize(i) for i in items], + "imported_series": imported_series, + } + except Exception as e: + return {"error": str(e)} + + @staticmethod + def _serialize(row: dict) -> dict: + """Dict JSON-kompatibel machen""" + result = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + result[k] = str(v) + else: + result[k] = v + return result + + @staticmethod + def _fmt_size(b: int) -> str: + """Bytes menschenlesbar""" + for u in ("B", "KiB", "MiB", "GiB"): + if b < 1024: + return f"{b:.1f} {u}" + b /= 1024 + return f"{b:.1f} TiB" diff --git a/video-konverter/app/services/library.py b/video-konverter/app/services/library.py new file mode 100644 index 0000000..901d450 --- /dev/null +++ b/video-konverter/app/services/library.py @@ -0,0 +1,2139 @@ +"""Video-Bibliothek Service: Scanning, DB-Verwaltung, Filter, Duplikat-Finder""" +import asyncio +import json +import logging +import os +import re +from pathlib import Path +from typing import Optional, TYPE_CHECKING + +import aiomysql + +from app.config import Config +from app.services.probe import ProbeService + +if TYPE_CHECKING: + from app.routes.ws import WebSocketManager + + +# Regex fuer Serien-Erkennung +# S01E02, s01e02, S1E2 +RE_SXXEXX = re.compile(r'[Ss](\d{1,2})[Ee](\d{1,3})') +# Doppel-Episoden: S01E01E02, S01E01-E02, S01E01+E02 +RE_SXXEXX_MULTI = re.compile( + r'[Ss](\d{1,2})[Ee](\d{1,3})(?:[-+]?[Ee](\d{1,3}))?' +) +# 1x02, 01x02 +RE_XXxXX = re.compile(r'(\d{1,2})x(\d{2,3})') +# Doppel-Episoden: 1x01-02, 1x01+02 +RE_XXxXX_MULTI = re.compile(r'(\d{1,2})x(\d{2,3})(?:[-+](\d{2,3}))?') +# Staffel/Season Ordner: "Season 01", "Staffel 1", "S01" +RE_SEASON_DIR = re.compile( + r'^(?:Season|Staffel|S)\s*(\d{1,2})$', re.IGNORECASE +) +# Fuehrende Nummer: "01 - Pilot.mkv", "01.mkv" +RE_LEADING_NUM = re.compile(r'^(\d{1,3})(?:\s*[-._]\s*|\.)(.+)') + +# Video-Extensions fuer Bibliothek +VIDEO_EXTENSIONS = { + '.mkv', '.mp4', '.avi', '.wmv', '.vob', '.ts', + '.m4v', '.flv', '.mov', '.webm', '.mpg', '.mpeg', +} + + +class LibraryService: + """Verwaltet die Video-Bibliothek mit MariaDB-Backend""" + + def __init__(self, config: Config, ws_manager: 'WebSocketManager'): + self.config = config + self.ws_manager = ws_manager + self._db_pool: Optional[aiomysql.Pool] = None + self._scanning: bool = False + self._scan_progress: dict = {"status": "idle", "current": "", "total": 0, "done": 0} + + @property + def library_config(self) -> dict: + return self.config.settings.get("library", {}) + + async def start(self) -> None: + """Initialisiert DB-Tabellen""" + await self._init_db() + logging.info("LibraryService gestartet") + + async def stop(self) -> None: + """Schliesst DB-Pool""" + if self._db_pool is not None: + self._db_pool.close() + await self._db_pool.wait_closed() + + # === DB-Pool (geteilt mit QueueService ueber gleiche Config) === + + async def _get_pool(self) -> Optional[aiomysql.Pool]: + if self._db_pool is not None: + return self._db_pool + db_cfg = self.config.settings.get("database", {}) + try: + self._db_pool = await aiomysql.create_pool( + host=db_cfg.get("host", "192.168.155.11"), + port=db_cfg.get("port", 3306), + user=db_cfg.get("user", "video"), + password=db_cfg.get("password", "8715"), + db=db_cfg.get("database", "video_converter"), + charset="utf8mb4", + autocommit=True, + minsize=1, + maxsize=5, + connect_timeout=10, + ) + return self._db_pool + except Exception as e: + logging.error(f"LibraryService DB-Verbindung fehlgeschlagen: {e}") + return None + + async def _init_db(self) -> None: + """Erstellt Bibliotheks-Tabellen""" + pool = await self._get_pool() + if not pool: + return + + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + # Scan-Pfade + await cur.execute(""" + CREATE TABLE IF NOT EXISTS library_paths ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(128) NOT NULL, + path VARCHAR(1024) NOT NULL, + media_type ENUM('series','movie') NOT NULL, + enabled TINYINT DEFAULT 1, + last_scan TIMESTAMP NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Migration: last_video_count Spalte + try: + await cur.execute( + "ALTER TABLE library_paths " + "ADD COLUMN last_video_count INT DEFAULT 0" + ) + except Exception: + pass # Spalte existiert bereits + + # Serien + await cur.execute(""" + CREATE TABLE IF NOT EXISTS library_series ( + id INT AUTO_INCREMENT PRIMARY KEY, + library_path_id INT NOT NULL, + folder_name VARCHAR(512) NOT NULL, + folder_path VARCHAR(1024) NOT NULL, + tvdb_id INT NULL, + title VARCHAR(512) NULL, + overview TEXT NULL, + first_aired DATE NULL, + poster_url VARCHAR(512) NULL, + status VARCHAR(64) NULL, + total_seasons INT DEFAULT 0, + total_episodes INT DEFAULT 0, + local_episodes INT DEFAULT 0, + missing_episodes INT DEFAULT 0, + redundant_files INT DEFAULT 0, + last_updated TIMESTAMP NULL, + FOREIGN KEY (library_path_id) + REFERENCES library_paths(id) ON DELETE CASCADE, + INDEX idx_tvdb_id (tvdb_id), + INDEX idx_library (library_path_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Videos + await cur.execute(""" + CREATE TABLE IF NOT EXISTS library_videos ( + id INT AUTO_INCREMENT PRIMARY KEY, + library_path_id INT NOT NULL, + series_id INT NULL, + file_path VARCHAR(1024) NOT NULL, + file_name VARCHAR(512) NOT NULL, + file_size BIGINT NOT NULL, + season_number INT NULL, + episode_number INT NULL, + episode_title VARCHAR(512) NULL, + video_codec VARCHAR(64), + width INT, + height INT, + resolution VARCHAR(16), + frame_rate DOUBLE, + video_bitrate INT, + is_10bit TINYINT DEFAULT 0, + hdr VARCHAR(32) NULL, + audio_tracks JSON, + subtitle_tracks JSON, + container VARCHAR(16), + duration_sec DOUBLE, + scanned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (library_path_id) + REFERENCES library_paths(id) ON DELETE CASCADE, + FOREIGN KEY (series_id) + REFERENCES library_series(id) ON DELETE SET NULL, + INDEX idx_series (series_id), + INDEX idx_library (library_path_id), + INDEX idx_codec (video_codec), + INDEX idx_resolution (width, height), + UNIQUE INDEX idx_filepath (file_path(768)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Filme (analog zu Serien, aber ohne Staffeln/Episoden) + await cur.execute(""" + CREATE TABLE IF NOT EXISTS library_movies ( + id INT AUTO_INCREMENT PRIMARY KEY, + library_path_id INT NOT NULL, + folder_name VARCHAR(512) NOT NULL, + folder_path VARCHAR(1024) NOT NULL, + tvdb_id INT NULL, + title VARCHAR(512) NULL, + overview TEXT NULL, + year INT NULL, + poster_url VARCHAR(512) NULL, + genres VARCHAR(512) NULL, + runtime INT NULL, + status VARCHAR(64) NULL, + video_count INT DEFAULT 0, + total_size BIGINT DEFAULT 0, + last_updated TIMESTAMP NULL, + FOREIGN KEY (library_path_id) + REFERENCES library_paths(id) ON DELETE CASCADE, + INDEX idx_tvdb_id (tvdb_id), + INDEX idx_library (library_path_id), + UNIQUE INDEX idx_folder (folder_path(768)) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # TVDB Episoden-Cache + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tvdb_episode_cache ( + id INT AUTO_INCREMENT PRIMARY KEY, + series_tvdb_id INT NOT NULL, + season_number INT NOT NULL, + episode_number INT NOT NULL, + episode_name VARCHAR(512), + aired DATE NULL, + runtime INT NULL, + cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_series (series_tvdb_id), + UNIQUE INDEX idx_episode ( + series_tvdb_id, season_number, episode_number + ) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # movie_id Spalte in library_videos (falls noch nicht vorhanden) + try: + await cur.execute( + "ALTER TABLE library_videos " + "ADD COLUMN movie_id INT NULL, " + "ADD INDEX idx_movie (movie_id), " + "ADD FOREIGN KEY (movie_id) " + "REFERENCES library_movies(id) ON DELETE SET NULL" + ) + except Exception: + pass # Spalte existiert bereits + + # episode_end Spalte fuer Doppel-Episoden (E01E02 in einer Datei) + try: + await cur.execute( + "ALTER TABLE library_videos " + "ADD COLUMN episode_end INT NULL AFTER episode_number" + ) + logging.info("episode_end Spalte hinzugefuegt") + except Exception: + pass # Spalte existiert bereits + + # redundant_files Spalte fuer Duplikat-Erkennung + try: + await cur.execute( + "ALTER TABLE library_series " + "ADD COLUMN redundant_files INT DEFAULT 0" + ) + logging.info("redundant_files Spalte hinzugefuegt") + except Exception: + pass # Spalte existiert bereits + + logging.info("Bibliotheks-Tabellen initialisiert") + except Exception as e: + logging.error(f"Bibliotheks-Tabellen erstellen fehlgeschlagen: {e}") + + # === Scan-Pfade verwalten === + + async def get_paths(self) -> list[dict]: + """Alle Scan-Pfade laden""" + pool = await self._get_pool() + if not pool: + return [] + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM library_paths ORDER BY name" + ) + rows = await cur.fetchall() + return [self._serialize_row(r) for r in rows] + except Exception as e: + logging.error(f"Scan-Pfade laden fehlgeschlagen: {e}") + return [] + + async def add_path(self, name: str, path: str, + media_type: str) -> Optional[int]: + """Neuen Scan-Pfad hinzufuegen""" + if media_type not in ('series', 'movie'): + return None + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO library_paths (name, path, media_type) " + "VALUES (%s, %s, %s)", + (name, path, media_type) + ) + logging.info(f"Scan-Pfad hinzugefuegt: {name} ({path})") + return cur.lastrowid + except Exception as e: + logging.error(f"Scan-Pfad hinzufuegen fehlgeschlagen: {e}") + return None + + async def remove_path(self, path_id: int) -> bool: + """Scan-Pfad und alle zugehoerigen Daten loeschen""" + 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 library_paths WHERE id = %s", (path_id,) + ) + logging.info(f"Scan-Pfad entfernt: ID {path_id}") + return cur.rowcount > 0 + except Exception as e: + logging.error(f"Scan-Pfad entfernen fehlgeschlagen: {e}") + return False + + async def update_path(self, path_id: int, name: str = None, + path: str = None, media_type: str = None, + enabled: bool = None) -> bool: + """Scan-Pfad aktualisieren""" + pool = await self._get_pool() + if not pool: + return False + updates = [] + params = [] + if name is not None: + updates.append("name = %s") + params.append(name) + if path is not None: + updates.append("path = %s") + params.append(path) + if media_type is not None and media_type in ('series', 'movie'): + updates.append("media_type = %s") + params.append(media_type) + if enabled is not None: + updates.append("enabled = %s") + params.append(1 if enabled else 0) + if not updates: + return False + params.append(path_id) + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + f"UPDATE library_paths SET {', '.join(updates)} " + f"WHERE id = %s", params + ) + return cur.rowcount > 0 + except Exception as e: + logging.error(f"Scan-Pfad aktualisieren fehlgeschlagen: {e}") + return False + + async def unlink_tvdb(self, series_id: int) -> bool: + """TVDB-Zuordnung einer Serie loesen""" + 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( + "UPDATE library_series SET " + "tvdb_id = NULL, poster_url = NULL, " + "overview = NULL, first_aired = NULL, " + "status = NULL, total_seasons = 0, " + "total_episodes = 0, missing_episodes = 0, " + "last_updated = NOW() " + "WHERE id = %s", (series_id,) + ) + logging.info( + f"TVDB-Zuordnung geloest: Serie {series_id}" + ) + return cur.rowcount > 0 + except Exception as e: + logging.error(f"TVDB loesen fehlgeschlagen: {e}") + return False + + async def delete_series(self, series_id: int, + delete_files: bool = False) -> dict: + """Serie aus DB loeschen. Optional auch Dateien + Ordner.""" + pool = await self._get_pool() + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + # Ordner-Pfad holen (vor dem Loeschen aus DB) + folder_path = None + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT folder_path FROM library_series " + "WHERE id = %s", (series_id,) + ) + row = await cur.fetchone() + if not row: + return {"error": "Serie nicht gefunden"} + folder_path = row[0] + + # Videos aus DB loeschen + await cur.execute( + "DELETE FROM library_videos WHERE series_id = %s", + (series_id,) + ) + vids = cur.rowcount + # Serie aus DB loeschen + await cur.execute( + "DELETE FROM library_series WHERE id = %s", + (series_id,) + ) + + result = {"success": True, "deleted_videos_db": vids} + + # Dateisystem loeschen wenn gewuenscht + if delete_files and folder_path and os.path.isdir(folder_path): + import shutil + import stat + def _rm_error(func, path, exc_info): + try: + os.chmod(path, stat.S_IRWXU) + func(path) + except Exception: + pass + try: + shutil.rmtree(folder_path, onerror=_rm_error) + result["deleted_folder"] = folder_path + logging.info( + f"Serie {series_id} komplett geloescht " + f"inkl. Ordner: {folder_path}" + ) + except Exception as e: + result["folder_error"] = str(e) + logging.error( + f"Ordner loeschen fehlgeschlagen: " + f"{folder_path}: {e}" + ) + else: + logging.info( + f"Serie {series_id} aus DB geloescht ({vids} Videos)" + ) + + return result + + except Exception as e: + logging.error(f"Serie loeschen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def delete_video(self, video_id: int, + delete_file: bool = False) -> dict: + """Einzelnes Video loeschen (DB + optional Datei)""" + pool = await self._get_pool() + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT file_path FROM library_videos WHERE id = %s", + (video_id,) + ) + row = await cur.fetchone() + if not row: + return {"error": "Video nicht gefunden"} + + file_path = row[0] + + # Aus DB loeschen + await cur.execute( + "DELETE FROM library_videos WHERE id = %s", + (video_id,) + ) + + result = {"success": True, "file_path": file_path} + + # Datei loeschen wenn gewuenscht + if delete_file and file_path and os.path.isfile(file_path): + try: + os.remove(file_path) + result["file_deleted"] = True + logging.info(f"Video geloescht: {file_path}") + except Exception as e: + result["file_error"] = str(e) + logging.error( + f"Video-Datei loeschen fehlgeschlagen: " + f"{file_path}: {e}" + ) + elif delete_file: + result["file_deleted"] = False + result["file_error"] = "Datei nicht gefunden" + + return result + + except Exception as e: + logging.error(f"Video loeschen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def get_movies(self, filters: dict = None, + page: int = 1, limit: int = 50) -> dict: + """Nur Filme (keine Serien) abfragen""" + filters = filters or {} + filters["media_type"] = "movie" + # series_id muss NULL sein (kein Serien-Video) + return await self.get_videos(filters, page, limit) + + # === Scanning === + + async def scan_all(self) -> dict: + """Alle aktivierten Pfade scannen""" + if self._scanning: + return {"error": "Scan laeuft bereits"} + paths = await self.get_paths() + enabled = [p for p in paths if p.get("enabled")] + if not enabled: + return {"error": "Keine aktiven Scan-Pfade konfiguriert"} + + self._scanning = True + total_videos = 0 + try: + for lib_path in enabled: + count = await self._scan_path(lib_path) + total_videos += count + return {"success": True, "videos_found": total_videos} + finally: + self._scanning = False + self._scan_progress = { + "status": "idle", "current": "", "total": 0, "done": 0 + } + # Scan-Ende per WebSocket melden + if self.ws_manager: + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + async def scan_single_path(self, path_id: int) -> dict: + """Einzelnen Pfad scannen""" + if self._scanning: + return {"error": "Scan laeuft bereits"} + + pool = await self._get_pool() + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM library_paths WHERE id = %s", + (path_id,) + ) + lib_path = await cur.fetchone() + if not lib_path: + return {"error": "Pfad nicht gefunden"} + except Exception as e: + return {"error": str(e)} + + self._scanning = True + try: + lib_path = self._serialize_row(lib_path) + count = await self._scan_path(lib_path) + return {"success": True, "videos_found": count} + finally: + self._scanning = False + self._scan_progress = { + "status": "idle", "current": "", "total": 0, "done": 0 + } + # Scan-Ende per WebSocket melden + if self.ws_manager: + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + async def _scan_path(self, lib_path: dict) -> int: + """Scannt einen einzelnen Bibliotheks-Pfad""" + base_path = lib_path["path"] + path_id = lib_path["id"] + media_type = lib_path["media_type"] + + if not os.path.isdir(base_path): + logging.warning(f"Scan-Pfad nicht gefunden: {base_path}") + return 0 + + logging.info(f"Scanne Bibliothek: {lib_path['name']} ({base_path})") + + # Gecachte Dateianzahl als Startwert fuer Fortschritt + cached_count = lib_path.get("last_video_count", 0) or 0 + if cached_count > 0: + self._scan_progress["total"] = cached_count + + count = 0 + + if media_type == "series": + count = await self._scan_series_path(base_path, path_id) + else: + count = await self._scan_movie_path(base_path, path_id) + + # Verwaiste Eintraege bereinigen (versteckte Ordner, geloeschte Serien) + await self._cleanup_stale_entries(path_id) + + # last_scan + last_video_count aktualisieren + total_videos = self._scan_progress.get("done", count) + pool = await self._get_pool() + if pool: + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE library_paths " + "SET last_scan = NOW(), " + "last_video_count = %s " + "WHERE id = %s", + (total_videos, path_id) + ) + except Exception: + pass + + logging.info(f"Scan abgeschlossen: {lib_path['name']} " + f"({count} Videos)") + return count + + async def _scan_series_path(self, base_path: str, + path_id: int) -> int: + """Scannt Serien-Ordner-Struktur""" + count = 0 + try: + entries = sorted(os.listdir(base_path)) + except OSError as e: + logging.error(f"Ordner lesen fehlgeschlagen: {base_path}: {e}") + return 0 + + for entry in entries: + # Versteckte Ordner ueberspringen + if entry.startswith("."): + continue + series_path = os.path.join(base_path, entry) + if not os.path.isdir(series_path): + continue + + # Serie in DB anlegen/finden + series_id = await self._ensure_series(path_id, entry, series_path) + if not series_id: + continue + + # Videos in Serie suchen (rekursiv) + video_files = self._find_videos_recursive(series_path) + + # total nur erhoehen wenn neue Dateien dazukommen + new_total = self._scan_progress["done"] + len(video_files) + if new_total > self._scan_progress["total"]: + self._scan_progress["total"] = new_total + + self._scan_progress.update({ + "status": "scanning", + "current": entry, + }) + + # Broadcast Scan-Progress + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + for vf in video_files: + added = await self._add_video_to_db( + path_id, series_id, vf, series_path + ) + if added: + count += 1 + self._scan_progress["done"] += 1 + + # Haeufigere WS-Updates (alle 10 Videos) + if self._scan_progress["done"] % 10 == 0: + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + # Lokale Episoden-Zaehler aktualisieren + await self._update_series_counts(series_id) + + return count + + async def _cleanup_stale_entries(self, path_id: int) -> None: + """Entfernt verwaiste DB-Eintraege (versteckte Ordner, nicht mehr vorhandene Dateien)""" + pool = await self._get_pool() + if not pool: + return + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + # 1. Videos entfernen deren Dateien nicht mehr existieren + await cur.execute( + "SELECT id, file_path, series_id FROM library_videos " + "WHERE library_path_id = %s", + (path_id,) + ) + all_videos = await cur.fetchall() + missing_ids = [] + affected_series = set() + for vid_id, file_path, series_id in all_videos: + if not os.path.exists(file_path): + missing_ids.append(vid_id) + if series_id: + affected_series.add(series_id) + + if missing_ids: + # In Batches loeschen (max 100 pro Query) + for i in range(0, len(missing_ids), 100): + batch = missing_ids[i:i+100] + placeholders = ",".join(["%s"] * len(batch)) + await cur.execute( + f"DELETE FROM library_videos WHERE id IN ({placeholders})", + batch + ) + logging.info( + f"Bereinigung: {len(missing_ids)} nicht mehr " + f"vorhandene Video-Dateien entfernt" + ) + + # 2. Serien mit versteckten Ordnernamen entfernen + await cur.execute( + "DELETE FROM library_videos WHERE series_id IN " + "(SELECT id FROM library_series " + " WHERE library_path_id = %s AND folder_name LIKE '.%%')", + (path_id,) + ) + removed_vids = cur.rowcount + await cur.execute( + "DELETE FROM library_series " + "WHERE library_path_id = %s AND folder_name LIKE '.%%'", + (path_id,) + ) + removed_series = cur.rowcount + if removed_series > 0 or removed_vids > 0: + logging.info( + f"Bereinigung: {removed_series} versteckte Serien, " + f"{removed_vids} Videos entfernt" + ) + + # 3. Serien ohne Videos entfernen (leere Ordner) + await cur.execute( + "DELETE FROM library_series " + "WHERE library_path_id = %s AND local_episodes = 0 " + "AND tvdb_id IS NULL", + (path_id,) + ) + empty = cur.rowcount + if empty > 0: + logging.info( + f"Bereinigung: {empty} leere Serien entfernt" + ) + + # 4. Serien-Zaehler aktualisieren fuer betroffene Serien + for series_id in affected_series: + await self._update_series_counts(series_id) + + except Exception as e: + logging.warning(f"Bereinigung fehlgeschlagen: {e}") + + async def _scan_movie_path(self, base_path: str, + path_id: int) -> int: + """Scannt Film-Ordner rekursiv. + Jeder Ordner mit Video-Dateien = ein Film. + Ordner nur mit Unterordnern = Film-Reihe (wird durchlaufen). + """ + count = 0 + movie_folders = self._find_movie_folders(base_path) + logging.info(f"Film-Scan: {len(movie_folders)} Film-Ordner gefunden") + + self._scan_progress.update({ + "status": "scanning", + "current": os.path.basename(base_path), + }) + + for folder_path, direct_videos in movie_folders: + folder_name = os.path.basename(folder_path) + + # Film-Eintrag erstellen/finden + movie_id = await self._ensure_movie( + path_id, folder_name, folder_path + ) + if not movie_id: + logging.warning(f"Film-Eintrag fehlgeschlagen: {folder_name}") + continue + + # total nur erhoehen wenn neue Dateien dazukommen + new_total = self._scan_progress["done"] + len(direct_videos) + if new_total > self._scan_progress["total"]: + self._scan_progress["total"] = new_total + + self._scan_progress.update({ + "status": "scanning", + "current": folder_name, + }) + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + for vf in direct_videos: + added = await self._add_video_to_db( + path_id, None, vf, base_path, movie_id=movie_id + ) + if added: + count += 1 + self._scan_progress["done"] += 1 + + # Haeufigere WS-Updates (alle 10 Videos) + if self._scan_progress["done"] % 10 == 0: + await self.ws_manager.broadcast({ + "data_library_scan": self._scan_progress + }) + + await self._update_movie_counts(movie_id) + + # Einzelne Video-Dateien direkt im Root + try: + for entry in os.scandir(base_path): + if entry.name.startswith(".") or not entry.is_file(): + continue + ext = os.path.splitext(entry.name)[1].lower() + if ext not in VIDEO_EXTENSIONS: + continue + name_no_ext = os.path.splitext(entry.name)[0] + movie_id = await self._ensure_movie( + path_id, name_no_ext, entry.path, is_file=True + ) + if movie_id: + added = await self._add_video_to_db( + path_id, None, entry.path, base_path, + movie_id=movie_id + ) + if added: + count += 1 + await self._update_movie_counts(movie_id) + except OSError: + pass + + # Verwaiste Film-Eintraege bereinigen + await self._cleanup_stale_movies(path_id) + + return count + + def _find_movie_folders(self, base_path: str) -> list[tuple[str, list[str]]]: + """Findet alle Ordner die Video-Dateien enthalten (rekursiv). + Gibt Liste von (ordner_pfad, [video_dateien]) zurueck.""" + results = [] + try: + for entry in sorted(os.scandir(base_path), + key=lambda e: e.name.lower()): + if entry.name.startswith(".") or not entry.is_dir(): + continue + + # Direkte Videos in diesem Ordner + direct_videos = [] + has_subdirs = False + try: + for sub in os.scandir(entry.path): + if sub.name.startswith("."): + continue + if sub.is_file(): + ext = os.path.splitext(sub.name)[1].lower() + if ext in VIDEO_EXTENSIONS: + direct_videos.append(sub.path) + elif sub.is_dir(): + has_subdirs = True + except OSError: + continue + + if direct_videos: + # Ordner hat Videos -> ist ein Film + results.append((entry.path, direct_videos)) + if has_subdirs: + # Auch Unterordner durchsuchen (Film-Reihen) + results.extend(self._find_movie_folders(entry.path)) + except OSError: + pass + return results + + def _find_videos_recursive(self, directory: str) -> list[str]: + """Findet alle Videodateien rekursiv""" + videos = [] + try: + for root, _dirs, files in os.walk(directory): + for f in sorted(files): + ext = os.path.splitext(f)[1].lower() + if ext in VIDEO_EXTENSIONS: + videos.append(os.path.join(root, f)) + except OSError: + pass + return videos + + async def _ensure_series(self, path_id: int, folder_name: str, + folder_path: str) -> Optional[int]: + """Serie in DB anlegen falls nicht vorhanden""" + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT id FROM library_series " + "WHERE library_path_id = %s AND folder_path = %s", + (path_id, folder_path) + ) + row = await cur.fetchone() + if row: + return row[0] + + await cur.execute( + "INSERT INTO library_series " + "(library_path_id, folder_name, folder_path, title) " + "VALUES (%s, %s, %s, %s)", + (path_id, folder_name, folder_path, folder_name) + ) + return cur.lastrowid + except Exception as e: + logging.error(f"Serie anlegen fehlgeschlagen: {folder_name}: {e}") + return None + + async def _ensure_movie(self, path_id: int, folder_name: str, + folder_path: str, + is_file: bool = False) -> Optional[int]: + """Film in DB anlegen falls nicht vorhanden""" + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT id FROM library_movies " + "WHERE library_path_id = %s AND folder_path = %s", + (path_id, folder_path) + ) + row = await cur.fetchone() + if row: + return row[0] + + # Titel aus Ordnername extrahieren (Jahr erkennen) + title = folder_name + year = None + # "Film Name (2020)" oder "Film Name (2020) 720p" + m = re.search(r'\((\d{4})\)', folder_name) + if m: + year = int(m.group(1)) + # Alles vor dem Jahr = Titel + title = folder_name[:m.start()].strip() + if not title: + title = folder_name + else: + # Punkte/Unterstriche durch Leerzeichen ersetzen + title = re.sub(r'[._]', ' ', folder_name).strip() + # Titel-Suffixe entfernen (Aufloesung etc.) + title = re.sub( + r'\s*(720p|1080p|2160p|4k|bluray|bdrip|webrip|' + r'web-dl|hdtv|x264|x265|hevc|aac|dts)\s*', + '', title, flags=re.IGNORECASE + ).strip() + + await cur.execute( + "INSERT INTO library_movies " + "(library_path_id, folder_name, folder_path, " + "title, year) " + "VALUES (%s, %s, %s, %s, %s)", + (path_id, folder_name, folder_path, title, year) + ) + return cur.lastrowid + except Exception as e: + logging.error(f"Film anlegen fehlgeschlagen: {folder_name}: {e}") + return None + + async def _update_movie_counts(self, movie_id: int) -> None: + """Aktualisiert Video-Zaehler und Gesamtgroesse eines Films""" + pool = await self._get_pool() + if not pool: + return + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT COUNT(*), COALESCE(SUM(file_size), 0) " + "FROM library_videos WHERE movie_id = %s", + (movie_id,) + ) + row = await cur.fetchone() + vid_count = row[0] if row else 0 + total_size = int(row[1]) if row else 0 + + await cur.execute( + "UPDATE library_movies SET video_count = %s, " + "total_size = %s WHERE id = %s", + (vid_count, total_size, movie_id) + ) + except Exception as e: + logging.error(f"Film-Zaehler aktualisieren fehlgeschlagen: {e}") + + async def _cleanup_stale_movies(self, path_id: int) -> None: + """Entfernt verwaiste Film-Eintraege""" + pool = await self._get_pool() + if not pool: + return + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + # Filme mit versteckten Ordnernamen + await cur.execute( + "DELETE FROM library_movies " + "WHERE library_path_id = %s " + "AND folder_name LIKE '.%%'", + (path_id,) + ) + # Filme ohne Videos und ohne TVDB + await cur.execute( + "DELETE FROM library_movies " + "WHERE library_path_id = %s " + "AND video_count = 0 AND tvdb_id IS NULL", + (path_id,) + ) + except Exception as e: + logging.warning(f"Film-Bereinigung fehlgeschlagen: {e}") + + async def _add_video_to_db(self, path_id: int, + series_id: Optional[int], + file_path: str, + base_path: str, + movie_id: Optional[int] = None) -> bool: + """Video analysieren und in DB speichern (UPSERT)""" + pool = await self._get_pool() + if not pool: + return False + + # Pruefen ob bereits in DB und nicht geaendert + try: + file_size = os.path.getsize(file_path) + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT id, file_size FROM library_videos " + "WHERE file_path = %s", + (file_path,) + ) + existing = await cur.fetchone() + if existing and existing[1] == file_size: + # Unveraendert - aber movie_id/series_id aktualisieren + await cur.execute( + "UPDATE library_videos " + "SET series_id = %s, movie_id = %s " + "WHERE id = %s", + (series_id, movie_id, existing[0]) + ) + return False + except Exception: + pass + + # ffprobe Analyse + media = await ProbeService.analyze(file_path) + if not media: + return False + + # Serien-Info aus Dateiname/Pfad parsen + season_num, episode_num, episode_end, episode_title = self._parse_episode_info( + file_path, base_path + ) + + # Audio/Subtitle als JSON + audio_tracks = json.dumps([ + { + "codec": a.codec_name, + "lang": a.language, + "channels": a.channels, + "bitrate": a.bit_rate, + } + for a in media.audio_streams + ]) + subtitle_tracks = json.dumps([ + {"codec": s.codec_name, "lang": s.language} + for s in media.subtitle_streams + ]) + + # Video-Info aus erstem Stream + v = media.video_streams[0] if media.video_streams else None + video_codec = v.codec_name if v else None + width = v.width if v else 0 + height = v.height if v else 0 + resolution = v.resolution if v else "" + frame_rate = v.frame_rate if v else 0.0 + video_bitrate = v.bit_rate if v else None + is_10bit = 1 if (v and v.is_10bit) else 0 + + container = media.source_extension.lstrip(".") + file_name = media.source_filename + + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO library_videos ( + library_path_id, series_id, movie_id, + file_path, file_name, + file_size, season_number, episode_number, + episode_end, episode_title, + video_codec, width, height, resolution, + frame_rate, video_bitrate, is_10bit, + audio_tracks, subtitle_tracks, + container, duration_sec + ) VALUES ( + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s + ) + ON DUPLICATE KEY UPDATE + file_size = VALUES(file_size), + series_id = VALUES(series_id), + movie_id = VALUES(movie_id), + season_number = VALUES(season_number), + episode_number = VALUES(episode_number), + episode_end = VALUES(episode_end), + episode_title = VALUES(episode_title), + video_codec = VALUES(video_codec), + width = VALUES(width), + height = VALUES(height), + resolution = VALUES(resolution), + frame_rate = VALUES(frame_rate), + video_bitrate = VALUES(video_bitrate), + is_10bit = VALUES(is_10bit), + audio_tracks = VALUES(audio_tracks), + subtitle_tracks = VALUES(subtitle_tracks), + container = VALUES(container), + duration_sec = VALUES(duration_sec), + scanned_at = NOW() + """, ( + path_id, series_id, movie_id, + file_path, file_name, + file_size, season_num, episode_num, + episode_end, episode_title, + video_codec, width, height, resolution, + frame_rate, video_bitrate, is_10bit, + audio_tracks, subtitle_tracks, + container, media.source_duration_sec, + )) + return True + except Exception as e: + logging.error(f"Video in DB speichern fehlgeschlagen: " + f"{file_name}: {e}") + return False + + def _parse_episode_info(self, file_path: str, + base_path: str) -> tuple[Optional[int], + Optional[int], + Optional[int], + Optional[str]]: + """Staffel-, Episodennummer(n) und Episodentitel aus Pfad/Dateiname extrahieren. + + Gibt zurueck: (season_number, episode_number, episode_end, episode_title) + episode_end ist die End-Episode bei Doppel-Episoden (z.B. E01E02 -> end=2) + """ + file_name = os.path.basename(file_path) + rel_path = os.path.relpath(file_path, base_path) + name_no_ext = os.path.splitext(file_name)[0] + + season_num = None + episode_num = None + episode_end = None + episode_title = None + + # 1. S01E02 oder Doppel-Episode S01E01E02 im Dateinamen + m = RE_SXXEXX_MULTI.search(file_name) + if m: + season_num = int(m.group(1)) + episode_num = int(m.group(2)) + if m.group(3): + episode_end = int(m.group(3)) + # Titel extrahieren: Alles nach "SxxExx(-Exx) - " etc. + episode_title = self._extract_episode_title(name_no_ext, m.end()) + + # 2. 1x02 oder 1x01-02 im Dateinamen + if season_num is None: + m = RE_XXxXX_MULTI.search(file_name) + if m: + season_num = int(m.group(1)) + episode_num = int(m.group(2)) + if m.group(3): + episode_end = int(m.group(3)) + episode_title = self._extract_episode_title(name_no_ext, m.end()) + + # 3. Staffel aus Ordnername + fuehrende Nummer + if season_num is None: + parts = rel_path.replace("\\", "/").split("/") + for part in parts[:-1]: # Ordner durchsuchen + m = RE_SEASON_DIR.match(part) + if m: + season_num = int(m.group(1)) + break + + # Episodennummer aus fuehrender Nummer im Dateinamen + m = RE_LEADING_NUM.match(file_name) + if m and season_num is not None: + episode_num = int(m.group(1)) + # Titel ist der Rest nach der Nummer + episode_title = m.group(2).rsplit(".", 1)[0].strip() + + return season_num, episode_num, episode_end, episode_title + + def _extract_episode_title(self, name_no_ext: str, + pos_after_episode: int) -> Optional[str]: + """Extrahiert Episodentitel aus Dateinamen nach SxxExx. + + Beispiele: + "Tulsa King - S01E01 - Nach Westen, alter Mann" -> "Nach Westen, alter Mann" + "Serie.S01E02.Titel.der.Episode.720p" -> "Titel der Episode" + "Serie - S01E03" -> None + """ + if pos_after_episode >= len(name_no_ext): + return None + + rest = name_no_ext[pos_after_episode:] + + # Fuehrende Trennzeichen entfernen (-, _, ., Leerzeichen) + rest = rest.lstrip(" -_.") + + if not rest: + return None + + # Qualitaets-/Release-Tags am Ende entfernen + # z.B. "720p", "1080p", "2160p", "x264", "HEVC", "WEB-DL" etc. + quality_pattern = re.compile( + r'[\s._-]*(720p|1080p|2160p|4k|hdtv|webrip|web-dl|bluray|' + r'bdrip|x264|x265|hevc|h264|h265|aac|dts|ac3|' + r'proper|repack|german|english|dubbed|dl|' + r'web|hdr|sdr|10bit|remux).*$', + re.IGNORECASE + ) + rest = quality_pattern.sub('', rest) + + # Punkte/Underscores durch Leerzeichen ersetzen (Scene-Releases) + # Aber nur wenn keine normalen Leerzeichen vorhanden + if ' ' not in rest and ('.' in rest or '_' in rest): + rest = rest.replace('.', ' ').replace('_', ' ') + + # Mehrfach-Leerzeichen und Trailing entfernen + rest = re.sub(r'\s+', ' ', rest).strip(' -_.') + + return rest if rest else None + + async def _update_series_counts(self, series_id: int) -> None: + """Aktualisiert die lokalen Episoden-Zaehler einer Serie. + + Zaehlt EINDEUTIGE Episoden (season_number, episode_number), + nicht die Anzahl der Dateien. Wenn mehrere Dateien die gleiche + Episode abdecken (z.B. .mkv und .webm), wird das als redundant gezaehlt. + + Beruecksichtigt Doppel-Episoden (episode_end): + Eine Datei mit E01E02 zaehlt als 2 Episoden. + """ + pool = await self._get_pool() + if not pool: + return + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + # Gesamtzahl der Dateien mit Episode-Info + await cur.execute(""" + SELECT COUNT(*) FROM library_videos + WHERE series_id = %s AND episode_number IS NOT NULL + """, (series_id,)) + row = await cur.fetchone() + total_files = row[0] if row and row[0] else 0 + + # Anzahl EINDEUTIGER Episoden berechnen + # Gruppiert nach (season_number, episode_number) + # Bei Doppel-Episoden: MAX(episode_end) - episode_number + 1 + await cur.execute(""" + SELECT SUM(episode_span) as unique_episodes FROM ( + SELECT + season_number, + episode_number, + CASE + WHEN MAX(episode_end) IS NOT NULL + THEN MAX(episode_end) - episode_number + 1 + ELSE 1 + END as episode_span + FROM library_videos + WHERE series_id = %s AND episode_number IS NOT NULL + GROUP BY season_number, episode_number + ) as unique_eps + """, (series_id,)) + row = await cur.fetchone() + unique_count = row[0] if row and row[0] else 0 + + # Redundante Dateien = Gesamtdateien - eindeutige Episoden + redundant = max(0, total_files - unique_count) + + await cur.execute( + "UPDATE library_series SET local_episodes = %s, " + "missing_episodes = GREATEST(0, total_episodes - %s), " + "redundant_files = %s " + "WHERE id = %s", + (unique_count, unique_count, redundant, series_id) + ) + except Exception as e: + logging.error(f"Serien-Zaehler aktualisieren fehlgeschlagen: {e}") + + # === Abfragen === + + async def get_videos(self, filters: dict = None, + page: int = 1, limit: int = 50) -> dict: + """Videos mit Filtern abfragen""" + pool = await self._get_pool() + if not pool: + return {"items": [], "total": 0, "page": page} + + filters = filters or {} + where_clauses = [] + params = [] + + if filters.get("library_path_id"): + where_clauses.append("v.library_path_id = %s") + params.append(int(filters["library_path_id"])) + + if filters.get("media_type"): + where_clauses.append("lp.media_type = %s") + params.append(filters["media_type"]) + + if filters.get("series_id"): + where_clauses.append("v.series_id = %s") + params.append(int(filters["series_id"])) + + if filters.get("video_codec"): + where_clauses.append("v.video_codec = %s") + params.append(filters["video_codec"]) + + if filters.get("min_width"): + where_clauses.append("v.width >= %s") + params.append(int(filters["min_width"])) + + if filters.get("max_width"): + where_clauses.append("v.width <= %s") + params.append(int(filters["max_width"])) + + if filters.get("container"): + where_clauses.append("v.container = %s") + params.append(filters["container"]) + + if filters.get("is_10bit"): + where_clauses.append("v.is_10bit = 1") + + if filters.get("audio_lang"): + where_clauses.append( + "JSON_CONTAINS(v.audio_tracks, JSON_OBJECT('lang', %s))" + ) + params.append(filters["audio_lang"]) + + if filters.get("audio_channels"): + where_clauses.append( + "JSON_CONTAINS(v.audio_tracks, " + "JSON_OBJECT('channels', CAST(%s AS SIGNED)))" + ) + params.append(int(filters["audio_channels"])) + + if filters.get("has_subtitle"): + where_clauses.append( + "JSON_CONTAINS(v.subtitle_tracks, JSON_OBJECT('lang', %s))" + ) + params.append(filters["has_subtitle"]) + + if filters.get("search"): + where_clauses.append("v.file_name LIKE %s") + params.append(f"%{filters['search']}%") + + # Filter: Nicht im Zielformat (Container + Codec) + if filters.get("not_converted"): + target_container = self.config.target_container # z.B. "webm" + # Videos die NICHT im Zielformat sind + where_clauses.append( + "(v.container != %s OR v.video_codec NOT IN ('av1', 'hevc'))" + ) + params.append(target_container) + + # Filter: Nur bestimmter Container NICHT + if filters.get("exclude_container"): + where_clauses.append("v.container != %s") + params.append(filters["exclude_container"]) + + # Filter: Nur bestimmter Codec NICHT + if filters.get("exclude_codec"): + where_clauses.append("v.video_codec != %s") + params.append(filters["exclude_codec"]) + + where_sql = "" + if where_clauses: + where_sql = "WHERE " + " AND ".join(where_clauses) + + # Sortierung + sort_col = filters.get("sort", "file_name") + allowed_sorts = { + "file_name", "file_size", "width", "video_codec", + "container", "duration_sec", "scanned_at" + } + if sort_col not in allowed_sorts: + sort_col = "file_name" + order = "DESC" if filters.get("order") == "desc" else "ASC" + + offset = (page - 1) * limit + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Count + await cur.execute( + f"SELECT COUNT(*) as cnt FROM library_videos v " + f"LEFT JOIN library_paths lp ON v.library_path_id = lp.id " + f"{where_sql}", + params + ) + total = (await cur.fetchone())["cnt"] + + # Daten + await cur.execute( + f"SELECT v.*, lp.name as library_name, " + f"lp.media_type, " + f"ls.title as series_title, ls.poster_url " + f"FROM library_videos v " + f"LEFT JOIN library_paths lp " + f"ON v.library_path_id = lp.id " + f"LEFT JOIN library_series ls " + f"ON v.series_id = ls.id " + f"{where_sql} " + f"ORDER BY v.{sort_col} {order} " + f"LIMIT %s OFFSET %s", + params + [limit, offset] + ) + rows = await cur.fetchall() + + items = [] + for row in rows: + item = self._serialize_row(row) + # JSON-Strings parsen + if isinstance(item.get("audio_tracks"), str): + try: + item["audio_tracks"] = json.loads( + item["audio_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["audio_tracks"] = [] + if isinstance(item.get("subtitle_tracks"), str): + try: + item["subtitle_tracks"] = json.loads( + item["subtitle_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["subtitle_tracks"] = [] + items.append(item) + + return { + "items": items, + "total": total, + "page": page, + "pages": (total + limit - 1) // limit if limit else 1, + } + except Exception as e: + logging.error(f"Videos abfragen fehlgeschlagen: {e}") + return {"items": [], "total": 0, "page": page} + + async def get_series_list(self, path_id: int = None) -> list[dict]: + """Alle Serien laden, optional nach Pfad gefiltert""" + pool = await self._get_pool() + if not pool: + return [] + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if path_id: + await cur.execute( + "SELECT * FROM library_series " + "WHERE library_path_id = %s " + "ORDER BY title", + (path_id,) + ) + else: + await cur.execute( + "SELECT * FROM library_series ORDER BY title" + ) + rows = await cur.fetchall() + return [self._serialize_row(r) for r in rows] + except Exception as e: + logging.error(f"Serien laden fehlgeschlagen: {e}") + return [] + + async def get_series_detail(self, series_id: int) -> Optional[dict]: + """Serie mit Episoden laden""" + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Serie + await cur.execute( + "SELECT * FROM library_series WHERE id = %s", + (series_id,) + ) + series = await cur.fetchone() + if not series: + return None + series = self._serialize_row(series) + + # Lokale Episoden + await cur.execute( + "SELECT * FROM library_videos " + "WHERE series_id = %s " + "ORDER BY season_number, episode_number, file_name", + (series_id,) + ) + episodes = await cur.fetchall() + episode_list = [] + for ep in episodes: + item = self._serialize_row(ep) + if isinstance(item.get("audio_tracks"), str): + try: + item["audio_tracks"] = json.loads( + item["audio_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["audio_tracks"] = [] + if isinstance(item.get("subtitle_tracks"), str): + try: + item["subtitle_tracks"] = json.loads( + item["subtitle_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["subtitle_tracks"] = [] + episode_list.append(item) + + series["episodes"] = episode_list + + # TVDB fehlende Episoden laden falls vorhanden + if series.get("tvdb_id"): + await cur.execute( + "SELECT * FROM tvdb_episode_cache " + "WHERE series_tvdb_id = %s " + "ORDER BY season_number, episode_number", + (series["tvdb_id"],) + ) + tvdb_eps = await cur.fetchall() + series["tvdb_episodes"] = [ + self._serialize_row(e) for e in tvdb_eps + ] + else: + series["tvdb_episodes"] = [] + + return series + except Exception as e: + logging.error(f"Serien-Detail laden fehlgeschlagen: {e}") + return None + + async def get_missing_episodes(self, series_id: int) -> list[dict]: + """Fehlende Episoden einer Serie (TVDB vs. lokal)""" + pool = await self._get_pool() + if not pool: + return [] + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # TVDB-ID der Serie holen + await cur.execute( + "SELECT tvdb_id FROM library_series WHERE id = %s", + (series_id,) + ) + row = await cur.fetchone() + if not row or not row["tvdb_id"]: + return [] + + tvdb_id = row["tvdb_id"] + + # Fehlende = TVDB-Episoden die nicht lokal vorhanden sind + # Beruecksichtigt Doppel-Episoden (episode_end): + # E01E02 deckt sowohl E01 als auch E02 ab + await cur.execute(""" + SELECT tc.season_number, tc.episode_number, + tc.episode_name, tc.aired + FROM tvdb_episode_cache tc + WHERE tc.series_tvdb_id = %s + AND NOT EXISTS ( + SELECT 1 FROM library_videos lv + WHERE lv.series_id = %s + AND lv.season_number = tc.season_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) + ) + AND tc.season_number > 0 + ORDER BY tc.season_number, tc.episode_number + """, (tvdb_id, series_id)) + rows = await cur.fetchall() + return [self._serialize_row(r) for r in rows] + except Exception as e: + logging.error(f"Fehlende Episoden laden fehlgeschlagen: {e}") + return [] + + async def get_all_missing_episodes(self, path_id: int = None, + page: int = 1, + limit: int = 50) -> dict: + """Alle fehlenden Episoden aller Serien laden (fuer Filter-Ansicht). + + Gibt virtuelle Eintraege zurueck mit: + - series_id, series_title, poster_url + - season_number, episode_number, episode_name, aired + - is_missing = True (Marker fuer Frontend) + """ + pool = await self._get_pool() + if not pool: + return {"items": [], "total": 0, "page": page} + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Basis-Query: Alle fehlenden Episoden mit Serien-Info + path_filter = "" + params = [] + if path_id: + path_filter = "AND ls.library_path_id = %s" + params.append(path_id) + + # Count-Query + await cur.execute(f""" + SELECT COUNT(*) as cnt + FROM tvdb_episode_cache tc + JOIN library_series ls ON tc.series_tvdb_id = ls.tvdb_id + WHERE tc.season_number > 0 + {path_filter} + AND NOT EXISTS ( + SELECT 1 FROM library_videos lv + WHERE lv.series_id = ls.id + AND lv.season_number = tc.season_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) + ) + """, params) + total = (await cur.fetchone())["cnt"] + + # Daten-Query mit Pagination + offset = (page - 1) * limit + await cur.execute(f""" + SELECT + ls.id as series_id, + ls.title as series_title, + ls.poster_url, + ls.folder_path, + tc.season_number, + tc.episode_number, + tc.episode_name, + tc.aired, + 1 as is_missing + FROM tvdb_episode_cache tc + JOIN library_series ls ON tc.series_tvdb_id = ls.tvdb_id + WHERE tc.season_number > 0 + {path_filter} + AND NOT EXISTS ( + SELECT 1 FROM library_videos lv + WHERE lv.series_id = ls.id + AND lv.season_number = tc.season_number + AND ( + lv.episode_number = tc.episode_number + OR ( + lv.episode_end IS NOT NULL + AND tc.episode_number >= lv.episode_number + AND tc.episode_number <= lv.episode_end + ) + ) + ) + ORDER BY ls.title, tc.season_number, tc.episode_number + LIMIT %s OFFSET %s + """, params + [limit, offset]) + rows = await cur.fetchall() + + items = [self._serialize_row(r) for r in rows] + return { + "items": items, + "total": total, + "page": page, + "pages": (total + limit - 1) // limit if limit else 1, + } + except Exception as e: + logging.error(f"Fehlende Episoden (alle) laden fehlgeschlagen: {e}") + return {"items": [], "total": 0, "page": page} + + async def update_series_tvdb(self, series_id: int, + tvdb_id: int) -> bool: + """TVDB-ID einer Serie zuordnen""" + 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( + "UPDATE library_series SET tvdb_id = %s " + "WHERE id = %s", + (tvdb_id, series_id) + ) + return cur.rowcount > 0 + except Exception as e: + logging.error(f"TVDB-ID zuordnen fehlgeschlagen: {e}") + return False + + # === Ordner-Ansicht === + + async def browse_path(self, path: str = None) -> dict: + """Ordnerstruktur aus DB-Eintraegen aufbauen. + Ohne path: Alle library_paths als Wurzeln. + Mit path: Unterordner + Videos in diesem Verzeichnis.""" + pool = await self._get_pool() + if not pool: + return {"folders": [], "videos": [], "breadcrumb": []} + + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if not path: + # Wurzel: Library-Pfade anzeigen + await cur.execute( + "SELECT lp.*, " + "(SELECT COUNT(*) FROM library_videos v " + " WHERE v.library_path_id = lp.id) as video_count, " + "(SELECT COALESCE(SUM(file_size), 0) FROM library_videos v " + " WHERE v.library_path_id = lp.id) as total_size " + "FROM library_paths lp WHERE lp.enabled = 1 " + "ORDER BY lp.name" + ) + roots = await cur.fetchall() + folders = [] + for r in roots: + folders.append({ + "name": r["name"], + "path": r["path"], + "media_type": r["media_type"], + "video_count": r["video_count"], + "total_size": int(r["total_size"] or 0), + }) + return { + "folders": folders, + "videos": [], + "breadcrumb": [], + "current_path": None, + } + + # Unterordner und Videos fuer gegebenen Pfad + # Normalisieren (kein trailing slash) + path = path.rstrip("/") + + # Breadcrumb aufbauen: Library-Root finden + await cur.execute( + "SELECT * FROM library_paths WHERE enabled = 1" + ) + all_paths = await cur.fetchall() + lib_root = None + for lp in all_paths: + if path == lp["path"] or path.startswith(lp["path"] + "/"): + lib_root = lp + break + + breadcrumb = [] + if lib_root: + breadcrumb.append({ + "name": lib_root["name"], + "path": lib_root["path"] + }) + # Zwischenordner + if path != lib_root["path"]: + rel = path[len(lib_root["path"]):].strip("/") + parts = rel.split("/") + acc = lib_root["path"] + for part in parts: + acc = acc + "/" + part + breadcrumb.append({ + "name": part, + "path": acc + }) + + # Alle file_paths die mit diesem Pfad beginnen + prefix = path + "/" + await cur.execute( + "SELECT file_path, file_name, file_size, " + "video_codec, width, height, is_10bit, " + "audio_tracks, subtitle_tracks, container, " + "duration_sec, id " + "FROM library_videos " + "WHERE file_path LIKE %s " + "ORDER BY file_path", + (prefix + "%",) + ) + rows = await cur.fetchall() + + # Unterordner und direkte Videos trennen + folder_map = {} # ordnername -> {count, size} + direct_videos = [] + + for row in rows: + rel = row["file_path"][len(prefix):] + if "/" in rel: + # Unterordner + subfolder = rel.split("/")[0] + if subfolder not in folder_map: + folder_map[subfolder] = { + "video_count": 0, "total_size": 0 + } + folder_map[subfolder]["video_count"] += 1 + folder_map[subfolder]["total_size"] += ( + row["file_size"] or 0 + ) + else: + # Direkte Datei + item = self._serialize_row(row) + if isinstance(item.get("audio_tracks"), str): + try: + item["audio_tracks"] = json.loads( + item["audio_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["audio_tracks"] = [] + if isinstance(item.get("subtitle_tracks"), str): + try: + item["subtitle_tracks"] = json.loads( + item["subtitle_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["subtitle_tracks"] = [] + direct_videos.append(item) + + folders = [] + for name in sorted(folder_map.keys()): + folders.append({ + "name": name, + "path": prefix + name, + "video_count": folder_map[name]["video_count"], + "total_size": folder_map[name]["total_size"], + }) + + return { + "folders": folders, + "videos": direct_videos, + "breadcrumb": breadcrumb, + "current_path": path, + } + except Exception as e: + logging.error(f"Ordner-Ansicht fehlgeschlagen: {e}") + return {"folders": [], "videos": [], "breadcrumb": []} + + # === Film-Abfragen === + + async def get_movie_list(self, path_id: int = None) -> list[dict]: + """Alle Filme laden, optional nach Pfad gefiltert""" + pool = await self._get_pool() + if not pool: + return [] + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if path_id: + await cur.execute( + "SELECT m.*, " + "(SELECT v.duration_sec FROM library_videos v " + " WHERE v.movie_id = m.id " + " ORDER BY v.file_size DESC LIMIT 1) " + "as duration_sec " + "FROM library_movies m " + "WHERE m.library_path_id = %s " + "ORDER BY m.title", + (path_id,) + ) + else: + await cur.execute( + "SELECT m.*, " + "(SELECT v.duration_sec FROM library_videos v " + " WHERE v.movie_id = m.id " + " ORDER BY v.file_size DESC LIMIT 1) " + "as duration_sec " + "FROM library_movies m " + "ORDER BY m.title" + ) + rows = await cur.fetchall() + return [self._serialize_row(r) for r in rows] + except Exception as e: + logging.error(f"Filme laden fehlgeschlagen: {e}") + return [] + + async def get_movie_detail(self, movie_id: int) -> Optional[dict]: + """Film mit seinen Video-Dateien laden""" + pool = await self._get_pool() + if not pool: + return None + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM library_movies WHERE id = %s", + (movie_id,) + ) + movie = await cur.fetchone() + if not movie: + return None + movie = self._serialize_row(movie) + + # Video-Dateien des Films + await cur.execute( + "SELECT * FROM library_videos " + "WHERE movie_id = %s ORDER BY file_name", + (movie_id,) + ) + videos = await cur.fetchall() + video_list = [] + for v in videos: + item = self._serialize_row(v) + if isinstance(item.get("audio_tracks"), str): + try: + item["audio_tracks"] = json.loads( + item["audio_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["audio_tracks"] = [] + if isinstance(item.get("subtitle_tracks"), str): + try: + item["subtitle_tracks"] = json.loads( + item["subtitle_tracks"] + ) + except (json.JSONDecodeError, TypeError): + item["subtitle_tracks"] = [] + video_list.append(item) + movie["videos"] = video_list + + return movie + except Exception as e: + logging.error(f"Film-Detail laden fehlgeschlagen: {e}") + return None + + async def delete_movie(self, movie_id: int, + delete_files: bool = False) -> dict: + """Film aus DB loeschen. Optional auch Dateien + Ordner.""" + pool = await self._get_pool() + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + folder_path = None + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "SELECT folder_path FROM library_movies " + "WHERE id = %s", (movie_id,) + ) + row = await cur.fetchone() + if not row: + return {"error": "Film nicht gefunden"} + folder_path = row[0] + + # Videos aus DB loeschen (movie_id nullen) + await cur.execute( + "UPDATE library_videos SET movie_id = NULL " + "WHERE movie_id = %s", (movie_id,) + ) + vids = cur.rowcount + # Film aus DB loeschen + await cur.execute( + "DELETE FROM library_movies WHERE id = %s", + (movie_id,) + ) + + result = {"success": True, "updated_videos": vids} + + if delete_files and folder_path and os.path.isdir(folder_path): + import shutil + import stat + def _rm_error(func, path, exc_info): + try: + os.chmod(path, stat.S_IRWXU) + func(path) + except Exception: + pass + try: + shutil.rmtree(folder_path, onerror=_rm_error) + result["deleted_folder"] = folder_path + except Exception as e: + result["folder_error"] = str(e) + + return result + except Exception as e: + logging.error(f"Film loeschen fehlgeschlagen: {e}") + return {"error": str(e)} + + async def unlink_movie_tvdb(self, movie_id: int) -> bool: + """TVDB-Zuordnung eines Films loesen""" + 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( + "UPDATE library_movies SET " + "tvdb_id = NULL, poster_url = NULL, " + "overview = NULL, genres = NULL, " + "runtime = NULL, status = NULL, " + "last_updated = NOW() " + "WHERE id = %s", (movie_id,) + ) + return cur.rowcount > 0 + except Exception as e: + logging.error(f"Film-TVDB loesen fehlgeschlagen: {e}") + return False + + # === Duplikat-Finder === + + async def find_duplicates(self) -> list[dict]: + """Findet potentielle Duplikate (gleiche Episode oder aehnliche Duration)""" + pool = await self._get_pool() + if not pool: + return [] + try: + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + # Serien-Duplikate: gleiche Serie + Staffel + Episode + await cur.execute(""" + SELECT v1.id as id1, v1.file_name as name1, + v1.file_path as path1, + v1.video_codec as codec1, + v1.width as width1, v1.height as height1, + v1.file_size as size1, v1.container as container1, + v2.id as id2, v2.file_name as name2, + v2.file_path as path2, + v2.video_codec as codec2, + v2.width as width2, v2.height as height2, + v2.file_size as size2, v2.container as container2 + FROM library_videos v1 + JOIN library_videos v2 + ON v1.id < v2.id + AND v1.series_id = v2.series_id + AND v1.season_number = v2.season_number + AND v1.episode_number = v2.episode_number + AND v1.series_id IS NOT NULL + AND v1.season_number IS NOT NULL + ORDER BY v1.file_name + LIMIT 200 + """) + rows = await cur.fetchall() + return [self._serialize_row(r) for r in rows] + except Exception as e: + logging.error(f"Duplikat-Suche fehlgeschlagen: {e}") + return [] + + # === Statistiken === + + async def get_stats(self) -> dict: + """Bibliotheks-Statistiken""" + pool = await self._get_pool() + if not pool: + return {} + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + SELECT + COUNT(*) as total_videos, + COUNT(DISTINCT series_id) as total_series, + SUM(file_size) as total_size, + SUM(duration_sec) as total_duration, + COUNT(DISTINCT video_codec) as codec_count + FROM library_videos + """) + row = await cur.fetchone() + if not row: + return {} + + # Codec-Verteilung + await cur.execute(""" + SELECT video_codec, COUNT(*) as cnt + FROM library_videos + WHERE video_codec IS NOT NULL + GROUP BY video_codec + ORDER BY cnt DESC + """) + codec_rows = await cur.fetchall() + + # Aufloesung-Verteilung + await cur.execute(""" + SELECT + CASE + WHEN width >= 3840 THEN '4K' + WHEN width >= 1920 THEN '1080p' + WHEN width >= 1280 THEN '720p' + ELSE 'SD' + END as res_group, + COUNT(*) as cnt + FROM library_videos + WHERE width > 0 + GROUP BY res_group + ORDER BY cnt DESC + """) + res_rows = await cur.fetchall() + + return { + "total_videos": row[0] or 0, + "total_series": row[1] or 0, + "total_size": int(row[2] or 0), + "total_duration": float(row[3] or 0), + "codecs": {r[0]: r[1] for r in codec_rows}, + "resolutions": {r[0]: r[1] for r in res_rows}, + } + except Exception as e: + logging.error(f"Bibliotheks-Statistiken fehlgeschlagen: {e}") + return {} + + # === Hilfsfunktionen === + + @staticmethod + def _serialize_row(row: dict) -> dict: + """MariaDB-Row fuer JSON serialisierbar machen""" + result = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + result[k] = str(v) + else: + result[k] = v + return result diff --git a/video-konverter/app/services/probe.py b/video-konverter/app/services/probe.py new file mode 100644 index 0000000..af5c12a --- /dev/null +++ b/video-konverter/app/services/probe.py @@ -0,0 +1,177 @@ +"""Asynchrone ffprobe Media-Analyse""" +import asyncio +import json +import logging +import os +from typing import Optional +from app.models.media import MediaFile, VideoStream, AudioStream, SubtitleStream + + +class ProbeService: + """ffprobe-basierte Media-Analyse - vollstaendig asynchron""" + + @staticmethod + async def analyze(file_path: str) -> Optional[MediaFile]: + """ + Analysiert eine Mediendatei mit ffprobe. + Fuehrt 3 Aufrufe parallel aus (Video/Audio/Subtitle). + Gibt None zurueck bei Fehler. + """ + if not os.path.exists(file_path): + logging.error(f"Datei nicht gefunden: {file_path}") + return None + + try: + # Alle drei Stream-Typen parallel abfragen + video_task = ProbeService._probe_streams(file_path, "v") + audio_task = ProbeService._probe_streams(file_path, "a") + subtitle_task = ProbeService._probe_streams(file_path, "s") + + video_data, audio_data, subtitle_data = await asyncio.gather( + video_task, audio_task, subtitle_task + ) + + # Streams parsen + video_streams = ProbeService._parse_video_streams(video_data) + audio_streams = ProbeService._parse_audio_streams(audio_data) + subtitle_streams = ProbeService._parse_subtitle_streams(subtitle_data) + + # Format-Informationen aus Video-Abfrage + size_bytes, duration_sec, bitrate = ProbeService._parse_format(video_data) + + media = MediaFile( + source_path=file_path, + source_size_bytes=size_bytes, + source_duration_sec=duration_sec, + source_bitrate=bitrate, + video_streams=video_streams, + audio_streams=audio_streams, + subtitle_streams=subtitle_streams, + ) + + logging.info( + f"Analysiert: {media.source_filename} " + f"({media.source_size_human[0]} {media.source_size_human[1]}, " + f"{MediaFile.format_time(duration_sec)}, " + f"{len(video_streams)}V/{len(audio_streams)}A/{len(subtitle_streams)}S)" + ) + return media + + except Exception as e: + logging.error(f"ffprobe Analyse fehlgeschlagen fuer {file_path}: {e}") + return None + + @staticmethod + async def _probe_streams(file_path: str, stream_type: str) -> dict: + """ + Einzelner ffprobe-Aufruf (async). + stream_type: 'v' (Video), 'a' (Audio), 's' (Subtitle) + """ + command = [ + "ffprobe", "-v", "error", + "-select_streams", stream_type, + "-show_entries", + "stream=index,channels,codec_name,codec_type,pix_fmt,level," + "r_frame_rate,bit_rate,sample_rate,width,height" + ":stream_tags=language", + "-show_entries", + "format=size,bit_rate,nb_streams,duration", + "-of", "json", + file_path, + ] + + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + + if process.returncode != 0: + logging.warning( + f"ffprobe Fehler (stream={stream_type}): " + f"{stderr.decode(errors='replace').strip()}" + ) + return {"streams": [], "format": {}} + + try: + return json.loads(stdout.decode()) + except json.JSONDecodeError: + logging.error(f"ffprobe JSON-Parsing fehlgeschlagen (stream={stream_type})") + return {"streams": [], "format": {}} + + @staticmethod + def _parse_video_streams(data: dict) -> list[VideoStream]: + """Parst ffprobe JSON in VideoStream-Objekte""" + streams = [] + for s in data.get("streams", []): + # Framerate berechnen (z.B. "24000/1001" -> 23.976) + fr_str = s.get("r_frame_rate", "0/1") + parts = fr_str.split("/") + if len(parts) == 2 and int(parts[1]) > 0: + frame_rate = round(int(parts[0]) / int(parts[1]), 3) + else: + frame_rate = 0.0 + + streams.append(VideoStream( + index=s.get("index", 0), + codec_name=s.get("codec_name", "unknown"), + width=s.get("width", 0), + height=s.get("height", 0), + pix_fmt=s.get("pix_fmt", ""), + frame_rate=frame_rate, + level=s.get("level"), + bit_rate=int(s["bit_rate"]) if s.get("bit_rate") else None, + )) + return streams + + @staticmethod + def _parse_audio_streams(data: dict) -> list[AudioStream]: + """Parst ffprobe JSON in AudioStream-Objekte""" + streams = [] + for s in data.get("streams", []): + language = s.get("tags", {}).get("language") + streams.append(AudioStream( + index=s.get("index", 0), + codec_name=s.get("codec_name", "unknown"), + channels=s.get("channels", 2), + sample_rate=int(s.get("sample_rate", 48000)), + language=language, + bit_rate=int(s["bit_rate"]) if s.get("bit_rate") else None, + )) + return streams + + @staticmethod + def _parse_subtitle_streams(data: dict) -> list[SubtitleStream]: + """Parst ffprobe JSON in SubtitleStream-Objekte""" + streams = [] + for s in data.get("streams", []): + language = s.get("tags", {}).get("language") + streams.append(SubtitleStream( + index=s.get("index", 0), + codec_name=s.get("codec_name", "unknown"), + language=language, + )) + return streams + + @staticmethod + def _parse_format(data: dict) -> tuple[int, float, int]: + """Parst Format-Informationen: (size_bytes, duration_sec, bitrate)""" + fmt = data.get("format", {}) + if isinstance(fmt, list): + fmt = fmt[0] if fmt else {} + + size_bytes = int(fmt.get("size", 0)) + bitrate = int(fmt.get("bit_rate", 0)) + + # Duration kann HH:MM:SS oder Sekunden sein + duration_raw = fmt.get("duration", "0") + try: + if ":" in str(duration_raw): + duration_sec = MediaFile.time_to_seconds(str(duration_raw)) + else: + duration_sec = float(duration_raw) + except (ValueError, TypeError): + duration_sec = 0.0 + + return size_bytes, duration_sec, bitrate diff --git a/video-konverter/app/services/progress.py b/video-konverter/app/services/progress.py new file mode 100644 index 0000000..2445349 --- /dev/null +++ b/video-konverter/app/services/progress.py @@ -0,0 +1,132 @@ +"""Echtzeit-Parsing der ffmpeg stderr-Ausgabe""" +import re +import asyncio +import logging +from typing import Callable, Awaitable +from app.models.job import ConversionJob +from app.models.media import MediaFile + + +class ProgressParser: + """ + Liest ffmpeg stderr und extrahiert Fortschrittsinformationen. + Ruft Callback fuer WebSocket-Updates auf. + Speichert letzte stderr-Zeilen fuer Fehlerdiagnose. + """ + + def __init__(self, on_progress: Callable[[ConversionJob], Awaitable[None]]): + self.on_progress = on_progress + self.last_lines: list[str] = [] + self._max_lines = 50 + + async def monitor(self, job: ConversionJob) -> None: + """ + Hauptschleife: Liest stderr des ffmpeg-Prozesses, + parst Fortschritt und ruft Callback auf. + """ + if not job.process or not job.process.stderr: + return + + empty_reads = 0 + max_empty_reads = 30 + update_counter = 0 + + while True: + try: + data = await job.process.stderr.read(1024) + except Exception: + break + + if not data: + empty_reads += 1 + if empty_reads > max_empty_reads: + break + await asyncio.sleep(0.5) + continue + + empty_reads = 0 + line = data.decode(errors="replace") + + # Letzte Zeilen fuer Fehlerdiagnose speichern + for part in line.splitlines(): + part = part.strip() + if part: + self.last_lines.append(part) + if len(self.last_lines) > self._max_lines: + self.last_lines.pop(0) + + self._extract_values(job, line) + self._calculate_progress(job) + job.update_stats(job.progress_fps, job.progress_speed, job.progress_bitrate) + + # WebSocket-Update senden + await self.on_progress(job) + + # Ausfuehrliches Logging alle 100 Reads + update_counter += 1 + if update_counter % 100 == 0: + logging.info( + f"[{job.media.source_filename}] " + f"{job.progress_percent:.1f}% | " + f"FPS: {job.progress_fps} | " + f"Speed: {job.progress_speed}x | " + f"ETA: {MediaFile.format_time(job.progress_eta_sec)}" + ) + + def get_error_output(self) -> str: + """Gibt die letzten stderr-Zeilen als String zurueck""" + return "\n".join(self.last_lines[-10:]) + + @staticmethod + def _extract_values(job: ConversionJob, line: str) -> None: + """Regex-Extraktion aus ffmpeg stderr""" + # Frame + match = re.findall(r"frame=\s*(\d+)", line) + if match: + frames = int(match[-1]) + if frames > job.progress_frames: + job.progress_frames = frames + + # FPS + match = re.findall(r"fps=\s*(\d+\.?\d*)", line) + if match: + job.progress_fps = float(match[-1]) + + # Speed + match = re.findall(r"speed=\s*(\d+\.?\d*)", line) + if match: + job.progress_speed = float(match[-1]) + + # Bitrate + match = re.findall(r"bitrate=\s*(\d+)", line) + if match: + job.progress_bitrate = int(match[-1]) + + # Size (KiB von ffmpeg) + match = re.findall(r"size=\s*(\d+)", line) + if match: + size_kib = int(match[-1]) + size_bytes = size_kib * 1024 + if size_bytes > job.progress_size_bytes: + job.progress_size_bytes = size_bytes + + # Time (HH:MM:SS) + match = re.findall(r"time=\s*(\d+:\d+:\d+\.?\d*)", line) + if match: + seconds = MediaFile.time_to_seconds(match[-1]) + if seconds > job.progress_time_sec: + job.progress_time_sec = seconds + + @staticmethod + def _calculate_progress(job: ConversionJob) -> None: + """Berechnet Fortschritt in % und ETA""" + total_frames = job.media.total_frames + if total_frames > 0: + job.progress_percent = min( + (job.progress_frames / total_frames) * 100, 100.0 + ) + + # ETA basierend auf durchschnittlicher FPS + if job.avg_fps > 0 and total_frames > 0: + remaining_frames = total_frames - job.progress_frames + job.progress_eta_sec = max(0, remaining_frames / job.avg_fps) diff --git a/video-konverter/app/services/queue.py b/video-konverter/app/services/queue.py new file mode 100644 index 0000000..bd7d050 --- /dev/null +++ b/video-konverter/app/services/queue.py @@ -0,0 +1,564 @@ +"""Job-Queue mit Persistierung und paralleler Ausfuehrung""" +import asyncio +import json +import logging +import os +import time +from collections import OrderedDict +from decimal import Decimal +from typing import Optional, TYPE_CHECKING + +import aiomysql + +from app.config import Config +from app.models.job import ConversionJob, JobStatus +from app.models.media import MediaFile +from app.services.encoder import EncoderService +from app.services.probe import ProbeService +from app.services.progress import ProgressParser +from app.services.scanner import ScannerService + +if TYPE_CHECKING: + from app.routes.ws import WebSocketManager + + +class QueueService: + """Verwaltet die Konvertierungs-Queue mit Persistierung""" + + def __init__(self, config: Config, ws_manager: 'WebSocketManager'): + self.config = config + self.ws_manager = ws_manager + self.encoder = EncoderService(config) + self.scanner = ScannerService(config) + self.jobs: OrderedDict[int, ConversionJob] = OrderedDict() + self._active_count: int = 0 + self._running: bool = False + self._queue_task: Optional[asyncio.Task] = None + self._queue_file = str(config.data_path / "queue.json") + self._db_pool: Optional[aiomysql.Pool] = None + + async def start(self) -> None: + """Startet den Queue-Worker und initialisiert die Datenbank""" + await self._init_db() + pending = self._load_queue() + self._running = True + self._queue_task = asyncio.create_task(self._process_loop()) + logging.info( + f"Queue gestartet ({len(self.jobs)} Jobs geladen, " + f"max {self.config.max_parallel_jobs} parallel)" + ) + # Gespeicherte Jobs asynchron wieder einreihen (mit allen Optionen) + if pending: + asyncio.create_task(self._restore_jobs(pending)) + + async def _restore_jobs(self, job_data: list[dict]) -> None: + """Stellt Jobs aus gespeicherten Daten wieder her""" + for item in job_data: + media = await ProbeService.analyze(item["source_path"]) + if media: + await self.add_job( + media, + preset_name=item.get("preset_name"), + delete_source=item.get("delete_source", False) + ) + + async def stop(self) -> None: + """Stoppt den Queue-Worker""" + self._running = False + if self._queue_task: + self._queue_task.cancel() + try: + await self._queue_task + except asyncio.CancelledError: + pass + if self._db_pool is not None: + self._db_pool.close() + await self._db_pool.wait_closed() + logging.info("Queue gestoppt") + + async def add_job(self, media: MediaFile, + preset_name: Optional[str] = None, + delete_source: bool = False) -> Optional[ConversionJob]: + """Fuegt neuen Job zur Queue hinzu""" + if self._is_duplicate(media.source_path): + logging.info(f"Duplikat uebersprungen: {media.source_filename}") + return None + + if not preset_name: + preset_name = self.config.default_preset_name + + job = ConversionJob(media=media, preset_name=preset_name) + job.delete_source = delete_source + job.build_target_path(self.config) + self.jobs[job.id] = job + self._save_queue() + + logging.info( + f"Job hinzugefuegt: {media.source_filename} " + f"-> {job.target_filename} (Preset: {preset_name})" + f"{' [delete_source]' if delete_source else ''}" + ) + + await self.ws_manager.broadcast_queue_update() + return job + + async def add_paths(self, paths: list[str], + preset_name: Optional[str] = None, + recursive: Optional[bool] = None, + delete_source: bool = False) -> list[ConversionJob]: + """Fuegt mehrere Pfade hinzu (Dateien und Ordner)""" + jobs = [] + all_files = [] + + for path in paths: + path = path.strip() + if not path: + continue + scanned = self.scanner.scan_path(path, recursive) + all_files.extend(scanned) + + logging.info(f"{len(all_files)} Dateien aus {len(paths)} Pfaden gefunden") + + for file_path in all_files: + media = await ProbeService.analyze(file_path) + if media: + job = await self.add_job(media, preset_name, delete_source) + if job: + jobs.append(job) + + return jobs + + async def remove_job(self, job_id: int) -> bool: + """Entfernt Job aus Queue, bricht laufende Konvertierung ab""" + job = self.jobs.get(job_id) + if not job: + return False + + if job.status == JobStatus.ACTIVE and job.process: + try: + job.process.terminate() + await asyncio.sleep(0.5) + if job.process.returncode is None: + job.process.kill() + except ProcessLookupError: + pass + + if job.task and not job.task.done(): + job.task.cancel() + + del self.jobs[job_id] + self._save_queue() + await self.ws_manager.broadcast_queue_update() + logging.info(f"Job entfernt: {job.media.source_filename}") + return True + + async def cancel_job(self, job_id: int) -> bool: + """Bricht laufenden Job ab""" + job = self.jobs.get(job_id) + if not job or job.status != JobStatus.ACTIVE: + return False + + if job.process: + try: + job.process.terminate() + except ProcessLookupError: + pass + + job.status = JobStatus.CANCELLED + job.finished_at = time.time() + self._active_count = max(0, self._active_count - 1) + self._save_queue() + await self.ws_manager.broadcast_queue_update() + logging.info(f"Job abgebrochen: {job.media.source_filename}") + return True + + async def retry_job(self, job_id: int) -> bool: + """Setzt fehlgeschlagenen Job zurueck auf QUEUED""" + job = self.jobs.get(job_id) + if not job or job.status not in (JobStatus.FAILED, JobStatus.CANCELLED): + return False + + job.status = JobStatus.QUEUED + job.progress_percent = 0.0 + job.progress_frames = 0 + job.progress_fps = 0.0 + job.progress_speed = 0.0 + job.progress_size_bytes = 0 + job.progress_time_sec = 0.0 + job.progress_eta_sec = 0.0 + job.started_at = None + job.finished_at = None + job._stat_fps = [0.0, 0] + job._stat_speed = [0.0, 0] + job._stat_bitrate = [0, 0] + self._save_queue() + await self.ws_manager.broadcast_queue_update() + logging.info(f"Job wiederholt: {job.media.source_filename}") + return True + + def get_queue_state(self) -> dict: + """Queue-Status fuer WebSocket""" + queue = {} + for job_id, job in self.jobs.items(): + if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE, + JobStatus.FAILED, JobStatus.CANCELLED): + queue[job_id] = job.to_dict_queue() + return {"data_queue": queue} + + def get_active_jobs(self) -> dict: + """Aktive Jobs fuer WebSocket""" + active = {} + for job_id, job in self.jobs.items(): + if job.status == JobStatus.ACTIVE: + active[job_id] = job.to_dict_active() + return {"data_convert": active} + + def get_all_jobs(self) -> list[dict]: + """Alle Jobs als Liste fuer API""" + return [ + {"id": jid, **job.to_dict_active(), "status_name": job.status.name} + for jid, job in self.jobs.items() + ] + + # --- Interner Queue-Worker --- + + async def _process_loop(self) -> None: + """Hauptschleife: Startet neue Jobs wenn Kapazitaet frei""" + while self._running: + try: + if self._active_count < self.config.max_parallel_jobs: + next_job = self._get_next_queued() + if next_job: + asyncio.create_task(self._execute_job(next_job)) + except Exception as e: + logging.error(f"Queue-Worker Fehler: {e}") + + await asyncio.sleep(0.5) + + async def _execute_job(self, job: ConversionJob) -> None: + """Fuehrt einzelnen Konvertierungs-Job aus""" + self._active_count += 1 + job.status = JobStatus.ACTIVE + job.started_at = time.time() + + await self.ws_manager.broadcast_queue_update() + + command = self.encoder.build_command(job) + logging.info( + f"Starte Konvertierung: {job.media.source_filename}\n" + f" Befehl: {' '.join(command)}" + ) + + try: + job.process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + progress = ProgressParser(self.ws_manager.broadcast_progress) + await progress.monitor(job) + await job.process.wait() + + if job.process.returncode == 0: + job.status = JobStatus.FINISHED + # Tatsaechliche Dateigroesse von Disk lesen + if os.path.exists(job.target_path): + job.progress_size_bytes = os.path.getsize(job.target_path) + logging.info( + f"Konvertierung abgeschlossen: {job.media.source_filename} " + f"({MediaFile.format_time(time.time() - job.started_at)})" + ) + await self._post_conversion_cleanup(job) + else: + job.status = JobStatus.FAILED + error_output = progress.get_error_output() + logging.error( + f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): " + f"{job.media.source_filename}\n" + f" ffmpeg stderr:\n{error_output}" + ) + + except asyncio.CancelledError: + job.status = JobStatus.CANCELLED + logging.info(f"Konvertierung abgebrochen: {job.media.source_filename}") + except Exception as e: + job.status = JobStatus.FAILED + logging.error(f"Fehler bei Konvertierung: {e}") + finally: + 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() + + async def _post_conversion_cleanup(self, job: ConversionJob) -> None: + """Cleanup nach erfolgreicher Konvertierung""" + files_cfg = self.config.files_config + + # Quelldatei loeschen: Global per Config ODER per Job-Option + should_delete = files_cfg.get("delete_source", False) or job.delete_source + + if should_delete: + target_exists = os.path.exists(job.target_path) + target_size = os.path.getsize(job.target_path) if target_exists else 0 + if target_exists and target_size > 0: + try: + os.remove(job.media.source_path) + logging.info(f"Quelldatei geloescht: {job.media.source_path}") + except OSError as e: + logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}") + + cleanup_cfg = self.config.cleanup_config + if cleanup_cfg.get("enabled", False): + deleted = self.scanner.cleanup_directory(job.media.source_dir) + if deleted: + logging.info( + f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}" + ) + + def _get_next_queued(self) -> Optional[ConversionJob]: + """Naechster Job mit Status QUEUED (FIFO)""" + for job in self.jobs.values(): + if job.status == JobStatus.QUEUED: + return job + return None + + def _is_duplicate(self, source_path: str) -> bool: + """Prueft ob Pfad bereits in Queue (nur aktive/wartende)""" + for job in self.jobs.values(): + if (job.media.source_path == source_path and + job.status in (JobStatus.QUEUED, JobStatus.ACTIVE)): + return True + return False + + def _save_queue(self) -> None: + """Persistiert Queue nach queue.json""" + queue_data = [] + for job in self.jobs.values(): + if job.status in (JobStatus.QUEUED, JobStatus.FAILED): + queue_data.append(job.to_json()) + try: + with open(self._queue_file, "w", encoding="utf-8") as f: + json.dump(queue_data, f, indent=2) + except Exception as e: + logging.error(f"Queue speichern fehlgeschlagen: {e}") + + def _load_queue(self) -> list[dict]: + """Laedt Queue aus queue.json, gibt Job-Daten zurueck""" + if not os.path.exists(self._queue_file): + return [] + try: + with open(self._queue_file, "r", encoding="utf-8") as f: + queue_data = json.load(f) + pending = [ + { + "source_path": item["source_path"], + "preset_name": item.get("preset_name"), + "delete_source": item.get("delete_source", False), + } + for item in queue_data + if item.get("status", 0) == JobStatus.QUEUED + ] + if pending: + logging.info(f"{len(pending)} Jobs aus Queue geladen") + return pending + except Exception as e: + logging.error(f"Queue laden fehlgeschlagen: {e}") + return [] + + # --- MariaDB Statistik-Datenbank --- + + def _get_db_config(self) -> dict: + """DB-Konfiguration aus Settings""" + db_cfg = self.config.settings.get("database", {}) + return { + "host": db_cfg.get("host", "192.168.155.11"), + "port": db_cfg.get("port", 3306), + "user": db_cfg.get("user", "video"), + "password": db_cfg.get("password", "8715"), + "db": db_cfg.get("database", "video_converter"), + } + + async def _get_pool(self) -> Optional[aiomysql.Pool]: + """Gibt den Connection-Pool zurueck, erstellt ihn bei Bedarf""" + if self._db_pool is not None: + return self._db_pool + db_cfg = self._get_db_config() + self._db_pool = await aiomysql.create_pool( + host=db_cfg["host"], + port=db_cfg["port"], + user=db_cfg["user"], + password=db_cfg["password"], + db=db_cfg["db"], + charset="utf8mb4", + autocommit=True, + minsize=1, + maxsize=5, + connect_timeout=10, + ) + return self._db_pool + + async def _init_db(self) -> None: + """Erstellt MariaDB-Tabelle falls nicht vorhanden""" + db_cfg = self._get_db_config() + try: + pool = await self._get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS conversions ( + id INT AUTO_INCREMENT PRIMARY KEY, + source_path VARCHAR(1024) NOT NULL, + source_filename VARCHAR(512) NOT NULL, + source_size_bytes BIGINT, + source_duration_sec DOUBLE, + source_frame_rate DOUBLE, + source_frames_total INT, + target_path VARCHAR(1024), + target_filename VARCHAR(512), + target_size_bytes BIGINT, + target_container VARCHAR(10), + preset_name VARCHAR(64), + status INT, + started_at DOUBLE, + finished_at DOUBLE, + duration_sec DOUBLE, + avg_fps DOUBLE, + avg_speed DOUBLE, + avg_bitrate INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_created_at (created_at), + INDEX idx_status (status) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + logging.info( + f"MariaDB verbunden: {db_cfg['host']}:{db_cfg['port']}/" + f"{db_cfg['db']}" + ) + except Exception as e: + logging.error( + f"MariaDB Initialisierung fehlgeschlagen " + f"({db_cfg['host']}:{db_cfg['port']}): {e}" + ) + logging.warning("Statistiken werden ohne Datenbank ausgefuehrt") + self._db_pool = None + + async def _save_stats(self, job: ConversionJob) -> None: + """Speichert Konvertierungs-Ergebnis in MariaDB""" + if self._db_pool is None: + return + stats = job.to_dict_stats() + try: + pool = await self._get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + INSERT INTO conversions ( + source_path, source_filename, source_size_bytes, + source_duration_sec, source_frame_rate, source_frames_total, + target_path, target_filename, target_size_bytes, + target_container, preset_name, status, + started_at, finished_at, duration_sec, + avg_fps, avg_speed, avg_bitrate + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, ( + stats["source_path"], stats["source_filename"], + stats["source_size_bytes"], stats["source_duration_sec"], + stats["source_frame_rate"], stats["source_frames_total"], + stats["target_path"], stats["target_filename"], + stats["target_size_bytes"], stats["target_container"], + stats["preset_name"], stats["status"], + stats["started_at"], stats["finished_at"], + stats["duration_sec"], stats["avg_fps"], + stats["avg_speed"], stats["avg_bitrate"], + )) + + # Max-Eintraege bereinigen + max_entries = self.config.settings.get( + "statistics", {} + ).get("max_entries", 5000) + await cur.execute( + "DELETE FROM conversions WHERE id NOT IN (" + "SELECT id FROM (SELECT id FROM conversions " + "ORDER BY created_at DESC LIMIT %s) AS tmp)", + (max_entries,) + ) + except Exception as e: + logging.error(f"Statistik speichern fehlgeschlagen: {e}") + + async def get_statistics(self, limit: int = 50, + offset: int = 0) -> list[dict]: + """Liest Statistiken aus MariaDB""" + if self._db_pool is None: + return [] + try: + pool = await self._get_pool() + async with pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM conversions ORDER BY created_at DESC " + "LIMIT %s OFFSET %s", + (limit, offset), + ) + rows = await cur.fetchall() + # MariaDB-Typen JSON-kompatibel machen + result = [] + for row in rows: + entry = {} + for k, v in row.items(): + if isinstance(v, Decimal): + entry[k] = float(v) + elif hasattr(v, "isoformat"): + entry[k] = str(v) + else: + entry[k] = v + result.append(entry) + return result + except Exception as e: + logging.error(f"Statistik lesen fehlgeschlagen: {e}") + return [] + + async def get_statistics_summary(self) -> dict: + """Zusammenfassung der Statistiken""" + if self._db_pool is None: + return {} + try: + pool = await self._get_pool() + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END), + SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END), + SUM(source_size_bytes), + SUM(target_size_bytes), + SUM(duration_sec), + AVG(avg_fps), + AVG(avg_speed) + FROM conversions + """) + row = await cur.fetchone() + if row: + # Decimal -> float/int fuer JSON + def _n(v, as_int=False): + v = v or 0 + return int(v) if as_int else float(v) + + return { + "total": _n(row[0], True), + "finished": _n(row[1], True), + "failed": _n(row[2], True), + "total_source_size": _n(row[3], True), + "total_target_size": _n(row[4], True), + "space_saved": _n(row[3], True) - _n(row[4], True), + "total_duration": _n(row[5]), + "avg_fps": round(float(row[6] or 0), 1), + "avg_speed": round(float(row[7] or 0), 2), + } + return {} + except Exception as e: + logging.error(f"Statistik-Zusammenfassung fehlgeschlagen: {e}") + return {} diff --git a/video-konverter/app/services/scanner.py b/video-konverter/app/services/scanner.py new file mode 100644 index 0000000..df2c09f --- /dev/null +++ b/video-konverter/app/services/scanner.py @@ -0,0 +1,149 @@ +"""Rekursives Ordner-Scanning und Cleanup-Service""" +import os +import logging +import fnmatch +from pathlib import Path +from typing import Optional +from app.config import Config + + +# Sicherheits-Blacklist: Diese Pfade duerfen NIE bereinigt werden +_PROTECTED_PATHS = {"/", "/home", "/root", "/mnt", "/media", "/tmp", "/var", "/etc"} + + +class ScannerService: + """Scannt Ordner nach Videodateien und fuehrt optionalen Cleanup durch""" + + def __init__(self, config: Config): + self.config = config + + def scan_path(self, path: str, recursive: Optional[bool] = None) -> list[str]: + """ + Scannt einen Pfad nach Videodateien. + - Einzelne Datei: Gibt [path] zurueck wenn gueltige Extension + - Ordner: Scannt nach konfigurierten Extensions + """ + path = path.strip() + if not path or not os.path.exists(path): + logging.warning(f"Pfad nicht gefunden: {path}") + return [] + + files_cfg = self.config.files_config + extensions = set(files_cfg.get("scan_extensions", [])) + + # Einzelne Datei + if os.path.isfile(path): + ext = os.path.splitext(path)[1].lower() + if ext in extensions: + return [path] + else: + logging.warning(f"Dateiendung {ext} nicht in scan_extensions: {path}") + return [] + + # Ordner scannen + if os.path.isdir(path): + use_recursive = recursive if recursive is not None else \ + files_cfg.get("recursive_scan", True) + return self._scan_directory(path, use_recursive, extensions) + + return [] + + def _scan_directory(self, directory: str, recursive: bool, + extensions: set) -> list[str]: + """Scannt Ordner nach Video-Extensions""" + results = [] + + if recursive: + for root, dirs, files in os.walk(directory): + for f in files: + if Path(f).suffix.lower() in extensions: + results.append(os.path.join(root, f)) + else: + try: + for f in os.listdir(directory): + full = os.path.join(directory, f) + if os.path.isfile(full) and Path(f).suffix.lower() in extensions: + results.append(full) + except PermissionError: + logging.error(f"Keine Leseberechtigung: {directory}") + + logging.info(f"Scan: {len(results)} Dateien in {directory} " + f"(rekursiv={recursive})") + return sorted(results) + + def cleanup_directory(self, directory: str) -> list[str]: + """ + Loescht konfigurierte Datei-Typen aus einem Verzeichnis. + Ausfuehrliche Sicherheits-Checks! + """ + cleanup_cfg = self.config.cleanup_config + + # Check 1: Cleanup aktiviert? + if not cleanup_cfg.get("enabled", False): + return [] + + # Check 2: Gueltiger Pfad? + if not os.path.isdir(directory): + return [] + + # Check 3: Geschuetzter Pfad? + abs_path = os.path.abspath(directory) + if abs_path in _PROTECTED_PATHS: + logging.warning(f"Geschuetzter Pfad, Cleanup uebersprungen: {abs_path}") + return [] + + # Check 4: Ordner muss Videodateien enthalten (oder enthalten haben) + if not self._has_video_files(directory): + logging.debug(f"Keine Videodateien im Ordner, Cleanup uebersprungen: " + f"{directory}") + return [] + + delete_extensions = set(cleanup_cfg.get("delete_extensions", [])) + deleted = [] + + # NUR im angegebenen Ordner, NICHT rekursiv + try: + for f in os.listdir(directory): + full_path = os.path.join(directory, f) + if not os.path.isfile(full_path): + continue + + ext = os.path.splitext(f)[1].lower() + if ext not in delete_extensions: + continue + + if self._is_excluded(f): + continue + + try: + os.remove(full_path) + deleted.append(full_path) + logging.info(f"Cleanup: Geloescht {full_path}") + except OSError as e: + logging.error(f"Cleanup: Loeschen fehlgeschlagen {full_path}: {e}") + + except PermissionError: + logging.error(f"Keine Berechtigung fuer Cleanup: {directory}") + + if deleted: + logging.info(f"Cleanup: {len(deleted)} Dateien in {directory} geloescht") + + return deleted + + def _is_excluded(self, filename: str) -> bool: + """Prueft ob Dateiname auf exclude_patterns matcht""" + patterns = self.config.cleanup_config.get("exclude_patterns", []) + return any( + fnmatch.fnmatch(filename.lower(), p.lower()) for p in patterns + ) + + def _has_video_files(self, directory: str) -> bool: + """Prueft ob Ordner mindestens eine Videodatei enthaelt""" + extensions = set(self.config.files_config.get("scan_extensions", [])) + try: + for f in os.listdir(directory): + if os.path.splitext(f)[1].lower() in extensions: + return True + except PermissionError: + pass + return False diff --git a/video-konverter/app/services/tvdb.py b/video-konverter/app/services/tvdb.py new file mode 100644 index 0000000..4a1fc90 --- /dev/null +++ b/video-konverter/app/services/tvdb.py @@ -0,0 +1,1033 @@ +"""TheTVDB API v4 Integration fuer Serien-Metadaten""" +import logging +import os +from typing import Optional, TYPE_CHECKING + +import aiohttp +import aiomysql + +from app.config import Config + +if TYPE_CHECKING: + from app.services.library import LibraryService + +# tvdb-v4-official ist optional - funktioniert auch ohne +try: + import tvdb_v4_official + TVDB_AVAILABLE = True +except ImportError: + TVDB_AVAILABLE = False + logging.warning("tvdb-v4-official nicht installiert - TVDB deaktiviert") + +# Artwork-Typ-IDs -> Namen +ARTWORK_TYPE_MAP = { + 1: "banner", + 2: "poster", + 3: "fanart", + 5: "icon", + 6: "season_poster", + 7: "season_banner", + 22: "clearlogo", + 23: "clearart", +} + + +class TVDBService: + """TVDB API v4 Client fuer Serien- und Film-Metadaten""" + + def __init__(self, config: Config): + self.config = config + self._client = None + self._db_pool: Optional[aiomysql.Pool] = None + + @property + def _api_key(self) -> str: + return self.config.settings.get("library", {}).get("tvdb_api_key", "") + + @property + def _pin(self) -> str: + return self.config.settings.get("library", {}).get("tvdb_pin", "") + + @property + def _language(self) -> str: + """Konfigurierte TVDB-Sprache (Standard: deu)""" + return self.config.settings.get("library", {}).get( + "tvdb_language", "deu" + ) + + @property + def is_configured(self) -> bool: + return TVDB_AVAILABLE and bool(self._api_key) + + def set_db_pool(self, pool: aiomysql.Pool) -> None: + """Setzt den DB-Pool (geteilt mit LibraryService)""" + self._db_pool = pool + + def _get_client(self): + """Erstellt oder gibt TVDB-Client zurueck""" + if not TVDB_AVAILABLE: + return None + if not self._api_key: + return None + + if self._client is None: + try: + if self._pin: + self._client = tvdb_v4_official.TVDB( + self._api_key, pin=self._pin + ) + else: + self._client = tvdb_v4_official.TVDB(self._api_key) + logging.info("TVDB Client verbunden") + except Exception as e: + logging.error(f"TVDB Verbindung fehlgeschlagen: {e}") + return None + + return self._client + + # === DB-Tabellen === + + async def init_db(self) -> None: + """Erstellt TVDB-Cache-Tabellen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tvdb_cast_cache ( + id INT AUTO_INCREMENT PRIMARY KEY, + series_tvdb_id INT NOT NULL, + person_name VARCHAR(256) NOT NULL, + character_name VARCHAR(256), + sort_order INT DEFAULT 0, + image_url VARCHAR(512), + person_image_url VARCHAR(512), + cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_series (series_tvdb_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + await cur.execute(""" + CREATE TABLE IF NOT EXISTS tvdb_artwork_cache ( + id INT AUTO_INCREMENT PRIMARY KEY, + series_tvdb_id INT NOT NULL, + artwork_type VARCHAR(32) NOT NULL, + image_url VARCHAR(512) NOT NULL, + thumbnail_url VARCHAR(512), + width INT DEFAULT 0, + height INT DEFAULT 0, + is_primary TINYINT DEFAULT 0, + local_path VARCHAR(1024) NULL, + cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_series (series_tvdb_id), + INDEX idx_type (artwork_type) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + + # Neue Spalten in library_series (falls noch nicht vorhanden) + try: + await cur.execute( + "ALTER TABLE library_series " + "ADD COLUMN metadata_path VARCHAR(1024) NULL" + ) + except Exception: + pass # Spalte existiert bereits + try: + await cur.execute( + "ALTER TABLE library_series " + "ADD COLUMN genres VARCHAR(512) NULL" + ) + except Exception: + pass + + logging.info("TVDB-Cache-Tabellen initialisiert") + except Exception as e: + logging.error(f"TVDB-Tabellen erstellen fehlgeschlagen: {e}") + + @staticmethod + def _serialize_row(row: dict) -> dict: + """DB-Row JSON-kompatibel machen (datetime -> str)""" + result = {} + for k, v in row.items(): + if hasattr(v, "isoformat"): + result[k] = str(v) + else: + result[k] = v + return result + + # === Suche === + + def _localize_search_result(self, item: dict) -> tuple[str, str]: + """Lokalisierten Namen + Overview aus Suchergebnis extrahieren. + Nutzt konfigurierte Sprache, Fallback auf Englisch.""" + lang = self._language + name = item.get("name", "") + overview = item.get("overview", "") + + # translations = {"deu": "Deutscher Titel", "eng": "English", ...} + trans = item.get("translations") or {} + if isinstance(trans, dict): + name = trans.get(lang) or name + + # overviews = {"deu": "Deutsche Beschreibung", "eng": "English", ...} + overviews = item.get("overviews") or {} + if isinstance(overviews, dict): + overview = (overviews.get(lang) + or overviews.get("eng") + or overview) + + return name, overview + + async def search_series(self, query: str, + language: Optional[str] = None) -> list[dict]: + """Sucht Serien auf TVDB. + + Args: + query: Suchbegriff + language: Sprache fuer Ergebnisse (z.B. 'deu', 'eng'). + None = konfigurierte Sprache verwenden. + """ + client = self._get_client() + if not client: + return [] + + # Sprache fuer Lokalisierung + display_lang = language or self._language + + try: + results = client.search(query, type="series") + if not results: + return [] + + series_list = [] + for item in results[:20]: # 20 statt 10 Ergebnisse + # Lokalisierung mit gewaehlter Sprache + name = item.get("name", "") + overview = item.get("overview", "") + + trans = item.get("translations") or {} + if isinstance(trans, dict): + # Gewaehlte Sprache oder Original + name = trans.get(display_lang) or name + + overviews = item.get("overviews") or {} + if isinstance(overviews, dict): + overview = (overviews.get(display_lang) + or overviews.get("eng") + or overview) + + # Original-Name fuer Anzeige wenn anders + original_name = item.get("name", "") + + series_list.append({ + "tvdb_id": item.get("tvdb_id") or item.get("objectID"), + "name": name, + "original_name": original_name if original_name != name else "", + "overview": overview, + "first_air_date": item.get("first_air_time") + or item.get("firstAirDate", ""), + "year": item.get("year", ""), + "status": item.get("status", ""), + "poster": item.get("thumbnail") + or item.get("image_url", ""), + }) + return series_list + except Exception as e: + logging.error(f"TVDB Suche fehlgeschlagen: {e}") + return [] + + async def search_movies(self, query: str) -> list[dict]: + """Sucht Filme auf TVDB""" + client = self._get_client() + if not client: + return [] + + try: + results = client.search(query, type="movie") + if not results: + return [] + + movie_list = [] + for item in results[:10]: + name, overview = self._localize_search_result(item) + movie_list.append({ + "tvdb_id": item.get("tvdb_id") or item.get("objectID"), + "name": name, + "overview": overview, + "year": item.get("year", ""), + "poster": item.get("thumbnail") + or item.get("image_url", ""), + }) + return movie_list + except Exception as e: + logging.error(f"TVDB Film-Suche fehlgeschlagen: {e}") + return [] + + # === Serien-Info === + + async def get_series_info(self, tvdb_id: int) -> Optional[dict]: + """Holt Serien-Details von TVDB (Dict-basiert)""" + client = self._get_client() + if not client: + return None + + try: + series = client.get_series_extended(tvdb_id) + if not series: + return None + + # API gibt Dict zurueck + poster_url = series.get("image", "") + name = series.get("name", "") + overview = series.get("overview", "") + + # Lokalisierte Uebersetzung holen (Fallback Englisch) + pref_lang = self._language + for lang in (pref_lang, "eng"): + try: + trans = client.get_series_translation(tvdb_id, lang) + if trans: + if not overview and trans.get("overview"): + overview = trans["overview"] + if lang == pref_lang: + if trans.get("overview"): + overview = trans["overview"] + if trans.get("name"): + name = trans["name"] + if overview: + break + except Exception: + pass + + artworks = series.get("artworks") or [] + for art in artworks: + art_type = art.get("type", {}) + type_id = art_type.get("id", 0) if isinstance(art_type, dict) else art_type + if type_id == 2: + poster_url = art.get("image", poster_url) + break + + # Staffeln zaehlen + seasons = [] + for s in (series.get("seasons") or []): + s_type = s.get("type", {}) + type_id = s_type.get("id", 0) if isinstance(s_type, dict) else 0 + # Typ 1 = official + if type_id == 1 or not s_type: + s_num = s.get("number", 0) + if s_num and s_num > 0: + seasons.append(s_num) + + # Genres + genres = [] + for g in (series.get("genres") or []): + gname = g.get("name", "") + if gname: + genres.append(gname) + + # Status + status_obj = series.get("status", {}) + status_name = "" + if isinstance(status_obj, dict): + status_name = status_obj.get("name", "") + elif isinstance(status_obj, str): + status_name = status_obj + + return { + "tvdb_id": tvdb_id, + "name": name, + "overview": overview, + "first_aired": series.get("firstAired", ""), + "status": status_name, + "poster_url": poster_url, + "total_seasons": len(seasons), + "genres": ", ".join(genres), + } + except Exception as e: + logging.error(f"TVDB Serien-Info fehlgeschlagen (ID {tvdb_id}): {e}") + return None + + # === Film-Info === + + async def get_movie_info(self, tvdb_id: int) -> Optional[dict]: + """Holt Film-Details von TVDB""" + client = self._get_client() + if not client: + return None + + try: + movie = client.get_movie_extended(tvdb_id) + if not movie: + return None + + poster_url = movie.get("image", "") + name = movie.get("name", "") + overview = movie.get("overview", "") + + # Lokalisierte Uebersetzung holen (Fallback Englisch) + pref_lang = self._language + for lang in (pref_lang, "eng"): + try: + trans = client.get_movie_translation(tvdb_id, lang) + if trans: + if not overview and trans.get("overview"): + overview = trans["overview"] + if lang == pref_lang and trans.get("name"): + name = trans["name"] + if overview: + break + except Exception: + pass + + # Genres + genres = [] + for g in (movie.get("genres") or []): + gname = g.get("name", "") + if gname: + genres.append(gname) + + # Status + status_obj = movie.get("status", {}) + status_name = "" + if isinstance(status_obj, dict): + status_name = status_obj.get("name", "") + elif isinstance(status_obj, str): + status_name = status_obj + + # Jahr + year = movie.get("year") + if not year: + first_release = movie.get("first_release", {}) + if isinstance(first_release, dict): + date_str = first_release.get("date", "") + if date_str and len(date_str) >= 4: + try: + year = int(date_str[:4]) + except ValueError: + pass + + return { + "tvdb_id": tvdb_id, + "name": name, + "overview": overview, + "year": year, + "poster_url": poster_url, + "genres": ", ".join(genres), + "runtime": movie.get("runtime"), + "status": status_name, + } + except Exception as e: + logging.error( + f"TVDB Film-Info fehlgeschlagen (ID {tvdb_id}): {e}" + ) + return None + + async def match_and_update_movie(self, movie_id: int, + tvdb_id: int, + library_service: 'LibraryService' + ) -> dict: + """TVDB-ID einem Film zuordnen und Infos aktualisieren""" + info = await self.get_movie_info(tvdb_id) + if not info: + return {"error": "TVDB Film-Info nicht gefunden"} + + pool = self._db_pool + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE library_movies SET + tvdb_id = %s, + title = %s, + overview = %s, + year = %s, + poster_url = %s, + genres = %s, + runtime = %s, + status = %s, + last_updated = NOW() + WHERE id = %s + """, ( + tvdb_id, + info["name"], + info.get("overview", ""), + info.get("year"), + info.get("poster_url", ""), + info.get("genres", ""), + info.get("runtime"), + info.get("status", ""), + movie_id, + )) + + return { + "success": True, + "name": info["name"], + "year": info.get("year"), + } + except Exception as e: + logging.error(f"TVDB Film-Match fehlgeschlagen: {e}") + return {"error": str(e)} + + # === Cast / Characters === + + async def get_series_characters(self, tvdb_id: int) -> list[dict]: + """Holt Cast/Darsteller von TVDB. + Prueft zuerst DB-Cache, dann TVDB-API.""" + # Cache pruefen + cached = await self._get_cached_cast(tvdb_id) + if cached: + return cached + + client = self._get_client() + if not client: + return [] + + try: + series = client.get_series_extended(tvdb_id) + if not series: + return [] + + characters = [] + for c in (series.get("characters") or []): + # type=3 ist Actor + if c.get("type") != 3: + continue + characters.append({ + "person_name": c.get("personName", ""), + "character_name": c.get("name", ""), + "sort_order": c.get("sort", 0), + "image_url": c.get("image", ""), + "person_image_url": c.get("personImgURL", ""), + }) + + # Sortieren + characters.sort(key=lambda x: x.get("sort_order", 999)) + + # In DB cachen + await self._cache_cast(tvdb_id, characters) + + return characters + except Exception as e: + logging.error(f"TVDB Cast laden fehlgeschlagen (ID {tvdb_id}): {e}") + return [] + + async def _get_cached_cast(self, tvdb_id: int) -> Optional[list[dict]]: + """Cast aus DB-Cache laden""" + if not self._db_pool: + return None + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM tvdb_cast_cache " + "WHERE series_tvdb_id = %s ORDER BY sort_order", + (tvdb_id,) + ) + rows = await cur.fetchall() + if rows: + return [self._serialize_row(r) for r in rows] + except Exception: + pass + return None + + async def _cache_cast(self, tvdb_id: int, + characters: list[dict]) -> None: + """Darsteller in DB cachen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tvdb_cast_cache " + "WHERE series_tvdb_id = %s", (tvdb_id,) + ) + for c in characters: + await cur.execute( + "INSERT INTO tvdb_cast_cache " + "(series_tvdb_id, person_name, character_name, " + "sort_order, image_url, person_image_url) " + "VALUES (%s, %s, %s, %s, %s, %s)", + (tvdb_id, c["person_name"], + c["character_name"], c["sort_order"], + c["image_url"], c["person_image_url"]) + ) + except Exception as e: + logging.error(f"TVDB Cast cachen fehlgeschlagen: {e}") + + # === Artworks === + + async def get_series_artworks(self, tvdb_id: int) -> list[dict]: + """Holt Artworks einer Serie. + Prueft zuerst DB-Cache, dann TVDB-API.""" + cached = await self._get_cached_artworks(tvdb_id) + if cached: + return cached + + client = self._get_client() + if not client: + return [] + + try: + series = client.get_series_extended(tvdb_id) + if not series: + return [] + + artworks = [] + for a in (series.get("artworks") or []): + art_type_obj = a.get("type", {}) + type_id = art_type_obj.get("id", 0) if isinstance( + art_type_obj, dict + ) else art_type_obj + type_name = ARTWORK_TYPE_MAP.get(type_id) + if not type_name: + continue + + artworks.append({ + "artwork_type": type_name, + "image_url": a.get("image", ""), + "thumbnail_url": a.get("thumbnail", ""), + "width": a.get("width", 0), + "height": a.get("height", 0), + }) + + # Cachen + await self._cache_artworks(tvdb_id, artworks) + + return artworks + except Exception as e: + logging.error(f"TVDB Artworks laden fehlgeschlagen (ID {tvdb_id}): {e}") + return [] + + async def _get_cached_artworks(self, tvdb_id: int) -> Optional[list[dict]]: + """Artworks aus DB-Cache laden""" + if not self._db_pool: + return None + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + await cur.execute( + "SELECT * FROM tvdb_artwork_cache " + "WHERE series_tvdb_id = %s " + "ORDER BY artwork_type, is_primary DESC", + (tvdb_id,) + ) + rows = await cur.fetchall() + if rows: + return [self._serialize_row(r) for r in rows] + except Exception: + pass + return None + + async def _cache_artworks(self, tvdb_id: int, + artworks: list[dict]) -> None: + """Artworks in DB cachen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tvdb_artwork_cache " + "WHERE series_tvdb_id = %s", (tvdb_id,) + ) + seen_primary = set() + for a in artworks: + art_type = a["artwork_type"] + is_primary = 1 if art_type not in seen_primary else 0 + seen_primary.add(art_type) + await cur.execute( + "INSERT INTO tvdb_artwork_cache " + "(series_tvdb_id, artwork_type, image_url, " + "thumbnail_url, width, height, is_primary) " + "VALUES (%s, %s, %s, %s, %s, %s, %s)", + (tvdb_id, art_type, a["image_url"], + a["thumbnail_url"], a["width"], + a["height"], is_primary) + ) + except Exception as e: + logging.error(f"TVDB Artworks cachen fehlgeschlagen: {e}") + + # === Metadaten herunterladen === + + async def download_metadata(self, series_id: int, tvdb_id: int, + series_folder: str) -> dict: + """Laedt Poster, Fanart, Cast-Bilder in .metadata/ Ordner""" + if not series_folder or not os.path.isdir(series_folder): + return {"error": "Serien-Ordner nicht gefunden"} + + meta_dir = os.path.join(series_folder, ".metadata") + os.makedirs(meta_dir, exist_ok=True) + cast_dir = os.path.join(meta_dir, "cast") + os.makedirs(cast_dir, exist_ok=True) + + downloaded = 0 + errors = 0 + + # Artworks holen (ggf. von API) + artworks = await self.get_series_artworks(tvdb_id) + # Cast holen (ggf. von API) + cast = await self.get_series_characters(tvdb_id) + + async with aiohttp.ClientSession() as session: + # Primaere Bilder herunterladen (Poster, Fanart, Banner) + seen_types = set() + for art in artworks: + art_type = art["artwork_type"] + if art_type in seen_types: + continue + if art_type not in ("poster", "fanart", "banner"): + continue + seen_types.add(art_type) + + url = art["image_url"] + if not url: + continue + ext = os.path.splitext(url)[1] or ".jpg" + target = os.path.join(meta_dir, f"{art_type}{ext}") + + ok = await self._download_file(session, url, target) + if ok: + downloaded += 1 + else: + errors += 1 + + # Cast-Bilder herunterladen + for c in cast: + url = c.get("person_image_url") or c.get("image_url", "") + name = c.get("person_name", "") + if not url or not name: + continue + # Sicherer Dateiname + safe_name = "".join( + ch if ch.isalnum() or ch in " _-" else "_" + for ch in name + ).strip() + ext = os.path.splitext(url)[1] or ".jpg" + target = os.path.join(cast_dir, f"{safe_name}{ext}") + + ok = await self._download_file(session, url, target) + if ok: + downloaded += 1 + else: + errors += 1 + + # metadata_path in DB setzen + if self._db_pool: + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE library_series SET metadata_path = %s " + "WHERE id = %s", + (meta_dir, series_id) + ) + except Exception: + pass + + logging.info( + f"TVDB Metadaten heruntergeladen fuer Serie {series_id}: " + f"{downloaded} Dateien, {errors} Fehler" + ) + return { + "success": True, + "downloaded": downloaded, + "errors": errors, + "metadata_path": meta_dir, + } + + @staticmethod + async def _download_file(session: aiohttp.ClientSession, + url: str, target: str) -> bool: + """Einzelne Datei herunterladen""" + try: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: + if resp.status != 200: + return False + data = await resp.read() + with open(target, "wb") as f: + f.write(data) + return True + except Exception as e: + logging.warning(f"Download fehlgeschlagen: {url}: {e}") + return False + + # === Episoden === + + async def fetch_episodes(self, tvdb_id: int) -> list[dict]: + """Holt alle Episoden einer Serie von TVDB und cached sie in DB. + Nutzt konfigurierte Sprache fuer Episodennamen.""" + client = self._get_client() + if not client: + return [] + + pref_lang = self._language + + try: + episodes = [] + page = 0 + while True: + # Mit Sprach-Parameter abrufen fuer lokalisierte Namen + result = client.get_series_episodes( + tvdb_id, season_type="official", page=page, + lang=pref_lang, + ) + if not result: + break + # API gibt Dict zurueck, kein Objekt + if isinstance(result, dict): + eps = result.get("episodes", []) + elif hasattr(result, "episodes"): + eps = result.episodes + else: + break + if not eps: + break + for ep in eps: + if isinstance(ep, dict): + s_num = ep.get("seasonNumber", 0) + e_num = ep.get("number", 0) + ep_name = ep.get("name", "") + ep_aired = ep.get("aired") + ep_runtime = ep.get("runtime") + else: + s_num = getattr(ep, "seasonNumber", 0) + e_num = getattr(ep, "number", 0) + ep_name = getattr(ep, "name", "") + ep_aired = getattr(ep, "aired", None) + ep_runtime = getattr(ep, "runtime", None) + if s_num and s_num > 0 and e_num and e_num > 0: + episodes.append({ + "season_number": s_num, + "episode_number": e_num, + "episode_name": ep_name or "", + "aired": ep_aired, + "runtime": ep_runtime, + }) + page += 1 + if page > 50: + break + + if episodes and self._db_pool: + await self._cache_episodes(tvdb_id, episodes) + + logging.info(f"TVDB: {len(episodes)} Episoden fuer " + f"Serie {tvdb_id} geladen ({pref_lang})") + return episodes + except Exception as e: + logging.error(f"TVDB Episoden laden fehlgeschlagen " + f"(ID {tvdb_id}): {e}") + return [] + + async def _cache_episodes(self, tvdb_id: int, + episodes: list[dict]) -> None: + """Episoden in DB cachen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "DELETE FROM tvdb_episode_cache " + "WHERE series_tvdb_id = %s", + (tvdb_id,) + ) + for ep in episodes: + await cur.execute( + "INSERT INTO tvdb_episode_cache " + "(series_tvdb_id, season_number, episode_number, " + "episode_name, aired, runtime) " + "VALUES (%s, %s, %s, %s, %s, %s)", + ( + tvdb_id, ep["season_number"], + ep["episode_number"], ep["episode_name"], + ep["aired"], ep["runtime"], + ) + ) + except Exception as e: + logging.error(f"TVDB Episode cachen fehlgeschlagen: {e}") + + # === Match & Update === + + async def match_and_update_series(self, series_id: int, + tvdb_id: int, + library_service: 'LibraryService' + ) -> dict: + """TVDB-ID zuordnen, Infos holen, Episoden + Cast + Artworks cachen""" + info = await self.get_series_info(tvdb_id) + if not info: + return {"error": "TVDB-Serien-Info nicht gefunden"} + + episodes = await self.fetch_episodes(tvdb_id) + + # Cast und Artworks cachen (im Hintergrund) + await self.get_series_characters(tvdb_id) + await self.get_series_artworks(tvdb_id) + + pool = self._db_pool + if not pool: + return {"error": "Keine DB-Verbindung"} + + try: + async with pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE library_series SET + tvdb_id = %s, + title = %s, + overview = %s, + first_aired = %s, + poster_url = %s, + status = %s, + total_seasons = %s, + total_episodes = %s, + genres = %s, + last_updated = NOW() + WHERE id = %s + """, ( + tvdb_id, + info["name"], + info.get("overview", ""), + info.get("first_aired") or None, + info.get("poster_url", ""), + info.get("status", ""), + info.get("total_seasons", 0), + len(episodes), + info.get("genres", ""), + series_id, + )) + + await self._update_episode_titles(series_id, tvdb_id) + await library_service._update_series_counts(series_id) + + return { + "success": True, + "name": info["name"], + "total_episodes": len(episodes), + } + except Exception as e: + logging.error(f"TVDB Match fehlgeschlagen: {e}") + return {"error": str(e)} + + async def _update_episode_titles(self, series_id: int, + tvdb_id: int) -> None: + """Episoden-Titel aus TVDB-Cache in lokale Videos uebertragen""" + if not self._db_pool: + return + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE library_videos v + JOIN tvdb_episode_cache tc + ON tc.series_tvdb_id = %s + AND tc.season_number = v.season_number + AND tc.episode_number = v.episode_number + SET v.episode_title = tc.episode_name + WHERE v.series_id = %s + """, (tvdb_id, series_id)) + except Exception as e: + logging.error(f"Episoden-Titel aktualisieren fehlgeschlagen: {e}") + + # === Auto-Match === + + @staticmethod + def _clean_search_title(title: str) -> tuple[str, str]: + """Bereinigt Titel fuer bessere TVDB-Suche. + Entfernt fuehrende Sortier-Nummern, Aufloesung-Suffixe, Klammern.""" + import re + t = title.strip() + # Fuehrende Sortier-Nummern entfernen ("1 X-Men" -> "X-Men") + t = re.sub(r'^\d{1,2}\s+', '', t) + # Aufloesung/Qualitaets-Suffixe entfernen + t = re.sub( + r'\s*(720p|1080p|2160p|4k|bluray|bdrip|webrip|web-dl|hdtv|' + r'x264|x265|hevc|aac|dts|remux)\s*', + ' ', t, flags=re.IGNORECASE + ).strip() + # Klammern mit Inhalt entfernen "(2020)" etc. + t_no_parens = re.sub(r'\s*\([^)]*\)\s*', ' ', t).strip() + # Trailing-Nummern entfernen ("X-Men 1" -> "X-Men") + t_no_num = re.sub(r'\s+\d{1,2}$', '', t) + # Variante 1: bereinigt, Variante 2: ohne Klammern und Trailing-Nummern + clean1 = t.strip() + clean2 = t_no_parens.strip() if t_no_parens != clean1 else t_no_num.strip() + return clean1, clean2 + + async def collect_suggestions( + self, + media_type: str, + progress_callback=None, + ) -> list[dict]: + """Sammelt TVDB-Vorschlaege fuer alle Serien oder Filme ohne TVDB. + media_type: 'series' oder 'movies' + Gibt Liste von Vorschlaegen zurueck: [{ + id, local_name, year, type, + suggestions: [{tvdb_id, name, year, poster, overview}] + }]""" + if not self._db_pool: + return [] + + # Items ohne TVDB laden + async with self._db_pool.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cur: + if media_type == "series": + await cur.execute( + "SELECT id, folder_name, title " + "FROM library_series WHERE tvdb_id IS NULL " + "ORDER BY title" + ) + else: + await cur.execute( + "SELECT id, folder_name, title, year " + "FROM library_movies WHERE tvdb_id IS NULL " + "ORDER BY title" + ) + items = await cur.fetchall() + + total = len(items) + proposals = [] + + for i, item in enumerate(items): + name = item.get("title") or item["folder_name"] + t_full, t_clean = self._clean_search_title(name) + + try: + search_fn = (self.search_series if media_type == "series" + else self.search_movies) + results = await search_fn(t_full) + if not results and t_clean != t_full: + results = await search_fn(t_clean) + except Exception as e: + logging.warning(f"TVDB-Suche fehlgeschlagen: {name}: {e}") + results = [] + + # Top 3 Vorschlaege sammeln + suggestions = [] + for r in (results or [])[:3]: + suggestions.append({ + "tvdb_id": r.get("tvdb_id"), + "name": r.get("name", ""), + "year": r.get("year", ""), + "poster": r.get("poster", ""), + "overview": (r.get("overview") or "")[:150], + }) + + proposals.append({ + "id": item["id"], + "local_name": name, + "year": item.get("year"), + "type": media_type, + "suggestions": suggestions, + }) + + if progress_callback: + await progress_callback( + i + 1, total, name, len(proposals) + ) + + return proposals diff --git a/video-konverter/app/static/css/style.css b/video-konverter/app/static/css/style.css new file mode 100644 index 0000000..ca1dde3 --- /dev/null +++ b/video-konverter/app/static/css/style.css @@ -0,0 +1,1899 @@ +/* === Basis === */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background-color: #0f0f0f; + color: #e0e0e0; + line-height: 1.5; +} + +a { color: #90caf9; text-decoration: none; } +a:hover { text-decoration: underline; } + +/* === Header === */ +header { + background-color: #1a1a1a; + padding: 0.8rem 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #2a2a2a; + position: sticky; + top: 0; + z-index: 100; +} + +header h1 { + font-size: 1.3rem; + font-weight: 600; + color: #fff; +} + +nav { display: flex; gap: 0.5rem; } + +.nav-link { + padding: 0.4rem 0.8rem; + border-radius: 6px; + color: #aaa; + font-size: 0.85rem; + transition: all 0.2s; +} +.nav-link:hover { color: #fff; background: #2a2a2a; text-decoration: none; } +.nav-link.active { color: #fff; background: #333; } + +/* === Main === */ +main { padding: 1.5rem; max-width: 1920px; margin: 0 auto; } + +h2 { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; + color: #fff; +} + +/* === Sections === */ +section { margin-bottom: 2rem; } + +/* === Video Cards (Aktive Konvertierungen) === */ +.video-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 1rem; + margin-bottom: 0.8rem; + transition: border-color 0.2s; +} +.video-card:hover { border-color: #444; } + +.video-card h3 { + font-size: 0.9rem; + font-weight: 500; + margin-bottom: 0.6rem; + color: #fff; + text-align: center; +} + +.video-card-values { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + margin-top: 0.8rem; +} + +.video-card-values-items { + text-align: center; + font-size: 0.75rem; + color: #aaa; +} +.video-card-values-items b { color: #ccc; } + +/* === Progress Bar === */ +.progress-container { + width: 100%; + background: #252525; + border-radius: 6px; + height: 8px; + overflow: hidden; + margin-top: 0.5rem; +} + +.progress-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #4caf50, #00c853); + border-radius: 6px; + transition: width 0.4s ease; +} + +.progress-text { + font-size: 0.7rem; + color: #888; + text-align: right; + margin-top: 0.2rem; +} + +/* === Globaler Progress-Balken === */ +.global-progress-container:empty { display: none; } +.global-progress { + background: #1a1a1a; + padding: 0.3rem 1rem; + border-bottom: 1px solid #333; + font-size: 0.75rem; +} +.global-progress .progress-container { + margin-top: 0.2rem; +} +.global-progress-info { + display: flex; + justify-content: space-between; + align-items: center; +} +.global-progress-info .gp-label { + font-weight: 600; + color: #4caf50; +} +#gp-import .gp-label { color: #42a5f5; } +#gp-import .progress-bar { background: linear-gradient(90deg, #1565c0, #42a5f5); } +#gp-convert .gp-label { color: #ff9800; } +#gp-convert .progress-bar { background: linear-gradient(90deg, #e65100, #ff9800); } + +/* === Queue === */ +#queue { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.8rem; +} + +.queue-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.8rem; + display: flex; + flex-direction: column; +} + +.queue-card h4 { + font-size: 0.8rem; + font-weight: 500; + color: #ddd; + margin-bottom: 0.5rem; + word-break: break-all; +} + +.queue-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 0.5rem; +} + +/* === Status Badges === */ +.status-badge { + display: inline-block; + padding: 0.15rem 0.5rem; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 600; +} +.status-badge.ok { background: #1b5e20; color: #81c784; } +.status-badge.warn { background: #e65100; color: #ffb74d; } +.status-badge.error { background: #b71c1c; color: #ef9a9a; } +.status-badge.active { background: #0d47a1; color: #90caf9; } +.status-badge.queued { background: #333; color: #aaa; } +.status-badge.info { background: #455a64; color: #b0bec5; } + +/* === Buttons === */ +.btn-primary { + background: #1976d2; + color: #fff; + border: none; + padding: 0.5rem 1.2rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + transition: background 0.2s; +} +.btn-primary:hover { background: #1565c0; } + +.btn-secondary { + background: #333; + color: #ddd; + border: 1px solid #444; + padding: 0.5rem 1.2rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} +.btn-secondary:hover { background: #444; } + +.btn-danger { + background: transparent; + color: #ef5350; + border: none; + cursor: pointer; + font-size: 0.8rem; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} +.btn-danger:hover { background: #2a1a1a; } + +.btn-small { + font-size: 0.7rem; + padding: 0.2rem 0.4rem; +} + +/* === Admin - Forms === */ +.admin-section { margin-bottom: 2rem; } + +fieldset { + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; + background: #1a1a1a; +} + +legend { + font-size: 0.9rem; + font-weight: 600; + color: #90caf9; + padding: 0 0.5rem; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.form-group { display: flex; flex-direction: column; gap: 0.3rem; } + +.form-group label { + font-size: 0.8rem; + color: #aaa; +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group select { + background: #252525; + border: 1px solid #333; + color: #e0e0e0; + padding: 0.5rem; + border-radius: 6px; + font-size: 0.85rem; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #1976d2; +} + +.checkbox-group label { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + color: #ddd; + cursor: pointer; +} + +.checkbox-group input[type="checkbox"] { + accent-color: #1976d2; + width: 16px; + height: 16px; +} + +.form-actions { + margin-top: 1rem; + display: flex; + gap: 0.5rem; +} + +/* === Presets Grid === */ +.presets-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 0.8rem; +} + +.preset-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.8rem; +} + +.preset-card h3 { + font-size: 0.85rem; + margin-bottom: 0.5rem; + color: #fff; +} + +.preset-details { display: flex; flex-wrap: wrap; gap: 0.3rem; } + +.tag { + display: inline-block; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.65rem; + background: #252525; + color: #aaa; + border: 1px solid #333; +} +.tag.gpu { background: #1b5e20; color: #81c784; border-color: #2e7d32; } +.tag.cpu { background: #0d47a1; color: #90caf9; border-color: #1565c0; } + +/* === Statistics === */ +.stats-summary { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.8rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 1rem; + text-align: center; +} + +.stat-value { + display: block; + font-size: 1.5rem; + font-weight: 700; + color: #fff; +} + +.stat-label { + font-size: 0.75rem; + color: #888; + margin-top: 0.3rem; +} + +/* === Data Table === */ +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th, +.data-table td { + padding: 0.6rem 0.8rem; + text-align: left; + font-size: 0.8rem; + border-bottom: 1px solid #222; +} + +.data-table th { + background: #1a1a1a; + color: #aaa; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; +} + +.data-table tr:hover { background: #1a1a1a; } + +.pagination { + margin-top: 1rem; + text-align: center; +} + +/* === Toast === */ +#toast-container { + position: fixed; + top: 4rem; + right: 1rem; + z-index: 1000; +} + +.toast { + padding: 0.7rem 1.2rem; + border-radius: 8px; + font-size: 0.85rem; + margin-bottom: 0.5rem; + opacity: 0; + transform: translateX(20px); + transition: opacity 0.3s ease, transform 0.3s ease; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} +.toast.show { + opacity: 1; + transform: translateX(0); +} +.toast-success { background: #1b5e20; color: #a5d6a7; border-left: 3px solid #4caf50; } +.toast-error { background: #b71c1c; color: #ef9a9a; border-left: 3px solid #f44336; } +.toast-info { background: #1565c0; color: #90caf9; border-left: 3px solid #2196f3; } + +@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; } } +@keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } + +/* === Action Bar === */ +.action-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +/* === Modal === */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + justify-content: center; + align-items: center; + z-index: 500; + backdrop-filter: blur(4px); +} + +.modal { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 12px; + width: 90%; + max-width: 800px; + max-height: 85vh; + display: flex; + flex-direction: column; +} + +.modal-small { max-width: 500px; } + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.2rem; + border-bottom: 1px solid #2a2a2a; +} + +.modal-header h2 { margin-bottom: 0; } + +.btn-close { + background: none; + border: none; + color: #888; + font-size: 1.5rem; + cursor: pointer; + padding: 0 0.3rem; + line-height: 1; +} +.btn-close:hover { color: #fff; } + +.modal-breadcrumb { + padding: 0.5rem 1.2rem; + font-size: 0.8rem; + color: #888; + border-bottom: 1px solid #222; + background: #151515; +} + +.bc-item { + color: #90caf9; + cursor: pointer; +} +.bc-item:hover { text-decoration: underline; } +.bc-sep { color: #555; } + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1.2rem; + border-top: 1px solid #2a2a2a; + font-size: 0.8rem; + color: #888; +} + +.modal-footer div { display: flex; gap: 0.5rem; } + +/* === Filebrowser === */ +.fb-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.5rem 1.2rem; + border-bottom: 1px solid #1f1f1f; + font-size: 0.85rem; + transition: background 0.15s; +} +.fb-item:hover { background: #222; } + +.fb-dir { cursor: pointer; } +.fb-parent { color: #888; } + +.fb-check { flex-shrink: 0; } +.fb-check input[type="checkbox"] { + accent-color: #1976d2; + width: 15px; + height: 15px; + cursor: pointer; +} + +.fb-icon { font-size: 1.1rem; flex-shrink: 0; } +.fb-name { flex: 1; word-break: break-all; } +.fb-size { color: #888; font-size: 0.75rem; flex-shrink: 0; } + +.fb-badge { + background: #0d47a1; + color: #90caf9; + padding: 0.1rem 0.4rem; + border-radius: 4px; + font-size: 0.65rem; + flex-shrink: 0; +} + +.fb-loading, .fb-error, .fb-empty { + padding: 2rem; + text-align: center; + color: #888; +} +.fb-error { color: #ef5350; } + +/* === Upload === */ +.upload-zone { + border: 2px dashed #333; + border-radius: 10px; + padding: 2rem; + text-align: center; + margin: 1rem 1.2rem; + transition: border-color 0.2s, background 0.2s; +} +.upload-zone:hover, .upload-zone.drag-over { + border-color: #1976d2; + background: #0d47a122; +} + +.upload-zone p { color: #888; font-size: 0.9rem; } +.upload-hint { font-size: 0.75rem; margin: 0.5rem 0; } + +.upload-btn { + display: inline-block; + cursor: pointer; + margin-top: 0.3rem; +} + +.upload-list { padding: 0 1.2rem; } + +.upload-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0; + border-bottom: 1px solid #222; + font-size: 0.8rem; +} +.upload-item-name { flex: 1; word-break: break-all; color: #ddd; } +.upload-item-size { color: #888; font-size: 0.75rem; } + +.upload-progress { padding: 0.8rem 1.2rem; } +.upload-progress .progress-container { margin-top: 0; } + +.btn-primary:disabled, .btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* === Bibliothek === */ +.library-section { margin-bottom: 2rem; } + +.library-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} +.library-actions { display: flex; gap: 0.5rem; } + +.library-stats { + display: flex; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} +.lib-stat { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 0.5rem 1rem; + text-align: center; + min-width: 100px; +} +.lib-stat-value { + display: block; + font-size: 1.2rem; + font-weight: 700; + color: #fff; +} +.lib-stat-label { + font-size: 0.7rem; + color: #888; +} + +.library-layout { + display: grid; + grid-template-columns: 220px 220px 1fr; + gap: 1rem; +} + +/* Pfad-Navigation (ganz links) */ +.library-nav { + display: block; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.8rem; + position: sticky; + top: 4rem; + max-height: calc(100vh - 5rem); + overflow-y: auto; +} +.library-nav h3 { + font-size: 0.85rem; + margin-bottom: 0.6rem; + color: #fff; +} +.nav-path-item { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.6rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.8rem; + color: #aaa; + transition: all 0.15s; + margin-bottom: 0.2rem; + border: 1px solid transparent; +} +.nav-path-item:hover { + background: #252525; + color: #ddd; +} +.nav-path-item.active { + background: #1976d2; + color: #fff; + border-color: #1976d2; +} +.nav-path-icon { + font-size: 0.9rem; + flex-shrink: 0; +} +.nav-path-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} +.nav-path-count { + font-size: 0.65rem; + color: #666; + flex-shrink: 0; +} +.nav-path-item.active .nav-path-count { + color: rgba(255,255,255,0.7); +} + +/* Filter-Sidebar */ +.library-filters { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 1rem; + position: sticky; + top: 4rem; + max-height: calc(100vh - 5rem); + overflow-y: auto; +} +.library-filters h3 { + font-size: 0.9rem; + margin-bottom: 0.8rem; + color: #fff; +} + +.filter-group { + margin-bottom: 0.8rem; +} +.filter-group > label { + font-size: 0.75rem; + color: #aaa; + display: block; + margin-bottom: 0.2rem; +} +.filter-group input[type="text"], +.filter-group select { + width: 100%; + background: #252525; + border: 1px solid #333; + color: #e0e0e0; + padding: 0.35rem 0.5rem; + border-radius: 5px; + font-size: 0.8rem; +} +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: #1976d2; +} + +.filter-radios label, +.filter-checks label { + display: block; + font-size: 0.8rem; + color: #ccc; + cursor: pointer; + padding: 0.1rem 0; +} +.filter-radios input, +.filter-checks input { + accent-color: #1976d2; + margin-right: 0.3rem; +} + +/* Filter Presets */ +.filter-presets select { + margin-bottom: 0.4rem; +} +.preset-actions { + display: flex; + gap: 0.3rem; + margin-top: 0.3rem; +} +.preset-actions button { + flex: 1; + font-size: 0.65rem; + padding: 0.2rem 0.3rem; +} +.btn-block { + width: 100%; +} + +/* Fehlende Episoden Ansicht */ +.missing-episodes-view { + padding: 1rem; +} +.missing-episodes-view h3 { + margin-bottom: 1rem; + color: #f44336; +} +.missing-series-block { + background: #1e1e1e; + border-radius: 8px; + margin-bottom: 1rem; + overflow: hidden; +} +.missing-series-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.8rem 1rem; + background: #252525; + border-bottom: 1px solid #333; +} +.missing-series-header h4 { + flex: 1; + margin: 0; + font-size: 1rem; +} +.missing-poster { + width: 40px; + height: 60px; + object-fit: cover; + border-radius: 4px; +} +.missing-count { + background: #f44336; + color: #fff; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: bold; +} +.missing-episodes-list { + padding: 0.5rem 1rem; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.4rem; +} +.missing-episode { + display: flex; + gap: 0.5rem; + padding: 0.3rem 0.5rem; + background: #2a2a2a; + border-radius: 4px; + font-size: 0.8rem; +} +.missing-episode .ep-num { + color: #f44336; + font-weight: bold; + min-width: 60px; +} +.missing-episode .ep-name { + color: #ccc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.missing-pagination { + text-align: center; + padding: 1rem; + color: #888; +} +.missing-pagination button { + margin: 0 0.5rem; +} + +/* Tabs */ +.library-tabs { + display: flex; + gap: 0.3rem; + margin-bottom: 1rem; + border-bottom: 1px solid #2a2a2a; + padding-bottom: 0.3rem; +} +.tab-btn { + background: none; + border: none; + color: #888; + font-size: 0.85rem; + padding: 0.4rem 0.8rem; + cursor: pointer; + border-radius: 6px 6px 0 0; + transition: all 0.2s; +} +.tab-btn:hover { color: #fff; } +.tab-btn.active { + color: #fff; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-bottom-color: #0f0f0f; +} + +/* Video-Tabelle */ +.table-wrapper { overflow-x: auto; } +.td-name { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.td-audio .tag, .td-sub .tag { margin-right: 0.2rem; } +.tag.codec { background: #0d47a1; color: #90caf9; border-color: #1565c0; } +.tag.hdr { background: #4a148c; color: #ce93d8; border-color: #6a1b9a; } +.tag.ok { background: #1b5e20; color: #81c784; border-color: #2e7d32; } + +.loading-msg { + text-align: center; + color: #666; + padding: 2rem; + font-size: 0.85rem; +} + +/* Pagination */ +.page-info { + font-size: 0.8rem; + color: #888; + margin-right: 0.5rem; +} + +/* Serien-Grid */ +.series-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.8rem; +} + +.series-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.8rem; + display: flex; + gap: 0.8rem; + cursor: pointer; + transition: border-color 0.2s; +} +.series-card:hover { border-color: #444; } + +.series-poster { + width: 60px; + height: 90px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} +.series-poster-placeholder { + width: 60px; + height: 90px; + background: #252525; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + color: #666; + flex-shrink: 0; +} + +.series-info { flex: 1; min-width: 0; } +.series-info h4 { + font-size: 0.85rem; + color: #fff; + margin-bottom: 0.3rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.series-meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + align-items: center; + font-size: 0.75rem; + color: #aaa; +} + +/* === Film-Grid === */ +.movie-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 0.8rem; +} + +.movie-card { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 0.8rem; + display: flex; + gap: 0.8rem; + cursor: pointer; + transition: border-color 0.2s; +} +.movie-card:hover { border-color: #444; } + +.movie-poster { + width: 80px; + height: 120px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} +.movie-poster-placeholder { + width: 80px; + height: 120px; + background: #252525; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + color: #666; + flex-shrink: 0; +} + +.movie-info { flex: 1; min-width: 0; } +.movie-info h4 { + font-size: 0.85rem; + color: #fff; + margin-bottom: 0.2rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.movie-genres { + font-size: 0.7rem; + color: #888; + margin-bottom: 0.2rem; +} +.movie-overview { + font-size: 0.75rem; + color: #999; + line-height: 1.3; + margin-bottom: 0.3rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.movie-meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + align-items: center; + font-size: 0.75rem; + color: #aaa; +} + +/* Film-Detail Modal */ +.movie-detail-header { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} +.movie-detail-poster { + width: 140px; + height: 210px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; +} +.movie-detail-info { flex: 1; } + +/* Serien-Detail Modal */ +.series-detail-header { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} +.series-detail-poster { + width: 120px; + height: 180px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; +} +.series-detail-info { flex: 1; } +.series-overview { + font-size: 0.85rem; + color: #ccc; + margin-bottom: 0.5rem; + line-height: 1.4; +} +.series-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.season-details { + margin-bottom: 0.5rem; + border: 1px solid #2a2a2a; + border-radius: 8px; + overflow: hidden; +} +.season-details summary { + background: #1a1a1a; + padding: 0.6rem 0.8rem; + cursor: pointer; + font-size: 0.85rem; + font-weight: 600; + color: #fff; +} +.season-details summary:hover { background: #222; } +.season-table { margin: 0; } +.season-table th { background: #151515; } + +.row-missing { opacity: 0.6; } +.row-missing td { color: #888; } +.text-warn { color: #ffb74d; } +.text-muted { color: #888; font-size: 0.8rem; } + +/* TVDB Modal */ +.tvdb-results { max-height: 400px; overflow-y: auto; } +.tvdb-result { + display: flex; + gap: 0.8rem; + padding: 0.6rem; + border-bottom: 1px solid #222; + cursor: pointer; + transition: background 0.15s; +} +.tvdb-result:hover { background: #222; } +.tvdb-thumb { + width: 45px; + height: 65px; + object-fit: cover; + border-radius: 3px; + flex-shrink: 0; +} +.tvdb-overview { + font-size: 0.75rem; + color: #888; + margin-top: 0.2rem; +} + +/* Duplikate */ +.duplicates-list { max-height: 70vh; overflow-y: auto; } +.duplicate-pair { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem; + border-bottom: 1px solid #222; +} +.dupe-item { flex: 1; } +.dupe-name { + font-size: 0.8rem; + color: #ddd; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dupe-vs { + color: #666; + font-size: 0.7rem; + flex-shrink: 0; +} + +/* Scan-Progress */ +.scan-progress { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 0.6rem 1rem; + margin-bottom: 1rem; +} +.scan-status { + font-size: 0.8rem; + color: #aaa; + display: block; + margin-top: 0.3rem; +} + +/* Pagination-Zeile mit Limit-Dropdown */ +.pagination-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 0.5rem; + gap: 1rem; +} +.page-limit { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: #888; +} +.page-limit select { + background: #252525; + color: #ddd; + border: 1px solid #333; + border-radius: 4px; + padding: 0.2rem 0.4rem; + font-size: 0.8rem; +} + +/* Ordner-Ansicht */ +.browser-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.6rem 0; + font-size: 0.85rem; + border-bottom: 1px solid #2a2a2a; + margin-bottom: 0.8rem; +} +.breadcrumb-link { + color: #64b5f6; + text-decoration: none; +} +.breadcrumb-link:hover { + text-decoration: underline; +} +.breadcrumb-sep { + color: #555; +} +.browser-folders { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 0.5rem; + margin-bottom: 1rem; +} +.browser-folder { + display: flex; + align-items: center; + gap: 0.6rem; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 6px; + padding: 0.6rem 0.8rem; + cursor: pointer; + transition: border-color 0.15s; +} +.browser-folder:hover { + border-color: #444; + background: #1e1e1e; +} +.folder-icon { + font-size: 1.6rem; + flex-shrink: 0; +} +.folder-info { + display: flex; + flex-direction: column; + min-width: 0; +} +.folder-name { + font-size: 0.85rem; + font-weight: 600; + color: #ddd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.folder-meta { + font-size: 0.75rem; + color: #888; +} +.folder-main { + display: flex; + align-items: center; + gap: 0.6rem; + flex: 1; + min-width: 0; + cursor: pointer; +} +.btn-folder-delete { + position: absolute; + top: 0.4rem; + right: 0.4rem; + background: rgba(0,0,0,0.5); + border: none; + color: #888; + padding: 0.35rem; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + display: flex; + align-items: center; + justify-content: center; +} +.browser-folder { + position: relative; +} +.browser-folder:hover .btn-folder-delete { + opacity: 1; +} +.btn-folder-delete:hover { + color: #e74c3c; + background: rgba(231, 76, 60, 0.2); +} +.browser-videos { + margin-top: 0.5rem; +} + +/* === Library-Sektionen (ein Bereich pro Scan-Pfad) === */ +.lib-section { + background: #161616; + border: 1px solid #2a2a2a; + border-radius: 10px; + margin-bottom: 1.2rem; + overflow: hidden; +} +.lib-section-header { + display: flex; + align-items: center; + gap: 0.8rem; + padding: 0.8rem 1rem; + background: #1a1a1a; + border-bottom: 1px solid #2a2a2a; + flex-wrap: wrap; +} +.lib-section-header h3 { + font-size: 1rem; + font-weight: 600; + color: #fff; + margin: 0; +} +.lib-section-actions { + margin-left: auto; + display: flex; + gap: 0.3rem; +} +.lib-section .library-tabs { + margin: 0; + padding: 0 0.8rem; + border-bottom: 1px solid #222; + background: #181818; +} +.lib-section .section-content { + padding: 0.8rem 1rem; +} + +/* Detail-Tabs im Serien-Modal */ +.detail-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid #2a2a2a; + background: #151515; +} +.detail-tab { + background: none; + border: none; + color: #888; + font-size: 0.85rem; + padding: 0.6rem 1.2rem; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; +} +.detail-tab:hover { color: #fff; } +.detail-tab.active { + color: #90caf9; + border-bottom-color: #1976d2; +} + +/* Modal-Header erweitert */ +.modal-header-actions { + display: flex; + gap: 0.3rem; + align-items: center; +} + +/* Genres in Serien */ +.series-genres-line { + font-size: 0.75rem; + color: #888; +} +.series-genres { + font-size: 0.7rem; + color: #888; + margin-bottom: 0.2rem; +} + +/* === Cast-Grid === */ +.cast-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.8rem; +} +.cast-card { + display: flex; + gap: 0.6rem; + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 8px; + padding: 0.6rem; + align-items: center; +} +.cast-photo { + width: 50px; + height: 70px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} +.cast-photo-placeholder { + width: 50px; + height: 70px; + background: #252525; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + color: #555; + flex-shrink: 0; +} +.cast-info { + display: flex; + flex-direction: column; + gap: 0.1rem; + min-width: 0; +} +.cast-info strong { + font-size: 0.8rem; + color: #ddd; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cast-info .text-muted { + font-size: 0.7rem; +} + +/* === Artwork-Galerie === */ +.artwork-type-header { + font-size: 0.9rem; + font-weight: 600; + color: #fff; + margin: 1rem 0 0.5rem; + padding-bottom: 0.3rem; + border-bottom: 1px solid #2a2a2a; +} +.artwork-type-header:first-child { margin-top: 0; } + +.artwork-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 0.5rem; +} +.artwork-item { + position: relative; + border-radius: 6px; + overflow: hidden; + border: 1px solid #2a2a2a; + transition: border-color 0.2s; +} +.artwork-item:hover { border-color: #1976d2; text-decoration: none; } +.artwork-item img { + width: 100%; + height: auto; + display: block; +} +.artwork-size { + position: absolute; + bottom: 4px; + right: 4px; + background: rgba(0,0,0,0.7); + color: #aaa; + font-size: 0.6rem; + padding: 0.1rem 0.3rem; + border-radius: 3px; +} + +/* === Pfade-Verwaltung === */ +.path-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem; + border-bottom: 1px solid #222; + gap: 0.5rem; + flex-wrap: wrap; +} +.path-info { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; + min-width: 0; +} +.path-actions { + display: flex; + gap: 0.3rem; + flex-shrink: 0; +} + +/* === Clean-Modal === */ +.clean-list { max-height: 60vh; overflow-y: auto; } +.clean-list .data-table td { padding: 0.3rem 0.5rem; font-size: 0.75rem; } + +/* === Import-Modal === */ +.import-browser-bar { + display: flex; + gap: 0.4rem; + padding: 0.6rem 1rem; + background: #181818; + border-bottom: 1px solid #2a2a2a; + align-items: center; +} +.import-browser { + max-height: 45vh; + overflow-y: auto; + border-bottom: 1px solid #2a2a2a; +} +.import-browser .fb-breadcrumb { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.4rem 1rem; + font-size: 0.8rem; + background: #151515; + border-bottom: 1px solid #222; + position: sticky; + top: 0; + z-index: 1; +} +.import-browser .fb-breadcrumb a { + color: #64b5f6; +} +.import-browser-folder { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 1rem; + font-size: 0.85rem; + cursor: pointer; + border-bottom: 1px solid #1a1a1a; + transition: background 0.12s; +} +.import-browser-folder:hover { background: #1e1e1e; } +.import-browser-folder.selected { + background: #0d47a1; + color: #fff; +} +.import-browser-folder .fb-icon { font-size: 1.1rem; flex-shrink: 0; } +.import-browser-folder .fb-name { flex: 1; } +.import-browser-folder .fb-meta { + font-size: 0.7rem; + color: #888; + flex-shrink: 0; +} +.import-browser-folder.selected .fb-meta { color: rgba(255,255,255,0.7); } +.import-setup-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 1rem; + background: #181818; + gap: 1rem; + flex-wrap: wrap; +} +.import-setup-opts { + display: flex; + gap: 0.4rem; + align-items: center; + font-size: 0.8rem; + color: #aaa; +} +.import-setup-opts select { + background: #252525; + color: #ddd; + border: 1px solid #333; + border-radius: 5px; + padding: 0.3rem 0.4rem; + font-size: 0.8rem; +} +.import-setup-footer > div { + display: flex; + gap: 0.5rem; + align-items: center; +} +.import-items-list { max-height: 55vh; overflow-y: auto; } +.import-items-list .data-table td { padding: 0.3rem 0.5rem; font-size: 0.75rem; } +.import-actions-cell { + white-space: nowrap; + display: flex; + gap: 0.2rem; +} +.row-conflict { background: #2a1a10 !important; } +.row-conflict:hover { background: #332010 !important; } +.row-pending { background: #2a1020 !important; } +.row-pending:hover { background: #331030 !important; } + +/* === Play-Button === */ +.btn-play { + background: #2a7a2a; + color: #fff; + border: none; + border-radius: 4px; + padding: 0.2rem 0.5rem; + cursor: pointer; + font-size: 0.8rem; +} +.btn-play:hover { background: #3a9a3a; } + +/* === Video-Player Modal === */ +.player-overlay { + z-index: 10000; + background: rgba(0, 0, 0, 0.95); +} +.player-container { + width: 95vw; + max-width: 1400px; + display: flex; + flex-direction: column; +} +.player-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.8rem; + color: #fff; + font-size: 0.9rem; +} +.player-header .btn-close { + font-size: 1.5rem; + color: #aaa; + background: none; + border: none; + cursor: pointer; +} +.player-header .btn-close:hover { color: #fff; } +#player-video { + width: 100%; + max-height: 85vh; + background: #000; + border-radius: 4px; +} + +/* === TVDB Review-Modal === */ +.tvdb-review-list { + max-height: 70vh; + overflow-y: auto; + padding: 0.5rem; +} +.review-item { + background: #1a1a1a; + border: 1px solid #2a2a2a; + border-radius: 8px; + margin-bottom: 0.5rem; + overflow: hidden; + transition: opacity 0.3s; +} +.review-item-done { + opacity: 0.5; + border-color: #2e7d32; +} +.review-item-skipped { + opacity: 0.35; + border-color: #555; +} +.review-item-loading { + opacity: 0.6; + pointer-events: none; +} +.review-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.8rem; + background: #151515; + flex-wrap: wrap; +} +.review-local-name { + font-size: 0.95rem; + color: #fff; +} +.review-skip-btn, .review-search-btn { + margin-left: auto; + font-size: 0.7rem !important; +} +.review-search-btn { + margin-left: 0; +} +.tag-series { background: #1565c0; color: #fff; } +.tag-movie { background: #6a1b9a; color: #fff; } +.review-suggestions { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.8rem; + flex-wrap: wrap; +} +.review-suggestion { + display: flex; + gap: 0.5rem; + background: #222; + border: 1px solid #333; + border-radius: 6px; + padding: 0.5rem; + cursor: pointer; + flex: 1; + min-width: 200px; + max-width: 350px; + transition: border-color 0.15s, background 0.15s; +} +.review-suggestion:hover { + border-color: #64b5f6; + background: #1a2a3a; +} +.review-poster { + width: 50px; + height: 75px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} +.review-poster-placeholder { + width: 50px; + height: 75px; + background: #333; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #666; + font-size: 1.2rem; + flex-shrink: 0; +} +.review-suggestion-info { + flex: 1; + min-width: 0; +} +.review-suggestion-info strong { + font-size: 0.85rem; + color: #e0e0e0; + display: block; +} +.review-overview { + font-size: 0.7rem; + color: #888; + margin: 0.2rem 0 0; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.review-manual-search { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + align-items: flex-start; + padding: 0.5rem 0; + width: 100%; +} +.review-search-input { + flex: 1; + min-width: 200px; + background: #252525; + color: #ddd; + border: 1px solid #444; + border-radius: 5px; + padding: 0.4rem 0.6rem; + font-size: 0.8rem; +} +.review-search-results { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; + width: 100%; + margin-top: 0.3rem; +} + +/* === Codec-Stats (Konvertierung) === */ +.codec-stats { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin: 0.5rem 0; +} +.codec-stats .tag { + font-size: 0.75rem; + padding: 0.2rem 0.5rem; +} + +/* === Benachrichtigungs-Glocke === */ +.notification-bell { + position: fixed; + bottom: 20px; + left: 20px; + width: 48px; + height: 48px; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: #888; + transition: all 0.2s ease; + z-index: 1000; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); +} +.notification-bell:hover { + background: #333; + color: #fff; + transform: scale(1.05); +} +.notification-bell.has-error { + color: #ff6b6b; + animation: bell-shake 0.5s ease; +} +@keyframes bell-shake { + 0%, 100% { transform: rotate(0); } + 25% { transform: rotate(-10deg); } + 75% { transform: rotate(10deg); } +} +.notification-badge { + position: absolute; + top: -4px; + right: -4px; + background: #e74c3c; + color: #fff; + font-size: 0.65rem; + font-weight: bold; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +/* === Log-Panel === */ +.notification-panel { + position: fixed; + bottom: 80px; + left: 20px; + width: 400px; + max-height: 50vh; + background: #1e1e1e; + border: 1px solid #444; + border-radius: 8px; + display: flex; + flex-direction: column; + z-index: 1001; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); +} +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.6rem 0.8rem; + border-bottom: 1px solid #333; + font-weight: 500; + color: #ddd; +} +.notification-header > div { + display: flex; + gap: 0.3rem; + align-items: center; +} +.notification-list { + flex: 1; + overflow-y: auto; + max-height: 45vh; +} +.notification-item { + padding: 0.5rem 0.8rem; + border-bottom: 1px solid #2a2a2a; + font-size: 0.8rem; + display: flex; + gap: 0.5rem; + align-items: flex-start; +} +.notification-item:hover { + background: #252525; +} +.notification-item.error { + background: rgba(231, 76, 60, 0.1); + border-left: 3px solid #e74c3c; +} +.notification-item.warning { + background: rgba(241, 196, 15, 0.1); + border-left: 3px solid #f1c40f; +} +.notification-time { + color: #666; + font-size: 0.7rem; + white-space: nowrap; + min-width: 55px; +} +.notification-msg { + color: #ccc; + word-break: break-word; + flex: 1; +} +.notification-item.error .notification-msg { + color: #ff8a8a; +} +.notification-empty { + padding: 2rem; + text-align: center; + color: #666; + font-size: 0.85rem; +} + +/* === Responsive === */ +@media (max-width: 768px) { + header { flex-direction: column; gap: 0.5rem; } + nav { width: 100%; justify-content: center; } + main { padding: 1rem; } + .video-card-values { grid-template-columns: repeat(2, 1fr); } + .form-grid { grid-template-columns: 1fr; } + .stats-summary { grid-template-columns: repeat(2, 1fr); } + .library-layout { grid-template-columns: 1fr; } + .library-nav { position: static; max-height: none; } + .library-filters { position: static; max-height: none; } + .td-name { max-width: 150px; } + .series-grid { grid-template-columns: 1fr; } + .movie-grid { grid-template-columns: 1fr; } + .cast-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } + .artwork-gallery { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); } + .lib-section-header { flex-direction: column; align-items: flex-start; } +} diff --git a/video-konverter/app/static/icons/favicon.ico b/video-konverter/app/static/icons/favicon.ico new file mode 100644 index 0000000..a8550c8 Binary files /dev/null and b/video-konverter/app/static/icons/favicon.ico differ diff --git a/video-konverter/app/static/js/filebrowser.js b/video-konverter/app/static/js/filebrowser.js new file mode 100644 index 0000000..0e94fcc --- /dev/null +++ b/video-konverter/app/static/js/filebrowser.js @@ -0,0 +1,301 @@ +/** + * Filebrowser + Upload fuer VideoKonverter + */ + +// === Filebrowser === + +let fbCurrentPath = "/mnt"; +let fbSelectedFiles = new Set(); +let fbSelectedDirs = new Set(); + +function openFileBrowser() { + document.getElementById("filebrowser-overlay").style.display = "flex"; + fbSelectedFiles.clear(); + fbSelectedDirs.clear(); + fbNavigate("/mnt"); +} + +function closeFileBrowser() { + document.getElementById("filebrowser-overlay").style.display = "none"; +} + +function closeBrowserOnOverlay(e) { + if (e.target === e.currentTarget) closeFileBrowser(); +} + +async function fbNavigate(path) { + fbCurrentPath = path; + fbSelectedFiles.clear(); + fbSelectedDirs.clear(); + updateFbSelection(); + + const content = document.getElementById("fb-content"); + content.innerHTML = '
Lade...
'; + + try { + const resp = await fetch("/api/browse?path=" + encodeURIComponent(path)); + const data = await resp.json(); + + if (!resp.ok) { + content.innerHTML = `
${data.error}
`; + return; + } + + renderBreadcrumb(data.path); + renderBrowser(data); + } catch (e) { + content.innerHTML = '
Verbindungsfehler
'; + } +} + +function renderBreadcrumb(path) { + const bc = document.getElementById("fb-breadcrumb"); + const parts = path.split("/").filter(Boolean); + let html = '/mnt'; + + let current = ""; + for (const part of parts) { + current += "/" + part; + if (current === "/mnt") continue; + html += ` / `; + html += `${part}`; + } + bc.innerHTML = html; +} + +function renderBrowser(data) { + const content = document.getElementById("fb-content"); + let html = ""; + + // "Nach oben" Link + if (data.parent) { + html += `
+ + .. +
`; + } + + // Ordner + for (const dir of data.dirs) { + const badge = dir.video_count > 0 ? `${dir.video_count} Videos` : ""; + html += `
+ + 📁 + ${dir.name} + ${badge} +
`; + } + + // Dateien + for (const file of data.files) { + html += `
+ + 🎥 + ${file.name} + ${file.size_human} +
`; + } + + if (data.dirs.length === 0 && data.files.length === 0) { + html = '
Keine Videodateien in diesem Ordner
'; + } + + content.innerHTML = html; +} + +function fbToggleFile(path, checked) { + if (checked) fbSelectedFiles.add(path); + else fbSelectedFiles.delete(path); + updateFbSelection(); +} + +function fbToggleDir(path, checked) { + if (checked) fbSelectedDirs.add(path); + else fbSelectedDirs.delete(path); + updateFbSelection(); +} + +function fbSelectAll() { + const checks = document.querySelectorAll("#fb-content input[type=checkbox]"); + const allChecked = Array.from(checks).every(c => c.checked); + + checks.forEach(c => { + c.checked = !allChecked; + c.dispatchEvent(new Event("change")); + }); +} + +function updateFbSelection() { + const count = fbSelectedFiles.size + fbSelectedDirs.size; + document.getElementById("fb-selected-count").textContent = `${count} ausgewaehlt`; + document.getElementById("fb-convert").disabled = count === 0; +} + +async function fbConvertSelected() { + const paths = [...fbSelectedFiles, ...fbSelectedDirs]; + if (paths.length === 0) return; + + document.getElementById("fb-convert").disabled = true; + document.getElementById("fb-convert").textContent = "Wird gesendet..."; + + try { + const resp = await fetch("/api/convert", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({files: paths}), + }); + const data = await resp.json(); + + showToast(data.message, "success"); + closeFileBrowser(); + } catch (e) { + showToast("Fehler beim Senden", "error"); + } finally { + document.getElementById("fb-convert").disabled = false; + document.getElementById("fb-convert").textContent = "Konvertieren"; + } +} + +// === Upload === + +let uploadFiles = []; + +function openUpload() { + document.getElementById("upload-overlay").style.display = "flex"; + uploadFiles = []; + document.getElementById("upload-list").innerHTML = ""; + document.getElementById("upload-progress").style.display = "none"; + document.getElementById("upload-start").disabled = true; + document.getElementById("upload-input").value = ""; +} + +function closeUpload() { + document.getElementById("upload-overlay").style.display = "none"; +} + +function closeUploadOnOverlay(e) { + if (e.target === e.currentTarget) closeUpload(); +} + +function handleDragOver(e) { + e.preventDefault(); + e.currentTarget.classList.add("drag-over"); +} + +function handleDragLeave(e) { + e.currentTarget.classList.remove("drag-over"); +} + +function handleDrop(e) { + e.preventDefault(); + e.currentTarget.classList.remove("drag-over"); + addFiles(e.dataTransfer.files); +} + +function handleFileSelect(e) { + addFiles(e.target.files); +} + +function addFiles(fileList) { + for (const file of fileList) { + if (!uploadFiles.some(f => f.name === file.name && f.size === file.size)) { + uploadFiles.push(file); + } + } + renderUploadList(); +} + +function removeUploadFile(index) { + uploadFiles.splice(index, 1); + renderUploadList(); +} + +function renderUploadList() { + const list = document.getElementById("upload-list"); + if (uploadFiles.length === 0) { + list.innerHTML = ""; + document.getElementById("upload-start").disabled = true; + return; + } + + let html = ""; + uploadFiles.forEach((file, i) => { + const size = file.size < 1024 * 1024 + ? (file.size / 1024).toFixed(0) + " KiB" + : file.size < 1024 * 1024 * 1024 + ? (file.size / (1024 * 1024)).toFixed(1) + " MiB" + : (file.size / (1024 * 1024 * 1024)).toFixed(2) + " GiB"; + html += `
+ ${file.name} + ${size} + +
`; + }); + list.innerHTML = html; + document.getElementById("upload-start").disabled = false; +} + +async function startUpload() { + if (uploadFiles.length === 0) return; + + const btn = document.getElementById("upload-start"); + btn.disabled = true; + btn.textContent = "Wird hochgeladen..."; + + const progress = document.getElementById("upload-progress"); + const bar = document.getElementById("upload-bar"); + const status = document.getElementById("upload-status"); + progress.style.display = "block"; + + const formData = new FormData(); + uploadFiles.forEach(f => formData.append("files", f)); + + try { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/upload"); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const pct = (e.loaded / e.total) * 100; + bar.style.width = pct + "%"; + const loaded = (e.loaded / (1024 * 1024)).toFixed(1); + const total = (e.total / (1024 * 1024)).toFixed(1); + status.textContent = `${loaded} / ${total} MiB (${pct.toFixed(0)}%)`; + } + }; + + const result = await new Promise((resolve, reject) => { + xhr.onload = () => { + if (xhr.status === 200) resolve(JSON.parse(xhr.responseText)); + else reject(JSON.parse(xhr.responseText)); + }; + xhr.onerror = () => reject({error: "Netzwerkfehler"}); + xhr.send(formData); + }); + + showToast(result.message, "success"); + closeUpload(); + } catch (e) { + showToast(e.error || "Upload fehlgeschlagen", "error"); + btn.disabled = false; + btn.textContent = "Hochladen & Konvertieren"; + } +} + +// === Toast === + +function showToast(message, type) { + const container = document.getElementById("toast-container"); + if (!container) return; + + const toast = document.createElement("div"); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => toast.remove(), 3000); +} diff --git a/video-konverter/app/static/js/library.js b/video-konverter/app/static/js/library.js new file mode 100644 index 0000000..135d6f0 --- /dev/null +++ b/video-konverter/app/static/js/library.js @@ -0,0 +1,3170 @@ +/** + * Video-Bibliothek Frontend + * Bereiche pro Scan-Pfad, CRUD, Clean, Import, TVDB-Metadaten + */ + +let libraryPaths = []; +let sectionStates = {}; // pathId -> {tab, page, limit} +let activePathId = null; // null = alle anzeigen +let filterTimeout = null; +let currentSeriesId = null; +let currentMovieId = null; +let currentDetailTab = "episodes"; +let currentImportJobId = null; +let cleanData = []; + +// === Initialisierung === + +document.addEventListener("DOMContentLoaded", function () { + // Gespeicherten UI-State wiederherstellen + _restoreUIState(); + loadStats(); + loadFilterPresets(); + loadLibraryPaths(); +}); + +// === UI-State in localStorage === + +function _saveUIState() { + try { + localStorage.setItem("vk_activePathId", JSON.stringify(activePathId)); + // Aktuelle Filter-Werte speichern + const filterState = {}; + const fields = [ + "filter-search", "filter-codec", "filter-container", + "filter-audio-lang", "filter-audio-ch", "filter-resolution", + "filter-sort", "filter-order" + ]; + for (const f of fields) { + const el = document.getElementById(f); + if (el) filterState[f] = el.value; + } + const cb10bit = document.getElementById("filter-10bit"); + if (cb10bit) filterState["filter-10bit"] = cb10bit.checked; + const cbNotConv = document.getElementById("filter-not-converted"); + if (cbNotConv) filterState["filter-not-converted"] = cbNotConv.checked; + localStorage.setItem("vk_filterState", JSON.stringify(filterState)); + // Tab-States pro Pfad speichern + const tabStates = {}; + for (const [pid, st] of Object.entries(sectionStates)) { + tabStates[pid] = st.tab; + } + localStorage.setItem("vk_tabStates", JSON.stringify(tabStates)); + } catch (e) { /* localStorage nicht verfuegbar */ } +} + +function _restoreUIState() { + try { + const stored = localStorage.getItem("vk_activePathId"); + if (stored !== null) activePathId = JSON.parse(stored); + } catch (e) { /* ignorieren */ } +} + +function _restoreFilterState() { + try { + const raw = localStorage.getItem("vk_filterState"); + if (!raw) return; + const fs = JSON.parse(raw); + const fields = [ + "filter-search", "filter-codec", "filter-container", + "filter-audio-lang", "filter-audio-ch", "filter-resolution", + "filter-sort", "filter-order" + ]; + for (const f of fields) { + const el = document.getElementById(f); + if (el && fs[f] !== undefined) el.value = fs[f]; + } + const cb10bit = document.getElementById("filter-10bit"); + if (cb10bit && fs["filter-10bit"] !== undefined) cb10bit.checked = fs["filter-10bit"]; + const cbNotConv = document.getElementById("filter-not-converted"); + if (cbNotConv && fs["filter-not-converted"] !== undefined) cbNotConv.checked = fs["filter-not-converted"]; + } catch (e) { /* ignorieren */ } +} + +function _restoreTabStates() { + try { + const raw = localStorage.getItem("vk_tabStates"); + if (!raw) return; + const ts = JSON.parse(raw); + for (const [pid, tab] of Object.entries(ts)) { + if (sectionStates[pid]) { + sectionStates[pid].tab = tab; + } + } + } catch (e) { /* ignorieren */ } +} + +// === Statistiken === + +function loadStats() { + fetch("/api/library/stats") + .then(r => r.json()) + .then(data => { + document.getElementById("stat-videos").textContent = data.total_videos || 0; + document.getElementById("stat-series").textContent = data.total_series || 0; + document.getElementById("stat-size").textContent = formatSize(data.total_size || 0); + document.getElementById("stat-duration").textContent = formatDuration(data.total_duration || 0); + }) + .catch(() => {}); +} + +// === Library-Bereiche laden === + +function loadLibraryPaths() { + fetch("/api/library/paths") + .then(r => r.json()) + .then(data => { + libraryPaths = data.paths || []; + renderPathNav(); + renderLibrarySections(); + }) + .catch(() => { + document.getElementById("library-content").innerHTML = + '
Fehler beim Laden der Bibliothek
'; + }); +} + +// === Pfad-Navigation (links) === + +function renderPathNav() { + const nav = document.getElementById("nav-paths-list"); + if (!nav) return; + const enabled = libraryPaths.filter(p => p.enabled); + if (!enabled.length) { + nav.innerHTML = '
Keine Pfade
'; + return; + } + + let html = ''; + // "Alle" Eintrag + html += ``; + + for (const lp of enabled) { + const icon = lp.media_type === 'series' ? '🎬' : '🎦'; + const isActive = activePathId === lp.id; + html += ``; + } + nav.innerHTML = html; +} + +function selectLibraryPath(pathId) { + activePathId = pathId; + _saveUIState(); + renderPathNav(); + renderLibrarySections(); +} + +function renderLibrarySections() { + const container = document.getElementById("library-content"); + if (!libraryPaths.length) { + container.innerHTML = '
Keine Scan-Pfade konfiguriert. Klicke "Pfade verwalten" um zu starten.
'; + return; + } + + // Welche Pfade anzeigen? + const visiblePaths = libraryPaths.filter(lp => { + if (!lp.enabled) return false; + if (activePathId !== null && lp.id !== activePathId) return false; + return true; + }); + + if (!visiblePaths.length) { + container.innerHTML = '
Kein aktiver Pfad ausgewaehlt
'; + return; + } + + let html = ""; + for (const lp of visiblePaths) { + const pid = lp.id; + const isSeriesLib = lp.media_type === "series"; + if (!sectionStates[pid]) { + sectionStates[pid] = { + tab: "videos", + page: 1, + limit: 50, + }; + } + const st = sectionStates[pid]; + + html += `
`; + html += `
`; + html += `

${escapeHtml(lp.name)}

`; + html += `${escapeHtml(lp.path)}`; + html += `
`; + html += ``; + html += `
`; + + // Tabs - Serien-Pfad: Videos+Serien+Ordner / Film-Pfad: Videos+Filme+Ordner + html += `
`; + html += ``; + if (isSeriesLib) { + html += ``; + } else { + html += ``; + } + html += ``; + html += `
`; + + // Tab-Content + html += `
`; + html += `
Lade...
`; + html += `
`; + + html += `
`; + } + container.innerHTML = html; + + // Gespeicherte Tab-States wiederherstellen + _restoreTabStates(); + + // Daten laden fuer sichtbare Bereiche + for (const lp of visiblePaths) { + loadSectionData(lp.id); + } +} + +function switchSectionTab(pathId, tab) { + sectionStates[pathId].tab = tab; + sectionStates[pathId].page = 1; + _saveUIState(); + // Tab-Buttons aktualisieren + const tabBar = document.getElementById("tabs-" + pathId); + if (tabBar) { + tabBar.querySelectorAll(".tab-btn").forEach(b => { + b.classList.toggle("active", b.getAttribute("data-tab") === tab); + }); + } + loadSectionData(pathId); +} + +function loadSectionData(pathId) { + const st = sectionStates[pathId]; + switch (st.tab) { + case "videos": loadSectionVideos(pathId); break; + case "series": loadSectionSeries(pathId); break; + case "movies": loadSectionMovies(pathId); break; + case "browser": loadSectionBrowser(pathId); break; + } +} + +// === Videos pro Bereich === + +function loadSectionVideos(pathId, page) { + const st = sectionStates[pathId]; + if (page) st.page = page; + const params = buildFilterParams(); + params.set("library_path_id", pathId); + params.set("page", st.page); + params.set("limit", st.limit); + + const content = document.getElementById("content-" + pathId); + fetch("/api/library/videos?" + params.toString()) + .then(r => r.json()) + .then(data => { + let html = '
'; + html += renderVideoTable(data.items || []); + html += '
'; + html += renderPagination(data.total || 0, data.page || 1, data.pages || 1, pathId, "videos"); + content.innerHTML = html; + }) + .catch(() => { content.innerHTML = '
Fehler
'; }); +} + +// === Filme pro Bereich === + +function loadSectionMovies(pathId) { + const content = document.getElementById("content-" + pathId); + fetch("/api/library/movies-list?path_id=" + pathId) + .then(r => r.json()) + .then(data => { + content.innerHTML = renderMovieGrid(data.movies || []); + }) + .catch(() => { content.innerHTML = '
Fehler
'; }); +} + +function renderMovieGrid(movies) { + if (!movies.length) return '
Keine Filme gefunden
'; + + let html = '
'; + for (const m of movies) { + const poster = m.poster_url + ? `` + : '
Kein Poster
'; + const year = m.year ? `${m.year}` : ""; + const genres = m.genres ? `
${escapeHtml(m.genres)}
` : ""; + const duration = m.duration_sec ? formatDuration(m.duration_sec) : ""; + const size = m.total_size ? formatSize(m.total_size) : ""; + const tvdbBtn = m.tvdb_id + ? 'TVDB' + : ``; + const overview = m.overview + ? `

${escapeHtml(m.overview.substring(0, 120))}${m.overview.length > 120 ? '...' : ''}

` + : ""; + + html += `
+ ${poster} +
+

${escapeHtml(m.title || m.folder_name)}

+ ${genres} + ${overview} +
+ ${year} + ${duration ? `${duration}` : ""} + ${size ? `${size}` : ""} + ${m.video_count || 0} Dateien + ${tvdbBtn} +
+
+
`; + } + html += '
'; + return html; +} + +// === Serien pro Bereich === + +function loadSectionSeries(pathId) { + const content = document.getElementById("content-" + pathId); + fetch("/api/library/series?path_id=" + pathId) + .then(r => r.json()) + .then(data => { + content.innerHTML = renderSeriesGrid(data.series || []); + }) + .catch(() => { content.innerHTML = '
Fehler
'; }); +} + +// === Ordner pro Bereich === + +let _browserLoading = false; + +function loadSectionBrowser(pathId, subPath) { + // Doppelklick-Schutz: Zweiten Aufruf ignorieren solange geladen wird + if (_browserLoading) return; + _browserLoading = true; + + const content = document.getElementById("content-" + pathId); + content.innerHTML = '
Lade Ordner...
'; + + const params = new URLSearchParams(); + if (subPath) params.set("path", subPath); + else { + // Basispfad der Library verwenden + const lp = libraryPaths.find(p => p.id === pathId); + if (lp) params.set("path", lp.path); + } + + fetch("/api/library/browse?" + params.toString()) + .then(r => r.json()) + .then(data => { + let html = renderBreadcrumb(data.breadcrumb || [], pathId); + html += renderBrowser(data.folders || [], data.videos || [], pathId); + content.innerHTML = html; + }) + .catch(() => { content.innerHTML = '
Fehler
'; }) + .finally(() => { _browserLoading = false; }); +} + +// === Video-Tabelle (gemeinsam genutzt) === + +function renderVideoTable(items) { + if (!items.length) return '
Keine Videos gefunden
'; + + let html = ''; + html += ''; + html += ''; + html += ''; + + for (const v of items) { + const audioInfo = (v.audio_tracks || []).map(a => { + const lang = (a.lang || "?").toUpperCase().substring(0, 3); + const ch = channelLayout(a.channels); + return `${lang} ${ch}`; + }).join(" "); + const subInfo = (v.subtitle_tracks || []).map(s => + `${(s.lang || "?").toUpperCase().substring(0, 3)}` + ).join(" "); + const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-"; + const is10bit = v.is_10bit ? ' 10bit' : ""; + + const vidTitle = v.file_name || "Video"; + html += ` + + + + + + + + + + `; + } + html += '
DateinameAufl.CodecAudioUntertitelGroesseDauerContainerAktion
${escapeHtml(v.file_name || "-")}${res}${is10bit}${v.video_codec || "-"}${audioInfo || "-"}${subInfo || "-"}${formatSize(v.file_size || 0)}${formatDuration(v.duration_sec || 0)}${(v.container || "-").toUpperCase()} + + + +
'; + return html; +} + +function renderPagination(total, page, pages, pathId, tabType) { + let html = '
'; + html += ''; + html += `
+ + +
`; + html += '
'; + return html; +} + +function changeSectionLimit(pathId, val) { + sectionStates[pathId].limit = parseInt(val) || 50; + sectionStates[pathId].page = 1; + loadSectionData(pathId); +} + +// === Serien-Grid === + +function renderSeriesGrid(series) { + if (!series.length) return '
Keine Serien gefunden
'; + + let html = '
'; + for (const s of series) { + const poster = s.poster_url + ? `` + : '
Kein Poster
'; + const missing = s.missing_episodes > 0 + ? `${s.missing_episodes} fehlend` : ""; + const redundant = s.redundant_files > 0 + ? `${s.redundant_files} redundant` : ""; + const genres = s.genres ? `
${escapeHtml(s.genres)}
` : ""; + const tvdbBtn = s.tvdb_id + ? `TVDB` + : ``; + + html += `
+ ${poster} +
+

${escapeHtml(s.title || s.folder_name)}

+ ${genres} +
+ ${s.local_episodes || 0} Episoden + ${missing} + ${redundant} + ${tvdbBtn} +
+ ${s.status ? `${s.status}` : ""} +
+
`; + } + html += '
'; + return html; +} + +// === Ordner-Ansicht === + +function renderBreadcrumb(crumbs, pathId) { + let html = '
'; + html += `Basis`; + for (const c of crumbs) { + html += ' / '; + html += `${escapeHtml(c.name)}`; + } + html += '
'; + return html; +} + +function renderBrowser(folders, videos, pathId) { + if (!folders.length && !videos.length) return '
Leerer Ordner
'; + + let html = ""; + if (folders.length) { + html += '
'; + for (const f of folders) { + const size = formatSize(f.total_size || 0); + const pathEsc = f.path.replace(/'/g, "\\'"); + html += `
+
+ 📁 +
+ ${escapeHtml(f.name)} + ${f.video_count} Videos, ${size} +
+
+ +
`; + } + html += '
'; + } + + if (videos.length) { + html += '
'; + html += renderVideoTable(videos); + html += '
'; + } + return html; +} + +// === Serien-Detail === + +function openSeriesDetail(seriesId) { + if (event) event.stopPropagation(); + currentSeriesId = seriesId; + currentDetailTab = "episodes"; + document.getElementById("series-modal").style.display = "flex"; + + // Tabs zuruecksetzen + document.querySelectorAll(".detail-tab").forEach(b => b.classList.remove("active")); + document.querySelector('.detail-tab[onclick*="episodes"]').classList.add("active"); + + fetch(`/api/library/series/${seriesId}`) + .then(r => r.json()) + .then(data => { + document.getElementById("series-modal-title").textContent = data.title || data.folder_name; + document.getElementById("series-modal-genres").textContent = data.genres || ""; + // Aktions-Buttons anzeigen + document.getElementById("btn-tvdb-refresh").style.display = data.tvdb_id ? "" : "none"; + document.getElementById("btn-tvdb-unlink").style.display = data.tvdb_id ? "" : "none"; + document.getElementById("btn-metadata-dl").style.display = data.tvdb_id ? "" : "none"; + renderEpisodesTab(data); + }) + .catch(() => { + document.getElementById("series-modal-body").innerHTML = + '
Fehler beim Laden
'; + }); +} + +function switchDetailTab(tab) { + currentDetailTab = tab; + document.querySelectorAll(".detail-tab").forEach(b => b.classList.remove("active")); + document.querySelector(`.detail-tab[onclick*="${tab}"]`).classList.add("active"); + + if (tab === "episodes") { + fetch(`/api/library/series/${currentSeriesId}`) + .then(r => r.json()) + .then(data => renderEpisodesTab(data)) + .catch(() => {}); + } else if (tab === "cast") { + loadCast(); + } else if (tab === "artworks") { + loadArtworks(); + } +} + +function renderEpisodesTab(series) { + const body = document.getElementById("series-modal-body"); + let html = '
'; + if (series.poster_url) { + html += ``; + } + html += '
'; + if (series.overview) html += `

${escapeHtml(series.overview)}

`; + html += '
'; + if (series.first_aired) html += `${series.first_aired}`; + if (series.status) html += `${series.status}`; + html += `${series.local_episodes || 0} lokal`; + if (series.total_episodes) html += `${series.total_episodes} gesamt`; + if (series.missing_episodes > 0) html += `${series.missing_episodes} fehlend`; + if (series.redundant_files > 0) html += `${series.redundant_files} redundant`; + html += '
'; + + // Episoden nach Staffeln + const episodes = series.episodes || []; + const tvdbEpisodes = series.tvdb_episodes || []; + const seasons = {}; + + for (const ep of episodes) { + const s = ep.season_number || 0; + if (!seasons[s]) seasons[s] = {local: [], missing: []}; + seasons[s].local.push(ep); + } + if (tvdbEpisodes.length) { + const localSet = new Set(episodes.map(e => `${e.season_number}-${e.episode_number}`)); + for (const ep of tvdbEpisodes) { + const key = `${ep.season_number}-${ep.episode_number}`; + if (!localSet.has(key) && ep.season_number > 0) { + const s = ep.season_number; + if (!seasons[s]) seasons[s] = {local: [], missing: []}; + seasons[s].missing.push(ep); + } + } + } + + const sortedSeasons = Object.keys(seasons).map(Number).sort((a, b) => a - b); + for (const sNum of sortedSeasons) { + const sData = seasons[sNum]; + html += `
`; + html += `Staffel ${sNum || "Unbekannt"} (${sData.local.length} vorhanden`; + if (sData.missing.length) html += `, ${sData.missing.length} fehlend`; + html += ')'; + + html += ''; + html += ''; + html += ''; + + const allEps = []; + for (const ep of sData.local) allEps.push({...ep, _type: "local"}); + for (const ep of sData.missing) allEps.push({...ep, _type: "missing"}); + allEps.sort((a, b) => (a.episode_number || 0) - (b.episode_number || 0)); + + for (const ep of allEps) { + if (ep._type === "missing") { + html += ` + + + + + `; + } else { + const audioInfo = (ep.audio_tracks || []).map(a => { + const lang = (a.lang || "?").toUpperCase().substring(0, 3); + return `${lang} ${channelLayout(a.channels)}`; + }).join(" "); + const res = ep.width && ep.height ? resolutionLabel(ep.width, ep.height) : "-"; + const epTitle = ep.episode_title || ep.file_name || "Episode"; + const fileExt = (ep.file_name || "").split(".").pop().toUpperCase() || "-"; + html += ` + + + + + + + + `; + } + } + html += '
NrTitelAufl.CodecTypAudioAktion
${ep.episode_number || "-"}${escapeHtml(ep.episode_name || "-")}Nicht vorhandenFEHLT
${ep.episode_number || "-"}${escapeHtml(epTitle)}${res}${ep.video_codec || "-"}${fileExt}${audioInfo || "-"} + + + +
'; + } + + if (!sortedSeasons.length) html += '
Keine Episoden gefunden
'; + body.innerHTML = html; +} + +function loadCast() { + const body = document.getElementById("series-modal-body"); + body.innerHTML = '
Lade Darsteller...
'; + + fetch(`/api/library/series/${currentSeriesId}/cast`) + .then(r => r.json()) + .then(data => { + const cast = data.cast || []; + if (!cast.length) { + body.innerHTML = '
Keine Darsteller-Daten vorhanden
'; + return; + } + let html = '
'; + for (const c of cast) { + const img = c.person_image_url || c.image_url; + const imgTag = img + ? `` + : '
?
'; + html += `
+ ${imgTag} +
+ ${escapeHtml(c.person_name)} + ${escapeHtml(c.character_name || "")} +
+
`; + } + html += '
'; + body.innerHTML = html; + }) + .catch(() => { body.innerHTML = '
Fehler
'; }); +} + +function loadArtworks() { + const body = document.getElementById("series-modal-body"); + body.innerHTML = '
Lade Bilder...
'; + + fetch(`/api/library/series/${currentSeriesId}/artworks`) + .then(r => r.json()) + .then(data => { + const artworks = data.artworks || []; + if (!artworks.length) { + body.innerHTML = '
Keine Bilder vorhanden
'; + return; + } + // Nach Typ gruppieren + const groups = {}; + for (const a of artworks) { + const t = a.artwork_type || "sonstige"; + if (!groups[t]) groups[t] = []; + groups[t].push(a); + } + let html = ''; + for (const [type, items] of Object.entries(groups)) { + html += `

${escapeHtml(type.charAt(0).toUpperCase() + type.slice(1))} (${items.length})

`; + html += ''; + } + body.innerHTML = html; + }) + .catch(() => { body.innerHTML = '
Fehler
'; }); +} + +function closeSeriesModal() { + document.getElementById("series-modal").style.display = "none"; + currentSeriesId = null; +} + +// === Serien-Aktionen === + +function tvdbRefresh() { + if (!currentSeriesId) return; + fetch(`/api/library/series/${currentSeriesId}/tvdb-refresh`, {method: "POST"}) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { showToast("TVDB aktualisiert: " + (data.name || ""), "success"); openSeriesDetail(currentSeriesId); } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +async function tvdbUnlink() { + if (!currentSeriesId) return; + if (!await showConfirm("TVDB-Zuordnung wirklich loesen?")) return; + fetch(`/api/library/series/${currentSeriesId}/tvdb`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { closeSeriesModal(); reloadAllSections(); } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function downloadMetadata() { + if (!currentSeriesId) return; + const btn = document.getElementById("btn-metadata-dl"); + btn.textContent = "Laden..."; + btn.disabled = true; + fetch(`/api/library/series/${currentSeriesId}/metadata-download`, {method: "POST"}) + .then(r => r.json()) + .then(data => { + btn.textContent = "Metadaten laden"; + btn.disabled = false; + if (data.error) showToast("Fehler: " + data.error, "error"); + else showToast(`${data.downloaded || 0} Dateien heruntergeladen, ${data.errors || 0} Fehler`, "success"); + }) + .catch(e => { btn.textContent = "Metadaten laden"; btn.disabled = false; showToast("Fehler: " + e, "error"); }); +} + +async function deleteSeries(withFiles) { + if (!currentSeriesId) return; + if (withFiles) { + if (!await showConfirm("Alle Dateien und Ordner werden UNWIDERRUFLICH geloescht!", {title: "Serie komplett loeschen", icon: "danger", okText: "Endgueltig loeschen", danger: true})) return; + } else { + if (!await showConfirm("Serie aus der Datenbank loeschen?", {detail: "Dateien bleiben erhalten", okText: "Aus DB loeschen", danger: true})) return; + } + const url = withFiles + ? `/api/library/series/${currentSeriesId}?delete_files=1` + : `/api/library/series/${currentSeriesId}`; + fetch(url, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + let msg = "Serie aus DB geloescht."; + if (data.deleted_folder) msg += " Ordner geloescht."; + showToast(msg, "success"); + if (data.folder_error) showToast("Ordner-Fehler: " + data.folder_error, "error"); + closeSeriesModal(); + reloadAllSections(); + loadStats(); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function showDeleteFolderDialog(folderPath, pathId, videoCount) { + const folderName = folderPath.split('/').pop(); + document.getElementById("confirm-title").textContent = "Ordner loeschen"; + document.getElementById("confirm-icon").innerHTML = ` + + + + + + `; + document.getElementById("confirm-message").innerHTML = ` + ${escapeHtml(folderName)}
+ wirklich loeschen?`; + document.getElementById("confirm-detail").innerHTML = ` + ${videoCount} Video${videoCount !== 1 ? 's' : ''} werden unwiderruflich geloescht.
+ Dieser Vorgang kann nicht rueckgaengig gemacht werden!`; + document.getElementById("confirm-btn-ok").textContent = "Endgueltig loeschen"; + document.getElementById("confirm-modal").style.display = "flex"; + + pendingConfirmAction = () => executeDeleteFolder(folderPath, pathId); +} + +function executeDeleteFolder(folderPath, pathId) { + fetch("/api/library/delete-folder", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({folder_path: folderPath}) + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + const msg = `${data.deleted_files || 0} Dateien geloescht`; + showToast(msg, "success"); + if (pathId) loadSectionData(pathId); + loadStats(); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Film-Detail === + +function openMovieDetail(movieId) { + if (event) event.stopPropagation(); + currentMovieId = movieId; + document.getElementById("movie-modal").style.display = "flex"; + + fetch(`/api/library/movies/${movieId}`) + .then(r => r.json()) + .then(data => { + document.getElementById("movie-modal-title").textContent = + data.title || data.folder_name; + document.getElementById("movie-modal-genres").textContent = + data.genres || ""; + // Aktions-Buttons + document.getElementById("btn-movie-tvdb-unlink").style.display = + data.tvdb_id ? "" : "none"; + + let html = '
'; + if (data.poster_url) { + html += ``; + } + html += '
'; + if (data.overview) html += `

${escapeHtml(data.overview)}

`; + html += '
'; + if (data.year) html += `${data.year}`; + if (data.runtime) html += `${data.runtime} min`; + if (data.status) html += `${data.status}`; + html += `${data.video_count || 0} Dateien`; + if (data.total_size) html += `${formatSize(data.total_size)}`; + html += '
'; + + // Video-Dateien des Films + const videos = data.videos || []; + if (videos.length) { + html += '

Video-Dateien

'; + html += ''; + html += ''; + html += ''; + for (const v of videos) { + const audioInfo = (v.audio_tracks || []).map(a => { + const lang = (a.lang || "?").toUpperCase().substring(0, 3); + return `${lang} ${channelLayout(a.channels)}`; + }).join(" "); + const res = v.width && v.height ? resolutionLabel(v.width, v.height) : "-"; + const movieTitle = v.file_name || "Video"; + const fileExt = (v.file_name || "").split(".").pop().toUpperCase() || "-"; + html += ` + + + + + + + + + `; + } + html += '
DateiAufl.CodecTypAudioGroesseDauerAktion
${escapeHtml(v.file_name || "-")}${res}${v.is_10bit ? ' 10bit' : ''}${v.video_codec || "-"}${fileExt}${audioInfo || "-"}${formatSize(v.file_size || 0)}${formatDuration(v.duration_sec || 0)} + + + +
'; + } + + document.getElementById("movie-modal-body").innerHTML = html; + }) + .catch(() => { + document.getElementById("movie-modal-body").innerHTML = + '
Fehler beim Laden
'; + }); +} + +function closeMovieModal() { + document.getElementById("movie-modal").style.display = "none"; + currentMovieId = null; +} + +async function movieTvdbUnlink() { + if (!currentMovieId) return; + if (!await showConfirm("TVDB-Zuordnung wirklich loesen?", {title: "TVDB trennen", okText: "Trennen"})) return; + fetch(`/api/library/movies/${currentMovieId}/tvdb`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { closeMovieModal(); reloadAllSections(); } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +async function deleteMovie(withFiles) { + if (!currentMovieId) return; + if (withFiles) { + if (!await showConfirm("Film komplett loeschen?

Alle Dateien werden UNWIDERRUFLICH geloescht!", {title: "Film loeschen", okText: "Endgueltig loeschen", icon: "danger", danger: true})) return; + if (!await showConfirm("Wirklich sicher?", {title: "Letzte Warnung", okText: "Ja, loeschen", icon: "danger", danger: true})) return; + } else { + if (!await showConfirm("Film aus der Datenbank loeschen?
(Dateien bleiben erhalten)", {title: "Film entfernen", okText: "Aus DB loeschen"})) return; + } + const url = withFiles + ? `/api/library/movies/${currentMovieId}?delete_files=1` + : `/api/library/movies/${currentMovieId}`; + fetch(url, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + showToast("Film aus DB geloescht." + (data.deleted_folder ? " Ordner geloescht." : ""), "success"); + closeMovieModal(); + reloadAllSections(); + loadStats(); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Film-TVDB-Zuordnung === + +function openMovieTvdbModal(movieId, title) { + document.getElementById("movie-tvdb-modal").style.display = "flex"; + document.getElementById("movie-tvdb-id").value = movieId; + document.getElementById("movie-tvdb-search-input").value = cleanSearchTitle(title); + document.getElementById("movie-tvdb-results").innerHTML = ""; + searchMovieTvdb(); +} + +function closeMovieTvdbModal() { + document.getElementById("movie-tvdb-modal").style.display = "none"; +} + +function searchMovieTvdb() { + const query = document.getElementById("movie-tvdb-search-input").value.trim(); + if (!query) return; + + const results = document.getElementById("movie-tvdb-results"); + results.innerHTML = '
Suche...
'; + + fetch(`/api/tvdb/search-movies?q=${encodeURIComponent(query)}`) + .then(r => r.json()) + .then(data => { + if (data.error) { results.innerHTML = `
${escapeHtml(data.error)}
`; return; } + if (!data.results || !data.results.length) { results.innerHTML = '
Keine Ergebnisse
'; return; } + results.innerHTML = data.results.map(r => ` +
+ ${r.poster ? `` : ""} +
+ ${escapeHtml(r.name)} + ${r.year || ""} +

${escapeHtml((r.overview || "").substring(0, 150))}

+
+
+ `).join(""); + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +function matchMovieTvdb(tvdbId) { + const movieId = document.getElementById("movie-tvdb-id").value; + const results = document.getElementById("movie-tvdb-results"); + results.innerHTML = '
Verknuepfe...
'; + + fetch(`/api/library/movies/${movieId}/tvdb-match`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({tvdb_id: tvdbId}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { results.innerHTML = `
${escapeHtml(data.error)}
`; } + else { closeMovieTvdbModal(); reloadAllSections(); } + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +let movieTvdbSearchTimer = null; +function debounceMovieTvdbSearch() { + if (movieTvdbSearchTimer) clearTimeout(movieTvdbSearchTimer); + movieTvdbSearchTimer = setTimeout(searchMovieTvdb, 500); +} + +// === Filter === + +let filterPresets = {}; +let defaultView = "all"; + +function buildFilterParams() { + const params = new URLSearchParams(); + const search = document.getElementById("filter-search").value.trim(); + if (search) params.set("search", search); + const codec = document.getElementById("filter-codec").value; + if (codec) params.set("video_codec", codec); + const container = document.getElementById("filter-container").value; + if (container) params.set("container", container); + const audioLang = document.getElementById("filter-audio-lang").value; + if (audioLang) params.set("audio_lang", audioLang); + const audioCh = document.getElementById("filter-audio-ch").value; + if (audioCh) params.set("audio_channels", audioCh); + if (document.getElementById("filter-10bit").checked) params.set("is_10bit", "1"); + if (document.getElementById("filter-not-converted").checked) params.set("not_converted", "1"); + const resolution = document.getElementById("filter-resolution").value; + if (resolution) params.set("min_width", resolution); + const sort = document.getElementById("filter-sort").value; + if (sort) params.set("sort", sort); + const order = document.getElementById("filter-order").value; + if (order) params.set("order", order); + return params; +} + +function applyFilters() { + // Preset-Auswahl zuruecksetzen wenn manuell gefiltert wird + document.getElementById("filter-preset").value = ""; + _saveUIState(); + for (const pid of Object.keys(sectionStates)) { + sectionStates[pid].page = 1; + loadSectionData(parseInt(pid)); + } +} + +function debounceFilter() { + if (filterTimeout) clearTimeout(filterTimeout); + filterTimeout = setTimeout(applyFilters, 400); +} + +// Filter-Presets laden +async function loadFilterPresets() { + try { + const resp = await fetch("/api/library/filter-presets"); + const data = await resp.json(); + filterPresets = data.presets || {}; + defaultView = data.default_view || "all"; + + // Preset-Dropdown befuellen + const select = document.getElementById("filter-preset"); + // Bestehende Custom-Presets entfernen (4 feste Optionen behalten: Alle, Nicht konvertiert, Alte Formate, Fehlende Episoden) + while (select.options.length > 4) { + select.remove(4); + } + // Gespeicherte Presets hinzufuegen + for (const [id, preset] of Object.entries(filterPresets)) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = preset.name || id; + select.appendChild(opt); + } + + // Gespeicherten Filter-State wiederherstellen (hat Vorrang) + _restoreFilterState(); + + // Standard-Ansicht nur beim ersten Besuch anwenden + if (!localStorage.getItem("vk_filterState") && defaultView && defaultView !== "all") { + applyPreset(defaultView); + } + } catch (e) { + console.error("Filter-Presets laden fehlgeschlagen:", e); + } +} + +// Preset anwenden +let showMissingMode = false; + +function applyPreset(presetId) { + const id = presetId || document.getElementById("filter-preset").value; + + // Alle Filter zuruecksetzen + resetFiltersQuiet(); + showMissingMode = false; + + if (!id) { + // "Alle anzeigen" + _updateDeletePresetBtn(); + applyFilters(); + return; + } + + // Eingebaute Presets + if (id === "not_converted") { + document.getElementById("filter-not-converted").checked = true; + } else if (id === "old_formats") { + // Alte Formate = alles ausser AV1 + document.getElementById("filter-codec").value = ""; + document.getElementById("filter-not-converted").checked = true; + } else if (id === "missing_episodes") { + // Fehlende Episoden - spezieller Modus + showMissingMode = true; + loadMissingEpisodes(); + document.getElementById("filter-preset").value = id; + return; + } else if (filterPresets[id]) { + // Custom Preset + const p = filterPresets[id]; + if (p.video_codec) document.getElementById("filter-codec").value = p.video_codec; + if (p.container) document.getElementById("filter-container").value = p.container; + if (p.min_width) document.getElementById("filter-resolution").value = p.min_width; + if (p.audio_lang) document.getElementById("filter-audio-lang").value = p.audio_lang; + if (p.is_10bit) document.getElementById("filter-10bit").checked = true; + if (p.not_converted) document.getElementById("filter-not-converted").checked = true; + if (p.show_missing) { + showMissingMode = true; + loadMissingEpisodes(); + document.getElementById("filter-preset").value = id; + return; + } + } + + document.getElementById("filter-preset").value = id; + _updateDeletePresetBtn(); + _saveUIState(); + + for (const pid of Object.keys(sectionStates)) { + sectionStates[pid].page = 1; + loadSectionData(parseInt(pid)); + } +} + +// Fehlende Episoden laden und anzeigen +let missingPage = 1; +async function loadMissingEpisodes(page = 1) { + missingPage = page; + const container = document.getElementById("library-content"); + container.innerHTML = '
Lade fehlende Episoden...
'; + + try { + const resp = await fetch(`/api/library/missing-episodes?page=${page}&limit=50`); + const data = await resp.json(); + + if (!data.items || data.items.length === 0) { + container.innerHTML = '
Keine fehlenden Episoden gefunden. Alle Serien sind vollstaendig!
'; + return; + } + + // Gruppiere nach Serie + const bySeries = {}; + for (const ep of data.items) { + const key = ep.series_id; + if (!bySeries[key]) { + bySeries[key] = { + series_id: ep.series_id, + series_title: ep.series_title, + poster_url: ep.poster_url, + episodes: [] + }; + } + bySeries[key].episodes.push(ep); + } + + let html = `
+

Fehlende Episoden (${data.total} insgesamt)

`; + + for (const series of Object.values(bySeries)) { + html += `
+
+ ${series.poster_url ? `` : ''} +

${series.series_title}

+ ${series.episodes.length} fehlend +
+
`; + + for (const ep of series.episodes) { + const aired = ep.aired ? ` (${ep.aired})` : ''; + html += `
+ S${String(ep.season_number).padStart(2,'0')}E${String(ep.episode_number).padStart(2,'0')} + ${ep.episode_name || 'Unbekannt'}${aired} +
`; + } + + html += `
`; + } + + // Pagination + if (data.pages > 1) { + html += '
'; + if (page > 1) { + html += ``; + } + html += ` Seite ${page} von ${data.pages} `; + if (page < data.pages) { + html += ``; + } + html += '
'; + } + + html += '
'; + container.innerHTML = html; + + } catch (e) { + container.innerHTML = `
Fehler: ${e}
`; + } +} + +// Filter zuruecksetzen (ohne Reload) +function resetFiltersQuiet() { + document.getElementById("filter-search").value = ""; + document.getElementById("filter-codec").value = ""; + document.getElementById("filter-container").value = ""; + document.getElementById("filter-resolution").value = ""; + document.getElementById("filter-audio-lang").value = ""; + document.getElementById("filter-audio-ch").value = ""; + document.getElementById("filter-10bit").checked = false; + document.getElementById("filter-not-converted").checked = false; + document.getElementById("filter-sort").value = "file_name"; + document.getElementById("filter-order").value = "asc"; +} + +// Filter zuruecksetzen (mit Reload) +function resetFilters() { + resetFiltersQuiet(); + document.getElementById("filter-preset").value = ""; + applyFilters(); +} + +// Aktuellen Filter als Preset speichern +async function saveCurrentFilter() { + const name = await showPrompt("Name fuer diesen Filter:", {title: "Filter speichern", placeholder: "z.B. Nur 4K", okText: "Speichern"}); + if (!name) return; + + const id = name.toLowerCase().replace(/[^a-z0-9]/g, "_"); + const filters = {}; + + const codec = document.getElementById("filter-codec").value; + if (codec) filters.video_codec = codec; + const container = document.getElementById("filter-container").value; + if (container) filters.container = container; + const resolution = document.getElementById("filter-resolution").value; + if (resolution) filters.min_width = resolution; + const audioLang = document.getElementById("filter-audio-lang").value; + if (audioLang) filters.audio_lang = audioLang; + if (document.getElementById("filter-10bit").checked) filters.is_10bit = true; + if (document.getElementById("filter-not-converted").checked) filters.not_converted = true; + + try { + const resp = await fetch("/api/library/filter-presets", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({id, name, filters}), + }); + if (resp.ok) { + showToast(`Filter "${name}" gespeichert`, "success"); + loadFilterPresets(); + } else { + showToast("Fehler beim Speichern", "error"); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +// Aktuellen Filter als Standard setzen +async function setAsDefault() { + const presetId = document.getElementById("filter-preset").value || "all"; + try { + const resp = await fetch("/api/library/default-view", { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({default_view: presetId}), + }); + if (resp.ok) { + defaultView = presetId; + showToast("Standard-Ansicht gespeichert", "success"); + } else { + showToast("Fehler beim Speichern", "error"); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +// Aktuelles Custom-Preset loeschen +async function deleteCurrentPreset() { + const select = document.getElementById("filter-preset"); + const presetId = select.value; + // Nur Custom-Presets loeschen (nicht die eingebauten) + const builtIn = ["", "not_converted", "old_formats", "missing_episodes"]; + if (builtIn.includes(presetId)) return; + + if (!await showConfirm(`Filter "${escapeHtml(select.options[select.selectedIndex].text)}" wirklich loeschen?`, {title: "Filter loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + + try { + const resp = await fetch(`/api/library/filter-presets/${presetId}`, { + method: "DELETE", + }); + if (resp.ok) { + showToast("Filter geloescht", "success"); + select.value = ""; + applyPreset(""); + loadFilterPresets(); + } else { + showToast("Fehler beim Loeschen", "error"); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +// Loeschen-Button je nach Preset ein-/ausblenden +function _updateDeletePresetBtn() { + const btn = document.getElementById("btn-delete-preset"); + if (!btn) return; + const presetId = document.getElementById("filter-preset").value; + const builtIn = ["", "not_converted", "old_formats", "missing_episodes"]; + btn.style.display = builtIn.includes(presetId) ? "none" : ""; +} + +// === Scan === + +function startScan() { + fetch("/api/library/scan", {method: "POST"}) + .catch(e => console.error("Scan-Fehler:", e)); + // Progress-Updates kommen via WebSocket -> globaler Progress-Balken in base.html +} + +function scanSinglePath(pathId) { + fetch(`/api/library/scan/${pathId}`, {method: "POST"}) + .catch(e => console.error("Scan-Fehler:", e)); + // Progress-Updates kommen via WebSocket -> globaler Progress-Balken in base.html +} + +function reloadAllSections() { + for (const pid of Object.keys(sectionStates)) { + loadSectionData(parseInt(pid)); + } +} + +// === TVDB Auto-Match (Review-Modus) === + +let tvdbReviewData = []; // Vorschlaege die noch geprueft werden muessen + +async function startAutoMatch() { + if (!await showConfirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?", {title: "Auto-Match starten", detail: "Das kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.", okText: "Starten", icon: "info"})) return; + + const progress = document.getElementById("auto-match-progress"); + progress.style.display = "block"; + document.getElementById("auto-match-status").textContent = "Suche TVDB-Vorschlaege..."; + document.getElementById("auto-match-bar").style.width = "0%"; + + fetch("/api/library/tvdb-auto-match?type=all", {method: "POST"}) + .then(r => r.json()) + .then(data => { + if (data.error) { + document.getElementById("auto-match-status").textContent = "Fehler: " + data.error; + setTimeout(() => { progress.style.display = "none"; }, 3000); + return; + } + pollAutoMatchStatus(); + }) + .catch(e => { + document.getElementById("auto-match-status").textContent = "Fehler: " + e; + }); +} + +function pollAutoMatchStatus() { + const progress = document.getElementById("auto-match-progress"); + const interval = setInterval(() => { + fetch("/api/library/tvdb-auto-match-status") + .then(r => r.json()) + .then(data => { + const bar = document.getElementById("auto-match-bar"); + const status = document.getElementById("auto-match-status"); + + if (data.phase === "done") { + clearInterval(interval); + bar.style.width = "100%"; + const suggestions = data.suggestions || []; + // Nur Items mit mindestens einem Vorschlag anzeigen + const withSuggestions = suggestions.filter(s => s.suggestions && s.suggestions.length > 0); + const noResults = suggestions.length - withSuggestions.length; + status.textContent = `${withSuggestions.length} Vorschlaege gefunden, ${noResults} ohne Ergebnis`; + + setTimeout(() => { + progress.style.display = "none"; + }, 2000); + + if (withSuggestions.length > 0) { + openTvdbReviewModal(withSuggestions); + } + } else if (data.phase === "error") { + clearInterval(interval); + status.textContent = "Fehler beim Sammeln der Vorschlaege"; + setTimeout(() => { progress.style.display = "none"; }, 3000); + } else if (!data.active && data.phase !== "done") { + clearInterval(interval); + progress.style.display = "none"; + } else { + const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0; + bar.style.width = pct + "%"; + const phase = data.phase === "series" ? "Serien" : "Filme"; + status.textContent = `${phase}: ${data.current || ""} (${data.done}/${data.total})`; + } + }) + .catch(() => clearInterval(interval)); + }, 5000); // 5s Fallback (WS liefert Live-Updates) +} + +// === TVDB Review-Modal === + +function openTvdbReviewModal(suggestions) { + tvdbReviewData = suggestions; + document.getElementById("tvdb-review-modal").style.display = "flex"; + renderTvdbReviewList(); +} + +function closeTvdbReviewModal() { + document.getElementById("tvdb-review-modal").style.display = "none"; + tvdbReviewData = []; + reloadAllSections(); + loadStats(); +} + +function renderTvdbReviewList() { + const list = document.getElementById("tvdb-review-list"); + const remaining = tvdbReviewData.filter(item => !item._confirmed && !item._skipped); + const confirmed = tvdbReviewData.filter(item => item._confirmed); + const skipped = tvdbReviewData.filter(item => item._skipped); + + document.getElementById("tvdb-review-info").textContent = + `${remaining.length} offen, ${confirmed.length} zugeordnet, ${skipped.length} uebersprungen`; + + if (!tvdbReviewData.length) { + list.innerHTML = '
Keine Vorschlaege vorhanden
'; + return; + } + + let html = ''; + for (let i = 0; i < tvdbReviewData.length; i++) { + const item = tvdbReviewData[i]; + const typeLabel = item.type === "series" ? "Serie" : "Film"; + const typeClass = item.type === "series" ? "tag-series" : "tag-movie"; + + // Status-Klasse + let statusClass = ""; + let statusHtml = ""; + if (item._confirmed) { + statusClass = "review-item-done"; + statusHtml = `Zugeordnet: ${escapeHtml(item._confirmedName || "")}`; + } else if (item._skipped) { + statusClass = "review-item-skipped"; + statusHtml = 'Uebersprungen'; + } + + html += `
`; + html += `
`; + html += `${typeLabel}`; + html += `${escapeHtml(item.local_name)}`; + if (item.year) html += `(${item.year})`; + if (statusHtml) html += statusHtml; + if (!item._confirmed && !item._skipped) { + html += ``; + html += ``; + } + html += `
`; + + // Vorschlaege anzeigen (nur wenn noch nicht bestaetigt/uebersprungen) + if (!item._confirmed && !item._skipped) { + html += `
`; + if (!item.suggestions || !item.suggestions.length) { + html += 'Keine Vorschlaege gefunden'; + } else { + for (const s of item.suggestions) { + const poster = s.poster + ? `` + : '
?
'; + html += `
`; + html += poster; + html += `
`; + html += `${escapeHtml(s.name)}`; + if (s.year) html += ` (${s.year})`; + if (s.overview) html += `

${escapeHtml(s.overview)}

`; + html += `
`; + html += `
`; + } + } + // Manuelles Suchfeld (versteckt, wird bei Klick auf "Manuell suchen" angezeigt) + html += ``; + html += `
`; + } + + html += `
`; + } + list.innerHTML = html; +} + +function confirmReviewItem(index, tvdbId, name) { + const item = tvdbReviewData[index]; + if (item._confirmed || item._skipped) return; + + // Visuelles Feedback + const el = document.getElementById("review-item-" + index); + if (el) el.classList.add("review-item-loading"); + + fetch("/api/library/tvdb-confirm", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({id: item.id, type: item.type, tvdb_id: tvdbId}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + if (el) el.classList.remove("review-item-loading"); + return; + } + item._confirmed = true; + item._confirmedName = data.name || name; + renderTvdbReviewList(); + }) + .catch(e => { + showToast("Fehler: " + e, "error"); + if (el) el.classList.remove("review-item-loading"); + }); +} + +function skipReviewItem(index) { + tvdbReviewData[index]._skipped = true; + renderTvdbReviewList(); +} + +function skipAllReviewItems() { + for (const item of tvdbReviewData) { + if (!item._confirmed) item._skipped = true; + } + renderTvdbReviewList(); +} + +function manualTvdbSearchReview(index) { + const el = document.getElementById("review-manual-" + index); + if (el) { + el.style.display = el.style.display === "none" ? "flex" : "none"; + } +} + +function executeManualReviewSearch(index) { + const item = tvdbReviewData[index]; + const input = document.getElementById("review-search-input-" + index); + const results = document.getElementById("review-search-results-" + index); + const query = input ? input.value.trim() : ""; + if (!query) return; + + results.innerHTML = '
Suche...
'; + + const endpoint = item.type === "series" + ? `/api/tvdb/search?q=${encodeURIComponent(query)}` + : `/api/tvdb/search-movies?q=${encodeURIComponent(query)}`; + + fetch(endpoint) + .then(r => r.json()) + .then(data => { + if (data.error) { + results.innerHTML = `
${escapeHtml(data.error)}
`; + return; + } + const list = data.results || []; + if (!list.length) { + results.innerHTML = '
Keine Ergebnisse
'; + return; + } + results.innerHTML = list.map(r => ` +
+ ${r.poster ? `` : '
?
'} +
+ ${escapeHtml(r.name)} + ${r.year ? `(${r.year})` : ""} +

${escapeHtml((r.overview || "").substring(0, 120))}

+
+
+ `).join(""); + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +// === TVDB Modal === + +function openTvdbModal(seriesId, folderName) { + document.getElementById("tvdb-modal").style.display = "flex"; + document.getElementById("tvdb-series-id").value = seriesId; + document.getElementById("tvdb-search-input").value = cleanSearchTitle(folderName); + document.getElementById("tvdb-results").innerHTML = ""; + // Checkbox zuruecksetzen + const engCheckbox = document.getElementById("tvdb-search-english"); + if (engCheckbox) engCheckbox.checked = false; + searchTvdb(); +} + +function closeTvdbModal() { + document.getElementById("tvdb-modal").style.display = "none"; +} + +function searchTvdb() { + const query = document.getElementById("tvdb-search-input").value.trim(); + if (!query) return; + + const results = document.getElementById("tvdb-results"); + results.innerHTML = '
Suche...
'; + + // Sprache: eng wenn Checkbox aktiv, sonst Standard (deu) + const useEnglish = document.getElementById("tvdb-search-english")?.checked; + const langParam = useEnglish ? "&lang=eng" : ""; + + fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}${langParam}`) + .then(r => r.json()) + .then(data => { + if (data.error) { results.innerHTML = `
${escapeHtml(data.error)}
`; return; } + if (!data.results || !data.results.length) { results.innerHTML = '
Keine Ergebnisse
'; return; } + results.innerHTML = data.results.map(r => { + // Zeige Original-Namen wenn vorhanden und unterschiedlich + const origName = r.original_name && r.original_name !== r.name + ? `(${escapeHtml(r.original_name)})` + : ""; + return ` +
+ ${r.poster ? `` : ""} +
+ ${escapeHtml(r.name)} ${origName} + ${r.year || ""} +

${escapeHtml((r.overview || "").substring(0, 150))}

+
+
+ `}).join(""); + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +function matchTvdb(tvdbId) { + const seriesId = document.getElementById("tvdb-series-id").value; + const results = document.getElementById("tvdb-results"); + results.innerHTML = '
Verknuepfe...
'; + + fetch(`/api/library/series/${seriesId}/tvdb-match`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({tvdb_id: tvdbId}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { results.innerHTML = `
${escapeHtml(data.error)}
`; } + else { closeTvdbModal(); reloadAllSections(); } + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +let tvdbSearchTimer = null; +function debounceTvdbSearch() { + if (tvdbSearchTimer) clearTimeout(tvdbSearchTimer); + tvdbSearchTimer = setTimeout(searchTvdb, 500); +} + +// === Konvertierung === + +async function convertVideo(videoId) { + if (event) event.stopPropagation(); + if (!await showConfirm("Video zur Konvertierung senden?", {title: "Konvertieren", okText: "Starten", icon: "info"})) return; + + fetch(`/api/library/videos/${videoId}/convert`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else showToast(data.message || "Job erstellt", "success"); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Serie komplett konvertieren === + +function openConvertSeriesModal() { + if (!currentSeriesId) return; + + document.getElementById("convert-series-modal").style.display = "flex"; + document.getElementById("convert-series-status").innerHTML = + '
Lade Codec-Status...
'; + + // Codec-Status laden + fetch(`/api/library/series/${currentSeriesId}/convert-status`) + .then(r => r.json()) + .then(data => { + if (data.error) { + document.getElementById("convert-series-status").innerHTML = + `
${escapeHtml(data.error)}
`; + return; + } + + let html = `
${data.total} Episoden
`; + html += '
'; + for (const [codec, count] of Object.entries(data.codec_counts || {})) { + const isTarget = codec.includes("av1") || codec.includes("hevc"); + const cls = isTarget ? "tag ok" : "tag"; + html += `${codec}: ${count} `; + } + html += '
'; + document.getElementById("convert-series-status").innerHTML = html; + }) + .catch(e => { + document.getElementById("convert-series-status").innerHTML = + `
Fehler: ${e}
`; + }); +} + +function closeConvertSeriesModal() { + document.getElementById("convert-series-modal").style.display = "none"; +} + +function executeConvertSeries() { + if (!currentSeriesId) return; + + const targetCodec = document.getElementById("convert-target-codec").value; + const forceAll = document.getElementById("convert-force-all").checked; + const deleteOld = document.getElementById("convert-delete-old").checked; + + const btn = document.querySelector("#convert-series-modal .btn-primary"); + btn.textContent = "Wird gestartet..."; + btn.disabled = true; + + fetch(`/api/library/series/${currentSeriesId}/convert`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + target_codec: targetCodec, + force_all: forceAll, + delete_old: deleteOld, + }), + }) + .then(r => r.json()) + .then(data => { + btn.textContent = "Konvertierung starten"; + btn.disabled = false; + + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + + let msg = data.message || "Konvertierung gestartet"; + if (data.already_done > 0) { + msg += ` (${data.already_done} bereits im Zielformat)`; + } + showToast(msg, "success"); + closeConvertSeriesModal(); + }) + .catch(e => { + btn.textContent = "Konvertierung starten"; + btn.disabled = false; + showToast("Fehler: " + e, "error"); + }); +} + +// === Serien-Ordner aufraeumen === + +async function cleanupSeriesFolder() { + if (!currentSeriesId) return; + + if (!await showConfirm("Alle Dateien im Serien-Ordner loeschen, die NICHT in der Bibliothek sind?", { + title: "Ordner aufraeumen", + detail: "Behalten werden:
- Alle Videos in der Bibliothek
- .metadata Ordner
- .nfo, .jpg, .png Dateien

Dies kann nicht rueckgaengig gemacht werden!", + okText: "Aufraeumen", + icon: "danger", + danger: true + })) return; + + fetch(`/api/library/series/${currentSeriesId}/cleanup`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + let msg = `${data.deleted} Dateien geloescht.`; + if (data.errors > 0) msg += ` ${data.errors} Fehler.`; + showToast(msg, data.errors > 0 ? "warning" : "success"); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Duplikate === + +function showDuplicates() { + document.getElementById("duplicates-modal").style.display = "flex"; + const list = document.getElementById("duplicates-list"); + list.innerHTML = '
Suche Duplikate...
'; + + fetch("/api/library/duplicates") + .then(r => r.json()) + .then(data => { + const dupes = data.duplicates || []; + if (!dupes.length) { list.innerHTML = '
Keine Duplikate gefunden
'; return; } + list.innerHTML = dupes.map(d => ` +
+
+ ${escapeHtml(d.name1)} + ${d.codec1} + ${d.width1}x${d.height1} + ${formatSize(d.size1)} +
+
vs
+
+ ${escapeHtml(d.name2)} + ${d.codec2} + ${d.width2}x${d.height2} + ${formatSize(d.size2)} +
+
+ `).join(""); + }) + .catch(e => { list.innerHTML = `
Fehler: ${e}
`; }); +} + +function closeDuplicatesModal() { + document.getElementById("duplicates-modal").style.display = "none"; +} + +// === Pfade-Verwaltung === + +function openPathsModal() { + document.getElementById("paths-modal").style.display = "flex"; + loadPathsList(); +} + +function closePathsModal() { + document.getElementById("paths-modal").style.display = "none"; +} + +function loadPathsList() { + fetch("/api/library/paths") + .then(r => r.json()) + .then(data => { + const paths = data.paths || []; + const list = document.getElementById("paths-list"); + if (!paths.length) { + list.innerHTML = '
Keine Pfade konfiguriert
'; + return; + } + list.innerHTML = paths.map(p => ` +
+
+ ${escapeHtml(p.name)} + ${escapeHtml(p.path)} + ${p.media_type === 'series' ? 'Serien' : 'Filme'} + ${p.enabled ? 'Aktiv' : 'Deaktiviert'} + ${p.video_count !== undefined ? `${p.video_count} Videos` : ""} +
+
+ + +
+
+ `).join(""); + }) + .catch(() => {}); +} + +function addPath() { + const name = document.getElementById("new-path-name").value.trim(); + const path = document.getElementById("new-path-path").value.trim(); + const media_type = document.getElementById("new-path-type").value; + if (!name || !path) { showToast("Name und Pfad erforderlich", "error"); return; } + + fetch("/api/library/paths", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({name, path, media_type}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { + document.getElementById("new-path-name").value = ""; + document.getElementById("new-path-path").value = ""; + showToast("Pfad hinzugefuegt", "success"); + loadPathsList(); + loadLibraryPaths(); + } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function togglePath(pathId, enabled) { + fetch(`/api/library/paths/${pathId}`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({enabled}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { loadPathsList(); loadLibraryPaths(); } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +async function deletePath(pathId) { + if (!await showConfirm("Pfad wirklich loeschen?
(Videos bleiben erhalten, aber werden aus DB entfernt)", {title: "Pfad loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + fetch(`/api/library/paths/${pathId}`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) showToast("Fehler: " + data.error, "error"); + else { showToast("Pfad geloescht", "success"); loadPathsList(); loadLibraryPaths(); } + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Clean-Modal === + +function openCleanModal() { + document.getElementById("clean-modal").style.display = "flex"; + document.getElementById("clean-list").innerHTML = + '
Klicke "Junk scannen" um zu starten
'; + document.getElementById("clean-info").textContent = ""; + cleanData = []; +} + +function closeCleanModal() { + document.getElementById("clean-modal").style.display = "none"; +} + +function scanForJunk() { + const list = document.getElementById("clean-list"); + list.innerHTML = '
Scanne...
'; + + fetch("/api/library/clean/scan") + .then(r => r.json()) + .then(data => { + cleanData = data.files || []; + document.getElementById("clean-info").textContent = + `${data.total_count || 0} Dateien, ${formatSize(data.total_size || 0)}`; + + // Extensions-Filter fuellen + const exts = new Set(cleanData.map(f => f.extension)); + const select = document.getElementById("clean-ext-filter"); + select.innerHTML = ''; + for (const ext of [...exts].sort()) { + select.innerHTML += ``; + } + + renderCleanList(cleanData); + }) + .catch(e => { list.innerHTML = `
Fehler: ${e}
`; }); +} + +function renderCleanList(files) { + const list = document.getElementById("clean-list"); + if (!files.length) { + list.innerHTML = '
Keine Junk-Dateien gefunden
'; + return; + } + + let html = ''; + html += ''; + html += ''; + html += ''; + + for (let i = 0; i < files.length; i++) { + const f = files[i]; + html += ` + + + + + + `; + } + html += '
DateinameSerie/OrdnerExtensionGroesse
${escapeHtml(f.name)}${escapeHtml(f.parent_series || "-")}${escapeHtml(f.extension)}${formatSize(f.size)}
'; + list.innerHTML = html; +} + +function filterCleanList() { + const ext = document.getElementById("clean-ext-filter").value; + if (!ext) { renderCleanList(cleanData); return; } + renderCleanList(cleanData.filter(f => f.extension === ext)); +} + +function toggleCleanSelectAll() { + const checked = document.getElementById("clean-select-all")?.checked + || document.getElementById("clean-select-all-header")?.checked || false; + document.querySelectorAll(".clean-check").forEach(cb => cb.checked = checked); +} + +async function deleteSelectedJunk() { + const checked = document.querySelectorAll(".clean-check:checked"); + if (!checked.length) { showToast("Keine Dateien ausgewaehlt", "error"); return; } + if (!await showConfirm(`${checked.length} Dateien wirklich loeschen?`, {title: "Junk loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + + const files = []; + checked.forEach(cb => { + const idx = parseInt(cb.getAttribute("data-idx")); + if (cleanData[idx]) files.push(cleanData[idx].path); + }); + + fetch("/api/library/clean/delete", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({files}), + }) + .then(r => r.json()) + .then(data => { + showToast(`${data.deleted || 0} geloescht, ${data.failed || 0} fehlgeschlagen`, data.failed > 0 ? "warning" : "success"); + if (data.errors && data.errors.length) console.warn("Clean-Fehler:", data.errors); + scanForJunk(); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function deleteEmptyDirs() { + fetch("/api/library/clean/empty-dirs", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(data => showToast(`${data.deleted_dirs || 0} leere Ordner geloescht`, "success")) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Import-Modal === + +let importBrowseTimer = null; + +function openImportModal() { + document.getElementById("import-modal").style.display = "flex"; + document.getElementById("import-setup").style.display = ""; + document.getElementById("import-preview").style.display = "none"; + document.getElementById("import-progress").style.display = "none"; + document.getElementById("import-source").value = ""; + document.getElementById("import-folder-info").textContent = ""; + document.getElementById("btn-analyze-import").disabled = true; + currentImportJobId = null; + + // Ziel-Libraries laden + fetch("/api/library/paths") + .then(r => r.json()) + .then(data => { + const select = document.getElementById("import-target"); + select.innerHTML = (data.paths || []).map(p => + `` + ).join(""); + }) + .catch(() => {}); + + // Bestehende Import-Jobs laden + loadExistingImportJobs(); + + // Standard-Pfad im Filebrowser oeffnen + importBrowse("/mnt"); +} + +function loadExistingImportJobs() { + fetch("/api/library/import") + .then(r => r.json()) + .then(data => { + const jobs = (data.jobs || []).filter(j => j.status !== 'done'); + const container = document.getElementById("import-existing"); + const list = document.getElementById("import-jobs-list"); + + if (!jobs.length) { + container.style.display = "none"; + return; + } + + container.style.display = ""; + list.innerHTML = jobs.map(j => { + const statusClass = j.status === 'ready' ? 'tag-success' : + j.status === 'error' ? 'tag-error' : + j.status === 'importing' ? 'tag-warning' : ''; + const statusText = j.status === 'ready' ? 'Bereit' : + j.status === 'analyzing' ? 'Analyse...' : + j.status === 'importing' ? 'Laeuft' : + j.status === 'error' ? 'Fehler' : j.status; + const sourceName = j.source_path.split('/').pop(); + return ` + + ${j.status !== 'importing' ? `` : ''} + `; + }).join(""); + }) + .catch(() => { + document.getElementById("import-existing").style.display = "none"; + }); +} + +async function deleteImportJob(jobId, ev) { + if (ev) ev.stopPropagation(); + if (!await showConfirm("Import-Job wirklich loeschen?", {title: "Job loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + fetch(`/api/library/import/${jobId}`, { method: "DELETE" }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + showToast("Import-Job geloescht", "success"); + loadExistingImportJobs(); + }) + .catch(() => showToast("Loeschen fehlgeschlagen", "error")); +} + +async function deleteCurrentImportJob() { + if (!currentImportJobId) return; + if (!await showConfirm("Import-Job wirklich loeschen?", {title: "Job loeschen", okText: "Loeschen", icon: "danger", danger: true})) return; + fetch(`/api/library/import/${currentImportJobId}`, { method: "DELETE" }) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + showToast("Import-Job geloescht", "success"); + resetImport(); + }) + .catch(() => showToast("Loeschen fehlgeschlagen", "error")); +} + +function loadImportJob(jobId) { + currentImportJobId = jobId; + document.getElementById("import-setup").style.display = "none"; + document.getElementById("import-existing").style.display = "none"; + document.getElementById("import-series-assign").style.display = "none"; + document.getElementById("import-preview").style.display = "none"; + + fetch(`/api/library/import/${jobId}`) + .then(r => r.json()) + .then(data => { + if (data.error) { + showToast("Fehler: " + data.error, "error"); + resetImport(); + return; + } + + const job = data.job || {}; + + // Je nach Status richtigen Bereich anzeigen + if (job.status === 'importing') { + document.getElementById("import-progress").style.display = ""; + startImportPolling(); + } else if (job.status === 'pending_assignment') { + document.getElementById("import-series-assign").style.display = ""; + loadPendingSeries(); + } else { + document.getElementById("import-preview").style.display = ""; + renderImportItems(data); + } + }) + .catch(e => { + showToast("Fehler beim Laden: " + e, "error"); + resetImport(); + }); +} + +function closeImportModal() { + document.getElementById("import-modal").style.display = "none"; +} + +function resetImport() { + document.getElementById("import-setup").style.display = ""; + document.getElementById("import-series-assign").style.display = "none"; + document.getElementById("import-preview").style.display = "none"; + document.getElementById("import-progress").style.display = "none"; + currentImportJobId = null; +} + +function debounceImportPath() { + if (importBrowseTimer) clearTimeout(importBrowseTimer); + importBrowseTimer = setTimeout(() => { + const val = document.getElementById("import-source").value.trim(); + if (val && val.startsWith("/")) importBrowse(val); + }, 600); +} + +function importBrowse(path) { + const browser = document.getElementById("import-browser"); + browser.innerHTML = '
Lade...
'; + + fetch("/api/library/browse-fs?path=" + encodeURIComponent(path)) + .then(r => r.json()) + .then(data => { + if (data.error) { + browser.innerHTML = `
${escapeHtml(data.error)}
`; + return; + } + + let html = ''; + + // Breadcrumb + html += '
'; + html += `/mnt`; + for (const c of (data.breadcrumb || [])) { + if (c.path === "/mnt" || c.path.length < 5) continue; + html += ` / `; + html += `${escapeHtml(c.name)}`; + } + html += '
'; + + // Eltern-Ordner + const parts = data.current_path.split("/"); + if (parts.length > 2) { + const parentPath = parts.slice(0, -1).join("/") || "/mnt"; + html += `
+ 🔙 + .. +
`; + } + + // Unterordner: Einfachklick = auswaehlen, Doppelklick = navigieren + for (const f of (data.folders || [])) { + const meta = f.video_count > 0 ? `${f.video_count} Videos` : ""; + html += `
+ 📁 + ${escapeHtml(f.name)} + ${meta} +
`; + } + + if (!data.folders?.length && !data.video_count) { + html += '
Leerer Ordner
'; + } + + browser.innerHTML = html; + + // Pfad-Input aktualisieren und Video-Info anzeigen + updateImportFolderInfo(data); + }) + .catch(e => { + browser.innerHTML = `
Fehler: ${e}
`; + }); +} + +// Klick-Handler: Einfachklick = auswaehlen, Doppelklick = navigieren +let _importClickTimer = null; +function importFolderClick(path, el) { + if (_importClickTimer) { + // Zweiter Klick innerhalb 300ms -> Doppelklick -> navigieren + clearTimeout(_importClickTimer); + _importClickTimer = null; + importBrowse(path); + } else { + // Erster Klick -> kurz warten ob Doppelklick kommt + _importClickTimer = setTimeout(() => { + _importClickTimer = null; + importSelectFolder(path, el); + }, 250); + } +} + +function importSelectFolder(path, el) { + // Vorherige Auswahl entfernen + document.querySelectorAll(".import-browser-folder.selected").forEach( + f => f.classList.remove("selected") + ); + el.classList.add("selected"); + document.getElementById("import-source").value = path; + + // Video-Info fuer den ausgewaehlten Ordner laden + fetch("/api/library/browse-fs?path=" + encodeURIComponent(path)) + .then(r => r.json()) + .then(data => { + if (!data.error) updateImportFolderInfo(data); + }) + .catch(() => {}); +} + +function updateImportFolderInfo(data) { + const info = document.getElementById("import-folder-info"); + const btn = document.getElementById("btn-analyze-import"); + const totalVids = (data.video_count || 0); + // Auch Unterordner zaehlen + let subVids = 0; + for (const f of (data.folders || [])) subVids += (f.video_count || 0); + const allVids = totalVids + subVids; + + if (allVids > 0) { + info.textContent = `${allVids} Videos gefunden`; + btn.disabled = false; + } else { + info.textContent = "Keine Videos im Ordner"; + btn.disabled = true; + } +} + +async function createImportJob() { + const source = document.getElementById("import-source").value.trim(); + const target = document.getElementById("import-target").value; + const mode = document.getElementById("import-mode").value; + if (!source || !target) { showToast("Quellordner und Ziel erforderlich", "error"); return; } + + const btn = document.getElementById("btn-analyze-import"); + btn.textContent = "Analysiere..."; + btn.disabled = true; + + try { + // Job erstellen + let resp = await fetch("/api/library/import", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({source_path: source, target_library_id: parseInt(target), mode}), + }); + let data = await resp.json(); + if (data.error) { + showToast("Fehler: " + data.error, "error"); + btn.textContent = "Analysieren"; + btn.disabled = false; + return; + } + currentImportJobId = data.job_id; + + // Analyse starten (nur Dateinamen-Erkennung, KEIN TVDB) + resp = await fetch(`/api/library/import/${data.job_id}/analyze`, {method: "POST"}); + data = await resp.json(); + btn.textContent = "Analysieren"; + btn.disabled = false; + + if (data.error) { + showToast("Analyse-Fehler: " + data.error, "error"); + return; + } + + // Pruefen ob Serien zugeordnet werden muessen + const job = data.job || {}; + if (job.status === "pending_assignment") { + // Phase 2: Serien-Zuordnung anzeigen + document.getElementById("import-setup").style.display = "none"; + document.getElementById("import-series-assign").style.display = ""; + document.getElementById("import-preview").style.display = "none"; + await loadPendingSeries(); + } else { + // Direkt zu Konflikt-Ansicht + document.getElementById("import-setup").style.display = "none"; + document.getElementById("import-series-assign").style.display = "none"; + document.getElementById("import-preview").style.display = ""; + renderImportItems(data); + } + } catch (e) { + btn.textContent = "Analysieren"; + btn.disabled = false; + showToast("Fehler: " + e, "error"); + } +} + +// === Serien-Zuordnung (Phase 2) === + +async function loadPendingSeries() { + if (!currentImportJobId) return; + try { + const resp = await fetch(`/api/library/import/${currentImportJobId}/pending-series`); + const data = await resp.json(); + renderPendingSeries(data.series || []); + } catch (e) { + showToast("Fehler beim Laden: " + e, "error"); + } +} + +function renderPendingSeries(series) { + const container = document.getElementById("import-series-list"); + if (!series.length) { + // Keine Serien mehr -> weiter zu Konflikt-Check + finishSeriesAssignment(); + return; + } + + let html = '
'; + html += 'Die folgenden Serien wurden erkannt. Bitte ordne sie der richtigen TVDB-Serie zu:'; + html += '
'; + + for (const s of series) { + html += `
`; + html += `${escapeHtml(s.detected_name)}`; + html += ` (${s.count} Folgen, Staffel ${s.seasons})`; + html += ``; + html += ``; + html += `
`; + } + container.innerHTML = html; + + // Event-Listener hinzufuegen + container.querySelectorAll(".import-assign-btn").forEach(btn => { + btn.addEventListener("click", () => { + openSeriesAssignModal(btn.dataset.series, parseInt(btn.dataset.count)); + }); + }); + container.querySelectorAll(".import-skip-btn").forEach(btn => { + btn.addEventListener("click", () => { + skipImportSeries(btn.dataset.series); + }); + }); +} + +async function finishSeriesAssignment() { + // Alle Serien zugeordnet -> Job-Status abrufen und Konflikte anzeigen + if (!currentImportJobId) return; + try { + const resp = await fetch(`/api/library/import/${currentImportJobId}`); + const data = await resp.json(); + + document.getElementById("import-series-assign").style.display = "none"; + document.getElementById("import-preview").style.display = ""; + renderImportItems(data); + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +function renderImportItems(data) { + const items = data.items || []; + const job = data.job || {}; + const list = document.getElementById("import-items-list"); + + const matched = items.filter(i => i.status === "matched").length; + const conflicts = items.filter(i => i.status === "conflict").length; + const unresolvedConflicts = items.filter(i => i.status === "conflict" && !i.user_action).length; + const pending = items.filter(i => i.status === "pending").length; + const pendingSeries = items.filter(i => i.status === "pending_series").length; + const skipped = items.filter(i => i.status === "skipped").length; + + document.getElementById("import-info").textContent = + `${items.length} Dateien: ${matched} bereit, ${conflicts} Konflikte, ${skipped} uebersprungen`; + + // Start-Button nur wenn keine ungeloesten Konflikte UND keine pending Items + const hasUnresolved = items.some(i => + (i.status === "conflict" && !i.user_action) || + i.status === "pending" || + i.status === "pending_series" + ); + const btn = document.getElementById("btn-start-import"); + btn.disabled = hasUnresolved; + btn.title = hasUnresolved + ? `${unresolvedConflicts + pending + pendingSeries} Dateien muessen erst bearbeitet werden` + : "Import starten"; + + if (!items.length) { + list.innerHTML = '
Keine Dateien gefunden
'; + return; + } + + let html = ""; + + // Massen-Aktionen fuer Konflikte wenn welche vorhanden + if (unresolvedConflicts > 0) { + html += '
'; + html += `
`; + html += `${unresolvedConflicts} Konflikte`; + html += ``; + html += ``; + html += ``; + html += `
`; + } + + // Tabelle mit allen Items + html += ''; + html += ''; + html += ''; + + for (const item of items) { + const statusClass = item.status === "conflict" ? "status-badge warn" + : item.status === "matched" ? "status-badge ok" + : item.status === "done" ? "status-badge ok" + : item.status === "pending" ? "status-badge error" + : item.status === "pending_series" ? "status-badge error" + : item.status === "skipped" ? "status-badge" + : "status-badge"; + let statusText = item.status === "conflict" ? "Konflikt" + : item.status === "matched" ? "OK" + : item.status === "done" ? "Fertig" + : item.status === "skipped" ? "Skip" + : item.status === "pending" ? "Nicht erkannt" + : item.status === "pending_series" ? "Serie fehlt" + : item.status; + if (item.user_action === "overwrite") statusText = "Ueberschreiben"; + if (item.user_action === "skip") statusText = "Skip"; + + const sourceName = item.source_file ? item.source_file.split("/").pop() : "-"; + const se = (item.detected_season && item.detected_episode) + ? `S${String(item.detected_season).padStart(2, "0")}E${String(item.detected_episode).padStart(2, "0")}` + : "-"; + + const rowClass = item.status === "conflict" && !item.user_action ? "row-conflict" + : (item.status === "pending" || item.status === "pending_series") ? "row-pending" + : ""; + + html += ` + + + + + + + `; + } + html += '
QuelldateiSerieS/ETitelZielStatus
${escapeHtml(sourceName)}${escapeHtml(item.tvdb_series_name || item.detected_series || "-")}${se}${escapeHtml(item.tvdb_episode_title || "-")}${escapeHtml(item.target_filename || "-")}${statusText} + ${item.conflict_reason && !item.user_action ? `
${escapeHtml(item.conflict_reason)}
` : ""} +
'; + list.innerHTML = html; +} + +// Massen-Aktionen fuer Konflikte +async function resolveAllConflicts(action) { + if (!currentImportJobId) return; + try { + const resp = await fetch(`/api/library/import/${currentImportJobId}/resolve-all-conflicts`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({action}), + }); + const data = await resp.json(); + if (data.error) { + showToast("Fehler: " + data.error, "error"); + return; + } + showToast(`${data.updated} Konflikte: ${action}`, "success"); + refreshImportPreview(); + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +async function setOverwriteMode(overwrite) { + if (!currentImportJobId) return; + try { + await fetch(`/api/library/import/${currentImportJobId}/overwrite-mode`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({overwrite}), + }); + if (overwrite) { + refreshImportPreview(); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +function resolveImportConflict(itemId, action) { + fetch(`/api/library/import/items/${itemId}/resolve`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({action}), + }) + .then(r => r.json()) + .then(() => refreshImportPreview()) + .catch(e => showToast("Fehler: " + e, "error")); +} + +// === Import-Zuordnungs-Modal (Einzel + Serie) === + +let _assignItemId = null; +let _assignTvdbId = null; +let _assignSeriesName = ""; +let _assignSearchTimer = null; +let _assignSeriesMode = false; // true = Serien-Zuordnung (alle Folgen) +let _assignDetectedSeries = ""; // Original detected_series fuer Batch + +function openImportAssignModal(itemId, filename) { + _assignItemId = itemId; + _assignTvdbId = null; + _assignSeriesName = ""; + _assignSeriesMode = false; + _assignDetectedSeries = ""; + + const modal = document.getElementById("import-assign-modal"); + modal.style.display = "flex"; + document.getElementById("import-assign-filename").textContent = filename; + document.getElementById("import-assign-search").value = ""; + document.getElementById("import-assign-results").innerHTML = ""; + document.getElementById("import-assign-selected").style.display = "none"; + document.getElementById("import-assign-season").value = ""; + document.getElementById("import-assign-episode").value = ""; + // Staffel/Episode einblenden (Einzelmodus) + const seFields = document.getElementById("import-assign-se-fields"); + if (seFields) seFields.style.display = ""; + document.getElementById("import-assign-search").focus(); +} + +function openSeriesAssignModal(detectedSeries, count) { + _assignItemId = null; + _assignTvdbId = null; + _assignSeriesName = ""; + _assignSeriesMode = true; + _assignDetectedSeries = detectedSeries; + + const modal = document.getElementById("import-assign-modal"); + modal.style.display = "flex"; + document.getElementById("import-assign-filename").textContent = + `Serie: ${detectedSeries} (${count} Folgen)`; + document.getElementById("import-assign-search").value = detectedSeries; + document.getElementById("import-assign-results").innerHTML = ""; + document.getElementById("import-assign-selected").style.display = "none"; + // Staffel/Episode verstecken (Serienmodus) + const seFields = document.getElementById("import-assign-se-fields"); + if (seFields) seFields.style.display = "none"; + // Sofort TVDB-Suche starten + searchAssignTvdb(); +} + +function closeImportAssignModal() { + document.getElementById("import-assign-modal").style.display = "none"; + _assignItemId = null; + _assignSeriesMode = false; + _assignDetectedSeries = ""; + // Staffel/Episode wieder einblenden + const seFields = document.getElementById("import-assign-se-fields"); + if (seFields) seFields.style.display = ""; +} + +async function skipImportSeries(detectedSeries) { + if (!currentImportJobId) return; + if (!await showConfirm(`Alle Folgen von "${escapeHtml(detectedSeries)}" ueberspringen?`, {title: "Serie ueberspringen", okText: "Alle Skip"})) return; + + try { + const r = await fetch(`/api/library/import/${currentImportJobId}/skip-series`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({detected_series: detectedSeries}), + }); + const data = await r.json(); + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + showToast(`${data.skipped} Folgen uebersprungen`, "success"); + + // Pruefen ob wir in der Serien-Zuordnung sind + const seriesAssignEl = document.getElementById("import-series-assign"); + if (seriesAssignEl && seriesAssignEl.style.display !== "none") { + // Noch in Phase 2 -> Serien-Liste aktualisieren + loadPendingSeries(); + } else { + // In Konflikt-Ansicht -> Preview aktualisieren + refreshImportPreview(); + } + } catch (e) { + showToast("Fehler: " + e, "error"); + } +} + +function debounceAssignSearch() { + if (_assignSearchTimer) clearTimeout(_assignSearchTimer); + _assignSearchTimer = setTimeout(searchAssignTvdb, 500); +} + +function searchAssignTvdb() { + const query = document.getElementById("import-assign-search").value.trim(); + if (!query) return; + + const results = document.getElementById("import-assign-results"); + results.innerHTML = '
Suche...
'; + + fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}`) + .then(r => r.json()) + .then(data => { + if (data.error) { results.innerHTML = `
${escapeHtml(data.error)}
`; return; } + if (!data.results || !data.results.length) { results.innerHTML = '
Keine Ergebnisse
'; return; } + results.innerHTML = data.results.slice(0, 8).map(r => ` +
+ ${r.poster ? `` : ""} +
+ ${escapeHtml(r.name)} + ${r.year || ""} +

${escapeHtml((r.overview || "").substring(0, 120))}

+
+
+ `).join(""); + }) + .catch(e => { results.innerHTML = `
Fehler: ${e}
`; }); +} + +function selectAssignSeries(tvdbId, name) { + _assignTvdbId = tvdbId; + _assignSeriesName = name; + document.getElementById("import-assign-results").innerHTML = ""; + document.getElementById("import-assign-selected").style.display = ""; + document.getElementById("import-assign-selected-name").textContent = name; + document.getElementById("import-assign-search").value = ""; +} + +function submitImportAssign() { + const manualName = document.getElementById("import-assign-search").value.trim(); + const seriesName = _assignSeriesName || manualName; + + if (!seriesName) { showToast("Serie auswaehlen oder Namen eingeben", "error"); return; } + + const btn = document.querySelector("#import-assign-modal .btn-primary"); + btn.disabled = true; + + if (_assignSeriesMode && _assignDetectedSeries) { + // Serien-Zuordnung: Alle Folgen auf einmal (neue API) + btn.textContent = "Zuordne alle..."; + + fetch(`/api/library/import/${currentImportJobId}/assign-series`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + detected_series: _assignDetectedSeries, + tvdb_id: _assignTvdbId || null, + tvdb_name: seriesName, + }), + }) + .then(r => r.json()) + .then(data => { + btn.disabled = false; + btn.textContent = "Zuordnen"; + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + showToast(`${data.updated} Folgen zugeordnet`, "success"); + closeImportAssignModal(); + + // Pruefen ob noch Serien zugeordnet werden muessen + if (data.remaining_series > 0) { + // Noch Serien uebrig -> Liste aktualisieren + loadPendingSeries(); + } else { + // Alle Serien zugeordnet -> weiter zu Konflikten + finishSeriesAssignment(); + } + }) + .catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; showToast("Fehler: " + e, "error"); }); + return; + } + + // Einzel-Zuordnung + if (!_assignItemId) return; + const season = parseInt(document.getElementById("import-assign-season").value); + const episode = parseInt(document.getElementById("import-assign-episode").value); + if (isNaN(season) || isNaN(episode)) { showToast("Staffel und Episode eingeben", "error"); return; } + + btn.textContent = "Zuordne..."; + + fetch(`/api/library/import/items/${_assignItemId}/reassign`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + series_name: seriesName, + season: season, + episode: episode, + tvdb_id: _assignTvdbId || null, + }), + }) + .then(r => r.json()) + .then(data => { + btn.disabled = false; + btn.textContent = "Zuordnen"; + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + closeImportAssignModal(); + refreshImportPreview(); + }) + .catch(e => { btn.disabled = false; btn.textContent = "Zuordnen"; showToast("Fehler: " + e, "error"); }); +} + +function skipImportItem(itemId) { + fetch(`/api/library/import/items/${itemId}/skip`, {method: "POST"}) + .then(r => r.json()) + .then(() => refreshImportPreview()) + .catch(e => showToast("Fehler: " + e, "error")); +} + +function refreshImportPreview() { + if (!currentImportJobId) return; + fetch(`/api/library/import/${currentImportJobId}`) + .then(r => r.json()) + .then(data => renderImportItems(data)) + .catch(() => {}); +} + +let importPollingId = null; +let _importWsActive = false; // WebSocket liefert Updates? + +async function executeImport() { + if (!currentImportJobId) return; + + document.getElementById("import-preview").style.display = "none"; + document.getElementById("import-progress").style.display = ""; + document.getElementById("import-status-text").textContent = "Importiere..."; + document.getElementById("import-bar").style.width = "0%"; + _importWsActive = false; + + // Starte Import (non-blocking - Server antwortet sofort) + fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"}); + + // Fallback-Polling nur starten falls WS nicht verbunden + if (!ws || ws.readyState !== WebSocket.OPEN) { + startImportPolling(); + } else { + // WS verbunden - trotzdem langsames Fallback-Polling + // falls WS-Updates ausbleiben + setTimeout(() => { + if (!_importWsActive) startImportPolling(); + }, 3000); + } +} + +// WebSocket-Handler fuer Import-Fortschritt +function handleImportWS(data) { + if (!data || !data.job_id) return; + // Nur Updates fuer aktuellen Job + if (data.job_id !== currentImportJobId) return; + + _importWsActive = true; + // Polling abschalten wenn WS liefert + stopImportPolling(); + + const progressEl = document.getElementById("import-progress"); + if (progressEl) progressEl.style.display = ""; + + const status = data.status || ""; + const total = data.total || 1; + const processed = data.processed || 0; + const curFile = data.current_file || ""; + const bytesDone = data.bytes_done || 0; + const bytesTotal = data.bytes_total || 0; + + // Prozent berechnen + let pct = (processed / total) * 100; + if (bytesTotal > 0 && processed < total) { + pct += (bytesDone / bytesTotal) * (100 / total); + } + pct = Math.min(Math.round(pct), 100); + + const bar = document.getElementById("import-bar"); + const statusText = document.getElementById("import-status-text"); + if (bar) bar.style.width = pct + "%"; + + if (status === "analyzing") { + if (statusText) statusText.textContent = + `Analysiere: ${processed} / ${total} - ${curFile}`; + } else if (status === "embedding") { + if (statusText) statusText.textContent = + `Metadaten schreiben: ${curFile ? curFile.substring(0, 50) : ""} (${processed}/${total})`; + } else if (status === "importing") { + let txt = `Importiere: ${processed} / ${total} Dateien`; + if (curFile && bytesTotal > 0 && processed < total) { + const curPct = Math.round((bytesDone / bytesTotal) * 100); + txt += ` - ${curFile.substring(0, 40)}... (${formatSize(bytesDone)} / ${formatSize(bytesTotal)}, ${curPct}%)`; + } else { + txt += ` (${pct}%)`; + } + if (statusText) statusText.textContent = txt; + } else if (status === "done" || status === "error") { + if (bar) bar.style.width = "100%"; + if (statusText) statusText.textContent = + status === "done" + ? `Import abgeschlossen (${processed} Dateien)` + : `Import mit Fehlern beendet`; + + // Ergebnis per REST holen fuer Details + fetch(`/api/library/import/${data.job_id}`) + .then(r => r.json()) + .then(result => { + const items = result.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; + if (statusText) statusText.textContent = + `Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`; + + // Ziel-Pfad scannen + const job = result.job; + if (job && job.target_library_id && imported > 0) { + fetch(`/api/library/scan/${job.target_library_id}`, {method: "POST"}) + .then(() => setTimeout(() => { + loadSectionData(job.target_library_id); + loadStats(); + }, 2000)) + .catch(() => { reloadAllSections(); loadStats(); }); + } else { + reloadAllSections(); + loadStats(); + } + }) + .catch(() => {}); + } +} + +// WebSocket-Handler fuer Scan-Fortschritt +// Aktualisiert Bibliothek wenn Scan fertig ist (globaler Progress-Balken in base.html) +function handleScanWS(data) { + if (!data) return; + if (data.status === "idle") { + loadStats(); + reloadAllSections(); + } +} + +function startImportPolling() { + if (importPollingId) clearInterval(importPollingId); + + importPollingId = setInterval(async () => { + try { + const r = await fetch(`/api/library/import/${currentImportJobId}`); + const data = await r.json(); + + if (data.error) { + stopImportPolling(); + document.getElementById("import-status-text").textContent = "Fehler: " + data.error; + return; + } + + const job = data.job; + if (!job) return; + + const total = job.total_files || 1; + const done = job.processed_files || 0; + + // 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") { + stopImportPolling(); + document.getElementById("import-bar").style.width = "100%"; + + // Zaehle Ergebnisse + const items = data.items || []; + const imported = items.filter(i => i.status === "done").length; + const errors = items.filter(i => i.status === "error").length; + const skipped = items.filter(i => i.status === "skipped").length; + + document.getElementById("import-status-text").textContent = + `Fertig: ${imported} importiert, ${skipped} uebersprungen, ${errors} Fehler`; + + // Nur Ziel-Pfad scannen und neu laden (statt alles) + const targetPathId = job.target_library_id; + if (targetPathId && imported > 0) { + fetch(`/api/library/scan/${targetPathId}`, {method: "POST"}) + .then(() => { + setTimeout(() => { + loadSectionData(targetPathId); + loadStats(); + }, 2000); + }) + .catch(() => { + reloadAllSections(); + loadStats(); + }); + } else { + reloadAllSections(); + loadStats(); + } + } + } catch (e) { + console.error("Import-Polling Fehler:", e); + } + }, 5000); // 5s statt 500ms - nur Fallback +} + +function stopImportPolling() { + if (importPollingId) { + clearInterval(importPollingId); + importPollingId = null; + } +} + +// === Hilfsfunktionen === + +function formatSize(bytes) { + if (!bytes) return "0 B"; + const units = ["B", "KiB", "MiB", "GiB", "TiB"]; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(i > 1 ? 1 : 0) + " " + units[i]; +} + +function formatDuration(sec) { + if (!sec || sec <= 0) return "-"; + const d = Math.floor(sec / 86400); + const h = Math.floor((sec % 86400) / 3600); + const m = Math.floor((sec % 3600) / 60); + const parts = []; + if (d) parts.push(d + "d"); + if (h) parts.push(h + "h"); + if (m || !parts.length) parts.push(m + "m"); + return parts.join(" "); +} + +function resolutionLabel(w, h) { + if (w >= 3840) return "4K"; + if (w >= 1920) return "1080p"; + if (w >= 1280) return "720p"; + if (w >= 720) return "576p"; + return w + "x" + h; +} + +function channelLayout(ch) { + const layouts = {1: "Mono", 2: "2.0", 3: "2.1", 6: "5.1", 8: "7.1"}; + return layouts[ch] || ch + "ch"; +} + +function escapeHtml(str) { + if (!str) return ""; + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeAttr(str) { + // String fuer HTML-Attribute (onclick) sicher machen + // Escaped: ' (fuer JS-String), &, < + return (str || "") + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/&/g, "&") + .replace(/ { + // Autoplay blockiert - User muss manuell starten + }); +} + +function closePlayer() { + const video = document.getElementById("player-video"); + video.pause(); + video.removeAttribute("src"); + video.load(); + _playerVideoId = null; + document.getElementById("player-modal").style.display = "none"; +} + +// ESC schliesst den Player +document.addEventListener("keydown", function(e) { + if (e.key === "Escape") { + const player = document.getElementById("player-modal"); + if (player && player.style.display === "flex") { + closePlayer(); + e.stopPropagation(); + } + } +}); + +// === Video loeschen === + +async function deleteVideo(videoId, title, context) { + if (!await showConfirm(`"${escapeHtml(title)}" wirklich loeschen?`, {title: "Video loeschen", detail: "Datei wird unwiderruflich entfernt!", okText: "Loeschen", icon: "danger", danger: true})) return; + + fetch(`/api/library/videos/${videoId}?delete_file=1`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) { showToast("Fehler: " + data.error, "error"); return; } + showToast("Video geloescht", "success"); + + // Zeile aus Tabelle entfernen (ohne Modal neu zu laden) + const row = document.querySelector(`tr[data-video-id="${videoId}"]`); + if (row) { + row.remove(); + } else { + // Fallback: Ansicht komplett aktualisieren + if (context === "series" && currentSeriesId) { + openSeriesDetail(currentSeriesId); + } else if (context === "movie" && currentMovieId) { + openMovieDetail(currentMovieId); + } else { + reloadAllSections(); + } + } + loadStats(); + }) + .catch(e => showToast("Fehler: " + e, "error")); +} diff --git a/video-konverter/app/static/js/websocket.js b/video-konverter/app/static/js/websocket.js new file mode 100644 index 0000000..021f810 --- /dev/null +++ b/video-konverter/app/static/js/websocket.js @@ -0,0 +1,200 @@ +/** + * WebSocket-Client fuer Echtzeit-Updates + * Verbindet sich mit dem Server und aktualisiert Dashboard dynamisch + */ + +let ws = null; +let videoActive = {}; +let videoQueue = {}; +let reconnectTimer = null; + +// WebSocket verbinden +function connectWebSocket() { + if (!window.WS_URL) return; + + ws = new WebSocket(WS_URL); + + ws.onopen = function () { + console.log("WebSocket verbunden:", WS_URL); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + ws.onmessage = function (event) { + try { + const packet = JSON.parse(event.data); + + if (packet.data_flow !== undefined) { + updateProgress(packet.data_flow); + } else if (packet.data_convert !== undefined) { + updateActiveConversions(packet.data_convert); + } else if (packet.data_queue !== undefined) { + updateQueue(packet.data_queue); + } else if (packet.data_log !== undefined) { + // Log-Nachrichten ans Benachrichtigungs-System weiterleiten + if (typeof addNotification === "function") { + addNotification(packet.data_log.message, packet.data_log.level); + } + } else if (packet.data_import !== undefined) { + // Import-Fortschritt an Library weiterleiten + if (typeof handleImportWS === "function") { + handleImportWS(packet.data_import); + } + } else if (packet.data_library_scan !== undefined) { + // Scan-Fortschritt an Library weiterleiten + if (typeof handleScanWS === "function") { + handleScanWS(packet.data_library_scan); + } + } + // Globaler Progress-Balken aktualisieren + if (typeof _updateGlobalProgress === "function") { + _updateGlobalProgress(packet); + } + } catch (e) { + console.error("WebSocket Nachricht parsen fehlgeschlagen:", e); + } + }; + + ws.onclose = function () { + console.log("WebSocket getrennt, Reconnect in 3s..."); + reconnectTimer = setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = function (err) { + console.error("WebSocket Fehler:", err); + }; +} + +// === Aktive Konvertierungen === + +function updateActiveConversions(data) { + const container = document.getElementById("active-conversions"); + if (!container) return; + + // Entfernte Jobs loeschen + for (const key in videoActive) { + if (!(key in data)) { + const elem = document.getElementById("convert_" + key); + if (elem) elem.remove(); + delete videoActive[key]; + } + } + + // Neue Jobs hinzufuegen + for (const [key, video] of Object.entries(data)) { + if (!videoActive[key]) { + const card = document.createElement("div"); + card.className = "video-card"; + card.id = "convert_" + key; + card.innerHTML = ` +

${video.source_file_name} → ${video.target_file_name}

+
+
+
+
+ 0% +
+
+
Frames
0
+
FPS
0
+
Speed
0x
+
Groesse
0 KiB
+
Bitrate
0 kbits/s
+
Zeit
0 Min
+
Verbleibend
-
+
+ +
+
+ `; + container.appendChild(card); + videoActive[key] = video; + } + } +} + +function updateProgress(flow) { + const container = document.getElementById("convert_" + flow.id); + if (!container) return; + + container.querySelector(".frames").textContent = flow.frames || 0; + container.querySelector(".fps").textContent = flow.fps || 0; + container.querySelector(".speed").textContent = flow.speed || 0; + container.querySelector(".size").textContent = flow.size ? flow.size[0] : 0; + container.querySelector(".size_unit").textContent = flow.size ? flow.size[1] : "KiB"; + container.querySelector(".bitrate").textContent = flow.bitrate ? flow.bitrate[0] : 0; + container.querySelector(".bitrate_unit").textContent = flow.bitrate ? flow.bitrate[1] : "kbits/s"; + container.querySelector(".time").textContent = flow.time || "0 Min"; + container.querySelector(".eta").textContent = flow.time_remaining || "-"; + container.querySelector(".loading-pct").textContent = (flow.loading || 0).toFixed(1); + + const bar = container.querySelector(".progress-bar"); + bar.style.width = (flow.loading || 0) + "%"; +} + +// === Warteschlange === + +function updateQueue(data) { + const container = document.getElementById("queue"); + if (!container) return; + + // Entfernte/geaenderte Jobs loeschen + for (const key in videoQueue) { + if (!(key in data) || videoQueue[key]?.status !== data[key]?.status) { + const elem = document.getElementById("queue_" + key); + if (elem) elem.remove(); + delete videoQueue[key]; + } + } + + // Neue Jobs hinzufuegen + for (const [key, video] of Object.entries(data)) { + if (!videoQueue[key]) { + const card = document.createElement("div"); + card.className = "queue-card"; + card.id = "queue_" + key; + + let statusHtml; + if (video.status === 1) { + statusHtml = 'Aktiv'; + } else if (video.status === 3) { + statusHtml = 'Fehler'; + } else if (video.status === 4) { + statusHtml = 'Abgebrochen'; + } else { + statusHtml = 'Wartend'; + } + + card.innerHTML = ` +

${video.source_file_name}

+ + `; + container.appendChild(card); + videoQueue[key] = video; + } + } +} + +// === Befehle senden === + +function sendCommand(command, id) { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ + data_command: { cmd: command, id: id } + })); + } else { + console.warn("WebSocket nicht verbunden"); + } +} + +// Verbindung herstellen +connectWebSocket(); diff --git a/video-konverter/app/templates/admin.html b/video-konverter/app/templates/admin.html new file mode 100644 index 0000000..c1739be --- /dev/null +++ b/video-konverter/app/templates/admin.html @@ -0,0 +1,343 @@ +{% extends "base.html" %} + +{% block title %}Einstellungen - VideoKonverter{% endblock %} + +{% block content %} +
+

Einstellungen

+ +
+ + +
+ Encoding +
+
+ + +
+ +
+ + + {% if gpu_available %} + GPU verfuegbar + {% else %} + Keine GPU + {% endif %} +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+ Dateien +
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+
+
+ + +
+ Cleanup +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ Audio +
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + +
+ Untertitel +
+
+ + +
+
+
+ + +
+ Bibliothek / TVDB +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ Logging +
+
+ + +
+
+
+ +
+ +
+
+
+
+ + +
+

Bibliothek - Scan-Pfade

+
+
Lade Pfade...
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+

Encoding-Presets

+
+ {% for key, preset in presets.items() %} +
+

{{ preset.name }}

+
+ {{ preset.video_codec }} + {{ preset.container }} + {{ preset.quality_param }}={{ preset.quality_value }} + {% if preset.hw_init %}GPU{% else %}CPU{% endif %} +
+
+ {% endfor %} +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/base.html b/video-konverter/app/templates/base.html new file mode 100644 index 0000000..22b8288 --- /dev/null +++ b/video-konverter/app/templates/base.html @@ -0,0 +1,405 @@ + + + + + + {% block title %}VideoKonverter{% endblock %} + + + + {% block head %}{% endblock %} + + +
+
+

VideoKonverter

+
+ +
+ + +
+ + + +
+ +
+ {% block content %}{% endblock %} +
+ + + + + + + +
+ + +
+ + + + + +
+ + + + + + + {% block scripts %}{% endblock %} + + diff --git a/video-konverter/app/templates/dashboard.html b/video-konverter/app/templates/dashboard.html new file mode 100644 index 0000000..b0fc951 --- /dev/null +++ b/video-konverter/app/templates/dashboard.html @@ -0,0 +1,89 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - VideoKonverter{% endblock %} + +{% block content %} + +
+
+ + +
+
+ + +
+

Aktive Konvertierungen

+
+ +
+
+ + +
+

Warteschlange

+
+ +
+
+ + + + + + +{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/video-konverter/app/templates/library.html b/video-konverter/app/templates/library.html new file mode 100644 index 0000000..d550b80 --- /dev/null +++ b/video-konverter/app/templates/library.html @@ -0,0 +1,530 @@ +{% extends "base.html" %} + +{% block title %}Bibliothek - VideoKonverter{% endblock %} + +{% block content %} +
+
+

Video-Bibliothek

+
+ + + + + + +
+
+ + + + + +
+
-Videos
+
-Serien
+
-Gesamt
+
-Spielzeit
+
+ +
+ + + + + + + +
+
Lade Bibliothek...
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/video-konverter/app/templates/partials/stats_table.html b/video-konverter/app/templates/partials/stats_table.html new file mode 100644 index 0000000..b9820eb --- /dev/null +++ b/video-konverter/app/templates/partials/stats_table.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + {% endfor %} + +
DateiGroesse (Quelle)Groesse (Ziel)DauerFPSSpeedStatus
{{ entry.source_filename }}{{ "%.1f"|format(entry.source_size_bytes / 1048576) }} MiB{{ "%.1f"|format((entry.target_size_bytes or 0) / 1048576) }} MiB{{ "%.0f"|format(entry.duration_sec or 0) }}s{{ "%.1f"|format(entry.avg_fps or 0) }}{{ "%.2f"|format(entry.avg_speed or 0) }}x + {% if entry.status == 2 %} + OK + {% elif entry.status == 3 %} + Fehler + {% elif entry.status == 4 %} + Abgebrochen + {% else %} + {{ entry.status }} + {% endif %} +
+ +{% if entries | length >= 25 %} + +{% endif %} diff --git a/video-konverter/app/templates/statistics.html b/video-konverter/app/templates/statistics.html new file mode 100644 index 0000000..c0cae41 --- /dev/null +++ b/video-konverter/app/templates/statistics.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}Statistik - VideoKonverter{% endblock %} + +{% block content %} +
+

Statistik

+ + + {% if summary %} +
+
+ {{ summary.total }} + Gesamt +
+
+ {{ summary.finished }} + Erfolgreich +
+
+ {{ summary.failed }} + Fehlgeschlagen +
+
+ {{ "%.1f"|format(summary.space_saved / 1073741824) }} GiB + Platz gespart +
+
+ {{ "%.1f"|format(summary.avg_fps) }} + Avg FPS +
+
+ {{ "%.2f"|format(summary.avg_speed) }}x + Avg Speed +
+
+ {% endif %} + + +
+ Lade Statistiken... +
+
+{% endblock %} diff --git a/video-konverter/cfg_defaults/presets.yaml b/video-konverter/cfg_defaults/presets.yaml new file mode 100644 index 0000000..94507db --- /dev/null +++ b/video-konverter/cfg_defaults/presets.yaml @@ -0,0 +1,82 @@ +# === GPU-Presets (Intel VAAPI) === +gpu_av1: + name: "GPU AV1 (Standard)" + video_codec: "av1_vaapi" + container: "webm" + quality_param: "qp" + quality_value: 30 + gop_size: 240 + video_filter: "format=nv12,hwupload" + hw_init: true + extra_params: {} + +gpu_av1_10bit: + name: "GPU AV1 10-Bit" + video_codec: "av1_vaapi" + container: "webm" + quality_param: "qp" + quality_value: 30 + gop_size: 240 + video_filter: "format=p010,hwupload" + hw_init: true + extra_params: {} + +gpu_hevc: + name: "GPU HEVC/H.265" + video_codec: "hevc_vaapi" + container: "mkv" + quality_param: "qp" + quality_value: 28 + gop_size: 240 + video_filter: "format=nv12,hwupload" + hw_init: true + extra_params: {} + +gpu_h264: + name: "GPU H.264" + video_codec: "h264_vaapi" + container: "mp4" + quality_param: "qp" + quality_value: 23 + gop_size: 240 + video_filter: "format=nv12,hwupload" + hw_init: true + extra_params: {} + +# === CPU-Presets === +cpu_av1: + name: "CPU AV1/SVT-AV1 (Standard)" + video_codec: "libsvtav1" + container: "webm" + quality_param: "crf" + quality_value: 30 + gop_size: 240 + speed_preset: 5 + video_filter: "" + hw_init: false + extra_params: + svtav1-params: "tune=0:film-grain=8" + +cpu_hevc: + name: "CPU HEVC/x265" + video_codec: "libx265" + container: "mkv" + quality_param: "crf" + quality_value: 28 + gop_size: 250 + speed_preset: "medium" + video_filter: "" + hw_init: false + extra_params: {} + +cpu_h264: + name: "CPU H.264/x264" + video_codec: "libx264" + container: "mp4" + quality_param: "crf" + quality_value: 23 + gop_size: 250 + speed_preset: "medium" + video_filter: "" + hw_init: false + extra_params: {} diff --git a/video-konverter/entrypoint.sh b/video-konverter/entrypoint.sh new file mode 100644 index 0000000..0fde904 --- /dev/null +++ b/video-konverter/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis, +# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid). + +CFG_DIR="/opt/video-konverter/app/cfg" +DEFAULTS_DIR="/opt/video-konverter/cfg_defaults" + +# Alle Default-Dateien kopieren, wenn nicht vorhanden +for file in "$DEFAULTS_DIR"/*; do + filename=$(basename "$file") + if [ ! -f "$CFG_DIR/$filename" ]; then + echo "Kopiere Default-Config: $filename" + cp "$file" "$CFG_DIR/$filename" + fi +done + +# Anwendung starten +exec python3 __main__.py diff --git a/video-konverter/requirements.txt b/video-konverter/requirements.txt new file mode 100644 index 0000000..8a0330c --- /dev/null +++ b/video-konverter/requirements.txt @@ -0,0 +1,6 @@ +aiohttp>=3.9.0 +aiohttp-jinja2>=1.6 +jinja2>=3.1.0 +PyYAML>=6.0 +aiomysql>=0.2.0 +tvdb-v4-official>=1.1.0