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>
255 lines
10 KiB
Python
255 lines
10 KiB
Python
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
|
|
|
|
## Aufruf
|
|
|
|
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
|
|
|
|
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
|
|
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
|
|
* pyinstaller, pystray, pillow
|
|
* Das UI muss gebaut sein: cd docker/ui && npm run build
|
|
|
|
## Warum die Versions-Ressource nicht optional ist
|
|
|
|
Ohne sie steht im Taskmanager unter „Beschreibung" nichts, und in den
|
|
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
|
|
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte —
|
|
und Windows SmartScreen sieht das genauso.
|
|
|
|
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
|
|
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
|
|
daneben aus dieser Ressource.
|
|
|
|
## Was mit hinein muss
|
|
|
|
docker/api/ die API-Module (main.py und Nachbarn)
|
|
docker/ui/dist/ die gebaute Weboberflaeche
|
|
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
|
|
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
|
|
|
|
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/` —
|
|
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
HIER = os.path.dirname(os.path.abspath(__file__))
|
|
REPO = os.path.dirname(os.path.dirname(HIER))
|
|
|
|
VERSION_VORLAGE = """
|
|
VSVersionInfo(
|
|
ffi=FixedFileInfo(
|
|
filevers=({v0}, {v1}, {v2}, 0),
|
|
prodvers=({v0}, {v1}, {v2}, 0),
|
|
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
|
|
date=(0, 0)
|
|
),
|
|
kids=[
|
|
StringFileInfo([
|
|
StringTable(u'040704b0', [
|
|
StringStruct(u'CompanyName', u'Rippy'),
|
|
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
|
|
StringStruct(u'FileVersion', u'{version}'),
|
|
StringStruct(u'InternalName', u'Rippy'),
|
|
StringStruct(u'OriginalFilename', u'Rippy.exe'),
|
|
StringStruct(u'ProductName', u'Rippy'),
|
|
StringStruct(u'ProductVersion', u'{version}')])
|
|
]),
|
|
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
|
|
# Windows die Angaben je nach Systemsprache gar nicht an.
|
|
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
|
|
]
|
|
)
|
|
"""
|
|
|
|
|
|
def versionsdatei(version: str, ordner: str) -> str:
|
|
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
|
|
pfad = os.path.join(ordner, "version_info.txt")
|
|
with open(pfad, "w", encoding="utf-8") as f:
|
|
f.write(VERSION_VORLAGE.format(
|
|
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
|
|
return pfad
|
|
|
|
|
|
def pruefen() -> list:
|
|
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
|
|
|
|
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
|
|
aussieht wie fertig und es nicht ist.
|
|
"""
|
|
maengel = []
|
|
if sys.platform != "win32":
|
|
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
|
|
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
|
|
maengel.append("docker/api/main.py fehlt.")
|
|
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
|
|
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
|
|
"die App nicht rippen.")
|
|
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
|
|
maengel.append(
|
|
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
|
|
"Abhilfe: cd docker/ui && npm ci && npm run build")
|
|
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
|
|
if not os.path.isfile(icon):
|
|
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
|
|
else:
|
|
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
|
|
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
|
|
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
|
|
# noch keine Datei, die funktioniert.
|
|
import sys as _sys
|
|
_sys.path.insert(0, HIER)
|
|
from icon import fehlende_pflichtgroessen
|
|
|
|
fehlt = fehlende_pflichtgroessen(icon)
|
|
if fehlt:
|
|
maengel.append(
|
|
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
|
|
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
|
|
% (fehlt,))
|
|
try:
|
|
import PyInstaller # noqa: F401
|
|
except ImportError:
|
|
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
|
|
for modul, paket, wofuer in (
|
|
("pystray", "pystray", "kein Tray-Symbol"),
|
|
("PIL", "pillow", "kein Tray-Symbol"),
|
|
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
|
|
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
|
|
):
|
|
try:
|
|
__import__(modul)
|
|
except ImportError:
|
|
maengel.append(
|
|
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
|
|
return maengel
|
|
|
|
|
|
def bauen(ausgabe: str, version: str) -> str:
|
|
arbeit = os.path.join(ausgabe, "_bau")
|
|
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",
|
|
"--onefile",
|
|
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
|
|
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
|
|
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
|
|
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
|
|
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
|
|
# Sekunden auf, bis Python so weit ist.
|
|
#
|
|
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
|
|
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
|
|
"--windowed",
|
|
"--name", "RippySetup",
|
|
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
|
|
"--version-file", versionsdatei(version, arbeit),
|
|
"--distpath", ausgabe,
|
|
"--workpath", os.path.join(arbeit, "work"),
|
|
"--specpath", arbeit,
|
|
"--paths", os.path.join(REPO, "src"),
|
|
"--paths", os.path.join(REPO, "docker", "api"),
|
|
"--paths", os.path.join(REPO, "docker", "worker"),
|
|
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
|
|
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
|
|
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
|
|
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
|
|
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
|
|
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
|
|
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
|
|
"--hidden-import", "uvicorn.logging",
|
|
"--hidden-import", "uvicorn.loops.auto",
|
|
"--hidden-import", "uvicorn.protocols.http.auto",
|
|
"--hidden-import", "uvicorn.protocols.websockets.auto",
|
|
"--hidden-import", "uvicorn.lifespan.on",
|
|
"--hidden-import", "rippy.drives.windows",
|
|
"--hidden-import", "pystray._win32",
|
|
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
|
|
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
|
|
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
|
|
# genau dorthin, wovon wir gerade weg wollten.
|
|
"--hidden-import", "webview",
|
|
"--hidden-import", "webview.platforms.winforms",
|
|
"--hidden-import", "webview.platforms.edgechromium",
|
|
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
|
|
# und beides zieht Ballast nach.
|
|
"--exclude-module", "psycopg2",
|
|
"--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)
|
|
if ergebnis.returncode != 0:
|
|
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
|
|
|
|
exe = os.path.join(ausgabe, "RippySetup.exe")
|
|
if not os.path.isfile(exe):
|
|
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
|
|
return exe
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
|
|
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
|
|
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
|
|
p.add_argument("--nur-pruefen", action="store_true")
|
|
args = p.parse_args(argv)
|
|
|
|
print("Rippy — Windows-Bau")
|
|
print(" Repo %s" % REPO)
|
|
print(" Ausgabe %s" % args.ausgabe)
|
|
print(" Version %s" % args.version)
|
|
print()
|
|
|
|
maengel = pruefen()
|
|
if maengel:
|
|
print("Der Bau kann so nicht laufen:")
|
|
for m in maengel:
|
|
print(" X %s" % m)
|
|
return 1
|
|
print("Alle Voraussetzungen erfuellt.")
|
|
if args.nur_pruefen:
|
|
return 0
|
|
|
|
os.makedirs(args.ausgabe, exist_ok=True)
|
|
exe = bauen(args.ausgabe, args.version)
|
|
|
|
groesse = os.path.getsize(exe) / 1024**2
|
|
print()
|
|
print("Fertig: %s (%.1f MB)" % (exe, groesse))
|
|
|
|
arbeit = os.path.join(args.ausgabe, "_bau")
|
|
shutil.rmtree(arbeit, ignore_errors=True)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|