From cedab61ddf98ab836ff40c542f7fe814e51ce4e9 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 19:18:10 +0200 Subject: [PATCH] =?UTF-8?q?chore(main):=20Der=20Windows-Anteil=20ist=20rau?= =?UTF-8?q?s=20=E2=80=94=20Entscheid=207,=20=C2=A7=2011=20umgesetzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teil A (ganze Dateien): windows_app, fenster, setup_fenster, einrichtung, standalone, daemon, drives/windows + win_ioctl, platform/verknuepfungen + win_registry, tools/einrichten, rip/audio_cd + musicbrainz_cd, queue/ (lokal + laeufer), packaging/windows/, test_keine_container_reste — samt Tests. Bilanz: +51 / -10.039 Zeilen in 48 Dateien. Bleibt TROTZ § 11.2-Listung (Importe gemessen, nicht geraten): winlauf (OHNE_FENSTER nutzt der Worker; Teil B laeuft nativ Windows), dateiangaben/katalog/beschaffen (ripping + api importieren sie), makemkv_aufruf KOMPLETT (ablauf.py ruft key_setzen_und_pruefen; Registry-Zweige braucht der Remote-Worker), bus/waechter (bedient Jobs/Logs/Laufwerke der API), die nativ-Pfadzweige (Teil B). Teil C: drives/treiber() liefert unter Windows den Klartext-Platzhalter kein_windows.py (Importe/Testsammlung auf Entwicklungs-PCs bleiben heil, BENUTZUNG wirft mit Verweis auf v5); ripping.rip_cd ohne Windows-Zweig; betrieb.windows_laufwerke + Aufrufer raus; /worker-setup/windows* bleibt (Teil B, Entscheid 8). Lokal: Ruff gruen, 658 Tests gruen (vorher 977 — die Differenz sind die Windows-Tests, deren Gegenstand mitgegangen ist). Das Wissen liegt vollstaendig in Rippy v5 (rippy-windows/), mit denselben Testfaellen. Co-Authored-By: Claude Fable 5 --- SAVEPOINT.md | 55 +- docker/api/main.py | 28 +- docker/api/test_pfadgrenze.py | 3 - docker/worker/ripping.py | 60 +- packaging/windows/build.py | 254 ----- packaging/windows/einstieg.py | 61 -- packaging/windows/icon.py | 142 --- packaging/windows/requirements-bau.txt | 25 - packaging/windows/test_icon.py | 76 -- packaging/windows/werkzeuge.py | 135 --- src/rippy/betrieb.py | 44 +- src/rippy/daemon.py | 338 ------- src/rippy/drives/__init__.py | 8 +- src/rippy/drives/kein_windows.py | 34 + src/rippy/drives/test_win_ioctl.py | 84 -- src/rippy/drives/test_windows.py | 264 ----- src/rippy/drives/win_ioctl.py | 177 ---- src/rippy/drives/windows.py | 684 ------------- src/rippy/einrichtung.py | 417 -------- src/rippy/fenster.py | 281 ------ src/rippy/platform/test_verknuepfungen.py | 129 --- src/rippy/platform/test_win_registry.py | 128 --- src/rippy/platform/test_winlauf.py | 20 - src/rippy/platform/test_winwaisen.py | 20 - src/rippy/platform/verknuepfungen.py | 178 ---- src/rippy/platform/win_registry.py | 230 ----- src/rippy/queue/__init__.py | 10 - src/rippy/queue/laeufer.py | 152 --- src/rippy/queue/lokal.py | 290 ------ src/rippy/queue/test_laeufer.py | 185 ---- src/rippy/queue/test_lokal.py | 215 ----- src/rippy/rip/audio_cd.py | 292 ------ src/rippy/rip/musicbrainz_cd.py | 178 ---- src/rippy/rip/test_audio_cd.py | 243 ----- src/rippy/rip/test_musicbrainz_cd.py | 179 ---- src/rippy/setup_fenster.py | 698 -------------- src/rippy/standalone.py | 243 ----- src/rippy/test_arbeitsort.py | 13 - src/rippy/test_daemon.py | 139 --- src/rippy/test_einrichtung.py | 281 ------ src/rippy/test_fenster.py | 198 ---- src/rippy/test_keine_container_reste.py | 179 ---- src/rippy/test_kette.py | 137 --- src/rippy/test_setup_fenster.py | 379 -------- src/rippy/test_standalone.py | 100 -- src/rippy/test_windows_app.py | 584 ----------- src/rippy/tools/einrichten.py | 241 ----- src/rippy/tools/test_einrichten.py | 271 ------ src/rippy/windows_app.py | 1063 --------------------- 49 files changed, 105 insertions(+), 10040 deletions(-) delete mode 100644 packaging/windows/build.py delete mode 100644 packaging/windows/einstieg.py delete mode 100644 packaging/windows/icon.py delete mode 100644 packaging/windows/requirements-bau.txt delete mode 100644 packaging/windows/test_icon.py delete mode 100644 packaging/windows/werkzeuge.py delete mode 100644 src/rippy/daemon.py create mode 100644 src/rippy/drives/kein_windows.py delete mode 100644 src/rippy/drives/test_win_ioctl.py delete mode 100644 src/rippy/drives/test_windows.py delete mode 100644 src/rippy/drives/win_ioctl.py delete mode 100644 src/rippy/drives/windows.py delete mode 100644 src/rippy/einrichtung.py delete mode 100644 src/rippy/fenster.py delete mode 100644 src/rippy/platform/test_verknuepfungen.py delete mode 100644 src/rippy/platform/test_win_registry.py delete mode 100644 src/rippy/platform/verknuepfungen.py delete mode 100644 src/rippy/platform/win_registry.py delete mode 100644 src/rippy/queue/__init__.py delete mode 100644 src/rippy/queue/laeufer.py delete mode 100644 src/rippy/queue/lokal.py delete mode 100644 src/rippy/queue/test_laeufer.py delete mode 100644 src/rippy/queue/test_lokal.py delete mode 100644 src/rippy/rip/audio_cd.py delete mode 100644 src/rippy/rip/musicbrainz_cd.py delete mode 100644 src/rippy/rip/test_audio_cd.py delete mode 100644 src/rippy/rip/test_musicbrainz_cd.py delete mode 100644 src/rippy/setup_fenster.py delete mode 100644 src/rippy/standalone.py delete mode 100644 src/rippy/test_daemon.py delete mode 100644 src/rippy/test_einrichtung.py delete mode 100644 src/rippy/test_fenster.py delete mode 100644 src/rippy/test_keine_container_reste.py delete mode 100644 src/rippy/test_kette.py delete mode 100644 src/rippy/test_setup_fenster.py delete mode 100644 src/rippy/test_standalone.py delete mode 100644 src/rippy/test_windows_app.py delete mode 100644 src/rippy/tools/einrichten.py delete mode 100644 src/rippy/tools/test_einrichten.py delete mode 100644 src/rippy/windows_app.py diff --git a/SAVEPOINT.md b/SAVEPOINT.md index 5923fcb..e6d45d5 100644 --- a/SAVEPOINT.md +++ b/SAVEPOINT.md @@ -1,6 +1,59 @@ # SAVEPOINT — Rippy -## Aktueller Stand: v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026) +## Aktueller Stand: Der Windows-Anteil ist raus aus `main` (30.08.2026) + +> **Entscheid 7, umgesetzt: 10.039 Zeilen entfernt, 51 hinzugefügt.** Die +> Windows-Standalone-App (Teil A aus KONZEPT-WINDOWS.md § 11) ist aus +> dieser Codebasis entfernt — Rippy für Windows ist ab jetzt Rippy v5, +> ein eigenes Produkt auf dem Branch `worktree-windows-electron`. +> Ampel-Praxis lokal: Ruff grün, **658 Tests grün** (vorher 977 — die +> Differenz sind die Windows-Tests, deren Gegenstand mitgegangen ist). + +### Was entfernt wurde (Teil A — ganze Dateien) + +`windows_app` · `fenster` · `setup_fenster` · `einrichtung` · +`standalone` · `daemon` · `drives/windows` + `win_ioctl` · +`platform/verknuepfungen` + `win_registry` · `tools/einrichten` · +`rip/audio_cd` + `musicbrainz_cd` · `queue/` (lokal + laeufer) · +`packaging/windows/` · `test_keine_container_reste` (gegenstandslos) — +jeweils samt Tests. + +### Was BLEIBT, obwohl § 11.2 es unter A führte (gemessen, nicht geraten) + +* **`platform/winlauf`** — `OHNE_FENSTER` und die Prozess-Regeln nutzt + der Docker-Worker-Code selbst, und der **Remote-Encode-Worker (Teil B, + Entscheid 8)** läuft nativ auf Windows. +* **`platform/dateiangaben`, `tools/katalog`, `tools/beschaffen`** — + Importe aus `ripping.py` und `docker/api/main.py`. +* **`rip/makemkv_aufruf` komplett** — `key_setzen_und_pruefen` und + `einstellungen_sicherstellen` ruft `docker/worker/ablauf.py`; die + Registry-Zweige braucht Teil B. +* **`bus/waechter`** — er bedient Jobs, Logs UND Laufwerke der + Docker-API, nicht nur die Windows-Disc-Wache. +* Die `nativ`-Pfadzweige in `ablauf`/`rohdaten`/`mounts` — Teil B sucht + Roh-MKVs über die Freigabe. + +### Teil C — die verstreuten Zweige + +* `drives/treiber()` liefert unter Windows jetzt `kein_windows.py`: + Importe und Test-Sammlung auf Entwicklungs-PCs bleiben heil, jede + BENUTZUNG wirft mit Klartext und Verweis auf v5. +* `ripping.rip_cd`: Der Windows-CD-Zweig (Roh-Sektoren + FLAC) ist nach + v5 gewandert (dort § 6.3, samt der 150-Frames- und 2048er-Fallen). +* `betrieb.windows_laufwerke` + die zwei Aufrufer in `main.py` + (Laufwerksbuchstaben-Browsing) sind raus; `/worker-setup/windows*` + bleibt (das ist Teil B). + +### Wo das Wissen jetzt liegt + +Vollständig in Rippy v5 (`rippy-windows/`), mit denselben Testfällen: +Win32-Steuercodes samt Herleitung, Auswurf-Dreischritt, MSF/LBA- und +2048er-Fallen, MusicBrainz-DiscID (gegen die echte AC/DC-Kennung +getestet), die MakeMKV-Schlüsselkette samt Registry-Weg. + +--- + +## v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026) > **Ein falscher Kommandozeilen-Schalter kostete einen fertigen 16,5-GB-Rip — > und HandBrake meldete es als Erfolg.** Dazu vierzehn weitere Funde aus diff --git a/docker/api/main.py b/docker/api/main.py index c27540a..d876f53 100644 --- a/docker/api/main.py +++ b/docker/api/main.py @@ -1507,19 +1507,9 @@ async def storage_targets(): "free_gb": frei_gb, }) - # Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen. - if frei_blaettern(): - from rippy import betrieb - bekannt = {z["path"].rstrip("\\/").lower() for z in ziele} - for laufwerk in betrieb.windows_laufwerke(): - if laufwerk.rstrip("\\/").lower() in bekannt: - continue - ziele.append({ - "name": "Laufwerk %s" % laufwerk.rstrip("\\/"), - "path": laufwerk, - "is_mount": False, - "free_gb": platz(laufwerk), - }) + # Der Laufwerksbuchstaben-Block stand hier für die Windows- + # Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT- + # WINDOWS.md Entscheid 7); im Container gab es hier nie etwas. return ziele return await asyncio.to_thread(sammle) @@ -2145,7 +2135,7 @@ async def browse(path: str = ""): einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten. """ if not path.strip(): - laufwerke = betrieb_laufwerke() if frei_blaettern() else [] + laufwerke = [] if laufwerke: return {"path": "", "parent": None, "dirs": laufwerke, "files": []} path = medien_wurzel() @@ -2203,16 +2193,6 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str return "" if oben == pfad else oben -def betrieb_laufwerke() -> list: - """Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers.""" - from rippy import betrieb - try: - return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk} - for laufwerk in betrieb.windows_laufwerke()] - except Exception: # noqa: BLE001 - return [] - - class MkdirRequest(BaseModel): path: str name: str diff --git a/docker/api/test_pfadgrenze.py b/docker/api/test_pfadgrenze.py index ea19379..9130b9f 100644 --- a/docker/api/test_pfadgrenze.py +++ b/docker/api/test_pfadgrenze.py @@ -77,6 +77,3 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer(): assert main.medien_wurzel() -def test_laufwerke_ohne_windows_sind_eine_leere_liste(): - """Auf dem Linux-Runner der Ampel darf das kein Fehler sein.""" - assert isinstance(main.betrieb_laufwerke(), list) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index 308f86f..4b25b54 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -1114,66 +1114,14 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c log_cb=log_cb) -def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None, - output_dir: str = None, auswerfen: bool = True) -> dict: - """Audio-CD unter Windows: selbst lesen, mit FLAC kodieren. - - Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das - Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen). - """ - from rippy.drives import windows as win - from rippy.rip import audio_cd - - class Leser: - toc = staticmethod(win.audio_toc) - - @staticmethod - def spur(geraet, start, sektoren, schreiben, fortschritt=None): - return win.audio_spur_lesen(geraet, start, sektoren, schreiben, - fortschritt) - - # Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der - # Ordnername als auch die Titel. Ohne Treffer heissen die Dateien - # `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine - # Disc, die niemand kennt. - from rippy.rip import musicbrainz_cd - - try: - spuren = win.audio_toc(device_path) - antwort = musicbrainz_cd.abfragen( - musicbrainz_cd.kennung_aus_spuren(spuren)) - except Exception: # noqa: BLE001 - antwort = None - tags = musicbrainz_cd.tags_je_spur(antwort) - ordner = musicbrainz_cd.album_ordner(antwort) - - ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id) - ergebnis = audio_cd.rippen(device_path, ziel, Leser(), - werkzeuge.finden("flac"), - fortschritt=progress_cb, tags_je_spur=tags) - if auswerfen and ergebnis.get("status") == "success": - wirf_disc_aus(device_path) - return ergebnis - - def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None, auswerfen: bool = True) -> dict: """Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer - Auswurf" (abcde macht das selbst per -x).""" - # ── Windows geht einen eigenen Weg (Befund 29.08.2026) ───────────── - # - # `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows - # nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt. - # Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine - # Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit - # dem FLAC-Encoder aus dem Werkzeug-Katalog. - # - # Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit - # Wochen so im SAVEPOINT. - if os.name == "nt": - return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir, - auswerfen) + Auswurf" (abcde macht das selbst per -x). + Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach + Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3) — + diese Fassung rippt CDs nur noch im Container, mit abcde.""" if not check_abcde_installed(): return { "status": "error", diff --git a/packaging/windows/build.py b/packaging/windows/build.py deleted file mode 100644 index e321f5b..0000000 --- a/packaging/windows/build.py +++ /dev/null @@ -1,254 +0,0 @@ -"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt. - -## Aufruf - - python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0] - -Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer): - * Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt) - * pyinstaller, pystray, pillow - * Das UI muss gebaut sein: cd docker/ui && npm run build - -## Warum die Versions-Ressource nicht optional ist - -Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den -Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE -ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte — -und Windows SmartScreen sieht das genauso. - -Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht. -Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG -daneben aus dieser Ressource. - -## Was mit hinein muss - - docker/api/ die API-Module (main.py und Nachbarn) - docker/ui/dist/ die gebaute Weboberflaeche - src/rippy/ der Kern (kommt ueber den Import automatisch mit) - rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste - -`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` — -genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist. -""" - -import argparse -import os -import shutil -import subprocess -import sys - -HIER = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(os.path.dirname(HIER)) - -VERSION_VORLAGE = """ -VSVersionInfo( - ffi=FixedFileInfo( - filevers=({v0}, {v1}, {v2}, 0), - prodvers=({v0}, {v1}, {v2}, 0), - mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0, - date=(0, 0) - ), - kids=[ - StringFileInfo([ - StringTable(u'040704b0', [ - StringStruct(u'CompanyName', u'Rippy'), - StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'), - StringStruct(u'FileVersion', u'{version}'), - StringStruct(u'InternalName', u'Rippy'), - StringStruct(u'OriginalFilename', u'Rippy.exe'), - StringStruct(u'ProductName', u'Rippy'), - StringStruct(u'ProductVersion', u'{version}')]) - ]), - # 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt - # Windows die Angaben je nach Systemsprache gar nicht an. - VarFileInfo([VarStruct(u'Translation', [1031, 1200])]) - ] -) -""" - - -def versionsdatei(version: str, ordner: str) -> str: - teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3] - pfad = os.path.join(ordner, "version_info.txt") - with open(pfad, "w", encoding="utf-8") as f: - f.write(VERSION_VORLAGE.format( - version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2]))) - return pfad - - -def pruefen() -> list: - """Alles da? Erst pruefen, dann bauen — wie install.sh es macht. - - Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die - aussieht wie fertig und es nicht ist. - """ - maengel = [] - if sys.platform != "win32": - maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.") - if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")): - maengel.append("docker/api/main.py fehlt.") - if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")): - maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann " - "die App nicht rippen.") - if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")): - maengel.append( - "docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. " - "Abhilfe: cd docker/ui && npm ci && npm run build") - icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") - if not os.path.isfile(icon): - maengel.append("deploy/worker-windows/rippy.ico fehlt.") - else: - # Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein - # Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue - # und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist - # noch keine Datei, die funktioniert. - import sys as _sys - _sys.path.insert(0, HIER) - from icon import fehlende_pflichtgroessen - - fehlt = fehlende_pflichtgroessen(icon) - if fehlt: - maengel.append( - "rippy.ico fehlen die Groessen %s — Windows zeigt dann ein " - "leeres Blatt. Abhilfe: python packaging/windows/icon.py" - % (fehlt,)) - try: - import PyInstaller # noqa: F401 - except ImportError: - maengel.append("pyinstaller fehlt (pip install pyinstaller).") - for modul, paket, wofuer in ( - ("pystray", "pystray", "kein Tray-Symbol"), - ("PIL", "pillow", "kein Tray-Symbol"), - ("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"), - ("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"), - ): - try: - __import__(modul) - except ImportError: - maengel.append( - f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.") - return maengel - - -def bauen(ausgabe: str, version: str) -> str: - arbeit = os.path.join(ausgabe, "_bau") - os.makedirs(arbeit, exist_ok=True) - - trenner = ";" # Windows-Trennzeichen fuer --add-data - - # HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner - # ohne Internet nicht komprimieren -- und der Commander war da deutlich: - # "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg - # NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim - # Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py. - import sys as _sys - _sys.path.insert(0, HIER) - from werkzeuge import paket_ordner_bauen - - werkzeug_ordner = paket_ordner_bauen(ausgabe) - if not werkzeug_ordner: - print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim") - print(" Einrichten selbst -- das braucht aber eine Verbindung.") - - befehl = [ - sys.executable, "-m", "PyInstaller", - "--noconfirm", "--clean", - "--onefile", - # KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde - # das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken - # zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI - # Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit - # nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei - # Sekunden auf, bis Python so weit ist. - # - # Was dafuer noetig ist, steht in packaging/windows/einstieg.py: - # ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben. - "--windowed", - "--name", "RippySetup", - "--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"), - "--version-file", versionsdatei(version, arbeit), - "--distpath", ausgabe, - "--workpath", os.path.join(arbeit, "work"), - "--specpath", arbeit, - "--paths", os.path.join(REPO, "src"), - "--paths", os.path.join(REPO, "docker", "api"), - "--paths", os.path.join(REPO, "docker", "worker"), - "--add-data", os.path.join(REPO, "docker", "api") + trenner + "api", - # Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen. - # ablauf.py, ripping.py, medien.py, caps.py und Nachbarn. - "--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker", - "--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui", - "--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".", - # Was PyInstaller nicht von allein findet: dynamisch importierte Module. - "--hidden-import", "uvicorn.logging", - "--hidden-import", "uvicorn.loops.auto", - "--hidden-import", "uvicorn.protocols.http.auto", - "--hidden-import", "uvicorn.protocols.websockets.auto", - "--hidden-import", "uvicorn.lifespan.on", - "--hidden-import", "rippy.drives.windows", - "--hidden-import", "pystray._win32", - # pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also - # sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster - # nicht auf und Rippy faellt still auf den Browser zurueck -- also - # genau dorthin, wovon wir gerade weg wollten. - "--hidden-import", "webview", - "--hidden-import", "webview.platforms.winforms", - "--hidden-import", "webview.platforms.edgechromium", - # Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery, - # und beides zieht Ballast nach. - "--exclude-module", "psycopg2", - "--exclude-module", "tkinter", - os.path.join(REPO, "packaging", "windows", "einstieg.py"), - ] - if werkzeug_ordner: - # Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt. - befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"] - - print("PyInstaller wird aufgerufen …\n") - ergebnis = subprocess.run(befehl, cwd=REPO) - if ergebnis.returncode != 0: - raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode) - - exe = os.path.join(ausgabe, "RippySetup.exe") - if not os.path.isfile(exe): - raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe) - return exe - - -def main(argv=None) -> int: - p = argparse.ArgumentParser(description="Baut RippySetup.exe") - p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows")) - p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0")) - p.add_argument("--nur-pruefen", action="store_true") - args = p.parse_args(argv) - - print("Rippy — Windows-Bau") - print(" Repo %s" % REPO) - print(" Ausgabe %s" % args.ausgabe) - print(" Version %s" % args.version) - print() - - maengel = pruefen() - if maengel: - print("Der Bau kann so nicht laufen:") - for m in maengel: - print(" X %s" % m) - return 1 - print("Alle Voraussetzungen erfuellt.") - if args.nur_pruefen: - return 0 - - os.makedirs(args.ausgabe, exist_ok=True) - exe = bauen(args.ausgabe, args.version) - - groesse = os.path.getsize(exe) / 1024**2 - print() - print("Fertig: %s (%.1f MB)" % (exe, groesse)) - - arbeit = os.path.join(args.ausgabe, "_bau") - shutil.rmtree(arbeit, ignore_errors=True) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packaging/windows/einstieg.py b/packaging/windows/einstieg.py deleted file mode 100644 index dbe48a2..0000000 --- a/packaging/windows/einstieg.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Einstiegspunkt der gebuendelten RippySetup.exe. - -Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles -Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier -steht, laesst sich nicht testen — also steht hier so wenig wie moeglich. - -## Warum hier trotzdem etwas zur Ausgabe steht - -Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start -KEINE Konsole aufgeht — ausdruecklicher Wunsch des Commanders am 28.08.2026: -„Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet." - -Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr` -schreibt — uvicorn tut das —, wuerde mit -`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und -zwar mitten im Start, ohne dass irgendwo etwas stuende. - -Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`: - - 1. In einem Terminal gestartet? -> dort hineinschreiben - 2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log -""" - -import multiprocessing -import sys - - -def _ausgabe_richten() -> None: - """Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei.""" - from rippy.platform import winlauf - - if not winlauf.ohne_konsole(): - return # aus dem Quellcode gestartet - if winlauf.an_elternkonsole_haengen(): - # Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt - # sys.stdout trotz Konsole None. - try: - sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace", - buffering=1) - sys.stderr = sys.stdout - return - except OSError: - pass - winlauf.ausgabe_umleiten() - - -def main() -> int: - # Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein - # Kindprozess unter Windows die ganze EXE erneut — eine Startschleife, - # die sich als "das Programm oeffnet sich immer wieder" zeigt. - multiprocessing.freeze_support() - - _ausgabe_richten() - - from rippy.windows_app import main as rippy_main - - return rippy_main() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/packaging/windows/icon.py b/packaging/windows/icon.py deleted file mode 100644 index 83a8a3a..0000000 --- a/packaging/windows/icon.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Ein Windows-Symbol, das Windows auch wirklich anzeigt. - -## Der Befund (28.08.2026) - -Commander: *„Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes -Leeres Blatt)."* - -Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte: - - Bilder = 1 - 256x256 32 bit 5657 Bytes PNG - -**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle -die passende Größe aus der Datei: - - Taskleiste, Verknüpfung klein 16x16 - Desktop, Startmenü 32x32 / 48x48 - Große Kacheln, Alt-Tab 256x256 - -Fehlt die passende Größe, skaliert die Shell nicht immer selbst — sie zeigt -das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er -enthält, passiert das zuverlässig. - -## Was diese Datei tut - -Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen. -Die kleinen Größen werden mit `LANCZOS` verkleinert — bei 16x16 ist das der -Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei. - -Aufruf (macht der Bau selbst, siehe `build.py`): - - python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI] -""" - -import argparse -import os - -# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe -# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die -# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus. -GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256) - -HIER = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(os.path.dirname(HIER)) -QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") - - -def groessen_in(pfad: str) -> list: - """Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen) - - Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der - Fehler, den es hier zu verhindern gilt. - """ - import struct - - with open(pfad, "rb") as f: - roh = f.read() - if len(roh) < 6: - return [] - reserviert, typ, anzahl = struct.unpack(" tuple: - """Welche Pflichtgroessen fehlen? Leer heisst: alles da.""" - vorhanden = set(groessen_in(pfad)) - return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden) - - -def bauen(quelle: str = None, ziel: str = None) -> str: - """Baut die vollstaendige .ico. Gibt den Zielpfad zurueck. - - Wirft, wenn es nicht geht — ein Symbol, das still fehlschlaegt, fuehrt - genau zu dem leeren Blatt, das hier abgestellt werden soll. - """ - from PIL import Image - - quelle = quelle or QUELLE - ziel = ziel or quelle - - bild = Image.open(quelle) - bild = bild.convert("RGBA") - - # Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem - # zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste - # Groesse, und aus 16x16 laesst sich kein 256er machen. - if hasattr(bild, "size") and bild.size[0] < 256: - try: - bild = Image.open(quelle) - bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen - bild = bild.convert("RGBA") - except Exception: # noqa: BLE001 - pass - - ebenen = [] - for kante in GROESSEN: - if kante >= bild.size[0]: - ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS)) - else: - # LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied - # zwischen einem erkennbaren Zeichen und Pixelbrei. - ebenen.append(bild.resize((kante, kante), Image.LANCZOS)) - - ebenen[-1].save(ziel, format="ICO", - sizes=[(k, k) for k in GROESSEN]) - return ziel - - -def main(argv=None) -> int: - p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico") - p.add_argument("--quelle", default=QUELLE) - p.add_argument("--ziel", default=None) - p.add_argument("--nur-pruefen", action="store_true") - args = p.parse_args(argv) - - vorher = groessen_in(args.quelle) - print("Vorher: %s" % (vorher or "keine lesbare .ico")) - if args.nur_pruefen: - return 0 if len(vorher) > 1 else 1 - - ziel = bauen(args.quelle, args.ziel) - print("Nachher: %s" % groessen_in(ziel)) - print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel))) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packaging/windows/requirements-bau.txt b/packaging/windows/requirements-bau.txt deleted file mode 100644 index 8fb5eac..0000000 --- a/packaging/windows/requirements-bau.txt +++ /dev/null @@ -1,25 +0,0 @@ -# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt. -# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe. -# -# py -3.12 -m venv venv -# venv\Scripts\pip install -r docker/api/requirements.txt -# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt -# venv\Scripts\python packaging/windows/build.py -# -# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute -# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel -# ist eine Entscheidung, kein Zufall. - -pyinstaller==6.22.2 - -# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht -# zu beenden, ausser ueber den Taskmanager. -pystray==0.19.5 -pillow==12.3.0 - -# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der -# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und -# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht. -# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung. -pywebview==6.2.1 -pythonnet==3.1.0 diff --git a/packaging/windows/test_icon.py b/packaging/windows/test_icon.py deleted file mode 100644 index f358ef3..0000000 --- a/packaging/windows/test_icon.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt. - -## Der Befund (28.08.2026) - -Commander: *„Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses -Leeres Blatt)."* - -In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG. -Windows holt sich fuer jede Stelle die passende Groesse: - - Taskleiste, kleine Verknuepfung 16x16 - Desktop, Startmenue 32x32 / 48x48 - Alt-Tab, grosse Kacheln 256x256 - -Fehlt sie, skaliert die Shell nicht zuverlaessig selbst — sie zeigt das leere -Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das -regelmaessig. - -Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den -ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel. -""" - -import os - -import pytest - -HIER = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(os.path.dirname(HIER)) -ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") - - -def _icon_modul(): - import sys - - sys.path.insert(0, HIER) - import icon - - return icon - - -def _groessen(): - return _icon_modul().groessen_in(ICON) - - -# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse -# fordern, die dieser Test nicht prueft — oder umgekehrt. -PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN - - -def test_icon_ist_da(): - assert os.path.isfile(ICON), ICON - - -def test_icon_ist_eine_gueltige_ico(): - assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf" - - -@pytest.mark.parametrize("kante", PFLICHTGROESSEN) -def test_pflichtgroesse_ist_enthalten(kante): - """DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer.""" - vorhanden = _groessen() - assert kante in vorhanden, ( - "%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle " - "ein leeres Blatt. Abhilfe: python packaging/windows/icon.py" - % (kante, kante, vorhanden) - ) - - -def test_eine_einzige_groesse_reicht_nie(): - """Der Zustand, in dem die Datei war. Als eigener Test, damit die - Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl.""" - assert len(_groessen()) > 1, ( - "rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den " - "leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste " - "gefuehrt hat." - ) diff --git a/packaging/windows/werkzeuge.py b/packaging/windows/werkzeuge.py deleted file mode 100644 index 4b24cb9..0000000 --- a/packaging/windows/werkzeuge.py +++ /dev/null @@ -1,135 +0,0 @@ -"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann. - -## Warum HandBrake mitgeliefert wird und MakeMKV nicht - -Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden -oder während des Setups separat installiert werden! Ohne das ist das tool -NICHT einsatzfähig."* - -Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist -die Lizenz, nicht die Bequemlichkeit: - -* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange - Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und - Rippy kann auch auf einem Rechner ohne Internet komprimieren. -* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch - Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und - startet dessen Installer — der Nutzer bezieht sie also weiterhin von - MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung - wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1. - -## Warum die Datei nicht im Repo liegt - -24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem -HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach -`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket — -dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM. - -Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen. -""" - -import os -import shutil -import sys - -HIER = os.path.dirname(os.path.abspath(__file__)) -REPO = os.path.dirname(os.path.dirname(HIER)) - -# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2 -# verlangt ihn bei jeder Weitergabe. -GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2 - -Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und -steht unter der GPL-2. Das bedeutet fuer dich: - - * Du darfst es weitergeben und veraendern. - * Der Quelltext ist oeffentlich verfuegbar unter: - https://github.com/HandBrake/HandBrake - * Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter: - https://www.gnu.org/licenses/old-licenses/gpl-2.0.html - -Mitgelieferte Fassung: {version} -Bezogen von: {url} - -MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim -Einrichten von makemkv.com geladen und mit dem Installer des Herstellers -installiert. -""" - - -def vendor_ordner(basis: str = None) -> str: - return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor") - - -def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict: - """Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt. - - Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist - leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll - möglich bleiben, muss dann aber laut sagen, was fehlt. - """ - sys.path.insert(0, os.path.join(REPO, "src")) - from rippy.tools import beschaffen - - ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake") - exe = os.path.join(ordner, "HandBrakeCLI.exe") - - if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024: - return {"pfad": exe, "version": _version_von(exe), "geladen": False} - - melden(" HandBrakeCLI wird geholt …") - try: - pfad = beschaffen.handbrake_holen( - ziel_ordner=ordner, - fortschritt=lambda t, anteil=None: melden(" " + t)) - except Exception as e: # noqa: BLE001 - melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e) - return {"pfad": "", "version": "", "geladen": False} - return {"pfad": pfad, "version": _version_von(pfad), "geladen": True} - - -def _version_von(exe: str) -> str: - sys.path.insert(0, os.path.join(REPO, "src")) - from rippy.tools import katalog - - try: - return katalog.version_von("handbrake", exe) or "" - except Exception: # noqa: BLE001 - return "" - - -def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str: - """Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist. - - Struktur — genau die, in der `katalog.kandidaten()` sucht: - - tools/handbrake/HandBrakeCLI.exe - tools/handbrake/LIZENZ-HandBrake.txt - """ - hb = handbrake_bereitstellen(ziel_basis, melden) - if not hb["pfad"]: - return "" - - basis = os.path.join(vendor_ordner(ziel_basis), "paket") - ordner = os.path.join(basis, "handbrake") - os.makedirs(ordner, exist_ok=True) - ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe") - if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe): - shutil.copy2(hb["pfad"], ziel_exe) - - from rippy.tools import beschaffen - - with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w", - encoding="utf-8") as f: - f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt", - url=beschaffen.HANDBRAKE_API)) - - melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)" - % (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2)) - return basis - - -if __name__ == "__main__": - ordner = paket_ordner_bauen() - print("Paket-Ordner:", ordner or "(leer)") - raise SystemExit(0 if ordner else 1) diff --git a/src/rippy/betrieb.py b/src/rippy/betrieb.py index df6e6a7..0b2ab3d 100644 --- a/src/rippy/betrieb.py +++ b/src/rippy/betrieb.py @@ -254,44 +254,6 @@ def platz_orte(werte: dict, container: bool = None, return orte -#: Rückgabewerte von GetDriveTypeW, die als Ablage taugen (learn.microsoft.com -#: /windows/win32/api/fileapi/nf-fileapi-getdrivetypew): 3 = feste Platte, -#: 4 = Netzlaufwerk. NICHT dabei: 2 (Wechselmedium — ein USB-Stick als -#: Arbeitsverzeichnis für 100 GB Rohdaten ist eine Falle) und 5 (CD/DVD — -#: dort liegt die Disc, die gerade gerippt wird). -LAUFWERKSARTEN = (3, 4) - - -def windows_laufwerke(art=None, buchstaben=None) -> list: - """Welche Laufwerksbuchstaben taugen als Ablage? — gemessen, nicht geraten. - - Warum das gebraucht wird: Auf Windows ist die Antwort auf „wohin mit 100 GB - Rohdaten" fast immer *ein anderes Laufwerk*. Ohne diese Liste stand im - Auswahlfeld nur ein einziger Eintrag, und der Commander konnte nichts - ändern. - - `art` und `buchstaben` sind einspritzbar, damit das auf dem Linux-Runner - der Ampel prüfbar bleibt — dort gibt es kein `GetDriveTypeW`. - """ - if buchstaben is None: - buchstaben = [c + ":\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"] - if art is None: - try: - import ctypes - art = ctypes.WinDLL("kernel32").GetDriveTypeW - art.argtypes = [ctypes.c_wchar_p] - except (OSError, AttributeError): - return [] - gefunden = [] - for pfad in buchstaben: - try: - if art(pfad) in LAUFWERKSARTEN: - gefunden.append(pfad) - except OSError: - continue - return gefunden - - def mit_einstellungen(werte: dict, einstellungen: dict) -> dict: r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen. @@ -337,7 +299,7 @@ def ablage_vorgabe(werte: dict, container: bool, system: str) -> str: return eigen if container: return "/app/media" - if system == "windows": - basis = os.environ.get("USERPROFILE") or os.path.expanduser("~") - return os.path.join(basis, "Videos", "Rippy") + # Der Windows-Sonderzweig ist mit der Standalone-App gegangen (v5 ist + # ein eigenes Produkt, KONZEPT-WINDOWS.md Entscheid 7) — nativ heisst + # hier nur noch: ein Entwickler-Lauf ausserhalb des Containers. return os.path.join(os.path.expanduser("~"), "Videos", "Rippy") diff --git a/src/rippy/daemon.py b/src/rippy/daemon.py deleted file mode 100644 index 61b0fe5..0000000 --- a/src/rippy/daemon.py +++ /dev/null @@ -1,338 +0,0 @@ -"""`rippyd` — Rippy als EIN Prozess, ohne Docker, ohne Postgres, ohne Redis. - -## Was hier zusammenkommt - -Alles, was die Etappen V2-1 bis V2-3 vorbereitet haben: - - Store -> SQLite (V2-2) statt PostgreSQL - Queue -> LocalQueue (V2-2) statt Celery/Redis - Bus -> In-Process (V2-3) statt Redis Pub/Sub - Drives -> der Treiber dieser Maschine (V2-1/V2-4) - UI -> von FastAPI ausgeliefert statt von nginx - -Dieselbe API, dasselbe UI, derselbe Rip-Code wie im Docker-Betrieb. Was sich -unterscheidet, ist allein die Verdrahtung — genau das war der Punkt. - -## Warum das UI unter `/` und die API unter `/api` haengt - -Das UI ruft seine Endpunkte als `/api/jobs`, `/api/events` usw. auf. Im -Docker-Betrieb entfernt der nginx dieses Praefix und reicht an die API weiter -(`ui/nginx.conf`). Ohne nginx muss jemand anderes das tun. - -Statt das UI umzubauen — 30+ Aufrufstellen, und die Docker-Variante muesste -weiter funktionieren — haengt hier die bestehende App als Unteranwendung unter -`/api`. Ein `mount()`, und beide Betriebsarten sehen fuer den Browser identisch -aus. - -## Was `--profil` bedeutet - - standalone alles in einem Prozess (Windows-App, Headless-Linux) - api nur die Weboberflaeche, Arbeit machen andere Knoten - node nur Arbeit, keine Weboberflaeche - -Fuer V2-4 zaehlt `standalone`; die anderen beiden kommen mit V2-5. -""" - -import argparse -import os -import sys - - -def ausgabe_auf_utf8_stellen() -> None: - """stdout/stderr auf UTF-8 zwingen — sonst stirbt Rippy an einer Log-Zeile. - - ## Der Vorfall (28.08.2026, erster Standalone-Start unter Windows) - - Der Start brach ab mit: - - File "docker/api/main.py", line 81, in startup_event - print(f"⚠️ Konfigurations-Warnung: {e}") - UnicodeEncodeError: 'charmap' codec can't encode characters - ERROR: Application startup failed. Exiting. - - Windows-Konsolen arbeiten standardmaessig mit cp1252. Rippys Meldungen - sind durchgehend deutsch und enthalten Umlaute, Anfuehrungszeichen und - Warnzeichen — unter Linux nie ein Thema, unter Windows toedlich. - - Das Bittere daran: Es war nicht die WARNUNG, die den Start verhindert hat, - sondern der VERSUCH, sie auszugeben. Eine Meldung ueber ein kleines Problem - hat das ganze Programm umgebracht. - - Deshalb zwei Dinge, nicht eins: - - * `encoding="utf-8"` — damit die Zeichen ueberhaupt durchgehen. - * `errors="replace"` — damit ein Zeichen, das trotzdem klemmt, ein - Fragezeichen wird und NICHT eine Ausnahme. Eine Ausgabe darf unter - keinen Umstaenden etwas abbrechen koennen. - """ - for strom in (sys.stdout, sys.stderr): - umstellen = getattr(strom, "reconfigure", None) - if umstellen is None: - continue # umgeleitet, kein TextIOWrapper - try: - umstellen(encoding="utf-8", errors="replace") - except (ValueError, OSError): - pass # dann eben nicht — aber nie werfen - - -ausgabe_auf_utf8_stellen() - - -def _api_pfad() -> str: - """Wo liegen die API-Module (main.py, db.py, mounts.py, …)? - - Drei Faelle, und alle drei kommen wirklich vor: - - * **Aus dem Repo gestartet** — `docker/api` neben `src/`. - * **Im PyInstaller-Paket** — alles unter `sys._MEIPASS`. - * **Installiert** — neben dem Programm. - - Der Umweg ist noetig, weil die API-Module sich flach importieren - (`import db`, `import mounts`). Das aufzuloesen ist der naechste - Umbauschritt; bis dahin wird der Pfad hier gesetzt statt an drei Stellen - geraten. - """ - gebuendelt = getattr(sys, "_MEIPASS", None) - if gebuendelt: - return os.path.join(gebuendelt, "api") - - hier = os.path.dirname(os.path.abspath(__file__)) # …/src/rippy - repo = os.path.dirname(os.path.dirname(hier)) # …/ - kandidaten = [ - os.path.join(repo, "docker", "api"), - os.path.join(os.path.dirname(sys.executable), "api"), - ] - for pfad in kandidaten: - if os.path.isfile(os.path.join(pfad, "main.py")): - return pfad - raise RuntimeError( - "Die API-Module wurden nicht gefunden. Gesucht wurde in:\n " - + "\n ".join(kandidaten) - + "\nOhne sie kann Rippy nicht starten." - ) - - -def _ui_pfad() -> str: - """Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert. - - ## Warum die installierte Kopie VOR dem Paket kommt (28.08.2026) - - Gemessen an einem Dienst, der seit einer Stunde lief: `/api/health` gab - HTTP 200, `/` gab **404**. Im Fenster stand `{"detail":"Not Found"}`. - - Der Grund lag nicht im Server, sondern unter ihm. Eine - PyInstaller-Onefile-EXE entpackt sich beim Start nach `%TEMP%\\_MEIxxxxx` - und liest von dort weiter — `StaticFiles` schlaegt bei JEDER Anfrage in - diesem Ordner nach. Nachgesehen, was davon uebrig war: - - _MEI000074b02 31 Eintraege, 5 Ordner — kein `ui`, kein `api` - _MEI000082e82 44 Eintraege, 16 Ordner — vollstaendig - - Der halbe Ordner gehoerte dem laufenden Dienst. Ein Temp-Verzeichnis ist - kein Ort, an dem etwas eine Woche lang liegen bleibt: Aufraeumwerkzeuge, - Storage Sense und die Aufraeumroutine anderer Onefile-Prozesse greifen - dort hinein. Und der Ausfall ist der schlimmstmoegliche — die API - antwortet weiter, der Dienst gilt als gesund, nur die Oberflaeche ist - weg. Genau die Sorte stiller Fehlschlag, vor der AGENTS.md warnt. - - Deshalb legt der Installer eine Kopie neben das Programm, und die hat - hier Vorrang. Sie liegt in `%LOCALAPPDATA%\\Rippy`, gehoert Rippy und - verschwindet beim Deinstallieren mit. - """ - neben_programm = os.path.join(os.path.dirname(_programmordner()), "ui") - if os.path.isdir(neben_programm): - return neben_programm - - gebuendelt = getattr(sys, "_MEIPASS", None) - if gebuendelt: - pfad = os.path.join(gebuendelt, "ui") - return pfad if os.path.isdir(pfad) else "" - - hier = os.path.dirname(os.path.abspath(__file__)) - repo = os.path.dirname(os.path.dirname(hier)) - pfad = os.path.join(repo, "docker", "ui", "dist") - return pfad if os.path.isdir(pfad) else "" - - -def _programmordner() -> str: - """Die laufende Programmdatei — im Paket die EXE, sonst der Interpreter. - - Gibt eine DATEI zurueck, nicht den Ordner; der Aufrufer nimmt sich mit - `dirname` was er braucht. `sys.executable` ist unter PyInstaller die - Rippy.exe selbst, nicht das ausgepackte Python. - """ - return sys.executable - - -def argumente(argv=None) -> argparse.Namespace: - p = argparse.ArgumentParser( - prog="rippyd", - description="Rippy als eigenstaendiger Dienst — ohne Docker.", - ) - p.add_argument("--profil", default=None, - choices=["standalone", "api", "node"], - help="Betriebsart (Vorgabe: standalone)") - p.add_argument("--config", default=None, - help="Pfad zur rippy.toml") - p.add_argument("--host", default=None) - p.add_argument("--port", type=int, default=None) - p.add_argument("--db", default=None, - help="Pfad zur SQLite-Datei (ueberschreibt die Konfiguration)") - p.add_argument("--kein-ui", action="store_true", - help="Weboberflaeche nicht ausliefern") - return p.parse_args(argv) - - -def flags_aus_argumenten(args) -> dict: - """Nur das, was WIRKLICH angegeben wurde — sonst schlaegt ein - argparse-Vorgabewert die Konfigurationsdatei, und niemand versteht, - warum der Eintrag dort nicht greift.""" - flags: dict = {} - if args.profil: - flags["profil"] = args.profil - server = {} - if args.host: - server["host"] = args.host - if args.port: - server["port"] = args.port - if args.kein_ui: - server["ui"] = False - if server: - flags["server"] = server - if args.db: - flags["store"] = {"treiber": "sqlite", "pfad": args.db} - return flags - - -def einstellungen_laden(args): - from rippy import config - - datei = args.config or os.environ.get("RIPPY_CONFIG") or standard_konfigpfad() - werte = config.laden(datei if os.path.isfile(datei) else None, - flags=flags_aus_argumenten(args)) - unbekannt = config.unbekannte_schluessel( - config.aus_datei(datei) if os.path.isfile(datei) else {}) - return werte, datei, unbekannt - - -def standard_konfigpfad() -> str: - if os.name == "nt": - basis = os.environ.get("PROGRAMDATA") or os.path.expanduser("~") - return os.path.join(basis, "Rippy", "rippy.toml") - return "/etc/rippy/rippy.toml" - - -def anwendung_bauen(werte: dict): - """Baut die ASGI-Anwendung: API unter /api, UI unter /. - - Gibt `(app, ui_pfad)` zurueck — `ui_pfad` ist leer, wenn kein UI - ausgeliefert wird. - """ - sys.path.insert(0, _api_pfad()) - - from contextlib import asynccontextmanager - - from fastapi import FastAPI - - import main as rippy_api # noqa: E402 - - @asynccontextmanager - async def lebenszyklus(_): - """Den Start der EINGEHAENGTEN Anwendung mit ausloesen. - - ⚠️ Starlette reicht Lifespan-Ereignisse NICHT an `mount()`-Unteranwen- - dungen weiter. Ohne diese Zeilen laeuft `startup_event()` in main.py - nie — und dann fehlt alles, was dort passiert: - - * `db.init_db()` legt die Tabellen an. Ohne das antwortet /settings - mit `sqlite3.OperationalError: no such table: settings`. - * Der Ereignis-Waechter startet nicht, der SSE-Strom bleibt stumm. - * Gespeicherte Netzwerk-Ziele werden nicht wieder eingehaengt. - - Am 28.08.2026 beim ersten Standalone-Start genau so gemessen: HTTP 200 - auf /api/health (die Route braucht keine Datenbank), HTTP 500 auf - /api/jobs und /api/settings, und der Ereignis-Strom lieferte in sechs - Sekunden nichts. - """ - async with rippy_api.app.router.lifespan_context(rippy_api.app): - yield - - aussen = FastAPI(title="Rippy", version="2.0", lifespan=lebenszyklus) - aussen.mount("/api", rippy_api.app) - - ui = _ui_pfad() if werte.get("server", {}).get("ui", True) else "" - if ui: - from fastapi.staticfiles import StaticFiles - - # html=True laesst StaticFiles bei unbekannten Pfaden index.html - # ausliefern — noetig, weil das UI seine Ansichten selbst verwaltet - # (im Docker-Betrieb macht das `try_files $uri /index.html`). - aussen.mount("/", StaticFiles(directory=ui, html=True), name="ui") - return aussen, ui - - -def starten(argv=None) -> int: - args = argumente(argv) - werte, konfigdatei, unbekannt = einstellungen_laden(args) - - from rippy import config, drives, store - - # ⚠️ VOR dem Import der API: main.py legt beim Start Tabellen an, und der - # Store muss bis dahin auf der richtigen Datenbank stehen. Andersherum - # laege die SQLite-Datei am Vorgabeort und die Konfiguration waere - # wirkungslos — ohne dass irgendwo etwas stuende. - url = config.datenbank_url(werte) - store.verbinden(url) - os.environ.setdefault("DATABASE_URL", url) - - print("Rippy startet") - print(f" Profil {werte.get('profil')}") - print(f" Konfiguration {konfigdatei if os.path.isfile(konfigdatei) else '(keine — Vorgaben)'}") - print(f" Datenbank {url}") - print(f" Laufwerke {drives.treiber().__name__}") - for schluessel in unbekannt: - # Ein stillschweigend ignorierter Schluessel ist dieselbe Fehlerklasse - # wie ein verschluckter Fehler: sieht gesund aus, wirkt aber nicht. - print(f" ⚠️ Unbekannter Eintrag in der Konfiguration: {schluessel}") - - app, ui = anwendung_bauen(werte) - print(f" Oberflaeche {ui or '(nicht mitgeliefert)'}") - - # Im Standalone-Betrieb arbeitet DIESER Prozess die Auftraege ab — - # es gibt keinen Worker daneben. Ohne diesen Schritt koennte Rippy alles - # anzeigen und nichts tun: Ein Rip wuerde eingereiht und laege dann fuer - # immer da, weil niemand ihn holt. - if werte.get("profil") == "standalone" and werte["queue"]["treiber"] == "lokal": - from rippy import standalone - from rippy.bus.memory import bus as ereignis_bus - - laeufer = standalone.starten( - knoten=werte["queue"].get("knoten") or None, - bus=ereignis_bus, store=store, - slots=werte["queue"].get("rip_slots", 1), - ) - print(f" Laeufer {len(laeufer)} (Auftraege werden hier abgearbeitet)") - else: - print(" Laeufer keiner — Auftraege gehen an Celery") - - host = werte["server"]["host"] - port = werte["server"]["port"] - print(f" Adresse http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}") - print() - - import uvicorn - - uvicorn.run(app, host=host, port=port, log_level="info") - return 0 - - -def main() -> int: - try: - return starten() - except KeyboardInterrupt: - print("\nRippy beendet.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/rippy/drives/__init__.py b/src/rippy/drives/__init__.py index 7beb786..f3271f6 100644 --- a/src/rippy/drives/__init__.py +++ b/src/rippy/drives/__init__.py @@ -41,8 +41,12 @@ def treiber(plattform: str = None): """ plattform = plattform if plattform is not None else sys.platform if plattform.startswith("win"): - from rippy.drives import windows - return windows + # Der Windows-Treiber ist mit der Standalone-App gegangen (Rippy v5, + # Entscheid 7). Der Platzhalter hält Importe und Test-Sammlung auf + # Windows-Entwicklungsrechnern am Leben; jede BENUTZUNG wirft mit + # Klartext — siehe kein_windows.py. + from rippy.drives import kein_windows + return kein_windows from rippy.drives import linux return linux diff --git a/src/rippy/drives/kein_windows.py b/src/rippy/drives/kein_windows.py new file mode 100644 index 0000000..09ece34 --- /dev/null +++ b/src/rippy/drives/kein_windows.py @@ -0,0 +1,34 @@ +"""Der Platzhalter, der unter Windows die Wahrheit sagt. + +Der echte Windows-Laufwerkstreiber ist mit der Standalone-App gegangen — +Rippy für Windows ist seit dem 30.08.2026 ein eigenes Produkt (Rippy v5, +KONZEPT-WINDOWS.md Entscheid 7). Diese Codebasis ist die Docker/Linux- +Fassung; der Remote-Encode-Worker auf Windows rippt nicht (Entscheid 8). + +Warum ein Modul statt eines Fehlers in `treiber()`: Entwicklungs- und +Test-Läufe auf einem Windows-PC müssen IMPORTIERBAR bleiben (die Ampel- +Praxis misst lokal, bevor sie pusht). Erst die BENUTZUNG eines Laufwerks +ist hier ein Fehler — und zwar einer mit Ansage. +""" + +MELDUNG = ( + "Kein Windows-Laufwerkstreiber in der Docker-Fassung — fuer Windows " + "gibt es Rippy v5 (Branch worktree-windows-electron)." +) + + +def _fehlt(*_args, **_kwargs): + raise RuntimeError(MELDUNG) + + +# Dieselben Namen wie linux.py — test_treiberwahl wacht darüber. +list_optical_devices = _fehlt +device_info = _fehlt +drive_status = _fehlt +disc_status = _fehlt +detect_disc_type = _fehlt +disc_size_bytes = _fehlt +eject = _fehlt +auswerfen_versuchen = _fehlt +auswerfen_mit_grund = _fehlt +verriegeln = _fehlt diff --git a/src/rippy/drives/test_win_ioctl.py b/src/rippy/drives/test_win_ioctl.py deleted file mode 100644 index fa1e20c..0000000 --- a/src/rippy/drives/test_win_ioctl.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Die Win32-Steuercodes gegen die dokumentierten Zahlen rechnen. - -## Warum dieser Test wichtig ist - -Ein falscher IOCTL-Code liefert kein „unbekannter Befehl". Windows antwortet -mit `ERROR_INVALID_FUNCTION` — und das liest sich wie „dieses Laufwerk kann -das nicht". Man sucht dann tagelang am Gerät statt an einer Zahl. - -Die Werte rechts sind die in `winioctl.h` / `ntddcdrm.h` und auf Microsoft -Learn dokumentierten Konstanten. Links steht, was `ctl_code()` aus den -Bestandteilen bildet. Stimmen beide überein, ist die Herleitung richtig. - -Läuft auf JEDER Plattform: Hier wird nur gerechnet, nichts aufgerufen. Der -Zahlendreher fällt damit schon auf dem Entwicklungsrechner auf und nicht erst -an einem echten Laufwerk. -""" - -import pytest - -from rippy.drives import win_ioctl as w - - -def test_ctl_code_bildet_das_makro_aus_winioctl_h_ab(): - """CTL_CODE(DeviceType, Function, Method, Access) = - (DeviceType << 16) | (Access << 14) | (Function << 2) | Method""" - assert w.ctl_code(0, 0, 0, 0) == 0 - assert w.ctl_code(1, 0, 0, 0) == 1 << 16 - assert w.ctl_code(0, 1, 0, 0) == 1 << 2 - assert w.ctl_code(0, 0, 3, 0) == 3 - assert w.ctl_code(0, 0, 0, 1) == 1 << 14 - - -@pytest.mark.parametrize("name,erwartet", [ - # Die Zahlen aus der Microsoft-Dokumentation bzw. den SDK-Headern. - ("IOCTL_STORAGE_CHECK_VERIFY2", 0x002D0800), - ("IOCTL_STORAGE_MEDIA_REMOVAL", 0x002D4804), - ("IOCTL_STORAGE_EJECT_MEDIA", 0x002D4808), - ("IOCTL_STORAGE_LOAD_MEDIA", 0x002D480C), - ("IOCTL_CDROM_DISK_TYPE", 0x00020040), - ("IOCTL_DISK_GET_LENGTH_INFO", 0x0007405C), -]) -def test_steuercode_stimmt_mit_der_dokumentation_ueberein(name, erwartet): - assert getattr(w, name) == erwartet, ( - f"{name} ist {getattr(w, name):#010x}, dokumentiert ist {erwartet:#010x}. " - "Ein falscher Steuercode meldet ERROR_INVALID_FUNCTION — das sieht aus " - "wie 'das Laufwerk kann das nicht' und schickt die Fehlersuche in die " - "falsche Richtung." - ) - - -# ── Gerätepfad ────────────────────────────────────────────────────────── -@pytest.mark.parametrize("eingabe", ["D", "D:", "d:", "D:\\", "d:/", " D: "]) -def test_geraetepfad_ist_immer_dieselbe_form(eingabe): - assert w.geraetepfad(eingabe) == r"\\.\D:" - - -def test_geraetepfad_haengt_keinen_backslash_an(): - """Der entscheidende Punkt: MIT abschliessendem Backslash oeffnet - CreateFileW das WURZELVERZEICHNIS des Dateisystems statt des Geraets. - Die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION.""" - assert not w.geraetepfad("E:\\").endswith("\\") - assert w.geraetepfad("E:\\") == r"\\.\E:" - - -def test_geraetepfad_ohne_angabe_wirft(): - for leer in ("", " ", ":", "\\"): - with pytest.raises(ValueError): - w.geraetepfad(leer) - - -def test_drive_cdrom_ist_fuenf(): - """GetDriveTypeW meldet optische Laufwerke als 5. Ein Zahlendreher hier - hiesse: Rippy findet entweder gar kein Laufwerk oder haelt die - Systemplatte fuer eines.""" - assert w.DRIVE_CDROM == 5 - assert w.DRIVE_FIXED == 3 - assert w.DRIVE_REMOVABLE == 2 - - -def test_error_not_ready_ist_einundzwanzig(): - """Das ist die Antwort von CHECK_VERIFY2 bei leerem Laufwerk — und damit - die Windows-Entsprechung von CDS_NO_DISC. Sie ENTSCHEIDET, ob ein - Auswurf als geglueckt gilt.""" - assert w.ERROR_NOT_READY == 21 diff --git a/src/rippy/drives/test_windows.py b/src/rippy/drives/test_windows.py deleted file mode 100644 index d0c6c6b..0000000 --- a/src/rippy/drives/test_windows.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Der Windows-Treiber gegen ein nachgebautes Laufwerk. - -## Was diese Tests beweisen — und was nicht - -**Sie beweisen den ABLAUF:** dass erst entriegelt und dann ausgeworfen wird, -dass danach nachgesehen statt geglaubt wird, dass ein verriegeltes Laufwerk als -Fehlschlag gilt, dass ein unzugängliches Laufwerk „unbekannt" ergibt und nicht -„leer", und dass beide Verträge (werfend / nicht werfend) stimmen. - -**Sie beweisen NICHT, dass echte Hardware sich so verhält.** Dafür braucht es -ein angestecktes Laufwerk und eine Disc. Bis das gemessen ist, gilt der Treiber -als gebaut, nicht als bewiesen. - -Das nachgebaute Laufwerk unten bildet genau das Verhalten ab, das am -26.07.2026 am echten BU40N unter Linux GEMESSEN wurde: Der Auswurf-Befehl wird -auch bei verriegelter Tür mit Erfolg quittiert und tut nichts. Es gibt keinen -Anlass anzunehmen, dass Windows das anders macht — beide Wege enden im selben -SCSI-Befehl an dasselbe Gerät. -""" - -import pytest - -from rippy.drives import win_ioctl as w -from rippy.drives import windows - - -class FakeLaufwerk: - """Ein Laufwerk aus Papier — verhält sich wie das gemessene echte. - - `verriegelt=True` bildet den Zustand nach einem MakeMKV-Rip ab: Die Tür ist - zu, und der Auswurf-Befehl wird trotzdem mit Erfolg quittiert. - """ - - def __init__(self, medium=True, verriegelt=False, kennt_lockdoor=True, - buchstaben=("C", "D"), typen=None, disk_flags=None, - groesse=None, oeffnen_fehler=None): - self.medium = medium - self.verriegelt = verriegelt - self.kennt_lockdoor = kennt_lockdoor - self._buchstaben = list(buchstaben) - self._typen = typen or {"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM} - self.disk_flags = disk_flags - self.groesse = groesse - self.oeffnen_fehler = oeffnen_fehler - self.aufrufe = [] # Reihenfolge der Steuercodes - self.offen = 0 - - # -- Laufwerks-Auflistung - def laufwerksbuchstaben(self): - return list(self._buchstaben) - - def laufwerkstyp(self, buchstabe): - return self._typen.get(buchstabe, w.DRIVE_UNKNOWN) - - # -- Gerät - def oeffnen(self, pfad): - if self.oeffnen_fehler is not None: - raise self.oeffnen_fehler - self.offen += 1 - return 4242 - - def schliessen(self, handle): - self.offen -= 1 - - def steuern(self, handle, code, eingabe=b"", ausgabe_bytes=0): - self.aufrufe.append(code) - - if code == w.IOCTL_STORAGE_MEDIA_REMOVAL: - if not self.kennt_lockdoor: - raise windows.Win32Fehler("MEDIA_REMOVAL", w.ERROR_INVALID_FUNCTION) - self.verriegelt = eingabe[:1] == b"\x01" - return b"" - - if code == w.IOCTL_STORAGE_EJECT_MEDIA: - # ⚠️ Der gemessene Punkt: Erfolg wird IMMER gemeldet. Gewirkt hat - # er nur, wenn die Tuer nicht verriegelt ist. - if not self.verriegelt: - self.medium = False - return b"" - - if code == w.IOCTL_STORAGE_CHECK_VERIFY2: - if not self.medium: - raise windows.Win32Fehler("CHECK_VERIFY2", w.ERROR_NOT_READY) - return b"" - - if code == w.IOCTL_CDROM_DISK_TYPE: - if self.disk_flags is None: - raise windows.Win32Fehler("DISK_TYPE", w.ERROR_INVALID_FUNCTION) - return int(self.disk_flags).to_bytes(4, "little") - - if code == w.IOCTL_DISK_GET_LENGTH_INFO: - if self.groesse is None: - raise windows.Win32Fehler("GET_LENGTH_INFO", w.ERROR_INVALID_FUNCTION) - return int(self.groesse).to_bytes(8, "little") - - raise windows.Win32Fehler(f"unerwarteter Code {code:#x}", w.ERROR_INVALID_FUNCTION) - - -# ── Laufwerke finden ──────────────────────────────────────────────────── -def test_nur_optische_laufwerke_werden_gelistet(): - """Waeren es alle, stuende die Systemplatte als „Laufwerk" im UI.""" - api = FakeLaufwerk(buchstaben=("C", "D", "E", "Z"), typen={ - "C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM, - "E": w.DRIVE_CDROM, "Z": w.DRIVE_REMOTE, - }) - assert windows.list_optical_devices(api) == [r"\\.\D:", r"\\.\E:"] - - -def test_ohne_optisches_laufwerk_ist_die_liste_leer(): - api = FakeLaufwerk(buchstaben=("C",), typen={"C": w.DRIVE_FIXED}) - assert windows.list_optical_devices(api) == [] - - -# ── Auswurf: die Reihenfolge ist der Punkt ────────────────────────────── -def test_auswurf_entriegelt_zuerst_und_klappt_dann(): - api = FakeLaufwerk(medium=True, verriegelt=True) - assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is True - assert api.medium is False - # Entriegeln VOR Auswerfen — sonst quittiert das Laufwerk Erfolg und tut nichts. - assert api.aufrufe[0] == w.IOCTL_STORAGE_MEDIA_REMOVAL - assert api.aufrufe[1] == w.IOCTL_STORAGE_EJECT_MEDIA - - -def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt(): - """Der teure Fehler aus v1, hier vorweggenommen: Ein Laufwerk, das das - Entriegeln nicht kennt und verriegelt bleibt, quittiert den Auswurf - trotzdem mit Erfolg. Wer dem Rueckgabewert glaubt, schreibt „Disc - ausgeworfen" ins Log, waehrend die Schublade zu bleibt.""" - api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False) - assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is False - assert api.medium is True # nie aufgegangen - - -def test_auswurf_ohne_laufwerk_wirft_nicht(): - api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_FILE_NOT_FOUND)) - assert windows.auswerfen_versuchen(r"\\.\Z:", api=api, warten=lambda s: None) is False - - -def test_geraet_wird_immer_wieder_geschlossen(): - """Ein offenes Handle auf einem optischen Laufwerk blockiert den Auswurf - fuer JEDEN anderen — auch fuer den Explorer.""" - api = FakeLaufwerk(medium=True) - windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) - assert api.offen == 0 - - -# ── Der andere Vertrag: eject() muss werfen ───────────────────────────── -def test_eject_ist_still_wenn_die_disc_rauskommt(): - api = FakeLaufwerk(medium=True, verriegelt=True) - assert windows.eject(r"\\.\D:", api=api) is None - - -def test_eject_wirft_wenn_die_disc_drin_bleibt(): - api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False) - with pytest.raises(OSError, match="noch"): - windows.eject(r"\\.\D:", api=api) - - -def test_eject_reicht_den_urspruenglichen_fehler_weiter(): - """„Zugriff verweigert" und „Gerät nicht gefunden" sind zwei ganz - verschiedene Probleme. Wer sie hinter einer eigenen Meldung versteckt, - nimmt dem Nutzer die einzige brauchbare Auskunft.""" - original = windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED) - api = FakeLaufwerk(oeffnen_fehler=original) - with pytest.raises(OSError) as fehler: - windows.eject(r"\\.\D:", api=api) - assert fehler.value is original - # `code` statt `winerror`: letzteres gibt es nur unter Windows, und der - # Treiber wird auch anderswo getestet. - assert fehler.value.code == w.ERROR_ACCESS_DENIED - - -# ── Zustand und Typ ───────────────────────────────────────────────────── -def test_leeres_laufwerk_ist_leer_und_nicht_unbekannt(): - api = FakeLaufwerk(medium=False) - assert windows.detect_disc_type(r"\\.\D:", api=api) == "no_disc" - assert windows.device_info(r"\\.\D:", api=api)["status"] == "empty" - - -def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer(): - """DIE Regel des Ports. Ein Laufwerk, das gerade belegt ist, hat nicht - „keine Disc" — man weiss es nur nicht. In v1 hat genau diese Vermischung - Listen im UI geleert.""" - api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED)) - assert windows.detect_disc_type(r"\\.\D:", api=api) == "unknown" - 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"), - (w.CDROM_DISK_DATA_TRACK, 25 * 1024**3, "bluray"), - (w.CDROM_DISK_DATA_TRACK, 50 * 1000**3, "bluray"), # BD-50 - (w.CDROM_DISK_DATA_TRACK, 66 * 1000**3, "uhd"), # BD-66 - (w.CDROM_DISK_DATA_TRACK, 100 * 1000**3, "uhd"), -]) -def test_disc_typ_kommt_aus_derselben_zuordnung_wie_unter_linux(flags, groesse, erwartet): - """Die Einordnung liegt in cdrom.classify() — EINE Funktion fuer beide - Plattformen, und die ist eigenstaendig getestet. Hier wird nur geprueft, - dass Windows die beiden Eingangswerte richtig beschafft.""" - api = FakeLaufwerk(medium=True, disk_flags=flags, groesse=groesse) - assert windows.detect_disc_type(r"\\.\D:", api=api) == erwartet - - -def test_verriegeln_schickt_das_richtige_byte(): - api = FakeLaufwerk(medium=True) - windows.verriegeln(r"\\.\D:", True, api=api) - assert api.verriegelt is True - windows.verriegeln(r"\\.\D:", False, api=api) - assert api.verriegelt is False - - -def test_device_info_hat_dieselben_felder_wie_unter_linux(): - """Das UI unterscheidet nicht nach Plattform — fehlt ein Feld, bleibt die - 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", "grund"} - assert eintrag["id"] == "D" - assert eintrag["status"] == "ready" - assert eintrag["type"] == "bluray" - - -def test_treiber_verweigert_sich_ausserhalb_von_windows(): - """Sonst waere der erste Fehler ein kryptischer ctypes-Absturz statt - einer Ansage.""" - import sys as _sys - - if _sys.platform.startswith("win"): - pytest.skip("laeuft nur, wenn NICHT unter Windows getestet wird") - with pytest.raises(RuntimeError, match="nur unter Windows"): - windows.Win32() diff --git a/src/rippy/drives/win_ioctl.py b/src/rippy/drives/win_ioctl.py deleted file mode 100644 index 02e5774..0000000 --- a/src/rippy/drives/win_ioctl.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Die Win32-Steuercodes für optische Laufwerke — hergeleitet, nicht abgeschrieben. - -## Warum sie hier ausgerechnet werden - -`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf. Vor Nutzung -fremder CLI-Flags oder Bibliotheks-APIs: --help/Doku prüfen und die Fundstelle -im Commit nennen."* - -Zahlen wie `0x2D4808` sind genau der Fall, in dem sich ein Zahlendreher -lautlos versteckt: Ein falscher Code liefert kein „unbekannter Befehl", sondern -`ERROR_INVALID_FUNCTION` — und das sieht aus wie „das Laufwerk kann das nicht". -Man sucht dann am Gerät statt an der Zahl. - -Deshalb steht hier das Makro aus `winioctl.h` als Funktion, und jede Konstante -wird daraus gebildet. Wer nachrechnen will, sieht die Bestandteile. - - #define CTL_CODE(DeviceType, Function, Method, Access) ( \\ - ((DeviceType) << 16) | ((Access) << 14) | \\ - ((Function) << 2) | (Method) \\ - ) - -Fundstellen (Windows SDK / Microsoft Learn): - * CTL_CODE, FILE_DEVICE_*, METHOD_*, FILE_*_ACCESS — winioctl.h - * IOCTL_STORAGE_CHECK_VERIFY2 — learn.microsoft.com/windows/win32/api/ - winioctl/ni-winioctl-ioctl_storage_check_verify2 - * IOCTL_STORAGE_MEDIA_REMOVAL / _EJECT_MEDIA / _LOAD_MEDIA — ebenda - * IOCTL_CDROM_DISK_TYPE, CDROM_DISK_AUDIO_TRACK/DATA_TRACK — ntddcdrm.h - * IOCTL_DISK_GET_LENGTH_INFO — winioctl.h (GET_LENGTH_INFORMATION) - -## Gegenprobe - -`test_win_ioctl.py` rechnet die hier gebildeten Werte gegen die Zahlen, die in -der Microsoft-Dokumentation stehen. Stimmt eine nicht, faellt das beim Testen -auf — auf JEDER Plattform, auch ohne Windows und ohne Laufwerk. -""" - -# ── Bausteine aus winioctl.h ──────────────────────────────────────────── -FILE_DEVICE_CD_ROM = 0x00000002 -FILE_DEVICE_DISK = 0x00000007 -FILE_DEVICE_MASS_STORAGE = 0x0000002D # = IOCTL_STORAGE_BASE - -METHOD_BUFFERED = 0 -METHOD_IN_DIRECT = 1 -METHOD_OUT_DIRECT = 2 -METHOD_NEITHER = 3 - -FILE_ANY_ACCESS = 0 -FILE_READ_ACCESS = 0x0001 -FILE_WRITE_ACCESS = 0x0002 - - -def ctl_code(geraetetyp: int, funktion: int, methode: int, zugriff: int) -> int: - """Das CTL_CODE-Makro aus winioctl.h, eins zu eins.""" - return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) - - -# ── Speicher (IOCTL_STORAGE_*) ────────────────────────────────────────── -# „Ist ein Medium eingelegt?" — die Windows-Entsprechung von -# CDROM_DRIVE_STATUS. Antwortet mit ERROR_NOT_READY (21), wenn nichts drin ist. -IOCTL_STORAGE_CHECK_VERIFY2 = ctl_code( - FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS) - -# Tür verriegeln/entriegeln — die Entsprechung von CDROM_LOCKDOOR. -# Erwartet eine PREVENT_MEDIA_REMOVAL-Struktur (ein BOOLEAN). -IOCTL_STORAGE_MEDIA_REMOVAL = ctl_code( - FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS) - -# Auswerfen — die Entsprechung von CDROMEJECT. -IOCTL_STORAGE_EJECT_MEDIA = ctl_code( - FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS) - -# Schublade einziehen — die Entsprechung von CDROMCLOSETRAY. -IOCTL_STORAGE_LOAD_MEDIA = ctl_code( - FILE_DEVICE_MASS_STORAGE, 0x0203, METHOD_BUFFERED, FILE_READ_ACCESS) - -# Hersteller, Modell und Seriennummer des LAUFWERKS (nicht der Disc). -# -# Die Entsprechung von `/sys/class/block//device/{vendor,model,wwid}` -# unter Linux. Ohne diesen Aufruf stand im UI „Modell: unbekannt, -# Seriennummer: –" — der Commander hat es am 28.08.2026 gemeldet. -# -# Erwartet eine STORAGE_PROPERTY_QUERY (PropertyId=0 StorageDeviceProperty, -# QueryType=0 PropertyStandardQuery) und antwortet mit einem -# STORAGE_DEVICE_DESCRIPTOR: feste Kopfdaten mit OFFSETS auf die Zeichenketten, -# die dahinter im selben Puffer liegen. -IOCTL_STORAGE_QUERY_PROPERTY = ctl_code( - FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS) - -STORAGE_DEVICE_PROPERTY = 0 -PROPERTY_STANDARD_QUERY = 0 - -# Feldpositionen im STORAGE_DEVICE_DESCRIPTOR (winioctl.h), in Bytes. -SDD_VENDOR_ID_OFFSET = 12 -SDD_PRODUCT_ID_OFFSET = 16 -SDD_PRODUCT_REVISION_OFFSET = 20 -SDD_SERIAL_NUMBER_OFFSET = 24 - - -# ── CD-ROM (IOCTL_CDROM_*) ────────────────────────────────────────────── -# Audio- oder Datenspur? Grob, aber genau das, was CDROM_DISC_STATUS auch -# liefert — mehr braucht die Einordnung nicht (die Feinunterscheidung -# DVD/BD/UHD kommt über die Größe). -IOCTL_CDROM_DISK_TYPE = ctl_code( - FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS) - -CDROM_DISK_AUDIO_TRACK = 0x00000001 -CDROM_DISK_DATA_TRACK = 0x00000002 - -# ── Größe des Mediums ─────────────────────────────────────────────────── -# Die Entsprechung von BLKGETSIZE64. Liefert GET_LENGTH_INFORMATION -# (ein LARGE_INTEGER, also 8 Byte). -IOCTL_DISK_GET_LENGTH_INFO = ctl_code( - FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS) - -# ── Rückmeldungen von GetLastError() ──────────────────────────────────── -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 - -# ── GetDriveTypeW ─────────────────────────────────────────────────────── -DRIVE_UNKNOWN = 0 -DRIVE_NO_ROOT_DIR = 1 -DRIVE_REMOVABLE = 2 -DRIVE_FIXED = 3 -DRIVE_REMOTE = 4 -DRIVE_CDROM = 5 -DRIVE_RAMDISK = 6 - -# ── CreateFileW ───────────────────────────────────────────────────────── -GENERIC_READ = 0x80000000 -FILE_SHARE_READ = 0x00000001 -FILE_SHARE_WRITE = 0x00000002 -OPEN_EXISTING = 3 -INVALID_HANDLE_VALUE = -1 - - -def geraetepfad(buchstabe: str) -> str: - r"""„D:" oder „D" oder „D:\" -> r"\\.\D:" - - Der Win32-Gerätepfad hat KEINEN abschließenden Backslash. Mit einem - öffnet `CreateFileW` das Wurzelverzeichnis des Dateisystems statt des - Geräts — die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION, was wie - „das Laufwerk kann das nicht" aussieht. - """ - kern = (buchstabe or "").strip().rstrip("\\/").rstrip(":") - if not kern: - raise ValueError("Kein Laufwerksbuchstabe angegeben") - return "\\\\.\\" + kern[0].upper() + ":" - - -# ── Audio-CD lesen (29.08.2026) ───────────────────────────────────────── -# -# Beide aus ntddcdrm.h. Eine Audio-CD hat kein Dateisystem: Windows zeigt -# `Track01.cda`, das sind aber 44-Byte-Platzhalter. Die Musik liegt roh in -# 2352-Byte-Sektoren und muss sektorweise gelesen werden. - -#: Inhaltsverzeichnis der CD (CDROM_TOC: je Track Startadresse als MSF). -IOCTL_CDROM_READ_TOC = ctl_code( - FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS) - -#: Rohe Sektoren lesen. Eingabe ist RAW_READ_INFO: -#: -#: LARGE_INTEGER DiskOffset; Sektor * 2048 (NICHT * 2352 — dokumentiert) -#: ULONG SectorCount; -#: TRACK_MODE_TYPE TrackMode; CDDA = 2 -#: -#: Der Ausgabepuffer muss SectorCount * 2352 gross sein. -#: learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info -IOCTL_CDROM_RAW_READ = ctl_code( - FILE_DEVICE_CD_ROM, 0x000F, METHOD_OUT_DIRECT, FILE_READ_ACCESS) - -#: Groesse von CDROM_TOC: 4 Byte Kopf + 100 Eintraege a 8 Byte. -CDROM_TOC_BYTES = 4 + 100 * 8 diff --git a/src/rippy/drives/windows.py b/src/rippy/drives/windows.py deleted file mode 100644 index d3b1c5e..0000000 --- a/src/rippy/drives/windows.py +++ /dev/null @@ -1,684 +0,0 @@ -r"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`. - -Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl: - - auswerfen_versuchen(geraet) -> bool gibt False zurück, wirft nie - eject(geraet) -> None wirft OSError - -## Warum ctypes und nicht pywin32 - -pywin32 wäre eine zusätzliche Abhängigkeit von rund 20 MB, die in ein -PyInstaller-Paket mit hinein muss und bei jedem Python-Wechsel neu passen -will. Alles, was hier gebraucht wird, sind fünf Funktionen aus `kernel32.dll` — -die spricht `ctypes` direkt an, ohne irgendetwas mitzuliefern. - -## Die zwei Regeln des Ports gelten hier genauso - -**1. Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** Unter Linux wurde am -26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG -quittiert und nichts tut — MakeMKV verriegelt die Tür während des Rips und -entriegelt sie nicht wieder. Es gibt keinen Grund anzunehmen, dass Windows das -anders handhabt: `IOCTL_STORAGE_EJECT_MEDIA` meldet ebenfalls nur, dass der -Befehl angenommen wurde. Deshalb wird auch hier nachgesehen — der Beweis ist -`IOCTL_STORAGE_CHECK_VERIFY2`, das bei leerem Laufwerk `ERROR_NOT_READY` -liefert. - -**2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer.** Wenn sich das Gerät -nicht öffnen lässt oder ein Steuercode scheitert, ist die Antwort „ich weiß es -nicht" — nicht „da ist keine Disc". Genau diese Vermischung hat in v1 die -Listen im UI geleert. - -## Was hier geprüft ist und was nicht - -**Ohne Laufwerk geprüft:** die Steuercodes (`test_win_ioctl.py` rechnet sie -gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung, -die Typ-Zuordnung — alles über eine eingespritzte Win32-Nachbildung -(`test_windows.py`). Diese Tests laufen auf jeder Plattform. - -**An echter Hardware gemessen (28.08.2026, LG BU40N extern am Windows-PC, -Laufwerk G:, LEER):** - - GetDriveTypeW("G:\") -> 5 (DRIVE_CDROM) - list_optical_devices() -> ['\.\G:'] - CreateFileW(r"\.\G:") -> Handle 380 - CHECK_VERIFY2 bei leerem Laufwerk -> ERROR_NOT_READY (21) - drive_status() -> 1 (CDS_NO_DISC) - detect_disc_type() -> 'no_disc' - device_info()['status'] -> 'empty' - MEDIA_REMOVAL (entriegeln) -> Erfolg, 0,1 ms - EJECT_MEDIA -> Erfolg, 664,8 ms - -Die 664,8 ms sind der eigentliche Beleg, dass etwas passiert ist: Ein -abgelehnter Steuercode kommt in rund 2 ms zurück (siehe die Fehler oben). -Eine mechanische Bewegung dauert. - -**⚠️ DIESES LAUFWERK KANN DIE SCHUBLADE NICHT SELBST EINZIEHEN.** -`IOCTL_STORAGE_LOAD_MEDIA` antwortet mit `ERROR_INVALID_FUNCTION` (1) — bei -flachen und extern angeschlossenen Laufwerken ist das die Regel, nicht die -Ausnahme. Deshalb gibt es hier bewusst KEINE `einziehen()`-Funktion: Ein Knopf -„Schublade schließen", der auf der Hälfte aller Laufwerke stillschweigend -nichts tut, wäre genau die Sorte Placebo, die in Etappe 19 aufgeräumt wurde. -Die Konstante steht in `win_ioctl.py` — wer sie je benutzt, muss den Fehlschlag -sichtbar machen. - -**MIT EINGELEGTER DISC gemessen (28.08.2026, dasselbe Laufwerk, BD-50):** - - drive_status() -> 4 (CDS_DISC_OK), sofort - IOCTL_CDROM_DISK_TYPE -> Bytes 02 00 00 00 - = DATA_TRACK (0x2) - IOCTL_DISK_GET_LENGTH_INFO -> 48 149 364 736 Bytes - = 44,84 GiB - disc_status() -> 101 (CDS_DATA_1) - detect_disc_type() -> 'bluray' - device_info()['status'] -> 'ready' - device_info()['type'] -> 'bluray' - - auswerfen_versuchen() -> True, 2,41 s - drive_status() davor / danach -> 4 / 1 - -**Die letzte Zeile ist der eigentliche Beweis.** Der Auswurf wurde nicht am -Rückgabewert des Steuercodes festgemacht, sondern am ZUSTAND davor und -danach: Die Disc war drin (4) und ist danach draußen (1). Genau das, was in -v1 monatelang gefehlt hat — dort quittierte das ioctl Erfolg, die Schublade -blieb zu, und im Log stand „Disc ausgeworfen". - -Die 44,84 GiB prüfen nebenbei die Schwelle mit: Eine BD-50 liegt knapp unter -den 55 GiB, ab denen `cdrom.classify()` auf `uhd` geht — und wird korrekt als -`bluray` eingeordnet. - -**Damit gilt die Hardware-Schicht dieses Treibers als BEWIESEN**, nicht mehr -nur als gebaut. Offen bleibt allein die Ereignis-Erkennung -(`WM_DEVICECHANGE`) — die wird erst gebaut. -""" - -import sys - -from rippy.drives import win_ioctl as w -from rippy.drives.cdrom import ( - CDS_AUDIO, - CDS_DATA_1, - CDS_DISC_OK, - CDS_NO_DISC, - classify, -) - -# Wie lange nach dem Auswurf-Befehl auf die Schublade gewartet wird. -# Gleicher Wert wie unter Linux — ein Laufwerk braucht ein bis zwei Sekunden. -AUSWURF_WARTEN_SEKUNDEN = 5 - -# Ergebnisse von auswerfen_mit_grund — dieselben wie im Linux-Treiber, damit -# aufrufender Code nicht wissen muss, auf welcher Plattform er läuft. -AUSWURF_OK = "ok" -AUSWURF_KEIN_ZUGRIFF = "kein-zugriff" -AUSWURF_ABGELEHNT = "abgelehnt" -AUSWURF_BLEIBT_DRIN = "bleibt-drin" - - -class Win32Fehler(OSError): - """Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). - - ⚠️ Die Zuweisung MUSS nach `super().__init__()` stehen. Nachgemessen: - Ein zweiargumentiges `OSError.__init__(code, text)` setzt `winerror` - wieder auf `None` — ein davor gesetzter Wert ist danach fort. Genau - daran sind beim Bau dieser Datei vier Tests gescheitert: `_medium_da` - erkannte `ERROR_NOT_READY` nicht mehr, hielt jedes leere Laufwerk für - einen echten Fehler und meldete „unbekannt" statt „leer". - - `code` steht zusätzlich als eigener Name bereit — `winerror` gibt es nur - unter Windows, und dieser Treiber wird auch anderswo getestet. - """ - - def __init__(self, was: str, code: int): - super().__init__(code, f"{was} scheiterte (Win32-Fehler {code})") - self.code = code - self.winerror = code - - -class Win32: - """Die echten kernel32-Aufrufe. - - In Tests wird stattdessen eine Nachbildung eingesetzt — deshalb steckt - hier NUR die Anbindung und keine Entscheidung. Was der Treiber tut, steht - in den Funktionen darunter und ist damit ohne Windows prüfbar. - """ - - def __init__(self): - if not sys.platform.startswith("win"): - raise RuntimeError( - "Der Windows-Treiber laeuft nur unter Windows. Auf Linux ist " - "rippy.drives.linux zustaendig." - ) - import ctypes - from ctypes import wintypes - - 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: - maske = self._k32.GetLogicalDrives() - return [chr(ord("A") + i) for i in range(26) if maske & (1 << i)] - - def laufwerkstyp(self, buchstabe: str) -> int: - return self._k32.GetDriveTypeW(f"{buchstabe}:\\") - - # ── Gerät öffnen ──────────────────────────────────────────────────── - def oeffnen(self, geraetepfad: str): - handle = self._k32.CreateFileW( - geraetepfad, - w.GENERIC_READ, - w.FILE_SHARE_READ | w.FILE_SHARE_WRITE, - None, - w.OPEN_EXISTING, - 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 - - def schliessen(self, handle) -> None: - self._k32.CloseHandle(handle) - - # ── Steuercode senden ─────────────────────────────────────────────── - def steuern(self, handle, code: int, eingabe: bytes = b"", - ausgabe_bytes: int = 0) -> bytes: - """DeviceIoControl. Wirft Win32Fehler, gibt sonst die Antwort zurück.""" - ctypes = self._ctypes - ein_puffer = ctypes.create_string_buffer(eingabe) if eingabe else None - aus_puffer = ctypes.create_string_buffer(ausgabe_bytes) if ausgabe_bytes else None - zurueck = self._wintypes.DWORD(0) - erfolg = self._k32.DeviceIoControl( - handle, code, - ein_puffer, len(eingabe), - aus_puffer, ausgabe_bytes, - ctypes.byref(zurueck), None, - ) - if not erfolg: - raise Win32Fehler(f"DeviceIoControl({code:#x})", ctypes.get_last_error()) - 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() - - -# ── Laufwerke finden ──────────────────────────────────────────────────── -def list_optical_devices(api=None) -> list: - """Alle optischen Laufwerke — als Gerätepfade wie r"\\\\.\\D:". - - Kein Scan über alle 26 Buchstaben mit Öffnen-Versuch: `GetDriveTypeW` - beantwortet die Frage ohne jeden Zugriff auf das Gerät. Ein Öffnen-Versuch - auf einem leeren Laufwerk kann sonst mehrere Sekunden dauern (das Laufwerk - fährt hoch), und das mal 26. - """ - api = _api(api) - return sorted( - w.geraetepfad(b) for b in api.laufwerksbuchstaben() - if api.laufwerkstyp(b) == w.DRIVE_CDROM - ) - - -# ── Zustand ───────────────────────────────────────────────────────────── -def _medium_da(api, handle) -> bool: - """Liegt eine Disc ein? Wirft Win32Fehler bei allem außer NOT_READY.""" - try: - api.steuern(handle, w.IOCTL_STORAGE_CHECK_VERIFY2) - return True - except Win32Fehler as e: - if e.code in (w.ERROR_NOT_READY, w.ERROR_NO_MEDIA_IN_DRIVE): - return False - raise - - -def drive_status(geraet: str, api=None) -> int: - """Wie `linux.detection.drive_status`: CDS_DISC_OK oder CDS_NO_DISC. - - Wirft, wenn der Zustand NICHT feststellbar ist. Der Aufrufer muss - entscheiden, was er damit macht — ein stilles „leer" wäre eine falsche - Aussage über die Welt. - """ - api = _api(api) - handle = api.oeffnen(geraet) - try: - return CDS_DISC_OK if _medium_da(api, handle) else CDS_NO_DISC - finally: - api.schliessen(handle) - - -def disc_status(geraet: str, api=None) -> int: - """Audio- oder Datenträger — übersetzt in die CDS_*-Werte von cdrom.py. - - Damit passt das Ergebnis in dieselbe `classify()`, die der Linux-Treiber - benutzt: EINE Zuordnungslogik für beide Plattformen, und die ist getestet. - """ - api = _api(api) - handle = api.oeffnen(geraet) - try: - antwort = api.steuern(handle, w.IOCTL_CDROM_DISK_TYPE, ausgabe_bytes=4) - flags = int.from_bytes(antwort[:4], "little") if antwort else 0 - finally: - api.schliessen(handle) - if flags & w.CDROM_DISK_AUDIO_TRACK: - return CDS_AUDIO - if flags & w.CDROM_DISK_DATA_TRACK: - return CDS_DATA_1 - return 0 # unbekannt -> classify() sagt "unknown" - - -def disc_size_bytes(geraet: str, api=None) -> int: - """Größe des eingelegten Mediums — die Entsprechung von BLKGETSIZE64. - - Sie entscheidet zwischen DVD, Blu-ray und 4K-UHD (Schwellen in cdrom.py). - """ - api = _api(api) - handle = api.oeffnen(geraet) - try: - antwort = api.steuern(handle, w.IOCTL_DISK_GET_LENGTH_INFO, ausgabe_bytes=8) - return int.from_bytes(antwort[:8], "little") if antwort else 0 - finally: - api.schliessen(handle) - - -def detect_disc_type(geraet: str, api=None) -> str: - """cd | dvd | bluray | uhd | no_disc | unknown.""" - api = _api(api) - try: - if drive_status(geraet, api) != CDS_DISC_OK: - return "no_disc" - return classify(disc_status(geraet, api), disc_size_bytes(geraet, api)) - except OSError: - # „konnte nicht nachsehen" — ausdruecklich NICHT "no_disc". - return "unknown" - - -# ── Verriegeln ────────────────────────────────────────────────────────── -def verriegeln(geraet: str, an: bool, api=None) -> None: - """Tür verriegeln (True) oder entriegeln (False). - - `PREVENT_MEDIA_REMOVAL` ist eine Struktur mit genau einem BOOLEAN. - """ - api = _api(api) - handle = api.oeffnen(geraet) - try: - api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, - eingabe=b"\x01" if an else b"\x00") - finally: - api.schliessen(handle) - - -# ── Auswerfen ─────────────────────────────────────────────────────────── -def auswerfen_mit_grund(geraet: str, api=None, warten=None): - """Die gemeinsame Mechanik. Wirft NIE. - - Rückgabe `(grund, fehler)` wie im Linux-Treiber. Reihenfolge ist - dieselbe und aus demselben Grund: **entriegeln, auswerfen, nachsehen.** - """ - api = _api(api) - if warten is None: - import time - warten = time.sleep - - try: - handle = api.oeffnen(geraet) - except OSError as e: - return AUSWURF_KEIN_ZUGRIFF, e - - try: - # Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht — - # bei einem nicht verriegelten Laufwerk klappt er ohnehin. - try: - api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, eingabe=b"\x00") - except OSError: - pass - - try: - api.steuern(handle, w.IOCTL_STORAGE_EJECT_MEDIA) - except OSError as e: - return AUSWURF_ABGELEHNT, e - - # NACHSEHEN. Der Befehl oben meldet nur, dass er angenommen wurde. - for _ in range(AUSWURF_WARTEN_SEKUNDEN): - try: - if not _medium_da(api, handle): - return AUSWURF_OK, None - except OSError as e: - return AUSWURF_ABGELEHNT, e - warten(1) - return AUSWURF_BLEIBT_DRIN, None - finally: - try: - api.schliessen(handle) - except OSError: - pass - - -def auswerfen_versuchen(geraet: str, api=None, warten=None) -> bool: - """Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers.""" - grund, _ = auswerfen_mit_grund(geraet, api=api, warten=warten) - return grund == AUSWURF_OK - - -def eject(geraet: str, api=None) -> None: - """Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API.""" - grund, fehler = auswerfen_mit_grund(geraet, api=api) - if grund == AUSWURF_OK: - return - if fehler is not None: - raise fehler - if grund == AUSWURF_KEIN_ZUGRIFF: - raise OSError(f"Das Laufwerk {geraet} ließ sich nicht ansprechen.") - raise OSError( - "Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch " - "drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?" - ) - - -# ── Für das UI ────────────────────────────────────────────────────────── -def _text_bei(puffer: bytes, offset_pos: int) -> str: - """Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt. - - Reine Funktion, damit die Auswertung ohne Laufwerk prüfbar ist. Offset 0 - heißt „gibt es nicht" — das ist der dokumentierte Weg, wie ein - STORAGE_DEVICE_DESCRIPTOR ein fehlendes Feld meldet. - """ - if len(puffer) < offset_pos + 4: - return "" - offset = int.from_bytes(puffer[offset_pos:offset_pos + 4], "little") - if not offset or offset >= len(puffer): - return "" - ende = puffer.find(b"\x00", offset) - roh = puffer[offset:ende if ende >= 0 else len(puffer)] - return roh.decode("latin-1", errors="replace").strip() - - -def geraeteangaben(geraet: str, api=None) -> dict: - """Hersteller, Modell und Seriennummer des LAUFWERKS. - - ## Warum es das gibt (Commander-Befund 28.08.2026) - - Im UI stand: - - Modell unbekannt - Seriennummer – - - Der Linux-Treiber liest beides aus `/sys/class/block//device/`; der - Windows-Treiber lieferte schlicht leere Felder. Die Entsprechung ist - `IOCTL_STORAGE_QUERY_PROPERTY` — es antwortet mit einem - `STORAGE_DEVICE_DESCRIPTOR`, dessen Kopfdaten OFFSETS auf die - Zeichenketten dahinter enthalten. - - Wirft nicht: Ein Laufwerk ohne Angaben ist ärgerlich, aber kein Grund, - die ganze Geräteliste scheitern zu lassen. - """ - api = _api(api) - abfrage = (w.STORAGE_DEVICE_PROPERTY.to_bytes(4, "little") - + w.PROPERTY_STANDARD_QUERY.to_bytes(4, "little") - + b"\x00" * 4) - try: - handle = api.oeffnen(geraet) - except OSError: - return {} - try: - puffer = api.steuern(handle, w.IOCTL_STORAGE_QUERY_PROPERTY, - abfrage, 1024) - except OSError: - return {} - finally: - api.schliessen(handle) - - return { - "hersteller": _text_bei(puffer, w.SDD_VENDOR_ID_OFFSET), - "modell": _text_bei(puffer, w.SDD_PRODUCT_ID_OFFSET), - "fassung": _text_bei(puffer, w.SDD_PRODUCT_REVISION_OFFSET), - "seriennummer": _text_bei(puffer, w.SDD_SERIAL_NUMBER_OFFSET), - } - - -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 "" - - -#: 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. - - `status` kennt hier drei Werte, nicht zwei: `ready`, `empty` und - **`unknown`**. Der dritte ist Absicht — ein Laufwerk, das sich nicht - ansprechen lässt (belegt, gerade angesteckt, defekt), ist nicht leer. - """ - 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 as e: - disc_typ = "unknown" - grund = zugriffs_grund(e) - else: - status = "empty" - except OSError as e: - status = "unknown" - grund = zugriffs_grund(e) - - buchstabe = kennung(geraet) - angaben = _angaben_gemerkt(geraet, api) - modell = " ".join(t for t in (angaben.get("hersteller"), - angaben.get("modell")) if t) - return { - "id": buchstabe, - "name": f"Laufwerk {buchstabe}:", - "type": disc_typ, - "path": geraet, - "status": status, - "model": modell, - "serial": angaben.get("seriennummer", ""), - # Leer, solange alles geht. Sonst steht hier, WARUM „unknown" - # dasteht — siehe ZUGRIFFS_GRUENDE. - "grund": grund, - } - - -# Die Angaben eines Laufwerks aendern sich nicht, solange es dasselbe -# Laufwerk ist -- die Disc-Wache ruft `device_info` aber alle drei Sekunden. -# Ohne dieses Gedaechtnis waere das alle drei Sekunden ein CreateFileW plus -# DeviceIoControl auf ein Geraet, das gerade rippt. -_ANGABEN_SPEICHER: dict = {} - - -def _angaben_gemerkt(geraet: str, api=None) -> dict: - if geraet not in _ANGABEN_SPEICHER: - _ANGABEN_SPEICHER[geraet] = geraeteangaben(geraet, api) or {} - return _ANGABEN_SPEICHER[geraet] - -# ── Audio-CD (29.08.2026) ─────────────────────────────────────────────── -# -# Die LETZTE offene Luecke des Windows-Betriebs: `cdparanoia` und `abcde` -# sind Linux-Werkzeuge, es gab sie hier nie. Windows bietet stattdessen zwei -# Steuercodes an — die Rechnung dazu steht in `rippy.rip.audio_cd`, damit sie -# ohne Laufwerk pruefbar ist. - - -def audio_toc(geraet: str, api=None) -> list: - """Das Inhaltsverzeichnis einer Audio-CD — `[]`, wenn keine drin ist. - - Gibt die Spuren aus `audio_cd.toc_zerlegen()` zurueck. - """ - from rippy.rip import audio_cd - - api = _api(api) - handle = api.oeffnen(geraet) - try: - roh = api.steuern(handle, w.IOCTL_CDROM_READ_TOC, - ausgabe_bytes=w.CDROM_TOC_BYTES) - finally: - api.schliessen(handle) - return audio_cd.toc_zerlegen(roh) - - -def audio_spur_lesen(geraet: str, start: int, sektoren: int, schreiben, - fortschritt=None, api=None) -> int: - """Eine Audio-Spur sektorweise lesen. Gibt die gelesenen Bytes zurueck. - - `schreiben(bytes)` bekommt jeden Block — so wandert eine 47-MB-Spur nie - komplett in den Speicher. `fortschritt(anteil)` bekommt 0..1. - - ⚠️ `DiskOffset` zaehlt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352 - Bytes hat. Das ist dokumentiert und sieht falsch aus; wer hier 2352 - einsetzt, liest an der falschen Stelle. - """ - import struct - - from rippy.rip import audio_cd - - api = _api(api) - handle = api.oeffnen(geraet) - gelesen = 0 - try: - teile = audio_cd.bloecke(start, sektoren) - for i, (von, anzahl) in enumerate(teile): - eingabe = struct.pack(" „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — -> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt -> garnichts Rippy geht einfach auf." - -Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend -nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts -gefragt und erfuhr nichts — auch nicht, ob sein Rechner überhaupt kann, was -Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht, -seit wann. - -## Was hier passiert und was NICHT - -Dieses Modul enthält die **Prüfungen und die Entscheidungen** — keine -Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne -WebView2, ohne Fenster und ohne Installation prüfbar bleibt. - -## Die drei Sorten Befund - - ok geht - warnung geht, aber der Nutzer sollte es wissen - fehler geht NICHT — die Installation würde nichts nützen - -Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine -Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für -jede Sorte: - -* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine - reine Komprimier-Maschine — ein vorgesehener Betriebsfall (`install.sh` sagt - das seit Langem: „läuft trotzdem durch"). -* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann. -* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden. - -## Warum jede Prüfung sagt, was zu tun ist - -Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder -Eintrag ein `abhilfe`-Feld — leer nur dann, wenn wirklich nichts zu tun ist. -""" - -import os -import shutil -import socket -import sys - -from rippy import pfade - -OK = "ok" -WARNUNG = "warnung" -FEHLER = "fehler" - -# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist -# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der -# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft. -MINDESTPLATZ_MB = 500 - -STANDARD_PORT = 7788 - - -def _eintrag(name, zustand, text, abhilfe=""): - return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe} - - -# ── Die einzelnen Prüfungen ───────────────────────────────────────────── -def pruefe_windows(version=None) -> dict: - """Windows 10 oder neuer. Darunter gibt es kein WebView2. - - ⚠️ Ein eingespritzter `version`-Wert wird BEACHTET, auch auf Linux. Vorher - stand die Plattform-Prüfung davor, und damit war jeder eingespritzte Wert - wirkungslos: Auf dem Linux-Runner der Ampel gab `pruefe_windows(22631)` - ein FEHLER zurück, obwohl der Test genau eine gültige Build-Nummer - übergeben hatte. Ein Einspritzpunkt, der ignoriert wird, ist keiner. - """ - if version is None and os.name != "nt": - return _eintrag("Betriebssystem", FEHLER, "Kein Windows", - "Für Linux gibt es die Docker- und die Headless-Fassung.") - bau = version if version is not None else sys.getwindowsversion().build - if bau >= 22000: - return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau) - if bau >= 10240: - return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau) - return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau, - "Rippy braucht mindestens Windows 10.") - - -def pruefe_webview(version=None) -> dict: - """Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem. - - Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser - funktioniert, er ist nur weniger schön. - """ - if version is None: - from rippy import fenster - - version = fenster.webview2_version() - if version: - return _eintrag("Fenster (WebView2)", OK, version) - return _eintrag( - "Fenster (WebView2)", WARNUNG, "nicht gefunden", - 'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos ' - 'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").') - - -def pruefe_platz(ziel: str, frei_mb=None) -> dict: - """Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner.""" - if frei_mb is None: - pfad = pfade.naechster_vorhandener(ziel) - if not pfad: - return _eintrag("Speicherplatz", FEHLER, - "Der Ort ist nicht erreichbar: %s" % ziel, - "Einen anderen Zielordner wählen.") - try: - frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024) - except OSError as e: - return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e, - "Einen anderen Zielordner wählen.") - if frei_mb >= MINDESTPLATZ_MB: - return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024)) - return _eintrag("Speicherplatz", FEHLER, - "nur %d MB frei, gebraucht werden %d MB" - % (frei_mb, MINDESTPLATZ_MB), - "Platz schaffen oder einen anderen Zielordner wählen.") - - -def laeuft_erhoeht(erhoeht=None) -> bool: - """Läuft dieser Prozess mit Administratorrechten? - - Wichtig für die Diagnose, nicht als Anforderung: **Rippy braucht keine.** - Erhöht gestartet handelt es sich sogar Nachteile ein — siehe unten. - """ - if erhoeht is not None: - return bool(erhoeht) - if os.name != "nt": - return False - import ctypes - - try: - return bool(ctypes.WinDLL("shell32").IsUserAnAdmin()) - except (AttributeError, OSError): - return False - - -def netzlaufwerke(pruefer=None) -> list: - """Die eingebundenen Netzlaufwerke, wie DIESER Prozess sie sieht.""" - if pruefer is not None: - return list(pruefer) - if os.name != "nt": - return [] - import ctypes - import string - - DRIVE_REMOTE = 4 - try: - k = ctypes.WinDLL("kernel32") - maske = k.GetLogicalDrives() - return [b + ":" for i, b in enumerate(string.ascii_uppercase) - if (maske & (1 << i)) - and k.GetDriveTypeW(b + ":\\") == DRIVE_REMOTE] - except (AttributeError, OSError): - return [] - - -def pruefe_administrator(erhoeht=None, laufwerke=None) -> dict: - r"""Als Administrator gestartet? Dann fehlen die Netzlaufwerke. - - ## Der Befund des Commanders (28.08.2026) - - > „Wenn man das Tool als Admin startet, kann man im explorer kein - > netzlaufwerk wählen. Wenn man im explorer ein via windows eingebundenes - > netzlaufwerk einbindet dann kommt der fehler das man nicht installieren - > kann, weil die schreibrechte fehlen." - - Beides ist Windows-Verhalten, kein Rippy-Fehler — aber Rippy hat ihn - hineinlaufen lassen. Ein erhöhter Prozess bekommt ein ANDERES - Zugriffstoken, und eingebundene Netzlaufwerke hängen am Token der - Sitzung. `X:` existiert dort schlicht nicht. (Auf diesem Rechner - nachgesehen: `EnableLinkedConnections` ist nicht gesetzt — der Standard.) - - Der Teufelskreis, in den das führt: - - C:\Program Files\… verlangt Administrator - Administrator versteckt die Netzlaufwerke - Netzlaufwerk wirkt dann wie „keine Schreibrechte" - - **Rippy braucht überhaupt keine Administratorrechte.** Der Vorgabeordner - liegt unter `%LOCALAPPDATA%`, der Autostart unter `HKCU`. - """ - if not laeuft_erhoeht(erhoeht): - return _eintrag("Rechte", OK, "normal gestartet (so ist es richtig)") - gefunden = netzlaufwerke(laufwerke) - text = "als Administrator gestartet" - return _eintrag( - "Rechte", WARNUNG, text, - "Rippy braucht keine Administratorrechte — und erhöht sieht es die " - "eingebundenen Netzlaufwerke NICHT" + - (" (%s fehlen hier)" % ", ".join(gefunden) if gefunden else "") + - ". Für ein Netzlaufwerk den UNC-Pfad eintippen " - "(\\\\Server\\Freigabe) oder Rippy ohne Administrator starten.") - - -def laufwerk_fehlt(ziel: str, vorhanden=None) -> bool: - r"""Zeigt der Pfad auf einen Laufwerksbuchstaben, den es hier nicht gibt? - - `vorhanden` ist einspritzbar — und das ist kein Beiwerk: Die Frage - „existiert Laufwerk Q:" laesst sich auf dem Linux-Runner der Ampel gar - nicht beantworten, dort gibt es ueberhaupt keine Laufwerksbuchstaben. - Ohne Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit - genau dem passenden fehlenden Laufwerk pruefbar — also nirgends. - """ - laufwerk = pfade.laufwerk_von(ziel or "") - if len(laufwerk) != 2 or not laufwerk.endswith(":"): - return False # UNC-Pfad oder POSIX - if vorhanden is not None: - return not vorhanden - if os.name != "nt": - return False - return not os.path.isdir(laufwerk + "\\") - - -def pruefe_schreibrecht(ziel: str, probe=None, erhoeht=None, - laufwerk_da=None) -> dict: - """Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten. - - Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage - nach dem Nur-Lesen-Flag, nicht nach den Zugriffsrechten. Also wird eine - Datei angelegt und wieder gelöscht. - - Und der Grund wird BENANNT. „Schreibrechte fehlen" war die Meldung, die - der Commander bei einem Netzlaufwerk bekam — richtig im Wortlaut, - unbrauchbar als Auskunft: Das Laufwerk war für den erhöhten Prozess gar - nicht da. - """ - if probe is not None: - geklappt, grund = probe - else: - geklappt, grund = _schreibprobe(ziel) - if geklappt: - return _eintrag("Schreibrecht", OK, ziel) - - # ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel - # kennt `splitdrive` kein "Q:" und gab einen leeren String zurueck -- - # dieselbe Falle, fuer die es `rippy/pfade.py` gibt. Zum vierten Mal. - if laufwerk_fehlt(ziel, laufwerk_da): - laufwerk = pfade.laufwerk_von(ziel or "") - # Ein Laufwerksbuchstabe, den es in DIESEM Prozess nicht gibt. - zusatz = (" Als Administrator gestartete Programme sehen die " - "eingebundenen Netzlaufwerke der Sitzung nicht." - if laeuft_erhoeht(erhoeht) else "") - return _eintrag( - "Schreibrecht", FEHLER, - "%s — Laufwerk %s ist hier nicht vorhanden" % (ziel, laufwerk), - ("Statt des Laufwerksbuchstabens den UNC-Pfad eintragen " - "(\\\\Server\\Freigabe)." + zusatz)) - - if (ziel or "").startswith("\\\\") and laeuft_erhoeht(erhoeht): - # Auch Netz-ANMELDUNGEN hängen am Token der Sitzung, nicht nur die - # Laufwerksbuchstaben. Ein erhöhter Prozess kennt die Zugangsdaten - # zur Freigabe deshalb nicht und bekommt „Zugriff verweigert" — - # dieselbe Meldung wie bei einem echten Rechteproblem. - return _eintrag( - "Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), - "Als Administrator gestartete Programme kennen die Anmeldung an " - "der Freigabe nicht. Rippy ohne Administrator starten — dann " - "geht der Netzwerkpfad.") - - if _unter_programme(ziel): - return _eintrag( - "Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), - 'Der Ordner „Programme" verlangt Administratorrechte — und die ' - 'verstecken wiederum die Netzlaufwerke. Rippy braucht sie nicht: ' - 'Der Vorgabeordner unter „Benutzer" funktioniert ohne.') - - return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund), - 'Einen Ordner unterhalb von „Benutzer" wählen — dort ' - 'braucht Rippy keine Administratorrechte.') - - -def _unter_programme(ziel: str) -> bool: - """Liegt das Ziel unter „Programme"? (reine Funktion)""" - unten = (ziel or "").lower().replace("/", "\\") - return ("\\program files" in unten or unten.startswith("program files") - or "\\programme\\" in unten) - - -def _schreibprobe(ziel: str): - import tempfile - - try: - os.makedirs(ziel, exist_ok=True) - with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe", - delete=True): - pass - return True, "" - except OSError as e: - return False, e.strerror or str(e) - - -def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict: - """Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen.""" - if belegt is None: - belegt = _port_belegt(port) - if not belegt: - return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port) - # Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist - # eine Neuinstallation genau richtig. - return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port, - "Läuft Rippy schon? Sonst im Setup einen anderen Port " - "wählen.") - - -def _port_belegt(port: int) -> bool: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(0.5) - try: - s.bind(("127.0.0.1", port)) - return False - except OSError: - return True - - -def pruefe_laufwerk(laufwerke=None) -> dict: - """Ein optisches Laufwerk. WARNUNG, kein Fehler. - - Rippy ohne Laufwerk ist eine reine Komprimier-Maschine — ein vorgesehener - Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt. - """ - if laufwerke is None: - try: - from rippy import drives - - laufwerke = drives.treiber().list_optical_devices() - except Exception: # noqa: BLE001 - laufwerke = [] - if laufwerke: - return _eintrag("Optisches Laufwerk", OK, - ", ".join(str(x) for x in laufwerke)) - return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden", - "Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. " - "Ein USB-Brenner reicht.") - - -def pruefe_werkzeuge(bestand=None) -> list: - """MakeMKV und HandBrake — je ein eigener Eintrag. - - MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre - es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`). - """ - from rippy.tools import katalog - - bestand = katalog.bestand() if bestand is None else bestand - ergebnis = [] - for name, eintrag in sorted(bestand.items()): - titel = eintrag.get("titel", name) - if eintrag.get("da"): - ergebnis.append(_eintrag(titel, OK, - eintrag.get("version") or "gefunden")) - elif name == "handbrake": - ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert")) - else: - ergebnis.append(_eintrag( - titel, WARNUNG, "nicht gefunden", - "Das Setup lädt es von makemkv.com und startet den Installer " - "des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen.")) - return ergebnis - - -# ── Alles zusammen ────────────────────────────────────────────────────── -def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list: - """Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge.""" - return ([pruefe_windows(), pruefe_webview(), pruefe_administrator(), - pruefe_laufwerk()] - + pruefe_werkzeuge() - + [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)]) - - -def darf_installieren(pruefungen: list) -> bool: - """Blockiert NUR bei einem echten Fehler. (reine Funktion) - - Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler, - der nur warnt, ist eine Falle. - """ - return not any(p["zustand"] == FEHLER for p in pruefungen) - - -def zusammenfassung(pruefungen: list) -> str: - """Ein Satz über den Gesamtzustand. (reine Funktion)""" - fehler = [p for p in pruefungen if p["zustand"] == FEHLER] - warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG] - if fehler: - return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler) - if warnungen: - return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen) - return "Alles bereit." - - -def vorgaben() -> dict: - """Womit die Maske vorbelegt wird.""" - from rippy import betrieb - from rippy.platform import win_registry as reg - - return { - "ziel": reg.standard_installationsordner(), - "ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"), - # Der Arbeitsordner gehoert ins Setup, nicht erst in die Einstellungen - # (Commander-Wunsch 28.08.2026). Grund: Der Roh-Rip einer 4K-UHD ist - # bis zu 100 GB gross. Wer das erst NACH der ersten vollen Platte - # erfaehrt, hat es zu spaet erfahren — genau so lief am 25.07.2026 - # die VM-Platte voll. - "arbeit": betrieb.arbeits_vorgabe({}, container=False, - plattform_name="win32"), - "port": STANDARD_PORT, - "autostart": True, - "verknuepfungen": True, - "werkzeuge_holen": True, - } diff --git a/src/rippy/fenster.py b/src/rippy/fenster.py deleted file mode 100644 index d541323..0000000 --- a/src/rippy/fenster.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Rippy als echtes Fenster — nicht als Browser-Tab. - -## Die Frage des Commanders (28.08.2026) - -> „Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir -> kein Electron oder sowas und machen daraus einen echten Client?" - -Die erste Hälfte ist berechtigt: Ein Browser-Tab IST kein Client. Er hat eine -Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat kein -eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er -habe Rippy beendet. - -Die zweite Hälfte wäre die teure Antwort darauf. Gemessen, nicht geschätzt: - - Electron ~150-210 MB (Chromium + Node komplett mit) - pywebview + WebView2 8,0 MB (in die Bau-Umgebung gemessen) - -Electron brächte eine ZWEITE komplette Laufzeitumgebung neben Python mit — -und ein zweites Chromium neben dem, das auf diesem Rechner ohnehin schon -liegt. Denn Windows 11 liefert die WebView2-Laufzeit mit; auf diesem Rechner -am 28.08.2026 nachgesehen: 151.0.4129.107. - -Nachgemessen, was sie wirklich rendert — nicht angenommen: - - navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0 - fetch ja EventSource ja - CSS Grid ja Pfeilfunktionen ja - -Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also -denselben Renderer — nur ohne ihn ein zweites Mal mitzuschleppen. - -## Warum nicht MSHTML als Rückfall - -`pywebview` kann unter Windows auch auf den alten IE-Renderer zurückfallen. -Eine React-Oberfläche mit CSS Grid stellt der NICHT dar — sie erschiene leer -oder zerfallen. Ein leeres Fenster ist schlimmer als ein Browser-Tab. -Deshalb wird die Laufzeit VORHER geprüft, und ohne sie geht Rippy in den -Browser statt in ein Fenster, das nichts zeigen kann. - -## Warum das Fenster ein eigener Prozess ist - -Das Tray-Symbol (`pystray`) braucht unter Windows den HAUPT-Thread, sonst -bekommt es keine Fensternachrichten. Das Fenster (`pywebview`/WinForms) -braucht ihn genauso. Zwei Nachrichtenschleifen passen nicht in einen Thread. - -Also: `Rippy.exe --dienst` hält Server und Tray, `Rippy.exe --fenster` ist -der Client davor. Beide heißen im Taskmanager „Rippy.exe" — was der -Commander wollte —, und das Fenster lässt sich schließen, ohne den Dienst -mitzureißen. - -## Was hier NICHT passiert - -Kein Aufräumen von Ausnahmen zu einem stillen `False`. Geht das Fenster -nicht auf, muss der Aufrufer das ERFAHREN, damit er den Browser öffnen kann. -Ein stiller Fehlschlag wäre ein Doppelklick, nach dem nichts passiert. -""" - -import os -import sys - -TITEL = "Rippy" -BREITE = 1280 -HOEHE = 860 -MINDESTBREITE = 900 -MINDESTHOEHE = 600 - -# Die Evergreen-Laufzeit von WebView2 trägt sich unter dieser festen Kennung -# bei EdgeUpdate ein. Sie ist von Microsoft vergeben und ändert sich nicht. -WEBVIEW2_KENNUNG = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" - -# Die Laufzeit kann pro Maschine oder pro Nutzer liegen — beides ist gültig. -_SCHLUESSEL = ( - ("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients"), - ("HKLM", r"SOFTWARE\Microsoft\EdgeUpdate\Clients"), - ("HKCU", r"Software\Microsoft\EdgeUpdate\Clients"), -) - - -def echte_version(wert) -> str: - """Prüft einen `pv`-Wert aus der Registry (reine Funktion). - - EdgeUpdate trägt bei einer DEINSTALLIERTEN Laufzeit `0.0.0.0` ein, statt - den Schlüssel zu löschen. Wer das für vorhanden hält, baut ein Fenster, - das nie etwas anzeigt — und der Nutzer sieht einen weißen Kasten ohne - jede Erklärung. - """ - text = str(wert or "").strip() - return "" if not text or text == "0.0.0.0" else text - - -def webview2_version() -> str: - """Die Version der WebView2-Laufzeit. Leer, wenn sie nicht da ist. - - Die Version statt eines bloßen „ja/nein": Steht in einem Fehlerbericht - `151.0.4129.107`, ist die Lage klar. Steht dort `True`, fängt das Raten - an. - """ - if os.name != "nt": - return "" - import winreg - - wurzeln = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER} - for name, pfad in _SCHLUESSEL: - try: - with winreg.OpenKey(wurzeln[name], pfad + "\\" + WEBVIEW2_KENNUNG) as k: - wert, _ = winreg.QueryValueEx(k, "pv") - except OSError: - continue - version = echte_version(wert) - if version: - return version - return "" - - -def webview2_vorhanden() -> bool: - return bool(webview2_version()) - - -def bibliothek_vorhanden() -> bool: - """Ist `pywebview` überhaupt im Paket? (Ohne es zu starten.)""" - import importlib.util - - try: - return importlib.util.find_spec("webview") is not None - except (ImportError, ValueError): - return False - - -def verfuegbar() -> tuple: - """Kann ein echtes Fenster aufgehen? (ja/nein, Begründung) - - Gibt IMMER eine Begründung zurück, auch beim Ja. Wer nur `False` - zurückgibt, zwingt den Nächsten zum Nachforschen — und genau solche - stillen Fehlschläge sind in diesem Projekt schon zweimal teuer geworden. - """ - if os.name != "nt": - return False, "kein Windows" - if not bibliothek_vorhanden(): - return False, "pywebview ist nicht mit im Paket" - version = webview2_version() - if not version: - return False, "die WebView2-Laufzeit fehlt (ohne sie bliebe das Fenster leer)" - return True, "WebView2 " + version - - -def speicherort() -> str: - """Wo WebView2 seinen Zwischenspeicher ablegt. - - Ohne eigene Angabe landet er im TEMP — und ist bei jedem Aufräumen weg. - Neben der Datenbank gehört er zu Rippy und verschwindet beim - Deinstallieren mit. - """ - basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - ordner = os.path.join(basis, "Rippy", "fenster") - try: - os.makedirs(ordner, exist_ok=True) - except OSError: - return "" - return ordner - - -def helfer_skript(speicherort: str) -> str: - """Das PowerShell-Skript, das NUR unsere WebView2-Helfer beendet. - - Reine Funktion, damit der Filter prüfbar ist, ohne etwas zu beenden. - - ## Warum das nötig ist (gemessen 28.08.2026) - - Beim Deinstallieren blieben **148 Dateien** liegen, alle unter - `fenster\\EBWebView\\`. Der Grund: WebView2 startet eigene Prozesse - (`msedgewebview2.exe` — Renderer, GPU, Netz, Crashpad). Stirbt Rippy, - laufen sie als Waisen weiter und halten den Zwischenspeicher offen. - `shutil.rmtree` scheiterte, der Fehler wurde verschluckt, und der - Deinstallierer meldete trotzdem „Rippy wurde entfernt." - - ## Warum über den Speicherort gefiltert wird - - `msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu beenden, - wäre ein Übergriff — der Nutzer verlöre die Fenster fremder Anwendungen. - Jeder Helfer trägt seinen `--user-data-dir` in der Befehlszeile; nur wer - auf UNSEREN Ordner zeigt, gehört uns. - """ - ort = (speicherort or "").replace("'", "''") - return ( - "Get-CimInstance Win32_Process -Filter \"Name='msedgewebview2.exe'\" " - "| Where-Object { $_.CommandLine -like '*%s*' } " - "| ForEach-Object { Stop-Process -Id $_.ProcessId -Force " - "-ErrorAction SilentlyContinue }" % ort - ) - - -def helfer_beenden(speicherort: str = None) -> bool: - """Beendet die WebView2-Helfer, die zu UNSEREM Speicherort gehören. - - True, wenn der Versuch durchlief. Wirft nicht — ein Deinstallieren darf - daran nicht scheitern. - """ - if os.name != "nt": - return False - import subprocess - - ort = speicherort or speicherort_lesen() - if not ort: - return False - from rippy.platform.winlauf import OHNE_FENSTER - - try: - ergebnis = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", - helfer_skript(ort)], - capture_output=True, text=True, timeout=60, - creationflags=OHNE_FENSTER) - except (OSError, subprocess.SubprocessError): - return False - return ergebnis.returncode == 0 - - -def speicherort_lesen() -> str: - """Der Speicherort OHNE ihn anzulegen — fürs Aufräumen.""" - basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - return os.path.join(basis, "Rippy", "fenster") - - -def oeffnen(adresse: str, titel: str = TITEL, breite: int = BREITE, - hoehe: int = HOEHE) -> None: - """Öffnet das Fenster und kehrt erst zurück, wenn es geschlossen wird. - - Wirft, wenn es nicht geht — der Aufrufer entscheidet dann über den - Browser. Diese Funktion trifft die Entscheidung NICHT selbst, weil sie - sie nicht mitteilen könnte: Sie läuft, bis das Fenster zu ist. - """ - import webview - - webview.create_window( - titel, adresse, - width=breite, height=hoehe, - min_size=(MINDESTBREITE, MINDESTHOEHE), - text_select=True, # sonst ließe sich kein Log-Text kopieren - confirm_close=False, # der Dienst läuft weiter; nichts geht verloren - ) - # gui="edgechromium" erzwingt WebView2 und verbietet den Rückfall auf - # MSHTML. Lieber ein Fehler, den der Aufrufer sieht, als ein Fenster, - # das die Oberfläche nicht darstellen kann. - webview.start(gui="edgechromium", private_mode=False, - storage_path=speicherort()) - - -def oeffnen_oder_browser(adresse: str, titel: str = TITEL) -> str: - """Fenster, sonst Browser. Gibt zurück, was WIRKLICH passiert ist. - - Der Rückgabewert ist `"fenster"`, `"browser"` oder `"nichts"` — keine - Erfolgsmeldung. Der Aufrufer soll dem Nutzer sagen können, was er sieht, - statt zu behaupten, es sei alles gut gegangen. - """ - geht, grund = verfuegbar() - if geht: - try: - oeffnen(adresse, titel) - return "fenster" - except Exception as e: # noqa: BLE001 - # Bewusst breit: Was WinForms und die CLR beim Start werfen - # können, ist nicht abschließend bekannt — und keine dieser - # Ausnahmen darf den Doppelklick ins Leere laufen lassen. Sie - # wird gemeldet, nicht verschluckt. - melden("Das Fenster ging nicht auf (%s: %s) — es öffnet der Browser." - % (type(e).__name__, e)) - else: - melden("Fenster nicht möglich (%s) — es öffnet der Browser." % grund) - - import webbrowser - - return "browser" if webbrowser.open(adresse) else "nichts" - - -def melden(text: str) -> None: - """Ausgabe, die auch ohne Konsole nicht abstürzt (siehe windows_app).""" - try: - sys.stdout.write(text + "\n") - sys.stdout.flush() - except Exception: # noqa: BLE001 - pass diff --git a/src/rippy/platform/test_verknuepfungen.py b/src/rippy/platform/test_verknuepfungen.py deleted file mode 100644 index 50a7d32..0000000 --- a/src/rippy/platform/test_verknuepfungen.py +++ /dev/null @@ -1,129 +0,0 @@ -"""Verknuepfungen: der Ordner, das Skript, das Zurueckraeumen. - -Das PowerShell-Skript wird als reine Funktion gebaut und geprueft — so faellt -ein Zitier-Fehler auf, ohne dass eine .lnk entstehen muss. Die Tests, die -wirklich eine anlegen, laufen nur unter Windows und raeumen hinter sich auf. - -Hinweis zu den Pfaden: Windows-Pfade stehen hier in ROHEN Zeichenketten oder -werden aus `B` (dem Backslash) zusammengesetzt. In normalen Zeichenketten -waeren `C:\\D` und `C:\\Users` ungueltige Escape-Sequenzen — heute eine -DeprecationWarning, in einer kuenftigen Python-Version ein harter Fehler. -""" - -import os - -import pytest - -from rippy.platform import verknuepfungen as vk - -nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") - -B = chr(92) # Backslash — haelt jede Escape-Falle aus den Tests raus - - -# ── Das Skript ────────────────────────────────────────────────────────── -def test_skript_setzt_ziel_und_argumente(): - lnk = "C:" + B + "D" + B + "Rippy.lnk" - exe = "C:" + B + "R" + B + "Rippy.exe" - s = vk.skript(lnk, exe, "--oeffnen") - assert "CreateShortcut('" + lnk + "')" in s - assert "$s.TargetPath = '" + exe + "'" in s - assert "$s.Arguments = '--oeffnen'" in s - assert s.rstrip().endswith("$s.Save()") - - -def test_apostroph_im_pfad_bricht_das_skript_nicht(): - """In PowerShell wird ein Apostroph innerhalb einfacher Anfuehrungszeichen - durch VERDOPPELN maskiert. Ohne das bricht ein Pfad mit Apostroph das - Skript mitten entzwei — und der Fehler sieht aus wie ein Syntaxfehler in - Rippy statt wie ein Pfad, der ein Sonderzeichen enthaelt.""" - s = vk.skript("C:" + B + "Users" + B + "O'Brien" + B + "Rippy.lnk", - "C:" + B + "R" + B + "Rippy.exe") - assert "O''Brien" in s - # Gerade Zahl von Anfuehrungszeichen — sonst ist ein String offen. - assert s.count("'") % 2 == 0 - - -def test_ohne_arbeitsordner_wird_der_ordner_des_ziels_genommen(): - ordner = "C:" + B + "Programme" + B + "Rippy" - s = vk.skript("C:" + B + "D" + B + "R.lnk", ordner + B + "Rippy.exe") - assert "$s.WorkingDirectory = '" + ordner + "'" in s - - -def test_leere_angaben_erzeugen_keine_leeren_zeilen(): - """Ein `$s.IconLocation = ''` wuerde das Symbol LOESCHEN statt es - unveraendert zu lassen.""" - s = vk.skript("C:" + B + "D" + B + "R.lnk", "C:" + B + "R" + B + "R.exe") - assert "IconLocation" not in s - assert "Description" not in s - - -# ── Die Orte ──────────────────────────────────────────────────────────── -@nur_windows -def test_desktop_und_startmenue_kommen_aus_der_registry(): - """Ein fest verdrahtetes %USERPROFILE%/Desktop waere eine ANNAHME. Wer - OneDrive benutzt oder seine Ordner verschoben hat, dessen Desktop liegt - woanders — die Verknuepfung landete dann in einem Ordner, den niemand - sieht, und der Nutzer suchte ein Symbol, das es nirgends gibt.""" - desktop = vk.shell_ordner("Desktop") - programme = vk.shell_ordner("Programs") - assert desktop and os.path.isdir(desktop) - assert programme and os.path.isdir(programme) - assert vk.desktop_pfad().endswith("Rippy.lnk") - assert "Start Menu" in vk.startmenue_pfad() - - -def test_unbekannter_ordner_ergibt_leer_statt_zu_raten(): - assert vk.shell_ordner("GibtEsNicht") == "" - - -# ── Wirklich anlegen ──────────────────────────────────────────────────── -@nur_windows -def test_verknuepfung_entsteht_wirklich(tmp_path): - """Nachsehen statt glauben: PowerShell meldet auch dann Erfolg, wenn - Save() still scheitert. `anlegen()` prueft deshalb die Datei.""" - exe = tmp_path / "Rippy.exe" - exe.write_bytes(b"x" * 512) - lnk = str(tmp_path / "Rippy.lnk") - - assert vk.anlegen(lnk, str(exe), "--oeffnen", beschreibung="Test") is True - assert os.path.isfile(lnk) - assert os.path.getsize(lnk) > 0 - - assert vk.entfernen(lnk) is True - assert not os.path.exists(lnk) - - -def _freier_laufwerksbuchstabe() -> str: - """Ein Buchstabe, den es auf DIESER Maschine wirklich nicht gibt. - - Der erste Anlauf nahm fest `Z:` — auf dem Commander-PC ist das ein - NETZLAUFWERK (X, Y und Z sind dort belegt). Der Test legte damit - froehlich eine Verknuepfung auf einer Freigabe an und schlug fehl, weil - er einen Misserfolg erwartete. Ein fest verdrahteter Laufwerksbuchstabe - ist eine Annahme ueber eine fremde Maschine. - """ - import ctypes - - maske = ctypes.WinDLL("kernel32").GetLogicalDrives() - for i in range(25, -1, -1): # von Z rueckwaerts - if not (maske & (1 << i)): - return chr(ord("A") + i) - return "" - - -@nur_windows -def test_anlegen_auf_unmoeglichem_pfad_wirft_nicht(tmp_path): - """Eine fehlende Verknuepfung ist aergerlich, aber kein Grund, eine sonst - geglueckte Installation scheitern zu lassen.""" - buchstabe = _freier_laufwerksbuchstabe() - if not buchstabe: - pytest.skip("Alle 26 Laufwerksbuchstaben sind belegt") - exe = tmp_path / "Rippy.exe" - exe.write_bytes(b"x") - assert vk.anlegen(buchstabe + ":" + B + "gibt-es-nicht" + B + "R.lnk", - str(exe)) is False - - -def test_entfernen_auf_nichts_ist_kein_fehler(): - assert vk.entfernen("C:" + B + "gibt-es-nicht" + B + "R.lnk") is False diff --git a/src/rippy/platform/test_win_registry.py b/src/rippy/platform/test_win_registry.py deleted file mode 100644 index a886894..0000000 --- a/src/rippy/platform/test_win_registry.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Die Werte für „Programme und Features" — geprüft, bevor sie geschrieben werden. - -Die reinen Funktionen laufen auf jeder Plattform. Die Tests, die wirklich in -die Registry schreiben, laufen nur unter Windows und räumen hinter sich auf — -sie benutzen einen eigenen Schlüsselnamen, damit eine echte Installation -niemals angefasst wird. -""" - -import os - -import pytest - -from rippy.platform import win_registry as reg - -TEST_SCHLUESSEL = "RippyTestEintrag" # NIE der echte -nur_windows = pytest.mark.skipif(os.name != "nt", reason="Registry gibt es nur unter Windows") - - -# ── Die Werte selbst ──────────────────────────────────────────────────── -def test_pflichtwerte_sind_gesetzt(): - """Windows zeigt einen Eintrag OHNE DisplayName gar nicht an, und ohne - UninstallString ist der Deinstallieren-Knopf tot.""" - werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\RippySetup.exe", "2.0.0") - assert werte["DisplayName"] - assert werte["UninstallString"] - - -def test_uninstallstring_ist_gequotet(): - """DER klassische Fehler. Ohne Anfuehrungszeichen liest Windows - „C:\\Program Files\\Rippy\\x.exe" als „C:\\Program" mit Argumenten — - beim Deinstallieren passiert dann nichts oder etwas Falsches. - """ - werte = reg.eintrag_werte( - r"C:\Program Files\Rippy", r"C:\Program Files\Rippy\RippySetup.exe", "2.0.0") - assert werte["UninstallString"].startswith('"C:\\Program Files\\Rippy\\RippySetup.exe"') - assert werte["UninstallString"].endswith("--deinstallieren") - - -def test_stille_deinstallation_ist_vorgesehen(): - """Windows benutzt QuietUninstallString bei automatisierten Entfernungen. - Fehlt er, faellt Windows auf den lauten zurueck und ein Dialog blockiert - eine unbeaufsichtigte Abmeldung.""" - werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0") - assert "--still" in werte["QuietUninstallString"] - - -def test_reparieren_und_aendern_sind_ausgeblendet(): - """Rippy kann beides nicht. Knoepfe, die ins Leere fuehren, sind - dieselbe Sorte Placebo wie ein Fortschrittsbalken ohne Fortschritt.""" - werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0") - assert werte["NoModify"] == 1 - assert werte["NoRepair"] == 1 - - -def test_groesse_wird_in_kib_erwartet(): - """EstimatedSize ist in KiB. Wer Bytes eintraegt, meldet in „Programme und - Features" das Tausendfache — aus 90 MB werden 90 GB.""" - werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0", groesse_kib=92_160) - assert werte["EstimatedSize"] == 92_160 # = 90 MiB - - -def test_ohne_icon_kein_leerer_eintrag(): - """Ein leerer DisplayIcon laesst Windows das Standard-Symbol zeigen — - aber ein Wert, der da ist und ins Leere zeigt, macht ein kaputtes Bild.""" - assert "DisplayIcon" not in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0") - assert "DisplayIcon" in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0", icon=r"C:\R\r.ico") - - -def test_uninstall_pfad_liegt_unter_dem_richtigen_schluessel(): - pfad = reg.uninstall_pfad("Rippy") - assert pfad.startswith(r"Software\Microsoft\Windows\CurrentVersion\Uninstall") - assert pfad.endswith(r"\Rippy") - - -def test_installationsordner_braucht_keine_adminrechte(): - """Program Files waere gewohnter, verlangt aber bei JEDER Installation - und jedem Update eine UAC-Abfrage.""" - ordner = reg.standard_installationsordner() - assert "Rippy" in ordner - if os.name == "nt": - assert "Program Files" not in ordner - - -def test_ordnergroesse_zaehlt_wirklich(tmp_path): - (tmp_path / "a.bin").write_bytes(b"x" * 4096) - unter = tmp_path / "unter" - unter.mkdir() - (unter / "b.bin").write_bytes(b"y" * 4096) - assert reg.ordnergroesse_kib(str(tmp_path)) == 8 - - -# ── Wirklich schreiben (nur Windows) ──────────────────────────────────── -@nur_windows -def test_eintrag_landet_wirklich_in_der_registry(tmp_path): - """Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist - kein Beweis. Genau diese Lehre steht in AGENTS.md.""" - ordner = str(tmp_path) - (tmp_path / "RippySetup.exe").write_bytes(b"x" * 1024) - deinstaller = str(tmp_path / "RippySetup.exe") - try: - reg.eintragen(ordner, deinstaller, "2.0.0-test", schluessel=TEST_SCHLUESSEL) - assert reg.ist_eingetragen(TEST_SCHLUESSEL) is True - - gelesen = reg.gelesene_werte(TEST_SCHLUESSEL) - assert gelesen["DisplayName"] == "Rippy" - assert gelesen["DisplayVersion"] == "2.0.0-test" - assert gelesen["UninstallString"].startswith('"' + deinstaller + '"') - assert gelesen["NoModify"] == 1 - finally: - reg.austragen(TEST_SCHLUESSEL) - assert reg.ist_eingetragen(TEST_SCHLUESSEL) is False - - -@nur_windows -def test_austragen_auf_nichts_ist_kein_fehler(): - assert reg.austragen("RippyGibtEsNicht") is False - - -@nur_windows -def test_autostart_setzen_lesen_entfernen(tmp_path): - name = "RippyTestAutostart" - befehl = '"' + str(tmp_path / "Rippy.exe") + '"' - try: - reg.autostart_setzen(befehl, name=name) - assert reg.autostart_wert(name) == befehl - finally: - reg.autostart_entfernen(name) - assert reg.autostart_wert(name) == "" diff --git a/src/rippy/platform/test_winlauf.py b/src/rippy/platform/test_winlauf.py index 19a7b18..6e2121c 100644 --- a/src/rippy/platform/test_winlauf.py +++ b/src/rippy/platform/test_winlauf.py @@ -161,26 +161,6 @@ def test_die_elternumgebung_wird_nicht_veraendert(): assert eltern == {"_MEIPASS2": "/tmp/_MEI123"} -def test_beide_startwege_reichen_den_zeiger_nicht_weiter(): - """Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=` - setzen. Sonst kommen die Reste zurueck, und zwar still.""" - import re - - quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py") - with open(os.path.abspath(quelle), encoding="utf-8") as f: - text = f.read() - # Seit dem 29.08.2026 gehen beide Wege ueber `eigenstaendig_starten` — - # der Dienst und das Fenster muessen sich aus Rippys Arbeitsgruppe - # herausloesen (winlauf.kinder_an_die_leine). Am `env=` aendert das nichts, - # und genau darum geht es hier weiterhin. - stellen = [m.start() for m in re.finditer( - r"(subprocess\.Popen|eigenstaendig_starten)\(\s*\[programm", text)] - assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen) - for stelle in stellen: - aufruf = text[stelle:stelle + 300] - assert "env=umgebung_ohne_bundle()" in aufruf, ( - "Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120]) - def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path): """Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren. diff --git a/src/rippy/platform/test_winwaisen.py b/src/rippy/platform/test_winwaisen.py index c40ecfb..2650d52 100644 --- a/src/rippy/platform/test_winwaisen.py +++ b/src/rippy/platform/test_winwaisen.py @@ -144,26 +144,6 @@ def test_die_struktur_hat_die_groesse_die_windows_erwartet(): assert ctypes.sizeof(winlauf._ERWEITERTE_GRENZEN()) == 144 -def test_die_drei_dauerlaeufer_loesen_sich_heraus(): - """Dienst, Fenster und Aufraeum-Skript MUESSEN Rippy ueberleben. - - Wuerden sie in der Arbeitsgruppe haengen, wuerde die Deinstallation ihr - eigenes Aufraeum-Skript umbringen und der Dienst mit seinem Starter sterben. - """ - import inspect - - from rippy import windows_app - - for funktion in (windows_app.starte_hintergrund, # der Dienst - windows_app.starte_fensterprozess, # das Fenster - windows_app.selbst_loeschen): # das Aufraeum-Skript - quelle = inspect.getsource(funktion) - assert "eigenstaendig_starten" in quelle, ( - f"{funktion.__name__} startet noch direkt — der Prozess wuerde" - " mit Rippy sterben") - assert "subprocess.Popen" not in quelle, ( - f"{funktion.__name__} hat noch einen direkten Start daneben") - def test_der_rueckfall_ohne_die_fahne_ist_vorhanden(): """Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis, diff --git a/src/rippy/platform/verknuepfungen.py b/src/rippy/platform/verknuepfungen.py deleted file mode 100644 index 9317855..0000000 --- a/src/rippy/platform/verknuepfungen.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Verknüpfungen auf dem Desktop und im Startmenü. - -## Warum über PowerShell und nicht über pywin32 - -Eine `.lnk` ist keine Textdatei, sondern ein binäres Shell-Objekt. Sie von -Hand zu schreiben ist Bastelei; `pywin32` könnte es, wäre aber eine -20-MB-Abhängigkeit für vier Zeilen. Windows bringt die Fähigkeit selbst mit: -`WScript.Shell` ist seit Jahrzehnten da und braucht keine Installation. - -Dieses Projekt geht den Weg schon einmal — der alte Worker-Installer legt -seine Verknüpfungen ebenfalls „nativ via PowerShell (kein win32com mehr -nötig)" an. - -## Warum die Ordner aus der Registry kommen - -`%USERPROFILE%\\Desktop` ist eine ANNAHME. Wer OneDrive benutzt oder seine -Ordner verschoben hat, dessen Desktop liegt woanders — die Verknüpfung -landete dann in einem Ordner, den niemand sieht, und der Nutzer suchte ein -Symbol, das es nirgends gibt. - -Die Wahrheit steht unter `Shell Folders` in der Registry. Auf diesem Rechner -am 28.08.2026 nachgesehen: - - Desktop C:\\Users\\…\\Desktop - Programs C:\\Users\\…\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs - -## Worauf die Verknüpfung zeigt - -Nicht auf `--dienst`. Ein Doppelklick auf ein Desktop-Symbol soll Rippy -ÖFFNEN — und dabei ist es dem Nutzer egal, ob der Dienst schon läuft. Die -Verknüpfung ruft deshalb `Rippy.exe --oeffnen`: Läuft der Dienst, geht nur -der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das -beim zweiten Klick einen zweiten Server startet, wäre eine Falle. -""" - -import os -import subprocess - -from rippy import pfade - - -def shell_ordner(name: str) -> str: - """Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs"). - - Aus der Registry, nicht geraten. Leerer String, wenn er nicht zu - ermitteln ist — dann wird eben keine Verknüpfung angelegt, statt eine - an einer erfundenen Stelle. - """ - if os.name != "nt": - return "" - import winreg - - try: - with winreg.OpenKey( - winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders", - ) as k: - wert, _ = winreg.QueryValueEx(k, name) - return os.path.expandvars(str(wert)) - except OSError: - return "" - - -def _ps_text(wert: str) -> str: - """Text für ein einfach gequotetes PowerShell-Argument. - - In PowerShell wird ein Apostroph innerhalb einfacher Anführungszeichen - durch VERDOPPELN maskiert. Ohne das bricht ein Pfad wie - `C:\\Users\\O'Brien\\…` das Skript mitten entzwei — und der Fehler sieht - aus wie ein Syntaxfehler in Rippy. - """ - return (wert or "").replace("'", "''") - - -def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "", - beschreibung: str = "", arbeitsordner: str = "") -> str: - """Das PowerShell-Skript, das die Verknüpfung anlegt (reine Funktion).""" - zeilen = [ - "$s = (New-Object -ComObject WScript.Shell).CreateShortcut('%s')" % _ps_text(lnk), - "$s.TargetPath = '%s'" % _ps_text(ziel), - ] - if argumente: - zeilen.append("$s.Arguments = '%s'" % _ps_text(argumente)) - if icon: - zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon)) - if beschreibung: - zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung)) - # ntpath, nicht os.path: Eine .lnk zeigt IMMER auf einen - # Windows-Pfad — auch wenn dieser Code gerade auf dem Linux-Runner - # der Ampel laeuft. `os.path.dirname` gab dort fuer - # C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der - # Test schlug nur dort fehl. - zeilen.append("$s.WorkingDirectory = '%s'" - % _ps_text(arbeitsordner or pfade.ordner_von(ziel))) - zeilen.append("$s.Save()") - return "; ".join(zeilen) - - -def anlegen(lnk: str, ziel: str, argumente: str = "", icon: str = "", - beschreibung: str = "", arbeitsordner: str = "") -> bool: - """Legt eine Verknüpfung an. False, wenn es nicht geklappt hat. - - Wirft nicht: Eine fehlende Verknüpfung ist ärgerlich, aber kein Grund, - eine sonst geglückte Installation scheitern zu lassen. Der Aufrufer - sagt es dem Nutzer. - """ - if os.name != "nt": - return False - from rippy.platform.winlauf import OHNE_FENSTER - - try: - # os.makedirs MUSS mit in den try: Auf einem Laufwerk, das es nicht - # gibt, wirft es — und dann waere aus "Verknuepfung konnte nicht - # angelegt werden" ein Abbruch der ganzen Installation geworden. - os.makedirs(os.path.dirname(lnk), exist_ok=True) - ergebnis = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", - skript(lnk, ziel, argumente, icon, beschreibung, arbeitsordner)], - capture_output=True, text=True, timeout=60, - creationflags=OHNE_FENSTER, - ) - except (OSError, subprocess.SubprocessError): - return False - # Nachsehen statt dem Rueckgabewert glauben: PowerShell meldet auch dann - # Erfolg, wenn Save() still scheitert. - return ergebnis.returncode == 0 and os.path.isfile(lnk) - - -def entfernen(lnk: str) -> bool: - try: - os.remove(lnk) - return True - except OSError: - return False - - -# ── Die beiden Orte ───────────────────────────────────────────────────── -def desktop_pfad(name: str = "Rippy") -> str: - ordner = shell_ordner("Desktop") - return os.path.join(ordner, name + ".lnk") if ordner else "" - - -def startmenue_pfad(name: str = "Rippy") -> str: - ordner = shell_ordner("Programs") - return os.path.join(ordner, name + ".lnk") if ordner else "" - - -def alle_anlegen(programm: str, icon: str = "", desktop: bool = True, - startmenue: bool = True) -> dict: - """Legt beide Verknüpfungen an. Gibt zurück, was WIRKLICH entstanden ist. - - Der Rückgabewert ist die Liste der angelegten Dateien — nicht eine - Erfolgsmeldung. So kann der Installer sagen, was er getan hat, statt - zu behaupten, es sei alles gut gegangen. - """ - gemacht = {} - ziele = [] - if desktop: - ziele.append(("desktop", desktop_pfad())) - if startmenue: - ziele.append(("startmenue", startmenue_pfad())) - - for schluessel, lnk in ziele: - if not lnk: - continue - if anlegen(lnk, programm, argumente="--oeffnen", icon=icon or programm, - beschreibung="Rippy öffnen — automatisches Ripping"): - gemacht[schluessel] = lnk - return gemacht - - -def alle_entfernen() -> list: - """Nimmt beide Verknüpfungen zurück. Gibt zurück, was entfernt wurde.""" - entfernt = [] - for lnk in (desktop_pfad(), startmenue_pfad()): - if lnk and os.path.isfile(lnk) and entfernen(lnk): - entfernt.append(lnk) - return entfernt diff --git a/src/rippy/platform/win_registry.py b/src/rippy/platform/win_registry.py deleted file mode 100644 index bed74f8..0000000 --- a/src/rippy/platform/win_registry.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Windows-Registrierung: „Programme und Features" und Autostart. - -## Wie ein Programm in „Programme und Features" landet - -Nicht durch das Kopieren von Dateien. Windows liest diese Liste ausschließlich -aus der Registry, unter: - - HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\ - HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\ - -HKCU ist die Installation für DIESEN Benutzer, HKLM die für alle — letztere -braucht Administratorrechte. Rippy nimmt HKCU: Der Eintrag erscheint genauso in -„Apps & Features", aber die Installation läuft ohne UAC-Abfrage durch. - -Pflicht sind genau zwei Werte: - - DisplayName was in der Liste steht - UninstallString was Windows startet, wenn man „Deinstallieren" klickt - -Alles andere ist Kür, aber es macht den Unterschied zwischen einem -gepflegten Eintrag und einem, der aussieht wie Schadsoftware: -`DisplayVersion`, `Publisher`, `InstallLocation`, `DisplayIcon`, -`EstimatedSize` (in KiB!), `NoModify`, `NoRepair`. - -## Autostart - - HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run - -Bewusst HKCU und nicht der `Startup`-Ordner: Ein Eintrag in Run ist mit einem -Registry-Zugriff wieder weg, eine Verknüpfung im Startup-Ordner muss man -suchen. Und bewusst NICHT als Windows-Dienst — der bräuchte Administrator­ -rechte bei jeder Installation und jedem Update. - -## Warum die Werte hier getrennt gebaut werden - -`eintrag_werte()` und `uninstall_pfad()` sind reine Funktionen. Damit lässt -sich prüfen, dass die Pflichtwerte gesetzt sind und der Deinstallations-Befehl -richtig gequotet ist — ohne in die Registry zu schreiben. Ein falsch -gequoteter `UninstallString` ist ein klassischer Fehler: Steht der Pfad mit -Leerzeichen ohne Anführungszeichen drin, startet Windows beim Deinstallieren -das falsche Programm oder gar nichts. -""" - -import os -import sys - -# Der Schlüsselname unter Uninstall. Muss über Versionen hinweg GLEICH bleiben — -# ändert er sich, entsteht bei einem Update ein zweiter Eintrag, und der alte -# bleibt als Leiche stehen. -# ## Warum der Name ueberschreibbar sein MUSS (28.08.2026) -# -# Er stand als Vorgabewert direkt in jeder Signatur. Vorgabewerte wertet -# Python zur DEFINITIONSZEIT aus — ein Test konnte den Namen also gar nicht -# umbiegen und arbeitete zwangslaeufig am ECHTEN System. -# -# Gemessen, was das anrichtet: Nach `Rippy.exe` (installiert) stand der -# Eintrag da; nach EINEM `pytest -q` war er weg. Der Commander sah Rippy -# danach nicht mehr unter „Installierte Apps" — sein Autostart-Eintrag ging -# denselben Weg. Der Test raeumte auf, was er nie angelegt hatte. -# -# Deshalb: `None` in der Signatur, aufgeloest zur LAUFZEIT. Damit wirkt ein -# `monkeypatch.setattr(reg, "SCHLUESSEL", ...)` ueberall. -SCHLUESSEL = "Rippy" - -UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall" -RUN_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Run" - -ANZEIGENAME = "Rippy" -HERAUSGEBER = "Rippy" - - -def uninstall_pfad(schluessel: str = None) -> str: - return UNINSTALL_PFAD + "\\" + (schluessel or SCHLUESSEL) - - -def _quoten(pfad: str) -> str: - """Pfad für die Kommandozeile in Anführungszeichen — immer. - - „C:\\Program Files\\Rippy\\…" ohne Anführungszeichen liest Windows als - „C:\\Program" mit den Argumenten „Files\\Rippy\\…". Beim Deinstallieren - passiert dann entweder nichts oder etwas Falsches. - """ - return '"' + pfad.strip('"') + '"' - - -def eintrag_werte(installationsordner: str, deinstaller: str, version: str, - icon: str = "", groesse_kib: int = 0) -> dict: - """Die Werte für „Programme und Features" (reine Funktion). - - `groesse_kib` ist in KiB — Windows zeigt sie als „Größe" an. Wer Bytes - einträgt, meldet 1000-mal zu viel; das ist ein häufiger Fehler. - """ - werte = { - "DisplayName": ANZEIGENAME, - "DisplayVersion": version, - "Publisher": HERAUSGEBER, - "InstallLocation": installationsordner, - "UninstallString": _quoten(deinstaller) + " --deinstallieren", - "QuietUninstallString": _quoten(deinstaller) + " --deinstallieren --still", - # Rippy kann sich nicht selbst reparieren oder teilweise installieren — - # die Knöpfe dafür gehören also ausgeblendet, statt ins Leere zu führen. - "NoModify": 1, - "NoRepair": 1, - } - if icon: - werte["DisplayIcon"] = icon - if groesse_kib: - werte["EstimatedSize"] = int(groesse_kib) - return werte - - -def ordnergroesse_kib(ordner: str) -> int: - """Größe eines Ordners in KiB — für den EstimatedSize-Wert.""" - summe = 0 - for wurzel, _, dateien in os.walk(ordner): - for name in dateien: - try: - summe += os.path.getsize(os.path.join(wurzel, name)) - except OSError: - pass - return summe // 1024 - - -# ── Schreiben und Entfernen (nur unter Windows) ───────────────────────── -def _winreg(): - if os.name != "nt": - raise RuntimeError("Registry-Zugriff gibt es nur unter Windows.") - import winreg - - return winreg - - -def eintragen(installationsordner: str, deinstaller: str, version: str, - icon: str = "", schluessel: str = None) -> None: - """Schreibt den Eintrag für „Programme und Features".""" - winreg = _winreg() - werte = eintrag_werte( - installationsordner, deinstaller, version, icon, - ordnergroesse_kib(installationsordner), - ) - with winreg.CreateKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k: - for name, wert in werte.items(): - if isinstance(wert, int): - winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, wert) - else: - winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert)) - - -def austragen(schluessel: str = None) -> bool: - """Entfernt den Eintrag. False, wenn es ihn gar nicht gab.""" - winreg = _winreg() - try: - winreg.DeleteKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) - return True - except FileNotFoundError: - return False - - -def ist_eingetragen(schluessel: str = None) -> bool: - winreg = _winreg() - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)): - return True - except FileNotFoundError: - return False - - -def gelesene_werte(schluessel: str = None) -> dict: - """Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests. - - Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist - kein Beweis dafür, dass der Eintrag angekommen ist. - """ - winreg = _winreg() - ergebnis = {} - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k: - i = 0 - while True: - try: - name, wert, _ = winreg.EnumValue(k, i) - except OSError: - break - ergebnis[name] = wert - i += 1 - except FileNotFoundError: - pass - return ergebnis - - -# ── Autostart ─────────────────────────────────────────────────────────── -def autostart_setzen(befehl: str, name: str = None) -> None: - winreg = _winreg() - with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k: - winreg.SetValueEx(k, name or SCHLUESSEL, 0, winreg.REG_SZ, befehl) - - -def autostart_entfernen(name: str = None) -> bool: - winreg = _winreg() - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0, - winreg.KEY_ALL_ACCESS) as k: - winreg.DeleteValue(k, name or SCHLUESSEL) - return True - except FileNotFoundError: - return False - - -def autostart_wert(name: str = None) -> str: - winreg = _winreg() - try: - with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k: - return winreg.QueryValueEx(k, name or SCHLUESSEL)[0] - except (FileNotFoundError, OSError): - return "" - - -def standard_installationsordner() -> str: - """%LOCALAPPDATA%\\Rippy — dorthin darf ohne Administratorrechte geschrieben werden. - - `Program Files` wäre der gewohnte Ort, verlangt aber bei JEDER Installation - und jedem Update eine UAC-Abfrage. Für ein Programm, das sich selbst - aktualisieren soll, ist das der falsche Handel. - """ - basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") - return os.path.join(basis, "Rippy") - - -def laeuft_als_gebuendelte_exe() -> bool: - return getattr(sys, "frozen", False) diff --git a/src/rippy/queue/__init__.py b/src/rippy/queue/__init__.py deleted file mode 100644 index 48a5ead..0000000 --- a/src/rippy/queue/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Queue-Schicht: Aufträge verteilen (KONZEPT-V2.md § 3.4). - -Zwei Treiber, ein Port (`rippy.ports.Queue`): - - lokal.py Auftragstabelle + Lease, ohne Broker — Standalone-Betrieb - celery.py Celery über Redis — verteilter Betrieb (V2-5) - -Beide stehen auf demselben Grundsatz: Die Datenbank ist die Wahrheit über den -Job-Zustand, der Broker ist nur der Wecker. -""" diff --git a/src/rippy/queue/laeufer.py b/src/rippy/queue/laeufer.py deleted file mode 100644 index 599d9a8..0000000 --- a/src/rippy/queue/laeufer.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Der Läufer: nimmt Aufträge aus der LocalQueue und arbeitet sie ab. - -## Wofür er da ist - -Im verteilten Betrieb macht Celery das: Ein Worker-Prozess wartet auf -Nachrichten und ruft die Task-Funktion auf. Im Standalone-Betrieb gibt es -keinen Broker und keinen zweiten Prozess — also übernimmt dieser Läufer die -Rolle. Er ist das letzte Stück, das gefehlt hat, damit Rippy unter Windows -nicht nur anzeigen, sondern auch **arbeiten** kann. - -## Die Lease ist der Kern, nicht ein Detail - -Ein Rip läuft 30 bis 90 Minuten. In dieser Zeit muss zweierlei stimmen: - -* **Niemand sonst darf denselben Auftrag anfassen.** Zwei Rips auf einem - Laufwerk wären zwei kaputte Dateien. -* **Stürzt der Läufer ab, muss der Auftrag zurückfallen.** Sonst bliebe er - für immer als „läuft" stehen — genau der Zustand, den v1 mit - `zombies.py` nachträglich einsammeln musste. - -Beides erledigt die Lease aus `rippy.queue.lokal`: Der Läufer verlängert sie -alle 15 Sekunden aus einem eigenen Thread. Hört er auf zu leben, läuft sie -nach 60 Sekunden ab und der Auftrag ist wieder frei. - -## Warum der Herzschlag NICHT im Arbeits-Thread liegt - -Weil er dann mit der Arbeit stillstünde. Der Rip verbringt seine Zeit in -`subprocess.communicate()` — dort läuft kein Python-Code, der nebenbei eine -Lease verlängern könnte. Der Herzschlag braucht deshalb einen eigenen Thread, -der nichts anderes tut. - -## Was der Läufer NICHT tut - -Er kennt den Rip-Vorgang nicht. Er bekommt eine Funktion `ausfuehren(auftrag)` -und ruft sie auf. Das hält ihn testbar (die Tests hier rippen nichts) und -erlaubt dieselbe Mechanik später für andere Auftragsarten. -""" - -import threading -import time -import traceback - -from rippy.queue import lokal - - -class Laeufer: - """Ein Arbeiter: holt einen Auftrag, hält ihn, arbeitet ihn ab.""" - - def __init__(self, ausfuehren, knoten: str, kann: set = None, - bus=None, store=None, - herzschlag_sekunden: float = lokal.LEASE_ERNEUERN_SEKUNDEN): - self._ausfuehren = ausfuehren - self.knoten = knoten - self.kann = kann or set() - self._bus = bus - self._store = store - self._herzschlag_sekunden = herzschlag_sekunden - self._laeuft = False - self.aktueller_auftrag = None - - # ── Ein Auftrag ───────────────────────────────────────────────────── - def einmal(self) -> bool: - """Holt EINEN Auftrag und arbeitet ihn ab. False, wenn nichts da war. - - Blockiert für die Dauer der Arbeit — bei einem Rip also Stunden. Das - ist Absicht: Der Aufrufer entscheidet, ob das in einem eigenen Thread - passiert. - """ - auftrag = lokal.uebernehmen(self.knoten, self.kann) - if auftrag is None: - return False - - self.aktueller_auftrag = auftrag - schlagen = self._herzschlag_starten(auftrag["id"]) - try: - ergebnis = self._ausfuehren(auftrag) - lokal.abschliessen(auftrag["id"], ergebnis if isinstance(ergebnis, dict) else {}) - self._melden("job.finished", auftrag, {"status": "fertig"}) - except Exception as e: - # Ein Fehlschlag darf den Laeufer NICHT mitnehmen — sonst bleibt - # nach einem kaputten Auftrag jeder weitere liegen. Der Auftrag - # geht zurueck in die Queue (bis MAX_VERSUCHE), der Grund wird - # protokolliert, und zwar mit Rueckverfolgung: Ein "Fehler" ohne - # Stelle ist beim naechsten Mal wertlos. - self._protokollieren(auftrag, e) - lokal.fehlgeschlagen(auftrag["id"], f"{type(e).__name__}: {e}") - self._melden("job.finished", auftrag, {"status": "fehler", "fehler": str(e)}) - finally: - schlagen.set() - self.aktueller_auftrag = None - return True - - def _herzschlag_starten(self, auftrag_id: str) -> threading.Event: - """Verlängert die Lease, solange gearbeitet wird. Eigener Thread.""" - fertig = threading.Event() - - def schlagen(): - while not fertig.wait(self._herzschlag_sekunden): - if not lokal.lebenszeichen(auftrag_id, self.knoten): - # Der Auftrag gehoert uns nicht mehr — jemand anderes hat - # ihn uebernommen, weil unsere Lease abgelaufen war. Dann - # ist Weiterarbeiten falsch, aber Abbrechen mitten im Rip - # waere schlimmer. Also: laut sagen und weiterlaufen. - self._protokollieren_text( - f"Auftrag {auftrag_id} ist an einen anderen Knoten " - "gefallen — die Lease war abgelaufen.") - return - - threading.Thread(target=schlagen, daemon=True, - name=f"lease-{auftrag_id[:8]}").start() - return fertig - - # ── Die Schleife ──────────────────────────────────────────────────── - def schleife(self, takt: float = 1.0) -> None: - """Läuft, bis `stoppen()` gerufen wird. Für einen eigenen Thread.""" - self._laeuft = True - while self._laeuft: - try: - if not self.einmal(): - time.sleep(takt) - except Exception as e: # die Schleife selbst darf nie sterben - self._protokollieren_text(f"Läufer-Fehler: {e}") - time.sleep(takt * 5) - - def stoppen(self) -> None: - self._laeuft = False - - # ── Melden ────────────────────────────────────────────────────────── - def _melden(self, typ: str, auftrag: dict, daten: dict) -> None: - if not self._bus: - return - try: - self._bus.senden(typ, {**daten, "art": auftrag.get("art")}, - entitaet="job", entitaet_id=auftrag.get("job_id")) - except Exception: - pass # eine Meldung darf die Arbeit nicht aufhalten - - def _protokollieren(self, auftrag: dict, fehler: Exception) -> None: - self._protokollieren_text( - f"Auftrag {auftrag.get('art')} für Job {auftrag.get('job_id')} " - f"gescheitert: {type(fehler).__name__}: {fehler}\n" - + "".join(traceback.format_exception_only(type(fehler), fehler)).strip() - ) - - def _protokollieren_text(self, text: str) -> None: - if self._store: - try: - self._store.add_log("error", "laeufer", text) - return - except Exception: - pass - print("[Läufer] " + text) diff --git a/src/rippy/queue/lokal.py b/src/rippy/queue/lokal.py deleted file mode 100644 index 0c90547..0000000 --- a/src/rippy/queue/lokal.py +++ /dev/null @@ -1,290 +0,0 @@ -"""LocalQueue — Aufträge ohne Broker. Erfüllt `rippy.ports.Queue`. - -## Der Grundsatz, aus dem alles folgt (KONZEPT-V2.md § 3.1) - - Die Datenbank ist die Wahrheit über den Job-Zustand. - Der Broker ist nur der Wecker. - -In v1 war es umgekehrt gedacht: Celery hielt den Auftrag, die Datenbank -spiegelte ihn nach. Deshalb gibt es dort `zombies.py` (206 Zeilen) plus -`test_zombies.py` (263 Zeilen) — einen nachträglichen Reparaturmechanismus für -Jobs, die in der Datenbank laufen, während in Celery niemand mehr an ihnen -arbeitet. Nach einer Gnadenfrist werden sie „ehrlich auf failed gesetzt". - -Wenn die Datenbank die Wahrheit ist, verschwindet dieses Problem, statt -repariert zu werden: - -* Ein Auftrag ist eine Zeile mit `status`, `claimed_by`, `lease_until`. -* Ein Knoten übernimmt ihn per **bedingtem UPDATE** — atomar, es gewinnt genau - einer, auch wenn zehn gleichzeitig fragen. -* Er hält ihn per Lease am Leben: alle `LEASE_ERNEUERN_SEKUNDEN` wird - `lease_until` vorgerückt. -* Läuft die Lease ab, ist der Auftrag frei. Egal ob der Knoten abgestürzt ist, - das Netz weg war oder jemand den Stecker gezogen hat. - -Es gibt also keinen Zustand „läuft, aber niemand arbeitet daran" mehr, den -jemand erkennen müsste. Er kann höchstens `LEASE_DAUER_SEKUNDEN` lang bestehen -und heilt sich dann selbst. - -## Warum keine Queue-Bibliothek (Taskiq, ARQ, Celery-lite) - -Drei Gründe, alle am konkreten Fall gemessen: - -1. **Der teure Teil ist kein Task, sondern ein Subprozess.** Ein Rip ist ein - 30–90-Minuten-`makemkvcon`-Aufruf, dessen stdout zeilenweise geparst wird - (`ripping.get_progress_from_prgv`). Was die Bibliotheken liefern — - Serialisierung, Retry, Broker-Anbindung — löst davon nichts. Was gebraucht - wird — Fortschritts-Streaming, Abbruch mitten im Lauf, Lease — muss man - ohnehin selbst bauen. -2. **Zwei Bibliotheken heißen zwei Programmiermodelle.** Der verteilte Modus - behält Celery (funktioniert, der Remote-Windows-Worker ist bewiesen). Eine - zweite Queue-Bibliothek daneben bedeutet zwei Fehlerbilder, zwei - Retry-Semantiken, zwei Sorten Doku. -3. **Mit dem Grundsatz oben ist der Treiber trivial** — die eigentliche - Auswahl unten ist ein SELECT und ein bedingtes UPDATE. - -## Wozu die Zeitgrenzen - -`LEASE_DAUER_SEKUNDEN` muss deutlich größer sein als -`LEASE_ERNEUERN_SEKUNDEN`, sonst gilt ein arbeitender Knoten bei jedem -Aussetzer als tot und ein zweiter greift sich denselben Auftrag. Faktor 4 ist -reichlich: Drei Erneuerungen dürfen ausfallen, bevor jemand übernimmt. -""" - -import json -import uuid -from datetime import timedelta - -from sqlalchemy import or_ - -from rippy import store -from rippy.store import auftraege - -# ⚠️ NICHT `from rippy.store import engine`. Ein Import bindet den Wert EINMAL, -# beim Laden des Moduls — ein späteres `store.verbinden(...)` (Standalone-Start, -# Tests) käme hier nie an, und dieses Modul spräche weiter mit der alten -# Datenbank. Über `store.engine` wird bei jedem Aufruf nachgesehen. - -# Wie lange ein übernommener Auftrag als „in Arbeit" gilt, ohne Lebenszeichen. -LEASE_DAUER_SEKUNDEN = 60 -# In welchem Abstand ein arbeitender Knoten die Lease verlängert. -LEASE_ERNEUERN_SEKUNDEN = 15 - -# Zustände -WARTEND = "wartend" -LAUFEND = "laufend" -FERTIG = "fertig" -FEHLER = "fehler" -ABGEBROCHEN = "abgebrochen" - -# Wie oft ein Auftrag höchstens neu vergeben wird, bevor er als Fehler gilt. -# Ohne Obergrenze würde ein Auftrag, der den bearbeitenden Knoten jedes Mal -# umbringt, endlos im Kreis laufen und dabei jeden freien Knoten mitnehmen. -MAX_VERSUCHE = 3 - - -def _jetzt(): - return store.utcnow() - - -def einreihen(job_id: str, art: str, faehigkeiten: dict = None, - payload: dict = None, prioritaet: int = 0) -> str: - """Legt einen Auftrag an und gibt seine Kennung zurück.""" - auftrag_id = str(uuid.uuid4()) - with store.engine.begin() as conn: - conn.execute(auftraege.insert().values( - id=auftrag_id, - job_id=job_id, - art=art, - status=WARTEND, - faehigkeiten=json.dumps(faehigkeiten or {}), - payload=json.dumps(payload or {}), - prioritaet=prioritaet, - versuche=0, - created_at=_jetzt(), - )) - return auftrag_id - - -def _passt(auftrag_faehigkeiten: str, knoten_kann: set) -> bool: - """Kann dieser Knoten den Auftrag bedienen? (pure Funktion) - - Ein Auftrag verlangt Fähigkeiten als Schlüssel-Wert-Paare, z. B. - `{"art": "rip", "drive": "sr0"}`. Der Knoten meldet, was er kann, als - Menge von `"schluessel=wert"`-Strings bzw. blanken Schlüsseln. - - Leere Anforderung heißt: jeder darf. Das ist Absicht — ein Auftrag ohne - besondere Ansprüche soll nicht liegen bleiben, nur weil niemand explizit - „kann nichts Besonderes" gemeldet hat. - """ - try: - verlangt = json.loads(auftrag_faehigkeiten or "{}") - except (ValueError, TypeError): - verlangt = {} - if not verlangt: - return True - for schluessel, wert in verlangt.items(): - if f"{schluessel}={wert}" in knoten_kann or schluessel in knoten_kann: - continue - return False - return True - - -def uebernehmen(knoten: str, kann: set = None): - """Holt EINEN passenden Auftrag und markiert ihn als übernommen. - - Gibt den Auftrag als dict zurück oder None, wenn nichts passt. - - ## Warum das UPDATE eine Bedingung trägt - - Zwischen dem SELECT und dem UPDATE kann ein anderer Knoten denselben - Auftrag genommen haben. Das `WHERE … AND claimed_by IS NULL` (bzw. eine - abgelaufene Lease) sorgt dafür, dass genau EINER gewinnt: Der Verlierer - ändert null Zeilen und sucht weiter. Das ist derselbe Mechanismus auf - SQLite wie auf PostgreSQL — beide führen ein UPDATE atomar aus. - - Ohne die Bedingung hätte man das klassische Doppel-Rip: zwei Knoten am - selben Laufwerk, und der zweite überschreibt die Ausgabe des ersten. - """ - kann = kann or set() - jetzt = _jetzt() - with store.engine.begin() as conn: - kandidaten = conn.execute( - auftraege.select() - .where(auftraege.c.status.in_((WARTEND, LAUFEND))) - .where(or_( - auftraege.c.claimed_by.is_(None), - auftraege.c.lease_until.is_(None), - auftraege.c.lease_until < jetzt, # Lease abgelaufen = frei - )) - .where(auftraege.c.versuche < MAX_VERSUCHE) - .order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at) - .limit(20) - ).mappings().all() - - for kandidat in kandidaten: - if not _passt(kandidat["faehigkeiten"], kann): - continue - betroffen = conn.execute( - auftraege.update() - .where(auftraege.c.id == kandidat["id"]) - .where(or_( - auftraege.c.claimed_by.is_(None), - auftraege.c.lease_until.is_(None), - auftraege.c.lease_until < jetzt, - )) - .values( - status=LAUFEND, - claimed_by=knoten, - lease_until=jetzt + timedelta(seconds=LEASE_DAUER_SEKUNDEN), - versuche=(kandidat["versuche"] or 0) + 1, - ) - ).rowcount - if betroffen == 1: - eintrag = dict(kandidat) - eintrag.update({ - "status": LAUFEND, - "claimed_by": knoten, - "versuche": (kandidat["versuche"] or 0) + 1, - }) - return eintrag - return None - - -def lebenszeichen(auftrag_id: str, knoten: str) -> bool: - """Verlängert die Lease. False, wenn der Auftrag inzwischen weg ist. - - Der `knoten`-Vergleich ist kein Beiwerk: Hat ein anderer den Auftrag - übernommen (weil diese Lease einmal abgelaufen war), darf der alte - Bearbeiter ihn NICHT wieder an sich ziehen. Ein False heißt für den - Aufrufer: aufhören, jemand anderes macht das jetzt. - """ - with store.engine.begin() as conn: - betroffen = conn.execute( - auftraege.update() - .where(auftraege.c.id == auftrag_id) - .where(auftraege.c.claimed_by == knoten) - .where(auftraege.c.status == LAUFEND) - .values(lease_until=_jetzt() + timedelta(seconds=LEASE_DAUER_SEKUNDEN)) - ).rowcount - return betroffen == 1 - - -def abschliessen(auftrag_id: str, ergebnis: dict = None) -> None: - with store.engine.begin() as conn: - conn.execute( - auftraege.update() - .where(auftraege.c.id == auftrag_id) - .values(status=FERTIG, finished_at=_jetzt(), - payload=json.dumps(ergebnis or {}), lease_until=None) - ) - - -def fehlgeschlagen(auftrag_id: str, fehler: str, erneut: bool = True) -> None: - """Auftrag ist gescheitert. - - `erneut=True` gibt ihn wieder frei, solange die Versuche reichen — ein - abgestürzter Encoder soll den Job nicht endgültig verlieren. Ist - MAX_VERSUCHE erreicht, bleibt er auf `fehler` stehen: Ein Auftrag, der - dreimal denselben Knoten umgebracht hat, wird beim vierten Mal nicht - plötzlich funktionieren. - """ - with store.engine.begin() as conn: - zeile = conn.execute( - auftraege.select().where(auftraege.c.id == auftrag_id) - ).mappings().first() - if not zeile: - return - wieder_frei = erneut and (zeile["versuche"] or 0) < MAX_VERSUCHE - conn.execute( - auftraege.update() - .where(auftraege.c.id == auftrag_id) - .values( - status=WARTEND if wieder_frei else FEHLER, - claimed_by=None, - lease_until=None, - fehler=(fehler or "")[:2000], - finished_at=None if wieder_frei else _jetzt(), - ) - ) - - -def abbrechen(auftrag_id: str) -> None: - with store.engine.begin() as conn: - conn.execute( - auftraege.update() - .where(auftraege.c.id == auftrag_id) - .values(status=ABGEBROCHEN, claimed_by=None, lease_until=None, - finished_at=_jetzt()) - ) - - -def offene_auftraege(job_id: str = None) -> list: - """Alles, was noch nicht durch ist — für `rippy queue` und das UI.""" - abfrage = auftraege.select().where( - auftraege.c.status.in_((WARTEND, LAUFEND)) - ).order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at) - if job_id: - abfrage = abfrage.where(auftraege.c.job_id == job_id) - with store.engine.connect() as conn: - return [dict(z) for z in conn.execute(abfrage).mappings().all()] - - -def verwaiste_freigeben() -> int: - """Gibt Aufträge frei, deren Lease abgelaufen ist. Gibt die Anzahl zurück. - - Braucht man streng genommen nicht — `uebernehmen` behandelt abgelaufene - Leases ohnehin als frei. Diese Funktion macht es nur SICHTBAR: Nach einem - Absturz soll im UI und im Log stehen, dass etwas zurückgefallen ist, statt - dass es stillschweigend wieder auftaucht. - """ - jetzt = _jetzt() - with store.engine.begin() as conn: - betroffen = conn.execute( - auftraege.update() - .where(auftraege.c.status == LAUFEND) - .where(auftraege.c.lease_until.isnot(None)) - .where(auftraege.c.lease_until < jetzt) - .values(status=WARTEND, claimed_by=None, lease_until=None) - ).rowcount - return betroffen or 0 diff --git a/src/rippy/queue/test_laeufer.py b/src/rippy/queue/test_laeufer.py deleted file mode 100644 index c19be63..0000000 --- a/src/rippy/queue/test_laeufer.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Der Läufer: hält er den Auftrag, gibt er ihn zurück, überlebt er Fehler? - -Gerippt wird hier nichts — die Arbeit ist eine eingespritzte Funktion. Geprüft -wird das Drumherum, und genau dort sitzen die Fehler, die man im Betrieb -teuer bezahlt: ein Läufer, der nach dem ersten kaputten Auftrag stehenbleibt, -oder eine Lease, die während eines dreistündigen Encodes abläuft. -""" - -import threading -import time - -import pytest - -from rippy import store -from rippy.queue import laeufer as laeufer_modul -from rippy.queue import lokal - - -@pytest.fixture -def q(tmp_path): - vorher = store.zustand_sichern() - store.verbinden(f"sqlite:///{(tmp_path / 'l.db').as_posix()}") - store.init_db() - yield lokal - store.engine_holen().dispose() - store.zustand_wiederherstellen(vorher) - - -class FakeBus: - def __init__(self): - self.gesendet = [] - - def senden(self, typ, daten=None, entitaet=None, entitaet_id=None): - self.gesendet.append((typ, entitaet_id, daten or {})) - - -class FakeStore: - def __init__(self): - self.protokoll = [] - - def add_log(self, level, quelle, text): - self.protokoll.append((level, quelle, text)) - - -# ── Grundfall ─────────────────────────────────────────────────────────── -def test_ohne_auftrag_passiert_nichts(q): - lauf = laeufer_modul.Laeufer(lambda a: {}, "knoten-a") - assert lauf.einmal() is False - - -def test_auftrag_wird_geholt_und_abgearbeitet(q): - gesehen = [] - q.einreihen("job1", "rip", payload={"device": "/dev/sr0"}) - lauf = laeufer_modul.Laeufer(lambda a: gesehen.append(a) or {"ok": True}, "knoten-a") - - assert lauf.einmal() is True - assert len(gesehen) == 1 - assert gesehen[0]["art"] == "rip" - assert q.offene_auftraege() == [] # abgeschlossen, nicht mehr offen - - -def test_zweiter_laeufer_bekommt_ihn_nicht(q): - """Die zentrale Zusage — sonst zwei Rips auf einem Laufwerk.""" - q.einreihen("job1", "rip") - lauf_a = laeufer_modul.Laeufer(lambda a: {}, "knoten-a") - lauf_b = laeufer_modul.Laeufer(lambda a: {}, "knoten-b") - - langsam = threading.Event() - lauf_a._ausfuehren = lambda a: langsam.wait(2) or {} - t = threading.Thread(target=lauf_a.einmal, daemon=True) - t.start() - time.sleep(0.3) - try: - assert lauf_b.einmal() is False, "Knoten B hat denselben Auftrag bekommen" - finally: - langsam.set() - t.join(timeout=5) - - -# ── Fehler ────────────────────────────────────────────────────────────── -def test_ein_kaputter_auftrag_nimmt_den_laeufer_nicht_mit(q): - """Ohne das bliebe nach dem ersten Fehlschlag JEDER weitere Auftrag - liegen — und im UI saehe es aus, als tue Rippy nichts mehr.""" - protokoll = FakeStore() - q.einreihen("job1", "rip") - - def kaputt(_): - raise RuntimeError("MakeMKV ist abgestuerzt") - - lauf = laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll) - assert lauf.einmal() is True # er hat gearbeitet, nicht geworfen - assert any("MakeMKV ist abgestuerzt" in text for _, _, text in protokoll.protokoll) - - -def test_gescheiterter_auftrag_geht_zurueck_in_die_queue(q): - """Ein abgestuerzter Encoder soll den Job nicht endgueltig verlieren.""" - auftrag_id = q.einreihen("job1", "transcode") - - def kaputt(_): - raise RuntimeError("weg") - - laeufer_modul.Laeufer(kaputt, "knoten-a", store=FakeStore()).einmal() - offen = q.offene_auftraege() - assert len(offen) == 1 and offen[0]["id"] == auftrag_id - - -def test_fehler_wird_mit_typ_gemeldet(q): - """„Fehler" allein ist beim naechsten Mal wertlos. Der Typ gehoert dazu.""" - q.einreihen("job1", "rip") - protokoll = FakeStore() - - def kaputt(_): - raise FileNotFoundError("makemkvcon fehlt") - - laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll).einmal() - text = " ".join(t for _, _, t in protokoll.protokoll) - assert "FileNotFoundError" in text - assert "makemkvcon fehlt" in text - - -# ── Lease ─────────────────────────────────────────────────────────────── -def test_lease_wird_waehrend_der_arbeit_verlaengert(q): - """DER Punkt bei einem Rip: Er dauert Stunden, die Lease 60 Sekunden. - Ohne Herzschlag fiele der Auftrag mitten im Lauf zurueck — und ein - zweiter Laeufer finge an, dieselbe Disc zu lesen. - """ - from datetime import timedelta - - auftrag_id = q.einreihen("job1", "rip") - weiter = threading.Event() - - def langsam(_): - # Waehrend der Arbeit die Lease kuenstlich ablaufen lassen — - # der Herzschlag muss sie zurueckholen. - with store.engine_holen().begin() as conn: - conn.execute( - store.auftraege.update() - .where(store.auftraege.c.id == auftrag_id) - .values(lease_until=store.utcnow() - timedelta(seconds=10))) - weiter.wait(3) - return {} - - lauf = laeufer_modul.Laeufer(langsam, "knoten-a", herzschlag_sekunden=0.2) - t = threading.Thread(target=lauf.einmal, daemon=True) - t.start() - time.sleep(1.0) # dem Herzschlag Zeit geben - try: - assert laeufer_modul.lokal.uebernehmen("knoten-b") is None, \ - "Die Lease wurde nicht verlaengert — ein zweiter Knoten kam ran" - finally: - weiter.set() - t.join(timeout=5) - - -def test_aktueller_auftrag_ist_sichtbar_und_danach_wieder_leer(q): - """Fuer das UI und fuer die Diagnose: Woran arbeitet dieser Knoten gerade?""" - q.einreihen("job1", "rip") - gesehen = {} - lauf = laeufer_modul.Laeufer( - lambda a: gesehen.update(id=lauf.aktueller_auftrag["id"]) or {}, "knoten-a") - lauf.einmal() - assert gesehen["id"] - assert lauf.aktueller_auftrag is None - - -# ── Meldungen ─────────────────────────────────────────────────────────── -def test_abschluss_wird_gemeldet(q): - bus = FakeBus() - q.einreihen("job1", "rip") - laeufer_modul.Laeufer(lambda a: {}, "knoten-a", bus=bus).einmal() - assert [t for t, _, _ in bus.gesendet] == ["job.finished"] - assert bus.gesendet[0][1] == "job1" - - -def test_ein_kaputter_bus_haelt_die_arbeit_nicht_auf(q): - """Eine Meldung ist Beiwerk. Wenn sie scheitert, ist der Rip trotzdem - fertig — und darf nicht als Fehlschlag gelten.""" - class KaputterBus: - def senden(self, *a, **k): - raise RuntimeError("Bus weg") - - q.einreihen("job1", "rip") - lauf = laeufer_modul.Laeufer(lambda a: {"ok": True}, "knoten-a", bus=KaputterBus()) - assert lauf.einmal() is True - assert q.offene_auftraege() == [] diff --git a/src/rippy/queue/test_lokal.py b/src/rippy/queue/test_lokal.py deleted file mode 100644 index 247f131..0000000 --- a/src/rippy/queue/test_lokal.py +++ /dev/null @@ -1,215 +0,0 @@ -"""LocalQueue: übernehmen, Lease, Rückfall — gegen echtes SQLite (V2-2). - -Der Grundsatz, den diese Tests prüfen: **Die Datenbank ist die Wahrheit, der -Broker ist nur der Wecker.** Konkret heißt das, dass zwei Dinge stimmen müssen, -und beide sind hier nachgestellt: - -1. Zwei Knoten dürfen sich denselben Auftrag NICHT teilen. Ohne diese Zusage - liefen zwei Rips auf demselben Laufwerk, und der zweite überschriebe die - Ausgabe des ersten. -2. Ein abgestürzter Knoten muss seinen Auftrag von SELBST wieder hergeben. - Genau das konnte v1 nicht — dort brauchte es `zombies.py` (206 Zeilen plus - 263 Zeilen Tests), um hinterher aufzuräumen. -""" - -from datetime import timedelta - -import pytest - -from rippy import store -from rippy.queue import lokal - - -@pytest.fixture -def q(tmp_path): - """Frische SQLite-Datenbank je Test, danach zurückdrehen.""" - vorher = store.zustand_sichern() - store.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}") - store.init_db() - yield lokal - store.engine_holen().dispose() - store.zustand_wiederherstellen(vorher) - - -def _lease_zuruecksetzen(auftrag_id, sekunden): - """Schiebt die Lease künstlich in die Vergangenheit — so sieht ein - abgestürzter Knoten aus, ohne dass ein Test eine Minute warten muss.""" - with store.engine.begin() as conn: - conn.execute( - store.auftraege.update() - .where(store.auftraege.c.id == auftrag_id) - .values(lease_until=store.utcnow() - timedelta(seconds=sekunden)) - ) - - -# ── Grundfall ─────────────────────────────────────────────────────────── -def test_eingereihter_auftrag_wird_uebernommen(q): - q.einreihen("job1", "rip") - auftrag = q.uebernehmen("knoten-a") - assert auftrag is not None - assert auftrag["art"] == "rip" - assert auftrag["claimed_by"] == "knoten-a" - assert auftrag["status"] == lokal.LAUFEND - - -def test_ohne_auftrag_kommt_none(q): - assert q.uebernehmen("knoten-a") is None - - -def test_hoehere_prioritaet_kommt_zuerst(q): - q.einreihen("job1", "transcode", prioritaet=0) - q.einreihen("job2", "rip", prioritaet=5) - assert q.uebernehmen("knoten-a")["job_id"] == "job2" - - -# ── Die zentrale Zusage: genau EINER gewinnt ──────────────────────────── -def test_zwei_knoten_bekommen_nicht_denselben_auftrag(q): - """DIE Zusage der ganzen Queue. Ohne sie: zwei Rips auf einem Laufwerk.""" - q.einreihen("job1", "rip") - erster = q.uebernehmen("knoten-a") - zweiter = q.uebernehmen("knoten-b") - assert erster is not None - assert zweiter is None, "Der zweite Knoten hat denselben Auftrag bekommen" - - -def test_zwei_auftraege_gehen_an_zwei_knoten(q): - """Die Gegenprobe — sonst wäre die Sperre oben nur eine kaputte Queue.""" - q.einreihen("job1", "rip") - q.einreihen("job2", "rip") - a = q.uebernehmen("knoten-a") - b = q.uebernehmen("knoten-b") - assert a and b and a["id"] != b["id"] - - -# ── Lease: ein abgestürzter Knoten gibt von selbst her ────────────────── -def test_abgelaufene_lease_macht_den_auftrag_wieder_frei(q): - """Das ist der Ersatz für zombies.py. - - In v1 blieb ein Job nach einem Worker-Absturz auf `transcoding` stehen, - und ein eigener Mechanismus musste ihn nach einer Gnadenfrist „ehrlich auf - failed setzen". Hier passiert nichts dergleichen: Die Lease läuft ab, und - der Auftrag ist wieder zu haben. - """ - auftrag_id = q.einreihen("job1", "rip") - erster = q.uebernehmen("knoten-a") - assert erster is not None - assert q.uebernehmen("knoten-b") is None # solange die Lease lebt - - _lease_zuruecksetzen(auftrag_id, 10) # Knoten A ist abgestürzt - - zweiter = q.uebernehmen("knoten-b") - assert zweiter is not None - assert zweiter["claimed_by"] == "knoten-b" - assert zweiter["versuche"] == 2 - - -def test_lebenszeichen_haelt_den_auftrag(q): - auftrag_id = q.einreihen("job1", "rip") - q.uebernehmen("knoten-a") - _lease_zuruecksetzen(auftrag_id, 10) - - assert q.lebenszeichen(auftrag_id, "knoten-a") is True - assert q.uebernehmen("knoten-b") is None, "Lease wurde nicht verlaengert" - - -def test_fremder_knoten_kann_die_lease_nicht_verlaengern(q): - """Sonst könnte ein Knoten, dessen Lease einmal abgelaufen ist, den Auftrag - dem neuen Bearbeiter wieder wegnehmen — und beide arbeiteten daran.""" - auftrag_id = q.einreihen("job1", "rip") - q.uebernehmen("knoten-a") - assert q.lebenszeichen(auftrag_id, "knoten-b") is False - - -def test_lebenszeichen_auf_unbekannten_auftrag_ist_false(q): - assert q.lebenszeichen("gibt-es-nicht", "knoten-a") is False - - -def test_verwaiste_freigeben_macht_den_rueckfall_sichtbar(q): - """Nötig ist die Funktion nicht — uebernehmen() behandelt abgelaufene - Leases ohnehin als frei. Sie macht den Rückfall aber SICHTBAR, statt ihn - stillschweigend passieren zu lassen.""" - auftrag_id = q.einreihen("job1", "rip") - q.uebernehmen("knoten-a") - assert q.verwaiste_freigeben() == 0 - _lease_zuruecksetzen(auftrag_id, 10) - assert q.verwaiste_freigeben() == 1 - assert q.offene_auftraege()[0]["status"] == lokal.WARTEND - - -# ── Fähigkeiten ───────────────────────────────────────────────────────── -def test_passt_ohne_anforderung_darf_jeder(): - assert lokal._passt("{}", set()) is True - assert lokal._passt(None, set()) is True - - -def test_passt_verlangt_alle_faehigkeiten(): - verlangt = '{"art": "transcode", "encoder": "nvenc"}' - assert lokal._passt(verlangt, {"art=transcode", "encoder=nvenc"}) is True - assert lokal._passt(verlangt, {"art=transcode"}) is False - - -def test_passt_bei_kaputtem_json_nicht_abstuerzen(): - """Ein beschädigtes Feld darf die Auftragsvergabe nicht anhalten — - sonst blockiert eine einzige kaputte Zeile die ganze Queue.""" - assert lokal._passt("{kein json", {"art=rip"}) is True - - -def test_knoten_ohne_die_faehigkeit_bekommt_nichts(q): - q.einreihen("job1", "transcode", faehigkeiten={"encoder": "nvenc"}) - assert q.uebernehmen("nur-cpu", kann={"encoder=x265"}) is None - assert q.uebernehmen("gpu-knoten", kann={"encoder=nvenc"}) is not None - - -def test_passender_auftrag_wird_gefunden_obwohl_ein_fremder_vorne_liegt(q): - """Der GPU-Knoten darf nicht leer ausgehen, nur weil ein CPU-Auftrag mit - höherer Prioritaet davor steht.""" - q.einreihen("job1", "transcode", faehigkeiten={"encoder": "x265"}, prioritaet=9) - q.einreihen("job2", "transcode", faehigkeiten={"encoder": "nvenc"}) - auftrag = q.uebernehmen("gpu", kann={"encoder=nvenc"}) - assert auftrag is not None and auftrag["job_id"] == "job2" - - -# ── Abschluss und Fehlschlag ──────────────────────────────────────────── -def test_abgeschlossener_auftrag_kommt_nicht_zurueck(q): - auftrag_id = q.einreihen("job1", "rip") - q.uebernehmen("knoten-a") - q.abschliessen(auftrag_id, {"pfad": "/app/media/Akira"}) - assert q.uebernehmen("knoten-b") is None - assert q.offene_auftraege() == [] - - -def test_fehlschlag_gibt_den_auftrag_wieder_frei(q): - auftrag_id = q.einreihen("job1", "transcode") - q.uebernehmen("knoten-a") - q.fehlgeschlagen(auftrag_id, "Encoder abgestuerzt") - zweiter = q.uebernehmen("knoten-b") - assert zweiter is not None and zweiter["id"] == auftrag_id - - -def test_nach_max_versuchen_ist_schluss(q): - """Ohne Obergrenze liefe ein Auftrag, der jeden Knoten umbringt, endlos im - Kreis und nähme dabei jeden freien Knoten mit.""" - auftrag_id = q.einreihen("job1", "transcode") - for _ in range(lokal.MAX_VERSUCHE): - q.uebernehmen("knoten-a") - q.fehlgeschlagen(auftrag_id, "immer derselbe Fehler") - assert q.uebernehmen("knoten-a") is None - with store.engine.connect() as conn: - zeile = conn.execute( - store.auftraege.select().where(store.auftraege.c.id == auftrag_id) - ).mappings().first() - assert zeile["status"] == lokal.FEHLER - assert "immer derselbe Fehler" in zeile["fehler"] - - -def test_abbrechen_nimmt_den_auftrag_aus_dem_verkehr(q): - auftrag_id = q.einreihen("job1", "rip") - q.abbrechen(auftrag_id) - assert q.uebernehmen("knoten-a") is None - - -def test_offene_auftraege_lassen_sich_nach_job_filtern(q): - q.einreihen("job1", "rip") - q.einreihen("job2", "rip") - assert len(q.offene_auftraege()) == 2 - assert len(q.offene_auftraege(job_id="job1")) == 1 diff --git a/src/rippy/rip/audio_cd.py b/src/rippy/rip/audio_cd.py deleted file mode 100644 index 185d4a0..0000000 --- a/src/rippy/rip/audio_cd.py +++ /dev/null @@ -1,292 +0,0 @@ -r"""Audio-CDs unter Windows lesen — ohne cdparanoia. - -## Warum es diese Datei gibt (Commander, 29.08.2026) - -Nach dem Rundgang durch die Docker-Reste blieb genau eine Lücke offen, und -sie stand seit Wochen so im SAVEPOINT: - -> **BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT.** `cdparanoia` -> (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge. - -Auf die Ansage „mach es" wird sie hier geschlossen. Übernommen wird nichts -von abcde — es gibt keine Windows-Fassung, und ein Nachbau seiner Shell-Logik -wäre ein zweites Projekt. Gebaut wird der Weg, den Windows selbst anbietet. - -## Warum eine Audio-CD nicht einfach kopiert werden kann - -Windows zeigt auf einer Audio-CD `Track01.cda` & Co. Das sind **keine -Audiodateien**, sondern 44 Byte große Platzhalter mit der Track-Nummer darin. -Die Musik liegt roh auf der Disc, in 2352-Byte-Sektoren ohne Dateisystem — -44100 Hz, 16 Bit, Stereo, little endian. Wer sie will, muss sie sektorweise -lesen. - -## Die zwei Win32-Aufrufe - -`IOCTL_CDROM_READ_TOC` liefert das Inhaltsverzeichnis: je Track die -Startadresse als MSF (Minute/Sekunde/Frame) und ob er Audio ist. - -`IOCTL_CDROM_RAW_READ` liest die Sektoren. Zwei Fallen, beide dokumentiert -(learn.microsoft.com, `RAW_READ_INFO`): - -* `DiskOffset` zählt in **2048er**-Einheiten, obwohl ein Audio-Sektor 2352 - Bytes hat. `sektor * 2048` ist also richtig und sieht falsch aus. -* Der Ausgabepuffer muss `SectorCount * 2352` groß sein. - -## Was hier PUR ist — und warum das der Punkt ist - -Ein Laufwerk lässt sich in der Ampel nicht herstellen. Deshalb steht alles, -was sich rechnen lässt, in reinen Funktionen: MSF↔LBA, das Zerlegen der -TOC-Bytes, der WAV-Kopf, die Aufteilung in Lese-Blöcke. Geprüft wird damit -das, woran solche Arbeit erfahrungsgemäß scheitert — die Rechnung, nicht der -Treiber. -""" - -import struct - -#: Ein Audio-Sektor auf einer CD: 588 Sample-Paare à 4 Byte. -SEKTOR_BYTES = 2352 - -#: `RAW_READ_INFO.DiskOffset` zählt in diesen Einheiten — NICHT in 2352. -#: (learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info) -OFFSET_EINHEIT = 2048 - -#: 75 Frames je Sekunde, und die ersten 2 Sekunden sind Vorlauf. -FRAMES_JE_SEKUNDE = 75 -VORLAUF_FRAMES = 150 - -#: Wie viele Sektoren auf einmal gelesen werden. 27 * 2352 = 63504 Bytes — -#: knapp unter der 64-KB-Grenze, die viele Treiber je Anfrage setzen. -BLOCK_SEKTOREN = 27 - -#: Audioformat einer CD: 44100 Hz, 16 Bit, Stereo. -RATE = 44100 -BITS = 16 -KANAELE = 2 - -#: TRACK_MODE_TYPE.CDDA aus ntddcdrm.h -CDDA = 2 - - -def msf_zu_lba(minute: int, sekunde: int, frame: int) -> int: - """MSF-Adresse → logische Sektornummer (pure Funktion). - - Die Rechnung ist Standard (ECMA-130): Jede Sekunde hat 75 Frames, und die - Adressierung beginnt bei 00:02:00 — die ersten zwei Sekunden sind - Vorlauf. Wer die 150 vergisst, liest jeden Track zwei Sekunden zu früh. - """ - return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES - - -def dauer_text(sektoren: int) -> str: - """Sektoren → „M:SS" (pure Funktion).""" - gesamt = max(0, sektoren) // FRAMES_JE_SEKUNDE - return "%d:%02d" % (gesamt // 60, gesamt % 60) - - -def toc_zerlegen(rohdaten: bytes) -> list: - """Die Bytes von IOCTL_CDROM_READ_TOC → Track-Liste (pure Funktion). - - Aufbau von `CDROM_TOC` (ntddcdrm.h): - - 0..1 Length (big endian, ohne diese zwei Bytes) - 2 FirstTrack - 3 LastTrack - 4.. TrackData[], je 8 Byte: - 0 Reserved - 1 Adr:4 / Control:4 — Control-Bit 2 gesetzt = DATEN-Track - 2 TrackNumber — 0xAA ist der Lead-Out - 3 Reserved - 4..7 Address — 0, M, S, F - - Rückgabe: `[{"nr", "start", "sektoren", "audio", "dauer"}, …]`. Die Länge - eines Tracks ergibt sich aus dem Start des NÄCHSTEN — deshalb muss der - Lead-Out mitgelesen werden, sonst fehlt beim letzten Track die Länge. - """ - if not rohdaten or len(rohdaten) < 12: - return [] - eintraege = [] - stelle = 4 - while stelle + 8 <= len(rohdaten): - _res, adr_control, nummer, _res2, _null, m, s, f = struct.unpack( - ">BBBBBBBB", rohdaten[stelle:stelle + 8]) - eintraege.append({ - "nr": nummer, - "lba": msf_zu_lba(m, s, f), - # Control-Bit 2 (0x04) heisst „Daten-Track". Eine Misch-CD hat - # beides; wir rippen nur die Audio-Spuren. - "audio": not (adr_control & 0x04), - }) - stelle += 8 - - spuren = [] - for i, eintrag in enumerate(eintraege): - if eintrag["nr"] == 0xAA: # Lead-Out: nur als Endmarke - continue - naechster = eintraege[i + 1]["lba"] if i + 1 < len(eintraege) else None - if naechster is None: - continue # ohne Endmarke keine Laenge - laenge = naechster - eintrag["lba"] - if laenge <= 0: - continue - spuren.append({ - "nr": eintrag["nr"], - "start": eintrag["lba"], - "sektoren": laenge, - "audio": eintrag["audio"], - "dauer": dauer_text(laenge), - }) - return spuren - - -def bloecke(start: int, sektoren: int, je_block: int = BLOCK_SEKTOREN) -> list: - """Einen Track in Lese-Blöcke zerlegen (pure Funktion). - - `[(start_sektor, anzahl), …]`. Am Stück zu lesen scheitert: Viele Treiber - nehmen je Anfrage höchstens 64 KB, und ein Track hat schnell 20 000 - Sektoren (47 MB). - """ - if sektoren <= 0 or je_block <= 0: - return [] - teile = [] - gelesen = 0 - while gelesen < sektoren: - anzahl = min(je_block, sektoren - gelesen) - teile.append((start + gelesen, anzahl)) - gelesen += anzahl - return teile - - -def wav_kopf(daten_bytes: int, rate: int = RATE, kanaele: int = KANAELE, - bits: int = BITS) -> bytes: - """Der 44-Byte-WAV-Kopf für rohe CD-Audiodaten (pure Funktion). - - CD-Audio ist bereits PCM in genau dem Format, das WAV erwartet — es fehlt - nur der Kopf. Deshalb wird nichts umgerechnet: Die Sektoren wandern - unverändert hinter diese 44 Byte. - """ - byte_rate = rate * kanaele * bits // 8 - block = kanaele * bits // 8 - return (b"RIFF" + struct.pack(" str: - """Ein Datei- oder Ordnername ohne verbotene Zeichen (pure Funktion). - - ⚠️ Das ist keine Kosmetik. Beim Beweis der MusicBrainz-Abfrage kam als - Ordnername `AC/DC - Back in Black` heraus (29.08.2026) — der Schrägstrich - hätte unter Windows einen Unterordner `DC - Back in Black` in einem Ordner - `AC` aufgemacht. Die Band heißt nun mal so. - - Ersetzt wird durch nichts; ein Bindestrich wäre geraten. Punkte und - Leerzeichen am Ende fallen weg — die verweigert der Explorer. - """ - sauber = "".join(z for z in (text or "") if z not in VERBOTEN) - return " ".join(sauber.split()).rstrip(". ") - - -def dateiname(nr: int, titel: str = "") -> str: - """`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist.""" - sauber = sauberer_name(titel) - return ("%02d - %s" % (nr, sauber)) if sauber else ("Track %02d" % nr) - - -def flac_befehl(programm: str, wav: str, ziel: str, tags: dict = None) -> list: - """Der FLAC-Aufruf (pure Funktion, testbar). - - `-8` ist die beste Kompression; sie kostet bei Audio-CD-Material Sekunden - und spart gegenüber `-5` spürbar Platz. `--totally-silent`, weil Rippy den - Fortschritt selbst meldet. - """ - befehl = [programm, "-8", "--totally-silent", "-o", ziel] - for name, wert in (tags or {}).items(): - if wert: - befehl += ["--tag", "%s=%s" % (name, wert)] - befehl.append(wav) - return befehl - - -def rippen(geraet: str, ziel_ordner: str, leser, encoder: str, - fortschritt=None, laufen=None, tags_je_spur=None) -> dict: - """Eine Audio-CD nach FLAC rippen. Der Windows-Weg. - - ## Warum das hier steht und nicht in `ripping.py` - - `ripping.py` ist der Worker-Code mit abcde und makemkvcon — beides - Linux-Werkzeuge oder Windows-Programme mit eigener Logik. Dieser Weg - gehört zum gemeinsamen Paket, weil er nur zwei Dinge braucht: einen - `leser` (die Win32-Seite) und einen Encoder. - - Alles Fremde ist eingespritzt: - - * `leser.toc(geraet)` -> Spurliste - * `leser.spur(geraet, start, n, schreiben)` -> Sektoren - * `laufen(befehl)` -> ruft FLAC auf - - Damit ist der ganze Ablauf ohne Laufwerk und ohne Encoder prüfbar — und - genau das war bei jedem Fehler dieser Sitzung der Unterschied zwischen - „gefunden" und „übersehen". - """ - import os - import subprocess - import tempfile - - def melde(anteil, text=""): - if fortschritt: - fortschritt(anteil, text) - - spuren = [s for s in leser.toc(geraet) if s["audio"]] - if not spuren: - return {"status": "error", - "error": "Keine Audio-Spuren gefunden. Ist das wirklich eine " - "Audio-CD? (Daten-Discs kommen hier nicht an.)"} - if not encoder: - return {"status": "error", - "error": "Der FLAC-Encoder fehlt. Er wird beim Einrichten " - "geholt — Einstellungen → System zeigt den Stand."} - - os.makedirs(ziel_ordner, exist_ok=True) - laufen = laufen or (lambda b: subprocess.run( - b, capture_output=True, timeout=600, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))) - dateien = [] - - for i, spur in enumerate(spuren): - tags = (tags_je_spur or {}).get(spur["nr"]) or {} - name = dateiname(spur["nr"], tags.get("TITLE", "")) - ziel = os.path.join(ziel_ordner, name + ".flac") - melde(int(i * 100 / len(spuren)), - "Spur %d von %d (%s)" % (i + 1, len(spuren), spur["dauer"])) - - # Erst nach WAV, dann kodieren. Direkt in FLAC zu leiten waere - # eleganter — aber ein abgebrochener Lesevorgang hinterliesse dann - # eine halbe FLAC-Datei, die wie eine ganze aussieht. - with tempfile.TemporaryDirectory(prefix="rippy-cd-") as arbeit: - wav = os.path.join(arbeit, "spur.wav") - bytes_gesamt = spur["sektoren"] * SEKTOR_BYTES - with open(wav, "wb") as f: - f.write(wav_kopf(bytes_gesamt)) - anteil_basis = i / len(spuren) - - def teil(anteil, _b=anteil_basis, _n=len(spuren)): - melde(int((_b + anteil / _n) * 100)) - - leser.spur(geraet, spur["start"], spur["sektoren"], f.write, teil) - - ergebnis = laufen(flac_befehl(encoder, wav, ziel, tags)) - if getattr(ergebnis, "returncode", 0) != 0: - fehler = getattr(ergebnis, "stderr", b"") or b"" - return {"status": "error", - "error": "FLAC brach bei Spur %d ab: %s" - % (spur["nr"], - fehler.decode("utf-8", "replace")[:200])} - dateien.append(ziel) - - melde(100, "Fertig") - return {"status": "success", "output_dir": ziel_ordner, "files": dateien} diff --git a/src/rippy/rip/musicbrainz_cd.py b/src/rippy/rip/musicbrainz_cd.py deleted file mode 100644 index f445fc1..0000000 --- a/src/rippy/rip/musicbrainz_cd.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Die Disc-Kennung einer Audio-CD und die Tags dazu. - -## Warum es das gibt (Commander, 29.08.2026) - -Audio-CDs laufen unter Windows seit heute — die Dateien hiessen aber -`Track 01.flac`. Eine Musiksammlung ohne Titel ist eine Sammlung von Nummern. - -## Wie MusicBrainz eine CD wiedererkennt - -Nicht am Namen (den kennt die CD nicht) und nicht an einer Kennung auf der -Disc (die gibt es nicht), sondern an der **Lage ihrer Spuren**. Zwei Pressungen -desselben Albums haben verschiedene Abstaende, zwei Exemplare derselben -Pressung dieselben. Daraus wird ein Fingerabdruck gerechnet: - - Text = erste Spur (2 Hex, gross) - + letzte Spur (2 Hex) - + Lead-Out-Versatz (8 Hex) - + 99 Spur-Versaetze (je 8 Hex, fehlende als Nullen) - Kennung = base64(sha1(Text)) mit + / = ersetzt durch . _ - - -Quelle: musicbrainz.org/doc/Disc_ID_Calculation (AGENTS Regel D — die -Rechnung steht dort, nicht in meinem Kopf). - -## Die Falle: der Vorlauf - -Die Versaetze in dieser Rechnung zaehlen **mit** den 150 Frames Vorlauf — es -sind MSF-Adressen, keine LBA-Nummern. `rippy.rip.audio_cd` rechnet den -Vorlauf beim Lesen bewusst heraus (dort ist er falsch). Wer hier dieselben -Zahlen einsetzt, bekommt eine Kennung, die MusicBrainz nicht kennt — und zwar -ohne Fehlermeldung, denn die Antwort ist dann einfach „unbekannte Disc". -""" - -import base64 -import hashlib -import json -import urllib.parse -import urllib.request - -from rippy.rip.audio_cd import VORLAUF_FRAMES - -#: Der Webdienst. `fmt=json` spart uns einen XML-Leser. -DIENST = "https://musicbrainz.org/ws/2/discid/" - -#: MusicBrainz verlangt eine sprechende Kennung mit Kontakt (Richtlinie auf -#: musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting). Ohne sie wird gedrosselt -#: oder geblockt. -KOPF = {"User-Agent": "Rippy/2.0 (https://github.com/Hitonabi/rippy)"} - -#: Wie viele Spur-Versaetze im Text stehen — immer 99, auch bei 12 Spuren. -PLAETZE = 99 - - -def disc_id(spur_versaetze, lead_out: int, erste: int = 1, - letzte: int = None) -> str: - """Die MusicBrainz-Kennung einer CD (pure Funktion). - - `spur_versaetze` sind die Startadressen der Spuren **einschliesslich** - Vorlauf, `lead_out` das Ende der letzten Spur — ebenfalls mit Vorlauf. - """ - if not spur_versaetze: - return "" - letzte = letzte if letzte is not None else erste + len(spur_versaetze) - 1 - teile = ["%02X" % erste, "%02X" % letzte, "%08X" % lead_out] - for platz in range(PLAETZE): - wert = spur_versaetze[platz] if platz < len(spur_versaetze) else 0 - teile.append("%08X" % wert) - roh = hashlib.sha1("".join(teile).encode("ascii")).digest() - # Base64 mit MusicBrainz' eigenem Alphabet: + / = werden zu . _ - - return base64.b64encode(roh).decode("ascii").translate( - str.maketrans("+/=", "._-")) - - -def kennung_aus_spuren(spuren) -> str: - """Kennung direkt aus `audio_cd.toc_zerlegen()` (pure Funktion). - - Rechnet den Vorlauf wieder HINZU: `audio_cd` liefert LBA-Nummern (fuer - das Lesen richtig), MusicBrainz will MSF-Adressen. Genau hier gehen - solche Rechnungen sonst schief. - """ - if not spuren: - return "" - geordnet = sorted(spuren, key=lambda s: s["nr"]) - versaetze = [s["start"] + VORLAUF_FRAMES for s in geordnet] - letzte_spur = geordnet[-1] - lead_out = letzte_spur["start"] + letzte_spur["sektoren"] + VORLAUF_FRAMES - return disc_id(versaetze, lead_out, - erste=geordnet[0]["nr"], letzte=letzte_spur["nr"]) - - -def abfragen(kennung: str, laden=None, timeout: int = 15): - """Die Disc bei MusicBrainz nachschlagen. `None`, wenn unbekannt. - - `laden` ist einspritzbar — damit ist alles darunter ohne Netz pruefbar. - """ - if not kennung: - return None - url = (DIENST + urllib.parse.quote(kennung, safe="._-") - + "?fmt=json&inc=recordings+artist-credits") - if laden is None: - def laden(u): - anfrage = urllib.request.Request(u, headers=KOPF) - with urllib.request.urlopen(anfrage, timeout=timeout) as antwort: - return antwort.read() - try: - return json.loads(laden(url).decode("utf-8", "replace")) - except Exception: # noqa: BLE001 - # Unbekannte Disc, kein Netz, geaendertes Format — alles dasselbe - # Ergebnis: keine Tags. Ein Rip darf daran nicht scheitern. - return None - - -def _kuenstler(eintrag) -> str: - """„Artist Credit" zu einem Namen zusammensetzen (pure Funktion). - - MusicBrainz gibt Beteiligungen als Liste mit Bindewoertern zurueck — - „Simon" + " & " + „Garfunkel". Wer nur den ersten Namen nimmt, verliert - die Haelfte. - """ - teile = [] - for stueck in eintrag or []: - teile.append((stueck.get("name") or "") - + (stueck.get("joinphrase") or "")) - return "".join(teile).strip() - - -def tags_je_spur(antwort) -> dict: - """MusicBrainz-Antwort → `{spur_nr: {TAG: wert}}` (pure Funktion). - - Genommen wird die ERSTE Veroeffentlichung, die diese Disc enthaelt. Es - koennen mehrere sein (Wiederveroeffentlichungen mit identischer - Spurlage) — dann ist jede gleich richtig, und eine Auswahl waere geraten. - """ - if not antwort: - return {} - releases = antwort.get("releases") or [] - if not releases: - return {} - release = releases[0] - album = release.get("title") or "" - jahr = (release.get("date") or "")[:4] - album_kuenstler = _kuenstler(release.get("artist-credit")) - - tags = {} - for medium in release.get("media") or []: - for spur in medium.get("tracks") or []: - try: - nr = int(spur.get("position") or 0) - except (TypeError, ValueError): - continue - if nr <= 0: - continue - kuenstler = (_kuenstler(spur.get("artist-credit")) - or album_kuenstler) - tags[nr] = { - "TITLE": spur.get("title") or "", - "ARTIST": kuenstler, - "ALBUM": album, - "ALBUMARTIST": album_kuenstler, - "DATE": jahr, - "TRACKNUMBER": str(nr), - } - return tags - - -def album_ordner(antwort) -> str: - """`Künstler - Album` für den Zielordner — "" wenn nichts bekannt ist.""" - tags = tags_je_spur(antwort) - if not tags: - return "" - erste = tags[min(tags)] - kuenstler = erste.get("ALBUMARTIST") or erste.get("ARTIST") or "" - album = erste.get("ALBUM") or "" - from rippy.rip.audio_cd import sauberer_name - - roh = "%s - %s" % (kuenstler, album) if (kuenstler and album) else (album or kuenstler) - # ⚠️ „AC/DC - Back in Black" (beim Beweis herausgekommen) haette unter - # Windows einen Unterordner aufgemacht. Die Band heisst nun mal so. - return sauberer_name(roh) diff --git a/src/rippy/rip/test_audio_cd.py b/src/rippy/rip/test_audio_cd.py deleted file mode 100644 index a1ca4fd..0000000 --- a/src/rippy/rip/test_audio_cd.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Audio-CD unter Windows — die Rechnung, ohne Laufwerk geprüft. - -## Warum es das gibt (Commander, 29.08.2026) - -Nach dem Rundgang durch die Docker-Reste blieb eine Lücke offen, die seit -Wochen so im SAVEPOINT stand: **Audio-CDs laufen unter Windows nicht** — -`cdparanoia` und `abcde` sind Linux-Werkzeuge. Auf „mach es" wird sie -geschlossen. - -Ein Laufwerk lässt sich in der Ampel nicht herstellen, ein Encoder auch -nicht. Beides ist deshalb eingespritzt, und geprüft wird das, woran solche -Arbeit wirklich scheitert: die Adressrechnung, das Zerlegen der TOC-Bytes, -der WAV-Kopf, die Blockaufteilung. -""" - -import struct - -import pytest - -from rippy.rip import audio_cd - - -# ── Adressrechnung ────────────────────────────────────────────────────── -@pytest.mark.parametrize("m,s,f,erwartet", [ - (0, 2, 0, 0), # der Anfang der ersten Spur - (0, 3, 0, 75), # eine Sekunde spaeter - (1, 2, 0, 4500), # eine Minute - # (3*60+25)*75 + 40 - 150 = 15265 — nachgerechnet, nicht geschaetzt. - (3, 25, 40, 15265), -]) -def test_msf_wird_zur_richtigen_sektornummer(m, s, f, erwartet): - """Die 150 Frames Vorlauf sind der Klassiker: Wer sie vergisst, liest - jede Spur zwei Sekunden zu frueh.""" - assert audio_cd.msf_zu_lba(m, s, f) == erwartet - - -def test_der_vorlauf_ist_wirklich_abgezogen(): - assert audio_cd.msf_zu_lba(0, 0, 0) == -audio_cd.VORLAUF_FRAMES - - -@pytest.mark.parametrize("sektoren,text", [ - (0, "0:00"), - (75, "0:01"), - (75 * 61, "1:01"), - (75 * 214, "3:34"), -]) -def test_dauer_wird_lesbar(sektoren, text): - assert audio_cd.dauer_text(sektoren) == text - - -# ── Das Inhaltsverzeichnis ────────────────────────────────────────────── -def _eintrag(nr, m, s, f, daten=False): - return struct.pack(">BBBBBBBB", 0, 0x14 if daten else 0x10, nr, 0, 0, m, s, f) - - -def _toc(*eintraege): - rumpf = b"".join(eintraege) - return struct.pack(">HBB", len(rumpf) + 2, 1, len(eintraege) - 1) + rumpf - - -def test_eine_normale_audio_cd_wird_zerlegt(): - roh = _toc(_eintrag(1, 0, 2, 0), - _eintrag(2, 3, 27, 0), - _eintrag(0xAA, 7, 12, 0)) # Lead-Out - spuren = audio_cd.toc_zerlegen(roh) - - assert [s["nr"] for s in spuren] == [1, 2] - assert spuren[0]["start"] == 0 - assert spuren[0]["sektoren"] == audio_cd.msf_zu_lba(3, 27, 0) - assert all(s["audio"] for s in spuren) - - -def test_die_letzte_spur_bekommt_ihre_laenge_aus_dem_lead_out(): - """Ohne den Lead-Out fehlt beim letzten Track die Laenge — er waere dann - entweder 0 lang oder wuerde ueber das Disc-Ende hinaus gelesen.""" - roh = _toc(_eintrag(1, 0, 2, 0), _eintrag(0xAA, 5, 2, 0)) - spuren = audio_cd.toc_zerlegen(roh) - assert len(spuren) == 1 - assert spuren[0]["sektoren"] == 5 * 60 * 75 - - -def test_daten_spuren_werden_als_solche_erkannt(): - """Eine Misch-CD hat beides. Nur die Audio-Spuren werden gerippt — und - genau daran haengt, dass eine Blu-ray hier nicht als Musik durchgeht.""" - roh = _toc(_eintrag(1, 0, 2, 0, daten=True), _eintrag(0xAA, 9, 9, 0)) - assert audio_cd.toc_zerlegen(roh)[0]["audio"] is False - - -def test_leere_oder_kaputte_daten_ergeben_keine_spuren(): - """Ohne Disc kommt Unsinn zurueck — das darf nichts umwerfen.""" - for roh in (b"", b"\x00" * 4, None): - assert audio_cd.toc_zerlegen(roh) == [] - - -# ── Lesen in Bloecken ─────────────────────────────────────────────────── -def test_ein_track_wird_in_bloecke_zerlegt(): - """Am Stueck zu lesen scheitert: Viele Treiber nehmen je Anfrage - hoechstens 64 KB.""" - teile = audio_cd.bloecke(100, 60, je_block=27) - assert teile == [(100, 27), (127, 27), (154, 6)] - assert sum(n for _, n in teile) == 60 - - -def test_der_block_bleibt_unter_64_kb(): - """27 * 2352 = 63504 — knapp darunter. Wer hier hoeher geht, bekommt vom - Treiber einen Fehler statt Daten.""" - assert audio_cd.BLOCK_SEKTOREN * audio_cd.SEKTOR_BYTES < 65536 - - -def test_keine_bloecke_ohne_laenge(): - assert audio_cd.bloecke(0, 0) == [] - assert audio_cd.bloecke(0, -5) == [] - - -# ── Der WAV-Kopf ──────────────────────────────────────────────────────── -def test_wav_kopf_ist_44_byte_und_beschreibt_cd_audio(): - kopf = audio_cd.wav_kopf(1000) - assert len(kopf) == 44 - assert kopf[:4] == b"RIFF" and kopf[8:12] == b"WAVE" - kanaele, rate, _byte_rate, _block, bits = struct.unpack(":"/\\|?*': - assert zeichen not in name - - -# ── Der FLAC-Aufruf ───────────────────────────────────────────────────── -def test_flac_bekommt_ziel_und_quelle(): - befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac") - assert befehl[0] == "flac.exe" - assert befehl[-1] == "a.wav" - assert "-o" in befehl and "b.flac" in befehl - - -def test_tags_wandern_als_schalter_mit(): - befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac", - {"TITLE": "Lied", "ARTIST": ""}) - assert "TITLE=Lied" in befehl - assert not any(t.startswith("ARTIST=") for t in befehl), "leere Tags weglassen" - - -# ── Der ganze Ablauf, ohne Laufwerk und ohne Encoder ──────────────────── -class FakeLeser: - """Ein Laufwerk mit zwei Audio-Spuren und einer Daten-Spur.""" - - def __init__(self): - self.gelesen = [] - - def toc(self, geraet): - return [ - {"nr": 1, "start": 0, "sektoren": 54, "audio": True, "dauer": "0:00"}, - {"nr": 2, "start": 54, "sektoren": 27, "audio": True, "dauer": "0:00"}, - {"nr": 3, "start": 81, "sektoren": 99, "audio": False, "dauer": "0:01"}, - ] - - def spur(self, geraet, start, sektoren, schreiben, fortschritt=None): - self.gelesen.append((start, sektoren)) - schreiben(b"\0" * (sektoren * audio_cd.SEKTOR_BYTES)) - if fortschritt: - fortschritt(1.0) - - -class FertigMelder: - returncode = 0 - stderr = b"" - - -def test_ein_ganzer_rip_laeuft_durch(tmp_path): - aufrufe = [] - - def laufen(befehl): - aufrufe.append(befehl) - # FLAC schreibt normalerweise die Zieldatei — hier nachgestellt. - with open(befehl[befehl.index("-o") + 1], "wb") as f: - f.write(b"fLaC") - return FertigMelder() - - ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), - "flac.exe", laufen=laufen) - - assert ergebnis["status"] == "success" - assert len(ergebnis["files"]) == 2, "die Daten-Spur gehoert nicht dazu" - assert all(p.endswith(".flac") for p in ergebnis["files"]) - assert len(aufrufe) == 2 - - -def test_eine_daten_disc_wird_nicht_gerippt(tmp_path): - """Genau das ist beim Messen passiert: Im Laufwerk lag eine Blu-ray, und - die meldet sich als EIN Daten-Track ueber 1,1 Millionen Sektoren.""" - class NurDaten(FakeLeser): - def toc(self, geraet): - return [{"nr": 1, "start": 0, "sektoren": 1154141, - "audio": False, "dauer": "256:28"}] - - ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), NurDaten(), "flac.exe", - laufen=lambda b: FertigMelder()) - assert ergebnis["status"] == "error" - assert "Audio-CD" in ergebnis["error"] - - -def test_ohne_encoder_wird_nichts_behauptet(tmp_path): - ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "") - assert ergebnis["status"] == "error" - assert "FLAC-Encoder" in ergebnis["error"] - - -def test_ein_encoder_fehlschlag_wird_gemeldet(tmp_path): - class Kaputt: - returncode = 1 - stderr = b"ERROR: no space left" - - ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "flac.exe", - laufen=lambda b: Kaputt()) - assert ergebnis["status"] == "error" - assert "no space left" in ergebnis["error"] - - -def test_die_spuren_werden_vollstaendig_gelesen(tmp_path): - leser = FakeLeser() - audio_cd.rippen(r"\\.\G:", str(tmp_path), leser, "flac.exe", - laufen=lambda b: FertigMelder()) - assert leser.gelesen == [(0, 54), (54, 27)], \ - "jede Audio-Spur genau einmal, von ihrem Start bis zu ihrer Laenge" diff --git a/src/rippy/rip/test_musicbrainz_cd.py b/src/rippy/rip/test_musicbrainz_cd.py deleted file mode 100644 index 56f14c3..0000000 --- a/src/rippy/rip/test_musicbrainz_cd.py +++ /dev/null @@ -1,179 +0,0 @@ -"""Die Disc-Kennung und die Tags — an echten MusicBrainz-Daten geprüft. - -## Warum das ohne Audio-CD geht - -MusicBrainz gibt zu jeder bekannten Kennung die **Spurlage** heraus, aus der -sie gerechnet wurde. Wer die Lage zurückbekommt und daraus dieselbe Kennung -rechnet, hat die Rechnung belegt — ganz ohne Disc. - -Genau so wurde sie am 29.08.2026 abgenommen, gegen „Back in Black" von -AC/DC. Die Zahlen unten sind **nicht ausgedacht**, sie stammen aus dieser -Abfrage: - - https://musicbrainz.org/ws/2/release/?inc=discids&fmt=json - - Kennung 3KVSIWn_ewv9z0cCizmOJzDWtsQ- - Spuren 10 - Lead-Out 189281 - -Der Test läuft ohne Netz: Die Antwort ist hier festgehalten, nicht abgerufen. -""" - -import pytest - -from rippy.rip import audio_cd -from rippy.rip import musicbrainz_cd as mb - -#: Aus der echten MusicBrainz-Antwort (29.08.2026, AC/DC „Back in Black"), -#: abgerufen über `https://musicbrainz.org/ws/2/discid/?fmt=json`. -#: -#: ⚠️ Beim ersten Anlauf standen hier ausgedachte Zahlen — ich hatte beim -#: Beweis nur Spurzahl und Lead-Out ausgegeben und den Rest „passend" -#: ergänzt. Der Test wurde prompt rot, und zwar zu Recht. Diese Zahlen sind -#: abgefragt, nicht erinnert. -ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937, - 133712, 151660, 170112] -ACDC_LEAD_OUT = 189281 -ACDC_KENNUNG = "3KVSIWn_ewv9z0cCizmOJzDWtsQ-" - - -def test_die_kennung_stimmt_mit_musicbrainz_ueberein(): - """DER Beleg. Weicht auch nur ein Zeichen ab, findet MusicBrainz die Disc - nicht — und zwar ohne Fehlermeldung, denn die Antwort waere dann schlicht - „unbekannte Disc".""" - assert mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT, - erste=1, letzte=10) == ACDC_KENNUNG - - -def test_der_vorlauf_wird_wieder_hinzugerechnet(): - """Die Falle: `audio_cd` liefert LBA-Nummern (fuer das LESEN richtig), - MusicBrainz will MSF-Adressen — also 150 mehr. Wer das verwechselt, - bekommt eine Kennung, die niemand kennt.""" - # Dieselbe Disc, aber so, wie `toc_zerlegen` sie liefert. - spuren = [] - for i, versatz in enumerate(ACDC_VERSAETZE): - naechster = (ACDC_VERSAETZE[i + 1] if i + 1 < len(ACDC_VERSAETZE) - else ACDC_LEAD_OUT) - spuren.append({"nr": i + 1, - "start": versatz - audio_cd.VORLAUF_FRAMES, - "sektoren": naechster - versatz, - "audio": True, "dauer": ""}) - assert mb.kennung_aus_spuren(spuren) == ACDC_KENNUNG - - -def test_ohne_spuren_gibt_es_keine_kennung(): - assert mb.disc_id([], 1000) == "" - assert mb.kennung_aus_spuren([]) == "" - - -def test_die_kennung_benutzt_das_musicbrainz_alphabet(): - """Nicht das normale Base64: + / = werden zu . _ - — sonst passt sie - nicht in eine URL und der Dienst antwortet mit 404.""" - kennung = mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT, letzte=10) - assert not set("+/=") & set(kennung) - - -def test_es_stehen_immer_99_plaetze_im_text(): - """Auch bei 10 Spuren. Wer nur die vorhandenen einsetzt, rechnet einen - anderen Hash.""" - assert mb.PLAETZE == 99 - # Zwei Discs mit gleicher Spurzahl, aber anderer Lage -> andere Kennung. - a = mb.disc_id([150, 20000], 40000, letzte=2) - b = mb.disc_id([150, 20001], 40000, letzte=2) - assert a != b - - -# ── Die Tags ──────────────────────────────────────────────────────────── -ANTWORT = { - "releases": [{ - "title": "Back in Black", - "date": "1980-07-25", - "artist-credit": [{"name": "AC/DC"}], - "media": [{"tracks": [ - {"position": 1, "title": "Hells Bells"}, - {"position": 2, "title": "Shoot to Thrill"}, - ]}], - }] -} - - -def test_tags_kommen_je_spur(): - tags = mb.tags_je_spur(ANTWORT) - assert tags[1]["TITLE"] == "Hells Bells" - assert tags[2]["TRACKNUMBER"] == "2" - assert tags[1]["ALBUM"] == "Back in Black" - assert tags[1]["DATE"] == "1980", "nur das Jahr, nicht das ganze Datum" - - -def test_ohne_antwort_gibt_es_keine_tags(): - """Eine unbekannte Disc ist kein Fehler — die Dateien heissen dann - `Track 01.flac`, und das ist die Wahrheit.""" - assert mb.tags_je_spur(None) == {} - assert mb.tags_je_spur({}) == {} - assert mb.tags_je_spur({"releases": []}) == {} - - -def test_mehrere_beteiligte_werden_zusammengesetzt(): - """MusicBrainz gibt „Simon" + „ & " + „Garfunkel". Wer nur den ersten - Namen nimmt, verliert die Haelfte der Band.""" - antwort = {"releases": [{ - "title": "X", "artist-credit": [ - {"name": "Simon", "joinphrase": " & "}, {"name": "Garfunkel"}], - "media": [{"tracks": [{"position": 1, "title": "Y"}]}]}]} - assert mb.tags_je_spur(antwort)[1]["ARTIST"] == "Simon & Garfunkel" - - -def test_ein_abweichender_spur_kuenstler_gewinnt(): - """Sampler: Jede Spur hat ihren eigenen Interpreten, das Album einen - Sammelnamen.""" - antwort = {"releases": [{ - "title": "Sampler", "artist-credit": [{"name": "Various Artists"}], - "media": [{"tracks": [ - {"position": 1, "title": "A", "artist-credit": [{"name": "Nena"}]}]}]}]} - tags = mb.tags_je_spur(antwort)[1] - assert tags["ARTIST"] == "Nena" - assert tags["ALBUMARTIST"] == "Various Artists" - - -def test_der_ordnername_ist_windows_tauglich(): - """`AC/DC - Back in Black` haette einen Unterordner aufgemacht — genau - das kam beim Beweis am 29.08.2026 heraus.""" - name = mb.album_ordner(ANTWORT) - assert name == "ACDC - Back in Black" - for zeichen in audio_cd.VERBOTEN: - assert zeichen not in name - - -def test_ohne_treffer_kein_ordnername(): - assert mb.album_ordner(None) == "" - - -# ── Die Abfrage ───────────────────────────────────────────────────────── -def test_die_abfrage_baut_die_richtige_adresse(): - gesehen = {} - - def laden(url): - gesehen["url"] = url - return b'{"releases": []}' - - mb.abfragen(ACDC_KENNUNG, laden=laden) - assert ACDC_KENNUNG in gesehen["url"] - assert "fmt=json" in gesehen["url"] - assert "recordings" in gesehen["url"], "ohne Aufnahmen gibt es keine Titel" - - -def test_ein_netzfehler_ist_kein_grund_zu_scheitern(): - """Ein Rip darf nicht daran haengen, ob MusicBrainz gerade antwortet.""" - def kaputt(url): - raise OSError("kein Netz") - - assert mb.abfragen(ACDC_KENNUNG, laden=kaputt) is None - - -def test_ohne_kennung_wird_gar_nicht_gefragt(): - assert mb.abfragen("", laden=lambda u: 1 / 0) is None - - -@pytest.mark.parametrize("muell", [b"kein json", b"", b""]) -def test_eine_unlesbare_antwort_wirft_nicht(muell): - assert mb.abfragen("x", laden=lambda u: muell) is None diff --git a/src/rippy/setup_fenster.py b/src/rippy/setup_fenster.py deleted file mode 100644 index f42aba9..0000000 --- a/src/rippy/setup_fenster.py +++ /dev/null @@ -1,698 +0,0 @@ -"""Das Fenster des Einrichtungs-Assistenten. - -## Warum es das gibt - -Commander am 28.08.2026: - -> „Dann hätte ich beim ‚Setup' auch erwartet das nen echtes setup passiert — -> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt -> garnichts Rippy geht einfach auf." - -## Warum WebView2 und nicht ein Windows-Dialog - -Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit -(Entscheid 4 in `KONZEPT-V2.md` § 10) — der Assistent kostet damit **null -zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen -ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau -ausdrücklich ausgeschlossen. - -## Warum die Prüfungen NICHT hier stehen - -Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar. -Was hier steht, lässt sich nicht testen — also steht hier so wenig wie -möglich: Aufbau, Anzeige, und die Brücke zwischen beidem. - -## Was passiert, wenn das Fenster nicht aufgeht - -Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein -Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer -als eines, das nicht fragt. -""" - -import threading - -from rippy import einrichtung - -TITEL = "Rippy einrichten" -BREITE = 860 -HOEHE = 760 - - -# Die Abschnitte der Installation und was sie am Balken belegen. -# -# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert -# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen -# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der -# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei -# 90 % minutenlang steht, ist schlimmer als gar keiner. -ABSCHNITTE = { - "dateien": (0.00, 0.08, "Dateien werden abgelegt"), - "ablage": (0.08, 0.10, "Ablage wird gespeichert"), - "werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"), - "fertig": (0.97, 1.00, "Fertig"), -} - - -class Fortschritt: - """Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um. - - Reine Rechnerei, damit sie ohne Fenster prüfbar ist — und weil ein - Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als - keiner. - - Zwei Regeln: - - * **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads - enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der - Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang. - * **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine - Aussage über den Fortschritt. - """ - - def __init__(self): - self.prozent = 0 - self.abschnitt = "" - self.zeilen = [] - - def beginne(self, name: str) -> None: - von, _bis, titel = ABSCHNITTE[name] - self.abschnitt = titel - self._von, self._bis, _ = ABSCHNITTE[name] - self.setze(von) - - def melde(self, text, anteil=None) -> None: - if text: - self.zeilen.append(str(text)) - if anteil is None: - return - anteil = min(1.0, max(0.0, float(anteil))) - self.setze(self._von + (self._bis - self._von) * anteil) - - def setze(self, anteil: float) -> None: - self.prozent = max(self.prozent, int(round(anteil * 100))) - - def abholen(self) -> dict: - """Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich.""" - zeilen, self.zeilen = self.zeilen, [] - return {"zeilen": zeilen, "prozent": self.prozent, - "abschnitt": self.abschnitt} - - -#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen` -#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10, -#: FOLDER=20, SAVE=30). -FOLDER_DIALOG = 20 - - -class Bruecke: - """Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML. - - Jede Methode gibt Daten zurück, keine Erfolgsmeldungen — dieselbe Regel - wie überall sonst in diesem Projekt. - """ - - def __init__(self): - self.ergebnis = None - self._fenster = None - self._lauf = Fortschritt() - - # ── Auskunft ──────────────────────────────────────────────────────── - def vorgaben(self) -> dict: - return einrichtung.vorgaben() - - def pruefen(self, ziel: str = "", port: int = 0) -> dict: - """Der Voraussetzungs-Bericht für die angegebenen Werte.""" - vorgabe = einrichtung.vorgaben() - befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"], - int(port or vorgabe["port"])) - return { - "befunde": befunde, - "darf": einrichtung.darf_installieren(befunde), - "text": einrichtung.zusammenfassung(befunde), - } - - def updates(self) -> dict: - """Gibt es für die installierten Werkzeuge etwas Neueres? - - ## Warum das ein EIGENER Aufruf ist (28.08.2026) - - Commander: *„bitte baue ein, das wenn MakeMKV oder Handbrake schon - installiert sind ein update verfügbar ist."* - - Die Auskunft kostet Netz — und makemkv.com braucht dafür im - schlimmsten Fall gut zwanzig Sekunden (gemessen). In der - Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben. - Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und - diese Auskunft trägt die Update-Hinweise nach. - - Leere Werte heißen „nicht erreichbar", nicht „kein Update" — eine - Nichtauskunft ist keine Aussage. - """ - from rippy.tools import beschaffen, einrichten as werkzeuge, katalog - - try: - bestand = katalog.bestand() - neueste = beschaffen.neueste_versionen() - except Exception: # noqa: BLE001 - return {"erreichbar": False, "offen": []} - - offen = [{"name": name, - "titel": eintrag.get("titel", name), - "installiert": eintrag.get("version", ""), - "neueste": neu} - for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)] - return {"erreichbar": any(neueste.values()), "offen": offen} - - def ordner_waehlen(self, vorschlag: str = "") -> str: - """Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde.""" - # Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer - # da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in - # jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der - # nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und - # am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen - # eigenen Fehler. - try: - import webview - art = webview.FOLDER_DIALOG - except ImportError: - art = FOLDER_DIALOG - - try: - gewaehlt = self._fenster.create_file_dialog( - art, directory=vorschlag or "") - except Exception: # noqa: BLE001 - return "" - if not gewaehlt: - return "" - return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt) - - def fortschritt(self) -> dict: - """Was seit dem letzten Abruf passiert ist — Zeilen UND Stand. - - Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl, - und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text - und Stand auseinanderlaufen. - """ - return self._lauf.abholen() - - # ── Handeln ───────────────────────────────────────────────────────── - def installieren(self, optionen: dict) -> dict: - """Installiert im Hintergrund-Thread und gibt sofort zurück. - - Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter - Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster - sieht aus wie ein Absturz. - """ - threading.Thread(target=self._installieren_wirklich, - args=(optionen or {},), daemon=True).start() - return {"gestartet": True} - - def _installieren_wirklich(self, optionen: dict) -> None: - from rippy import windows_app - - melden = self._lauf.melde - - try: - self._lauf.beginne("dateien") - melden("Dateien werden abgelegt …") - ziel = windows_app.installieren( - ziel=optionen.get("ziel") or None, - autostart=bool(optionen.get("autostart", True)), - starten_danach=False, - verknuepfen=bool(optionen.get("verknuepfungen", True)), - ) - orte = {} - if optionen.get("ablage"): - orte["outputDir"] = optionen["ablage"] - # Der Arbeitsordner gehoert mit ins Setup (Commander 28.08.2026). - # Leer heisst „nimm die Vorgabe" — dann steht hier nichts, und der - # Betrieb rechnet ihn sich neben der Ablage aus. - if optionen.get("arbeit"): - orte["workDir"] = optionen["arbeit"] - if orte: - self._lauf.beginne("ablage") - melden("Ordner werden gespeichert …") - _einstellungen_schreiben(orte) - self._lauf.beginne("werkzeuge") - if optionen.get("werkzeuge_holen", True): - melden("Werkzeuge werden geprüft …") - from rippy.tools import einrichten as werkzeug_einrichtung - - bericht = werkzeug_einrichtung.sicherstellen( - fortschritt=melden, - aktualisieren=bool(optionen.get("aktualisieren", False))) - melden(werkzeug_einrichtung.bericht(bericht)) - bereit = bericht["bereit"] - _beta_key_holen(melden) - else: - bereit = True - self._lauf.beginne("fertig") - self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit} - melden("Fertig.", 1.0) - except Exception as ex: # noqa: BLE001 - # Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig - # unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr - # passiert. - self.ergebnis = {"ok": False, - "fehler": "%s: %s" % (type(ex).__name__, ex)} - melden("Fehlgeschlagen: %s" % ex) - - def stand(self) -> dict: - """None, solange es läuft. Danach das Ergebnis.""" - return self.ergebnis or {} - - def als_admin(self) -> dict: - """Startet das Setup mit Administratorrechten neu. - - Nur auf Knopfdruck, nie von selbst. Der Grund steht in - `winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen - Netzlaufwerke NICHT mehr — für eine Ablage auf einer Freigabe wäre das - genau der falsche Weg. - - Gibt zurück, ob der Neustart angenommen wurde. Ein „nein" ist der - Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt — dann bleibt - das Fenster einfach offen. - """ - from rippy import windows_app - from rippy.platform import winlauf - - geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei()) - if geklappt: - self.schliessen() - return {"gestartet": geklappt} - - def schliessen(self) -> None: - try: - self._fenster.destroy() - except Exception: # noqa: BLE001 - pass - - -def _ablage_speichern(pfad: str) -> None: - """Die gewählte Ablage in die Einstellungen schreiben. - - ## Der Fehler, der das Setup abgebrochen hat (28.08.2026) - - Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den - Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit: - - Fehlgeschlagen: ModuleNotFoundError: No module named 'db' - - Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der - Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt - seitdem `from rippy import store as db`. Ich habe ein Modul importiert, - das ich mir aus einem Alias zusammengereimt habe — genau die Sorte - erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal - im eigenen Code. - - Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war - ohnehin überflüssig. - """ - _einstellungen_schreiben({"outputDir": pfad}) - - -def _einstellungen_schreiben(neu: dict) -> None: - """Werte in die Einstellungen mischen — lesen, setzen, zurückschreiben. - - `save_settings` überschreibt das GANZE JSON, deshalb erst lesen. - """ - from rippy import config, store - - werte = config.laden() - store.verbinden(config.datenbank_url(werte)) - store.init_db() - aktuell = store.get_settings() or {} - aktuell.update(neu) - store.save_settings(aktuell) - - -def _beta_key_holen(melden=None) -> str: - """Den kostenlosen MakeMKV-Beta-Key aus dem Forum holen und ablegen. - - ## Warum das ins Setup gehört (Commander 28.08.2026) - - > „Bezüglich des MKV Beta Keys — der könnte theoretisch auch automatisch - > ausgelesen werden, ich glaube das web rippy kann das" - - Er hat recht, es kann: `docker/api/makemkv_key.py` holt den Key aus dem - Forum-Thread t=1053 und läuft seit jeher täglich mit. Nachgemessen am - 28.08.2026: Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen. - - Nur half das beim ERSTEN Rip nicht: Die Schleife startet 60 Sekunden nach - dem Serverstart. Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt - ohne Key — und MakeMKV fällt in den 30-Tage-Testmodus. - - Ohne Key ist Rippy nicht kaputt; DVDs gehen weiterhin. Deshalb ist ein - Fehlschlag hier auch kein Abbruch, sondern eine Zeile im Protokoll. - - Rechtlich unverändert: Das ist die öffentliche Beta-LIZENZ der Software, - NICHT ein Disc-Schlüssel. Rippy liefert und verteilt keine Disc-Schlüssel. - """ - melden = melden or (lambda *a, **k: None) - try: - import sys - - from rippy.daemon import _api_pfad - - # `_api_pfad` gibt den Pfad NUR zurueck — eingehaengt wird er in - # daemon.starten(). Hier laeuft der Assistent, nicht der Dienst. - pfad = _api_pfad() - if pfad and pfad not in sys.path: - sys.path.insert(0, pfad) - import makemkv_key - - key = makemkv_key.fetch_current_key() - except Exception as ex: # noqa: BLE001 - melden(" Beta-Key nicht erreichbar (%s) — nachtragbar unter " - "Einstellungen." % type(ex).__name__) - return "" - if not key: - melden(" Kein Beta-Key im Forum gefunden — nachtragbar unter " - "Einstellungen.") - return "" - _einstellungen_schreiben({"makemkvAppKey": key}) - - # Und jetzt nachsehen, ob MakeMKV ihn ueberhaupt annimmt. Am 29.08.2026 - # tat es das nicht: Version 1.18.4 ist aelter als der aktuelle Schluessel - # verlangt (MSG 5020 + 5021). Ein abgelehnter Schluessel ist SCHLIMMER als - # gar keiner — ohne las MakeMKV die Disc noch, mit dem abgelehnten - # verweigerte es alles. `key_setzen_und_pruefen` nimmt ihn deshalb wieder - # weg. Begruendung in `rippy.rip.makemkv_aufruf.key_pruefen`. - from rippy.rip import makemkv_aufruf - from rippy.tools import katalog - - # Was der Docker-Betrieb beim Start ausdruecklich setzt, gilt hier auch — - # uebersetzt in die Windows-Form (Registry statt settings.conf). Ohne den - # Web-Kontakt holt MakeMKV keine Disc-Schluessel fuer 4K-UHD nach. - for name in makemkv_aufruf.einstellungen_sicherstellen(): - melden(" MakeMKV-Einstellung gesetzt: %s" % name) - - urteil = makemkv_aufruf.key_setzen_und_pruefen(key, katalog.finden("makemkv")) - if urteil["ok"]: - melden(" MakeMKV-Beta-Key geholt und angenommen (…%s)." % key[-6:]) - else: - melden(" MakeMKV-Beta-Key geholt, aber NICHT angenommen: %s" - % urteil["grund"]) - return key - - -def starten() -> dict: - """Öffnet den Assistenten. Gibt zurück, was daraus geworden ist. - - `{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann, - ob er ohne Assistent installiert. - """ - import webview - - bruecke = Bruecke() - fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke, - width=BREITE, height=HOEHE, - min_size=(720, 560), text_select=True) - bruecke._fenster = fenster - webview.start(gui="edgechromium", private_mode=False) - return bruecke.ergebnis or {} - - -def moeglich() -> tuple: - """Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)""" - from rippy import fenster as fenster_modul - - return fenster_modul.verfuegbar() - - -# ── Die Seite ─────────────────────────────────────────────────────────── -# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine -# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne -# Internet aufgehen — dort wird er am dringendsten gebraucht. -SEITE = r""" - - -
-

Rippy einrichten

-
Wird geprüft, was dein Rechner mitbringt — danach entscheidest du, wohin.
-
-
-
-

Voraussetzungen

-
wird geprüft …
-
- -
-

Wohin

- - - - -
- -
-

Sonst noch

- - - - -
- -
-

Läuft

-
- wird vorbereitet … - 0 % -
-
-
-
-
-
-
wird geprüft …
- - - -
- - -""" - -# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests). -SEITE_LAENGE = len(SEITE) - - -def seite_enthaelt(text: str) -> bool: - """Kleine Hilfe für Tests: Steht etwas in der Seite?""" - return text in SEITE diff --git a/src/rippy/standalone.py b/src/rippy/standalone.py deleted file mode 100644 index f622ea0..0000000 --- a/src/rippy/standalone.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Standalone-Betrieb: Auftraege lokal zustellen und im selben Prozess abarbeiten. - -## Was hier zusammenkommt - -Das ist das letzte Stueck, damit Rippy unter Windows nicht nur ANZEIGEN, -sondern auch ARBEITEN kann: - - celery_client.zusteller_setzen(…) die API reiht lokal ein statt an Celery - rippy.queue.lokal die Auftragstabelle mit Lease - rippy.queue.laeufer holt sie und arbeitet sie ab - ablauf.rippen / .komprimieren der Ablauf selbst, ohne Celery - -Der Ablauf ist derselbe wie im Docker-Betrieb — Zeile fuer Zeile dieselbe -Datei. Was sich unterscheidet, ist allein die Zustellung. - -## Warum die Task-NAMEN erhalten bleiben - -Die API schickt `"worker.tasks.rip_disc"` los. Dieser Name ist der Vertrag -zwischen API und Worker; `test_api_smoke.py` nagelt ihn ausdruecklich fest. -Hier wird er deshalb NICHT durch etwas Eigenes ersetzt, sondern uebersetzt — -so bleibt beides gueltig, und ein Betrieb laesst sich umstellen, ohne dass -die API etwas davon merkt. - -## Warum die Kompression NICHT weitergereicht wird - -`ablauf.rippen()` nimmt einen Rueckruf `weiterreichen`, mit dem die -Kompression an einen anderen Knoten geht. Im Standalone-Betrieb gibt es -keinen anderen Knoten — hier komprimiert derselbe Prozess weiter. Deshalb -wird der Rueckruf bewusst NICHT gestellt: `ablauf` ruft dann selbst -`komprimieren()` auf, im selben Auftrag, ohne Umweg ueber die Queue. - -Das ist auch das Ehrlichere: Eine Queue mit genau einem Bearbeiter, die -Arbeit an sich selbst weiterreicht, ist nur Zeremonie. -""" - -import os -import sys -import threading - -# Die Auftragsarten, die die API kennt — auf die Formen, die `ablauf` versteht. -AUFTRAGSARTEN = { - "worker.tasks.rip_disc": "rip", - "worker.tasks.transcode_files": "transcode", - "worker.tasks.scan_tracks": "scan", -} - - -def _worker_pfad() -> str: - """Wo liegen die Worker-Module (ablauf.py, ripping.py, medien.py)? - - Dieselben drei Faelle wie bei der API (siehe `daemon._api_pfad`): aus dem - Repo gestartet, im PyInstaller-Paket, oder installiert. - """ - gebuendelt = getattr(sys, "_MEIPASS", None) - if gebuendelt: - return os.path.join(gebuendelt, "worker") - - hier = os.path.dirname(os.path.abspath(__file__)) - repo = os.path.dirname(os.path.dirname(hier)) - kandidaten = [ - os.path.join(repo, "docker", "worker"), - os.path.join(os.path.dirname(sys.executable), "worker"), - ] - for pfad in kandidaten: - if os.path.isfile(os.path.join(pfad, "ablauf.py")): - return pfad - raise RuntimeError( - "Die Worker-Module wurden nicht gefunden. Gesucht wurde in:\n " - + "\n ".join(kandidaten) - + "\nOhne sie kann Rippy nicht rippen." - ) - - -def _ablauf(): - """`ablauf` importieren — erst beim ersten Bedarf. - - Nicht auf Modulebene: Das Modul zieht `ripping`, `medien` und `db` nach, - und die brauchen einen gesetzten sys.path. Ausserdem soll der Import - nicht schon beim Start des Servers passieren, sondern dann, wenn wirklich - gearbeitet wird. - """ - pfad = _worker_pfad() - if pfad not in sys.path: - sys.path.insert(0, pfad) - import ablauf - - return ablauf - - -# ── Zustellung ────────────────────────────────────────────────────────── -def zustellung_bauen(bus=None): - """Gibt die Funktion zurueck, die `celery_client.zusteller_setzen` erwartet.""" - from rippy.queue import lokal - - def zustellen(task_name: str, args: list, queue: str = None): - art = AUFTRAGSARTEN.get(task_name) - if art is None: - raise ValueError( - f"Unbekannte Auftragsart {task_name!r}. Bekannt sind: " - + ", ".join(sorted(AUFTRAGSARTEN)) - + ". Wer eine neue einfuehrt, traegt sie in " - "rippy/standalone.py AUFTRAGSARTEN ein — sonst landet sie " - "im Standalone-Betrieb nirgends, und zwar lautlos." - ) - if art == "rip": - device_path, job_id, target_dir = (list(args) + [None, None, None])[:3] - auftrag_id = lokal.einreihen( - job_id, "rip", faehigkeiten={"art": "rip"}, - payload={"device_path": device_path, "target_dir": target_dir}) - elif art == "transcode": - job_id, raw_dir, final_dir = (list(args) + [None, None, None])[:3] - auftrag_id = lokal.einreihen( - job_id, "transcode", faehigkeiten={"art": "transcode"}, - payload={"raw_dir": raw_dir, "final_dir": final_dir}) - else: # scan - device_path = args[0] if args else "" - auftrag_id = lokal.einreihen( - "", "scan", faehigkeiten={"art": "scan"}, - payload={"device_path": device_path}) - - if bus: - try: - bus.senden("system.notice", - {"level": "info", "text": f"{art} eingereiht"}) - except Exception: - pass - return auftrag_id - - return zustellen - - -# ── Ausfuehrung ───────────────────────────────────────────────────────── -def ausfuehren(auftrag: dict): - """Arbeitet EINEN Auftrag ab — der Laeufer ruft das auf.""" - import json - - ablauf = _ablauf() - try: - payload = json.loads(auftrag.get("payload") or "{}") - except (ValueError, TypeError): - payload = {} - - art = auftrag.get("art") - if art == "rip": - # KEIN `weiterreichen`: Im Standalone-Betrieb komprimiert derselbe - # Prozess weiter (siehe Modul-Docstring). - return ablauf.rippen(payload.get("device_path"), auftrag["job_id"], - payload.get("target_dir")) - if art == "transcode": - return ablauf.komprimieren(auftrag["job_id"], payload.get("raw_dir"), - payload.get("final_dir")) - if art == "scan": - return ablauf.scan_tracks(payload.get("device_path")) - raise ValueError(f"Unbekannte Auftragsart im Auftrag: {art!r}") - - -# ── Herzschlag ────────────────────────────────────────────────────────── -HERZSCHLAG_SEKUNDEN = 60 - - -def herzschlag_starten(knoten: str, store, takt: float = HERZSCHLAG_SEKUNDEN): - """Meldet DIESEN Prozess als Arbeiter — mit seinen Faehigkeiten. - - ## Warum das noetig ist - - `/capabilities` liest die `workers`-Tabelle. Gefuellt hat sie bisher der - Celery-Herzschlag in `celery_app.py`. Im Standalone-Betrieb gibt es den - nicht — also stand dort „0 Worker", und im UI blieb die Encoder-Auswahl - LEER. Rippy haette auf einem Rechner mit AMD-Hardwarebeschleunigung - behauptet, es koenne nichts. - - Gemeldet wird, was `caps.py` MISST: die Encoder, die HandBrake auf dieser - Maschine wirklich anbietet, die Preset-Namen dieser HandBrake-Fassung, - CPU-Modell, Kerne und Vektorbefehle. Nichts davon wird behauptet — das - ist die Lehre aus Etappe 19 („Encoder wurden behauptet statt gemessen"). - """ - import threading - import time - - def schlagen(): - while True: - try: - pfad = _worker_pfad() - if pfad not in sys.path: - sys.path.insert(0, pfad) - import caps - - store.save_worker(knoten, caps.erkenne_encoder(), - caps.werkzeug_versionen()) - except Exception as e: - # Nicht sterben — aber auch nicht schweigen. - try: - store.add_log("warning", "standalone", - f"Faehigkeiten-Meldung fehlgeschlagen: {e}") - except Exception: - pass - time.sleep(takt) - - threading.Thread(target=schlagen, daemon=True, name="rippy-herzschlag").start() - - -# ── Alles zusammen ────────────────────────────────────────────────────── -def starten(knoten: str = None, bus=None, store=None, slots: int = 1) -> list: - """Zustellung umbiegen und die Laeufer starten. Gibt die Laeufer zurueck. - - `slots` ist die Zahl gleichzeitiger Auftraege. Vorgabe 1: Ein Rip belegt - das Laufwerk, und zwei gleichzeitige Encodes auf einem Rechner sind - langsamer als zwei nacheinander. - """ - import socket - - from rippy.queue.laeufer import Laeufer - - knoten = knoten or socket.gethostname() - - # Die API reiht ab jetzt lokal ein statt an Celery. - sys.path.insert(0, os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))), "docker", "api")) - try: - import celery_client - except ImportError: # im Paket liegt sie woanders - from rippy.daemon import _api_pfad - - sys.path.insert(0, _api_pfad()) - import celery_client - - celery_client.zusteller_setzen(zustellung_bauen(bus)) - - # Sich selbst als Arbeiter melden — sonst zeigt das UI "0 Worker" und - # eine leere Encoder-Auswahl, obwohl dieser Rechner alles kann. - if store is not None: - herzschlag_starten(knoten, store) - - laeufer = [] - for nummer in range(max(1, slots)): - lauf = Laeufer(ausfuehren, f"{knoten}#{nummer}", - kann={"art=rip", "art=transcode", "art=scan"}, - bus=bus, store=store) - threading.Thread(target=lauf.schleife, daemon=True, - name=f"rippy-laeufer-{nummer}").start() - laeufer.append(lauf) - return laeufer diff --git a/src/rippy/test_arbeitsort.py b/src/rippy/test_arbeitsort.py index 99bf0cb..e5933ea 100644 --- a/src/rippy/test_arbeitsort.py +++ b/src/rippy/test_arbeitsort.py @@ -105,19 +105,6 @@ def test_die_auskunft_im_container_nennt_die_container_orte(): # ── Die Laufwerke ─────────────────────────────────────────────────────── -def test_nur_feste_platten_und_netzlaufwerke(): - """Ein CD-Laufwerk als Arbeitsverzeichnis waere Unsinn — dort liegt die - Disc, die gerade gerippt wird. Ein USB-Stick fuer 100 GB eine Falle.""" - arten = {"C:\\": 3, "D:\\": 3, "E:\\": 5, "F:\\": 2, "Z:\\": 4} - gefunden = betrieb.windows_laufwerke(art=lambda p: arten.get(p, 1), - buchstaben=list(arten)) - assert gefunden == ["C:\\", "D:\\", "Z:\\"] - - -def test_ohne_windows_gibt_es_keine_laufwerke(): - """Auf dem Linux-Runner der Ampel gibt es kein GetDriveTypeW. Das muss - eine leere Liste sein, kein Fehler.""" - assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == [] @pytest.mark.parametrize("werte,container", [ diff --git a/src/rippy/test_daemon.py b/src/rippy/test_daemon.py deleted file mode 100644 index 7e3adbc..0000000 --- a/src/rippy/test_daemon.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Der Daemon-Start — die Teile, die ohne laufenden Server pruefbar sind. - -Nicht geprueft wird hier, dass uvicorn hochkommt; das ist ein Rauchtest fuer -den Betrieb. Geprueft wird, was davor entschieden wird — und genau dort -sitzen die Fehler, die man spaeter nicht mehr findet: - -* Ein argparse-Vorgabewert, der die Konfigurationsdatei schlaegt. -* Eine Datenbank, die am falschen Ort landet, weil der Store erst NACH dem - Import der API umgestellt wird. -""" - -import os -import sys - -from rippy import config, daemon - - -# ── Argumente ─────────────────────────────────────────────────────────── -def test_ohne_argumente_bleibt_alles_offen(): - """DIE Falle bei argparse: Ein Vorgabewert im Parser sieht aus wie eine - Angabe des Nutzers und schlaegt damit die Konfigurationsdatei. Der - Eintrag dort waere wirkungslos, und niemand wuesste warum. - """ - assert daemon.flags_aus_argumenten(daemon.argumente([])) == {} - - -def test_port_kommt_als_flag_durch(): - flags = daemon.flags_aus_argumenten(daemon.argumente(["--port", "9000"])) - assert flags == {"server": {"port": 9000}} - - -def test_mehrere_angaben_landen_im_richtigen_abschnitt(): - flags = daemon.flags_aus_argumenten( - daemon.argumente(["--host", "127.0.0.1", "--port", "8080", - "--profil", "standalone", "--kein-ui"])) - assert flags["profil"] == "standalone" - assert flags["server"] == {"host": "127.0.0.1", "port": 8080, "ui": False} - - -def test_db_flag_stellt_auf_sqlite_um(): - flags = daemon.flags_aus_argumenten(daemon.argumente(["--db", "C:/tmp/r.db"])) - assert flags["store"] == {"treiber": "sqlite", "pfad": "C:/tmp/r.db"} - - -def test_flag_schlaegt_die_konfigurationsdatei(): - """Die Praezedenz aus config.py — hier am echten Daemon-Weg nachgeprueft.""" - werte = config.laden( - umgebung={"RIPPY_SERVER__PORT": "5000"}, - flags=daemon.flags_aus_argumenten(daemon.argumente(["--port", "9999"])), - ) - assert werte["server"]["port"] == 9999 - - -# ── Pfade ─────────────────────────────────────────────────────────────── -def test_api_pfad_wird_gefunden(): - """Ohne die API-Module startet gar nichts. Wenn sich die Verzeichnis- - struktur aendert, soll das HIER auffallen und nicht beim Nutzer.""" - pfad = daemon._api_pfad() - assert os.path.isfile(os.path.join(pfad, "main.py")) - - -def test_ui_pfad_ist_entweder_da_oder_ehrlich_leer(): - """Leerer String heisst „kein UI mitgeliefert" — das ist ein gueltiger - Zustand (Kopfloser Betrieb). Was NICHT passieren darf: ein Pfad, den es - nicht gibt; dann liefe StaticFiles ins Leere.""" - pfad = daemon._ui_pfad() - assert pfad == "" or os.path.isdir(pfad) - - -def test_kopie_neben_dem_programm_geht_vor_dem_paket(tmp_path, monkeypatch): - """DER Befund vom 28.08.2026. Ein Dienst lief eine Stunde: - `/api/health` gab 200, `/` gab 404, im Fenster stand - `{"detail":"Not Found"}`. - - Der Server war in Ordnung — sein Entpack-Verzeichnis unter `%TEMP%` war - es nicht: 31 Eintraege statt 44, kein `ui`. `StaticFiles` schlaegt bei - JEDER Anfrage dort nach, also wurde aus einer laufenden Oberflaeche eine - 404, waehrend die API weiter gesund meldete. - - Die installierte Kopie hat deshalb Vorrang. Faellt dieser Test aus, ist - der Dienst wieder darauf angewiesen, dass niemand das Temp aufraeumt. - """ - installiert = tmp_path / "installiert" - (installiert / "ui").mkdir(parents=True) - (installiert / "ui" / "index.html").write_text("", - encoding="utf-8") - paket = tmp_path / "temp" / "_MEI4711" - (paket / "ui").mkdir(parents=True) - - monkeypatch.setattr(daemon, "_programmordner", - lambda: str(installiert / "Rippy.exe")) - monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False) - - assert daemon._ui_pfad() == str(installiert / "ui") - - -def test_ohne_kopie_bleibt_das_paket_der_rueckfall(tmp_path, monkeypatch): - """Wer die Setup-Datei direkt startet, hat keinen Installationsordner. - Dann ist das Entpack-Verzeichnis richtig — nur eben nicht auf Dauer.""" - paket = tmp_path / "_MEI4711" - (paket / "ui").mkdir(parents=True) - monkeypatch.setattr(daemon, "_programmordner", - lambda: str(tmp_path / "leer" / "RippySetup.exe")) - monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False) - - assert daemon._ui_pfad() == str(paket / "ui") - - -def test_standard_konfigpfad_passt_zur_plattform(): - pfad = daemon.standard_konfigpfad() - if os.name == "nt": - assert pfad.endswith(os.path.join("Rippy", "rippy.toml")) - else: - assert pfad == "/etc/rippy/rippy.toml" - - -# ── Konfiguration einlesen ────────────────────────────────────────────── -def test_fehlende_konfigdatei_ist_kein_fehler(tmp_path, monkeypatch): - monkeypatch.delenv("RIPPY_CONFIG", raising=False) - args = daemon.argumente(["--config", str(tmp_path / "gibt-es-nicht.toml")]) - werte, datei, unbekannt = daemon.einstellungen_laden(args) - assert werte["server"]["port"] == 7788 # Vorgabe - assert unbekannt == [] - - -def test_unbekannte_eintraege_werden_gemeldet(tmp_path, monkeypatch): - """Sie werden NICHT uebernommen (config.py) — aber sie muessen sichtbar - sein. Ein Tippfehler, der stillschweigend wirkungslos bleibt, kostet - Stunden.""" - if config.tomllib is None: - import pytest - pytest.skip("tomllib gibt es erst ab Python 3.11") - datei = tmp_path / "rippy.toml" - datei.write_text("[server]\nport = 8080\nprot = 9090\n", encoding="utf-8") - monkeypatch.delenv("RIPPY_CONFIG", raising=False) - werte, _, unbekannt = daemon.einstellungen_laden( - daemon.argumente(["--config", str(datei)])) - assert werte["server"]["port"] == 8080 - assert unbekannt == ["server.prot"] diff --git a/src/rippy/test_einrichtung.py b/src/rippy/test_einrichtung.py deleted file mode 100644 index 948ad0a..0000000 --- a/src/rippy/test_einrichtung.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Der Einrichtungs-Assistent — die Pruefungen, ohne etwas zu installieren. - -## Warum es diese Tests gibt - -Commander am 28.08.2026: *„Dann haette ich beim ‚Setup' auch erwartet das nen -echtes setup passiert — wo will ich das hinspeichern, nen pre requirement -check usw. Da kommt garnichts Rippy geht einfach auf."* - -Er hatte recht: Ein Doppelklick installierte stillschweigend und oeffnete das -Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts. - -Alle Pruefungen sind eingespritzt — kein Fenster, keine Installation, kein -WebView2. Sonst waere „kein Laufwerk" nur auf einem Rechner ohne Laufwerk -pruefbar, und genau dort sieht es niemand. -""" - -import os - -from rippy import einrichtung as e - - -def _p(zustand, name="X"): - return {"name": name, "zustand": zustand, "text": "", "abhilfe": ""} - - -# ── Was blockiert und was nicht ───────────────────────────────────────── -def test_ohne_fehler_darf_installiert_werden(): - assert e.darf_installieren([_p(e.OK), _p(e.WARNUNG)]) is True - - -def test_ein_fehler_blockiert(): - assert e.darf_installieren([_p(e.OK), _p(e.FEHLER)]) is False - - -def test_eine_warnung_blockiert_NICHT(): - """Eine Warnung, die den Knopf sperrt, ist eine Bevormundung. Rippy ohne - optisches Laufwerk ist ein vorgesehener Betriebsfall — eine reine - Komprimier-Maschine.""" - assert e.darf_installieren([_p(e.WARNUNG), _p(e.WARNUNG)]) is True - - -# ── Die einzelnen Pruefungen ──────────────────────────────────────────── -def test_windows_10_und_11_gehen(): - assert e.pruefe_windows(22631)["zustand"] == e.OK - assert e.pruefe_windows(19045)["zustand"] == e.OK - - -def test_zu_altes_windows_ist_ein_fehler(): - befund = e.pruefe_windows(7601) # Windows 7 - assert befund["zustand"] == e.FEHLER - assert befund["abhilfe"], "ein Befund ohne Abhilfe laesst den Nutzer ratlos" - - -def test_fehlendes_webview_ist_nur_eine_warnung(): - """Ohne WebView2 faellt Rippy auf den Browser zurueck. Das ist weniger - schoen, aber es LAEUFT — ein Fehler waere hier eine Luege.""" - befund = e.pruefe_webview(version="") - assert befund["zustand"] == e.WARNUNG - assert "Browser" in befund["abhilfe"] - - -def test_vorhandenes_webview_ist_ok(): - assert e.pruefe_webview(version="151.0.4129.107")["zustand"] == e.OK - - -def test_zu_wenig_platz_ist_ein_fehler(): - befund = e.pruefe_platz("C:\\egal", frei_mb=100) - assert befund["zustand"] == e.FEHLER - assert "500" in befund["text"] - - -def test_genug_platz_ist_ok(): - assert e.pruefe_platz("C:\\egal", frei_mb=5000)["zustand"] == e.OK - - -def test_fehlendes_schreibrecht_ist_ein_fehler(): - """`os.access(..., W_OK)` luegt unter Windows — es beantwortet die Frage - anhand des Nur-Lesen-Flags, nicht anhand der Zugriffsrechte. Deshalb wird - wirklich geschrieben.""" - befund = e.pruefe_schreibrecht("C:\\Programme\\Rippy", - probe=(False, "Zugriff verweigert")) - assert befund["zustand"] == e.FEHLER - assert "Benutzer" in befund["abhilfe"] - - -def test_schreibprobe_am_echten_ordner(tmp_path): - befund = e.pruefe_schreibrecht(str(tmp_path)) - assert befund["zustand"] == e.OK - # Und die Probe darf nichts hinterlassen. - assert list(tmp_path.iterdir()) == [] - - -# ── Administrator und Netzlaufwerke ───────────────────────────────────── -def test_normal_gestartet_ist_der_richtige_zustand(): - """Rippy braucht KEINE Administratorrechte — Vorgabeordner unter - %LOCALAPPDATA%, Autostart unter HKCU.""" - befund = e.pruefe_administrator(erhoeht=False) - assert befund["zustand"] == e.OK - - -def test_als_administrator_wird_vor_den_fehlenden_netzlaufwerken_gewarnt(): - """DER Befund des Commanders (28.08.2026): „Wenn man das Tool als Admin - startet, kann man im explorer kein netzlaufwerk waehlen." - - Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene - Netzlaufwerke haengen am Token der Sitzung. Windows-Verhalten, kein - Rippy-Fehler — aber Rippy hat ihn hineinlaufen lassen.""" - befund = e.pruefe_administrator(erhoeht=True, laufwerke=["X:", "Y:", "Z:"]) - assert befund["zustand"] == e.WARNUNG - assert "X:" in befund["abhilfe"] - assert "UNC" in befund["abhilfe"] or "Server" in befund["abhilfe"] - - -def test_fehlendes_laufwerk_wird_beim_namen_genannt(): - """„Schreibrechte fehlen" war richtig im Wortlaut und unbrauchbar als - Auskunft: Das Laufwerk war fuer den erhoehten Prozess gar nicht da.""" - # `laufwerk_da=False` eingespritzt: Die Frage "existiert Q:" laesst sich - # auf dem Linux-Runner der Ampel gar nicht beantworten. Ohne den - # Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit - # genau diesem fehlenden Laufwerk pruefbar -- also nirgends. - befund = e.pruefe_schreibrecht( - "Q:" + chr(92) + "Rippy", probe=(False, "Pfad nicht gefunden"), - erhoeht=True, laufwerk_da=False) - assert befund["zustand"] == e.FEHLER - assert "Q:" in befund["text"] - assert "nicht vorhanden" in befund["text"] - assert "Administrator" in befund["abhilfe"] - - -def test_programme_ordner_erklaert_den_teufelskreis(): - """Program Files verlangt Administrator, Administrator versteckt die - Netzlaufwerke — und Rippy braucht beides nicht.""" - befund = e.pruefe_schreibrecht( - "C:" + chr(92) + "Program Files (x86)" + chr(92) + "Rippy", - probe=(False, "Zugriff verweigert")) - assert befund["zustand"] == e.FEHLER - assert "Administratorrechte" in befund["abhilfe"] - assert "Vorgabeordner" in befund["abhilfe"] - - -def test_programme_wird_erkannt(): - B = chr(92) - assert e._unter_programme("C:" + B + "Program Files" + B + "Rippy") is True - assert e._unter_programme("C:" + B + "Program Files (x86)" + B + "R") is True - assert e._unter_programme("C:" + B + "Users" + B + "X" + B + "Rippy") is False - - -def test_belegter_port_ist_nur_eine_warnung(): - """Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine - Neuinstallation genau richtig.""" - befund = e.pruefe_port(7788, belegt=True) - assert befund["zustand"] == e.WARNUNG - assert "Rippy" in befund["abhilfe"] - - -def test_freier_port_ist_ok(): - assert e.pruefe_port(7788, belegt=False)["zustand"] == e.OK - - -def test_kein_laufwerk_ist_nur_eine_warnung(): - """install.sh sagt seit Langem: ohne Laufwerk „laeuft trotzdem durch, dann - ist das eine reine KOMPRIMIER-Maschine".""" - befund = e.pruefe_laufwerk(laufwerke=[]) - assert befund["zustand"] == e.WARNUNG - assert "komprimieren" in befund["abhilfe"] - - -def test_gefundenes_laufwerk_wird_genannt(): - befund = e.pruefe_laufwerk(laufwerke=["\\\\.\\G:"]) - assert befund["zustand"] == e.OK - assert "G:" in befund["text"] - - -# ── Die Werkzeuge ─────────────────────────────────────────────────────── -def _bestand(makemkv, handbrake): - return { - "makemkv": {"titel": "MakeMKV", "da": makemkv, "version": "1.18.4", - "pflicht": True}, - "handbrake": {"titel": "HandBrakeCLI", "da": handbrake, - "version": "1.11.2", "pflicht": False}, - } - - -def test_fehlendes_makemkv_ist_eine_warnung_mit_ausweg(): - befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(False, True))} - assert befunde["MakeMKV"]["zustand"] == e.WARNUNG - assert "makemkv.com" in befunde["MakeMKV"]["abhilfe"] - - -def test_fehlendes_handbrake_ist_kein_problem(): - """Es liegt im Paket — das Setup legt es beim Installieren daneben.""" - befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, False))} - assert befunde["HandBrakeCLI"]["zustand"] == e.OK - assert "mitgeliefert" in befunde["HandBrakeCLI"]["text"] - - -def test_vorhandene_werkzeuge_zeigen_ihre_version(): - befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, True))} - assert befunde["MakeMKV"]["text"] == "1.18.4" - - -# ── Die Zusammenfassung ───────────────────────────────────────────────── -def test_zusammenfassung_nennt_die_fehler_beim_namen(): - text = e.zusammenfassung([_p(e.FEHLER, "Speicherplatz"), _p(e.OK, "Port")]) - assert "nicht möglich" in text - assert "Speicherplatz" in text - - -def test_zusammenfassung_nennt_die_hinweise(): - text = e.zusammenfassung([_p(e.WARNUNG, "MakeMKV"), _p(e.OK, "Port")]) - assert "MakeMKV" in text - assert "nicht möglich" not in text - - -def test_alles_gruen_sagt_es_auch_so(): - assert e.zusammenfassung([_p(e.OK), _p(e.OK)]) == "Alles bereit." - - -def test_jede_pruefung_hat_dieselbe_form(): - """Ein fehlendes Feld waere in der Oberflaeche ein `undefined`.""" - for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""), - e.pruefe_administrator(erhoeht=True, laufwerke=["X:"]), - e.pruefe_administrator(erhoeht=False), - e.pruefe_laufwerk(laufwerke=[]), - e.pruefe_platz("C:\\x", frei_mb=9999), - e.pruefe_port(7788, belegt=False)] - + e.pruefe_werkzeuge(_bestand(False, False))): - assert set(befund) == {"name", "zustand", "text", "abhilfe"}, befund - assert befund["zustand"] in (e.OK, e.WARNUNG, e.FEHLER) - if befund["zustand"] != e.OK: - assert befund["abhilfe"], ( - "%s sagt nicht, was zu tun ist" % befund["name"]) - - -# ── Die Vorgaben ──────────────────────────────────────────────────────── -def test_vorgaben_sind_vollstaendig(): - if os.name != "nt": - import pytest - pytest.skip("Registry-Vorgaben gibt es nur unter Windows") - v = e.vorgaben() - assert set(v) == {"ziel", "ablage", "arbeit", "port", "autostart", - "verknuepfungen", "werkzeuge_holen"} - assert v["port"] == 7788 - assert v["ziel"] and v["ablage"] and v["arbeit"] - for feld in ("ablage", "arbeit"): - assert "/app/" not in v[feld], "Container-Pfad in einer Windows-Vorgabe" - - -def test_netzwerkpfad_als_administrator_wird_erklaert(): - """Der zweite Teil des Befunds: Auch Netz-ANMELDUNGEN haengen am Token - der Sitzung. Ein erhoehter Prozess kennt die Zugangsdaten zur Freigabe - nicht und bekommt 'Zugriff verweigert' -- dieselbe Meldung wie bei einem - echten Rechteproblem.""" - B = chr(92) - befund = e.pruefe_schreibrecht( - B + B + "NAS" + B + "Filme", probe=(False, "Zugriff verweigert"), - erhoeht=True) - assert befund["zustand"] == e.FEHLER - assert "ohne Administrator" in befund["abhilfe"] - - -def test_netzwerkpfad_ohne_administrator_bekommt_die_normale_meldung(): - B = chr(92) - befund = e.pruefe_schreibrecht( - B + B + "NAS" + B + "Filme", probe=(False, "Netzwerkpfad nicht gefunden"), - erhoeht=False) - assert befund["zustand"] == e.FEHLER - assert "Administrator" not in befund["abhilfe"] or "keine" in befund["abhilfe"] - - -def test_ein_vorhandenes_laufwerk_ist_kein_laufwerks_problem(): - """Sonst waere JEDER Schreibfehler auf C: ein 'Laufwerk fehlt'.""" - B = chr(92) - assert e.laufwerk_fehlt("C:" + B + "Programme", vorhanden=True) is False - assert e.laufwerk_fehlt("Q:" + B + "Rippy", vorhanden=False) is True - - -def test_unc_und_posix_sind_keine_laufwerksbuchstaben(): - B = chr(92) - assert e.laufwerk_fehlt(B + B + "NAS" + B + "Filme") is False - assert e.laufwerk_fehlt("/srv/rippy") is False diff --git a/src/rippy/test_fenster.py b/src/rippy/test_fenster.py deleted file mode 100644 index 8fcb797..0000000 --- a/src/rippy/test_fenster.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Das native Fenster: Verfuegbarkeit, Rueckfall, Speicherort. - -Ein Fenster laesst sich in einem Test nicht oeffnen — es wuerde auf einen -Doppelklick warten, den niemand macht. Geprueft wird deshalb alles DAVOR und -DANEBEN: die Entscheidung, ob ein Fenster moeglich ist, und der Rueckfall auf -den Browser. Genau dort sassen die Fehler, die dieses Projekt schon zweimal -gekostet hat. -""" - -import os - -import pytest - -from rippy import fenster - -nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows") - - -# ── Der Registry-Wert ─────────────────────────────────────────────────── -def test_null_version_gilt_als_nicht_vorhanden(): - """DER Fallstrick bei WebView2: EdgeUpdate LOESCHT den Schluessel bei - einer Deinstallation nicht, sondern setzt `pv` auf `0.0.0.0`. Wer nur - auf die Existenz des Schluessels prueft, oeffnet ein Fenster, das nichts - anzeigen kann — und der Nutzer sieht einen weissen Kasten.""" - assert fenster.echte_version("0.0.0.0") == "" - - -def test_echte_version_kommt_durch(): - assert fenster.echte_version("151.0.4129.107") == "151.0.4129.107" - - -@pytest.mark.parametrize("wert", ["", None, " "]) -def test_leere_werte_gelten_nicht(wert): - assert fenster.echte_version(wert) == "" - - -# ── Die Entscheidung ──────────────────────────────────────────────────── -def test_verfuegbar_gibt_immer_eine_begruendung(): - """Auch beim Ja. Ein blosses False zwingt den Naechsten zum Nachforschen - — und ein Fehlschlag ohne Begruendung ist in diesem Projekt schon - zweimal teuer geworden.""" - geht, grund = fenster.verfuegbar() - assert isinstance(geht, bool) - assert grund, "auch ein Ja braucht eine Begruendung" - - -@pytest.mark.skipif(os.name == "nt", reason="prueft das Verhalten auf Nicht-Windows") -def test_ohne_windows_kein_fenster(): - geht, grund = fenster.verfuegbar() - assert geht is False - assert "Windows" in grund - - -@pytest.mark.skipif(os.name == "nt", reason="winreg gibt es nur unter Windows") -def test_version_ohne_windows_ist_leer_statt_zu_werfen(): - """Der Kern laeuft auch unter Linux (Docker, headless). Ein ImportError - auf winreg wuerde dort jeden Aufruf sprengen.""" - assert fenster.webview2_version() == "" - - -def test_fehlende_bibliothek_verhindert_das_fenster(monkeypatch): - monkeypatch.setattr(os, "name", "nt") - monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: False) - geht, grund = fenster.verfuegbar() - assert geht is False - assert "pywebview" in grund - - -def test_fehlende_laufzeit_verhindert_das_fenster(monkeypatch): - """Lieber Browser als ein leeres Fenster: Ohne WebView2 faellt pywebview - auf MSHTML zurueck, und der stellt die React-Oberflaeche nicht dar.""" - monkeypatch.setattr(os, "name", "nt") - monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: True) - monkeypatch.setattr(fenster, "webview2_version", lambda: "") - geht, grund = fenster.verfuegbar() - assert geht is False - assert "WebView2" in grund - - -# ── Der Rueckfall ─────────────────────────────────────────────────────── -def test_ohne_fenster_geht_der_browser_auf(monkeypatch): - gerufen = [] - monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows")) - monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) - - assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser" - assert gerufen == ["http://127.0.0.1:7788"] - - -def test_geplatztes_fenster_faellt_auf_den_browser_zurueck(monkeypatch, capsys): - """Der wichtigste Test hier. Was WinForms und die CLR beim Start werfen - koennen, ist nicht abschliessend bekannt. Faellt das nicht zurueck, ist - der Doppelklick auf das Desktop-Symbol wirkungslos — und der Nutzer - haelt Rippy fuer kaputt.""" - def platzt(*a, **k): - raise RuntimeError("edgechromium nicht gefunden") - - gerufen = [] - monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151")) - monkeypatch.setattr(fenster, "oeffnen", platzt) - monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) - - assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser" - assert gerufen == ["http://127.0.0.1:7788"] - # Und der Grund muss SICHTBAR sein, nicht verschluckt. - assert "edgechromium nicht gefunden" in capsys.readouterr().out - - -def test_wenn_gar_nichts_geht_wird_das_auch_gesagt(monkeypatch): - """`webbrowser.open` gibt False zurueck, wenn kein Browser gefunden wird. - Ein `return "browser"` waere dann eine Luege.""" - monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows")) - monkeypatch.setattr("webbrowser.open", lambda a: False) - assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "nichts" - - -def test_erfolgreiches_fenster_oeffnet_keinen_browser(monkeypatch): - """Sonst haette der Nutzer BEIDES auf — Fenster und Tab.""" - gerufen = [] - monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151")) - monkeypatch.setattr(fenster, "oeffnen", lambda *a, **k: None) - monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True) - - assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "fenster" - assert gerufen == [] - - -# ── Die WebView2-Helfer ───────────────────────────────────────────────── -def test_helfer_werden_ueber_den_speicherort_gefiltert(): - """DER Befund vom 28.08.2026: Nach dem Deinstallieren blieben 148 Dateien - liegen, alle unter `fenster\\EBWebView\\`. WebView2 startet eigene - Prozesse (Renderer, GPU, Netz, Crashpad); stirbt Rippy, laufen sie als - Waisen weiter und halten den Zwischenspeicher offen.""" - s = fenster.helfer_skript(r"C:\Users\X\AppData\Local\Rippy\fenster") - assert "msedgewebview2.exe" in s - assert r"C:\Users\X\AppData\Local\Rippy\fenster" in s - assert "Stop-Process" in s - - -def test_der_filter_trifft_NUR_unsere_helfer(): - """`msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu - beenden waere ein Uebergriff — der Nutzer verloere die Fenster fremder - Anwendungen. Ohne `Where-Object` auf den Speicherort passiert genau das.""" - s = fenster.helfer_skript(r"C:\Rippy\fenster") - assert "Where-Object" in s, "ohne Filter wuerden FREMDE Fenster sterben" - assert s.index("Where-Object") < s.index("Stop-Process"), \ - "erst filtern, dann beenden" - - -def test_apostroph_im_pfad_bricht_das_skript_nicht(): - """In PowerShell wird ein Apostroph durch VERDOPPELN maskiert. Ohne das - bricht ein Pfad wie C:\\Users\\O'Brien\\… das Skript mitten entzwei.""" - s = fenster.helfer_skript(r"C:\Users\O'Brien\Rippy\fenster") - assert "O''Brien" in s - assert s.count("'") % 2 == 0 - - -def test_ohne_windows_werden_keine_helfer_beendet(): - if os.name == "nt": - pytest.skip("prueft das Verhalten auf Nicht-Windows") - assert fenster.helfer_beenden("/tmp/x") is False - - -def test_leerer_speicherort_beendet_nichts(monkeypatch): - """Ein leerer Filter wuerde auf ALLE msedgewebview2-Prozesse passen.""" - monkeypatch.setattr(fenster, "speicherort_lesen", lambda: "") - assert fenster.helfer_beenden("") is False - - -# ── Der Speicherort ───────────────────────────────────────────────────── -def test_speicherort_liegt_bei_rippy(monkeypatch, tmp_path): - """Im TEMP waere er bei jedem Aufraeumen weg — und beim Deinstallieren - bliebe er liegen.""" - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - ordner = fenster.speicherort() - assert ordner.startswith(str(tmp_path)) - assert os.path.isdir(ordner) - - -def test_unbeschreibbarer_ort_wirft_nicht(monkeypatch): - """Ein fehlender Zwischenspeicher ist aergerlich, kein Grund, das - Fenster gar nicht erst zu oeffnen.""" - def geht_nicht(*a, **k): - raise OSError("kein Platz") - - monkeypatch.setattr(os, "makedirs", geht_nicht) - assert fenster.speicherort() == "" - - -# ── Auf dieser Maschine wirklich nachsehen ────────────────────────────── -@nur_windows -def test_webview2_ist_auf_diesem_rechner_da(): - """Kein Muss-Test, sondern ein Messpunkt: Faellt er aus, laeuft Rippy im - Browser statt im Fenster — und man weiss auch gleich, warum.""" - version = fenster.webview2_version() - if not version: - pytest.skip("Keine WebView2-Laufzeit auf dieser Maschine") - assert version.count(".") >= 2, version diff --git a/src/rippy/test_keine_container_reste.py b/src/rippy/test_keine_container_reste.py deleted file mode 100644 index 58dfda0..0000000 --- a/src/rippy/test_keine_container_reste.py +++ /dev/null @@ -1,179 +0,0 @@ -"""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) diff --git a/src/rippy/test_kette.py b/src/rippy/test_kette.py deleted file mode 100644 index 17f5933..0000000 --- a/src/rippy/test_kette.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Die ganze Kette im Standalone-Betrieb — von der Zustellung bis zum Job-Ende. - -## Was hier bewiesen wird - - celery_client.abschicken(...) die API stellt zu - -> standalone.zustellung uebersetzt in einen Auftrag - -> lokal.einreihen Auftragstabelle mit Lease - -> Laeufer.einmal() holt ihn und arbeitet ihn ab - -> ablauf.rippen() der echte Ablauf, ohne Celery - -> db.update_job() der Job endet in einem ehrlichen Zustand - -Das ist der Weg, der unter Windows bis V2-4 gar nicht existierte: Dort haette -die API einen Rip eingereiht, und er waere fuer immer liegengeblieben, weil -niemand ihn holt. - -## Warum ohne Laufwerk getestet wird - -Es gibt hier keins (es haengt an der VM). Das ist kein Mangel, sondern der -wichtigere Fall: Ein Rip auf ein Geraet, das nicht antwortet, muss **ehrlich -scheitern** — mit einem Zustand und einer Begruendung, die im UI ankommen. -Was NICHT passieren darf: dass der Job auf „pending" stehenbleibt und -niemand erfaehrt, warum nichts geschieht. -""" - -import time - -import pytest - -from rippy import standalone, store -from rippy.queue import laeufer as laeufer_modul -from rippy.queue import lokal - - -@pytest.fixture -def umgebung(tmp_path): - vorher = store.zustand_sichern() - store.verbinden(f"sqlite:///{(tmp_path / 'kette.db').as_posix()}") - store.init_db() - yield store - store.engine_holen().dispose() - store.zustand_wiederherstellen(vorher) - - -class FakeBus: - def __init__(self): - self.gesendet = [] - - def senden(self, typ, daten=None, entitaet=None, entitaet_id=None): - self.gesendet.append((typ, entitaet_id, daten or {})) - - -def test_die_ganze_kette_bis_zum_ehrlichen_fehlschlag(umgebung): - """Ein Rip auf ein Geraet, das es nicht gibt. - - Erwartet wird KEIN Erfolg — erwartet wird, dass der Job in einem - ENDZUSTAND landet und die Begruendung im Log steht. Ein Job, der auf - „pending" haengenbleibt, waere der schlimmere Ausgang: Der Nutzer saehe - einen Balken, der sich nie bewegt, und nirgends stuende warum. - """ - umgebung.insert_job("job-kette", "/dev/gibt-es-nicht", disc_type=None) - - bus = FakeBus() - zustellen = standalone.zustellung_bauen(bus) - zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-kette", None]) - - assert len(lokal.offene_auftraege()) == 1, "Der Auftrag wurde nicht eingereiht" - - lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten", - kann={"art=rip"}, bus=bus, store=umgebung) - assert lauf.einmal() is True, "Der Laeufer hat den Auftrag nicht geholt" - - job = umgebung.get_job("job-kette") - assert job["status"] in ("failed", "completed"), ( - f"Der Job steht auf {job['status']!r} — er muss in einem Endzustand " - "landen, sonst wartet der Nutzer auf etwas, das nie passiert.") - assert job["status"] == "failed" - assert job["error"], "Ein Fehlschlag ohne Begruendung ist im UI wertlos" - - meldungen = " ".join(z["message"] for z in umgebung.list_logs(limit=50)) - assert "job-kette" in meldungen, "Im Log steht nichts ueber diesen Job" - - assert lokal.offene_auftraege() == [], "Der Auftrag blieb in der Queue liegen" - - -def test_der_laeufer_bleibt_danach_arbeitsfaehig(umgebung): - """Nach einem Fehlschlag muss der naechste Auftrag trotzdem laufen — - sonst legt ein einziger kaputter Rip die ganze Installation lahm.""" - umgebung.insert_job("job-a", "/dev/gibt-es-nicht") - umgebung.insert_job("job-b", "/dev/gibt-es-auch-nicht") - zustellen = standalone.zustellung_bauen() - zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-a", None]) - zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-auch-nicht", "job-b", None]) - - lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten", - kann={"art=rip"}, store=umgebung) - assert lauf.einmal() is True - assert lauf.einmal() is True - assert umgebung.get_job("job-a")["status"] == "failed" - assert umgebung.get_job("job-b")["status"] == "failed" - - -def test_die_api_stellt_ueber_dieselbe_stelle_zu(umgebung, monkeypatch): - """`celery_client.start_rip` ist der Weg, den POST /jobs geht. Er MUSS - im Standalone-Betrieb in der lokalen Queue landen — sonst reiht die API - an einen Broker ein, den es nicht gibt, und der Job verschwindet.""" - import sys - - from rippy.daemon import _api_pfad - - if _api_pfad() not in sys.path: - sys.path.insert(0, _api_pfad()) - import celery_client - - umgebung.insert_job("job-api", "/dev/sr0") - monkeypatch.setattr(celery_client, "_zusteller", standalone.zustellung_bauen()) - - celery_client.start_rip("/dev/sr0", "job-api", "/app/media/x") - - offen = lokal.offene_auftraege() - assert len(offen) == 1 - assert offen[0]["job_id"] == "job-api" - assert offen[0]["art"] == "rip" - - -def test_ein_rip_haengt_nicht_ewig(umgebung): - """Zeitgrenze als Zusage: Ein Auftrag auf ein totes Geraet muss ZUEGIG - scheitern. Haengt er, merkt es im Betrieb niemand — der Job steht auf - „laeuft", und der Laeufer nimmt keinen weiteren an.""" - umgebung.insert_job("job-zeit", "/dev/nichts") - standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/nichts", "job-zeit", None]) - - lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "k", kann={"art=rip"}, - store=umgebung) - start = time.monotonic() - lauf.einmal() - dauer = time.monotonic() - start - assert dauer < 30, f"Der Fehlschlag brauchte {dauer:.1f}s — das ist zu lang" diff --git a/src/rippy/test_setup_fenster.py b/src/rippy/test_setup_fenster.py deleted file mode 100644 index f45f1fc..0000000 --- a/src/rippy/test_setup_fenster.py +++ /dev/null @@ -1,379 +0,0 @@ -"""Der Einrichtungs-Assistent: Bruecke und Seite, ohne ein Fenster zu oeffnen. - -Ein Fenster laesst sich im Test nicht bedienen. Geprueft wird deshalb das, -was DAHINTER haengt: was die Seite abfragen darf, was beim Installieren -passiert, und dass ein Fehlschlag sichtbar wird statt zu verschwinden. - -Hinweis zu den Backslashes: Windows-Pfade stehen hier in ROHEN Zeichenketten -(`r"..."`). In einer normalen waere `C:\\Rippy` die ungueltige Escape-Sequenz -Backslash-R. -""" - -from rippy import setup_fenster - - -class LeeresFenster: - def __init__(self, antwort=None): - self.antwort = antwort - self.zerstoert = False - - def create_file_dialog(self, *a, **k): - if self.antwort is None: - raise RuntimeError("kein Dialog") - return self.antwort - - def destroy(self): - self.zerstoert = True - - -# ── Die Seite ─────────────────────────────────────────────────────────── -def test_die_seite_fragt_nach_dem_ablageort(): - """Die ausdrueckliche Frage des Commanders: „wo will ich das - hinspeichern".""" - assert setup_fenster.seite_enthaelt('id="ablage"') - assert setup_fenster.seite_enthaelt("Fertige Filme") - - -def test_die_seite_zeigt_die_voraussetzungen(): - """„nen pre requirement check usw." — vorher kam da gar nichts.""" - assert setup_fenster.seite_enthaelt("Voraussetzungen") - assert setup_fenster.seite_enthaelt("pywebview.api.pruefen") - - -def test_die_seite_laedt_nichts_aus_dem_netz(): - """Der Assistent muss auf einem Rechner OHNE Internet aufgehen — dort - wird er am dringendsten gebraucht.""" - for muster in ("http://", "https://", "