feat(schluessel): der Windows-PC holt die 4K-Schluessel jetzt selbst
Ampel / ampel (push) Successful in 31s
Ampel / ampel (push) Successful in 31s
Auf Commander-Entscheid gebaut. Belegt am 25.07.2026 auf beiden Maschinen:
`makemkvcon` unter LINUX ruft Disc-Schluessel NIE ab, die WINDOWS-Version schon
(Meldung 3338). Deshalb scheiterte jede unbekannte UHD-Disc auf der VM mit "The
volume key is unknown", und der Weg, der funktioniert, war Handarbeit: Laufwerk
an den PC, Disc oeffnen, _private_data.tar suchen, im UI hochladen.
Das laeuft jetzt von selbst - und zwar ZWEISCHICHTIG, mit Absicht:
1. Der WAECHTER (verlaesslich): sieht _private_data.tar nach und laedt sie zu
Rippy hoch, sobald sie sich geaendert hat. Braucht keine
Laufwerkserkennung, kein Disc-Oeffnen, nichts geraten. Deckt auch den Fall
ab, dass man die Disc einfach in der MakeMKV-Oberflaeche oeffnet.
2. Das ANSTOSSEN (nach bestem Wissen): liegt eine Disc im Laufwerk, wird
`makemkvcon info` darauf losgelassen - dabei holt MakeMKV den Schluessel.
Warum getrennt: Das Format der BELEGTEN `DRV:`-Zeile liess sich auf dem
Commander-PC nicht messen, weil dort kein optisches Laufwerk steckt (alle 16
Plaetze melden `DRV:i,256,999,0,"","",""` - das ist gemessen). Geraten wird also
nur in Schicht 2, und wenn die Vermutung falsch ist, passiert dort einfach
nichts - Schicht 1 arbeitet weiter. Die teure Annahme steckt nie im
verlaesslichen Teil.
Gemessene Fundstellen: Datenverzeichnis ist `%USERPROFILE%\.MakeMKV` (NICHT
%APPDATA%\MakeMKV, wie man vermuten wuerde) - dort lag die echte Datei mit
6.420.480 Bytes. Programm: C:\Program Files (x86)\MakeMKV\makemkvcon64.exe,
v1.18.4. Hochgeladen wird mit ROHEM Koerper an POST /system/keystore, weil der
API python-multipart fehlt.
Die Automatik schaltet sich selbst ab, wenn MakeMKV nicht installiert ist: Auf
einem reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede
Minute ins Leere greift, waere nur Rauschen. Ihre Meldungen gehen ueber die
Log-Bruecke auch nach Rippy - das ist genau die Auskunft, auf die man nach dem
Einlegen einer neuen UHD-Disc wartet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"""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
|
||||
|
||||
# `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",
|
||||
)
|
||||
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",
|
||||
)
|
||||
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"
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests der Schlüssel-Automatik — ohne MakeMKV, ohne Laufwerk, ohne Netz.
|
||||
|
||||
Die Testdaten sind gemessen (26.07.2026, Commander-PC, MakeMKV 1.18.4):
|
||||
`makemkvcon -r --cache=1 info disc:9999` auf einer Maschine OHNE optisches
|
||||
Laufwerk gibt sechzehn leere `DRV:`-Zeilen. Die BELEGTE Form ließ sich dort nicht
|
||||
messen — deshalb prüfen die Tests vor allem, dass die Automatik im Zweifel
|
||||
NICHTS tut.
|
||||
"""
|
||||
|
||||
import schluessel
|
||||
|
||||
# Wörtlich aus dem echten Lauf (gekürzt auf drei der sechzehn Zeilen).
|
||||
DRV_LEER = """MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x64-release)"
|
||||
MSG:5042,0,0,"Das Programm konnte keine verwendbaren optischen Laufwerke finden.","Das Programm konnte keine verwendbaren optischen Laufwerke finden."
|
||||
DRV:0,256,999,0,"","",""
|
||||
DRV:1,256,999,0,"","",""
|
||||
DRV:2,256,999,0,"","",""
|
||||
MSG:5010,0,0,"Das Öffnen der Disk schlug fehl ","Das Öffnen der Disk schlug fehl "
|
||||
TCOUNT:0
|
||||
"""
|
||||
|
||||
# So sieht eine belegte Zeile AUS SICHT DES FORMATS aus — nicht gemessen,
|
||||
# deshalb steht sie nur hier im Test und nicht als Annahme im Code.
|
||||
DRV_MIT_DISC = """DRV:0,2,999,12,"BD-RE HL-DT-ST BU40N","AKIRA","D:"
|
||||
DRV:1,256,999,0,"","",""
|
||||
"""
|
||||
|
||||
|
||||
def test_leere_laufwerke_ergeben_nichts():
|
||||
"""Der gemessene Fall: kein optisches Laufwerk → die Automatik tut nichts."""
|
||||
assert schluessel.parse_laufwerke(DRV_LEER) == []
|
||||
|
||||
|
||||
def test_kein_disc_name_gilt_als_nichts_da():
|
||||
"""Konservativ mit Absicht: Ein falsch-negativ ist harmlos (der Wächter
|
||||
greift trotzdem), ein falsch-positiv wäre ein makemkvcon-Lauf ins Leere."""
|
||||
assert schluessel.parse_laufwerke('DRV:0,2,999,12,"BD-RE BU40N","",""') == []
|
||||
|
||||
|
||||
def test_belegtes_laufwerk_wird_gefunden():
|
||||
gefunden = schluessel.parse_laufwerke(DRV_MIT_DISC)
|
||||
assert len(gefunden) == 1
|
||||
assert gefunden[0]["index"] == 0
|
||||
assert gefunden[0]["disc"] == "AKIRA"
|
||||
assert gefunden[0]["geraet"] == "D:"
|
||||
|
||||
|
||||
def test_muell_und_leer_stuerzen_nicht_ab():
|
||||
assert schluessel.parse_laufwerke("") == []
|
||||
assert schluessel.parse_laufwerke(None) == []
|
||||
assert schluessel.parse_laufwerke("irgendwas ganz anderes") == []
|
||||
|
||||
|
||||
# --- Wann hochgeladen wird --------------------------------------------------
|
||||
|
||||
|
||||
def test_erste_runde_laedt_immer_hoch():
|
||||
"""Rippy soll den Bestand kennen, auch wenn MakeMKV gerade nichts Neues
|
||||
gelernt hat."""
|
||||
assert schluessel.hat_sich_geaendert(None, (6420480, 1753000000)) is True
|
||||
|
||||
|
||||
def test_unveraenderte_datei_wird_nicht_wieder_geschickt():
|
||||
stand = (6420480, 1753000000)
|
||||
assert schluessel.hat_sich_geaendert(stand, stand) is False
|
||||
|
||||
|
||||
def test_geaenderte_datei_wird_geschickt():
|
||||
assert schluessel.hat_sich_geaendert(
|
||||
(6420480, 1753000000), (6500000, 1753009999)) is True
|
||||
|
||||
|
||||
def test_fehlende_datei_loest_keinen_upload_aus():
|
||||
"""(0, 0) heißt „gibt es nicht" — dann gibt es auch nichts zu schicken."""
|
||||
assert schluessel.hat_sich_geaendert(None, (0, 0)) is False
|
||||
assert schluessel.hat_sich_geaendert((6420480, 1753000000), (0, 0)) is False
|
||||
|
||||
|
||||
# --- Der Upload -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_schickt_den_rohen_koerper(tmp_path):
|
||||
"""Kein JSON, kein Multipart: genau so nimmt POST /system/keystore die Datei
|
||||
an (der API fehlt python-multipart)."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"TAR-INHALT")
|
||||
gesehen = {}
|
||||
|
||||
class Antwort:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
def oeffner(anfrage, timeout=None):
|
||||
gesehen["url"] = anfrage.full_url
|
||||
gesehen["daten"] = anfrage.data
|
||||
gesehen["typ"] = anfrage.headers.get("Content-type")
|
||||
return Antwort()
|
||||
|
||||
assert schluessel.hochladen("1.2.3.4", str(datei), oeffner=oeffner) == ""
|
||||
assert gesehen["url"] == "http://1.2.3.4/api/system/keystore"
|
||||
assert gesehen["daten"] == b"TAR-INHALT"
|
||||
assert gesehen["typ"] == "application/octet-stream"
|
||||
|
||||
|
||||
def test_upload_ohne_adresse_oder_datei_meldet_klartext():
|
||||
assert "Adresse" in schluessel.hochladen("", "/x.tar")
|
||||
assert "Schlüsseldatei" in schluessel.hochladen("1.2.3.4", "")
|
||||
|
||||
|
||||
def test_upload_fehler_wird_gemeldet_nicht_geworfen(tmp_path):
|
||||
"""Der Worker muss weiterarbeiten, auch wenn Rippy gerade weg ist."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"x")
|
||||
|
||||
def kaputt(anfrage, timeout=None):
|
||||
raise OSError("Netz weg")
|
||||
|
||||
fehler = schluessel.hochladen("1.2.3.4", str(datei), oeffner=kaputt)
|
||||
assert "Hochladen fehlgeschlagen" in fehler
|
||||
|
||||
|
||||
# --- Eine ganze Runde -------------------------------------------------------
|
||||
|
||||
|
||||
def test_runde_ohne_makemkv_tut_nichts():
|
||||
"""Auf einem PC ohne MakeMKV darf die Automatik nicht herumfummeln."""
|
||||
stand, was = schluessel.runde("1.2.3.4", None, programm="")
|
||||
assert (stand, was) == (None, "kein-makemkv")
|
||||
|
||||
|
||||
def test_runde_stoesst_erst_an_und_prueft_dann(monkeypatch, tmp_path):
|
||||
"""Reihenfolge mit Absicht: Ein gerade geholter Schluessel soll in DERSELBEN
|
||||
Runde mitgenommen werden, nicht erst eine Minute spaeter."""
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"neu")
|
||||
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
|
||||
|
||||
ablauf = []
|
||||
|
||||
def laufen(cmd, **kwargs):
|
||||
# disc:9999 = Laufwerke auflisten, disc:0 = Disc oeffnen
|
||||
ablauf.append(cmd[-1])
|
||||
|
||||
class E:
|
||||
stdout = DRV_MIT_DISC if cmd[-1] == "disc:9999" else ""
|
||||
return E()
|
||||
|
||||
class Antwort:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b""
|
||||
|
||||
meldungen = []
|
||||
stand, was = schluessel.runde(
|
||||
"1.2.3.4", None, melden=lambda lvl, t: meldungen.append((lvl, t)),
|
||||
programm="makemkvcon.exe", laufen=laufen,
|
||||
oeffner=lambda a, timeout=None: Antwort())
|
||||
|
||||
assert ablauf == ["disc:9999", "disc:0"]
|
||||
assert was == "hochgeladen"
|
||||
assert stand == schluessel.datei_stand(str(datei))
|
||||
assert any("Disc erkannt" in t for _, t in meldungen)
|
||||
assert any("wirkt ab dem nächsten Rip" in t for _, t in meldungen)
|
||||
|
||||
|
||||
def test_runde_ohne_aenderung_schickt_nichts(monkeypatch, tmp_path):
|
||||
datei = tmp_path / "_private_data.tar"
|
||||
datei.write_bytes(b"alt")
|
||||
monkeypatch.setattr(schluessel, "schluesseldatei", lambda: str(datei))
|
||||
vorher = schluessel.datei_stand(str(datei))
|
||||
|
||||
def laufen(cmd, **kwargs):
|
||||
class E:
|
||||
stdout = DRV_LEER
|
||||
return E()
|
||||
|
||||
def darf_nicht(anfrage, timeout=None):
|
||||
raise AssertionError("kein Upload erwartet")
|
||||
|
||||
stand, was = schluessel.runde(
|
||||
"1.2.3.4", vorher, programm="makemkvcon.exe", laufen=laufen,
|
||||
oeffner=darf_nicht)
|
||||
assert (stand, was) == (vorher, "unveraendert")
|
||||
@@ -254,6 +254,37 @@ def _job_holen():
|
||||
return None
|
||||
|
||||
|
||||
def _schluessel_wache(bruecke=None):
|
||||
"""Holt 4K-Disc-Schlüssel und schiebt sie zu Rippy (siehe schluessel.py).
|
||||
|
||||
Der Sinn in einem Satz: `makemkvcon` unter LINUX ruft Disc-Schlüssel nie ab,
|
||||
die WINDOWS-Version schon — und dieser Worker läuft auf Windows. Damit fällt
|
||||
die Handarbeit weg, `_private_data.tar` nach jeder neuen UHD-Disc selbst
|
||||
hinüberzutragen.
|
||||
|
||||
Schaltet sich selbst ab, wenn MakeMKV hier nicht installiert ist: Auf einem
|
||||
reinen Encoding-PC gibt es nichts zu holen, und eine Schleife, die jede
|
||||
Minute ins Leere greift, wäre nur Rauschen.
|
||||
"""
|
||||
import schluessel
|
||||
|
||||
if not schluessel.makemkvcon_pfad():
|
||||
return
|
||||
|
||||
def melden(level, text):
|
||||
if bruecke:
|
||||
bruecke.zeile(f"[schluessel] {level.upper()} {text}")
|
||||
print(f"Schlüssel-Automatik: {text}")
|
||||
|
||||
stand = None
|
||||
while True:
|
||||
try:
|
||||
stand, _was = schluessel.runde(RIPPY_HOST, stand, melden=melden)
|
||||
except Exception as e: # darf den Worker nie mitnehmen
|
||||
print(f"Schlüssel-Automatik fehlgeschlagen: {type(e).__name__}: {e}")
|
||||
time.sleep(schluessel.TAKT_SEKUNDEN)
|
||||
|
||||
|
||||
def _beobachter(icon):
|
||||
"""Fragt im Takt nach, was läuft — für Menütext und Standby-Sperre."""
|
||||
while True:
|
||||
@@ -377,6 +408,13 @@ if __name__ == "__main__":
|
||||
threading.Thread(
|
||||
target=_beobachter, args=(tray,), daemon=True, name="job-beobachter"
|
||||
).start()
|
||||
# Schlüssel-Automatik für 4K-UHD. Eigene Log-Brücke, damit ihre Meldungen
|
||||
# auch in Rippy landen ("Schlüsselspeicher übergeben") — das ist genau die
|
||||
# Auskunft, auf die man nach dem Einlegen einer neuen UHD-Disc wartet.
|
||||
threading.Thread(
|
||||
target=_schluessel_wache, args=(_bruecke_bauen(),),
|
||||
daemon=True, name="schluessel-wache",
|
||||
).start()
|
||||
try:
|
||||
tray.run()
|
||||
finally:
|
||||
|
||||
Reference in New Issue
Block a user