"""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"