feat: VideoKonverter v2.9 - Projekt-Reset aus Docker-Image
Projekt aus Docker-Image videoconverter:2.9 extrahiert. Enthält zweiphasigen Import-Workflow mit Serien-Zuordnung. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
37dff4de69
42 changed files with 17794 additions and 0 deletions
21
.dockerignore
Normal file
21
.dockerignore
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Tar-Archive nicht in den Build-Kontext einschliessen
|
||||
*.tar
|
||||
*.tar.gz
|
||||
|
||||
# Alte Projektverzeichnisse
|
||||
AV1 Encoder*
|
||||
kio/
|
||||
python.data-tv-file-v2/
|
||||
|
||||
# Git und IDE
|
||||
.git/
|
||||
.claude/
|
||||
.vscode/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
# Runtime-Daten
|
||||
data/
|
||||
logs/
|
||||
video-konverter/app/cfg/settings.yaml
|
||||
video-konverter/app/cfg/*.json
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Data
|
||||
data/
|
||||
|
||||
# Docker
|
||||
*.tar
|
||||
|
||||
# Alt-Backups
|
||||
alt/
|
||||
_extract/
|
||||
|
||||
# Config (wird zur Laufzeit gemountet)
|
||||
video-konverter/app/cfg/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
448
CHANGELOG.md
Normal file
448
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
# Changelog
|
||||
|
||||
Alle relevanten Aenderungen am VideoKonverter-Projekt.
|
||||
|
||||
## [2.9.0] - 2026-02-27
|
||||
|
||||
### Import-System Neustrukturierung
|
||||
|
||||
**Zweiphasiger Import-Workflow**
|
||||
- Phase 1 (Analyse): Nur Dateiname-Erkennung (Serie/Staffel/Episode)
|
||||
- Phase 2 (Serien-Zuordnung): User ordnet erkannte Serien TVDB-Serien zu
|
||||
- Erst nach Zuordnung: Zielpfade berechnen, TVDB-Episodentitel holen, Konflikte pruefen
|
||||
- Status-Flow: `pending` -> `analyzing` -> `pending_assignment` -> `ready` -> `importing` -> `done`
|
||||
|
||||
**Serien-Zuordnungs-UI**
|
||||
- Neue Ansicht nach Analyse zeigt alle erkannten Serien
|
||||
- Pro Serie: Anzahl Dateien, Staffel-Range
|
||||
- TVDB-Suche und -Zuordnung pro Serie
|
||||
- Erst wenn alle Serien zugeordnet: Import freigeben
|
||||
|
||||
**Backend-Aenderungen**
|
||||
- `analyze_job()` in importer.py: Nur Dateinamen-Parsing, kein TVDB
|
||||
- `get_pending_series()`: Gruppierte Liste aller zuzuordnenden Serien
|
||||
- `assign_series_mapping()`: TVDB-Zuordnung + Zielpfad-Berechnung
|
||||
- Neuer Item-Status `pending_series` fuer noch nicht zugeordnete Dateien
|
||||
|
||||
**Mindestgroesse fuer Episoden**
|
||||
- Dateien < 100 MiB werden als Sample/Trailer markiert und uebersprungen
|
||||
- Verhindert Import von NFO-begleitenden Samples
|
||||
|
||||
### Geaenderte Dateien
|
||||
- `app/services/importer.py` - Zweiphasiger Workflow, MIN_EPISODE_SIZE, assign_series_mapping()
|
||||
- `app/routes/library_api.py` - Neue Endpoints fuer Serien-Zuordnung
|
||||
- `app/static/js/library.js` - Serien-Zuordnungs-UI
|
||||
- `app/templates/library.html` - import-series-assign Container
|
||||
|
||||
### Neue API-Endpoints
|
||||
- `GET /api/library/import/{id}/pending-series` - Zuzuordnende Serien auflisten
|
||||
- `POST /api/library/import/{id}/assign-series` - Serie TVDB zuordnen
|
||||
|
||||
---
|
||||
|
||||
## [2.5.0] - 2026-02-24
|
||||
|
||||
### GPU & Deployment
|
||||
|
||||
**Intel A380 GPU-Konfiguration**
|
||||
- GPU-Device auf renderD129 (Intel A380) korrigiert - renderD128 war AMD auf Eddys Unraid
|
||||
- GPU-Erkennung per `/sys/class/drm/renderD*/device/uevent` statt `vainfo`
|
||||
- `VK_GPU_DEVICE` Umgebungsvariable statt hardcoded Device-Mapping
|
||||
- AV1 10-Bit (`gpu_av1_10bit`) als Standard-Preset (bessere Qualitaet bei gleichem Speed)
|
||||
|
||||
**Docker Entrypoint**
|
||||
- Neues `entrypoint.sh`: Kopiert Default-Configs (`presets.yaml`, `settings.yaml`) automatisch ins gemountete cfg-Volume bei Erstinstallation
|
||||
- `cfg_defaults/` Verzeichnis im Image als Backup der Default-Konfigdateien
|
||||
- `Config._load_presets()` mit Fallback auf `cfg_defaults` falls presets.yaml fehlt
|
||||
- Behebt leeres Preset-Dropdown auf Unraid bei frisch gemounteten Volumes
|
||||
|
||||
**Unraid Docker-Template**
|
||||
- Neue `unraid/my-VideoKonverter.xml` fuer Unraid WebGUI-Installation
|
||||
- Alle ENV-Variablen mit Defaults vorkonfiguriert
|
||||
- GPU-Device, Pfad-Mappings, DB-Konfiguration voreingestellt
|
||||
- ExtraParams fuer `--group-add video --device=/dev/dri:/dev/dri`
|
||||
|
||||
**Startseite**
|
||||
- Redirect von `/` auf `/library` statt Dashboard
|
||||
|
||||
### Geaenderte Dateien
|
||||
- `Dockerfile` - cfg_defaults Sicherung, entrypoint.sh statt CMD
|
||||
- `entrypoint.sh` - NEU: Default-Config-Kopie bei Erststart
|
||||
- `docker-compose.yml` - VK_GPU_DEVICE renderD129, /dev/dri komplett, gpu_av1_10bit Default
|
||||
- `app/config.py` - _load_presets() mit cfg_defaults Fallback
|
||||
- `app/routes/pages.py` - Startseite Redirect auf /library
|
||||
- `unraid/my-VideoKonverter.xml` - NEU: Unraid Docker-Template
|
||||
|
||||
---
|
||||
|
||||
## [2.4.0] - 2026-02-24
|
||||
|
||||
### Video-Player & Streaming
|
||||
|
||||
**Browser-Streaming**
|
||||
- Video-Player mit ffmpeg-Transcoding-Stream (Video copy + Audio->AAC Stereo)
|
||||
- Unterstuetzt Formate die Browser nicht nativ abspielen (EAC3, DTS, AC3, TrueHD)
|
||||
- Fragmented MP4 mit `frag_keyframe+empty_moov+faststart`, chunked Transfer-Encoding
|
||||
- Endpoint: `GET /api/library/videos/{id}/stream?t=0`
|
||||
|
||||
**Play-/Delete-Buttons**
|
||||
- Play-Button bei jedem Video in Serien-, Film- und Ordner-Ansichten
|
||||
- Delete-Button zum Loeschen einzelner Videos (DB-Eintrag + Datei auf Disk)
|
||||
- Bestaetigungs-Dialog vor dem Loeschen
|
||||
|
||||
### Import-System
|
||||
|
||||
**Nicht-erkannte Dateien zuordnen**
|
||||
- Modal fuer nicht-erkannte Dateien: Serie/Staffel/Episode manuell zuweisen oder ueberspringen
|
||||
- Import-Start blockiert solange ungeloeste Items vorhanden
|
||||
- Duplikat-Erkennung bei erneutem Scan nach Import
|
||||
|
||||
### Technische Aenderungen
|
||||
|
||||
**ENV-Variablen Refactoring**
|
||||
- Alle Umgebungsvariablen mit `VK_*` Prefix (VK_DB_HOST, VK_MODE, VK_PORT etc.)
|
||||
- `_ENV_MAP` in config.py: Zentrales Mapping ENV -> Settings-Pfad mit Typ-Konvertierung
|
||||
- Rueckwaertskompatibilitaet per `_ENV_ALIASES`
|
||||
|
||||
**WebSocket Server-Log Push**
|
||||
- Server-Logs werden per WebSocket an alle Clients gepusht (statt HTTP-Polling)
|
||||
- `WebLogHandler` in api.py schreibt Log-Eintraege in den WebSocket-Manager
|
||||
|
||||
**Audio-Fix**
|
||||
- `channelmap=channel_layout=5.1` Filter fuer libopus bei EAC3/AC3 mit `5.1(side)` Layout
|
||||
- Behebt Encoder-Fehler bei Surround-Audio-Transcoding
|
||||
|
||||
**Ordner-Loeschen Fix**
|
||||
- Filebrowser: Ordner-Loeschen funktioniert jetzt korrekt
|
||||
|
||||
### Geaenderte Dateien
|
||||
- `app/config.py` - ENV-Mapping, VK_* Prefix, Typ-Konvertierung (+180 Z.)
|
||||
- `app/routes/library_api.py` - Video-Stream, Video-Delete, Import-Zuordnung (+200 Z.)
|
||||
- `app/services/importer.py` - Zuordnungs-Modal, Duplikat-Erkennung (+160 Z.)
|
||||
- `app/services/encoder.py` - channelmap 5.1(side) Fix
|
||||
- `app/services/library.py` - Video-Delete, Rescan-Logik (+70 Z.)
|
||||
- `app/static/js/library.js` - Player, Play/Delete-Buttons, Import-Modal (+270 Z.)
|
||||
- `app/static/css/style.css` - Player-Styles, Button-Styles (+50 Z.)
|
||||
- `app/templates/library.html` - Player-Modal, Import-Zuordnungs-Modal (+60 Z.)
|
||||
- `app/templates/base.html` - WebSocket-Log-Push Integration
|
||||
- `app/routes/ws.py` - Server-Log WebSocket-Broadcast
|
||||
- `docker-compose.yml` - VK_* ENV-Variablen
|
||||
|
||||
### Neue API-Endpoints
|
||||
- `GET /api/library/videos/{id}/stream` - Video-Transcoding-Stream
|
||||
- `DELETE /api/library/videos/{id}` - Video loeschen (DB + Datei)
|
||||
- `POST /api/library/import/{id}/assign` - Import-Item manuell zuordnen
|
||||
- `POST /api/library/import/{id}/skip` - Import-Item ueberspringen
|
||||
|
||||
---
|
||||
|
||||
## [2.3.0] - 2026-02-24
|
||||
|
||||
### Import-System Verbesserungen
|
||||
|
||||
**Bestehende Import-Jobs laden**
|
||||
- Neue `GET /api/library/import` API liefert alle Import-Jobs
|
||||
- Import-Modal zeigt jetzt offene Jobs oben an (Buttons mit Status)
|
||||
- Klick auf Job laedt und zeigt Vorschau zum Fortsetzen
|
||||
- Verhindert doppelte Importe der gleichen Quelle
|
||||
|
||||
**Import-Fortschritt mit Byte-Level**
|
||||
- Neue DB-Felder: `current_file_name`, `current_file_bytes`, `current_file_total`
|
||||
- Kopieren in 64MB-Chunks mit Progress-Updates alle 50MB
|
||||
- UI zeigt aktuelle Datei und Byte-Fortschritt
|
||||
|
||||
**Gezielter Rescan nach Import**
|
||||
- Nach Import wird nur der Ziel-Library-Pfad gescannt
|
||||
- `imported_series` Liste im Job-Status fuer betroffene Ordner
|
||||
- Statt `reloadAllSections()` nur `loadSectionData(targetPathId)`
|
||||
|
||||
### Ordner-Verwaltung
|
||||
|
||||
**Ordner-Loeschen Button**
|
||||
- Neuer Muelleimer-Button (SVG-Icon) oben rechts bei Ordnern
|
||||
- Erscheint nur bei Hover, rot bei Mouse-Over
|
||||
- Schoener Bestaetigungs-Dialog statt Browser-confirm()
|
||||
- Toast-Benachrichtigung statt alert()
|
||||
|
||||
**Delete-Folder API**
|
||||
- `POST /api/library/delete-folder` mit Sicherheitspruefung
|
||||
- Prueft ob Pfad unter einem Library-Pfad liegt
|
||||
- Loescht Ordner + alle DB-Eintraege (library_videos)
|
||||
- Gibt geloeschte Dateien/Ordner/DB-Eintraege zurueck
|
||||
|
||||
### Serie konvertieren
|
||||
|
||||
**Batch-Konvertierung fuer Serien**
|
||||
- Neuer Button "Serie konvertieren" im Serien-Modal
|
||||
- Modal mit Codec-Auswahl (AV1/HEVC/H.264)
|
||||
- Option: Alle neu konvertieren (auch bereits passende)
|
||||
- Option: Quelldateien nach Konvertierung loeschen
|
||||
- `POST /api/library/series/{id}/convert` API
|
||||
- `GET /api/library/series/{id}/convert-status` fuer Codec-Statistik
|
||||
|
||||
**Cleanup-Funktion fuer Serien**
|
||||
- "Alte Dateien loeschen" Button im Serien-Modal
|
||||
- Loescht alles ausser: registrierte Videos, .metadata, .nfo, Bilder
|
||||
- `POST /api/library/series/{id}/cleanup` API
|
||||
|
||||
### Server-Log System
|
||||
|
||||
**Benachrichtigungs-Glocke**
|
||||
- Glocken-Icon unten rechts auf allen Seiten
|
||||
- Badge zeigt ungelesene Fehler-Anzahl (rot)
|
||||
- Log-Panel mit allen Server-Meldungen
|
||||
- Fehler/Warnings farblich hervorgehoben
|
||||
|
||||
**Log-API**
|
||||
- `GET /api/logs?since=ID` liefert neue Log-Eintraege
|
||||
- In-Memory-Buffer (max 200 Eintraege)
|
||||
- Polling alle 2 Sekunden
|
||||
|
||||
### UI-Verbesserungen
|
||||
|
||||
**Toast-Benachrichtigungen**
|
||||
- Verbesserte Styling mit Slide-Animation
|
||||
- Farbige linke Border (success/error/info)
|
||||
- 4 Sekunden Anzeigedauer
|
||||
|
||||
**TVDB-Suche**
|
||||
- Checkbox "Englische Titel durchsuchen" im TVDB-Modal
|
||||
- Ermoeglicht Suche nach englischen Originaltiteln
|
||||
|
||||
### Technische Aenderungen
|
||||
|
||||
**Neue/Geaenderte Dateien**
|
||||
- `app/routes/library_api.py` - 6 neue Endpoints (+200 Z.)
|
||||
- `app/services/importer.py` - get_all_jobs(), Progress-Tracking (+100 Z.)
|
||||
- `app/services/queue.py` - delete_source Option bei add_paths()
|
||||
- `app/static/js/library.js` - Dialog-System, Toast, Import-Jobs (+150 Z.)
|
||||
- `app/static/css/style.css` - Toast, Delete-Button, Dialog-Styles (+50 Z.)
|
||||
- `app/templates/library.html` - Confirm-Modal, Convert-Modal (+50 Z.)
|
||||
- `app/templates/base.html` - Benachrichtigungs-Glocke + Log-Panel (+100 Z.)
|
||||
- `app/routes/api.py` - /api/logs Endpoint, WebLogHandler (+40 Z.)
|
||||
- `app/models/job.py` - delete_source Flag
|
||||
|
||||
**Neue API-Endpoints**
|
||||
- `GET /api/library/import` - Liste aller Import-Jobs
|
||||
- `POST /api/library/delete-folder` - Ordner loeschen
|
||||
- `POST /api/library/series/{id}/convert` - Serie konvertieren
|
||||
- `GET /api/library/series/{id}/convert-status` - Codec-Status
|
||||
- `POST /api/library/series/{id}/cleanup` - Alte Dateien loeschen
|
||||
- `GET /api/logs` - Server-Log abrufen
|
||||
|
||||
---
|
||||
|
||||
## [2.2.0] - 2026-02-21
|
||||
|
||||
### Bugfixes
|
||||
|
||||
**TVDB Review-Modal nicht klickbar**
|
||||
- `JSON.stringify()` erzeugte doppelte Anfuehrungszeichen in HTML onclick-Attributen
|
||||
- Neue `escapeAttr()`-Funktion ersetzt `"` durch `"` fuer sichere HTML-Attribute
|
||||
- 4 Stellen in `library.js` korrigiert (Serien, Filme, Review-Liste, manuelle Suche)
|
||||
|
||||
**Film-TVDB-Suche liefert keine Ergebnisse**
|
||||
- Filmtitel mit fuehrenden Nummern (z.B. "10 Logan The Wolverine") fanden nichts auf TVDB
|
||||
- Neue `cleanSearchTitle()`-Funktion entfernt fuehrende Nummern und Aufloesungs-Suffixe
|
||||
- Angewendet in `openMovieTvdbModal()` und `openTvdbModal()`
|
||||
|
||||
**Auto-Match Progress-Variable nicht im Scope**
|
||||
- `pollAutoMatchStatus()` referenzierte `progress` aus `startAutoMatch()`-Scope
|
||||
- Variable wird jetzt lokal in `pollAutoMatchStatus()` definiert
|
||||
|
||||
**TVDB-Sprache wurde nicht gespeichert**
|
||||
- `pages.py` HTMX-Save-Handler fehlte `tvdb_language` Feld
|
||||
- Hinzugefuegt: `settings["library"]["tvdb_language"] = data.get("tvdb_language", "deu")`
|
||||
|
||||
### Geaenderte Dateien
|
||||
- `app/static/js/library.js` - +escapeAttr(), +cleanSearchTitle(), 4x Attribut-Escaping, Progress-Fix
|
||||
- `app/routes/pages.py` - tvdb_language in Settings-Save
|
||||
|
||||
---
|
||||
|
||||
## [2.1.0] - 2026-02-21
|
||||
|
||||
### TVDB-Sprachkonfiguration
|
||||
|
||||
Alle TVDB-Metadaten (Serien-Titel, Beschreibungen, Episoden-Namen) werden jetzt
|
||||
in der konfigurierten Sprache abgerufen statt immer Englisch.
|
||||
|
||||
#### Neue Features
|
||||
- **Sprach-Dropdown** in Admin-UI (Deutsch, Englisch, Franzoesisch, Spanisch, Italienisch, Japanisch)
|
||||
- **API-Endpoints**: `GET/PUT /api/tvdb/language` zum Lesen/Aendern der Sprache
|
||||
- **Episoden-Refresh**: `POST /api/library/tvdb-refresh-episodes` aktualisiert alle gecachten Episoden
|
||||
- Alle TVDB-API-Aufrufe nutzen `self._language` Property
|
||||
|
||||
#### TVDB Review-Modal (Neues Feature)
|
||||
- Statt blindem Auto-Match werden jetzt **Vorschlaege gesammelt** und zur Pruefung angezeigt
|
||||
- `collect_suggestions()` in tvdb.py: Top 3 TVDB-Treffer pro ungematchter Serie/Film
|
||||
- Review-Modal: Poster-Vorschau, Beschreibung, Jahr, Einzelbestaetigung
|
||||
- Manuelle TVDB-Suche falls Vorschlaege nicht passen
|
||||
- Polling-basierter Fortschritt waehrend der Analyse
|
||||
|
||||
#### Film-Scanning (Neues Feature)
|
||||
- Bibliothek unterstuetzt jetzt **Filme** neben Serien
|
||||
- Neue DB-Tabelle `library_movies` fuer Film-Metadaten
|
||||
- Film-Erkennung: Ein Video pro Ordner = Film, Ordnername = Filmtitel
|
||||
- Film-Grid in der Bibliothek-UI mit Poster, Titel, Jahr
|
||||
- TVDB-Zuordnung fuer Filme (Suche + manuelle Zuordnung)
|
||||
|
||||
#### Import- und Clean-Service (Grundgeruest)
|
||||
- `app/services/importer.py` (734 Z.) - Import-Logik mit Serien-Erkennung + TVDB-Lookup
|
||||
- `app/services/cleaner.py` (155 Z.) - Junk-Scan + Loeschen von Nicht-Video-Dateien
|
||||
|
||||
#### Geaenderte Dateien
|
||||
- `app/services/tvdb.py` - _language Property, lokalisierte Suche, collect_suggestions() (298 -> 1005 Z.)
|
||||
- `app/services/library.py` - Film-Scan, _ensure_movie, _add_video_to_db (1082 -> 1747 Z.)
|
||||
- `app/routes/library_api.py` - TVDB-Language-Endpoints, Confirm-Endpoint, Film-Endpoints (260 -> 998 Z.)
|
||||
- `app/static/js/library.js` - Review-Modal, Film-Grid, Auto-Match-Polling (587 -> 1912 Z.)
|
||||
- `app/static/css/style.css` - Review-Modal CSS, Film-Grid CSS (889 -> 1554 Z.)
|
||||
- `app/templates/library.html` - Review-Modal HTML, Film-TVDB-Modal (330 -> 392 Z.)
|
||||
- `app/templates/admin.html` - TVDB-Sprach-Dropdown (330 -> 342 Z.)
|
||||
- `app/server.py` - CleanerService + ImporterService Integration
|
||||
- `app/cfg/settings.yaml` - tvdb_language, import-Settings, cleanup keep_extensions
|
||||
|
||||
#### Neue API-Endpoints
|
||||
- `GET /api/tvdb/language` - Aktuelle TVDB-Sprache
|
||||
- `PUT /api/tvdb/language` - TVDB-Sprache aendern
|
||||
- `POST /api/library/tvdb-refresh-episodes` - Alle Episoden-Caches aktualisieren
|
||||
- `POST /api/library/tvdb-auto-match` - Review-Vorschlaege sammeln
|
||||
- `POST /api/library/tvdb-confirm` - Einzelnen TVDB-Match bestaetigen
|
||||
- `GET /api/library/movies` - Filme auflisten
|
||||
- `POST /api/library/movies/{id}/tvdb-match` - Film-TVDB-Zuordnung
|
||||
|
||||
---
|
||||
|
||||
## [2.0.0] - 2026-02-20
|
||||
|
||||
### Video-Bibliothek (Neues Feature)
|
||||
|
||||
Komplette Video-Bibliotheksverwaltung mit Serien-Erkennung, ffprobe-Analyse,
|
||||
TVDB-Integration und umfangreichen Filterfunktionen.
|
||||
|
||||
#### Neue Dateien
|
||||
- `app/services/library.py` - LibraryService (Scan, DB, Filter, Duplikate)
|
||||
- `app/services/tvdb.py` - TVDBService (Auth, Suche, Episoden-Abgleich)
|
||||
- `app/routes/library_api.py` - REST API Endpoints fuer Bibliothek
|
||||
- `app/templates/library.html` - Bibliothek-Hauptseite mit Filter-Sidebar
|
||||
- `app/static/js/library.js` - Bibliothek-Frontend (Filter, TVDB, Scan-Progress)
|
||||
|
||||
#### Geaenderte Dateien
|
||||
- `app/server.py` - LibraryService + TVDBService Integration
|
||||
- `app/routes/pages.py` - Route /library + TVDB-Settings speichern
|
||||
- `app/templates/base.html` - Nav-Link "Bibliothek" hinzugefuegt
|
||||
- `app/templates/admin.html` - TVDB-Settings + Scan-Pfad-Verwaltung
|
||||
- `app/static/css/style.css` - Library-Styles (~250 Zeilen ergaenzt)
|
||||
- `app/cfg/settings.yaml` - library-Sektion (enabled, tvdb_api_key, tvdb_pin)
|
||||
- `requirements.txt` - tvdb-v4-official>=1.1.0 hinzugefuegt
|
||||
|
||||
#### Datenbank
|
||||
Vier neue Tabellen (werden automatisch beim Start erstellt):
|
||||
- `library_paths` - Konfigurierbare Scan-Pfade (Serien/Filme)
|
||||
- `library_series` - Erkannte Serien mit TVDB-Verknuepfung
|
||||
- `library_videos` - Videos mit vollstaendigen ffprobe-Metadaten
|
||||
- `tvdb_episode_cache` - TVDB-Episoden-Cache
|
||||
|
||||
#### Features im Detail
|
||||
|
||||
**Ordner-Scan**
|
||||
- Konfigurierbare Scan-Pfade fuer Serien und Filme getrennt
|
||||
- Serien-Erkennung via Ordnerstruktur (S01E01, 1x02, Season/Staffel XX)
|
||||
- ffprobe-Analyse: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR
|
||||
- Versteckte Ordner (.Trash-*) werden automatisch uebersprungen
|
||||
- UPSERT-Logik: unveraenderte Dateien werden nicht erneut analysiert
|
||||
- Scan-Fortschritt via WebSocket + Polling im UI
|
||||
- Verwaiste DB-Eintraege werden nach Scan automatisch bereinigt
|
||||
|
||||
**Filter-System**
|
||||
- Video-Codec: AV1, HEVC, H.264, MPEG-4
|
||||
- Aufloesung: 4K, 1080p, 720p, SD
|
||||
- Container: MKV, MP4, AVI, WebM, TS, WMV
|
||||
- Audio: Sprache (Deutsch, Englisch), Kanaele (Stereo, 5.1, 7.1)
|
||||
- 10-Bit Filter
|
||||
- Freitext-Suche im Dateinamen
|
||||
- Sortierung nach Name, Groesse, Aufloesung, Dauer, Codec, Datum
|
||||
- Pagination (50 Videos pro Seite)
|
||||
|
||||
**TVDB-Integration**
|
||||
- Authentifizierung via API Key + optionalem PIN
|
||||
- Serien-Suche mit Ergebnis-Vorschau (Poster, Beschreibung, Jahr)
|
||||
- Episoden-Abgleich: Soll (TVDB) vs. Ist (lokal) = fehlende Episoden
|
||||
- Poster-URLs, Beschreibung, Status (Continuing/Ended)
|
||||
- Episoden-Cache in DB (reduziert API-Aufrufe)
|
||||
|
||||
**Duplikat-Finder**
|
||||
- Erkennt gleiche Episoden in verschiedenen Formaten (z.B. AVI + WebM)
|
||||
- Vergleich ueber Serie + Staffel + Episode
|
||||
- Anzeige mit Codec, Aufloesung, Groesse fuer beide Versionen
|
||||
|
||||
**Direkt-Konvertierung**
|
||||
- "Conv"-Button bei jedem Video in der Bibliothek
|
||||
- Sendet Video direkt an die bestehende Konvertierungs-Queue
|
||||
- Optionale Preset-Auswahl
|
||||
|
||||
**Admin-UI Erweiterung**
|
||||
- TVDB API Key + PIN Eingabefelder
|
||||
- Scan-Pfad-Verwaltung (hinzufuegen, loeschen, einzeln scannen)
|
||||
- Letzter Scan-Zeitpunkt pro Pfad angezeigt
|
||||
|
||||
**Statistik-Leiste**
|
||||
- Gesamt-Videos, Serien-Anzahl, Speicherbedarf, Gesamtspielzeit
|
||||
- Codec-Verteilung, Aufloesungs-Verteilung
|
||||
|
||||
#### API Endpoints (17 neue)
|
||||
- `GET/POST/DELETE /api/library/paths` - Scan-Pfade CRUD
|
||||
- `POST /api/library/scan` - Komplett-Scan
|
||||
- `POST /api/library/scan/{id}` - Einzel-Scan
|
||||
- `GET /api/library/scan-status` - Scan-Fortschritt
|
||||
- `GET /api/library/videos` - Videos mit Filtern
|
||||
- `GET /api/library/series` - Alle Serien
|
||||
- `GET /api/library/series/{id}` - Serien-Detail mit Episoden
|
||||
- `GET /api/library/series/{id}/missing` - Fehlende Episoden
|
||||
- `POST /api/library/series/{id}/tvdb-match` - TVDB zuordnen
|
||||
- `GET /api/library/duplicates` - Duplikate finden
|
||||
- `POST /api/library/videos/{id}/convert` - Direkt konvertieren
|
||||
- `GET /api/library/stats` - Bibliotheks-Statistiken
|
||||
- `GET /api/tvdb/search?q=` - TVDB-Suche
|
||||
|
||||
#### Erster Scan-Lauf
|
||||
- 80 Serien erkannt, 5.260 Videos analysiert
|
||||
- ~3.7 TiB Gesamtgroesse, ~150 Tage Spielzeit
|
||||
- Codecs: H.264 (2.942), MPEG-4 (2.094), AV1 (199), HEVC (24)
|
||||
- Aufloesungen: SD (4.256), 720p (651), 1080p (300), 4K (52)
|
||||
- 9 Duplikat-Paare gefunden
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-02-20
|
||||
|
||||
### Komplett-Neubau
|
||||
|
||||
Vollstaendiger Neubau des VideoKonverter-Servers als moderne
|
||||
Python/aiohttp-Anwendung mit Web-UI.
|
||||
|
||||
#### Kern-Features
|
||||
- **aiohttp-Server** mit Jinja2-Templating und HTMX
|
||||
- **WebSocket** fuer Echtzeit-Fortschritts-Updates
|
||||
- **Queue-System** mit MariaDB-Persistierung und parallelen Jobs
|
||||
- **FFmpeg-Encoding** mit GPU (Intel VAAPI) und CPU Support
|
||||
- **7 Encoding-Presets**: GPU AV1/HEVC/H.264 + CPU SVT-AV1/x265/x264
|
||||
- **Dashboard** mit aktiven Jobs und Queue-Uebersicht
|
||||
- **Admin-UI** fuer Einstellungen, Presets, Encoding-Modus
|
||||
- **Statistik-Seite** mit Konvertierungs-Historie
|
||||
- **File-Browser** zum Auswaehlen von Dateien/Ordnern
|
||||
- **Docker-Support** mit GPU- und CPU-Profilen
|
||||
|
||||
#### Audio-Handling
|
||||
- Alle Spuren behalten (kein Downmix)
|
||||
- Konfigurierbare Sprach-Filter (DE, EN, Undefiniert)
|
||||
- Opus-Transcoding mit bitrate-basiertem Kanalmanagement
|
||||
- Surround-Kanaele (5.1, 7.1) bleiben erhalten
|
||||
|
||||
#### Technische Details
|
||||
- Async/Await durchgaengig (aiohttp, aiomysql, asyncio.subprocess)
|
||||
- MariaDB statt SQLite (Lock-Probleme in Docker behoben)
|
||||
- WebSocket-Broadcast fuer alle verbundenen Clients
|
||||
- Automatische GPU-Erkennung (VAAPI, vainfo)
|
||||
- Konfigurierbare Settings via YAML
|
||||
- Log-Rotation (7 Tage, 10 MiB pro Datei)
|
||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
FROM ubuntu:24.04
|
||||
|
||||
# Basis-Pakete + ffmpeg + Intel GPU Treiber
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
python3 \
|
||||
python3-pip \
|
||||
intel-opencl-icd \
|
||||
intel-media-va-driver-non-free \
|
||||
libva-drm2 \
|
||||
libva2 \
|
||||
libmfx1 \
|
||||
vainfo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Umgebungsvariablen fuer Intel GPU
|
||||
ENV LIBVA_DRIVER_NAME=iHD
|
||||
ENV LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
|
||||
|
||||
# VideoKonverter Defaults (ueberschreibbar per docker run -e / Unraid UI)
|
||||
ENV VK_DB_HOST=localhost
|
||||
ENV VK_DB_PORT=3306
|
||||
ENV VK_DB_USER=video
|
||||
ENV VK_DB_PASSWORD=""
|
||||
ENV VK_DB_NAME=video_converter
|
||||
ENV VK_MODE=cpu
|
||||
ENV VK_PORT=8080
|
||||
ENV VK_LOG_LEVEL=INFO
|
||||
|
||||
WORKDIR /opt/video-konverter
|
||||
|
||||
# Python-Abhaengigkeiten
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
|
||||
# Anwendung kopieren (aus video-konverter Unterverzeichnis)
|
||||
COPY video-konverter/__main__.py .
|
||||
COPY video-konverter/app/ ./app/
|
||||
|
||||
# Default-Konfigdateien sichern (werden beim Start ins gemountete cfg kopiert)
|
||||
RUN cp -r /opt/video-konverter/app/cfg /opt/video-konverter/cfg_defaults
|
||||
|
||||
# Daten- und Log-Verzeichnisse (beschreibbar fuer UID 1000)
|
||||
RUN mkdir -p /opt/video-konverter/data /opt/video-konverter/logs \
|
||||
&& chmod 777 /opt/video-konverter/data /opt/video-konverter/logs
|
||||
|
||||
# Entrypoint (kopiert Defaults in gemountete Volumes)
|
||||
COPY entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
# Konfiguration und Daten als Volumes
|
||||
VOLUME ["/opt/video-konverter/app/cfg", "/opt/video-konverter/data", "/opt/video-konverter/logs"]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
355
README.md
Normal file
355
README.md
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
# VideoKonverter
|
||||
|
||||
Web-basierter Video-Konverter mit GPU-Beschleunigung (Intel VAAPI), Video-Bibliotheksverwaltung und TVDB-Integration. Laeuft als Docker-Container auf Unraid oder lokal.
|
||||
|
||||
## Features
|
||||
|
||||
### Video-Konvertierung
|
||||
- **GPU-Encoding**: Intel VAAPI (AV1, AV1 10-Bit, HEVC, H.264) ueber Intel A380
|
||||
- **CPU-Encoding**: SVT-AV1, x265, x264 als Fallback
|
||||
- **AV1 10-Bit Standard**: Bessere Qualitaet bei gleichem Speed (p010 Pixel-Format)
|
||||
- **Konfigurierbare Presets**: 7 Presets (4x GPU + 3x CPU)
|
||||
- **Parallele Jobs**: Mehrere Videos gleichzeitig konvertieren
|
||||
- **Audio-Handling**: Alle Spuren behalten (DE+EN), kein Downmix, Opus-Transcoding
|
||||
- **Live-Fortschritt**: WebSocket-basierte Echtzeit-Updates im Dashboard
|
||||
- **Queue-Management**: Drag-and-Drop, Pause, Abbruch, Prioritaeten
|
||||
|
||||
### Video-Player
|
||||
- **Browser-Streaming**: Direktes Abspielen mit ffmpeg-Transcoding (EAC3/DTS/AC3 -> AAC)
|
||||
- **Play-Buttons**: In Serien-, Film- und Ordner-Ansichten
|
||||
- **Delete-Buttons**: Einzelne Videos loeschen (DB + Datei)
|
||||
|
||||
### Video-Bibliothek
|
||||
- **Ordner-Scan**: Konfigurierbare Scan-Pfade fuer Serien und Filme
|
||||
- **Serien-Erkennung**: Automatisch via Ordnerstruktur (`S01E01`, `1x02`, `Staffel/Season XX`)
|
||||
- **Doppel-Episoden**: Erkennung von Multi-Episoden-Dateien (`S01E01E02`, `S01E01-E02`, `1x01-02`)
|
||||
- **Episoden-Titel**: Automatische Extraktion aus Dateinamen (ohne Qualitaets-Tags)
|
||||
- **Film-Erkennung**: Ein Video pro Ordner = Film, Ordnername als Filmtitel
|
||||
- **ffprobe-Analyse**: Codec, Aufloesung, Bitrate, Audio-Spuren, Untertitel, HDR/10-Bit
|
||||
- **TVDB-Integration**: Serien + Filme, Poster, Episoden-Titel, fehlende Episoden
|
||||
- **TVDB Review-Modal**: Vorschlaege pruefen statt blindem Auto-Match, manuelle Suche
|
||||
- **TVDB-Sprachkonfiguration**: Metadaten in Deutsch, Englisch oder anderen Sprachen
|
||||
- **Schnellfilter**: Vordefinierte Filter (Nicht konvertiert, Alte Formate, Fehlende Episoden)
|
||||
- **Filter-Presets**: Eigene Filter speichern und als Standard setzen
|
||||
- **Fehlende Episoden**: Uebersicht aller fehlenden Episoden ueber alle Serien
|
||||
- **Filter**: Video-Codec, Aufloesung, Container, Audio-Sprache, Kanaele, 10-Bit
|
||||
- **Duplikat-Finder**: Gleiche Episode in verschiedenen Formaten erkennen
|
||||
- **Import-Service**: Videos einsortieren mit Serien-Erkennung und TVDB-Lookup
|
||||
- **Clean-Service**: Nicht-Video-Dateien (NFO, JPG, SRT etc.) finden und entfernen
|
||||
- **Direkt-Konvertierung**: Videos aus der Bibliothek direkt in die Queue senden
|
||||
|
||||
### Administration
|
||||
- **Web-UI**: Responsive Dashboard, Bibliothek, Einstellungen, Statistik
|
||||
- **Settings**: Encoding-Modus, Ziel-Container, Audio-/Untertitel-Sprachen, Cleanup
|
||||
- **TVDB-Verwaltung**: API-Key/PIN konfigurieren, Sprache waehlen, Serien + Filme zuordnen
|
||||
- **Scan-Pfad-Management**: Pfade hinzufuegen/loeschen/scannen ueber Admin-UI
|
||||
- **Statistik**: Konvertierungs-Historie mit Groessen-Ersparnis, Dauer, Codec-Verteilung
|
||||
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ Browser (Dashboard / Bibliothek / Admin / Stats) │
|
||||
├──────────────┬──────────────┬──────────────────────┤
|
||||
│ HTMX/JS │ WebSocket │ REST API │
|
||||
├──────────────┴──────────────┴──────────────────────┤
|
||||
│ aiohttp Server │
|
||||
│ ┌──────────┐ ┌────────────┐ ┌──────────────────┐ │
|
||||
│ │ Queue │ │ Encoder │ │ LibraryService │ │
|
||||
│ │ Service │ │ Service │ │ (Scan, Filter, │ │
|
||||
│ │ │ │ (ffmpeg) │ │ Serien + Filme) │ │
|
||||
│ ├──────────┤ ├────────────┤ ├──────────────────┤ │
|
||||
│ │ Scanner │ │ Probe │ │ TVDBService │ │
|
||||
│ │ Service │ │ Service │ │ (API v4, i18n) │ │
|
||||
│ ├──────────┤ │ (ffprobe) │ ├──────────────────┤ │
|
||||
│ │ Importer │ └────────────┘ │ CleanerService │ │
|
||||
│ │ Service │ │ (Junk-Scan) │ │
|
||||
│ └──────────┘ └──────────────────┘ │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ MariaDB (statistics, library_*, tvdb_episode_cache)│
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Technologie-Stack
|
||||
|
||||
| Komponente | Technologie |
|
||||
|------------|-------------|
|
||||
| Backend | Python 3.12, aiohttp (async) |
|
||||
| Templates | Jinja2 + HTMX |
|
||||
| Datenbank | MariaDB (aiomysql) |
|
||||
| Video | FFmpeg + FFprobe |
|
||||
| GPU | Intel VAAPI (iHD-Treiber) |
|
||||
| Metadaten | TVDB API v4 (tvdb-v4-official) |
|
||||
| Container | Docker (Ubuntu 24.04) |
|
||||
| Echtzeit | WebSocket |
|
||||
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
video-konverter/
|
||||
├── __main__.py # Einstiegspunkt
|
||||
├── Dockerfile # Ubuntu 24.04 + ffmpeg + Intel GPU
|
||||
├── entrypoint.sh # Default-Configs in Volumes kopieren
|
||||
├── docker-compose.yml # GPU + CPU Profile
|
||||
├── requirements.txt # Python-Abhaengigkeiten
|
||||
├── unraid/
|
||||
│ └── my-VideoKonverter.xml # Unraid Docker-Template
|
||||
├── app/
|
||||
│ ├── server.py # Haupt-Server (aiohttp Application)
|
||||
│ ├── config.py # Settings + Presets laden
|
||||
│ ├── cfg/
|
||||
│ │ ├── settings.yaml # Laufzeit-Einstellungen
|
||||
│ │ └── presets.yaml # Encoding-Presets (7 Stueck)
|
||||
│ ├── models/
|
||||
│ │ ├── media.py # MediaFile, VideoStream, AudioStream
|
||||
│ │ └── job.py # ConvertJob, JobStatus
|
||||
│ ├── services/
|
||||
│ │ ├── library.py # Bibliothek: Scan, Filter, Duplikate (1747 Z.)
|
||||
│ │ ├── tvdb.py # TVDB: Auth, Suche, Episoden, Sprache (1005 Z.)
|
||||
│ │ ├── importer.py # Import: Erkennung, TVDB-Lookup, Kopieren (734 Z.)
|
||||
│ │ ├── cleaner.py # Clean: Junk-Scan, Nicht-Video-Dateien (155 Z.)
|
||||
│ │ ├── queue.py # Job-Queue mit MariaDB-Persistierung (541 Z.)
|
||||
│ │ ├── encoder.py # FFmpeg-Wrapper (GPU + CPU)
|
||||
│ │ ├── probe.py # FFprobe-Analyse
|
||||
│ │ ├── scanner.py # Dateisystem-Scanner
|
||||
│ │ └── progress.py # Encoding-Fortschritt parsen
|
||||
│ ├── routes/
|
||||
│ │ ├── api.py # REST API (Queue, Jobs, Convert)
|
||||
│ │ ├── library_api.py # REST API (Bibliothek, TVDB, Scan) (998 Z.)
|
||||
│ │ ├── pages.py # HTML-Seiten (Dashboard, Admin, etc.)
|
||||
│ │ └── ws.py # WebSocket-Manager
|
||||
│ ├── templates/
|
||||
│ │ ├── base.html # Basis-Layout mit Navigation
|
||||
│ │ ├── dashboard.html # Queue + aktive Jobs
|
||||
│ │ ├── library.html # Bibliothek mit Filtern
|
||||
│ │ ├── admin.html # Einstellungen + TVDB + Scan-Pfade
|
||||
│ │ ├── statistics.html # Konvertierungs-Statistik
|
||||
│ │ └── partials/
|
||||
│ │ └── stats_table.html
|
||||
│ └── static/
|
||||
│ ├── css/style.css # Komplettes Styling (1554 Z.)
|
||||
│ └── js/
|
||||
│ ├── library.js # Bibliothek-UI (1912 Z.)
|
||||
│ ├── websocket.js # WebSocket-Client
|
||||
│ └── filebrowser.js # Datei-Browser
|
||||
├── data/ # Queue-Persistierung
|
||||
├── logs/ # Server-Logs
|
||||
└── testmedia/ # Test-Dateien
|
||||
```
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
### Voraussetzungen
|
||||
- Docker + Docker Compose
|
||||
- MariaDB-Server (extern, z.B. auf Unraid)
|
||||
- Optional: Intel GPU fuer Hardware-Encoding (Intel A380 empfohlen)
|
||||
|
||||
### MariaDB einrichten
|
||||
```sql
|
||||
CREATE DATABASE video_converter CHARACTER SET utf8mb4;
|
||||
CREATE USER 'video'@'%' IDENTIFIED BY 'dein_passwort';
|
||||
GRANT ALL PRIVILEGES ON video_converter.* TO 'video'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
Die Tabellen werden automatisch beim ersten Start erstellt.
|
||||
|
||||
### Unraid-Installation
|
||||
|
||||
1. Docker-Image laden (Unraid Web-Terminal):
|
||||
```bash
|
||||
docker load -i /mnt/user/downloads/videokonverter-cpu.tar
|
||||
```
|
||||
|
||||
2. XML-Template kopieren:
|
||||
```bash
|
||||
cp /mnt/user/downloads/my-VideoKonverter.xml /boot/config/plugins/dockerMan/templates-user/
|
||||
```
|
||||
|
||||
3. In der Unraid Docker-WebGUI: "Container hinzufuegen" → Template waehlen → Werte anpassen → Starten
|
||||
|
||||
Das Template enthält alle Variablen mit korrekten Defaults.
|
||||
|
||||
### Konfiguration
|
||||
|
||||
Alle Einstellungen sind per **Umgebungsvariablen** (VK_*) konfigurierbar.
|
||||
ENV-Variablen ueberschreiben immer die `settings.yaml`.
|
||||
|
||||
| Variable | Default | Beschreibung |
|
||||
|----------|---------|-------------|
|
||||
| `VK_DB_HOST` | `192.168.155.11` | MariaDB Host |
|
||||
| `VK_DB_PORT` | `3306` | MariaDB Port |
|
||||
| `VK_DB_USER` | `video` | MariaDB Benutzer |
|
||||
| `VK_DB_PASSWORD` | - | MariaDB Passwort |
|
||||
| `VK_DB_NAME` | `video_converter` | Datenbank-Name |
|
||||
| `VK_MODE` | `cpu` | Encoding-Modus: `gpu`, `cpu`, `auto` |
|
||||
| `VK_GPU_DEVICE` | `/dev/dri/renderD129` | GPU Render-Device |
|
||||
| `VK_DEFAULT_PRESET` | `gpu_av1_10bit` | Standard Encoding-Preset |
|
||||
| `VK_MAX_JOBS` | `1` | Max. parallele Jobs |
|
||||
| `VK_TVDB_API_KEY` | - | TVDB API Key |
|
||||
| `VK_TVDB_LANGUAGE` | `deu` | TVDB Sprache |
|
||||
| `VK_LOG_LEVEL` | `INFO` | Log-Level |
|
||||
|
||||
Alternativ kann `app/cfg/settings.yaml` direkt bearbeitet werden.
|
||||
Bei Erstinstallation werden Default-Konfigdateien automatisch ins cfg-Volume kopiert.
|
||||
|
||||
### GPU-Device ermitteln
|
||||
|
||||
Auf dem Host pruefen welches renderD* die Intel GPU ist:
|
||||
```bash
|
||||
cat /sys/class/drm/renderD*/device/uevent | grep -B1 DRIVER
|
||||
```
|
||||
Beispiel: `renderD128` = AMD, `renderD129` = Intel → `VK_GPU_DEVICE=/dev/dri/renderD129`
|
||||
|
||||
### Starten
|
||||
|
||||
**GPU-Modus** (Produktion auf Unraid):
|
||||
```bash
|
||||
docker compose --profile gpu up --build -d
|
||||
```
|
||||
|
||||
**CPU-Modus** (lokal testen):
|
||||
```bash
|
||||
PUID=1000 PGID=1000 docker compose --profile cpu up --build -d
|
||||
```
|
||||
|
||||
Web-UI: http://localhost:8080
|
||||
|
||||
|
||||
## Encoding-Presets
|
||||
|
||||
| Preset | Codec | Container | Qualitaet | Modus |
|
||||
|--------|-------|-----------|-----------|-------|
|
||||
| GPU AV1 | av1_vaapi | WebM | QP 30 | GPU |
|
||||
| GPU AV1 10-Bit | av1_vaapi | WebM | QP 30 | GPU |
|
||||
| GPU HEVC | hevc_vaapi | MKV | QP 28 | GPU |
|
||||
| GPU H.264 | h264_vaapi | MP4 | QP 23 | GPU |
|
||||
| CPU AV1/SVT | libsvtav1 | WebM | CRF 30 | CPU |
|
||||
| CPU HEVC/x265 | libx265 | MKV | CRF 28 | CPU |
|
||||
| CPU H.264/x264 | libx264 | MP4 | CRF 23 | CPU |
|
||||
|
||||
|
||||
## API Referenz
|
||||
|
||||
### Konvertierung
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| POST | `/api/convert` | Dateien/Ordner zur Queue hinzufuegen |
|
||||
| GET | `/api/queue` | Queue-Status abrufen |
|
||||
| DELETE | `/api/jobs/{id}` | Job entfernen/abbrechen |
|
||||
|
||||
### Bibliothek
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/api/library/paths` | Scan-Pfade auflisten |
|
||||
| POST | `/api/library/paths` | Scan-Pfad hinzufuegen |
|
||||
| DELETE | `/api/library/paths/{id}` | Scan-Pfad loeschen |
|
||||
| POST | `/api/library/scan` | Alle Pfade scannen |
|
||||
| POST | `/api/library/scan/{id}` | Einzelnen Pfad scannen |
|
||||
| GET | `/api/library/scan-status` | Scan-Fortschritt |
|
||||
| GET | `/api/library/videos` | Videos filtern (siehe Filter-Params) |
|
||||
| GET | `/api/library/series` | Alle Serien |
|
||||
| GET | `/api/library/series/{id}` | Serie mit Episoden |
|
||||
| GET | `/api/library/series/{id}/missing` | Fehlende Episoden einer Serie |
|
||||
| GET | `/api/library/missing-episodes` | Alle fehlenden Episoden (paginated) |
|
||||
| GET | `/api/library/filter-presets` | Filter-Presets laden |
|
||||
| POST | `/api/library/filter-presets` | Neues Preset speichern |
|
||||
| PUT | `/api/library/filter-presets` | Presets aktualisieren |
|
||||
| DELETE | `/api/library/filter-presets/{id}` | Preset loeschen |
|
||||
| PUT | `/api/library/default-view` | Standard-Ansicht setzen |
|
||||
| POST | `/api/library/series/{id}/tvdb-match` | TVDB-ID zuordnen |
|
||||
| POST | `/api/library/series/{id}/convert` | Alle Episoden konvertieren |
|
||||
| GET | `/api/library/series/{id}/convert-status` | Codec-Status der Serie |
|
||||
| POST | `/api/library/series/{id}/cleanup` | Alte Dateien loeschen |
|
||||
| GET | `/api/library/duplicates` | Duplikate finden |
|
||||
| POST | `/api/library/videos/{id}/convert` | Direkt konvertieren |
|
||||
| POST | `/api/library/delete-folder` | Ordner komplett loeschen |
|
||||
| GET | `/api/library/stats` | Bibliotheks-Statistiken |
|
||||
| GET | `/api/library/movies` | Filme auflisten |
|
||||
| POST | `/api/library/movies/{id}/tvdb-match` | Film-TVDB-Zuordnung |
|
||||
| POST | `/api/library/tvdb-auto-match` | Review-Vorschlaege sammeln |
|
||||
| POST | `/api/library/tvdb-confirm` | TVDB-Match bestaetigen |
|
||||
| POST | `/api/library/tvdb-refresh-episodes` | Episoden-Cache aktualisieren |
|
||||
| GET | `/api/tvdb/search?q=` | TVDB-Suche |
|
||||
| GET | `/api/tvdb/language` | TVDB-Sprache lesen |
|
||||
| PUT | `/api/tvdb/language` | TVDB-Sprache aendern |
|
||||
|
||||
### Streaming
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/api/library/videos/{id}/stream` | Video-Transcoding-Stream (ffmpeg pipe) |
|
||||
| DELETE | `/api/library/videos/{id}` | Video loeschen (DB + Datei) |
|
||||
|
||||
### Import
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/api/library/import` | Alle Import-Jobs auflisten |
|
||||
| POST | `/api/library/import` | Neuen Import-Job erstellen |
|
||||
| GET | `/api/library/import/{id}` | Import-Job Status mit Items |
|
||||
| POST | `/api/library/import/{id}/analyze` | Import analysieren |
|
||||
| POST | `/api/library/import/{id}/execute` | Import ausfuehren |
|
||||
| POST | `/api/library/import/{id}/assign` | Item manuell zuordnen |
|
||||
| POST | `/api/library/import/{id}/skip` | Item ueberspringen |
|
||||
|
||||
### System
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|-------------|
|
||||
| GET | `/api/logs` | Server-Logs abrufen |
|
||||
| GET | `/api/system` | System-Info (GPU, Jobs) |
|
||||
|
||||
### Video-Filter (`/api/library/videos`)
|
||||
```
|
||||
?video_codec=hevc # h264, hevc, av1, mpeg4
|
||||
&min_width=1920 # Mindest-Aufloesung
|
||||
&container=mkv # mkv, mp4, avi, webm
|
||||
&audio_lang=ger # Audio-Sprache
|
||||
&audio_channels=6 # Kanal-Anzahl (2=Stereo, 6=5.1)
|
||||
&is_10bit=1 # Nur 10-Bit
|
||||
¬_converted=1 # Nicht im Zielformat (Container + Codec)
|
||||
&exclude_codec=av1 # Codec ausschliessen
|
||||
&exclude_container=webm # Container ausschliessen
|
||||
&search=breaking # Dateiname-Suche
|
||||
&sort=file_size # Sortierung
|
||||
&order=desc # asc | desc
|
||||
&page=1&limit=50 # Pagination
|
||||
```
|
||||
|
||||
|
||||
## Datenbank-Schema
|
||||
|
||||
### library_paths
|
||||
Konfigurierte Scan-Pfade fuer Serien- und Film-Ordner.
|
||||
|
||||
### library_series
|
||||
Erkannte Serien mit optionaler TVDB-Verknuepfung (Poster, Beschreibung, Episoden-Zaehler).
|
||||
|
||||
### library_movies
|
||||
Erkannte Filme mit optionaler TVDB-Verknuepfung (Poster, Beschreibung, Jahr).
|
||||
|
||||
### library_videos
|
||||
Jedes Video mit vollstaendigen ffprobe-Metadaten:
|
||||
- Video: Codec, Aufloesung, Framerate, Bitrate, 10-Bit, HDR
|
||||
- Audio: JSON-Array mit Spuren (`[{"codec":"eac3","lang":"ger","channels":6,"bitrate":256000}]`)
|
||||
- Untertitel: JSON-Array (`[{"codec":"subrip","lang":"ger"}]`)
|
||||
- Serien: Staffel/Episode-Nummer, episode_end (fuer Doppel-Episoden), Episoden-Titel
|
||||
|
||||
### tvdb_episode_cache
|
||||
Zwischenspeicher fuer TVDB-Episodendaten (Serie, Staffel, Episode, Name, Ausstrahlung).
|
||||
|
||||
|
||||
## Docker Volumes
|
||||
|
||||
| Volume (Host) | Container-Pfad | Beschreibung |
|
||||
|----------------|---------------|-------------|
|
||||
| `./app/cfg` (lokal) / `/mnt/user/appdata/videokonverter/cfg` (Unraid) | `/opt/video-konverter/app/cfg` | Konfiguration (persistent, Defaults werden automatisch kopiert) |
|
||||
| `./data` (lokal) / `/mnt/user/appdata/videokonverter/data` (Unraid) | `/opt/video-konverter/data` | Queue-Persistierung |
|
||||
| `./logs` (lokal) / `/mnt/user/appdata/videokonverter/logs` (Unraid) | `/opt/video-konverter/logs` | Server-Logs |
|
||||
| `/mnt` (lokal) / `/mnt/user` (Unraid) | `/mnt` | Medien-Pfade |
|
||||
| `/dev/dri` | `/dev/dri` | GPU-Devices (fuer VAAPI) |
|
||||
|
||||
|
||||
## Lizenz
|
||||
|
||||
Privates Projekt von Eddy (Eduard Wisch).
|
||||
89
docker-compose.yml
Normal file
89
docker-compose.yml
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
services:
|
||||
# === GPU-Modus (Produktion auf Unraid) ===
|
||||
# Starten mit: docker compose --profile gpu up --build
|
||||
# Unraid: nobody:users = 99:100
|
||||
video-konverter:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: video-konverter
|
||||
restart: unless-stopped
|
||||
user: "${PUID:-99}:${PGID:-100}"
|
||||
ports:
|
||||
- "${VK_PORT:-8080}:8080"
|
||||
volumes:
|
||||
# Konfiguration (persistent)
|
||||
- ./video-konverter/app/cfg:/opt/video-konverter/app/cfg
|
||||
# Daten (Queue-Persistierung)
|
||||
- ./data:/opt/video-konverter/data
|
||||
# Logs
|
||||
- ./logs:/opt/video-konverter/logs
|
||||
# /mnt 1:1 durchreichen - Pfade von Dolphin stimmen dann im Container
|
||||
- /mnt:/mnt:rw
|
||||
devices:
|
||||
# Alle GPUs durchreichen - VK_GPU_DEVICE waehlt die richtige
|
||||
- /dev/dri:/dev/dri
|
||||
group_add:
|
||||
- "video"
|
||||
environment:
|
||||
# GPU-Treiber
|
||||
- LIBVA_DRIVER_NAME=iHD
|
||||
- LIBVA_DRIVERS_PATH=/usr/lib/x86_64-linux-gnu/dri
|
||||
# === VideoKonverter Konfiguration (VK_*) ===
|
||||
# Alle Werte ueberschreiben die settings.yaml
|
||||
# Datenbank
|
||||
- VK_DB_HOST=${VK_DB_HOST:-192.168.155.11}
|
||||
- VK_DB_PORT=${VK_DB_PORT:-3306}
|
||||
- VK_DB_USER=${VK_DB_USER:-video}
|
||||
- VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715}
|
||||
- VK_DB_NAME=${VK_DB_NAME:-video_converter}
|
||||
# Encoding
|
||||
- VK_MODE=gpu
|
||||
- VK_GPU_DEVICE=${VK_GPU_DEVICE:-/dev/dri/renderD129}
|
||||
- VK_MAX_JOBS=${VK_MAX_JOBS:-1}
|
||||
- VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-gpu_av1_10bit}
|
||||
# Library / TVDB
|
||||
- VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-}
|
||||
- VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu}
|
||||
# Logging
|
||||
- VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO}
|
||||
profiles:
|
||||
- gpu
|
||||
|
||||
# === CPU-Modus (lokales Testen ohne GPU) ===
|
||||
# Starten mit: docker compose --profile cpu up --build
|
||||
# Lokal: CIFS-Mount nutzt UID 1000, daher PUID/PGID ueberschreiben:
|
||||
# PUID=1000 PGID=1000 docker compose --profile cpu up --build
|
||||
video-konverter-cpu:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: video-konverter-cpu
|
||||
user: "${PUID:-99}:${PGID:-100}"
|
||||
ports:
|
||||
- "${VK_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./video-konverter/app/cfg:/opt/video-konverter/app/cfg
|
||||
- ./data:/opt/video-konverter/data
|
||||
- ./logs:/opt/video-konverter/logs
|
||||
# /mnt 1:1 durchreichen - Pfade identisch zum Host
|
||||
- /mnt:/mnt:rw
|
||||
environment:
|
||||
# === VideoKonverter Konfiguration (VK_*) ===
|
||||
# Datenbank
|
||||
- VK_DB_HOST=${VK_DB_HOST:-192.168.155.11}
|
||||
- VK_DB_PORT=${VK_DB_PORT:-3306}
|
||||
- VK_DB_USER=${VK_DB_USER:-video}
|
||||
- VK_DB_PASSWORD=${VK_DB_PASSWORD:-8715}
|
||||
- VK_DB_NAME=${VK_DB_NAME:-video_converter}
|
||||
# Encoding
|
||||
- VK_MODE=cpu
|
||||
- VK_MAX_JOBS=${VK_MAX_JOBS:-1}
|
||||
- VK_DEFAULT_PRESET=${VK_DEFAULT_PRESET:-cpu_av1}
|
||||
# Library / TVDB
|
||||
- VK_TVDB_API_KEY=${VK_TVDB_API_KEY:-}
|
||||
- VK_TVDB_LANGUAGE=${VK_TVDB_LANGUAGE:-deu}
|
||||
# Logging
|
||||
- VK_LOG_LEVEL=${VK_LOG_LEVEL:-INFO}
|
||||
profiles:
|
||||
- cpu
|
||||
14
video-konverter/__main__.py
Normal file
14
video-konverter/__main__.py
Normal 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
video-konverter/app/__init__.py
Normal file
0
video-konverter/app/__init__.py
Normal file
333
video-konverter/app/config.py
Normal file
333
video-konverter/app/config.py
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
"""Konfigurationsmanagement - Singleton fuer Settings und Presets
|
||||
|
||||
Alle wichtigen Settings koennen per Umgebungsvariable ueberschrieben werden.
|
||||
ENV-Variablen haben IMMER Vorrang vor settings.yaml.
|
||||
|
||||
Mapping (VK_ Prefix):
|
||||
Datenbank: VK_DB_HOST, VK_DB_PORT, VK_DB_USER, VK_DB_PASSWORD, VK_DB_NAME
|
||||
Encoding: VK_MODE (cpu/gpu/auto), VK_GPU_DEVICE, VK_MAX_JOBS, VK_DEFAULT_PRESET
|
||||
Server: VK_PORT, VK_HOST, VK_EXTERNAL_URL
|
||||
Library: VK_TVDB_API_KEY, VK_TVDB_LANGUAGE, VK_LIBRARY_ENABLED (true/false)
|
||||
Dateien: VK_TARGET_CONTAINER (webm/mkv/mp4)
|
||||
Logging: VK_LOG_LEVEL (DEBUG/INFO/WARNING/ERROR)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from logging.handlers import TimedRotatingFileHandler, RotatingFileHandler
|
||||
|
||||
# Mapping: ENV-Variable -> (settings-pfad, typ)
|
||||
# Pfad als Tuple: ("section", "key")
|
||||
_ENV_MAP: dict[str, tuple[tuple[str, str], type]] = {
|
||||
"VK_DB_HOST": (("database", "host"), str),
|
||||
"VK_DB_PORT": (("database", "port"), int),
|
||||
"VK_DB_USER": (("database", "user"), str),
|
||||
"VK_DB_PASSWORD": (("database", "password"), str),
|
||||
"VK_DB_NAME": (("database", "database"), str),
|
||||
"VK_MODE": (("encoding", "mode"), str),
|
||||
"VK_GPU_DEVICE": (("encoding", "gpu_device"), str),
|
||||
"VK_MAX_JOBS": (("encoding", "max_parallel_jobs"), int),
|
||||
"VK_DEFAULT_PRESET": (("encoding", "default_preset"), str),
|
||||
"VK_PORT": (("server", "port"), int),
|
||||
"VK_HOST": (("server", "host"), str),
|
||||
"VK_EXTERNAL_URL": (("server", "external_url"), str),
|
||||
"VK_TVDB_API_KEY": (("library", "tvdb_api_key"), str),
|
||||
"VK_TVDB_LANGUAGE": (("library", "tvdb_language"), str),
|
||||
"VK_LIBRARY_ENABLED": (("library", "enabled"), bool),
|
||||
"VK_TARGET_CONTAINER": (("files", "target_container"), str),
|
||||
"VK_LOG_LEVEL": (("logging", "level"), str),
|
||||
}
|
||||
|
||||
# Rueckwaertskompatibilitaet
|
||||
_ENV_ALIASES: dict[str, str] = {
|
||||
"VIDEO_KONVERTER_MODE": "VK_MODE",
|
||||
}
|
||||
|
||||
# Default-Settings wenn keine settings.yaml existiert
|
||||
_DEFAULT_SETTINGS: dict = {
|
||||
"database": {
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"user": "video",
|
||||
"password": "",
|
||||
"database": "video_converter",
|
||||
},
|
||||
"encoding": {
|
||||
"mode": "cpu",
|
||||
"default_preset": "cpu_av1",
|
||||
"gpu_device": "/dev/dri/renderD128",
|
||||
"gpu_driver": "iHD",
|
||||
"max_parallel_jobs": 1,
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 8080,
|
||||
"external_url": "",
|
||||
"use_https": False,
|
||||
"websocket_path": "/ws",
|
||||
},
|
||||
"files": {
|
||||
"delete_source": False,
|
||||
"recursive_scan": True,
|
||||
"scan_extensions": [".mkv", ".mp4", ".avi", ".wmv", ".vob", ".ts", ".m4v", ".flv", ".mov"],
|
||||
"target_container": "webm",
|
||||
"target_folder": "same",
|
||||
},
|
||||
"audio": {
|
||||
"bitrate_map": {2: "128k", 6: "320k", 8: "450k"},
|
||||
"default_bitrate": "192k",
|
||||
"default_codec": "libopus",
|
||||
"keep_channels": True,
|
||||
"languages": ["ger", "eng", "und"],
|
||||
},
|
||||
"subtitle": {
|
||||
"codec_blacklist": ["hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle"],
|
||||
"languages": ["ger", "eng"],
|
||||
},
|
||||
"library": {
|
||||
"enabled": True,
|
||||
"import_default_mode": "copy",
|
||||
"import_naming_pattern": "{series} - S{season:02d}E{episode:02d} - {title}.{ext}",
|
||||
"import_season_pattern": "Season {season:02d}",
|
||||
"scan_interval_hours": 0,
|
||||
"tvdb_api_key": "",
|
||||
"tvdb_language": "deu",
|
||||
"tvdb_pin": "",
|
||||
},
|
||||
"cleanup": {
|
||||
"enabled": False,
|
||||
"delete_extensions": [".avi", ".wmv", ".vob", ".nfo", ".txt", ".jpg", ".png", ".srt", ".sub", ".idx"],
|
||||
"keep_extensions": [".srt"],
|
||||
"exclude_patterns": ["readme*", "*.md"],
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
"file": "server.log",
|
||||
"rotation": "time",
|
||||
"backup_count": 7,
|
||||
"max_size_mb": 10,
|
||||
},
|
||||
"statistics": {
|
||||
"cleanup_days": 365,
|
||||
"max_entries": 5000,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Config:
|
||||
"""Laedt und verwaltet settings.yaml und presets.yaml.
|
||||
ENV-Variablen (VK_*) ueberschreiben YAML-Werte."""
|
||||
_instance: Optional['Config'] = None
|
||||
|
||||
def __new__(cls) -> 'Config':
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
self._base_path = Path(__file__).parent
|
||||
self._cfg_path = self._base_path / "cfg"
|
||||
self._log_path = self._base_path.parent / "logs"
|
||||
self._data_path = self._base_path.parent / "data"
|
||||
|
||||
# Verzeichnisse sicherstellen
|
||||
self._cfg_path.mkdir(parents=True, exist_ok=True)
|
||||
self._log_path.mkdir(parents=True, exist_ok=True)
|
||||
self._data_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.settings: dict = {}
|
||||
self.presets: dict = {}
|
||||
self._load_settings()
|
||||
self._load_presets()
|
||||
self._apply_env_overrides()
|
||||
|
||||
@property
|
||||
def log_file_path(self) -> str:
|
||||
"""Pfad zur aktiven Log-Datei"""
|
||||
log_file = self.settings.get("logging", {}).get("file", "server.log")
|
||||
return str(self._log_path / log_file)
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
"""Laedt settings.yaml oder erzeugt Defaults"""
|
||||
import copy
|
||||
settings_file = self._cfg_path / "settings.yaml"
|
||||
if settings_file.exists():
|
||||
try:
|
||||
with open(settings_file, "r", encoding="utf-8") as f:
|
||||
self.settings = yaml.safe_load(f) or {}
|
||||
logging.info(f"Settings geladen: {settings_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"Settings lesen fehlgeschlagen: {e}")
|
||||
self.settings = copy.deepcopy(_DEFAULT_SETTINGS)
|
||||
else:
|
||||
# Keine settings.yaml -> Defaults verwenden und speichern
|
||||
logging.info("Keine settings.yaml gefunden - erzeuge Defaults")
|
||||
self.settings = copy.deepcopy(_DEFAULT_SETTINGS)
|
||||
self._save_yaml(settings_file, self.settings)
|
||||
|
||||
def _load_presets(self) -> None:
|
||||
"""Laedt presets.yaml. Falls nicht vorhanden, aus cfg_defaults kopieren."""
|
||||
presets_file = self._cfg_path / "presets.yaml"
|
||||
if not presets_file.exists():
|
||||
# Versuche Default-Presets aus cfg_defaults zu kopieren
|
||||
defaults_file = Path(__file__).parent.parent / "cfg_defaults" / "presets.yaml"
|
||||
if defaults_file.exists():
|
||||
import shutil
|
||||
shutil.copy2(defaults_file, presets_file)
|
||||
logging.info(f"Default-Presets kopiert: {defaults_file} -> {presets_file}")
|
||||
else:
|
||||
logging.warning("Keine presets.yaml und keine Defaults gefunden")
|
||||
|
||||
if presets_file.exists():
|
||||
try:
|
||||
with open(presets_file, "r", encoding="utf-8") as f:
|
||||
self.presets = yaml.safe_load(f) or {}
|
||||
logging.info(f"Presets geladen: {presets_file}")
|
||||
except Exception as e:
|
||||
logging.error(f"Presets lesen fehlgeschlagen: {e}")
|
||||
self.presets = {}
|
||||
else:
|
||||
self.presets = {}
|
||||
|
||||
def _apply_env_overrides(self) -> None:
|
||||
"""Umgebungsvariablen (VK_*) ueberschreiben Settings.
|
||||
Unterstuetzt auch alte Variablennamen per Alias-Mapping."""
|
||||
applied = []
|
||||
|
||||
# Aliase aufloesen (z.B. VIDEO_KONVERTER_MODE -> VK_MODE)
|
||||
for old_name, new_name in _ENV_ALIASES.items():
|
||||
if old_name in os.environ and new_name not in os.environ:
|
||||
os.environ[new_name] = os.environ[old_name]
|
||||
|
||||
for env_key, ((section, key), val_type) in _ENV_MAP.items():
|
||||
raw = os.environ.get(env_key)
|
||||
if raw is None:
|
||||
continue
|
||||
|
||||
# Typ-Konvertierung
|
||||
try:
|
||||
if val_type is bool:
|
||||
value = raw.lower() in ("true", "1", "yes", "on")
|
||||
elif val_type is int:
|
||||
value = int(raw)
|
||||
else:
|
||||
value = raw
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(f"ENV {env_key}={raw!r} - ungueliger Wert, uebersprungen")
|
||||
continue
|
||||
|
||||
self.settings.setdefault(section, {})[key] = value
|
||||
applied.append(f"{env_key}={value}")
|
||||
|
||||
if applied:
|
||||
logging.info(f"ENV-Overrides angewendet: {', '.join(applied)}")
|
||||
|
||||
@staticmethod
|
||||
def _save_yaml(path: Path, data: dict) -> None:
|
||||
"""Schreibt dict als YAML in Datei"""
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, default_flow_style=False,
|
||||
indent=2, allow_unicode=True)
|
||||
logging.info(f"YAML gespeichert: {path}")
|
||||
except Exception as e:
|
||||
logging.error(f"YAML speichern fehlgeschlagen ({path}): {e}")
|
||||
|
||||
def save_settings(self) -> None:
|
||||
"""Schreibt aktuelle Settings zurueck in settings.yaml"""
|
||||
self._save_yaml(self._cfg_path / "settings.yaml", self.settings)
|
||||
|
||||
def save_presets(self) -> None:
|
||||
"""Schreibt Presets zurueck in presets.yaml"""
|
||||
self._save_yaml(self._cfg_path / "presets.yaml", self.presets)
|
||||
|
||||
def setup_logging(self) -> None:
|
||||
"""Konfiguriert Logging mit Rotation"""
|
||||
log_cfg = self.settings.get("logging", {})
|
||||
log_level = log_cfg.get("level", "INFO")
|
||||
log_file = log_cfg.get("file", "server.log")
|
||||
log_mode = log_cfg.get("rotation", "time")
|
||||
backup_count = log_cfg.get("backup_count", 7)
|
||||
|
||||
log_path = self._log_path / log_file
|
||||
handlers = [logging.StreamHandler()]
|
||||
|
||||
if log_mode == "time":
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
str(log_path), when="midnight", interval=1,
|
||||
backupCount=backup_count, encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
max_bytes = log_cfg.get("max_size_mb", 10) * 1024 * 1024
|
||||
file_handler = RotatingFileHandler(
|
||||
str(log_path), maxBytes=max_bytes,
|
||||
backupCount=backup_count, encoding="utf-8"
|
||||
)
|
||||
|
||||
handlers.append(file_handler)
|
||||
|
||||
# force=True weil Config.__init__ logging aufruft bevor setup_logging()
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level, logging.INFO),
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=handlers,
|
||||
force=True,
|
||||
)
|
||||
|
||||
# --- Properties fuer haeufig benoetigte Werte ---
|
||||
|
||||
@property
|
||||
def encoding_mode(self) -> str:
|
||||
return self.settings.get("encoding", {}).get("mode", "cpu")
|
||||
|
||||
@property
|
||||
def gpu_device(self) -> str:
|
||||
return self.settings.get("encoding", {}).get("gpu_device", "/dev/dri/renderD128")
|
||||
|
||||
@property
|
||||
def max_parallel_jobs(self) -> int:
|
||||
return self.settings.get("encoding", {}).get("max_parallel_jobs", 1)
|
||||
|
||||
@property
|
||||
def target_container(self) -> str:
|
||||
return self.settings.get("files", {}).get("target_container", "webm")
|
||||
|
||||
@property
|
||||
def default_preset_name(self) -> str:
|
||||
return self.settings.get("encoding", {}).get("default_preset", "cpu_av1")
|
||||
|
||||
@property
|
||||
def default_preset(self) -> dict:
|
||||
name = self.default_preset_name
|
||||
return self.presets.get(name, {})
|
||||
|
||||
@property
|
||||
def data_path(self) -> Path:
|
||||
return self._data_path
|
||||
|
||||
@property
|
||||
def audio_config(self) -> dict:
|
||||
return self.settings.get("audio", {})
|
||||
|
||||
@property
|
||||
def subtitle_config(self) -> dict:
|
||||
return self.settings.get("subtitle", {})
|
||||
|
||||
@property
|
||||
def files_config(self) -> dict:
|
||||
return self.settings.get("files", {})
|
||||
|
||||
@property
|
||||
def cleanup_config(self) -> dict:
|
||||
return self.settings.get("cleanup", {})
|
||||
|
||||
@property
|
||||
def server_config(self) -> dict:
|
||||
return self.settings.get("server", {})
|
||||
0
video-konverter/app/models/__init__.py
Normal file
0
video-konverter/app/models/__init__.py
Normal file
205
video-konverter/app/models/job.py
Normal file
205
video-konverter/app/models/job.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Konvertierungs-Job-Modell mit Status-Management"""
|
||||
import time
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Optional
|
||||
from app.models.media import MediaFile
|
||||
|
||||
# Globaler Zaehler fuer eindeutige IDs
|
||||
_id_counter = 0
|
||||
|
||||
|
||||
class JobStatus(IntEnum):
|
||||
QUEUED = 0
|
||||
ACTIVE = 1
|
||||
FINISHED = 2
|
||||
FAILED = 3
|
||||
CANCELLED = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionJob:
|
||||
"""Einzelner Konvertierungs-Auftrag"""
|
||||
media: MediaFile
|
||||
preset_name: str = ""
|
||||
|
||||
# Wird in __post_init__ gesetzt
|
||||
id: int = field(init=False)
|
||||
status: JobStatus = field(default=JobStatus.QUEUED)
|
||||
|
||||
# Ziel-Informationen
|
||||
target_path: str = ""
|
||||
target_filename: str = ""
|
||||
target_container: str = "webm"
|
||||
|
||||
# Optionen
|
||||
delete_source: bool = False # Quelldatei nach Konvertierung loeschen
|
||||
|
||||
# ffmpeg Prozess
|
||||
ffmpeg_cmd: list[str] = field(default_factory=list)
|
||||
process: Optional[asyncio.subprocess.Process] = field(default=None, repr=False)
|
||||
task: Optional[asyncio.Task] = field(default=None, repr=False)
|
||||
|
||||
# Fortschritt
|
||||
progress_percent: float = 0.0
|
||||
progress_fps: float = 0.0
|
||||
progress_speed: float = 0.0
|
||||
progress_bitrate: int = 0
|
||||
progress_size_bytes: int = 0
|
||||
progress_time_sec: float = 0.0
|
||||
progress_frames: int = 0
|
||||
progress_eta_sec: float = 0.0
|
||||
|
||||
# Zeitstempel
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: Optional[float] = None
|
||||
finished_at: Optional[float] = None
|
||||
|
||||
# Statistik-Akkumulation (Summe, Anzahl)
|
||||
_stat_fps: list = field(default_factory=lambda: [0.0, 0])
|
||||
_stat_speed: list = field(default_factory=lambda: [0.0, 0])
|
||||
_stat_bitrate: list = field(default_factory=lambda: [0, 0])
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
global _id_counter
|
||||
self.id = int(time.time() * 1000) * 1000 + _id_counter
|
||||
_id_counter += 1
|
||||
|
||||
def build_target_path(self, config) -> None:
|
||||
"""Baut Ziel-Pfad basierend auf Config"""
|
||||
files_cfg = config.files_config
|
||||
container = files_cfg.get("target_container", "webm")
|
||||
self.target_container = container
|
||||
|
||||
# Dateiname ohne Extension + neue Extension
|
||||
base_name = self.media.source_filename.rsplit(".", 1)[0]
|
||||
self.target_filename = f"{base_name}.{container}"
|
||||
|
||||
# Ziel-Ordner
|
||||
target_folder = files_cfg.get("target_folder", "same")
|
||||
if target_folder == "same":
|
||||
target_dir = self.media.source_dir
|
||||
else:
|
||||
target_dir = target_folder
|
||||
|
||||
self.target_path = f"{target_dir}/{self.target_filename}"
|
||||
|
||||
# Konfliktvermeidung: Wenn Ziel = Quelle (gleiche Extension)
|
||||
if self.target_path == self.media.source_path:
|
||||
base = self.target_path.rsplit(".", 1)[0]
|
||||
self.target_path = f"{base}_converted.{container}"
|
||||
self.target_filename = f"{base_name}_converted.{container}"
|
||||
|
||||
def update_stats(self, fps: float, speed: float, bitrate: int) -> None:
|
||||
"""Akkumuliert Statistik-Werte fuer Durchschnittsberechnung"""
|
||||
if fps > 0:
|
||||
self._stat_fps[0] += fps
|
||||
self._stat_fps[1] += 1
|
||||
if speed > 0:
|
||||
self._stat_speed[0] += speed
|
||||
self._stat_speed[1] += 1
|
||||
if bitrate > 0:
|
||||
self._stat_bitrate[0] += bitrate
|
||||
self._stat_bitrate[1] += 1
|
||||
|
||||
@property
|
||||
def avg_fps(self) -> float:
|
||||
if self._stat_fps[1] > 0:
|
||||
return self._stat_fps[0] / self._stat_fps[1]
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def avg_speed(self) -> float:
|
||||
if self._stat_speed[1] > 0:
|
||||
return self._stat_speed[0] / self._stat_speed[1]
|
||||
return 0.0
|
||||
|
||||
@property
|
||||
def avg_bitrate(self) -> int:
|
||||
if self._stat_bitrate[1] > 0:
|
||||
return int(self._stat_bitrate[0] / self._stat_bitrate[1])
|
||||
return 0
|
||||
|
||||
@property
|
||||
def duration_sec(self) -> float:
|
||||
"""Konvertierungsdauer in Sekunden"""
|
||||
if self.started_at and self.finished_at:
|
||||
return self.finished_at - self.started_at
|
||||
return 0.0
|
||||
|
||||
def to_dict_active(self) -> dict:
|
||||
"""Fuer WebSocket data_convert Nachricht"""
|
||||
size = MediaFile.format_size(self.media.source_size_bytes)
|
||||
return {
|
||||
"source_file_name": self.media.source_filename,
|
||||
"source_file": self.media.source_path,
|
||||
"source_path": self.media.source_dir,
|
||||
"source_duration": self.media.source_duration_sec,
|
||||
"source_size": [size[0], size[1]],
|
||||
"source_frame_rate": self.media.frame_rate,
|
||||
"source_frames_total": self.media.total_frames,
|
||||
"target_file_name": self.target_filename,
|
||||
"target_file": self.target_path,
|
||||
"status": self.status.value,
|
||||
"preset": self.preset_name,
|
||||
}
|
||||
|
||||
def to_dict_queue(self) -> dict:
|
||||
"""Fuer WebSocket data_queue Nachricht"""
|
||||
return {
|
||||
"source_file_name": self.media.source_filename,
|
||||
"source_file": self.media.source_path,
|
||||
"source_path": self.media.source_dir,
|
||||
"status": self.status.value,
|
||||
"preset": self.preset_name,
|
||||
}
|
||||
|
||||
def to_dict_progress(self) -> dict:
|
||||
"""Fuer WebSocket data_flow Nachricht"""
|
||||
target_size = MediaFile.format_size(self.progress_size_bytes)
|
||||
return {
|
||||
"id": self.id,
|
||||
"frames": self.progress_frames,
|
||||
"fps": self.progress_fps,
|
||||
"speed": self.progress_speed,
|
||||
"quantizer": 0,
|
||||
"size": [target_size[0], target_size[1]],
|
||||
"time": MediaFile.format_time(self.progress_time_sec),
|
||||
"time_remaining": MediaFile.format_time(self.progress_eta_sec),
|
||||
"loading": round(self.progress_percent, 1),
|
||||
"bitrate": [self.progress_bitrate, "kbits/s"],
|
||||
}
|
||||
|
||||
def to_dict_stats(self) -> dict:
|
||||
"""Fuer Statistik-Datenbank"""
|
||||
return {
|
||||
"source_path": self.media.source_path,
|
||||
"source_filename": self.media.source_filename,
|
||||
"source_size_bytes": self.media.source_size_bytes,
|
||||
"source_duration_sec": self.media.source_duration_sec,
|
||||
"source_frame_rate": self.media.frame_rate,
|
||||
"source_frames_total": self.media.total_frames,
|
||||
"target_path": self.target_path,
|
||||
"target_filename": self.target_filename,
|
||||
"target_size_bytes": self.progress_size_bytes,
|
||||
"target_container": self.target_container,
|
||||
"preset_name": self.preset_name,
|
||||
"status": self.status.value,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"duration_sec": self.duration_sec,
|
||||
"avg_fps": self.avg_fps,
|
||||
"avg_speed": self.avg_speed,
|
||||
"avg_bitrate": self.avg_bitrate,
|
||||
}
|
||||
|
||||
def to_json(self) -> dict:
|
||||
"""Fuer Queue-Persistierung"""
|
||||
return {
|
||||
"source_path": self.media.source_path,
|
||||
"preset_name": self.preset_name,
|
||||
"status": self.status.value,
|
||||
"created_at": self.created_at,
|
||||
"delete_source": self.delete_source,
|
||||
}
|
||||
166
video-konverter/app/models/media.py
Normal file
166
video-konverter/app/models/media.py
Normal 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
video-konverter/app/routes/__init__.py
Normal file
0
video-konverter/app/routes/__init__.py
Normal file
390
video-konverter/app/routes/api.py
Normal file
390
video-konverter/app/routes/api.py
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
"""REST API Endpoints"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
from app.config import Config
|
||||
from app.services.queue import QueueService
|
||||
from app.services.scanner import ScannerService
|
||||
from app.services.encoder import EncoderService
|
||||
from app.routes.ws import WebSocketManager
|
||||
|
||||
|
||||
def setup_api_routes(app: web.Application, config: Config,
|
||||
queue_service: QueueService,
|
||||
scanner: ScannerService,
|
||||
ws_manager: WebSocketManager = None) -> None:
|
||||
"""Registriert alle API-Routes"""
|
||||
|
||||
# --- Job-Management ---
|
||||
|
||||
async def post_convert(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /api/convert
|
||||
Body: {"files": ["/pfad/datei.mkv", "/pfad/ordner/"]}
|
||||
Optional: {"files": [...], "preset": "gpu_av1", "recursive": true}
|
||||
Hauptendpoint fuer KDE Dolphin Integration.
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"error": "Ungueltiges JSON"}, status=400
|
||||
)
|
||||
|
||||
files = data.get("files", [])
|
||||
if not files:
|
||||
return web.json_response(
|
||||
{"error": "Keine Dateien angegeben"}, status=400
|
||||
)
|
||||
|
||||
preset = data.get("preset")
|
||||
recursive = data.get("recursive")
|
||||
|
||||
logging.info(f"POST /api/convert: {len(files)} Pfade empfangen")
|
||||
|
||||
jobs = await queue_service.add_paths(files, preset, recursive)
|
||||
|
||||
return web.json_response({
|
||||
"message": f"{len(jobs)} Jobs erstellt",
|
||||
"jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs],
|
||||
})
|
||||
|
||||
async def get_jobs(request: web.Request) -> web.Response:
|
||||
"""GET /api/jobs - Alle Jobs mit Status"""
|
||||
return web.json_response({"jobs": queue_service.get_all_jobs()})
|
||||
|
||||
async def delete_job(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/jobs/{job_id}"""
|
||||
job_id = int(request.match_info["job_id"])
|
||||
success = await queue_service.remove_job(job_id)
|
||||
if success:
|
||||
return web.json_response({"message": "Job geloescht"})
|
||||
return web.json_response({"error": "Job nicht gefunden"}, status=404)
|
||||
|
||||
async def post_cancel(request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs/{job_id}/cancel"""
|
||||
job_id = int(request.match_info["job_id"])
|
||||
success = await queue_service.cancel_job(job_id)
|
||||
if success:
|
||||
return web.json_response({"message": "Job abgebrochen"})
|
||||
return web.json_response({"error": "Job nicht aktiv"}, status=400)
|
||||
|
||||
async def post_retry(request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs/{job_id}/retry"""
|
||||
job_id = int(request.match_info["job_id"])
|
||||
success = await queue_service.retry_job(job_id)
|
||||
if success:
|
||||
return web.json_response({"message": "Job wiederholt"})
|
||||
return web.json_response({"error": "Job nicht fehlgeschlagen"}, status=400)
|
||||
|
||||
# --- Settings ---
|
||||
|
||||
async def get_settings(request: web.Request) -> web.Response:
|
||||
"""GET /api/settings"""
|
||||
return web.json_response(config.settings)
|
||||
|
||||
async def put_settings(request: web.Request) -> web.Response:
|
||||
"""PUT /api/settings - Settings aktualisieren"""
|
||||
try:
|
||||
new_settings = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"error": "Ungueltiges JSON"}, status=400
|
||||
)
|
||||
|
||||
# Settings zusammenfuehren (deep merge)
|
||||
_deep_merge(config.settings, new_settings)
|
||||
config.save_settings()
|
||||
logging.info("Settings aktualisiert via API")
|
||||
return web.json_response({"message": "Settings gespeichert"})
|
||||
|
||||
# --- Presets ---
|
||||
|
||||
async def get_presets(request: web.Request) -> web.Response:
|
||||
"""GET /api/presets"""
|
||||
return web.json_response(config.presets)
|
||||
|
||||
async def put_preset(request: web.Request) -> web.Response:
|
||||
"""PUT /api/presets/{preset_name}"""
|
||||
preset_name = request.match_info["preset_name"]
|
||||
try:
|
||||
preset_data = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"error": "Ungueltiges JSON"}, status=400
|
||||
)
|
||||
|
||||
config.presets[preset_name] = preset_data
|
||||
config.save_presets()
|
||||
logging.info(f"Preset '{preset_name}' aktualisiert")
|
||||
return web.json_response({"message": f"Preset '{preset_name}' gespeichert"})
|
||||
|
||||
# --- Statistics ---
|
||||
|
||||
async def get_statistics(request: web.Request) -> web.Response:
|
||||
"""GET /api/statistics?limit=50&offset=0"""
|
||||
limit = int(request.query.get("limit", 50))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
stats = await queue_service.get_statistics(limit, offset)
|
||||
summary = await queue_service.get_statistics_summary()
|
||||
return web.json_response({"entries": stats, "summary": summary})
|
||||
|
||||
# --- System ---
|
||||
|
||||
async def get_system_info(request: web.Request) -> web.Response:
|
||||
"""GET /api/system - GPU-Status, verfuegbare Devices"""
|
||||
gpu_available = EncoderService.detect_gpu_available()
|
||||
devices = EncoderService.get_available_render_devices()
|
||||
return web.json_response({
|
||||
"encoding_mode": config.encoding_mode,
|
||||
"gpu_available": gpu_available,
|
||||
"gpu_devices": devices,
|
||||
"gpu_device_configured": config.gpu_device,
|
||||
"default_preset": config.default_preset_name,
|
||||
"max_parallel_jobs": config.max_parallel_jobs,
|
||||
"active_jobs": len([
|
||||
j for j in queue_service.jobs.values()
|
||||
if j.status.value == 1
|
||||
]),
|
||||
"queued_jobs": len([
|
||||
j for j in queue_service.jobs.values()
|
||||
if j.status.value == 0
|
||||
]),
|
||||
})
|
||||
|
||||
async def get_ws_config(request: web.Request) -> web.Response:
|
||||
"""GET /api/ws-config - WebSocket-URL fuer Client"""
|
||||
srv = config.server_config
|
||||
ext_url = srv.get("external_url", "")
|
||||
use_https = srv.get("use_https", False)
|
||||
port = srv.get("port", 8080)
|
||||
|
||||
if ext_url:
|
||||
ws_url = ext_url
|
||||
else:
|
||||
ws_url = f"{request.host}"
|
||||
|
||||
return web.json_response({
|
||||
"websocket_url": ws_url,
|
||||
"websocket_path": srv.get("websocket_path", "/ws"),
|
||||
"use_https": use_https,
|
||||
"port": port,
|
||||
})
|
||||
|
||||
# --- Filebrowser ---
|
||||
|
||||
# Erlaubte Basispfade (Sicherheit: nur unter /mnt navigierbar)
|
||||
_BROWSE_ROOTS = ["/mnt"]
|
||||
|
||||
def _is_path_allowed(path: str) -> bool:
|
||||
"""Prueft ob Pfad unter einem erlaubten Root liegt"""
|
||||
real = os.path.realpath(path)
|
||||
return any(real.startswith(root) for root in _BROWSE_ROOTS)
|
||||
|
||||
async def get_browse(request: web.Request) -> web.Response:
|
||||
"""
|
||||
GET /api/browse?path=/mnt
|
||||
Gibt Ordner und Videodateien im Verzeichnis zurueck.
|
||||
"""
|
||||
path = request.query.get("path", "/mnt")
|
||||
|
||||
if not _is_path_allowed(path):
|
||||
return web.json_response(
|
||||
{"error": "Zugriff verweigert"}, status=403
|
||||
)
|
||||
|
||||
if not os.path.isdir(path):
|
||||
return web.json_response(
|
||||
{"error": "Verzeichnis nicht gefunden"}, status=404
|
||||
)
|
||||
|
||||
scan_ext = set(config.files_config.get("scan_extensions", []))
|
||||
dirs = []
|
||||
files = []
|
||||
|
||||
try:
|
||||
for entry in sorted(os.listdir(path)):
|
||||
# Versteckte Dateien ueberspringen
|
||||
if entry.startswith("."):
|
||||
continue
|
||||
|
||||
full = os.path.join(path, entry)
|
||||
|
||||
if os.path.isdir(full):
|
||||
# Anzahl Videodateien im Unterordner zaehlen
|
||||
video_count = 0
|
||||
try:
|
||||
for f in os.listdir(full):
|
||||
if os.path.splitext(f)[1].lower() in scan_ext:
|
||||
video_count += 1
|
||||
except PermissionError:
|
||||
pass
|
||||
dirs.append({
|
||||
"name": entry,
|
||||
"path": full,
|
||||
"video_count": video_count,
|
||||
})
|
||||
|
||||
elif os.path.isfile(full):
|
||||
ext = os.path.splitext(entry)[1].lower()
|
||||
if ext in scan_ext:
|
||||
size = os.path.getsize(full)
|
||||
files.append({
|
||||
"name": entry,
|
||||
"path": full,
|
||||
"size": size,
|
||||
"size_human": _format_size(size),
|
||||
})
|
||||
|
||||
except PermissionError:
|
||||
return web.json_response(
|
||||
{"error": "Keine Leseberechtigung"}, status=403
|
||||
)
|
||||
|
||||
# Eltern-Pfad (zum Navigieren nach oben)
|
||||
parent = os.path.dirname(path)
|
||||
if not _is_path_allowed(parent):
|
||||
parent = None
|
||||
|
||||
return web.json_response({
|
||||
"path": path,
|
||||
"parent": parent,
|
||||
"dirs": dirs,
|
||||
"files": files,
|
||||
"total_files": len(files),
|
||||
})
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Kompakte Groessenangabe"""
|
||||
if size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.0f} KiB"
|
||||
if size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MiB"
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GiB"
|
||||
|
||||
# --- Upload ---
|
||||
|
||||
# Upload-Verzeichnis
|
||||
_UPLOAD_DIR = "/mnt/uploads"
|
||||
|
||||
async def post_upload(request: web.Request) -> web.Response:
|
||||
"""
|
||||
POST /api/upload (multipart/form-data)
|
||||
Laedt eine Videodatei hoch und startet die Konvertierung.
|
||||
"""
|
||||
os.makedirs(_UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
reader = await request.multipart()
|
||||
preset = None
|
||||
saved_files = []
|
||||
|
||||
while True:
|
||||
part = await reader.next()
|
||||
if part is None:
|
||||
break
|
||||
|
||||
if part.name == "preset":
|
||||
preset = (await part.text()).strip() or None
|
||||
continue
|
||||
|
||||
if part.name == "files":
|
||||
filename = part.filename
|
||||
if not filename:
|
||||
continue
|
||||
|
||||
# Sicherheit: Nur Dateiname ohne Pfad
|
||||
filename = os.path.basename(filename)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
scan_ext = set(config.files_config.get("scan_extensions", []))
|
||||
if ext not in scan_ext:
|
||||
logging.warning(f"Upload abgelehnt (Extension {ext}): {filename}")
|
||||
continue
|
||||
|
||||
# Datei speichern
|
||||
dest = os.path.join(_UPLOAD_DIR, filename)
|
||||
# Konfliktvermeidung
|
||||
if os.path.exists(dest):
|
||||
base, extension = os.path.splitext(filename)
|
||||
counter = 1
|
||||
while os.path.exists(dest):
|
||||
dest = os.path.join(
|
||||
_UPLOAD_DIR, f"{base}_{counter}{extension}"
|
||||
)
|
||||
counter += 1
|
||||
|
||||
size = 0
|
||||
with open(dest, "wb") as f:
|
||||
while True:
|
||||
chunk = await part.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
size += len(chunk)
|
||||
|
||||
saved_files.append(dest)
|
||||
logging.info(f"Upload: {filename} ({_format_size(size)})")
|
||||
|
||||
if not saved_files:
|
||||
return web.json_response(
|
||||
{"error": "Keine gueltigen Videodateien hochgeladen"}, status=400
|
||||
)
|
||||
|
||||
jobs = await queue_service.add_paths(saved_files, preset)
|
||||
|
||||
return web.json_response({
|
||||
"message": f"{len(saved_files)} Datei(en) hochgeladen, {len(jobs)} Jobs erstellt",
|
||||
"jobs": [{"id": j.id, "file": j.media.source_filename} for j in jobs],
|
||||
})
|
||||
|
||||
# --- Logs via WebSocket ---
|
||||
|
||||
class WebSocketLogHandler(logging.Handler):
|
||||
"""Pusht Logs direkt per WebSocket an alle Clients"""
|
||||
def __init__(self, ws_mgr: WebSocketManager):
|
||||
super().__init__()
|
||||
self._ws_manager = ws_mgr
|
||||
|
||||
def emit(self, record):
|
||||
if not self._ws_manager or not self._ws_manager.clients:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(
|
||||
self._ws_manager.broadcast_log(
|
||||
record.levelname, record.getMessage()
|
||||
)
|
||||
)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
if ws_manager:
|
||||
ws_log_handler = WebSocketLogHandler(ws_manager)
|
||||
ws_log_handler.setLevel(logging.INFO)
|
||||
logging.getLogger().addHandler(ws_log_handler)
|
||||
|
||||
# --- Routes registrieren ---
|
||||
app.router.add_get("/api/browse", get_browse)
|
||||
app.router.add_post("/api/upload", post_upload)
|
||||
app.router.add_post("/api/convert", post_convert)
|
||||
app.router.add_get("/api/jobs", get_jobs)
|
||||
app.router.add_delete("/api/jobs/{job_id}", delete_job)
|
||||
app.router.add_post("/api/jobs/{job_id}/cancel", post_cancel)
|
||||
app.router.add_post("/api/jobs/{job_id}/retry", post_retry)
|
||||
app.router.add_get("/api/settings", get_settings)
|
||||
app.router.add_put("/api/settings", put_settings)
|
||||
app.router.add_get("/api/presets", get_presets)
|
||||
app.router.add_put("/api/presets/{preset_name}", put_preset)
|
||||
app.router.add_get("/api/statistics", get_statistics)
|
||||
app.router.add_get("/api/system", get_system_info)
|
||||
app.router.add_get("/api/ws-config", get_ws_config)
|
||||
|
||||
|
||||
def _deep_merge(base: dict, override: dict) -> None:
|
||||
"""Rekursives Zusammenfuehren zweier Dicts"""
|
||||
for key, value in override.items():
|
||||
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
1929
video-konverter/app/routes/library_api.py
Normal file
1929
video-konverter/app/routes/library_api.py
Normal file
File diff suppressed because it is too large
Load diff
160
video-konverter/app/routes/pages.py
Normal file
160
video-konverter/app/routes/pages.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Server-gerenderte Seiten mit Jinja2 + HTMX"""
|
||||
import logging
|
||||
from aiohttp import web
|
||||
import aiohttp_jinja2
|
||||
from app.config import Config
|
||||
from app.services.queue import QueueService
|
||||
from app.services.encoder import EncoderService
|
||||
from app.models.media import MediaFile
|
||||
|
||||
|
||||
def setup_page_routes(app: web.Application, config: Config,
|
||||
queue_service: QueueService) -> None:
|
||||
"""Registriert Seiten-Routes"""
|
||||
|
||||
def _build_ws_url(request) -> str:
|
||||
"""Baut WebSocket-URL fuer den Client"""
|
||||
srv = config.server_config
|
||||
ext_url = srv.get("external_url", "")
|
||||
use_https = srv.get("use_https", False)
|
||||
ws_path = srv.get("websocket_path", "/ws")
|
||||
protocol = "wss" if use_https else "ws"
|
||||
|
||||
if ext_url:
|
||||
return f"{protocol}://{ext_url}{ws_path}"
|
||||
return f"{protocol}://{request.host}{ws_path}"
|
||||
|
||||
@aiohttp_jinja2.template("dashboard.html")
|
||||
async def dashboard(request: web.Request) -> dict:
|
||||
"""GET / - Dashboard"""
|
||||
return {
|
||||
"ws_url": _build_ws_url(request),
|
||||
"active_jobs": queue_service.get_active_jobs().get("data_convert", {}),
|
||||
"queue": queue_service.get_queue_state().get("data_queue", {}),
|
||||
}
|
||||
|
||||
@aiohttp_jinja2.template("admin.html")
|
||||
async def admin(request: web.Request) -> dict:
|
||||
"""GET /admin - Einstellungsseite"""
|
||||
gpu_available = EncoderService.detect_gpu_available()
|
||||
gpu_devices = EncoderService.get_available_render_devices()
|
||||
return {
|
||||
"settings": config.settings,
|
||||
"presets": config.presets,
|
||||
"gpu_available": gpu_available,
|
||||
"gpu_devices": gpu_devices,
|
||||
}
|
||||
|
||||
@aiohttp_jinja2.template("library.html")
|
||||
async def library(request: web.Request) -> dict:
|
||||
"""GET /library - Bibliothek"""
|
||||
return {}
|
||||
|
||||
@aiohttp_jinja2.template("statistics.html")
|
||||
async def statistics(request: web.Request) -> dict:
|
||||
"""GET /statistics - Statistik-Seite"""
|
||||
entries = await queue_service.get_statistics(limit=50)
|
||||
summary = await queue_service.get_statistics_summary()
|
||||
return {
|
||||
"entries": entries,
|
||||
"summary": summary,
|
||||
"format_size": MediaFile.format_size,
|
||||
"format_time": MediaFile.format_time,
|
||||
}
|
||||
|
||||
# --- HTMX Partials ---
|
||||
|
||||
async def htmx_save_settings(request: web.Request) -> web.Response:
|
||||
"""POST /htmx/settings - Settings via Formular speichern"""
|
||||
data = await request.post()
|
||||
|
||||
# Formular-Daten in Settings-Struktur konvertieren
|
||||
settings = config.settings
|
||||
|
||||
# Encoding
|
||||
settings["encoding"]["mode"] = data.get("encoding_mode", "cpu")
|
||||
settings["encoding"]["gpu_device"] = data.get("gpu_device",
|
||||
"/dev/dri/renderD128")
|
||||
settings["encoding"]["default_preset"] = data.get("default_preset",
|
||||
"cpu_av1")
|
||||
settings["encoding"]["max_parallel_jobs"] = int(
|
||||
data.get("max_parallel_jobs", 1)
|
||||
)
|
||||
|
||||
# Files
|
||||
settings["files"]["target_container"] = data.get("target_container", "webm")
|
||||
settings["files"]["target_folder"] = data.get("target_folder", "same")
|
||||
settings["files"]["delete_source"] = data.get("delete_source") == "on"
|
||||
settings["files"]["recursive_scan"] = data.get("recursive_scan") == "on"
|
||||
|
||||
# Cleanup
|
||||
settings["cleanup"]["enabled"] = data.get("cleanup_enabled") == "on"
|
||||
cleanup_ext = data.get("cleanup_extensions", "")
|
||||
if cleanup_ext:
|
||||
settings["cleanup"]["delete_extensions"] = [
|
||||
e.strip() for e in cleanup_ext.split(",") if e.strip()
|
||||
]
|
||||
exclude_pat = data.get("cleanup_exclude", "")
|
||||
if exclude_pat:
|
||||
settings["cleanup"]["exclude_patterns"] = [
|
||||
p.strip() for p in exclude_pat.split(",") if p.strip()
|
||||
]
|
||||
|
||||
# Audio
|
||||
audio_langs = data.get("audio_languages", "ger,eng,und")
|
||||
settings["audio"]["languages"] = [
|
||||
l.strip() for l in audio_langs.split(",") if l.strip()
|
||||
]
|
||||
settings["audio"]["default_codec"] = data.get("audio_codec", "libopus")
|
||||
settings["audio"]["keep_channels"] = data.get("keep_channels") == "on"
|
||||
|
||||
# Subtitle
|
||||
sub_langs = data.get("subtitle_languages", "ger,eng")
|
||||
settings["subtitle"]["languages"] = [
|
||||
l.strip() for l in sub_langs.split(",") if l.strip()
|
||||
]
|
||||
|
||||
# Logging
|
||||
settings["logging"]["level"] = data.get("log_level", "INFO")
|
||||
|
||||
# Bibliothek / TVDB
|
||||
settings.setdefault("library", {})
|
||||
settings["library"]["tvdb_api_key"] = data.get("tvdb_api_key", "")
|
||||
settings["library"]["tvdb_pin"] = data.get("tvdb_pin", "")
|
||||
settings["library"]["tvdb_language"] = data.get("tvdb_language", "deu")
|
||||
|
||||
config.save_settings()
|
||||
logging.info("Settings via Admin-UI gespeichert")
|
||||
|
||||
# Erfolgs-HTML zurueckgeben (HTMX swap)
|
||||
return web.Response(
|
||||
text='<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,
|
||||
}
|
||||
|
||||
async def redirect_to_library(request: web.Request):
|
||||
"""GET / -> Weiterleitung zur Bibliothek"""
|
||||
raise web.HTTPFound("/library")
|
||||
|
||||
# Routes registrieren
|
||||
app.router.add_get("/", redirect_to_library)
|
||||
app.router.add_get("/dashboard", dashboard)
|
||||
app.router.add_get("/library", library)
|
||||
app.router.add_get("/admin", admin)
|
||||
app.router.add_get("/statistics", statistics)
|
||||
app.router.add_post("/htmx/settings", htmx_save_settings)
|
||||
app.router.add_get("/htmx/stats", htmx_stats_table)
|
||||
127
video-konverter/app/routes/ws.py
Normal file
127
video-konverter/app/routes/ws.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""WebSocket Handler fuer Echtzeit-Updates"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Set, TYPE_CHECKING
|
||||
from aiohttp import web
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.queue import QueueService
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Verwaltet WebSocket-Verbindungen und Broadcasts"""
|
||||
|
||||
def __init__(self):
|
||||
self.clients: Set[web.WebSocketResponse] = set()
|
||||
self.queue_service: Optional['QueueService'] = None
|
||||
|
||||
def set_queue_service(self, queue_service: 'QueueService') -> None:
|
||||
"""Setzt Referenz auf QueueService"""
|
||||
self.queue_service = queue_service
|
||||
|
||||
async def handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""WebSocket-Endpoint Handler"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
self.clients.add(ws)
|
||||
|
||||
client_ip = request.remote
|
||||
logging.info(f"WebSocket Client verbunden: {client_ip} "
|
||||
f"({len(self.clients)} aktiv)")
|
||||
|
||||
# Initialen Status senden
|
||||
if self.queue_service:
|
||||
try:
|
||||
await ws.send_json(self.queue_service.get_active_jobs())
|
||||
await ws.send_json(self.queue_service.get_queue_state())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
await self._handle_message(data)
|
||||
except json.JSONDecodeError:
|
||||
logging.warning(f"Ungueltige JSON-Nachricht: {msg.data[:100]}")
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
logging.error(f"WebSocket Fehler: {ws.exception()}")
|
||||
except Exception as e:
|
||||
logging.error(f"WebSocket Handler Fehler: {e}")
|
||||
finally:
|
||||
self.clients.discard(ws)
|
||||
logging.info(f"WebSocket Client getrennt: {client_ip} "
|
||||
f"({len(self.clients)} aktiv)")
|
||||
|
||||
return ws
|
||||
|
||||
async def broadcast(self, message: dict) -> None:
|
||||
"""Sendet Nachricht an alle verbundenen Clients"""
|
||||
if not self.clients:
|
||||
return
|
||||
|
||||
msg_json = json.dumps(message)
|
||||
dead_clients = set()
|
||||
|
||||
for client in self.clients.copy():
|
||||
try:
|
||||
await client.send_str(msg_json)
|
||||
except Exception:
|
||||
dead_clients.add(client)
|
||||
|
||||
self.clients -= dead_clients
|
||||
|
||||
async def broadcast_queue_update(self) -> None:
|
||||
"""Sendet aktuelle Queue an alle Clients"""
|
||||
if not self.queue_service:
|
||||
return
|
||||
await self.broadcast(self.queue_service.get_active_jobs())
|
||||
await self.broadcast(self.queue_service.get_queue_state())
|
||||
|
||||
async def broadcast_progress(self, job) -> None:
|
||||
"""Sendet Fortschritts-Update fuer einen Job"""
|
||||
await self.broadcast({"data_flow": job.to_dict_progress()})
|
||||
|
||||
async def broadcast_log(self, level: str, message: str) -> None:
|
||||
"""Sendet Log-Nachricht an alle Clients"""
|
||||
await self.broadcast({
|
||||
"data_log": {"level": level, "message": message}
|
||||
})
|
||||
|
||||
async def _handle_message(self, data: dict) -> None:
|
||||
"""Verarbeitet eingehende WebSocket-Nachrichten"""
|
||||
if not self.queue_service:
|
||||
return
|
||||
|
||||
if "data_path" in data:
|
||||
# Pfade empfangen (Legacy-Kompatibilitaet + neues Format)
|
||||
paths = data["data_path"]
|
||||
if isinstance(paths, str):
|
||||
# Einzelner Pfad als String
|
||||
paths = [paths]
|
||||
elif isinstance(paths, dict) and "paths" in paths:
|
||||
# Altes Format: {"paths": [...]}
|
||||
paths = paths["paths"]
|
||||
elif not isinstance(paths, list):
|
||||
paths = [str(paths)]
|
||||
|
||||
await self.queue_service.add_paths(paths)
|
||||
|
||||
elif "data_command" in data:
|
||||
cmd = data["data_command"]
|
||||
cmd_type = cmd.get("cmd", "")
|
||||
job_id = cmd.get("id")
|
||||
|
||||
if not job_id:
|
||||
return
|
||||
|
||||
if cmd_type == "delete":
|
||||
await self.queue_service.remove_job(int(job_id))
|
||||
elif cmd_type == "cancel":
|
||||
await self.queue_service.cancel_job(int(job_id))
|
||||
elif cmd_type == "retry":
|
||||
await self.queue_service.retry_job(int(job_id))
|
||||
|
||||
elif "data_message" in data:
|
||||
logging.info(f"Client-Nachricht: {data['data_message']}")
|
||||
174
video-konverter/app/server.py
Normal file
174
video-konverter/app/server.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Haupt-Server: HTTP + WebSocket + Templates in einer aiohttp-App"""
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
import aiohttp_jinja2
|
||||
import jinja2
|
||||
from app.config import Config
|
||||
from app.services.queue import QueueService
|
||||
from app.services.scanner import ScannerService
|
||||
from app.services.encoder import EncoderService
|
||||
from app.routes.ws import WebSocketManager
|
||||
from app.services.library import LibraryService
|
||||
from app.services.tvdb import TVDBService
|
||||
from app.services.cleaner import CleanerService
|
||||
from app.services.importer import ImporterService
|
||||
from app.routes.api import setup_api_routes
|
||||
from app.routes.library_api import setup_library_routes
|
||||
from app.routes.pages import setup_page_routes
|
||||
|
||||
|
||||
class VideoKonverterServer:
|
||||
"""Haupt-Server - ein Port fuer HTTP, WebSocket und Admin-UI"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = Config()
|
||||
self.config.setup_logging()
|
||||
|
||||
# Services
|
||||
self.ws_manager = WebSocketManager()
|
||||
self.scanner = ScannerService(self.config)
|
||||
self.queue_service = QueueService(self.config, self.ws_manager)
|
||||
self.ws_manager.set_queue_service(self.queue_service)
|
||||
|
||||
# Bibliothek-Services
|
||||
self.library_service = LibraryService(self.config, self.ws_manager)
|
||||
self.tvdb_service = TVDBService(self.config)
|
||||
self.cleaner_service = CleanerService(self.config, self.library_service)
|
||||
self.importer_service = ImporterService(
|
||||
self.config, self.library_service, self.tvdb_service
|
||||
)
|
||||
|
||||
# aiohttp App (50 GiB Upload-Limit fuer grosse Videodateien)
|
||||
self.app = web.Application(
|
||||
client_max_size=50 * 1024 * 1024 * 1024,
|
||||
middlewares=[self._no_cache_middleware]
|
||||
)
|
||||
self._setup_app()
|
||||
|
||||
@web.middleware
|
||||
async def _no_cache_middleware(self, request: web.Request,
|
||||
handler) -> web.Response:
|
||||
"""Verhindert Browser-Caching fuer API-Responses"""
|
||||
response = await handler(request)
|
||||
if request.path.startswith("/api/"):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
def _setup_app(self) -> None:
|
||||
"""Konfiguriert die aiohttp-Application"""
|
||||
# Jinja2 Templates (request_processor macht request in Templates verfuegbar)
|
||||
template_dir = Path(__file__).parent / "templates"
|
||||
aiohttp_jinja2.setup(
|
||||
self.app,
|
||||
loader=jinja2.FileSystemLoader(str(template_dir)),
|
||||
context_processors=[aiohttp_jinja2.request_processor],
|
||||
)
|
||||
|
||||
# WebSocket Route
|
||||
ws_path = self.config.server_config.get("websocket_path", "/ws")
|
||||
self.app.router.add_get(ws_path, self.ws_manager.handle_websocket)
|
||||
|
||||
# API Routes
|
||||
setup_api_routes(
|
||||
self.app, self.config, self.queue_service, self.scanner,
|
||||
self.ws_manager
|
||||
)
|
||||
|
||||
# Bibliothek API Routes
|
||||
setup_library_routes(
|
||||
self.app, self.config, self.library_service,
|
||||
self.tvdb_service, self.queue_service,
|
||||
self.cleaner_service, self.importer_service,
|
||||
)
|
||||
|
||||
# Seiten Routes
|
||||
setup_page_routes(self.app, self.config, self.queue_service)
|
||||
|
||||
# Statische Dateien
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.exists():
|
||||
self.app.router.add_static(
|
||||
"/static/", path=str(static_dir), name="static"
|
||||
)
|
||||
|
||||
# Startup/Shutdown Hooks
|
||||
self.app.on_startup.append(self._on_startup)
|
||||
self.app.on_shutdown.append(self._on_shutdown)
|
||||
|
||||
async def _on_startup(self, app: web.Application) -> None:
|
||||
"""Server-Start: GPU pruefen, Queue starten"""
|
||||
mode = self.config.encoding_mode
|
||||
|
||||
# Auto-Detection
|
||||
if mode == "auto":
|
||||
gpu_ok = EncoderService.detect_gpu_available()
|
||||
if gpu_ok:
|
||||
gpu_ok = await EncoderService.test_gpu_encoding(
|
||||
self.config.gpu_device
|
||||
)
|
||||
if gpu_ok:
|
||||
self.config.settings["encoding"]["mode"] = "gpu"
|
||||
self.config.settings["encoding"]["default_preset"] = "gpu_av1"
|
||||
logging.info(f"GPU erkannt ({self.config.gpu_device}), "
|
||||
f"verwende GPU-Encoding")
|
||||
else:
|
||||
self.config.settings["encoding"]["mode"] = "cpu"
|
||||
self.config.settings["encoding"]["default_preset"] = "cpu_av1"
|
||||
logging.info("Keine GPU erkannt, verwende CPU-Encoding")
|
||||
else:
|
||||
logging.info(f"Encoding-Modus: {mode}")
|
||||
|
||||
# Queue starten
|
||||
await self.queue_service.start()
|
||||
|
||||
# Bibliothek starten
|
||||
await self.library_service.start()
|
||||
|
||||
# DB-Pool mit anderen Services teilen
|
||||
if self.library_service._db_pool:
|
||||
self.tvdb_service.set_db_pool(self.library_service._db_pool)
|
||||
self.importer_service.set_db_pool(self.library_service._db_pool)
|
||||
|
||||
# WebSocket-Manager an ImporterService fuer Live-Updates
|
||||
self.importer_service.set_ws_manager(self.ws_manager)
|
||||
|
||||
# Zusaetzliche DB-Tabellen erstellen
|
||||
await self.tvdb_service.init_db()
|
||||
await self.importer_service.init_db()
|
||||
|
||||
host = self.config.server_config.get("host", "0.0.0.0")
|
||||
port = self.config.server_config.get("port", 8080)
|
||||
logging.info(f"Server bereit auf http://{host}:{port}")
|
||||
|
||||
async def _on_shutdown(self, app: web.Application) -> None:
|
||||
"""Server-Stop: Queue und Library stoppen"""
|
||||
await self.queue_service.stop()
|
||||
await self.library_service.stop()
|
||||
logging.info("Server heruntergefahren")
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Startet den Server"""
|
||||
host = self.config.server_config.get("host", "0.0.0.0")
|
||||
port = self.config.server_config.get("port", 8080)
|
||||
|
||||
runner = web.AppRunner(self.app)
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, host, port)
|
||||
await site.start()
|
||||
|
||||
logging.info(
|
||||
f"VideoKonverter Server laeuft auf http://{host}:{port}\n"
|
||||
f" Dashboard: http://{host}:{port}/\n"
|
||||
f" Bibliothek: http://{host}:{port}/library\n"
|
||||
f" Admin: http://{host}:{port}/admin\n"
|
||||
f" Statistik: http://{host}:{port}/statistics\n"
|
||||
f" WebSocket: ws://{host}:{port}/ws\n"
|
||||
f" API: http://{host}:{port}/api/convert (POST)"
|
||||
)
|
||||
|
||||
# Endlos laufen bis Interrupt
|
||||
await asyncio.Event().wait()
|
||||
0
video-konverter/app/services/__init__.py
Normal file
0
video-konverter/app/services/__init__.py
Normal file
155
video-konverter/app/services/cleaner.py
Normal file
155
video-konverter/app/services/cleaner.py
Normal 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
|
||||
234
video-konverter/app/services/encoder.py
Normal file
234
video-konverter/app/services/encoder.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"""ffmpeg Command Builder - GPU und CPU Encoding"""
|
||||
import os
|
||||
import logging
|
||||
import asyncio
|
||||
from app.config import Config
|
||||
from app.models.job import ConversionJob
|
||||
|
||||
|
||||
class EncoderService:
|
||||
"""Baut ffmpeg-Befehle basierend auf Preset und Media-Analyse"""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
|
||||
def build_command(self, job: ConversionJob) -> list[str]:
|
||||
"""
|
||||
Baut den vollstaendigen ffmpeg-Befehl.
|
||||
Beruecksichtigt GPU/CPU, Preset, Audio/Subtitle-Filter.
|
||||
"""
|
||||
preset = self.config.presets.get(job.preset_name, {})
|
||||
if not preset:
|
||||
logging.error(f"Preset '{job.preset_name}' nicht gefunden")
|
||||
preset = self.config.default_preset
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
|
||||
# GPU-Initialisierung
|
||||
cmd.extend(self._build_hw_init(preset))
|
||||
|
||||
# Input
|
||||
cmd.extend(["-i", job.media.source_path])
|
||||
|
||||
# Video-Stream (erster Video-Stream)
|
||||
cmd.extend(self._build_video_params(job, preset))
|
||||
|
||||
# Audio-Streams (alle passenden)
|
||||
cmd.extend(self._build_audio_params(job))
|
||||
|
||||
# Subtitle-Streams (alle passenden)
|
||||
cmd.extend(self._build_subtitle_params(job))
|
||||
|
||||
# Output
|
||||
cmd.append(job.target_path)
|
||||
|
||||
job.ffmpeg_cmd = cmd
|
||||
return cmd
|
||||
|
||||
def _build_hw_init(self, preset: dict) -> list[str]:
|
||||
"""GPU-Initialisierung fuer VAAPI"""
|
||||
if not preset.get("hw_init", False):
|
||||
return []
|
||||
|
||||
device = self.config.gpu_device
|
||||
return [
|
||||
"-init_hw_device", f"vaapi=intel:{device}",
|
||||
"-hwaccel", "vaapi",
|
||||
"-hwaccel_device", "intel",
|
||||
]
|
||||
|
||||
def _build_video_params(self, job: ConversionJob, preset: dict) -> list[str]:
|
||||
"""Video-Parameter: Codec, Quality, Filter"""
|
||||
cmd = ["-map", "0:v:0"] # Erster Video-Stream
|
||||
|
||||
# Video-Codec
|
||||
codec = preset.get("video_codec", "libsvtav1")
|
||||
cmd.extend(["-c:v", codec])
|
||||
|
||||
# Quality (CRF oder QP je nach Encoder)
|
||||
quality_param = preset.get("quality_param", "crf")
|
||||
quality_value = preset.get("quality_value", 30)
|
||||
cmd.extend([f"-{quality_param}", str(quality_value)])
|
||||
|
||||
# GOP-Groesse
|
||||
gop = preset.get("gop_size")
|
||||
if gop:
|
||||
cmd.extend(["-g", str(gop)])
|
||||
|
||||
# Speed-Preset (nur CPU-Encoder)
|
||||
speed = preset.get("speed_preset")
|
||||
if speed is not None:
|
||||
cmd.extend(["-preset", str(speed)])
|
||||
|
||||
# Video-Filter
|
||||
vf = preset.get("video_filter", "")
|
||||
if not vf and preset.get("hw_init"):
|
||||
# Auto-Detect Pixel-Format fuer GPU
|
||||
vf = self._detect_gpu_filter(job)
|
||||
if vf:
|
||||
cmd.extend(["-vf", vf])
|
||||
|
||||
# Extra-Parameter
|
||||
for key, value in preset.get("extra_params", {}).items():
|
||||
cmd.extend([f"-{key}", str(value)])
|
||||
|
||||
return cmd
|
||||
|
||||
def _build_audio_params(self, job: ConversionJob) -> list[str]:
|
||||
"""
|
||||
Audio-Streams: Filtert nach Sprache, setzt Codec/Bitrate.
|
||||
WICHTIG: Kanalanzahl wird beibehalten (kein Downmix)!
|
||||
Surround (5.1/7.1) und Stereo (2.0/2.1) bleiben erhalten.
|
||||
"""
|
||||
audio_cfg = self.config.audio_config
|
||||
languages = audio_cfg.get("languages", ["ger", "eng", "und"])
|
||||
codec = audio_cfg.get("default_codec", "libopus")
|
||||
bitrate_map = audio_cfg.get("bitrate_map", {2: "128k", 6: "320k", 8: "450k"})
|
||||
default_bitrate = audio_cfg.get("default_bitrate", "192k")
|
||||
keep_channels = audio_cfg.get("keep_channels", True)
|
||||
|
||||
cmd = []
|
||||
audio_idx = 0
|
||||
|
||||
for stream in job.media.audio_streams:
|
||||
# Sprachfilter: Wenn Sprache gesetzt, muss sie in der Liste sein
|
||||
lang = stream.language
|
||||
if lang and lang not in languages:
|
||||
continue
|
||||
|
||||
cmd.extend(["-map", f"0:{stream.index}"])
|
||||
|
||||
if codec == "copy":
|
||||
cmd.extend([f"-c:a:{audio_idx}", "copy"])
|
||||
else:
|
||||
cmd.extend([f"-c:a:{audio_idx}", codec])
|
||||
|
||||
# Bitrate nach Kanalanzahl (Surround bekommt mehr)
|
||||
# Konvertiere bitrate_map Keys zu int (YAML laedt sie als int)
|
||||
channels = stream.channels
|
||||
bitrate = str(bitrate_map.get(channels, default_bitrate))
|
||||
cmd.extend([f"-b:a:{audio_idx}", bitrate])
|
||||
|
||||
# Kanalanzahl beibehalten
|
||||
if keep_channels:
|
||||
cmd.extend([f"-ac:{audio_idx}", str(channels)])
|
||||
|
||||
# Channel-Layout normalisieren fuer libopus
|
||||
# EAC3/AC3 mit 5.1(side) Layout fuehrt zu Encoder-Fehler
|
||||
if codec == "libopus" and channels == 6:
|
||||
cmd.extend([
|
||||
f"-filter:a:{audio_idx}",
|
||||
"channelmap=channel_layout=5.1",
|
||||
])
|
||||
|
||||
audio_idx += 1
|
||||
|
||||
return cmd
|
||||
|
||||
def _build_subtitle_params(self, job: ConversionJob) -> list[str]:
|
||||
"""Subtitle-Streams: Filtert nach Sprache und Blacklist"""
|
||||
sub_cfg = self.config.subtitle_config
|
||||
languages = sub_cfg.get("languages", ["ger", "eng"])
|
||||
blacklist = sub_cfg.get("codec_blacklist", [])
|
||||
|
||||
cmd = []
|
||||
for stream in job.media.subtitle_streams:
|
||||
# Codec-Blacklist (Bild-basierte Untertitel)
|
||||
if stream.codec_name in blacklist:
|
||||
continue
|
||||
|
||||
# Sprachfilter
|
||||
lang = stream.language
|
||||
if lang and lang not in languages:
|
||||
continue
|
||||
|
||||
cmd.extend(["-map", f"0:{stream.index}"])
|
||||
|
||||
# Subtitle-Codec: Bei WebM nur webvtt moeglich
|
||||
if job.target_container == "webm" and cmd:
|
||||
cmd.extend(["-c:s", "webvtt"])
|
||||
elif cmd:
|
||||
cmd.extend(["-c:s", "copy"])
|
||||
|
||||
return cmd
|
||||
|
||||
def _detect_gpu_filter(self, job: ConversionJob) -> str:
|
||||
"""Erkennt Pixel-Format fuer GPU-Encoding"""
|
||||
if job.media.is_10bit:
|
||||
return "format=p010,hwupload"
|
||||
return "format=nv12,hwupload"
|
||||
|
||||
@staticmethod
|
||||
def detect_gpu_available() -> bool:
|
||||
"""Prueft ob GPU/VAAPI verfuegbar ist"""
|
||||
# Pruefe ob /dev/dri existiert
|
||||
if not os.path.exists("/dev/dri"):
|
||||
return False
|
||||
|
||||
# Pruefe ob renderD* Devices vorhanden
|
||||
devices = EncoderService.get_available_render_devices()
|
||||
if not devices:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_available_render_devices() -> list[str]:
|
||||
"""Listet verfuegbare /dev/dri/renderD* Geraete"""
|
||||
dri_path = "/dev/dri"
|
||||
if not os.path.exists(dri_path):
|
||||
return []
|
||||
|
||||
devices = []
|
||||
try:
|
||||
for entry in os.listdir(dri_path):
|
||||
if entry.startswith("renderD"):
|
||||
devices.append(f"{dri_path}/{entry}")
|
||||
except PermissionError:
|
||||
logging.warning("Kein Zugriff auf /dev/dri")
|
||||
|
||||
return sorted(devices)
|
||||
|
||||
@staticmethod
|
||||
async def test_gpu_encoding(device: str = "/dev/dri/renderD128") -> bool:
|
||||
"""Testet ob GPU-Encoding tatsaechlich funktioniert"""
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-init_hw_device", f"vaapi=test:{device}",
|
||||
"-f", "lavfi", "-i", "nullsrc=s=64x64:d=0.1",
|
||||
"-vf", "format=nv12,hwupload",
|
||||
"-c:v", "h264_vaapi",
|
||||
"-frames:v", "1",
|
||||
"-f", "null", "-",
|
||||
]
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(process.communicate(), timeout=10)
|
||||
return process.returncode == 0
|
||||
except Exception as e:
|
||||
logging.warning(f"GPU-Test fehlgeschlagen: {e}")
|
||||
return False
|
||||
1568
video-konverter/app/services/importer.py
Normal file
1568
video-konverter/app/services/importer.py
Normal file
File diff suppressed because it is too large
Load diff
2139
video-konverter/app/services/library.py
Normal file
2139
video-konverter/app/services/library.py
Normal file
File diff suppressed because it is too large
Load diff
177
video-konverter/app/services/probe.py
Normal file
177
video-konverter/app/services/probe.py
Normal 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
video-konverter/app/services/progress.py
Normal file
132
video-konverter/app/services/progress.py
Normal 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)
|
||||
564
video-konverter/app/services/queue.py
Normal file
564
video-konverter/app/services/queue.py
Normal file
|
|
@ -0,0 +1,564 @@
|
|||
"""Job-Queue mit Persistierung und paralleler Ausfuehrung"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from decimal import Decimal
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
import aiomysql
|
||||
|
||||
from app.config import Config
|
||||
from app.models.job import ConversionJob, JobStatus
|
||||
from app.models.media import MediaFile
|
||||
from app.services.encoder import EncoderService
|
||||
from app.services.probe import ProbeService
|
||||
from app.services.progress import ProgressParser
|
||||
from app.services.scanner import ScannerService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.routes.ws import WebSocketManager
|
||||
|
||||
|
||||
class QueueService:
|
||||
"""Verwaltet die Konvertierungs-Queue mit Persistierung"""
|
||||
|
||||
def __init__(self, config: Config, ws_manager: 'WebSocketManager'):
|
||||
self.config = config
|
||||
self.ws_manager = ws_manager
|
||||
self.encoder = EncoderService(config)
|
||||
self.scanner = ScannerService(config)
|
||||
self.jobs: OrderedDict[int, ConversionJob] = OrderedDict()
|
||||
self._active_count: int = 0
|
||||
self._running: bool = False
|
||||
self._queue_task: Optional[asyncio.Task] = None
|
||||
self._queue_file = str(config.data_path / "queue.json")
|
||||
self._db_pool: Optional[aiomysql.Pool] = None
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Startet den Queue-Worker und initialisiert die Datenbank"""
|
||||
await self._init_db()
|
||||
pending = self._load_queue()
|
||||
self._running = True
|
||||
self._queue_task = asyncio.create_task(self._process_loop())
|
||||
logging.info(
|
||||
f"Queue gestartet ({len(self.jobs)} Jobs geladen, "
|
||||
f"max {self.config.max_parallel_jobs} parallel)"
|
||||
)
|
||||
# Gespeicherte Jobs asynchron wieder einreihen (mit allen Optionen)
|
||||
if pending:
|
||||
asyncio.create_task(self._restore_jobs(pending))
|
||||
|
||||
async def _restore_jobs(self, job_data: list[dict]) -> None:
|
||||
"""Stellt Jobs aus gespeicherten Daten wieder her"""
|
||||
for item in job_data:
|
||||
media = await ProbeService.analyze(item["source_path"])
|
||||
if media:
|
||||
await self.add_job(
|
||||
media,
|
||||
preset_name=item.get("preset_name"),
|
||||
delete_source=item.get("delete_source", False)
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stoppt den Queue-Worker"""
|
||||
self._running = False
|
||||
if self._queue_task:
|
||||
self._queue_task.cancel()
|
||||
try:
|
||||
await self._queue_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._db_pool is not None:
|
||||
self._db_pool.close()
|
||||
await self._db_pool.wait_closed()
|
||||
logging.info("Queue gestoppt")
|
||||
|
||||
async def add_job(self, media: MediaFile,
|
||||
preset_name: Optional[str] = None,
|
||||
delete_source: bool = False) -> Optional[ConversionJob]:
|
||||
"""Fuegt neuen Job zur Queue hinzu"""
|
||||
if self._is_duplicate(media.source_path):
|
||||
logging.info(f"Duplikat uebersprungen: {media.source_filename}")
|
||||
return None
|
||||
|
||||
if not preset_name:
|
||||
preset_name = self.config.default_preset_name
|
||||
|
||||
job = ConversionJob(media=media, preset_name=preset_name)
|
||||
job.delete_source = delete_source
|
||||
job.build_target_path(self.config)
|
||||
self.jobs[job.id] = job
|
||||
self._save_queue()
|
||||
|
||||
logging.info(
|
||||
f"Job hinzugefuegt: {media.source_filename} "
|
||||
f"-> {job.target_filename} (Preset: {preset_name})"
|
||||
f"{' [delete_source]' if delete_source else ''}"
|
||||
)
|
||||
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
return job
|
||||
|
||||
async def add_paths(self, paths: list[str],
|
||||
preset_name: Optional[str] = None,
|
||||
recursive: Optional[bool] = None,
|
||||
delete_source: bool = False) -> list[ConversionJob]:
|
||||
"""Fuegt mehrere Pfade hinzu (Dateien und Ordner)"""
|
||||
jobs = []
|
||||
all_files = []
|
||||
|
||||
for path in paths:
|
||||
path = path.strip()
|
||||
if not path:
|
||||
continue
|
||||
scanned = self.scanner.scan_path(path, recursive)
|
||||
all_files.extend(scanned)
|
||||
|
||||
logging.info(f"{len(all_files)} Dateien aus {len(paths)} Pfaden gefunden")
|
||||
|
||||
for file_path in all_files:
|
||||
media = await ProbeService.analyze(file_path)
|
||||
if media:
|
||||
job = await self.add_job(media, preset_name, delete_source)
|
||||
if job:
|
||||
jobs.append(job)
|
||||
|
||||
return jobs
|
||||
|
||||
async def remove_job(self, job_id: int) -> bool:
|
||||
"""Entfernt Job aus Queue, bricht laufende Konvertierung ab"""
|
||||
job = self.jobs.get(job_id)
|
||||
if not job:
|
||||
return False
|
||||
|
||||
if job.status == JobStatus.ACTIVE and job.process:
|
||||
try:
|
||||
job.process.terminate()
|
||||
await asyncio.sleep(0.5)
|
||||
if job.process.returncode is None:
|
||||
job.process.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
if job.task and not job.task.done():
|
||||
job.task.cancel()
|
||||
|
||||
del self.jobs[job_id]
|
||||
self._save_queue()
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
logging.info(f"Job entfernt: {job.media.source_filename}")
|
||||
return True
|
||||
|
||||
async def cancel_job(self, job_id: int) -> bool:
|
||||
"""Bricht laufenden Job ab"""
|
||||
job = self.jobs.get(job_id)
|
||||
if not job or job.status != JobStatus.ACTIVE:
|
||||
return False
|
||||
|
||||
if job.process:
|
||||
try:
|
||||
job.process.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
job.status = JobStatus.CANCELLED
|
||||
job.finished_at = time.time()
|
||||
self._active_count = max(0, self._active_count - 1)
|
||||
self._save_queue()
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
logging.info(f"Job abgebrochen: {job.media.source_filename}")
|
||||
return True
|
||||
|
||||
async def retry_job(self, job_id: int) -> bool:
|
||||
"""Setzt fehlgeschlagenen Job zurueck auf QUEUED"""
|
||||
job = self.jobs.get(job_id)
|
||||
if not job or job.status not in (JobStatus.FAILED, JobStatus.CANCELLED):
|
||||
return False
|
||||
|
||||
job.status = JobStatus.QUEUED
|
||||
job.progress_percent = 0.0
|
||||
job.progress_frames = 0
|
||||
job.progress_fps = 0.0
|
||||
job.progress_speed = 0.0
|
||||
job.progress_size_bytes = 0
|
||||
job.progress_time_sec = 0.0
|
||||
job.progress_eta_sec = 0.0
|
||||
job.started_at = None
|
||||
job.finished_at = None
|
||||
job._stat_fps = [0.0, 0]
|
||||
job._stat_speed = [0.0, 0]
|
||||
job._stat_bitrate = [0, 0]
|
||||
self._save_queue()
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
logging.info(f"Job wiederholt: {job.media.source_filename}")
|
||||
return True
|
||||
|
||||
def get_queue_state(self) -> dict:
|
||||
"""Queue-Status fuer WebSocket"""
|
||||
queue = {}
|
||||
for job_id, job in self.jobs.items():
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.ACTIVE,
|
||||
JobStatus.FAILED, JobStatus.CANCELLED):
|
||||
queue[job_id] = job.to_dict_queue()
|
||||
return {"data_queue": queue}
|
||||
|
||||
def get_active_jobs(self) -> dict:
|
||||
"""Aktive Jobs fuer WebSocket"""
|
||||
active = {}
|
||||
for job_id, job in self.jobs.items():
|
||||
if job.status == JobStatus.ACTIVE:
|
||||
active[job_id] = job.to_dict_active()
|
||||
return {"data_convert": active}
|
||||
|
||||
def get_all_jobs(self) -> list[dict]:
|
||||
"""Alle Jobs als Liste fuer API"""
|
||||
return [
|
||||
{"id": jid, **job.to_dict_active(), "status_name": job.status.name}
|
||||
for jid, job in self.jobs.items()
|
||||
]
|
||||
|
||||
# --- Interner Queue-Worker ---
|
||||
|
||||
async def _process_loop(self) -> None:
|
||||
"""Hauptschleife: Startet neue Jobs wenn Kapazitaet frei"""
|
||||
while self._running:
|
||||
try:
|
||||
if self._active_count < self.config.max_parallel_jobs:
|
||||
next_job = self._get_next_queued()
|
||||
if next_job:
|
||||
asyncio.create_task(self._execute_job(next_job))
|
||||
except Exception as e:
|
||||
logging.error(f"Queue-Worker Fehler: {e}")
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def _execute_job(self, job: ConversionJob) -> None:
|
||||
"""Fuehrt einzelnen Konvertierungs-Job aus"""
|
||||
self._active_count += 1
|
||||
job.status = JobStatus.ACTIVE
|
||||
job.started_at = time.time()
|
||||
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
|
||||
command = self.encoder.build_command(job)
|
||||
logging.info(
|
||||
f"Starte Konvertierung: {job.media.source_filename}\n"
|
||||
f" Befehl: {' '.join(command)}"
|
||||
)
|
||||
|
||||
try:
|
||||
job.process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
progress = ProgressParser(self.ws_manager.broadcast_progress)
|
||||
await progress.monitor(job)
|
||||
await job.process.wait()
|
||||
|
||||
if job.process.returncode == 0:
|
||||
job.status = JobStatus.FINISHED
|
||||
# Tatsaechliche Dateigroesse von Disk lesen
|
||||
if os.path.exists(job.target_path):
|
||||
job.progress_size_bytes = os.path.getsize(job.target_path)
|
||||
logging.info(
|
||||
f"Konvertierung abgeschlossen: {job.media.source_filename} "
|
||||
f"({MediaFile.format_time(time.time() - job.started_at)})"
|
||||
)
|
||||
await self._post_conversion_cleanup(job)
|
||||
else:
|
||||
job.status = JobStatus.FAILED
|
||||
error_output = progress.get_error_output()
|
||||
logging.error(
|
||||
f"Konvertierung fehlgeschlagen (Code {job.process.returncode}): "
|
||||
f"{job.media.source_filename}\n"
|
||||
f" ffmpeg stderr:\n{error_output}"
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
job.status = JobStatus.CANCELLED
|
||||
logging.info(f"Konvertierung abgebrochen: {job.media.source_filename}")
|
||||
except Exception as e:
|
||||
job.status = JobStatus.FAILED
|
||||
logging.error(f"Fehler bei Konvertierung: {e}")
|
||||
finally:
|
||||
job.finished_at = time.time()
|
||||
self._active_count = max(0, self._active_count - 1)
|
||||
self._save_queue()
|
||||
await self._save_stats(job)
|
||||
await self.ws_manager.broadcast_queue_update()
|
||||
|
||||
async def _post_conversion_cleanup(self, job: ConversionJob) -> None:
|
||||
"""Cleanup nach erfolgreicher Konvertierung"""
|
||||
files_cfg = self.config.files_config
|
||||
|
||||
# Quelldatei loeschen: Global per Config ODER per Job-Option
|
||||
should_delete = files_cfg.get("delete_source", False) or job.delete_source
|
||||
|
||||
if should_delete:
|
||||
target_exists = os.path.exists(job.target_path)
|
||||
target_size = os.path.getsize(job.target_path) if target_exists else 0
|
||||
if target_exists and target_size > 0:
|
||||
try:
|
||||
os.remove(job.media.source_path)
|
||||
logging.info(f"Quelldatei geloescht: {job.media.source_path}")
|
||||
except OSError as e:
|
||||
logging.error(f"Quelldatei loeschen fehlgeschlagen: {e}")
|
||||
|
||||
cleanup_cfg = self.config.cleanup_config
|
||||
if cleanup_cfg.get("enabled", False):
|
||||
deleted = self.scanner.cleanup_directory(job.media.source_dir)
|
||||
if deleted:
|
||||
logging.info(
|
||||
f"{len(deleted)} Dateien bereinigt in {job.media.source_dir}"
|
||||
)
|
||||
|
||||
def _get_next_queued(self) -> Optional[ConversionJob]:
|
||||
"""Naechster Job mit Status QUEUED (FIFO)"""
|
||||
for job in self.jobs.values():
|
||||
if job.status == JobStatus.QUEUED:
|
||||
return job
|
||||
return None
|
||||
|
||||
def _is_duplicate(self, source_path: str) -> bool:
|
||||
"""Prueft ob Pfad bereits in Queue (nur aktive/wartende)"""
|
||||
for job in self.jobs.values():
|
||||
if (job.media.source_path == source_path and
|
||||
job.status in (JobStatus.QUEUED, JobStatus.ACTIVE)):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _save_queue(self) -> None:
|
||||
"""Persistiert Queue nach queue.json"""
|
||||
queue_data = []
|
||||
for job in self.jobs.values():
|
||||
if job.status in (JobStatus.QUEUED, JobStatus.FAILED):
|
||||
queue_data.append(job.to_json())
|
||||
try:
|
||||
with open(self._queue_file, "w", encoding="utf-8") as f:
|
||||
json.dump(queue_data, f, indent=2)
|
||||
except Exception as e:
|
||||
logging.error(f"Queue speichern fehlgeschlagen: {e}")
|
||||
|
||||
def _load_queue(self) -> list[dict]:
|
||||
"""Laedt Queue aus queue.json, gibt Job-Daten zurueck"""
|
||||
if not os.path.exists(self._queue_file):
|
||||
return []
|
||||
try:
|
||||
with open(self._queue_file, "r", encoding="utf-8") as f:
|
||||
queue_data = json.load(f)
|
||||
pending = [
|
||||
{
|
||||
"source_path": item["source_path"],
|
||||
"preset_name": item.get("preset_name"),
|
||||
"delete_source": item.get("delete_source", False),
|
||||
}
|
||||
for item in queue_data
|
||||
if item.get("status", 0) == JobStatus.QUEUED
|
||||
]
|
||||
if pending:
|
||||
logging.info(f"{len(pending)} Jobs aus Queue geladen")
|
||||
return pending
|
||||
except Exception as e:
|
||||
logging.error(f"Queue laden fehlgeschlagen: {e}")
|
||||
return []
|
||||
|
||||
# --- MariaDB Statistik-Datenbank ---
|
||||
|
||||
def _get_db_config(self) -> dict:
|
||||
"""DB-Konfiguration aus Settings"""
|
||||
db_cfg = self.config.settings.get("database", {})
|
||||
return {
|
||||
"host": db_cfg.get("host", "192.168.155.11"),
|
||||
"port": db_cfg.get("port", 3306),
|
||||
"user": db_cfg.get("user", "video"),
|
||||
"password": db_cfg.get("password", "8715"),
|
||||
"db": db_cfg.get("database", "video_converter"),
|
||||
}
|
||||
|
||||
async def _get_pool(self) -> Optional[aiomysql.Pool]:
|
||||
"""Gibt den Connection-Pool zurueck, erstellt ihn bei Bedarf"""
|
||||
if self._db_pool is not None:
|
||||
return self._db_pool
|
||||
db_cfg = self._get_db_config()
|
||||
self._db_pool = await aiomysql.create_pool(
|
||||
host=db_cfg["host"],
|
||||
port=db_cfg["port"],
|
||||
user=db_cfg["user"],
|
||||
password=db_cfg["password"],
|
||||
db=db_cfg["db"],
|
||||
charset="utf8mb4",
|
||||
autocommit=True,
|
||||
minsize=1,
|
||||
maxsize=5,
|
||||
connect_timeout=10,
|
||||
)
|
||||
return self._db_pool
|
||||
|
||||
async def _init_db(self) -> None:
|
||||
"""Erstellt MariaDB-Tabelle falls nicht vorhanden"""
|
||||
db_cfg = self._get_db_config()
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS conversions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
source_path VARCHAR(1024) NOT NULL,
|
||||
source_filename VARCHAR(512) NOT NULL,
|
||||
source_size_bytes BIGINT,
|
||||
source_duration_sec DOUBLE,
|
||||
source_frame_rate DOUBLE,
|
||||
source_frames_total INT,
|
||||
target_path VARCHAR(1024),
|
||||
target_filename VARCHAR(512),
|
||||
target_size_bytes BIGINT,
|
||||
target_container VARCHAR(10),
|
||||
preset_name VARCHAR(64),
|
||||
status INT,
|
||||
started_at DOUBLE,
|
||||
finished_at DOUBLE,
|
||||
duration_sec DOUBLE,
|
||||
avg_fps DOUBLE,
|
||||
avg_speed DOUBLE,
|
||||
avg_bitrate INT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
logging.info(
|
||||
f"MariaDB verbunden: {db_cfg['host']}:{db_cfg['port']}/"
|
||||
f"{db_cfg['db']}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"MariaDB Initialisierung fehlgeschlagen "
|
||||
f"({db_cfg['host']}:{db_cfg['port']}): {e}"
|
||||
)
|
||||
logging.warning("Statistiken werden ohne Datenbank ausgefuehrt")
|
||||
self._db_pool = None
|
||||
|
||||
async def _save_stats(self, job: ConversionJob) -> None:
|
||||
"""Speichert Konvertierungs-Ergebnis in MariaDB"""
|
||||
if self._db_pool is None:
|
||||
return
|
||||
stats = job.to_dict_stats()
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
INSERT INTO conversions (
|
||||
source_path, source_filename, source_size_bytes,
|
||||
source_duration_sec, source_frame_rate, source_frames_total,
|
||||
target_path, target_filename, target_size_bytes,
|
||||
target_container, preset_name, status,
|
||||
started_at, finished_at, duration_sec,
|
||||
avg_fps, avg_speed, avg_bitrate
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
stats["source_path"], stats["source_filename"],
|
||||
stats["source_size_bytes"], stats["source_duration_sec"],
|
||||
stats["source_frame_rate"], stats["source_frames_total"],
|
||||
stats["target_path"], stats["target_filename"],
|
||||
stats["target_size_bytes"], stats["target_container"],
|
||||
stats["preset_name"], stats["status"],
|
||||
stats["started_at"], stats["finished_at"],
|
||||
stats["duration_sec"], stats["avg_fps"],
|
||||
stats["avg_speed"], stats["avg_bitrate"],
|
||||
))
|
||||
|
||||
# Max-Eintraege bereinigen
|
||||
max_entries = self.config.settings.get(
|
||||
"statistics", {}
|
||||
).get("max_entries", 5000)
|
||||
await cur.execute(
|
||||
"DELETE FROM conversions WHERE id NOT IN ("
|
||||
"SELECT id FROM (SELECT id FROM conversions "
|
||||
"ORDER BY created_at DESC LIMIT %s) AS tmp)",
|
||||
(max_entries,)
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Statistik speichern fehlgeschlagen: {e}")
|
||||
|
||||
async def get_statistics(self, limit: int = 50,
|
||||
offset: int = 0) -> list[dict]:
|
||||
"""Liest Statistiken aus MariaDB"""
|
||||
if self._db_pool is None:
|
||||
return []
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor(aiomysql.DictCursor) as cur:
|
||||
await cur.execute(
|
||||
"SELECT * FROM conversions ORDER BY created_at DESC "
|
||||
"LIMIT %s OFFSET %s",
|
||||
(limit, offset),
|
||||
)
|
||||
rows = await cur.fetchall()
|
||||
# MariaDB-Typen JSON-kompatibel machen
|
||||
result = []
|
||||
for row in rows:
|
||||
entry = {}
|
||||
for k, v in row.items():
|
||||
if isinstance(v, Decimal):
|
||||
entry[k] = float(v)
|
||||
elif hasattr(v, "isoformat"):
|
||||
entry[k] = str(v)
|
||||
else:
|
||||
entry[k] = v
|
||||
result.append(entry)
|
||||
return result
|
||||
except Exception as e:
|
||||
logging.error(f"Statistik lesen fehlgeschlagen: {e}")
|
||||
return []
|
||||
|
||||
async def get_statistics_summary(self) -> dict:
|
||||
"""Zusammenfassung der Statistiken"""
|
||||
if self._db_pool is None:
|
||||
return {}
|
||||
try:
|
||||
pool = await self._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END),
|
||||
SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END),
|
||||
SUM(source_size_bytes),
|
||||
SUM(target_size_bytes),
|
||||
SUM(duration_sec),
|
||||
AVG(avg_fps),
|
||||
AVG(avg_speed)
|
||||
FROM conversions
|
||||
""")
|
||||
row = await cur.fetchone()
|
||||
if row:
|
||||
# Decimal -> float/int fuer JSON
|
||||
def _n(v, as_int=False):
|
||||
v = v or 0
|
||||
return int(v) if as_int else float(v)
|
||||
|
||||
return {
|
||||
"total": _n(row[0], True),
|
||||
"finished": _n(row[1], True),
|
||||
"failed": _n(row[2], True),
|
||||
"total_source_size": _n(row[3], True),
|
||||
"total_target_size": _n(row[4], True),
|
||||
"space_saved": _n(row[3], True) - _n(row[4], True),
|
||||
"total_duration": _n(row[5]),
|
||||
"avg_fps": round(float(row[6] or 0), 1),
|
||||
"avg_speed": round(float(row[7] or 0), 2),
|
||||
}
|
||||
return {}
|
||||
except Exception as e:
|
||||
logging.error(f"Statistik-Zusammenfassung fehlgeschlagen: {e}")
|
||||
return {}
|
||||
149
video-konverter/app/services/scanner.py
Normal file
149
video-konverter/app/services/scanner.py
Normal 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
|
||||
1033
video-konverter/app/services/tvdb.py
Normal file
1033
video-konverter/app/services/tvdb.py
Normal file
File diff suppressed because it is too large
Load diff
1899
video-konverter/app/static/css/style.css
Normal file
1899
video-konverter/app/static/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
video-konverter/app/static/icons/favicon.ico
Normal file
BIN
video-konverter/app/static/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
301
video-konverter/app/static/js/filebrowser.js
Normal file
301
video-konverter/app/static/js/filebrowser.js
Normal 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">↩</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}')">📁</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">🎥</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})">×</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);
|
||||
}
|
||||
3170
video-konverter/app/static/js/library.js
Normal file
3170
video-konverter/app/static/js/library.js
Normal file
File diff suppressed because it is too large
Load diff
200
video-konverter/app/static/js/websocket.js
Normal file
200
video-konverter/app/static/js/websocket.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* WebSocket-Client fuer Echtzeit-Updates
|
||||
* Verbindet sich mit dem Server und aktualisiert Dashboard dynamisch
|
||||
*/
|
||||
|
||||
let ws = null;
|
||||
let videoActive = {};
|
||||
let videoQueue = {};
|
||||
let reconnectTimer = null;
|
||||
|
||||
// WebSocket verbinden
|
||||
function connectWebSocket() {
|
||||
if (!window.WS_URL) return;
|
||||
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = function () {
|
||||
console.log("WebSocket verbunden:", WS_URL);
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
try {
|
||||
const packet = JSON.parse(event.data);
|
||||
|
||||
if (packet.data_flow !== undefined) {
|
||||
updateProgress(packet.data_flow);
|
||||
} else if (packet.data_convert !== undefined) {
|
||||
updateActiveConversions(packet.data_convert);
|
||||
} else if (packet.data_queue !== undefined) {
|
||||
updateQueue(packet.data_queue);
|
||||
} else if (packet.data_log !== undefined) {
|
||||
// Log-Nachrichten ans Benachrichtigungs-System weiterleiten
|
||||
if (typeof addNotification === "function") {
|
||||
addNotification(packet.data_log.message, packet.data_log.level);
|
||||
}
|
||||
} else if (packet.data_import !== undefined) {
|
||||
// Import-Fortschritt an Library weiterleiten
|
||||
if (typeof handleImportWS === "function") {
|
||||
handleImportWS(packet.data_import);
|
||||
}
|
||||
} else if (packet.data_library_scan !== undefined) {
|
||||
// Scan-Fortschritt an Library weiterleiten
|
||||
if (typeof handleScanWS === "function") {
|
||||
handleScanWS(packet.data_library_scan);
|
||||
}
|
||||
}
|
||||
// Globaler Progress-Balken aktualisieren
|
||||
if (typeof _updateGlobalProgress === "function") {
|
||||
_updateGlobalProgress(packet);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("WebSocket Nachricht parsen fehlgeschlagen:", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = function () {
|
||||
console.log("WebSocket getrennt, Reconnect in 3s...");
|
||||
reconnectTimer = setTimeout(connectWebSocket, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = function (err) {
|
||||
console.error("WebSocket Fehler:", err);
|
||||
};
|
||||
}
|
||||
|
||||
// === Aktive Konvertierungen ===
|
||||
|
||||
function updateActiveConversions(data) {
|
||||
const container = document.getElementById("active-conversions");
|
||||
if (!container) return;
|
||||
|
||||
// Entfernte Jobs loeschen
|
||||
for (const key in videoActive) {
|
||||
if (!(key in data)) {
|
||||
const elem = document.getElementById("convert_" + key);
|
||||
if (elem) elem.remove();
|
||||
delete videoActive[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Neue Jobs hinzufuegen
|
||||
for (const [key, video] of Object.entries(data)) {
|
||||
if (!videoActive[key]) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "video-card";
|
||||
card.id = "convert_" + key;
|
||||
card.innerHTML = `
|
||||
<h3 title="${video.source_path}">${video.source_file_name} → ${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();
|
||||
343
video-konverter/app/templates/admin.html
Normal file
343
video-konverter/app/templates/admin.html
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
{% 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) {
|
||||
showToast("Name und Pfad erforderlich", "error");
|
||||
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) {
|
||||
showToast("Fehler: " + data.error, "error");
|
||||
} else {
|
||||
document.getElementById("new-path-name").value = "";
|
||||
document.getElementById("new-path-path").value = "";
|
||||
showToast("Pfad hinzugefuegt", "success");
|
||||
loadLibraryPaths();
|
||||
}
|
||||
})
|
||||
.catch(e => showToast("Fehler: " + e, "error"));
|
||||
}
|
||||
|
||||
async function deletePath(pathId) {
|
||||
if (!await showConfirm("Scan-Pfad und alle zugehoerigen Daten loeschen?", {title: "Pfad loeschen", okText: "Loeschen", icon: "danger", danger: true})) return;
|
||||
fetch("/api/library/paths/" + pathId, {method: "DELETE"})
|
||||
.then(r => r.json())
|
||||
.then(() => { showToast("Pfad geloescht", "success"); loadLibraryPaths(); })
|
||||
.catch(e => showToast("Fehler: " + e, "error"));
|
||||
}
|
||||
|
||||
function scanPath(pathId) {
|
||||
fetch("/api/library/scan/" + pathId, {method: "POST"})
|
||||
.then(r => r.json())
|
||||
.then(data => showToast(data.message || "Scan gestartet", "success"))
|
||||
.catch(e => showToast("Fehler: " + e, "error"));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadLibraryPaths);
|
||||
</script>
|
||||
{% endblock %}
|
||||
405
video-konverter/app/templates/base.html
Normal file
405
video-konverter/app/templates/base.html
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
<!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="icon" href="/static/icons/favicon.ico" type="image/x-icon">
|
||||
<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="/dashboard" class="nav-link {% if request.path == '/dashboard' %}active{% endif %}">Dashboard</a>
|
||||
<a href="/library" class="nav-link {% if request.path.startswith('/library') %}active{% endif %}">Bibliothek</a>
|
||||
<a href="/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>
|
||||
|
||||
<!-- Globale Progress-Balken (mehrere gleichzeitig moeglich) -->
|
||||
<div id="global-progress-container" class="global-progress-container">
|
||||
<div id="gp-scan" class="global-progress" style="display:none">
|
||||
<div class="global-progress-info">
|
||||
<span class="gp-label">Scan</span>
|
||||
<span class="gp-detail text-muted"></span>
|
||||
</div>
|
||||
<div class="progress-container" style="height:4px">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gp-import" class="global-progress" style="display:none">
|
||||
<div class="global-progress-info">
|
||||
<span class="gp-label">Import</span>
|
||||
<span class="gp-detail text-muted"></span>
|
||||
</div>
|
||||
<div class="progress-container" style="height:4px">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="gp-convert" class="global-progress" style="display:none">
|
||||
<div class="global-progress-info">
|
||||
<span class="gp-label">Konvertierung</span>
|
||||
<span class="gp-detail text-muted"></span>
|
||||
</div>
|
||||
<div class="progress-container" style="height:4px">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Globaler Bestaetigungs-Dialog (ersetzt browser confirm()) -->
|
||||
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal modal-small">
|
||||
<div class="modal-header">
|
||||
<h2 id="confirm-title">Bestaetigung</h2>
|
||||
<button class="btn-close" onclick="closeConfirmModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:1.2rem">
|
||||
<div id="confirm-icon" style="text-align:center; font-size:3rem; margin-bottom:0.8rem">⚠</div>
|
||||
<div id="confirm-message" style="text-align:center; margin-bottom:1rem"></div>
|
||||
<div id="confirm-detail" style="text-align:center; font-size:0.85rem; color:#888; margin-bottom:1.2rem"></div>
|
||||
<div class="form-actions" style="justify-content:center">
|
||||
<button class="btn-danger" id="confirm-btn-ok" onclick="confirmAction()">Loeschen</button>
|
||||
<button class="btn-secondary" onclick="closeConfirmModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Globaler Eingabe-Dialog (ersetzt browser prompt()) -->
|
||||
<div id="prompt-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal modal-small">
|
||||
<div class="modal-header">
|
||||
<h2 id="prompt-title">Eingabe</h2>
|
||||
<button class="btn-close" onclick="closePromptModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:1.2rem">
|
||||
<div id="prompt-message" style="margin-bottom:0.8rem"></div>
|
||||
<input type="text" id="prompt-input" class="form-control" style="width:100%;margin-bottom:1rem"
|
||||
onkeydown="if(event.key==='Enter')submitPrompt()">
|
||||
<div class="form-actions" style="justify-content:center">
|
||||
<button class="btn-primary" id="prompt-btn-ok" onclick="submitPrompt()">OK</button>
|
||||
<button class="btn-secondary" onclick="closePromptModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<!-- Benachrichtigungs-Glocke -->
|
||||
<div id="notification-bell" class="notification-bell" onclick="toggleNotificationPanel()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
<span id="notification-badge" class="notification-badge" style="display:none">0</span>
|
||||
</div>
|
||||
|
||||
<!-- Log-Panel -->
|
||||
<div id="notification-panel" class="notification-panel" style="display:none">
|
||||
<div class="notification-header">
|
||||
<span>Server-Log</span>
|
||||
<div>
|
||||
<button class="btn-small btn-secondary" onclick="clearNotifications()">Alle loeschen</button>
|
||||
<button class="btn-close" onclick="toggleNotificationPanel()">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="notification-list" class="notification-list">
|
||||
<div class="notification-empty">Keine Nachrichten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === Benachrichtigungs-System ===
|
||||
const notifications = [];
|
||||
let unreadErrors = 0;
|
||||
|
||||
function toggleNotificationPanel() {
|
||||
const panel = document.getElementById("notification-panel");
|
||||
const isOpen = panel.style.display !== "none";
|
||||
panel.style.display = isOpen ? "none" : "flex";
|
||||
|
||||
if (!isOpen) {
|
||||
// Panel geoeffnet - Fehler als gelesen markieren
|
||||
unreadErrors = 0;
|
||||
updateBadge();
|
||||
}
|
||||
}
|
||||
|
||||
function updateBadge() {
|
||||
const badge = document.getElementById("notification-badge");
|
||||
const bell = document.getElementById("notification-bell");
|
||||
|
||||
if (unreadErrors > 0) {
|
||||
badge.textContent = unreadErrors > 99 ? "99+" : unreadErrors;
|
||||
badge.style.display = "";
|
||||
bell.classList.add("has-error");
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
bell.classList.remove("has-error");
|
||||
}
|
||||
}
|
||||
|
||||
function addNotification(msg, level = "info") {
|
||||
const time = new Date().toLocaleTimeString("de-DE", {hour: "2-digit", minute: "2-digit", second: "2-digit"});
|
||||
|
||||
notifications.unshift({msg, level, time});
|
||||
if (notifications.length > 100) notifications.pop();
|
||||
|
||||
if (level === "error" || level === "ERROR") {
|
||||
unreadErrors++;
|
||||
updateBadge();
|
||||
}
|
||||
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
const list = document.getElementById("notification-list");
|
||||
if (!notifications.length) {
|
||||
list.innerHTML = '<div class="notification-empty">Keine Nachrichten</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = notifications.map(n => {
|
||||
const cls = n.level.toLowerCase() === "error" ? "notification-item error" :
|
||||
n.level.toLowerCase() === "warning" ? "notification-item warning" :
|
||||
"notification-item";
|
||||
return `<div class="${cls}">
|
||||
<span class="notification-time">${n.time}</span>
|
||||
<span class="notification-msg">${escapeHtmlSimple(n.msg)}</span>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function clearNotifications() {
|
||||
notifications.length = 0;
|
||||
unreadErrors = 0;
|
||||
updateBadge();
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
function escapeHtmlSimple(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Log-Empfang per WebSocket (kein Polling mehr)
|
||||
// WebSocket sendet {data_log: {level, message}} - wird in websocket.js
|
||||
// oder hier abgefangen, je nachdem welche Seite geladen ist.
|
||||
let _logWs = null;
|
||||
|
||||
function connectLogWebSocket() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${location.host}/ws`;
|
||||
_logWs = new WebSocket(url);
|
||||
|
||||
_logWs.onmessage = function(event) {
|
||||
try {
|
||||
const packet = JSON.parse(event.data);
|
||||
if (packet.data_log) {
|
||||
addNotification(packet.data_log.message, packet.data_log.level);
|
||||
}
|
||||
// Globaler Progress-Balken
|
||||
_updateGlobalProgress(packet);
|
||||
} catch (e) {
|
||||
// JSON-Parse-Fehler ignorieren
|
||||
}
|
||||
};
|
||||
|
||||
_logWs.onclose = function() {
|
||||
setTimeout(connectLogWebSocket, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
// === Globale Progress-Balken (mehrere gleichzeitig) ===
|
||||
let _gpHideTimers = {};
|
||||
|
||||
function _gpShow(id, labelText, detailText, pct) {
|
||||
const el = document.getElementById("gp-" + id);
|
||||
if (!el) return;
|
||||
el.style.display = "";
|
||||
el.querySelector(".gp-label").textContent = labelText;
|
||||
el.querySelector(".gp-detail").textContent = detailText;
|
||||
el.querySelector(".progress-bar").style.width = pct + "%";
|
||||
_gpCancelHide(id);
|
||||
}
|
||||
|
||||
function _gpHide(id) {
|
||||
const el = document.getElementById("gp-" + id);
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
function _gpHideDelayed(id) {
|
||||
const el = document.getElementById("gp-" + id);
|
||||
if (el) el.querySelector(".progress-bar").style.width = "100%";
|
||||
_gpHideTimers[id] = setTimeout(() => _gpHide(id), 3000);
|
||||
}
|
||||
|
||||
function _gpCancelHide(id) {
|
||||
if (_gpHideTimers[id]) {
|
||||
clearTimeout(_gpHideTimers[id]);
|
||||
delete _gpHideTimers[id];
|
||||
}
|
||||
}
|
||||
|
||||
function _updateGlobalProgress(packet) {
|
||||
// Scan-Fortschritt
|
||||
if (packet.data_library_scan) {
|
||||
const d = packet.data_library_scan;
|
||||
if (d.status === "idle") {
|
||||
_gpHideDelayed("scan");
|
||||
return;
|
||||
}
|
||||
const pct = d.total > 0 ? Math.round((d.done / d.total) * 100) : 0;
|
||||
_gpShow("scan", "Scan", `${d.current || ""} (${d.done || 0}/${d.total || 0})`, pct);
|
||||
}
|
||||
|
||||
// Import-Fortschritt
|
||||
if (packet.data_import) {
|
||||
const d = packet.data_import;
|
||||
if (d.status === "done" || d.status === "error") {
|
||||
_gpHideDelayed("import");
|
||||
return;
|
||||
}
|
||||
const total = d.total || 1;
|
||||
const processed = d.processed || 0;
|
||||
let pct = (processed / total) * 100;
|
||||
if (d.bytes_total > 0 && processed < total) {
|
||||
pct += ((d.bytes_done || 0) / d.bytes_total) * (100 / total);
|
||||
}
|
||||
pct = Math.min(Math.round(pct), 100);
|
||||
let labelText = "Import";
|
||||
if (d.status === "analyzing") labelText = "Import-Analyse";
|
||||
else if (d.status === "embedding") labelText = "Metadaten";
|
||||
const detailText = d.current_file
|
||||
? `${d.current_file} (${processed}/${total})`
|
||||
: `${processed}/${total} Dateien`;
|
||||
_gpShow("import", labelText, detailText, pct);
|
||||
}
|
||||
|
||||
// Konvertierungs-Fortschritt
|
||||
if (packet.data_flow) {
|
||||
const d = packet.data_flow;
|
||||
const pct = d.loading || 0;
|
||||
const detailText = d.time_remaining ? `Verbleibend: ${d.time_remaining}` : `${pct.toFixed(1)}%`;
|
||||
_gpShow("convert", "Konvertierung", detailText, pct);
|
||||
}
|
||||
|
||||
// Konvertierung abgeschlossen (leere data_convert = nichts aktiv)
|
||||
if (packet.data_convert !== undefined && Object.keys(packet.data_convert).length === 0) {
|
||||
_gpHideDelayed("convert");
|
||||
}
|
||||
}
|
||||
|
||||
// === Globale Dialog-Funktionen (auf allen Seiten verfuegbar) ===
|
||||
|
||||
let _confirmResolve = null;
|
||||
let _promptResolve = null;
|
||||
let pendingConfirmAction = null;
|
||||
|
||||
/**
|
||||
* Zeigt einen Bestaetigungs-Dialog (ersetzt confirm()).
|
||||
* Gibt ein Promise zurueck das mit true/false aufgeloest wird.
|
||||
*/
|
||||
function showConfirm(message, {title = "Bestaetigung", detail = "", okText = "OK", icon = "warn", danger = false} = {}) {
|
||||
return new Promise(resolve => {
|
||||
_confirmResolve = resolve;
|
||||
document.getElementById("confirm-title").textContent = title;
|
||||
const iconEl = document.getElementById("confirm-icon");
|
||||
if (icon === "warn") {
|
||||
iconEl.innerHTML = '<span style="color:#f0ad4e">⚠</span>';
|
||||
} else if (icon === "danger" || icon === "delete") {
|
||||
iconEl.innerHTML = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" stroke-width="1.5"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
|
||||
} else if (icon === "info") {
|
||||
iconEl.innerHTML = '<span style="color:#4caf50">ℹ</span>';
|
||||
} else {
|
||||
iconEl.innerHTML = '<span>' + icon + '</span>';
|
||||
}
|
||||
document.getElementById("confirm-message").innerHTML = message;
|
||||
document.getElementById("confirm-detail").innerHTML = detail;
|
||||
const okBtn = document.getElementById("confirm-btn-ok");
|
||||
okBtn.textContent = okText;
|
||||
okBtn.className = danger ? "btn-danger" : "btn-primary";
|
||||
document.getElementById("confirm-modal").style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
function closeConfirmModal() {
|
||||
document.getElementById("confirm-modal").style.display = "none";
|
||||
if (_confirmResolve) { _confirmResolve(false); _confirmResolve = null; }
|
||||
pendingConfirmAction = null;
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (_confirmResolve) { _confirmResolve(true); _confirmResolve = null; }
|
||||
if (pendingConfirmAction) { pendingConfirmAction(); pendingConfirmAction = null; }
|
||||
document.getElementById("confirm-modal").style.display = "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt einen Eingabe-Dialog (ersetzt prompt()).
|
||||
* Gibt ein Promise zurueck das mit dem eingegebenen String oder null aufgeloest wird.
|
||||
*/
|
||||
function showPrompt(message, {title = "Eingabe", defaultValue = "", placeholder = "", okText = "OK"} = {}) {
|
||||
return new Promise(resolve => {
|
||||
_promptResolve = resolve;
|
||||
document.getElementById("prompt-title").textContent = title;
|
||||
document.getElementById("prompt-message").textContent = message;
|
||||
const input = document.getElementById("prompt-input");
|
||||
input.value = defaultValue;
|
||||
input.placeholder = placeholder;
|
||||
document.getElementById("prompt-btn-ok").textContent = okText;
|
||||
document.getElementById("prompt-modal").style.display = "flex";
|
||||
setTimeout(() => input.focus(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
function closePromptModal() {
|
||||
document.getElementById("prompt-modal").style.display = "none";
|
||||
if (_promptResolve) { _promptResolve(null); _promptResolve = null; }
|
||||
}
|
||||
|
||||
function submitPrompt() {
|
||||
const val = document.getElementById("prompt-input").value.trim();
|
||||
document.getElementById("prompt-modal").style.display = "none";
|
||||
if (_promptResolve) { _promptResolve(val || null); _promptResolve = null; }
|
||||
}
|
||||
|
||||
// === Globale Toast-Funktion (auf allen Seiten verfuegbar) ===
|
||||
function showToast(message, type = "info") {
|
||||
const container = document.getElementById("toast-container");
|
||||
if (!container) return;
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.classList.add("show"), 10);
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Nur Log-WebSocket starten wenn kein globaler WS existiert (Dashboard hat eigenen)
|
||||
if (!window.WS_URL) {
|
||||
connectLogWebSocket();
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
89
video-konverter/app/templates/dashboard.html
Normal file
89
video-konverter/app/templates/dashboard.html
Normal 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()">×</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()">×</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 %}
|
||||
530
video-konverter/app/templates/library.html
Normal file
530
video-konverter/app/templates/library.html
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
{% 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Schnellfilter / Presets -->
|
||||
<div class="filter-group filter-presets">
|
||||
<label>Schnellfilter</label>
|
||||
<select id="filter-preset" onchange="applyPreset()">
|
||||
<option value="">-- Alle anzeigen --</option>
|
||||
<option value="not_converted">Nicht konvertiert</option>
|
||||
<option value="old_formats">Alte Formate (kein AV1)</option>
|
||||
<option value="missing_episodes">Fehlende Episoden</option>
|
||||
</select>
|
||||
<div class="preset-actions">
|
||||
<button class="btn-small" onclick="saveCurrentFilter()" title="Aktuellen Filter speichern">Speichern</button>
|
||||
<button class="btn-small" onclick="setAsDefault()" title="Als Standard setzen">Standard</button>
|
||||
<button class="btn-small btn-danger" id="btn-delete-preset" onclick="deleteCurrentPreset()" title="Preset loeschen" style="display:none">Loeschen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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><input type="checkbox" id="filter-not-converted" onchange="applyFilters()"> Nicht konvertiert</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>
|
||||
|
||||
<div class="filter-group">
|
||||
<button class="btn-secondary btn-block" onclick="resetFilters()">Filter zuruecksetzen</button>
|
||||
</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()">×</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()">×</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 class="form-group" style="margin-top:0.5rem">
|
||||
<label style="display:inline-flex; align-items:center; gap:0.5rem; cursor:pointer">
|
||||
<input type="checkbox" id="tvdb-search-english" onchange="searchTvdb()">
|
||||
Englische Titel durchsuchen
|
||||
</label>
|
||||
</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()">×</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-primary" id="btn-convert-series" onclick="openConvertSeriesModal()">Serie konvertieren</button>
|
||||
<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-cleanup-series" onclick="cleanupSeriesFolder()">Alte Dateien loeschen</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()">×</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()">×</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()">×</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()">×</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()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0">
|
||||
<!-- Bestehende Import-Jobs -->
|
||||
<div id="import-existing" style="display:none; padding:0.8rem; border-bottom:1px solid #2a2a2a; background:#1a1a1a;">
|
||||
<div style="margin-bottom:0.5rem; font-size:0.85rem; color:#888;">Offene Import-Jobs:</div>
|
||||
<div id="import-jobs-list" style="display:flex; flex-wrap:wrap; gap:0.5rem;"></div>
|
||||
</div>
|
||||
<!-- 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: Serien-Zuordnung -->
|
||||
<div id="import-series-assign" style="display:none; padding:1rem;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem;">
|
||||
<h3 style="margin:0">Serien zuordnen</h3>
|
||||
<button class="btn-secondary" onclick="resetImport()">Abbrechen</button>
|
||||
</div>
|
||||
<div id="import-series-list"></div>
|
||||
</div>
|
||||
<!-- Schritt 3: Vorschau / Konflikte -->
|
||||
<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>
|
||||
<button class="btn-danger" onclick="deleteCurrentImportJob()" title="Job loeschen">Job loeschen</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()">×</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>
|
||||
<!-- Serie konvertieren Modal -->
|
||||
<div id="convert-series-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal modal-small">
|
||||
<div class="modal-header">
|
||||
<h2>Serie konvertieren</h2>
|
||||
<button class="btn-close" onclick="closeConvertSeriesModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:1rem">
|
||||
<div id="convert-series-status" style="margin-bottom:1rem"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Ziel-Codec</label>
|
||||
<select id="convert-target-codec">
|
||||
<option value="av1">AV1 (empfohlen)</option>
|
||||
<option value="hevc">HEVC / H.265</option>
|
||||
<option value="h264">H.264</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="convert-force-all">
|
||||
Alle Episoden neu konvertieren (auch bereits passende)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="convert-delete-old">
|
||||
Quelldateien nach Konvertierung loeschen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top:1rem">
|
||||
<button class="btn-primary" onclick="executeConvertSeries()">Konvertierung starten</button>
|
||||
<button class="btn-secondary" onclick="closeConvertSeriesModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bestaetigungs-Dialog -->
|
||||
<!-- Video-Player Modal -->
|
||||
<div id="player-modal" class="modal-overlay player-overlay" style="display:none">
|
||||
<div class="player-container">
|
||||
<div class="player-header">
|
||||
<span id="player-title">Video</span>
|
||||
<button class="btn-close" onclick="closePlayer()">×</button>
|
||||
</div>
|
||||
<video id="player-video" controls preload="metadata">
|
||||
Dein Browser unterstuetzt kein HTML5-Video.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import-Zuordnungs-Modal -->
|
||||
<div id="import-assign-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal modal-small">
|
||||
<div class="modal-header">
|
||||
<h2>Datei zuordnen</h2>
|
||||
<button class="btn-close" onclick="closeImportAssignModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:1rem">
|
||||
<div class="text-muted" style="margin-bottom:0.8rem;font-size:0.85rem">
|
||||
Datei: <strong id="import-assign-filename"></strong>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Serie suchen (TVDB)</label>
|
||||
<input type="text" id="import-assign-search" placeholder="Serienname..."
|
||||
oninput="debounceAssignSearch()"
|
||||
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
|
||||
</div>
|
||||
<div id="import-assign-results" class="tvdb-results" style="max-height:200px;overflow-y:auto"></div>
|
||||
|
||||
<div id="import-assign-selected" style="display:none; margin:0.5rem 0; padding:0.5rem; background:#1a3a1a; border:1px solid #2a5a2a; border-radius:5px">
|
||||
Ausgewaehlt: <strong id="import-assign-selected-name"></strong>
|
||||
<button class="btn-small btn-secondary" onclick="selectAssignSeries(null, '')" style="float:right;font-size:0.7rem">Loesen</button>
|
||||
</div>
|
||||
|
||||
<div id="import-assign-se-fields" style="display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; margin-top:0.8rem">
|
||||
<div class="form-group">
|
||||
<label>Staffel</label>
|
||||
<input type="number" id="import-assign-season" min="0" max="99" placeholder="1"
|
||||
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Episode</label>
|
||||
<input type="number" id="import-assign-episode" min="0" max="999" placeholder="1"
|
||||
style="width:100%;background:#252525;color:#ddd;border:1px solid #333;border-radius:5px;padding:0.4rem 0.6rem">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="margin-top:1rem">
|
||||
<button class="btn-primary" onclick="submitImportAssign()">Zuordnen</button>
|
||||
<button class="btn-secondary" onclick="closeImportAssignModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/library.js"></script>
|
||||
{% endblock %}
|
||||
47
video-konverter/app/templates/partials/stats_table.html
Normal file
47
video-konverter/app/templates/partials/stats_table.html
Normal 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 %}
|
||||
47
video-konverter/app/templates/statistics.html
Normal file
47
video-konverter/app/templates/statistics.html
Normal 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 %}
|
||||
82
video-konverter/cfg_defaults/presets.yaml
Normal file
82
video-konverter/cfg_defaults/presets.yaml
Normal 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: {}
|
||||
18
video-konverter/entrypoint.sh
Normal file
18
video-konverter/entrypoint.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/bash
|
||||
# Entrypoint: Kopiert Default-Konfigdateien ins gemountete cfg-Verzeichnis,
|
||||
# falls sie dort nicht existieren (z.B. bei Erstinstallation auf Unraid).
|
||||
|
||||
CFG_DIR="/opt/video-konverter/app/cfg"
|
||||
DEFAULTS_DIR="/opt/video-konverter/cfg_defaults"
|
||||
|
||||
# Alle Default-Dateien kopieren, wenn nicht vorhanden
|
||||
for file in "$DEFAULTS_DIR"/*; do
|
||||
filename=$(basename "$file")
|
||||
if [ ! -f "$CFG_DIR/$filename" ]; then
|
||||
echo "Kopiere Default-Config: $filename"
|
||||
cp "$file" "$CFG_DIR/$filename"
|
||||
fi
|
||||
done
|
||||
|
||||
# Anwendung starten
|
||||
exec python3 __main__.py
|
||||
6
video-konverter/requirements.txt
Normal file
6
video-konverter/requirements.txt
Normal 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
|
||||
Loading…
Reference in a new issue