From be04a9772e9675689df23f2dc5619b639560dd92 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 26 Jul 2026 14:03:10 +0200 Subject: [PATCH] fix(auswurf): CDROMEJECT meldete Erfolg und tat nichts - erst entriegeln Commander-Meldung: "Den Button gibt es in den Settings, aber es passiert nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt, mit der Disc, die gerade drin lag: wirf_disc_aus("/dev/sr0") -> True CDROM_DRIVE_STATUS danach -> 4 (Disc drin) Das ioctl wird also ANGENOMMEN und tut nichts. Ursache: MakeMKV verriegelt waehrend des Rips die Laufwerkstuer (CDROM_LOCKDOOR 1) und entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf trotzdem mit Erfolg. Gegenprobe an derselben Disc: CDROM_LOCKDOOR 0 + CDROMEJECT -> Status 2 (SCHUBLADE OFFEN) Genau das macht das Werkzeug `eject` immer: erst entriegeln, dann auswerfen. Zweite Haelfte des Fixes, und die wichtigere: Das Ergebnis wird GEPRUEFT statt geglaubt. Bisher gab wirf_disc_aus True zurueck, sobald das ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend die Schublade zu blieb. Deshalb ist der Fehler in v3.14 durchgerutscht: Dort wurde richtig festgestellt, dass die Einstellung von niemandem gelesen wurde, und danach WURDE sie gelesen - ausgeworfen wurde weiterhin nicht. Jetzt wird das Laufwerk gefragt (bis zu 5 s, die Schublade braucht ein bis zwei), und "kein Datentraeger" zaehlt mit, weil ein Slot-Laufwerk keine Schublade hat. Dieselbe Luecke steckte im Auswurf-Knopf der API (devices.eject) - dort mit Klartext-Fehler, wenn die Disc drin bleibt. Der Auswurf sitzt uebrigens schon an der richtigen Stelle: nach dem Rip, VOR dem Einreihen der Kompression. Und das Laufwerk ist waehrend `transcoding` frei - has_active_job blockiert nur bei pending/running, der Worker laeuft mit 4 Slots. Die zweite Disc parallel war also nur am nicht aufgehenden Laufwerk gescheitert. Co-Authored-By: Claude Opus 5 --- docker/api/devices.py | 36 ++++++++- docker/worker/ripping.py | 107 ++++++++++++++++++++++---- docker/worker/test_ripping_helpers.py | 84 ++++++++++++++++++++ 3 files changed, 210 insertions(+), 17 deletions(-) diff --git a/docker/api/devices.py b/docker/api/devices.py index 6d16cc3..f3cc848 100644 --- a/docker/api/devices.py +++ b/docker/api/devices.py @@ -20,13 +20,47 @@ from detection import ( # include/uapi/linux/cdrom.h CDROMEJECT = 0x5309 +CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln +CDROM_DRIVE_STATUS = 0x5326 +CDS_NO_DISC = 1 +CDS_TRAY_OPEN = 2 + +AUSWURF_WARTEN_SEKUNDEN = 5 def eject(device_path: str) -> None: - """Wirft die Disc aus (CDROMEJECT-ioctl). Wirft OSError bei Fehlern.""" + """Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt. + + ⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein + nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert + und tut nichts. MakeMKV verriegelt die Tür während des Rips + (`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die + Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe + an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2). + Deshalb macht das Werkzeug `eject` immer beides. + + Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket + zwischen den Containern; dort steht die ausführliche Herleitung. + """ + import time + fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK) try: + try: + ioctl(fd, CDROM_LOCKDOOR, 0) + except OSError: + pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen ioctl(fd, CDROMEJECT, 0) + # Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein + # Slot-Laufwerk hat keine Schublade. + for _ in range(AUSWURF_WARTEN_SEKUNDEN): + if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC): + return + time.sleep(1) + raise OSError( + "Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch " + "drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?" + ) finally: os.close(fd) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index cc90f85..254edd3 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -25,34 +25,109 @@ class RipAbbruch(Exception): Nutzer den Job abgebrochen hat (Status 'canceling' in der DB).""" -# include/uapi/linux/cdrom.h — dasselbe ioctl wie in api/devices.py +# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py CDROMEJECT = 0x5309 +CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln +CDROM_DRIVE_STATUS = 0x5326 +CDROMCLOSETRAY = 0x5319 + +# Antworten von CDROM_DRIVE_STATUS (cdrom.h) +CDS_NO_DISC = 1 +CDS_TRAY_OPEN = 2 +CDS_DRIVE_NOT_READY = 3 +CDS_DISC_OK = 4 + +# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein +# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges. +AUSWURF_WARTEN_SEKUNDEN = 5 -def wirf_disc_aus(device_path: str) -> bool: - """Wirft die Disc aus; gibt zurück, ob es geklappt hat. Wirft NIE. +def _auswurf_geglueckt(status: int) -> bool: + """Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion) - Befund 25.07.2026: Die Einstellung „Automatischer Auswurf" (Standard: ein, - Beschreibung „Disc nach erfolgreichem Ripping automatisch auswerfen") wurde - von niemandem gelesen — bei DVD/Blu-ray warf Rippy nie aus. + Sowohl „Schublade offen" als auch „kein Datenträger" zählen: Ein + Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC. + """ + return status in (CDS_TRAY_OPEN, CDS_NO_DISC) + + +def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None, + schliessen=None, warten=None) -> bool: + """Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE. + + ## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen) + + Der Commander meldete: „Der Button gibt es in den Settings, aber es passiert + nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt: + + wirf_disc_aus("/dev/sr0") → True + CDROM_DRIVE_STATUS danach → 4 (Disc drin) + + Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV + verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und + entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf + trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides: + erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc: + + CDROM_LOCKDOOR 0 + CDROMEJECT → Status 2 (SCHUBLADE OFFEN) + + ## Und deshalb wird das Ergebnis geprüft, nicht geglaubt + + Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier + „Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung + wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im + Log stand „Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts; + gefragt wird jetzt das Laufwerk. Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux — der native Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie. + + Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk). """ - try: - from fcntl import ioctl - except ImportError: # Windows - return False - try: - fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK) + if ioctl_fn is None: try: - ioctl(fd, CDROMEJECT, 0) - finally: - os.close(fd) - return True + from fcntl import ioctl + except ImportError: # Windows — dieser Worker rippt nie + return False + ioctl_fn = ioctl + if warten is None: + import time + warten = time.sleep + oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK)) + schliessen = schliessen or os.close + + try: + fd = oeffnen(device_path) except OSError: return False + try: + # Entriegeln ist der entscheidende Schritt. Scheitert er, wird der + # Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk + # (oder einem, das das ioctl nicht kennt) klappt er ohnehin. + try: + ioctl_fn(fd, CDROM_LOCKDOOR, 0) + except OSError: + pass + try: + ioctl_fn(fd, CDROMEJECT, 0) + except OSError: + return False + + # Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden. + for _ in range(AUSWURF_WARTEN_SEKUNDEN): + try: + if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)): + return True + except OSError: + return False + warten(1) + return False + finally: + try: + schliessen(fd) + except OSError: + pass def check_makemkv_installed() -> bool: diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 7c3c539..2004fae 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -380,3 +380,87 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte(): ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") assert ergebnis["error"] == "HandBrake endete mit Code 1" + + +# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) ----- + + +def _laufwerk(verriegelt=True, kennt_lockdoor=True): + """Ein nachgebautes Laufwerk, das sich wie das echte verhaelt. + + Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer + verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status + bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam. + """ + import ripping + + zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK, + "aufrufe": []} + + def ioctl_fn(fd, befehl, arg=0): + zustand["aufrufe"].append(befehl) + if befehl == ripping.CDROM_LOCKDOOR: + if not kennt_lockdoor: + raise OSError("ioctl unbekannt") + zustand["verriegelt"] = bool(arg) + return 0 + if befehl == ripping.CDROMEJECT: + if not zustand["verriegelt"]: + zustand["status"] = ripping.CDS_TRAY_OPEN + return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung + if befehl == ripping.CDROM_DRIVE_STATUS: + return zustand["status"] + raise OSError("unerwartetes ioctl") + + return zustand, ioctl_fn + + +def test_auswurf_entriegelt_zuerst_und_klappt_dann(): + import ripping + + zustand, ioctl_fn = _laufwerk(verriegelt=True) + ok = ripping.wirf_disc_aus( + "/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42, + schliessen=lambda fd: None, warten=lambda s: None) + assert ok is True + assert zustand["status"] == ripping.CDS_TRAY_OPEN + # Reihenfolge: entriegeln VOR auswerfen + assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR + assert zustand["aufrufe"][1] == ripping.CDROMEJECT + + +def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt(): + """Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das + ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend + die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts.""" + import ripping + + # Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt + zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False) + ok = ripping.wirf_disc_aus( + "/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42, + schliessen=lambda fd: None, warten=lambda s: None) + assert ok is False + assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen + + +def test_auswurf_bei_slot_laufwerk_ohne_schublade(): + """Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen + CDS_NO_DISC. Das muss als Erfolg zaehlen.""" + import ripping + + assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True + assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True + assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False + assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False + + +def test_auswurf_ohne_laufwerk_wirft_nicht(): + import ripping + + def oeffnen_kaputt(p): + raise OSError("kein Laufwerk") + + assert ripping.wirf_disc_aus( + "/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt, + schliessen=lambda fd: None, warten=lambda s: None) is False