"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, 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 läuft 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 gefülltem Schlüsselspeicher, mit und ohne --noscan, mit dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung (update.conf gelöscht, 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 wächst — und die Disc geht auf (TCOUNT:5, "Operation successfully completed"). * Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage "Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst 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 stützte 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 Schlüsselspeicher (_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 für Pressungen, die auch MakeMKV selbst nicht kennt. Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine. Es verwaltet nur, was der Nutzer selbst mitbringt. QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf): * Linux lädt 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/.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). Änderungen 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 für den Upload. Eine vollständige 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) zählt 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 zählen (pure Funktion, testbar). Bewusst eine Heuristik und keine vollständige 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: """Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück. Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis — MakeMKV würde 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 größer 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: Größe/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: während ein Rip läuft, darf makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen. """ pfad = keydb_pfad(daten_dir) os.makedirs(os.path.dirname(pfad), exist_ok=True) neben = pfad + ".neu" try: with open(neben, "w", encoding="utf-8", newline="\n") as datei: datei.write(inhalt) os.replace(neben, pfad) except OSError: # Die Nebendatei nie liegen lassen: eine halb geschriebene # KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und # belegt im Extremfall 64 MB, die niemand mehr aufräumt. try: os.remove(neben) except OSError: pass raise return keydb_status(daten_dir) def keydb_loeschen(daten_dir: str = None) -> dict: """Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen). Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg, den es nicht gab, und die Datei wirkte beim nächsten Rip weiter. """ try: os.remove(keydb_pfad(daten_dir)) except FileNotFoundError: pass return keydb_status(daten_dir) def ist_aacs_dump(name: str) -> bool: """Dateiname eines AACS-Dumps? (pure Funktion, testbar) MakeMKV legt ihn als .tgz direkt im Datenverzeichnis ab (Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt. """ return ( name.endswith(".tgz") and "/" not in name and "\\" not in name and not name.startswith(".") ) def dumps_auflisten(daten_dir: str = None) -> list: """Alle AACS-Dumps im Datenverzeichnis, neueste zuerst.""" ordner = daten_dir or DATEN_DIR try: namen = os.listdir(ordner) except OSError: return [] liste = [] for name in namen: if not ist_aacs_dump(name): continue try: angaben = os.stat(os.path.join(ordner, name)) except OSError: continue liste.append( { "name": name, "groesse_bytes": angaben.st_size, "geaendert": _iso(angaben.st_mtime), "_sort": angaben.st_mtime, } ) liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True) for eintrag in liste: del eintrag["_sort"] return liste # --- Schlüsselspeicher (_private_data.tar) ------------------------------- # # Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit # hkd_*.bin-Eintraegen. Unter Windows füllt 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 nächsten Start. PRIVATE_DATA_NAME = "_private_data.tar" # Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen # Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie # client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher, # würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht. MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024 def private_data_pfad(daten_dir: str = None) -> str: """Voller Pfad zum Schlüsselspeicher 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 für "wie viele Disc-Schluessel kennt diese Installation". Ein frischer, leerer Speicher enthält 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: """Prüft 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 ändert 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 größer als " f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein " "MakeMKV-Schlüsselspeicher." ) 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 — öffne dort erst einmal eine Disc." ) return "" def schluesselspeicher_status(daten_dir: str = None) -> dict: """Zustand des Schlüsselspeichers. 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 Schlüsselspeicher atomar ab und meldet den neuen Zustand. Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft, 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 überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit bei jedem Containerstart und vor jedem Rip alles andere weg — z. B. app_UpdateEnable. Leerer Key lässt 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 ""