From 288f9eeb8da914ea69ed08764239a557ae657431 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 11:12:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(tools):=20Werkzeuge=20finden,=20holen=20un?= =?UTF-8?q?d=20aktuell=20halten=20=E2=80=94=20Grundlage=20fuer=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: rippy/tools/ mit katalog.py (finden + Version) und beschaffen.py (herunterladen, installieren, Update-Stand). ripping.py und caps.py fragen den Katalog statt shutil.which. DER BEFUND, DER DAS NOETIG MACHT: Der Bestand suchte AUSSCHLIESSLICH mit shutil.which(). Im Container stimmt das — dort liegen beide Werkzeuge in /usr/local/bin. Unter Windows findet es NICHTS, auch wenn alles installiert ist: Windows-Programme liegen in Program Files, nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen — MakeMKV lag unter "C:\Program Files (x86)\MakeMKV\makemkvcon64.exe", which sah es nicht. Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das der Unterschied zwischen "laeuft" und "laeuft nicht". Vorher/nachher gemessen: vorher check_makemkv_installed() -> False (obwohl installiert) nachher check_makemkv_installed() -> True build_makemkv_cmd()[0] -> C:\Program Files (x86)\MakeMKV\makemkvcon64.exe SUCHREIHENFOLGE, mit Begruendung im Modul: eingestellt > Rippys eigener Werkzeug-Ordner > PATH > bekannte Installationsorte > Uninstall-Zweig der Registry. Rippys eigener Ordner steht VOR dem System, damit eine selbst gepflegte Fassung eine alte Systeminstallation schlaegt. VERSION: HandBrakeCLI kann --version. makemkvcon KANN DAS NICHT (usage.txt kennt keinen solchen Schalter) — die Version kommt aus dem Uninstall-Zweig. Dort steht bei MakeMKV zwar eine Version, aber InstallLocation ist LEER (nachgesehen); deshalb wird der Ordner ersatzweise aus DisplayIcon bzw. UninstallString abgeleitet. Der Test dafuer hat sofort einen Fehler gefunden: '"C:\...\uninstall.exe" /S' liess sich mit einem blossen strip('"') nicht aufloesen. BESCHAFFUNG, an echter Hardware gemessen: HandBrake GitHub-Release-API -> 1.11.2, HandBrakeCLI-1.11.2-win-x86_64.zip geladen, entpackt, gestartet: meldet sich als 1.11.2. 2,8 s. MakeMKV makemkv.com antwortete mit HTTP 525 (Cloudflare) — auch mit Browser-Kennung. Dieselbe Sperre, die im Projekt schon den Docker-Bau lahmlegt. Deshalb: Abruf wird versucht, ein Fehlschlag wird im Klartext gemeldet, und eine selbst geholte Datei laesst sich weiterhin verwenden. Ein Update-Server, der heute antwortet, darf keine Startbedingung fuer morgen sein. DREI FALLEN, GEGEN DIE ES TESTS GIBT: - Die .sig-Datei liegt im Release direkt neben dem ZIP und ist 566 Bytes gross. Wer nur nach "win" filtert, laedt sie. - "1.9.2" ist als Zeichenkette GROESSER als "1.11.2". Ein Update-Hinweis, der ab Version zehn dauerhaft in die Irre fuehrt — und genau dort ist HandBrake gerade. - Eine Fehlerseite kommt oft mit HTTP 200. Sie darf keine funktionierende Installation ersetzen: erst laden, pruefen, dann tauschen. Und: Ist die neueste Version unbekannt (Quelle tot), steht NICHT "Update verfuegbar" da. Eine Nichtauskunft ist keine Aussage. GEMESSEN: ruff sauber, 489 Tests gruen + 15 uebersprungen (vorher 456). Co-Authored-By: Claude Opus 5 --- docker/worker/caps.py | 27 ++- docker/worker/ripping.py | 39 +++- docker/worker/test_ripping_helpers.py | 8 +- src/rippy/tools/__init__.py | 11 + src/rippy/tools/beschaffen.py | 315 ++++++++++++++++++++++++++ src/rippy/tools/katalog.py | 300 ++++++++++++++++++++++++ src/rippy/tools/test_beschaffen.py | 144 ++++++++++++ src/rippy/tools/test_katalog.py | 176 ++++++++++++++ 8 files changed, 1005 insertions(+), 15 deletions(-) create mode 100644 src/rippy/tools/__init__.py create mode 100644 src/rippy/tools/beschaffen.py create mode 100644 src/rippy/tools/katalog.py create mode 100644 src/rippy/tools/test_beschaffen.py create mode 100644 src/rippy/tools/test_katalog.py diff --git a/docker/worker/caps.py b/docker/worker/caps.py index b229bd1..18d8976 100644 --- a/docker/worker/caps.py +++ b/docker/worker/caps.py @@ -12,6 +12,19 @@ import shutil import subprocess from rippy.platform.winlauf import OHNE_FENSTER +from rippy.tools import katalog as werkzeuge + + +def _hb() -> str: + """Pfad zu HandBrakeCLI — "" wenn es nicht da ist. + + Bis V2-4 stand hier `shutil.which("HandBrakeCLI")`. Das findet unter + Windows nichts, auch wenn HandBrake installiert ist (Program Files statt + PATH). Die Folge waere gewesen: Rippy meldet "keine Encoder gefunden" auf + einem Rechner, auf dem alles da ist — und die Encoder-Auswahl im UI bliebe + leer, ohne dass jemand den Grund saehe. + """ + return werkzeuge.finden("handbrake") HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b") @@ -148,11 +161,11 @@ def parse_preset_liste(text: str) -> list: def hole_handbrake_presets() -> list: """`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert).""" - if not shutil.which("HandBrakeCLI"): + if not _hb(): return [] try: aus = subprocess.run( - ["HandBrakeCLI", "--preset-list"], + [_hb(), "--preset-list"], capture_output=True, text=True, timeout=30, creationflags=OHNE_FENSTER, ) @@ -262,11 +275,11 @@ def cpu_merkmale() -> tuple: def hole_handbrake_hilfe() -> str: """`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert).""" - if not shutil.which("HandBrakeCLI"): + if not _hb(): return "" try: aus = subprocess.run( - ["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30, + [_hb(), "--help"], capture_output=True, text=True, timeout=30, creationflags=OHNE_FENSTER ) return (aus.stdout or "") + (aus.stderr or "") @@ -349,12 +362,12 @@ def werkzeug_versionen() -> dict: presets = hole_handbrake_presets() if presets: info["presets"] = presets - if shutil.which("makemkvcon"): + if werkzeuge.finden("makemkv"): info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert" - if shutil.which("HandBrakeCLI"): + if _hb(): try: aus = subprocess.run( - ["HandBrakeCLI", "--version"], + [_hb(), "--version"], capture_output=True, text=True, timeout=15, creationflags=OHNE_FENSTER, ) diff --git a/docker/worker/ripping.py b/docker/worker/ripping.py index c6cf9ce..de980de 100644 --- a/docker/worker/ripping.py +++ b/docker/worker/ripping.py @@ -37,6 +37,31 @@ from rippy.drives.linux import ( # noqa: F401 ) from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401 from rippy.platform.winlauf import OHNE_FENSTER +from rippy.tools import katalog as werkzeuge + +_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"} + + +def werkzeug(name: str) -> str: + """Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall. + + ## Warum hier nicht mehr `shutil.which` steht + + Bis V2-4 stand ueberall in dieser Datei der nackte Programmname + ("makemkvcon", "HandBrakeCLI"). Im Container stimmt das — dort liegen + beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn + beide installiert sind: Windows-Programme liegen in Program Files und + stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen — + MakeMKV lag da, und `shutil.which` sah es nicht. + + Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das + der Unterschied zwischen "laeuft" und "laeuft nicht". + + Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich + damit NICHTS — subprocess findet das Programm dort ueber den PATH wie + bisher. + """ + return werkzeuge.finden(name) or _NACKTER_NAME[name] RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media") @@ -48,7 +73,7 @@ class RipAbbruch(Exception): def check_makemkv_installed() -> bool: """Prüft, ob makemkvcon installiert ist.""" - return shutil.which("makemkvcon") is not None + return bool(werkzeuge.finden("makemkv")) def check_abcde_installed() -> bool: @@ -72,7 +97,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> mkv dev: 'all' oder eine Titel-Nummer (Hauptfilm) """ return [ - "makemkvcon", + werkzeug("makemkv"), "-r", "--noscan", "--progress=-same", @@ -232,7 +257,7 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple: wäre eine Minute Wartezeit für nichts. """ ergebnis = subprocess.run( - ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], + [werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"], capture_output=True, text=True, timeout=timeout, creationflags=OHNE_FENSTER, ) @@ -305,7 +330,7 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict: """Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode).""" try: ergebnis = subprocess.run( - ["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"], + [werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"], capture_output=True, text=True, timeout=timeout, creationflags=OHNE_FENSTER, ) @@ -334,7 +359,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int: return 0 try: ergebnis = subprocess.run( - ["HandBrakeCLI", "--scan", "-i", pfad], + [werkzeug("handbrake"), "--scan", "-i", pfad], capture_output=True, text=True, timeout=timeout, creationflags=OHNE_FENSTER, ) @@ -381,7 +406,7 @@ def get_progress_from_prgv(line: str) -> int: def check_handbrake_installed() -> bool: """Prüft, ob HandBrakeCLI installiert ist.""" - return shutil.which("HandBrakeCLI") is not None + return bool(werkzeuge.finden("handbrake")) DEFAULT_HB_PRESET = "H.265 MKV 1080p30" @@ -478,7 +503,7 @@ def build_handbrake_cmd(input_path: str, output_path: str, diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen. """ befehl = [ - "HandBrakeCLI", + werkzeug("handbrake"), "--input", input_path, "--output", output_path, "--preset", preset, diff --git a/docker/worker/test_ripping_helpers.py b/docker/worker/test_ripping_helpers.py index 634d1cb..6386ddb 100644 --- a/docker/worker/test_ripping_helpers.py +++ b/docker/worker/test_ripping_helpers.py @@ -20,7 +20,13 @@ from ripping import ( def test_makemkv_cmd_vollstaendig(): cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x") - assert cmd[0] == "makemkvcon" + # Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter + # Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem + # nackten Namen faende `subprocess` es dort nie. Im Container bleibt es + # der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf + # dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig; + # gepruefet wird deshalb, WORAUF der Befehl zeigt. + assert "makemkvcon" in cmd[0].lower() assert "-r" in cmd # Robot-Mode: maschinenlesbar assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.) assert "--progress=-same" in cmd # Fortschritt im selben Stream diff --git a/src/rippy/tools/__init__.py b/src/rippy/tools/__init__.py new file mode 100644 index 0000000..40fb4f5 --- /dev/null +++ b/src/rippy/tools/__init__.py @@ -0,0 +1,11 @@ +"""Werkzeug-Verwaltung: finden, holen, aktuell halten. + +Rippy unter Windows soll eigenstaendig arbeiten — also muss es seine +Werkzeuge auch selbst beschaffen koennen. Im Container ist das anders: dort +stecken sie im Image, und ein Update ist ein Rebuild. + + katalog.py Wo liegt was, in welcher Fassung (finden + Version) + beschaffen.py Herunterladen, installieren, aktuell halten +""" + +from rippy.tools.katalog import WERKZEUGE, bestand, finden, version_von # noqa: F401 diff --git a/src/rippy/tools/beschaffen.py b/src/rippy/tools/beschaffen.py new file mode 100644 index 0000000..2b888bd --- /dev/null +++ b/src/rippy/tools/beschaffen.py @@ -0,0 +1,315 @@ +"""Werkzeuge holen und aktuell halten — damit Rippy unter Windows allein zurechtkommt. + +## Warum es das nur hier gibt + +Im Container stecken HandBrake und MakeMKV im Image; ein Update ist ein +Rebuild. Der Docstring von `/system/updates` sagt das ausdrücklich: + + „Ein Selbst-Update gibt es bewusst NICHT: die Versionen stecken im + Worker-Image, das Update ist ein Image-Rebuild." + +Für den nativen Windows-Betrieb gilt das Gegenteil: Dort sind es eigenständige +Programme, und wenn Rippy eigenständig arbeiten soll, muss es sie auch selbst +beschaffen können. Sonst steht der Nutzer vor einer App, die ihm sagt, was ihr +fehlt, ohne etwas dagegen tun zu können. + +## Die zwei Werkzeuge sind unterschiedlich verlässlich — gemessen + +**HandBrake** kommt über die GitHub-Release-API. Am 28.08.2026 geprüft: + + api.github.com/repos/HandBrake/HandBrake/releases/latest -> 1.11.2 + HandBrakeCLI-1.11.2-win-x86_64.zip -> 24,5 MB + +Ein ZIP mit einer einzelnen `.exe`. Vollautomatisch, ohne Installer, ohne +Administratorrechte. + +**MakeMKV** ist der unzuverlässige Teil. Am 28.08.2026 gemessen: + + https://www.makemkv.com/download/ -> HTTP 525 (Cloudflare) + dito mit Browser-Kennung -> HTTP 525 + +Das ist dieselbe Sperre, die im Projekt schon den Docker-Bau lahmgelegt hat +(dort ist die Dauerlösung ein `vendor/`-Ordner mit von Hand geholten Dateien). + +Daraus folgt der Entwurf: **Ein Werkzeug, dessen Quelle heute antwortet, darf +nicht zur Voraussetzung dafür werden, dass Rippy morgen startet.** Der Abruf +wird versucht; scheitert er, sagt Rippy im Klartext, was los ist, und nimmt +stattdessen eine selbst heruntergeladene Datei entgegen. Genau die Haltung, +die `KONZEPT.md` schon für die Disc-Schlüssel festhält: Rippy hält den Platz +bereit, der Nutzer kann die Datei mitbringen. + +## Was NIE passiert + +Eine funktionierende Installation wird **nicht** durch einen halben Download +ersetzt. Es wird in eine temporäre Datei geladen, geprüft, und erst dann +getauscht. Ein abgebrochener Download darf nicht dazu führen, dass gar nichts +mehr geht. +""" + +import json +import os +import shutil +import subprocess +import tempfile +import urllib.error +import urllib.request +import zipfile + +from rippy.tools import katalog + +HANDBRAKE_API = "https://api.github.com/repos/HandBrake/HandBrake/releases/latest" +MAKEMKV_SEITE = "https://www.makemkv.com/download/" + +# Wie lange auf eine Antwort gewartet wird. Großzügig für den Download, +# knapp für die Versionsabfrage — die soll das UI nicht aufhalten. +ABFRAGE_TIMEOUT = 20 +DOWNLOAD_TIMEOUT = 600 + +# Eine HandBrakeCLI-ZIP ist rund 25 MB. Alles unter einem MB ist keine — +# sondern eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde. +MINDESTGROESSE_BYTES = 1024 * 1024 + + +class BeschaffungsFehler(RuntimeError): + """Es hat nicht geklappt — mit einer Begründung, die man lesen kann.""" + + +def _laden(url: str, timeout: int = ABFRAGE_TIMEOUT) -> bytes: + anfrage = urllib.request.Request(url, headers={ + # GitHub verlangt eine Kennung; ohne sie kommt 403. + "User-Agent": "Rippy/2.0 (+https://github.com/)", + "Accept": "*/*", + }) + with urllib.request.urlopen(anfrage, timeout=timeout) as antwort: + return antwort.read() + + +# ── HandBrake ─────────────────────────────────────────────────────────── +def handbrake_release() -> dict: + """Die neueste Fassung: {"version": …, "url": …, "groesse": …}.""" + try: + daten = json.loads(_laden(HANDBRAKE_API).decode("utf-8")) + except (urllib.error.URLError, OSError, ValueError) as e: + raise BeschaffungsFehler( + f"Die Versionsliste von HandBrake war nicht erreichbar ({e})." + ) from e + + version = katalog.version_aufraeumen(daten.get("tag_name") or "") + passend = waehle_handbrake_datei(daten.get("assets") or []) + if not passend: + raise BeschaffungsFehler( + f"HandBrake {version} bietet keine Windows-Kommandozeilen-Fassung " + "für diesen Rechner an." + ) + return {"version": version, "url": passend["browser_download_url"], + "groesse": passend.get("size", 0), "datei": passend["name"]} + + +def waehle_handbrake_datei(assets: list, maschine: str = None) -> dict: + """Welche Datei aus dem Release passt? (reine Funktion) + + Die Liste enthält mehrere Windows-Dateien — x86_64 und aarch64, jeweils + mit einer `.sig` daneben. Wer nur nach „win" filtert, lädt womöglich die + Signaturdatei (0 MB) oder die ARM-Fassung. + """ + import platform + + maschine = (maschine or platform.machine() or "").lower() + arch = "aarch64" if maschine in ("arm64", "aarch64") else "x86_64" + for asset in assets: + name = (asset.get("name") or "").lower() + if (name.startswith("handbrakecli-") and "win" in name + and arch in name and name.endswith(".zip")): + return asset + return {} + + +def handbrake_holen(ziel_ordner: str = None, fortschritt=None) -> str: + """Lädt HandBrakeCLI und legt es in Rippys Werkzeug-Ordner. Gibt den Pfad zurück.""" + ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "handbrake") + release = handbrake_release() + + def melden(text, anteil=None): + if fortschritt: + fortschritt(text, anteil) + + melden(f"HandBrake {release['version']} wird geladen …", 0.0) + with tempfile.TemporaryDirectory(prefix="rippy-hb-") as arbeit: + archiv = os.path.join(arbeit, release["datei"]) + _datei_laden(release["url"], archiv, release.get("groesse", 0), melden) + + melden("Wird entpackt …", 0.9) + with zipfile.ZipFile(archiv) as z: + namen = [n for n in z.namelist() if n.lower().endswith("handbrakecli.exe")] + if not namen: + raise BeschaffungsFehler( + "Im heruntergeladenen Archiv steckt keine HandBrakeCLI.exe. " + "Hat sich das Dateiformat des Projekts geändert?") + z.extract(namen[0], arbeit) + entpackt = os.path.join(arbeit, namen[0]) + + os.makedirs(ziel_ordner, exist_ok=True) + ziel = os.path.join(ziel_ordner, "HandBrakeCLI.exe") + # Erst daneben legen, dann tauschen: Ein abgebrochener Kopiervorgang + # darf keine funktionierende Fassung zerstören. + vorlaeufig = ziel + ".neu" + shutil.copy2(entpackt, vorlaeufig) + os.replace(vorlaeufig, ziel) + + melden(f"HandBrake {release['version']} ist einsatzbereit.", 1.0) + return ziel + + +def _datei_laden(url: str, ziel: str, erwartet: int, melden) -> None: + anfrage = urllib.request.Request(url, headers={"User-Agent": "Rippy/2.0"}) + try: + with urllib.request.urlopen(anfrage, timeout=DOWNLOAD_TIMEOUT) as antwort, \ + open(ziel, "wb") as datei: + geladen = 0 + while True: + brocken = antwort.read(256 * 1024) + if not brocken: + break + datei.write(brocken) + geladen += len(brocken) + if erwartet: + melden(None, min(0.85, 0.85 * geladen / erwartet)) + except (urllib.error.URLError, OSError) as e: + raise BeschaffungsFehler(f"Der Download ist gescheitert: {e}") from e + + groesse = os.path.getsize(ziel) + if groesse < MINDESTGROESSE_BYTES: + raise BeschaffungsFehler( + f"Die geladene Datei ist nur {groesse} Bytes groß — das ist keine " + "Programmdatei, sondern vermutlich eine Fehlerseite. " + "Nichts wurde verändert.") + + +# ── MakeMKV ───────────────────────────────────────────────────────────── +def makemkv_neueste_version(seite: str = None) -> str: + """Die neueste Fassung laut makemkv.com. "" wenn die Seite nicht antwortet. + + ⚠️ Am 28.08.2026 antwortete makemkv.com mit HTTP 525 (Cloudflare), auch + mit Browser-Kennung. Das ist kein Fehler auf unserer Seite und auch nicht + reparierbar — deshalb gibt diese Funktion "" zurück statt zu werfen: Ein + nicht erreichbarer Update-Server ist kein Grund, warum Rippy nicht laufen + sollte. + """ + import re + + try: + text = (seite if seite is not None + else _laden(MAKEMKV_SEITE).decode("utf-8", "replace")) + except (urllib.error.URLError, OSError): + return "" + treffer = re.search(r"MakeMKV\s+v?(\d+\.\d+\.\d+)", text or "") + return treffer.group(1) if treffer else "" + + +def makemkv_setup_url(version: str, basis: str = None) -> str: + """Die Adresse der Windows-Installationsdatei. + + Das Namensmuster stammt von makemkv.com selbst + (`Setup_MakeMKV_v.exe`). `basis` ist übersteuerbar — aus + demselben Grund wie `MAKEMKV_URL_BASE` beim Docker-Bau: Wenn die + Hauptquelle klemmt, soll ein Ausweichweg möglich sein, ohne den Code + anzufassen. + """ + basis = (basis or "https://www.makemkv.com/download").rstrip("/") + return f"{basis}/Setup_MakeMKV_v{version}.exe" + + +def makemkv_holen(version: str = "", basis: str = None, fortschritt=None, + still: bool = True) -> str: + """Lädt MakeMKV und startet den offiziellen Installer. + + **Rippy liefert MakeMKV NICHT mit** — es lädt die offizielle Datei vom + Hersteller und startet sie. Das ist dieselbe Black-Box-Trennung, die + `KONZEPT.md` § 6 für den Container festhält: MakeMKV bleibt ein fremdes + Programm, das der Nutzer bezieht; Rippy nimmt ihm nur die Handgriffe ab. + + Gibt den Pfad der heruntergeladenen Datei zurück. + """ + def melden(text, anteil=None): + if fortschritt: + fortschritt(text, anteil) + + version = version or makemkv_neueste_version() + if not version: + raise BeschaffungsFehler( + "Die Download-Seite von MakeMKV war nicht erreichbar, deshalb ist " + "unbekannt, welche Fassung aktuell ist. (Am 28.08.2026 antwortete " + "makemkv.com mit HTTP 525 — eine Störung auf deren Seite.) " + "Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; " + "Rippy findet es danach von allein.") + + ordner = os.path.join(katalog.werkzeug_ordner(), "downloads") + os.makedirs(ordner, exist_ok=True) + ziel = os.path.join(ordner, f"Setup_MakeMKV_v{version}.exe") + + melden(f"MakeMKV {version} wird geladen …", 0.0) + _datei_laden(makemkv_setup_url(version, basis), ziel, 0, melden) + + melden("Der Installer von MakeMKV wird gestartet …", 0.9) + starte_installer(ziel, still=still) + melden(f"MakeMKV {version} wird installiert.", 1.0) + return ziel + + +def starte_installer(pfad: str, still: bool = True) -> None: + """Startet eine Installationsdatei — auf Wunsch ohne Rückfragen. + + `/S` ist der stille Modus von NSIS, mit dem MakeMKVs Windows-Installer + gebaut ist. Ohne Kennzeichen erscheint der gewohnte Dialog; das ist der + ehrlichere Vorgabewert, wenn jemand zusieht. + """ + from rippy.platform.winlauf import OHNE_FENSTER + + befehl = [pfad] + (["/S"] if still else []) + try: + subprocess.Popen(befehl, creationflags=OHNE_FENSTER, close_fds=True) + except OSError as e: + raise BeschaffungsFehler( + f"Der Installer ließ sich nicht starten: {e}") from e + + +# ── Gemeinsam ─────────────────────────────────────────────────────────── +def neueste_versionen() -> dict: + """Was ist jeweils die aktuelle Fassung? Nicht erreichbar -> "". + + Wirft NIE. Ein Update-Check ist Komfort; er darf nichts blockieren. + """ + ergebnis = {"handbrake": "", "makemkv": ""} + try: + ergebnis["handbrake"] = handbrake_release()["version"] + except BeschaffungsFehler: + pass + ergebnis["makemkv"] = makemkv_neueste_version() + return ergebnis + + +def ist_neuer(neu: str, alt: str) -> bool: + """Ist `neu` wirklich neuer als `alt`? (reine Funktion) + + Ein Zeichenketten-Vergleich reicht nicht: "1.9.2" wäre damit größer als + "1.11.2", weil "9" > "1". Genau so entstehen Update-Hinweise, die bei + jeder Zahl über neun in die Irre führen. + """ + def teile(v): + return [int(t) for t in (v or "").split(".") if t.isdigit()] + + a, b = teile(neu), teile(alt) + if not a or not b: + return bool(a) and not b + return a > b + + +def lage(eingestellt: dict = None) -> dict: + """Was ist da, was ist neu, was fehlt — die Auskunft fürs UI.""" + vorhanden = katalog.bestand(eingestellt) + neueste = neueste_versionen() + for name, eintrag in vorhanden.items(): + eintrag["neueste"] = neueste.get(name, "") + eintrag["update"] = ist_neuer(eintrag["neueste"], eintrag["version"]) + eintrag["holbar"] = name == "handbrake" or bool(neueste.get(name)) + return vorhanden diff --git a/src/rippy/tools/katalog.py b/src/rippy/tools/katalog.py new file mode 100644 index 0000000..7141f00 --- /dev/null +++ b/src/rippy/tools/katalog.py @@ -0,0 +1,300 @@ +"""Wo Rippys Werkzeuge liegen — und wie man sie findet. + +## Warum das ein eigenes Modul braucht + +Der Bestand sucht ausschließlich mit `shutil.which()`: + + ripping.py:51 return shutil.which("makemkvcon") is not None + caps.py:151 if not shutil.which("HandBrakeCLI"): + +Im Container stimmt das — dort liegen beide in `/usr/local/bin`. **Unter +Windows findet es NICHTS**, und zwar auch dann nicht, wenn beide installiert +sind: Windows-Programme landen in `Program Files`, nicht im `PATH`. Am +28.08.2026 auf dem Commander-PC nachgesehen — MakeMKV liegt unter +`C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe`, und `which` sieht es nicht. + +Für einen Rippy, der unter Windows eigenständig arbeiten soll, ist das der +Unterschied zwischen „läuft" und „läuft nicht". + +## Die Suchreihenfolge — und warum sie so herum ist + +1. **Ausdrücklich eingestellt** (Konfiguration). Wer einen Pfad einträgt, will + genau den — sonst hätte er nichts eingetragen. +2. **Rippys eigener Werkzeug-Ordner.** Was Rippy selbst geholt hat, kennt es + auch. Steht VOR dem System, damit eine von Rippy aktuell gehaltene Fassung + eine alte Systeminstallation schlägt. +3. **PATH.** Der Linux-Weg, und unter Windows der Fall „selbst eingetragen". +4. **Bekannte Installationsorte.** Der Normalfall unter Windows. +5. **Der Uninstall-Zweig der Registry.** Letzte Rettung: Dort steht bei + MakeMKV zwar KEIN Installationsort (`InstallLocation` ist leer — am + 28.08.2026 nachgesehen), aber `DisplayIcon` und `UninstallString` zeigen + auf Dateien im Programmordner. + +## Versionen + +`HandBrakeCLI --version` ist dokumentiert. **`makemkvcon` hat keinen +Versions-Schalter** (usage.txt kennt keinen) — die Version steht deshalb im +Uninstall-Zweig der Registry (`DisplayVersion`). Das ist kein Notbehelf, +sondern die einzige Stelle, an der sie unter Windows überhaupt steht. +""" + +import os +import shutil +import subprocess + +# ── Die Werkzeuge ─────────────────────────────────────────────────────── +# `programme` Dateinamen, in dieser Reihenfolge probiert (64 Bit zuerst). +# `orte` Bekannte Installationsordner, mit %UMGEBUNGSVARIABLEN%. +# `anzeige` Wie das Werkzeug in der Uninstall-Liste heißt (Teilstring). +WERKZEUGE = { + "makemkv": { + "titel": "MakeMKV", + "zweck": "Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.", + "programme": ("makemkvcon64.exe", "makemkvcon.exe", "makemkvcon"), + "orte": ( + r"%ProgramFiles(x86)%\MakeMKV", + r"%ProgramFiles%\MakeMKV", + ), + "anzeige": "MakeMKV", + "version_flag": None, # kennt keinen Versions-Schalter + "pflicht": True, + }, + "handbrake": { + "titel": "HandBrakeCLI", + "zweck": "Komprimiert den verlustfreien Rip auf Arbeitsgröße.", + "programme": ("HandBrakeCLI.exe", "HandBrakeCLI"), + "orte": ( + r"%ProgramFiles%\HandBrake", + r"%ProgramFiles(x86)%\HandBrake", + ), + "anzeige": "HandBrake", + "version_flag": "--version", + "pflicht": False, # ohne HandBrake geht Rippen, nur ohne Kompression + }, +} + + +def werkzeug_ordner(basis: str = None) -> str: + """Rippys eigener Werkzeug-Ordner — dort landet, was Rippy selbst holt.""" + if basis: + return os.path.join(basis, "tools") + if os.name == "nt": + wurzel = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") + return os.path.join(wurzel, "Rippy", "tools") + return os.path.join(os.path.expanduser("~"), ".local", "share", "rippy", "tools") + + +def _entfalten(pfad: str, umgebung: dict) -> str: + """%ProgramFiles%\\X -> C:\\Program Files\\X (mit eigener Umgebung testbar).""" + ergebnis = pfad + for name, wert in umgebung.items(): + ergebnis = ergebnis.replace("%" + name + "%", wert) + return ergebnis if "%" not in ergebnis else "" + + +def kandidaten(name: str, eigener_ordner: str = None, umgebung: dict = None) -> list: + """Alle Pfade, an denen dieses Werkzeug liegen könnte — in Suchreihenfolge. + + Reine Funktion: Sie prüft NICHT, ob es die Dateien gibt. Das macht + `finden()`. So lässt sich die Reihenfolge testen, ohne irgendetwas zu + installieren. + """ + werkzeug = WERKZEUGE[name] + umgebung = os.environ if umgebung is None else umgebung + eigener_ordner = eigener_ordner or werkzeug_ordner() + + pfade = [] + # 2. Rippys eigener Ordner — VOR dem System, damit eine selbst gepflegte + # Fassung eine alte Systeminstallation schlägt. + for programm in werkzeug["programme"]: + pfade.append(os.path.join(eigener_ordner, name, programm)) + pfade.append(os.path.join(eigener_ordner, programm)) + # 4. Bekannte Installationsorte. + for ort in werkzeug["orte"]: + entfaltet = _entfalten(ort, umgebung) + if not entfaltet: + continue + for programm in werkzeug["programme"]: + pfade.append(os.path.join(entfaltet, programm)) + return pfade + + +def finden(name: str, eingestellt: str = "", eigener_ordner: str = None, + umgebung: dict = None, existiert=None, im_pfad=None, + aus_registry=None) -> str: + """Der Pfad zum Werkzeug — oder "" wenn es nicht da ist. + + Die vier Prüf-Funktionen sind einspritzbar, damit die Suchreihenfolge + ohne installierte Programme prüfbar ist. + """ + existiert = existiert or os.path.isfile + im_pfad = im_pfad or shutil.which + aus_registry = aus_registry if aus_registry is not None else aus_uninstall_zweig + + # 1. Ausdrücklich eingestellt schlägt alles. + if eingestellt and existiert(eingestellt): + return eingestellt + + # 2. + 4. + for pfad in kandidaten(name, eigener_ordner, umgebung): + if existiert(pfad): + return pfad + + # 3. PATH. + for programm in WERKZEUGE[name]["programme"]: + treffer = im_pfad(programm) + if treffer: + return treffer + + # 5. Registry (nur Windows, und nur als letzte Rettung). + if os.name == "nt": + ordner = aus_registry(WERKZEUGE[name]["anzeige"]).get("ordner", "") + if ordner: + for programm in WERKZEUGE[name]["programme"]: + pfad = os.path.join(ordner, programm) + if existiert(pfad): + return pfad + return "" + + +# ── Registry: Version und Ordner aus dem Uninstall-Zweig ──────────────── +UNINSTALL_ZWEIGE = ( + ("HKLM", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), + ("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"), + ("HKCU", r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"), +) + + +def ordner_aus_eintrag(werte: dict) -> str: + """Welcher Ordner steckt in einem Uninstall-Eintrag? (reine Funktion) + + `InstallLocation` wäre der richtige Ort — bei MakeMKV ist er aber LEER + (am 28.08.2026 auf dem Commander-PC nachgesehen). Dann helfen + `DisplayIcon` und `UninstallString`: Beide zeigen auf eine Datei IM + Programmordner, aus der sich der Ordner ableiten lässt. + """ + ort = (werte.get("InstallLocation") or "").strip().strip('"') + if ort: + return ort.rstrip("\\/") + for schluessel in ("DisplayIcon", "UninstallString"): + datei = programmdatei_aus(werte.get(schluessel) or "") + if datei: + return os.path.dirname(datei) + return "" + + +def programmdatei_aus(wert: str) -> str: + r"""Holt den Dateipfad aus einem Registry-Wert. (reine Funktion) + + Die beiden Felder sehen unterschiedlich aus, und beide tragen Beiwerk: + + DisplayIcon C:\...\makemkv.exe,0 <- Symbol-Nummer hinten + UninstallString "C:\...\uninstall.exe" /S <- Anfuehrungszeichen + Argument + + Ein blosses `.strip('"')` reicht nicht: Bei der zweiten Form bleibt + `uninstall.exe" /S` stehen, die Pruefung auf `.exe` schlaegt fehl, und der + Ordner wird nicht gefunden. Genau daran ist der Test beim Bau dieser + Funktion gescheitert — er war schneller als jede Ueberlegung. + """ + wert = (wert or "").strip() + if not wert: + return "" + if wert.startswith('"'): + ende = wert.find('"', 1) + if ende > 0: + wert = wert[1:ende] + else: + # Ohne Anfuehrungszeichen: bis einschliesslich .exe — der Rest ist + # Symbol-Nummer oder Argument. + stelle = wert.lower().find(".exe") + if stelle > 0: + wert = wert[:stelle + 4] + wert = wert.strip().strip('"') + return wert if wert.lower().endswith(".exe") else "" + + +def version_aufraeumen(roh: str) -> str: + """„v1.18.4" -> „1.18.4"; „HandBrake 1.9.2" -> „1.9.2".""" + text = (roh or "").strip() + if not text: + return "" + for teil in text.replace("\n", " ").split(): + kern = teil.lstrip("vV") + if kern and kern[0].isdigit() and "." in kern: + return kern + return text.lstrip("vV") + + +def aus_uninstall_zweig(anzeigename: str) -> dict: + """{"version": …, "ordner": …} aus der Uninstall-Liste. Leer, wenn nichts da.""" + if os.name != "nt": + return {} + import winreg + + huette = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER} + for wurzel, zweig in UNINSTALL_ZWEIGE: + try: + with winreg.OpenKey(huette[wurzel], zweig) as basis: + for i in range(winreg.QueryInfoKey(basis)[0]): + try: + unterschluessel = winreg.EnumKey(basis, i) + with winreg.OpenKey(basis, unterschluessel) as k: + werte = {} + for j in range(winreg.QueryInfoKey(k)[1]): + n, w, _ = winreg.EnumValue(k, j) + werte[n] = w + except OSError: + continue + name = str(werte.get("DisplayName") or "") + if anzeigename.lower() not in name.lower(): + continue + return { + "version": version_aufraeumen( + str(werte.get("DisplayVersion") or "") or name), + "ordner": ordner_aus_eintrag(werte), + "anzeigename": name, + } + except OSError: + continue + return {} + + +def version_von(name: str, pfad: str) -> str: + """Die Version des gefundenen Werkzeugs. + + HandBrakeCLI kann `--version`. **makemkvcon kann das nicht** — sein + dokumentierter Aufrufsatz (usage.txt) kennt keinen solchen Schalter. + Für MakeMKV kommt die Version deshalb aus der Registry. + """ + if not pfad: + return "" + flag = WERKZEUGE[name]["version_flag"] + if flag: + try: + from rippy.platform.winlauf import OHNE_FENSTER + + ergebnis = subprocess.run( + [pfad, flag], capture_output=True, text=True, timeout=20, + creationflags=OHNE_FENSTER, + ) + return version_aufraeumen((ergebnis.stdout or ergebnis.stderr or "").strip()) + except (OSError, subprocess.SubprocessError): + return "" + return aus_uninstall_zweig(WERKZEUGE[name]["anzeige"]).get("version", "") + + +def bestand(eingestellt: dict = None, eigener_ordner: str = None) -> dict: + """Was ist da, wo, in welcher Fassung? — die Grundlage fürs UI.""" + eingestellt = eingestellt or {} + ergebnis = {} + for name, werkzeug in WERKZEUGE.items(): + pfad = finden(name, eingestellt.get(name, ""), eigener_ordner) + ergebnis[name] = { + "titel": werkzeug["titel"], + "zweck": werkzeug["zweck"], + "pflicht": werkzeug["pflicht"], + "pfad": pfad, + "da": bool(pfad), + "version": version_von(name, pfad) if pfad else "", + } + return ergebnis diff --git a/src/rippy/tools/test_beschaffen.py b/src/rippy/tools/test_beschaffen.py new file mode 100644 index 0000000..f734804 --- /dev/null +++ b/src/rippy/tools/test_beschaffen.py @@ -0,0 +1,144 @@ +"""Werkzeuge holen — die Entscheidungen, ohne etwas herunterzuladen. + +Nichts hier geht ins Netz. Geprueft wird, was FALSCH gehen kann: +die falsche Datei aus einem Release, ein Versionsvergleich, der bei zweistelligen +Zahlen umkippt, und eine Fehlerseite, die als Programm durchgeht. +""" + +import os + +import pytest + +from rippy.tools import beschaffen + + +# ── Welche Datei aus dem Release? ─────────────────────────────────────── +# Woertlich aus der GitHub-Antwort vom 28.08.2026 (Release 1.11.2), gekuerzt. +ASSETS = [ + {"name": "HandBrake-1.11.2-x86_64-Win_GUI.exe", "size": 20_000_000}, + {"name": "HandBrakeCLI-1.11.2-win-aarch64.zip", "size": 19_300_000}, + {"name": "HandBrakeCLI-1.11.2-win-aarch64.zip.sig", "size": 566}, + {"name": "HandBrakeCLI-1.11.2-win-x86_64.zip", "size": 25_700_000}, + {"name": "HandBrakeCLI-1.11.2-win-x86_64.zip.sig", "size": 566}, + {"name": "HandBrake-1.11.2.tar.bz2", "size": 18_000_000}, +] + + +def test_nimmt_die_richtige_windows_datei(): + assert beschaffen.waehle_handbrake_datei(ASSETS, "AMD64")["name"] \ + == "HandBrakeCLI-1.11.2-win-x86_64.zip" + + +def test_nimmt_auf_arm_die_arm_fassung(): + assert beschaffen.waehle_handbrake_datei(ASSETS, "ARM64")["name"] \ + == "HandBrakeCLI-1.11.2-win-aarch64.zip" + + +def test_nimmt_niemals_die_signaturdatei(): + """Die .sig liegt direkt daneben und ist 566 Bytes gross. Wer nur nach + „win" und „x86_64" filtert, laedt womoeglich sie — und haette dann eine + „HandBrakeCLI", die nichts kann.""" + for maschine in ("AMD64", "ARM64"): + assert not beschaffen.waehle_handbrake_datei(ASSETS, maschine)["name"].endswith(".sig") + + +def test_nimmt_nicht_die_grafische_fassung(): + """Win_GUI.exe ist das Programm mit Fenster — Rippy braucht die + Kommandozeilen-Fassung.""" + assert "GUI" not in beschaffen.waehle_handbrake_datei(ASSETS, "AMD64")["name"] + + +def test_ohne_passende_datei_kommt_leer(): + assert beschaffen.waehle_handbrake_datei([], "AMD64") == {} + assert beschaffen.waehle_handbrake_datei( + [{"name": "HandBrake-1.11.2.tar.bz2", "size": 1}], "AMD64") == {} + + +# ── Versionsvergleich ─────────────────────────────────────────────────── +def test_zweistellige_versionen_werden_richtig_verglichen(): + """DER Klassiker: Als Zeichenkette ist „1.9.2" groesser als „1.11.2", + weil „9" > „1". Ein Update-Hinweis, der ab Version zehn dauerhaft in die + Irre fuehrt — und genau dort ist HandBrake gerade (1.11.2).""" + assert beschaffen.ist_neuer("1.11.2", "1.9.2") is True + assert beschaffen.ist_neuer("1.9.2", "1.11.2") is False + + +def test_gleiche_version_ist_kein_update(): + assert beschaffen.ist_neuer("1.18.4", "1.18.4") is False + + +def test_neuere_nachkommastelle_zaehlt(): + assert beschaffen.ist_neuer("1.18.5", "1.18.4") is True + assert beschaffen.ist_neuer("2.0.0", "1.18.4") is True + + +def test_ohne_installierte_fassung_ist_alles_ein_update(): + """Nichts installiert -> es gibt etwas zu holen.""" + assert beschaffen.ist_neuer("1.11.2", "") is True + + +def test_ohne_bekannte_neueste_kein_update(): + """Wenn die Quelle nicht antwortet, darf NICHT „Update verfuegbar" + dastehen. Eine Nichtauskunft ist keine Aussage.""" + assert beschaffen.ist_neuer("", "1.18.4") is False + assert beschaffen.ist_neuer("", "") is False + + +# ── Adressen ──────────────────────────────────────────────────────────── +def test_makemkv_setup_adresse(): + assert beschaffen.makemkv_setup_url("1.18.4") \ + == "https://www.makemkv.com/download/Setup_MakeMKV_v1.18.4.exe" + + +def test_makemkv_ausweichquelle_ist_moeglich(): + """Aus demselben Grund wie MAKEMKV_URL_BASE beim Docker-Bau: Wenn die + Hauptquelle klemmt (am 28.08.2026: HTTP 525), soll ein Ausweg moeglich + sein, ohne den Code anzufassen.""" + assert beschaffen.makemkv_setup_url("1.18.4", "http://192.168.178.10:8099/") \ + == "http://192.168.178.10:8099/Setup_MakeMKV_v1.18.4.exe" + + +def test_version_aus_der_download_seite(): + seite = "

MakeMKV v1.18.4

... " + assert beschaffen.makemkv_neueste_version(seite) == "1.18.4" + + +def test_unlesbare_seite_ergibt_leer_statt_absturz(): + """makemkv.com antwortete am 28.08.2026 mit HTTP 525. Ein nicht + erreichbarer Update-Server ist kein Grund, warum Rippy nicht laufen + sollte.""" + assert beschaffen.makemkv_neueste_version("") == "" + assert beschaffen.makemkv_neueste_version("Fehler 525") == "" + + +# ── Halbe Downloads ───────────────────────────────────────────────────── +def test_zu_kleine_datei_wird_abgelehnt(tmp_path, monkeypatch): + """Eine Fehlerseite kommt oft mit HTTP 200. Wuerde sie als HandBrakeCLI + abgelegt, waere eine funktionierende Installation durch 2 KB HTML + ersetzt — und der Fehler faellt erst beim naechsten Encode auf.""" + ziel = tmp_path / "x.zip" + + class FakeAntwort: + def read(self, n=-1): + if getattr(self, "_fertig", False): + return b"" + self._fertig = True + return b"503" + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(beschaffen.urllib.request, "urlopen", + lambda *a, **k: FakeAntwort()) + with pytest.raises(beschaffen.BeschaffungsFehler, match="Fehlerseite"): + beschaffen._datei_laden("http://x/y.zip", str(ziel), 0, lambda *a: None) + + +def test_werkzeug_ordner_liegt_dort_wo_geschrieben_werden_darf(): + ordner = beschaffen.katalog.werkzeug_ordner() + assert "tools" in ordner + if os.name == "nt": + assert "Program Files" not in ordner diff --git a/src/rippy/tools/test_katalog.py b/src/rippy/tools/test_katalog.py new file mode 100644 index 0000000..22662d4 --- /dev/null +++ b/src/rippy/tools/test_katalog.py @@ -0,0 +1,176 @@ +"""Werkzeuge finden — die Suchreihenfolge und die Versions-Auslese. + +## Warum es diese Tests gibt + +Der Bestand suchte ausschliesslich mit `shutil.which()`. Im Container stimmt +das; **unter Windows findet es nichts**, auch wenn beide Werkzeuge installiert +sind — Windows-Programme liegen in `Program Files`, nicht im PATH. Am +28.08.2026 auf dem Commander-PC nachgesehen: MakeMKV liegt unter +`C:\\Program Files (x86)\\MakeMKV\\makemkvcon64.exe`, `which` sieht es nicht. + +Alle Pruefungen hier sind eingespritzt — kein installiertes Werkzeug noetig, +laeuft auf jeder Plattform. +""" + +from rippy.tools import katalog + +# Eine Umgebung ohne echte Systemvariablen, damit die Pfade vorhersagbar sind. +UMGEBUNG = { + "ProgramFiles": r"C:\Program Files", + "ProgramFiles(x86)": r"C:\Program Files (x86)", +} + + +def _nur(*vorhandene): + """Eine `existiert`-Funktion, die genau diese Pfade kennt.""" + menge = {p.lower() for p in vorhandene} + return lambda p: p.lower() in menge + + +def _nichts_im_pfad(_): + return None + + +def _keine_registry(_): + return {} + + +# ── Suchreihenfolge ───────────────────────────────────────────────────── +def test_eingestellter_pfad_schlaegt_alles(): + """Wer einen Pfad eintraegt, will genau den — sonst haette er nichts + eingetragen. Ein Fund im System darf ihn nicht ueberstimmen.""" + treffer = katalog.finden( + "makemkv", eingestellt=r"D:\Eigenes\makemkvcon64.exe", + eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=_nur(r"D:\Eigenes\makemkvcon64.exe", + r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe"), + im_pfad=_nichts_im_pfad, aus_registry=_keine_registry) + assert treffer == r"D:\Eigenes\makemkvcon64.exe" + + +def test_eigener_ordner_schlaegt_die_systeminstallation(): + """Was Rippy selbst geholt und aktuell gehalten hat, gilt vor einer + womoeglich alten Fassung aus dem System.""" + treffer = katalog.finden( + "handbrake", eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=_nur(r"C:\Rippy\tools\handbrake\HandBrakeCLI.exe", + r"C:\Program Files\HandBrake\HandBrakeCLI.exe"), + im_pfad=_nichts_im_pfad, aus_registry=_keine_registry) + assert treffer == r"C:\Rippy\tools\handbrake\HandBrakeCLI.exe" + + +def test_bekannter_installationsort_wird_gefunden(): + """DER Fall, den `which` nicht kann — und der auf jedem Windows-Rechner + mit installiertem MakeMKV der Normalfall ist.""" + treffer = katalog.finden( + "makemkv", eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=_nur(r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe"), + im_pfad=_nichts_im_pfad, aus_registry=_keine_registry) + assert treffer == r"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe" + + +def test_64bit_fassung_kommt_vor_der_32bit(): + """makemkvcon64.exe und makemkvcon.exe liegen im selben Ordner + nebeneinander. Die 64-Bit-Fassung ist die richtige.""" + ordner = r"C:\Program Files (x86)\MakeMKV" + treffer = katalog.finden( + "makemkv", eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=_nur(ordner + r"\makemkvcon64.exe", ordner + r"\makemkvcon.exe"), + im_pfad=_nichts_im_pfad, aus_registry=_keine_registry) + assert treffer.endswith("makemkvcon64.exe") + + +def test_pfad_wird_benutzt_wenn_sonst_nichts_da_ist(): + treffer = katalog.finden( + "handbrake", eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=lambda p: False, + im_pfad=lambda n: r"C:\Werkzeuge\HandBrakeCLI.exe" if "HandBrake" in n else None, + aus_registry=_keine_registry) + assert treffer == r"C:\Werkzeuge\HandBrakeCLI.exe" + + +def test_nichts_gefunden_ist_ein_leerer_string(): + """Nicht None, nicht eine Ausnahme — "" laesst sich ueberall pruefen.""" + assert katalog.finden( + "handbrake", eigener_ordner=r"C:\Rippy\tools", umgebung=UMGEBUNG, + existiert=lambda p: False, im_pfad=_nichts_im_pfad, + aus_registry=_keine_registry) == "" + + +def test_kandidatenliste_haelt_die_reihenfolge_ein(): + pfade = katalog.kandidaten("makemkv", r"C:\Rippy\tools", UMGEBUNG) + eigener = next(i for i, p in enumerate(pfade) if r"C:\Rippy\tools" in p) + system = next(i for i, p in enumerate(pfade) if "Program Files" in p) + assert eigener < system, "Rippys eigener Ordner muss zuerst kommen" + + +def test_unaufgeloeste_variable_wird_uebersprungen(): + """Auf einem 32-Bit-System gibt es %ProgramFiles(x86)% nicht. Ein Pfad + mit stehengebliebenem Prozentzeichen waere Unsinn — er darf nicht in die + Liste.""" + pfade = katalog.kandidaten("makemkv", r"C:\Rippy\tools", + {"ProgramFiles": r"C:\Program Files"}) + assert not any("%" in p for p in pfade) + + +# ── Registry-Auswertung ───────────────────────────────────────────────── +def test_installlocation_wird_bevorzugt(): + assert katalog.ordner_aus_eintrag({ + "InstallLocation": r"C:\Program Files\HandBrake\\", + "DisplayIcon": r"C:\woanders\x.exe", + }) == r"C:\Program Files\HandBrake" + + +def test_leeres_installlocation_faellt_auf_displayicon_zurueck(): + """GENAU DER FALL bei MakeMKV: Am 28.08.2026 nachgesehen — der + Uninstall-Eintrag hat eine Version, aber InstallLocation ist LEER.""" + assert katalog.ordner_aus_eintrag({ + "InstallLocation": "", + "DisplayIcon": r"C:\Program Files (x86)\MakeMKV\makemkv.exe,0", + }) == r"C:\Program Files (x86)\MakeMKV" + + +def test_uninstallstring_als_letzte_rettung(): + assert katalog.ordner_aus_eintrag({ + "UninstallString": r'"C:\Program Files (x86)\MakeMKV\uninstall.exe" /S', + }) == r"C:\Program Files (x86)\MakeMKV" + + +def test_ohne_brauchbaren_eintrag_kommt_leer_zurueck(): + assert katalog.ordner_aus_eintrag({}) == "" + assert katalog.ordner_aus_eintrag({"DisplayIcon": "irgendwas"}) == "" + + +# ── Versionen ─────────────────────────────────────────────────────────── +def test_version_ohne_v_davor(): + assert katalog.version_aufraeumen("v1.18.4") == "1.18.4" + assert katalog.version_aufraeumen("1.18.4") == "1.18.4" + + +def test_version_aus_einem_ganzen_satz(): + """`HandBrakeCLI --version` gibt „HandBrake 1.11.2" aus.""" + assert katalog.version_aufraeumen("HandBrake 1.11.2") == "1.11.2" + assert katalog.version_aufraeumen("HandBrake 1.11.2\nsome build info") == "1.11.2" + + +def test_version_aus_dem_anzeigenamen(): + """MakeMKVs Uninstall-Eintrag heisst „MakeMKV v1.18.4".""" + assert katalog.version_aufraeumen("MakeMKV v1.18.4") == "1.18.4" + + +def test_leere_version_bleibt_leer(): + assert katalog.version_aufraeumen("") == "" + assert katalog.version_aufraeumen(None) == "" + + +def test_programmdatei_aus_beiden_formen(): + """Die zwei Registry-Felder tragen unterschiedliches Beiwerk — und beide + Formen kommen bei MakeMKV wirklich vor.""" + p = katalog.programmdatei_aus + assert p(r"C:\Program Files (x86)\MakeMKV\makemkv.exe,0") \ + == r"C:\Program Files (x86)\MakeMKV\makemkv.exe" + assert p(r'"C:\Program Files (x86)\MakeMKV\uninstall.exe" /S') \ + == r"C:\Program Files (x86)\MakeMKV\uninstall.exe" + assert p(r"C:\R\x.exe") == r"C:\R\x.exe" + assert p("") == "" + assert p("irgendwas ohne Programm") == ""