Files
rippy/docker/worker/schluessel.py
T
HitonabiandClaude Opus 5 059183c651
Ampel / ampel (push) Failing after 46s
feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.

DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:

1. EINTRAG IM TASKMANAGER
     ProcessName   Rippy
     Beschreibung  Rippy — automatisches Ripping
     Produkt       Rippy
     Version       2.0.0
   Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
   die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
   Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
   sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.

2. EINTRAG IN PROGRAMME UND FEATURES
   Aus der Registry zurueckgelesen, nicht behauptet:
     DisplayName      Rippy
     DisplayVersion   2.0.0
     Publisher        Rippy
     InstallLocation  <Zielordner>
     UninstallString  "<pfad>\Rippy.exe" --deinstallieren
     EstimatedSize    29241  (KiB)
     NoModify/NoRepair 1
   HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
   Installation laeuft aber ohne UAC-Abfrage durch.

VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.

VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:

1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
   Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
   Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
   grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
   schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
   store.verbinden() kaeme immer zu spaet.

2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
   `from celery_client import celery_client` loeste es sogar dann aus, wenn
   nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
   Ansage statt eines Importfehlers.

3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
   Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
   Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
   Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
   scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
   Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
   Prozesse; der Starter haelt die Datei nach dem Ende noch offen).

4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
   Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
   geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
   steht in der Registry (InstallLocation) und wird jetzt dort gelesen.

WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.

GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:39 +02:00

256 lines
9.6 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
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", 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"