Ampel / ampel (push) Failing after 46s
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>
256 lines
9.6 KiB
Python
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"
|