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 0000000..a8550c8
Binary files /dev/null and b/app/static/icons/favicon.ico differ
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 += `
+ 📚
+ Alle
+
`;
+
+ for (const lp of enabled) {
+ const icon = lp.media_type === 'series' ? '🎬' : '🎦';
+ const isActive = activePathId === lp.id;
+ html += `
+ ${icon}
+ ${escapeHtml(lp.name)}
+
`;
+ }
+ 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 += ``;
+
+ // Tabs - Serien-Pfad: Videos+Serien+Ordner / Film-Pfad: Videos+Filme+Ordner
+ html += `
`;
+ html += `Videos `;
+ if (isSeriesLib) {
+ html += `Serien `;
+ } else {
+ html += `Filme `;
+ }
+ html += `Ordner `;
+ 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 '
+ : `
TVDB zuordnen `;
+ 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 += 'Dateiname Aufl. Codec Audio ';
+ html += 'Untertitel Groesse Dauer Container Aktion ';
+ 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 += `
+ ${escapeHtml(v.file_name || "-")}
+ ${res}${is10bit}
+ ${v.video_codec || "-"}
+ ${audioInfo || "-"}
+ ${subInfo || "-"}
+ ${formatSize(v.file_size || 0)}
+ ${formatDuration(v.duration_sec || 0)}
+ ${(v.container || "-").toUpperCase()}
+ Conv
+ `;
+ }
+ html += '
';
+ return html;
+}
+
+function renderPagination(total, page, pages, pathId, tabType) {
+ let 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 `
+ : `
TVDB zuordnen `;
+
+ 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 = '';
+ 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 = '';
+
+ // 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 += 'Nr Titel Aufl. Codec Audio Aktion ';
+ 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 += `
+ ${ep.episode_number || "-"}
+ ${escapeHtml(ep.episode_name || "-")}
+ Nicht vorhanden
+ FEHLT
+ `;
+ } 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 += `
+ ${ep.episode_number || "-"}
+ ${escapeHtml(ep.episode_title || ep.file_name || "-")}
+ ${res}
+ ${ep.video_codec || "-"}
+ ${audioInfo || "-"}
+ Conv
+ `;
+ }
+ }
+ html += '
';
+ }
+
+ 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 += ``;
+ 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 = '';
+
+ // Video-Dateien des Films
+ const videos = data.videos || [];
+ if (videos.length) {
+ html += 'Video-Dateien ';
+ html += '';
+ html += 'Datei Aufl. Codec Audio Groesse Dauer Aktion ';
+ 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 += `
+ ${escapeHtml(v.file_name || "-")}
+ ${res}${v.is_10bit ? ' 10bit ' : ''}
+ ${v.video_codec || "-"}
+ ${audioInfo || "-"}
+ ${formatSize(v.file_size || 0)}
+ ${formatDuration(v.duration_sec || 0)}
+ Conv
+ `;
+ }
+ html += '
';
+ }
+
+ 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 += ``;
+
+ // 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 += `
Suchen `;
+ html += `
`;
+ 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 ` : ""}
+
+
+ ${p.enabled ? 'Deaktivieren' : 'Aktivieren'}
+ Loeschen
+
+
+ `).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 = 'Alle ';
+ for (const ext of [...exts].sort()) {
+ select.innerHTML += `${ext} `;
+ }
+
+ 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 = '';
+ 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 =>
+ `${escapeHtml(p.name)} (${p.media_type === 'series' ? 'Serien' : 'Filme'}) `
+ ).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 += 'Quelldatei Serie S/E Titel Ziel Status Aktion ';
+ 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 = `
+ Ueberschr.
+ Skip
+ Umbenennen
+ `;
+ } else if (item.status === "pending") {
+ // TVDB-Suchfeld fuer manuelles Matching
+ actionHtml = `TVDB suchen `;
+ } else if (item.user_action) {
+ actionHtml = `${item.user_action} `;
+ }
+
+ html += `
+ ${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}
+ `;
+ }
+ html += '
';
+ 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 0 x
+
Groesse 0 KiB
+
Bitrate 0 kbits/s
+
Zeit 0 Min
+
Verbleibend -
+
+ Abbrechen
+
+
+ `;
+ 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 %}
+
+
+
+
+ Bibliothek - Scan-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 %}
+
+
+
+
+
+ {% 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 %}
+
+
+
+ Dateien durchsuchen
+ Video hochladen
+
+
+
+
+
+ Aktive Konvertierungen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Videodateien hierher ziehen
+
oder
+
+ Dateien waehlen
+
+
+
+
+
+
+
Wird hochgeladen...
+
+
+
+
+
+{% 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 %}
+
+
+
+
+
+
+
+
+
+
+
+
- Videos
+
- Serien
+
- Gesamt
+
- Spielzeit
+
+
+
+
+
+ Bibliotheken
+
+
+
+
+
+ Filter
+
+
+ Suche
+
+
+
+
+ Aufloesung
+
+ Alle
+ 4K (3840+)
+ 1080p (1920+)
+ 720p (1280+)
+ SD (720+)
+
+
+
+
+ Video-Codec
+
+ Alle
+ HEVC/H.265
+ H.264
+ AV1
+ MPEG-4
+ MPEG-2
+
+
+
+
+ Container
+
+ Alle
+ MKV
+ MP4
+ AVI
+ WebM
+ TS
+ WMV
+
+
+
+
+ Audio-Sprache
+
+ Alle
+ Deutsch
+ Englisch
+
+
+
+
+ Audio-Kanaele
+
+ Alle
+ Stereo (2.0)
+ 5.1 Surround
+ 7.1 Surround
+
+
+
+
+ Nur 10-Bit
+
+
+
+ Sortierung
+
+ Name
+ Groesse
+ Aufloesung
+ Dauer
+ Codec
+ Scan-Datum
+
+
+ Aufsteigend
+ Absteigend
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Neuen Pfad hinzufuegen
+
+
+ Hinzufuegen
+
+
+
+
+
+
+
+
+
+
+
+
+ Serie suchen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Episoden
+ Darsteller
+ Bilder
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Film suchen
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Junk scannen
+ Ausgewaehlte loeschen
+ Leere Ordner loeschen
+
+
+
+ Filter Extension:
+
+ Alle
+
+
+ Alle auswaehlen
+
+
+
+
Klicke "Junk scannen" um zu starten
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Oeffnen
+
+
+
+
+
+
+
+
+ Import starten
+ Zurueck
+
+
+
+
+
+
+
+
+
+
+
+{% 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 @@
+
+
+
+ Datei
+ Groesse (Quelle)
+ Groesse (Ziel)
+ Dauer
+ FPS
+ Speed
+ Status
+
+
+
+ {% for entry in entries %}
+
+ {{ 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 %}
+
+
+ {% endfor %}
+
+
+
+{% 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