6438beec05
Ampel / ampel (push) Successful in 31s
Zwei Meldungen, beide auf dieselbe Wurzel zurueckgefuehrt: Der Worker startet
Konsolenprogramme, und das hat unter Windows Nebenwirkungen.
1. "Es geht immer alle paar Sekunden ne CMD auf." Kein Geist, sondern der
HERZSCHLAG. Der Worker laeuft als pythonw.exe, also ohne eigene Konsole. Wer
ohne Konsole ein Konsolenprogramm startet, bekommt von Windows eine NEUE - und
die ist sichtbar. `capture_output=True` hilft nicht: es leitet die Datenstroeme
um, unterdrueckt aber kein Fenster. Und caps.py fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu makemkvcon aus
der Schluessel-Automatik: vier bis fuenf Fenster pro Minute, in Schueben.
Neues winlauf.py haelt CREATE_NO_WINDOW an EINER Stelle; alle elf Aufrufe in
caps.py, ripping.py und schluessel.py benutzen es. Auf Linux ist der Wert 0 und
creationflags=0 eine Nulloperation - im Worker-Container gegengeprueft, damit
derselbe Code auf beiden Seiten laeuft.
2. Die 7200 Sekunden sind CELERYS EIGENE UMRECHNUNG, nicht die Uhren. Erst fiel
auf, dass die Sekundenbruchteile nur 40 ms auseinanderliegen (.177269 vs
.134799) - derselbe Augenblick, zweimal verschieden gerechnet. Die Quelle sagt
warum (celery/utils/time.py):
def utcoffset(): # Sekunden WEST von UTC, in Stunden
return time.altzone // 3600 # CEST -> -2 ; UTC -> 0
def adjust_timestamp(ts, offset, here=utcoffset):
return ts - (offset - here()) * 3600
Absender VM (offset 0), Empfaenger Windows-PC (here() = -2):
ts - (0 - (-2)) * 3600 = ts - 7200. Celery verschiebt den empfangenen Stempel
also selbst und vergleicht ihn dann mit der eigenen Uhr. Die Annahme dahinter -
Ereignisse truegen Ortszeit - stimmt nicht, `Event()` stempelt Epoch.
Abhilfe gemessen, nicht geraten: Mit TZ=UTC meldet Windows utcoffset 0
(timezone=0, isdst=0 - auf dem Commander-PC geprueft), damit wird die
Umrechnung zur Nulloperation. Beide .bat-Dateien setzen es jetzt. Nebeneffekt
ist erwuenscht: Der Worker rechnet dann in derselben Zone wie die VM und wie
Rippys UI.
Fuer Rippy war die Meldung ohnehin folgenlos - /capabilities fragt per
Celery-Ping (Frage/Antwort, ohne Zeitstempel), nicht ueber die
Gossip-Ereignisse. Aber eine Warnung, die bei jedem Blick ins Log steht und
nichts bedeutet, kostet Vertrauen.
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 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"
|