From c171f8879cb27a1c1cb3e131e55cbe6e67cde030 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 15:14:52 +0200 Subject: [PATCH] feat(schluessel): der Windows-PC holt die 4K-Schluessel jetzt selbst Auf Commander-Entscheid gebaut. Belegt am 25.07.2026 auf beiden Maschinen: `makemkvcon` unter LINUX ruft Disc-Schluessel NIE ab, die WINDOWS-Version schon (Meldung 3338). Deshalb scheiterte jede unbekannte UHD-Disc auf der VM mit "The volume key is unknown", und der Weg, der funktioniert, war Handarbeit: Laufwerk an den PC, Disc oeffnen, _private_data.tar suchen, im UI hochladen. Das laeuft jetzt von selbst - und zwar ZWEISCHICHTIG, mit Absicht: 1. Der WAECHTER (verlaesslich): sieht _private_data.tar nach und laedt sie zu Rippy hoch, sobald sie sich geaendert hat. Braucht keine Laufwerkserkennung, kein Disc-Oeffnen, nichts geraten. Deckt auch den Fall ab, dass man die Disc einfach in der MakeMKV-Oberflaeche oeffnet. 2. Das ANSTOSSEN (nach bestem Wissen): liegt eine Disc im Laufwerk, wird `makemkvcon info` darauf losgelassen - dabei holt MakeMKV den Schluessel. Warum getrennt: Das Format der BELEGTEN `DRV:`-Zeile liess sich auf dem Commander-PC nicht messen, weil dort kein optisches Laufwerk steckt (alle 16 Plaetze melden `DRV:i,256,999,0,"","",""` - das ist gemessen). Geraten wird also nur in Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach nichts - Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im verlaesslichen Teil. Gemessene Fundstellen: Datenverzeichnis ist `%USERPROFILE%\.MakeMKV` (NICHT %APPDATA%\MakeMKV, wie man vermuten wuerde) - dort lag die echte Datei mit 6.420.480 Bytes. Programm: C:\Program Files (x86)\MakeMKV\makemkvcon64.exe, v1.18.4. Hochgeladen wird mit ROHEM Koerper an POST /system/keystore, weil der API python-multipart fehlt. Die Automatik schaltet sich selbst ab, wenn MakeMKV nicht installiert ist: Auf einem reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede Minute ins Leere greift, waere nur Rauschen. Ihre Meldungen gehen ueber die Log-Bruecke auch nach Rippy - das ist genau die Auskunft, auf die man nach dem Einlegen einer neuen UHD-Disc wartet. Co-Authored-By: Claude Opus 5 --- docker/worker/schluessel.py | 252 +++++++++++++++++++++++++++++++ docker/worker/test_schluessel.py | 194 ++++++++++++++++++++++++ docker/worker/tray.py | 38 +++++ 3 files changed, 484 insertions(+) create mode 100644 docker/worker/schluessel.py create mode 100644 docker/worker/test_schluessel.py diff --git a/docker/worker/schluessel.py b/docker/worker/schluessel.py new file mode 100644 index 0000000..8fde149 --- /dev/null +++ b/docker/worker/schluessel.py @@ -0,0 +1,252 @@ +"""Schlüssel-Automatik für 4K-UHD — der Windows-PC holt, was Linux nie holt. + +## Das Problem, das das hier löst + +Belegt am 25.07.2026 auf BEIDEN Maschinen: `makemkvcon` unter **Linux** ruft +Disc-Schlüssel NIE ab — kein einziger Verbindungsversuch, geprüft mit leerem und +gefülltem Speicher, mit und ohne `--noscan`. Die **Windows**-Version tut es +(Meldung 3338). Deshalb scheitert jede unbekannte UHD-Disc auf der Rippy-VM mit +„The volume key is unknown", und der Weg, der funktioniert, war bisher +Handarbeit: Laufwerk an den Windows-PC, Disc öffnen, `_private_data.tar` suchen, +im UI hochladen. + +Das macht diese Datei automatisch. + +## Zwei Schichten, absichtlich getrennt + +**1. Der Wächter (verlässlich).** Er sieht `_private_data.tar` nach und lädt sie +zu Rippy hoch, sobald sie sich geändert hat. Er braucht keine +Laufwerkserkennung, kein Disc-Öffnen, nichts geraten — wenn MakeMKV neue +Schlüssel gelernt hat, wandern sie rüber. Das deckt auch den Fall ab, dass der +Nutzer die Disc einfach in der MakeMKV-Oberfläche öffnet. + +**2. Das Anstoßen (nach bestem Wissen).** Liegt eine Disc im Laufwerk dieses PCs, +wird `makemkvcon info` darauf losgelassen — dabei holt MakeMKV den Schlüssel. + +Warum die Trennung: Das Format der belegten `DRV:`-Zeile konnte auf dem +Commander-PC nicht gemessen werden (dort steckt kein optisches Laufwerk, alle 16 +Plätze melden `DRV:i,256,999,0,"","",""` — gemessen). Geraten wird deshalb nur in +Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach nichts — +Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im verlässlichen Teil. + +## Gemessene Fundstellen (AGENTS Regel D) + +- Datenverzeichnis unter Windows: `%USERPROFILE%\\.MakeMKV` — dort lag die echte + `_private_data.tar` (6.420.480 Bytes). NICHT `%APPDATA%\\MakeMKV`, wie man + vermuten würde. +- Programm: `C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe` (v1.18.4). +- Laufwerksliste: `makemkvcon -r --cache=1 info disc:9999` gibt `DRV:`-Zeilen. +- Hochladen: `POST /api/system/keystore` mit ROHEM Körper (kein JSON, kein + Multipart — die API hat kein python-multipart). +""" + +import os +import re +import subprocess +import urllib.request + +# `DRV:,,,,"","",""` +DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"') + +# Wie oft nachgesehen wird. Eine Minute reicht: Discs wechseln nicht im +# Sekundentakt, und ein `makemkvcon info` belastet das Laufwerk. +TAKT_SEKUNDEN = 60 + + +def daten_verzeichnis() -> str: + """MakeMKVs Datenverzeichnis auf DIESEM Rechner (leer, wenn nicht gefunden). + + `%USERPROFILE%\\.MakeMKV` ist der gemessene Ort (26.07.2026 auf dem + Commander-PC, MakeMKV 1.18.4). Die beiden anderen Kandidaten stehen als + Rückfall drin, weil MakeMKV über die Versionen umgezogen ist — geprüft wird, + welcher wirklich existiert, statt einen zu behaupten. + """ + profil = os.getenv("USERPROFILE") or os.path.expanduser("~") + kandidaten = [ + os.path.join(profil, ".MakeMKV"), + os.path.join(os.getenv("APPDATA") or "", "MakeMKV"), + os.path.join(os.getenv("LOCALAPPDATA") or "", "MakeMKV"), + ] + for pfad in kandidaten: + if pfad and os.path.isdir(pfad): + return pfad + return "" + + +def schluesseldatei() -> str: + """Voller Pfad zu `_private_data.tar` (leer, wenn es sie nicht gibt).""" + ordner = daten_verzeichnis() + if not ordner: + return "" + datei = os.path.join(ordner, "_private_data.tar") + return datei if os.path.isfile(datei) else "" + + +def makemkvcon_pfad() -> str: + """makemkvcon auf dieser Maschine (leer, wenn MakeMKV nicht installiert ist). + + Die 64-Bit-Variante zuerst — auf dem Commander-PC liegen beide, und die + 32-Bit-Version ist nur noch Beiwerk. + """ + import shutil + + fest = [ + r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe", + r"C:\Program Files (x86)\MakeMKV\makemkvcon.exe", + r"C:\Program Files\MakeMKV\makemkvcon64.exe", + r"C:\Program Files\MakeMKV\makemkvcon.exe", + ] + for pfad in fest: + if os.path.isfile(pfad): + return pfad + return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "" + + +def parse_laufwerke(ausgabe: str) -> list: + """`DRV:`-Zeilen → Laufwerke mit Disc (pure Funktion). + + Rückgabe: [{"index", "laufwerk", "disc", "geraet"}] — NUR Einträge, bei denen + ein Disc-Name steht. Ein leerer Platz sieht so aus (gemessen): + + DRV:0,256,999,0,"","","" + + ⚠️ Die BELEGTE Form ist nicht gemessen — auf dem Commander-PC steckt kein + optisches Laufwerk. Deshalb ist die Regel bewusst konservativ: ohne + Disc-Namen gilt „nichts da", und dann tut die Automatik einfach nichts. + Falsch-negativ ist hier harmlos (der Wächter greift trotzdem), + falsch-positiv wäre ein `makemkvcon`-Lauf ins Leere. + """ + gefunden = [] + for zeile in (ausgabe or "").splitlines(): + treffer = DRV_ZEILE.match(zeile.strip()) + if not treffer: + continue + index, _zustand, _flags, _x, laufwerk, disc, geraet = treffer.groups() + if not disc.strip(): + continue + gefunden.append({ + "index": int(index), + "laufwerk": laufwerk, + "disc": disc, + "geraet": geraet, + }) + return gefunden + + +def datei_stand(pfad: str) -> tuple: + """(Größe, Änderungszeit) — die Kennung, an der eine Änderung auffällt.""" + try: + s = os.stat(pfad) + return (s.st_size, int(s.st_mtime)) + except OSError: + return (0, 0) + + +def hat_sich_geaendert(vorher: tuple, jetzt: tuple) -> bool: + """Lohnt ein Upload? (pure Funktion) + + Nur wenn die Datei EXISTIERT und sich unterscheidet. Beim allerersten Lauf + (vorher = None) wird ebenfalls hochgeladen: Rippy soll den Bestand kennen, + auch wenn MakeMKV gerade nichts Neues gelernt hat. + """ + if jetzt == (0, 0): + return False + return vorher is None or vorher != jetzt + + +def laufwerke_lesen(programm: str, laufen=None, timeout: int = 120) -> list: + """Welche Laufwerke dieses PCs haben eine Disc? (leer bei jedem Fehler)""" + if not programm: + return [] + starten = laufen or subprocess.run + try: + ergebnis = starten( + [programm, "-r", "--cache=1", "info", "disc:9999"], + capture_output=True, text=True, timeout=timeout, + errors="replace", + ) + except (OSError, subprocess.TimeoutExpired): + return [] + return parse_laufwerke(ergebnis.stdout or "") + + +def disc_oeffnen(programm: str, index: int, laufen=None, timeout: int = 600) -> bool: + """Lässt MakeMKV die Disc lesen — dabei holt es unter Windows den Schlüssel. + + Der Rückgabewert sagt nur, ob der Aufruf durchlief. Ob ein Schlüssel dabei + herauskam, entscheidet allein die Datei — deshalb wird danach ihr Stand + verglichen und nicht diese Antwort geglaubt. + """ + if not programm: + return False + starten = laufen or subprocess.run + try: + starten( + [programm, "-r", "--noscan", "info", f"disc:{index}"], + capture_output=True, text=True, timeout=timeout, errors="replace", + ) + return True + except (OSError, subprocess.TimeoutExpired): + return False + + +def hochladen(host: str, datei: str, oeffner=None, timeout: int = 120) -> str: + """`_private_data.tar` zu Rippy schicken. "" = geklappt, sonst der Fehler. + + ROHER Körper, kein JSON und kein Multipart — genau so nimmt + `POST /system/keystore` die Datei an (der API fehlt python-multipart). + """ + if not host: + return "keine Rippy-Adresse gesetzt" + if not datei: + return "keine Schlüsseldatei gefunden" + try: + with open(datei, "rb") as f: + inhalt = f.read() + except OSError as e: + return f"Schlüsseldatei nicht lesbar: {e}" + + anfrage = urllib.request.Request( + f"http://{host}/api/system/keystore", data=inhalt, method="POST", + headers={"Content-Type": "application/octet-stream"}, + ) + macher = oeffner or urllib.request.urlopen + try: + with macher(anfrage, timeout=timeout) as antwort: + antwort.read() + return "" + except Exception as e: + return f"Hochladen fehlgeschlagen: {e}" + + +def runde(host: str, letzter_stand, melden=None, programm=None, + laufen=None, oeffner=None) -> tuple: + """Ein Durchlauf der Automatik. Rückgabe: (neuer_stand, was_passiert_ist). + + Reihenfolge mit Absicht: ERST anstoßen (falls eine Disc liegt), DANN die + Datei vergleichen. So wird ein gerade geholter Schlüssel in derselben Runde + mitgenommen, statt eine Minute zu warten. + """ + sage = melden or (lambda level, text: None) + prog = programm if programm is not None else makemkvcon_pfad() + if not prog: + return letzter_stand, "kein-makemkv" + + for laufwerk in laufwerke_lesen(prog, laufen=laufen): + sage("info", f"Disc erkannt: {laufwerk['disc']} — MakeMKV liest sie, " + "um den Disc-Schlüssel zu holen") + disc_oeffnen(prog, laufwerk["index"], laufen=laufen) + + datei = schluesseldatei() + jetzt = datei_stand(datei) if datei else (0, 0) + if not hat_sich_geaendert(letzter_stand, jetzt): + return letzter_stand, "unveraendert" + + fehler = hochladen(host, datei, oeffner=oeffner) + if fehler: + sage("warning", f"Schlüsselspeicher konnte nicht zu Rippy: {fehler}") + return letzter_stand, "fehler" + sage("success", + f"Schlüsselspeicher an Rippy übergeben ({jetzt[0]} Bytes) — " + "wirkt ab dem nächsten Rip") + return jetzt, "hochgeladen" diff --git a/docker/worker/test_schluessel.py b/docker/worker/test_schluessel.py new file mode 100644 index 0000000..9ffbb5b --- /dev/null +++ b/docker/worker/test_schluessel.py @@ -0,0 +1,194 @@ +"""Tests der Schlüssel-Automatik — ohne MakeMKV, ohne Laufwerk, ohne Netz. + +Die Testdaten sind gemessen (26.07.2026, Commander-PC, MakeMKV 1.18.4): +`makemkvcon -r --cache=1 info disc:9999` auf einer Maschine OHNE optisches +Laufwerk gibt sechzehn leere `DRV:`-Zeilen. Die BELEGTE Form ließ sich dort nicht +messen — deshalb prüfen die Tests vor allem, dass die Automatik im Zweifel +NICHTS tut. +""" + +import schluessel + +# Wörtlich aus dem echten Lauf (gekürzt auf drei der sechzehn Zeilen). +DRV_LEER = """MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)" +MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","Das Programm konnte keine verwendbaren optischen Laufwerke finden." +DRV:0,256,999,0,"","","" +DRV:1,256,999,0,"","","" +DRV:2,256,999,0,"","","" +MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","Das Öffnen der Disk schlug fehl " +TCOUNT:0 +""" + +# So sieht eine belegte Zeile AUS SICHT DES FORMATS aus — nicht gemessen, +# deshalb steht sie nur hier im Test und nicht als Annahme im Code. +DRV_MIT_DISC = """DRV:0,2,999,12,"BD-RE HL-DT-ST BU40N","AKIRA","D:" +DRV:1,256,999,0,"","","" +""" + + +def test_leere_laufwerke_ergeben_nichts(): + """Der gemessene Fall: kein optisches Laufwerk → die Automatik tut nichts.""" + assert schluessel.parse_laufwerke(DRV_LEER) == [] + + +def test_kein_disc_name_gilt_als_nichts_da(): + """Konservativ mit Absicht: Ein falsch-negativ ist harmlos (der Wächter + greift trotzdem), ein falsch-positiv wäre ein makemkvcon-Lauf ins Leere.""" + assert schluessel.parse_laufwerke('DRV:0,2,999,12,"BD-RE BU40N","",""') == [] + + +def test_belegtes_laufwerk_wird_gefunden(): + gefunden = schluessel.parse_laufwerke(DRV_MIT_DISC) + assert len(gefunden) == 1 + assert gefunden[0]["index"] == 0 + assert gefunden[0]["disc"] == "AKIRA" + assert gefunden[0]["geraet"] == "D:" + + +def test_muell_und_leer_stuerzen_nicht_ab(): + assert schluessel.parse_laufwerke("") == [] + assert schluessel.parse_laufwerke(None) == [] + assert schluessel.parse_laufwerke("irgendwas ganz anderes") == [] + + +# --- Wann hochgeladen wird -------------------------------------------------- + + +def test_erste_runde_laedt_immer_hoch(): + """Rippy soll den Bestand kennen, auch wenn MakeMKV gerade nichts Neues + gelernt hat.""" + assert schluessel.hat_sich_geaendert(None, (6420480, 1753000000)) is True + + +def test_unveraenderte_datei_wird_nicht_wieder_geschickt(): + stand = (6420480, 1753000000) + assert schluessel.hat_sich_geaendert(stand, stand) is False + + +def test_geaenderte_datei_wird_geschickt(): + assert schluessel.hat_sich_geaendert( + (6420480, 1753000000), (6500000, 1753009999)) is True + + +def test_fehlende_datei_loest_keinen_upload_aus(): + """(0, 0) heißt „gibt es nicht" — dann gibt es auch nichts zu schicken.""" + assert schluessel.hat_sich_geaendert(None, (0, 0)) is False + assert schluessel.hat_sich_geaendert((6420480, 1753000000), (0, 0)) is False + + +# --- Der Upload ------------------------------------------------------------- + + +def test_upload_schickt_den_rohen_koerper(tmp_path): + """Kein JSON, kein Multipart: genau so nimmt POST /system/keystore die Datei + an (der API fehlt python-multipart).""" + datei = tmp_path / "_private_data.tar" + datei.write_bytes(b"TAR-INHALT") + gesehen = {} + + class Antwort: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return b"" + + def oeffner(anfrage, timeout=None): + gesehen["url"] = anfrage.full_url + gesehen["daten"] = anfrage.data + gesehen["typ"] = anfrage.headers.get("Content-type") + return Antwort() + + assert schluessel.hochladen("1.2.3.4", str(datei), oeffner=oeffner) == "" + assert gesehen["url"] == "http://1.2.3.4/api/system/keystore" + assert gesehen["daten"] == b"TAR-INHALT" + assert gesehen["typ"] == "application/octet-stream" + + +def test_upload_ohne_adresse_oder_datei_meldet_klartext(): + assert "Adresse" in schluessel.hochladen("", "/x.tar") + assert "Schlüsseldatei" in schluessel.hochladen("1.2.3.4", "") + + +def test_upload_fehler_wird_gemeldet_nicht_geworfen(tmp_path): + """Der Worker muss weiterarbeiten, auch wenn Rippy gerade weg ist.""" + datei = tmp_path / "_private_data.tar" + datei.write_bytes(b"x") + + def kaputt(anfrage, timeout=None): + raise OSError("Netz weg") + + fehler = schluessel.hochladen("1.2.3.4", str(datei), oeffner=kaputt) + assert "Hochladen fehlgeschlagen" in fehler + + +# --- Eine ganze Runde ------------------------------------------------------- + + +def test_runde_ohne_makemkv_tut_nichts(): + """Auf einem PC ohne MakeMKV darf die Automatik nicht herumfummeln.""" + stand, was = schluessel.runde("1.2.3.4", None, programm="") + assert (stand, was) == (None, "kein-makemkv") + + +def test_runde_stoesst_erst_an_und_prueft_dann(monkeypatch, tmp_path): + """Reihenfolge mit Absicht: Ein gerade geholter Schluessel soll in DERSELBEN + Runde mitgenommen werden, nicht erst eine Minute spaeter.""" + datei = tmp_path / "_private_data.tar" + datei.write_bytes(b"neu") + monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei)) + + ablauf = [] + + def laufen(cmd, **kwargs): + # disc:9999 = Laufwerke auflisten, disc:0 = Disc oeffnen + ablauf.append(cmd[-1]) + + class E: + stdout = DRV_MIT_DISC if cmd[-1] == "disc:9999" else "" + return E() + + class Antwort: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def read(self): + return b"" + + meldungen = [] + stand, was = schluessel.runde( + "1.2.3.4", None, melden=lambda lvl, t: meldungen.append((lvl, t)), + programm="makemkvcon.exe", laufen=laufen, + oeffner=lambda a, timeout=None: Antwort()) + + assert ablauf == ["disc:9999", "disc:0"] + assert was == "hochgeladen" + assert stand == schluessel.datei_stand(str(datei)) + assert any("Disc erkannt" in t for _, t in meldungen) + assert any("wirkt ab dem nächsten Rip" in t for _, t in meldungen) + + +def test_runde_ohne_aenderung_schickt_nichts(monkeypatch, tmp_path): + datei = tmp_path / "_private_data.tar" + datei.write_bytes(b"alt") + monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei)) + vorher = schluessel.datei_stand(str(datei)) + + def laufen(cmd, **kwargs): + class E: + stdout = DRV_LEER + return E() + + def darf_nicht(anfrage, timeout=None): + raise AssertionError("kein Upload erwartet") + + stand, was = schluessel.runde( + "1.2.3.4", vorher, programm="makemkvcon.exe", laufen=laufen, + oeffner=darf_nicht) + assert (stand, was) == (vorher, "unveraendert") diff --git a/docker/worker/tray.py b/docker/worker/tray.py index 49db8a3..8a40b8f 100644 --- a/docker/worker/tray.py +++ b/docker/worker/tray.py @@ -254,6 +254,37 @@ def _job_holen(): return None +def _schluessel_wache(bruecke=None): + """Holt 4K-Disc-Schlüssel und schiebt sie zu Rippy (siehe schluessel.py). + + Der Sinn in einem Satz: `makemkvcon` unter LINUX ruft Disc-Schlüssel nie ab, + die WINDOWS-Version schon — und dieser Worker läuft auf Windows. Damit fällt + die Handarbeit weg, `_private_data.tar` nach jeder neuen UHD-Disc selbst + hinüberzutragen. + + Schaltet sich selbst ab, wenn MakeMKV hier nicht installiert ist: Auf einem + reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede + Minute ins Leere greift, wäre nur Rauschen. + """ + import schluessel + + if not schluessel.makemkvcon_pfad(): + return + + def melden(level, text): + if bruecke: + bruecke.zeile(f"[schluessel] {level.upper()} {text}") + print(f"Schlüssel-Automatik: {text}") + + stand = None + while True: + try: + stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden) + except Exception as e: # darf den Worker nie mitnehmen + print(f"Schlüssel-Automatik fehlgeschlagen: {type(e).__name__}: {e}") + time.sleep(schluessel.TAKT_SEKUNDEN) + + def _beobachter(icon): """Fragt im Takt nach, was läuft — für Menütext und Standby-Sperre.""" while True: @@ -377,6 +408,13 @@ if __name__ == "__main__": threading.Thread( target=_beobachter, args=(tray,), daemon=True, name="job-beobachter" ).start() + # Schlüssel-Automatik für 4K-UHD. Eigene Log-Brücke, damit ihre Meldungen + # auch in Rippy landen ("Schlüsselspeicher übergeben") — das ist genau die + # Auskunft, auf die man nach dem Einlegen einer neuen UHD-Disc wartet. + threading.Thread( + target=_schluessel_wache, args=(_bruecke_bauen(),), + daemon=True, name="schluessel-wache", + ).start() try: tray.run() finally: