feat(windows): das Setup richtet MakeMKV und HandBrake wirklich ein
Ampel / ampel (push) Successful in 54s

Commander: "handbrake und makemkv MUESSEN mitgeliefert werden oder waehrend
des Setups separat installiert werden! Ohne das ist das tool NICHT
einsatzfaehig."

Er hat recht, und die Luecke 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 Oberflaeche, die
ihm sagte was fehlt, und keinen Weg, es zu aendern.

Die beiden gehen unterschiedliche Wege, und der Grund ist die LIZENZ:

* HandBrakeCLI ist GPL-2 -> mitgeliefert. Weitergabe ausdruecklich erlaubt,
  solange Lizenztext und Quellverweis dabei sind (LIZENZ-HandBrake.txt liegt
  daneben). Damit komprimiert Rippy auch ohne Internet.
* MakeMKV ist proprietaer -> beim Einrichten vom Hersteller geholt und mit
  DESSEN Installer installiert. Weitergabe durch Dritte ist nicht erlaubt.
  Das ist dieselbe Black-Box-Trennung wie in KONZEPT.md 6 und deckt sich mit
  KONZEPT-V2.md 4.1 ("MakeMKV wird NICHT mitgeliefert").

Drei Regeln, die dazugehoeren:

1. Ein Setup meldet NIE Erfolg, waehrend ein Pflichtwerkzeug fehlt.
   sicherstellen() liest den Bestand VOR und NACH dem Versuch und gibt
   zurueck, 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 (Rip laeuft, nur ohne Kompression),
   ohne MakeMKV nicht. Nur Pflichtwerkzeuge entscheiden ueber "bereit".

Nachgemessen am fertigen Setup: HandBrake geloescht, installiert, nach DREI
Sekunden wieder da (74 MB, also aus dem Paket und nicht geladen), Meldung
"Rippy ist einsatzbereit. HandBrakeCLI 1.11.2 / MakeMKV 1.18.4".
RippySetup.exe waechst von 32,0 auf 56,4 MB.

fix(tools): der Fortschritts-Rueckruf bekommt immer einen Text

_datei_laden schickte `None` als Text, wenn sich nur der Fortschritt geaendert
hatte. Der erste echte Aufrufer starb daran:

    can only concatenate str (not "NoneType") to str

Ein Rueckruf, den man nur mit einer nirgends dokumentierten Sonderbehandlung
benutzen kann, ist eine Falle. Jetzt kommt immer ein Satz, gedrosselt auf
jedes zehnte Prozent -- 24 MB in 256-KB-Haeppchen waeren sonst hundert Zeilen
im Protokoll. Zwei Tests halten beides fest.

Nicht im Repo: Die 70,9 MB HandBrakeCLI holt der BAU nach
dist/windows/vendor/ (nicht versioniert) -- dasselbe Muster wie der
vendor/-Ordner fuer MakeMKV auf der VM.

Ampel lokal: 607 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 13:09:01 +02:00
co-authored by Claude Opus 5
parent 60c0f93237
commit ce4e7e6b8a
9 changed files with 694 additions and 6 deletions
+42
View File
@@ -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
+7
View File
@@ -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 →
+18
View File
@@ -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)
+135
View File
@@ -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)
+26 -2
View File
@@ -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
+185
View File
@@ -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
+58
View File
@@ -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 = [
+186
View File
@@ -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)) == []
+37 -4
View File
@@ -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())