35cfcbcb07
Ampel / ampel (push) Successful in 27s
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.
## Die .ps1-Falle war loesbar, nicht unumgehbar
Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:
ohne BOM: $s = "Größe: äöü" + Unerwartetes Token -> Skript kaputt
mit BOM: Groesse: aeoeue + laeuft, Length 16 korrekt
Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".
## Umstellung: Text ja, Bezeichner nein
Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:
- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
`GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.
ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:
1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
"Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
"uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.
## Installer im Rippy-Look
Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.
Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
376 lines
15 KiB
Python
376 lines
15 KiB
Python
"""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/<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). Ä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 <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
|
|
|
|
|
|
# --- 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 ""
|