diff --git a/docker/api/main.py b/docker/api/main.py index 6846d44..040c92e 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -245,7 +245,10 @@ async def _auto_rip_wenn_aktiviert(pfad: str): einstellungen = await asyncio.to_thread(db.get_settings) if not einstellungen.get("autoRipStart"): return - if await asyncio.to_thread(db.has_active_job, pfad): + # `job_offen`, nicht `has_active_job`: Waehrend der Kompression ist das + # Laufwerk zwar frei, der Vorgang aber nicht durch — und die Disc wurde + # soeben ausgeworfen. Herleitung in store.job_offen. + if await asyncio.to_thread(db.job_offen, pfad): return disc = DISC_CACHE.get(pfad) or {} if disc.get("_laeuft"): diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index 3462e10..e3bde2e 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -834,3 +834,49 @@ def test_der_job_start_raeumt_eine_haengende_erkennung_weg(): quelle = inspect.getsource(main) assert 'DISC_CACHE.pop(device_path, None)' in quelle, \ "beim Job-Start muss eine haengende Erkennungs-Marke weg" + + +# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ─────── +# +# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung +# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl". +# +# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`. +# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei". +# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete +# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr. + + +def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch): + import asyncio + + import main + + gestartet = [] + monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True}) + monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert + monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei + monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a)) + main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"} + + asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:")) + assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an" + + +def test_job_offen_zaehlt_die_kompression_mit(): + from rippy.store import JOB_OFFEN + + assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang" + assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch" + assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN + + +def test_auswurf_bleibt_waehrend_der_kompression_erlaubt(): + """Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt + jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc + darf raus. Nur die AUTOMATIK haelt sich zurueck.""" + import inspect + + from rippy import store + + assert "transcoding" not in inspect.getsource(store.has_active_job) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 9385ce8..8cd0aeb 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -780,6 +780,48 @@ def write_abcde_config(output_dir: str) -> str: return tmp.name +def disc_fehlt(device_path: str, zustand=None) -> str: + """Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut). + + ## Warum vorher nachgesehen wird (Befund 29.08.2026) + + Der Commander bekam: + + makemkvcon endete mit Code 11 — letzte Meldung: + Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden + + Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld + gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk. + Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch + trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein + und meldet einen Satz, aus dem niemand das schliessen kann. + + Nachsehen kostet Millisekunden und erspart genau diesen Satz. + + ⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer + das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und + „ich weiss es nicht" darf nie zu „es geht nicht" werden. + """ + if zustand is None: + try: + from rippy import drives as _schicht + + zustand = _schicht.treiber().drive_status + except Exception: # noqa: BLE001 + return "" + try: + stand = zustand(device_path) + except Exception: # noqa: BLE001 + return "" + if stand == CDS_NO_DISC: + return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip " + "wirft Rippy die Disc aus — fuer einen neuen Rip muss sie " + "wieder hinein." % device_path) + if stand == CDS_TRAY_OPEN: + return "Die Schublade von %s ist offen." % device_path + return "" + + def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all", log_cb=None) -> dict: """Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt. @@ -794,6 +836,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str if not check_makemkv_installed(): return {"status": "error", "error": "makemkvcon ist nicht installiert"} + fehlt = disc_fehlt(device_path) + if fehlt: + return {"status": "error", "error": fehlt} + os.makedirs(output_dir, exist_ok=True) try: diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 68589da..d0abfd5 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -519,3 +519,70 @@ def test_handbrake_kommando_mit_sprachauswahl(): assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn" assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu" assert "--first-audio" in cmd and "--all-subtitles" in cmd + + +# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ───────────────────── +# +# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der +# Disk schlug fehl — keine MKV-Datei entstanden" +# +# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld +# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem +# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein +# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen +# Satz, aus dem niemand das schliessen kann. + + +def test_ein_leeres_laufwerk_wird_vorher_erkannt(): + import ripping + from rippy.drives.linux import CDS_NO_DISC + + grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC) + assert "Kein Datentraeger" in grund + assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein" + + +def test_offene_schublade_auch(): + import ripping + from rippy.drives.linux import CDS_TRAY_OPEN + + assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN) + + +def test_mit_disc_wird_nicht_gemeckert(): + import ripping + from rippy.drives.linux import CDS_DISC_OK + + assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == "" + + +def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT(): + """„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das + Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist.""" + import ripping + + def wirft(p): + raise OSError(5, "Zugriff verweigert") + + assert ripping.disc_fehlt("G:", zustand=wirft) == "" + + +def test_run_makemkv_sieht_vorher_nach(): + """Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft + man weiterhin zwei Minuten ins Leere.""" + import inspect + + import ripping + + quelle = inspect.getsource(ripping.run_makemkv) + vor_popen = quelle.index("subprocess.Popen") + assert "disc_fehlt(" in quelle[:vor_popen], \ + "die Disc-Pruefung gehoert vor den makemkvcon-Start" + + +def test_die_sammelmeldung_5010_wird_erklaert(): + """5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts.""" + from rippy.rip.makemkv_aufruf import KRITISCHE_CODES + + assert 5010 in KRITISCHE_CODES + assert "Disc" in KRITISCHE_CODES[5010] diff --git a/src/rippy/rip/makemkv_aufruf.py b/src/rippy/rip/makemkv_aufruf.py index c213486..4d246c6 100644 --- a/src/rippy/rip/makemkv_aufruf.py +++ b/src/rippy/rip/makemkv_aufruf.py @@ -119,6 +119,17 @@ def text_von(roh) -> str: #: Aktivierungsschlüssel …" KRITISCHE_CODES = { 2024: "MakeMKV kennt dieses Laufwerk nicht", + # 5010 stand bis zum 29.08.2026 NICHT hier — und genau das war das + # Problem: Der Commander bekam „makemkvcon endete mit Code 11 — letzte + # Meldung: Das Öffnen der Disk schlug fehl". Das ist MakeMKVs + # Sammelmeldung für „ging nicht" und sagt für sich genommen nichts. + # + # Auffällig war, was FEHLTE: kein „Ursache:" davor. Wäre der Gerätepfad + # schuld gewesen, stünde dort 2024. Bleibt: kein Datenträger, oder ein + # anderes Programm hält das Laufwerk. Nach einem fertigen Rip wirft Rippy + # die Disc aus — ein zweiter Versuch trifft dann ein leeres Laufwerk. + 5010: "Das Laufwerk liess sich nicht öffnen — liegt die Disc noch drin? " + "Nach einem fertigen Rip wirft Rippy sie aus", 5020: "Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen", 5021: "MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV " "aktualisieren", diff --git a/src/rippy/store/__init__.py b/src/rippy/store/__init__.py index 5955318..6d32676 100644 --- a/src/rippy/store/__init__.py +++ b/src/rippy/store/__init__.py @@ -361,6 +361,47 @@ def has_active_job(device: str) -> bool: return zeile is not None +# Ein Job ist erst fertig, wenn er FERTIG ist. Die Kompression gehoert dazu. +# +# ⚠️ Nicht dasselbe wie `has_active_job`. Das fragt „haelt gerade jemand das +# Laufwerk?" — waehrend der Kompression tut das niemand, ein Auswurf ist dann +# erlaubt. Hier wird gefragt „ist der Vorgang durch?", und das ist er nicht. +JOB_OFFEN = ("pending", "running", "transcoding", "canceling") + + +def job_offen(device: str) -> bool: + """True, solange auf dem Geraet ein Job noch nicht abgeschlossen ist. + + ## Warum es diese zweite Frage gibt (Befund 29.08.2026) + + Der Commander meldete einen Rip-Fehlschlag „bei der Komprimierung", obwohl + der Rip laengst durch war. Der Ablauf dahinter: + + 1. Rip fertig → Rippy wirft die Disc aus (Standardeinstellung) + 2. Status wird auf `transcoding` gesetzt — und ab hier sagte + `has_active_job` NEIN, das Laufwerk sei frei + 3. Die Disc-Wache sieht beim Auswurf einen Statuswechsel und meldet + „eingelegt" + 4. Die Vollautomatik (`autoRipStart`) startet einen ZWEITEN Rip — auf + ein Laufwerk, dessen Schublade gerade herausfaehrt + + Der zweite Rip lief in ein leeres Laufwerk und endete mit „Das Oeffnen der + Disk schlug fehl". Auf dem Bildschirm sah das aus, als sei die Kompression + gescheitert — sie lief in Wahrheit ungestoert weiter. + + Solange ein Vorgang auf diesem Laufwerk laeuft, faengt die Automatik + keinen zweiten an. Von Hand darf der Commander weiterhin alles. + """ + with engine_holen().connect() as conn: + zeile = conn.execute( + select(jobs.c.id) + .where(jobs.c.device == device) + .where(jobs.c.status.in_(JOB_OFFEN)) + .limit(1) + ).first() + return zeile is not None + + def meta_merken(job_id: str, **felder) -> None: """Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.