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' && ( +
+
+
+ + 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'}`} + /> +
+
+ + 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'}`} + /> +
+
+

+ 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') && ( -
- - - {error && ( -
- - {error} -
- )} - - {/* Manuelle Korrektur: wenn die Automatik danebenliegt, entscheidest DU */} - {device && ( -
-

- Erkennung falsch? Richtigen Titel suchen und übernehmen: -

-
- 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'}`} - /> - -
- - {kandidaten.length > 0 && ( -
- {kandidaten.map((k, i) => ( - - ))} -
- )} -
- )} - - {/* Results */} - {result && ( -
- {/* Header — mit Poster, wenn eine Quelle eins geliefert hat */} -
- {posterAbsolut(result.metadata?.poster_path) && ( - {result.title} - )} -
-
- {/* 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 })()} - - {result.disc_type.toUpperCase()} - -
-

{result.title}

- {result.year && ( -

{result.year}

- )} -
-
- - {/* Confidence Score */} -
-
- Übereinstimmung: - - {Math.round(result.confidence * 100)}% - -
-
- - {/* Metadata */} -
- {result.metadata && ( -
- {result.metadata.overview && ( -
-

Beschreibung

-

{result.metadata.overview}

-
- )} - - {/* .length-Guards: {[] && …} rendert die leere Sektion, - {0 && …} sogar eine nackte "0" — klassische JSX-Falle */} - {result.metadata.genres && result.metadata.genres.length > 0 && ( -
-

Genre

-
- {result.metadata.genres.map((genre, index) => ( - - {genre} - - ))} -
-
- )} - - {(result.metadata.runtime ?? 0) > 0 && ( -
-

Laufzeit

-

{Math.floor(result.metadata.runtime / 60)}h {result.metadata.runtime % 60}m

-
- )} -
- )} - - {/* Tracks */} - {result.tracks && result.tracks.length > 0 && ( -
-

Tracks

-
- {result.tracks.map((track, index) => ( -
-
- {track.track_number} - {track.title} -
- {track.duration && ( - - {Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')} - - )} -
- ))} -
-
- )} -
- - {/* Action */} -
-
-
- - Bereit zum Ripping -
- -
-
-
- )} - - ) -} diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx index 6b9ecc7..4565950 100644 --- a/docker/ui/src/pages/Settings.tsx +++ b/docker/ui/src/pages/Settings.tsx @@ -23,6 +23,8 @@ interface SettingsState { transcodePreset: string keepOriginal: boolean mediaServer: string + jellyfinUrl: string + jellyfinApiKey: string workDir: string makemkvAppKey: string } @@ -44,6 +46,8 @@ const defaultSettings: SettingsState = { transcodePreset: 'H.265 MKV 1080p30', keepOriginal: false, mediaServer: 'none', + jellyfinUrl: '', + jellyfinApiKey: '', workDir: '', makemkvAppKey: '', } @@ -330,6 +334,52 @@ export default function SettingsPage() {

{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}

+ + {/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */} + {(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && ( +
+

+ Bibliotheks-Refresh (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. +

+
+ 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'}`} + /> +
+ 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'}`} + /> + +
+
+
+ )} } 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