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
|
import subprocess
|
||||||
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
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")
|
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:
|
def hole_handbrake_presets() -> list:
|
||||||
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
if not shutil.which("HandBrakeCLI"):
|
if not _hb():
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--preset-list"],
|
[_hb(), "--preset-list"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
@@ -262,11 +275,11 @@ def cpu_merkmale() -> tuple:
|
|||||||
|
|
||||||
def hole_handbrake_hilfe() -> str:
|
def hole_handbrake_hilfe() -> str:
|
||||||
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
|
||||||
if not shutil.which("HandBrakeCLI"):
|
if not _hb():
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
|
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER
|
creationflags=OHNE_FENSTER
|
||||||
)
|
)
|
||||||
return (aus.stdout or "") + (aus.stderr or "")
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
@@ -349,12 +362,12 @@ def werkzeug_versionen() -> dict:
|
|||||||
presets = hole_handbrake_presets()
|
presets = hole_handbrake_presets()
|
||||||
if presets:
|
if presets:
|
||||||
info["presets"] = presets
|
info["presets"] = presets
|
||||||
if shutil.which("makemkvcon"):
|
if werkzeuge.finden("makemkv"):
|
||||||
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
|
||||||
if shutil.which("HandBrakeCLI"):
|
if _hb():
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
["HandBrakeCLI", "--version"],
|
[_hb(), "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, text=True, timeout=15,
|
||||||
creationflags=OHNE_FENSTER,
|
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.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
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")
|
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
|
||||||
|
|
||||||
@@ -48,7 +73,7 @@ class RipAbbruch(Exception):
|
|||||||
|
|
||||||
def check_makemkv_installed() -> bool:
|
def check_makemkv_installed() -> bool:
|
||||||
"""Prüft, ob makemkvcon installiert ist."""
|
"""Prüft, ob makemkvcon installiert ist."""
|
||||||
return shutil.which("makemkvcon") is not None
|
return bool(werkzeuge.finden("makemkv"))
|
||||||
|
|
||||||
|
|
||||||
def check_abcde_installed() -> bool:
|
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)
|
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
"makemkvcon",
|
werkzeug("makemkv"),
|
||||||
"-r",
|
"-r",
|
||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--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.
|
wäre eine Minute Wartezeit für nichts.
|
||||||
"""
|
"""
|
||||||
ergebnis = subprocess.run(
|
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,
|
capture_output=True, text=True, timeout=timeout,
|
||||||
creationflags=OHNE_FENSTER,
|
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)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
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,
|
capture_output=True, text=True, timeout=timeout,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
@@ -334,7 +359,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
return 0
|
return 0
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["HandBrakeCLI", "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, text=True, timeout=timeout,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
@@ -381,7 +406,7 @@ def get_progress_from_prgv(line: str) -> int:
|
|||||||
|
|
||||||
def check_handbrake_installed() -> bool:
|
def check_handbrake_installed() -> bool:
|
||||||
"""Prüft, ob HandBrakeCLI installiert ist."""
|
"""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"
|
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.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
"HandBrakeCLI",
|
werkzeug("handbrake"),
|
||||||
"--input", input_path,
|
"--input", input_path,
|
||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--preset", preset,
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ from ripping import (
|
|||||||
|
|
||||||
def test_makemkv_cmd_vollstaendig():
|
def test_makemkv_cmd_vollstaendig():
|
||||||
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
|
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 "-r" in cmd # Robot-Mode: maschinenlesbar
|
||||||
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
|
||||||
assert "--progress=-same" in cmd # Fortschritt im selben Stream
|
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