diff --git a/docker/api/main.py b/docker/api/main.py index 040c92e..c27540a 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -233,6 +233,19 @@ async def _auto_prescan(pfad: str): except Exception as e: DISC_CACHE.pop(pfad, None) print(f"Auto-Pre-Scan {pfad}: {e}") + # ... und ins PROTOKOLL, nicht nur auf die Konsole + # (Befund 30.08.2026). Der Commander: „im log steht zwar + # erkannt, aber ein start des rips ist nicht moeglich." + # Genau so war es: Die letzte Zeile war das erfolgreiche + # „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche + # danach standen nur auf einer Konsole, die niemand sieht. + # Ein Protokoll, in dem nur die Erfolge stehen, luegt. + try: + db.add_log("warning", "watcher", + "Disc-Erkennung auf %s fehlgeschlagen: %s" + % (pfad, e)) + except Exception: # noqa: BLE001 + pass # Protokollieren darf die Wache nie anhalten async def _auto_rip_wenn_aktiviert(pfad: str): @@ -468,6 +481,9 @@ class Device(BaseModel): status: str model: Optional[str] = None serial: Optional[str] = None + # Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht. + # Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer. + grund: str = "" disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster) # Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc # erkennung noch läuft muss sichtbar sein") @@ -789,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list: return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da) +def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str: + """Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung. + + ## Warum die Einstellung allein nicht reicht (Befund 30.08.2026) + + Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten + gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als + `work_dir` — geschrieben in `start_rip`, gelesen im Worker von + `_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem + HEUTIGEN Wert der Einstellung `workDir`. + + Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB + Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde + damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin + die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der + Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen. + + Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen + Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt + wurde), statt nachzusehen. Der Job weiß es selbst. + """ + holen = getattr(job, "get", None) + eigen = "" + if holen: + eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip() + if not eigen: + werte = einstellungen if einstellungen is not None else db.get_settings() + eigen = ((werte or {}).get("workDir") or "").strip() + # normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie + # gemeint. "/" fällt in `kandidaten()` sauber durch. + return os.path.normpath(eigen or "/") + + # Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in # /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard # abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das @@ -915,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None: Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf „Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg. """ - work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") + einstellungen = db.get_settings() alt = _ROHDATEN["treffer"] treffer = {} for zeile in db.list_jobs(): if zeile.get("status") != "failed": continue job_id = zeile["id"] + # Je Job SEINE Wahl — nicht die heutige Einstellung. + work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen) ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir) if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id): treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten @@ -1050,8 +1101,7 @@ async def job_rohdaten(job_id: str): raise HTTPException(status_code=404, detail="Job nicht gefunden") def sammle(): - work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") - pfade = _rohdaten_suchen(job_id, work_dir) + pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job)) bytes_gesamt, dateien = _rohdaten_groesse(pfade) return { "pfade": pfade, @@ -1080,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False): geloescht_gb = 0.0 if rohdaten: def raeume(): - work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/") - pfade = _rohdaten_suchen(job_id, work_dir) + pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job)) bytes_gesamt, _ = _rohdaten_groesse(pfade) for pfad in pfade: shutil.rmtree(pfad, ignore_errors=True) @@ -1564,7 +1613,7 @@ async def retry_transcode(job_id: str): raise HTTPException(status_code=409, detail="Job rippt noch") einstellungen = await asyncio.to_thread(db.get_settings) - work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/") + work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen) gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir) if not gefunden: raise HTTPException( @@ -2528,6 +2577,13 @@ async def system_info(): werte = rippy_config.laden() except Exception: # noqa: BLE001 werte = {} + # Die Oberflaeche schreibt nach `outputDir`/`workDir` in die + # DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne + # diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal + # was eingestellt war (Befund 30.08.2026, siehe + # `betrieb.mit_einstellungen`). + werte = betriebs_auskunft.mit_einstellungen( + werte, db.get_settings(bei_fehler_leer=True)) for ort in betriebs_auskunft.platz_orte(werte): # Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann # das naechste vorhandene Elternverzeichnis messen: Der Nutzer @@ -2911,6 +2967,75 @@ async def get_devices(): await asyncio.to_thread(laufwerke_mit_disc)] +#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die +#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere. +_LETZTER_GRUND: Dict[str, str] = {} + + +def _grund_melden(pfad: str, grund: str) -> None: + """Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim + Wechsel. + + ## Der Befund des Commanders (30.08.2026) + + > „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar + > erkannt, aber ein start des rips ist nicht moeglich)" + + Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine + Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon. + Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und + Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar. + Die letzte Protokollzeile war das laengst veraltete „Disc erkannt". + + Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`). + Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur + beschreiben, sondern weiterhelfen. + """ + if _LETZTER_GRUND.get(pfad, "") == grund: + return + vorher = _LETZTER_GRUND.get(pfad, "") + _LETZTER_GRUND[pfad] = grund + try: + if grund: + db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund)) + elif vorher: + db.add_log("info", "watcher", + "Laufwerk %s antwortet wieder." % pfad) + except Exception: # noqa: BLE001 + pass # Protokollieren darf die Laufwerksliste nie aufhalten + + +def _job_haelt_das_laufwerk(pfad: str) -> bool: + """Laeuft auf diesem Laufwerk gerade ein Rip? + + ## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026) + + Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei + `CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon + gehoert. Am Protokoll des Commanders abgelesen: + + 12:49:52 bluray-Rip gestartet + 12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen + 12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB + 12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl + 12:50:12 makemkvcon endete mit Code 11 + + Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen." + + `_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel + („Es gibt keinen Grund, waehrend eines Rips zu scannen") — die + Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen + bereits, was drinliegt, der Job laeuft ja darauf. + + Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist + keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts. + """ + try: + return bool(db.has_active_job(pfad)) + except Exception: # noqa: BLE001 + return False # im Zweifel nachsehen, wie bisher + + def laufwerke_mit_disc() -> list: """Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer. @@ -2932,8 +3057,15 @@ def laufwerke_mit_disc() -> list: nur diese hier. """ geraete = [] + letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE} for pfad in device_discovery.list_optical_devices(): - info = device_discovery.device_info(pfad) + if _job_haelt_das_laufwerk(pfad) and pfad in letzte: + # Nicht anfassen — der letzte bekannte Stand gilt weiter. + info = {k: v for k, v in letzte[pfad].items() + if k not in ("disc", "disc_wird_erkannt")} + else: + info = device_discovery.device_info(pfad) + _grund_melden(pfad, info.get("grund") or "") disc = DISC_CACHE.get(pfad) if disc and disc.get("_laeuft"): # NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber diff --git a/docker/api/mounts.py b/docker/api/mounts.py index 270c835..0e0882a 100644 --- a/docker/api/mounts.py +++ b/docker/api/mounts.py @@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei). import os import posixpath + +from rippy.platform.winlauf import OHNE_FENSTER import re import subprocess import tempfile @@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool: ziel = _mountpoint(name) try: ergebnis = subprocess.run( - ["timeout", "3", "ls", ziel], capture_output=True, timeout=5 + ["timeout", "3", "ls", ziel], capture_output=True, + creationflags=OHNE_FENSTER, timeout=5 ) return ergebnis.returncode == 0 except (OSError, subprocess.TimeoutExpired): @@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str: """ try: ergebnis = subprocess.run( - ["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6 + ["timeout", "4", "ls", "-d", ziel], capture_output=True, + creationflags=OHNE_FENSTER, timeout=6 ) except (OSError, subprocess.TimeoutExpired): return "unklar" diff --git a/docker/api/phasen.py b/docker/api/phasen.py index 46d45e3..9193f17 100644 --- a/docker/api/phasen.py +++ b/docker/api/phasen.py @@ -44,8 +44,12 @@ UNKLAR = "unklar" NICHTS = "" -def _meta(meta_json) -> dict: - """Metadaten lesen, ohne an kaputtem JSON zu scheitern.""" +def meta_von(meta_json) -> dict: + """Metadaten lesen, ohne an kaputtem JSON zu scheitern. + + Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des + Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden. + """ if isinstance(meta_json, dict): return meta_json if not meta_json: @@ -68,7 +72,7 @@ def retry_art(job) -> str: """ if (job.get("status") or "") != "failed": return NICHTS - marke = _meta(job.get("meta")).get(RIP_FERTIG) + marke = meta_von(job.get("meta")).get(RIP_FERTIG) if marke is True: return NEU_KOMPRIMIEREN if marke is False: diff --git a/docker/api/prescan/prescan.py b/docker/api/prescan/prescan.py index 048717a..0c6ea16 100644 --- a/docker/api/prescan/prescan.py +++ b/docker/api/prescan/prescan.py @@ -11,9 +11,6 @@ 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 from clients.tmdb import TMDBClient from clients.jikan import JikanClient from clients.musicbrainz import MusicBrainzClient @@ -359,37 +356,37 @@ class PreScan: if label: toc["title"] = normalize_disc_label(label) - # Titel-Quelle 3: die Titelliste von makemkvcon (optional). + # Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr. # - # ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which` - # (Befund 28.08.2026). `which` sucht nur im PATH — unter - # Windows liegen Programme in „Programme", nicht im PATH. - # Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei - # installiertem MakeMKV None zurück, und dieser Zweig lief - # NIE. Genau derselbe Fehler, für den es `tools/katalog.py` - # gibt. - makemkv = werkzeug_katalog.finden("makemkv") - if makemkv: - result = subprocess.run( - [makemkv, "-r", "--noscan", "--minlength=300", "info", makemkv_quelle(device_path)], - capture_output=True, - 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:'): - parts = line.split(',') - if len(parts) >= 5: - toc["tracks"].append({ - "title": parts[4].strip().strip('"') if len(parts) > 4 else "Title", - "duration": int(parts[2]) if len(parts) > 2 else 0 - }) + # ## Warum sie weg ist (Befund 30.08.2026) + # + # Der Commander: „das erkennen der disk dauert sehr sehr + # lange. Das ging mal viel schneller." + # + # Hier stand ein `makemkvcon -r --noscan --minlength=300 + # info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc, + # vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf + # 20 bis 120 Sekunden; solange steht „Disc wird gelesen". + # + # Dass es frueher schnell war, hat einen unschoenen Grund: + # Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit + # `shutil.which`, und das findet unter Windows nichts + # (Programme liegen nicht im PATH). `be3fac5` hat das am + # 28.08.2026 richtig repariert — und damit erst die Kosten + # sichtbar gemacht, die hier immer schon standen. + # + # Der Aufwand war umsonst: Das Ergebnis landete allein in + # `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog + # holt seine Titelliste ueber `POST /devices/{id}/scan-tracks` + # und `GET /devices/{id}/tracks` — also dann, wenn sie + # gebraucht wird, statt bei jedem Einlegen auf Verdacht. + # Der Weg fuer eine Disc, die man gar nicht rippen will, + # sind so zwei Minuten Warten fuer nichts. + # + # Der Titel kommt aus Quelle 1 und 2 darueber; die sind + # billig. Wer den Aufruf je wieder braucht, braucht dazu + # `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und + # `tools.katalog` — die Importe sind mit ihm gegangen. except Exception as e: print(f"Pre-Scan TOC Error: {e}") return toc diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py index 77bdc7f..7c131e1 100644 --- a/docker/api/rohdaten.py +++ b/docker/api/rohdaten.py @@ -38,6 +38,7 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`. """ +import os import subprocess from rippy import pfade as _pfade @@ -48,6 +49,23 @@ RAW_STANDARD = "/app/temp/raw" MEDIA_ROOT = "/app/media" +def _ui_einstellungen() -> dict: + """Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank + gerade nicht antwortet. + + Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der + Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an + einer klemmenden Datenbank nicht scheitern — dann gilt eben die + Vorgabe, wie bisher. + """ + try: + from rippy import store as db + + return db.get_settings(bei_fehler_leer=True) or {} + except Exception: # noqa: BLE001 + return {} + + def wurzeln(werte=None) -> tuple: """`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb. @@ -70,6 +88,11 @@ def wurzeln(werte=None) -> tuple: werte = config.laden() except Exception: # noqa: BLE001 werte = {} + # Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt + # ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie + # suchte die Rohdaten-Suche unter der Vorgabe statt unter dem, + # was eingestellt ist (Befund 30.08.2026). + werte = betrieb.mit_einstellungen(werte, _ui_einstellungen()) return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD, betrieb.medien_wurzel(werte) or MEDIA_ROOT, betrieb.frei_blaettern(werte)) @@ -99,10 +122,29 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner, from rippy import pfade orte = [pfade.verbinden(roh, job_id)] - wahl = (work_dir or "").strip().rstrip("/\\") + # Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit. + # + # ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet: + # Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit + # dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN. + # Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis: + # + # "F:\\".rstrip("/\\") -> "F:" + # verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False + # verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True + # + # `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf + # Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von + # `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB + # Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu + # rippen" an — Stunden am beschaedigten Datentraeger fuer nichts. + wahl = (work_dir or "").strip() + vergleich = wahl.rstrip("/\\") + grenze = (medien or "").rstrip("/\\") # Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem # ganz anderen Laufwerk und damit unter gar keiner Wurzel. - if wahl and (frei or wahl == medien or wahl.startswith(medien + "/")): + if vergleich and (frei or vergleich == grenze + or vergleich.startswith(grenze + "/")): orte.append(pfade.verbinden(wahl, job_id)) for name in media_unterordner or []: if name: @@ -115,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner, return eindeutig +def nativ_nachsehen() -> bool: + """Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten? + + Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe + Regel wie bei `betrieb.im_container`. Die Begruendung steht in + `pruefen`. + """ + return os.name == "nt" + + def pruefen(pfad: str, laufen=None) -> str: """Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze. @@ -131,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str: """ if not pfad: return "weg" + if laufen is None and nativ_nachsehen(): + # ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht + # + # ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet: + # + # 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-... + # 14:54:40 WindowsTerminal.exe + # + # Der Commander: „nun oeffnen sich diverse fenster im hintergrund, + # gehen ganz kurz auf und dann wieder zu." + # + # `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine + # `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls` + # noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows + # dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf, + # ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also + # die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit + # unter Windows grundsaetzlich unsichtbar. + # + # Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im + # Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein + # totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem + # Fehler zurueckkommen, nicht unabbrechbar haengen. + try: + return "da" if os.path.isdir(pfad) else "weg" + except OSError: + return "unklar" starten = laufen or subprocess.run try: ergebnis = starten( diff --git a/docker/api/test_api_smoke.py b/docker/api/test_api_smoke.py index e3bde2e..7cf7930 100644 --- a/docker/api/test_api_smoke.py +++ b/docker/api/test_api_smoke.py @@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch): monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: []) # Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine # Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses. - monkeypatch.setattr(main.db, "get_settings", lambda: {}) + # + # `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte + # Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`. + # Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer + # einen der Parameter benutzte — `system_info` fiel damit aus dem + # Schnappschuss, und dieser Test zeigte auf den Code statt auf sich + # selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er + # ersetzt, nicht nur den einen Aufruf, den es gerade gibt. + monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {}) zustand = asyncio.run(main._snapshot()) @@ -880,3 +888,47 @@ def test_auswurf_bleibt_waehrend_der_kompression_erlaubt(): from rippy import store assert "transcoding" not in inspect.getsource(store.has_active_job) + + +def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch): + """Befund 30.08.2026, aus dem Protokoll des Commanders: + + 12:49:52 bluray-Rip gestartet + 12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen + 12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB + 12:50:12 makemkvcon endete mit Code 11 + + Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs + auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk + hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem + 29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht. + """ + import main + + gefragt = [] + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: ["/dev/sr0"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})) + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})]) + monkeypatch.setattr(main.db, "has_active_job", lambda p: True) + + stand = main.laufwerke_mit_disc() + assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen" + assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt + + +def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch): + """Die Ausnahme darf nur fuer den laufenden Rip gelten.""" + import main + + gefragt = [] + monkeypatch.setattr(main.device_discovery, "list_optical_devices", + lambda: ["/dev/sr0"]) + monkeypatch.setattr(main.device_discovery, "device_info", + lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})) + monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})]) + monkeypatch.setattr(main.db, "has_active_job", lambda p: False) + + main.laufwerke_mit_disc() + assert gefragt == ["/dev/sr0"] diff --git a/docker/api/test_rohdaten.py b/docker/api/test_rohdaten.py index 08649b6..1b022db 100644 --- a/docker/api/test_rohdaten.py +++ b/docker/api/test_rohdaten.py @@ -58,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt(): assert f"/app/media/{JOB}" in orte +#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) — +#: Gegenstück zu CONTAINER weiter oben. +NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True) + + +def test_laufwerks_wurzel_bleibt_absolut(): + """Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel. + + Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch + VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner + auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer + ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und + der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am + beschädigten Datenträger für etwas, das schon dalag. + """ + orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV) + assert "F:" + chr(92) + JOB in orte + assert "F:" + JOB not in orte + # Ohne Schluss-Trenner muss dasselbe herauskommen + assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == ( + "F:" + chr(92) + "Roh" + chr(92) + JOB) + + +def test_media_root_mit_schluss_trenner_zaehlt_auch(): + """/app/media/ und /app/media sind derselbe Ort — der Vergleich darf + nicht am Trenner scheitern.""" + orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER) + assert f"/app/media/{JOB}" in orte + + def test_ohne_job_id_nichts(): assert rohdaten.kandidaten("", "/app/media", ["x"]) == [] @@ -237,3 +267,40 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort(): JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER) assert e["pfade"] == [f"/app/media/rippy/{JOB}"] assert e["unklar"] is True + + +def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path): + """Befund 30.08.2026, an der laufenden Instanz beobachtet: + + 14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-... + 14:54:40 WindowsTerminal.exe + + Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind + Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch + `-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je + Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also + die Antwort weg fuer JEDES Verzeichnis. + """ + gestartet = [] + monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True) + monkeypatch.setattr(rohdaten.subprocess, "run", + lambda *a, **k: gestartet.append(a)) + assert rohdaten.pruefen(str(tmp_path)) == "da" + assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg" + assert gestartet == [], "es darf kein Prozess gestartet werden" + + +def test_im_container_bleibt_der_kindprozess(monkeypatch): + """Dort ist der Umweg richtig: os.path.isdir kann an einem toten + CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da).""" + monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False) + aufrufe = [] + + class Antwort: + returncode = 0 + + monkeypatch.setattr(rohdaten.subprocess, "run", + lambda *a, **k: aufrufe.append(a[0]) or Antwort()) + assert rohdaten.pruefen("/app/media/x") == "da" + assert aufrufe[0][0] == "timeout" + assert aufrufe[0][-1] == "/app/media/x" diff --git a/docker/worker/ablauf.py b/docker/worker/ablauf.py index 692f15f..061e9ea 100644 --- a/docker/worker/ablauf.py +++ b/docker/worker/ablauf.py @@ -256,12 +256,33 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "", def _frei_bytes(pfad: str) -> int: - """Freier Platz am Pfad (nächster existierender Elternordner zählt).""" - kandidat = pfad - while kandidat and not os.path.exists(kandidat): - kandidat = os.path.dirname(kandidat) + """Freier Platz am Pfad (nächster existierender Elternordner zählt). + + ## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026) + + An dieser Stelle stand sie als eigene Schleife: + + while kandidat and not os.path.exists(kandidat): + kandidat = os.path.dirname(kandidat) + + Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an + einem freien Laufwerksbuchstaben nachgemessen. Zeigt das + Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade + nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks- + Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in + `_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige + Meldung stehen, und im Protokoll stuende nichts, womit man das + aufklären koennte. + + `pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit + Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen. + Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer + davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`. + """ + from rippy import pfade + try: - return shutil.disk_usage(kandidat or "/").free + return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free except OSError: return -1 @@ -612,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None, if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen: return gesehen.add(text) - db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}") + # Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte + # sind uebersetzt, die Nummern nicht — sie sind die einzige + # verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie + # war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE + # Meldung MakeMKV geschickt hatte; die Erkennung musste sich + # ersatzweise an einer URL im Text festhalten. + db.add_log("info", "makemkv", + f"Job {job_id}: MSG {code} — {text[:300]}") einstellungen = db.get_settings(bei_fehler_leer=True) ist_video = disc_type in ("dvd", "bluray", "uhd") @@ -740,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None, "Normale BD/DVD gehen weiterhin." ) + # Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg + # endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete + # sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel + # fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt + # eine halbe Disc für eine ganze. + if ergebnis.get("lesefehler"): + db.add_log( + "warning", "worker", + f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an " + "mehreren Stellen nicht durch. Es kann sein, dass ein Titel " + "fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial " + "von innen nach außen, nicht kreisend) oder ein anderes " + "Laufwerk probieren — Laufwerke unterscheiden sich hier stark.", + ) + # Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die # Kompression arbeitet auf der Datei, nicht am Laufwerk. # diff --git a/docker/worker/caps.py b/docker/worker/caps.py index b83a707..e7cd1c0 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -11,6 +11,7 @@ import re import subprocess from rippy.platform.winlauf import OHNE_FENSTER +from rippy.rip.handbrake_aufruf import HB_LESEN from rippy.tools import katalog as werkzeuge @@ -191,7 +192,7 @@ def hole_handbrake_presets() -> list: try: aus = subprocess.run( [_hb(), "--preset-list"], - capture_output=True, text=True, timeout=30, + capture_output=True, timeout=30, **HB_LESEN, creationflags=OHNE_FENSTER, ) return parse_preset_liste((aus.stdout or "") + (aus.stderr or "")) @@ -304,8 +305,8 @@ def hole_handbrake_hilfe() -> str: return "" try: aus = subprocess.run( - [_hb(), "--help"], capture_output=True, text=True, timeout=30, - creationflags=OHNE_FENSTER + [_hb(), "--help"], capture_output=True, timeout=30, + creationflags=OHNE_FENSTER, **HB_LESEN ) return (aus.stdout or "") + (aus.stderr or "") except (OSError, subprocess.TimeoutExpired): @@ -403,7 +404,7 @@ def werkzeug_versionen() -> dict: try: aus = subprocess.run( [_hb(), "--version"], - capture_output=True, text=True, timeout=15, + capture_output=True, timeout=15, **HB_LESEN, creationflags=OHNE_FENSTER, ) treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or "")) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 8cd0aeb..308f86f 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -37,6 +37,7 @@ from rippy.drives.linux import ( # noqa: F401 ) from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401 from rippy.platform.winlauf import OHNE_FENSTER +from rippy.rip.handbrake_aufruf import HB_LESEN from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle from rippy.tools import katalog as werkzeuge @@ -88,6 +89,24 @@ def check_cdparanoia_installed() -> bool: return shutil.which("cdparanoia") is not None +#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen". +#: +#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2, +#: deutschsprachiges MakeMKV): +#: +#: Encountered 29 errors of type 'Read Error' - see +#: http://www.makemkv.com/errors/read/ +#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl. +#: +#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die +#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere +#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu +#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben — +#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie +#: hierher statt dieser Textsuche. +LESEFEHLER_MARKE = "makemkv.com/errors/read" + + def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list: """Baut das MakeMKV-Kommando (pure Funktion, testbar). @@ -273,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list, Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig).""" gesamt = len(titel_liste) alle_dateien = [] + # Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2 + # sauber durchlief — je Titel laeuft ein eigener makemkvcon. + lesefehler = False for index, nr in enumerate(titel_liste): def anteilig(p, _i=index): if progress_cb: @@ -280,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list, ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr), log_cb=log_cb) + lesefehler = lesefehler or bool(ergebnis.get("lesefehler")) if ergebnis.get("status") == "cancelled": return ergebnis if ergebnis.get("status") != "success": ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}" + ergebnis["lesefehler"] = lesefehler return ergebnis alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner - return {"status": "success", "output_dir": output_dir, "files": alle_dateien} + return {"status": "success", "output_dir": output_dir, "files": alle_dateien, + "lesefehler": lesefehler} def laengster_titel(dauern: dict, meta: dict = None): @@ -362,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int: try: ergebnis = subprocess.run( [werkzeug("handbrake"), "--scan", "-i", pfad], - capture_output=True, text=True, timeout=timeout, + capture_output=True, timeout=timeout, **HB_LESEN, creationflags=OHNE_FENSTER, ) except (OSError, subprocess.TimeoutExpired): @@ -507,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str, Sprachen drin — und das ist bei einer verlustfreien Ablage richtig. `--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro - Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht + Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen. """ befehl = [ @@ -539,7 +564,23 @@ def build_handbrake_cmd(input_path: str, output_path: str, if audio: befehl += ["--audio-lang-list", ",".join(audio)] befehl.append("--first-audio") - befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"] + # `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026). + # + # Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es + # nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten + # HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile: + # + # unknown option (--audio-codec) + # HandBrake has exited. $? = 0 + # + # Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war + # in derselben Sekunde wieder weg, in der es startete, und meldete das + # als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein + # (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler + # festschreibt, statt ihn zu finden. + # + # `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft). + befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"] untertitel = [s for s in (untertitel_sprachen or []) if s] if untertitel: befehl += ["--subtitle-lang-list", ",".join(untertitel)] @@ -644,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR audio_sprachen, untertitel_sprachen), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - text=True, bufsize=1, creationflags=OHNE_FENSTER, + **HB_LESEN, ) return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb) except Exception as e: @@ -673,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str: return text[len(kopf):].strip() if text.startswith(kopf) else "" +#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden. +HB_ZEILEN_PUFFER = 12 +#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der +#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar. +HB_ZEILEN_MELDUNG = 4 + +_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)") + + +def unbekannter_schalter(zeile: str) -> str: + """Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt? + + ## Der Befund des Commanders (30.08.2026) + + > „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete + > mit Code 0 — Roh-Datei bleibt erhalten" + + 16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei, + in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht. + Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der + Befehlszeile, die Rippy baute: + + unknown option (--audio-codec) + HandBrake has exited. + $? = 0 + + **Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner + eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei — + und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch, + und es schickte die Suche in die vollkommen falsche Richtung. + + Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung + bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden, + statt wieder einen ganzen Rip zu kosten. + """ + treffer = _UNBEKANNTER_SCHALTER.search(zeile or "") + return treffer.group(1).strip() if treffer else "" + + +def hb_schluss(zeilen) -> str: + """HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure). + + Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein + Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig — + und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der + Ausgabe, nur hoerte niemand zu. + """ + sauber = [z.strip() for z in (zeilen or []) if z and z.strip()] + if not sauber: + return "" + return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:]) + + def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict: """Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist.""" falsches_preset = "" + falscher_schalter = "" + # Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige + # Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`). + letzte_zeilen = [] try: for line in process.stdout: # Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt @@ -684,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= # sich die Prozentzahl bewegt (Befund 25.07.2026). if abbruch_cb: abbruch_cb() + if line.strip(): + letzte_zeilen.append(line) + if len(letzte_zeilen) > HB_ZEILEN_PUFFER: + del letzte_zeilen[0] if not falsches_preset: falsches_preset = unbekanntes_preset(line) + if not falscher_schalter: + falscher_schalter = unbekannter_schalter(line) progress = get_progress_from_line(line) # >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte # Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei @@ -702,6 +806,24 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= if process.returncode == 0 and os.path.exists(output_path): return {"status": "success", "output_path": output_path} + # Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in + # RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe + # `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen + # anderen: sonst landet der Fall unten bei „HandBrake endete mit Code + # 0", und dort ist er nicht zu erraten. Genau das kostete am + # 30.08.2026 einen fertigen 16,5-GB-Rip. + if falscher_schalter: + return { + "status": "error", + "error": ( + 'Rippy hat HandBrake den Schalter „%s" übergeben, den ' + 'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler ' + 'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter + + hb_schluss(letzte_zeilen) + ), + "return_code": process.returncode, + } + # Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben. # # Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv: @@ -721,11 +843,17 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= "(der Container kommt aus dem Preset)." % (os.path.basename(daneben), os.path.basename(output_path))} + # Frueher stand hier geraten „Meist ist der Zielordner nicht + # beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da + # und leer, der Grund ein falscher Schalter) — und die Vermutung + # schickte die Suche in die falsche Richtung. Jetzt wird der + # Ordner GENANNT und HandBrake selbst zitiert. return { "status": "error", "error": ("HandBrake meldet Erfolg, aber es ist keine Datei " - "entstanden. Meist ist der Zielordner nicht " - "beschreibbar: %s" % os.path.dirname(output_path)), + "entstanden (Zielordner: %s)." + % os.path.dirname(output_path) + + hb_schluss(letzte_zeilen)), "return_code": 0, } if falsches_preset: @@ -742,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb= } return { "status": "error", - "error": f"HandBrake endete mit Code {process.returncode}", + "error": (f"HandBrake endete mit Code {process.returncode}" + + hb_schluss(letzte_zeilen)), "return_code": process.returncode, } @@ -862,6 +991,13 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str ) letzte_meldung = "" + # Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den + # kaputten Titel und macht mit dem naechsten weiter. Genau deshalb + # muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als + # „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im + # Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur + # den MakeMKV-Zeilen zu entnehmen, die niemand liest. + lesefehler = False # Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur # "Failed to open disc" — die URSACHE ("volume key is unknown", Key # abgelaufen) steht Zeilen davor und ging im Fehlertext verloren @@ -891,6 +1027,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str if meldung is None: continue code, letzte_meldung = meldung + if LESEFEHLER_MARKE in letzte_meldung: + lesefehler = True if code in KRITISCHE_CODES: kritische_meldungen.append( "%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip())) @@ -922,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str "output_dir": output_dir, "files": mkv_dateien, "return_code": process.returncode, + "lesefehler": lesefehler, } return { "status": "error", + "lesefehler": lesefehler, "error": ( f"makemkvcon endete mit Code {process.returncode}" + (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "") diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index d0abfd5..f1a6aa3 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -13,7 +13,9 @@ from ripping import ( build_makemkv_cmd, get_progress_from_line, get_progress_from_prgv, + hb_schluss, parse_msg, + unbekannter_schalter, write_abcde_config, ) @@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet(): assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv" assert "--preset" in cmd assert "--first-audio" in cmd # beste Spur pro Sprache behalten - assert "--audio-codec" in cmd + assert "--aencoder" in cmd assert "--all-subtitles" in cmd +def test_handbrake_kennt_keinen_schalter_audio_codec(): + """Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht. + + Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen: + + unknown option (--audio-codec) + HandBrake has exited. $? = 0 + + Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete + das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit + ins Leere, ohne dass irgendwo ein Grund stand. + + ⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der + den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter + ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten + Programm gemessen, nicht aus dem Gedächtnis behauptet. + """ + cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv") + assert "--audio-codec" not in cmd + assert cmd[cmd.index("--aencoder") + 1] == "copy" + + +def test_unbekannter_schalter_wird_erkannt(): + """Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2).""" + assert unbekannter_schalter( + "unknown option (--audio-codec)") == "--audio-codec" + # Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran. + assert unbekannter_schalter( + "[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht" + assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == "" + assert unbekannter_schalter("") == "" + assert unbekannter_schalter(None) == "" + + +def test_hb_schluss_haengt_handbrakes_letzte_worte_an(): + """Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der + 0 ist, sagt er nichts (Befund 30.08.2026).""" + assert hb_schluss([]) == "" + assert hb_schluss(None) == "" + assert hb_schluss([" ", " "]) == "" + text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"]) + assert "fünf" in text and "vier" in text + # Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI. + assert "eins" not in text + + def test_handbrake_progress_parsing(): # Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45 @@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code(): assert ergebnis["return_code"] == 3 -def test_fehler_ohne_preset_problem_bleibt_der_alte(): +def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte(): + """Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen. + + Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und + jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch + für Fehler vergibt) blieb damit gar keine Auskunft übrig. + """ import ripping class FakeProcess: @@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte(): return 1 ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") - assert ergebnis["error"] == "HandBrake endete mit Code 1" + assert ergebnis["error"].startswith("HandBrake endete mit Code 1") + assert "irgendwas ganz anderes" in ergebnis["error"] + assert ergebnis["return_code"] == 1 + + +def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null(): + """Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem + Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg. + + Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg, + aber es ist keine Datei entstanden" samt der falschen Vermutung + „Zielordner nicht beschreibbar" — und die schickte die Suche in die + vollkommen falsche Richtung. + """ + import ripping + + class FakeProcess: + def __init__(self): + self.stdout = iter([ + "[13:30:54] hb_init: starting libhb thread\n", + "unknown option (--audio-codec)\n", + "HandBrake has exited.\n", + ]) + self.returncode = 0 + + def kill(self): + pass + + def wait(self): + return 0 + + ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv") + assert ergebnis["status"] == "error" + assert "--audio-codec" in ergebnis["error"] + assert "Fehler in Rippy" in ergebnis["error"] + # ... und NICHT die alte Vermutung über den Zielordner + assert "beschreibbar" not in ergebnis["error"] # --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) ----------- diff --git a/src/rippy/betrieb.py b/src/rippy/betrieb.py index 442d45d..df6e6a7 100644 --- a/src/rippy/betrieb.py +++ b/src/rippy/betrieb.py @@ -292,6 +292,44 @@ def windows_laufwerke(art=None, buchstaben=None) -> list: return gefunden +def mit_einstellungen(werte: dict, einstellungen: dict) -> dict: + r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen. + + ## Warum es diese Bruecke braucht (Befund 30.08.2026) + + Der Commander: „die verzeichnise sind andere als dort steht." + + Rippy hat ZWEI Speicher fuer dieselbe Frage: + + Oberflaeche -> Datenbank, Schluessel `outputDir` und `workDir` + Betrieb -> Konfigurationsdatei, `storage.medien` / `storage.temp` + + Geschrieben wird nur der erste — die Einstellungsseite kennt die + Konfigurationsdatei gar nicht. `ablage_vorgabe` und `arbeits_vorgabe` + lesen aber den zweiten, und der ist leer. Sie fielen deshalb IMMER auf + die Vorgabe zurueck: In „Einstellungen -> System" standen dauerhaft + `…\Videos\Rippy` und `…\Videos\Rippy\_arbeit`, egal was + eingestellt war — waehrend der Rip in Wahrheit nach `F:\` lief. + + Der Worker macht es richtig herum: `_arbeitsverzeichnis()` liest + `workDir` aus der Datenbank und faellt erst DANN auf die Vorgabe + zurueck. Diese Funktion stellt dieselbe Reihenfolge fuer alle her, die + ueber `betrieb` fragen — statt sie ein drittes Mal nachzubauen. + + Pure Funktion: Sie nimmt beide Woerterbuecher und gibt ein neues zurueck. + """ + lager = dict(((werte or {}).get("storage") or {})) + ablage = ((einstellungen or {}).get("outputDir") or "").strip() + arbeit = ((einstellungen or {}).get("workDir") or "").strip() + if ablage: + lager["medien"] = ablage + if arbeit: + lager["temp"] = arbeit + zusammen = dict(werte or {}) + zusammen["storage"] = lager + return zusammen + + def ablage_vorgabe(werte: dict, container: bool, system: str) -> str: """Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort.""" eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "") diff --git a/src/rippy/drives/linux.py b/src/rippy/drives/linux.py index 31e4f75..b60a422 100644 --- a/src/rippy/drives/linux.py +++ b/src/rippy/drives/linux.py @@ -269,19 +269,27 @@ def device_info(device_path: str) -> dict: vendor = read_sys_attr(name, "vendor") model = read_sys_attr(name, "model") + grund = "" try: status_code = drive_status(device_path) - except OSError: + except OSError as e: status_code = -1 + grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e) disc_type = "unknown" - status = "empty" + # Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER — + # man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1 + # daran („DIE Regel des Ports", siehe dort); hier stand weiterhin + # „empty", und damit behauptete derselbe Ereignisstrom je nach + # Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026). + status = "unknown" if status_code < 0 else "empty" if status_code == CDS_DISC_OK: status = "ready" try: disc_type = classify(disc_status(device_path), disc_size_bytes(device_path)) - except OSError: + except OSError as e: disc_type = "unknown" + grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e) return { "id": name, @@ -291,4 +299,7 @@ def device_info(device_path: str) -> dict: "status": status, "model": model, "serial": read_sys_attr(name, "wwid"), + # Gleiche Felder wie im Windows-Treiber — das UI unterscheidet + # nicht nach Plattform (siehe test_windows: Feld-Parität). + "grund": grund, } diff --git a/src/rippy/drives/test_windows.py b/src/rippy/drives/test_windows.py index f2eff70..d0c6c6b 100644 --- a/src/rippy/drives/test_windows.py +++ b/src/rippy/drives/test_windows.py @@ -186,6 +186,37 @@ def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer(): assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown" +def test_unzugaengliches_laufwerk_sagt_auch_WARUM(): + """Befund 30.08.2026: „jetzt erkennt rippy die disk garnicht mehr (im + log steht zwar erkannt, aber ein start des rips ist nicht moeglich)". + + Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine + Medien-Abfragen mehr (Win32-Fehler 1); die Geraete-Auskunft kam weiter + durch. Im UI stand eine vollstaendige Laufwerkskarte mit Modell und + Seriennummer — und „unknown" ohne ein Wort dazu. Rippy kannte den + Grund und behielt ihn fuer sich. + """ + api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler( + "CreateFileW", w.ERROR_INVALID_FUNCTION)) + info = windows.device_info(r"\\.\D:", api=api) + assert info["status"] == "unknown" + assert "auswerfen" in info["grund"] + + +def test_grund_bleibt_leer_solange_alles_geht(): + """Ein Feld, das immer gefuellt ist, sagt nichts mehr.""" + api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, + groesse=25 * 1024**3) + assert windows.device_info(r"\\.\D:", api=api)["grund"] == "" + + +def test_unbekannte_fehlernummer_wird_GENANNT(): + """Eine Nummer, nach der man suchen kann, ist mehr als ein leerer Satz.""" + text = windows.zugriffs_grund(windows.Win32Fehler("CreateFileW", 4711)) + assert "4711" in text + assert windows.zugriffs_grund(OSError("ohne Nummer")) + + @pytest.mark.parametrize("flags,groesse,erwartet", [ (w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"), (w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"), @@ -215,7 +246,8 @@ def test_device_info_hat_dieselben_felder_wie_unter_linux(): Karte im Dashboard leer.""" api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3) eintrag = windows.device_info(r"\\.\D:", api=api) - assert set(eintrag) == {"id", "name", "type", "path", "status", "model", "serial"} + assert set(eintrag) == {"id", "name", "type", "path", "status", "model", + "serial", "grund"} assert eintrag["id"] == "D" assert eintrag["status"] == "ready" assert eintrag["type"] == "bluray" diff --git a/src/rippy/drives/win_ioctl.py b/src/rippy/drives/win_ioctl.py index 4a4dfa2..02e5774 100644 --- a/src/rippy/drives/win_ioctl.py +++ b/src/rippy/drives/win_ioctl.py @@ -117,6 +117,7 @@ ERROR_NOT_READY = 21 # kein Medium eingelegt ERROR_ACCESS_DENIED = 5 ERROR_FILE_NOT_FOUND = 2 ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht +ERROR_NOT_SUPPORTED = 50 # Geraet lehnt den Steuercode gerade ab ERROR_MEDIA_CHANGED = 1110 ERROR_NO_MEDIA_IN_DRIVE = 1112 diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py index cb8656e..d3b1c5e 100644 --- a/src/rippy/drives/windows.py +++ b/src/rippy/drives/windows.py @@ -154,6 +154,7 @@ class Win32: self._ctypes = ctypes self._wintypes = wintypes self._k32 = ctypes.WinDLL("kernel32", use_last_error=True) + _typen_erklaeren(ctypes, wintypes, self._k32) # ── Laufwerke finden ──────────────────────────────────────────────── def laufwerksbuchstaben(self) -> list: @@ -174,7 +175,12 @@ class Win32: 0, None, ) - if handle == w.INVALID_HANDLE_VALUE or handle in (0, None): + # Mit erklaertem `restype` ist der Fehlerwert nicht mehr -1, sondern + # 0xFFFFFFFFFFFFFFFF — siehe `_typen_erklaeren`. Beides pruefen: die + # nackte -1 bliebe sonst als tote Bedingung stehen und taeuschte + # Absicherung vor. + ungueltig = self._ctypes.c_void_p(-1).value + if handle in (None, 0, ungueltig, w.INVALID_HANDLE_VALUE): raise Win32Fehler("CreateFileW", self._ctypes.get_last_error()) return handle @@ -200,6 +206,48 @@ class Win32: return aus_puffer.raw[:zurueck.value] if aus_puffer else b"" +def _typen_erklaeren(ctypes, wintypes, k) -> None: + r"""Den kernel32-Funktionen ihre echten Typen beibringen. + + ## Warum das keine Formsache ist (Befund 30.08.2026) + + Ohne `restype` nimmt ctypes `c_int` an — 32 Bit, mit Vorzeichen. Ein + Windows-HANDLE ist auf einem 64-Bit-System aber ein Zeiger, und dasselbe + gilt fuer den Weg HINEIN: Ein Handle, das ohne `argtypes` uebergeben wird, + geht als `c_int` durch. Solange Windows kleine Handle-Werte vergibt — das + tut es meistens — faellt nichts auf. Oberhalb von 2^31 wird still + abgeschnitten, und das folgende `DeviceIoControl` arbeitet auf einem + Handle, das es nie gab. + + Dieselbe Lehre steht seit dem 29.08.2026 im Kopf von + `platform/winlauf.py` (dort die Job-Objekte). Hier war sie noch nicht + angekommen — und das ist der Treiber, durch den JEDE Disc-Erkennung und + jeder Auswurf laeuft. + + ⚠️ Mit `restype` aendert sich der FEHLERWERT von `CreateFileW`: Es gibt + `(HANDLE)-1` zurueck, und als Zeiger gelesen ist das + 0xFFFFFFFFFFFFFFFF, nicht -1. Wer nur die Typen erklaert und die alte + Pruefung stehen laesst, legt damit die Fehlerbehandlung still — ein + fehlgeschlagenes Oeffnen saehe aus wie ein Erfolg. Siehe `Win32.oeffnen`. + """ + k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, + wintypes.HANDLE] + k.CreateFileW.restype = wintypes.HANDLE + k.CloseHandle.argtypes = [wintypes.HANDLE] + k.CloseHandle.restype = wintypes.BOOL + k.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, + ctypes.c_void_p, wintypes.DWORD, + ctypes.c_void_p, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.c_void_p] + k.DeviceIoControl.restype = wintypes.BOOL + k.GetLogicalDrives.argtypes = [] + k.GetLogicalDrives.restype = wintypes.DWORD + k.GetDriveTypeW.argtypes = [wintypes.LPCWSTR] + k.GetDriveTypeW.restype = wintypes.UINT + + def _api(api): return api if api is not None else Win32() @@ -464,6 +512,63 @@ def pfad_zu_kennung(name: str, geraete=None) -> str: return "" +#: Klartext zu den Win32-Fehlern, an denen ein Laufwerks-Zugriff scheitert. +#: +#: ## Warum der Grund nicht im „unknown" verschwinden darf (30.08.2026) +#: +#: Nach einem Rip mit 29 Lesefehlern und einem gescheiterten Auswurf +#: antwortete das Laufwerk des Commanders auf nichts mehr, was mit dem +#: MEDIUM zu tun hat — an seinem Geraet gemessen: +#: +#: CreateFileW mit GENERIC_READ -> Win32-Fehler 1 +#: IOCTL_STORAGE_CHECK_VERIFY2 -> Win32-Fehler 1 +#: IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50 +#: CreateFileW mit Zugriff 0 -> geht +#: IOCTL_STORAGE_QUERY_PROPERTY -> geht +#: +#: Die GERAETE-Auskunft kam also weiter durch: Modell und Seriennummer +#: standen im UI, waehrend Typ und Status auf „unknown" fielen und sich +#: kein Rip mehr starten liess. Im Protokoll stand als letztes die laengst +#: veraltete Zeile „Disc erkannt". Sein Befund dazu: „jetzt erkennt rippy +#: die disk garnicht mehr (im log steht zwar erkannt, aber ein start des +#: rips ist nicht moeglich)". +#: +#: Rippy KANNTE die Ursache und behielt sie fuer sich. Das ist dieselbe +#: Sorte Nichtauskunft wie „Platz fuer Rippy: unbekannt" (28.08.2026) — +#: sie sieht aus wie eine Auskunft. +ZUGRIFFS_GRUENDE = { + w.ERROR_INVALID_FUNCTION: + "Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert " + "nach abgebrochenen Lesevorgaengen. Abhilfe: Disc ueber die Taste am " + "Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner " + "neu starten.", + w.ERROR_NOT_SUPPORTED: + "Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen " + "und neu einlegen.", + w.ERROR_ACCESS_DENIED: + "Ein anderes Programm haelt das Laufwerk fest.", + w.ERROR_FILE_NOT_FOUND: + "Dieses Laufwerk gibt es nicht mehr.", + w.ERROR_NOT_READY: + "Es liegt keine Disc im Laufwerk.", +} + + +def zugriffs_grund(fehler) -> str: + """Klartext zu einem fehlgeschlagenen Laufwerks-Zugriff (pure Funktion). + + Unbekannte Nummern werden GENANNT, nicht verschwiegen — eine Nummer, + nach der man suchen kann, ist mehr als ein leerer Satz. + """ + code = getattr(fehler, "winerror", None) or getattr(fehler, "code", None) + if code in ZUGRIFFS_GRUENDE: + return ZUGRIFFS_GRUENDE[code] + if code: + return ("Das Laufwerk antwortet nicht (Win32-Fehler %s). Abhilfe: " + "Disc auswerfen und neu einlegen." % code) + return "Das Laufwerk antwortet nicht." + + def device_info(geraet: str, api=None) -> dict: """Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber. @@ -474,18 +579,21 @@ def device_info(geraet: str, api=None) -> dict: api = _api(api) status = "unknown" disc_typ = "unknown" + grund = "" try: if drive_status(geraet, api) == CDS_DISC_OK: status = "ready" try: disc_typ = classify(disc_status(geraet, api), disc_size_bytes(geraet, api)) - except OSError: + except OSError as e: disc_typ = "unknown" + grund = zugriffs_grund(e) else: status = "empty" - except OSError: + except OSError as e: status = "unknown" + grund = zugriffs_grund(e) buchstabe = kennung(geraet) angaben = _angaben_gemerkt(geraet, api) @@ -499,6 +607,9 @@ def device_info(geraet: str, api=None) -> dict: "status": status, "model": modell, "serial": angaben.get("seriennummer", ""), + # Leer, solange alles geht. Sonst steht hier, WARUM „unknown" + # dasteht — siehe ZUGRIFFS_GRUENDE. + "grund": grund, } diff --git a/src/rippy/pfade.py b/src/rippy/pfade.py index 5e51378..2177b51 100644 --- a/src/rippy/pfade.py +++ b/src/rippy/pfade.py @@ -81,7 +81,16 @@ def naechster_vorhandener(pfad: str, existiert=None) -> str: existiert = existiert or os.path.isdir modul = _modul(pfad) - pfad = (pfad or "").rstrip("\\/") + pfad = (pfad or "").strip() + # Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als + # der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist + # unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel + # (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in + # `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe: + # aus "\\server\freigabe\" darf kein Ort ohne Trenner werden. + gekuerzt = pfad.rstrip("\\/") + if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]: + pfad = gekuerzt gesehen = set() while pfad and pfad not in gesehen: if existiert(pfad): diff --git a/src/rippy/rip/handbrake_aufruf.py b/src/rippy/rip/handbrake_aufruf.py new file mode 100644 index 0000000..7072c59 --- /dev/null +++ b/src/rippy/rip/handbrake_aufruf.py @@ -0,0 +1,44 @@ +"""Wie HandBrakeCLI angesprochen und wie seine Ausgabe gelesen wird. + +## Warum das im GEMEINSAMEN Paket liegt + +Zwei Module rufen HandBrake auf: `docker/worker/ripping.py` (der Encode) und +`docker/worker/caps.py` (Preset-Liste, Hilfe, Version). `caps` wird auch vom +Standalone-Betrieb geladen und darf `ripping` nicht importieren, nur um an +eine Konstante zu kommen. + +Dasselbe Muster wie bei `makemkv_aufruf.py`: Eine Entscheidung, zwei Orte — +einer altert. Dort kostete es die Formatzeile, hier waere es die Kodierung. +""" + +#: Wie HandBrakes Ausgabe gelesen wird — und warum mit errors="replace". +#: +#: ## Der Befund (30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen) +#: +#: HandBrake schreibt **zwei Kodierungen in denselben Strom**. Eine Datei +#: namens „Glück über München.mkv" gescannt, die Bytes des Pfades in +#: derselben Ausgabe: +#: +#: Zeile „Opening ..." C3 BC = UTF-8 +#: Zeile „..., title 1 ..." 81 = CP850 (OEM) +#: +#: Es gibt hier also keine richtige Kodierung, nur eine, die nicht +#: abstuerzt. Und ohne diese Zeile stuerzte es ab: In CP850 ist ü das Byte +#: **0x81**, und 0x81 ist in cp1252 — der Gebietsschema-Kodierung eines +#: deutschen Windows, die text=True von sich aus waehlt — **undefiniert**: +#: +#: UnicodeDecodeError: 'charmap' codec can't decode byte 0x81 +#: in position 785: character maps to +#: +#: In `run_handbrake` faellt das mitten in der Leseschleife an, wird von +#: `except Exception` eingefangen und landet als Fehlertext im Job. **Jeder +#: Film, dessen Pfad ein ü enthaelt, liess sich damit nicht komprimieren** +#: — „Glück", „Tür", „München", „Über", „Grün". Dasselbe gilt fuer +#: ì, Å, É, Ø (0x8D, 0x8F, 0x90, 0x9D). +#: +#: ⚠️ NICHT `text_von()` wie bei makemkvcon: Das liest binaer, und im +#: Binaermodus gibt es keine Universal-Newlines. HandBrake trennt seine +#: Fortschrittszeilen aber mit CR (0x0D) — der Balken waere weg. Alles, was +#: Rippy aus der Ausgabe liest, ist ASCII (Prozente, „Invalid preset", +#: „unknown option"); ersetzt wird also nur, was ohnehin nur Anzeige ist. +HB_LESEN = {"text": True, "errors": "replace"} diff --git a/src/rippy/rip/test_handbrake_aufruf.py b/src/rippy/rip/test_handbrake_aufruf.py new file mode 100644 index 0000000..415fa26 --- /dev/null +++ b/src/rippy/rip/test_handbrake_aufruf.py @@ -0,0 +1,32 @@ +"""Warum HandBrakes Ausgabe mit errors="replace" gelesen wird. + +Der Test prueft die VORAUSSETZUNG, nicht die Zuweisung: dass HandBrakes +OEM-Bytes in cp1252 wirklich nicht dekodierbar sind. Gemessen am 30.08.2026 +am mitgelieferten HandBrakeCLI 1.11.2 (AGENTS Regel D). +""" + +from rippy.rip import handbrake_aufruf + + +def test_oem_umlaute_sprengen_cp1252(): + """u-Umlaut ist in CP850 das Byte 0x81 — und in cp1252 undefiniert.""" + oem = "Glück über München".encode("cp850") + assert b"\x81" in oem + try: + oem.decode("cp1252") + raise AssertionError("cp1252 haette 0x81 ablehnen muessen") + except UnicodeDecodeError: + pass + + +def test_mit_der_eingestellten_fehlerbehandlung_bricht_nichts_mehr(): + oem = "Glück über München".encode("cp850") + text = oem.decode("cp1252", handbrake_aufruf.HB_LESEN["errors"]) + assert text.startswith("Gl") # ASCII bleibt heil + assert "ber" in text and "nchen" in text + + +def test_universal_newlines_bleiben_an(): + """HandBrake trennt Fortschrittszeilen mit CR — ohne text=True waere der + Fortschrittsbalken weg (deshalb NICHT binaer wie bei makemkvcon).""" + assert handbrake_aufruf.HB_LESEN["text"] is True diff --git a/src/rippy/test_betrieb.py b/src/rippy/test_betrieb.py index 472f984..36e3632 100644 --- a/src/rippy/test_betrieb.py +++ b/src/rippy/test_betrieb.py @@ -20,6 +20,8 @@ nie auf. from rippy import betrieb +B = chr(92) # Backslash, nie woertlich (siehe test_pfade) + DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}} WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}} @@ -217,3 +219,37 @@ def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck(): def test_wenn_gar_nichts_existiert_wird_nichts_behauptet(): assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht", existiert=lambda p: False) == "" + + +def test_eingestellte_orte_schlagen_die_vorgabe(): + """Der Befund vom 30.08.2026: „die verzeichnise sind andere als dort + steht." + + Die Oberflaeche schreibt `outputDir`/`workDir` in die Datenbank, + `betrieb` las `storage.*` aus der Konfigurationsdatei — und die ist + leer. In „Einstellungen -> System" stand deshalb dauerhaft die Vorgabe, + waehrend der Rip woanders hin lief. + """ + werte = betrieb.mit_einstellungen( + {"storage": {"medien": "", "temp": ""}}, + {"outputDir": "F:" + B + "Filme", "workDir": "F:" + B}) + assert werte["storage"]["medien"] == "F:" + B + "Filme" + assert werte["storage"]["temp"] == "F:" + B + assert betrieb.ablage_vorgabe(werte, False, "windows") == "F:" + B + "Filme" + assert betrieb.arbeits_vorgabe(werte, False, "windows") == "F:" + B + + +def test_ohne_einstellung_bleibt_die_vorgabe(): + """Leere Felder duerfen NICHTS ueberschreiben — sonst waere ein + ungesetztes Feld schlimmer als gar keine Bruecke.""" + vorher = {"storage": {"medien": "/app/media", "temp": "/app/temp"}} + werte = betrieb.mit_einstellungen(vorher, {"outputDir": "", "workDir": " "}) + assert werte["storage"]["medien"] == "/app/media" + assert werte["storage"]["temp"] == "/app/temp" + # ... und das Original bleibt unangetastet (pure Funktion) + assert vorher["storage"]["medien"] == "/app/media" + + +def test_bruecke_vertraegt_leere_eingaben(): + assert betrieb.mit_einstellungen({}, {})["storage"] == {} + assert betrieb.mit_einstellungen(None, None)["storage"] == {} diff --git a/src/rippy/test_pfade.py b/src/rippy/test_pfade.py index 0cb4453..0975de8 100644 --- a/src/rippy/test_pfade.py +++ b/src/rippy/test_pfade.py @@ -52,3 +52,36 @@ def test_naechster_vorhandener_auf_posix(): def test_wenn_gar_nichts_existiert_wird_nichts_behauptet(): assert pfade.naechster_vorhandener("Z:" + B + "nix", existiert=lambda p: False) == "" + + +def test_laufwerks_wurzel_bleibt_absolut(): + """Befund 30.08.2026: Der Schluss-Trenner wurde immer abgestreift. + + Aus "F:\" wurde "F:" — unter Windows der AKTUELLE Ordner auf + Laufwerk F, nicht dessen Wurzel. Wer das Ergebnis weiterverbindet, + landet woanders; genau so verschwanden in `rohdaten.kandidaten` 16,5 GB. + """ + assert pfade.naechster_vorhandener( + "F:" + B, existiert=lambda p: True) == "F:" + B + # UNC-Wurzel genauso + unc = B + B + "server" + B + "freigabe" + B + assert pfade.naechster_vorhandener(unc, existiert=lambda p: True) == unc + # Und POSIX bleibt POSIX + assert pfade.naechster_vorhandener("/", existiert=lambda p: True) == "/" + + +def test_unterordner_verliert_seinen_schluss_trenner_weiterhin(): + """Das Abstreifen war ja richtig — nur nicht bis auf den Laufwerksnamen.""" + da = "F:" + B + "Roh" + assert pfade.naechster_vorhandener(da + B, existiert=lambda p: p == da) == da + + +def test_fehlendes_laufwerk_terminiert(): + """Die Endlosschleife, an der `ablauf._frei_bytes` haengen blieb. + + `ntpath.dirname("Q:\\")` gibt sich selbst zurueck (30.08.2026 an + einem freien Laufwerksbuchstaben gemessen). Ohne den Abbruch bei + `eltern == pfad` dreht die Suche fuer immer — vor dem Rip, ohne Meldung. + """ + assert pfade.naechster_vorhandener("Q:" + B + "Rippy" + B + "_arbeit", + existiert=lambda p: False) == "" diff --git a/src/rippy/test_windows_app.py b/src/rippy/test_windows_app.py index 02c70a9..d5a61b0 100644 --- a/src/rippy/test_windows_app.py +++ b/src/rippy/test_windows_app.py @@ -67,6 +67,30 @@ def test_skript_raeumt_den_ordner_MIT_inhalt(): assert "rmdir /s /q" in s, "ohne /s /q bleibt jeder nicht leere Ordner stehen" +def test_laufwerks_wurzel_wird_nicht_geloescht(): + """Notbremse (30.08.2026): `ordner` stammt aus `InstallLocation` in der + Registry, gesetzt aus dem `--ziel` beim Installieren — also aus fremdem + Text. Waere er ein Laufwerks-Stammverzeichnis, loeschte die + Deinstallation das Laufwerk. Der noetige rstrip macht den Fall erst + scharf: aus "F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was + Windows gerade fuer den aktuellen Ordner auf F haelt. + """ + for wurzel in ("F:" + BACKSLASH, "F:", BACKSLASH + BACKSLASH + "srv" + + BACKSLASH + "freigabe" + BACKSLASH, "", "/"): + s = windows_app.aufraeum_skript("F:" + BACKSLASH + "Rippy.exe", wurzel) + assert "rmdir" not in s, "Wurzel %r haette geloescht werden koennen" % wurzel + assert "NICHT geloescht" in s + # Die EXE geht trotzdem weg, und das Skript raeumt sich selbst auf + assert "%~f0" in s + + +def test_echter_programmordner_wird_weiterhin_geloescht(): + """Die Bremse darf den Normalfall nicht treffen.""" + s = windows_app.aufraeum_skript("C:" + BACKSLASH + "R" + BACKSLASH + + "Rippy.exe", "C:" + BACKSLASH + "R") + assert ('rmdir /s /q "C:' + BACKSLASH + 'R"') in s + + # ── Installation (echt, in einem Testordner) ──────────────────────────── @nur_windows def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch): diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index 4cacbc8..ba6af23 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -41,6 +41,7 @@ import threading import time import webbrowser +from rippy import pfade from rippy.platform import verknuepfungen from rippy.platform import win_registry as reg @@ -501,19 +502,52 @@ for /l %%n in (1,1,15) do ( ) goto ende :weg -rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER -rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im -rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt -rem hatten -- der Ordner blieb dann samt Inhalt stehen. -rmdir /s /q "{ordner}" >nul 2>&1 +{ordner_zeile} :ende del /q "%~f0" >nul 2>&1 """ +#: Der Loeschbefehl fuer den Programmordner — samt Begruendung, die im +#: erzeugten Skript stehen bleiben soll. +_ORDNER_LOESCHEN = """rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER +rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im +rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt +rem hatten -- der Ordner blieb dann samt Inhalt stehen. +rmdir /s /q "%s" >nul 2>&1""" + + def aufraeum_skript(exe: str, ordner: str) -> str: - """Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar).""" - return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/")) + r"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar). + + ## Warum hier eine Notbremse sitzt (30.08.2026) + + `ordner` kommt aus `InstallLocation` in der Registry — also aus dem, was + beim Installieren als `--ziel` angegeben wurde. Das ist nicht Rippys + eigenes Wort, sondern fremder Text, und daraus entsteht hier ein + `rmdir /s /q`. + + Waere er ein Laufwerks-Stammverzeichnis, loeschte die Deinstallation das + Laufwerk. Der noetige `rstrip` macht den Fall sogar erst scharf: Aus + "F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was Windows gerade fuer + den aktuellen Ordner auf F haelt. + + Beobachtet wurde das nicht — aber die Notbremse kostet eine Zeile, und + der Fall waere nicht wiedergutzumachen. Bleibt der Ordner stehen, sieht + man das; verschwindet das Falsche, ist es weg. + + Der `rstrip` selbst muss bleiben: `rmdir "F:\Rippy\"` scheitert, weil + cmd.exe den Backslash vor dem Anfuehrungszeichen als Maskierung liest. + """ + ziel = (ordner or "").rstrip(chr(92) + "/") + # `pfade.laufwerk_von` urteilt nach der FORM des Pfades, nicht nach dem + # laufenden Rechner — damit greift die Bremse auch in der Linux-Ampel. + if ziel and ziel != pfade.laufwerk_von(ziel): + zeile = _ORDNER_LOESCHEN % ziel + else: + zeile = ("rem Programmordner NICHT geloescht: kein gueltiger Unterordner (%s)" + % (ordner or "leer")) + return AUFRAEUM_SKRIPT.format(exe=exe, ordner_zeile=zeile) def selbst_loeschen(exe: str, ordner: str) -> str: