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>
This commit is contained in:
Hitonabi
2026-07-25 01:26:08 +02:00
parent f74f4e54f6
commit 0935766f61
23 changed files with 1717 additions and 69 deletions
+141
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
@@ -1190,6 +1191,146 @@ async def system_info():
return await asyncio.to_thread(sammle)
# Eigene Wurzel für die Datei-Härtung der AACS-Dumps. Bewusst NICHT die
# MEDIA_ROOT-Helfer (_sicherer_dateiname/_validiere_ziel/_job_ausgabeordner):
# die prüfen hart gegen /app/media und würden hier IMMER 404 liefern.
# Das MakeMKV-Datenverzeichnis liegt woanders (in der API auf
# /app/makemkv-data, im Worker auf /root/.MakeMKV — laut docker-compose.yml
# beides dasselbe Host-Verzeichnis).
MAKEMKV_DATA_ROOT = os.path.realpath(makemkv_daten.DATEN_DIR)
class KeydbRequest(BaseModel):
inhalt: str # voller Text der KEYDB.cfg (kein Upload — es gibt kein python-multipart)
@app.get("/system/keydb")
async def get_keydb_status():
"""Was liegt gerade als KEYDB.cfg im MakeMKV-Datenverzeichnis?
Hintergrund (Befund 25.07.2026, live auf der VM nachgemessen): Bei
4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc" und
holt den Schlüssel NICHT mehr online nach — die dokumentierten
Schlüssel-Server lösen weltweit nicht mehr auf. Der einzige heute
funktionierende Weg ist eine KEYDB.cfg, die der Nutzer selbst mitbringt.
Rippy liefert KEINE Schlüssel mit, lädt keine herunter und verteilt keine —
es stellt nur den Platz bereit und zeigt ehrlich an, was dort liegt.
Fehlendes Verzeichnis oder fehlende Datei ist der NORMALFALL: dann kommt
200 mit vorhanden=false zurück, niemals 404 oder 500.
"""
def sammle():
return makemkv_daten.keydb_status()
return await asyncio.to_thread(sammle)
@app.post("/system/keydb")
async def set_keydb(request: KeydbRequest):
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
WICHTIG für die Ehrlichkeit: Die Datei wirkt erst beim NÄCHSTEN Rip —
makemkvcon liest sie beim Prozessstart, ein bereits laufender Rip merkt
nichts davon. Genau so steht es auch im Log-Eintrag.
"""
# Reine Prüfung (kein Dateisystem) — fängt den häufigsten Bedienfehler ab:
# statt der KEYDB.cfg landet die HTML-Fehlerseite eines Downloads im Feld.
fehler = makemkv_daten.keydb_pruefen(request.inhalt)
if fehler:
raise HTTPException(status_code=422, detail=fehler)
def schreibe():
return makemkv_daten.keydb_schreiben(request.inhalt)
try:
status = await asyncio.to_thread(schreibe)
except OSError as e:
raise HTTPException(
status_code=500,
detail=(
f"KEYDB.cfg konnte nicht geschrieben werden: {e}. "
"Prüfe, ob das MakeMKV-Datenverzeichnis auf der VM existiert und "
"beschreibbar ist (Standard: /srv/rippy/makemkv)."
),
)
await asyncio.to_thread(
db.add_log, "success", "makemkv-keydb",
f"KEYDB.cfg abgelegt: {status['eintraege']} Zeilen mit Disc-Kennung, "
f"{status['groesse_bytes']} Bytes ({status['pfad']}). "
"Wirkt erst beim NÄCHSTEN Rip — MakeMKV liest die Datei beim Start.",
)
return status
@app.delete("/system/keydb")
async def delete_keydb():
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Einfügen).
Auch hier gilt: Die Änderung wirkt erst beim NÄCHSTEN Rip. Fehlt die Datei
schon, ist das kein Fehler — es kommt derselbe Zustand mit vorhanden=false.
"""
def loesche():
return makemkv_daten.keydb_loeschen()
try:
status = await asyncio.to_thread(loesche)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"KEYDB.cfg konnte nicht entfernt werden: {e}",
)
await asyncio.to_thread(
db.add_log, "warning", "makemkv-keydb",
"KEYDB.cfg entfernt. Ab dem NÄCHSTEN Rip fehlen die selbst mitgebrachten "
"Schlüssel wieder — UHD-Discs können dann erneut an "
"'The volume key is unknown for this disc' scheitern.",
)
return status
@app.get("/system/aacs-dumps")
async def get_aacs_dumps():
"""AACS-Dumps, die MakeMKV selbst abgelegt hat (neueste zuerst).
MakeMKV schreibt sie beim gescheiterten UHD-Versuch ins Datenverzeichnis
(Meldung 3332 "Saved AACS dump file as file:///root/.MakeMKV/<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
+242
View File
@@ -0,0 +1,242 @@
"""MakeMKV-Datenverzeichnis: KEYDB.cfg, AACS-Dumps, settings.conf.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, live im Worker nachgemessen):
Bei 4K-UHD-Discs meldet MakeMKV "The volume key is unknown for this disc".
Das Laufwerk ist dabei in Ordnung — makemkvcon meldet "Using LibreDrive mode
(v06.3)" und liest die Disc — MakeMKV kennt nur den Schluessel DIESER Pressung
nicht. Und es holt ihn NICHT mehr online nach:
* /root/.MakeMKV/_private_data.tar enthielt ausser der Index-Datei keine
einzige hkd_*.bin, also nie einen heruntergeladenen Schluessel;
* auch mit erzwungener frischer Pruefung (update.conf geloescht, Meldung
5074 belegt den Web-Kontakt) und mit app_UpdateEnable = "1" kam keiner;
* die im MakeMKV-Forum dokumentierten Schluessel-Server hkdata.fairuse.org
und hkdata.crabdance.com loesen weltweit nicht mehr auf (gegen Fritz!Box,
8.8.8.8 und 1.1.1.1 geprueft: NXDOMAIN).
Betroffen war Akira UHD (MKB v76, Pressung von Dezember 2020) — also gerade
KEINE Neuerscheinung. Die bisherige Fehlermeldung ("Disc neuer als die
Schluessel-Datenbank, mit einem der naechsten Updates rippbar") war damit
schlicht falsch.
Der einzige heute funktionierende Weg ist eine KEYDB.cfg im
MakeMKV-Datenverzeichnis. Rippy liefert KEINE Schluessel mit, laedt keine
herunter und verteilt keine — es stellt nur den Platz dafuer bereit und zeigt
ehrlich an, was dort liegt. Die Datei bringt der Nutzer selbst mit.
QUELLEN (AGENTS Regel D — externe Schnittstellen nie aus dem Kopf):
* Datenverzeichnis und Dateiname GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Heruntergeladene Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<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 os
import re
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze fuer den Upload. Eine vollstaendige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zaehlt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zaehlen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollstaendige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden — sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prueft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurueck.
Verhindert den haeufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis —
MakeMKV wuerde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist groesser als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Groesse/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: waehrend ein Rip laeuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt — das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <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
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
+7 -1
View File
@@ -18,7 +18,13 @@ def test_main_importierbar_und_routen_verdrahtet():
from main import app
routen = {route.path for route in app.routes}
for pfad in ("/health", "/jobs", "/devices", "/logs", "/settings", "/prescan"):
for pfad in (
"/health", "/jobs", "/devices", "/logs", "/settings", "/prescan",
# KEYDB.cfg + AACS-Dumps: der Platz für die selbst mitgebrachte
# Schlüsseldatei (Befund 25.07.2026 — MakeMKV holt UHD-Schlüssel nicht
# mehr online nach). Ohne diese Routen ist die Seite im UI tot.
"/system/keydb", "/system/aacs-dumps", "/system/aacs-dumps/{dateiname}",
):
assert pfad in routen, f"Route {pfad} fehlt"
+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("", "") == ""