feat(tools): Werkzeuge finden, holen und aktuell halten — Grundlage fuer Windows
Ampel / ampel (push) Failing after 47s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 11:12:21 +02:00
co-authored by Claude Opus 5
parent 059183c651
commit 288f9eeb8d
8 changed files with 1005 additions and 15 deletions
+20 -7
View File
@@ -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,
)
+32 -7
View File
@@ -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:<pfad> <titel> <ziel> '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,
+7 -1
View File
@@ -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
+11
View File
@@ -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
+315
View File
@@ -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<version>.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
+300
View File
@@ -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
+144
View File
@@ -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 = "<html><h2>MakeMKV v1.18.4</h2> ... </html>"
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("<html>Fehler 525</html>") == ""
# ── 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"<html>503</html>"
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
+176
View File
@@ -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") == ""