feat(tools): Werkzeuge finden, holen und aktuell halten — Grundlage fuer Windows
Ampel / ampel (push) Failing after 47s
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:
co-authored by
Claude Opus 5
parent
059183c651
commit
288f9eeb8d
+20
-7
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == ""
|
||||
Reference in New Issue
Block a user