Restefeger: Auth komplett raus, Serien-Flow + Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
Ampel / ampel (push) Successful in 55s
Ampel / ampel (push) Successful in 55s
AUTH ENTFERNT (Commander-Entscheid 24.07., KONZEPT §10): /token- und /api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT/ python-multipart, JWT_SECRET_KEY-Pflicht. Heimnetz-only, das UI hatte nie einen Login — die Auth-Oberflaeche war Placebo und die passlib/bcrypt- Falle brach die Ampel. Rate-Limit pro IP bleibt. Schnellstart laeuft jetzt ganz ohne .env-Pflichtwerte. Serien-Flow (Etappe-12-Kern, ARM-Wunde #395): - Rip-Dialog: Serienname + Staffel -> Ablage <Serie>/Season NN (jellyfin.org/docs Naming-Schema); tvshow.nfo + poster.jpg im Serien-Ordner, bei Staffel 2 nicht ueberschrieben. - Episoden-Matching per Laufzeitabgleich: HandBrakeCLI --scan ('+ duration:', handbrake.fr/docs) je MKV gegen TMDB-Staffel-Laufzeiten (GET /metadata/tv/{id}/season/{n}; tv-season-details-API). Ordnungserhaltend; komplette Staffel auf einer Disc klappt auch bei uniformen Anime-Laufzeiten (Sequenz-Stufe). Umbenannt wird NUR bei eindeutiger Zuordnung — sonst ehrliches Log. Mit Tests. Weitere Punkte: - Jellyfin/Emby-Bibliotheks-Refresh nach jedem fertigen Rip (POST /Library/Refresh, X-Emby-Token lt. jellyfin.org/docs) — URL/Key + Test-Knopf in Einstellungen -> Ripping. - Duplikat-Warnung: Disc-Fingerabdruck (jetzt Teil des Prescan-Ergebnisses + der Job-Metadaten) gegen die Historie; Karte zeigt 'bereits gerippt', Vollautomatik ueberspringt Duplikate. - 'Nur Hauptfilm' ECHT: makemkvcon info -> TINFO-Attr-9-Laufzeiten (usage.txt) -> laengster Titel -> mkv dev:X <nr>. Vorher wirkungsloses Setting; pro Rip im Dialog uebersteuerbar. Mit Tests. - OMDb-Treffer eingedeutscht via TMDB /find (external_source=imdb_id, de-DE; find-by-id-API). - Dashboard: Speicherplatz-Anzeige (amber < 60 GB) + CSV-Export (GET /jobs/export, Semikolon+BOM fuer deutsches Excel). - Metadaten-Seite entfernt (Abnahme durch Commander-Auftrag) inkl. Placebo-Endpoints /metadata/lookup (scannte Dummy-Device) und /metadata/confirm (schrieb nie gelesenen Cache-Key). - Doppel-Jahr-Fix: 'X (2009) (2009)' in Log und Ordnernamen. - Remote-Worker-Blocker: redis (6379) + postgres (5432) waren NIE veroeffentlicht — kein Remote-Worker konnte sich je verbinden. Ports jetzt offen (Heimnetz-Kompromiss, kommentiert) + API_URL fuer Worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+2
-3
@@ -4,9 +4,8 @@
|
|||||||
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
||||||
POSTGRES_PASSWORD=rippy
|
POSTGRES_PASSWORD=rippy
|
||||||
|
|
||||||
# JWT-Signierschlüssel — PFLICHT, API startet sonst nicht.
|
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||||
# Erzeugen: openssl rand -hex 32
|
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.)
|
||||||
JWT_SECRET_KEY=
|
|
||||||
|
|
||||||
# Metadaten-APIs
|
# Metadaten-APIs
|
||||||
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
||||||
|
|||||||
+13
-2
@@ -35,8 +35,8 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
|
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
|
||||||
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
|
||||||
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
|
||||||
| JWT-Auth (Access 15min/Refresh 7 Tage) | ✔ M | | |
|
| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
|
||||||
| Rate-Limiting (100/min pro API-Key) | ✔ M | | |
|
| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
|
||||||
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
|
||||||
| React-UI (statisch via Nginx) | ✔ M | | |
|
| React-UI (statisch via Nginx) | ✔ M | | |
|
||||||
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
| Proxmox LXC Template + Ansible Playbooks | ✔ M | | |
|
||||||
@@ -175,3 +175,14 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
|
|||||||
- **23./24.07.2026 — udev → ioctl:** Der im Konzept beschriebene udev-Daemon
|
- **23./24.07.2026 — udev → ioctl:** Der im Konzept beschriebene udev-Daemon
|
||||||
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
|
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
|
||||||
Kernel-ioctl (3 s) — gleiches Verhalten, universell lauffähig.
|
Kernel-ioctl (3 s) — gleiches Verhalten, universell lauffähig.
|
||||||
|
- **24.07.2026 — AUTH GESTRICHEN (Commander-Entscheid):** Das Muss-Feature
|
||||||
|
„JWT-Auth" ist komplett entfernt (Endpoints, auth.py, Abhängigkeiten,
|
||||||
|
Env-Pflicht). Begründung: Rippy läuft ausschließlich im Heimnetz, das UI
|
||||||
|
hatte nie einen Login-Flow — die Auth-Oberfläche war Placebo und die
|
||||||
|
passlib/bcrypt-Abhängigkeit hat die CI-Ampel gebrochen. Rate-Limiting
|
||||||
|
(pro IP) bleibt. Wer Rippy je nach außen öffnet, stellt einen
|
||||||
|
Reverse-Proxy mit eigener Auth davor (z. B. Authelia/Caddy basicauth).
|
||||||
|
- **24.07.2026 — Serien-Flow:** Staffel-Ablage <Serie>/Season NN plus
|
||||||
|
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
|
||||||
|
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
|
||||||
|
EINDEUTIGER Zuordnung wird umbenannt).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Voraussetzungen: Docker + Docker Compose, ein optisches Laufwerk am Host.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> rippy && cd rippy
|
git clone <repo-url> rippy && cd rippy
|
||||||
cp .env.example .env # JWT_SECRET_KEY eintragen (openssl rand -hex 32)
|
cp .env.example .env # optional: API-Keys — geht auch später im UI
|
||||||
mkdir -p /srv/rippy/media # Ablage-Basis (anpassbar in docker-compose.yml)
|
mkdir -p /srv/rippy/media # Ablage-Basis (anpassbar in docker-compose.yml)
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
@@ -50,9 +50,15 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
|||||||
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
|
du beim Start (Filme/Serien/Musik/eigener Pfad, inkl. Netzwerk-Ziele) —
|
||||||
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
|
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
|
||||||
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
|
der Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner.
|
||||||
Vor dem Start prüft Rippy den freien Platz gegen die Disc-Größe. Alle
|
Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
|
||||||
Tonspuren und Untertitel bleiben erhalten (wichtig für Anime/O-Ton).
|
gerippte Discs erkennt Rippy am Fingerabdruck und warnt. Vor dem Start
|
||||||
Audio-CDs laufen über abcde → FLAC + MusicBrainz.
|
prüft Rippy den freien Platz gegen die Disc-Größe. Alle Tonspuren und
|
||||||
|
Untertitel bleiben erhalten (wichtig für Anime/O-Ton). Audio-CDs laufen
|
||||||
|
über abcde → FLAC + MusicBrainz.
|
||||||
|
**Serien**: Im Dialog „Serien" wählen → Serienname + Staffel angeben —
|
||||||
|
Ablage wird `<Serie>/Season NN`, und die Episoden werden nach dem Rip
|
||||||
|
per Laufzeitabgleich (TMDB) automatisch zu „Serie S01E02.mkv" benannt
|
||||||
|
(nur bei eindeutiger Zuordnung — sonst bleiben die Namen, mit Log).
|
||||||
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
|
||||||
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
|
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
|
||||||
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
|
||||||
@@ -62,7 +68,9 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
|
|||||||
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
|
Plex oder keins. Fertige Rips heißen dann „Titel (Jahr)" statt
|
||||||
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
|
Job-UUID; für Jellyfin/Emby/Kodi legt Rippy zusätzlich movie.nfo +
|
||||||
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
|
poster.jpg dazu (Kodi-NFO-Schema, lesen alle drei nativ). Plex nutzt
|
||||||
nur die Benennung.
|
nur die Benennung. **Jellyfin/Emby**: Server-URL + API-Key eintragen,
|
||||||
|
dann stößt Rippy nach jedem fertigen Rip sofort einen Bibliotheks-Scan
|
||||||
|
an — Disc rein, Film erscheint im Server.
|
||||||
5. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
5. **4K-UHD**: braucht ein LibreDrive-fähiges Laufwerk (MakeMKV-Forum:
|
||||||
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
|
||||||
Laufwerk. ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
Laufwerk. ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
|
||||||
@@ -121,7 +129,6 @@ eingebaute CPU-Worker alles selbst — Rippy bleibt All-in-one.
|
|||||||
|
|
||||||
| Variable | Pflicht | Zweck |
|
| Variable | Pflicht | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `JWT_SECRET_KEY` | ✔ | Signierschlüssel (openssl rand -hex 32) |
|
|
||||||
| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
| `TMDB_API_KEY` | empfohlen | Metadaten (deutsche Texte) — alternativ im UI/Wizard; beide Key-Arten gehen (v3-Schlüssel und v4-Token) |
|
||||||
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
|
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
|
||||||
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
|
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
|
||||||
|
|||||||
+53
-24
@@ -361,28 +361,57 @@ auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Ideen-Katalog (24.07.2026, priorisiert) — Lücken, die noch offen sind
|
## Etappe 15 (24.07.2026): Restefeger — der Ideen-Katalog wird abgearbeitet
|
||||||
|
|
||||||
1. **Serien-Staffel-Flow**: Beim Rippen einer Serien-Disc Staffel/Disc-Nr
|
**Commander-Auftrag:** „Arbeite alles ab, abgesehen von Auth — das ist
|
||||||
abfragen → Ablage `Show/Season 02/`, Episoden-Matching wie Etappe 12.
|
irrelevant und kann komplett raus."
|
||||||
2. **Duplikat-Warnung**: Disc-Fingerprint schon in der Job-Historie →
|
|
||||||
„schon am X gerippt — trotzdem?" (Fingerprint-Infrastruktur existiert).
|
- [x] **AUTH KOMPLETT ENTFERNT** (Commander-Entscheid): /token- und
|
||||||
3. **Jellyfin-Bibliotheks-Refresh** nach Ablage (API-Key + URL in den
|
/api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT,
|
||||||
Einstellungen, POST /Library/Refresh) — der letzte Schritt zur
|
JWT_SECRET_KEY-Pflicht — alles raus (KONZEPT §10). Der Schnellstart
|
||||||
Null-Klick-Kette Disc→Bildschirm.
|
braucht keine .env-Pflichtwerte mehr. Rate-Limit pro IP bleibt.
|
||||||
4. **Track-Auswahl vor dem Rip** (awaiting_selection, Etappe 12) — spart
|
- [x] **Serien-Staffel-Flow**: Im Rip-Dialog Serienname + Staffel →
|
||||||
bei Bonus-Material-Discs Stunden.
|
Ablage `<Serie>/Season NN`; tvshow.nfo + poster.jpg landen im
|
||||||
5. **Auth vor schreibende Endpoints** + Login-Flow im UI (JWT existiert).
|
Serien-Ordner (werden bei Staffel 2 nicht überschrieben).
|
||||||
6. **Speicherplatz-Kachel im Dashboard** (Media/Arbeitsverzeichnis als
|
- [x] **Episoden-Erkennung per Laufzeitabgleich** (ARM-Wunde #395):
|
||||||
Balken — /system/info liefert die Daten schon).
|
Datei-Laufzeiten (HandBrake-Scan) gegen TMDB-Episoden-Laufzeiten,
|
||||||
7. **Rip-Historie exportieren** (CSV/JSON) für die Sammlung-Übersicht.
|
ordnungserhaltend; komplette Staffel auf einer Disc geht auch bei
|
||||||
8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile —
|
uniformen Laufzeiten. Umbenannt wird NUR bei eindeutiger Zuordnung
|
||||||
die `theme === 'dark' ? … : …`-Kaskaden sind der größte UI-Schuldenberg.
|
(„Serie S01E02.mkv"), sonst bleiben die Namen + ehrliches Log.
|
||||||
9. **Nativer Windows-Worker (ohne Docker)**: Endnutzer installieren kein
|
- [x] **Jellyfin/Emby-Bibliotheks-Refresh** nach jedem fertigen Rip
|
||||||
Docker Desktop nur für den Transcode-Worker. Realistischer Weg: ein
|
(POST /Library/Refresh, X-Emby-Token) — URL/Key + Test-Knopf in den
|
||||||
PyInstaller-Paket (Celery solo-Pool + HandBrakeCLI.exe gebündelt) als
|
Einstellungen. Die Null-Klick-Kette Disc→Bildschirm steht.
|
||||||
Windows-Dienst, Anbindung wie gehabt über Redis. Aufwand: mittel;
|
- [x] **Duplikat-Warnung**: Disc-Fingerabdruck gegen die Job-Historie —
|
||||||
Celery unter Windows nur im solo/threads-Pool verlässlich.
|
Hinweis auf der Disc-Karte, Vollautomatik überspringt Duplikate.
|
||||||
10. **Deutsche Texte auch für OMDb-Treffer**: OMDb liefert nur Englisch.
|
- [x] **„Nur Hauptfilm" ECHT**: das Setting war wirkungslos — jetzt
|
||||||
Wenn OMDb/Jikan die Quelle war, per TMDB-Find (imdb_id → TMDB-Eintrag,
|
Info-Lauf → längster Titel → nur der wird gerippt; pro Rip im
|
||||||
language=de-DE) die deutsche Beschreibung nachladen.
|
Dialog übersteuerbar. (Volle Track-Tabelle bleibt Ausbaustufe.)
|
||||||
|
- [x] **Deutsche Texte für OMDb-Treffer** via TMDB /find (IMDb-ID → de-DE).
|
||||||
|
- [x] **Speicherplatz in der Dashboard-Leiste** (amber unter 60 GB frei).
|
||||||
|
- [x] **CSV-Export** der Job-Historie (Semikolon+BOM, Excel-tauglich).
|
||||||
|
- [x] **Metadaten-Seite entfernt** (+ Placebo-Endpoints /metadata/lookup
|
||||||
|
und /metadata/confirm — lookup scannte ein Dummy-Device).
|
||||||
|
- [x] **Doppel-Jahr-Fix** („X (2009) (2009)" in Log/Ordnernamen).
|
||||||
|
- [x] **Remote-Worker-Blocker**: redis/postgres waren nie veröffentlicht —
|
||||||
|
kein Remote-Worker konnte sich je verbinden. Ports 6379/5432 jetzt
|
||||||
|
offen (Heimnetz-Kompromiss, dokumentiert) + API_URL für Worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ideen-Katalog (Rest) — bewusst offen
|
||||||
|
|
||||||
|
1. **Volle Track-Auswahl-Tabelle vor dem Rip** (awaiting_selection):
|
||||||
|
Scan-Task → Titel-Tabelle im UI → Auswahl → Rip. Der 80%-Fall ist mit
|
||||||
|
„Nur Hauptfilm" abgedeckt; die Tabelle lohnt für Bonus-Material-Discs.
|
||||||
|
2. **Nativer Windows-Worker (ohne Docker)**: PyInstaller-Paket (Celery
|
||||||
|
solo-Pool + HandBrakeCLI.exe gebündelt) als Windows-Dienst; verbindet
|
||||||
|
sich über die jetzt offenen Ports (6379/5432/8000). Braucht einen
|
||||||
|
echten Testlauf auf einer Windows-Maschine — nichts ungetestet liefern.
|
||||||
|
3. **Design 2.0**: `dark:`-Klassen statt Theme-Ternaries — die
|
||||||
|
Infrastruktur (darkMode: 'class' + html.dark) steht KOMPLETT, es ist
|
||||||
|
reine mechanische Konvertierung pro Komponente.
|
||||||
|
4. **AI-Box als VAAPI-Transcode-Worker**: Ports sind offen, Compose steht
|
||||||
|
(deploy/remote-transcode-worker.yml) — es fehlt nur der NFS/SMB-Export
|
||||||
|
der Rippy-Ablage an die AI-Box (Infra-Entscheid auf der VM).
|
||||||
|
5. **Kodi-Bibliotheks-Refresh** (JSON-RPC VideoLibrary.Scan) analog zu
|
||||||
|
Jellyfin/Emby.
|
||||||
|
|||||||
+35
-1
@@ -1,6 +1,40 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v3.3 — Praxis-Feedback-Runde (24.07.2026, Claude)
|
## Aktueller Stand: v3.4 — Restefeger: der Ideen-Katalog ist abgearbeitet (24.07.2026, Claude)
|
||||||
|
|
||||||
|
**Commander-Entscheid: AUTH IST KOMPLETT RAUS** (Heimnetz-only; das UI hatte
|
||||||
|
nie einen Login, die Endpoints waren Placebo, passlib/bcrypt brach die Ampel).
|
||||||
|
/token + /api-keys + auth.py + Abhängigkeiten entfernt, JWT_SECRET_KEY ist
|
||||||
|
keine Pflicht mehr — der Schnellstart läuft ohne .env-Zwang. KONZEPT §10.
|
||||||
|
|
||||||
|
**Neu in v3.4 (Details in ROADMAP Etappe 15):**
|
||||||
|
- **Serien-Flow**: Rip-Dialog fragt Serienname + Staffel → Ablage
|
||||||
|
`<Serie>/Season NN`, tvshow.nfo/poster im Serien-Ordner, und die
|
||||||
|
**Episoden werden per Laufzeitabgleich (TMDB) erkannt und benannt**
|
||||||
|
(„Serie S01E02.mkv") — nur bei eindeutiger Zuordnung, sonst ehrliches Log.
|
||||||
|
Komplette Staffel auf einer Disc klappt auch bei uniformen Anime-Laufzeiten.
|
||||||
|
- **Jellyfin/Emby-Refresh** nach jedem fertigen Rip (URL/API-Key +
|
||||||
|
Test-Knopf in Einstellungen → Ripping) — Disc rein, Film erscheint im
|
||||||
|
Server, null Klicks dazwischen.
|
||||||
|
- **Duplikat-Warnung** per Disc-Fingerabdruck (Karte zeigt „bereits
|
||||||
|
gerippt", Vollautomatik überspringt).
|
||||||
|
- **„Nur Hauptfilm" funktioniert jetzt wirklich** (Info-Lauf → längster
|
||||||
|
Titel; pro Rip im Dialog wählbar). Vorher wirkungsloses Setting.
|
||||||
|
- **OMDb-Treffer werden eingedeutscht** (TMDB /find über die IMDb-ID).
|
||||||
|
- Dashboard: **Speicherplatz-Anzeige** (amber < 60 GB) + **CSV-Export**.
|
||||||
|
- **Metadaten-Seite entfernt** (Korrektur-Popup ist der einzige Weg) samt
|
||||||
|
Placebo-Endpoints; Doppel-Jahr „(2009) (2009)" gefixt.
|
||||||
|
- **Remote-Worker-Blocker behoben**: redis/postgres waren NIE veröffentlicht
|
||||||
|
— Ports 6379/5432 jetzt offen, API_URL für Worker gesetzt. Damit sind
|
||||||
|
AI-Box/Windows-Worker überhaupt erst anschließbar.
|
||||||
|
|
||||||
|
**Bewusst offen** (ROADMAP „Ideen-Katalog (Rest)"): volle Track-Tabelle,
|
||||||
|
nativer Windows-Worker (braucht Testlauf auf Ziel-Hardware), Design-2.0-
|
||||||
|
Konvertierung (Infrastruktur steht), AI-Box-NFS-Export, Kodi-Refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorheriger Stand: v3.3 — Praxis-Feedback-Runde (24.07.2026, Claude)
|
||||||
|
|
||||||
**Zwei echte Bugs mit Beweis gefixt:**
|
**Zwei echte Bugs mit Beweis gefixt:**
|
||||||
1. **SMB-Mount „Unable to apply new capability set"**: mount.cifs hebt
|
1. **SMB-Mount „Unable to apply new capability set"**: mount.cifs hebt
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ services:
|
|||||||
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
|
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
|
||||||
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
|
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
|
||||||
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
|
||||||
|
# Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
|
||||||
|
- API_URL=http://${RIPPY_HOST}:8000
|
||||||
volumes:
|
volumes:
|
||||||
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
|
||||||
- /mnt/rippy/media:/app/media
|
- /mnt/rippy/media:/app/media
|
||||||
|
|||||||
+15
-4
@@ -13,10 +13,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||||
- THETVDB_API_KEY=${THETVDB_API_KEY}
|
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||||
- OMDB_API_KEY=${OMDB_API_KEY}
|
- OMDB_API_KEY=${OMDB_API_KEY:-}
|
||||||
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
|
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
|
||||||
@@ -77,6 +76,9 @@ services:
|
|||||||
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
|
||||||
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
|
||||||
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
|
||||||
|
# Für Serien-Episoden-Matching: der Worker fragt die API nach den
|
||||||
|
# Episoden-Laufzeiten der Staffel (TMDB).
|
||||||
|
- API_URL=http://api:8000
|
||||||
- LOG_LEVEL=INFO
|
- LOG_LEVEL=INFO
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
@@ -127,6 +129,12 @@ services:
|
|||||||
- POSTGRES_USER=rippy
|
- POSTGRES_USER=rippy
|
||||||
- POSTGRES_PASSWORD=rippy
|
- POSTGRES_PASSWORD=rippy
|
||||||
- POSTGRES_DB=rippy
|
- POSTGRES_DB=rippy
|
||||||
|
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||||
|
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||||
|
# ports: konnte sich NIE ein Remote-Worker anbinden. Heimnetz-Kompromiss,
|
||||||
|
# wie die Klartext-Credentials (README).
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
@@ -140,6 +148,9 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
# Siehe postgres: Pflicht für Remote-Worker (Celery-Broker).
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
"""JWT-Auth-Module für Rippy API."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
import jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
|
|
||||||
# Passwort-Hashing-Kontext
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
# Geheimer Schlüssel für JWT — MUSS konfiguriert sein (.env: JWT_SECRET_KEY).
|
|
||||||
# Review-Fund 22.07.: der frühere Zufalls-Fallback erzeugte PRO PROZESS einen
|
|
||||||
# neuen Schlüssel → jeder Neustart/zweite Worker invalidierte alle Tokens.
|
|
||||||
# Lieber laut scheitern als still kaputt sein.
|
|
||||||
if not settings.jwt_secret_key:
|
|
||||||
raise RuntimeError(
|
|
||||||
"JWT_SECRET_KEY ist nicht gesetzt (.env). Ohne festen Schlüssel wären "
|
|
||||||
"alle Tokens nach jedem Neustart ungültig — Start verweigert."
|
|
||||||
)
|
|
||||||
SECRET_KEY = settings.jwt_secret_key
|
|
||||||
ALGORITHM = "HS256"
|
|
||||||
|
|
||||||
# Token-Lifetimes
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 15
|
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verifiziere Passwort."""
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password: str) -> str:
|
|
||||||
"""Hash Passwort."""
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: Dict, expires_delta: timedelta = None) -> str:
|
|
||||||
"""Erstelle Access Token (Default 15 min)."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
||||||
expire = datetime.now(timezone.utc) + delta
|
|
||||||
to_encode.update({"exp": expire, "type": "access"})
|
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: Dict) -> str:
|
|
||||||
"""Erstelle Refresh Token (7 Tage)."""
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
|
||||||
to_encode.update({"exp": expire, "type": "refresh"})
|
|
||||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_token(token: str) -> Optional[Dict]:
|
|
||||||
"""Dekodiere Token (None bei abgelaufen/ungültig)."""
|
|
||||||
try:
|
|
||||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
except jwt.ExpiredSignatureError:
|
|
||||||
return None
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_access_token(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token ein gültiges Access Token ist."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
return bool(payload and payload.get("type") == "access")
|
|
||||||
|
|
||||||
|
|
||||||
def is_refresh_token(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token ein gültiges Refresh Token ist."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
return bool(payload and payload.get("type") == "refresh")
|
|
||||||
|
|
||||||
|
|
||||||
# Token-Blacklist für Logout: Token → Ablauf-Zeitstempel (exp).
|
|
||||||
# Bewusste MVP-Grenze: in-memory = pro Prozess (siehe SAVEPOINT.md).
|
|
||||||
# Review-Fund 22.07.: das frühere cleanup löschte die GESAMTE Blacklist —
|
|
||||||
# Logout war ein Placebo. Jetzt fliegen nur abgelaufene Tokens raus
|
|
||||||
# (die sind eh ungültig, decode_token lehnt sie ab).
|
|
||||||
token_blacklist: Dict[str, float] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def add_to_blacklist(token: str) -> None:
|
|
||||||
"""Füge gültigen Token zur Blacklist hinzu (bis zu seinem Ablauf)."""
|
|
||||||
payload = decode_token(token)
|
|
||||||
if payload:
|
|
||||||
token_blacklist[token] = float(payload.get("exp", time.time()))
|
|
||||||
|
|
||||||
|
|
||||||
def is_blacklisted(token: str) -> bool:
|
|
||||||
"""Prüfe ob Token auf der Blacklist steht."""
|
|
||||||
return token in token_blacklist
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_blacklist() -> None:
|
|
||||||
"""Entferne NUR abgelaufene Tokens von der Blacklist."""
|
|
||||||
now = time.time()
|
|
||||||
for token in [t for t, exp in token_blacklist.items() if exp <= now]:
|
|
||||||
del token_blacklist[token]
|
|
||||||
@@ -95,6 +95,42 @@ class TMDBClient:
|
|||||||
"""Hole Serien-Details."""
|
"""Hole Serien-Details."""
|
||||||
params = {"language": "de-DE"}
|
params = {"language": "de-DE"}
|
||||||
return self._request(f"tv/{tv_id}", params)
|
return self._request(f"tv/{tv_id}", params)
|
||||||
|
|
||||||
|
def get_tv_season(self, tv_id: int, season: int) -> Optional[Dict]:
|
||||||
|
"""Staffel-Details inkl. Episoden-Laufzeiten (Basis fürs
|
||||||
|
Episoden-Matching; developer.themoviedb.org/reference/tv-season-details)."""
|
||||||
|
return self._request(f"tv/{tv_id}/season/{season}", {"language": "de-DE"})
|
||||||
|
|
||||||
|
def find_by_imdb(self, imdb_id: str) -> Optional[Dict]:
|
||||||
|
"""IMDb-ID → deutscher TMDB-Eintrag (/find, external_source=imdb_id;
|
||||||
|
developer.themoviedb.org/reference/find-by-id).
|
||||||
|
|
||||||
|
Zweck: OMDb liefert nur englische Texte — über die IMDb-ID holt sich
|
||||||
|
Rippy Titel/Beschreibung/Poster auf Deutsch nach (Commander-Wunsch).
|
||||||
|
"""
|
||||||
|
result = self._request(
|
||||||
|
f"find/{imdb_id}",
|
||||||
|
{"external_source": "imdb_id", "language": "de-DE"},
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
for schluessel, typ, titel_feld, datum_feld in (
|
||||||
|
("movie_results", "movie", "title", "release_date"),
|
||||||
|
("tv_results", "tv", "name", "first_air_date"),
|
||||||
|
):
|
||||||
|
eintraege = result.get(schluessel) or []
|
||||||
|
if eintraege:
|
||||||
|
e = eintraege[0]
|
||||||
|
datum = e.get(datum_feld) or ""
|
||||||
|
return {
|
||||||
|
"type": typ,
|
||||||
|
"tmdb_id": e.get("id"),
|
||||||
|
"title": e.get(titel_feld) or "",
|
||||||
|
"overview": e.get("overview") or "",
|
||||||
|
"poster_path": e.get("poster_path") or "",
|
||||||
|
"year": int(datum[:4]) if datum[:4].isdigit() else None,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
def get_movie_images(self, movie_id: int) -> Dict[str, str]:
|
||||||
"""Hole Poster/Fanart URLs."""
|
"""Hole Poster/Fanart URLs."""
|
||||||
|
|||||||
@@ -24,13 +24,9 @@ class Settings(BaseSettings):
|
|||||||
# Logging
|
# Logging
|
||||||
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
log_level: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$", description="Log Level")
|
||||||
|
|
||||||
# JWT — PFLICHT (auth.py verweigert den Start ohne; kein Zufalls-Fallback mehr)
|
# JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
|
||||||
jwt_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)")
|
# Heimnetz-only, es gab nie einen Login-Flow im UI — siehe KONZEPT §10.
|
||||||
|
|
||||||
# Admin-Login (MVP — vorher hartkodiert admin/rippy123 in main.py)
|
|
||||||
admin_username: str = Field(default="admin", description="Admin-Benutzername")
|
|
||||||
admin_password: str = Field(default="rippy123", description="Admin-Passwort (per .env ÄNDERN!)")
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -48,10 +48,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
|
|||||||
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
"MUSICBRAINZ_USER nicht gesetzt. Öffentlicher Zugriff wird verwendet."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional: JWT Secret (wird generiert, wenn nicht gesetzt)
|
|
||||||
if not settings.jwt_secret_key:
|
|
||||||
warnings.append(
|
|
||||||
"JWT_SECRET_KEY nicht gesetzt. Auto-Generierung wird verwendet."
|
|
||||||
)
|
|
||||||
|
|
||||||
return settings, warnings
|
return settings, warnings
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Pytest-Setup für API-Tests: flache Modul-Imports + Test-Secret."""
|
"""Pytest-Setup für API-Tests: flache Modul-Imports."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
sys.path.insert(0, os.path.dirname(__file__))
|
||||||
|
|
||||||
# auth.py verweigert den Start ohne JWT_SECRET_KEY (gewollt) — Tests bringen ihres mit.
|
|
||||||
os.environ.setdefault("JWT_SECRET_KEY", "test-geheimnis-nur-fuer-tests")
|
|
||||||
|
|||||||
+157
-151
@@ -17,39 +17,23 @@ import notify
|
|||||||
from celery_client import celery_client, start_rip
|
from celery_client import celery_client, start_rip
|
||||||
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
from detection import CDS_DISC_OK, CDS_NO_DISC, CDS_TRAY_OPEN, drive_status
|
||||||
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
|
|
||||||
from config import settings
|
|
||||||
from config_validation import validate_config, ConfigValidationError
|
from config_validation import validate_config, ConfigValidationError
|
||||||
from cache import init_cache, set as cache_set
|
from cache import init_cache
|
||||||
from auth import (
|
from ratelimit import check_rate_limit, get_rate_limit_remaining
|
||||||
create_access_token,
|
|
||||||
create_refresh_token,
|
|
||||||
decode_token,
|
|
||||||
is_blacklisted,
|
|
||||||
add_to_blacklist,
|
|
||||||
)
|
|
||||||
from ratelimit import (
|
|
||||||
check_rate_limit,
|
|
||||||
get_rate_limit_remaining,
|
|
||||||
validate_api_key,
|
|
||||||
api_keys,
|
|
||||||
create_api_key as ratelimit_create_api_key,
|
|
||||||
delete_api_key as ratelimit_delete_api_key,
|
|
||||||
)
|
|
||||||
from prescan import PreScan
|
from prescan import PreScan
|
||||||
from nfo_generator import NFOGenerator
|
from nfo_generator import NFOGenerator
|
||||||
from image_downloader import ImageDownloader
|
from image_downloader import ImageDownloader
|
||||||
|
|
||||||
|
# Auth (JWT/Login/API-Keys) KOMPLETT entfernt — Commander-Entscheid 24.07.2026:
|
||||||
|
# Rippy läuft ausschließlich im Heimnetz, die Endpoints schützten ohnehin
|
||||||
|
# nichts (kein Login-Flow im UI) und waren damit Placebo-Oberfläche.
|
||||||
|
# Dokumentiert in KONZEPT.md Abschnitt 10.
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Rippy API",
|
title="Rippy API",
|
||||||
description="API für das automatische Ripping-System",
|
description="API für das automatische Ripping-System",
|
||||||
version="1.0.0"
|
version="1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
# OAuth2 Scheme
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
@@ -76,6 +60,26 @@ async def startup_event():
|
|||||||
DISC_CACHE: Dict[str, Dict] = {}
|
DISC_CACHE: Dict[str, Dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _duplikat_suchen(fingerprint: str):
|
||||||
|
"""Wurde eine Disc mit diesem Fingerabdruck schon erfolgreich gerippt?"""
|
||||||
|
if not fingerprint:
|
||||||
|
return None
|
||||||
|
for job in db.list_jobs(200):
|
||||||
|
if job.get("status") != "completed":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if meta.get("fingerprint") == fingerprint:
|
||||||
|
return {
|
||||||
|
"job_id": job["id"],
|
||||||
|
"title": job.get("title"),
|
||||||
|
"finished_at": job["finished_at"].isoformat() if job.get("finished_at") else None,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _auto_prescan(pfad: str):
|
async def _auto_prescan(pfad: str):
|
||||||
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
|
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
|
||||||
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
||||||
@@ -85,12 +89,23 @@ async def _auto_prescan(pfad: str):
|
|||||||
prescan = PreScan()
|
prescan = PreScan()
|
||||||
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
|
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
|
||||||
DISC_CACHE[pfad] = ergebnis.to_dict()
|
DISC_CACHE[pfad] = ergebnis.to_dict()
|
||||||
|
jahr_text = f" ({ergebnis.year})" if (
|
||||||
|
ergebnis.year and not ergebnis.title.endswith(f"({ergebnis.year})")
|
||||||
|
) else ""
|
||||||
db.add_log(
|
db.add_log(
|
||||||
"info", "watcher",
|
"info", "watcher",
|
||||||
f"Disc erkannt: {ergebnis.title}"
|
f"Disc erkannt: {ergebnis.title}{jahr_text}"
|
||||||
+ (f" ({ergebnis.year})" if ergebnis.year else "")
|
|
||||||
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
|
+ f" [{ergebnis.disc_type}, Confidence {ergebnis.confidence:.0%}] auf {pfad}",
|
||||||
)
|
)
|
||||||
|
# Duplikat-Warnung: dieselbe Disc (Fingerabdruck) schon fertig gerippt?
|
||||||
|
dup = await asyncio.to_thread(_duplikat_suchen, ergebnis.fingerprint)
|
||||||
|
if dup:
|
||||||
|
DISC_CACHE[pfad]["bereits_gerippt"] = dup
|
||||||
|
db.add_log(
|
||||||
|
"warning", "watcher",
|
||||||
|
f"Diese Disc wurde bereits gerippt ({dup.get('title')}, "
|
||||||
|
f"Job {dup['job_id'][:8]}…) — Dashboard zeigt den Hinweis.",
|
||||||
|
)
|
||||||
await _auto_rip_wenn_aktiviert(pfad)
|
await _auto_rip_wenn_aktiviert(pfad)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
DISC_CACHE.pop(pfad, None)
|
DISC_CACHE.pop(pfad, None)
|
||||||
@@ -112,6 +127,13 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
disc = DISC_CACHE.get(pfad) or {}
|
disc = DISC_CACHE.get(pfad) or {}
|
||||||
if disc.get("_laeuft"):
|
if disc.get("_laeuft"):
|
||||||
return
|
return
|
||||||
|
if disc.get("bereits_gerippt"):
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "info", "api",
|
||||||
|
"Automatik übersprungen: Disc wurde bereits gerippt — "
|
||||||
|
'manuell per „Rippen starten" trotzdem möglich.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
basis = einstellungen.get("outputDir") or MEDIA_ROOT
|
basis = einstellungen.get("outputDir") or MEDIA_ROOT
|
||||||
meta = disc.get("metadata") or {}
|
meta = disc.get("metadata") or {}
|
||||||
@@ -175,19 +197,13 @@ async def disc_watcher():
|
|||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
# Middleware für Rate-Limiting
|
# Middleware für Rate-Limiting (pro Client-IP — schützt vor Amok-Skripten,
|
||||||
|
# nicht vor Angreifern; Rippy ist Heimnetz-only)
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def rate_limit_middleware(request: Request, call_next):
|
async def rate_limit_middleware(request: Request, call_next):
|
||||||
"""Rate-Limiting Middleware."""
|
"""Rate-Limiting Middleware."""
|
||||||
client_ip = request.client.host
|
client_ip = request.client.host
|
||||||
api_key = request.headers.get("X-API-Key")
|
|
||||||
|
|
||||||
# Prüfe API Key
|
|
||||||
if api_key:
|
|
||||||
key_info = validate_api_key(api_key)
|
|
||||||
if not key_info:
|
|
||||||
raise HTTPException(status_code=401, detail="Ungültiger API Key")
|
|
||||||
|
|
||||||
# Rate Limit prüfen
|
# Rate Limit prüfen
|
||||||
if not check_rate_limit(client_ip):
|
if not check_rate_limit(client_ip):
|
||||||
return Response(
|
return Response(
|
||||||
@@ -318,6 +334,9 @@ class JobCreateRequest(BaseModel):
|
|||||||
device: Optional[str] = None # Alias, so schickt es das UI
|
device: Optional[str] = None # Alias, so schickt es das UI
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
target_dir: Optional[str] = None # Ablageziel unter /app/media (frei wählbar)
|
target_dir: Optional[str] = None # Ablageziel unter /app/media (frei wählbar)
|
||||||
|
series: Optional[str] = None # Serien-Flow: Ablage <Serie>/Season NN
|
||||||
|
season: Optional[int] = None
|
||||||
|
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
|
||||||
|
|
||||||
|
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
@@ -352,18 +371,26 @@ async def create_job(request: JobCreateRequest):
|
|||||||
|
|
||||||
# Titel + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
|
# Titel + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
|
||||||
# sie für den Ordnernamen und die Media-Server-Aufbereitung (NFO/Poster),
|
# sie für den Ordnernamen und die Media-Server-Aufbereitung (NFO/Poster),
|
||||||
# das UI fürs Job-Detail-Popup.
|
# das UI fürs Job-Detail-Popup. Serien-Flow und Hauptfilm-Wahl wandern
|
||||||
|
# ebenfalls in die Job-Metadaten.
|
||||||
titel = request.title
|
titel = request.title
|
||||||
meta_json = None
|
meta_dict = {}
|
||||||
disc = DISC_CACHE.get(device_path)
|
disc = DISC_CACHE.get(device_path)
|
||||||
if disc and not disc.get("_laeuft"):
|
if disc and not disc.get("_laeuft"):
|
||||||
if not titel:
|
if not titel:
|
||||||
titel = disc.get("title")
|
titel = disc.get("title")
|
||||||
meta_json = json.dumps({
|
meta_dict = {
|
||||||
"year": disc.get("year"),
|
"year": disc.get("year"),
|
||||||
"confidence": disc.get("confidence"),
|
"confidence": disc.get("confidence"),
|
||||||
|
"fingerprint": disc.get("fingerprint"),
|
||||||
**(disc.get("metadata") or {}),
|
**(disc.get("metadata") or {}),
|
||||||
})
|
}
|
||||||
|
if request.series and request.series.strip():
|
||||||
|
meta_dict["series"] = request.series.strip()
|
||||||
|
meta_dict["season"] = max(1, int(request.season or 1))
|
||||||
|
if request.main_feature_only is not None:
|
||||||
|
meta_dict["main_feature_only"] = request.main_feature_only
|
||||||
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
||||||
@@ -607,6 +634,93 @@ async def delete_worker(name: str):
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/metadata/tv/{tv_id}/season/{season}")
|
||||||
|
async def tv_season_laufzeiten(tv_id: int, season: int):
|
||||||
|
"""Episoden-Laufzeiten einer Staffel (TMDB) — Basis fürs
|
||||||
|
Episoden-Matching des Workers (Serien-Flow)."""
|
||||||
|
def hole():
|
||||||
|
prescan = PreScan()
|
||||||
|
daten = prescan.tmdb.get_tv_season(tv_id, season)
|
||||||
|
if not daten:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"episodes": [
|
||||||
|
{"episode": e.get("episode_number"), "runtime": e.get("runtime")}
|
||||||
|
for e in daten.get("episodes", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
ergebnis = await asyncio.to_thread(hole)
|
||||||
|
if ergebnis is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Staffel bei TMDB nicht gefunden")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
class MediaServerRefreshRequest(BaseModel):
|
||||||
|
url: str
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/mediaserver/refresh")
|
||||||
|
async def mediaserver_refresh(request: MediaServerRefreshRequest):
|
||||||
|
"""Bibliotheks-Scan von Jellyfin/Emby anstoßen — auch als Verbindungs-Test
|
||||||
|
aus den Einstellungen (POST /Library/Refresh, Header X-Emby-Token)."""
|
||||||
|
import requests as _requests
|
||||||
|
|
||||||
|
url = request.url.strip().rstrip("/")
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(status_code=422, detail="Server-URL muss mit http(s):// beginnen")
|
||||||
|
|
||||||
|
def anstossen():
|
||||||
|
return _requests.post(
|
||||||
|
url + "/Library/Refresh",
|
||||||
|
headers={"X-Emby-Token": request.api_key.strip()}, timeout=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
antwort = await asyncio.to_thread(anstossen)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Server nicht erreichbar: {e}")
|
||||||
|
if antwort.status_code >= 300:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Server antwortete mit HTTP {antwort.status_code} — API-Key prüfen "
|
||||||
|
"(Jellyfin: Administration → API-Schlüssel)",
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(db.add_log, "info", "api", f"Bibliotheks-Refresh angestoßen ({url})")
|
||||||
|
return {"status": "refreshed"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/jobs/export")
|
||||||
|
async def export_jobs():
|
||||||
|
"""Job-Historie als CSV (Semikolon + BOM — öffnet sauber in deutschem Excel)."""
|
||||||
|
def baue():
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
puffer = io.StringIO()
|
||||||
|
w = csv.writer(puffer, delimiter=";")
|
||||||
|
w.writerow(["ID", "Titel", "Typ", "Status", "Fortschritt %", "Gerät",
|
||||||
|
"Start", "Ende", "Ablage", "Fehler"])
|
||||||
|
for j in db.list_jobs(1000):
|
||||||
|
w.writerow([
|
||||||
|
j["id"], j.get("title") or "", j.get("disc_type") or "",
|
||||||
|
j.get("status") or "", j.get("progress") or 0, j.get("device") or "",
|
||||||
|
j["created_at"].isoformat() if j.get("created_at") else "",
|
||||||
|
j["finished_at"].isoformat() if j.get("finished_at") else "",
|
||||||
|
j.get("output_path") or "",
|
||||||
|
(j.get("error") or "").replace("\n", " "),
|
||||||
|
])
|
||||||
|
return puffer.getvalue()
|
||||||
|
|
||||||
|
inhalt = await asyncio.to_thread(baue)
|
||||||
|
return Response(
|
||||||
|
content="" + inhalt,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="rippy-jobs.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/metadata/status")
|
@app.get("/metadata/status")
|
||||||
async def metadata_status():
|
async def metadata_status():
|
||||||
"""Live-Prüfung der Metadaten-Quellen — beantwortet „funktioniert mein
|
"""Live-Prüfung der Metadaten-Quellen — beantwortet „funktioniert mein
|
||||||
@@ -918,41 +1032,10 @@ async def job_stream():
|
|||||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
# Metadaten-Lookup Endpoints
|
# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
|
||||||
class MetadataLookupRequest(BaseModel):
|
# Metadaten-Seite): lookup scannte ein DUMMY-Device (/dev/dvd — existiert
|
||||||
title: str
|
# nicht) und confirm schrieb in einen Cache-Key, den nie jemand las.
|
||||||
year: Optional[int] = None
|
# Die echte Korrektur läuft über /metadata/search + /metadata/override.
|
||||||
disc_type: str = "dvd"
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/metadata/lookup")
|
|
||||||
async def lookup_metadata(request: MetadataLookupRequest):
|
|
||||||
"""Suche Metadaten für Disc."""
|
|
||||||
prescan = PreScan()
|
|
||||||
|
|
||||||
# Dummy device für Pre-Scan
|
|
||||||
device = "/dev/dvd" if request.disc_type in ["dvd", "bluray"] else "/dev/cdrom"
|
|
||||||
|
|
||||||
result = prescan.scan(device)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": result.title,
|
|
||||||
"year": result.year,
|
|
||||||
"confidence": result.confidence,
|
|
||||||
"metadata": result.metadata,
|
|
||||||
"tracks": result.tracks
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/metadata/confirm")
|
|
||||||
async def confirm_metadata(title: str, year: Optional[int] = None, metadata: Dict = None):
|
|
||||||
"""Bestätige Metadaten."""
|
|
||||||
from cache.keys import generate_confirmed_key
|
|
||||||
# In Cache speichern
|
|
||||||
cache_key = generate_confirmed_key(title, year)
|
|
||||||
cache_set(cache_key, {"title": title, "year": year, "metadata": metadata or {}})
|
|
||||||
|
|
||||||
return {"status": "confirmed", "key": cache_key}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/metadata/search")
|
@app.get("/metadata/search")
|
||||||
@@ -1154,82 +1237,5 @@ async def jellyfin_format(request: JellyfinFormatRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# Auth Endpoints
|
# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
|
||||||
class LoginRequest(BaseModel):
|
# Heimnetz-only, kein Login-Flow im UI, die Endpoints waren Placebo.
|
||||||
username: str
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/token")
|
|
||||||
async def login(request: LoginRequest):
|
|
||||||
"""Login und Token generieren."""
|
|
||||||
# Einfache Auth für MVP (in Produktion mit Datenbank); Zugangsdaten aus .env
|
|
||||||
if request.username == settings.admin_username and request.password == settings.admin_password:
|
|
||||||
access_token = create_access_token(
|
|
||||||
data={"sub": request.username, "scopes": ["admin"]}
|
|
||||||
)
|
|
||||||
refresh_token = create_refresh_token(
|
|
||||||
data={"sub": request.username}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"access_token": access_token,
|
|
||||||
"refresh_token": refresh_token,
|
|
||||||
"token_type": "bearer"
|
|
||||||
}
|
|
||||||
raise HTTPException(status_code=401, detail="Ungültige Anmeldedaten")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/token/refresh")
|
|
||||||
async def refresh_token(refresh_token: str):
|
|
||||||
"""Refresh Access Token."""
|
|
||||||
payload = decode_token(refresh_token)
|
|
||||||
if not payload or payload.get("type") != "refresh":
|
|
||||||
raise HTTPException(status_code=401, detail="Ungültiges Refresh Token")
|
|
||||||
|
|
||||||
access_token = create_access_token(
|
|
||||||
data={"sub": payload.get("sub"), "scopes": payload.get("scopes", [])}
|
|
||||||
)
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/token/invalidate")
|
|
||||||
async def invalidate_token(token: str):
|
|
||||||
"""Invalidate Token (Logout)."""
|
|
||||||
if is_blacklisted(token):
|
|
||||||
raise HTTPException(status_code=400, detail="Token bereits invalidiert")
|
|
||||||
|
|
||||||
# Review-Fix 22.07.: vorher wurde hier NICHTS geblacklistet (Placebo-Logout)
|
|
||||||
add_to_blacklist(token)
|
|
||||||
if not is_blacklisted(token):
|
|
||||||
raise HTTPException(status_code=400, detail="Ungültiger Token")
|
|
||||||
return {"status": "invalidated"}
|
|
||||||
|
|
||||||
|
|
||||||
# API Key Endpoints
|
|
||||||
class APIKeyCreateRequest(BaseModel):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
# Review-Fix 22.07.: diese Endpoints nutzten `secrets` und `api_keys`, die in
|
|
||||||
# diesem Modul NIE existierten (Crash bei jedem Aufruf) — der echte Key-Store
|
|
||||||
# lebt in ratelimit.py und wird jetzt benutzt.
|
|
||||||
@app.post("/api-keys")
|
|
||||||
async def create_api_key(request: APIKeyCreateRequest):
|
|
||||||
"""Erstelle API Key."""
|
|
||||||
# In Produktion mit Auth prüfen
|
|
||||||
return ratelimit_create_api_key(request.name)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api-keys")
|
|
||||||
async def list_api_keys():
|
|
||||||
"""Liste API Keys."""
|
|
||||||
return list(api_keys.values())
|
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api-keys/{key}")
|
|
||||||
async def delete_api_key(key: str):
|
|
||||||
"""Lösche API Key."""
|
|
||||||
# In Produktion mit Auth prüfen
|
|
||||||
if ratelimit_delete_api_key(key):
|
|
||||||
return {"status": "deleted"}
|
|
||||||
raise HTTPException(status_code=404, detail="API Key nicht gefunden")
|
|
||||||
|
|||||||
@@ -203,7 +203,8 @@ class PreScanResult:
|
|||||||
year: int = None,
|
year: int = None,
|
||||||
confidence: float = 0.0,
|
confidence: float = 0.0,
|
||||||
metadata: Dict = None,
|
metadata: Dict = None,
|
||||||
tracks: List[Dict] = None
|
tracks: List[Dict] = None,
|
||||||
|
fingerprint: str = ""
|
||||||
):
|
):
|
||||||
self.disc_type = disc_type
|
self.disc_type = disc_type
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -211,6 +212,9 @@ class PreScanResult:
|
|||||||
self.confidence = confidence
|
self.confidence = confidence
|
||||||
self.metadata = metadata or {}
|
self.metadata = metadata or {}
|
||||||
self.tracks = tracks or []
|
self.tracks = tracks or []
|
||||||
|
# Fingerabdruck (Label|Größe) wandert mit ins Ergebnis — Basis der
|
||||||
|
# Duplikat-Warnung („diese Disc wurde schon gerippt")
|
||||||
|
self.fingerprint = fingerprint
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
def to_dict(self) -> Dict:
|
||||||
return {
|
return {
|
||||||
@@ -219,7 +223,8 @@ class PreScanResult:
|
|||||||
"year": self.year,
|
"year": self.year,
|
||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
"metadata": self.metadata,
|
"metadata": self.metadata,
|
||||||
"tracks": self.tracks
|
"tracks": self.tracks,
|
||||||
|
"fingerprint": self.fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -361,7 +366,8 @@ class PreScan:
|
|||||||
disc_type="CD",
|
disc_type="CD",
|
||||||
title=album,
|
title=album,
|
||||||
tracks=toc["tracks"],
|
tracks=toc["tracks"],
|
||||||
confidence=confidence
|
confidence=confidence,
|
||||||
|
fingerprint=toc.get("fingerprint", "")
|
||||||
)
|
)
|
||||||
cache_set(cache_key, result.to_dict())
|
cache_set(cache_key, result.to_dict())
|
||||||
return result
|
return result
|
||||||
@@ -486,6 +492,16 @@ class PreScan:
|
|||||||
"year": None
|
"year": None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Deutsche Texte für OMDb-Treffer nachladen (Commander-Wunsch 24.07.):
|
||||||
|
# OMDb kann nur Englisch — über die IMDb-ID liefert TMDB /find den
|
||||||
|
# deutschen Titel, die Beschreibung und oft ein besseres Poster.
|
||||||
|
if matched and metadata.get("source") == "omdb" and str(metadata.get("id", "")).startswith("tt"):
|
||||||
|
deutsch = self.tmdb.find_by_imdb(metadata["id"])
|
||||||
|
if deutsch:
|
||||||
|
for feld in ("title", "overview", "poster_path", "year", "type"):
|
||||||
|
if deutsch.get(feld):
|
||||||
|
metadata[feld] = deutsch[feld]
|
||||||
|
|
||||||
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
|
# Bei Treffer den sauberen API-Titel anzeigen statt des Disc-Titels
|
||||||
if matched and metadata.get("title"):
|
if matched and metadata.get("title"):
|
||||||
title = metadata["title"]
|
title = metadata["title"]
|
||||||
@@ -496,7 +512,8 @@ class PreScan:
|
|||||||
year=metadata.get("year"),
|
year=metadata.get("year"),
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
tracks=toc.get("tracks", [])
|
tracks=toc.get("tracks", []),
|
||||||
|
fingerprint=toc.get("fingerprint", "")
|
||||||
)
|
)
|
||||||
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
|
# Nur ECHTE Treffer cachen: ein gecachtes "unknown" würde sonst auch
|
||||||
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
|
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
|
||||||
|
|||||||
+6
-41
@@ -1,9 +1,12 @@
|
|||||||
"""Rate-Limiting-Modul für Rippy API."""
|
"""Rate-Limiting-Modul für Rippy API (pro Client-IP).
|
||||||
|
|
||||||
|
Der API-Key-Store, der hier früher lebte, ist mit dem Auth-Rückbau
|
||||||
|
(Commander-Entscheid 24.07.2026) entfernt — Heimnetz-only, siehe KONZEPT §10.
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import secrets
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Dict, Optional
|
from typing import Dict
|
||||||
|
|
||||||
# Default Rate Limit
|
# Default Rate Limit
|
||||||
MAX_REQUESTS_PER_MINUTE = 100
|
MAX_REQUESTS_PER_MINUTE = 100
|
||||||
@@ -48,41 +51,3 @@ def get_rate_limit_remaining(client_id: str, max_requests: int = MAX_REQUESTS_PE
|
|||||||
def reset_rate_limit(client_id: str) -> None:
|
def reset_rate_limit(client_id: str) -> None:
|
||||||
"""Setze Rate Limit für Client zurück."""
|
"""Setze Rate Limit für Client zurück."""
|
||||||
rate_limit_store[client_id] = []
|
rate_limit_store[client_id] = []
|
||||||
|
|
||||||
|
|
||||||
# API-Key Store (in Produktion mit Datenbank)
|
|
||||||
api_keys: Dict[str, Dict] = {
|
|
||||||
"example_key": {
|
|
||||||
"key": "example_key",
|
|
||||||
"name": "Beispiel API Key",
|
|
||||||
"created_at": time.time(),
|
|
||||||
"rate_limit": 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def validate_api_key(api_key: str) -> Optional[Dict]:
|
|
||||||
"""Validiere API Key."""
|
|
||||||
if api_key in api_keys:
|
|
||||||
return api_keys[api_key]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def create_api_key(name: str) -> Dict:
|
|
||||||
"""Erstelle neuer API Key."""
|
|
||||||
key = secrets.token_urlsafe(32)
|
|
||||||
api_keys[key] = {
|
|
||||||
"key": key,
|
|
||||||
"name": name,
|
|
||||||
"created_at": time.time(),
|
|
||||||
"rate_limit": MAX_REQUESTS_PER_MINUTE
|
|
||||||
}
|
|
||||||
return api_keys[key]
|
|
||||||
|
|
||||||
|
|
||||||
def delete_api_key(key: str) -> bool:
|
|
||||||
"""Lösche API Key."""
|
|
||||||
if key in api_keys:
|
|
||||||
del api_keys[key]
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ redis==5.0.4
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
pydantic==2.9.0
|
pydantic==2.9.0
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
passlib==1.7.4
|
# passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
|
||||||
# bcrypt MUSS gepinnt bleiben: passlib 1.7.4 liest bcrypt.__about__ (in
|
# Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
|
||||||
# bcrypt >= 4.1 entfernt) — der Backend-Selbsttest crasht dann mit
|
# die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
|
||||||
# "password cannot be longer than 72 bytes" und JEDES Hashing schlägt fehl.
|
|
||||||
# Genau das hielt die Ampel ab dem 23.07. rot (stable blieb 10 Commits zurück).
|
|
||||||
bcrypt==4.0.1
|
|
||||||
PyJWT==2.9.0
|
|
||||||
python-multipart==0.0.9
|
|
||||||
aiofiles==24.1.0
|
aiofiles==24.1.0
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"""Tests für auth.py: Hashing, Token-Lebenszyklus, Blacklist."""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from auth import (
|
|
||||||
add_to_blacklist,
|
|
||||||
cleanup_blacklist,
|
|
||||||
create_access_token,
|
|
||||||
create_refresh_token,
|
|
||||||
decode_token,
|
|
||||||
get_password_hash,
|
|
||||||
is_access_token,
|
|
||||||
is_blacklisted,
|
|
||||||
is_refresh_token,
|
|
||||||
token_blacklist,
|
|
||||||
verify_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_passwort_hash_roundtrip():
|
|
||||||
hashed = get_password_hash("geheim123")
|
|
||||||
assert hashed != "geheim123"
|
|
||||||
assert verify_password("geheim123", hashed) is True
|
|
||||||
assert verify_password("falsch", hashed) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_access_token_roundtrip():
|
|
||||||
token = create_access_token({"sub": "commander"})
|
|
||||||
payload = decode_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["sub"] == "commander"
|
|
||||||
assert payload["type"] == "access"
|
|
||||||
assert is_access_token(token) is True
|
|
||||||
assert is_refresh_token(token) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_token_roundtrip():
|
|
||||||
token = create_refresh_token({"sub": "commander"})
|
|
||||||
payload = decode_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["type"] == "refresh"
|
|
||||||
assert is_refresh_token(token) is True
|
|
||||||
assert is_access_token(token) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_muell_token_gibt_none_und_false():
|
|
||||||
assert decode_token("kein.echter.token") is None
|
|
||||||
# Rückgabetyp muss bool sein, nicht None (Review-Fund 22.07.)
|
|
||||||
assert is_access_token("kein.echter.token") is False
|
|
||||||
assert is_refresh_token("kein.echter.token") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_blacklist_logout_wirkt():
|
|
||||||
token = create_access_token({"sub": "commander"})
|
|
||||||
assert is_blacklisted(token) is False
|
|
||||||
add_to_blacklist(token)
|
|
||||||
assert is_blacklisted(token) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_cleanup_entfernt_nur_abgelaufene():
|
|
||||||
"""Review-Fund 22.07.: das alte cleanup löschte ALLES — Logout war Placebo."""
|
|
||||||
frisch = create_access_token({"sub": "commander"})
|
|
||||||
add_to_blacklist(frisch)
|
|
||||||
token_blacklist["laengst-abgelaufener-token"] = time.time() - 3600
|
|
||||||
|
|
||||||
cleanup_blacklist()
|
|
||||||
|
|
||||||
assert "laengst-abgelaufener-token" not in token_blacklist
|
|
||||||
assert is_blacklisted(frisch) is True
|
|
||||||
@@ -3,12 +3,13 @@ import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-rea
|
|||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { useDarkMode } from './context/ThemeContext'
|
import { useDarkMode } from './context/ThemeContext'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MetadataPreview from './pages/MetadataPreview'
|
|
||||||
import SettingsPage from './pages/Settings'
|
import SettingsPage from './pages/Settings'
|
||||||
import LogsPage from './pages/Logs'
|
import LogsPage from './pages/Logs'
|
||||||
import FirstRunWizard from './components/FirstRunWizard'
|
import FirstRunWizard from './components/FirstRunWizard'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'metadata' | 'settings' | 'logs'
|
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
|
||||||
|
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
|
||||||
|
type Page = 'dashboard' | 'settings' | 'logs'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [page, setPage] = useState<Page>('dashboard')
|
const [page, setPage] = useState<Page>('dashboard')
|
||||||
@@ -27,7 +28,6 @@ function App() {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
{ id: 'metadata', label: 'Metadaten', icon: Disc },
|
|
||||||
{ id: 'logs', label: 'Logs', icon: Terminal },
|
{ id: 'logs', label: 'Logs', icon: Terminal },
|
||||||
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
{ id: 'settings', label: 'Einstellungen', icon: Settings },
|
||||||
]
|
]
|
||||||
@@ -86,7 +86,6 @@ function App() {
|
|||||||
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
<div className="max-w-7xl mx-auto p-6">
|
||||||
{page === 'dashboard' && <Dashboard />}
|
{page === 'dashboard' && <Dashboard />}
|
||||||
{page === 'metadata' && <MetadataPreview />}
|
|
||||||
{page === 'logs' && <LogsPage />}
|
{page === 'logs' && <LogsPage />}
|
||||||
{page === 'settings' && <SettingsPage />}
|
{page === 'settings' && <SettingsPage />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import RipTargetModal from './RipTargetModal'
|
import RipTargetModal, { RipOptionen } from './RipTargetModal'
|
||||||
import MetadataKorrektur from './MetadataKorrektur'
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
|
||||||
interface DiscInfo {
|
interface DiscInfo {
|
||||||
@@ -17,6 +17,11 @@ interface DiscInfo {
|
|||||||
type?: string
|
type?: string
|
||||||
source?: string
|
source?: string
|
||||||
}
|
}
|
||||||
|
bereits_gerippt?: {
|
||||||
|
job_id: string
|
||||||
|
title?: string
|
||||||
|
finished_at?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Device {
|
interface Device {
|
||||||
@@ -66,13 +71,15 @@ export default function DeviceDiscovery() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRip = async (device: Device, targetDir?: string) => {
|
const startRip = async (device: Device, targetDir?: string, optionen?: RipOptionen) => {
|
||||||
setActionBusy(true)
|
setActionBusy(true)
|
||||||
setActionFeedback(null)
|
setActionFeedback(null)
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/jobs', {
|
const response = await api.post('/jobs', {
|
||||||
device_path: device.path,
|
device_path: device.path,
|
||||||
...(targetDir ? { target_dir: targetDir } : {}),
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
|
...(optionen?.series ? { series: optionen.series, season: optionen.season } : {}),
|
||||||
|
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
|
||||||
})
|
})
|
||||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
@@ -203,6 +210,15 @@ export default function DeviceDiscovery() {
|
|||||||
{device.disc!.metadata.overview}
|
{device.disc!.metadata.overview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{device.disc!.bereits_gerippt && (
|
||||||
|
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
|
||||||
|
<AlertCircle size={13} className="flex-shrink-0" />
|
||||||
|
Diese Disc wurde bereits gerippt
|
||||||
|
{device.disc!.bereits_gerippt.finished_at
|
||||||
|
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
||||||
|
: ''} — nochmal rippen ist trotzdem möglich.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-3 mt-3">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalDevice(device)}
|
onClick={() => setModalDevice(device)}
|
||||||
@@ -341,11 +357,12 @@ export default function DeviceDiscovery() {
|
|||||||
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
||||||
<RipTargetModal
|
<RipTargetModal
|
||||||
isOpen={modalDevice !== null}
|
isOpen={modalDevice !== null}
|
||||||
initialType={modalDevice?.type === 'cd' ? 'music' : 'movies'}
|
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
|
||||||
|
discTitle={modalDevice?.disc?.title}
|
||||||
onClose={() => setModalDevice(null)}
|
onClose={() => setModalDevice(null)}
|
||||||
onSave={(target) => {
|
onSave={(target, optionen) => {
|
||||||
if (modalDevice) {
|
if (modalDevice) {
|
||||||
startRip(modalDevice, target.path)
|
startRip(modalDevice, target.path, optionen)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,11 +11,18 @@ interface TargetConfig {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RipOptionen {
|
||||||
|
series?: string
|
||||||
|
season?: number
|
||||||
|
mainFeatureOnly?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface RipTargetModalProps {
|
interface RipTargetModalProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||||
|
discTitle?: string // Vorschlag für den Serien-Namen
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (target: TargetConfig) => void
|
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrowseDir {
|
interface BrowseDir {
|
||||||
@@ -25,8 +32,11 @@ interface BrowseDir {
|
|||||||
|
|
||||||
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
|
||||||
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
|
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
|
||||||
export default function RipTargetModal({ isOpen, initialType, onClose, onSave }: RipTargetModalProps) {
|
export default function RipTargetModal({ isOpen, initialType, discTitle, onClose, onSave }: RipTargetModalProps) {
|
||||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||||
|
const [serienName, setSerienName] = useState('')
|
||||||
|
const [staffel, setStaffel] = useState(1)
|
||||||
|
const [nurHauptfilm, setNurHauptfilm] = useState(false)
|
||||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
|
||||||
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
|
||||||
@@ -44,8 +54,11 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
|
|||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
setSelectedType(initialType || 'movies')
|
setSelectedType(initialType || 'movies')
|
||||||
setCustomPath('')
|
setCustomPath('')
|
||||||
|
setSerienName(discTitle || '')
|
||||||
|
setStaffel(1)
|
||||||
api.get('/settings').then(r => {
|
api.get('/settings').then(r => {
|
||||||
const s = r.data || {}
|
const s = r.data || {}
|
||||||
|
setNurHauptfilm(!!s.mainFeatureOnly)
|
||||||
const basis = s.outputDir || '/app/media'
|
const basis = s.outputDir || '/app/media'
|
||||||
setTargets([
|
setTargets([
|
||||||
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
|
||||||
@@ -70,11 +83,16 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
const optionen: RipOptionen = { mainFeatureOnly: nurHauptfilm }
|
||||||
|
if (selectedType === 'series' && serienName.trim()) {
|
||||||
|
optionen.series = serienName.trim()
|
||||||
|
optionen.season = Math.max(1, staffel || 1)
|
||||||
|
}
|
||||||
const target = targets.find(t => t.type === selectedType)
|
const target = targets.find(t => t.type === selectedType)
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true })
|
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
|
||||||
} else if (target) {
|
} else if (target) {
|
||||||
onSave(target)
|
onSave(target, optionen)
|
||||||
}
|
}
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -120,6 +138,52 @@ export default function RipTargetModal({ isOpen, initialType, onClose, onSave }:
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden
|
||||||
|
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
|
||||||
|
{selectedType === 'series' && (
|
||||||
|
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
||||||
|
<div className="grid grid-cols-[1fr_90px] gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
|
||||||
|
<input
|
||||||
|
value={serienName}
|
||||||
|
onChange={e => setSerienName(e.target.value)}
|
||||||
|
placeholder="z. B. Neon Genesis Evangelion"
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={staffel}
|
||||||
|
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
||||||
|
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} —
|
||||||
|
Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch
|
||||||
|
zu „S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01…" benannt (nur bei eindeutiger Zuordnung).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
|
||||||
|
{selectedType === 'movies' && (
|
||||||
|
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={nurHauptfilm}
|
||||||
|
onChange={e => setNurHauptfilm(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-indigo-600"
|
||||||
|
/>
|
||||||
|
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ordner-Browser */}
|
{/* Ordner-Browser */}
|
||||||
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||||
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, Trash2 } from 'lucide-react'
|
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
@@ -151,6 +151,7 @@ const ENCODER_KURZ: Record<string, string> = {
|
|||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||||
@@ -189,6 +190,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
loadData()
|
loadData()
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
||||||
|
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
|
||||||
const interval = setInterval(loadData, 5000)
|
const interval = setInterval(loadData, 5000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -244,6 +246,21 @@ export default function Dashboard() {
|
|||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
|
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{plaetze.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive size={16} className="text-indigo-500" />
|
||||||
|
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span>
|
||||||
|
{plaetze.map(p => (
|
||||||
|
<span
|
||||||
|
key={p.name}
|
||||||
|
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${p.frei_gb < 60 ? 'bg-amber-600/20 text-amber-500 font-medium' : theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}
|
||||||
|
>
|
||||||
|
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Laufwerke + erkannte Disc */}
|
{/* Laufwerke + erkannte Disc */}
|
||||||
@@ -290,6 +307,17 @@ export default function Dashboard() {
|
|||||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
|
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
|
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<a
|
||||||
|
href="/api/jobs/export"
|
||||||
|
download
|
||||||
|
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
|
||||||
|
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet size={13} />
|
||||||
|
CSV
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setAufraeumenOffen(true)}
|
onClick={() => setAufraeumenOffen(true)}
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { BookOpen, Music, Film, AlertCircle, CheckCircle, Search, Loader2 } from 'lucide-react'
|
|
||||||
import type { LucideIcon } from 'lucide-react'
|
|
||||||
import { api } from '../lib/api'
|
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
|
||||||
|
|
||||||
interface DeviceOption {
|
|
||||||
path: string
|
|
||||||
name: string
|
|
||||||
status: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuchKandidat {
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
poster?: string
|
|
||||||
overview?: string
|
|
||||||
type: string
|
|
||||||
source: string
|
|
||||||
id?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function posterAbsolut(p?: string): string | null {
|
|
||||||
if (!p) return null
|
|
||||||
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Metadata {
|
|
||||||
type: 'movie' | 'tv' | 'music' | 'unknown'
|
|
||||||
id: number
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
overview?: string
|
|
||||||
poster_path?: string
|
|
||||||
backdrop_path?: string
|
|
||||||
runtime?: number
|
|
||||||
genres?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Track {
|
|
||||||
track_number: number
|
|
||||||
title: string
|
|
||||||
duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PrescanResult {
|
|
||||||
disc_type: 'cd' | 'dvd' | 'bluray'
|
|
||||||
title: string
|
|
||||||
year?: number
|
|
||||||
confidence: number
|
|
||||||
metadata?: Metadata
|
|
||||||
tracks?: Track[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MetadataPreview() {
|
|
||||||
const [device, setDevice] = useState<string>('')
|
|
||||||
const [devices, setDevices] = useState<DeviceOption[]>([])
|
|
||||||
const [result, setResult] = useState<PrescanResult | null>(null)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [confirming, setConfirming] = useState(false)
|
|
||||||
const [suchbegriff, setSuchbegriff] = useState('')
|
|
||||||
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
|
|
||||||
const [sucheLaeuft, setSucheLaeuft] = useState(false)
|
|
||||||
const { theme } = useDarkMode()
|
|
||||||
|
|
||||||
const titelSuchen = async () => {
|
|
||||||
if (!suchbegriff) return
|
|
||||||
setSucheLaeuft(true)
|
|
||||||
try {
|
|
||||||
const r = await api.get('/metadata/search', { params: { q: suchbegriff } })
|
|
||||||
setKandidaten(r.data)
|
|
||||||
} catch {
|
|
||||||
setKandidaten([])
|
|
||||||
} finally {
|
|
||||||
setSucheLaeuft(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kandidatWaehlen = async (k: SuchKandidat) => {
|
|
||||||
try {
|
|
||||||
const r = await api.post('/metadata/override', {
|
|
||||||
device_path: device,
|
|
||||||
title: k.title,
|
|
||||||
year: k.year,
|
|
||||||
poster: k.poster,
|
|
||||||
overview: k.overview,
|
|
||||||
type: k.type,
|
|
||||||
source: k.source,
|
|
||||||
id: k.id,
|
|
||||||
})
|
|
||||||
setResult(r.data) // Karte + Dashboard zeigen ab jetzt DEINE Wahl
|
|
||||||
setKandidaten([])
|
|
||||||
setSuchbegriff('')
|
|
||||||
} catch {
|
|
||||||
setError('Übernehmen fehlgeschlagen.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Echte Laufwerke laden — vorher standen hier erfundene Festwerte
|
|
||||||
// (/dev/dvd, /dev/bluray), die es im Container nie gab.
|
|
||||||
useEffect(() => {
|
|
||||||
const loadDevices = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/devices')
|
|
||||||
setDevices(response.data)
|
|
||||||
if (response.data.length === 1) {
|
|
||||||
setDevice(response.data[0].path)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setDevices([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadDevices()
|
|
||||||
const interval = setInterval(loadDevices, 5000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleScan = async () => {
|
|
||||||
if (!device) {
|
|
||||||
setError('Bitte wählen Sie ein Gerät aus.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post('/prescan', { device_path: device })
|
|
||||||
setResult(response.data)
|
|
||||||
} catch (err) {
|
|
||||||
setError('Pre-Scan fehlgeschlagen.')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
setConfirming(true)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post('/metadata/confirm', {
|
|
||||||
title: result.title,
|
|
||||||
year: result.year,
|
|
||||||
metadata: result.metadata
|
|
||||||
})
|
|
||||||
|
|
||||||
// Job erstellen — POST /jobs existiert seit 23.07. wirklich
|
|
||||||
await api.post('/jobs', {
|
|
||||||
device_path: device,
|
|
||||||
title: result.title
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
setError('Bestätigung fehlgeschlagen.')
|
|
||||||
} finally {
|
|
||||||
setConfirming(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getIcon = (type: string): LucideIcon => {
|
|
||||||
switch (type) {
|
|
||||||
case 'cd':
|
|
||||||
return Music
|
|
||||||
case 'dvd':
|
|
||||||
case 'bluray':
|
|
||||||
return Film
|
|
||||||
default:
|
|
||||||
return BookOpen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number): string => {
|
|
||||||
if (confidence >= 0.9) return theme === 'dark' ? 'text-green-400 bg-green-900/30' : 'text-green-600 bg-green-100'
|
|
||||||
if (confidence >= 0.7) return theme === 'dark' ? 'text-yellow-400 bg-yellow-900/30' : 'text-yellow-600 bg-yellow-100'
|
|
||||||
return theme === 'dark' ? 'text-red-400 bg-red-900/30' : 'text-red-600 bg-red-100'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6 max-w-4xl mx-auto transition-colors duration-300">
|
|
||||||
<div className="mb-8 transition-colors duration-300">
|
|
||||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Metadaten-Preview</h1>
|
|
||||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Wählen Sie ein Gerät aus, um die Metadaten vor dem Ripping zu lookupen
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Device Selection */}
|
|
||||||
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
|
||||||
<div className="flex gap-4 transition-colors duration-300">
|
|
||||||
<select
|
|
||||||
value={device}
|
|
||||||
onChange={(e) => setDevice(e.target.value)}
|
|
||||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
|
|
||||||
>
|
|
||||||
<option value="" className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
|
||||||
{devices.length === 0 ? 'Kein Laufwerk gefunden' : 'Gerät auswählen...'}
|
|
||||||
</option>
|
|
||||||
{devices.map((d) => (
|
|
||||||
<option key={d.path} value={d.path} className={theme === 'dark' ? 'bg-slate-800 text-slate-100' : 'bg-white text-slate-900'}>
|
|
||||||
{d.name} ({d.path}){d.status === 'empty' ? ' — keine Disc' : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleScan}
|
|
||||||
disabled={!device || loading}
|
|
||||||
className={`flex items-center gap-2 px-6 py-2 rounded-lg text-white transition-colors ${
|
|
||||||
!device || loading
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin" size={20} />
|
|
||||||
Scannen...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Search size={20} />
|
|
||||||
Scan starten
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className={`mb-6 p-4 rounded-lg flex items-center gap-3 transition-colors duration-300 ${theme === 'dark' ? 'bg-red-900/30 border border-red-700 text-red-400' : 'bg-red-50 border border-red-200 text-red-700'}`}>
|
|
||||||
<AlertCircle size={24} />
|
|
||||||
<span>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Manuelle Korrektur: wenn die Automatik danebenliegt, entscheidest DU */}
|
|
||||||
{device && (
|
|
||||||
<div className={`p-4 rounded-lg shadow mb-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
|
||||||
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
|
||||||
Erkennung falsch? Richtigen Titel suchen und übernehmen:
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<input
|
|
||||||
value={suchbegriff}
|
|
||||||
onChange={e => setSuchbegriff(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && titelSuchen()}
|
|
||||||
placeholder="z. B. Evangelion 2.22"
|
|
||||||
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-gray-300 text-slate-900'}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={titelSuchen}
|
|
||||||
disabled={!suchbegriff || sucheLaeuft}
|
|
||||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{sucheLaeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
|
||||||
Suchen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kandidaten.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
|
||||||
{kandidaten.map((k, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => kandidatWaehlen(k)}
|
|
||||||
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
|
|
||||||
>
|
|
||||||
{k.poster ? (
|
|
||||||
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
|
||||||
<Film size={32} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-2">
|
|
||||||
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
|
|
||||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{result && (
|
|
||||||
<div className={`rounded-lg shadow overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
|
||||||
{/* Header — mit Poster, wenn eine Quelle eins geliefert hat */}
|
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-700 p-6 text-white flex gap-5">
|
|
||||||
{posterAbsolut(result.metadata?.poster_path) && (
|
|
||||||
<img
|
|
||||||
src={posterAbsolut(result.metadata?.poster_path)!}
|
|
||||||
alt={result.title}
|
|
||||||
className="w-24 rounded-lg shadow-lg object-cover flex-shrink-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
{/* Fix 23.07.: Lucide-Icons sind forwardRef-Komponenten — ein
|
|
||||||
Direkt-Aufruf getIcon(...)({size}) crasht die ganze Seite
|
|
||||||
(weißes Fenster). Als JSX rendern. */}
|
|
||||||
{(() => { const TypIcon = getIcon(result.disc_type); return <TypIcon size={28} /> })()}
|
|
||||||
<span className="text-sm font-medium opacity-90 uppercase tracking-wide">
|
|
||||||
{result.disc_type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold">{result.title}</h2>
|
|
||||||
{result.year && (
|
|
||||||
<p className="text-lg opacity-90 mt-1">{result.year}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confidence Score */}
|
|
||||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-b border-gray-200'}`}>
|
|
||||||
<div className="flex items-center gap-2 transition-colors duration-300">
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>Übereinstimmung:</span>
|
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium transition-colors duration-300 ${getConfidenceColor(result.confidence)}`}>
|
|
||||||
{Math.round(result.confidence * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="p-6 transition-colors duration-300">
|
|
||||||
{result.metadata && (
|
|
||||||
<div className="space-y-4 transition-colors duration-300">
|
|
||||||
{result.metadata.overview && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Beschreibung</h3>
|
|
||||||
<p className={`leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{result.metadata.overview}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* .length-Guards: {[] && …} rendert die leere Sektion,
|
|
||||||
{0 && …} sogar eine nackte "0" — klassische JSX-Falle */}
|
|
||||||
{result.metadata.genres && result.metadata.genres.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Genre</h3>
|
|
||||||
<div className="flex flex-wrap gap-2 transition-colors duration-300">
|
|
||||||
{result.metadata.genres.map((genre, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className={`px-3 py-1 rounded-full text-sm transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-700'}`}
|
|
||||||
>
|
|
||||||
{genre}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(result.metadata.runtime ?? 0) > 0 && (
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Laufzeit</h3>
|
|
||||||
<p className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`}>{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tracks */}
|
|
||||||
{result.tracks && result.tracks.length > 0 && (
|
|
||||||
<div className="mt-6 transition-colors duration-300">
|
|
||||||
<h3 className={`text-sm font-medium uppercase mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Tracks</h3>
|
|
||||||
<div className="space-y-2 transition-colors duration-300">
|
|
||||||
{result.tracks.map((track, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 transition-colors duration-300">
|
|
||||||
<div className="flex items-center gap-3 transition-colors duration-300">
|
|
||||||
<span className={`w-8 font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>{track.track_number}</span>
|
|
||||||
<span className={`transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-gray-900'}`}>{track.title}</span>
|
|
||||||
</div>
|
|
||||||
{track.duration && (
|
|
||||||
<span className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
||||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action */}
|
|
||||||
<div className={`p-6 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 border-t border-slate-700' : 'bg-gray-50 border-t border-gray-200'}`}>
|
|
||||||
<div className="flex items-center justify-between transition-colors duration-300">
|
|
||||||
<div className="flex items-center gap-3 text-sm transition-colors duration-300">
|
|
||||||
<CheckCircle className={theme === 'dark' ? 'text-green-400' : 'text-green-600'} size={20} />
|
|
||||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-gray-600'}>Bereit zum Ripping</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={confirming}
|
|
||||||
className="flex items-center gap-2 px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{confirming ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="animate-spin" size={20} />
|
|
||||||
Bestätigen...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle size={20} />
|
|
||||||
Rippen starten
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -23,6 +23,8 @@ interface SettingsState {
|
|||||||
transcodePreset: string
|
transcodePreset: string
|
||||||
keepOriginal: boolean
|
keepOriginal: boolean
|
||||||
mediaServer: string
|
mediaServer: string
|
||||||
|
jellyfinUrl: string
|
||||||
|
jellyfinApiKey: string
|
||||||
workDir: string
|
workDir: string
|
||||||
makemkvAppKey: string
|
makemkvAppKey: string
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,8 @@ const defaultSettings: SettingsState = {
|
|||||||
transcodePreset: 'H.265 MKV 1080p30',
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
keepOriginal: false,
|
keepOriginal: false,
|
||||||
mediaServer: 'none',
|
mediaServer: 'none',
|
||||||
|
jellyfinUrl: '',
|
||||||
|
jellyfinApiKey: '',
|
||||||
workDir: '',
|
workDir: '',
|
||||||
makemkvAppKey: '',
|
makemkvAppKey: '',
|
||||||
}
|
}
|
||||||
@@ -330,6 +334,52 @@ export default function SettingsPage() {
|
|||||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
|
||||||
|
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
|
||||||
|
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-600' : 'border-slate-200'}`}>
|
||||||
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||||
|
<strong>Bibliotheks-Refresh</strong> (optional): Trag Server-URL + API-Key ein,
|
||||||
|
dann stößt Rippy nach jedem fertigen Rip sofort einen Scan an — der Film
|
||||||
|
erscheint ohne Warten in {settings.mediaServer === 'jellyfin' ? 'Jellyfin' : 'Emby'}.
|
||||||
|
API-Key: Administration → API-Schlüssel → Neu.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.jellyfinUrl}
|
||||||
|
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
||||||
|
placeholder="http://192.168.178.50:8096"
|
||||||
|
className={`px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.jellyfinApiKey}
|
||||||
|
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
||||||
|
placeholder="API-Key"
|
||||||
|
className={`flex-1 px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/mediaserver/refresh', {
|
||||||
|
url: settings.jellyfinUrl, api_key: settings.jellyfinApiKey,
|
||||||
|
})
|
||||||
|
toast('success', 'Verbunden — Bibliotheks-Scan wurde angestoßen!')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Verbindung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
|
||||||
|
className="px-3 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Testen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|||||||
+139
-9
@@ -28,7 +28,9 @@ def sicherer_name(titel: str, jahr=None) -> str:
|
|||||||
name = re.sub(r"\s+", " ", name)[:150].strip()
|
name = re.sub(r"\s+", " ", name)[:150].strip()
|
||||||
if not name:
|
if not name:
|
||||||
return ""
|
return ""
|
||||||
if jahr:
|
# Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen —
|
||||||
|
# "X (2009) (2009)" sah man sonst im Log UND im Ordnernamen (Fund 24.07.).
|
||||||
|
if jahr and not name.endswith(f"({jahr})"):
|
||||||
name = f"{name} ({jahr})"
|
name = f"{name} ({jahr})"
|
||||||
return name
|
return name
|
||||||
|
|
||||||
@@ -48,6 +50,116 @@ def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
|
|||||||
return pfad
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
|
def serien_ordner(basis: str, serie: str, staffel: int) -> str:
|
||||||
|
"""Jellyfin-Konvention: <basis>/<Serie>/Season NN (pure Funktion, testbar).
|
||||||
|
|
||||||
|
Namensschema laut jellyfin.org/docs/general/server/media/shows — Emby,
|
||||||
|
Kodi und Plex verstehen dieselbe Struktur.
|
||||||
|
"""
|
||||||
|
name = sicherer_name(serie) or "Unbekannte Serie"
|
||||||
|
return os.path.join(basis, name, f"Season {int(staffel):02d}")
|
||||||
|
|
||||||
|
|
||||||
|
def matche_episoden(datei_sekunden: list, episoden: list,
|
||||||
|
toleranz_prozent: float = 8.0, toleranz_min_s: int = 180):
|
||||||
|
"""Ordnet Dateien Episoden zu — über Laufzeiten, ordnungserhaltend.
|
||||||
|
|
||||||
|
datei_sekunden: Laufzeiten der MKVs in Disc-Reihenfolge (Sekunden).
|
||||||
|
episoden: [(episoden_nr, laufzeit_sekunden), …] in Serienreihenfolge.
|
||||||
|
Rückgabe: Liste der Episoden-Nummern je Datei — oder None, wenn die
|
||||||
|
Zuordnung nicht EINDEUTIG gelingt (dann lieber gar nicht umbenennen
|
||||||
|
als falsch; ARMs offene Wunde #395 war genau das Ratespiel).
|
||||||
|
|
||||||
|
Zwei Stufen:
|
||||||
|
1. Komplette Staffel auf der Disc (Dateizahl == Episodenzahl, Anime-Box):
|
||||||
|
Sequenz-Zuordnung 1:1 in Reihenfolge — auch bei uniformen Laufzeiten
|
||||||
|
eindeutig, weil nichts übrig bleibt.
|
||||||
|
2. Sonst Einzel-Zuordnung über DISTINKTE Laufzeiten; passt eine Datei
|
||||||
|
auf mehrere Episoden ähnlich gut → None. Teil-Discs mit lauter
|
||||||
|
gleich langen Episoden sind über Laufzeit prinzipiell nicht
|
||||||
|
zuordenbar — das gibt ehrlich None statt einer Ratenummer.
|
||||||
|
"""
|
||||||
|
if not datei_sekunden or not episoden:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def toleranz(dauer):
|
||||||
|
return max(toleranz_min_s, dauer * toleranz_prozent / 100)
|
||||||
|
|
||||||
|
# Stufe 1: komplette Staffel in Disc-Reihenfolge
|
||||||
|
if len(datei_sekunden) == len(episoden):
|
||||||
|
if all(
|
||||||
|
ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer)
|
||||||
|
for dauer, (_, ep_dauer) in zip(datei_sekunden, episoden)
|
||||||
|
):
|
||||||
|
return [nr for nr, _ in episoden]
|
||||||
|
|
||||||
|
# Stufe 2: eindeutige Einzel-Zuordnung, ordnungserhaltend
|
||||||
|
zuordnung = []
|
||||||
|
start = 0
|
||||||
|
for dauer in datei_sekunden:
|
||||||
|
kandidaten = [
|
||||||
|
(nr, ep_dauer) for nr, ep_dauer in episoden[start:]
|
||||||
|
if ep_dauer and abs(dauer - ep_dauer) <= toleranz(dauer)
|
||||||
|
]
|
||||||
|
if len(kandidaten) == 0:
|
||||||
|
return None
|
||||||
|
if len(kandidaten) > 1:
|
||||||
|
# Nur eindeutig, wenn der beste Kandidat KLAR besser passt als
|
||||||
|
# der zweitbeste (doppelt so nah) — sonst wäre es geraten.
|
||||||
|
kandidaten.sort(key=lambda k: abs(dauer - k[1]))
|
||||||
|
if not (abs(dauer - kandidaten[0][1]) * 2 < abs(dauer - kandidaten[1][1])):
|
||||||
|
return None
|
||||||
|
nr = kandidaten[0][0]
|
||||||
|
zuordnung.append(nr)
|
||||||
|
start = next(i for i, (e_nr, _) in enumerate(episoden) if e_nr == nr) + 1
|
||||||
|
return zuordnung
|
||||||
|
|
||||||
|
|
||||||
|
def episoden_umbenennen(ordner: str, serie: str, staffel: int, zuordnung: list) -> list:
|
||||||
|
"""Benennt die MKVs im Ordner nach Jellyfin-Schema um: 'Serie S01E02.mkv'.
|
||||||
|
|
||||||
|
Reihenfolge = sortierte Dateinamen (MakeMKV nummeriert title_t00…tNN in
|
||||||
|
Disc-Reihenfolge — dieselbe Reihenfolge, in der gematcht wurde).
|
||||||
|
"""
|
||||||
|
meldungen = []
|
||||||
|
dateien = sorted(f for f in os.listdir(ordner) if f.endswith(".mkv"))
|
||||||
|
if len(dateien) != len(zuordnung):
|
||||||
|
return [f"Umbenennen übersprungen: {len(dateien)} Dateien, {len(zuordnung)} Zuordnungen"]
|
||||||
|
serien_name = sicherer_name(serie) or "Serie"
|
||||||
|
for alt, ep_nr in zip(dateien, zuordnung):
|
||||||
|
neu = f"{serien_name} S{int(staffel):02d}E{int(ep_nr):02d}.mkv"
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
ziel = os.path.join(ordner, neu)
|
||||||
|
if os.path.exists(ziel):
|
||||||
|
meldungen.append(f"Umbenennen übersprungen ({neu} existiert schon)")
|
||||||
|
continue
|
||||||
|
os.rename(os.path.join(ordner, alt), ziel)
|
||||||
|
meldungen.append(f"{alt} → {neu}")
|
||||||
|
return meldungen
|
||||||
|
|
||||||
|
|
||||||
|
def bibliothek_refresh(media_server: str, url: str, api_key: str) -> str:
|
||||||
|
"""Stößt den Bibliotheks-Scan des Media-Servers an (Jellyfin/Emby).
|
||||||
|
|
||||||
|
API laut jellyfin.org/docs (Emby-kompatibel): POST /Library/Refresh,
|
||||||
|
Auth über den Header X-Emby-Token. Rückgabe: Klartext fürs Log.
|
||||||
|
Wirft nie — der Refresh ist Komfort, nicht Teil des Rips.
|
||||||
|
"""
|
||||||
|
if media_server not in ("jellyfin", "emby") or not url or not api_key:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
antwort = requests.post(
|
||||||
|
url.rstrip("/") + "/Library/Refresh",
|
||||||
|
headers={"X-Emby-Token": api_key}, timeout=15,
|
||||||
|
)
|
||||||
|
if antwort.status_code < 300:
|
||||||
|
return f"{media_server}-Bibliothek aktualisiert ({url})"
|
||||||
|
return f"Bibliotheks-Refresh: HTTP {antwort.status_code} von {url}"
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return f"Bibliotheks-Refresh fehlgeschlagen: {e}"
|
||||||
|
|
||||||
|
|
||||||
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
||||||
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
|
||||||
ist_serie = (meta.get("type") == "tv")
|
ist_serie = (meta.get("type") == "tv")
|
||||||
@@ -71,9 +183,15 @@ def baue_nfo(meta: dict, titel: str, jahr=None) -> str:
|
|||||||
return "\n".join(zeilen) + "\n"
|
return "\n".join(zeilen) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) -> list:
|
def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict,
|
||||||
|
serien_root: str = None) -> list:
|
||||||
"""Schreibt NFO + Poster in den fertigen Ordner (best effort).
|
"""Schreibt NFO + Poster in den fertigen Ordner (best effort).
|
||||||
|
|
||||||
|
serien_root: bei Staffel-Rips der Serien-Ordner (Parent von "Season NN") —
|
||||||
|
tvshow.nfo + poster.jpg gehören laut Kodi-Schema dorthin, nicht in die
|
||||||
|
Staffel. Bestehende Dateien werden dort NIE überschrieben (Staffel 2
|
||||||
|
einer schon aufbereiteten Serie).
|
||||||
|
|
||||||
Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster
|
Rückgabe: Liste von Meldungen fürs Log. Wirft NIE — ein fehlendes Poster
|
||||||
darf einen gelungenen Rip nicht auf 'failed' drehen.
|
darf einen gelungenen Rip nicht auf 'failed' drehen.
|
||||||
"""
|
"""
|
||||||
@@ -82,23 +200,35 @@ def aufbereiten(ordner: str, media_server: str, titel: str, jahr, meta: dict) ->
|
|||||||
return meldungen
|
return meldungen
|
||||||
meta = meta or {}
|
meta = meta or {}
|
||||||
|
|
||||||
|
if serien_root:
|
||||||
|
ziel_ordner = serien_root
|
||||||
|
nfo_name = "tvshow.nfo"
|
||||||
|
nfo_meta = {**meta, "type": "tv"}
|
||||||
|
ueberschreiben = False
|
||||||
|
else:
|
||||||
|
ziel_ordner = ordner
|
||||||
|
nfo_name = "tvshow.nfo" if meta.get("type") == "tv" else "movie.nfo"
|
||||||
|
nfo_meta = meta
|
||||||
|
ueberschreiben = True
|
||||||
|
|
||||||
|
nfo_pfad = os.path.join(ziel_ordner, nfo_name)
|
||||||
try:
|
try:
|
||||||
ist_serie = (meta.get("type") == "tv")
|
if ueberschreiben or not os.path.exists(nfo_pfad):
|
||||||
nfo_name = "tvshow.nfo" if ist_serie else "movie.nfo"
|
with open(nfo_pfad, "w", encoding="utf-8") as f:
|
||||||
with open(os.path.join(ordner, nfo_name), "w", encoding="utf-8") as f:
|
f.write(baue_nfo(nfo_meta, titel, jahr))
|
||||||
f.write(baue_nfo(meta, titel, jahr))
|
meldungen.append(f"{nfo_name} geschrieben")
|
||||||
meldungen.append(f"{nfo_name} geschrieben")
|
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
meldungen.append(f"NFO fehlgeschlagen: {e}")
|
meldungen.append(f"NFO fehlgeschlagen: {e}")
|
||||||
|
|
||||||
poster = meta.get("poster_path") or ""
|
poster = meta.get("poster_path") or ""
|
||||||
if poster:
|
poster_pfad = os.path.join(ziel_ordner, "poster.jpg")
|
||||||
|
if poster and (ueberschreiben or not os.path.exists(poster_pfad)):
|
||||||
if not poster.startswith("http"):
|
if not poster.startswith("http"):
|
||||||
poster = f"https://image.tmdb.org/t/p/w500{poster}"
|
poster = f"https://image.tmdb.org/t/p/w500{poster}"
|
||||||
try:
|
try:
|
||||||
antwort = requests.get(poster, timeout=15)
|
antwort = requests.get(poster, timeout=15)
|
||||||
if antwort.status_code == 200 and antwort.content:
|
if antwort.status_code == 200 and antwort.content:
|
||||||
with open(os.path.join(ordner, "poster.jpg"), "wb") as f:
|
with open(poster_pfad, "wb") as f:
|
||||||
f.write(antwort.content)
|
f.write(antwort.content)
|
||||||
meldungen.append("poster.jpg gespeichert")
|
meldungen.append("poster.jpg gespeichert")
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
-r Robot-Mode: maschinenlesbare Ausgabe (PRGV/MSG-Zeilen)
|
||||||
@@ -48,7 +48,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
|||||||
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
|
Container zum Hängen (1.18.4) bzw. Segfault (1.17.7):
|
||||||
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
|
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
|
||||||
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
--progress=-same Fortschritt in denselben Stream wie die Meldungen
|
||||||
mkv dev:<pfad> all <ziel> alle Titel der Disc verlustfrei als MKV
|
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
"makemkvcon",
|
||||||
@@ -57,11 +57,74 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
|
|||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
f"dev:{device_path}",
|
||||||
"all",
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tinfo_dauern(ausgabe: str) -> dict:
|
||||||
|
"""TINFO-Zeilen → {titel_nr: dauer_sekunden} (pure Funktion, testbar).
|
||||||
|
|
||||||
|
makemkvcon-Robot-Format laut makemkv.com/developers/usage.txt:
|
||||||
|
TINFO:<titel>,<attr_id>,<code>,"<wert>" — Attribut 9 ist die Laufzeit
|
||||||
|
als "H:MM:SS" (ap_iaDuration).
|
||||||
|
"""
|
||||||
|
dauern = {}
|
||||||
|
for zeile in ausgabe.splitlines():
|
||||||
|
treffer = re.match(r'TINFO:(\d+),9,\d+,"(\d+):(\d{2}):(\d{2})"', zeile.strip())
|
||||||
|
if treffer:
|
||||||
|
nr, h, m, s = (int(g) for g in treffer.groups())
|
||||||
|
dauern[nr] = h * 3600 + m * 60 + s
|
||||||
|
return dauern
|
||||||
|
|
||||||
|
|
||||||
|
def laengster_titel(dauern: dict):
|
||||||
|
"""Titel-Nummer mit der längsten Laufzeit — der Hauptfilm (pure Funktion)."""
|
||||||
|
if not dauern:
|
||||||
|
return None
|
||||||
|
return max(dauern, key=dauern.get)
|
||||||
|
|
||||||
|
|
||||||
|
def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
||||||
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return {}
|
||||||
|
return parse_tinfo_dauern(ergebnis.stdout or "")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
|
"""HandBrake-Scan-Ausgabe → Dauer in Sekunden (pure Funktion, testbar).
|
||||||
|
|
||||||
|
HandBrakeCLI --scan schreibt je Titel "+ duration: HH:MM:SS"
|
||||||
|
(handbrake.fr/docs, Scan-Ausgabe); bei einer MKV-Datei gibt es genau
|
||||||
|
einen Titel.
|
||||||
|
"""
|
||||||
|
treffer = re.search(r"\+ duration: (\d+):(\d{2}):(\d{2})", ausgabe)
|
||||||
|
if not treffer:
|
||||||
|
return 0
|
||||||
|
h, m, s = (int(g) for g in treffer.groups())
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
|
||||||
|
|
||||||
|
def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
||||||
|
"""Laufzeit einer MKV-Datei in Sekunden (HandBrakeCLI --scan)."""
|
||||||
|
if not check_handbrake_installed():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
ergebnis = subprocess.run(
|
||||||
|
["HandBrakeCLI", "--scan", "-i", pfad],
|
||||||
|
capture_output=True, text=True, timeout=timeout,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return 0
|
||||||
|
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
|
||||||
|
|
||||||
|
|
||||||
def get_progress_from_prgv(line: str) -> int:
|
def get_progress_from_prgv(line: str) -> int:
|
||||||
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
|
||||||
|
|
||||||
@@ -178,7 +241,7 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all") -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt."""
|
||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
@@ -187,7 +250,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(
|
process = subprocess.Popen(
|
||||||
build_makemkv_cmd(device_path, output_dir),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -252,16 +315,27 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None) -> dict:
|
|||||||
return {"status": "error", "error": str(e)}
|
return {"status": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None, output_dir: str = None) -> dict:
|
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
|
||||||
|
output_dir: str = None, nur_hauptfilm: bool = False) -> dict:
|
||||||
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
|
||||||
|
|
||||||
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
|
||||||
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
|
||||||
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
überschreibt das Ziel (Transcode-Fall: Roh-Rip nach /app/temp).
|
||||||
|
nur_hauptfilm: Vorab-Info-Lauf, dann nur den LÄNGSTEN Titel rippen —
|
||||||
|
das löst das eingestellte (vorher wirkungslose) mainFeatureOnly ein.
|
||||||
"""
|
"""
|
||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
output_dir = os.path.join(RIP_OUTPUT_DIR, disc_type, disc_id)
|
||||||
return run_makemkv(device_path, output_dir, progress_cb=progress_cb)
|
|
||||||
|
titel = "all"
|
||||||
|
if nur_hauptfilm:
|
||||||
|
dauern = lies_titel_dauern(device_path)
|
||||||
|
haupt = laengster_titel(dauern)
|
||||||
|
if haupt is not None:
|
||||||
|
titel = str(haupt)
|
||||||
|
# Kein Titel ermittelbar → ehrlich auf 'all' zurückfallen statt raten
|
||||||
|
return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel)
|
||||||
|
|
||||||
|
|
||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None) -> dict:
|
||||||
|
|||||||
+89
-6
@@ -17,6 +17,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
import db
|
import db
|
||||||
import medien
|
import medien
|
||||||
import notify
|
import notify
|
||||||
@@ -26,11 +28,14 @@ from ripping import (
|
|||||||
DEFAULT_HB_PRESET,
|
DEFAULT_HB_PRESET,
|
||||||
RIP_OUTPUT_DIR,
|
RIP_OUTPUT_DIR,
|
||||||
RipAbbruch,
|
RipAbbruch,
|
||||||
|
lies_datei_dauer,
|
||||||
rip_cd,
|
rip_cd,
|
||||||
rip_video,
|
rip_video,
|
||||||
run_handbrake,
|
run_handbrake,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
|
||||||
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
@@ -111,6 +116,47 @@ def _abbruch_angefordert(job_id: str) -> bool:
|
|||||||
return db.get_job_status(job_id) == "canceling"
|
return db.get_job_status(job_id) == "canceling"
|
||||||
|
|
||||||
|
|
||||||
|
def _serien_episoden_zuordnen(ausgabe: str, serie: str, staffel, meta: dict) -> list:
|
||||||
|
"""Episoden-Zuordnung per Laufzeitabgleich (ARM-Wunde #395, besser gelöst).
|
||||||
|
|
||||||
|
Laufzeiten der Staffel kommen von der API (TMDB); die der Dateien vom
|
||||||
|
HandBrake-Scan. Nur bei EINDEUTIGEM Treffer wird umbenannt. Wirft nie.
|
||||||
|
"""
|
||||||
|
if meta.get("source") != "tmdb" or meta.get("type") != "tv" or not meta.get("id"):
|
||||||
|
return ["Episoden-Zuordnung übersprungen (nur mit TMDB-Serien-Treffer möglich)"]
|
||||||
|
try:
|
||||||
|
antwort = requests.get(
|
||||||
|
f"{API_URL}/metadata/tv/{meta['id']}/season/{int(staffel)}", timeout=30
|
||||||
|
)
|
||||||
|
if antwort.status_code != 200:
|
||||||
|
return [f"Episoden-Laufzeiten nicht abrufbar (HTTP {antwort.status_code})"]
|
||||||
|
episoden = [
|
||||||
|
(e["episode"], (e.get("runtime") or 0) * 60)
|
||||||
|
for e in antwort.json().get("episodes", [])
|
||||||
|
]
|
||||||
|
except (requests.RequestException, ValueError) as e:
|
||||||
|
return [f"Episoden-Laufzeiten nicht abrufbar: {e}"]
|
||||||
|
if not episoden:
|
||||||
|
return ["Keine Episoden-Laufzeiten bei TMDB hinterlegt — Dateinamen bleiben"]
|
||||||
|
|
||||||
|
dateien = sorted(
|
||||||
|
os.path.join(ausgabe, f) for f in os.listdir(ausgabe) if f.endswith(".mkv")
|
||||||
|
)
|
||||||
|
if not dateien:
|
||||||
|
return []
|
||||||
|
dauern = [lies_datei_dauer(p) for p in dateien]
|
||||||
|
if 0 in dauern:
|
||||||
|
return ["Datei-Laufzeiten nicht lesbar — Dateinamen bleiben"]
|
||||||
|
|
||||||
|
zuordnung = medien.matche_episoden(dauern, episoden)
|
||||||
|
if not zuordnung:
|
||||||
|
return [
|
||||||
|
"Keine EINDEUTIGE Episoden-Zuordnung über die Laufzeiten — "
|
||||||
|
"Dateinamen bleiben (lieber ehrlich als falsch benannt)"
|
||||||
|
]
|
||||||
|
return medien.episoden_umbenennen(ausgabe, serie, staffel, zuordnung)
|
||||||
|
|
||||||
|
|
||||||
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
def _benachrichtigen(job_id: str, betreff: str, text: str, level: str) -> None:
|
||||||
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
@@ -143,17 +189,38 @@ def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
|
|||||||
if ergebnis.get("status") == "success":
|
if ergebnis.get("status") == "success":
|
||||||
ausgabe = ergebnis.get("output_dir")
|
ausgabe = ergebnis.get("output_dir")
|
||||||
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
|
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
|
||||||
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
|
|
||||||
einstellungen = db.get_settings()
|
einstellungen = db.get_settings()
|
||||||
try:
|
try:
|
||||||
meta = json.loads(job.get("meta") or "{}")
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
meta = {}
|
meta = {}
|
||||||
|
serie = meta.get("series")
|
||||||
|
staffel = meta.get("season")
|
||||||
|
|
||||||
|
# Serien-Rips: Episoden per Laufzeit zuordnen und umbenennen
|
||||||
|
# (Show S01E02.mkv) — nur bei EINDEUTIGER Zuordnung, sonst
|
||||||
|
# bleiben die MakeMKV-Namen (ehrlich geloggt).
|
||||||
|
if serie and staffel:
|
||||||
|
for meldung in _serien_episoden_zuordnen(ausgabe, serie, staffel, meta):
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
|
|
||||||
|
# Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster);
|
||||||
|
# bei Serien wandern tvshow.nfo/poster in den Serien-Ordner.
|
||||||
for meldung in medien.aufbereiten(
|
for meldung in medien.aufbereiten(
|
||||||
ausgabe, einstellungen.get("mediaServer") or "none",
|
ausgabe, einstellungen.get("mediaServer") or "none",
|
||||||
job.get("title") or "", meta.get("year"), meta,
|
serie or job.get("title") or "", meta.get("year"), meta,
|
||||||
|
serien_root=os.path.dirname(ausgabe) if serie and staffel else None,
|
||||||
):
|
):
|
||||||
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
|
|
||||||
|
# Bibliotheks-Refresh (Jellyfin/Emby): der Server scannt sofort
|
||||||
|
meldung = medien.bibliothek_refresh(
|
||||||
|
einstellungen.get("mediaServer") or "none",
|
||||||
|
(einstellungen.get("jellyfinUrl") or "").strip(),
|
||||||
|
(einstellungen.get("jellyfinApiKey") or "").strip(),
|
||||||
|
)
|
||||||
|
if meldung:
|
||||||
|
db.add_log("info", "worker", f"Job {job_id}: {meldung}")
|
||||||
db.update_job(
|
db.update_job(
|
||||||
job_id,
|
job_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
@@ -228,15 +295,29 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
|
|
||||||
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
# Sprechender Zielordner „<Titel> (Jahr)" statt Job-UUID — Jellyfin & Co.
|
||||||
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
|
||||||
|
# Serien-Rips landen stattdessen in <Serie>/Season NN (Staffel-Flow).
|
||||||
job = db.get_job(job_id) or {}
|
job = db.get_job(job_id) or {}
|
||||||
try:
|
try:
|
||||||
meta = json.loads(job.get("meta") or "{}")
|
meta = json.loads(job.get("meta") or "{}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
meta = {}
|
meta = {}
|
||||||
final_dir = medien.zielordner(
|
if meta.get("series") and meta.get("season"):
|
||||||
_zielbasis(target_dir, disc_type),
|
final_dir = medien.serien_ordner(
|
||||||
job.get("title") or "", meta.get("year"), job_id,
|
_zielbasis(target_dir, disc_type), meta["series"], meta["season"]
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
final_dir = medien.zielordner(
|
||||||
|
_zielbasis(target_dir, disc_type),
|
||||||
|
job.get("title") or "", meta.get("year"), job_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nur Hauptfilm (längster Titel): pro Rip wählbar, sonst Setting;
|
||||||
|
# bei Serien-Discs sinnlos (dort zählen ALLE Episoden-Titel).
|
||||||
|
nur_hauptfilm = meta.get("main_feature_only")
|
||||||
|
if nur_hauptfilm is None:
|
||||||
|
nur_hauptfilm = bool(einstellungen.get("mainFeatureOnly", False))
|
||||||
|
if meta.get("series"):
|
||||||
|
nur_hauptfilm = False
|
||||||
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
|
||||||
db.update_job(job_id, output_path=final_dir)
|
db.update_job(job_id, output_path=final_dir)
|
||||||
|
|
||||||
@@ -267,11 +348,13 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
|
|||||||
device_path, job_id, disc_type,
|
device_path, job_id, disc_type,
|
||||||
progress_cb=fortschritt,
|
progress_cb=fortschritt,
|
||||||
output_dir=raw_dir,
|
output_dir=raw_dir,
|
||||||
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ergebnis = rip_video(
|
ergebnis = rip_video(
|
||||||
device_path, job_id, disc_type,
|
device_path, job_id, disc_type,
|
||||||
progress_cb=fortschritt, output_dir=final_dir,
|
progress_cb=fortschritt, output_dir=final_dir,
|
||||||
|
nur_hauptfilm=nur_hauptfilm,
|
||||||
)
|
)
|
||||||
|
|
||||||
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Tests für die Media-Server-Aufbereitung (Benennung + NFO)."""
|
"""Tests für die Media-Server-Aufbereitung (Benennung, NFO, Serien-Matching)."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from medien import baue_nfo, sicherer_name, zielordner
|
from medien import baue_nfo, matche_episoden, serien_ordner, sicherer_name, zielordner
|
||||||
|
|
||||||
|
|
||||||
def test_sicherer_name_entfernt_verbotene_zeichen():
|
def test_sicherer_name_entfernt_verbotene_zeichen():
|
||||||
@@ -55,3 +55,50 @@ def test_baue_nfo_film_mit_plot_und_genres():
|
|||||||
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
|
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
|
||||||
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
|
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
|
||||||
assert "<tvshow>" in nfo and "</tvshow>" in nfo
|
assert "<tvshow>" in nfo and "</tvshow>" in nfo
|
||||||
|
|
||||||
|
|
||||||
|
def test_sicherer_name_haengt_jahr_nicht_doppelt_an():
|
||||||
|
"""OMDb-Titel tragen das Jahr teils schon — kein 'X (2009) (2009)' mehr."""
|
||||||
|
assert sicherer_name("Summer Wars (2009)", 2009) == "Summer Wars (2009)"
|
||||||
|
assert sicherer_name("Summer Wars", 2009) == "Summer Wars (2009)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_serien_ordner_jellyfin_schema():
|
||||||
|
assert serien_ordner("/app/media/series", "Neon Genesis: Evangelion", 2) == os.path.join(
|
||||||
|
"/app/media/series", "Neon Genesis Evangelion", "Season 02"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_komplette_staffel_auch_bei_uniformen_laufzeiten():
|
||||||
|
# Anime-Standardfall: ganze Staffel auf der Disc, alle Episoden ~24 min
|
||||||
|
dateien = [1440, 1452, 1431]
|
||||||
|
episoden = [(1, 1440), (2, 1440), (3, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_teil_disc_mit_distinkten_laufzeiten():
|
||||||
|
# Disc 2 einer Staffel: Laufzeiten unterscheiden sich klar → zuordenbar
|
||||||
|
dateien = [1440, 2650]
|
||||||
|
episoden = [(1, 700), (2, 1440), (3, 2650)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_haelt_reihenfolge():
|
||||||
|
# Datei 2 ist ein Doppellänger → muss auf die SPÄTERE lange Episode fallen
|
||||||
|
dateien = [1440, 2880]
|
||||||
|
episoden = [(1, 1440), (2, 2880)]
|
||||||
|
assert matche_episoden(dateien, episoden) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_mehrdeutig_gibt_none():
|
||||||
|
# Alle Episoden gleich lang und mehr Kandidaten als Dateien in Toleranz →
|
||||||
|
# Datei 1 passt auf Ep 1 UND Ep 2 gleich gut → keine eindeutige Wahl
|
||||||
|
dateien = [1440]
|
||||||
|
episoden = [(1, 1440), (2, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_matche_episoden_ohne_treffer_gibt_none():
|
||||||
|
dateien = [600] # 10 min passt auf keine 24-min-Episode
|
||||||
|
episoden = [(1, 1440), (2, 1440)]
|
||||||
|
assert matche_episoden(dateien, episoden) is None
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Tests für die Hauptfilm-Auswahl (TINFO-Parsing) und Datei-Laufzeiten."""
|
||||||
|
|
||||||
|
from ripping import (
|
||||||
|
build_makemkv_cmd,
|
||||||
|
laengster_titel,
|
||||||
|
parse_scan_dauer,
|
||||||
|
parse_tinfo_dauern,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Echte Robot-Mode-Zeilenform laut makemkv.com/developers/usage.txt:
|
||||||
|
# TINFO:<titel>,<attr>,<code>,"wert" — Attribut 9 = Laufzeit "H:MM:SS"
|
||||||
|
TINFO_BEISPIEL = """
|
||||||
|
TINFO:0,2,0,"Hauptfilm"
|
||||||
|
TINFO:0,9,0,"1:52:29"
|
||||||
|
TINFO:1,9,0,"0:03:12"
|
||||||
|
TINFO:2,9,0,"0:24:01"
|
||||||
|
MSG:1011,0,1,"Using LibreDrive mode","%1","x"
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_tinfo_dauern():
|
||||||
|
dauern = parse_tinfo_dauern(TINFO_BEISPIEL)
|
||||||
|
assert dauern == {0: 6749, 1: 192, 2: 1441}
|
||||||
|
|
||||||
|
|
||||||
|
def test_laengster_titel_ist_der_hauptfilm():
|
||||||
|
assert laengster_titel({0: 6749, 1: 192, 2: 1441}) == 0
|
||||||
|
assert laengster_titel({}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_makemkv_cmd_mit_titelwahl():
|
||||||
|
cmd = build_makemkv_cmd("/dev/sr0", "/out", titel="7")
|
||||||
|
assert cmd[-2:] == ["7", "/out"]
|
||||||
|
assert "all" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_makemkv_cmd_default_bleibt_all():
|
||||||
|
cmd = build_makemkv_cmd("/dev/sr0", "/out")
|
||||||
|
assert cmd[-2:] == ["all", "/out"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_scan_dauer():
|
||||||
|
ausgabe = "…\n + duration: 01:52:29\n…"
|
||||||
|
assert parse_scan_dauer(ausgabe) == 6749
|
||||||
|
assert parse_scan_dauer("kein treffer") == 0
|
||||||
Reference in New Issue
Block a user