From 08dcf34f5d043bdf62e0ea8129672113e85b7184 Mon Sep 17 00:00:00 2001 From: data Date: Sat, 21 Feb 2026 20:07:45 +0100 Subject: [PATCH] VideoKonverter v2.2.0 - Initial Commit Kompletter Video-Konverter mit Web-UI, GPU-Beschleunigung (Intel VAAPI), Video-Bibliothek mit Serien/Film-Erkennung und TVDB-Integration. Features: - AV1/HEVC/H.264 Encoding (GPU + CPU) - Video-Bibliothek mit ffprobe-Analyse und Filtern - TVDB-Integration mit Review-Modal und Sprachkonfiguration - Film-Scanning und TVDB-Zuordnung - Import- und Clean-Service (Grundgeruest) - WebSocket Live-Updates, Queue-Management - Docker mit GPU/CPU-Profilen Co-Authored-By: Claude Opus 4.6 --- .gitignore | 174 +-- CHANGELOG.md | 218 +++ Dockerfile | 39 + README.md | 288 +++- __main__.py | 14 + app/__init__.py | 0 app/cfg/presets.yaml | 82 + app/cfg/settings.yaml | 89 ++ app/config.py | 173 ++ app/models/__init__.py | 0 app/models/job.py | 201 +++ app/models/media.py | 166 ++ app/routes/__init__.py | 0 app/routes/api.py | 361 +++++ app/routes/library_api.py | 998 ++++++++++++ app/routes/pages.py | 155 ++ app/routes/ws.py | 121 ++ app/server.py | 156 ++ app/services/__init__.py | 0 app/services/cleaner.py | 155 ++ app/services/encoder.py | 226 +++ app/services/importer.py | 734 +++++++++ app/services/library.py | 1747 +++++++++++++++++++++ app/services/probe.py | 177 +++ app/services/progress.py | 132 ++ app/services/queue.py | 541 +++++++ app/services/scanner.py | 149 ++ app/services/tvdb.py | 1005 ++++++++++++ app/static/css/style.css | 1554 ++++++++++++++++++ app/static/icons/favicon.ico | Bin 0 -> 16958 bytes app/static/js/filebrowser.js | 301 ++++ app/static/js/library.js | 1912 +++++++++++++++++++++++ app/static/js/websocket.js | 181 +++ app/templates/admin.html | 342 ++++ app/templates/base.html | 32 + app/templates/dashboard.html | 89 ++ app/templates/library.html | 392 +++++ app/templates/partials/stats_table.html | 47 + app/templates/statistics.html | 47 + docker-compose.yml | 52 + requirements.txt | 6 + 41 files changed, 12902 insertions(+), 154 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 __main__.py create mode 100644 app/__init__.py create mode 100644 app/cfg/presets.yaml create mode 100644 app/cfg/settings.yaml create mode 100644 app/config.py create mode 100644 app/models/__init__.py create mode 100644 app/models/job.py create mode 100644 app/models/media.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/api.py create mode 100644 app/routes/library_api.py create mode 100644 app/routes/pages.py create mode 100644 app/routes/ws.py create mode 100644 app/server.py create mode 100644 app/services/__init__.py create mode 100644 app/services/cleaner.py create mode 100644 app/services/encoder.py create mode 100644 app/services/importer.py create mode 100644 app/services/library.py create mode 100644 app/services/probe.py create mode 100644 app/services/progress.py create mode 100644 app/services/queue.py create mode 100644 app/services/scanner.py create mode 100644 app/services/tvdb.py create mode 100644 app/static/css/style.css create mode 100644 app/static/icons/favicon.ico create mode 100644 app/static/js/filebrowser.js create mode 100644 app/static/js/library.js create mode 100644 app/static/js/websocket.js create mode 100644 app/templates/admin.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/library.html create mode 100644 app/templates/partials/stats_table.html create mode 100644 app/templates/statistics.html create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore index ab3e8ce..102cf00 100644 --- a/.gitignore +++ b/.gitignore @@ -1,164 +1,32 @@ -# ---> Python -# Byte-compiled / optimized / DLL files +# Python __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ -# Spyder project settings -.spyderproject -.spyproject +# Logs +logs/ +*.log -# Rope project settings -.ropeproject +# Laufzeit-Daten +data/ -# mkdocs documentation -/site +# Test-Medien +testmedia/ -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json +# IDE +.idea/ +.vscode/ +*.swp +*.swo -# Pyre type checker -.pyre/ +# Docker +.docker/ -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# OS +.DS_Store +Thumbs.db +# Secrets - NICHT einchecken wenn individuelle Passwoerter gesetzt +# app/cfg/settings.yaml wird eingecheckt (Template-Werte) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1e93d6a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,218 @@ +# Changelog + +Alle relevanten Aenderungen am VideoKonverter-Projekt. + +## [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..61be73f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +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 + +WORKDIR /opt/video-konverter + +# Python-Abhaengigkeiten +COPY requirements.txt . +RUN pip install --no-cache-dir --break-system-packages -r requirements.txt + +# Anwendung kopieren +COPY __main__.py . +COPY app/ ./app/ + +# 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 + +# Konfiguration und Daten als Volumes +VOLUME ["/opt/video-konverter/app/cfg", "/opt/video-konverter/data", "/opt/video-konverter/logs"] + +EXPOSE 8080 + +CMD ["python3", "__main__.py"] diff --git a/README.md b/README.md index 5ac37d1..ca42cf0 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,288 @@ -# docker.videokonverter +# 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, HEVC, H.264) ueber Intel A380 +- **CPU-Encoding**: SVT-AV1, x265, x264 als Fallback +- **Konfigurierbare Presets**: GPU/CPU, verschiedene Codecs und Qualitaetsstufen +- **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-Bibliothek +- **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme +- **Serien-Erkennung**: Automatisch via Ordnerstruktur (`S01E01`, `1x02`, `Staffel/Season XX`) +- **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 +- **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 +├── docker-compose.yml # GPU + CPU Profile +├── requirements.txt # Python-Abhaengigkeiten +├── 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 + +### 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. + +### Konfiguration +In `app/cfg/settings.yaml` anpassen: +```yaml +database: + host: "192.168.155.11" + port: 3306 + user: "video" + password: "dein_passwort" + database: "video_converter" + +encoding: + mode: "cpu" # "gpu" | "cpu" | "auto" + gpu_device: "/dev/dri/renderD128" + default_preset: "cpu_av1" + max_parallel_jobs: 1 + +files: + target_container: "webm" # "webm" | "mkv" | "mp4" + delete_source: false + recursive_scan: true + +library: + enabled: true + tvdb_api_key: "" # Von thetvdb.com + tvdb_pin: "" # Subscriber PIN (optional) + tvdb_language: "deu" # deu, eng, fra, spa, ita, jpn + import_default_mode: "copy" # "copy" | "move" + import_naming_pattern: "{series} - S{season:02d}E{episode:02d} - {title}.{ext}" + import_season_pattern: "Season {season:02d}" +``` + +### 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 | +| POST | `/api/library/series/{id}/tvdb-match` | TVDB-ID zuordnen | +| GET | `/api/library/duplicates` | Duplikate finden | +| POST | `/api/library/videos/{id}/convert` | Direkt konvertieren | +| 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 | + +### 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 +&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, Episoden-Titel + +### tvdb_episode_cache +Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung). + + +## Docker Volumes + +| Volume | Container-Pfad | Beschreibung | +|--------|---------------|-------------| +| `./app/cfg` | `/opt/video-konverter/app/cfg` | Konfiguration (persistent) | +| `./data` | `/opt/video-konverter/data` | Queue-Persistierung | +| `./logs` | `/opt/video-konverter/logs` | Server-Logs | +| `/mnt` | `/mnt` | Medien-Pfade (1:1 durchgereicht) | + + +## Lizenz + +Privates Projekt von Eddy (Eduard Wisch). diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..371b5b0 --- /dev/null +++ b/__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/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/cfg/presets.yaml b/app/cfg/presets.yaml new file mode 100644 index 0000000..94507db --- /dev/null +++ b/app/cfg/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/app/cfg/settings.yaml b/app/cfg/settings.yaml new file mode 100644 index 0000000..813ecbe --- /dev/null +++ b/app/cfg/settings.yaml @@ -0,0 +1,89 @@ +audio: + bitrate_map: + 2: 128k + 6: 320k + 8: 450k + default_bitrate: 192k + default_codec: libopus + keep_channels: true + languages: + - ger + - eng + - und +cleanup: + delete_extensions: + - .avi + - .wmv + - .vob + - .nfo + - .txt + - .jpg + - .png + - .srt + - .sub + - .idx + enabled: false + exclude_patterns: + - readme* + - '*.md' + keep_extensions: + - .srt +database: + database: video_converter + host: 192.168.155.11 + password: '8715' + port: 3306 + user: video +encoding: + default_preset: cpu_av1 + gpu_device: /dev/dri/renderD128 + gpu_driver: iHD + max_parallel_jobs: 1 + mode: cpu +files: + delete_source: false + recursive_scan: true + scan_extensions: + - .mkv + - .mp4 + - .avi + - .wmv + - .vob + - .ts + - .m4v + - .flv + - .mov + target_container: webm + target_folder: same +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: 5db8defd-41cd-4e0d-a637-ac0a96cbedd9 + tvdb_language: deu + tvdb_pin: '' +logging: + backup_count: 7 + file: server.log + level: INFO + max_size_mb: 10 + rotation: time +server: + external_url: '' + host: 0.0.0.0 + port: 8080 + use_https: false + websocket_path: /ws +statistics: + cleanup_days: 365 + max_entries: 5000 +subtitle: + codec_blacklist: + - hdmv_pgs_subtitle + - dvd_subtitle + - dvb_subtitle + languages: + - ger + - eng diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f4a8b05 --- /dev/null +++ b/app/config.py @@ -0,0 +1,173 @@ +"""Konfigurationsmanagement - Singleton fuer Settings und Presets""" +import os +import logging +import yaml +from pathlib import Path +from typing import Optional +from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler + + +class Config: + """Laedt und verwaltet settings.yaml und presets.yaml""" + _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._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() + + def _load_settings(self) -> None: + """Laedt settings.yaml""" + settings_file = self._cfg_path / "settings.yaml" + 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 FileNotFoundError: + logging.error(f"Settings nicht gefunden: {settings_file}") + self.settings = {} + + def _load_presets(self) -> None: + """Laedt presets.yaml""" + presets_file = self._cfg_path / "presets.yaml" + 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 FileNotFoundError: + logging.error(f"Presets nicht gefunden: {presets_file}") + self.presets = {} + + def _apply_env_overrides(self) -> None: + """Umgebungsvariablen ueberschreiben Settings""" + env_mode = os.environ.get("VIDEO_KONVERTER_MODE") + if env_mode and env_mode in ("cpu", "gpu", "auto"): + self.settings.setdefault("encoding", {})["mode"] = env_mode + logging.info(f"Encoding-Modus per Umgebungsvariable: {env_mode}") + + def save_settings(self) -> None: + """Schreibt aktuelle Settings zurueck in settings.yaml""" + settings_file = self._cfg_path / "settings.yaml" + try: + with open(settings_file, "w", encoding="utf-8") as f: + yaml.dump(self.settings, f, default_flow_style=False, + indent=2, allow_unicode=True) + logging.info("Settings gespeichert") + except Exception as e: + logging.error(f"Settings speichern fehlgeschlagen: {e}") + + def save_presets(self) -> None: + """Schreibt Presets zurueck in presets.yaml""" + presets_file = self._cfg_path / "presets.yaml" + try: + with open(presets_file, "w", encoding="utf-8") as f: + yaml.dump(self.presets, f, default_flow_style=False, + indent=2, allow_unicode=True) + logging.info("Presets gespeichert") + except Exception as e: + logging.error(f"Presets speichern fehlgeschlagen: {e}") + + 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/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/job.py b/app/models/job.py new file mode 100644 index 0000000..2bbcdd4 --- /dev/null +++ b/app/models/job.py @@ -0,0 +1,201 @@ +"""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" + + # 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, + } diff --git a/app/models/media.py b/app/models/media.py new file mode 100644 index 0000000..c364d90 --- /dev/null +++ b/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/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/routes/api.py b/app/routes/api.py new file mode 100644 index 0000000..353d9fb --- /dev/null +++ b/app/routes/api.py @@ -0,0 +1,361 @@ +"""REST API Endpoints""" +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 + + +def setup_api_routes(app: web.Application, config: Config, + queue_service: QueueService, + scanner: ScannerService) -> 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], + }) + + # --- 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/app/routes/library_api.py b/app/routes/library_api.py new file mode 100644 index 0000000..9e861c5 --- /dev/null +++ b/app/routes/library_api.py @@ -0,0 +1,998 @@ +"""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"): + 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}) + + # === 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""" + 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 (API Key fehlt)"}, + status=400, + ) + results = await tvdb_service.search_series(query) + 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}) + + # === 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 + + 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": "Job konnte nicht erstellt werden"}, status=500 + ) + + # === 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 + ) + job_id = int(request.match_info["job_id"]) + result = await importer_service.analyze_job(job_id) + return web.json_response(result) + + 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 + ) + job_id = int(request.match_info["job_id"]) + result = await importer_service.get_job_status(job_id) + return web.json_response(result) + + async def post_execute_import(request: web.Request) -> web.Response: + """POST /api/library/import/{job_id}/execute""" + 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.execute_import(job_id) + return web.json_response(result) + + 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 + ) + + # === Routes registrieren === + # 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) + # 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 + ) + # 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 + ) + # 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_post("/api/library/import", post_create_import) + 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 + ) + # 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, + ) diff --git a/app/routes/pages.py b/app/routes/pages.py new file mode 100644 index 0000000..9d11075 --- /dev/null +++ b/app/routes/pages.py @@ -0,0 +1,155 @@ +"""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, + } + + # Routes registrieren + app.router.add_get("/", 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/app/routes/ws.py b/app/routes/ws.py new file mode 100644 index 0000000..767ac3b --- /dev/null +++ b/app/routes/ws.py @@ -0,0 +1,121 @@ +"""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 _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/app/server.py b/app/server.py new file mode 100644 index 0000000..967b40e --- /dev/null +++ b/app/server.py @@ -0,0 +1,156 @@ +"""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) + self._setup_app() + + 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 + ) + + # 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) + + # 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/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/cleaner.py b/app/services/cleaner.py new file mode 100644 index 0000000..343b679 --- /dev/null +++ b/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/app/services/encoder.py b/app/services/encoder.py new file mode 100644 index 0000000..cf230e6 --- /dev/null +++ b/app/services/encoder.py @@ -0,0 +1,226 @@ +"""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)]) + + 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/app/services/importer.py b/app/services/importer.py new file mode 100644 index 0000000..82ac3e7 --- /dev/null +++ b/app/services/importer.py @@ -0,0 +1,734 @@ +"""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 +) + + +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 + + def set_db_pool(self, pool: aiomysql.Pool) -> None: + self._db_pool = pool + + @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','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, + 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','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 analyze_job(self, job_id: int) -> dict: + """Analysiert alle Dateien: Erkennung + TVDB-Lookup + Konflikt-Check""" + 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"} + + # Ziel-Library laden + await cur.execute( + "SELECT * FROM library_paths WHERE id = %s", + (job["target_library_id"],) + ) + lib_path = await cur.fetchone() + if not lib_path: + return {"error": "Ziel-Library 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 + tvdb_cache = {} # Serienname -> TVDB-Info + for item in items: + await self._analyze_item( + item, lib_path, job, tvdb_cache + ) + + # Status auf ready + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute( + "UPDATE import_jobs SET status = 'ready' " + "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)} + + # Mindestgroesse fuer "echte" Episoden (darunter = Sample/Trailer) + MIN_EPISODE_SIZE = 100 * 1024 * 1024 # 100 MiB + + async def _analyze_item(self, item: dict, lib_path: dict, + job: dict, tvdb_cache: dict) -> None: + """Einzelnes Item analysieren: Erkennung + TVDB + Konflikt""" + filename = os.path.basename(item["source_file"]) + ext = os.path.splitext(filename)[1].lstrip(".") + + # 0. 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 + + # 1. 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") + + # 2. Dauer per ffprobe (fuer Konflikt-Check) + duration = None + try: + media = await ProbeService.analyze(item["source_file"]) + if media: + duration = media.source_duration_sec + except Exception: + pass + + # 3. TVDB-Lookup (gecacht pro Serienname) + tvdb_id = None + tvdb_name = series_name + tvdb_ep_title = "" + if series_name and self.tvdb.is_configured: + if series_name.lower() not in tvdb_cache: + results = await self.tvdb.search_series(series_name) + if results: + tvdb_cache[series_name.lower()] = results[0] + else: + tvdb_cache[series_name.lower()] = None + + cached = tvdb_cache.get(series_name.lower()) + if cached: + tvdb_id = cached.get("tvdb_id") + tvdb_name = cached.get("name", series_name) + + # Episodentitel aus TVDB + if tvdb_id and season and episode: + tvdb_ep_title = await self._get_episode_title( + int(tvdb_id), season, episode + ) + + # 4. Ziel-Pfad berechnen + pattern = job.get("naming_pattern") or self._naming_pattern + season_pattern = job.get("season_pattern") or self._season_pattern + target_dir, target_file = self._build_target( + tvdb_name or series_name or "Unbekannt", + season, episode, + tvdb_ep_title or "", + ext, + lib_path["path"], + pattern, season_pattern + ) + target_path = os.path.join(target_dir, target_file) + + # 5. Konflikt-Check + status = "matched" if series_name and season and episode else "pending" + 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"] + + # Groessen-Vergleich + 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 bereits " + f"(Quelle: {self._fmt_size(source_size)}, " + f"Ziel: {self._fmt_size(existing_size)}, " + f"Abweichung: {diff_pct:.0f}%)" + ) + else: + conflict = "Datei existiert bereits (aehnliche Groesse)" + else: + conflict = "Datei existiert bereits" + status = "conflict" + + # 6. In DB aktualisieren + try: + async with self._db_pool.acquire() as conn: + async with conn.cursor() as cur: + await cur.execute(""" + UPDATE import_items SET + source_duration = %s, + 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 = %s, + conflict_reason = %s, + existing_file_path = %s, + existing_file_size = %s + WHERE id = %s + """, ( + duration, series_name, season, episode, + tvdb_id, tvdb_name, tvdb_ep_title, + target_dir, target_file, status, + conflict, existing_path, existing_size, + item["id"], + )) + except Exception as e: + logging.error(f"Import-Item analysieren fehlgeschlagen: {e}") + + 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'). + """ + filename = os.path.basename(file_path) + parent_dir = os.path.basename(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 + + # 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 + + 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 + try: + filename = pattern.format( + series=series, season=s, episode=e, + title=title or "Unbekannt", ext=ext + ) + except (KeyError, ValueError): + filename = f"{series} - S{s:02d}E{e:02d} - {title or 'Unbekannt'}.{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)""" + 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") + + for item in items: + ok = await self._process_item(item, mode) + if ok: + done += 1 + 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) + ) + + # Job abschliessen + 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", (status, job_id) + ) + + return {"done": done, "errors": errors} + + except Exception as e: + logging.error(f"Import ausfuehren fehlgeschlagen: {e}") + return {"error": str(e)} + + async def _process_item(self, item: dict, mode: str) -> bool: + """Einzelnes Item importieren (kopieren/verschieben)""" + 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) + + try: + # Zielordner erstellen + os.makedirs(target_dir, exist_ok=True) + + if mode == "move": + shutil.move(src, target) + else: + shutil.copy2(src, target) + + logging.info( + f"Import: {os.path.basename(src)} -> {target}" + ) + 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 _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 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 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 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() + + return { + "job": self._serialize(job), + "items": [self._serialize(i) for i in items], + } + 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/app/services/library.py b/app/services/library.py new file mode 100644 index 0000000..f4173cf --- /dev/null +++ b/app/services/library.py @@ -0,0 +1,1747 @@ +"""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})') +# 1x02, 01x02 +RE_XXxXX = re.compile(r'(\d{1,2})x(\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 + """) + + # 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, + 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 + + 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 + try: + shutil.rmtree(folder_path) + 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 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 + } + + 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 + } + + 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})") + 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 aktualisieren + 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() " + "WHERE id = %s", (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) + + self._scan_progress.update({ + "status": "scanning", + "current": entry, + "total": self._scan_progress["total"] + len(video_files), + }) + + # 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 + + # 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: + # 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" + ) + + # 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" + ) + 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), + "total": 0, + "done": 0, + }) + + 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 + + self._scan_progress.update({ + "status": "scanning", + "current": folder_name, + "total": self._scan_progress["total"] + len(direct_videos), + }) + 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 + + 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 = 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, + 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 + ) + 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), + 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, + 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]]: + """Staffel- und Episodennummer aus Pfad/Dateiname extrahieren""" + file_name = os.path.basename(file_path) + rel_path = os.path.relpath(file_path, base_path) + + # 1. S01E02 im Dateinamen + m = RE_SXXEXX.search(file_name) + if m: + return int(m.group(1)), int(m.group(2)) + + # 2. 1x02 im Dateinamen + m = RE_XXxXX.search(file_name) + if m: + return int(m.group(1)), int(m.group(2)) + + # 3. Staffel aus Ordnername + fuehrende Nummer + parts = rel_path.replace("\\", "/").split("/") + season_num = None + 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: + return season_num, int(m.group(1)) + + if season_num is not None: + return season_num, None + + return None, None + + async def _update_series_counts(self, series_id: int) -> None: + """Aktualisiert die lokalen Episoden-Zaehler einer Serie""" + 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(*) FROM library_videos " + "WHERE series_id = %s AND episode_number IS NOT NULL", + (series_id,) + ) + row = await cur.fetchone() + local_count = row[0] if row else 0 + + await cur.execute( + "UPDATE library_series SET local_episodes = %s, " + "missing_episodes = GREATEST(0, total_episodes - %s) " + "WHERE id = %s", + (local_count, local_count, 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']}%") + + 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 + 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 + ) + 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 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 + try: + shutil.rmtree(folder_path) + 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/app/services/probe.py b/app/services/probe.py new file mode 100644 index 0000000..af5c12a --- /dev/null +++ b/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/app/services/progress.py b/app/services/progress.py new file mode 100644 index 0000000..2445349 --- /dev/null +++ b/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/app/services/queue.py b/app/services/queue.py new file mode 100644 index 0000000..cbb7bff --- /dev/null +++ b/app/services/queue.py @@ -0,0 +1,541 @@ +"""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 + if pending: + asyncio.create_task(self.add_paths(pending)) + + 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) -> 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.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})" + ) + + 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) -> 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) + 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 + + if files_cfg.get("delete_source", False): + 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[str]: + """Laedt Queue aus queue.json, gibt Pfade 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 = [ + item["source_path"] 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/app/services/scanner.py b/app/services/scanner.py new file mode 100644 index 0000000..df2c09f --- /dev/null +++ b/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/app/services/tvdb.py b/app/services/tvdb.py new file mode 100644 index 0000000..d897227 --- /dev/null +++ b/app/services/tvdb.py @@ -0,0 +1,1005 @@ +"""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) -> list[dict]: + """Sucht Serien auf TVDB""" + client = self._get_client() + if not client: + return [] + + try: + results = client.search(query, type="series") + if not results: + return [] + + series_list = [] + for item in results[:10]: + name, overview = self._localize_search_result(item) + series_list.append({ + "tvdb_id": item.get("tvdb_id") or item.get("objectID"), + "name": name, + "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/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..0883f50 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,1554 @@ +/* === 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; +} + +/* === 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; } + +/* === 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.6rem 1rem; + border-radius: 6px; + font-size: 0.8rem; + margin-bottom: 0.5rem; + animation: fadeIn 0.3s ease, fadeOut 0.3s ease 2.7s; +} +.toast.success { background: #1b5e20; color: #81c784; } +.toast.error { background: #b71c1c; color: #ef9a9a; } + +@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; +} + +/* 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; +} +.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; } + +/* === 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; +} + +/* === 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/app/static/icons/favicon.ico b/app/static/icons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a8550c8031551c8976ad89655ef688790e3af7ee GIT binary patch literal 16958 zcmeHN-ES0C6rXB>`VU|hA1VmpD@d%8E~T}!fdWN7ia@Bb)j~)kw0wj>_y}%|*g^w5 zG=9aJmRKMG;s*vJAwgD1XvJ3_e1NA~c_cjWCj2<(&YU>i&TeP!Tr<1oPV?)zbM866 z^E+p5x9w&fr;NUqE_LYp%g&s7$C>Il&Kv@{oGt?U)kS;uvJBj8qU?km7)cU4$WA0TjqSjA3-jr_4zXC?m{Y0&AN;ejK4@T7b zNynSgjfEG%NV=b>^-bxX1CP5*3ibZ_}jG(-2jtAAb4_I6`NC z)d4rQVgCa(99XVXQ|jsD`g5>yFBpEG&RTeo?ZpJ#Am67S0dMb9wXkB(V~VHOzg_yT zf2MRdb^rvOp8Yw@p>XRc;K#W`K-(U7k7HJKjG%Exr9W3^E%kJA{W*B69SpxuXDvLq zO}#R%?gkF-0M@wtv_Fc~?ezB#eO$@FCH3M`VeM8hO!44mC(uhhZ`h`Iv9|3I*0gnD z)20{V*6`;W)4je0Ao#g;*2M9UdC>Bn;-mEwtZO-o)vf={|39X5Z)m1x)f~V7rY*h9 zsJ8 z-PqCyMpAv~)-|OY?<@yHS4UELrgY=oIxv#zL$|Ie-Ds@_Lsv&qd8TyZ{Yo&B>O;4# zDc#ssfl`L<{3H+CU#0gVN*B_Hu0{J?F#P#JKHe`Te?Be#KEkZ=!Avmxu|YoGFD8FJ zE&e{jtg*A4-is<9-+zEl^9+)Jk8a++b^>++b^>++ zb^`7MVCoV&v8nq5hwgt#pPgb$pP|dO5+}Bnh5SRG5eaQ?dGWt`Hk)ztv*n(B&a=;Y r))AXgb+S%A>%~c)@CdJ0N9u}>X!@mZvAOe+`;<3Fi4)6XGH^Www{?b^ literal 0 HcmV?d00001 diff --git a/app/static/js/filebrowser.js b/app/static/js/filebrowser.js new file mode 100644 index 0000000..0e94fcc --- /dev/null +++ b/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/app/static/js/library.js b/app/static/js/library.js new file mode 100644 index 0000000..72d3912 --- /dev/null +++ b/app/static/js/library.js @@ -0,0 +1,1912 @@ +/** + * 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 () { + loadStats(); + loadLibraryPaths(); +}); + +// === 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; + 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; + + // 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; + // 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 === + +function loadSectionBrowser(pathId, subPath) { + 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
'; }); +} + +// === 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' : ""; + + 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 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} + ${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); + 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`; + 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) : "-"; + html += ` + + + + + + + `; + } + } + html += '
NrTitelAufl.CodecAudioAktion
${ep.episode_number || "-"}${escapeHtml(ep.episode_name || "-")}Nicht vorhandenFEHLT
${ep.episode_number || "-"}${escapeHtml(ep.episode_title || ep.file_name || "-")}${res}${ep.video_codec || "-"}${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) alert("Fehler: " + data.error); + else { alert("TVDB aktualisiert: " + (data.name || "")); openSeriesDetail(currentSeriesId); } + }) + .catch(e => alert("Fehler: " + e)); +} + +function tvdbUnlink() { + if (!currentSeriesId || !confirm("TVDB-Zuordnung wirklich loesen?")) return; + fetch(`/api/library/series/${currentSeriesId}/tvdb`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) alert("Fehler: " + data.error); + else { closeSeriesModal(); reloadAllSections(); } + }) + .catch(e => alert("Fehler: " + e)); +} + +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) alert("Fehler: " + data.error); + else alert(`${data.downloaded || 0} Dateien heruntergeladen, ${data.errors || 0} Fehler`); + }) + .catch(e => { btn.textContent = "Metadaten laden"; btn.disabled = false; alert("Fehler: " + e); }); +} + +function deleteSeries(withFiles) { + if (!currentSeriesId) return; + if (withFiles) { + if (!confirm("ACHTUNG: Serie komplett loeschen?\n\nAlle Dateien und Ordner werden UNWIDERRUFLICH geloescht!")) return; + if (!confirm("Wirklich sicher? Dieser Vorgang kann NICHT rueckgaengig gemacht werden!")) return; + } else { + if (!confirm("Serie aus der Datenbank loeschen?\n(Dateien bleiben erhalten)")) 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) { alert("Fehler: " + data.error); return; } + let msg = "Serie aus DB geloescht."; + if (data.deleted_folder) msg += "\nOrdner geloescht: " + data.deleted_folder; + if (data.folder_error) msg += "\nOrdner-Fehler: " + data.folder_error; + alert(msg); + closeSeriesModal(); + reloadAllSections(); + loadStats(); + }) + .catch(e => alert("Fehler: " + e)); +} + +// === 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) : "-"; + html += ` + + + + + + + + `; + } + html += '
DateiAufl.CodecAudioGroesseDauerAktion
${escapeHtml(v.file_name || "-")}${res}${v.is_10bit ? ' 10bit' : ''}${v.video_codec || "-"}${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; +} + +function movieTvdbUnlink() { + if (!currentMovieId || !confirm("TVDB-Zuordnung wirklich loesen?")) return; + fetch(`/api/library/movies/${currentMovieId}/tvdb`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) alert("Fehler: " + data.error); + else { closeMovieModal(); reloadAllSections(); } + }) + .catch(e => alert("Fehler: " + e)); +} + +function deleteMovie(withFiles) { + if (!currentMovieId) return; + if (withFiles) { + if (!confirm("ACHTUNG: Film komplett loeschen?\n\nAlle Dateien werden UNWIDERRUFLICH geloescht!")) return; + if (!confirm("Wirklich sicher?")) return; + } else { + if (!confirm("Film aus der Datenbank loeschen?\n(Dateien bleiben erhalten)")) 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) { alert("Fehler: " + data.error); return; } + alert("Film aus DB geloescht." + (data.deleted_folder ? "\nOrdner geloescht." : "")); + closeMovieModal(); + reloadAllSections(); + loadStats(); + }) + .catch(e => alert("Fehler: " + e)); +} + +// === 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 === + +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"); + 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() { + for (const pid of Object.keys(sectionStates)) { + sectionStates[pid].page = 1; + loadSectionData(parseInt(pid)); + } +} + +function debounceFilter() { + if (filterTimeout) clearTimeout(filterTimeout); + filterTimeout = setTimeout(applyFilters, 400); +} + +// === Scan === + +function startScan() { + const progress = document.getElementById("scan-progress"); + progress.style.display = "block"; + document.getElementById("scan-status").textContent = "Scan wird gestartet..."; + document.getElementById("scan-bar").style.width = "0%"; + + fetch("/api/library/scan", {method: "POST"}) + .then(() => pollScanStatus()) + .catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; }); +} + +function scanSinglePath(pathId) { + const progress = document.getElementById("scan-progress"); + progress.style.display = "block"; + document.getElementById("scan-status").textContent = "Scan wird gestartet..."; + document.getElementById("scan-bar").style.width = "0%"; + + fetch(`/api/library/scan/${pathId}`, {method: "POST"}) + .then(() => pollScanStatus()) + .catch(e => { document.getElementById("scan-status").textContent = "Fehler: " + e; }); +} + +function pollScanStatus() { + const interval = setInterval(() => { + fetch("/api/library/scan-status") + .then(r => r.json()) + .then(data => { + if (data.status === "idle") { + clearInterval(interval); + document.getElementById("scan-progress").style.display = "none"; + loadStats(); + reloadAllSections(); + } else { + const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0; + document.getElementById("scan-bar").style.width = pct + "%"; + document.getElementById("scan-status").textContent = + `Scanne: ${data.current || ""} (${data.done || 0}/${data.total || 0})`; + } + }) + .catch(() => clearInterval(interval)); + }, 1000); +} + +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 + +function startAutoMatch() { + if (!confirm("TVDB-Vorschlaege fuer alle nicht-zugeordneten Serien und Filme sammeln?\n\nDas kann einige Minuten dauern. Du kannst danach jeden Vorschlag pruefen und bestaetigen.")) 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)); + }, 1000); +} + +// === 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) { + alert("Fehler: " + data.error); + if (el) el.classList.remove("review-item-loading"); + return; + } + item._confirmed = true; + item._confirmedName = data.name || name; + renderTvdbReviewList(); + }) + .catch(e => { + alert("Fehler: " + e); + 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 = ""; + 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...
'; + + 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.map(r => ` +
+ ${r.poster ? `` : ""} +
+ ${escapeHtml(r.name)} + ${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 === + +function convertVideo(videoId) { + if (event) event.stopPropagation(); + if (!confirm("Video zur Konvertierung senden?")) 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) alert("Fehler: " + data.error); + else alert("Job erstellt: " + (data.message || "OK")); + }) + .catch(e => alert("Fehler: " + e)); +} + +// === 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) { alert("Name und Pfad erforderlich"); 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) alert("Fehler: " + data.error); + else { + document.getElementById("new-path-name").value = ""; + document.getElementById("new-path-path").value = ""; + loadPathsList(); + loadLibraryPaths(); + } + }) + .catch(e => alert("Fehler: " + e)); +} + +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) alert("Fehler: " + data.error); + else { loadPathsList(); loadLibraryPaths(); } + }) + .catch(e => alert("Fehler: " + e)); +} + +function deletePath(pathId) { + if (!confirm("Pfad wirklich loeschen? (Videos bleiben erhalten, aber werden aus DB entfernt)")) return; + fetch(`/api/library/paths/${pathId}`, {method: "DELETE"}) + .then(r => r.json()) + .then(data => { + if (data.error) alert("Fehler: " + data.error); + else { loadPathsList(); loadLibraryPaths(); } + }) + .catch(e => alert("Fehler: " + e)); +} + +// === 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); +} + +function deleteSelectedJunk() { + const checked = document.querySelectorAll(".clean-check:checked"); + if (!checked.length) { alert("Keine Dateien ausgewaehlt"); return; } + if (!confirm(`${checked.length} Dateien wirklich loeschen?`)) 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 => { + alert(`${data.deleted || 0} geloescht, ${data.failed || 0} fehlgeschlagen`); + if (data.errors && data.errors.length) console.warn("Clean-Fehler:", data.errors); + scanForJunk(); + }) + .catch(e => alert("Fehler: " + e)); +} + +function deleteEmptyDirs() { + fetch("/api/library/clean/empty-dirs", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({}), + }) + .then(r => r.json()) + .then(data => alert(`${data.deleted_dirs || 0} leere Ordner geloescht`)) + .catch(e => alert("Fehler: " + e)); +} + +// === 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(() => {}); + + // Standard-Pfad im Filebrowser oeffnen + importBrowse("/mnt"); +} + +function closeImportModal() { + document.getElementById("import-modal").style.display = "none"; +} + +function resetImport() { + document.getElementById("import-setup").style.display = ""; + 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 + 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}
`; + }); +} + +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; + } +} + +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) { alert("Quellordner und Ziel erforderlich"); return; } + + const btn = document.getElementById("btn-analyze-import"); + btn.textContent = "Analysiere..."; + btn.disabled = true; + + fetch("/api/library/import", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({source_path: source, target_library_id: parseInt(target), mode}), + }) + .then(r => r.json()) + .then(data => { + if (data.error) { alert("Fehler: " + data.error); btn.textContent = "Analysieren"; btn.disabled = false; return; } + currentImportJobId = data.job_id; + return fetch(`/api/library/import/${data.job_id}/analyze`, {method: "POST"}); + }) + .then(r => r ? r.json() : null) + .then(data => { + btn.textContent = "Analysieren"; + btn.disabled = false; + if (!data) return; + if (data.error) { alert("Analyse-Fehler: " + data.error); return; } + document.getElementById("import-setup").style.display = "none"; + document.getElementById("import-preview").style.display = ""; + renderImportItems(data); + }) + .catch(e => { btn.textContent = "Analysieren"; btn.disabled = false; alert("Fehler: " + e); }); +} + +function renderImportItems(data) { + const items = data.items || []; + 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 pending = items.filter(i => i.status === "pending").length; + document.getElementById("import-info").textContent = + `${items.length} Dateien: ${matched} erkannt, ${conflicts} Konflikte, ${pending} offen`; + + // Start-Button nur wenn keine ungeloesten Konflikte + const hasUnresolved = items.some(i => i.status === "conflict" && !i.user_action); + document.getElementById("btn-start-import").disabled = hasUnresolved; + + if (!items.length) { + list.innerHTML = '
Keine Dateien gefunden
'; + return; + } + + let 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" + : "status-badge"; + const statusText = item.status === "conflict" ? "Konflikt" + : item.status === "matched" ? "OK" + : item.status === "done" ? "Fertig" + : item.status === "skipped" ? "Uebersprungen" + : item.status; + + 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")}` + : "-"; + + let actionHtml = ""; + if (item.status === "conflict" && !item.user_action) { + actionHtml = ` + + + + `; + } else if (item.status === "pending") { + // TVDB-Suchfeld fuer manuelles Matching + actionHtml = ``; + } else if (item.user_action) { + actionHtml = `${item.user_action}`; + } + + html += ` + + + + + + + + `; + } + html += '
QuelldateiSerieS/ETitelZielStatusAktion
${escapeHtml(sourceName)}${escapeHtml(item.tvdb_series_name || item.detected_series || "-")}${se}${escapeHtml(item.tvdb_episode_title || "-")}${escapeHtml(item.target_filename || "-")}${statusText} + ${item.conflict_reason ? `
${escapeHtml(item.conflict_reason)}
` : ""} +
${actionHtml}
'; + list.innerHTML = html; +} + +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 => alert("Fehler: " + e)); +} + +function openImportTvdbSearch(itemId) { + // Einfaches Prompt fuer TVDB-Suche + const query = prompt("TVDB-Serienname eingeben:"); + if (!query) return; + + fetch(`/api/tvdb/search?q=${encodeURIComponent(query)}`) + .then(r => r.json()) + .then(data => { + if (!data.results || !data.results.length) { alert("Keine Ergebnisse"); return; } + // Erste 5 anzeigen + const choices = data.results.slice(0, 5).map((r, i) => + `${i + 1}. ${r.name} (${r.year || "?"})` + ).join("\n"); + const choice = prompt(`Ergebnisse:\n${choices}\n\nNummer eingeben:`); + if (!choice) return; + const idx = parseInt(choice) - 1; + if (idx < 0 || idx >= data.results.length) return; + + const selected = data.results[idx]; + fetch(`/api/library/import/items/${itemId}`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + tvdb_series_id: selected.tvdb_id, + tvdb_series_name: selected.name, + status: "matched", + }), + }) + .then(() => refreshImportPreview()) + .catch(e => alert("Fehler: " + e)); + }) + .catch(e => alert("Fehler: " + e)); +} + +function refreshImportPreview() { + if (!currentImportJobId) return; + fetch(`/api/library/import/${currentImportJobId}`) + .then(r => r.json()) + .then(data => renderImportItems(data)) + .catch(() => {}); +} + +function executeImport() { + if (!currentImportJobId || !confirm("Import jetzt starten?")) 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%"; + + fetch(`/api/library/import/${currentImportJobId}/execute`, {method: "POST"}) + .then(r => r.json()) + .then(data => { + document.getElementById("import-bar").style.width = "100%"; + if (data.error) { + document.getElementById("import-status-text").textContent = "Fehler: " + data.error; + } else { + document.getElementById("import-status-text").textContent = + `Fertig: ${data.done || 0} importiert, ${data.errors || 0} Fehler`; + reloadAllSections(); + loadStats(); + } + }) + .catch(e => { + document.getElementById("import-status-text").textContent = "Fehler: " + e; + }); +} + +// === 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) { + // JS-String-Literal sicher fuer HTML-onclick-Attribute erzeugen + // JSON.stringify erzeugt "...", die " muessen fuer HTML-Attribute escaped werden + return JSON.stringify(str || "").replace(/&/g, "&").replace(/"/g, """).replace(/${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/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..2e02671 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,342 @@ +{% 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/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..9f7b6ad --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,32 @@ + + + + + + {% block title %}VideoKonverter{% endblock %} + + + {% block head %}{% endblock %} + + +
+
+

VideoKonverter

+
+ +
+ +
+ {% block content %}{% endblock %} +
+ +
+ + {% block scripts %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..b0fc951 --- /dev/null +++ b/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/app/templates/library.html b/app/templates/library.html new file mode 100644 index 0000000..212bb87 --- /dev/null +++ b/app/templates/library.html @@ -0,0 +1,392 @@ +{% 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/app/templates/partials/stats_table.html b/app/templates/partials/stats_table.html new file mode 100644 index 0000000..b9820eb --- /dev/null +++ b/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/app/templates/statistics.html b/app/templates/statistics.html new file mode 100644 index 0000000..c0cae41 --- /dev/null +++ b/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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..38d4a58 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +services: + # === GPU-Modus (Produktion auf Unraid) === + # Starten mit: docker compose --profile gpu up --build + # Unraid: nobody:users = 99:100 + video-konverter: + build: . + container_name: video-konverter + restart: unless-stopped + user: "${PUID:-99}:${PGID:-100}" + ports: + - "8080:8080" + volumes: + # Konfiguration (persistent) + - ./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: + # Intel A380 GPU - beide Devices noetig! + - /dev/dri/renderD128:/dev/dri/renderD128 + - /dev/dri/card0:/dev/dri/card0 + group_add: + - "video" + environment: + - LIBVA_DRIVER_NAME=iHD + - LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri + 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: . + container_name: video-konverter-cpu + user: "${PUID:-99}:${PGID:-100}" + ports: + - "8080:8080" + volumes: + - ./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: + - VIDEO_KONVERTER_MODE=cpu + profiles: + - cpu diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8a0330c --- /dev/null +++ b/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