From 1b84ec2a45d7257b34db859deabd83afc88a2d67 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sat, 29 Aug 2026 16:14:10 +0200 Subject: [PATCH] fix(windows): Vollstaendiger Rundgang durch die Docker-Reste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach Docker Resten." Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade, Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten Pfaden, Container-Texte. Sechs echte Fehler dabei. ## 1. `/dev/{name}` in drei Endpunkten — der schwerste Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus `/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum. Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()` und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf. ## 2. `os.path.isdir("/app")` — zum zweiten Mal Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test daran, ob es einen Ordner `/app` gibt. ## 3. `shutil.which` in `schluessel.py` Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die Schluessel-Automatik fuer 4K-UHD lief damit nie an. ## 4. `posixpath.join` auf echten Pfaden `rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch aussehen und jeden Vergleich brechen. ## 5. Container-Pfad in einer Nutzermeldung „Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer die Datei retten will, sucht sonst am falschen Ort. ## 6. Container-Pfade als UI-Vorbelegung Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da war. Leer ist ehrlicher: Es behauptet nichts. ## Und HandBrakes „Code 0" Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung (an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird es gefunden statt weggeworfen. ## Der Waechter `test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft jede Ausnahme raus, die niemand mehr braucht. Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen. 887 Tests gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- docker/api/main.py | 35 +++- docker/api/rohdaten.py | 9 +- docker/ui/src/components/RipTargetModal.tsx | 16 +- docker/ui/src/lib/useBetrieb.tsx | 7 +- docker/worker/ablauf.py | 24 ++- docker/worker/ripping.py | 69 ++++++++ docker/worker/schluessel.py | 9 +- docker/worker/test_erreichbarkeit.py | 9 + src/rippy/drives/linux.py | 20 ++- src/rippy/drives/windows.py | 30 +++- src/rippy/test_keine_container_reste.py | 179 ++++++++++++++++++++ 11 files changed, 384 insertions(+), 23 deletions(-) create mode 100644 src/rippy/test_keine_container_reste.py diff --git a/docker/api/main.py b/docker/api/main.py index 1a8b20b..6846d44 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -1473,12 +1473,35 @@ async def storage_targets(): return await asyncio.to_thread(sammle) +def geraetepfad(name: str) -> str: + """Die Kennung aus der URL zum echten Geraetepfad — oder 404. + + ## Der Befund (29.08.2026) + + Hier stand an DREI Stellen: + + device_path = f"/dev/{name}" + + Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf — + unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in + keiner Laufwerksliste. **Auswerfen und „Disc scannen" antworteten unter + Windows also immer mit 404**, ohne dass irgendwo stuende, warum. + + Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`), + also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht + mehr auseinanderlaufen. + """ + pfad = device_discovery.pfad_zu_kennung(name) + if not pfad: + raise HTTPException(status_code=404, + detail=f"Laufwerk {name} nicht gefunden") + return pfad + + @app.post("/devices/{name}/eject") async def eject_device(name: str): """Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft.""" - device_path = f"/dev/{name}" - if device_path not in device_discovery.list_optical_devices(): - raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") + device_path = geraetepfad(name) if await asyncio.to_thread(db.has_active_job, device_path): raise HTTPException( status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job" @@ -1498,9 +1521,7 @@ async def scan_tracks_starten(name: str): Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s. """ - device_path = f"/dev/{name}" - if device_path not in device_discovery.list_optical_devices(): - raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden") + device_path = geraetepfad(name) if await asyncio.to_thread(db.has_active_job, device_path): raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job") @@ -1512,7 +1533,7 @@ async def scan_tracks_starten(name: str): @app.get("/devices/{name}/tracks") async def scan_tracks_ergebnis(name: str): """Ergebnis des Titel-Scans (Polling-Ziel des UI).""" - device_path = f"/dev/{name}" + device_path = geraetepfad(name) daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}") if not daten: return {"status": "none"} diff --git a/docker/api/rohdaten.py b/docker/api/rohdaten.py index da28fe7..77bdc7f 100644 --- a/docker/api/rohdaten.py +++ b/docker/api/rohdaten.py @@ -38,9 +38,10 @@ 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 posixpath import subprocess +from rippy import pfade as _pfade + # Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall # stehen — die WURZELN dieses Betriebs liefert `wurzeln()`. RAW_STANDARD = "/app/temp/raw" @@ -246,7 +247,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple: except OSError: continue for name in namen: - voll = posixpath.join(pfad, name) + # `pfade.verbinden` statt posixpath: Auf Windows ist `pfad` + # ein echter Windows-Pfad, und `posixpath.join` baute daraus + # `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch + # aussehen und jeden Vergleich brechen (Befund 29.08.2026). + voll = _pfade.verbinden(pfad, name) try: if isfile(voll): bytes_gesamt += getsize(voll) diff --git a/docker/ui/src/components/RipTargetModal.tsx b/docker/ui/src/components/RipTargetModal.tsx index e7972e2..6778f42 100644 --- a/docker/ui/src/components/RipTargetModal.tsx +++ b/docker/ui/src/components/RipTargetModal.tsx @@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. const betrieb = useBetrieb() - const [targets, setTargets] = useState([ - { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, - { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, - { id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, - ]) + /* + * Leer starten statt mit Container-Pfaden (Befund 29.08.2026). + * + * Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem + * Windows-PC gibt es die nicht — und fuer den Augenblick zwischen Oeffnen + * des Dialogs und der Antwort von /settings stand dort ein Pfad, den es + * nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts. + */ + const [targets, setTargets] = useState([]) const [browsePath, setBrowsePath] = useState('') const [browseParent, setBrowseParent] = useState(null) const [browseDirs, setBrowseDirs] = useState([]) @@ -223,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) setStandardAudio(codes(s.audioSprachen)) setStandardUntertitel(codes(s.untertitelSprachen)) - const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' + const basis = s.outputDir || betrieb.ablage_vorgabe setTargets([ { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, diff --git a/docker/ui/src/lib/useBetrieb.tsx b/docker/ui/src/lib/useBetrieb.tsx index dbf23c0..176d9cf 100644 --- a/docker/ui/src/lib/useBetrieb.tsx +++ b/docker/ui/src/lib/useBetrieb.tsx @@ -77,8 +77,11 @@ const VORGABE: Betrieb = { werkzeuge_verwalten: false, frei_blaettern: false, }, - ablage_vorgabe: '/app/media', - arbeits_vorgabe: '/app/temp', + // Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den + // Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das + // auf einem Windows-PC falsch ist (Befund 29.08.2026). + ablage_vorgabe: '', + arbeits_vorgabe: '', hilfe_befehl: 'docker compose -p rippy ps', geladen: false, } diff --git a/docker/worker/ablauf.py b/docker/worker/ablauf.py index e2ce219..692f15f 100644 --- a/docker/worker/ablauf.py +++ b/docker/worker/ablauf.py @@ -867,7 +867,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str: def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, - final_container: str, final_lokal: str) -> str: + final_container: str, final_lokal: str, + fremd=None) -> str: """Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "". Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den @@ -889,7 +890,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str, Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit. """ mapping = os.getenv("RIPPY_PATH_MAP", "") - fremder_worker = not os.path.isdir("/app") + # ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe + # Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt + # es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen + # FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die + # Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container. + # + # `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind: + # Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war + # damit an den laufenden Rechner gebunden. + if fremd is None: + from caps import _extern + + fremd = _extern() == "ja" + fremder_worker = fremd for zweck, container, lokal in ( ("die Quelle (Rohdaten)", raw_container, raw_lokal), @@ -1067,7 +1081,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str): "status": "error", "error": ( f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: " - f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten" + # Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund + # 29.08.2026). Auf Windows gibt es den nicht, und wer die + # Roh-Datei retten will, sucht sonst am falschen Ort. + f"{hb.get('error')} — Roh-Datei bleibt unter " + f"{os.path.dirname(quelle)} erhalten" ), } _job_abschliessen(job_id, ergebnis) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 5b89b91..1aefa8f 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -476,6 +476,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str: return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET +#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2 +#: gegengeprueft (`--help`, Abschnitt `-f, --format`). +FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4", + ".mov": "av_mov", ".webm": "av_webm"} + + def build_handbrake_cmd(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, audio_sprachen=None, untertitel_sprachen=None) -> list: @@ -510,6 +516,25 @@ def build_handbrake_cmd(input_path: str, output_path: str, "--output", output_path, "--preset", preset, ] + # Den Container zur Endung erzwingen (Befund 29.08.2026). + # + # Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake + # endete mit Code 0" + # + # Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort + # keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem + # Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es + # `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output + # format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle + # nichts und nannte das „fehlgeschlagen". + # + # `-f/--format` ist der dokumentierte Schalter dafuer (an dem + # mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv, + # av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei, + # die auseinanderlaufen koennen. + format_name = FORMATE.get(os.path.splitext(output_path)[1].lower()) + if format_name: + befehl += ["--format", format_name] audio = [s for s in (audio_sprachen or []) if s] if audio: befehl += ["--audio-lang-list", ",".join(audio)] @@ -579,6 +604,23 @@ def sprachliste(wert) -> list: return sauber +def _datei_daneben(erwartet: str) -> str: + """Dieselbe Datei mit anderer Endung im selben Ordner — oder "". + + HandBrake waehlt den Container nach dem Preset. Passt er nicht zur + verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer + Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die + HandBrake ueberhaupt schreiben kann. + """ + ordner = os.path.dirname(erwartet) + stamm = os.path.splitext(os.path.basename(erwartet))[0] + for endung in FORMATE: + kandidat = os.path.join(ordner, stamm + endung) + if kandidat != erwartet and os.path.isfile(kandidat): + return kandidat + return "" + + def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET, progress_cb=None, abbruch_cb=None, audio_sprachen=None, untertitel_sprachen=None) -> dict: @@ -659,6 +701,33 @@ 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} + + # Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben. + # + # Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv: + # HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet + # Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist + # der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein + # MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`. + # + # Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd). + # Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen + # erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist. + if process.returncode == 0: + daneben = _datei_daneben(output_path) + if daneben: + return {"status": "success", "output_path": daneben, + "hinweis": "HandBrake hat %s geschrieben statt %s " + "(der Container kommt aus dem Preset)." + % (os.path.basename(daneben), + os.path.basename(output_path))} + 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)), + "return_code": 0, + } if falsches_preset: return { "status": "error", diff --git a/docker/worker/schluessel.py b/docker/worker/schluessel.py index 0b80a7e..37f43bc 100644 --- a/docker/worker/schluessel.py +++ b/docker/worker/schluessel.py @@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str: for pfad in fest: if os.path.isfile(pfad): return pfad - return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "" + # ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows + # liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem + # Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief + # damit nie an. Der Werkzeug-Katalog kennt die echten Orte. + from rippy.tools import katalog + + return (katalog.finden("makemkv") + or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "") def parse_laufwerke(ausgabe: str) -> list: diff --git a/docker/worker/test_erreichbarkeit.py b/docker/worker/test_erreichbarkeit.py index 396e163..94aa66b 100644 --- a/docker/worker/test_erreichbarkeit.py +++ b/docker/worker/test_erreichbarkeit.py @@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False): return tasks._erreichbarkeit_pruefen( "/app/media/rippy/job1", "\\\\NAS\\rippy\\job1", "/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)", + # „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum + # 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit + # hing dieser Test am laufenden Rechner, und unter Windows hielt sich + # der eigenstaendige Rippy fuer einen fremden Worker. + fremd=not im_container, ) @@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch): meldung = tasks._erreichbarkeit_pruefen( "/app/media/rippy/job1", "/app/media/rippy/job1", "/app/media/movies/X", "/app/media/movies/X", + # Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte. + # Vorher ergab sich das aus „es gibt kein /app", und genau daran hing + # der Test am laufenden Rechner (Befund 29.08.2026). + fremd=True, ) assert "deckt diesen Pfad aber nicht ab" in meldung assert "von keinem Eintrag übersetzt" in meldung diff --git a/src/rippy/drives/linux.py b/src/rippy/drives/linux.py index 7ee5602..31e4f75 100644 --- a/src/rippy/drives/linux.py +++ b/src/rippy/drives/linux.py @@ -229,6 +229,24 @@ def read_sys_attr(device_name: str, attr: str) -> str: return "" +def kennung(device_path: str) -> str: + """Der kurze Name eines Laufwerks — `/dev/sr0` wird zu `sr0`. + + Gegenstueck zu `pfad_zu_kennung`. Begruendung im Windows-Treiber: Hin- + und Rueckweg muessen zusammenpassen, sonst antworten die Endpunkte mit + 404 (Befund 29.08.2026). + """ + return os.path.basename(device_path) + + +def pfad_zu_kennung(name: str, geraete=None) -> str: + """Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt.""" + for pfad in (geraete if geraete is not None else list_optical_devices()): + if pfad == name or kennung(pfad) == name: + return pfad + return "" + + def device_info(device_path: str) -> dict: """Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl. @@ -247,7 +265,7 @@ def device_info(device_path: str) -> dict: drive_status, ) - name = os.path.basename(device_path) + name = kennung(device_path) vendor = read_sys_attr(name, "vendor") model = read_sys_attr(name, "model") diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py index c2216b5..66e7dbc 100644 --- a/src/rippy/drives/windows.py +++ b/src/rippy/drives/windows.py @@ -436,6 +436,34 @@ def geraeteangaben(geraet: str, api=None) -> dict: } +def kennung(geraet: str) -> str: + r"""Der kurze Name eines Laufwerks — `\.\G:` wird zu `G`. + + ## Warum das eine benannte Funktion ist (Befund 29.08.2026) + + In `main.py` stand an DREI Stellen der Rueckweg als feste Zeile: + + device_path = f"/dev/{name}" + + Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf — + unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in + keiner Laufwerksliste. Ergebnis: **Auswerfen und „Disc scannen" antworten + unter Windows immer mit 404**, ohne dass irgendwo stuende, warum. + + Hin und zurueck muessen zusammenpassen. Deshalb gehoert beides zum + Treiber: Er vergibt die Kennung, und er loest sie wieder auf. + """ + return geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":") + + +def pfad_zu_kennung(name: str, geraete=None) -> str: + """Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt.""" + for pfad in (geraete if geraete is not None else list_optical_devices()): + if pfad == name or kennung(pfad) == name: + return pfad + return "" + + def device_info(geraet: str, api=None) -> dict: """Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber. @@ -459,7 +487,7 @@ def device_info(geraet: str, api=None) -> dict: except OSError: status = "unknown" - buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":") + buchstabe = kennung(geraet) angaben = _angaben_gemerkt(geraet, api) modell = " ".join(t for t in (angaben.get("hersteller"), angaben.get("modell")) if t) diff --git a/src/rippy/test_keine_container_reste.py b/src/rippy/test_keine_container_reste.py new file mode 100644 index 0000000..58dfda0 --- /dev/null +++ b/src/rippy/test_keine_container_reste.py @@ -0,0 +1,179 @@ +"""Der Wächter gegen Container-Annahmen im Windows-Betrieb. + +## Warum es diesen Test gibt (Commander, 29.08.2026) + +> „Bro, du musst alles was rippy jetzt im code hat für Windows Bauen! Jeden +> pfad, alles wo die tools drauf zugreifen. Diese Rippy version MUSS 100% +> Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach Docker +> Resten." + +Der Rundgang dazu hat an einem Tag gefunden: + + /app/media als Pfadgrenze -> Auswahl blieb leer, Browser tot + /app/temp/raw als Arbeitsordner -> 436 MB landeten in F:\\app\\temp + /root/.MakeMKV fuer den Beta-Key -> Key wurde nie gelesen + /dev/{name} in drei Endpunkten -> Auswerfen und Disc-Scan gaben 404 + os.path.isdir("/app") zweimal -> Rippy hielt sich fuer fremd + shutil.which fuer makemkvcon -> Schluessel-Automatik lief nie an + posixpath auf echten Pfaden -> C:\\Roh/datei.mkv + +Jeder einzelne Fall sah gleich aus: kein Absturz, keine Meldung, nur etwas, +das still nicht tat, was es sollte. Genau deshalb steht hier ein MECHANISCHER +Waechter und keine Merkregel. + +## Die Regel + +In allem, was der Windows-Betrieb laedt, darf ein Container- oder Linux-Pfad +nur stehen, wenn er in einem Zweig steht, der auf Windows nicht laeuft. Diese +Zweige stehen unten namentlich in `ERLAUBT` — mit Begruendung. Wer eine neue +Fundstelle hinzufuegt, muss sie dort begruenden; das ist der eigentliche +Zweck. +""" + +import io +import os +import re + +import pytest + +WURZEL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +#: Was der Windows-Betrieb wirklich laedt. +GEPRUEFT = ("src/rippy", "docker/api", "docker/worker") + +#: Module, die es unter Windows GAR NICHT gibt — dort sind Linux-Pfade richtig. +NUR_LINUX = ( + "src/rippy/drives/linux.py", # der Linux-Treiber + "docker/api/mounts.py", # Freigaben einhaengen macht der Wirt +) + +#: Begruendete Ausnahmen: Datei -> warum der Pfad dort stehen DARF. +ERLAUBT = { + "src/rippy/betrieb.py": + "Container-Zweige von ablage_vorgabe/arbeits_vorgabe/platz_orte — " + "sie laufen nur mit container=True", + "src/rippy/config.py": + "Linux-Zweig von standard_datenbankpfad (Windows: %LOCALAPPDATA%)", + "src/rippy/daemon.py": + "Linux-Zweig von standard_konfigpfad (Windows: %PROGRAMDATA%)", + "src/rippy/rip/makemkv_daten.py": + "Linux-Zweig von daten_verzeichnis (Windows: %USERPROFILE%\\.MakeMKV)", + "docker/api/config.py": + "Pydantic-Vorgaben ohne Leser — kein Aufrufer im Quellcode", + "docker/api/main.py": + "MEDIA_ROOT als benannter Rueckfall; die Wurzel liefert medien_wurzel()", + "docker/api/rohdaten.py": + "RAW_STANDARD/MEDIA_ROOT als Rueckfall; die Wurzeln liefert wurzeln()", + "docker/api/prescan/prescan.py": + "Linux-Zweig von disc_wurzel (Windows haengt die Disc selbst ein) " + "und die Audio-CD-Werkzeuge, die es unter Windows nicht gibt", + "docker/worker/ablauf.py": + "RAW_DIR/MEDIA_ROOT als Rueckfall; die Wurzeln liefert _betriebs_wurzeln()", + "docker/worker/ripping.py": + "RIP_OUTPUT_DIR als Rueckfall und die Audio-CD-Werkzeuge (Linux-only)", +} + +PFAD_MUSTER = re.compile(r'["\'](/(?:app|root|dev|mnt|srv|etc|var|usr)(?:/[^"\']*)?)["\']') + + +def _dateien(): + for teil in GEPRUEFT: + for ordner, unter, namen in os.walk(os.path.join(WURZEL, teil)): + unter[:] = [u for u in unter if u != "__pycache__"] + for name in namen: + if not name.endswith(".py") or name.startswith("test_"): + continue + voll = os.path.join(ordner, name) + rel = os.path.relpath(voll, WURZEL).replace("\\", "/") + if rel in NUR_LINUX: + continue + yield rel, voll + + +def _code_zeilen(voll: str): + """Zeilen OHNE Kommentare und Docstrings — nur was wirklich laeuft. + + Ueber den TOKENIZER, nicht ueber „faengt mit Anfuehrungszeichen an": Ein + Docstring kann `r\"\"\"` oder `f\"\"\"` heissen, und der erste Anlauf dieses + Tests ist prompt an seinem eigenen `r\"\"\"`-Docstring haengengeblieben. + Python weiss selbst am besten, was Code ist und was Text. + """ + import tokenize + + with tokenize.open(voll) as f: + try: + marken = list(tokenize.generate_tokens(f.readline)) + except (tokenize.TokenError, IndentationError, SyntaxError): + return + + # Zeilen, in denen echter Code steht (alles ausser Kommentar, Text, + # Einrueckung und Zeilenende). + egal = {tokenize.COMMENT, tokenize.STRING, tokenize.NL, tokenize.NEWLINE, + tokenize.INDENT, tokenize.DEDENT, tokenize.ENDMARKER} + zeilen = {} + for marke in marken: + if marke.type in egal: + continue + zeilen[marke.start[0]] = marke.line.rstrip("\n") + # Zeichenketten IM Code (kein Docstring) muessen mit — dort stehen die + # Pfade. Erkennbar daran, dass in derselben Zeile auch Code steht. + for marke in marken: + if marke.type == tokenize.STRING and marke.start[0] in zeilen: + zeilen[marke.start[0]] = marke.line.rstrip("\n") + for nr in sorted(zeilen): + yield nr, zeilen[nr] + + +def test_kein_unbegruendeter_container_pfad(): + """DER Waechter. Neue Fundstelle -> hier begruenden oder beheben.""" + offen = [] + for rel, voll in _dateien(): + for nr, zeile in _code_zeilen(voll): + treffer = PFAD_MUSTER.search(zeile) + if treffer and rel not in ERLAUBT: + offen.append("%s:%d %s" % (rel, nr, treffer.group(1))) + assert not offen, ( + "Container-Pfad im Windows-Betrieb ohne Begruendung:\n " + + "\n ".join(offen) + + "\n\nEntweder fuer Windows umschreiben (betrieb.medien_wurzel, " + "rippy.pfade) oder in ERLAUBT begruenden.") + + +def test_die_ausnahmen_sind_noch_noetig(): + """Eine Ausnahme, die niemand mehr braucht, ist eine Unwahrheit im Test.""" + ungenutzt = [] + for rel in ERLAUBT: + voll = os.path.join(WURZEL, rel) + if not os.path.isfile(voll): + ungenutzt.append(rel + " (Datei gibt es nicht mehr)") + continue + if not any(PFAD_MUSTER.search(z) for _, z in _code_zeilen(voll)): + ungenutzt.append(rel + " (kein Container-Pfad mehr drin)") + assert not ungenutzt, "Ausnahme ueberfluessig:\n " + "\n ".join(ungenutzt) + + +@pytest.mark.parametrize("modul,name", [ + ("src/rippy/drives/windows.py", "kennung"), + ("src/rippy/drives/windows.py", "pfad_zu_kennung"), + ("src/rippy/drives/linux.py", "kennung"), + ("src/rippy/drives/linux.py", "pfad_zu_kennung"), +]) +def test_beide_treiber_koennen_die_kennung_in_beide_richtungen(modul, name): + """Hin und zurueck muessen zusammenpassen. + + `main.py` baute den Geraetepfad aus der Kennung selbst zusammen + (`/dev/{name}`) — unter Windows heisst das Laufwerk aber `\\\\.\\G:`. + Auswerfen und „Disc scannen" antworteten deshalb IMMER mit 404. + """ + text = io.open(os.path.join(WURZEL, modul), encoding="utf-8", + errors="replace").read() + assert "def %s(" % name in text + + +def test_niemand_baut_den_geraetepfad_mehr_selbst(): + """Der konkrete Fehler: `f"/dev/{name}"` in drei Endpunkten.""" + voll = os.path.join(WURZEL, "docker/api/main.py") + gebaut = [nr for nr, z in _code_zeilen(voll) if 'f"/dev/{' in z] + assert not gebaut, ( + "main.py:%s baut den Geraetepfad selbst — device_discovery." + "pfad_zu_kennung() benutzen" % gebaut)