Files
rippy/docker/worker/schluessel.py
HitonabiandClaude Opus 5 1b84ec2a45
Ampel / ampel (push) Successful in 1m20s
fix(windows): Vollstaendiger Rundgang durch die Docker-Reste
Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."

Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.

## 1. `/dev/{name}` in drei Endpunkten — der schwerste

Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.

Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.

## 2. `os.path.isdir("/app")` — zum zweiten Mal

Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.

## 3. `shutil.which` in `schluessel.py`

Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.

## 4. `posixpath.join` auf echten Pfaden

`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.

## 5. Container-Pfad in einer Nutzermeldung

„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.

## 6. Container-Pfade als UI-Vorbelegung

Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.

## Und HandBrakes „Code 0"

Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.

## Der Waechter

`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.

Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.

887 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:14:10 +02:00

263 lines
10 KiB
Python

"""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
from rippy.platform.winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
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
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
from rippy.tools import katalog
return (katalog.finden("makemkv")
or 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", creationflags=OHNE_FENSTER,
)
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",
creationflags=OHNE_FENSTER,
)
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"