From 0935766f61a5f2ccabb0bc3c8a733e7fc50e6e87 Mon Sep 17 00:00:00 2001
From: Hitonabi
Date: Sat, 25 Jul 2026 01:26:08 +0200
Subject: [PATCH] feat(uhd): KEYDB.cfg-Unterstuetzung - MakeMKVs
Schluessel-Kanal liefert nichts mehr
4K-UHD scheiterte an "The volume key is unknown for this disc". Am 25.07.2026
im Worker-Container nachgemessen: Laufwerk und MakeMKV sind in Ordnung
(LibreDrive v06.3 / Meldung 1011, Disc wird gelesen, AACS-Dump geschrieben) -
MakeMKV versucht gar nicht erst, online einen Schluessel zu holen. Belege:
_private_data.tar enthaelt nur die Index-Datei und KEINE hkd_*.bin; auch mit
geloeschter update.conf (Meldung 5074 belegt den Web-Kontakt) und mit
app_UpdateEnable="1" kam keiner; hkdata.fairuse.org und hkdata.crabdance.com
loesen weltweit nicht mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8, 1.1.1.1).
Betroffen war Akira UHD (MKB v76, Pressung Dez. 2020) - also gerade KEINE
Neuerscheinung. Der bisherige Fehlertext ("Disc neuer als die
Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war falsch.
Einziger heute funktionierender Weg ist eine vom Nutzer selbst mitgebrachte
KEYDB.cfg. Rippy liefert KEINE Schluessel mit, laedt keine herunter und
verteilt keine - es stellt nur den Platz bereit und zeigt an, was dort liegt.
- Datenverzeichnis persistent gemountet (MAKEMKV_DATA_HOST, Default
/srv/rippy/makemkv): KEYDB.cfg und AACS-Dumps ueberleben jeden Rebuild.
Vorher loeschte jeder "up -d --build" beides - inklusive des Dumps, auf den
die Fehlermeldung selbst verwies.
- entrypoint.sh und tasks.py schreiben settings.conf ergaenzend statt
zerstoerend. Der entrypoint bricht bei nicht beschreibbarem Verzeichnis
nicht mehr ab - mit "restart: unless-stopped" waere das ein Crashloop
gewesen, in dem auch reines DVD-Rippen tot ist.
- Neues Zwillings-Modul makemkv_daten.py (docker/api + docker/worker,
byteweise identisch; test_zwillinge_sind_byteweise_identisch wacht darueber
und wurde durch absichtliches Verstellen als wirksam nachgewiesen).
- API: GET/POST/DELETE /system/keydb, GET /system/aacs-dumps(/{dateiname}).
JSON-Body statt Multipart - python-multipart ist bewusst nicht installiert
und wuerde die API beim Import toeten. nginx client_max_body_size 64m,
sonst scheitert der Upload mit 413, bevor die API ihn sieht.
- UI (Einstellungen -> System): Status, Hochladen per Datei-Dialog, Entfernen,
Dump-Download, KEYDB-Plakette je Worker (nur wo das Verzeichnis wirklich
gemountet ist - ein Remote-Transcode-Worker truege sonst eine Warnung,
die ihn nichts angeht).
- parse_msg() + log_cb: MakeMKV-Meldungen landen im Rippy-Log (gedrosselt:
Code 1003 raus, keine Wiederholungen, max. 40 je Rip). Nebenbei behoben:
der alte Parser (split(",", 4)[3]) schnitt jede Meldung am ersten Komma ab.
- Fuenf Stellen richtiggestellt, die behaupteten, MakeMKV-Updates braechten
die neueste Disc-Schluessel-Datenbank mit (UI, Anleitung, README,
Worker-Dockerfile, makemkv_key.py).
NICHT bewiesen: ein erfolgreicher UHD-Rip - es lag keine KEYDB.cfg mit dem
Akira-Schluessel vor. Belegt sind der Befund und die neue Mechanik. So steht
es auch im SAVEPOINT und in der ROADMAP.
Quellen (AGENTS Regel D):
- Datenverzeichnis + Dateiname GROSS/case-sensitiv:
https://forum.makemkv.com/forum/viewtopic.php?t=30636
- hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
- headless settings.conf / app_UpdateEnable:
https://forum.makemkv.com/forum/viewtopic.php?t=20364
- KEYDB.cfg-Zeilenformat (libaacs):
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
- MSG-/PRGV-Format: https://www.makemkv.com/developers/usage.txt
Co-Authored-By: Claude Opus 5
---
.env.example | 7 +
AGENTS.md | 5 +-
KONZEPT.md | 16 ++
README.md | 61 ++++-
ROADMAP.md | 59 +++++
SAVEPOINT.md | 83 ++++++-
docker-compose.yml | 17 ++
docker/api/main.py | 141 +++++++++++
docker/api/makemkv_daten.py | 242 +++++++++++++++++++
docker/api/makemkv_key.py | 10 +-
docker/api/test_api_smoke.py | 8 +-
docker/api/test_makemkv_daten.py | 118 +++++++++
docker/ui/nginx.conf | 6 +
docker/ui/src/pages/Anleitung.tsx | 33 ++-
docker/ui/src/pages/Settings.tsx | 268 ++++++++++++++++++++-
docker/worker/Dockerfile | 10 +-
docker/worker/caps.py | 20 ++
docker/worker/entrypoint.sh | 50 +++-
docker/worker/makemkv_daten.py | 242 +++++++++++++++++++
docker/worker/ripping.py | 72 ++++--
docker/worker/tasks.py | 82 +++++--
docker/worker/test_makemkv_daten_worker.py | 181 ++++++++++++++
docker/worker/test_ripping_helpers.py | 55 +++++
23 files changed, 1717 insertions(+), 69 deletions(-)
create mode 100644 docker/api/makemkv_daten.py
create mode 100644 docker/api/test_makemkv_daten.py
create mode 100644 docker/worker/makemkv_daten.py
create mode 100644 docker/worker/test_makemkv_daten_worker.py
diff --git a/.env.example b/.env.example
index 3f028a5..6712eb3 100644
--- a/.env.example
+++ b/.env.example
@@ -33,6 +33,13 @@ OMDB_API_KEY=
# nächsten Rip, ohne Neustart, und schlägt diesen Env-Wert).
MAKEMKV_APP_KEY=
+# MakeMKV-Datenverzeichnis auf dem HOST (bleibt über Rebuilds hinweg erhalten).
+# Hier hinein gehört die KEYDB.cfg für 4K-UHD-Discs; hier landen auch die
+# AACS-Dumps fehlgeschlagener Discs. Beides ist ab Einstellungen → System
+# im Browser erreichbar — dieser Pfad ist nur für den Fall, dass du die
+# Dateien direkt auf der Maschine anfassen willst.
+#MAKEMKV_DATA_HOST=/srv/rippy/makemkv
+
# MakeMKV-Version fürs Worker-Image (Einstellungen → System meldet Updates).
# Update: Version hier anheben, dann auf der Rippy-Maschine
# docker compose build worker && docker compose up -d worker
diff --git a/AGENTS.md b/AGENTS.md
index 5434953..efb0dbc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,10 +55,13 @@ Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
- Kein Proxmox-LXC-Nesting-Workaround — das Projekt lebt bewusst in normalem Docker.
- Kein ARM-Fork — Rippy ist ein Eigenbau von Grund auf.
-## Aktueller Stand (24.07.2026)
+## Aktueller Stand (25.07.2026)
- ✅ **E2E bewiesen (v3.1):** BD-50 komplett durch die Kette (43 GB → 4,8 GB)
- ✅ **Etappe 13 (v3.2):** Universal-Komfort-Runde — Media-Server-Integration,
echte Benachrichtigungen, SMB-Klartext-Fehler, UHD-Arbeitsverzeichnis,
MakeMKV-Key via UI, Job-Detail-Popup, Toast-Feedback, Ampel-Blocker behoben
+- ✅ **Etappe 17 (v3.10):** 4K-UHD-Disc-Schlüssel — persistentes
+ MakeMKV-Datenverzeichnis + `KEYDB.cfg` im UI (der Nachweis mit einer
+ echten Schlüssel-Datei steht noch aus)
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
diff --git a/KONZEPT.md b/KONZEPT.md
index dc9080c..ae77310 100644
--- a/KONZEPT.md
+++ b/KONZEPT.md
@@ -127,6 +127,7 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
| Risiko | Status | Behandlung |
|--------|--------|------------|
| MakeMKV-Beta-Key-Management | Gelöst | Key-Erneuerung als Cron-Job; DMCA-Ausnahme in DE |
+| **Disc-Schlüssel für 4K-UHD (AACS 2.0)** | **Offen — durch Rippy nicht lösbar** | Am 25.07.2026 auf der VM gemessen: MakeMKV holt für unbekannte UHD-Discs keinen Schlüssel mehr (kein Netz-Versuch im Log, keine `hkd_*.bin` im `_private_data.tar`), die dokumentierten Schlüssel-Server sind weltweit tot. Rippy stellt NUR ein persistentes Datenverzeichnis für eine vom Nutzer selbst mitgebrachte `KEYDB.cfg` bereit und gibt die AACS-Dumps heraus — es liefert, lädt und verteilt keine Schlüssel. Siehe §10 (25.07.2026) |
| LXC-Device-Node-Änderungen | Gelöst | udev-Resolver (UUID/Serial) |
| Pending-Queue / State-Manager | Gelöst | Redis mit AOF-Persistence |
| Hybrid-Discs | Offen | MVP erkennt nur Standard; als "Kann" notiert |
@@ -182,6 +183,21 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
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).
+- **25.07.2026 — 4K-UHD-Disc-Schlüssel: Rippy stellt Platz bereit, keine
+ Schlüssel:** Das Muss-Feature „MakeMKV-Ripping (lossless)" bleibt
+ unverändert; ergänzt wird nur ein **persistentes MakeMKV-Datenverzeichnis**
+ (`MAKEMKV_DATA_HOST`, im Worker `/root/.MakeMKV`, in der API
+ `/app/makemkv-data`) samt Bedienung im UI. Begründung: Am 25.07.2026 auf
+ der VM nachgemessen — MakeMKV versucht bei einer unbekannten UHD-Disc gar
+ keinen Online-Abruf mehr, und die früher genutzten Schlüssel-Server sind
+ weltweit tot; der einzige heute funktionierende Weg ist eine `KEYDB.cfg`
+ im Datenverzeichnis. **Rippy liefert und verteilt KEINE Disc-Schlüssel und
+ lädt auch keine herunter** — es hält nur den Platz für eine Datei bereit,
+ die der Nutzer selbst mitbringt, zeigt ehrlich an, was dort liegt, und gibt
+ die AACS-Dumps heraus, die MakeMKV ohnehin selbst schreibt. Das ist genau
+ die Grenze, die `docker/api/makemkv_key.py` in Zeile 14 zieht: die
+ kostenlose Beta-LIZENZ der Software ist etwas anderes als das
+ Entschlüsseln oder Verteilen von Disc-Schlüsseln.
- **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
diff --git a/README.md b/README.md
index b0efc94..874fc03 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,26 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
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
+5. **4K-UHD**: braucht **zwei** Dinge — ein LibreDrive-fähiges Laufwerk
+ (MakeMKV-Forum: „Ultimate UHD Drives Flashing Guide") **und** einen
+ passenden Disc-Schlüssel. Normale BD/DVD gehen mit jedem Laufwerk und
+ ohne Schlüssel-Datei.
+ **Der Schlüssel ist heute die eigentliche Hürde.** Am 25.07.2026 auf
+ der Rippy-VM gemessen (Akira UHD, MKB v76, Pressung Dezember 2020):
+ Laufwerk und MakeMKV waren in Ordnung — „Using LibreDrive mode
+ (v06.3)", die Disc wurde gelesen — und trotzdem kam „The volume key is
+ unknown for this disc". MakeMKV versucht dabei **gar nicht mehr**,
+ online einen Schlüssel zu holen, und die früher genutzten
+ Schlüssel-Server (`hkdata.fairuse.org`, `hkdata.crabdance.com`) lösen
+ weltweit nicht mehr auf. Ein MakeMKV-Update ändert daran nichts.
+ Der einzige Weg, der heute funktioniert, ist eine Datei **`KEYDB.cfg`**
+ (GROSS geschrieben — Linux unterscheidet Groß- und Kleinschreibung) im
+ MakeMKV-Datenverzeichnis. Die legst du unter **Einstellungen → System**
+ ab (siehe unten).
+ ⚠️ **Rippy liefert keine Schlüssel mit, lädt keine herunter und
+ verteilt keine.** Rippy stellt nur den Platz für eine Datei bereit, die
+ du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt.
+ ⚠️ UHD-Rohdaten sind bis 100 GB groß — wenn die Platte der
Rippy-Maschine dafür zu klein ist, lege das **Arbeitsverzeichnis**
(Einstellungen → Verarbeitung) auf eine eingehängte Freigabe, z. B.
`/app/media/nas-arbeit`. Rippy bricht sonst VOR dem Rip mit einer
@@ -106,7 +123,7 @@ fehlgeschlagen, abgebrochen) und erkennt den Dienst an der URL selbst:
| ntfy (Handy-Push) | `https://ntfy.sh/mein-geheimes-thema` | Roh-Text + Titel |
| Eigenes (HA, n8n, …) | beliebige HTTPS-URL | `{"title","message","level"}` |
-## System, MakeMKV-Beta-Key & Updates
+## System, MakeMKV-Beta-Key, Disc-Schlüssel & Updates
**Einstellungen → System** zeigt die Werkzeug-Versionen jedes Workers
(MakeMKV, HandBrake), den Key-Status und den freien Speicherplatz. Der
@@ -114,11 +131,37 @@ MakeMKV-Beta-Key (wechselt ~monatlich, Forum-Thread t=1053) wird hier im
UI eingetragen und gilt ab dem **nächsten Rip** — ohne Rebuild, ohne
Neustart; er schlägt den Key aus der `.env`.
+**Disc-Schlüssel (`KEYDB.cfg`)** — im selben Tab, nur für 4K-UHD nötig:
+
+- **Status**: Rippy zeigt, ob eine `KEYDB.cfg` im MakeMKV-Datenverzeichnis
+ liegt, mit Pfad, Größe, Anzahl der Disc-Einträge und Änderungsdatum.
+- **Hochladen**: Knopf „KEYDB.cfg hochladen", dann deine eigene Datei im
+ Datei-Dialog auswählen — mehr ist nicht zu tun. Rippy prüft den Inhalt
+ auf Plausibilität und lehnt Unsinn (leere Datei, versehentlich geladene
+ HTML-Fehlerseite) mit einer deutschen Klartext-Meldung ab, statt ihn
+ stillschweigend zu speichern.
+- **Entfernen**: ein Knopf, die Datei ist wieder weg.
+- **AACS-Dumps herunterladen**: MakeMKV legt bei jeder unbekannten Disc
+ selbst einen Dump ab. Genau den braucht man, wenn man im MakeMKV-Forum
+ um den Schlüssel für eine neue Pressung bittet — hier holst du ihn dir
+ aus dem Container, ohne SSH.
+
+Der Beta-Key ist die **Lizenz für die Software**; die `KEYDB.cfg` sind
+**Disc-Schlüssel** — zwei völlig verschiedene Dinge. **Rippy liefert und
+lädt keine Disc-Schlüssel**, es hält nur ein persistentes Verzeichnis
+dafür bereit (`MAKEMKV_DATA_HOST`, siehe Tabelle unten) — rebuild-fest,
+damit deine Datei einen `docker compose build` überlebt.
+
**Updates:** „Auf Updates prüfen" vergleicht mit makemkv.com und den
offiziellen HandBrake-Releases (12 h gecacht). MakeMKV-Update ohne
Code-Änderung: `MAKEMKV_VERSION=x.y.z` in die `.env`, dann
-`docker compose build worker && docker compose up -d worker` — neue
-Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
+`docker compose build worker && docker compose up -d worker`.
+⚠️ **Richtigstellung (25.07.2026):** Hier stand früher, neue MakeMKV-
+Versionen brächten „auch die neueste Disc-Schlüssel-Datenbank mit". Das
+ist widerlegt — auf der VM nachgemessen: MakeMKV holt für eine unbekannte
+UHD-Disc keinen Schlüssel mehr, weder mitgeliefert noch aus dem Netz. Ein
+Update lohnt für Programmfehler und neue Laufwerke, hilft aber NICHT
+gegen „The volume key is unknown". Dafür brauchst du die `KEYDB.cfg`.
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
offiziellen Version hinterher); der native Windows-Worker nutzt die
aktuelle Version direkt.
@@ -153,6 +196,7 @@ bleibt All-in-one.
| `THETVDB_API_KEY` | optional | Serien-Fallback |
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
+| `MAKEMKV_DATA_HOST` | optional | Host-Verzeichnis für MakeMKVs Daten (Default `/srv/rippy/makemkv`) — hier liegen deine `KEYDB.cfg` und die AACS-Dumps; persistent, überlebt jeden Rebuild |
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
@@ -181,6 +225,11 @@ Voraussetzungen auf dem Ziel-Host:
```
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
+4. MakeMKV-Datenverzeichnis anlegen: `mkdir -p /srv/rippy/makemkv` (oder
+ eigenen Pfad über `MAKEMKV_DATA_HOST` in der `.env`). Dort liegen
+ MakeMKVs Programmzustand, die AACS-Dumps und — falls du 4K-UHD rippst —
+ deine eigene `KEYDB.cfg`. Das Verzeichnis gehört dem Host, damit ein
+ `docker compose build` deine Datei nicht wegwirft.
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
--build`, `http://` öffnen — der Einrichtungs-Assistent führt durch
diff --git a/ROADMAP.md b/ROADMAP.md
index 4a28fe1..49ec459 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -414,6 +414,65 @@ irrelevant und kann komplett raus."
---
+## Etappe 17 (25.07.2026): 4K-UHD-Schlüssel (KEYDB.cfg)
+
+**Quelle:** Messung am 25.07. live auf der Rippy-VM im Worker-Container.
+Eine 4K-UHD (Akira, MKB v76, Pressung Dezember 2020) scheitert an „The
+volume key is unknown for this disc" — obwohl makemkvcon „Using LibreDrive
+mode (v06.3)" und „Using direct disc access mode" meldet, die Disc liest
+und den AACS-Dump ablegt (Meldung 3332). Das Debug-Log geht ohne einen
+einzigen Netz-Versuch von „Loaded content hash table" direkt auf den
+Fehler; `_private_data.tar` enthielt nur die Index-Datei und keine einzige
+`hkd_*.bin`; auch mit gelöschter `update.conf` (Meldung 5074 belegt den
+Web-Kontakt) und `app_UpdateEnable = "1"` kam kein Schlüssel; die
+Forum-Schlüssel-Server `hkdata.fairuse.org` und `hkdata.crabdance.com`
+lösen weltweit nicht mehr auf. **Fazit: Ein MakeMKV-Update löst das
+nicht** (das behauptete v3.3 — dort richtiggestellt). Der einzige heute
+funktionierende Weg ist eine `KEYDB.cfg` im MakeMKV-Datenverzeichnis.
+
+**Gebaut:**
+- [x] **Persistentes Datenverzeichnis**: `${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}`
+ vom Host gemountet — Worker `/root/.MakeMKV`, API `/app/makemkv-data`,
+ beide mit `MAKEMKV_DATA_DIR`. `KEYDB.cfg` und AACS-Dumps überleben
+ jeden Rebuild. `.env.example` erklärt die Variable.
+- [x] **entrypoint.sh entschärft**: `settings.conf` wird ergänzt statt
+ überschrieben (der Beta-Key hatte sonst alles andere gelöscht),
+ `app_UpdateEnable = "1"` gesetzt.
+- [x] **Zwillings-Modul `makemkv_daten.py`** (identisch in `docker/api/`
+ und `docker/worker/`) als einzige Wahrheit über das Verzeichnis:
+ Status lesen, Inhalt prüfen, atomar schreiben, löschen, Dumps
+ auflisten — alles reine Funktionen, damit die Ampel sie ohne
+ Postgres/Redis testen kann.
+- [x] **API**: `GET/POST/DELETE /system/keydb`, `GET /system/aacs-dumps`
+ und `GET /system/aacs-dumps/{dateiname}`. Der Inhalt kommt bewusst
+ als JSON-Body — es gibt kein `python-multipart`, ein Endpunkt mit
+ `UploadFile`/`File()` würde die API beim Import töten.
+- [x] **UI (Einstellungen → System)**: Status der `KEYDB.cfg` (Pfad,
+ Größe, Anzahl Disc-Einträge, Datum), Inhalt einfügen, entfernen,
+ AACS-Dumps herunterladen — ohne SSH auf die VM.
+- [x] **MakeMKV redet endlich**: `parse_msg()` in `ripping.py` + Log-Callback
+ in `tasks.py` schreiben MakeMKV-Meldungen ins Rippy-Log (gedrosselt:
+ Code 1003 raus, keine Wiederholungen, max. 40 je Rip). UHD-Fehlertext
+ ehrlich neu geschrieben; `caps.py` meldet `keydb: ja | nein | unbekannt`.
+- [x] **Doku nachgezogen**: README (UHD-Voraussetzungen, System-Panel,
+ `MAKEMKV_DATA_HOST`, Bereitstellung), SAVEPOINT v3.10 inkl.
+ Richtigstellung von v3.3, KONZEPT §8 + §10.
+
+**Offen aus dieser Runde:**
+- [ ] **Der Nachweis mit einer echten `KEYDB.cfg` steht aus.** Zum
+ Zeitpunkt der Änderung lag keine Datei vor, die den Akira-Schlüssel
+ enthält — belegt sind der Befund und die Mechanik, NICHT ein
+ erfolgreicher UHD-Rip.
+- [ ] Damit bleibt auch der volle Transcode-E2E an einer UHD weiter offen
+ (siehe SAVEPOINT v3.7) — an normalen BD/DVD ist die Kette bewiesen.
+
+**Ausdrücklich nicht gebaut (und wird es auch nicht):** Rippy liefert
+keine Disc-Schlüssel mit, lädt keine herunter und verteilt keine. Es
+stellt nur den Platz für eine Datei bereit, die der Nutzer selbst
+mitbringt, und zeigt ehrlich an, was dort liegt.
+
+---
+
## Ideen-Katalog (Rest) — bewusst offen
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
diff --git a/SAVEPOINT.md b/SAVEPOINT.md
index 25bc540..da00148 100644
--- a/SAVEPOINT.md
+++ b/SAVEPOINT.md
@@ -1,6 +1,73 @@
# SAVEPOINT — Rippy
-## Aktueller Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
+## Aktueller Stand: v3.10 — 4K-UHD: KEYDB.cfg statt Warten auf MakeMKV (25.07.2026)
+
+- **Der Befund (am 25.07. live auf der VM im Worker-Container
+ nachgemessen — kein Verdacht, alles belegt):** Eine 4K-UHD (Akira,
+ MKB v76, Pressung Dezember 2020) scheitert mit „The volume key is
+ unknown for this disc". **Laufwerk und MakeMKV sind dabei in Ordnung:**
+ makemkvcon meldet „Using LibreDrive mode (v06.3)" und „Using direct
+ disc access mode", liest die Disc und legt den AACS-Dump ab
+ (Meldung 3332). Der Fehler liegt also NICHT an der Hardware.
+- **Die echte Ursache — MakeMKV fragt gar nicht erst:** Das Debug-Log
+ geht ohne einen einzigen Netz-Versuch von „Loaded content hash table"
+ direkt auf „The volume key is unknown". **Beweise:**
+ `/root/.MakeMKV/_private_data.tar` enthielt nur die Index-Datei und
+ KEINE einzige `hkd_*.bin` — es wurde also nie ein Schlüssel geladen.
+ Auch mit erzwungener frischer Prüfung (`update.conf` gelöscht,
+ Meldung 5074 belegt den Web-Kontakt) und mit `app_UpdateEnable = "1"`
+ kam keiner. Und die im MakeMKV-Forum dokumentierten Schlüssel-Server
+ `hkdata.fairuse.org` und `hkdata.crabdance.com` lösen weltweit nicht
+ mehr auf (NXDOMAIN gegen Fritz!Box, 8.8.8.8 und 1.1.1.1).
+- **Richtigstellung zu v3.3 (weiter unten korrigiert):** Dort stand, die
+ Ursache sei „Disc neuer als MakeMKVs Schlüssel-DB" und ein
+ MakeMKV-Update werde das lösen. Das ist widerlegt — es gibt keine
+ nachladbare Schlüssel-Datenbank mehr. **Warten auf ein MakeMKV-Update
+ hilft bei diesem Fehler nicht.**
+- **Der einzige Weg, der heute funktioniert: `KEYDB.cfg`** — GROSS
+ geschrieben (unter Linux case-sensitiv) im MakeMKV-Datenverzeichnis.
+ Rippy macht diesen Weg jetzt begehbar, statt auf MakeMKV zu warten.
+- **Gebaut — persistentes Datenverzeichnis:** `docker-compose.yml` mountet
+ `${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}` vom Host — im Worker auf
+ `/root/.MakeMKV`, in der API auf `/app/makemkv-data`; beide Container
+ bekommen `MAKEMKV_DATA_DIR`. Damit überleben `KEYDB.cfg` und die
+ AACS-Dumps jeden Rebuild. `entrypoint.sh` schreibt `settings.conf`
+ jetzt ergänzend statt zerstörend (sonst hätte der Beta-Key den Rest
+ überbügelt) und setzt `app_UpdateEnable = "1"`.
+- **Gebaut — eine einzige Wahrheit über das Verzeichnis:** neues
+ Zwillings-Modul `makemkv_daten.py` (identisch in `docker/api/` und
+ `docker/worker/`) mit reinen Helfern — Status lesen, Inhalt prüfen,
+ atomar schreiben, löschen, AACS-Dumps auflisten. Nur reine Funktionen,
+ damit die Ampel sie ohne Postgres/Redis testen kann.
+- **Gebaut — Bedienung im UI:** Einstellungen → System zeigt den
+ `KEYDB.cfg`-Status (Pfad, Größe, Anzahl Disc-Einträge, Datum), nimmt die
+ Datei über einen ganz normalen Datei-Dialog entgegen (der Browser liest
+ sie und schickt den Text als JSON — serverseitig bewusst KEIN
+ Multipart-Upload, es gibt kein `python-multipart`, das würde die API
+ beim Import töten), lehnt
+ unplausiblen Inhalt mit deutschem Klartext ab, kann die Datei wieder
+ entfernen und die AACS-Dumps zum Download anbieten.
+- **Gebaut — man sieht endlich, was MakeMKV sagt:** `parse_msg()` in
+ `ripping.py` plus Log-Callback in `tasks.py` schreiben MakeMKV-Meldungen
+ ins Rippy-Log (gedrosselt: Code 1003 raus, keine Wiederholungen, max. 40
+ je Rip). Der UHD-Fehlertext ist ehrlich neu geschrieben. `caps.py`
+ meldet zusätzlich `keydb: ja | nein | unbekannt` je Worker.
+- **Abgrenzung, die überall durchscheint:** **Rippy liefert KEINE
+ Schlüssel mit, lädt keine herunter und verteilt keine.** Rippy stellt
+ nur den Platz für eine Datei bereit, die der Nutzer selbst mitbringt,
+ und zeigt ehrlich an, was dort liegt. Genau die Grenze zieht schon
+ `docker/api/makemkv_key.py` (Zeile 14) für den Beta-Key: das ist die
+ Software-LIZENZ, nicht das Entschlüsseln oder Verteilen von
+ Disc-Schlüsseln.
+- **⚠️ NOCH NICHT end-to-end bewiesen — ehrlich gesagt:** Zum Zeitpunkt
+ dieser Änderung lag KEINE `KEYDB.cfg` vor, die den Akira-Schlüssel
+ enthält. Belegt sind der Befund oben und die neue Mechanik (Mount,
+ Modul, Endpunkte, UI) — **nicht** ein erfolgreicher UHD-Rip. Der
+ Nachweis steht aus und braucht eine echte Schlüssel-Datei.
+
+---
+
+## Vorheriger Stand: v3.9 — Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
- **RippyWorkerSetup.exe** ersetzt den .vbs/.bat-Weg (Commander-Einwand:
.vbs ist abgekündigt + wird als gefährlich geflaggt). install-gui.ps1 via
@@ -183,16 +250,20 @@ Konvertierung (Infrastruktur steht), AI-Box-NFS-Export, Kodi-Refresh.
- **UHD-Kette diagnostiziert (Nachtrag, gleicher Tag): Der BU40N-Flash IST
erledigt** — makemkvcon meldet „Using LibreDrive mode (v06.3)". Der
Summer-Wars-Fehlschlag lag NICHT am Laufwerk, sondern an „The volume key
- is unknown": die Disc (MKB v82) ist neuer als MakeMKVs Schlüssel-DB —
- auf der VM mit 1.17.7 UND der aktuellsten 1.18.4 bewiesen. Konsequenzen:
+ is unknown" — auf der VM mit 1.17.7 UND der aktuellsten 1.18.4
+ reproduziert. Konsequenzen:
MakeMKV auf **1.18.4** gehoben (Scan-Hänger durch überall genutztes
--noscan umgangen, auf der BU40N sauber gelaufen; URL-Base → /download),
run_makemkv reicht kritische Meldungen (volume key/Key abgelaufen) in
den Fehlertext durch, UHD-Klartext unterscheidet jetzt „Laufwerk kann
- kein UHD" von „Disc neuer als Key-DB" inkl. Forum-Dump-Hinweis
+ kein UHD" von „Disc-Schlüssel fehlt" inkl. Forum-Dump-Hinweis
(MakeMKV speichert den AACS-Dump automatisch unter /root/.MakeMKV/).
- Summer Wars bleibt bis zu einem MakeMKV-Key-Update nicht entschlüsselbar
- — das ist Stand der Technik, kein Rippy-Bug.
+ **⚠️ Korrigiert am 25.07.2026 (siehe v3.10 ganz oben):** Die hier
+ ursprünglich genannte Erklärung „die Disc ist neuer als MakeMKVs
+ Schlüssel-DB, ein MakeMKV-Update löst das" ist FALSCH. Nachgemessen:
+ MakeMKV hat gar keine nachladbare Schlüssel-DB mehr und versucht auch
+ keinen Online-Abruf; die alten Schlüssel-Server sind tot. Der Weg zum
+ Ziel heißt `KEYDB.cfg`, nicht „warten auf die nächste Version".
- **Vollautomatik** (Einstellungen → Ripping): Disc erkannt → Rip startet
ohne Popup in den passenden Schnellwahl-Ordner (Serie/Film/Musik).
- **Job-Verwaltung**: „Neu komprimieren" nur noch, wenn Rohdaten wirklich
diff --git a/docker-compose.yml b/docker-compose.yml
index 2e3b15f..69d5bd3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,6 +16,10 @@ services:
- TMDB_API_KEY=${TMDB_API_KEY:-}
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
- OMDB_API_KEY=${OMDB_API_KEY:-}
+ # Dasselbe Host-Verzeichnis wie beim Worker, nur an neutraler Stelle:
+ # die API liest den KEYDB-Status und die AACS-Dumps und nimmt eine
+ # hochgeladene KEYDB.cfg entgegen (Einstellungen → System).
+ - MAKEMKV_DATA_DIR=/app/makemkv-data
- LOG_LEVEL=INFO
healthcheck:
test: ["CMD-SHELL", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8000/health\")'"]
@@ -47,6 +51,8 @@ services:
bind:
propagation: rshared
- temp:/app/temp
+ # MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
+ - ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
devices:
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks:
@@ -78,6 +84,10 @@ services:
- REDIS_URL=redis://redis:6379/0
- RIP_OUTPUT_DIR=/app/media
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
+ # MakeMKVs Datenverzeichnis im Container — fest verdrahtet in MakeMKV
+ # selbst (HOME/.MakeMKV, HOME ist /root). Steht hier, damit Worker-Code
+ # und Mount dieselbe Wahrheit benutzen.
+ - MAKEMKV_DATA_DIR=/root/.MakeMKV
# Anzeigename in Einstellungen → Worker (stabil über Rebuilds hinweg;
# via .env übersteuerbar, z. B. WORKER_NAME=wohnzimmer-vm)
- WORKER_NAME=${WORKER_NAME:-rippy-hauptworker}
@@ -92,6 +102,13 @@ services:
bind:
propagation: rslave
- temp:/app/temp
+ # MakeMKV-Datenverzeichnis PERSISTENT (Befund 25.07.2026, live gemessen):
+ # Hier liegen die KEYDB.cfg — heute die EINZIGE funktionierende
+ # Schlüsselquelle für 4K-UHD, weil MakeMKVs Online-Kanal nichts mehr
+ # liefert —, die AACS-Dumps fehlgeschlagener Discs und settings.conf.
+ # Ohne diesen Mount löschte JEDER `up -d --build` beides; die
+ # Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
+ - ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
devices:
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
diff --git a/docker/api/main.py b/docker/api/main.py
index 654046c..b2c0d9f 100644
--- a/docker/api/main.py
+++ b/docker/api/main.py
@@ -12,6 +12,7 @@ import uuid
import db
import devices as device_discovery
+import makemkv_daten
import makemkv_key
import mounts as mount_verwaltung
import notify
@@ -1190,6 +1191,146 @@ async def system_info():
return await asyncio.to_thread(sammle)
+# Eigene Wurzel für die Datei-Härtung der AACS-Dumps. Bewusst NICHT die
+# MEDIA_ROOT-Helfer (_sicherer_dateiname/_validiere_ziel/_job_ausgabeordner):
+# die prüfen hart gegen /app/media und würden hier IMMER 404 liefern.
+# Das MakeMKV-Datenverzeichnis liegt woanders (in der API auf
+# /app/makemkv-data, im Worker auf /root/.MakeMKV — laut docker-compose.yml
+# beides dasselbe Host-Verzeichnis).
+MAKEMKV_DATA_ROOT = os.path.realpath(makemkv_daten.DATEN_DIR)
+
+
+class KeydbRequest(BaseModel):
+ inhalt: str # voller Text der KEYDB.cfg (kein Upload — es gibt kein python-multipart)
+
+
+@app.get("/system/keydb")
+async def get_keydb_status():
+ """Was liegt gerade als KEYDB.cfg im MakeMKV-Datenverzeichnis?
+
+ Hintergrund (Befund 25.07.2026, live auf der VM nachgemessen): Bei
+ 4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc" und
+ holt den Schlüssel NICHT mehr online nach — die dokumentierten
+ Schlüssel-Server lösen weltweit nicht mehr auf. Der einzige heute
+ funktionierende Weg ist eine KEYDB.cfg, die der Nutzer selbst mitbringt.
+ Rippy liefert KEINE Schlüssel mit, lädt keine herunter und verteilt keine —
+ es stellt nur den Platz bereit und zeigt ehrlich an, was dort liegt.
+
+ Fehlendes Verzeichnis oder fehlende Datei ist der NORMALFALL: dann kommt
+ 200 mit vorhanden=false zurück, niemals 404 oder 500.
+ """
+ def sammle():
+ return makemkv_daten.keydb_status()
+
+ return await asyncio.to_thread(sammle)
+
+
+@app.post("/system/keydb")
+async def set_keydb(request: KeydbRequest):
+ """Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
+
+ WICHTIG für die Ehrlichkeit: Die Datei wirkt erst beim NÄCHSTEN Rip —
+ makemkvcon liest sie beim Prozessstart, ein bereits laufender Rip merkt
+ nichts davon. Genau so steht es auch im Log-Eintrag.
+ """
+ # Reine Prüfung (kein Dateisystem) — fängt den häufigsten Bedienfehler ab:
+ # statt der KEYDB.cfg landet die HTML-Fehlerseite eines Downloads im Feld.
+ fehler = makemkv_daten.keydb_pruefen(request.inhalt)
+ if fehler:
+ raise HTTPException(status_code=422, detail=fehler)
+
+ def schreibe():
+ return makemkv_daten.keydb_schreiben(request.inhalt)
+
+ try:
+ status = await asyncio.to_thread(schreibe)
+ except OSError as e:
+ raise HTTPException(
+ status_code=500,
+ detail=(
+ f"KEYDB.cfg konnte nicht geschrieben werden: {e}. "
+ "Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert und "
+ "beschreibbar ist (Standard: /srv/rippy/makemkv)."
+ ),
+ )
+ await asyncio.to_thread(
+ db.add_log, "success", "makemkv-keydb",
+ f"KEYDB.cfg abgelegt: {status['eintraege']} Zeilen mit Disc-Kennung, "
+ f"{status['groesse_bytes']} Bytes ({status['pfad']}). "
+ "Wirkt erst beim NÄCHSTEN Rip — MakeMKV liest die Datei beim Start.",
+ )
+ return status
+
+
+@app.delete("/system/keydb")
+async def delete_keydb():
+ """Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Einfügen).
+
+ Auch hier gilt: Die Änderung wirkt erst beim NÄCHSTEN Rip. Fehlt die Datei
+ schon, ist das kein Fehler — es kommt derselbe Zustand mit vorhanden=false.
+ """
+ def loesche():
+ return makemkv_daten.keydb_loeschen()
+
+ try:
+ status = await asyncio.to_thread(loesche)
+ except OSError as e:
+ raise HTTPException(
+ status_code=500,
+ detail=f"KEYDB.cfg konnte nicht entfernt werden: {e}",
+ )
+ await asyncio.to_thread(
+ db.add_log, "warning", "makemkv-keydb",
+ "KEYDB.cfg entfernt. Ab dem NÄCHSTEN Rip fehlen die selbst mitgebrachten "
+ "Schlüssel wieder — UHD-Discs können dann erneut an "
+ "'The volume key is unknown for this disc' scheitern.",
+ )
+ return status
+
+
+@app.get("/system/aacs-dumps")
+async def get_aacs_dumps():
+ """AACS-Dumps, die MakeMKV selbst abgelegt hat (neueste zuerst).
+
+ MakeMKV schreibt sie beim gescheiterten UHD-Versuch ins Datenverzeichnis
+ (Meldung 3332 "Saved AACS dump file as file:///root/.MakeMKV/.tgz",
+ am 25.07.2026 so beobachtet). Rippy wertet sie nicht aus und schickt sie
+ nirgendwohin — es zeigt nur, dass sie da sind, damit der Nutzer selbst
+ entscheiden kann, was er damit tut.
+ """
+ def liste():
+ return {"dumps": makemkv_daten.dumps_auflisten()}
+
+ return await asyncio.to_thread(liste)
+
+
+@app.get("/system/aacs-dumps/{dateiname}")
+async def download_aacs_dump(dateiname: str):
+ """Lädt EINEN AACS-Dump herunter.
+
+ Pfad-Validierung genauso streng wie beim Job-Datei-Download: nackter Name
+ ohne Pfadtrenner und ohne führenden Punkt (ist_aacs_dump) PLUS realpath,
+ der das MakeMKV-Datenverzeichnis nicht verlassen darf (kein ..-Ausbruch,
+ kein Symlink nach draußen).
+ """
+ if not makemkv_daten.ist_aacs_dump(dateiname):
+ raise HTTPException(
+ status_code=404,
+ detail="Kein gültiger Dump-Name — erwartet wird eine .tgz-Datei ohne Pfadangabe.",
+ )
+ pfad = os.path.join(MAKEMKV_DATA_ROOT, dateiname)
+
+ def pruefe():
+ return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MAKEMKV_DATA_ROOT)
+
+ if not await asyncio.to_thread(pruefe):
+ raise HTTPException(
+ status_code=404,
+ detail="Dump nicht gefunden — MakeMKV legt ihn erst beim gescheiterten UHD-Versuch an.",
+ )
+ return FileResponse(pfad, filename=dateiname, media_type="application/gzip")
+
+
class NotificationTestRequest(BaseModel):
url: str
diff --git a/docker/api/makemkv_daten.py b/docker/api/makemkv_daten.py
new file mode 100644
index 0000000..1ce430d
--- /dev/null
+++ b/docker/api/makemkv_daten.py
@@ -0,0 +1,242 @@
+"""MakeMKV-Datenverzeichnis: KEYDB.cfg, AACS-Dumps, settings.conf.
+
+WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, live im Worker nachgemessen):
+Bei 4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc".
+Das Laufwerk ist dabei in Ordnung — makemkvcon meldet "Using LibreDrive mode
+(v06.3)" und liest die Disc — MakeMKV kennt nur den Schluessel DIESER Pressung
+nicht. Und es holt ihn NICHT mehr online nach:
+
+ * /root/.MakeMKV/_private_data.tar enthielt ausser der Index-Datei keine
+ einzige hkd_*.bin, also nie einen heruntergeladenen Schluessel;
+ * auch mit erzwungener frischer Pruefung (update.conf geloescht, Meldung
+ 5074 belegt den Web-Kontakt) und mit app_UpdateEnable = "1" kam keiner;
+ * die im MakeMKV-Forum dokumentierten Schluessel-Server hkdata.fairuse.org
+ und hkdata.crabdance.com loesen weltweit nicht mehr auf (gegen Fritz!Box,
+ 8.8.8.8 und 1.1.1.1 geprueft: NXDOMAIN).
+
+Betroffen war Akira UHD (MKB v76, Pressung von Dezember 2020) — also gerade
+KEINE Neuerscheinung. Die bisherige Fehlermeldung ("Disc neuer als die
+Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war damit
+schlicht falsch.
+
+Der einzige heute funktionierende Weg ist eine KEYDB.cfg im
+MakeMKV-Datenverzeichnis. Rippy liefert KEINE Schluessel mit, laedt keine
+herunter und verteilt keine — es stellt nur den Platz dafuer bereit und zeigt
+ehrlich an, was dort liegt. Die Datei bringt der Nutzer selbst mit.
+
+QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
+ * Datenverzeichnis und Dateiname GROSS geschrieben (unter Linux
+ case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
+ https://forum.makemkv.com/forum/viewtopic.php?t=30636
+ * Heruntergeladene Schluessel liegen als hkd_*.bin in _private_data.tar:
+ https://forum.makemkv.com/forum/viewtopic.php?t=32675
+ * Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
+ optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
+ "| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
+ https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
+ * Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
+ as file:///root/.MakeMKV/.tgz" (am 25.07.2026 so beobachtet).
+
+ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
+Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
+(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen.
+"""
+
+import os
+import re
+from datetime import datetime, timezone
+
+# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
+# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
+# docker-compose.yml auf DASSELBE Host-Verzeichnis.
+DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
+
+# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
+KEYDB_NAME = "KEYDB.cfg"
+
+# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt
+# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
+# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
+MAX_KEYDB_BYTES = 64 * 1024 * 1024
+
+# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
+# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
+# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag.
+_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
+
+
+def _iso(zeitstempel: float) -> str:
+ """Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
+ return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
+
+
+def keydb_pfad(daten_dir: str = None) -> str:
+ """Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
+ return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
+
+
+def zaehle_disc_eintraege(inhalt: str) -> int:
+ """Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar).
+
+ Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest
+ die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
+ macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
+ "da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
+ unterscheiden — sie wird deshalb auch genau so beschriftet.
+ """
+ return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
+
+
+def keydb_pruefen(inhalt: str) -> str:
+ """Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
+
+ Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
+ HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
+ MakeMKV wuerde dann still weiter "volume key is unknown" melden.
+ """
+ if not inhalt.strip():
+ return "Die Datei ist leer."
+ if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
+ return (
+ "Die Datei ist groesser als "
+ f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
+ )
+ if inhalt.lstrip()[:1] == "<":
+ return (
+ "Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
+ "vermutlich wurde eine Fehlerseite statt der Datei geladen."
+ )
+ if zaehle_disc_eintraege(inhalt) == 0:
+ return (
+ "Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
+ "dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
+ )
+ return ""
+
+
+def keydb_status(daten_dir: str = None) -> dict:
+ """Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
+ pfad = keydb_pfad(daten_dir)
+ try:
+ angaben = os.stat(pfad)
+ except OSError:
+ return {
+ "vorhanden": False,
+ "pfad": pfad,
+ "groesse_bytes": 0,
+ "eintraege": 0,
+ "geaendert": "",
+ }
+ eintraege = 0
+ try:
+ with open(pfad, encoding="utf-8", errors="replace") as datei:
+ eintraege = zaehle_disc_eintraege(datei.read())
+ except OSError:
+ pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem
+ return {
+ "vorhanden": True,
+ "pfad": pfad,
+ "groesse_bytes": angaben.st_size,
+ "eintraege": eintraege,
+ "geaendert": _iso(angaben.st_mtime),
+ }
+
+
+def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
+ """Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
+
+ Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf
+ makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
+ """
+ pfad = keydb_pfad(daten_dir)
+ os.makedirs(os.path.dirname(pfad), exist_ok=True)
+ neben = pfad + ".neu"
+ try:
+ with open(neben, "w", encoding="utf-8", newline="\n") as datei:
+ datei.write(inhalt)
+ os.replace(neben, pfad)
+ except OSError:
+ # Die Nebendatei nie liegen lassen: eine halb geschriebene
+ # KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
+ # belegt im Extremfall 64 MB, die niemand mehr aufräumt.
+ try:
+ os.remove(neben)
+ except OSError:
+ pass
+ raise
+ return keydb_status(daten_dir)
+
+
+def keydb_loeschen(daten_dir: str = None) -> dict:
+ """Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
+
+ Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
+ Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
+ Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
+ den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
+ """
+ try:
+ os.remove(keydb_pfad(daten_dir))
+ except FileNotFoundError:
+ pass
+ return keydb_status(daten_dir)
+
+
+def ist_aacs_dump(name: str) -> bool:
+ """Dateiname eines AACS-Dumps? (pure Funktion, testbar)
+
+ MakeMKV legt ihn als .tgz direkt im Datenverzeichnis ab
+ (Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
+ ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
+ """
+ return (
+ name.endswith(".tgz")
+ and "/" not in name
+ and "\\" not in name
+ and not name.startswith(".")
+ )
+
+
+def dumps_auflisten(daten_dir: str = None) -> list:
+ """Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
+ ordner = daten_dir or DATEN_DIR
+ try:
+ namen = os.listdir(ordner)
+ except OSError:
+ return []
+ liste = []
+ for name in namen:
+ if not ist_aacs_dump(name):
+ continue
+ try:
+ angaben = os.stat(os.path.join(ordner, name))
+ except OSError:
+ continue
+ liste.append(
+ {
+ "name": name,
+ "groesse_bytes": angaben.st_size,
+ "geaendert": _iso(angaben.st_mtime),
+ "_sort": angaben.st_mtime,
+ }
+ )
+ liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
+ for eintrag in liste:
+ del eintrag["_sort"]
+ return liste
+
+
+def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
+ """app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
+
+ Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
+ ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
+ bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
+ app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen,
+ damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
+ """
+ zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
+ if key:
+ zeilen.append('app_Key = "{}"'.format(key))
+ text = "\n".join(zeilen).strip("\n")
+ return text + "\n" if text else ""
diff --git a/docker/api/makemkv_key.py b/docker/api/makemkv_key.py
index 6ae9953..b65f44e 100644
--- a/docker/api/makemkv_key.py
+++ b/docker/api/makemkv_key.py
@@ -11,8 +11,14 @@ Dieses Modul holt den aktuellen Key vom Forum und schreibt ihn in die Settings
Rebuild/Neustart, ab dem naechsten Rip.
Wichtig: Das ist die kostenlose, oeffentliche Beta-LIZENZ der Software selbst —
-NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Den AACS-Schluessel
-zieht MakeMKV via LibreDrive ohnehin selbst aus dem Laufwerk.
+NICHT das Entschluesseln oder Verteilen von Disc-Schluesseln. Diese Grenze gilt
+unveraendert; Rippy liefert keine Disc-Schluessel mit und verteilt keine.
+
+Richtigstellung 25.07.2026: Hier stand frueher, MakeMKV ziehe den AACS-Schluessel
+via LibreDrive ohnehin selbst aus dem Laufwerk. Das stimmt fuer Blu-ray, aber
+NICHT fuer 4K-UHD — dort braucht MakeMKV den Volume-Key der jeweiligen Pressung,
+und den bekommt es weder aus dem Laufwerk noch (heute) aus dem Netz. Belege und
+Messungen stehen im Modul-Kopf von makemkv_daten.py.
Quelle/Format dokumentiert (AGENTS Regel D — nicht geraten):
- Forum-Thread: https://forum.makemkv.com/forum/viewtopic.php?t=1053
diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py
index 79d13e7..ef683bc 100644
--- a/docker/api/test_api_smoke.py
+++ b/docker/api/test_api_smoke.py
@@ -18,7 +18,13 @@ def test_main_importierbar_und_routen_verdrahtet():
from main import app
routen = {route.path for route in app.routes}
- for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"):
+ for pfad in (
+ "/health", "/jobs", "/devices", "/logs", "/settings", "/prescan",
+ # KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
+ # Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
+ # mehr online nach). Ohne diese Routen ist die Seite im UI tot.
+ "/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
+ ):
assert pfad in routen, f"Route {pfad} fehlt"
diff --git a/docker/api/test_makemkv_daten.py b/docker/api/test_makemkv_daten.py
new file mode 100644
index 0000000..b32cd3f
--- /dev/null
+++ b/docker/api/test_makemkv_daten.py
@@ -0,0 +1,118 @@
+"""Tests fuer die puren Helfer aus makemkv_daten.
+
+Bewusst OHNE Dateisystem, DB und fcntl — deshalb laufen sie auch auf Windows
+und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
+ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
+Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
+das Zusammenfuehren der settings.conf.
+"""
+from makemkv_daten import (
+ ist_aacs_dump,
+ keydb_pruefen,
+ settings_conf_zusammenfuehren,
+ zaehle_disc_eintraege,
+)
+
+# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
+# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
+# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
+_GUELTIG = """; KEYDB.cfg — Beispiel
+
+0123456789ABCDEF0123456789ABCDEF01234567 = Akira
+0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
+"""
+
+
+def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
+ # Kommentar- und Leerzeilen duerfen NICHT mitgezaehlt werden, sonst meldet
+ # das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthaelt.
+ assert zaehle_disc_eintraege(_GUELTIG) == 2
+
+
+def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
+ nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
+ assert zaehle_disc_eintraege(nur_kommentare) == 0
+ assert zaehle_disc_eintraege("") == 0
+
+
+def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
+ assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
+
+
+def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
+ # 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
+ # oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
+ assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
+
+
+def test_keydb_pruefen_meldet_leere_datei():
+ assert keydb_pruefen("") != ""
+ assert keydb_pruefen(" \n\n ") != ""
+
+
+def test_keydb_pruefen_erkennt_html():
+ # Haeufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
+ # eines Downloads im Feld. MakeMKV wuerde dann still weiter meckern.
+ fehler = keydb_pruefen("\n404 Not Found\n")
+ assert "HTML" in fehler
+
+
+def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
+ # Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
+ assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
+
+
+def test_keydb_pruefen_laesst_gueltige_datei_durch():
+ # "" heisst laut Vertrag: alles in Ordnung, darf geschrieben werden.
+ assert keydb_pruefen(_GUELTIG) == ""
+
+
+def test_ist_aacs_dump_erkennt_echten_namen():
+ # So heisst der Dump, den MakeMKV am 25.07.2026 fuer Akira UHD abgelegt hat
+ # (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
+ assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
+
+
+def test_ist_aacs_dump_blockt_pfad_tricks():
+ # Der Download-Endpunkt haengt den Namen an das Datenverzeichnis — ein
+ # durchgelassenes ".." oder ein Pfadtrenner waere ein Ausbruch.
+ assert ist_aacs_dump("../x.tgz") is False
+ assert ist_aacs_dump("../../etc/passwd.tgz") is False
+ assert ist_aacs_dump("unter/ordner.tgz") is False
+ assert ist_aacs_dump("unter\\ordner.tgz") is False
+ assert ist_aacs_dump(".versteckt.tgz") is False
+
+
+def test_ist_aacs_dump_lehnt_andere_endungen_ab():
+ # Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
+ # _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
+ assert ist_aacs_dump("KEYDB.cfg") is False
+ assert ist_aacs_dump("settings.conf") is False
+ assert ist_aacs_dump("_private_data.tar") is False
+ assert ist_aacs_dump("") is False
+
+
+def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
+ # Regression: bis 25.07.2026 wurde die Datei komplett ueberschrieben. Mit
+ # dem jetzt persistenten Datenverzeichnis waere app_UpdateEnable vor jedem
+ # Rip weg gewesen.
+ alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
+ neu = settings_conf_zusammenfuehren(alt, "T-neu")
+ assert 'app_Key = "T-neu"' in neu
+ assert "T-alt" not in neu
+ assert 'app_UpdateEnable = "1"' in neu
+ assert 'app_DestinationDir = "/tmp"' in neu
+
+
+def test_settings_conf_leerer_key_entfernt_die_zeile():
+ # Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
+ neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
+ assert "app_Key" not in neu
+ assert 'app_UpdateEnable = "1"' in neu
+
+
+def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
+ # Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
+ # Datei mit abschliessendem Zeilenumbruch.
+ assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
+ assert settings_conf_zusammenfuehren("", "") == ""
diff --git a/docker/ui/nginx.conf b/docker/ui/nginx.conf
index 6a593df..e20130f 100644
--- a/docker/ui/nginx.conf
+++ b/docker/ui/nginx.conf
@@ -17,6 +17,12 @@ server {
# SSE (/api/stream/jobs) braucht ungepufferte, lange Verbindungen:
proxy_buffering off;
proxy_read_timeout 3600s;
+ # Der nginx-Default ist 1 MB. Eine echte KEYDB.cfg ist deutlich groesser,
+ # der Upload ueber POST /api/system/keydb wuerde also schon hier mit
+ # 413 abgewiesen — die API bekaeme die Anfrage nie zu sehen und das UI
+ # haette keinen detail-Text, den es anzeigen koennte. 64m entspricht dem
+ # Limit MAX_KEYDB_BYTES in makemkv_daten.py.
+ client_max_body_size 64m;
}
location / {
diff --git a/docker/ui/src/pages/Anleitung.tsx b/docker/ui/src/pages/Anleitung.tsx
index 4b9cd32..9a8ba96 100644
--- a/docker/ui/src/pages/Anleitung.tsx
+++ b/docker/ui/src/pages/Anleitung.tsx
@@ -134,12 +134,15 @@ export default function AnleitungPage() {
Einstellungen → System eintragen — gilt ab dem nächsten Rip, ohne Neustart. DVDs gehen
immer auch ohne Key. Dort stehen auch die Werkzeug-Versionen und der freie Speicherplatz.
+ {/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
Updates: „Auf Updates prüfen" (ebenfalls Einstellungen →
System) vergleicht die installierten Versionen mit makemkv.com und den offiziellen
HandBrake-Releases. Gibt es ein MakeMKV-Update, zeigt Rippy den fertigen
- Update-Befehl an — wichtig, weil neue Versionen auch die neueste
- Disc-Schlüssel-Datenbank mitbringen.
+ Update-Befehl an — neue Versionen bringen bessere Laufwerks-Unterstützung und
+ Fehlerbehebungen. Disc-Schlüssel für 4K-UHD kommen dagegen nicht
+ aus einem Update, sondern nur aus der Datei KEYDB.cfg, die du selbst
+ unter Einstellungen → System hochlädst (mehr dazu unter „Häufige Fragen").
@@ -157,11 +160,31 @@ export default function AnleitungPage() {
„Diese Disc wurde bereits gerippt"? Rippy erkennt Discs am
Fingerabdruck. Nochmal rippen geht trotzdem — der Hinweis verhindert nur Versehen.
+ {/*
+ 25.07.2026 auf der Rippy-VM nachgemessen und komplett neu geschrieben:
+ Hier stand vorher, ein MakeMKV-Update mache die Disc rippbar. Das stimmt
+ nicht — MakeMKV versucht bei einer unbekannten UHD-Pressung gar nicht mehr,
+ online einen Schluessel zu holen, und die dokumentierten Schluessel-Server
+ (hkdata.fairuse.org, hkdata.crabdance.com) loesen weltweit nicht mehr auf.
+ */}
4K-UHD schlägt fehl mit „volume key is unknown"? Das Laufwerk
- liest die Disc (LibreDrive), aber MakeMKV kennt den Schlüssel dieser (zu neuen) Pressung
- noch nicht. Den automatisch gespeicherten AACS-Dump im MakeMKV-Forum einreichen — mit einem
- der nächsten Updates ist die Disc rippbar.
+ liest die Disc einwandfrei (LibreDrive) — MakeMKV fehlt nur der Schlüssel dieser Pressung.
+ Früher holte MakeMKV solche Schlüssel selbst aus dem Netz; dieser Kanal liefert heute nichts
+ mehr, und ein MakeMKV-Update ändert daran nichts.
+
+
+ Der einzige Weg, der heute funktioniert, ist eine Datei namens KEYDB.cfg —
+ eine Textliste mit Disc-Schlüsseln, die du selbst mitbringst. Unter Einstellungen → System
+ hochladen, sie wirkt ab dem nächsten Rip. Rippy liefert keine
+ Schlüssel mit und lädt auch keine herunter — Rippy stellt nur den Platz für deine
+ Datei bereit und zeigt dir an, was dort liegt.
+
+
+ Der AACS-Dump zu einer gescheiterten Disc bleibt jetzt
+ erhalten und steht unter Einstellungen → System zum Herunterladen. Ihn kannst du im
+ MakeMKV-Forum im Bereich „Ultra HD Blu-ray" einreichen — daraus lässt sich der Schlüssel
+ für deine Pressung ermitteln, den du dann in deine KEYDB.cfg einträgst.
„Neu komprimieren" fehlt bei einem Fehl-Job? Der Knopf
diff --git a/docker/ui/src/pages/Settings.tsx b/docker/ui/src/pages/Settings.tsx
index 227d617..80487d4 100644
--- a/docker/ui/src/pages/Settings.tsx
+++ b/docker/ui/src/pages/Settings.tsx
@@ -1,5 +1,5 @@
-import { useState, useEffect } from 'react'
-import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
+import { useState, useEffect, useRef } from 'react'
+import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv, KeyRound, Upload, Trash2, Download } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import StorageMounts from '../components/StorageMounts'
@@ -62,7 +62,26 @@ type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'ap
interface WorkerInfo {
name: string
encoders: string[]
- info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
+ // keydb: "ja" | "nein" | "unbekannt" — sagt, ob DIESER Worker eine KEYDB.cfg
+ // in seinem MakeMKV-Datenverzeichnis sieht. Nur der Worker, der wirklich
+ // rippt, zaehlt, deshalb steht die Angabe pro Worker und nicht global.
+ info?: { makemkv?: string, handbrake?: string, makemkv_key?: string, keydb?: string }
+}
+
+// Antwort von GET/POST/DELETE /system/keydb (Feldnamen exakt wie die API sie liefert).
+interface KeydbStatus {
+ vorhanden: boolean
+ pfad: string
+ groesse_bytes: number
+ eintraege: number
+ geaendert: string
+}
+
+// Ein AACS-Dump aus GET /system/aacs-dumps.
+interface AacsDump {
+ name: string
+ groesse_bytes: number
+ geaendert: string
}
interface SystemInfo {
@@ -73,6 +92,24 @@ interface SystemInfo {
webhook_gesetzt: boolean
}
+// Byte-Zahl menschenlesbar — eine echte KEYDB.cfg ist mehrere MB gross.
+function bytesLesbar(bytes: number): string {
+ if (!bytes) return '0 B'
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
+ return `${bytes} B`
+}
+
+// Die API liefert ISO-8601 in UTC (oder einen leeren String). Ohne Zonen-Kennung
+// wuerde der Browser den Zeitstempel als Ortszeit lesen und die Uhrzeit um den
+// Zonen-Versatz verschieben — deshalb notfalls ein Z anhaengen.
+function zeitLesbar(iso: string): string {
+ if (!iso) return 'unbekannt'
+ const mitZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`
+ const d = new Date(mitZone)
+ return isNaN(d.getTime()) ? 'unbekannt' : d.toLocaleString('de-DE')
+}
+
export default function SettingsPage() {
const [settings, setSettings] = useState(defaultSettings)
const [activeTab, setActiveTab] = useState('ripping')
@@ -86,6 +123,13 @@ export default function SettingsPage() {
const [quellenBusy, setQuellenBusy] = useState(false)
const [updates, setUpdates] = useState | null>(null)
const [updatesBusy, setUpdatesBusy] = useState(false)
+ // KEYDB.cfg und AACS-Dumps sind DATEIEN im MakeMKV-Datenverzeichnis, keine
+ // Einstellungen — deshalb bewusst NICHT in SettingsState: der globale
+ // Speichern-Knopf postet dieses Objekt komplett und wuerde sie mitschleifen.
+ const [keydb, setKeydb] = useState(null)
+ const [keydbBusy, setKeydbBusy] = useState(false)
+ const [dumps, setDumps] = useState([])
+ const keydbInput = useRef(null)
const { toast } = useToast()
@@ -106,6 +150,49 @@ export default function SettingsPage() {
}
}
+ // Status der KEYDB.cfg + Liste der AACS-Dumps frisch holen. Wird beim Laden
+ // der Seite und nach jeder Aktion aufgerufen — die API ist die Wahrheit,
+ // nicht das, was wir gerade hochgeschickt haben.
+ const keydbLaden = () => {
+ api.get('/system/keydb').then(r => setKeydb(r.data)).catch(() => setKeydb(null))
+ api.get('/system/aacs-dumps').then(r => setDumps(r.data?.dumps || [])).catch(() => setDumps([]))
+ // Die Worker-Plaketten oben kommen aus /capabilities und stammen aus dem
+ // letzten Herzschlag des Workers (minuetlich) — ohne dieses Nachladen
+ // widerspraeche die Kachel nach einem Upload sichtbar der Erfolgsmeldung.
+ // Ganz sofort ist sie trotzdem nicht: der Worker meldet erst beim
+ // naechsten Herzschlag neu. Genau so steht es auch im Erklaertext.
+ api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
+ }
+
+ const keydbHochladen = async (datei: File) => {
+ setKeydbBusy(true)
+ try {
+ // Bewusst als JSON-Text und nicht als Multipart-Upload: der API fehlt
+ // python-multipart, ein File()-Endpunkt wuerde sie beim Import killen.
+ const inhalt = await datei.text()
+ await api.post('/system/keydb', { inhalt })
+ keydbLaden()
+ toast('success', 'KEYDB.cfg gespeichert — sie wirkt ab dem nächsten Rip.')
+ } catch (e: any) {
+ toast('error', e?.response?.data?.detail || 'Hochladen fehlgeschlagen — ist das wirklich eine KEYDB.cfg, und läuft die API?')
+ } finally {
+ setKeydbBusy(false)
+ }
+ }
+
+ const keydbEntfernen = async () => {
+ setKeydbBusy(true)
+ try {
+ await api.delete('/system/keydb')
+ keydbLaden()
+ toast('success', 'KEYDB.cfg entfernt — Rippy nutzt jetzt keine Schlüsseldatei mehr.')
+ } catch (e: any) {
+ toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen — läuft die API?')
+ } finally {
+ setKeydbBusy(false)
+ }
+ }
+
const quellenPruefen = async () => {
setQuellenBusy(true)
try {
@@ -131,6 +218,12 @@ export default function SettingsPage() {
api.get('/system/info')
.then(r => setSystemInfo(r.data))
.catch(() => setSystemInfo(null))
+ api.get('/system/keydb')
+ .then(r => setKeydb(r.data))
+ .catch(() => setKeydb(null))
+ api.get('/system/aacs-dumps')
+ .then(r => setDumps(r.data?.dumps || []))
+ .catch(() => setDumps([]))
}, [])
const webhookTesten = async () => {
@@ -633,13 +726,40 @@ export default function SettingsPage() {
HandBrake {w.info.handbrake}
)}
+ {/* Nur das Verzeichnis des rippenden Workers zaehlt — deshalb je Worker. */}
+ {w.info?.keydb === 'ja' && (
+
+ KEYDB.cfg vorhanden
+
+ )}
+ {w.info?.keydb === 'nein' && (
+
+ keine KEYDB.cfg
+
+ )}
+ {w.info?.keydb === 'unbekannt' && (
+
+ KEYDB.cfg unbekannt
+
+ )}
))
)}
+ {/*
+ Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
+ Disc-Schluessel-Datenbank mit. Auf der Rippy-VM nachgemessen: falsch.
+ MakeMKV hat gar keine mitgelieferte Schluessel-Datenbank, und der
+ Online-Kanal liefert nichts mehr (die Server hkdata.fairuse.org und
+ hkdata.crabdance.com loesen weltweit nicht mehr auf). Updates bringen
+ Laufwerks-Firmware-Unterstuetzung und Fehlerbehebungen — Schluessel
+ fuer neue UHD-Pressungen kommen ausschliesslich aus der KEYDB.cfg.
+ */}
MakeMKV lässt sich aktuell halten
- (Update-Check unten; Version über MAKEMKV_VERSION + Rebuild) — wichtig wegen der
- Disc-Schlüssel-Datenbank.
+ (Update-Check unten; Version über MAKEMKV_VERSION + Rebuild) — neue Versionen bringen
+ vor allem Laufwerks-Unterstützung und Fehlerbehebungen. Disc-Schlüssel für neue
+ 4K-UHD-Pressungen kommen dagegen NICHT aus einem Update, sondern nur aus der KEYDB.cfg
+ im Block darunter.
{' '}HandBrake im eingebauten
Docker-Worker ist bewusst die stabile Debian-Version — für die Kompression völlig
ausreichend und wird nicht separat aktualisiert. Native Worker (Windows) holen
@@ -655,6 +775,140 @@ export default function SettingsPage() {
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
+ {/*
+ MakeMKV-Schluessel (KEYDB.cfg) — Befund vom 25.07.2026 auf der Rippy-VM:
+ MakeMKV versucht bei einer unbekannten UHD-Pressung gar nicht mehr, online
+ einen Schluessel zu holen, und die dokumentierten Schluessel-Server sind tot.
+ Der einzige heute funktionierende Weg ist eine Datei KEYDB.cfg (GROSS
+ geschrieben, unter Linux case-sensitiv) im MakeMKV-Datenverzeichnis.
+ Rippy stellt nur den Platz dafuer bereit — mitgeliefert wird nichts.
+ */}
+
+
+
+
+ MakeMKV-Schlüssel (KEYDB.cfg)
+
+
+ {/*
+ Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
+ JSON gepostet. Kein Multipart — der API fehlt python-multipart.
+ */}
+ {
+ const datei = e.target.files?.[0]
+ // Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
+ e.target.value = ''
+ if (datei) keydbHochladen(datei)
+ }}
+ />
+
+ {keydb?.vorhanden && (
+
+ )}
+
+ DVDs und normale Blu-rays laufen trotzdem. Nur bei 4K-UHD-Discs, deren Schlüssel MakeMKV
+ nicht kennt, bricht der Rip mit „The volume key is unknown for this disc" ab.
+
+
+ )}
+
+
+ Die KEYDB.cfg ist eine reine
+ Textdatei mit Disc-Schlüsseln für 4K-UHD-Blu-rays. MakeMKV holte solche Schlüssel früher selbst
+ aus dem Netz — das funktioniert heute nicht mehr, die alten Schlüssel-Server sind abgeschaltet.
+ Ein MakeMKV-Update hilft dagegen nicht.
+
+
+ Wichtig: Rippy liefert keine
+ Schlüssel mit, lädt keine herunter und verteilt keine. Rippy stellt nur den Platz für eine Datei
+ bereit, die du selbst mitbringst, und zeigt dir ehrlich an, was dort liegt. Die Zahl oben ist
+ genau das: die gezählten Zeilen mit einer Disc-Kennung in deiner Datei — keine von MakeMKV
+ bestätigte Anzahl brauchbarer Schlüssel.
+
+
+ Eine hochgeladene Datei ersetzt die bisherige und wirkt
+ ab dem nächsten Rip — laufende
+ Jobs bleiben unberührt, ein Neustart ist nicht nötig. Die Plakette am Worker weiter oben
+ folgt erst mit dessen nächstem Herzschlag (etwa eine Minute) — dieser Kasten hier ist sofort
+ aktuell.
+
+
+
+ {/*
+ AACS-Dumps: MakeMKV legt sie bei einer unbekannten UHD-Disc ab (Meldung 3332).
+ Seit das Datenverzeichnis persistent gemountet ist, ueberleben sie den
+ Container-Neustart — vorher waren sie nach jedem Rip weg.
+ */}
+
+
+
AACS-Dumps
+
+
+ {dumps.length === 0 ? (
+
+ Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
+ an einem unbekannten Schlüssel scheitert.
+
+ Ein AACS-Dump ist das, was das Laufwerk von der Disc gelesen hat, bevor der Schlüssel fehlte.
+ Er enthält keinen Schlüssel und nützt dir allein nichts — aber du kannst ihn herunterladen und
+ im MakeMKV-Forum im Bereich Ultra HD
+ Blu-ray einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
+ den trägst du dann in deine KEYDB.cfg ein.
+
+
+
Update-Check
@@ -672,7 +926,9 @@ export default function SettingsPage() {
⚠️ Update verfügbar — in der .env MAKEMKV_VERSION={updates.makemkv.verfuegbar} setzen,
dann auf der Rippy-Maschine docker compose build worker && docker compose up -d worker.
- Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
+ {/* 25.07.2026 richtiggestellt: ein Update bringt KEINE Schluessel — die kommen nur aus der KEYDB.cfg. */}
+ Neue Versionen bringen Laufwerks-Unterstützung und Fehlerbehebungen — aber
+ keine Disc-Schlüssel: die kommen ausschließlich aus deiner KEYDB.cfg.
)
: ✓ aktuell}
diff --git a/docker/worker/Dockerfile b/docker/worker/Dockerfile
index 02d383e..5356f6f 100644
--- a/docker/worker/Dockerfile
+++ b/docker/worker/Dockerfile
@@ -16,9 +16,13 @@ FROM python:3.12-slim-bookworm AS makemkv-build
# 1.18er-Laufwerks-Scan-Hängers (Forum t=38128) und der Flash-Fähigkeit
# gepinnt. Beides erledigt: der Scan wird überall mit --noscan umgangen
# (info-Lauf mit 1.18.4 auf der BU40N sauber durchgelaufen, 24.07.), und
-# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt). Die AKTUELLE
-# Version zählt, weil sie die neueste AACS-Schlüssel-Datenbank mitbringt —
-# 1.17.7 kannte z. B. den Key der Summer-Wars-UHD (MKB v82) nicht.
+# das Laufwerk ist geflasht (LibreDrive v06.3 bestätigt).
+# Richtigstellung 25.07.2026: Hier stand, die aktuelle Version zähle, weil sie
+# "die neueste AACS-Schlüssel-Datenbank mitbringt". Das ist widerlegt — MakeMKV
+# bringt gar keine Disc-Schlüssel mit, und der Online-Kanal liefert nichts mehr
+# (Messungen im Modul-Kopf von makemkv_daten.py). Aktuell bleiben lohnt sich
+# trotzdem: Laufwerks-Unterstützung und Fehlerbehebungen. Schlüssel für neue
+# UHD-Pressungen kommen ausschließlich aus der KEYDB.cfg im Datenverzeichnis.
# MAKEMKV_URL_BASE ist übersteuerbar (Build-Arg): /download hat nur die
# aktuelle Version, /download/old die älteren. Wenn Cloudflare BuildKit-
# Downloads drosselt (24.07.: außerhalb des Builds ging alles, im Build
diff --git a/docker/worker/caps.py b/docker/worker/caps.py
index 749b923..1a56688 100644
--- a/docker/worker/caps.py
+++ b/docker/worker/caps.py
@@ -85,4 +85,24 @@ def werkzeug_versionen() -> dict:
info["makemkv_key"] = "env"
else:
info["makemkv_key"] = "keiner"
+ # Sieht DIESER Worker eine KEYDB.cfg? Die API zeigt ihren eigenen Mount —
+ # bei einem Remote-Worker kann das etwas ganz anderes sein, und nur das
+ # Verzeichnis des rippenden Workers zählt (Befund 25.07.2026).
+ #
+ # Gemeldet wird die Angabe NUR, wenn das Datenverzeichnis hier wirklich
+ # eingehängt ist (os.path.ismount). Ein Remote-Transcode-Worker benutzt
+ # dasselbe Image — makemkvcon ist dort also vorhanden und der entrypoint
+ # legt den Ordner an —, er bekommt den Mount aber nicht und rippt nie.
+ # Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
+ # KEYDB.cfg", obwohl ihn das gar nichts angeht.
+ try:
+ import makemkv_daten
+ daten_dir = makemkv_daten.DATEN_DIR
+ except Exception:
+ daten_dir = ""
+ if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
+ try:
+ info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
+ except Exception:
+ info["keydb"] = "unbekannt"
return info
diff --git a/docker/worker/entrypoint.sh b/docker/worker/entrypoint.sh
index c76d54d..9e6fa8e 100644
--- a/docker/worker/entrypoint.sh
+++ b/docker/worker/entrypoint.sh
@@ -1,11 +1,51 @@
#!/bin/sh
-# Schreibt den MakeMKV-Beta-Key aus der Umgebung in die Settings (falls gesetzt).
-# Ohne Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
+# Bereitet MakeMKVs Datenverzeichnis vor, BEVOR der Worker startet.
+#
+# Wichtig (Befund 25.07.2026): Dieses Verzeichnis ist jetzt ein persistenter
+# Mount vom Host (docker-compose.yml). Darin liegen KEYDB.cfg (die einzige
+# heute funktionierende Schlüsselquelle für 4K-UHD), die AACS-Dumps
+# fehlgeschlagener Discs und MakeMKVs settings.conf. Deshalb wird die
+# settings.conf hier ERGÄNZT statt überschrieben — vorher hat dieses Skript
+# sie bei jedem Start plattgemacht und dabei alles außer app_Key gelöscht.
+#
+# Ohne Beta-Key: DVD-Ripping geht immer, Blu-ray läuft im 30-Tage-Testmodus.
set -e
-if [ -n "${MAKEMKV_APP_KEY}" ]; then
- mkdir -p /root/.MakeMKV
- printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" > /root/.MakeMKV/settings.conf
+DATEN_DIR="${MAKEMKV_DATA_DIR:-/root/.MakeMKV}"
+CONF="${DATEN_DIR}/settings.conf"
+
+# Die ganze Vorbereitung ist BEST EFFORT und läuft deshalb in einer Subshell
+# ohne set -e. Grund: Das Datenverzeichnis kommt jetzt vom Host und kann
+# schreibgeschützt sein oder einer fremden UID gehören (Freigabe, root_squash).
+# Vorher war das unmöglich — geschrieben wurde containerintern. Bräche der
+# Start daran ab, ergäbe "restart: unless-stopped" eine Endlosschleife, in der
+# auch reines DVD-Rippen tot wäre, obwohl das die Datei gar nicht braucht.
+# Denselben Weg geht tasks.py: OSError wird zur Warnung, der Job läuft weiter.
+if ! (
+ set +e
+ mkdir -p "${DATEN_DIR}" || exit 1
+ [ -f "${CONF}" ] || touch "${CONF}" || exit 1
+
+ # app_Key aus der Umgebung setzen: alte Zeile raus, neue ans Ende. Alles
+ # andere in der Datei bleibt stehen. (grep -v liefert Exit 1, wenn nichts
+ # übrig bleibt — das ist hier kein Fehler.)
+ if [ -n "${MAKEMKV_APP_KEY}" ]; then
+ grep -v '^app_Key' "${CONF}" > "${CONF}.neu" 2>/dev/null
+ printf 'app_Key = "%s"\n' "${MAKEMKV_APP_KEY}" >> "${CONF}.neu" || exit 1
+ mv "${CONF}.neu" "${CONF}" || exit 1
+ fi
+
+ # Web-Kontakt explizit einschalten. MakeMKV hat das per Default ohnehin an
+ # (Meldung 5074, am 25.07. im Worker verifiziert) — explizit steht es hier,
+ # damit die Einstellung nachvollziehbar ist und nicht versehentlich kippt.
+ # Quelle: forum.makemkv.com/forum/viewtopic.php?t=20364 (headless settings.conf)
+ grep -q '^app_UpdateEnable' "${CONF}" || printf 'app_UpdateEnable = "1"\n' >> "${CONF}" || exit 1
+ exit 0
+); then
+ echo "WARNUNG: ${DATEN_DIR} ist nicht beschreibbar." >&2
+ echo " MakeMKV-Beta-Key und app_UpdateEnable wurden NICHT gesetzt." >&2
+ echo " Der Worker startet trotzdem. Prüfe Rechte und Eigentümer des" >&2
+ echo " Host-Verzeichnisses (Standard: /srv/rippy/makemkv)." >&2
fi
exec "$@"
diff --git a/docker/worker/makemkv_daten.py b/docker/worker/makemkv_daten.py
new file mode 100644
index 0000000..1ce430d
--- /dev/null
+++ b/docker/worker/makemkv_daten.py
@@ -0,0 +1,242 @@
+"""MakeMKV-Datenverzeichnis: KEYDB.cfg, AACS-Dumps, settings.conf.
+
+WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, live im Worker nachgemessen):
+Bei 4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc".
+Das Laufwerk ist dabei in Ordnung — makemkvcon meldet "Using LibreDrive mode
+(v06.3)" und liest die Disc — MakeMKV kennt nur den Schluessel DIESER Pressung
+nicht. Und es holt ihn NICHT mehr online nach:
+
+ * /root/.MakeMKV/_private_data.tar enthielt ausser der Index-Datei keine
+ einzige hkd_*.bin, also nie einen heruntergeladenen Schluessel;
+ * auch mit erzwungener frischer Pruefung (update.conf geloescht, Meldung
+ 5074 belegt den Web-Kontakt) und mit app_UpdateEnable = "1" kam keiner;
+ * die im MakeMKV-Forum dokumentierten Schluessel-Server hkdata.fairuse.org
+ und hkdata.crabdance.com loesen weltweit nicht mehr auf (gegen Fritz!Box,
+ 8.8.8.8 und 1.1.1.1 geprueft: NXDOMAIN).
+
+Betroffen war Akira UHD (MKB v76, Pressung von Dezember 2020) — also gerade
+KEINE Neuerscheinung. Die bisherige Fehlermeldung ("Disc neuer als die
+Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war damit
+schlicht falsch.
+
+Der einzige heute funktionierende Weg ist eine KEYDB.cfg im
+MakeMKV-Datenverzeichnis. Rippy liefert KEINE Schluessel mit, laedt keine
+herunter und verteilt keine — es stellt nur den Platz dafuer bereit und zeigt
+ehrlich an, was dort liegt. Die Datei bringt der Nutzer selbst mit.
+
+QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
+ * Datenverzeichnis und Dateiname GROSS geschrieben (unter Linux
+ case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
+ https://forum.makemkv.com/forum/viewtopic.php?t=30636
+ * Heruntergeladene Schluessel liegen als hkd_*.bin in _private_data.tar:
+ https://forum.makemkv.com/forum/viewtopic.php?t=32675
+ * Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
+ optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
+ "| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
+ https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
+ * Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
+ as file:///root/.MakeMKV/.tgz" (am 25.07.2026 so beobachtet).
+
+ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ — beide
+Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
+(gleiche Lage wie bei db.py). Aenderungen IMMER in BEIDEN Dateien nachziehen.
+"""
+
+import os
+import re
+from datetime import datetime, timezone
+
+# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
+# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
+# docker-compose.yml auf DASSELBE Host-Verzeichnis.
+DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
+
+# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
+KEYDB_NAME = "KEYDB.cfg"
+
+# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt
+# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
+# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
+MAX_KEYDB_BYTES = 64 * 1024 * 1024
+
+# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
+# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
+# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag.
+_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
+
+
+def _iso(zeitstempel: float) -> str:
+ """Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
+ return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
+
+
+def keydb_pfad(daten_dir: str = None) -> str:
+ """Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
+ return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
+
+
+def zaehle_disc_eintraege(inhalt: str) -> int:
+ """Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar).
+
+ Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest
+ die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
+ macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
+ "da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
+ unterscheiden — sie wird deshalb auch genau so beschriftet.
+ """
+ return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
+
+
+def keydb_pruefen(inhalt: str) -> str:
+ """Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
+
+ Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
+ HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
+ MakeMKV wuerde dann still weiter "volume key is unknown" melden.
+ """
+ if not inhalt.strip():
+ return "Die Datei ist leer."
+ if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
+ return (
+ "Die Datei ist groesser als "
+ f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
+ )
+ if inhalt.lstrip()[:1] == "<":
+ return (
+ "Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
+ "vermutlich wurde eine Fehlerseite statt der Datei geladen."
+ )
+ if zaehle_disc_eintraege(inhalt) == 0:
+ return (
+ "Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
+ "dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
+ )
+ return ""
+
+
+def keydb_status(daten_dir: str = None) -> dict:
+ """Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
+ pfad = keydb_pfad(daten_dir)
+ try:
+ angaben = os.stat(pfad)
+ except OSError:
+ return {
+ "vorhanden": False,
+ "pfad": pfad,
+ "groesse_bytes": 0,
+ "eintraege": 0,
+ "geaendert": "",
+ }
+ eintraege = 0
+ try:
+ with open(pfad, encoding="utf-8", errors="replace") as datei:
+ eintraege = zaehle_disc_eintraege(datei.read())
+ except OSError:
+ pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem
+ return {
+ "vorhanden": True,
+ "pfad": pfad,
+ "groesse_bytes": angaben.st_size,
+ "eintraege": eintraege,
+ "geaendert": _iso(angaben.st_mtime),
+ }
+
+
+def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
+ """Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
+
+ Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf
+ makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
+ """
+ pfad = keydb_pfad(daten_dir)
+ os.makedirs(os.path.dirname(pfad), exist_ok=True)
+ neben = pfad + ".neu"
+ try:
+ with open(neben, "w", encoding="utf-8", newline="\n") as datei:
+ datei.write(inhalt)
+ os.replace(neben, pfad)
+ except OSError:
+ # Die Nebendatei nie liegen lassen: eine halb geschriebene
+ # KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
+ # belegt im Extremfall 64 MB, die niemand mehr aufräumt.
+ try:
+ os.remove(neben)
+ except OSError:
+ pass
+ raise
+ return keydb_status(daten_dir)
+
+
+def keydb_loeschen(daten_dir: str = None) -> dict:
+ """Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
+
+ Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
+ Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
+ Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
+ den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
+ """
+ try:
+ os.remove(keydb_pfad(daten_dir))
+ except FileNotFoundError:
+ pass
+ return keydb_status(daten_dir)
+
+
+def ist_aacs_dump(name: str) -> bool:
+ """Dateiname eines AACS-Dumps? (pure Funktion, testbar)
+
+ MakeMKV legt ihn als .tgz direkt im Datenverzeichnis ab
+ (Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
+ ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
+ """
+ return (
+ name.endswith(".tgz")
+ and "/" not in name
+ and "\\" not in name
+ and not name.startswith(".")
+ )
+
+
+def dumps_auflisten(daten_dir: str = None) -> list:
+ """Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
+ ordner = daten_dir or DATEN_DIR
+ try:
+ namen = os.listdir(ordner)
+ except OSError:
+ return []
+ liste = []
+ for name in namen:
+ if not ist_aacs_dump(name):
+ continue
+ try:
+ angaben = os.stat(os.path.join(ordner, name))
+ except OSError:
+ continue
+ liste.append(
+ {
+ "name": name,
+ "groesse_bytes": angaben.st_size,
+ "geaendert": _iso(angaben.st_mtime),
+ "_sort": angaben.st_mtime,
+ }
+ )
+ liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
+ for eintrag in liste:
+ del eintrag["_sort"]
+ return liste
+
+
+def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
+ """app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
+
+ Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
+ ueberschrieben. Mit dem jetzt persistenten Datenverzeichnis waere damit
+ bei jedem Containerstart und vor jedem Rip alles andere weg — z. B.
+ app_UpdateEnable. Leerer Key laesst die vorhandene Zeile ebenfalls fallen,
+ damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
+ """
+ zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
+ if key:
+ zeilen.append('app_Key = "{}"'.format(key))
+ text = "\n".join(zeilen).strip("\n")
+ return text + "\n" if text else ""
diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py
index ddefa2e..b843971 100644
--- a/docker/worker/ripping.py
+++ b/docker/worker/ripping.py
@@ -114,7 +114,7 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> list:
def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
- progress_cb=None) -> dict:
+ progress_cb=None, log_cb=None) -> dict:
"""Rippt GENAU die gewählten Titel (makemkvcon kann pro Aufruf nur einen
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
gesamt = len(titel_liste)
@@ -124,7 +124,8 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
if progress_cb:
progress_cb(int((_i * 100 + p) / gesamt))
- ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr))
+ ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
+ log_cb=log_cb)
if ergebnis.get("status") == "cancelled":
return ergebnis
if ergebnis.get("status") != "success":
@@ -181,6 +182,27 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
return parse_scan_dauer((ergebnis.stdout or "") + (ergebnis.stderr or ""))
+_MSG_RE = re.compile(r'^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"')
+
+
+def parse_msg(zeile: str):
+ """MSG-Zeile -> (code, klartext) oder None (pure Funktion, testbar).
+
+ Format laut https://www.makemkv.com/developers/usage.txt:
+ MSG:code,flags,count,"message","format","param0",... — Feld 4 ist der
+ fertig zusammengesetzte Klartext.
+
+ Bis zum 25.07.2026 stand hier line.split(",", 4)[3]: das schnitt JEDE
+ Meldung ab, die selbst ein Komma enthaelt — und MakeMKV schreibt solche
+ laufend ("Title #1 has length of 12 seconds, which is less than ...").
+ Deshalb eine Regex, die die Anfuehrungszeichen respektiert.
+ """
+ treffer = _MSG_RE.match(zeile.strip())
+ if not treffer:
+ return None
+ return int(treffer.group(1)), treffer.group(2).replace('\\"', '"')
+
+
def get_progress_from_prgv(line: str) -> int:
"""Extrahiert Gesamt-Fortschritt (0-100) aus einer PRGV-Zeile.
@@ -297,8 +319,17 @@ def write_abcde_config(output_dir: str) -> str:
return tmp.name
-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."""
+def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
+ log_cb=None) -> dict:
+ """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
+
+ log_cb(code, text) bekommt JEDE MakeMKV-Meldung. Bewusst ein eigener
+ Kanal statt progress_cb: der Fortschritts-Callback in tasks.py verwirft
+ Aufrufe, bei denen sich die Prozentzahl nicht geaendert hat — Meldungen
+ waeren dort also grossteils verschwunden. Ohne diesen Kanal war am
+ 25.07.2026 nicht von aussen erkennbar, dass MakeMKV bei der UHD-Disc
+ nicht einmal versucht, einen Schluessel zu holen (siehe makemkv_daten).
+ """
if not check_makemkv_installed():
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
@@ -328,15 +359,23 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
try:
for line in process.stdout:
progress = get_progress_from_prgv(line)
- if progress >= 0 and progress_cb:
- progress_cb(progress)
- elif line.startswith("MSG:"):
- # MSG:code,flags,count,"message",... — Klartext ist Feld 4
- teile = line.split(",", 4)
- if len(teile) >= 4:
- letzte_meldung = teile[3].strip('"')
- if any(muster in letzte_meldung for muster in KRITISCH):
- kritische_meldungen.append(letzte_meldung)
+ if progress >= 0:
+ if progress_cb:
+ progress_cb(progress)
+ continue
+ meldung = parse_msg(line)
+ if meldung is None:
+ continue
+ code, letzte_meldung = meldung
+ if any(muster in letzte_meldung for muster in KRITISCH):
+ kritische_meldungen.append(letzte_meldung)
+ if log_cb:
+ try:
+ log_cb(code, letzte_meldung)
+ except RipAbbruch:
+ raise
+ except Exception:
+ pass # Protokollieren darf einen laufenden Rip nie beenden
except RipAbbruch:
process.kill()
process.wait()
@@ -373,7 +412,7 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_cb=None,
output_dir: str = None, nur_hauptfilm: bool = False,
- titel_liste: list = None) -> dict:
+ titel_liste: list = None, log_cb=None) -> dict:
"""Rippt eine DVD oder Blu-ray verlustfrei mit MakeMKV.
Bewusst KEIN eigener Celery-Task: der einzige Task ist worker.tasks.rip_disc,
@@ -388,7 +427,7 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
if titel_liste:
os.makedirs(output_dir, exist_ok=True)
- return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb)
+ return rip_titel_auswahl(device_path, output_dir, titel_liste, progress_cb, log_cb)
titel = "all"
if nur_hauptfilm:
@@ -397,7 +436,8 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
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)
+ return run_makemkv(device_path, output_dir, progress_cb=progress_cb, titel=titel,
+ log_cb=log_cb)
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 3ba9124..a95e0a6 100644
--- a/docker/worker/tasks.py
+++ b/docker/worker/tasks.py
@@ -20,6 +20,7 @@ import shutil
import requests
import db
+import makemkv_daten
import medien
import notify
from celery_app import celery_app
@@ -147,15 +148,26 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
Damit ist der Monats-Key ohne Rebuild/Neustart aktualisierbar
(Einstellungen → System). Format wie entrypoint.sh: settings.conf.
+
+ Ergänzend statt überschreibend (Befund 25.07.2026): das Datenverzeichnis
+ ist jetzt persistent, und hier stand vorher ein open(..., "w") — das warf
+ vor JEDEM Rip alles andere aus der settings.conf, z. B. app_UpdateEnable
+ aus dem entrypoint. Beide Schreiber müssen gleich arbeiten, sonst kommt
+ der Fehler beim nächsten Rip still zurück.
"""
key = (einstellungen.get("makemkvAppKey") or "").strip()
if not key:
return
- ordner = os.path.expanduser("~/.MakeMKV")
+ pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
try:
- os.makedirs(ordner, exist_ok=True)
- with open(os.path.join(ordner, "settings.conf"), "w") as f:
- f.write(f'app_Key = "{key}"\n')
+ os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
+ try:
+ with open(pfad, encoding="utf-8", errors="replace") as f:
+ alt = f.read()
+ except OSError:
+ alt = ""
+ with open(pfad, "w", encoding="utf-8", newline="\n") as f:
+ f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
except OSError as e:
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
@@ -344,6 +356,22 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
)
db.update_job(job_id, progress=progress)
+ # MakeMKV-Meldungen ins Log (Befund 25.07.2026): Bis dahin überlebte NUR
+ # die letzte Zeile ("Failed to open disc"), und die sagt nichts. Dass
+ # MakeMKV bei der UHD-Disc nicht einmal versucht, einen Schlüssel zu
+ # holen, war deshalb nur per Hand-Lauf im Container zu sehen.
+ # Gedrosselt, weil das UI global nur die letzten 200 Zeilen zeigt: jede
+ # Meldung höchstens einmal, insgesamt höchstens MAX_MELDUNGEN je Rip.
+ # Code 1003 ist MakeMKVs eigenes DEBUG-Rauschen (am 25.07. beobachtet).
+ MAX_MELDUNGEN = 40
+ gesehen = set()
+
+ def melde_makemkv(code: int, text: str):
+ if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
+ return
+ gesehen.add(text)
+ db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
+
einstellungen = db.get_settings()
ist_video = disc_type in ("dvd", "bluray", "uhd")
transcode_an = ist_video and einstellungen.get("transcodeEnabled", True)
@@ -411,6 +439,7 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
output_dir=raw_dir,
nur_hauptfilm=nur_hauptfilm,
titel_liste=titel_liste,
+ log_cb=melde_makemkv,
)
else:
ergebnis = rip_video(
@@ -418,26 +447,43 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
progress_cb=fortschritt, output_dir=final_dir,
nur_hauptfilm=nur_hauptfilm,
titel_liste=titel_liste,
+ log_cb=melde_makemkv,
)
- # UHD-Fehler in Klartext übersetzen — "Failed to open disc" allein hilft
- # niemandem. Zwei bekannte Ursachen (Befunde 24.07., Summer-Wars-UHD):
- if ergebnis.get("status") == "error" and disc_type == "uhd":
+ # MakeMKV-Fehler in Klartext übersetzen — "Failed to open disc" allein
+ # hilft niemandem.
+ if ergebnis.get("status") == "error":
fehler_text = ergebnis.get("error") or ""
if "volume key is unknown" in fehler_text:
- # LibreDrive lief bereits — MakeMKV kennt nur den Disc-Schlüssel
- # nicht: Version zu alt ODER Disc neuer als die Key-Datenbank.
+ # Befund 25.07.2026, im Worker nachgemessen (Akira UHD, MKB v76,
+ # Pressung Dez. 2020): Laufwerk und MakeMKV sind in Ordnung —
+ # MakeMKV fragt online gar nicht erst nach einem Schlüssel, und
+ # der Online-Kanal liefert auch nichts mehr. Der alte Text hier
+ # ("Disc neuer als die Schlüssel-Datenbank, mit einem der
+ # nächsten Updates rippbar") war schlicht falsch und hat in die
+ # falsche Richtung geschickt. Details: makemkv_daten.py.
+ keydb = makemkv_daten.keydb_status()
ergebnis["error"] += (
- " — Klartext: Das Laufwerk liest die Disc (LibreDrive OK), "
- "aber MakeMKV kennt den Schlüssel dieser Disc nicht. Erst "
- "prüfen: MakeMKV aktuell? (Einstellungen → System; Update = "
- "Image-Rebuild). Ist es aktuell, ist die Disc neuer als die "
- "Schlüssel-Datenbank — MakeMKV hat einen AACS-Dump unter "
- "/root/.MakeMKV/ im Worker gespeichert; im MakeMKV-Forum "
- "(Bereich 'Ultra HD Blu-ray') einreichen, mit einem der "
- "nächsten Updates ist die Disc dann rippbar."
+ " — Klartext: Laufwerk und Rippy sind in Ordnung, MakeMKV "
+ "liest die Disc. Es kennt nur den Schlüssel dieser Pressung "
+ "nicht und holt ihn auch nicht mehr online nach — MakeMKVs "
+ "Schlüssel-Kanal liefert nichts mehr (am 25.07.2026 im "
+ "Worker nachgemessen). Abhilfe: eine KEYDB.cfg unter "
+ "Einstellungen → System hochladen; sie wirkt ab dem nächsten "
+ "Rip. "
+ + (
+ "Aktuell liegt dort keine KEYDB.cfg."
+ if not keydb.get("vorhanden")
+ else "Es liegt bereits eine KEYDB.cfg dort — sie kennt "
+ "diese Pressung offenbar nicht; eine neuere Fassung "
+ "kann helfen."
+ )
+ + " Den AACS-Dump dieser Disc bewahrt Rippy jetzt dauerhaft "
+ "auf; er steht unter Einstellungen → System zum Download "
+ "bereit (für die Einreichung im MakeMKV-Forum, Bereich "
+ "'Ultra HD Blu-ray')."
)
- elif "Failed to open disc" in fehler_text:
+ elif disc_type == "uhd" and "Failed to open disc" in fehler_text:
ergebnis["error"] += (
" — 4K-UHD erkannt: Das Laufwerk kann UHD-Discs vermutlich "
"nicht entschlüsseln. Dafür ist eine LibreDrive-Firmware nötig "
diff --git a/docker/worker/test_makemkv_daten_worker.py b/docker/worker/test_makemkv_daten_worker.py
new file mode 100644
index 0000000..51f0cdc
--- /dev/null
+++ b/docker/worker/test_makemkv_daten_worker.py
@@ -0,0 +1,181 @@
+"""Tests fuer makemkv_daten.py: die reinen Helfer rund um das MakeMKV-Datenverzeichnis.
+
+WARUM ES DIESE TESTS GIBT (Befund 25.07.2026, live im Worker nachgemessen):
+Eine 4K-UHD-Disc (Akira UHD, MKB v76) scheiterte mit "The volume key is unknown
+for this disc", obwohl Laufwerk und MakeMKV in Ordnung waren. Der einzige heute
+noch funktionierende Weg ist eine selbst mitgebrachte KEYDB.cfg im
+Datenverzeichnis. Damit haengt einiges an diesen kleinen Funktionen: erkennen wir
+die Datei falsch, meldet das UI "alles gut", waehrend MakeMKV weiter scheitert.
+
+Getestet wird nur, was ohne Postgres, Redis und ohne Laufwerk laeuft — also die
+puren Funktionen mit echten Beispieldaten. Zeilenformat der KEYDB.cfg laut
+libaacs (AGENTS Regel D, externe Schnittstellen nie aus dem Kopf):
+https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
+
+WARUM DER DATEINAME "_worker" HINTEN DRANHAENGT (25.07.2026): makemkv_daten.py
+ist eine Zwillingsdatei, es gibt sie unter docker/api/ UND docker/worker/, und
+beide Seiten haben Tests. Da im Projekt keine __init__.py liegen, importiert
+pytest Testdateien unter ihrem blossen Dateinamen — zwei Dateien namens
+test_makemkv_daten.py brechen deshalb die Sammelphase ab ("import file
+mismatch") und faerben die ganze Ampel rot. Nicht zurueckbenennen.
+"""
+
+import hashlib
+import importlib.util
+import os
+
+# WICHTIG (Prüfbefund 25.07.2026): Ein schlichtes "from makemkv_daten import ..."
+# lädt bei "pytest -q" vom Repo-Wurzelverzeichnis NICHT diese Datei, sondern die
+# API-Kopie — docker/api wird zuerst gesammelt, und jeder weitere Import trifft
+# nur noch den sys.modules-Cache. Die Tests hier hätten den Worker-Zwilling also
+# nie angefasst und eine Abweichung wäre grün durchgelaufen. Deshalb wird er
+# ausdrücklich über seinen Pfad geladen.
+_HIER = os.path.dirname(os.path.abspath(__file__))
+_WORKER_MODUL = os.path.join(_HIER, "makemkv_daten.py")
+_API_MODUL = os.path.abspath(os.path.join(_HIER, "..", "api", "makemkv_daten.py"))
+
+_spec = importlib.util.spec_from_file_location("makemkv_daten_worker_kopie", _WORKER_MODUL)
+_modul = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(_modul)
+
+ist_aacs_dump = _modul.ist_aacs_dump
+keydb_pruefen = _modul.keydb_pruefen
+settings_conf_zusammenfuehren = _modul.settings_conf_zusammenfuehren
+zaehle_disc_eintraege = _modul.zaehle_disc_eintraege
+
+
+def test_zwillinge_sind_byteweise_identisch():
+ """docker/api/makemkv_daten.py MUSS dieselbe Datei sein wie diese hier.
+
+ Das Modul existiert bewusst doppelt — es gibt in diesem Projekt kein
+ gemeinsames Paket für API und Worker (gleiche Lage wie bei db.py). Genau
+ deshalb braucht es einen Wächter: laufen die beiden auseinander, zeigt das
+ UI etwas anderes an, als der rippende Worker tatsächlich sieht, und es
+ fällt niemandem auf. Dieser Test ist die einzige Stelle, die das
+ mechanisch prüft.
+ """
+ with open(_WORKER_MODUL, "rb") as datei:
+ worker = hashlib.sha256(datei.read()).hexdigest()
+ with open(_API_MODUL, "rb") as datei:
+ api = hashlib.sha256(datei.read()).hexdigest()
+ assert worker == api, (
+ "docker/worker/makemkv_daten.py und docker/api/makemkv_daten.py sind "
+ "auseinandergelaufen - Aenderungen immer in BEIDE Dateien uebernehmen."
+ )
+
+# Eine kleine, aber echte KEYDB.cfg im libaacs-Format: Kommentarkopf, eine
+# Disc-Zeile MIT 0x-Praefix, eine OHNE, dazu ein Fortsetzungsfeld und eine
+# Leerzeile. Erwartete Zahl der Eintraege: 2.
+BEISPIEL_KEYDB = """; KEYDB.cfg
+; Kommentarzeilen beginnen mit einem Semikolon
+
+0x8F4E2C1A9B7D3E5F0A6C8B2D4E1F3A5C7B9D0E2F = AKIRA
+| V | 0123456789ABCDEF0123456789ABCDEF
+
+A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D4 = BLADE RUNNER 2049
+"""
+
+
+def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
+ """Nur Zeilen mit 40 Hex-Zeichen und Gleichheitszeichen sind Eintraege.
+
+ Kommentare, Leerzeilen und Fortsetzungsfelder duerfen nicht mitzaehlen —
+ sonst meldet das UI bei einer reinen Kommentardatei stolz "42 Eintraege".
+ """
+ assert zaehle_disc_eintraege(BEISPIEL_KEYDB) == 2
+
+
+def test_zaehle_disc_eintraege_ignoriert_kommentare_und_leerzeilen():
+ # Eine Datei ganz ohne Disc-Zeile hat null Eintraege, nicht drei.
+ nur_beiwerk = "; nur ein Kommentar\n\n| V | 0123456789ABCDEF0123456789ABCDEF\n"
+ assert zaehle_disc_eintraege(nur_beiwerk) == 0
+
+
+def test_zaehle_disc_eintraege_ignoriert_zu_kurze_kennung():
+ """39 Hex-Zeichen sind keine Disc-Kennung.
+
+ Genau so sieht eine beim Kopieren verstuemmelte Datei aus — die darf nicht
+ als gueltig durchgehen, sonst sucht der Commander den Fehler beim Laufwerk.
+ """
+ zu_kurz = "A1B2C3D4E5F60718293A4B5C6D7E8F90A1B2C3D = KAPUTT\n"
+ assert zaehle_disc_eintraege(zu_kurz) == 0
+
+
+def test_keydb_pruefen_meldet_leere_datei():
+ # Haeufigster Fehlgriff: das Textfeld war leer, es wird trotzdem gespeichert.
+ assert keydb_pruefen("") != ""
+ assert keydb_pruefen(" \n\n ") != ""
+
+
+def test_keydb_pruefen_erkennt_html_fehlerseite():
+ """Der zweithaeufigste Fehlgriff: der Download lieferte eine HTML-Seite.
+
+ MakeMKV wuerde die Datei still ignorieren und weiter "volume key is unknown"
+ melden — deshalb muss der Fehler schon beim Hochladen sichtbar werden.
+ """
+ html = "\n
404 Not Found
\n"
+ meldung = keydb_pruefen(html)
+ assert meldung != ""
+ assert "HTML" in meldung
+
+
+def test_keydb_pruefen_meldet_text_ohne_disc_zeile():
+ # Irgendein Text (hier: eine README) ist keine KEYDB.cfg.
+ meldung = keydb_pruefen("Diese Datei enthaelt keine Schluessel, nur Prosa.\n")
+ assert meldung != ""
+
+
+def test_keydb_pruefen_akzeptiert_gueltigen_inhalt():
+ # Leerer Rueckgabewert heisst laut Vertrag: alles in Ordnung.
+ assert keydb_pruefen(BEISPIEL_KEYDB) == ""
+
+
+def test_ist_aacs_dump_akzeptiert_echten_namen():
+ """Name aus der Praxis: so legt MakeMKV den Dump laut Meldung 3332 ab
+ (am 25.07.2026 im Worker so beobachtet)."""
+ assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
+
+
+def test_ist_aacs_dump_lehnt_pfad_tricks_und_fremde_dateien_ab():
+ """Der Download-Endpunkt haengt den Namen an das Datenverzeichnis an —
+ ohne diese Pruefung koennte man sich damit aus dem Verzeichnis heraus
+ lesen. Versteckte Dateien und Nicht-Dumps sind ebenfalls nichts fuer die
+ Liste."""
+ assert ist_aacs_dump("../ausbruch.tgz") is False
+ assert ist_aacs_dump(".versteckt.tgz") is False
+ assert ist_aacs_dump("irgendwas.txt") is False
+ assert ist_aacs_dump("..\\windows\\ausbruch.tgz") is False
+
+
+def test_settings_conf_ersetzt_key_und_behaelt_den_rest():
+ """DIE Regression, um die es geht: bis zum 25.07.2026 haben entrypoint.sh
+ und tasks.py die settings.conf komplett ueberschrieben. Mit dem jetzt
+ persistenten Datenverzeichnis waere damit bei jedem Containerstart und vor
+ jedem Rip alles andere weg — allen voran app_UpdateEnable."""
+ alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\napp_DefaultSelectionString = "+sel:all"\n'
+ neu = settings_conf_zusammenfuehren(alt, "T-neuerSchluessel")
+ assert 'app_Key = "T-neuerSchluessel"' in neu
+ assert 'app_Key = "T-alterSchluessel"' not in neu
+ assert 'app_UpdateEnable = "1"' in neu
+ assert 'app_DefaultSelectionString = "+sel:all"' in neu
+ # Genau EINE app_Key-Zeile, sonst gewinnt am Ende die falsche.
+ assert neu.count("app_Key") == 1
+
+
+def test_settings_conf_leerer_key_entfernt_die_zeile():
+ # Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
+ alt = 'app_Key = "T-alterSchluessel"\napp_UpdateEnable = "1"\n'
+ neu = settings_conf_zusammenfuehren(alt, "")
+ assert "app_Key" not in neu
+ assert 'app_UpdateEnable = "1"' in neu
+
+
+def test_settings_conf_aus_dem_nichts_ergibt_saubere_datei():
+ """Erststart: die Datei gibt es noch gar nicht. Der abschliessende
+ Zeilenumbruch ist Absicht — MakeMKV liest die Datei zeilenweise."""
+ assert settings_conf_zusammenfuehren("", "T-neuerSchluessel") == 'app_Key = "T-neuerSchluessel"\n'
+
+
+def test_settings_conf_ohne_key_und_ohne_inhalt_bleibt_leer():
+ # Kein Inhalt, kein Key: keine Datei mit einer einsamen Leerzeile erzeugen.
+ assert settings_conf_zusammenfuehren("", "") == ""
diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py
index 8479a5d..d927a8c 100644
--- a/docker/worker/test_ripping_helpers.py
+++ b/docker/worker/test_ripping_helpers.py
@@ -13,6 +13,7 @@ from ripping import (
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
+ parse_msg,
write_abcde_config,
)
@@ -63,6 +64,60 @@ def test_prgv_parsing_ignoriert_fremde_zeilen():
assert get_progress_from_prgv("PRGV:kaputt") == -1
+def test_parse_msg_trennt_code_und_klartext():
+ """Echte Zeilen aus einem makemkvcon-Lauf vom 25.07.2026 (Akira UHD).
+
+ Feld 4 ist laut https://www.makemkv.com/developers/usage.txt der fertig
+ zusammengesetzte Klartext — genau der landet im Rippy-Log.
+ """
+ assert parse_msg(
+ 'MSG:1005,0,1,"MakeMKV v1.18.4 linux(x64-release) started","%1 started","MakeMKV v1.18.4 linux(x64-release)"'
+ ) == (1005, "MakeMKV v1.18.4 linux(x64-release) started")
+ assert parse_msg(
+ 'MSG:1011,0,1,"Using LibreDrive mode (v06.3 id=866A98CB9C4E)","%1","Using LibreDrive mode (v06.3 id=866A98CB9C4E)"'
+ ) == (1011, "Using LibreDrive mode (v06.3 id=866A98CB9C4E)")
+
+
+def test_parse_msg_liest_die_uhd_fehlermeldung():
+ """3303 ist der Befund, um den es beim ganzen KEYDB-Thema geht: das
+ Laufwerk laeuft im LibreDrive-Modus, MakeMKV kennt nur den Schluessel
+ DIESER Pressung nicht. Ohne diese Zeile im Log raet der Commander."""
+ assert parse_msg(
+ 'MSG:3303,16777216,0,"The volume key is unknown for this disc - video can\'t be decrypted","The volume key is unknown for this disc - video can\'t be decrypted"'
+ ) == (3303, "The volume key is unknown for this disc - video can't be decrypted")
+ assert parse_msg('MSG:5010,0,0,"Failed to open disc","Failed to open disc"') == (
+ 5010,
+ "Failed to open disc",
+ )
+
+
+def test_parse_msg_schneidet_meldungen_mit_komma_nicht_ab():
+ """DER Grund fuer die Regex (Stand 25.07.2026): vorher stand hier
+ line.split(",", 4)[3]. Das schnitt jede Meldung ab, die selbst ein Komma
+ enthaelt — und MakeMKV schreibt solche laufend. Im Log stand dann nur noch
+ ein Satzfragment, das mehr verwirrt als hilft."""
+ zeile = (
+ 'MSG:3025,0,3,"Title #1 has length of 12 seconds, which is less than '
+ 'minimum title length of 120 seconds and was therefore skipped",'
+ '"Title #%1 has length of %2 seconds which is less than minimum title '
+ 'length of %3 seconds and was therefore skipped","1","12","120"'
+ )
+ code, text = parse_msg(zeile)
+ assert code == 3025
+ assert text.endswith("and was therefore skipped")
+ assert "which is less than" in text
+
+
+def test_parse_msg_ignoriert_fremde_zeilen():
+ # Alles ausser MSG muss None liefern, sonst landet Fortschritts-Rauschen
+ # (PRGV kommt mehrmals pro Sekunde) als Log-Eintrag in der Datenbank.
+ assert parse_msg("PRGV:100,32768,65536") is None
+ assert parse_msg('DRV:0,2,999,12,"BD-RE ASUS BW-16D1HT","AKIRA","/dev/sr0"') is None
+ assert parse_msg("TCOUNT:5") is None
+ assert parse_msg("") is None
+ assert parse_msg("irgendwelcher Muell ohne Struktur") is None
+
+
def test_abcde_cmd_hat_genau_ein_ausgabeformat():
"""Review-Fund 22.07.: '-o' stand doppelt (Format UND Verzeichnis) — abcde
parste das Verzeichnis als Format, CD-Ripping war nie funktionsfähig."""