diff --git a/KONZEPT-V2.md b/KONZEPT-V2.md index 3cbd68a..8f74c22 100644 --- a/KONZEPT-V2.md +++ b/KONZEPT-V2.md @@ -1320,6 +1320,48 @@ auch § 4.1 unverändert gültig — dort stand WebView2 von Anfang an. **Folge für den Plan:** Kein neuer Etappen-Punkt — das ist Teil von V2-4 und dort erledigt. `src/rippy/fenster.py` trägt die vollständige Begründung. +### Entscheid 5 — Die Werkzeuge gehören ins Setup, nicht in eine Fehlermeldung + +Der Commander am 28.08.2026: + +> „handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups +> separat installiert werden! Ohne das ist das tool NICHT einsatzfähig" + +Er hat recht, und die Lücke war real: `katalog.py` konnte die Werkzeuge +finden, `beschaffen.py` konnte sie holen — **das Setup rief beides nie auf.** +Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die +ihm mitteilte, was fehlt, und keinen Weg, es zu ändern. + +Die beiden gehen unterschiedliche Wege, und der Grund ist die **Lizenz**, +nicht die Bequemlichkeit: + +| | Weg | Warum | +|---|---|---| +| **HandBrakeCLI** | **mitgeliefert** (+35 MB) | GPL-2 erlaubt die Weitergabe ausdrücklich, solange Lizenztext und Quellverweis dabei sind. `LIZENZ-HandBrake.txt` liegt daneben. Damit komprimiert Rippy auch ohne Internet. | +| **MakeMKV** | **beim Einrichten geholt** | Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. Der Nutzer bezieht sie also weiterhin von MakeMKV; Rippy nimmt ihm nur die Handgriffe ab. | + +Das deckt sich mit § 4.1 („MakeMKV wird NICHT mitgeliefert") und mit der +Black-Box-Trennung aus `KONZEPT.md` § 6. Beides bleibt gültig. + +**Drei Regeln, die zum Entscheid gehören:** + +1. **Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt.** + `einrichten.sicherstellen()` liest den Bestand VOR und NACH dem Versuch + und gibt zurück, was danach wirklich da ist — nicht, dass es versucht + wurde. +2. **Ein nicht erreichbarer Download bricht die Installation nicht ab.** Am + 28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem + installiert und sagt im Klartext, was fehlt und wie man es beschafft. +3. **Ohne HandBrake ist Rippy einsatzbereit, ohne MakeMKV nicht.** Ein Rip + läuft ohne Kompression durch — das ist ein vorgesehener Betriebsfall. + Ohne MakeMKV ist Rippy ein Anzeigeprogramm. Nur `pflicht`-Werkzeuge + entscheiden über „einsatzbereit". + +**Nicht im Repo:** Die 70,9 MB HandBrakeCLI holt der **Bau** nach +`dist/windows/vendor/` (nicht versioniert) — dasselbe Muster wie der +`vendor/`-Ordner für MakeMKV auf der VM. Ein Binärblob in git wäre bei jedem +Klon dabei und bei jedem Update ein zweites Mal. + --- ### Was jetzt noch fehlt, bevor gebaut wird diff --git a/ROADMAP.md b/ROADMAP.md index 1287412..e5f1853 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -632,6 +632,13 @@ Medien. - [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`, `tools/beschaffen.py`) — Rippy findet, holt und aktualisiert HandBrake/MakeMKV selbst +- [x] **Das Setup richtet die Werkzeuge wirklich ein** (`tools/einrichten.py`, + Entscheid 5) — HandBrakeCLI liegt im Paket (GPL-2), MakeMKV wird beim + Einrichten vom Hersteller geholt. Ein Setup, dem ein Pflichtwerkzeug + fehlt, meldet das im Klartext statt Erfolg. +- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte + nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten + ein leeres Blatt - [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED` — **offen** - **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer → diff --git a/packaging/windows/build.py b/packaging/windows/build.py index cfdbe07..e321f5b 100644 --- a/packaging/windows/build.py +++ b/packaging/windows/build.py @@ -135,6 +135,21 @@ def bauen(ausgabe: str, version: str) -> str: os.makedirs(arbeit, exist_ok=True) trenner = ";" # Windows-Trennzeichen fuer --add-data + + # HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner + # ohne Internet nicht komprimieren -- und der Commander war da deutlich: + # "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg + # NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim + # Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py. + import sys as _sys + _sys.path.insert(0, HIER) + from werkzeuge import paket_ordner_bauen + + werkzeug_ordner = paket_ordner_bauen(ausgabe) + if not werkzeug_ordner: + print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim") + print(" Einrichten selbst -- das braucht aber eine Verbindung.") + befehl = [ sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", @@ -185,6 +200,9 @@ def bauen(ausgabe: str, version: str) -> str: "--exclude-module", "tkinter", os.path.join(REPO, "packaging", "windows", "einstieg.py"), ] + if werkzeug_ordner: + # Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt. + befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"] print("PyInstaller wird aufgerufen …\n") ergebnis = subprocess.run(befehl, cwd=REPO) diff --git a/packaging/windows/werkzeuge.py b/packaging/windows/werkzeuge.py new file mode 100644 index 0000000..4b24cb9 --- /dev/null +++ b/packaging/windows/werkzeuge.py @@ -0,0 +1,135 @@ +"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann. + +## Warum HandBrake mitgeliefert wird und MakeMKV nicht + +Commander am 28.08.2026: *„handbrake und makemkv MÜSSEN mitgeliefert werden +oder während des Setups separat installiert werden! Ohne das ist das tool +NICHT einsatzfähig."* + +Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist +die Lizenz, nicht die Bequemlichkeit: + +* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange + Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und + Rippy kann auch auf einem Rechner ohne Internet komprimieren. +* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch + Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und + startet dessen Installer — der Nutzer bezieht sie also weiterhin von + MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung + wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1. + +## Warum die Datei nicht im Repo liegt + +24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem +HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach +`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket — +dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM. + +Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen. +""" + +import os +import shutil +import sys + +HIER = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(os.path.dirname(HIER)) + +# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2 +# verlangt ihn bei jeder Weitergabe. +GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2 + +Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und +steht unter der GPL-2. Das bedeutet fuer dich: + + * Du darfst es weitergeben und veraendern. + * Der Quelltext ist oeffentlich verfuegbar unter: + https://github.com/HandBrake/HandBrake + * Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter: + https://www.gnu.org/licenses/old-licenses/gpl-2.0.html + +Mitgelieferte Fassung: {version} +Bezogen von: {url} + +MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim +Einrichten von makemkv.com geladen und mit dem Installer des Herstellers +installiert. +""" + + +def vendor_ordner(basis: str = None) -> str: + return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor") + + +def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict: + """Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt. + + Gibt `{"pfad": …, "version": …, "geladen": bool}` zurück; `pfad` ist + leer, wenn es nicht geklappt hat. Wirft NICHT — ein Bau ohne Netz soll + möglich bleiben, muss dann aber laut sagen, was fehlt. + """ + sys.path.insert(0, os.path.join(REPO, "src")) + from rippy.tools import beschaffen + + ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake") + exe = os.path.join(ordner, "HandBrakeCLI.exe") + + if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024: + return {"pfad": exe, "version": _version_von(exe), "geladen": False} + + melden(" HandBrakeCLI wird geholt …") + try: + pfad = beschaffen.handbrake_holen( + ziel_ordner=ordner, + fortschritt=lambda t, anteil=None: melden(" " + t)) + except Exception as e: # noqa: BLE001 + melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e) + return {"pfad": "", "version": "", "geladen": False} + return {"pfad": pfad, "version": _version_von(pfad), "geladen": True} + + +def _version_von(exe: str) -> str: + sys.path.insert(0, os.path.join(REPO, "src")) + from rippy.tools import katalog + + try: + return katalog.version_von("handbrake", exe) or "" + except Exception: # noqa: BLE001 + return "" + + +def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str: + """Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist. + + Struktur — genau die, in der `katalog.kandidaten()` sucht: + + tools/handbrake/HandBrakeCLI.exe + tools/handbrake/LIZENZ-HandBrake.txt + """ + hb = handbrake_bereitstellen(ziel_basis, melden) + if not hb["pfad"]: + return "" + + basis = os.path.join(vendor_ordner(ziel_basis), "paket") + ordner = os.path.join(basis, "handbrake") + os.makedirs(ordner, exist_ok=True) + ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe") + if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe): + shutil.copy2(hb["pfad"], ziel_exe) + + from rippy.tools import beschaffen + + with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w", + encoding="utf-8") as f: + f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt", + url=beschaffen.HANDBRAKE_API)) + + melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)" + % (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2)) + return basis + + +if __name__ == "__main__": + ordner = paket_ordner_bauen() + print("Paket-Ordner:", ordner or "(leer)") + raise SystemExit(0 if ordner else 1) diff --git a/src/rippy/tools/beschaffen.py b/src/rippy/tools/beschaffen.py index 2b888bd..bcad7a7 100644 --- a/src/rippy/tools/beschaffen.py +++ b/src/rippy/tools/beschaffen.py @@ -161,19 +161,43 @@ def handbrake_holen(ziel_ordner: str = None, fortschritt=None) -> str: def _datei_laden(url: str, ziel: str, erwartet: int, melden) -> None: + """Lädt eine Datei und meldet den Fortschritt. + + ## Warum hier IMMER ein Text kommt (28.08.2026) + + Vorher stand hier `melden(None, anteil)` — „nur der Fortschritt hat sich + geändert". Der erste echte Aufrufer, der den Text ausgab, starb daran: + + can only concatenate str (not "NoneType") to str + + Ein Rückruf, den man nur mit einer Sonderbehandlung benutzen kann, die + nirgends dokumentiert ist, ist eine Falle. Jetzt kommt immer ein Satz — + der steht dann auch im Protokoll, wo er beim Suchen hilft. + """ 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 + letzte_stufe = -1 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)) + if not erwartet: + continue + anteil = min(1.0, geladen / erwartet) + # Nur bei jedem zehnten Prozent melden: 24 MB in + # 256-KB-Häppchen wären sonst hundert Zeilen im Protokoll. + stufe = int(anteil * 10) + if stufe != letzte_stufe: + letzte_stufe = stufe + melden("%d %% geladen (%.1f von %.1f MB)" + % (anteil * 100, geladen / 1024**2, + erwartet / 1024**2), + min(0.85, 0.85 * anteil)) except (urllib.error.URLError, OSError) as e: raise BeschaffungsFehler(f"Der Download ist gescheitert: {e}") from e diff --git a/src/rippy/tools/einrichten.py b/src/rippy/tools/einrichten.py new file mode 100644 index 0000000..e438c0c --- /dev/null +++ b/src/rippy/tools/einrichten.py @@ -0,0 +1,185 @@ +"""Beim Einrichten dafür sorgen, dass Rippy wirklich arbeiten kann. + +## Der Befund des Commanders (28.08.2026) + +> „handbrake und makemkv MÜSSEN mitgeliefert werden oder während des Setups +> separat installiert werden! Ohne das ist das tool NICHT einsatzfähig" + +Er hat recht, und die Lücke war real: `katalog.py` konnte die Werkzeuge +finden, `beschaffen.py` konnte sie holen — **das Setup rief beides nie auf.** +Wer Rippy auf einem frischen Rechner installierte, bekam eine Oberfläche, die +ihm mitteilte, was fehlt, und keinen Weg, es zu ändern. + +## Warum die beiden unterschiedlich behandelt werden + +Das ist keine Bequemlichkeit, sondern eine Lizenzfrage: + +| | Weg | Warum | +|---|---|---| +| **HandBrake** | **mitgeliefert** | GPL-2. Weitergabe ist ausdrücklich erlaubt, solange Lizenztext und Quellverweis dabei sind (`LIZENZ-HandBrake.txt`, Über-Dialog). Damit funktioniert Rippy auch ohne Netz. | +| **MakeMKV** | **während des Setups geholt** | Proprietär. Die Lizenz erlaubt keine Weitergabe durch Dritte. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer — der Nutzer bezieht sie also weiterhin von MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. | + +Das ist dieselbe Black-Box-Trennung wie in `KONZEPT.md` § 6 und deckt sich mit +`KONZEPT-V2.md` § 4.1 („MakeMKV wird NICHT mitgeliefert"). + +## Was hier NIE passiert + +**Ein Setup, das sich als erfolgreich meldet, während ein Pflichtwerkzeug +fehlt.** Genau davor warnt AGENTS.md: Ein stiller Fehlschlag ist schlimmer +als ein lauter. `sicherstellen()` gibt deshalb zurück, was WIRKLICH da ist — +nicht, dass es versucht wurde —, und `bericht()` macht daraus einen Satz, den +der Nutzer lesen kann. + +Und: Ein nicht erreichbarer Download-Server bricht die Installation nicht ab. +Rippy ist dann installiert und sagt, was noch fehlt. Am 28.08.2026 antwortete +makemkv.com mit HTTP 525 — das darf keine kaputte Installation ergeben. +""" + +import os + +from rippy.tools import beschaffen, katalog + + +def fehlende(bestand: dict) -> list: + """Welche Werkzeuge fehlen? Pflicht zuerst. (reine Funktion)""" + fehlt = [(name, e) for name, e in bestand.items() if not e.get("da")] + return sorted(fehlt, key=lambda p: (not p[1].get("pflicht"), p[0])) + + +def einsatzbereit(bestand: dict) -> bool: + """Kann Rippy rippen? (reine Funktion) + + Nur die PFLICHT-Werkzeuge entscheiden. Ohne HandBrake läuft ein Rip + durch, nur eben ohne Kompression — das ist ein vorgesehener Betriebsfall + und kein Fehler. + """ + return all(e.get("da") for e in bestand.values() if e.get("pflicht")) + + +def sicherstellen(fortschritt=None, eingestellt: dict = None, + handbrake_holen=None, makemkv_holen=None, + bestand_lesen=None) -> dict: + """Holt, was fehlt. Gibt zurück, was DANACH wirklich da ist. + + Die drei Funktionen sind einspritzbar — so ist der ganze Ablauf prüfbar, + ohne etwas herunterzuladen oder zu installieren. + + Der Rückgabewert: + + {"bestand": {...}, Stand NACH dem Versuch + "geholt": ["handbrake"], was neu dazugekommen ist + "fehler": {"makemkv": "…"}, + "bereit": False} + """ + def melden(text, anteil=None): + if fortschritt: + fortschritt(text, anteil) + + bestand_lesen = bestand_lesen or (lambda: katalog.bestand(eingestellt)) + handbrake_holen = handbrake_holen or beschaffen.handbrake_holen + makemkv_holen = makemkv_holen or beschaffen.makemkv_holen + + vorher = bestand_lesen() + geholt, fehler = [], {} + + for name, eintrag in fehlende(vorher): + titel = eintrag.get("titel", name) + melden("%s fehlt — wird geholt …" % titel) + try: + if name == "handbrake": + handbrake_holen(fortschritt=fortschritt) + elif name == "makemkv": + # still=False: Der Nutzer SOLL den Installer von MakeMKV + # sehen. Ein fremdes Programm still im Hintergrund zu + # installieren, ist ein Ubergriff — und wenn es hakt, sieht + # er wenigstens, woran. + makemkv_holen(fortschritt=fortschritt, still=False) + else: + continue + except Exception as e: # noqa: BLE001 + # Bewusst breit: Netzfehler, HTTP-Fehler, ZIP-Fehler, Rechte — + # keiner davon darf die Installation von Rippy selbst umwerfen. + fehler[name] = str(e) or type(e).__name__ + melden("%s konnte nicht geholt werden." % titel) + continue + geholt.append(name) + + nachher = bestand_lesen() + return { + "bestand": nachher, + "geholt": geholt, + "fehler": fehler, + "bereit": einsatzbereit(nachher), + } + + +# ── Was der Nutzer davon zu sehen bekommt ─────────────────────────────── +HILFE_MAKEMKV = ( + "MakeMKV von makemkv.com laden und installieren — Rippy findet es " + "danach von allein.") +HILFE_HANDBRAKE = ( + "HandBrakeCLI von handbrake.fr laden und die .exe nach " + "%LOCALAPPDATA%\\Rippy\\tools\\handbrake\\ legen.") + +HILFE = {"makemkv": HILFE_MAKEMKV, "handbrake": HILFE_HANDBRAKE} + + +def bericht(ergebnis: dict) -> str: + """Ein lesbarer Satz über den Zustand. (reine Funktion) + + Nennt beim Namen, was fehlt, UND was dagegen zu tun ist. Ein Bericht, der + nur ein Problem meldet, lässt den Nutzer ratlos zurück. + """ + zeilen = [] + for name, eintrag in sorted(ergebnis["bestand"].items()): + titel = eintrag.get("titel", name) + if eintrag.get("da"): + version = eintrag.get("version") or "?" + wie = " (neu geholt)" if name in ergebnis["geholt"] else "" + zeilen.append("%s %s%s" % (titel, version, wie)) + else: + grund = ergebnis["fehler"].get(name, "") + zeilen.append("%s FEHLT%s" % (titel, ": " + grund if grund else "")) + zeilen.append(" " + HILFE.get(name, "")) + + if ergebnis["bereit"]: + kopf = "Rippy ist einsatzbereit." + else: + kopf = ("Rippy ist installiert, kann aber noch NICHT rippen — es " + "fehlt ein Pflichtwerkzeug.") + return kopf + "\n\n" + "\n".join(zeilen) + + +def mitgelieferte_werkzeuge(quelle: str, ziel: str) -> list: + """Kopiert mitgelieferte Werkzeuge neben das Programm. + + Gibt die Namen zurück, die WIRKLICH angekommen sind — nicht die, die + versucht wurden. Leer ist ein gültiges Ergebnis (nichts mitgeliefert). + + `quelle` ist der `tools`-Ordner im Paket, `ziel` der Installationsordner. + Dort sucht `katalog.kandidaten()` vor allen Systempfaden, damit eine von + Rippy gepflegte Fassung eine alte Systeminstallation schlägt. + """ + import shutil + + if not os.path.isdir(quelle): + return [] + angekommen = [] + for name in sorted(os.listdir(quelle)): + von = os.path.join(quelle, name) + if not os.path.isdir(von): + continue + nach = os.path.join(ziel, "tools", name) + try: + # Nur anlegen, wenn dort noch nichts liegt: Eine vom Nutzer + # aktualisierte Fassung darf ein Update von Rippy nicht auf den + # mitgelieferten Stand zurueckdrehen. + if os.path.isdir(nach) and os.listdir(nach): + angekommen.append(name) + continue + os.makedirs(os.path.dirname(nach), exist_ok=True) + shutil.copytree(von, nach, dirs_exist_ok=True) + except OSError: + continue + angekommen.append(name) + return angekommen diff --git a/src/rippy/tools/test_beschaffen.py b/src/rippy/tools/test_beschaffen.py index f734804..8a6229a 100644 --- a/src/rippy/tools/test_beschaffen.py +++ b/src/rippy/tools/test_beschaffen.py @@ -12,6 +12,64 @@ import pytest from rippy.tools import beschaffen +# ── Der Fortschritts-Rueckruf ─────────────────────────────────────────── +class GefaelschteAntwort: + """Ein urlopen-Ergebnis mit vorgegebenem Inhalt.""" + + def __init__(self, daten): + self._daten = daten + self._gelesen = 0 + + def read(self, n): + stueck = self._daten[self._gelesen:self._gelesen + n] + self._gelesen += len(stueck) + return stueck + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def test_fortschritt_bekommt_IMMER_einen_text(tmp_path, monkeypatch): + """DER Fehler vom 28.08.2026 beim ersten echten Aufrufer: + + can only concatenate str (not "NoneType") to str + + `_datei_laden` schickte `None` als Text, wenn sich nur der Fortschritt + geaendert hatte. Wer den Text ausgab — der Bau, das Setup — starb daran. + Ein Rueckruf, den man nur mit einer nirgends dokumentierten + Sonderbehandlung benutzen kann, ist eine Falle. + """ + inhalt = b"x" * (3 * 1024 * 1024) + monkeypatch.setattr(beschaffen.urllib.request, "urlopen", + lambda *a, **k: GefaelschteAntwort(inhalt)) + + meldungen = [] + beschaffen._datei_laden( + "http://egal", str(tmp_path / "d.bin"), len(inhalt), + lambda text, anteil=None: meldungen.append(text)) + + assert meldungen, "es kam ueberhaupt keine Meldung" + assert all(isinstance(m, str) for m in meldungen), meldungen + # Und der Aufrufer darf sie bedenkenlos verketten: + assert all((" " + m) for m in meldungen) + + +def test_fortschritt_flutet_das_protokoll_nicht(tmp_path, monkeypatch): + """24 MB in 256-KB-Haeppchen waeren fast hundert Zeilen.""" + inhalt = b"x" * (24 * 1024 * 1024) + monkeypatch.setattr(beschaffen.urllib.request, "urlopen", + lambda *a, **k: GefaelschteAntwort(inhalt)) + + meldungen = [] + beschaffen._datei_laden( + "http://egal", str(tmp_path / "d.bin"), len(inhalt), + lambda text, anteil=None: meldungen.append(text)) + assert len(meldungen) <= 12, len(meldungen) + + # ── Welche Datei aus dem Release? ─────────────────────────────────────── # Woertlich aus der GitHub-Antwort vom 28.08.2026 (Release 1.11.2), gekuerzt. ASSETS = [ diff --git a/src/rippy/tools/test_einrichten.py b/src/rippy/tools/test_einrichten.py new file mode 100644 index 0000000..b819626 --- /dev/null +++ b/src/rippy/tools/test_einrichten.py @@ -0,0 +1,186 @@ +"""Das Einrichten der Werkzeuge — ohne einen einzigen Download. + +## Warum es diese Tests gibt + +Commander am 28.08.2026: *„handbrake und makemkv MUESSEN mitgeliefert werden +oder waehrend des Setups separat installiert werden! Ohne das ist das tool +NICHT einsatzfaehig."* + +Er hatte recht: `katalog.py` konnte finden, `beschaffen.py` konnte holen — +**das Setup rief beides nie auf.** Auf einem frischen Rechner bekam der +Nutzer eine Oberflaeche, die ihm sagte, was fehlt, und keinen Weg, es zu +aendern. + +Alle Pruefungen hier sind eingespritzt: kein Netz, kein Installer, keine +Plattformabhaengigkeit. +""" + +import os + +from rippy.tools import einrichten + + +def _eintrag(da, pflicht=True, titel="Werkzeug", version="1.0"): + return {"da": da, "pflicht": pflicht, "titel": titel, + "version": version if da else "", "pfad": "X" if da else ""} + + +def _bestand(makemkv=True, handbrake=True): + return { + "makemkv": _eintrag(makemkv, True, "MakeMKV", "1.18.4"), + "handbrake": _eintrag(handbrake, False, "HandBrakeCLI", "1.11.2"), + } + + +# ── Was fehlt ─────────────────────────────────────────────────────────── +def test_nichts_fehlt(): + assert einrichten.fehlende(_bestand()) == [] + + +def test_pflicht_kommt_zuerst(): + """Wer die Liste abarbeitet, soll das Wichtigste zuerst holen — bei einer + wackligen Verbindung entscheidet das, was ankommt.""" + fehlt = einrichten.fehlende(_bestand(makemkv=False, handbrake=False)) + assert [name for name, _ in fehlt] == ["makemkv", "handbrake"] + + +def test_ohne_handbrake_ist_rippy_trotzdem_bereit(): + """Ein Rip laeuft ohne HandBrake durch, nur eben ohne Kompression. Das + ist ein vorgesehener Betriebsfall — und kein Grund, dem Nutzer zu sagen, + Rippy sei nicht einsatzfaehig.""" + assert einrichten.einsatzbereit(_bestand(handbrake=False)) is True + + +def test_ohne_makemkv_ist_rippy_NICHT_bereit(): + """Genau der Punkt des Commanders. Ohne MakeMKV kann Rippy keine Disc + lesen — dann ist es ein Anzeigeprogramm.""" + assert einrichten.einsatzbereit(_bestand(makemkv=False)) is False + + +# ── Der Ablauf ────────────────────────────────────────────────────────── +def test_es_wird_nur_geholt_was_fehlt(): + """Eine funktionierende Installation darf nicht durch einen Download + ersetzt werden.""" + gerufen = [] + ergebnis = einrichten.sicherstellen( + bestand_lesen=lambda: _bestand(), + handbrake_holen=lambda **k: gerufen.append("hb"), + makemkv_holen=lambda **k: gerufen.append("mkv")) + assert gerufen == [] + assert ergebnis["bereit"] is True + + +def test_fehlendes_werkzeug_wird_geholt(): + stand = {"n": 0} + + def lesen(): + stand["n"] += 1 + return _bestand(makemkv=stand["n"] > 1) # beim zweiten Mal da + + ergebnis = einrichten.sicherstellen( + bestand_lesen=lesen, + handbrake_holen=lambda **k: None, + makemkv_holen=lambda **k: None) + assert ergebnis["geholt"] == ["makemkv"] + assert ergebnis["bereit"] is True + + +def test_ein_gescheiterter_download_wirft_nicht(): + """Am 28.08.2026 antwortete makemkv.com mit HTTP 525. Das darf keine + kaputte Installation ergeben — Rippy ist dann installiert und sagt, was + noch fehlt.""" + def platzt(**k): + raise OSError("HTTP 525") + + ergebnis = einrichten.sicherstellen( + bestand_lesen=lambda: _bestand(makemkv=False), + handbrake_holen=lambda **k: None, + makemkv_holen=platzt) + assert ergebnis["bereit"] is False + assert "HTTP 525" in ergebnis["fehler"]["makemkv"] + + +def test_der_zustand_wird_NACH_dem_versuch_gelesen(): + """Ein Setup, das meldet „ich habe es versucht", ist wertlos. Es muss + melden, was DANACH da ist.""" + versuche = {"n": 0} + + def lesen(): + versuche["n"] += 1 + return _bestand(makemkv=False) # bleibt weg + + ergebnis = einrichten.sicherstellen( + bestand_lesen=lesen, + handbrake_holen=lambda **k: None, + makemkv_holen=lambda **k: None) # meldet Erfolg … + assert versuche["n"] == 2, "vorher UND nachher lesen" + assert ergebnis["bereit"] is False, "… ist aber trotzdem nicht da" + + +def test_fortschritt_wird_durchgereicht(): + meldungen = [] + einrichten.sicherstellen( + fortschritt=lambda text, anteil=None: meldungen.append(text), + bestand_lesen=lambda: _bestand(makemkv=False), + handbrake_holen=lambda **k: None, + makemkv_holen=lambda **k: None) + assert any("MakeMKV" in m for m in meldungen) + + +# ── Der Bericht ───────────────────────────────────────────────────────── +def test_bericht_nennt_das_fehlende_UND_den_ausweg(): + """Ein Bericht, der nur ein Problem meldet, laesst den Nutzer ratlos.""" + text = einrichten.bericht({ + "bestand": _bestand(makemkv=False), + "geholt": [], "fehler": {"makemkv": "HTTP 525"}, "bereit": False}) + assert "NICHT rippen" in text + assert "MakeMKV FEHLT" in text + assert "HTTP 525" in text + assert "makemkv.com" in text + + +def test_bericht_sagt_klar_wenn_alles_da_ist(): + text = einrichten.bericht({ + "bestand": _bestand(), "geholt": [], "fehler": {}, "bereit": True}) + assert "einsatzbereit" in text + assert "FEHLT" not in text + + +def test_bericht_markiert_was_neu_dazukam(): + text = einrichten.bericht({ + "bestand": _bestand(), "geholt": ["handbrake"], "fehler": {}, + "bereit": True}) + assert "neu geholt" in text + + +# ── Mitgelieferte Werkzeuge ───────────────────────────────────────────── +def test_mitgeliefertes_wird_neben_das_programm_gelegt(tmp_path): + quelle = tmp_path / "paket" / "tools" / "handbrake" + quelle.mkdir(parents=True) + (quelle / "HandBrakeCLI.exe").write_bytes(b"x" * 64) + + ziel = tmp_path / "installiert" + ziel.mkdir() + assert einrichten.mitgelieferte_werkzeuge( + str(tmp_path / "paket" / "tools"), str(ziel)) == ["handbrake"] + assert os.path.isfile(str(ziel / "tools" / "handbrake" / "HandBrakeCLI.exe")) + + +def test_eine_neuere_fassung_wird_nicht_zurueckgedreht(tmp_path): + """Wer HandBrake selbst aktualisiert hat, darf das durch ein Rippy-Update + nicht verlieren.""" + quelle = tmp_path / "paket" / "tools" / "handbrake" + quelle.mkdir(parents=True) + (quelle / "HandBrakeCLI.exe").write_bytes(b"alt") + + ziel = tmp_path / "installiert" + (ziel / "tools" / "handbrake").mkdir(parents=True) + (ziel / "tools" / "handbrake" / "HandBrakeCLI.exe").write_bytes(b"neuer") + + einrichten.mitgelieferte_werkzeuge(str(tmp_path / "paket" / "tools"), str(ziel)) + assert (ziel / "tools" / "handbrake" / "HandBrakeCLI.exe").read_bytes() == b"neuer" + + +def test_ohne_mitgeliefertes_passiert_nichts(tmp_path): + assert einrichten.mitgelieferte_werkzeuge( + str(tmp_path / "gibt-es-nicht"), str(tmp_path)) == [] diff --git a/src/rippy/windows_app.py b/src/rippy/windows_app.py index 9e45f3a..f0507ee 100644 --- a/src/rippy/windows_app.py +++ b/src/rippy/windows_app.py @@ -148,6 +148,13 @@ def installieren(ziel: str = None, autostart: bool = True, ui_auspacken(ziel) + # Mitgelieferte Werkzeuge (HandBrakeCLI) neben das Programm legen. Dort + # sucht katalog.kandidaten() VOR allen Systempfaden. + from rippy.tools import einrichten + + for name in einrichten.mitgelieferte_werkzeuge(_mitgeliefert("tools"), ziel): + melden(" Werkzeug bereitgestellt: %s" % name) + # Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein # Programm, das man nur von Hand wieder loswird. reg.eintragen(ziel, programm, VERSION, @@ -559,6 +566,7 @@ Optionen: --ziel PFAD Installationsordner --kein-autostart --keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag + --ohne-werkzeuge MakeMKV/HandBrake NICHT pruefen und nicht holen --im-browser im Browser statt im eigenen Fenster --still ohne Rückmeldungen """ @@ -620,16 +628,41 @@ def main(argv=None) -> int: raise melden("Rippy wurde nach %s installiert." % ziel) - melden("Die Oberfläche öffnet sich gleich in einem eigenen Fenster.") + + # Ohne MakeMKV kann Rippy keine Disc lesen — dann ist es ein + # Anzeigeprogramm. Der Commander am 28.08.2026: „Ohne das ist das tool + # NICHT einsatzfaehig." Also gehoert die Beschaffung ins Setup und nicht + # in eine Meldung im UI, die niemand befolgen kann. + text = "Rippy wurde installiert:\n\n%s" % ziel + if "--ohne-werkzeuge" not in argv: + text = werkzeuge_einrichten() + if "--nicht-starten" not in argv: # Als eigener Prozess und aus dem INSTALLIERTEN Programm heraus: # Das Setup soll sich beenden koennen, ohne das Fenster mitzunehmen. starte_fensterprozess(os.path.join(ziel, "Rippy.exe")) - else: - abschluss_melden("Rippy wurde installiert:\n\n%s" % ziel, - still="--still" in argv) + abschluss_melden(text, still="--still" in argv) return 0 +def werkzeuge_einrichten() -> str: + """Holt fehlende Werkzeuge und gibt den Bericht als Text zurück. + + Wirft nicht: Ein nicht erreichbarer Download-Server ist kein Grund, eine + sonst geglückte Installation scheitern zu lassen. Was fehlt, steht dann + im Bericht — mitsamt dem Weg, es von Hand zu beschaffen. + """ + from rippy.tools import einrichten + + try: + ergebnis = einrichten.sicherstellen( + fortschritt=lambda t, anteil=None: melden(" " + t)) + return einrichten.bericht(ergebnis) + except Exception as e: # noqa: BLE001 + return ('Rippy ist installiert. Die Werkzeug-Prüfung ist gescheitert ' + '(%s: %s) — sieh in den Einstellungen unter „System" nach.' + % (type(e).__name__, e)) + + if __name__ == "__main__": raise SystemExit(main())