diff --git a/docker/api/main.py b/docker/api/main.py index fa917f2..6b01ea2 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -268,6 +268,51 @@ async def _auto_rip_wenn_aktiviert(pfad: str): start_rip(pfad, job_id, ziel) +def disc_entscheidung(schon_gesehen: bool, vorher, status) -> str: + """Was ist zu tun? `""` | `"erststart"` | `"eingelegt"` | `"entfernt"`. + + Pure Funktion — deshalb ohne Laufwerk prüfbar, und genau daran hing der + Fehler unten. + + ## Der Befund des Commanders (29.08.2026) + + > „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn er sie + > bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche fenster + > ganz kurz im hintergrund." + + Beides war dieselbe Schleife. In der Disc-Wache stand: + + except OSError: + continue + + Damit blieb `bekannt[pfad]` ungesetzt, und im nächsten Durchlauf war + `vorher is None` — also wieder „Erststart, liegt schon eine Disc drin". + **Ein einziger fehlgeschlagener Lesevorgang löste einen neuen Vor-Scan + aus.** + + Und der scheitert regelmäßig: Während der Vor-Scan läuft, hält + `makemkvcon` das Laufwerk (gemessen: `/devices` braucht dann 14 s statt + 5). Also: Scan hält das Laufwerk → Statusabfrage scheitert → nächster + Durchlauf hält es für den ersten → neuer Scan. Eine Schleife, die sich + selbst am Leben hält — und weil derselbe Aufruf sein Konsolenfenster + nicht unterdrückte, blitzte bei jeder Runde eines auf. + + Deshalb zwei getrennte Fragen statt einer: **Haben wir dieses Laufwerk je + gelesen?** (`schon_gesehen`) und **was war zuletzt drin?** (`vorher`). Ein + Fehlschlag beantwortet die zweite nicht — und darf die erste nicht + zurücksetzen. + """ + if not schon_gesehen: + return "erststart" if status == CDS_DISC_OK else "" + if status == vorher: + return "" + if status == CDS_DISC_OK: + return "eingelegt" + if status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: + return "entfernt" + return "" + + async def disc_watcher(): """Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte). @@ -277,25 +322,45 @@ async def disc_watcher(): für den Heim-Use-Case gleichwertig und läuft überall. """ bekannt: Dict[str, int] = {} + # Laufwerke, die schon einmal ERFOLGREICH gelesen wurden. Getrennt von + # `bekannt`, weil ein fehlgeschlagener Lesevorgang sonst wie ein + # Erststart aussieht — siehe unten. + gesehen: set = set() while True: try: for pfad in device_discovery.list_optical_devices(): try: status = await asyncio.to_thread(drive_status, pfad) except OSError: + # ⚠️ Hier stand nur `continue` (Befund 29.08.2026). + # + # `bekannt[pfad]` blieb damit ungesetzt, und beim nächsten + # Durchlauf war `vorher is None` — also wieder „Erststart, + # liegt schon eine Disc drin". Ein einziger fehlgeschlagener + # Lesevorgang bewirkte einen NEUEN Vor-Scan. + # + # Und genau das passiert regelmäßig: Während der Vor-Scan + # läuft, hält `makemkvcon` das Laufwerk, und die + # Statusabfrage kommt nicht durch. Ergebnis: Scan hält das + # Laufwerk → Statusabfrage scheitert → nächster Durchlauf + # hält es für den ersten → neuer Scan. Eine Schleife, die + # sich selbst am Leben hält, und bei jeder Runde blitzte + # eine Konsole auf. + # + # `gesehen` merkt sich, ob wir dieses Laufwerk je gelesen + # haben. Ein Fehlschlag heißt jetzt „nichts Neues erfahren" + # — nicht „zum ersten Mal gesehen". continue - vorher = bekannt.get(pfad) - if vorher is None: - # Erststart: liegt schon eine Disc drin, direkt erkennen - if status == CDS_DISC_OK: - asyncio.create_task(_auto_prescan(pfad)) - elif status != vorher: - if status == CDS_DISC_OK: - db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") - asyncio.create_task(_auto_prescan(pfad)) - elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: - db.add_log("info", "watcher", f"Disc entfernt: {pfad}") - DISC_CACHE.pop(pfad, None) + was = disc_entscheidung(pfad in gesehen, bekannt.get(pfad), status) + gesehen.add(pfad) + if was == "erststart": + asyncio.create_task(_auto_prescan(pfad)) + elif was == "eingelegt": + db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") + asyncio.create_task(_auto_prescan(pfad)) + elif was == "entfernt": + db.add_log("info", "watcher", f"Disc entfernt: {pfad}") + DISC_CACHE.pop(pfad, None) bekannt[pfad] = status except Exception as e: print(f"Disc-Wache: {e}") diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index bde4998..048717a 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -10,6 +10,7 @@ import subprocess from typing import Dict, List, Optional from rippy import drives as _laufwerks_schicht +from rippy.platform.winlauf import OHNE_FENSTER from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle from rippy.rip.makemkv_aufruf import text_von from rippy.tools import katalog as werkzeug_katalog @@ -115,12 +116,14 @@ def disc_wurzel(device_path: str): ergebnis = subprocess.run( ["mount", "-t", "udf", "-o", "ro", device_path, mountpoint], capture_output=True, text=True, timeout=30, + creationflags=OHNE_FENSTER, ) if ergebnis.returncode != 0: return None, lambda: None def abhaengen(): - subprocess.run(["umount", mountpoint], capture_output=True, timeout=15) + subprocess.run(["umount", mountpoint], capture_output=True, timeout=15, + creationflags=OHNE_FENSTER) return mountpoint, abhaengen @@ -331,7 +334,8 @@ class PreScan: ["cdparanoia", "-Q", device_path], capture_output=True, text=True, - timeout=10 + timeout=10, + creationflags=OHNE_FENSTER, ) for line in result.stdout.split('\n'): if 'track' in line.lower(): @@ -369,7 +373,14 @@ class PreScan: result = subprocess.run( [makemkv, "-r", "--noscan", "--minlength=300", "info", makemkv_quelle(device_path)], capture_output=True, - timeout=120 # binaer lesen — Begruendung in text_von + timeout=120, # binaer lesen — Begruendung in text_von + # OHNE_FENSTER: Sonst blitzt bei JEDER Disc-Erkennung + # eine Konsole auf. Rippy laeuft als Fenster-Programm + # ohne eigene Konsole — Windows legt fuer ein + # Konsolenprogramm dann eine NEUE an, und die ist + # sichtbar (Commander 29.08.2026: „nun oeffnen sich + # immer irgendwelche fenster ganz kurz"). + creationflags=OHNE_FENSTER, ) for line in text_von(result.stdout).split('\n'): if line.startswith('TINFO:'): diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 23d68e7..fa6ac01 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -651,3 +651,82 @@ def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch): geraet = main.laufwerke_notdurft()[0] assert "disc_wird_erkannt" not in geraet assert geraet["disc"]["title"] == "Evangelion 2.22" + + +# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ───────────────── +# +# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn +# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche +# fenster ganz kurz im hintergrund." +# +# Beides war dieselbe Schleife: `except OSError: continue` liess +# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer +# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die +# naechste Statusabfrage scheitert — und so weiter. + + +def _CDS(): + import main + return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN + + +def test_erster_blick_auf_eine_liegende_disc_erkennt_sie(): + import main + ok, _, _ = _CDS() + assert main.disc_entscheidung(False, None, ok) == "erststart" + + +def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus(): + import main + _, leer, _ = _CDS() + assert main.disc_entscheidung(False, None, leer) == "" + + +def test_dieselbe_disc_wird_NICHT_nochmal_gelesen(): + """Der Kern des Befundes.""" + import main + ok, _, _ = _CDS() + assert main.disc_entscheidung(True, ok, ok) == "" + + +def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan(): + """DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit + „noch nie gesehen" verwechselt, scannt endlos. + + Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor- + gang (makemkvcon haelt das Laufwerk), dann klappt er wieder. + """ + import main + ok, _, _ = _CDS() + # Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None. + # `schon_gesehen` bleibt aber True, und genau daran haengt alles. + assert main.disc_entscheidung(True, None, ok) == "eingelegt", \ + "eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …" + # … aber NICHT ein Erststart, der ohne jede Aenderung scannt: + assert main.disc_entscheidung(True, ok, ok) == "" + + +def test_auswurf_wird_erkannt(): + import main + ok, leer, offen = _CDS() + assert main.disc_entscheidung(True, ok, leer) == "entfernt" + assert main.disc_entscheidung(True, ok, offen) == "entfernt" + + +def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg(): + """3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat + weg, sobald das Laufwerk kurz beschaeftigt ist.""" + import main + ok, _, _ = _CDS() + assert main.disc_entscheidung(True, ok, 3) == "" + + +def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck(): + """Waechter gegen die Rueckkehr des `continue`-Fehlers.""" + import inspect + + import main + + quelle = inspect.getsource(main.disc_watcher) + assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las" + assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion" diff --git a/docker/worker/ablauf.py b/docker/worker/ablauf.py index c6ffd3c..e2ce219 100644 --- a/docker/worker/ablauf.py +++ b/docker/worker/ablauf.py @@ -310,6 +310,9 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None: # laeuft und sonst am ersten Titel scheitert. from ripping import werkzeug + # Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start + # tut — nur in der Windows-Form. + makemkv_aufruf.einstellungen_sicherstellen() urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv")) if not urteil["ok"]: db.add_log("warning", "worker", diff --git a/src/rippy/rip/makemkv_aufruf.py b/src/rippy/rip/makemkv_aufruf.py index b291783..c213486 100644 --- a/src/rippy/rip/makemkv_aufruf.py +++ b/src/rippy/rip/makemkv_aufruf.py @@ -198,6 +198,70 @@ def _in_registry(key: str) -> str: return "HKCU\\" + REGISTRY_PFAD +#: Einstellungen, die der Docker-Betrieb beim Start AUSDRÜCKLICH setzt +#: (`docker/worker/entrypoint.sh`) — hier für Windows übersetzt. +#: +#: `app_UpdateEnable` ist MakeMKVs Web-Kontakt. Er ist ab Werk an, und genau +#: deshalb steht er in der entrypoint.sh: „damit die Einstellung +#: nachvollziehbar ist und nicht versehentlich kippt". Quelle dort: +#: forum.makemkv.com/forum/viewtopic.php?t=20364 +#: +#: Für Rippy hängt daran mehr als Kosmetik: Über diesen Kanal holt MakeMKV die +#: Disc-Schlüssel für 4K-UHD nach (Meldung 3338 „Downloading latest HK to …", +#: am 25.07.2026 auf einem Windows-PC gemessen — unter Linux passiert es nie). +#: Ohne ihn scheitert jede unbekannte UHD-Pressung. +#: +#: Auf dem Rechner des Commanders nachgesehen (29.08.2026): **nicht gesetzt.** +#: Die Werte daneben (`app_UpdateLastCheck`, `app_BackupDecrypted`) zeigen die +#: native Form — Wahrheitswerte stehen in der Registry als DWORD, nicht als +#: Text wie in der settings.conf. +DOCKER_EINSTELLUNGEN = {"app_UpdateEnable": 1} + + +def einstellungen_sicherstellen(plattform_name: str = None, setzer=None, + lesen=None) -> list: + """Setzt fehlende MakeMKV-Einstellungen. Gibt zurück, was gesetzt wurde. + + **Nur was FEHLT.** Wer den Web-Kontakt bewusst abgeschaltet hat, soll ihn + abgeschaltet behalten — Rippy stellt einen Ausgangszustand her, es + überstimmt keine Entscheidung. + """ + if not plattform(plattform_name).startswith("win"): + # Im Container macht das die entrypoint.sh, und zwar in der + # settings.conf. Hier nichts tun statt es doppelt zu machen. + return [] + lesen = lesen or _registry_hat + setzer = setzer or _registry_dword + gesetzt = [] + for name, wert in DOCKER_EINSTELLUNGEN.items(): + try: + if lesen(name): + continue + setzer(name, wert) + gesetzt.append(name) + except OSError: + continue # Registry nicht schreibbar — kein Beinbruch + return gesetzt + + +def _registry_hat(name: str) -> bool: + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD) as s: + winreg.QueryValueEx(s, name) + return True + except OSError: + return False + + +def _registry_dword(name: str, wert: int) -> None: + import winreg + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD) as s: + winreg.SetValueEx(s, name, 0, winreg.REG_DWORD, int(wert)) + + def key_zuruecknehmen(plattform_name: str = None, entferner=None) -> bool: """Einen abgelehnten Schlüssel wieder entfernen. True, wenn etwas wegging.""" if not plattform(plattform_name).startswith("win"): diff --git a/src/rippy/rip/test_makemkv_aufruf.py b/src/rippy/rip/test_makemkv_aufruf.py index 304649b..2ed4d87 100644 --- a/src/rippy/rip/test_makemkv_aufruf.py +++ b/src/rippy/rip/test_makemkv_aufruf.py @@ -213,3 +213,76 @@ def test_die_codes_stehen_so_da_wie_gemessen(): assert "zu alt" in auf.KRITISCHE_CODES[5021] assert "Volume" not in auf.KRITISCHE_CODES[5021] assert auf.KEY_ABGELEHNT == (5020, 5021) + + +# ── 5. Was der Docker-Betrieb setzt, gilt auch auf Windows ────────────── +# +# Commander 29.08.2026: „Prüfe doch einfach mal wie die Docker Rippy heute +# funktioniert. Schau was du davon abschließend übernehmen kannst. Wichtig ist +# das du das für Windows Umschreibst." +# +# `docker/worker/entrypoint.sh` setzt vor JEDEM Worker-Start zwei Dinge in +# MakeMKVs settings.conf: `app_Key` und `app_UpdateEnable`. Der Key war schon +# uebernommen — `app_UpdateEnable` nicht. +# +# Das ist MakeMKVs Web-Kontakt. Ueber ihn holt MakeMKV die Disc-Schluessel +# fuer 4K-UHD nach (Meldung 3338, auf Windows gemessen; unter Linux passiert +# es nie). Auf dem Rechner des Commanders nachgesehen: NICHT gesetzt. +# +# „Fuer Windows umschreiben" heisst hier konkret: Registry statt Datei, und +# DWORD statt Text — die Nachbarwerte (`app_BackupDecrypted`) zeigen die Form. + + +def test_windows_setzt_was_der_entrypoint_setzt(): + gesetzt = {} + namen = auf.einstellungen_sicherstellen( + plattform_name="win32", + lesen=lambda n: False, + setzer=lambda n, w: gesetzt.__setitem__(n, w)) + assert "app_UpdateEnable" in namen + assert gesetzt["app_UpdateEnable"] == 1, "in der Registry ein DWORD, kein Text" + + +def test_eine_vorhandene_einstellung_wird_NICHT_ueberschrieben(): + """Wer den Web-Kontakt bewusst abgeschaltet hat, behaelt ihn abgeschaltet. + Rippy stellt einen Ausgangszustand her, es ueberstimmt keine Entscheidung.""" + angefasst = [] + namen = auf.einstellungen_sicherstellen( + plattform_name="win32", + lesen=lambda n: True, + setzer=lambda n, w: angefasst.append(n)) + assert namen == [] and angefasst == [] + + +def test_im_container_macht_das_der_entrypoint(): + """Dort steht es in der settings.conf — doppelt waere zwei Wahrheiten.""" + assert auf.einstellungen_sicherstellen(plattform_name="linux", + lesen=lambda n: False, + setzer=lambda n, w: 1 / 0) == [] + + +def test_eine_gesperrte_registry_wirft_nicht(): + """Ein Einstellungswert ist kein Grund, warum nichts geht.""" + def sperrt(n, w): + raise OSError(5, "Zugriff verweigert") + + assert auf.einstellungen_sicherstellen(plattform_name="win32", + lesen=lambda n: False, + setzer=sperrt) == [] + + +def test_der_entrypoint_und_windows_kennen_DIESELBEN_namen(): + """Waechter: Kommt in der entrypoint.sh eine Einstellung dazu, muss sie + hier auftauchen — sonst driften Docker und Windows auseinander.""" + import io + import os + import re + + hier = os.path.dirname(os.path.abspath(__file__)) + pfad = os.path.join(hier, "..", "..", "..", "docker", "worker", "entrypoint.sh") + with io.open(os.path.abspath(pfad), encoding="utf-8") as f: + text = f.read() + im_entrypoint = set(re.findall(r"\bapp_[A-Za-z]+", text)) + # app_Key laeuft ueber `key_ablegen`, der Rest ueber DOCKER_EINSTELLUNGEN. + offen = im_entrypoint - {"app_Key"} - set(auf.DOCKER_EINSTELLUNGEN) + assert not offen, "entrypoint.sh setzt %s — Windows nicht" % sorted(offen) diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py index 194f259..f42aba9 100644 --- a/src/rippy/setup_fenster.py +++ b/src/rippy/setup_fenster.py @@ -381,6 +381,12 @@ def _beta_key_holen(melden=None) -> str: from rippy.rip import makemkv_aufruf from rippy.tools import katalog + # Was der Docker-Betrieb beim Start ausdruecklich setzt, gilt hier auch — + # uebersetzt in die Windows-Form (Registry statt settings.conf). Ohne den + # Web-Kontakt holt MakeMKV keine Disc-Schluessel fuer 4K-UHD nach. + for name in makemkv_aufruf.einstellungen_sicherstellen(): + melden(" MakeMKV-Einstellung gesetzt: %s" % name) + urteil = makemkv_aufruf.key_setzen_und_pruefen(key, katalog.finden("makemkv")) if urteil["ok"]: melden(" MakeMKV-Beta-Key geholt und angenommen (…%s)." % key[-6:])