VideoKonverter v2.2.0 - Initial Commit

Kompletter Video-Konverter mit Web-UI, GPU-Beschleunigung (Intel VAAPI),
Video-Bibliothek mit Serien/Film-Erkennung und TVDB-Integration.

Features:
- AV1/HEVC/H.264 Encoding (GPU + CPU)
- Video-Bibliothek mit ffprobe-Analyse und Filtern
- TVDB-Integration mit Review-Modal und Sprachkonfiguration
- Film-Scanning und TVDB-Zuordnung
- Import- und Clean-Service (Grundgeruest)
- WebSocket Live-Updates, Queue-Management
- Docker mit GPU/CPU-Profilen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-21 20:07:45 +01:00
parent 1a04f99097
commit 08dcf34f5d
41 changed files with 12902 additions and 154 deletions

174
.gitignore vendored
View file

@ -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)

218
CHANGELOG.md Normal file
View file

@ -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 `&quot;` 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)

39
Dockerfile Normal file
View file

@ -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"]

288
README.md
View file

@ -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).

14
__main__.py Normal file
View file

@ -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)

0
app/__init__.py Normal file
View file

82
app/cfg/presets.yaml Normal file
View file

@ -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: {}

89
app/cfg/settings.yaml Normal file
View file

@ -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

173
app/config.py Normal file
View file

@ -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", {})

0
app/models/__init__.py Normal file
View file

201
app/models/job.py Normal file
View file

@ -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,
}

166
app/models/media.py Normal file
View file

@ -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

0
app/routes/__init__.py Normal file
View file

361
app/routes/api.py Normal file
View file

@ -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

998
app/routes/library_api.py Normal file
View file

@ -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,
)

155
app/routes/pages.py Normal file
View file

@ -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='<div class="toast success">Settings gespeichert!</div>',
content_type="text/html",
)
@aiohttp_jinja2.template("partials/stats_table.html")
async def htmx_stats_table(request: web.Request) -> dict:
"""GET /htmx/stats?page=1 - Paginierte Statistik"""
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)

121
app/routes/ws.py Normal file
View file

@ -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']}")

156
app/server.py Normal file
View file

@ -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()

0
app/services/__init__.py Normal file
View file

155
app/services/cleaner.py Normal file
View file

@ -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

226
app/services/encoder.py Normal file
View file

@ -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

734
app/services/importer.py Normal file
View file

@ -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"

1747
app/services/library.py Normal file

File diff suppressed because it is too large Load diff

177
app/services/probe.py Normal file
View file

@ -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

132
app/services/progress.py Normal file
View file

@ -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)

541
app/services/queue.py Normal file
View file

@ -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 {}

149
app/services/scanner.py Normal file
View file

@ -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

1005
app/services/tvdb.py Normal file

File diff suppressed because it is too large Load diff

1554
app/static/css/style.css Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -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 = '<div class="fb-loading">Lade...</div>';
try {
const resp = await fetch("/api/browse?path=" + encodeURIComponent(path));
const data = await resp.json();
if (!resp.ok) {
content.innerHTML = `<div class="fb-error">${data.error}</div>`;
return;
}
renderBreadcrumb(data.path);
renderBrowser(data);
} catch (e) {
content.innerHTML = '<div class="fb-error">Verbindungsfehler</div>';
}
}
function renderBreadcrumb(path) {
const bc = document.getElementById("fb-breadcrumb");
const parts = path.split("/").filter(Boolean);
let html = '<span class="bc-item" onclick="fbNavigate(\'/mnt\')">/mnt</span>';
let current = "";
for (const part of parts) {
current += "/" + part;
if (current === "/mnt") continue;
html += ` <span class="bc-sep">/</span> `;
html += `<span class="bc-item" onclick="fbNavigate('${current}')">${part}</span>`;
}
bc.innerHTML = html;
}
function renderBrowser(data) {
const content = document.getElementById("fb-content");
let html = "";
// "Nach oben" Link
if (data.parent) {
html += `<div class="fb-item fb-dir fb-parent" onclick="fbNavigate('${data.parent}')">
<span class="fb-icon">&#8617;</span>
<span class="fb-name">..</span>
</div>`;
}
// Ordner
for (const dir of data.dirs) {
const badge = dir.video_count > 0 ? `<span class="fb-badge">${dir.video_count} Videos</span>` : "";
html += `<div class="fb-item fb-dir" ondblclick="fbNavigate('${dir.path}')">
<label class="fb-check" onclick="event.stopPropagation()">
<input type="checkbox" onchange="fbToggleDir('${dir.path}', this.checked)">
</label>
<span class="fb-icon" onclick="fbNavigate('${dir.path}')">&#128193;</span>
<span class="fb-name" onclick="fbNavigate('${dir.path}')">${dir.name}</span>
${badge}
</div>`;
}
// Dateien
for (const file of data.files) {
html += `<div class="fb-item fb-file">
<label class="fb-check">
<input type="checkbox" onchange="fbToggleFile('${file.path}', this.checked)">
</label>
<span class="fb-icon">&#127909;</span>
<span class="fb-name">${file.name}</span>
<span class="fb-size">${file.size_human}</span>
</div>`;
}
if (data.dirs.length === 0 && data.files.length === 0) {
html = '<div class="fb-empty">Keine Videodateien in diesem Ordner</div>';
}
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 += `<div class="upload-item">
<span class="upload-item-name">${file.name}</span>
<span class="upload-item-size">${size}</span>
<button class="btn-danger btn-small" onclick="removeUploadFile(${i})">&times;</button>
</div>`;
});
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);
}

1912
app/static/js/library.js Normal file

File diff suppressed because it is too large Load diff

181
app/static/js/websocket.js Normal file
View file

@ -0,0 +1,181 @@
/**
* WebSocket-Client fuer Echtzeit-Updates
* Verbindet sich mit dem Server und aktualisiert Dashboard dynamisch
*/
let ws = null;
let videoActive = {};
let videoQueue = {};
let reconnectTimer = null;
// WebSocket verbinden
function connectWebSocket() {
if (!window.WS_URL) return;
ws = new WebSocket(WS_URL);
ws.onopen = function () {
console.log("WebSocket verbunden:", WS_URL);
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
};
ws.onmessage = function (event) {
try {
const packet = JSON.parse(event.data);
if (packet.data_flow !== undefined) {
updateProgress(packet.data_flow);
} else if (packet.data_convert !== undefined) {
updateActiveConversions(packet.data_convert);
} else if (packet.data_queue !== undefined) {
updateQueue(packet.data_queue);
}
} catch (e) {
console.error("WebSocket Nachricht parsen fehlgeschlagen:", e);
}
};
ws.onclose = function () {
console.log("WebSocket getrennt, Reconnect in 3s...");
reconnectTimer = setTimeout(connectWebSocket, 3000);
};
ws.onerror = function (err) {
console.error("WebSocket Fehler:", err);
};
}
// === Aktive Konvertierungen ===
function updateActiveConversions(data) {
const container = document.getElementById("active-conversions");
if (!container) return;
// Entfernte Jobs loeschen
for (const key in videoActive) {
if (!(key in data)) {
const elem = document.getElementById("convert_" + key);
if (elem) elem.remove();
delete videoActive[key];
}
}
// Neue Jobs hinzufuegen
for (const [key, video] of Object.entries(data)) {
if (!videoActive[key]) {
const card = document.createElement("div");
card.className = "video-card";
card.id = "convert_" + key;
card.innerHTML = `
<h3 title="${video.source_path}">${video.source_file_name} &rarr; ${video.target_file_name}</h3>
<div class="progress-container">
<div class="progress-bar"></div>
</div>
<div class="progress-text">
<span class="loading-pct">0</span>%
</div>
<div class="video-card-values">
<div class="video-card-values-items"><b>Frames</b><br><span class="frames">0</span></div>
<div class="video-card-values-items"><b>FPS</b><br><span class="fps">0</span></div>
<div class="video-card-values-items"><b>Speed</b><br><span class="speed">0</span>x</div>
<div class="video-card-values-items"><b>Groesse</b><br><span class="size">0</span> <span class="size_unit">KiB</span></div>
<div class="video-card-values-items"><b>Bitrate</b><br><span class="bitrate">0</span> <span class="bitrate_unit">kbits/s</span></div>
<div class="video-card-values-items"><b>Zeit</b><br><span class="time">0 Min</span></div>
<div class="video-card-values-items"><b>Verbleibend</b><br><span class="eta">-</span></div>
<div class="video-card-values-items">
<button class="btn-danger" onclick="sendCommand('cancel', ${key})">Abbrechen</button>
</div>
</div>
`;
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 = '<span class="status-badge active">Aktiv</span>';
} else if (video.status === 3) {
statusHtml = '<span class="status-badge error">Fehler</span>';
} else if (video.status === 4) {
statusHtml = '<span class="status-badge warn">Abgebrochen</span>';
} else {
statusHtml = '<span class="status-badge queued">Wartend</span>';
}
card.innerHTML = `
<h4 title="${video.source_path}">${video.source_file_name}</h4>
<div class="queue-card-footer">
${statusHtml}
<div>
${video.status === 3 || video.status === 4 ?
`<button class="btn-secondary btn-small" onclick="sendCommand('retry', ${key})">Wiederholen</button>` : ""}
<button class="btn-danger btn-small" onclick="sendCommand('delete', ${key})">Loeschen</button>
</div>
</div>
`;
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();

342
app/templates/admin.html Normal file
View file

@ -0,0 +1,342 @@
{% extends "base.html" %}
{% block title %}Einstellungen - VideoKonverter{% endblock %}
{% block content %}
<section class="admin-section">
<h2>Einstellungen</h2>
<form hx-post="/htmx/settings" hx-target="#save-result" hx-swap="innerHTML">
<!-- Encoding -->
<fieldset>
<legend>Encoding</legend>
<div class="form-grid">
<div class="form-group">
<label for="encoding_mode">Modus</label>
<select name="encoding_mode" id="encoding_mode">
<option value="cpu" {% if settings.encoding.mode == 'cpu' %}selected{% endif %}>CPU</option>
<option value="gpu" {% if settings.encoding.mode == 'gpu' %}selected{% endif %}>GPU (Intel VAAPI)</option>
<option value="auto" {% if settings.encoding.mode == 'auto' %}selected{% endif %}>Auto-Erkennung</option>
</select>
</div>
<div class="form-group">
<label for="gpu_device">GPU Device</label>
<select name="gpu_device" id="gpu_device">
{% for device in gpu_devices %}
<option value="{{ device }}" {% if device == settings.encoding.gpu_device %}selected{% endif %}>{{ device }}</option>
{% endfor %}
{% if not gpu_devices %}
<option value="/dev/dri/renderD128">Keine GPU erkannt</option>
{% endif %}
</select>
{% if gpu_available %}
<span class="status-badge ok">GPU verfuegbar</span>
{% else %}
<span class="status-badge warn">Keine GPU</span>
{% endif %}
</div>
<div class="form-group">
<label for="default_preset">Standard-Preset</label>
<select name="default_preset" id="default_preset">
{% for key, preset in presets.items() %}
<option value="{{ key }}" {% if key == settings.encoding.default_preset %}selected{% endif %}>{{ preset.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="max_parallel_jobs">Max. parallele Jobs</label>
<input type="number" name="max_parallel_jobs" id="max_parallel_jobs"
value="{{ settings.encoding.max_parallel_jobs }}" min="1" max="8">
</div>
</div>
</fieldset>
<!-- Dateien -->
<fieldset>
<legend>Dateien</legend>
<div class="form-grid">
<div class="form-group">
<label for="target_container">Ziel-Container</label>
<select name="target_container" id="target_container">
<option value="webm" {% if settings.files.target_container == 'webm' %}selected{% endif %}>WebM (AV1/Opus)</option>
<option value="mkv" {% if settings.files.target_container == 'mkv' %}selected{% endif %}>MKV (Matroska)</option>
<option value="mp4" {% if settings.files.target_container == 'mp4' %}selected{% endif %}>MP4</option>
</select>
</div>
<div class="form-group">
<label for="target_folder">Ziel-Ordner</label>
<input type="text" name="target_folder" id="target_folder"
value="{{ settings.files.target_folder }}"
placeholder="same = gleicher Ordner">
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="delete_source"
{% if settings.files.delete_source %}checked{% endif %}>
Quelldatei nach Konvertierung loeschen
</label>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="recursive_scan"
{% if settings.files.recursive_scan %}checked{% endif %}>
Unterordner rekursiv scannen
</label>
</div>
</div>
</fieldset>
<!-- Cleanup -->
<fieldset>
<legend>Cleanup</legend>
<div class="form-grid">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="cleanup_enabled"
{% if settings.cleanup.enabled %}checked{% endif %}>
Auto-Cleanup aktivieren
</label>
</div>
<div class="form-group">
<label>Zu loeschende Extensions</label>
<input type="text" name="cleanup_extensions"
value="{{ settings.cleanup.delete_extensions | join(', ') }}"
placeholder=".avi, .wmv, .nfo, .txt, .jpg">
</div>
<div class="form-group">
<label>Ausnahmen (Muster)</label>
<input type="text" name="cleanup_exclude"
value="{{ settings.cleanup.exclude_patterns | join(', ') }}"
placeholder="readme*, *.md">
</div>
</div>
</fieldset>
<!-- Audio -->
<fieldset>
<legend>Audio</legend>
<div class="form-grid">
<div class="form-group">
<label for="audio_languages">Sprachen</label>
<input type="text" name="audio_languages" id="audio_languages"
value="{{ settings.audio.languages | join(', ') }}"
placeholder="ger, eng, und">
</div>
<div class="form-group">
<label for="audio_codec">Codec</label>
<select name="audio_codec" id="audio_codec">
<option value="libopus" {% if settings.audio.default_codec == 'libopus' %}selected{% endif %}>Opus</option>
<option value="aac" {% if settings.audio.default_codec == 'aac' %}selected{% endif %}>AAC</option>
<option value="copy" {% if settings.audio.default_codec == 'copy' %}selected{% endif %}>Stream Copy</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" name="keep_channels"
{% if settings.audio.keep_channels %}checked{% endif %}>
Kanalanzahl beibehalten (kein Downmix)
</label>
</div>
</div>
</fieldset>
<!-- Untertitel -->
<fieldset>
<legend>Untertitel</legend>
<div class="form-grid">
<div class="form-group">
<label for="subtitle_languages">Sprachen</label>
<input type="text" name="subtitle_languages" id="subtitle_languages"
value="{{ settings.subtitle.languages | join(', ') }}"
placeholder="ger, eng">
</div>
</div>
</fieldset>
<!-- TVDB / Bibliothek -->
<fieldset>
<legend>Bibliothek / TVDB</legend>
<div class="form-grid">
<div class="form-group">
<label for="tvdb_api_key">TVDB API Key</label>
<input type="text" name="tvdb_api_key" id="tvdb_api_key"
value="{{ settings.library.tvdb_api_key if settings.library else '' }}"
placeholder="API Key von thetvdb.com">
</div>
<div class="form-group">
<label for="tvdb_pin">TVDB PIN</label>
<input type="text" name="tvdb_pin" id="tvdb_pin"
value="{{ settings.library.tvdb_pin if settings.library else '' }}"
placeholder="Subscriber PIN (optional)">
</div>
<div class="form-group">
<label for="tvdb_language">TVDB Sprache</label>
<select name="tvdb_language" id="tvdb_language">
{% set lang = settings.library.tvdb_language if settings.library and settings.library.tvdb_language else 'deu' %}
<option value="deu" {% if lang == 'deu' %}selected{% endif %}>Deutsch</option>
<option value="eng" {% if lang == 'eng' %}selected{% endif %}>English</option>
<option value="fra" {% if lang == 'fra' %}selected{% endif %}>Francais</option>
<option value="spa" {% if lang == 'spa' %}selected{% endif %}>Espanol</option>
<option value="ita" {% if lang == 'ita' %}selected{% endif %}>Italiano</option>
<option value="jpn" {% if lang == 'jpn' %}selected{% endif %}>Japanese</option>
</select>
</div>
</div>
</fieldset>
<!-- Logging -->
<fieldset>
<legend>Logging</legend>
<div class="form-grid">
<div class="form-group">
<label for="log_level">Log-Level</label>
<select name="log_level" id="log_level">
{% for level in ['DEBUG', 'INFO', 'WARNING', 'ERROR'] %}
<option value="{{ level }}" {% if level == settings.logging.level %}selected{% endif %}>{{ level }}</option>
{% endfor %}
</select>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary">Speichern</button>
</div>
<div id="save-result"></div>
</form>
</section>
<!-- Scan-Pfade -->
<section class="admin-section">
<h2>Bibliothek - Scan-Pfade</h2>
<div id="library-paths">
<div class="loading-msg">Lade Pfade...</div>
</div>
<div class="form-grid" style="margin-top:1rem">
<div class="form-group">
<label>Name</label>
<input type="text" id="new-path-name" placeholder="z.B. Serien">
</div>
<div class="form-group">
<label>Pfad</label>
<input type="text" id="new-path-path" placeholder="/mnt/30 - Media/Serien">
</div>
<div class="form-group">
<label>Typ</label>
<select id="new-path-type">
<option value="series">Serien</option>
<option value="movie">Filme</option>
</select>
</div>
<div class="form-group" style="justify-content:flex-end">
<button class="btn-primary" onclick="addLibraryPath()">Pfad hinzufuegen</button>
</div>
</div>
</section>
<!-- Presets -->
<section class="admin-section">
<h2>Encoding-Presets</h2>
<div class="presets-grid">
{% for key, preset in presets.items() %}
<div class="preset-card">
<h3>{{ preset.name }}</h3>
<div class="preset-details">
<span class="tag">{{ preset.video_codec }}</span>
<span class="tag">{{ preset.container }}</span>
<span class="tag">{{ preset.quality_param }}={{ preset.quality_value }}</span>
{% if preset.hw_init %}<span class="tag gpu">GPU</span>{% else %}<span class="tag cpu">CPU</span>{% endif %}
</div>
</div>
{% endfor %}
</div>
</section>
{% endblock %}
{% block scripts %}
<script>
// Scan-Pfade Verwaltung
function loadLibraryPaths() {
fetch("/api/library/paths")
.then(r => r.json())
.then(data => {
const container = document.getElementById("library-paths");
const paths = data.paths || [];
if (!paths.length) {
container.innerHTML = '<div class="loading-msg">Keine Scan-Pfade konfiguriert</div>';
return;
}
container.innerHTML = paths.map(p => `
<div class="preset-card" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
<div>
<strong>${p.name}</strong>
<span class="tag">${p.media_type === 'series' ? 'Serien' : 'Filme'}</span>
<br><span style="font-size:0.8rem;color:#888">${p.path}</span>
${p.last_scan ? '<br><span style="font-size:0.75rem;color:#666">Letzter Scan: ' + p.last_scan + '</span>' : ''}
</div>
<div style="display:flex;gap:0.3rem">
<button class="btn-small btn-secondary" onclick="scanPath(${p.id})">Scannen</button>
<button class="btn-small btn-danger" onclick="deletePath(${p.id})">Loeschen</button>
</div>
</div>
`).join("");
})
.catch(() => {
document.getElementById("library-paths").innerHTML =
'<div style="text-align:center;color:#666;padding:1rem">Fehler beim Laden</div>';
});
}
function addLibraryPath() {
const name = document.getElementById("new-path-name").value.trim();
const path = document.getElementById("new-path-path").value.trim();
const mediaType = 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: name, path: path, media_type: mediaType}),
})
.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 = "";
loadLibraryPaths();
}
})
.catch(e => alert("Fehler: " + e));
}
function deletePath(pathId) {
if (!confirm("Scan-Pfad und alle zugehoerigen Daten loeschen?")) return;
fetch("/api/library/paths/" + pathId, {method: "DELETE"})
.then(r => r.json())
.then(() => loadLibraryPaths())
.catch(e => alert("Fehler: " + e));
}
function scanPath(pathId) {
fetch("/api/library/scan/" + pathId, {method: "POST"})
.then(r => r.json())
.then(data => alert(data.message || "Scan gestartet"))
.catch(e => alert("Fehler: " + e));
}
document.addEventListener("DOMContentLoaded", loadLibraryPaths);
</script>
{% endblock %}

32
app/templates/base.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VideoKonverter{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block head %}{% endblock %}
</head>
<body>
<header>
<div class="header-left">
<h1>VideoKonverter</h1>
</div>
<nav>
<a href="/" class="nav-link {% if request.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
<a href="/admin" class="nav-link {% if request.path == '/admin' %}active{% endif %}">Einstellungen</a>
<a href="/statistics" class="nav-link {% if request.path == '/statistics' %}active{% endif %}">Statistik</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<div id="toast-container"></div>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}Dashboard - VideoKonverter{% endblock %}
{% block content %}
<!-- Aktionen -->
<section id="actions-section">
<div class="action-bar">
<button class="btn-primary" onclick="openFileBrowser()">Dateien durchsuchen</button>
<button class="btn-secondary" onclick="openUpload()">Video hochladen</button>
</div>
</section>
<!-- Aktive Konvertierungen -->
<section id="active-section">
<h2>Aktive Konvertierungen</h2>
<div id="active-conversions">
<!-- Wird dynamisch via WebSocket gefuellt -->
</div>
</section>
<!-- Warteschlange -->
<section id="queue-section">
<h2>Warteschlange</h2>
<div id="queue">
<!-- Wird dynamisch via WebSocket gefuellt -->
</div>
</section>
<!-- Filebrowser Modal -->
<div id="filebrowser-overlay" class="modal-overlay" style="display:none" onclick="closeBrowserOnOverlay(event)">
<div class="modal">
<div class="modal-header">
<h2>Dateien durchsuchen</h2>
<button class="btn-close" onclick="closeFileBrowser()">&times;</button>
</div>
<div class="modal-breadcrumb" id="fb-breadcrumb"></div>
<div class="modal-body" id="fb-content">
Lade...
</div>
<div class="modal-footer">
<span id="fb-selected-count">0 ausgewaehlt</span>
<div>
<button class="btn-secondary" id="fb-select-all" onclick="fbSelectAll()">Alle auswaehlen</button>
<button class="btn-primary" id="fb-convert" onclick="fbConvertSelected()" disabled>Konvertieren</button>
</div>
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="upload-overlay" class="modal-overlay" style="display:none" onclick="closeUploadOnOverlay(event)">
<div class="modal modal-small">
<div class="modal-header">
<h2>Video hochladen</h2>
<button class="btn-close" onclick="closeUpload()">&times;</button>
</div>
<div class="modal-body">
<div class="upload-zone" id="upload-zone"
ondrop="handleDrop(event)" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)">
<p>Videodateien hierher ziehen</p>
<p class="upload-hint">oder</p>
<label class="btn-secondary upload-btn">
Dateien waehlen
<input type="file" id="upload-input" multiple accept="video/*" onchange="handleFileSelect(event)" style="display:none">
</label>
</div>
<div id="upload-list" class="upload-list"></div>
<div id="upload-progress" class="upload-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="upload-bar"></div>
</div>
<span id="upload-status">Wird hochgeladen...</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" id="upload-start" onclick="startUpload()" disabled>Hochladen & Konvertieren</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var WS_URL = "{{ ws_url }}";
</script>
<script src="/static/js/websocket.js"></script>
<script src="/static/js/filebrowser.js"></script>
{% endblock %}

392
app/templates/library.html Normal file
View file

@ -0,0 +1,392 @@
{% extends "base.html" %}
{% block title %}Bibliothek - VideoKonverter{% endblock %}
{% block content %}
<section class="library-section">
<div class="library-header">
<h2>Video-Bibliothek</h2>
<div class="library-actions">
<button class="btn-primary" onclick="startScan()">Scan starten</button>
<button class="btn-secondary" onclick="openPathsModal()">Pfade verwalten</button>
<button class="btn-secondary" onclick="openCleanModal()">Aufraeumen</button>
<button class="btn-secondary" onclick="openImportModal()">Importieren</button>
<button class="btn-secondary" onclick="showDuplicates()">Duplikate</button>
<button class="btn-secondary" onclick="startAutoMatch()">TVDB Auto-Match</button>
</div>
</div>
<!-- Scan-Progress -->
<div id="scan-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="scan-bar"></div>
</div>
<span class="scan-status" id="scan-status">Scanne...</span>
</div>
<!-- Auto-Match Progress -->
<div id="auto-match-progress" class="scan-progress" style="display:none">
<div class="progress-container">
<div class="progress-bar" id="auto-match-bar"></div>
</div>
<span class="scan-status" id="auto-match-status">TVDB Auto-Match...</span>
</div>
<!-- Statistik-Leiste -->
<div class="library-stats" id="library-stats">
<div class="lib-stat"><span class="lib-stat-value" id="stat-videos">-</span><span class="lib-stat-label">Videos</span></div>
<div class="lib-stat"><span class="lib-stat-value" id="stat-series">-</span><span class="lib-stat-label">Serien</span></div>
<div class="lib-stat"><span class="lib-stat-value" id="stat-size">-</span><span class="lib-stat-label">Gesamt</span></div>
<div class="lib-stat"><span class="lib-stat-value" id="stat-duration">-</span><span class="lib-stat-label">Spielzeit</span></div>
</div>
<div class="library-layout">
<!-- Pfad-Navigation -->
<nav class="library-nav" id="library-nav">
<h3>Bibliotheken</h3>
<div id="nav-paths-list">
<div class="loading-msg" style="padding:0.5rem;font-size:0.75rem">Lade...</div>
</div>
</nav>
<!-- Filter-Sidebar -->
<aside class="library-filters" id="filters">
<h3>Filter</h3>
<div class="filter-group">
<label>Suche</label>
<input type="text" id="filter-search" placeholder="Dateiname..." oninput="debounceFilter()">
</div>
<div class="filter-group">
<label>Aufloesung</label>
<select id="filter-resolution" onchange="applyFilters()">
<option value="">Alle</option>
<option value="3840">4K (3840+)</option>
<option value="1920">1080p (1920+)</option>
<option value="1280">720p (1280+)</option>
<option value="720">SD (720+)</option>
</select>
</div>
<div class="filter-group">
<label>Video-Codec</label>
<select id="filter-codec" onchange="applyFilters()">
<option value="">Alle</option>
<option value="hevc">HEVC/H.265</option>
<option value="h264">H.264</option>
<option value="av1">AV1</option>
<option value="mpeg4">MPEG-4</option>
<option value="mpeg2video">MPEG-2</option>
</select>
</div>
<div class="filter-group">
<label>Container</label>
<select id="filter-container" onchange="applyFilters()">
<option value="">Alle</option>
<option value="mkv">MKV</option>
<option value="mp4">MP4</option>
<option value="avi">AVI</option>
<option value="webm">WebM</option>
<option value="ts">TS</option>
<option value="wmv">WMV</option>
</select>
</div>
<div class="filter-group">
<label>Audio-Sprache</label>
<select id="filter-audio-lang" onchange="applyFilters()">
<option value="">Alle</option>
<option value="ger">Deutsch</option>
<option value="eng">Englisch</option>
</select>
</div>
<div class="filter-group">
<label>Audio-Kanaele</label>
<select id="filter-audio-ch" onchange="applyFilters()">
<option value="">Alle</option>
<option value="2">Stereo (2.0)</option>
<option value="6">5.1 Surround</option>
<option value="8">7.1 Surround</option>
</select>
</div>
<div class="filter-group">
<label><input type="checkbox" id="filter-10bit" onchange="applyFilters()"> Nur 10-Bit</label>
</div>
<div class="filter-group">
<label>Sortierung</label>
<select id="filter-sort" onchange="applyFilters()">
<option value="file_name">Name</option>
<option value="file_size">Groesse</option>
<option value="width">Aufloesung</option>
<option value="duration_sec">Dauer</option>
<option value="video_codec">Codec</option>
<option value="scanned_at">Scan-Datum</option>
</select>
<select id="filter-order" onchange="applyFilters()">
<option value="asc">Aufsteigend</option>
<option value="desc">Absteigend</option>
</select>
</div>
</aside>
<!-- Hauptbereich: Dynamische Bereiche pro Library-Pfad -->
<div class="library-content" id="library-content">
<div class="loading-msg">Lade Bibliothek...</div>
</div>
</div>
</section>
<!-- === MODALS === -->
<!-- Pfade-Verwaltung Modal -->
<div id="paths-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
<h2>Scan-Pfade verwalten</h2>
<button class="btn-close" onclick="closePathsModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<div id="paths-list"></div>
<hr style="border-color:#333; margin:1rem 0">
<h3 style="font-size:0.9rem; margin-bottom:0.5rem">Neuen Pfad hinzufuegen</h3>
<div class="form-grid">
<div class="form-group">
<label>Name</label>
<input type="text" id="new-path-name" placeholder="z.B. Serien">
</div>
<div class="form-group">
<label>Pfad</label>
<input type="text" id="new-path-path" placeholder="/mnt/30 - Media/10 - Serien">
</div>
<div class="form-group">
<label>Typ</label>
<select id="new-path-type">
<option value="series">Serien</option>
<option value="movie">Filme</option>
</select>
</div>
</div>
<div class="form-actions">
<button class="btn-primary" onclick="addPath()">Hinzufuegen</button>
</div>
</div>
</div>
</div>
<!-- TVDB Such-Modal -->
<div id="tvdb-modal" class="modal-overlay" style="display:none">
<div class="modal modal-small">
<div class="modal-header">
<h2>TVDB zuordnen</h2>
<button class="btn-close" onclick="closeTvdbModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<input type="hidden" id="tvdb-series-id">
<div class="form-group">
<label>Serie suchen</label>
<input type="text" id="tvdb-search-input" placeholder="Serienname..."
oninput="debounceTvdbSearch()">
</div>
<div id="tvdb-results" class="tvdb-results"></div>
</div>
</div>
</div>
<!-- Duplikate-Modal -->
<div id="duplicates-modal" class="modal-overlay" style="display:none">
<div class="modal">
<div class="modal-header">
<h2>Duplikate</h2>
<button class="btn-close" onclick="closeDuplicatesModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<div id="duplicates-list" class="duplicates-list">
<div class="loading-msg">Suche Duplikate...</div>
</div>
</div>
</div>
</div>
<!-- Serien-Detail-Modal -->
<div id="series-modal" class="modal-overlay" style="display:none">
<div class="modal" style="max-width:1000px">
<div class="modal-header">
<div style="flex:1">
<h2 id="series-modal-title">Serie</h2>
<span id="series-modal-genres" class="series-genres-line"></span>
</div>
<div class="modal-header-actions">
<button class="btn-small btn-secondary" id="btn-tvdb-refresh" onclick="tvdbRefresh()" style="display:none">TVDB aktualisieren</button>
<button class="btn-small btn-secondary" id="btn-tvdb-unlink" onclick="tvdbUnlink()" style="display:none">TVDB loesen</button>
<button class="btn-small btn-secondary" id="btn-metadata-dl" onclick="downloadMetadata()" style="display:none">Metadaten laden</button>
<button class="btn-small btn-secondary" id="btn-series-delete-db" onclick="deleteSeries(false)">Aus DB loeschen</button>
<button class="btn-small btn-danger" id="btn-series-delete-all" onclick="deleteSeries(true)">Komplett loeschen</button>
<button class="btn-close" onclick="closeSeriesModal()">&times;</button>
</div>
</div>
<div class="modal-body" style="padding:0">
<!-- Detail-Tabs -->
<div class="detail-tabs">
<button class="detail-tab active" onclick="switchDetailTab('episodes')">Episoden</button>
<button class="detail-tab" onclick="switchDetailTab('cast')">Darsteller</button>
<button class="detail-tab" onclick="switchDetailTab('artworks')">Bilder</button>
</div>
<div id="series-modal-body" style="padding:1rem">
</div>
</div>
</div>
</div>
<!-- Film-Detail-Modal -->
<div id="movie-modal" class="modal-overlay" style="display:none">
<div class="modal" style="max-width:900px">
<div class="modal-header">
<div style="flex:1">
<h2 id="movie-modal-title">Film</h2>
<span id="movie-modal-genres" class="series-genres-line"></span>
</div>
<div class="modal-header-actions">
<button class="btn-small btn-secondary" id="btn-movie-tvdb-unlink" onclick="movieTvdbUnlink()" style="display:none">TVDB loesen</button>
<button class="btn-small btn-secondary" onclick="deleteMovie(false)">Aus DB loeschen</button>
<button class="btn-small btn-danger" onclick="deleteMovie(true)">Komplett loeschen</button>
<button class="btn-close" onclick="closeMovieModal()">&times;</button>
</div>
</div>
<div class="modal-body" style="padding:1rem">
<div id="movie-modal-body"></div>
</div>
</div>
</div>
<!-- Film-TVDB Such-Modal -->
<div id="movie-tvdb-modal" class="modal-overlay" style="display:none">
<div class="modal modal-small">
<div class="modal-header">
<h2>Film TVDB zuordnen</h2>
<button class="btn-close" onclick="closeMovieTvdbModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<input type="hidden" id="movie-tvdb-id">
<div class="form-group">
<label>Film suchen</label>
<input type="text" id="movie-tvdb-search-input" placeholder="Filmname..."
oninput="debounceMovieTvdbSearch()">
</div>
<div id="movie-tvdb-results" class="tvdb-results"></div>
</div>
</div>
</div>
<!-- Clean-Modal -->
<div id="clean-modal" class="modal-overlay" style="display:none">
<div class="modal" style="max-width:1000px">
<div class="modal-header">
<h2>Bibliothek aufraeumen</h2>
<button class="btn-close" onclick="closeCleanModal()">&times;</button>
</div>
<div class="modal-body" style="padding:1rem">
<div class="clean-actions" style="margin-bottom:1rem; display:flex; gap:0.5rem; align-items:center;">
<button class="btn-primary" onclick="scanForJunk()">Junk scannen</button>
<button class="btn-secondary" onclick="deleteSelectedJunk()">Ausgewaehlte loeschen</button>
<button class="btn-secondary" onclick="deleteEmptyDirs()">Leere Ordner loeschen</button>
<span id="clean-info" class="text-muted" style="margin-left:auto"></span>
</div>
<div class="clean-filter" style="margin-bottom:0.5rem">
<label style="font-size:0.8rem; color:#aaa;">Filter Extension:</label>
<select id="clean-ext-filter" onchange="filterCleanList()" style="background:#252525;color:#ddd;border:1px solid #333;border-radius:4px;padding:0.2rem;font-size:0.8rem;">
<option value="">Alle</option>
</select>
<label style="margin-left:0.5rem; font-size:0.8rem; cursor:pointer; color:#ccc;">
<input type="checkbox" id="clean-select-all" onchange="toggleCleanSelectAll()"> Alle auswaehlen
</label>
</div>
<div id="clean-list" class="clean-list">
<div class="loading-msg">Klicke "Junk scannen" um zu starten</div>
</div>
</div>
</div>
</div>
<!-- Import-Modal -->
<div id="import-modal" class="modal-overlay" style="display:none">
<div class="modal" style="max-width:1100px">
<div class="modal-header">
<h2>Videos importieren</h2>
<button class="btn-close" onclick="closeImportModal()">&times;</button>
</div>
<div class="modal-body" style="padding:0">
<!-- Schritt 1: Ordner waehlen -->
<div id="import-setup">
<!-- Filebrowser -->
<div class="import-browser-bar">
<input type="text" id="import-source" placeholder="/mnt/..." oninput="debounceImportPath()"
style="flex:1;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem;font-size:0.85rem">
<button class="btn-small btn-secondary" onclick="importBrowse(document.getElementById('import-source').value || '/mnt')">Oeffnen</button>
</div>
<div id="import-browser" class="import-browser"></div>
<!-- Einstellungen + Analysieren -->
<div class="import-setup-footer">
<div class="import-setup-opts">
<label>Ziel:</label>
<select id="import-target"></select>
<label>Modus:</label>
<select id="import-mode">
<option value="copy">Kopieren</option>
<option value="move">Verschieben</option>
</select>
</div>
<div>
<span id="import-folder-info" class="text-muted"></span>
<button class="btn-primary" id="btn-analyze-import" onclick="createImportJob()" disabled>Analysieren</button>
</div>
</div>
</div>
<!-- Schritt 2: Vorschau -->
<div id="import-preview" style="display:none">
<div class="import-actions" style="padding:0.6rem 1rem; display:flex; gap:0.5rem; align-items:center; border-bottom:1px solid #2a2a2a;">
<button class="btn-primary" id="btn-start-import" onclick="executeImport()">Import starten</button>
<button class="btn-secondary" onclick="resetImport()">Zurueck</button>
<span id="import-info" class="text-muted" style="margin-left:auto"></span>
</div>
<div id="import-items-list" class="import-items-list"></div>
</div>
<!-- Schritt 3: Fortschritt -->
<div id="import-progress" style="display:none; padding:1rem;">
<div class="progress-container">
<div class="progress-bar" id="import-bar"></div>
</div>
<span class="text-muted" id="import-status-text">Importiere...</span>
</div>
</div>
</div>
</div>
<!-- TVDB Review-Modal -->
<div id="tvdb-review-modal" class="modal-overlay" style="display:none">
<div class="modal" style="max-width:1100px">
<div class="modal-header">
<div style="flex:1">
<h2>TVDB Vorschlaege pruefen</h2>
<span id="tvdb-review-info" class="text-muted" style="font-size:0.8rem"></span>
</div>
<div class="modal-header-actions">
<button class="btn-small btn-secondary" id="btn-review-skip-all" onclick="skipAllReviewItems()">Alle ueberspringen</button>
<button class="btn-close" onclick="closeTvdbReviewModal()">&times;</button>
</div>
</div>
<div class="modal-body" style="padding:0">
<div id="tvdb-review-list" class="tvdb-review-list">
<div class="loading-msg">Keine Vorschlaege</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/library.js"></script>
{% endblock %}

View file

@ -0,0 +1,47 @@
<table class="data-table">
<thead>
<tr>
<th>Datei</th>
<th>Groesse (Quelle)</th>
<th>Groesse (Ziel)</th>
<th>Dauer</th>
<th>FPS</th>
<th>Speed</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td title="{{ entry.source_path }}">{{ entry.source_filename }}</td>
<td>{{ "%.1f"|format(entry.source_size_bytes / 1048576) }} MiB</td>
<td>{{ "%.1f"|format((entry.target_size_bytes or 0) / 1048576) }} MiB</td>
<td>{{ "%.0f"|format(entry.duration_sec or 0) }}s</td>
<td>{{ "%.1f"|format(entry.avg_fps or 0) }}</td>
<td>{{ "%.2f"|format(entry.avg_speed or 0) }}x</td>
<td>
{% if entry.status == 2 %}
<span class="status-badge ok">OK</span>
{% elif entry.status == 3 %}
<span class="status-badge error">Fehler</span>
{% elif entry.status == 4 %}
<span class="status-badge warn">Abgebrochen</span>
{% else %}
<span class="status-badge">{{ entry.status }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if entries | length >= 25 %}
<div class="pagination">
<button hx-get="/htmx/stats?page={{ page + 1 }}"
hx-target="#stats-table"
hx-swap="innerHTML"
class="btn-secondary">
Weitere laden...
</button>
</div>
{% endif %}

View file

@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Statistik - VideoKonverter{% endblock %}
{% block content %}
<section class="stats-section">
<h2>Statistik</h2>
<!-- Zusammenfassung -->
{% if summary %}
<div class="stats-summary">
<div class="stat-card">
<span class="stat-value">{{ summary.total }}</span>
<span class="stat-label">Gesamt</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ summary.finished }}</span>
<span class="stat-label">Erfolgreich</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ summary.failed }}</span>
<span class="stat-label">Fehlgeschlagen</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ "%.1f"|format(summary.space_saved / 1073741824) }} GiB</span>
<span class="stat-label">Platz gespart</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ "%.1f"|format(summary.avg_fps) }}</span>
<span class="stat-label">Avg FPS</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ "%.2f"|format(summary.avg_speed) }}x</span>
<span class="stat-label">Avg Speed</span>
</div>
</div>
{% endif %}
<!-- Tabelle -->
<div id="stats-table"
hx-get="/htmx/stats?page=1"
hx-trigger="load"
hx-swap="innerHTML">
Lade Statistiken...
</div>
</section>
{% endblock %}

52
docker-compose.yml Normal file
View file

@ -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

6
requirements.txt Normal file
View file

@ -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