From 8eb5653848dfe53e2e12df64e62213a0c24e99c8 Mon Sep 17 00:00:00 2001
From: Hitonabi
Date: Fri, 24 Jul 2026 14:22:19 +0200
Subject: [PATCH] Restefeger: Auth komplett raus, Serien-Flow +
Episoden-Matching, Jellyfin-Refresh, Duplikat-Warnung, echtes Nur-Hauptfilm
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 /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 . 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
---
.env.example | 5 +-
KONZEPT.md | 15 +-
README.md | 19 +-
ROADMAP.md | 77 ++--
SAVEPOINT.md | 36 +-
deploy/remote-transcode-worker.yml | 2 +
docker-compose.yml | 19 +-
docker/api/auth.py | 105 -----
docker/api/clients/tmdb.py | 36 ++
docker/api/config.py | 8 +-
docker/api/config_validation.py | 6 -
docker/api/conftest.py | 5 +-
docker/api/main.py | 308 +++++++-------
docker/api/prescan/prescan.py | 25 +-
docker/api/ratelimit.py | 47 +--
docker/api/requirements.txt | 11 +-
docker/api/test_auth.py | 69 ---
docker/ui/src/App.tsx | 7 +-
docker/ui/src/components/DeviceDiscovery.tsx | 27 +-
docker/ui/src/components/RipTargetModal.tsx | 72 +++-
docker/ui/src/pages/Dashboard.tsx | 30 +-
docker/ui/src/pages/MetadataPreview.tsx | 420 -------------------
docker/ui/src/pages/Settings.tsx | 50 +++
docker/worker/medien.py | 148 ++++++-
docker/worker/ripping.py | 88 +++-
docker/worker/tasks.py | 95 ++++-
docker/worker/test_medien.py | 51 ++-
docker/worker/test_titel_auswahl.py | 45 ++
28 files changed, 934 insertions(+), 892 deletions(-)
delete mode 100644 docker/api/auth.py
delete mode 100644 docker/api/test_auth.py
delete mode 100644 docker/ui/src/pages/MetadataPreview.tsx
create mode 100644 docker/worker/test_titel_auswahl.py
diff --git a/.env.example b/.env.example
index 772e29b..be4bcdb 100644
--- a/.env.example
+++ b/.env.example
@@ -4,9 +4,8 @@
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
POSTGRES_PASSWORD=rippy
-# JWT-Signierschlüssel — PFLICHT, API startet sonst nicht.
-# Erzeugen: openssl rand -hex 32
-JWT_SECRET_KEY=
+# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
+# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.)
# Metadaten-APIs
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
diff --git a/KONZEPT.md b/KONZEPT.md
index 3a84163..dc9080c 100644
--- a/KONZEPT.md
+++ b/KONZEPT.md
@@ -35,8 +35,8 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| AcoustID-Fingerprinting (chromaprint) | ✔ M | | |
| Multi-Disc-Set-Handling (Release-Group-Resolver) | ✔ M | | |
| SQLite-Cache für API-Rate-Limits | ✔ M | | |
-| JWT-Auth (Access 15min/Refresh 7 Tage) | ✔ M | | |
-| Rate-Limiting (100/min pro API-Key) | ✔ M | | |
+| ~~JWT-Auth (Access 15min/Refresh 7 Tage)~~ — GESTRICHEN 24.07.2026, siehe §10 | | | |
+| Rate-Limiting (100/min pro Client-IP) | ✔ M | | |
| Celery-Queue + Redis mit AOF-Persistence | ✔ M | | |
| React-UI (statisch via Nginx) | ✔ 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
ist im Container prinzipbedingt nicht lauffähig; die Disc-Wache pollt per
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 /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).
diff --git a/README.md b/README.md
index ffd3a3f..b64f2f7 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Voraussetzungen: Docker + Docker Compose, ein optisches Laufwerk am Host.
```bash
git clone 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)
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) —
oder du stellst die **Vollautomatik** an (Einstellungen → Ripping) und
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
- Tonspuren und Untertitel bleiben erhalten (wichtig für Anime/O-Ton).
- Audio-CDs laufen über abcde → FLAC + MusicBrainz.
+ Optional **nur Hauptfilm** (längster Titel, Extras bleiben weg). Bereits
+ gerippte Discs erkennt Rippy am Fingerabdruck und warnt. Vor dem Start
+ 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 `/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,
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
(„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
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
- 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:
„Ultimate UHD Drives Flashing Guide"). Normale BD/DVD gehen mit jedem
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 |
|---|---|---|
-| `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) |
| `WORKER_NAME` | optional | Anzeigename des eingebauten Workers (Standard: rippy-hauptworker) |
| `OMDB_API_KEY` | optional | zweite Metadaten-Quelle (Fallback) |
diff --git a/ROADMAP.md b/ROADMAP.md
index 3920765..e9c978e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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
- abfragen → Ablage `Show/Season 02/`, Episoden-Matching wie Etappe 12.
-2. **Duplikat-Warnung**: Disc-Fingerprint schon in der Job-Historie →
- „schon am X gerippt — trotzdem?" (Fingerprint-Infrastruktur existiert).
-3. **Jellyfin-Bibliotheks-Refresh** nach Ablage (API-Key + URL in den
- Einstellungen, POST /Library/Refresh) — der letzte Schritt zur
- Null-Klick-Kette Disc→Bildschirm.
-4. **Track-Auswahl vor dem Rip** (awaiting_selection, Etappe 12) — spart
- bei Bonus-Material-Discs Stunden.
-5. **Auth vor schreibende Endpoints** + Login-Flow im UI (JWT existiert).
-6. **Speicherplatz-Kachel im Dashboard** (Media/Arbeitsverzeichnis als
- Balken — /system/info liefert die Daten schon).
-7. **Rip-Historie exportieren** (CSV/JSON) für die Sammlung-Übersicht.
-8. **Design 2.0**: Design-Tokens statt Theme-Ternaries in jeder Zeile —
- die `theme === 'dark' ? … : …`-Kaskaden sind der größte UI-Schuldenberg.
-9. **Nativer Windows-Worker (ohne Docker)**: Endnutzer installieren kein
- Docker Desktop nur für den Transcode-Worker. Realistischer Weg: ein
- PyInstaller-Paket (Celery solo-Pool + HandBrakeCLI.exe gebündelt) als
- Windows-Dienst, Anbindung wie gehabt über Redis. Aufwand: mittel;
- Celery unter Windows nur im solo/threads-Pool verlässlich.
-10. **Deutsche Texte auch für OMDb-Treffer**: OMDb liefert nur Englisch.
- Wenn OMDb/Jikan die Quelle war, per TMDB-Find (imdb_id → TMDB-Eintrag,
- language=de-DE) die deutsche Beschreibung nachladen.
+**Commander-Auftrag:** „Arbeite alles ab, abgesehen von Auth — das ist
+irrelevant und kann komplett raus."
+
+- [x] **AUTH KOMPLETT ENTFERNT** (Commander-Entscheid): /token- und
+ /api-keys-Endpoints, auth.py, test_auth.py, passlib/bcrypt/PyJWT,
+ JWT_SECRET_KEY-Pflicht — alles raus (KONZEPT §10). Der Schnellstart
+ braucht keine .env-Pflichtwerte mehr. Rate-Limit pro IP bleibt.
+- [x] **Serien-Staffel-Flow**: Im Rip-Dialog Serienname + Staffel →
+ Ablage `/Season NN`; tvshow.nfo + poster.jpg landen im
+ Serien-Ordner (werden bei Staffel 2 nicht überschrieben).
+- [x] **Episoden-Erkennung per Laufzeitabgleich** (ARM-Wunde #395):
+ Datei-Laufzeiten (HandBrake-Scan) gegen TMDB-Episoden-Laufzeiten,
+ ordnungserhaltend; komplette Staffel auf einer Disc geht auch bei
+ uniformen Laufzeiten. Umbenannt wird NUR bei eindeutiger Zuordnung
+ („Serie S01E02.mkv"), sonst bleiben die Namen + ehrliches Log.
+- [x] **Jellyfin/Emby-Bibliotheks-Refresh** nach jedem fertigen Rip
+ (POST /Library/Refresh, X-Emby-Token) — URL/Key + Test-Knopf in den
+ Einstellungen. Die Null-Klick-Kette Disc→Bildschirm steht.
+- [x] **Duplikat-Warnung**: Disc-Fingerabdruck gegen die Job-Historie —
+ Hinweis auf der Disc-Karte, Vollautomatik überspringt Duplikate.
+- [x] **„Nur Hauptfilm" ECHT**: das Setting war wirkungslos — jetzt
+ Info-Lauf → längster Titel → nur der wird gerippt; pro Rip im
+ 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.
diff --git a/SAVEPOINT.md b/SAVEPOINT.md
index dede9af..5129700 100644
--- a/SAVEPOINT.md
+++ b/SAVEPOINT.md
@@ -1,6 +1,40 @@
# 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
+ `/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:**
1. **SMB-Mount „Unable to apply new capability set"**: mount.cifs hebt
diff --git a/deploy/remote-transcode-worker.yml b/deploy/remote-transcode-worker.yml
index 9a0ba10..74ec4b0 100644
--- a/deploy/remote-transcode-worker.yml
+++ b/deploy/remote-transcode-worker.yml
@@ -28,6 +28,8 @@ services:
# Anzeigename in Einstellungen → Worker — z. B. den Maschinennamen
# setzen: WORKER_NAME=gaming-pc docker compose -f … up -d
- WORKER_NAME=${WORKER_NAME:-transcode-worker}
+ # Serien-Episoden-Matching fragt die Rippy-API nach Laufzeiten
+ - API_URL=http://${RIPPY_HOST}:8000
volumes:
# Freigabe der Rippy-Maschine (siehe Kopf-Kommentar)
- /mnt/rippy/media:/app/media
diff --git a/docker-compose.yml b/docker-compose.yml
index b959a5c..97727c6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,10 +13,9 @@ services:
environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
- REDIS_URL=redis://redis:6379/0
- - TMDB_API_KEY=${TMDB_API_KEY}
- - THETVDB_API_KEY=${THETVDB_API_KEY}
- - OMDB_API_KEY=${OMDB_API_KEY}
- - JWT_SECRET_KEY=${JWT_SECRET_KEY}
+ - TMDB_API_KEY=${TMDB_API_KEY:-}
+ - THETVDB_API_KEY=${THETVDB_API_KEY:-}
+ - OMDB_API_KEY=${OMDB_API_KEY:-}
- LOG_LEVEL=INFO
healthcheck:
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;
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
- 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
volumes:
- type: bind
@@ -127,6 +129,12 @@ services:
- POSTGRES_USER=rippy
- POSTGRES_PASSWORD=rippy
- POSTGRES_DB=rippy
+ # Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
+ # Windows-Worker) verbinden sich über :5432/6379 — ohne
+ # ports: konnte sich NIE ein Remote-Worker anbinden. Heimnetz-Kompromiss,
+ # wie die Klartext-Credentials (README).
+ ports:
+ - "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
@@ -140,6 +148,9 @@ services:
redis:
image: redis:7-alpine
+ # Siehe postgres: Pflicht für Remote-Worker (Celery-Broker).
+ ports:
+ - "6379:6379"
volumes:
- redis-data:/data
networks:
diff --git a/docker/api/auth.py b/docker/api/auth.py
deleted file mode 100644
index 841fbe5..0000000
--- a/docker/api/auth.py
+++ /dev/null
@@ -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]
diff --git a/docker/api/clients/tmdb.py b/docker/api/clients/tmdb.py
index 2d7a5af..2186d10 100644
--- a/docker/api/clients/tmdb.py
+++ b/docker/api/clients/tmdb.py
@@ -95,6 +95,42 @@ class TMDBClient:
"""Hole Serien-Details."""
params = {"language": "de-DE"}
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]:
"""Hole Poster/Fanart URLs."""
diff --git a/docker/api/config.py b/docker/api/config.py
index e023b38..386e547 100644
--- a/docker/api/config.py
+++ b/docker/api/config.py
@@ -24,13 +24,9 @@ class Settings(BaseSettings):
# Logging
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_secret_key: Optional[str] = Field(default=None, description="JWT Secret Key (PFLICHT, siehe .env)")
+ # JWT/Admin-Login entfernt (Commander-Entscheid 24.07.2026): Rippy ist
+ # 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:
env_file = ".env"
env_file_encoding = "utf-8"
diff --git a/docker/api/config_validation.py b/docker/api/config_validation.py
index fe4f76d..0524ae6 100644
--- a/docker/api/config_validation.py
+++ b/docker/api/config_validation.py
@@ -48,10 +48,4 @@ def get_config_with_fallback() -> tuple[Settings, list[str]]:
"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
diff --git a/docker/api/conftest.py b/docker/api/conftest.py
index 850e3ad..f4b9893 100644
--- a/docker/api/conftest.py
+++ b/docker/api/conftest.py
@@ -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 sys
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")
diff --git a/docker/api/main.py b/docker/api/main.py
index 2138e55..cd0b618 100644
--- a/docker/api/main.py
+++ b/docker/api/main.py
@@ -17,39 +17,23 @@ import notify
from celery_client import celery_client, start_rip
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 cache import init_cache, set as cache_set
-from auth import (
- 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 cache import init_cache
+from ratelimit import check_rate_limit, get_rate_limit_remaining
from prescan import PreScan
from nfo_generator import NFOGenerator
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(
title="Rippy API",
description="API für das automatische Ripping-System",
version="1.0.0"
)
-# OAuth2 Scheme
-oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
-
@app.on_event("startup")
async def startup_event():
@@ -76,6 +60,26 @@ async def startup_event():
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):
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
@@ -85,12 +89,23 @@ async def _auto_prescan(pfad: str):
prescan = PreScan()
ergebnis = await asyncio.to_thread(prescan.scan, pfad)
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(
"info", "watcher",
- f"Disc erkannt: {ergebnis.title}"
- + (f" ({ergebnis.year})" if ergebnis.year else "")
+ f"Disc erkannt: {ergebnis.title}{jahr_text}"
+ 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)
except Exception as e:
DISC_CACHE.pop(pfad, None)
@@ -112,6 +127,13 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
disc = DISC_CACHE.get(pfad) or {}
if disc.get("_laeuft"):
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
meta = disc.get("metadata") or {}
@@ -175,19 +197,13 @@ async def disc_watcher():
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")
async def rate_limit_middleware(request: Request, call_next):
"""Rate-Limiting Middleware."""
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
if not check_rate_limit(client_ip):
return Response(
@@ -318,6 +334,9 @@ class JobCreateRequest(BaseModel):
device: Optional[str] = None # Alias, so schickt es das UI
title: Optional[str] = None
target_dir: Optional[str] = None # Ablageziel unter /app/media (frei wählbar)
+ series: Optional[str] = None # Serien-Flow: Ablage /Season NN
+ season: Optional[int] = None
+ main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
MEDIA_ROOT = "/app/media"
@@ -352,18 +371,26 @@ async def create_job(request: JobCreateRequest):
# Titel + Metadaten aus der Disc-Erkennung übernehmen — der Worker nutzt
# 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
- meta_json = None
+ meta_dict = {}
disc = DISC_CACHE.get(device_path)
if disc and not disc.get("_laeuft"):
if not titel:
titel = disc.get("title")
- meta_json = json.dumps({
+ meta_dict = {
"year": disc.get("year"),
"confidence": disc.get("confidence"),
+ "fingerprint": disc.get("fingerprint"),
**(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())
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"}
+@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")
async def metadata_status():
"""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")
-# Metadaten-Lookup Endpoints
-class MetadataLookupRequest(BaseModel):
- title: str
- year: Optional[int] = None
- 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}
+# /metadata/lookup + /metadata/confirm entfernt (24.07., mit der
+# Metadaten-Seite): lookup scannte ein DUMMY-Device (/dev/dvd — existiert
+# nicht) und confirm schrieb in einen Cache-Key, den nie jemand las.
+# Die echte Korrektur läuft über /metadata/search + /metadata/override.
@app.get("/metadata/search")
@@ -1154,82 +1237,5 @@ async def jellyfin_format(request: JellyfinFormatRequest):
raise HTTPException(status_code=500, detail=str(e))
-# Auth Endpoints
-class LoginRequest(BaseModel):
- 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")
+# Auth-Endpoints (/token, /api-keys) entfernt — Commander-Entscheid 24.07.:
+# Heimnetz-only, kein Login-Flow im UI, die Endpoints waren Placebo.
diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py
index 9ab2992..ff0b118 100644
--- a/docker/api/prescan/prescan.py
+++ b/docker/api/prescan/prescan.py
@@ -203,7 +203,8 @@ class PreScanResult:
year: int = None,
confidence: float = 0.0,
metadata: Dict = None,
- tracks: List[Dict] = None
+ tracks: List[Dict] = None,
+ fingerprint: str = ""
):
self.disc_type = disc_type
self.title = title
@@ -211,6 +212,9 @@ class PreScanResult:
self.confidence = confidence
self.metadata = metadata 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:
return {
@@ -219,7 +223,8 @@ class PreScanResult:
"year": self.year,
"confidence": self.confidence,
"metadata": self.metadata,
- "tracks": self.tracks
+ "tracks": self.tracks,
+ "fingerprint": self.fingerprint
}
@@ -361,7 +366,8 @@ class PreScan:
disc_type="CD",
title=album,
tracks=toc["tracks"],
- confidence=confidence
+ confidence=confidence,
+ fingerprint=toc.get("fingerprint", "")
)
cache_set(cache_key, result.to_dict())
return result
@@ -486,6 +492,16 @@ class PreScan:
"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
if matched and metadata.get("title"):
title = metadata["title"]
@@ -496,7 +512,8 @@ class PreScan:
year=metadata.get("year"),
confidence=confidence,
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
# nach Key-Eintrag/Fix ewig wieder serviert (Redis ist persistent).
diff --git a/docker/api/ratelimit.py b/docker/api/ratelimit.py
index a497862..bd7cb74 100644
--- a/docker/api/ratelimit.py
+++ b/docker/api/ratelimit.py
@@ -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 secrets
from collections import defaultdict
-from typing import Dict, Optional
+from typing import Dict
# Default Rate Limit
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:
"""Setze Rate Limit für Client zurück."""
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
diff --git a/docker/api/requirements.txt b/docker/api/requirements.txt
index ad036e0..5401196 100644
--- a/docker/api/requirements.txt
+++ b/docker/api/requirements.txt
@@ -7,12 +7,7 @@ redis==5.0.4
requests==2.32.3
pydantic==2.9.0
pydantic-settings==2.5.2
-passlib==1.7.4
-# bcrypt MUSS gepinnt bleiben: passlib 1.7.4 liest bcrypt.__about__ (in
-# bcrypt >= 4.1 entfernt) — der Backend-Selbsttest crasht dann mit
-# "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
+# passlib/bcrypt/PyJWT/python-multipart entfernt (Auth-Rückbau 24.07.2026,
+# Commander-Entscheid: Heimnetz-only, siehe KONZEPT §10) — damit ist auch
+# die passlib↔bcrypt-Versionsfalle Geschichte, die die Ampel rot hielt.
aiofiles==24.1.0
diff --git a/docker/api/test_auth.py b/docker/api/test_auth.py
deleted file mode 100644
index 823d3fe..0000000
--- a/docker/api/test_auth.py
+++ /dev/null
@@ -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
diff --git a/docker/ui/src/App.tsx b/docker/ui/src/App.tsx
index 28749c6..e9a8e05 100644
--- a/docker/ui/src/App.tsx
+++ b/docker/ui/src/App.tsx
@@ -3,12 +3,13 @@ import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal } from 'lucide-rea
import { api } from './lib/api'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard'
-import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
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() {
const [page, setPage] = useState('dashboard')
@@ -27,7 +28,6 @@ function App() {
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
- { id: 'metadata', label: 'Metadaten', icon: Disc },
{ id: 'logs', label: 'Logs', icon: Terminal },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
]
@@ -86,7 +86,6 @@ function App() {
{page === 'dashboard' && }
- {page === 'metadata' && }
{page === 'logs' && }
{page === 'settings' && }
diff --git a/docker/ui/src/components/DeviceDiscovery.tsx b/docker/ui/src/components/DeviceDiscovery.tsx
index d364b21..8f907d4 100644
--- a/docker/ui/src/components/DeviceDiscovery.tsx
+++ b/docker/ui/src/components/DeviceDiscovery.tsx
@@ -3,7 +3,7 @@ import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-rea
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
-import RipTargetModal from './RipTargetModal'
+import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
interface DiscInfo {
@@ -17,6 +17,11 @@ interface DiscInfo {
type?: string
source?: string
}
+ bereits_gerippt?: {
+ job_id: string
+ title?: string
+ finished_at?: string
+ }
}
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)
setActionFeedback(null)
try {
const response = await api.post('/jobs', {
device_path: device.path,
...(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`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
@@ -203,6 +210,15 @@ export default function DeviceDiscovery() {
{device.disc!.metadata.overview}
)}
+ {device.disc!.bereits_gerippt && (
+
+
+ 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.
+
+ )}
+ {/* Serien-Flow: Name + Staffel → Ablage /Season NN, Episoden
+ werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
+ {selectedType === 'series' && (
+
+
+
+ Ablage: {serienName.trim() || ''}/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).
+
+
+ )}
+
+ {/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung) */}
+ {selectedType === 'movies' && (
+
+ )}
+
{/* Ordner-Browser */}
diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx
index efb37e3..42db653 100644
--- a/docker/ui/src/pages/Dashboard.tsx
+++ b/docker/ui/src/pages/Dashboard.tsx
@@ -1,5 +1,5 @@
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 { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
@@ -151,6 +151,7 @@ const ENCODER_KURZ: Record = {
export default function Dashboard() {
const [jobs, setJobs] = useState([])
const [workers, setWorkers] = useState([])
+ const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
@@ -189,6 +190,7 @@ export default function Dashboard() {
loadData()
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)
return () => clearInterval(interval)
}, [])
@@ -244,6 +246,21 @@ export default function Dashboard() {
kein Job
)}
+ {plaetze.length > 0 && (
+
+
+ Frei:
+ {plaetze.map(p => (
+
+ {p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
+
+ ))}
+
+ )}
{/* Laufwerke + erkannte Disc */}
@@ -290,6 +307,17 @@ export default function Dashboard() {
Neueste Jobs
{jobs.length} Jobs insgesamt
+ {jobs.length > 0 && (
+
+
+ CSV
+
+ )}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
}
diff --git a/docker/worker/medien.py b/docker/worker/medien.py
index 400d3ce..37559fe 100644
--- a/docker/worker/medien.py
+++ b/docker/worker/medien.py
@@ -28,7 +28,9 @@ def sicherer_name(titel: str, jahr=None) -> str:
name = re.sub(r"\s+", " ", name)[:150].strip()
if not name:
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})"
return name
@@ -48,6 +50,116 @@ def zielordner(basis: str, titel: str, jahr, job_id: str) -> str:
return pfad
+def serien_ordner(basis: str, serie: str, staffel: int) -> str:
+ """Jellyfin-Konvention: //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:
"""Pure Funktion (testbar): minimales movie.nfo/tvshow.nfo im Kodi-Schema."""
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"
-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).
+ 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
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
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:
- ist_serie = (meta.get("type") == "tv")
- nfo_name = "tvshow.nfo" if ist_serie else "movie.nfo"
- with open(os.path.join(ordner, nfo_name), "w", encoding="utf-8") as f:
- f.write(baue_nfo(meta, titel, jahr))
- meldungen.append(f"{nfo_name} geschrieben")
+ if ueberschreiben or not os.path.exists(nfo_pfad):
+ with open(nfo_pfad, "w", encoding="utf-8") as f:
+ f.write(baue_nfo(nfo_meta, titel, jahr))
+ meldungen.append(f"{nfo_name} geschrieben")
except OSError as e:
meldungen.append(f"NFO fehlgeschlagen: {e}")
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"):
poster = f"https://image.tmdb.org/t/p/w500{poster}"
try:
antwort = requests.get(poster, timeout=15)
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)
meldungen.append("poster.jpg gespeichert")
except requests.RequestException as e:
diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py
index bd4f6f8..edb60fd 100644
--- a/docker/worker/ripping.py
+++ b/docker/worker/ripping.py
@@ -40,7 +40,7 @@ def check_cdparanoia_installed() -> bool:
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).
-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):
/sys zeigt dort auch Geräte ohne /dev-Knoten (Befund 23.07.)
--progress=-same Fortschritt in denselben Stream wie die Meldungen
- mkv dev: all alle Titel der Disc verlustfrei als MKV
+ mkv dev: 'all' oder eine Titel-Nummer (Hauptfilm)
"""
return [
"makemkvcon",
@@ -57,11 +57,74 @@ def build_makemkv_cmd(device_path: str, output_dir: str) -> list:
"--progress=-same",
"mkv",
f"dev:{device_path}",
- "all",
+ str(titel),
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:,,,"" — 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:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
@@ -178,7 +241,7 @@ def write_abcde_config(output_dir: str) -> str:
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."""
if not check_makemkv_installed():
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:
process = subprocess.Popen(
- build_makemkv_cmd(device_path, output_dir),
+ build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
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)}
-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.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
der hier mit seinem eigenen Fortschritts-Callback durchgreift. output_dir
ü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:
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:
diff --git a/docker/worker/tasks.py b/docker/worker/tasks.py
index d5e6f1f..07cdd88 100644
--- a/docker/worker/tasks.py
+++ b/docker/worker/tasks.py
@@ -17,6 +17,8 @@ import json
import os
import shutil
+import requests
+
import db
import medien
import notify
@@ -26,11 +28,14 @@ from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
+ lies_datei_dauer,
rip_cd,
rip_video,
run_handbrake,
)
+API_URL = os.getenv("API_URL", "http://api:8000")
+
RAW_DIR = os.getenv("RAW_DIR", "/app/temp/raw")
MEDIA_ROOT = "/app/media"
@@ -111,6 +116,47 @@ def _abbruch_angefordert(job_id: str) -> bool:
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:
"""Webhook-Meldung bei Job-Ende — best effort, nie job-entscheidend."""
einstellungen = db.get_settings()
@@ -143,17 +189,38 @@ def _job_abschliessen(job_id: str, ergebnis: dict) -> None:
if ergebnis.get("status") == "success":
ausgabe = ergebnis.get("output_dir")
if ausgabe and (job.get("disc_type") in ("dvd", "bluray", "uhd")):
- # Media-Server-Aufbereitung (Jellyfin/Emby/Kodi: NFO + Poster)
einstellungen = db.get_settings()
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
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(
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}")
+
+ # 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(
job_id,
status="completed",
@@ -228,15 +295,29 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# Sprechender Zielordner „ (Jahr)" statt Job-UUID — Jellyfin & Co.
# erkennen den Film am Ordnernamen. UUID bleibt Fallback ohne Titel.
+ # Serien-Rips landen stattdessen in /Season NN (Staffel-Flow).
job = db.get_job(job_id) or {}
try:
meta = json.loads(job.get("meta") or "{}")
except ValueError:
meta = {}
- final_dir = medien.zielordner(
- _zielbasis(target_dir, disc_type),
- job.get("title") or "", meta.get("year"), job_id,
- )
+ if meta.get("series") and meta.get("season"):
+ final_dir = medien.serien_ordner(
+ _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)
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,
progress_cb=fortschritt,
output_dir=raw_dir,
+ nur_hauptfilm=nur_hauptfilm,
)
else:
ergebnis = rip_video(
device_path, job_id, disc_type,
progress_cb=fortschritt, output_dir=final_dir,
+ nur_hauptfilm=nur_hauptfilm,
)
# UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
diff --git a/docker/worker/test_medien.py b/docker/worker/test_medien.py
index a9a84ce..638fa7f 100644
--- a/docker/worker/test_medien.py
+++ b/docker/worker/test_medien.py
@@ -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
-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():
@@ -55,3 +55,50 @@ def test_baue_nfo_film_mit_plot_und_genres():
def test_baue_nfo_serie_bekommt_tvshow_wurzel():
nfo = baue_nfo({"type": "tv"}, "Neon Genesis Evangelion", 1995)
assert "" in nfo and "" 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
diff --git a/docker/worker/test_titel_auswahl.py b/docker/worker/test_titel_auswahl.py
new file mode 100644
index 0000000..f1a6b49
--- /dev/null
+++ b/docker/worker/test_titel_auswahl.py
@@ -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:,,,"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