From 95beb913648df9d3777a7a1000e85b8ea03deb81 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 15:19:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(tools):=20Ausweichquellen=20fuer=20MakeMKV?= =?UTF-8?q?=20=E2=80=94=20recherchiert=20und=20gemessen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: "bitte baue fuer MakeMKV fallback seiten ein." Anlass war ein echter Ausfall. Am 28.08.2026 gemessen: https://www.makemkv.com/download/ HTTP 525 (dauerhaft) https://makemkv.com/download/ HTTP 525 http://www.makemkv.com/download/ HTTP 403 https://forum.makemkv.com/ 200 / 522 / Zeitablauf (wechselnd) web.archive.org/web/2025id_/...exe 200, 16.432.607 Bytes 525/522 heissen: Cloudflare erreicht den Ursprungsserver nicht. Eine Stoerung beim Hersteller -- dieselbe, die im Projekt schon einmal jeden Worker-Build lahmgelegt hat. ## Die Kette Version: Hersteller-Seite (massgeblich) -> Forum-Ankuendigungen -> Archiv Datei: eigene Quelle -> Hersteller -> Internet Archive Der Hersteller zuerst, immer. Das Archiv ist Rueckfallebene, und Rippy nennt hinterher die Quelle, aus der die Datei kam. Zwei Feinheiten, beide aus Messungen statt aus dem Kopf: * Die HOECHSTE Nummer gewinnt, nicht die erste. Antwortet das Forum nicht, meldet das Archiv einen aelteren Stand (1.18.2 statt 1.18.4) -- "neueste Fassung" waere dann eine falsche Aussage. * Ein zweiter Anlauf, mit knapper Zeitgrenze. Drei Abrufe am Forum: TimeoutError, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in zwei von drei Faellen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt acht Sekunden je Versuch, schlimmstenfalls gut zwanzig. ## Warum eine fremde Quelle trotzdem sicher ist Der naheliegende Schutz geht NICHT: MakeMKV signiert seinen Installer nicht (Get-AuthenticodeSignature -> NotSigned, an der echten Datei gemessen). Eine Signaturpruefung waere eine, die immer fehlschlaegt -- schlimmer als keine, weil sie Sicherheit vortaeuscht. Geprueft wird die Versions-Ressource, ebenfalls gemessen: CompanyName GuinpinSoft inc FileDescription MakeMKV installer FileVersion v1.18.4 Das ersetzt keine Signatur, faengt aber ab, was hier wirklich droht: eine Fehlerseite mit .exe-Namen, ein abgebrochener Download, eine falsche Fassung. Ende zu Ende nachgewiesen, ohne etwas zu installieren: Version laut Kette: 1.18.4 Hersteller: HTTP 525 -> uebersprungen Internet Archive: 15,7 MB in 6,1s Pruefung: BESTANDEN (MakeMKV v1.18.4) Die Grenze aus KONZEPT.md 6 bleibt: Rippy liefert MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers -- im Rueckfall aus einem Archiv, das genau diese Datei aufbewahrt. Ampel lokal: 733 gruen, ruff sauber. Co-Authored-By: Claude Opus 5 --- KONZEPT-V2.md | 50 ++++ src/rippy/platform/dateiangaben.py | 124 ++++++++++ src/rippy/platform/test_dateiangaben.py | 98 ++++++++ src/rippy/tools/beschaffen.py | 311 ++++++++++++++++++++---- src/rippy/tools/test_beschaffen.py | 113 +++++++++ 5 files changed, 653 insertions(+), 43 deletions(-) create mode 100644 src/rippy/platform/dateiangaben.py create mode 100644 src/rippy/platform/test_dateiangaben.py diff --git a/KONZEPT-V2.md b/KONZEPT-V2.md index ba85571..c7ea759 100644 --- a/KONZEPT-V2.md +++ b/KONZEPT-V2.md @@ -1415,6 +1415,56 @@ WebView2 statt Win32-Dialog: Es ist wegen Entscheid 4 ohnehin da — der Assistent kostet **null zusätzliche Bytes**. Die Seite lädt nichts aus dem Netz; sie muss auf einem Rechner ohne Internet aufgehen. + +#### 5a — Ausweichquellen für MakeMKV (28.08.2026, nachgetragen) + +Der Commander: *„bitte baue für MakeMKV fallback seiten ein."* Anlass war ein +echter Ausfall. Gemessen: + + https://www.makemkv.com/download/ HTTP 525 (dauerhaft) + https://forum.makemkv.com/ Zeitablauf / 522 / OK (wechselnd) + web.archive.org/web/2025id_/…exe HTTP 200, 16.432.607 Bytes + +525/522 heißen: Cloudflare erreicht den Ursprungsserver nicht. Eine Störung +beim Hersteller, nichts, was Rippy reparieren könnte. + +**Die Kette, in dieser Reihenfolge:** + +| | Version | Datei | +|---|---|---| +| eigene Quelle (`basis`) | — | zuerst, wer sie einträgt hat sich etwas dabei gedacht | +| Hersteller-Seite | **maßgeblich** — wer sie erreicht, sucht nicht weiter | ja | +| Hersteller-Forum | ja (Ankündigungs-Bereich) | — | +| Internet Archive | ja (Rückfall) | ja | + +Zwei Feinheiten, beide aus Messungen: + +* **Die höchste Nummer gewinnt, nicht die erste.** Antwortet das Forum nicht, + meldet das Archiv einen älteren Stand (1.18.2 statt 1.18.4) — „neueste + Fassung" wäre dann eine falsche Aussage. +* **Ein zweiter Anlauf, mit knapper Zeitgrenze.** Drei Abrufe am Forum: + Zeitablauf, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in + zwei von drei Fällen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste + Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt: acht + Sekunden je Versuch, schlimmstenfalls gut zwanzig insgesamt. + +**Und warum das trotzdem sicher ist.** Der naheliegende Schutz geht nicht: +MakeMKV **signiert seinen Installer nicht** (`Get-AuthenticodeSignature` → +`NotSigned`, an der echten Datei gemessen). Eine Signaturprüfung wäre eine, +die immer fehlschlägt. Geprüft wird stattdessen die Versions-Ressource: + + CompanyName GuinpinSoft inc + FileDescription MakeMKV installer + FileVersion v1.18.4 + +Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die +Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine +Fehlerseite mit `.exe`-Namen, ein abgebrochener Download, eine falsche +Fassung. Und Rippy nennt hinterher die Quelle, aus der die Datei kam. + +**Die Grenze aus `KONZEPT.md` § 6 bleibt unangetastet:** Rippy liefert +MakeMKV weiterhin NICHT mit. Es holt die Datei des Herstellers — im +Rückfall aus einem Archiv, das genau diese Datei aufbewahrt. --- diff --git a/src/rippy/platform/dateiangaben.py b/src/rippy/platform/dateiangaben.py new file mode 100644 index 0000000..567b2cc --- /dev/null +++ b/src/rippy/platform/dateiangaben.py @@ -0,0 +1,124 @@ +"""Was eine Windows-Datei über sich selbst sagt — und warum das hier zählt. + +## Wozu (28.08.2026) + +Rippy lädt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil +makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu +holen und ungeprüft auszuführen wäre leichtsinnig. + +Der naheliegende Schutz — die digitale Signatur — geht NICHT: + + Get-AuthenticodeSignature Setup_MakeMKV_v1.18.4.exe + Status: NotSigned + +Am 28.08.2026 an der echten Datei gemessen: **MakeMKV signiert seinen +Installer nicht.** Eine Signaturprüfung wäre also eine Prüfung, die immer +fehlschlägt — schlimmer als keine, weil sie Sicherheit vortäuscht und dann +den richtigen Weg blockiert. + +Prüfbar ist die **Versions-Ressource**. An derselben Datei gemessen: + + CompanyName GuinpinSoft inc + FileDescription MakeMKV installer + FileVersion v1.18.4 + ProductName MakeMKV + +Das ist kein Ersatz für eine Signatur — wer die Datei fälscht, kann auch die +Ressource fälschen. Aber es fängt zuverlässig ab, was hier wirklich droht: +eine Fehlerseite, ein umbenanntes Archiv, eine falsche Version, ein +abgebrochener Download. Und es sagt dem Nutzer, WAS er da bekommen hat. +""" + +import os + +FELDER = ("CompanyName", "FileDescription", "FileVersion", "ProductName", + "ProductVersion") + + +def versionsangaben(pfad: str) -> dict: + """Die Versions-Ressource einer Windows-Datei. Leer, wenn keine da ist. + + Wirft nicht: Eine Datei ohne Ressource ist ein gültiges Ergebnis (und + genau das, was eine Fehlerseite mit `.exe`-Namen hätte). + """ + if os.name != "nt" or not os.path.isfile(pfad): + return {} + import ctypes + + try: + version = ctypes.WinDLL("version") + groesse = version.GetFileVersionInfoSizeW(pfad, None) + if not groesse: + return {} + puffer = ctypes.create_string_buffer(groesse) + if not version.GetFileVersionInfoW(pfad, 0, groesse, puffer): + return {} + + zeiger = ctypes.c_void_p() + laenge = ctypes.c_uint() + if not version.VerQueryValueW(puffer, r"\VarFileInfo\Translation", + ctypes.byref(zeiger), ctypes.byref(laenge)): + return {} + paar = ctypes.cast(zeiger, ctypes.POINTER(ctypes.c_uint16)) + sprache = "%04x%04x" % (paar[0], paar[1]) + + ergebnis = {} + for feld in FELDER: + z = ctypes.c_void_p() + n = ctypes.c_uint() + pfad_im_block = "\\StringFileInfo\\%s\\%s" % (sprache, feld) + if version.VerQueryValueW(puffer, pfad_im_block, + ctypes.byref(z), ctypes.byref(n)) and n.value: + ergebnis[feld] = ctypes.wstring_at(z.value, n.value - 1).strip() + return ergebnis + except (AttributeError, OSError, ValueError): + return {} + + +def ist_programm(pfad: str) -> bool: + """Fängt die Datei mit `MZ` an? (Das tut jedes Windows-Programm.) + + Der billigste und wirksamste Test gegen das, was wirklich passiert: eine + HTML-Fehlerseite, die unter dem Namen `Setup_MakeMKV_v1.18.4.exe` + gespeichert wurde. + """ + try: + with open(pfad, "rb") as f: + return f.read(2) == b"MZ" + except OSError: + return False + + +# ── Die Prüfung als reine Funktion ────────────────────────────────────── +def passt(angaben: dict, firma: str = "", produkt: str = "", + version: str = "") -> tuple: + """Passen die Angaben zu dem, was erwartet wird? (ja/nein, Begründung) + + Reine Funktion — damit jeder Fall prüfbar ist, ohne eine Datei zu haben. + Leere Erwartungen werden nicht geprüft: Wer nichts fordert, bekommt kein + Urteil vorgesetzt. + """ + if not angaben: + return False, "Die Datei hat keine Versionsangaben — das ist kein " \ + "Installationsprogramm, sondern vermutlich eine Fehlerseite." + + gefunden_firma = angaben.get("CompanyName", "") + if firma and firma.lower() not in gefunden_firma.lower(): + return False, ("Die Datei stammt laut ihren eigenen Angaben von " + "%r, erwartet war %r." % (gefunden_firma or "niemandem", firma)) + + text = " ".join(angaben.get(f, "") for f in + ("ProductName", "FileDescription")).lower() + if produkt and produkt.lower() not in text: + return False, ("Die Datei bezeichnet sich als %r, erwartet war %r." + % (text.strip() or "nichts", produkt)) + + if version: + gefunden = (angaben.get("FileVersion") or + angaben.get("ProductVersion") or "").lstrip("vV") + if gefunden and not gefunden.startswith(version): + return False, ("Die Datei ist Fassung %s, erwartet war %s." + % (gefunden, version)) + + return True, "%s %s" % (angaben.get("ProductName", "?"), + angaben.get("FileVersion", "?")) diff --git a/src/rippy/platform/test_dateiangaben.py b/src/rippy/platform/test_dateiangaben.py new file mode 100644 index 0000000..d6c60ba --- /dev/null +++ b/src/rippy/platform/test_dateiangaben.py @@ -0,0 +1,98 @@ +"""Was eine Windows-Datei ueber sich selbst sagt. + +## Warum das geprueft wird + +Rippy laedt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil +makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu holen +und ungeprueft auszufuehren waere leichtsinnig. + +Der naheliegende Schutz -- die digitale Signatur -- geht NICHT: Am 28.08.2026 +an der echten Datei gemessen, `Get-AuthenticodeSignature` sagt `NotSigned`. +MakeMKV signiert seinen Installer nicht. Eine Signaturpruefung waere also eine +Pruefung, die immer fehlschlaegt. + +Geprueft wird deshalb die Versions-Ressource. An derselben Datei gemessen: + + CompanyName GuinpinSoft inc + FileDescription MakeMKV installer + FileVersion v1.18.4 + +Alle Pruefungen hier sind rein -- keine Datei noetig, laeuft auf jeder +Plattform. +""" + +import os + +from rippy.platform import dateiangaben as d + +ECHT = { + "CompanyName": "GuinpinSoft inc", + "FileDescription": "MakeMKV installer", + "FileVersion": "v1.18.4", + "ProductName": "MakeMKV", +} + + +def test_die_echte_datei_wird_erkannt(): + gut, grund = d.passt(ECHT, firma="GuinpinSoft", produkt="MakeMKV", + version="1.18.4") + assert gut is True, grund + assert "1.18.4" in grund + + +def test_ohne_versionsangaben_ist_es_keine_programmdatei(): + """Das ist der haeufigste Fall: eine Fehlerseite mit .exe-Namen.""" + gut, grund = d.passt({}, firma="GuinpinSoft") + assert gut is False + assert "Fehlerseite" in grund + + +def test_fremde_firma_wird_abgelehnt(): + fremd = dict(ECHT, CompanyName="Irgendwer GmbH") + gut, grund = d.passt(fremd, firma="GuinpinSoft") + assert gut is False + assert "Irgendwer" in grund + + +def test_falsche_version_wird_abgelehnt(): + """Ein Archiv-Schnappschuss koennte eine aeltere Fassung liefern.""" + gut, grund = d.passt(ECHT, firma="GuinpinSoft", version="1.19.0") + assert gut is False + assert "1.18.4" in grund and "1.19.0" in grund + + +def test_fremdes_produkt_wird_abgelehnt(): + fremd = dict(ECHT, ProductName="Notepad", FileDescription="Editor") + gut, _ = d.passt(fremd, firma="GuinpinSoft", produkt="MakeMKV") + assert gut is False + + +def test_ohne_erwartung_wird_nichts_geurteilt(): + """Wer nichts fordert, bekommt kein Urteil vorgesetzt.""" + gut, _ = d.passt(ECHT) + assert gut is True + + +def test_das_v_vor_der_version_stoert_nicht(): + """Die Datei meldet 'v1.18.4', erwartet wird '1.18.4'.""" + gut, _ = d.passt(ECHT, version="1.18.4") + assert gut is True + + +def test_mz_pruefung_an_einer_echten_datei(tmp_path): + programm = tmp_path / "p.exe" + programm.write_bytes(b"MZ" + b"\x00" * 100) + assert d.ist_programm(str(programm)) is True + + seite = tmp_path / "fehler.exe" + seite.write_bytes(b"525") + assert d.ist_programm(str(seite)) is False + + assert d.ist_programm(str(tmp_path / "gibt-es-nicht.exe")) is False + + +def test_versionsangaben_ohne_windows_sind_leer(): + if os.name == "nt": + import pytest + pytest.skip("prueft das Verhalten auf Nicht-Windows") + assert d.versionsangaben("/bin/sh") == {} diff --git a/src/rippy/tools/beschaffen.py b/src/rippy/tools/beschaffen.py index bcad7a7..a71cf78 100644 --- a/src/rippy/tools/beschaffen.py +++ b/src/rippy/tools/beschaffen.py @@ -51,6 +51,7 @@ import os import shutil import subprocess import tempfile +import time import urllib.error import urllib.request import zipfile @@ -58,13 +59,24 @@ 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 +# Wackelige Quellen bekommen einen zweiten Anlauf. Gemessen am 28.08.2026: +# forum.makemkv.com scheiterte zweimal und antwortete beim dritten Versuch. +VERSUCHE = 2 +WARTEN_ZWISCHEN_VERSUCHEN = 2.0 + +# Fuer die Versionsabfrage: knapp. Eine Seite zu lesen dauert bei einem +# gesunden Server Millisekunden; 20 s Zeitgrenze mal zwei Versuche mal zwei +# wacklige Quellen waren am 28.08.2026 SECHSUNDVIERZIG Sekunden, in denen +# das Setup eingefroren aussah. Acht Sekunden sind fuer eine Seite reichlich +# und begrenzen den schlimmsten Fall auf gut zwanzig. +VERSIONS_TIMEOUT = 8 + # 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 @@ -74,14 +86,38 @@ class BeschaffungsFehler(RuntimeError): """Es hat nicht geklappt — mit einer Begründung, die man lesen kann.""" -def _laden(url: str, timeout: int = ABFRAGE_TIMEOUT) -> bytes: +def _laden(url: str, timeout: int = ABFRAGE_TIMEOUT, + versuche: int = VERSUCHE) -> bytes: + """Eine Seite holen — mit Wiederholung bei wackligen Servern. + + ## Warum wiederholt wird (gemessen 28.08.2026) + + Drei Abrufe von `forum.makemkv.com` hintereinander: + + 1: TimeoutError (20,1 s) + 2: HTTP 522 (19,7 s) Cloudflare erreicht den Ursprung nicht + 3: OK, 127.311 Bytes (0,2 s) + + Der Server ist nicht tot, er wackelt. Ohne Wiederholung fiel die Quelle + in zwei von drei Fällen aus — und dann gewann der ältere Archiv-Stand, + also 1.18.2 statt 1.18.4. Ein zweiter Anlauf kostet Sekunden und ändert + das Ergebnis. + """ 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() + letzter = None + for nummer in range(max(1, versuche)): + try: + with urllib.request.urlopen(anfrage, timeout=timeout) as antwort: + return antwort.read() + except (urllib.error.URLError, OSError) as e: + letzter = e + if nummer + 1 < max(1, versuche): + time.sleep(WARTEN_ZWISCHEN_VERSUCHEN) + raise letzter # ── HandBrake ─────────────────────────────────────────────────────────── @@ -210,49 +246,214 @@ def _datei_laden(url: str, ziel: str, erwartet: int, melden) -> None: # ── MakeMKV ───────────────────────────────────────────────────────────── -def makemkv_neueste_version(seite: str = None) -> str: - """Die neueste Fassung laut makemkv.com. "" wenn die Seite nicht antwortet. +# +# ## Warum es hier eine Quellenkette gibt (28.08.2026) +# +# Der Commander: „bitte baue für MakeMKV fallback seiten ein." +# +# Anlass war ein echter Ausfall. Am 28.08.2026 gemessen: +# +# https://www.makemkv.com/download/ HTTP 525 +# https://makemkv.com/download/ HTTP 525 +# http://www.makemkv.com/download/ HTTP 403 +# https://www.makemkv.com/ HTTP 525 +# https://forum.makemkv.com/ HTTP 200 <- lebt +# +# 525 heißt: Cloudflare erreicht den Ursprungsserver nicht. Das ist eine +# Störung beim Hersteller, nichts, was Rippy reparieren könnte — und +# dieselbe, die im Projekt schon einmal jeden Worker-Build lahmgelegt hat. +# +# ## Woher die Version kommt, wenn die Seite schweigt +# +# Das Forum liegt auf einem ANDEREN Host und war erreichbar. Im +# Ankündigungs-Bereich steht die aktuelle Fassung; am 28.08.2026 gemessen: +# `1.18.4` — dieselbe, die auf dem Rechner des Commanders installiert war. +# +# ## Woher die DATEI kommt +# +# Aus dem Internet Archive, das eine Kopie der Datei DES HERSTELLERS hält. +# Gemessen: +# +# web/2024id_/…/Setup_MakeMKV_v1.18.4.exe 200, 16.432.607 Bytes +# web/2025id_/… 200, dieselbe Datei +# web/3000id_/… 525 <- leitet auf die tote Seite +# web/id_/… HTML statt Programm +# +# Ein Zeitstempel in der VERGANGENHEIT ist nötig: Ohne ihn holt das Archiv +# die Datei live nach — und die Quelle ist ja gerade tot. +# +# ## Warum das trotzdem sicher ist +# +# Weil nachgesehen wird, WAS ankommt. Der naheliegende Schutz — die digitale +# Signatur — geht nicht: MakeMKV signiert seinen Installer nicht +# (`Get-AuthenticodeSignature` → `NotSigned`, an der echten Datei gemessen). +# Geprüft wird deshalb die Versions-Ressource, ebenfalls gemessen: +# +# CompanyName GuinpinSoft inc +# FileDescription MakeMKV installer +# FileVersion v1.18.4 +# +# Das ersetzt keine Signatur — wer die Datei fälscht, fälscht auch die +# Ressource. Es fängt aber zuverlässig ab, was hier wirklich droht: eine +# Fehlerseite, ein abgebrochener Download, eine falsche Version. +# +# ## Und die Reihenfolge ist Absicht +# +# Der Hersteller zuerst, immer. Das Archiv ist die Rückfallebene, nicht der +# Normalfall — und Rippy sagt hinterher, woher die Datei kam. - ⚠️ 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. +MAKEMKV_SEITE = "https://www.makemkv.com/download/" +MAKEMKV_FORUM = "https://forum.makemkv.com/forum/viewforum.php?f=3" + +# Der Pfad beim Hersteller — an EINER Stelle, damit die Archiv-Adresse ihn +# nicht abschreiben muss. +MAKEMKV_DATEI = "https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe" + +# Ein Zeitstempel in der Vergangenheit. Ohne ihn holt das Archiv live nach. +ARCHIV_STAND = "2025" + +MAKEMKV_FIRMA = "GuinpinSoft" +MAKEMKV_PRODUKT = "MakeMKV" + +VERSION_RE = r"MakeMKV\s+v?(\d+\.\d+\.\d+)" + + +def hoechste_version(text: str) -> str: + """Die höchste Fassung, die in einem Text vorkommt. (reine Funktion) + + Höchste und nicht erste: Das Forum listet alte Ankündigungen mit; die + neueste Fassung ist die mit der größten Nummer. Und verglichen wird nach + Zahlen, nicht nach Zeichen — sonst wäre „1.9.2" größer als „1.18.4". """ import re - try: - text = (seite if seite is not None - else _laden(MAKEMKV_SEITE).decode("utf-8", "replace")) - except (urllib.error.URLError, OSError): + funde = re.findall(VERSION_RE, text or "") + if not funde: return "" - treffer = re.search(r"MakeMKV\s+v?(\d+\.\d+\.\d+)", text or "") - return treffer.group(1) if treffer else "" + return max(funde, key=lambda v: [int(t) for t in v.split(".")]) + + +def versionsquellen() -> list: + """Wo nach der aktuellen Fassung gesucht wird. + + `massgeblich` heißt: Diese Quelle IST die Wahrheit — wer sie erreicht, + braucht nicht weiterzusuchen. Das gilt nur für den Hersteller selbst. + """ + return [ + {"name": "Hersteller-Seite", "url": MAKEMKV_SEITE, "massgeblich": True}, + {"name": "Hersteller-Forum", "url": MAKEMKV_FORUM, "massgeblich": False}, + {"name": "Archiv der Hersteller-Seite", "massgeblich": False, + "url": "https://web.archive.org/web/%s/%s" % (ARCHIV_STAND, MAKEMKV_SEITE)}, + ] + + +def makemkv_neueste_version(seite: str = None, quellen: list = None) -> str: + """Die neueste Fassung. "" wenn KEINE Quelle antwortet. + + ## Warum die HÖCHSTE gewinnt und nicht die erste (gemessen 28.08.2026) + + Vier Läufe hintereinander: + + Lauf 1: 1.18.4 (0,3 s) Forum hat geantwortet + Lauf 2: 1.18.4 (0,4 s) + Lauf 3: 1.18.2 (21,3 s) Forum lief in die Zeitgrenze + Lauf 4: 1.18.2 (21,0 s) + + `forum.makemkv.com` antwortet unzuverlässig. Fällt es aus, gewinnt sonst + der ältere Archiv-Schnappschuss — und Rippy holt 1.18.2, obwohl 1.18.4 + aktuell ist. „Neueste Fassung" wäre dann eine falsche Aussage. + + Deshalb: Antwortet der HERSTELLER, ist die Sache entschieden. Sonst wird + zusammengetragen, was die übrigen Quellen melden, und die höchste Nummer + gewinnt — eine Quelle kann nur eine Fassung nennen, die es wirklich gab. + + `seite` bleibt als Einspritzpunkt für Tests (fertiger HTML-Text). + """ + if seite is not None: + return hoechste_version(seite) + + gefunden = [] + for quelle in (quellen if quellen is not None else versionsquellen()): + try: + text = _laden(quelle["url"], + timeout=VERSIONS_TIMEOUT).decode("utf-8", "replace") + except (urllib.error.URLError, OSError): + continue + version = hoechste_version(text) + if not version: + continue + if quelle.get("massgeblich"): + return version + gefunden.append(version) + if not gefunden: + return "" + return max(gefunden, key=lambda v: [int(t) for t in v.split(".")]) def makemkv_setup_url(version: str, basis: str = None) -> str: - """Die Adresse der Windows-Installationsdatei. + """Die Adresse beim Hersteller (oder unter einer eigenen Basis). - Das Namensmuster stammt von makemkv.com selbst - (`Setup_MakeMKV_v.exe`). `basis` ist übersteuerbar — aus - demselben Grund wie `MAKEMKV_URL_BASE` beim Docker-Bau: Wenn die - Hauptquelle klemmt, soll ein Ausweichweg möglich sein, ohne den Code - anzufassen. + `basis` ist übersteuerbar — aus demselben Grund wie `MAKEMKV_URL_BASE` + beim Docker-Bau: Wer eine eigene Kopie im Netz liegen hat, soll sie + benutzen können, ohne den Code anzufassen. """ - basis = (basis or "https://www.makemkv.com/download").rstrip("/") - return f"{basis}/Setup_MakeMKV_v{version}.exe" + if basis: + return "%s/Setup_MakeMKV_v%s.exe" % (basis.rstrip("/"), version) + return MAKEMKV_DATEI % version + + +def makemkv_quellen(version: str, basis: str = None) -> list: + """Alle Bezugsquellen für den Installer, in Reihenfolge. + + Der Hersteller zuerst, IMMER. Eine eigene Basis steht davor — wer sie + einträgt, hat sich etwas dabei gedacht. + """ + quellen = [] + if basis: + quellen.append(("eigene Quelle", makemkv_setup_url(version, basis))) + quellen.append(("Hersteller", MAKEMKV_DATEI % version)) + quellen.append(("Internet Archive", + "https://web.archive.org/web/%sid_/%s" + % (ARCHIV_STAND, MAKEMKV_DATEI % version))) + return quellen + + +def pruefe_makemkv_datei(pfad: str, version: str = "") -> tuple: + """Ist das WIRKLICH der MakeMKV-Installer? (ja/nein, Begründung) + + Ohne diese Prüfung wäre eine Ausweichquelle leichtsinnig. Mit ihr wird + aus „von woanders geladen" ein „nachweislich die Datei des Herstellers". + Begründung vollständig in `rippy/platform/dateiangaben.py`. + """ + from rippy.platform import dateiangaben + + if not dateiangaben.ist_programm(pfad): + return False, ('Die geladene Datei ist kein Windows-Programm ' + '(sie beginnt nicht mit „MZ") — vermutlich eine ' + 'Fehlerseite.') + if os.name != "nt": + # Auf Linux gibt es keine Versions-Ressourcen. Der MZ-Test hat schon + # gegriffen; mehr ist dort nicht zu holen, und eine Prüfung, die man + # nicht durchführen kann, darf nicht als bestanden gelten — aber auch + # nicht als Fehlschlag. + return True, "Windows-Programm (Versionsangaben nur unter Windows lesbar)" + return dateiangaben.passt(dateiangaben.versionsangaben(pfad), + firma=MAKEMKV_FIRMA, produkt=MAKEMKV_PRODUKT, + version=version) def makemkv_holen(version: str = "", basis: str = None, fortschritt=None, - still: bool = True) -> str: + still: bool = True, quellen: list = None) -> 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. + **Rippy liefert MakeMKV NICHT mit** — es lädt die offizielle Datei 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. + Gibt den Pfad der heruntergeladenen Datei zurück. Wirft mit dem Bericht + ALLER Versuche, wenn keine Quelle geliefert hat — eine Meldung, die nur + „ging nicht" sagt, lässt den Nutzer ratlos zurück. """ def melden(text, anteil=None): if fortschritt: @@ -261,23 +462,47 @@ def makemkv_holen(version: str = "", basis: str = None, fortschritt=None, 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.") + "Keine der Quellen konnte sagen, welche Fassung aktuell ist " + "(Hersteller-Seite, Forum, Archiv). 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") + ziel = os.path.join(ordner, "Setup_MakeMKV_v%s.exe" % version) + vorlaeufig = ziel + ".neu" - melden(f"MakeMKV {version} wird geladen …", 0.0) - _datei_laden(makemkv_setup_url(version, basis), ziel, 0, melden) + fehlschlaege = [] + for name, url in (quellen if quellen is not None + else makemkv_quellen(version, basis)): + melden("MakeMKV %s wird geladen (%s) …" % (version, name), 0.0) + try: + _datei_laden(url, vorlaeufig, 0, melden) + gut, grund = pruefe_makemkv_datei(vorlaeufig, version) + if not gut: + raise BeschaffungsFehler(grund) + except Exception as e: # noqa: BLE001 + fehlschlaege.append("%s: %s" % (name, e)) + melden(" %s hat nicht geliefert." % name) + try: + os.remove(vorlaeufig) + except OSError: + pass + continue - melden("Der Installer von MakeMKV wird gestartet …", 0.9) - starte_installer(ziel, still=still) - melden(f"MakeMKV {version} wird installiert.", 1.0) - return ziel + # Erst tauschen, wenn die Datei geprüft ist. Ein halber Download darf + # eine gute Datei nicht ersetzen. + os.replace(vorlaeufig, ziel) + melden("Der Installer von MakeMKV wird gestartet (Quelle: %s) …" % name, 0.9) + starte_installer(ziel, still=still) + melden("MakeMKV %s wird installiert." % version, 1.0) + return ziel + + raise BeschaffungsFehler( + "Keine Quelle hat den MakeMKV-Installer geliefert:\n " + + "\n ".join(fehlschlaege) + + "\nAusweg: MakeMKV von Hand von makemkv.com laden und installieren; " + "Rippy findet es danach von allein.") def starte_installer(pfad: str, still: bool = True) -> None: diff --git a/src/rippy/tools/test_beschaffen.py b/src/rippy/tools/test_beschaffen.py index 8a6229a..565843a 100644 --- a/src/rippy/tools/test_beschaffen.py +++ b/src/rippy/tools/test_beschaffen.py @@ -200,3 +200,116 @@ def test_werkzeug_ordner_liegt_dort_wo_geschrieben_werden_darf(): assert "tools" in ordner if os.name == "nt": assert "Program Files" not in ordner + + +# ── Die MakeMKV-Quellenkette ──────────────────────────────────────────── +# +# Anlass: Commander am 28.08.2026 -- "bitte baue fuer MakeMKV fallback seiten +# ein." Gemessen an dem Tag: +# +# https://www.makemkv.com/download/ HTTP 525 +# https://forum.makemkv.com/ HTTP 200 <- lebt +# web.archive.org/web/2025id_/...exe HTTP 200, 16.432.607 Bytes +def test_hoechste_version_statt_der_ersten(): + """Das Forum listet alte Ankuendigungen mit. Die neueste Fassung ist die + mit der GROESSTEN Nummer -- und verglichen wird nach Zahlen, nicht nach + Zeichen, sonst waere 1.9.2 groesser als 1.18.4.""" + text = "MakeMKV v1.9.2 ... MakeMKV v1.18.4 ... MakeMKV v1.17.0" + assert beschaffen.hoechste_version(text) == "1.18.4" + + +def test_ohne_version_im_text_kommt_leer(): + assert beschaffen.hoechste_version("nichts hier") == "" + assert beschaffen.hoechste_version("") == "" + assert beschaffen.hoechste_version(None) == "" + + +def test_der_hersteller_kommt_immer_zuerst(): + """Das Archiv ist die Rueckfallebene, nicht der Normalfall.""" + quellen = beschaffen.makemkv_quellen("1.18.4") + assert [n for n, _ in quellen] == ["Hersteller", "Internet Archive"] + assert quellen[0][1].startswith("https://www.makemkv.com/") + + +def test_eine_eigene_quelle_steht_ganz_vorn(): + """Wer sie eintraegt, hat sich etwas dabei gedacht.""" + quellen = beschaffen.makemkv_quellen("1.18.4", basis="https://nas/mkv") + assert quellen[0][0] == "eigene Quelle" + assert quellen[0][1] == "https://nas/mkv/Setup_MakeMKV_v1.18.4.exe" + + +def test_die_archiv_adresse_hat_einen_zeitstempel(): + """web/3000id_/ und web/id_/ holen die Datei LIVE nach -- und die Quelle + ist ja gerade tot. Am 28.08.2026 gemessen: 3000id_ gab 525, id_ gab HTML + statt Programm.""" + _, url = beschaffen.makemkv_quellen("1.18.4")[-1] + assert "web.archive.org" in url + assert "id_/" in url + assert beschaffen.ARCHIV_STAND in url + assert url.endswith("Setup_MakeMKV_v1.18.4.exe") + + +def test_versionsquellen_haben_alle_drei_wege(): + quellen = beschaffen.versionsquellen() + assert len(quellen) == 3 + urls = [q["url"] for q in quellen] + assert any("forum.makemkv.com" in u for u in urls), "das Forum lebte als einziges" + assert any("web.archive.org" in u for u in urls) + # Genau EINE Quelle ist massgeblich: der Hersteller selbst. + assert [q["massgeblich"] for q in quellen] == [True, False, False] + + +def test_eine_tote_quelle_wird_uebersprungen(monkeypatch): + """Der eigentliche Zweck der Kette.""" + gefragt = [] + + def laden(url, timeout=20): + gefragt.append(url) + if "makemkv.com/download" in url and "archive" not in url: + raise OSError("HTTP 525") + return b"MakeMKV v1.18.4" + + monkeypatch.setattr(beschaffen, "_laden", laden) + assert beschaffen.makemkv_neueste_version() == "1.18.4" + assert len(gefragt) >= 2, "die zweite Quelle wurde nie gefragt" + + +def test_ein_alter_archiv_stand_gewinnt_NICHT(monkeypatch): + """DER gemessene Fall vom 28.08.2026: forum.makemkv.com antwortet + unzuverlaessig. Vier Laeufe hintereinander: + + 1.18.4 (0,3s) 1.18.4 (0,4s) 1.18.2 (21,3s) 1.18.2 (21,0s) + + Lief das Forum in die Zeitgrenze, gewann der aeltere Archiv-Schnappschuss + -- und Rippy haette 1.18.2 geholt, obwohl 1.18.4 aktuell ist.""" + def laden(url, timeout=20): + if "forum" in url: + return b"MakeMKV v1.18.4" + if "archive" in url: + return b"MakeMKV v1.18.2 MakeMKV v1.18.1" + raise OSError("HTTP 525") + + monkeypatch.setattr(beschaffen, "_laden", laden) + assert beschaffen.makemkv_neueste_version() == "1.18.4" + + +def test_der_hersteller_beendet_die_suche(monkeypatch): + """Antwortet er, ist die Sache entschieden -- auch wenn irgendwo eine + hoehere Nummer herumliegt. Er IST die Wahrheit.""" + gefragt = [] + + def laden(url, timeout=20): + gefragt.append(url) + return b"MakeMKV v1.18.4" + + monkeypatch.setattr(beschaffen, "_laden", laden) + assert beschaffen.makemkv_neueste_version() == "1.18.4" + assert len(gefragt) == 1, "nach der massgeblichen Quelle wurde weitergesucht" + + +def test_wenn_KEINE_quelle_antwortet_kommt_leer(monkeypatch): + def platzt(url, timeout=20): + raise OSError("weg") + + monkeypatch.setattr(beschaffen, "_laden", platzt) + assert beschaffen.makemkv_neueste_version() == ""