9 Commits

Author SHA1 Message Date
Hitonabi 5cd1876c90 docs(savepoint): Nachtrag - 4K-Transcode abgebrochen, zwei neue Befunde
Ampel / ampel (push) Successful in 48s
Der im Uebergabestand als "laeuft" beschriebene 4K-Lauf wurde um 18:31 vom
Commander abgebrochen. Damit war die Aussage im Block darueber ueberholt -
nachgezogen, statt sie stehen zu lassen.

Zwei Befunde, beide gemessen:

1. 4K-HEVC ist auf dieser CPU nicht machbar: 18:02 bis 18:31 ergab 1 Prozent,
   hochgerechnet rund 50 Stunden fuer den Film. Zu entscheiden: UHD gar nicht
   komprimieren (Roh-MKV behalten), Hardware-Encoder (Remote-Worker mit GPU),
   oder bewusst bei 1080p bleiben. Preset-je-Disc-Typ ist richtig, reicht
   allein aber nicht.

2. Abbruch wirkt verzoegert (FEHLER, nicht behoben): Job steht seit 18:31 auf
   "canceling", HandBrake lief um 18:35 immer noch. Der Abbruch wird nur in
   datei_fortschritt geprueft, und die Closure laeuft nur bei geaenderter
   Prozentzahl ("if gesamt == letzter[0]: return"). Bei 1 Prozent alle 30
   Minuten sieht "Abbrechen" bis zu eine halbe Stunde lang wirkungslos aus.
   Der Abbruch gehoert unabhaengig vom Fortschritt geprueft.

Ausserdem festgehalten: die 1080p-Fassung wurde von HandBrake beim Start auf
0 Bytes gekuerzt, im Akira-Ordner liegt derzeit ein unbrauchbares Fragment.
Der 75-GB-Rohschnitt ist unversehrt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:32:34 +02:00
Hitonabi b526a0a033 docs(savepoint): Uebergabestand v3.13 - gemessen vs. vermutet getrennt
Ampel / ampel (push) Successful in 46s
Uebergabe an eine neue Sitzung. Der Block trennt bewusst, was per Befehl auf
der VM geprueft wurde, von dem was offen bzw. nur erwartet ist - inklusive
zweier Fehlschluesse dieser Sitzung, damit die naechste Sitzung weiss, welchen
Aussagen sie nicht blind glauben soll.

Kern:
- Ein 4K-HandBrake-Lauf laeuft GERADE (Akira, Preset "H.265 MKV 2160p60 4K").
  Die vorherige 1080p-Datei wurde dabei auf 0 Bytes gekuerzt; der 75-GB-
  Rohschnitt liegt unversehrt in /app/temp/raw.
- progress=99 am Job ist ein Altwert aus dem Absturz, NICHT der echte Stand.
- Noch nicht bewiesen: dass _original_aufheben() im Ernstfall nur warnt statt
  die Platte vollzuschreiben. Genau dieser Pfad ist neu.
- Gefunden, nicht gebaut: Zombie-Erkennung. Nach dem Absturz stand der Job auf
  "transcoding", obwohl kein Prozess lief und beide Celery-Queues leer waren.
  _kann_neu_komprimieren verlangt status == "failed", deshalb fehlt dem Nutzer
  auch der "Neu komprimieren"-Knopf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:08:51 +02:00
Hitonabi 8bb075c656 feat(rip): Arbeitsverzeichnis je Rip waehlbar statt global vorgegeben
Ampel / ampel (push) Successful in 28s
Commander-Vorgabe 25.07.2026: "Du sollst es nicht automatisch setzen, es soll
auswaehlbar sein - z. B. beim Rippen starten, oder VORHER wenn Vollautomatik
eingeschaltet ist."

Bisher gab es nur die globale Einstellung workDir, und die war ein freies
Textfeld - man musste den Container-Pfad (/app/media/...) kennen. Beim
Akira-Rip landeten deshalb 74 GB Rohdaten auf der 148-GB-VM-Platte, obwohl
eine NAS-Freigabe mit 2,3 TB eingehaengt war.

- RipTargetModal: neue Auswahl "Arbeitsverzeichnis fuer die Rohdaten",
  gespeist aus /storage-targets (mit Kennzeichnung als Netzwerk-Freigabe und
  freiem Platz je Ziel). Leer = "Standard aus den Einstellungen", der Wert
  wird zur Orientierung mit angezeigt. Bei Musik ausgeblendet - CD-Rips gehen
  direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
- POST /jobs nimmt work_dir entgegen, mit derselben Pfad-Haerte wie das Ziel
  (_validiere_ziel: muss unter /app/media liegen), und legt es in die
  Job-Metadaten.
- _arbeitsverzeichnis(einstellungen, job_wahl) im Worker: Wahl dieses Rips ->
  Setting -> Container-Default. Damit bleibt die Einstellung genau das, was
  bei Vollautomatik-Rips greift, weil dort niemand gefragt wird. Der Text im
  Einstellungen-Tab sagt das jetzt auch so.
- posixpath statt os.path in _arbeitsverzeichnis: das sind immer
  Container-Pfade, auch wenn ein nativer Windows-Worker das Modul laedt (der
  uebersetzt erst spaeter per pfad_lokal). os.path.normpath machte unter
  Windows Backslashes daraus, wodurch die MEDIA_ROOT-Pruefung nicht mehr
  griff - lokal als Testfehler aufgefallen.
- Test deckt die Reihenfolge und die Ausbruchsversuche ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:59:37 +02:00
Hitonabi c065967f4d fix(ui,transcode): Dialog-Falle, Arbeitsverzeichnis waehlbar, Original-Aufheben entschaerft
Ampel / ampel (push) Successful in 28s
Drei Befunde aus dem ersten echten UHD-Durchlauf (Akira). Der Rip und die
Kompression liefen sauber durch - danach hat "Original behalten" die Platte
vollgeschrieben und den fertigen Job als fehlgeschlagen markiert.

1. ORIGINAL-AUFHEBEN DARF DEN JOB NICHT MEHR TOETEN (tasks.py)
   Vorher stand dort ein nacktes shutil.move(raw_dir, ziel). Arbeits-
   verzeichnis (/app/temp, Docker-Volume) und Ziel (/app/media, Bind-Mount)
   sind VERSCHIEDENE Dateisysteme - os.rename scheitert mit EXDEV, shutil.move
   faellt auf Kopieren zurueck. Ergebnis am 25.07.: 74-GB-Vollkopie auf
   dieselbe Platte, Abbruch bei 41 GB mit ENOSPC, Platte 100 % voll, Worker-
   Container startete nicht mehr ("failed to mount: no space left on device"),
   und der Job galt als FEHLGESCHLAGEN - obwohl die komprimierte Datei
   (4,8 GB) fertig und in Ordnung war. Der Nutzer sah nur eine leere Queue.
   Jetzt: _original_aufheben() prueft erst, ob ueberhaupt kopiert werden muss
   (gleiches Dateisystem -> reines Umhaengen), prueft sonst den freien Platz
   VORHER, faengt jeden OSError ab, raeumt eine halbe Kopie weg und meldet das
   als WARNUNG. Der Job bleibt erfolgreich, die Roh-Datei bleibt liegen.
   Zwei Tests decken beide Wege ab.

2. ARBEITSVERZEICHNIS IST JETZT WAEHLBAR (Settings.tsx)
   Es war ein freies Textfeld - man musste den Container-Pfad (/app/media/...)
   KENNEN, um eine Netzwerk-Freigabe zu treffen. Genau daran ist es
   gescheitert, weshalb der 74-GB-Rohschnitt ueberhaupt erst auf der VM-Platte
   landete. Jetzt eine Auswahl aus /storage-targets (dieselbe Liste wie bei
   den Speicherzielen), inklusive Kennzeichnung als Netzwerk-Freigabe und
   freiem Platz je Ziel.

3. DIALOGE KLEBTEN IM PANEL (ui/Modal.tsx)
   "Rippen starten" im Laufwerke-Tab oeffnete den Dialog INNERHALB des
   Bereichs, teils abgeschnitten. Ursache: .glass-panel in index.css setzt
   backdrop-filter: blur(16px), und ein Element mit backdrop-filter wird zum
   Bezugsrahmen fuer position: fixed seiner Nachfahren - das Modal war damit
   an der Card ausgerichtet statt am Fenster. Modal rendert jetzt per
   createPortal an document.body. Behebt es fuer ALLE Dialoge auf einmal.

Aufgeraeumt: die abgebrochene 41-GB-Teilkopie unter
"/app/media/movies/Akira (1988)/original/" geloescht (nachweislich
unvollstaendig - 41 GB gegen 79,6 GB Quelle, Quelle intakt). Platte wieder
bei 78 %, Container laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:48:10 +02:00
Hitonabi e84afc1718 feat(transcode): ein HandBrake-Preset je Disc-Typ statt eines fuer alles
Ampel / ampel (push) Successful in 29s
Rueckfrage des Commanders beim ersten echten UHD-Rip: "merkt Rippy, dass es
eine UHD ist, und nimmt direkt das 4K-Preset?" Antwort war nein. Die
Kompression fragte den Disc-Typ ueberhaupt nicht:

    preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET

Live eingestellt war "HQ 1080p30 Surround". Der gerade laufende Akira-Rip
waere also verlustfrei in 4K gerippt und danach auf 1080p heruntergerechnet
worden - und mit keepOriginal=False waere der 4K-Rohschnitt danach geloescht
worden. Umgekehrt wurde eine DVD auf 1080p hochskaliert, was nichts bringt.

- preset_fuer(disc_type, einstellungen) in ripping.py, pure und getestet.
  Reihenfolge: Preset des Disc-Typs -> allgemeines transcodePreset ->
  DEFAULT_HB_PRESET. Bestandsinstallationen aendern ihr Verhalten NICHT,
  solange die neuen Felder nicht gespeichert sind.
- Drei Einstellungen: transcodePresetDvd / transcodePresetBluray /
  transcodePresetUhd. transcodePreset bleibt als Rueckfall bestehen.
- transcode_files holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
  mit ins Log ("Disc-Typ 'uhd', Preset '...'").
- UI: drei Auswahlfelder statt einem, mit Klartext dazu, warum eine 4K-UHD
  auf ein 2160p-Preset gehoert.

Preset-Namen stammen aus "HandBrakeCLI --preset-list" im Worker-Image
(HandBrake 1.6.1) - nicht aus dem Kopf (AGENTS Regel D):
H.265 MKV 2160p60 4K, HQ 2160p60 4K HEVC Surround,
Super HQ 2160p60 4K HEVC Surround, H.265 MKV 1080p30, HQ 1080p30 Surround,
Super HQ 1080p30 Surround, H.265 MKV 576p25, H.265 MKV 480p30,
HQ 576p25 Surround.

Sofortmassnahme am laufenden Job (auf Ansage des Commanders): keepOriginal
auf True gesetzt - nur dieses eine Feld, gegengeprueft dass kein anderer
Schluessel veraendert wurde. Damit ueberlebt der 4K-Rohschnitt die
Kompression.

ACHTUNG - Deploy bewusst NICHT ausgefuehrt: docker compose up -d --build
wuerde den Worker-Container neu erstellen und den laufenden Akira-Rip
abbrechen. Erst nach Abschluss des Jobs deployen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:38:52 +02:00
Hitonabi f449c4ee34 fix(uhd): 4K-UHD geloest - makemkvcon holt Schluessel unter Linux nie
Ampel / ampel (push) Successful in 28s
Richtigstellung des Vortags-Befunds. Dort stand, MakeMKVs Schluessel-Kanal
sei abgeschaltet. Das war FALSCH: die Herleitung stuetzte sich auf zwei
Hostnamen aus alten Forumsbeitraegen (hkdata.fairuse.org,
hkdata.crabdance.com), die zwar wirklich nicht mehr aufloesen, von MakeMKV
aber laengst nicht mehr benutzt werden. Aufgedeckt durch den Einwand des
Commanders, unter Windows ginge es sofort.

Gegenprobe mit demselben Laufwerk und derselben Disc (Akira UHD, MKB v76):

                        Linux (Worker)      Windows
  Verbindungen          KEINE EINZIGE       185.84.108.20:443
  Meldung 3338          nie                 "Downloading latest HK"
  _private_data.tar     2048 B, 0 Keys      6,4 MB, 604 Keys
  Disc                  volume key unknown  TCOUNT:5, geht auf

Gegengeprueft mit leerem UND gefuelltem Speicher, mit und ohne --noscan,
mit dev:/dev/sr0 und disc:0, mit geloeschter update.conf. Linux fragt nie.
Die Meldungsvorlage "Downloading latest %1 to %2 ..." steckt sehr wohl im
Linux-Binary - sie loest nur nicht aus. Gleiches Symptom im MakeMKV-Forum,
seit Jahren offen (t=25782, t=34022). Der Dienst lebt; der Worker erreicht
185.84.108.20:443 sogar problemlos.

BEWIESEN: Nach Uebernahme des Windows-Schluesselspeichers oeffnet
makemkvcon auf der VM die Akira-UHD - "Operation successfully completed",
TCOUNT:5, fuenf Titel, identisch zum Windows-Ergebnis. Erster belegter
UHD-Disc-Zugriff auf der Rippy-Maschine.

- makemkv_daten.py (beide Zwillinge): zaehle_schluessel,
  private_data_pruefen, schluesselspeicher_status, private_data_schreiben.
  Die Pruefung lehnt einen Speicher OHNE hkd_*.bin ab - sonst laedt jemand
  den leeren Vorrat einer frischen Installation hoch, nichts aendert sich,
  und niemand versteht warum. Modulkopf komplett neu, inkl. der
  Fehldiagnose als Warnung fuer spaeter.
- API: GET/POST /system/keystore. Der Rohkoerper der Anfrage IST die Datei
  (binaer - JSON/Base64 waere Ballast, Multipart kann die API nicht).
  Groessengrenze 64 MB = client_max_body_size in nginx.conf.
- UI: neuer Block "Disc-Schluessel fuer 4K-UHD" UEBER dem KEYDB-Block, mit
  Schluessel-Anzahl, Upload und Anleitung fuer den Windows-Weg. KEYDB.cfg
  ist jetzt als Notnagel beschriftet. Worker-Plakette zeigt die Anzahl;
  0 heisst sichtbar "4K-UHD scheitert".
- tasks.py: UHD-Fehlertext sagt den Windows-Weg an und nennt die Anzahl
  bekannter Schluessel dieses Workers.
- caps.py meldet schluessel je Worker.
- Alle Falschaussagen korrigiert: UI (3), Anleitung (2), README (3),
  KONZEPT §8 + §10, Worker-Dockerfile, makemkv_key.py (dort stand "Den
  AACS-Schluessel zieht MakeMKV via LibreDrive ohnehin selbst aus dem
  Laufwerk" - gilt fuer Blu-ray, NICHT fuer UHD).
- SAVEPOINT v3.11, ROADMAP Etappe 18 (Etappe 17 mit Nachtrag), AGENTS.

Offen: voller UHD-Rip inkl. Transcode-E2E; und ob sich der Abruf unter
Linux doch anstossen laesst.

Quellen (AGENTS Regel D):
- Linux laedt keine Hashed Keys, gleiches Symptom:
  https://forum.makemkv.com/forum/viewtopic.php?t=25782
  https://forum.makemkv.com/forum/viewtopic.php?t=34022
- Schluessel als hkd_*.bin in _private_data.tar:
  https://forum.makemkv.com/forum/viewtopic.php?t=32675
- Meldungsformat: https://www.makemkv.com/developers/usage.txt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:18:09 +02:00
Hitonabi 0935766f61 feat(uhd): KEYDB.cfg-Unterstuetzung - MakeMKVs Schluessel-Kanal liefert nichts mehr
Ampel / ampel (push) Successful in 28s
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 <noreply@anthropic.com>
2026-07-25 01:26:08 +02:00
Hitonabi f74f4e54f6 fix(api): Job-Meta (poster_path) an die Jobliste durchreichen
Ampel / ampel (push) Successful in 32s
GET /jobs nutzt response_model=List[Job]; das Job-Model hatte kein meta-Feld,
also schnitt FastAPI die Disc-Metadaten (poster_path) weg -> Dashboard.tsx
bekam job.meta = undefined -> Filmstreifen-Platzhalter statt TMDB-Poster,
sowohl in der Jobliste als auch im aktiven Rip-Header (beide aus /jobs).

Additiv: meta: Optional[Dict] ins Job-Model + in _job_row_to_model parsen
(json.loads wie im Detail-Endpunkt, defensiv gegen kaputtes JSON). Keine
UI-Aenderung noetig -- posterUrl() rendert dann die vorhandenen Poster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:03:41 +02:00
Hitonabi 0832ef777c chore(handoff): portabel machen + Doku/Onboarding glaetten (Review-Umsetzung)
Ampel / ampel (push) Successful in 27s
Aus dem Weitergabe-Review. Kein Verhaltenswechsel fuer den Ursprungs-Host
(alle Defaults = bisheriges Verhalten):

Portabilitaet:
- Laufwerk-Geraeteknoten via .env parametrisiert (OPTICAL_SR/OPTICAL_SG,
  Defaults sr0/sg1) - der #1-Blocker: auf Fremdhosts liegt das sg-Node woanders.
- deploy.sh generisch (RIPPY_VM/RIPPY_REPO_URL/... aus der Umgebung) - keine
  festen Besitzer-Adressen (interne IP + DDNS) mehr im Repo.
- POSTGRES_PASSWORD in compose durchverdrahtet (Default rippy) - war No-op.

Aufraeumen (toter/irrefuehrender Code):
- udev/ entfernt (fehlende Regeldatei, falscher Container-Name; durch ioctl-
  Polling ersetzt - reine Altlast).
- docker/postgres/*, docker/redis/*, init.sql entfernt (nie eingebunden).

Onboarding:
- FirstRunWizard verdrahtet: App.tsx fragt GET /setup und zeigt den
  Einrichtungs-Assistenten beim ersten Start (war gebaut, aber nie gerendert).

Doku:
- README: Laufwerk-Abschnitt auf .env, ENV-Tabelle (OPTICAL_*/POSTGRES),
  rshared-Anleitung, neuer Abschnitt "Haertung fuer fremde/exponierte Netze".
- config_validation.py: TMDB Pflicht->empfohlen, Zeichensalat-Tippfehler gefixt.
- .env.example: TMDB-Wording, OPTICAL_*-Variablen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:32:59 +02:00
39 changed files with 3091 additions and 504 deletions
+21 -3
View File
@@ -1,14 +1,25 @@
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
POSTGRES_PASSWORD=rippy
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
# generischen sg-Knoten des Laufwerks. Die sg-Nummer variiert je Host — auf DIESER
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
OPTICAL_SR=/dev/sr0
OPTICAL_SG=/dev/sg1
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.)
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
# Metadaten-APIs
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
# später im UI setzbar): kostenlos auf themoviedb.org
TMDB_API_KEY=
# TVDb (optional, Serien-Fallback)
THETVDB_API_KEY=
@@ -22,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
+6 -1
View File
@@ -55,10 +55,15 @@ 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
-**Etappe 18 (v3.11):** 4K-UHD gelöst — `makemkvcon` holt Schlüssel
unter Linux nie, unter Windows schon; Schlüsselspeicher übernehmbar.
Akira-UHD geht auf der VM auf (`TCOUNT:5`, bewiesen)
- 📝 **Details immer in SAVEPOINT.md** — diese Sektion nennt nur die Etappe
+18
View File
@@ -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)** | **Gelöst mit Handgriff** | Am 25.07.2026 auf beiden Maschinen gemessen: `makemkvcon` unter **Linux** ruft Disc-Schlüssel nie ab (kein einziger Verbindungsversuch, mit leerem wie gefülltem Speicher, mit und ohne `--noscan`, `dev:` wie `disc:`), die **Windows**-Version tut es (Meldung 3338). Rippy stellt ein persistentes Datenverzeichnis bereit und nimmt den Schlüsselspeicher `_private_data.tar` einer Windows-Installation sowie ersatzweise eine `KEYDB.cfg` entgegen; damit ging Akira UHD auf der VM auf. Rippy 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,23 @@ 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
BEIDEN Maschinen nachgemessen — `makemkvcon` unter Linux ruft Disc-Schlüssel
nie ab, die Windows-Version tut es. (Erste Fassung dieses Eintrags behauptete,
MakeMKVs Schlüssel-Kanal sei abgeschaltet; das war falsch und wurde am selben
Tag richtiggestellt.) Rippy nimmt deshalb den Schlüsselspeicher
`_private_data.tar` einer MakeMKV-Installation entgegen und ersatzweise eine
`KEYDB.cfg`. **Rippy liefert und verteilt KEINE Disc-Schlüssel und
lädt auch keine herunter** — es hält nur den Platz für Dateien 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 <Serie>/Season NN plus
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
+128 -20
View File
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
### Laufwerk anpassen
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
`docker-compose.override.yml` an:
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
(auf frischen Systemen oft `/dev/sg0`).
```yaml
services:
api:
devices: ["/dev/sr1:/dev/sr0"]
worker:
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
```bash
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
# oder: ls -la /sys/class/scsi_generic/
```
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
`ls -la /sys/class/scsi_generic/`.
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
```bash
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
```
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
@@ -60,7 +63,13 @@ NICHT als emuliertes CD-ROM (`media=cdrom`), das kann keine SCSI-Kommandos.
per Laufzeitabgleich (TMDB) automatisch zu „Serie S01E02.mkv" benannt
(nur bei eindeutiger Zuordnung — sonst bleiben die Namen, mit Log).
3. **Kompression** (HandBrake, eigener Job auf eigener Queue): x265/x264,
Preset im UI wählbar; Rohdatei wird erst nach Erfolg gelöscht
**je Disc-Typ ein eigenes Preset** (Einstellungen → Verarbeitung) —
Rippy kennt den Typ und wählt selbst. Wichtig, weil ein 1080p-Preset
eine 4K-UHD stillschweigend herunterrechnet und ein 1080p-Preset eine
DVD sinnlos hochskaliert. Sind die drei Felder nie gespeichert worden,
gilt weiter das alte allgemeine Preset — Bestandsinstallationen ändern
ihr Verhalten also nicht von selbst.
Rohdatei wird erst nach Erfolg gelöscht
(„Original behalten" als Option). Fehlgeschlagene Kompressionen lassen
sich ohne Neu-Rip neu anstoßen.
4. **Media-Server-Ablage**: Unter Einstellungen → Ripping (oder im
@@ -71,9 +80,34 @@ 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 — und zwar aus einem
überraschenden Grund.** Am 25.07.2026 auf beiden Maschinen 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".
Die Ursache:
**`makemkvcon` unter Linux holt Disc-Schlüssel nie aus dem Netz. Die
Windows-Version tut es.**
Gemessen: Linux baute in keinem einzigen Lauf eine Verbindung nach
draußen auf — geprüft mit leerem und mit gefülltem Schlüsselspeicher,
mit und ohne `--noscan`, mit `dev:` und `disc:`, und mit erzwungener
frischer Prüfung. Dieselbe Disc am selben Laufwerk unter Windows: „Lade
aktuelle HK …", Verbindung zum Schlüssel-Server, Disc geht auf.
Ein MakeMKV-Update ändert daran nichts, und es ist kein Fehler in
Rippy.
**Der Weg drumherum:** MakeMKV einmal auf einem Windows-PC die Disc
öffnen lassen und die dabei gefüllte Datei `_private_data.tar` bei
Rippy unter **Einstellungen → System** hochladen (siehe unten). Für
Pressungen, die auch MakeMKV nicht kennt, bleibt die **`KEYDB.cfg`**
als Notnagel.
⚠️ **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
@@ -103,7 +137,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
@@ -111,11 +145,47 @@ 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 für 4K-UHD** — im selben Tab, nur für UHD nötig. Der
Hauptweg zuerst:
- **Schlüsselspeicher (`_private_data.tar`)**: Rippy zeigt, **wie viele
Disc-Schlüssel** dieser Worker kennt. Steht dort 0, scheitert jede
unbekannte UHD-Disc.
**So füllst du ihn:** MakeMKV auf einem Windows-PC installieren
(gleicher Beta-Key), Laufwerk anstecken, Disc einmal öffnen — dabei lädt
MakeMKV die Schlüssel nach. Dann in MakeMKV unter *Preferences →
General* das „MakeMKV data directory" nachschlagen und die Datei
`_private_data.tar` daraus hier hochladen. Wirkt ab dem nächsten Rip;
für neue Discs gelegentlich wiederholen.
Rippy prüft die Datei und lehnt sie ab, wenn kein einziger Schlüssel
drinsteckt — sonst ändert sich nichts und niemand versteht, warum.
- **`KEYDB.cfg`** (Notnagel): nur nötig, wenn eine Pressung auch mit
gefülltem Speicher nicht aufgeht, MakeMKV sie also selbst nicht kennt.
Hochladen, Status und Entfernen genau wie oben.
- **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**, der Schlüsselspeicher
und 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 Dateien einen
`docker compose build` überleben. Der Schlüsselspeicher ist der
Zwischenspeicher **deiner eigenen** MakeMKV-Installation.
**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 — MakeMKV liefert überhaupt keine Disc-Schlüssel mit, es
holt sie zur Laufzeit. Und die Linux-Version holt sie nie. Ein Update
lohnt für Programmfehler und neue Laufwerke, hilft aber NICHT gegen „The
volume key is unknown". Dafür brauchst du den Schlüsselspeicher (siehe
oben).
HandBrake kommt im Docker-Worker bewusst aus Debian (stabil, hinkt der
offiziellen Version hinterher); der native Windows-Worker nutzt die
aktuelle Version direkt.
@@ -150,6 +220,10 @@ 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 dein Schlüsselspeicher `_private_data.tar`, eine etwaige `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 |
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
@@ -165,7 +239,22 @@ Voraussetzungen auf dem Ziel-Host:
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
`docker-compose.yml` anpassen — muss ein rshared-fähiger Bind sein).
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
```bash
sudo mount --bind /srv/rippy/media /srv/rippy/media
sudo mount --make-rshared /srv/rippy/media
```
(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 —
dein Schlüsselspeicher (`_private_data.tar`) sowie eine etwaige
`KEYDB.cfg`. Das Verzeichnis gehört dem Host, damit ein
`docker compose build` deine Dateien nicht wegwirft.
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
@@ -175,6 +264,25 @@ docker compose up -d --build`.
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
### Härtung für fremde/exponierte Netze
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
nicht auf `0.0.0.0` binden.
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
den DB-Port nie offen ins unsichere Netz zu hängen.
## Entwicklung
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
+156
View File
@@ -414,6 +414,162 @@ 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.
> ⚠️ **Nachtrag am selben Tag (siehe Etappe 18): Die letzte Folgerung war
> falsch.** Die beiden toten Hostnamen stammen aus alten Forumsbeiträgen und
> werden von MakeMKV längst nicht mehr benutzt — der Dienst lebt. Richtig
> ist: `makemkvcon` unter **Linux** fragt nie nach Schlüsseln, die
> **Windows**-Version schon. Alles hier Gebaute bleibt richtig und nötig,
> nur die `KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
**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.
**Nachtrag zu „Offen":** Beide Punkte sind mit Etappe 18 erledigt — Akira
ging auf der VM auf, allerdings über den Schlüsselspeicher statt über eine
`KEYDB.cfg`.
**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.
---
## Etappe 18 (25.07.2026): 4K-UHD gelöst — Schlüsselspeicher statt KEYDB.cfg
**Quelle:** Einwand des Commanders („wenn ich MakeMKV lokal auf meinem
Windows-PC installiere, würde es SOFORT gehen — wir übersehen etwas
Gewaltiges"). Er hatte recht. Gegenprobe mit demselben Laufwerk und
derselben Disc an einem Windows-PC:
| | Linux (Worker) | Windows |
|---|---|---|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
| Meldung 3338 „Downloading latest HK" | nie | ja |
| `_private_data.tar` | 2048 B, 0 Schlüssel | 6,4 MB, 604 Schlüssel |
| Disc | „volume key is unknown" | **TCOUNT:5, geht auf** |
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`: Linux
fragt nie. Die Meldungsvorlage „Downloading latest %1 to %2 ..." steckt
sehr wohl im Linux-Binary — sie löst nur nicht aus. Gleiches Symptom im
MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
**Damit ist die Diagnose aus Etappe 17 („der Schlüssel-Kanal ist tot")
widerlegt.** Sie stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen,
die MakeMKV längst nicht mehr benutzt.
**Gebaut:**
- [x] **Schlüsselspeicher im Modul**: `zaehle_schluessel`,
`private_data_pruefen`, `schluesselspeicher_status`,
`private_data_schreiben` in `makemkv_daten.py` (beide Zwillinge).
Die Prüfung lehnt einen Speicher OHNE `hkd_*.bin` ab — sonst lädt
jemand den leeren Vorrat einer frischen Installation hoch, es ändert
sich nichts, und niemand versteht warum.
- [x] **API**: `GET /system/keystore` und `POST /system/keystore`. Der
Rohkörper der Anfrage IST die Datei — binär, deshalb kein JSON und
kein Base64; Multipart kann die API ohnehin nicht.
- [x] **UI**: neuer Block „Disc-Schlüssel für 4K-UHD" ÜBER dem
KEYDB-Block, mit Schlüssel-Anzahl, Upload und Schritt-für-Schritt-
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als Notnagel
beschriftet. Worker-Plakette zeigt die Schlüssel-Anzahl.
- [x] **Fehlertext** in `tasks.py` sagt den Windows-Weg an und nennt die
Anzahl bekannter Schlüssel dieses Workers.
- [x] **`caps.py`** meldet `schluessel` je Worker.
- [x] **Alle Falschaussagen korrigiert**: UI (3 Stellen), Anleitung (2),
README (3), KONZEPT §8 + §10, `makemkv_daten.py`-Modulkopf,
Worker-Dockerfile, `makemkv_key.py`.
**Bewiesen:** Nach Übernahme des Windows-Schlüsselspeichers öffnet
`makemkvcon` auf der VM die Akira-UHD — „Operation successfully
completed", `TCOUNT:5`, fünf Titel, identisch zum Windows-Ergebnis.
**Das ist der erste belegte UHD-Disc-Zugriff auf der Rippy-Maschine.**
**Offen aus dieser Runde:**
- [ ] Voller UHD-Rip inklusive Transcode-E2E (nur der Disc-Zugriff ist
belegt, nicht die komplette Kette bis zur fertigen Datei).
- [ ] Ob sich der Abruf unter Linux doch anstoßen lässt, ist ungeklärt —
der Code ist im Binary vorhanden. Bis dahin bleibt der Windows-Umweg.
---
## Etappe 19 (25.07.2026): Preset je Disc-Typ
**Quelle:** Rückfrage des Commanders beim ersten echten UHD-Rip („merkt
Rippy eigentlich, wenn es eine UHD-Disc ist, und wendet direkt das
4K-Preset an?"). Antwort war: nein. Die Kompression fragte den Disc-Typ
gar nicht — ein globales `transcodePreset` galt für alles, live eingestellt
`HQ 1080p30 Surround`. Der laufende 4K-Rip wäre danach auf 1080p
heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
**Gebaut:**
- [x] **`preset_fuer(disc_type, einstellungen)`** in `ripping.py` — pure
Funktion, drei Tests. Reihenfolge: Preset des Disc-Typs → allgemeines
`transcodePreset``DEFAULT_HB_PRESET`. Bestandsinstallationen
ändern ihr Verhalten NICHT, solange die neuen Felder ungespeichert sind.
- [x] **`transcode_files`** holt den Disc-Typ aus dem Job-Datensatz und
schreibt ihn mit ins Log („Disc-Typ 'uhd', Preset '…'").
- [x] **UI**: drei Auswahlfelder (DVD / Blu-ray / 4K-UHD) statt einem, mit
Erklärung, warum 4K auf ein 2160p-Preset gehört. Preset-Namen aus
`HandBrakeCLI --preset-list` im Worker-Image belegt, nicht geraten.
- [x] **Sofortmaßnahme am laufenden Job**: `keepOriginal` auf `True`, damit
der 4K-Rohschnitt die Kompression überlebt.
**Offen aus dieser Runde:**
- [ ] **Deploy steht aus** — er würde den laufenden Akira-Rip abbrechen.
Erst nach Abschluss des Jobs deployen, dann bei Bedarf „Neu
komprimieren" mit dem 4K-Preset.
---
## Ideen-Katalog (Rest) — bewusst offen
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
+263 -6
View File
@@ -1,6 +1,259 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v3.9Windows-Installer als echte .exe (Icon, kein Konsolenfenster) (24.07.2026)
## Aktueller Stand: v3.13ÜBERGABE (25.07.2026, 18:05)
> Dieser Block ist eine Übergabe an die nächste Sitzung. Er trennt bewusst
> **gemessen** von **vermutet** — in dieser Sitzung wurden zwei Behauptungen
> aufgestellt, die sich als falsch erwiesen (siehe „Vertrauensnotiz" unten).
> Im Zweifel: selbst nachmessen, nicht diesem Dokument glauben.
### GEMESSEN, Stand 18:05 (alles per Befehl auf der VM geprüft)
- **Container:** api/postgres/redis/ui/worker alle `Up`, api + postgres +
redis `healthy`.
- **Platte:** `/dev/sda2` 148 G, 105 G belegt, **37 G frei** (75 %).
- **Läuft gerade:** `HandBrakeCLI --input
/app/temp/raw/73b89777-…/title_t00.mkv --output
"/app/media/movies/Akira (1988)/title_t00.mkv" --preset
"H.265 MKV 2160p60 4K" --all-audio --all-subtitles`
- **Job `73b89777-a808-426f-8159-d406d27d0ec8`:** `status='transcoding'`,
`progress=99` (⚠️ **Altwert aus dem Absturz, NICHT der echte Fortschritt** —
der Lauf hat um 18:02 begonnen), `disc_type='uhd'`,
`output_path='/app/media/movies/Akira (1988)'`.
- **Dateien:** `…/Akira (1988)/title_t00.mkv` ist **0 Bytes** (HandBrake hat
die vorherige 1080p-Fassung beim Start gekürzt und schreibt gerade neu).
Roh-Rip `/app/temp/raw/73b89777-…/` = **75 GB, unversehrt.**
- **Git:** `8bb075c` ist HEAD und deployt. Davor `c065967`, `e84afc1`,
`f449c4e`, `0935766`. Alle Ampeln waren grün.
- **Schlüsselspeicher:** 604 Disc-Schlüssel, `/system/keystore` antwortet.
- **NAS:** `//192.168.178.62/rippy` auf `/srv/rippy/media/rippy` gemountet,
erreichbar, **2311 GB frei**. Wird als Ablageziel gelistet.
### NACHTRAG 18:35 — der 4K-Lauf wurde abgebrochen, zwei neue Befunde
- **4K-HEVC ist auf dieser CPU nicht machbar.** Gemessen: von 18:02 bis 18:31
kam der Lauf von 0 auf **1 %** → hochgerechnet **~50 Stunden** für den Film.
Der Commander hat um 18:31 abgebrochen. **Konsequenz, die noch zu
entscheiden ist:** entweder UHD gar nicht komprimieren (Roh-MKV behalten,
`transcodeEnabled` für UHD aus), oder ein Hardware-Encoder (Remote-Worker
mit GPU, `nvenc`/`vaapi`), oder bewusst bei 1080p bleiben. Das eben
gebaute Preset-je-Disc-Typ ist damit richtig, aber allein nicht genug.
- **Abbruch wirkt verzögert (FEHLER, nicht gebaut).** Job steht seit 18:31 auf
`canceling`, **HandBrake lief um 18:35 immer noch**. Ursache: Der Abbruch
wird nur in `datei_fortschritt` geprüft, und diese Closure läuft nur, wenn
sich die PROZENTZAHL ändert (`tasks.py`, `if gesamt == letzter[0]: return`).
Bei 1 % alle 30 Minuten heißt das: bis zu eine halbe Stunde, in der
„Abbrechen" für den Nutzer wirkungslos aussieht. Der Abbruch muss unabhängig
vom Fortschritt geprüft werden (z. B. zeitgesteuert beim Lesen der
HandBrake-Ausgabe).
- **Die 1080p-Fassung ist weg.** HandBrake hat sie beim Start auf 0 Bytes
gekürzt; aktuell liegen dort ~64 MB 4K-Fragment. Der **75-GB-Rohschnitt ist
unversehrt** — es ist also nichts unwiederbringlich verloren, aber im
Akira-Ordner liegt gerade eine unbrauchbare Datei.
### WAS ALS NÄCHSTES ANSTEHT
1. **Ausgang des 4K-Laufs prüfen.** Erwartung, die noch NICHT bewiesen ist:
Der Job endet **erfolgreich**, und „Original behalten" (Setting steht auf
`true`) meldet nur eine **Warnung**, weil 75 GB nicht neben 37 GB freien
Platz passen. Genau dieser Pfad ist neu (`_original_aufheben` in
`tasks.py`) und in der Praxis noch nie gelaufen. Wenn er hält, ist der
Ausfall von heute Mittag strukturell behoben.
2. **Zombie-Erkennung fehlt (gefunden, NICHT gebaut).** Nach dem Absturz stand
der Job auf `transcoding 96 %`, obwohl weder ein Prozess lief noch etwas in
den Celery-Queues stand (beide `llen` = 0). Folge: keine Anzeige, kein
Download, und der „Neu komprimieren"-Knopf fehlt, weil
`_kann_neu_komprimieren` (main.py) `status == "failed"` verlangt. Der
Worker sollte beim Start solche Leichen erkennen und ehrlich auf `failed`
setzen.
3. **`keepOriginal` steht auf `true`** — von dieser Sitzung gesetzt, um den
4K-Rohschnitt zu retten. Bewusst entscheiden, ob das so bleibt: mit
Arbeitsverzeichnis auf der NAS-Freigabe wäre es ein reines Umhängen statt
einer Vollkopie.
4. **75 GB Rohschnitt** liegen weiter in `/app/temp/raw/73b89777-…`. Nach
erfolgreichem 4K-Lauf entscheiden, ob er weg kann.
### VERTRAUENSNOTIZ — zwei Fehlschlüsse dieser Sitzung
- **„MakeMKVs Schlüssel-Kanal ist abgeschaltet"** — falsch. Stützte sich auf
zwei tote Hostnamen aus alten Forumsbeiträgen, die MakeMKV längst nicht mehr
benutzt. Richtig: `makemkvcon` unter **Linux** fragt nie, die
**Windows**-Version schon. Korrigiert in v3.11.
- **„Celery hat die Aufgabe nach dem Deploy erneut zugestellt"** — falsch.
Aus dem Status `transcoding` geraten, ohne Prozesse oder Queues zu prüfen.
Es war ein Zombie-Eintrag (Punkt 2 oben).
Beide Male war das Muster dasselbe: **aus einem Zustandswert auf einen
Mechanismus geschlossen, statt den Mechanismus zu messen.**
---
## Vorheriger Stand: v3.12 — Preset je Disc-Typ (25.07.2026)
- **Der Fund:** Beim ersten echten UHD-Rip aufgefallen — die Kompression
fragte den Disc-Typ **gar nicht**: `preset = einstellungen.get(
"transcodePreset")`, ein globales Preset für alles. Live eingestellt war
`HQ 1080p30 Surround`. Der laufende Akira-Rip wäre also verlustfrei in 4K
gerippt und danach **auf 1080p heruntergerechnet** worden — und mit
`keepOriginal: False` wäre der 4K-Rohschnitt anschließend gelöscht worden.
Aufgefallen ist es nur, weil der Commander gefragt hat, ob Rippy das
UHD-Preset automatisch nimmt.
- **Sofortmaßnahme am laufenden Job:** `keepOriginal` auf `True` gesetzt
(nur dieses eine Feld, gegengeprüft: kein anderer Schlüssel verändert).
Damit überlebt der 4K-Rohschnitt die Kompression auf jeden Fall.
- **Gebaut:** `preset_fuer(disc_type, einstellungen)` in `ripping.py` (pure,
getestet) plus drei Einstellungen `transcodePresetDvd` / `…Bluray` /
`…Uhd`. Reihenfolge: Preset des Disc-Typs → allgemeines
`transcodePreset` → `DEFAULT_HB_PRESET`. **Bestandsinstallationen ändern
ihr Verhalten nicht**, solange die neuen Felder nicht gespeichert sind.
`transcode_files` holt den Disc-Typ aus dem Job-Datensatz und schreibt ihn
mit ins Log.
- **UI (Einstellungen → Verarbeitung):** drei Auswahlfelder statt einem, mit
Klartext dazu, warum 4K auf ein 2160p-Preset gehört. Alle Preset-Namen
stammen aus `HandBrakeCLI --preset-list` im Worker-Image (1.6.1) — nicht
geraten (AGENTS Regel D).
- **⚠️ Deploy bewusst zurückgehalten:** `docker compose up -d --build`
würde den Worker-Container neu erstellen und den **laufenden Akira-Rip
abbrechen**. Erst deployen, wenn der Job durch ist. Der 4K-Rohschnitt ist
durch `keepOriginal` geschützt; danach reicht „Neu komprimieren" im UI,
um mit dem richtigen Preset in 4K zu komprimieren.
- **Nebenbefund:** `ps` gibt es im Worker-Image nicht (python-slim). Frühere
Prüfungen auf laufende Rips per `ps | grep` lieferten deshalb still
„nichts aktiv" — richtig geht es über `/proc`.
---
## Vorheriger Stand: v3.11 — 4K-UHD GELÖST: Akira geht auf (25.07.2026)
- **🎉 Der Durchbruch:** Nach Übernahme des Schlüsselspeichers öffnet
`makemkvcon` auf der VM die Akira-UHD: „Operation successfully
completed", **`TCOUNT:5`**, fünf Titel — identisch zum Windows-Ergebnis.
**Erster belegter UHD-Disc-Zugriff auf der Rippy-Maschine.**
- **Die echte Ursache — und sie ist eine andere als in v3.10:**
`makemkvcon` unter **Linux** ruft Disc-Schlüssel **nie** ab. Die
**Windows**-Version tut es. Gegenprobe mit demselben Laufwerk und
derselben Disc:
| | Linux (Worker) | Windows |
|---|---|---|
| Verbindungen beim Disc-Öffnen | **keine einzige** | 185.84.108.20:443 |
| Meldung 3338 „Downloading latest HK" | nie | ja |
| `_private_data.tar` | 2048 B, **0** Schlüssel | 6,4 MB, **604** Schlüssel |
| Disc | „volume key is unknown" | **geht auf** |
Gegengeprüft mit leerem UND gefülltem Speicher, mit und ohne `--noscan`,
mit `dev:/dev/sr0` und `disc:0`, mit gelöschter `update.conf`. Immer:
keine Verbindung. Die Meldungsvorlage „Downloading latest %1 to %2 ..."
steckt sehr wohl im Linux-Binary — sie löst nur nie aus. Gleiches
Symptom im MakeMKV-Forum, seit Jahren offen (t=25782, t=34022).
- **⚠️ Richtigstellung zu v3.10 (direkt darunter):** Dort steht, MakeMKVs
Schlüssel-Kanal sei abgeschaltet. **Das war falsch.** Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeiträgen
(`hkdata.fairuse.org`, `hkdata.crabdance.com`), die tatsächlich nicht
mehr auflösen — MakeMKV benutzt sie aber längst nicht mehr. **Der Dienst
lebt, der Worker erreicht ihn sogar** (Verbindungstest auf
185.84.108.20:443 erfolgreich); er wird unter Linux nur nie gefragt.
Aufgedeckt hat das der Commander mit dem Einwand, unter Windows ginge es
sofort. Alles in v3.10 Gebaute bleibt richtig und nötig — nur die
`KEYDB.cfg` ist nicht der Haupt-, sondern der Ersatzweg.
- **Gebaut — Schlüsselspeicher übernehmbar:** `GET`/`POST
/system/keystore` plus die Helfer in `makemkv_daten.py` (beide
Zwillinge). Der Rohkörper der Anfrage IST die Datei — binär, deshalb
kein JSON und kein Base64. Die Prüfung lehnt einen Speicher **ohne**
`hkd_*.bin` ab, sonst lädt jemand den leeren Vorrat einer frischen
Installation hoch und wundert sich, dass nichts passiert.
- **Gebaut — UI:** neuer Block „Disc-Schlüssel für 4K-UHD" **über** dem
KEYDB-Block, mit Schlüssel-Anzahl, Upload und der Schritt-für-Schritt-
Anleitung für den Windows-Umweg. KEYDB.cfg ist jetzt als **Notnagel**
beschriftet. Die Worker-Plakette zeigt die Anzahl bekannter Schlüssel;
`0` heißt sichtbar „4K-UHD scheitert".
- **Gebaut — ehrliche Texte:** Der UHD-Fehlertext nennt jetzt den
Windows-Weg und die Anzahl der Schlüssel dieses Workers. Alle
Falschaussagen korrigiert: UI (3 Stellen), Anleitung (2), README (3),
KONZEPT §8 + §10, Modulkopf von `makemkv_daten.py`, Worker-Dockerfile
und `makemkv_key.py` (dort stand: „Den AACS-Schlüssel zieht MakeMKV via
LibreDrive ohnehin selbst aus dem Laufwerk" — gilt für Blu-ray, NICHT
für UHD).
- **So hältst du den Vorrat aktuell:** Laufwerk an den Windows-PC, Disc in
MakeMKV öffnen, dann `_private_data.tar` aus dem MakeMKV-Datenverzeichnis
(*Preferences → General*) unter Einstellungen → System hochladen. Der
Speicher der Windows-Installation liegt bereits auf der VM unter
`/srv/rippy/makemkv/`.
- **Offen:** Der volle UHD-Rip inklusive Transcode-E2E ist noch nicht
durch — belegt ist der Disc-Zugriff, nicht die komplette Kette bis zur
fertigen Datei. Und ob sich der Abruf unter Linux doch anstoßen lässt,
ist ungeklärt; der Code dafür ist im Binary vorhanden.
---
## Vorheriger 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 +436,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
+36 -15
View File
@@ -1,27 +1,48 @@
#!/usr/bin/env bash
# Deploy für Rippy — deployt den aktuellen 'main'-Stand auf die VM.
# Single Source of Truth ist seit 24.07.2026 'main' (kein stable-Branch mehr);
# die Ampel prüft nur noch, befördert aber nichts. Vor dem Deploy also selbst
# sicherstellen, dass die Ampel für den zu deployenden Commit GRÜN ist.
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
# zu deployenden Commit GRUEN ist.
#
# Nutzt einen EIGENEN Klon (~/notfall-rippy) und fasst Arcanes
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
# im Arcane-Verzeichnis kollidiert mit dessen Sync (read-only .git-Objekte).
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
# reicht ohnehin der einfache Weg (siehe README):
# git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
# docker compose up -d --build
#
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
#
# Konfiguration ueber Umgebungsvariablen:
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
set -euo pipefail
VM="arcane@192.168.178.162"
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
DIENST="${1:-}"
ssh "$VM" 'bash -s' <<REMOTE
ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
set -euo pipefail
if [ ! -d ~/notfall-rippy/.git ]; then
git clone --branch main https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
REPO_URL="$1"
ENV_SRC="${2:-$HOME/rippy/.env}"
CLONE="${3:-$HOME/notfall-rippy}"
DIENST="$4"
if [ ! -d "$CLONE/.git" ]; then
git clone --branch main "$REPO_URL" "$CLONE"
fi
cd ~/notfall-rippy
cd "$CLONE"
git fetch origin main
git reset --hard origin/main
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
docker compose -p rippy up -d --build $DIENST
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
REMOTE
+29 -11
View File
@@ -11,11 +11,15 @@ services:
context: .
dockerfile: docker/api/Dockerfile
environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
- REDIS_URL=redis://redis:6379/0
- TMDB_API_KEY=${TMDB_API_KEY:-}
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
- OMDB_API_KEY=${OMDB_API_KEY:-}
# 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,8 +51,10 @@ 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:
- /dev/sr0:/dev/sr0
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
networks:
- rippy-net
depends_on:
@@ -74,10 +80,14 @@ services:
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
environment:
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
- 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,14 +102,22 @@ 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:
- /dev/sr0:/dev/sr0
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an ohne den
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
# neu starten.
- /dev/sg1:/dev/sg1
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
networks:
- rippy-net
depends_on:
@@ -132,7 +150,7 @@ services:
image: postgres:16-alpine
environment:
- POSTGRES_USER=rippy
- POSTGRES_PASSWORD=rippy
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
- POSTGRES_DB=rippy
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
+6 -4
View File
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
try:
settings = Settings()
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
# sich jederzeit im UI (Einstellungen → System) nachtragen.
if not settings.tmdb_api_key:
raise ConfigValidationError(
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
)
return settings
+217
View File
@@ -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
@@ -250,6 +251,7 @@ class Job(BaseModel):
title: Optional[str] = None
error: Optional[str] = None
can_retry: bool = False # Rohdaten vorhanden → „Neu komprimieren" sinnvoll
meta: Optional[Dict] = None # Disc-Metadaten (Poster/Jahr/Plot) — fürs Thumbnail in der Jobliste + aktivem Rip-Header
class Device(BaseModel):
id: str
@@ -265,6 +267,13 @@ class Device(BaseModel):
def _job_row_to_model(zeile: dict) -> Job:
"""DB-Zeile → UI-Form (Worker-Status 'running' heißt im UI 'processing')."""
status_map = {"running": "processing"}
# meta (JSON-Text) enthält u. a. poster_path — die UI baut daraus das Thumbnail.
# Muss hier mit ins Job-Model, sonst schneidet FastAPIs response_model es weg
# (Befund 25.07.: meta kam nie in der Jobliste an → Filmstreifen-Platzhalter).
try:
meta = json.loads(zeile["meta"]) if zeile.get("meta") else None
except (ValueError, TypeError):
meta = None
return Job(
id=zeile["id"],
type=zeile.get("disc_type") or "unknown",
@@ -275,6 +284,7 @@ def _job_row_to_model(zeile: dict) -> Job:
progress=zeile.get("progress") or 0,
title=zeile.get("title"),
error=zeile.get("error"),
meta=meta,
)
@app.get("/health")
@@ -349,6 +359,10 @@ class JobCreateRequest(BaseModel):
main_feature_only: Optional[bool] = None # pro Rip; None = Setting gilt
titles: Optional[List[int]] = None # exakte Titel-Auswahl (Track-Tabelle)
transcode_node: Optional[str] = None # gewählter Encoder-Worker (Celery-Node)
# Arbeitsverzeichnis NUR für diesen Rip (Commander-Wunsch 25.07.2026:
# beim Start wählbar, nicht global vorgegeben). Leer = der Wert aus
# Einstellungen → Verarbeitung, der auch für Vollautomatik-Rips gilt.
work_dir: Optional[str] = None
MEDIA_ROOT = "/app/media"
@@ -408,6 +422,12 @@ async def create_job(request: JobCreateRequest):
meta_dict["titles"] = titel_liste
if request.transcode_node:
meta_dict["transcode_node"] = request.transcode_node
# Arbeitsverzeichnis dieses Rips. Dieselbe Pfad-Härte wie beim Ziel: muss
# unter /app/media liegen, damit man nicht versehentlich 100 GB Rohdaten
# irgendwohin in den Container schreibt.
arbeits_dir = _validiere_ziel(request.work_dir)
if arbeits_dir:
meta_dict["work_dir"] = arbeits_dir
meta_json = json.dumps(meta_dict) if meta_dict else None
job_id = str(uuid.uuid4())
@@ -1181,6 +1201,203 @@ 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/keystore")
async def get_keystore():
"""Wie viele Disc-Schlüssel kennt diese Rippy-Installation?
Der Schlüsselspeicher (_private_data.tar) ist MakeMKVs eigener Vorrat.
Unter Windows füllt MakeMKV ihn selbst; unter Linux nie — deshalb muss er
hier von Hand hereingereicht werden (Befund 25.07.2026, siehe
makemkv_daten.py). Fehlt er, ist das der Normalfall: 200 mit
vorhanden=false, nie 404.
"""
def sammle():
return makemkv_daten.schluesselspeicher_status()
return await asyncio.to_thread(sammle)
@app.post("/system/keystore")
async def set_keystore(request: Request):
"""Nimmt den Schlüsselspeicher einer MakeMKV-Installation entgegen.
Der Rohkörper der Anfrage IST die Datei — bewusst kein Multipart-Upload
(python-multipart fehlt) und bewusst kein JSON: _private_data.tar ist
binär, und Base64 würde sie nur unnötig aufblähen.
Wirkt ab dem NÄCHSTEN Rip: makemkvcon liest den Speicher beim Start.
"""
rohdaten = await request.body()
def pruefe_und_schreibe():
# Prüfung liest ein mehrere MB grosses tar — gehört deshalb mit in
# den Thread und nicht in die Ereignisschleife.
fehler = makemkv_daten.private_data_pruefen(rohdaten)
if fehler:
return fehler, None
return "", makemkv_daten.private_data_schreiben(rohdaten)
try:
fehler, status = await asyncio.to_thread(pruefe_und_schreibe)
except OSError as e:
raise HTTPException(
status_code=500,
detail=(
f"Schlüsselspeicher konnte nicht geschrieben werden: {e}. "
"Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert "
"und beschreibbar ist (Standard: /srv/rippy/makemkv)."
),
)
if fehler:
raise HTTPException(status_code=422, detail=fehler)
await asyncio.to_thread(
db.add_log, "success", "makemkv-keydb",
f"Schlüsselspeicher übernommen: {status['schluessel']} Disc-Schlüssel, "
f"{status['groesse_bytes']} Bytes. Wirkt ab dem NÄCHSTEN Rip.",
)
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/<name>.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
+375
View File
@@ -0,0 +1,375 @@
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und
MakeMKV liest die Disc — sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* 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/<name>.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 io
import os
import re
import tarfile
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 <MKB..._NAME_....>.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
# --- Schluesselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthaelt nur eine
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist groesser als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schluesselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — oeffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
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 ""
+8 -2
View File
@@ -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
+10 -1
View File
@@ -18,7 +18,16 @@ 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}",
# Der Hauptweg fuer 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand ueber diesen Endpunkt.
"/system/keystore",
):
assert pfad in routen, f"Route {pfad} fehlt"
+118
View File
@@ -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("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\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("", "") == ""
-6
View File
@@ -1,6 +0,0 @@
FROM postgres:16
COPY init.sql /docker-entrypoint-initdb.d/
EXPOSE 5432
-3
View File
@@ -1,3 +0,0 @@
CREATE DATABASE rippy;
CREATE USER rippy WITH PASSWORD 'rippy123';
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
-5
View File
@@ -1,5 +0,0 @@
FROM redis:7-alpine
COPY redis.conf /usr/local/etc/redis/redis.conf
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
-6
View File
@@ -1,6 +0,0 @@
appendonly yes
appendfsync everysec
timeout 0
tcp-keepalive 300
loglevel notice
databases 16
+6
View File
@@ -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 / {
+15 -1
View File
@@ -1,14 +1,25 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs'
import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
export default function App() {
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
const [setupDone, setSetupDone] = useState<boolean | null>(null)
useEffect(() => {
api.get('/setup')
.then((r) => setSetupDone(Boolean(r.data?.done)))
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
}, [])
const navItems: { id: Page; label: string; icon: any }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
@@ -17,6 +28,9 @@ export default function App() {
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
]
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
return (
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
{/* Top Header Navigation Bar */}
@@ -90,6 +90,7 @@ export default function DeviceDiscovery() {
...(optionen?.mainFeatureOnly !== undefined ? { main_feature_only: optionen.mainFeatureOnly } : {}),
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
+58 -1
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu } from 'lucide-react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music, Cpu, HardDrive } from 'lucide-react'
import { api } from '../lib/api'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
@@ -19,6 +19,15 @@ export interface RipOptionen {
mainFeatureOnly?: boolean
titles?: number[]
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
}
// Ein Ablageziel aus GET /storage-targets.
interface StorageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
interface WorkerWahl {
@@ -112,6 +121,12 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [customPath, setCustomPath] = useState('')
// Arbeitsverzeichnis dieses Rips (Commander-Wunsch 25.07.2026: hier wählbar,
// nicht global vorgegeben). '' = der Wert aus den Einstellungen, der auch
// bei Vollautomatik-Rips gilt, weil dort niemand gefragt wird.
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
const [arbeitsDir, setArbeitsDir] = useState('')
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
useEffect(() => {
if (!isOpen) return
@@ -124,6 +139,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setTitelListe([])
setGewaehlt(new Set())
setEncoderNode('')
setArbeitsDir('')
api.get('/storage-targets')
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setArbeitsZiele([]))
// Online-Worker für die Encoder-Wahl (nur relevant, wenn ≥2 verfügbar)
api.get('/capabilities').then(r => {
setWorkers((r.data.workers || []).filter((w: WorkerWahl) => w.online && w.node))
@@ -131,6 +150,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
api.get('/settings').then(r => {
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
setStandardArbeitsDir((s.workDir || '').trim())
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
@@ -164,6 +184,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
optionen.titles = [...gewaehlt].sort((a, b) => a - b)
}
if (encoderNode) optionen.transcodeNode = encoderNode
if (arbeitsDir) optionen.workDir = arbeitsDir
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
@@ -245,6 +266,42 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</label>
)}
{/*
Arbeitsverzeichnis für DIESEN Rip (Commander-Wunsch 25.07.2026).
Warum es hier steht: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß
und lag bisher immer auf der Container-Platte — am 25.07. lief sie
damit voll (74 GB Rohschnitt auf 148 GB Platte). Die Wahl gehört zur
Disc, nicht in eine globale Einstellung. Leer = der Wert aus
Einstellungen → Verarbeitung; genau der greift auch bei
Vollautomatik-Rips, weil dort niemand gefragt wird.
Musik-Rips gehen direkt als FLAC ins Ziel, ohne Roh-Zwischenstufe.
*/}
{selectedType !== 'music' && (
<div>
<Select
label="Arbeitsverzeichnis für die Rohdaten"
value={arbeitsDir}
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard aus den Einstellungen
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
Bei 4K-UHD bis zu 100 GB nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
</p>
</div>
)}
{/* Encoder-/Worker-Wahl — nur wenn mehrere Worker online sind
(sonst gibt es nichts zu wählen). Musik wird nicht komprimiert. */}
{selectedType !== 'music' && workers.length >= 2 && (
+12 -2
View File
@@ -1,4 +1,5 @@
import { ReactNode, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
interface ModalProps {
@@ -35,7 +36,15 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
'4xl': 'max-w-4xl',
}
return (
// WICHTIG (Befund 25.07.2026): Der Dialog wird per Portal direkt an
// document.body gehängt statt dort zu bleiben, wo er im Baum steht.
// Grund: `.glass-panel` in index.css setzt `backdrop-filter: blur(16px)`,
// und ein Element mit backdrop-filter wird zum Bezugsrahmen für
// `position: fixed` seiner Nachfahren. Ein Modal INNERHALB einer Card war
// damit nicht mehr am Fenster ausgerichtet, sondern an der Card — es klebte
// im Panel und wurde am Rand abgeschnitten (gemeldet für „Rippen starten"
// im Laufwerke-Tab). Das Portal löst das für ALLE Dialoge auf einmal.
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
{/* Glass Backdrop */}
<div
@@ -61,6 +70,7 @@ export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: Mod
{children}
</div>
</div>
</div>
</div>,
document.body,
)
}
+32 -5
View File
@@ -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.
</p>
{/* 25.07.2026 richtiggestellt: Updates bringen keine Disc-Schluessel mit — siehe UHD-Absatz unten. */}
<p>
<span className={fett}>Updates:</span> „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. <span className={fett}>Disc-Schlüssel für 4K-UHD kommen dagegen nicht
aus einem Update</span> die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
nie. Wie du sie trotzdem bekommst, steht unter Häufige Fragen".
</p>
</Abschnitt>
@@ -157,11 +160,35 @@ export default function AnleitungPage() {
<span className={fett}>„Diese Disc wurde bereits gerippt"?</span> Rippy erkennt Discs am
Fingerabdruck. Nochmal rippen geht trotzdem der Hinweis verhindert nur Versehen.
</p>
{/*
25.07.2026 auf BEIDEN Maschinen nachgemessen und erneut korrigiert. Es
stand hier nacheinander zweierlei Falsches: erst "ein MakeMKV-Update macht
die Disc rippbar", dann "MakeMKVs Schluessel-Kanal ist tot". Richtig ist:
makemkvcon unter Linux ruft die Schluessel nie ab, die Windows-Version
schon (Meldung 3338, Verbindung nach 185.84.108.20:443).
*/}
<p>
<span className={fett}>4K-UHD schlägt fehl mit volume key is unknown"?</span> 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.
Der Grund liegt nicht bei dir und nicht bei Rippy: <span className={fett}>die Linux-Version
von MakeMKV holt Disc-Schlüssel nie selbst aus dem Netz</span>. Die Windows-Version tut es.
Ein MakeMKV-Update ändert daran nichts.
</p>
<p>
<span className={fett}>Der Weg drumherum:</span> MakeMKV einmalig auf einem Windows-PC
installieren (gleicher Beta-Key), das Laufwerk dort anstecken, die Disc öffnen — MakeMKV
lädt die Schlüssel dabei nach. Dann in MakeMKV unter <em>Preferences → General</em> das
„MakeMKV data directory" nachschlagen und die Datei <code>_private_data.tar</code> daraus
bei Rippy unter Einstellungen System hochladen. Wirkt ab dem nächsten Rip. Für neue
Discs gelegentlich wiederholen der Block dort zeigt dir, wie viele Schlüssel Rippy kennt.
</p>
<p>
Geht eine Pressung auch damit nicht auf, kennt MakeMKV sie selbst nicht. Dann bleiben zwei
Dinge: eine <code>KEYDB.cfg</code> (ebenfalls dort hochladbar, der Notnagel), oder den
<span className={fett}> AACS-Dump</span> im MakeMKV-Forum im Bereich Ultra HD Blu-ray"
einreichen — der bleibt jetzt erhalten und steht unter Einstellungen → System zum
Herunterladen. <span className={fett}>Rippy liefert keine Schlüssel mit und lädt keine
herunter</span> — es verwaltet nur, was du selbst mitbringst.
</p>
<p>
<span className={fett}>„Neu komprimieren" fehlt bei einem Fehl-Job?</span> Der Knopf
+463 -16
View File
@@ -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'
@@ -25,7 +25,13 @@ interface SettingsState {
autoRipStart: boolean
notificationWebhook: string
transcodeEnabled: boolean
// transcodePreset bleibt als Rückfall für Bestandsinstallationen bestehen —
// solange die drei Felder darunter nicht gespeichert sind, gilt weiter dieser
// Wert (siehe preset_fuer() in docker/worker/ripping.py).
transcodePreset: string
transcodePresetDvd: string
transcodePresetBluray: string
transcodePresetUhd: string
keepOriginal: boolean
mediaServer: string
jellyfinUrl: string
@@ -49,6 +55,9 @@ const defaultSettings: SettingsState = {
notificationWebhook: '',
transcodeEnabled: true,
transcodePreset: 'H.265 MKV 1080p30',
transcodePresetDvd: 'H.265 MKV 576p25',
transcodePresetBluray: 'H.265 MKV 1080p30',
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
keepOriginal: false,
mediaServer: 'none',
jellyfinUrl: '',
@@ -62,7 +71,43 @@ 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 Ablageziel aus GET /storage-targets (Feldnamen exakt wie die API liefert).
interface StorageZiel {
name: string
path: string
is_mount: boolean
free_gb: number | null
}
// Antwort von GET/POST /system/keystore — MakeMKVs eigener Schluesselvorrat.
interface KeystoreStatus {
vorhanden: boolean
pfad: string
groesse_bytes: number
schluessel: number
geaendert: string
}
// Ein AACS-Dump aus GET /system/aacs-dumps.
interface AacsDump {
name: string
groesse_bytes: number
geaendert: string
}
interface SystemInfo {
@@ -73,6 +118,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<SettingsState>(defaultSettings)
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
@@ -86,6 +149,19 @@ export default function SettingsPage() {
const [quellenBusy, setQuellenBusy] = useState(false)
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | 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<KeydbStatus | null>(null)
const [keydbBusy, setKeydbBusy] = useState(false)
const [dumps, setDumps] = useState<AacsDump[]>([])
const keydbInput = useRef<HTMLInputElement>(null)
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
const { toast } = useToast()
@@ -106,6 +182,69 @@ 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/keystore').then(r => setKeystore(r.data)).catch(() => setKeystore(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 keystoreHochladen = async (datei: File) => {
setKeystoreBusy(true)
try {
// Die Datei wandert als roher Anfrage-Körper zur API — _private_data.tar
// ist binär, JSON oder Base64 wäre nur unnötiger Ballast, und Multipart
// kann die API nicht (kein python-multipart).
await api.post('/system/keystore', datei, {
headers: { 'Content-Type': 'application/octet-stream' },
})
keydbLaden()
toast('success', 'Schlüsselspeicher übernommen — wirkt ab dem nächsten Rip.')
} catch (e: any) {
toast('error', e?.response?.data?.detail
|| 'Übernahme fehlgeschlagen — ist das wirklich die Datei _private_data.tar, und läuft die API?')
} finally {
setKeystoreBusy(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 +270,18 @@ 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/keystore')
.then(r => setKeystore(r.data))
.catch(() => setKeystore(null))
api.get('/storage-targets')
.then(r => setZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setZiele([]))
api.get('/system/aacs-dumps')
.then(r => setDumps(r.data?.dumps || []))
.catch(() => setDumps([]))
}, [])
const webhookTesten = async () => {
@@ -419,17 +570,54 @@ export default function SettingsPage() {
/>
</div>
{/*
Ein Preset JE DISC-TYP (25.07.2026). Vorher lief alles durch
dasselbe Preset — eine 4K-UHD wurde damit auf 1080p
heruntergerechnet und eine DVD auf 1080p hochskaliert.
Preset-Namen stammen aus `HandBrakeCLI --preset-list` im
Worker-Image (HandBrake 1.6.1), nicht aus dem Kopf.
*/}
<div className="space-y-3">
<p className="text-xs text-slate-500 dark:text-slate-400">
Ein Preset je Quelle Rippy erkennt den Disc-Typ selbst und wählt passend aus.
<strong className="text-slate-700 dark:text-slate-300"> Eine 4K-UHD gehört auf ein
2160p-Preset</strong>, sonst rechnet HandBrake sie auf 1080p herunter und die
4K-Auflösung ist weg.
</p>
<Select
label="HandBrake-Preset"
value={settings.transcodePreset}
onChange={(e) => handleChange('transcodePreset', e.target.value)}
label="Preset für 4K-UHD"
value={settings.transcodePresetUhd}
onChange={(e) => handleChange('transcodePresetUhd', e.target.value)}
>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (Standard)</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K für UHD-Quellen</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K 4K erhalten (empfohlen)</option>
<option value="HQ 2160p60 4K HEVC Surround">HQ 2160p60 4K HEVC Surround 4K, mehr Qualität</option>
<option value="Super HQ 2160p60 4K HEVC Surround">Super HQ 2160p60 4K HEVC Surround 4K, größer</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 bewusst auf 1080p verkleinern</option>
</Select>
<Select
label="Preset für Blu-ray"
value={settings.transcodePresetBluray}
onChange={(e) => handleChange('transcodePresetBluray', e.target.value)}
>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (empfohlen)</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround H.264, maximale Kompatibilität</option>
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround größer, beste Qualität</option>
</Select>
<Select
label="Preset für DVD"
value={settings.transcodePresetDvd}
onChange={(e) => handleChange('transcodePresetDvd', e.target.value)}
>
<option value="H.265 MKV 576p25">H.265 MKV 576p25 PAL-DVD, native Auflösung (empfohlen)</option>
<option value="H.265 MKV 480p30">H.265 MKV 480p30 NTSC-DVD, native Auflösung</option>
<option value="HQ 576p25 Surround">HQ 576p25 Surround H.264, maximale Kompatibilität</option>
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 hochskalieren (bringt nichts)</option>
</Select>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"
@@ -439,14 +627,35 @@ export default function SettingsPage() {
/>
</div>
<Input
label="Arbeitsverzeichnis für Roh-Rips (optional)"
{/*
Auswahl statt Eintippen (Befund 25.07.2026): Das Feld war ein
freies Textfeld — man musste den Container-Pfad KENNEN
(/app/media/…), um eine Netzwerk-Freigabe zu treffen. Genau
daran ist es gescheitert, und der 74-GB-Rohschnitt landete auf
der VM-Platte, bis sie voll war. Die Ziele kommen aus
/storage-targets — dieselbe Liste wie bei den Speicherzielen,
inklusive freiem Platz.
*/}
<Select
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
placeholder="leer = /app/temp (Container-Platte)"
/>
>
<option value="">Container-Platte (Standard) klein, nur für DVD/Blu-ray</option>
{ziele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
</Select>
<p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
Container-Platte. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die
Rohdatei am Ende nur umhängen statt sie zu kopieren.
{systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null}
@@ -633,13 +842,42 @@ export default function SettingsPage() {
HandBrake {w.info.handbrake}
</span>
)}
{/*
Nur das Verzeichnis des rippenden Workers zaehlt — deshalb je Worker.
Die Zahl der Disc-Schluessel ist die entscheidende Angabe fuer 4K-UHD:
0 heisst, dass JEDE unbekannte UHD-Disc scheitert.
*/}
{w.info?.schluessel && w.info.schluessel !== '0' && w.info.schluessel !== 'unbekannt' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
{w.info.schluessel} Disc-Schlüssel
</span>
)}
{w.info?.schluessel === '0' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
keine Disc-Schlüssel 4K-UHD scheitert
</span>
)}
{w.info?.keydb === 'ja' && (
<span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
+ KEYDB.cfg
</span>
)}
</div>
))
)}
{/*
Bis 25.07.2026 stand hier, MakeMKV-Updates braechten die neueste
Disc-Schluessel-Datenbank mit. Nachgemessen: falsch — MakeMKV liefert
gar keine Schluessel mit, es holt sie zur Laufzeit. Und die
Linux-Version holt sie nie (kein einziger Verbindungsversuch, auf
beiden Maschinen verglichen). Deshalb der Schluesselspeicher unten.
*/}
<p className="text-xs mt-3 pt-3 border-t border-slate-200/60 dark:border-slate-800 text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">MakeMKV</strong> lässt sich aktuell halten
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) wichtig wegen der
Disc-Schlüssel-Datenbank.
(Update-Check unten; Version über <code>MAKEMKV_VERSION</code> + Rebuild) neue Versionen bringen
vor allem Laufwerks-Unterstützung und Fehlerbehebungen. <strong>Disc-Schlüssel für 4K-UHD kommen
NICHT aus einem Update</strong> die holt MakeMKV zur Laufzeit, und die Linux-Version tut das
nie. Deshalb der Block Disc-Schlüssel für 4K-UHD" weiter unten.
{' '}<strong className="text-slate-700 dark:text-slate-300">HandBrake</strong> im eingebauten
Docker-Worker ist bewusst die stabile <strong>Debian-Version</strong> — für die Kompression völlig
ausreichend und wird <strong>nicht separat aktualisiert</strong>. Native Worker (Windows) holen
@@ -655,6 +893,213 @@ export default function SettingsPage() {
placeholder="T- (Forum-Thread MakeMKV is free while in beta)"
/>
{/*
Schluesselspeicher — der Hauptweg fuer 4K-UHD (Befund 25.07.2026,
auf beiden Maschinen gemessen): makemkvcon holt Disc-Schluessel
unter Linux NIE selbst, die Windows-Version tut es (Meldung 3338,
Verbindung nach 185.84.108.20:443). Deshalb wird der Vorrat hier
von Hand hereingereicht. Frueher stand an dieser Stelle die These,
MakeMKVs Schluessel-Kanal sei abgeschaltet — das war falsch.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<Database size={16} className="text-amber-500" />
Disc-Schlüssel für 4K-UHD
</p>
<div className="flex items-center gap-2">
<input
type="file"
className="hidden"
ref={keystoreInput}
accept=".tar"
onChange={(e) => {
const datei = e.target.files?.[0]
e.target.value = ''
if (datei) keystoreHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keystoreInput.current?.click()}
disabled={keystoreBusy}
>
<Upload size={14} />
{keystoreBusy ? 'Übernehme…' : '_private_data.tar übernehmen'}
</Button>
</div>
</div>
{keystore?.vorhanden && keystore.schluessel > 0 ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">{keystore.schluessel} Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
{bytesLesbar(keystore.groesse_bytes)} · zuletzt geändert {zeitLesbar(keystore.geaendert)}
</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30">
<p className="font-semibold">Kein einziger Disc-Schlüssel vorhanden.</p>
<p className="text-xs mt-1">
DVDs und normale Blu-rays laufen trotzdem. 4K-UHD-Discs scheitern dagegen mit
„The volume key is unknown for this disc" solange hier nichts liegt, jede einzelne.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Warum das nötig ist:</strong> MakeMKV
holt sich diese Schlüssel eigentlich selbst aus dem Netz. Die <strong>Linux-Version tut das
nicht</strong> am 25.07.2026 nachgemessen: sie baut dabei nicht eine einzige Verbindung auf.
Die Windows-Version schon. Ein MakeMKV-Update ändert daran nichts, es ist kein Fehler in Rippy.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">So füllst du den Vorrat:</strong> MakeMKV
auf einem Windows-PC installieren (gleicher Beta-Key), das Laufwerk dort anstecken und die Disc
einmal öffnen. MakeMKV lädt die Schlüssel dabei nach. Danach unter <em>Preferences General</em>
das MakeMKV data directory" nachschlagen, die Datei <code>_private_data.tar</code> daraus hier
hochladen — fertig. Für neue Discs von Zeit zu Zeit wiederholen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Das ist der Zwischenspeicher <strong className="text-slate-700 dark:text-slate-300">deiner
eigenen</strong> MakeMKV-Installation, über MakeMKVs offiziellen Weg mit deiner eigenen Lizenz
geholt. Rippy liefert keine Schlüssel mit und lädt keine herunter.
</p>
</div>
{/*
KEYDB.cfg — der NOTNAGEL, nicht der Hauptweg. Sie hilft bei Pressungen,
die auch MakeMKV selbst nicht kennt. Der Regelfall laeuft ueber den
Schluesselspeicher im Block darueber. Dateiname GROSS geschrieben, unter
Linux case-sensitiv. Rippy stellt nur den Platz bereit.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
<KeyRound size={16} className="text-amber-500" />
KEYDB.cfg (Notnagel)
</p>
<div className="flex items-center gap-2">
{/*
Versteckter Datei-Dialog: der Inhalt wird im Browser gelesen und als
JSON gepostet. Kein Multipart — der API fehlt python-multipart.
*/}
<input
type="file"
className="hidden"
ref={keydbInput}
accept=".cfg,text/plain"
onChange={(e) => {
const datei = e.target.files?.[0]
// Wert leeren, damit dieselbe Datei erneut gewählt werden kann.
e.target.value = ''
if (datei) keydbHochladen(datei)
}}
/>
<Button
variant="amber"
size="sm"
onClick={() => keydbInput.current?.click()}
disabled={keydbBusy}
>
<Upload size={14} />
{keydbBusy ? 'Arbeite…' : 'KEYDB.cfg hochladen'}
</Button>
{keydb?.vorhanden && (
<Button variant="danger" size="sm" onClick={keydbEntfernen} disabled={keydbBusy}>
<Trash2 size={14} />
Entfernen
</Button>
)}
</div>
</div>
{keydb?.vorhanden ? (
<div className="p-3 rounded-lg text-sm bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30">
<p className="font-semibold">Eine KEYDB.cfg liegt bereit.</p>
<p className="text-xs mt-1">
{bytesLesbar(keydb.groesse_bytes)} · {keydb.eintraege} Zeilen mit Disc-Kennung ·
zuletzt geändert {zeitLesbar(keydb.geaendert)}
</p>
<p className="text-xs mt-1 font-mono opacity-80">{keydb.pfad}</p>
</div>
) : (
<div className="p-3 rounded-lg text-sm bg-slate-200 dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
<p className="font-semibold">Keine KEYDB.cfg hinterlegt.</p>
<p className="text-xs mt-1">
Das ist normal und meistens auch nicht nötig — der Regelfall läuft über den
Schlüsselspeicher oben.
</p>
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Die <strong className="text-slate-700 dark:text-slate-300">KEYDB.cfg</strong> ist eine reine
Textdatei mit Disc-Schlüsseln. Sie ist der <strong className="text-slate-700 dark:text-slate-300">
Notnagel</strong> für den Fall, dass eine Pressung selbst über den Schlüsselspeicher nicht
aufgeht — also auch MakeMKV sie nicht kennt. Zuerst immer den Weg darüber versuchen.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Wichtig:</strong> 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.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Eine hochgeladene Datei ersetzt die bisherige und wirkt
<strong className="text-slate-700 dark:text-slate-300"> ab dem nächsten Rip</strong> — 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.
</p>
</div>
{/*
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.
*/}
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">AACS-Dumps</p>
<Button variant="secondary" size="sm" onClick={keydbLaden}>
Liste aktualisieren
</Button>
</div>
{dumps.length === 0 ? (
<p className="text-xs text-slate-500 dark:text-slate-400">
Noch kein Dump vorhanden. MakeMKV legt hier automatisch einen ab, sobald eine 4K-UHD-Disc
an einem unbekannten Schlüssel scheitert.
</p>
) : (
<div className="space-y-1.5">
{dumps.map(d => (
<a
key={d.name}
href={`/api/system/aacs-dumps/${encodeURIComponent(d.name)}`}
download
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0 font-mono">{d.name}</span>
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{bytesLesbar(d.groesse_bytes)} · {zeitLesbar(d.geaendert)}
</span>
</a>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
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 <strong className="text-slate-700 dark:text-slate-300">Ultra HD
Blu-ray</strong> einreichen. Dort können Leute daraus den Schlüssel für deine Pressung ermitteln;
den trägst du dann in deine KEYDB.cfg ein.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
@@ -672,7 +1117,9 @@ export default function SettingsPage() {
<p className="text-xs mt-1">
⚠️ Update verfügbar — in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
dann auf der Rippy-Maschine <code>docker compose build worker &amp;&amp; docker compose up -d worker</code>.
Neue Versionen bringen auch die neueste Disc-Schlüssel-Datenbank mit.
{/* 25.07.2026 richtiggestellt: ein Update bringt KEINE Disc-Schluessel mit. */}
Neue Versionen bringen Laufwerks-Unterstützung und Fehlerbehebungen — aber
<strong> keine Disc-Schlüssel</strong>: die kommen aus dem Schlüsselspeicher.
</p>
)
: <span className="text-xs ml-1 text-emerald-600 dark:text-emerald-400">✓ aktuell</span>}
+7 -3
View File
@@ -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
+27
View File
@@ -85,4 +85,31 @@ 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"
# Die entscheidende Zahl für 4K-UHD: wie viele Disc-Schlüssel kennt
# dieser Worker? 0 heißt, dass jede unbekannte UHD-Disc scheitert —
# makemkvcon holt sie unter Linux nie selbst (Befund 25.07.2026).
try:
info["schluessel"] = str(makemkv_daten.schluesselspeicher_status().get("schluessel", 0))
except Exception:
info["schluessel"] = "unbekannt"
return info
+45 -5
View File
@@ -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 "$@"
+375
View File
@@ -0,0 +1,375 @@
"""MakeMKV-Datenverzeichnis: Schluesselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk — LibreDrive v06.3 laeuft und
MakeMKV liest die Disc — sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefuelltem Schluesselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Pruefung
(update.conf geloescht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar waechst — und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafuer steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie loest nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stuetzte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen — MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schluesselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft — praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel fuer Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, laedt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Linux laedt keine Hashed Keys — dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* 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/<name>.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 io
import os
import re
import tarfile
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 <MKB..._NAME_....>.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
# --- Schluesselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher fuer die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows fuellt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim naechsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und waechst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: waere die Grenze hier hoeher,
# wuerde nginx den Upload abweisen, bevor die API ihn ueberhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schluesselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl fuer "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthaelt nur eine
Index-Datei und kommt hier auf 0 — genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prueft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann aendert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist groesser als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schluesselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — oeffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schluesselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schluesselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: waehrend ein Rip laeuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
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 ""
+80 -13
View File
@@ -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.
@@ -203,6 +225,33 @@ def check_handbrake_installed() -> bool:
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
# Ein Preset je Disc-Typ (Befund 25.07.2026). Vorher galt EIN Preset für
# alles: eine 4K-UHD wurde damit auf 1080p heruntergerechnet und eine DVD auf
# 1080p hochskaliert — beides Unsinn, und beim ersten UHD-Rip wäre die 4K-
# Auflösung still verlorengegangen. Den Disc-Typ kennt Rippy ohnehin.
PRESET_SCHLUESSEL = {
"dvd": "transcodePresetDvd",
"bluray": "transcodePresetBluray",
"uhd": "transcodePresetUhd",
}
def preset_fuer(disc_type: str, einstellungen: dict) -> str:
"""Welches HandBrake-Preset gilt für diesen Disc-Typ? (pure, testbar)
Reihenfolge: Preset des Disc-Typs → allgemeines transcodePreset →
DEFAULT_HB_PRESET. Diese Kette hält Bestandsinstallationen am Leben, in
denen nur `transcodePreset` gesetzt ist: solange der Nutzer die neuen
Felder nicht speichert, ändert sich sein Verhalten nicht.
"""
einstellungen = einstellungen or {}
schluessel = PRESET_SCHLUESSEL.get(disc_type or "")
if schluessel:
eigen = (einstellungen.get(schluessel) or "").strip()
if eigen:
return eigen
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET) -> list:
"""Baut das HandBrake-Kommando für die Kompressions-Stufe (pure Funktion).
@@ -297,8 +346,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 +386,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:
if progress >= 0:
if 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('"')
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 +439,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 +454,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 +463,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:
+151 -31
View File
@@ -15,11 +15,13 @@ komprimiert danach auf Arbeitsgröße. Die Rohdatei liegt nur temporär in
import glob
import json
import os
import posixpath
import shutil
import requests
import db
import makemkv_daten
import medien
import notify
from celery_app import celery_app
@@ -34,11 +36,11 @@ except ImportError: # Windows: kein fcntl
disc_size_bytes = None
from ripping import (
DEFAULT_HB_PRESET,
RIP_OUTPUT_DIR,
RipAbbruch,
lies_datei_dauer,
lies_titel_info,
preset_fuer,
rip_cd,
rip_video,
run_handbrake,
@@ -98,17 +100,27 @@ def _zielbasis(target_dir, disc_type: str) -> str:
return os.path.join(RIP_OUTPUT_DIR, disc_type)
def _arbeitsverzeichnis(einstellungen: dict) -> str:
"""Basis für Roh-Rips: UI-Setting `workDir` (unter /app/media, z. B. eine
NAS-Freigabe) schlägt den Container-Default /app/temp/raw.
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
`workDir` → Container-Default /app/temp/raw.
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
das Arbeitsverzeichnis muss deshalb auf ein großes Ziel umlegbar sein.
Pro Rip wählbar seit 25.07.2026 (Commander-Wunsch): beim „Rippen starten"
entscheidet man je Disc, wo die Rohdaten landen. Das Setting bleibt der
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
denen niemand gefragt wird.
"""
work_dir = (einstellungen.get("workDir") or "").strip()
if work_dir:
normalisiert = os.path.normpath(work_dir)
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
for kandidat in (job_wahl, einstellungen.get("workDir")):
wert = (kandidat or "").strip()
if wert:
normalisiert = posixpath.normpath(wert)
if normalisiert.startswith(MEDIA_ROOT):
return normalisiert
return RAW_DIR
@@ -147,19 +159,88 @@ 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}")
def _ordner_groesse(pfad: str) -> int:
"""Belegter Platz eines Ordners in Bytes (0, wenn nicht lesbar)."""
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for name in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, name))
except OSError:
pass
return summe
def _original_aufheben(job_id: str, raw_dir: str, final_dir: str) -> None:
"""Roh-Rip zusätzlich aufheben — darf den Job NIEMALS scheitern lassen.
Befund 25.07.2026 (Akira-UHD, echter Schaden): Hier stand ein nacktes
shutil.move(). Arbeitsverzeichnis (/app/temp, Docker-Volume) und Ziel
(/app/media, Bind-Mount) sind VERSCHIEDENE Dateisysteme — os.rename
scheitert dort mit EXDEV, und shutil.move fällt auf Kopieren zurück. Damit
lief eine 74-GB-Vollkopie auf dieselbe Platte an, bis sie mit ENOSPC voll
war. Ergebnis: Platte 100 % voll, Worker-Container startete nicht mehr —
und der Job wurde als FEHLGESCHLAGEN markiert, obwohl die komprimierte
Datei längst fertig und in Ordnung war. Der Nutzer sah nur „nichts da".
Deshalb dreifach abgesichert: Platz vorher prüfen, Fehler abfangen, und im
Zweifel die Rohdatei einfach liegen lassen, wo sie ist.
"""
ziel_original = os.path.join(final_dir, "original")
try:
# Liegt beides auf demselben Dateisystem, ist es ein reines Umhängen —
# sofort fertig, kein zusätzlicher Platz nötig.
gleiches_dateisystem = os.stat(raw_dir).st_dev == os.stat(final_dir).st_dev
if not gleiches_dateisystem:
benoetigt = _ordner_groesse(raw_dir)
frei = _frei_bytes(final_dir)
if frei < benoetigt * 1.05:
db.add_log(
"warning", "worker",
f"Job {job_id}: Original NICHT aufgehoben — dafür wären "
f"{benoetigt / 1024**3:.1f} GB nötig, frei sind nur "
f"{frei / 1024**3:.1f} GB. Die Roh-Datei bleibt unter "
f"{raw_dir} liegen. Abhilfe: Arbeitsverzeichnis "
"(Einstellungen → Verarbeitung) auf dieselbe Freigabe legen "
"wie das Ziel — dann wird nur umgehängt statt kopiert."
)
return
shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
except OSError as e:
# Halb geschriebene Kopie wegräumen, sonst belegt sie für immer Platz.
shutil.rmtree(ziel_original, ignore_errors=True)
db.add_log(
"warning", "worker",
f"Job {job_id}: Original konnte nicht aufgehoben werden ({e}). "
f"Die komprimierte Datei ist fertig; die Roh-Datei bleibt unter {raw_dir}."
)
def _abbruch_angefordert(job_id: str) -> bool:
"""Kooperativer Abbruch: hat der Nutzer über die API abgebrochen?"""
return db.get_job_status(job_id) == "canceling"
@@ -344,6 +425,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)
@@ -382,7 +479,8 @@ def rip_disc(self, device_path: str, job_id: str, target_dir: str = None):
# Geplantes Ziel sofort sichtbar machen (UI-Detail + retry-transcode)
db.update_job(job_id, output_path=final_dir)
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen), job_id)
# meta["work_dir"] = die Wahl aus dem Rip-Dialog; leer = Setting/Default.
raw_dir = os.path.join(_arbeitsverzeichnis(einstellungen, meta.get("work_dir")), job_id)
# Platz-Check VOR dem Rip: Disc-Größe ist per ioctl bekannt — eine volle
# Platte nach 40 GB wäre der teuerste Fehlschlag (4K-UHD: bis 100 GB roh).
@@ -411,6 +509,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 +517,44 @@ 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, auf BEIDEN Maschinen gemessen: Laufwerk und
# MakeMKV sind in Ordnung. makemkvcon unter Linux ruft die
# Disc-Schlüssel schlicht nie ab — die Windows-Version tut es
# (Meldung 3338). Hier stand vorher erst "Disc zu neu" und danach
# "der Schlüssel-Kanal ist tot"; beides war falsch und hat in die
# Irre geschickt. Herleitung im Kopf von makemkv_daten.py.
speicher = makemkv_daten.schluesselspeicher_status()
anzahl = speicher.get("schluessel", 0)
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 fehlt nur der Schlüssel dieser Pressung. "
"Der Grund: makemkvcon holt Schlüssel unter Linux nie selbst "
"nach — die Windows-Version schon. "
+ (
"Dieser Worker kennt aktuell GAR KEINEN Disc-Schlüssel. "
if not anzahl
else f"Dieser Worker kennt {anzahl} Disc-Schlüssel, "
"diese Pressung ist nicht dabei. "
)
elif "Failed to open disc" in fehler_text:
+ "Abhilfe: MakeMKV auf einem Windows-PC installieren, die "
"Disc dort einmal öffnen, dann die Datei _private_data.tar "
"aus dem MakeMKV-Datenverzeichnis unter Einstellungen → "
"System hochladen. Wirkt ab dem nächsten Rip. Klappt auch "
"das nicht, kennt MakeMKV die Pressung selbst nicht — dann "
"hilft nur eine KEYDB.cfg (ebenfalls dort hochladbar) oder "
"das Einreichen des AACS-Dumps im MakeMKV-Forum, Bereich "
"'Ultra HD Blu-ray'. Der Dump steht unter Einstellungen → "
"System zum Download bereit."
)
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 "
@@ -518,14 +635,19 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
return ergebnis
einstellungen = db.get_settings()
preset = einstellungen.get("transcodePreset") or DEFAULT_HB_PRESET
# Preset nach Disc-Typ (Befund 25.07.2026): vorher lief JEDE Quelle durch
# dasselbe Preset — eine 4K-UHD wurde damit auf 1080p heruntergerechnet.
job = db.get_job(job_id) or {}
disc_type = job.get("disc_type") or ""
preset = preset_fuer(disc_type, einstellungen)
original_behalten = einstellungen.get("keepOriginal", False)
os.makedirs(final_dir, exist_ok=True)
db.update_job(job_id, status="transcoding", progress=0, error=None)
db.add_log(
"info", "worker",
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), Preset '{preset}')",
f"Job {job_id}: Kompression gestartet ({len(quellen)} Datei(en), "
f"Disc-Typ '{disc_type or 'unbekannt'}', Preset '{preset}')",
)
anzahl = len(quellen)
@@ -558,9 +680,7 @@ def transcode_files(self, job_id: str, raw_dir: str, final_dir: str):
return ergebnis
if original_behalten:
ziel_original = os.path.join(final_dir, "original")
shutil.move(raw_dir, ziel_original)
db.add_log("info", "worker", f"Job {job_id}: Original behalten unter {ziel_original}")
_original_aufheben(job_id, raw_dir, final_dir)
else:
shutil.rmtree(raw_dir, ignore_errors=True)
+244
View File
@@ -0,0 +1,244 @@
"""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
private_data_pruefen = _modul.private_data_pruefen
zaehle_schluessel = _modul.zaehle_schluessel
def _tar_mit(namen):
"""Baut ein tar-Archiv im Speicher — so sieht MakeMKVs Schluesselspeicher aus.
Echte Eintragsnamen aus dem Speicher der Windows-Installation vom
25.07.2026: hkd_<8 Hex>.bin fuer die Schluessel, dazu Index-Dateien.
"""
import io
import tarfile
puffer = io.BytesIO()
with tarfile.open(fileobj=puffer, mode="w") as archiv:
for name in namen:
eintrag = tarfile.TarInfo(name)
eintrag.size = 3
archiv.addfile(eintrag, io.BytesIO(b"abc"))
return puffer.getvalue()
def test_zaehle_schluessel_zaehlt_nur_hkd_eintraege():
# Nur hkd_*.bin sind Disc-Schluessel. Index- und sdf-Dateien gehoeren zum
# Speicher dazu, sind aber keine Schluessel — sonst meldete das UI
# "1 Schluessel vorhanden" fuer einen komplett leeren Vorrat.
voll = _tar_mit([
"hkd_00000059.bin",
"hkd_0000005a.bin",
"sdf_000000a6.bin",
"--index-A2E950B3C3FC57DA9CB856DCAFBA5275F40423DB.bin",
])
assert zaehle_schluessel(voll) == 2
def test_zaehle_schluessel_leerer_speicher_ist_null():
# Genau dieser Zustand lag am 25.07.2026 auf der VM vor: ein Archiv mit
# ausschliesslich der Index-Datei. Jede unbekannte UHD-Disc scheitert dann.
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
assert zaehle_schluessel(leer) == 0
def test_zaehle_schluessel_bei_muell_kein_absturz():
# Darf niemals werfen — die Zahl landet im Worker-Herzschlag.
assert zaehle_schluessel(b"") == 0
assert zaehle_schluessel(b"das ist kein tar") == 0
def test_private_data_pruefen_nimmt_echten_speicher_an():
assert private_data_pruefen(_tar_mit(["hkd_00000059.bin"])) == ""
def test_private_data_pruefen_lehnt_leere_und_falsche_dateien_ab():
assert "leer" in private_data_pruefen(b"")
assert "tar-Archiv" in private_data_pruefen(b"<html>Fehlerseite</html>")
def test_private_data_pruefen_lehnt_speicher_ohne_schluessel_ab():
# Der teuerste Bedienfehler: den Speicher einer Installation hochladen,
# die selbst noch nie Schluessel geholt hat. Ohne diese Pruefung aendert
# sich nichts und niemand versteht, warum.
leer = _tar_mit(["--index-4EF73C3560D489497ACE763962A7F07A4A1545C4.bin"])
assert "kein einziger Schluessel" in private_data_pruefen(leer)
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 = "<!DOCTYPE html>\n<html><body><h1>404 Not Found</h1></body></html>\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("", "") == ""
+79
View File
@@ -112,3 +112,82 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
assert pfad_lokal("/app/media/movies/Film (2020)", mapping) == "Z:\\media\\movies\\Film (2020)"
assert pfad_lokal("/woanders/x", mapping) == "/woanders/x"
assert pfad_lokal("/app/temp/raw/abc", "") == "/app/temp/raw/abc"
def test_original_aufheben_laesst_job_nie_scheitern(tmp_path, monkeypatch):
"""Der Schritt "Original behalten" darf einen fertigen Job NICHT killen.
Schaden vom 25.07.2026 (Akira-UHD): shutil.move() ueber Dateisystemgrenzen
kopierte 74 GB, lief in ENOSPC, und der Job galt als fehlgeschlagen —
obwohl die komprimierte Datei laengst fertig war. Der Nutzer sah nur eine
leere Queue.
"""
import tasks
meldungen = []
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
raw = tmp_path / "raw"
raw.mkdir()
(raw / "title_t00.mkv").write_bytes(b"x" * 1024)
final = tmp_path / "final"
final.mkdir()
def platzt(*_a, **_k):
raise OSError(28, "No space left on device")
monkeypatch.setattr(tasks.shutil, "move", platzt)
# Darf NICHT werfen
tasks._original_aufheben("job-1", str(raw), str(final))
assert meldungen and meldungen[-1][0] == "warning"
assert "Roh-Datei bleibt" in meldungen[-1][2]
def test_original_aufheben_warnt_statt_platte_vollzuschreiben(tmp_path, monkeypatch):
"""Zu wenig Platz auf einem ANDEREN Dateisystem -> gar nicht erst anfangen."""
import tasks
meldungen = []
monkeypatch.setattr(tasks.db, "add_log", lambda *a: meldungen.append(a))
bewegt = []
monkeypatch.setattr(tasks.shutil, "move", lambda *a: bewegt.append(a))
raw = tmp_path / "raw"
raw.mkdir()
(raw / "gross.mkv").write_bytes(b"x" * 4096)
final = tmp_path / "final"
final.mkdir()
# Verschiedene Dateisysteme vortaeuschen und den freien Platz kleinrechnen.
# _ordner_groesse wird mitgefaelscht, weil ein selbstgebautes os.stat sonst
# os.path.getsize mitreisst (das liest st_size an derselben Stelle).
class Stat:
def __init__(self, dev): self.st_dev = dev
monkeypatch.setattr(tasks.os, "stat", lambda p: Stat(1 if "raw" in str(p) else 2))
monkeypatch.setattr(tasks, "_ordner_groesse", lambda _p: 4096)
monkeypatch.setattr(tasks, "_frei_bytes", lambda _p: 100)
tasks._original_aufheben("job-2", str(raw), str(final))
assert bewegt == [], "es darf NICHT kopiert werden, wenn der Platz nicht reicht"
assert meldungen[-1][0] == "warning"
assert "NICHT aufgehoben" in meldungen[-1][2]
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
"""Pro Rip waehlbar (Commander 25.07.2026), Einstellung bleibt Standard.
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
niemand gefragt wird.
"""
import tasks
einst = {"workDir": "/app/media/movies"}
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
# Ausbruchsversuche und Pfade ausserhalb /app/media fallen durch
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
# Leere Wahl faellt sauber auf die Einstellung zurueck
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
+95
View File
@@ -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."""
@@ -83,3 +138,43 @@ def test_abcde_config_enthaelt_zielverzeichnis():
assert "INTERACTIVE=n" in inhalt
finally:
os.unlink(pfad)
def test_preset_fuer_nimmt_das_preset_des_disc_typs():
# Befund 25.07.2026: vorher galt EIN Preset fuer alles — eine 4K-UHD wurde
# damit auf 1080p heruntergerechnet, und beim ersten echten UHD-Rip waere
# die 4K-Aufloesung still verlorengegangen.
from ripping import preset_fuer
einstellungen = {
"transcodePreset": "HQ 1080p30 Surround",
"transcodePresetDvd": "H.265 MKV 576p25",
"transcodePresetBluray": "H.265 MKV 1080p30",
"transcodePresetUhd": "H.265 MKV 2160p60 4K",
}
assert preset_fuer("uhd", einstellungen) == "H.265 MKV 2160p60 4K"
assert preset_fuer("bluray", einstellungen) == "H.265 MKV 1080p30"
assert preset_fuer("dvd", einstellungen) == "H.265 MKV 576p25"
def test_preset_fuer_faellt_auf_das_allgemeine_preset_zurueck():
# Bestandsinstallationen kennen die drei neuen Felder nicht. Solange der
# Nutzer sie nicht speichert, MUSS sich sein Verhalten nicht aendern.
from ripping import preset_fuer
alt = {"transcodePreset": "HQ 1080p30 Surround"}
assert preset_fuer("uhd", alt) == "HQ 1080p30 Surround"
assert preset_fuer("dvd", alt) == "HQ 1080p30 Surround"
# Leere Zeichenkette zaehlt als "nicht gesetzt" (leeres Select-Feld im UI)
assert preset_fuer("uhd", {"transcodePresetUhd": " ", "transcodePreset": "X"}) == "X"
def test_preset_fuer_ohne_einstellungen_nimmt_den_eingebauten_standard():
# Unbekannter Disc-Typ, leere oder fehlende Einstellungen: nie None, nie
# Absturz — sonst stirbt die Kompression an einem leeren --preset-Argument.
from ripping import DEFAULT_HB_PRESET, preset_fuer
assert preset_fuer("uhd", {}) == DEFAULT_HB_PRESET
assert preset_fuer("", None) == DEFAULT_HB_PRESET
assert preset_fuer(None, {}) == DEFAULT_HB_PRESET
assert preset_fuer("cd", {"transcodePresetUhd": "egal"}) == DEFAULT_HB_PRESET
-52
View File
@@ -1,52 +0,0 @@
# Rippy udev-Setup
## Problem
Rippy findet kein Laufwerk, obwohl eines am Host angeschlossen ist.
## Ursache
Die `docker-compose.yml` nutzte anonyme Volumes für `/dev/disc`, `/dev/cdrom`, `/dev/dvd`:
```yaml
volumes:
- disc:/dev/disc # ❌ Anonymes Volume, leer
```
## Lösung
Direkte Mounts vom Host in den Worker-Container:
### 1. docker-compose.yml anpassen
```yaml
volumes:
- /dev/cdrom:/dev/cdrom:ro
- /dev/dvd:/dev/dvd:ro
- /dev/sr0:/dev/sr0:ro
- disc:/dev/disc # ✅ Shared-Mount für udev-Symlinks
```
### 2. Worker-Dockerfile erweitern
- `isoinfo` für Disc-Typ-Erkennung
- `udev` für Geräteverwaltung
### 3. udev-Regel installieren
Kopiere `99-disc-ripper.rules` nach `/etc/udev/rules.d/`
### 4. udev-Daemon ausführen
Der Daemon (`udev_daemon.py`) läuft im Worker-Container und erstellt Symlinks mit UUID/Serial.
## Installation auf Arcane VM
```bash
cd /path/to/rippy/udev
bash install-arcane.sh
```
## Testen
1. Leg eine CD/DVD ein
2. Logs prüfen: `docker logs -f rippy-worker`
3. Symlinks: `docker exec rippy-worker ls -la /dev/disc`
4. Devices: `docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0`
## Dateien
- `99-disc-ripper.rules` — udev-Regel für Einwurf/Auswurf
- `udev_daemon.py` — Daemon für Disc-Erkennung
- `rippy-udev.service` — systemd-Service (optional)
- `install-arcane.sh` — Installations-Skript für Arcane VM
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
# Installations-Skript für Arcane VM (Debian 12)
# Auf der Arcane VM ausführen
set -e
echo "=== Rippy udev-Setup für Arcane VM ==="
# 1. udev-Regel installieren
echo "Installiere udev-Regel..."
cp 99-disc-ripper.rules /etc/udev/rules.d/
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
# 2. udev-Daemon in Worker-Container kopieren
echo "Kopiere udev-Daemon in Worker-Container..."
docker cp udev_daemon.py rippy-worker:/app/udev/
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
echo "✅ udev-Daemon und Regeln in Container kopiert"
# 3. udev-Regel neu laden (im Host)
echo "Lade udev-Regeln im Host neu..."
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln im Host neu geladen"
# 4. udev-Regel in Container neu laden
echo "Lade udev-Regeln im Container neu..."
docker exec -i rippy-worker udevadm control --reload-rules
docker exec -i rippy-worker udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln im Container neu geladen"
# 5. Service aktivieren (optional - wenn im Container als systemd läuft)
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
echo "Aktiviere systemd-Service..."
docker exec -i rippy-worker systemctl daemon-reload
docker exec -i rippy-worker systemctl enable rippy-udev
echo "✅ systemd-Service aktiviert"
fi
echo "=== Setup abgeschlossen ==="
echo ""
echo "Testen:"
echo " 1. Leg eine CD/DVD ein"
echo " 2. Logs prüfen: docker logs -f rippy-worker"
echo " 3. Symlinks: docker exec rippy-worker ls -la /dev/disc"
echo " 4. Devices: docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0"
echo ""
echo "Hinweis: /dev/disc ist ein Shared-Mount, der udev-Daemon erstellt Symlinks"
echo " mit UUID/Serial für jedes erkannte Laufwerk."
-43
View File
@@ -1,43 +0,0 @@
#!/bin/bash
# Installations-Skript für Rippy udev-Regel + Service
# Auf dem Host (Arcane VM / Proxmox LXC) ausführen
set -e
echo "=== Rippy udev-Setup ==="
# Prüfen ob wir root sind
if [ "$EUID" -ne 0 ]; then
echo "❌ Bitte als root ausführen (sudo bash install-udev.sh)"
exit 1
fi
# 1. udev-Regel installieren
echo "Installiere udev-Regel..."
cp 99-disc-ripper.rules /etc/udev/rules.d/
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
# 2. udev-Daemon in den Container kopieren
echo "Kopiere udev-Daemon in Worker-Container..."
docker cp udev_daemon.py rippy-worker:/app/udev/
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
echo "✅ udev-Daemon und Regeln in Container kopiert"
# 3. udev-Regel neu laden
echo "Lade udev-Regeln neu..."
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=block
echo "✅ udev-Regeln neu geladen"
# 4. Service aktivieren (optional - wenn im Container als systemd läuft)
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
echo "Aktiviere systemd-Service..."
docker exec -i rippy-worker systemctl daemon-reload
docker exec -i rippy-worker systemctl enable rippy-udev
echo "✅ systemd-Service aktiviert"
fi
echo "=== Setup abgeschlossen ==="
echo "Teste Disc-Einwurf: Leg eine CD/DVD ein"
echo "Prüfe Logs: docker logs -f rippy-worker"
-29
View File
@@ -1,29 +0,0 @@
# systemd-Service für Rippy udev-Daemon
# Diese Datei muss nach /etc/systemd/system/rippy-udev.service kopiert werden
[Unit]
Description=Rippy Disc Detection Daemon
After=dev-disc.device
Requires=dev-disc.device
[Service]
Type=simple
ExecStart=/usr/bin/python3 /app/udev_daemon.py
Restart=always
RestartSec=5
WorkingDirectory=/app
User=root
Group=root
# Environment
Environment=REDIS_URL=redis://redis:6379/0
Environment=PATH=/usr/local/bin:/usr/bin:/bin
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""
udev-Daemon für Rippy
Erkennt Disc-Einwurf/-auswurf und erstellt Celery-Jobs.
"""
import os
import sys
import subprocess
import redis
import json
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
DISC_DEVICE_PATH = "/dev/disc"
# Redis verbinden
def get_redis_client():
try:
return redis.from_url(REDIS_URL)
except Exception as e:
print(f"Fehler beim Verbinden mit Redis: {e}")
sys.exit(1)
def get_device_info(device_name: str) -> dict:
"""Ermittle Device-Info via udevadm."""
try:
result = subprocess.run(
["udevadm", "info", "-q", "property", "-n", f"/dev/{device_name}"],
capture_output=True,
text=True,
timeout=5
)
info = {}
for line in result.stdout.strip().split('\n'):
if '=' in line:
key, value = line.split('=', 1)
info[key] = value
return info
except Exception as e:
print(f"Fehler beim Auslesen von Device {device_name}: {e}")
return {}
def create_device_symlink(device_name: str, info: dict) -> str:
"""Erstelle Symlink mit UUID/Serial-Nummer."""
serial = info.get("ID_SERIAL", "unknown")
model = info.get("ID_MODEL", "unknown")
symlink_name = f"{model}_{serial}".replace(" ", "_")
symlink_path = f"{DISC_DEVICE_PATH}/{symlink_name}"
os.makedirs(DISC_DEVICE_PATH, exist_ok=True)
# Alten Symlink entfernen falls vorhanden
if os.path.exists(symlink_path):
os.unlink(symlink_path)
# Neuen Symlink erstellen
os.symlink(f"/dev/{device_name}", symlink_path)
return symlink_path
def delete_device_symlink(device_name: str):
"""Lösche Symlink beim Auswurf."""
symlink_path = f"{DISC_DEVICE_PATH}/{device_name}"
if os.path.exists(symlink_path):
os.unlink(symlink_path)
def detect_disc_type(device_path: str) -> str:
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
try:
result = subprocess.run(
["isoinfo", "-d", "-i", device_path],
capture_output=True,
text=True,
timeout=10
)
output = result.stdout.lower()
if "rock ridge" in output or "joliet" in output:
if "blu-ray" in output:
return "bluray"
return "dvd"
elif "cda" in output:
return "cd"
return "unknown"
except Exception:
return "unknown"
def create_job(device_path: str, disc_type: str):
"""Erstelle Celery-Job für Disc-Erkennung."""
try:
client = get_redis_client()
job_data = {
"device_path": device_path,
"disc_type": disc_type,
"timestamp": str(int(subprocess.check_output(["date", "+%s"]).decode().strip())),
"status": "pending"
}
client.lpush("rippy:jobs", json.dumps(job_data))
print(f"Job erstellt: {disc_type} -> {device_path}")
except Exception as e:
print(f"Fehler beim Erstellen des Jobs: {e}")
def handle_add(device_name: str):
"""Handle Disc-Einwurf."""
print(f"Disc-Einwurf erkannt: {device_name}")
info = get_device_info(device_name)
if not info:
print("Konnte Device-Info nicht auslesen")
return
symlink_path = create_device_symlink(device_name, info)
print(f"Symlink erstellt: {symlink_path}")
disc_type = detect_disc_type(symlink_path)
print(f"Disc-Typ erkannt: {disc_type}")
create_job(symlink_path, disc_type)
def handle_remove(device_name: str):
"""Handle Disc-Auswurf."""
print(f"Disc-Auswurf erkannt: {device_name}")
delete_device_symlink(device_name)
print(f"Symlink gelöscht: /dev/disc/{device_name}")
def main():
"""Hauptfunktion."""
if len(sys.argv) < 3:
print("Usage: udev_daemon.py <action> <device>")
sys.exit(1)
action = sys.argv[1]
device = sys.argv[2]
if action == "add":
handle_add(device)
elif action == "remove":
handle_remove(device)
else:
print(f"Unbekannte Aktion: {action}")
if __name__ == "__main__":
main()