feat(windows): das Setup richtet MakeMKV und HandBrake wirklich ein
Ampel / ampel (push) Successful in 54s
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:
co-authored by
Claude Opus 5
parent
60c0f93237
commit
ce4e7e6b8a
@@ -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
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)) == []
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user