chore(main): Der Windows-Anteil ist raus — Entscheid 7, § 11 umgesetzt
Ampel / ampel (push) Failing after 41s

Teil A (ganze Dateien): windows_app, fenster, setup_fenster,
einrichtung, standalone, daemon, drives/windows + win_ioctl,
platform/verknuepfungen + win_registry, tools/einrichten,
rip/audio_cd + musicbrainz_cd, queue/ (lokal + laeufer),
packaging/windows/, test_keine_container_reste — samt Tests.
Bilanz: +51 / -10.039 Zeilen in 48 Dateien.

Bleibt TROTZ § 11.2-Listung (Importe gemessen, nicht geraten):
winlauf (OHNE_FENSTER nutzt der Worker; Teil B laeuft nativ Windows),
dateiangaben/katalog/beschaffen (ripping + api importieren sie),
makemkv_aufruf KOMPLETT (ablauf.py ruft key_setzen_und_pruefen;
Registry-Zweige braucht der Remote-Worker), bus/waechter (bedient
Jobs/Logs/Laufwerke der API), die nativ-Pfadzweige (Teil B).

Teil C: drives/treiber() liefert unter Windows den Klartext-Platzhalter
kein_windows.py (Importe/Testsammlung auf Entwicklungs-PCs bleiben
heil, BENUTZUNG wirft mit Verweis auf v5); ripping.rip_cd ohne
Windows-Zweig; betrieb.windows_laufwerke + Aufrufer raus;
/worker-setup/windows* bleibt (Teil B, Entscheid 8).

Lokal: Ruff gruen, 658 Tests gruen (vorher 977 — die Differenz sind
die Windows-Tests, deren Gegenstand mitgegangen ist). Das Wissen liegt
vollstaendig in Rippy v5 (rippy-windows/), mit denselben Testfaellen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 19:18:10 +02:00
co-authored by Claude Fable 5
parent 6f230cbd7f
commit cedab61ddf
49 changed files with 105 additions and 10040 deletions
+54 -1
View File
@@ -1,6 +1,59 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026)
## Aktueller Stand: Der Windows-Anteil ist raus aus `main` (30.08.2026)
> **Entscheid 7, umgesetzt: 10.039 Zeilen entfernt, 51 hinzugefügt.** Die
> Windows-Standalone-App (Teil A aus KONZEPT-WINDOWS.md § 11) ist aus
> dieser Codebasis entfernt — Rippy für Windows ist ab jetzt Rippy v5,
> ein eigenes Produkt auf dem Branch `worktree-windows-electron`.
> Ampel-Praxis lokal: Ruff grün, **658 Tests grün** (vorher 977 — die
> Differenz sind die Windows-Tests, deren Gegenstand mitgegangen ist).
### Was entfernt wurde (Teil A — ganze Dateien)
`windows_app` · `fenster` · `setup_fenster` · `einrichtung` ·
`standalone` · `daemon` · `drives/windows` + `win_ioctl` ·
`platform/verknuepfungen` + `win_registry` · `tools/einrichten` ·
`rip/audio_cd` + `musicbrainz_cd` · `queue/` (lokal + laeufer) ·
`packaging/windows/` · `test_keine_container_reste` (gegenstandslos) —
jeweils samt Tests.
### Was BLEIBT, obwohl § 11.2 es unter A führte (gemessen, nicht geraten)
* **`platform/winlauf`** — `OHNE_FENSTER` und die Prozess-Regeln nutzt
der Docker-Worker-Code selbst, und der **Remote-Encode-Worker (Teil B,
Entscheid 8)** läuft nativ auf Windows.
* **`platform/dateiangaben`, `tools/katalog`, `tools/beschaffen`** —
Importe aus `ripping.py` und `docker/api/main.py`.
* **`rip/makemkv_aufruf` komplett** — `key_setzen_und_pruefen` und
`einstellungen_sicherstellen` ruft `docker/worker/ablauf.py`; die
Registry-Zweige braucht Teil B.
* **`bus/waechter`** — er bedient Jobs, Logs UND Laufwerke der
Docker-API, nicht nur die Windows-Disc-Wache.
* Die `nativ`-Pfadzweige in `ablauf`/`rohdaten`/`mounts` — Teil B sucht
Roh-MKVs über die Freigabe.
### Teil C — die verstreuten Zweige
* `drives/treiber()` liefert unter Windows jetzt `kein_windows.py`:
Importe und Test-Sammlung auf Entwicklungs-PCs bleiben heil, jede
BENUTZUNG wirft mit Klartext und Verweis auf v5.
* `ripping.rip_cd`: Der Windows-CD-Zweig (Roh-Sektoren + FLAC) ist nach
v5 gewandert (dort § 6.3, samt der 150-Frames- und 2048er-Fallen).
* `betrieb.windows_laufwerke` + die zwei Aufrufer in `main.py`
(Laufwerksbuchstaben-Browsing) sind raus; `/worker-setup/windows*`
bleibt (das ist Teil B).
### Wo das Wissen jetzt liegt
Vollständig in Rippy v5 (`rippy-windows/`), mit denselben Testfällen:
Win32-Steuercodes samt Herleitung, Auswurf-Dreischritt, MSF/LBA- und
2048er-Fallen, MusicBrainz-DiscID (gegen die echte AC/DC-Kennung
getestet), die MakeMKV-Schlüsselkette samt Registry-Weg.
---
## v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026)
> **Ein falscher Kommandozeilen-Schalter kostete einen fertigen 16,5-GB-Rip —
> und HandBrake meldete es als Erfolg.** Dazu vierzehn weitere Funde aus
+4 -24
View File
@@ -1507,19 +1507,9 @@ async def storage_targets():
"free_gb": frei_gb,
})
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
if frei_blaettern():
from rippy import betrieb
bekannt = {z["path"].rstrip("\\/").lower() for z in ziele}
for laufwerk in betrieb.windows_laufwerke():
if laufwerk.rstrip("\\/").lower() in bekannt:
continue
ziele.append({
"name": "Laufwerk %s" % laufwerk.rstrip("\\/"),
"path": laufwerk,
"is_mount": False,
"free_gb": platz(laufwerk),
})
# Der Laufwerksbuchstaben-Block stand hier für die Windows-
# Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT-
# WINDOWS.md Entscheid 7); im Container gab es hier nie etwas.
return ziele
return await asyncio.to_thread(sammle)
@@ -2145,7 +2135,7 @@ async def browse(path: str = ""):
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
"""
if not path.strip():
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
laufwerke = []
if laufwerke:
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
path = medien_wurzel()
@@ -2203,16 +2193,6 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str
return "" if oben == pfad else oben
def betrieb_laufwerke() -> list:
"""Die Laufwerke als Ordner-Einträge für die oberste Ebene des Browsers."""
from rippy import betrieb
try:
return [{"name": "Laufwerk " + laufwerk.rstrip("\\/"), "path": laufwerk}
for laufwerk in betrieb.windows_laufwerke()]
except Exception: # noqa: BLE001
return []
class MkdirRequest(BaseModel):
path: str
name: str
-3
View File
@@ -77,6 +77,3 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
assert main.medien_wurzel()
def test_laufwerke_ohne_windows_sind_eine_leere_liste():
"""Auf dem Linux-Runner der Ampel darf das kein Fehler sein."""
assert isinstance(main.betrieb_laufwerke(), list)
+4 -56
View File
@@ -1114,66 +1114,14 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
log_cb=log_cb)
def _rip_cd_windows(device_path: str, disc_id: str, progress_cb=None,
output_dir: str = None, auswerfen: bool = True) -> dict:
"""Audio-CD unter Windows: selbst lesen, mit FLAC kodieren.
Die Rechnung steht in `rippy.rip.audio_cd` (ohne Laufwerk pruefbar), das
Lesen im Windows-Treiber (der einzige Ort mit Win32-Aufrufen).
"""
from rippy.drives import windows as win
from rippy.rip import audio_cd
class Leser:
toc = staticmethod(win.audio_toc)
@staticmethod
def spur(geraet, start, sektoren, schreiben, fortschritt=None):
return win.audio_spur_lesen(geraet, start, sektoren, schreiben,
fortschritt)
# Erst die Disc bei MusicBrainz nachschlagen — daraus kommen sowohl der
# Ordnername als auch die Titel. Ohne Treffer heissen die Dateien
# `Track 01.flac`; das ist keine Ausrede, sondern die Wahrheit ueber eine
# Disc, die niemand kennt.
from rippy.rip import musicbrainz_cd
try:
spuren = win.audio_toc(device_path)
antwort = musicbrainz_cd.abfragen(
musicbrainz_cd.kennung_aus_spuren(spuren))
except Exception: # noqa: BLE001
antwort = None
tags = musicbrainz_cd.tags_je_spur(antwort)
ordner = musicbrainz_cd.album_ordner(antwort)
ziel = output_dir or os.path.join(RIP_OUTPUT_DIR, "cd", ordner or disc_id)
ergebnis = audio_cd.rippen(device_path, ziel, Leser(),
werkzeuge.finden("flac"),
fortschritt=progress_cb, tags_je_spur=tags)
if auswerfen and ergebnis.get("status") == "success":
wirf_disc_aus(device_path)
return ergebnis
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
auswerfen: bool = True) -> dict:
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
Auswurf" (abcde macht das selbst per -x)."""
# ── Windows geht einen eigenen Weg (Befund 29.08.2026) ─────────────
#
# `abcde` und `cdparanoia` sind Linux-Werkzeuge; es gibt sie fuer Windows
# nicht, und ein Nachbau ihrer Shell-Logik waere ein zweites Projekt.
# Windows bietet stattdessen zwei Steuercodes an, mit denen sich eine
# Audio-CD direkt lesen laesst — `rippy.rip.audio_cd`, kodiert wird mit
# dem FLAC-Encoder aus dem Werkzeug-Katalog.
#
# Das war die letzte offene Luecke des Windows-Betriebs; sie stand seit
# Wochen so im SAVEPOINT.
if os.name == "nt":
return _rip_cd_windows(device_path, disc_id, progress_cb, output_dir,
auswerfen)
Auswurf" (abcde macht das selbst per -x).
Der Windows-Weg (Roh-Sektoren + FLAC) ist mit der Standalone-App nach
Rippy v5 gewandert (KONZEPT-WINDOWS.md Entscheid 7, dort § 6.3)
diese Fassung rippt CDs nur noch im Container, mit abcde."""
if not check_abcde_installed():
return {
"status": "error",
-254
View File
@@ -1,254 +0,0 @@
"""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())
-61
View File
@@ -1,61 +0,0 @@
"""Einstiegspunkt der gebuendelten RippySetup.exe.
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
steht, laesst sich nicht testen also steht hier so wenig wie moeglich.
## Warum hier trotzdem etwas zur Ausgabe steht
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
KEINE Konsole aufgeht ausdruecklicher Wunsch des Commanders am 28.08.2026:
Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
schreibt uvicorn tut das , wuerde mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
zwar mitten im Start, ohne dass irgendwo etwas stuende.
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
1. In einem Terminal gestartet? -> dort hineinschreiben
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
"""
import multiprocessing
import sys
def _ausgabe_richten() -> None:
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return # aus dem Quellcode gestartet
if winlauf.an_elternkonsole_haengen():
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
# sys.stdout trotz Konsole None.
try:
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
buffering=1)
sys.stderr = sys.stdout
return
except OSError:
pass
winlauf.ausgabe_umleiten()
def main() -> int:
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
multiprocessing.freeze_support()
_ausgabe_richten()
from rippy.windows_app import main as rippy_main
return rippy_main()
if __name__ == "__main__":
sys.exit(main())
-142
View File
@@ -1,142 +0,0 @@
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
## Der Befund (28.08.2026)
Commander: *Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
Leeres Blatt)."*
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
Bilder = 1
256x256 32 bit 5657 Bytes PNG
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
die passende Größe aus der Datei:
Taskleiste, Verknüpfung klein 16x16
Desktop, Startmenü 32x32 / 48x48
Große Kacheln, Alt-Tab 256x256
Fehlt die passende Größe, skaliert die Shell nicht immer selbst sie zeigt
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
enthält, passiert das zuverlässig.
## Was diese Datei tut
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
Die kleinen Größen werden mit `LANCZOS` verkleinert bei 16x16 ist das der
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
Aufruf (macht der Bau selbst, siehe `build.py`):
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
"""
import argparse
import os
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def groessen_in(pfad: str) -> list:
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
Fehler, den es hier zu verhindern gilt.
"""
import struct
with open(pfad, "rb") as f:
roh = f.read()
if len(roh) < 6:
return []
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
if reserviert != 0 or typ != 1:
return []
gefunden = []
for i in range(anzahl):
block = roh[6 + i * 16: 22 + i * 16]
if len(block) < 16:
break
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
gefunden.append(breite)
return sorted(gefunden)
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
PFLICHTGROESSEN = (16, 32, 48, 256)
def fehlende_pflichtgroessen(pfad: str) -> tuple:
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
vorhanden = set(groessen_in(pfad))
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
def bauen(quelle: str = None, ziel: str = None) -> str:
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
Wirft, wenn es nicht geht ein Symbol, das still fehlschlaegt, fuehrt
genau zu dem leeren Blatt, das hier abgestellt werden soll.
"""
from PIL import Image
quelle = quelle or QUELLE
ziel = ziel or quelle
bild = Image.open(quelle)
bild = bild.convert("RGBA")
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
# Groesse, und aus 16x16 laesst sich kein 256er machen.
if hasattr(bild, "size") and bild.size[0] < 256:
try:
bild = Image.open(quelle)
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
bild = bild.convert("RGBA")
except Exception: # noqa: BLE001
pass
ebenen = []
for kante in GROESSEN:
if kante >= bild.size[0]:
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
else:
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
# zwischen einem erkennbaren Zeichen und Pixelbrei.
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
ebenen[-1].save(ziel, format="ICO",
sizes=[(k, k) for k in GROESSEN])
return ziel
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
p.add_argument("--quelle", default=QUELLE)
p.add_argument("--ziel", default=None)
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
vorher = groessen_in(args.quelle)
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
if args.nur_pruefen:
return 0 if len(vorher) > 1 else 1
ziel = bauen(args.quelle, args.ziel)
print("Nachher: %s" % groessen_in(ziel))
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-25
View File
@@ -1,25 +0,0 @@
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
#
# py -3.12 -m venv venv
# venv\Scripts\pip install -r docker/api/requirements.txt
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
# venv\Scripts\python packaging/windows/build.py
#
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
# ist eine Entscheidung, kein Zufall.
pyinstaller==6.22.2
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
# zu beenden, ausser ueber den Taskmanager.
pystray==0.19.5
pillow==12.3.0
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
pywebview==6.2.1
pythonnet==3.1.0
-76
View File
@@ -1,76 +0,0 @@
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
## Der Befund (28.08.2026)
Commander: *Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
Leeres Blatt)."*
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
Windows holt sich fuer jede Stelle die passende Groesse:
Taskleiste, kleine Verknuepfung 16x16
Desktop, Startmenue 32x32 / 48x48
Alt-Tab, grosse Kacheln 256x256
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst sie zeigt das leere
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
regelmaessig.
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
"""
import os
import pytest
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def _icon_modul():
import sys
sys.path.insert(0, HIER)
import icon
return icon
def _groessen():
return _icon_modul().groessen_in(ICON)
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
# fordern, die dieser Test nicht prueft — oder umgekehrt.
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
def test_icon_ist_da():
assert os.path.isfile(ICON), ICON
def test_icon_ist_eine_gueltige_ico():
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
def test_pflichtgroesse_ist_enthalten(kante):
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
vorhanden = _groessen()
assert kante in vorhanden, (
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (kante, kante, vorhanden)
)
def test_eine_einzige_groesse_reicht_nie():
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
assert len(_groessen()) > 1, (
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
"gefuehrt hat."
)
-135
View File
@@ -1,135 +0,0 @@
"""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)
+3 -41
View File
@@ -254,44 +254,6 @@ def platz_orte(werte: dict, container: bool = None,
return orte
#: Rückgabewerte von GetDriveTypeW, die als Ablage taugen (learn.microsoft.com
#: /windows/win32/api/fileapi/nf-fileapi-getdrivetypew): 3 = feste Platte,
#: 4 = Netzlaufwerk. NICHT dabei: 2 (Wechselmedium — ein USB-Stick als
#: Arbeitsverzeichnis für 100 GB Rohdaten ist eine Falle) und 5 (CD/DVD —
#: dort liegt die Disc, die gerade gerippt wird).
LAUFWERKSARTEN = (3, 4)
def windows_laufwerke(art=None, buchstaben=None) -> list:
"""Welche Laufwerksbuchstaben taugen als Ablage? — gemessen, nicht geraten.
Warum das gebraucht wird: Auf Windows ist die Antwort auf wohin mit 100 GB
Rohdaten" fast immer *ein anderes Laufwerk*. Ohne diese Liste stand im
Auswahlfeld nur ein einziger Eintrag, und der Commander konnte nichts
ändern.
`art` und `buchstaben` sind einspritzbar, damit das auf dem Linux-Runner
der Ampel prüfbar bleibt dort gibt es kein `GetDriveTypeW`.
"""
if buchstaben is None:
buchstaben = [c + ":\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
if art is None:
try:
import ctypes
art = ctypes.WinDLL("kernel32").GetDriveTypeW
art.argtypes = [ctypes.c_wchar_p]
except (OSError, AttributeError):
return []
gefunden = []
for pfad in buchstaben:
try:
if art(pfad) in LAUFWERKSARTEN:
gefunden.append(pfad)
except OSError:
continue
return gefunden
def mit_einstellungen(werte: dict, einstellungen: dict) -> dict:
r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen.
@@ -337,7 +299,7 @@ def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
return eigen
if container:
return "/app/media"
if system == "windows":
basis = os.environ.get("USERPROFILE") or os.path.expanduser("~")
return os.path.join(basis, "Videos", "Rippy")
# Der Windows-Sonderzweig ist mit der Standalone-App gegangen (v5 ist
# ein eigenes Produkt, KONZEPT-WINDOWS.md Entscheid 7) — nativ heisst
# hier nur noch: ein Entwickler-Lauf ausserhalb des Containers.
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
-338
View File
@@ -1,338 +0,0 @@
"""`rippyd` — Rippy als EIN Prozess, ohne Docker, ohne Postgres, ohne Redis.
## Was hier zusammenkommt
Alles, was die Etappen V2-1 bis V2-3 vorbereitet haben:
Store -> SQLite (V2-2) statt PostgreSQL
Queue -> LocalQueue (V2-2) statt Celery/Redis
Bus -> In-Process (V2-3) statt Redis Pub/Sub
Drives -> der Treiber dieser Maschine (V2-1/V2-4)
UI -> von FastAPI ausgeliefert statt von nginx
Dieselbe API, dasselbe UI, derselbe Rip-Code wie im Docker-Betrieb. Was sich
unterscheidet, ist allein die Verdrahtung genau das war der Punkt.
## Warum das UI unter `/` und die API unter `/api` haengt
Das UI ruft seine Endpunkte als `/api/jobs`, `/api/events` usw. auf. Im
Docker-Betrieb entfernt der nginx dieses Praefix und reicht an die API weiter
(`ui/nginx.conf`). Ohne nginx muss jemand anderes das tun.
Statt das UI umzubauen 30+ Aufrufstellen, und die Docker-Variante muesste
weiter funktionieren haengt hier die bestehende App als Unteranwendung unter
`/api`. Ein `mount()`, und beide Betriebsarten sehen fuer den Browser identisch
aus.
## Was `--profil` bedeutet
standalone alles in einem Prozess (Windows-App, Headless-Linux)
api nur die Weboberflaeche, Arbeit machen andere Knoten
node nur Arbeit, keine Weboberflaeche
Fuer V2-4 zaehlt `standalone`; die anderen beiden kommen mit V2-5.
"""
import argparse
import os
import sys
def ausgabe_auf_utf8_stellen() -> None:
"""stdout/stderr auf UTF-8 zwingen — sonst stirbt Rippy an einer Log-Zeile.
## Der Vorfall (28.08.2026, erster Standalone-Start unter Windows)
Der Start brach ab mit:
File "docker/api/main.py", line 81, in startup_event
print(f"⚠️ Konfigurations-Warnung: {e}")
UnicodeEncodeError: 'charmap' codec can't encode characters
ERROR: Application startup failed. Exiting.
Windows-Konsolen arbeiten standardmaessig mit cp1252. Rippys Meldungen
sind durchgehend deutsch und enthalten Umlaute, Anfuehrungszeichen und
Warnzeichen unter Linux nie ein Thema, unter Windows toedlich.
Das Bittere daran: Es war nicht die WARNUNG, die den Start verhindert hat,
sondern der VERSUCH, sie auszugeben. Eine Meldung ueber ein kleines Problem
hat das ganze Programm umgebracht.
Deshalb zwei Dinge, nicht eins:
* `encoding="utf-8"` damit die Zeichen ueberhaupt durchgehen.
* `errors="replace"` damit ein Zeichen, das trotzdem klemmt, ein
Fragezeichen wird und NICHT eine Ausnahme. Eine Ausgabe darf unter
keinen Umstaenden etwas abbrechen koennen.
"""
for strom in (sys.stdout, sys.stderr):
umstellen = getattr(strom, "reconfigure", None)
if umstellen is None:
continue # umgeleitet, kein TextIOWrapper
try:
umstellen(encoding="utf-8", errors="replace")
except (ValueError, OSError):
pass # dann eben nicht — aber nie werfen
ausgabe_auf_utf8_stellen()
def _api_pfad() -> str:
"""Wo liegen die API-Module (main.py, db.py, mounts.py, …)?
Drei Faelle, und alle drei kommen wirklich vor:
* **Aus dem Repo gestartet** `docker/api` neben `src/`.
* **Im PyInstaller-Paket** alles unter `sys._MEIPASS`.
* **Installiert** neben dem Programm.
Der Umweg ist noetig, weil die API-Module sich flach importieren
(`import db`, `import mounts`). Das aufzuloesen ist der naechste
Umbauschritt; bis dahin wird der Pfad hier gesetzt statt an drei Stellen
geraten.
"""
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
return os.path.join(gebuendelt, "api")
hier = os.path.dirname(os.path.abspath(__file__)) # …/src/rippy
repo = os.path.dirname(os.path.dirname(hier)) # …/
kandidaten = [
os.path.join(repo, "docker", "api"),
os.path.join(os.path.dirname(sys.executable), "api"),
]
for pfad in kandidaten:
if os.path.isfile(os.path.join(pfad, "main.py")):
return pfad
raise RuntimeError(
"Die API-Module wurden nicht gefunden. Gesucht wurde in:\n "
+ "\n ".join(kandidaten)
+ "\nOhne sie kann Rippy nicht starten."
)
def _ui_pfad() -> str:
"""Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert.
## Warum die installierte Kopie VOR dem Paket kommt (28.08.2026)
Gemessen an einem Dienst, der seit einer Stunde lief: `/api/health` gab
HTTP 200, `/` gab **404**. Im Fenster stand `{"detail":"Not Found"}`.
Der Grund lag nicht im Server, sondern unter ihm. Eine
PyInstaller-Onefile-EXE entpackt sich beim Start nach `%TEMP%\\_MEIxxxxx`
und liest von dort weiter `StaticFiles` schlaegt bei JEDER Anfrage in
diesem Ordner nach. Nachgesehen, was davon uebrig war:
_MEI000074b02 31 Eintraege, 5 Ordner kein `ui`, kein `api`
_MEI000082e82 44 Eintraege, 16 Ordner vollstaendig
Der halbe Ordner gehoerte dem laufenden Dienst. Ein Temp-Verzeichnis ist
kein Ort, an dem etwas eine Woche lang liegen bleibt: Aufraeumwerkzeuge,
Storage Sense und die Aufraeumroutine anderer Onefile-Prozesse greifen
dort hinein. Und der Ausfall ist der schlimmstmoegliche die API
antwortet weiter, der Dienst gilt als gesund, nur die Oberflaeche ist
weg. Genau die Sorte stiller Fehlschlag, vor der AGENTS.md warnt.
Deshalb legt der Installer eine Kopie neben das Programm, und die hat
hier Vorrang. Sie liegt in `%LOCALAPPDATA%\\Rippy`, gehoert Rippy und
verschwindet beim Deinstallieren mit.
"""
neben_programm = os.path.join(os.path.dirname(_programmordner()), "ui")
if os.path.isdir(neben_programm):
return neben_programm
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
pfad = os.path.join(gebuendelt, "ui")
return pfad if os.path.isdir(pfad) else ""
hier = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(os.path.dirname(hier))
pfad = os.path.join(repo, "docker", "ui", "dist")
return pfad if os.path.isdir(pfad) else ""
def _programmordner() -> str:
"""Die laufende Programmdatei — im Paket die EXE, sonst der Interpreter.
Gibt eine DATEI zurueck, nicht den Ordner; der Aufrufer nimmt sich mit
`dirname` was er braucht. `sys.executable` ist unter PyInstaller die
Rippy.exe selbst, nicht das ausgepackte Python.
"""
return sys.executable
def argumente(argv=None) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="rippyd",
description="Rippy als eigenstaendiger Dienst — ohne Docker.",
)
p.add_argument("--profil", default=None,
choices=["standalone", "api", "node"],
help="Betriebsart (Vorgabe: standalone)")
p.add_argument("--config", default=None,
help="Pfad zur rippy.toml")
p.add_argument("--host", default=None)
p.add_argument("--port", type=int, default=None)
p.add_argument("--db", default=None,
help="Pfad zur SQLite-Datei (ueberschreibt die Konfiguration)")
p.add_argument("--kein-ui", action="store_true",
help="Weboberflaeche nicht ausliefern")
return p.parse_args(argv)
def flags_aus_argumenten(args) -> dict:
"""Nur das, was WIRKLICH angegeben wurde — sonst schlaegt ein
argparse-Vorgabewert die Konfigurationsdatei, und niemand versteht,
warum der Eintrag dort nicht greift."""
flags: dict = {}
if args.profil:
flags["profil"] = args.profil
server = {}
if args.host:
server["host"] = args.host
if args.port:
server["port"] = args.port
if args.kein_ui:
server["ui"] = False
if server:
flags["server"] = server
if args.db:
flags["store"] = {"treiber": "sqlite", "pfad": args.db}
return flags
def einstellungen_laden(args):
from rippy import config
datei = args.config or os.environ.get("RIPPY_CONFIG") or standard_konfigpfad()
werte = config.laden(datei if os.path.isfile(datei) else None,
flags=flags_aus_argumenten(args))
unbekannt = config.unbekannte_schluessel(
config.aus_datei(datei) if os.path.isfile(datei) else {})
return werte, datei, unbekannt
def standard_konfigpfad() -> str:
if os.name == "nt":
basis = os.environ.get("PROGRAMDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy", "rippy.toml")
return "/etc/rippy/rippy.toml"
def anwendung_bauen(werte: dict):
"""Baut die ASGI-Anwendung: API unter /api, UI unter /.
Gibt `(app, ui_pfad)` zurueck `ui_pfad` ist leer, wenn kein UI
ausgeliefert wird.
"""
sys.path.insert(0, _api_pfad())
from contextlib import asynccontextmanager
from fastapi import FastAPI
import main as rippy_api # noqa: E402
@asynccontextmanager
async def lebenszyklus(_):
"""Den Start der EINGEHAENGTEN Anwendung mit ausloesen.
Starlette reicht Lifespan-Ereignisse NICHT an `mount()`-Unteranwen-
dungen weiter. Ohne diese Zeilen laeuft `startup_event()` in main.py
nie und dann fehlt alles, was dort passiert:
* `db.init_db()` legt die Tabellen an. Ohne das antwortet /settings
mit `sqlite3.OperationalError: no such table: settings`.
* Der Ereignis-Waechter startet nicht, der SSE-Strom bleibt stumm.
* Gespeicherte Netzwerk-Ziele werden nicht wieder eingehaengt.
Am 28.08.2026 beim ersten Standalone-Start genau so gemessen: HTTP 200
auf /api/health (die Route braucht keine Datenbank), HTTP 500 auf
/api/jobs und /api/settings, und der Ereignis-Strom lieferte in sechs
Sekunden nichts.
"""
async with rippy_api.app.router.lifespan_context(rippy_api.app):
yield
aussen = FastAPI(title="Rippy", version="2.0", lifespan=lebenszyklus)
aussen.mount("/api", rippy_api.app)
ui = _ui_pfad() if werte.get("server", {}).get("ui", True) else ""
if ui:
from fastapi.staticfiles import StaticFiles
# html=True laesst StaticFiles bei unbekannten Pfaden index.html
# ausliefern — noetig, weil das UI seine Ansichten selbst verwaltet
# (im Docker-Betrieb macht das `try_files $uri /index.html`).
aussen.mount("/", StaticFiles(directory=ui, html=True), name="ui")
return aussen, ui
def starten(argv=None) -> int:
args = argumente(argv)
werte, konfigdatei, unbekannt = einstellungen_laden(args)
from rippy import config, drives, store
# ⚠️ VOR dem Import der API: main.py legt beim Start Tabellen an, und der
# Store muss bis dahin auf der richtigen Datenbank stehen. Andersherum
# laege die SQLite-Datei am Vorgabeort und die Konfiguration waere
# wirkungslos — ohne dass irgendwo etwas stuende.
url = config.datenbank_url(werte)
store.verbinden(url)
os.environ.setdefault("DATABASE_URL", url)
print("Rippy startet")
print(f" Profil {werte.get('profil')}")
print(f" Konfiguration {konfigdatei if os.path.isfile(konfigdatei) else '(keine — Vorgaben)'}")
print(f" Datenbank {url}")
print(f" Laufwerke {drives.treiber().__name__}")
for schluessel in unbekannt:
# Ein stillschweigend ignorierter Schluessel ist dieselbe Fehlerklasse
# wie ein verschluckter Fehler: sieht gesund aus, wirkt aber nicht.
print(f" ⚠️ Unbekannter Eintrag in der Konfiguration: {schluessel}")
app, ui = anwendung_bauen(werte)
print(f" Oberflaeche {ui or '(nicht mitgeliefert)'}")
# Im Standalone-Betrieb arbeitet DIESER Prozess die Auftraege ab —
# es gibt keinen Worker daneben. Ohne diesen Schritt koennte Rippy alles
# anzeigen und nichts tun: Ein Rip wuerde eingereiht und laege dann fuer
# immer da, weil niemand ihn holt.
if werte.get("profil") == "standalone" and werte["queue"]["treiber"] == "lokal":
from rippy import standalone
from rippy.bus.memory import bus as ereignis_bus
laeufer = standalone.starten(
knoten=werte["queue"].get("knoten") or None,
bus=ereignis_bus, store=store,
slots=werte["queue"].get("rip_slots", 1),
)
print(f" Laeufer {len(laeufer)} (Auftraege werden hier abgearbeitet)")
else:
print(" Laeufer keiner — Auftraege gehen an Celery")
host = werte["server"]["host"]
port = werte["server"]["port"]
print(f" Adresse http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}")
print()
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
return 0
def main() -> int:
try:
return starten()
except KeyboardInterrupt:
print("\nRippy beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -2
View File
@@ -41,8 +41,12 @@ def treiber(plattform: str = None):
"""
plattform = plattform if plattform is not None else sys.platform
if plattform.startswith("win"):
from rippy.drives import windows
return windows
# Der Windows-Treiber ist mit der Standalone-App gegangen (Rippy v5,
# Entscheid 7). Der Platzhalter hält Importe und Test-Sammlung auf
# Windows-Entwicklungsrechnern am Leben; jede BENUTZUNG wirft mit
# Klartext — siehe kein_windows.py.
from rippy.drives import kein_windows
return kein_windows
from rippy.drives import linux
return linux
+34
View File
@@ -0,0 +1,34 @@
"""Der Platzhalter, der unter Windows die Wahrheit sagt.
Der echte Windows-Laufwerkstreiber ist mit der Standalone-App gegangen
Rippy für Windows ist seit dem 30.08.2026 ein eigenes Produkt (Rippy v5,
KONZEPT-WINDOWS.md Entscheid 7). Diese Codebasis ist die Docker/Linux-
Fassung; der Remote-Encode-Worker auf Windows rippt nicht (Entscheid 8).
Warum ein Modul statt eines Fehlers in `treiber()`: Entwicklungs- und
Test-Läufe auf einem Windows-PC müssen IMPORTIERBAR bleiben (die Ampel-
Praxis misst lokal, bevor sie pusht). Erst die BENUTZUNG eines Laufwerks
ist hier ein Fehler und zwar einer mit Ansage.
"""
MELDUNG = (
"Kein Windows-Laufwerkstreiber in der Docker-Fassung — fuer Windows "
"gibt es Rippy v5 (Branch worktree-windows-electron)."
)
def _fehlt(*_args, **_kwargs):
raise RuntimeError(MELDUNG)
# Dieselben Namen wie linux.py — test_treiberwahl wacht darüber.
list_optical_devices = _fehlt
device_info = _fehlt
drive_status = _fehlt
disc_status = _fehlt
detect_disc_type = _fehlt
disc_size_bytes = _fehlt
eject = _fehlt
auswerfen_versuchen = _fehlt
auswerfen_mit_grund = _fehlt
verriegeln = _fehlt
-84
View File
@@ -1,84 +0,0 @@
"""Die Win32-Steuercodes gegen die dokumentierten Zahlen rechnen.
## Warum dieser Test wichtig ist
Ein falscher IOCTL-Code liefert kein unbekannter Befehl". Windows antwortet
mit `ERROR_INVALID_FUNCTION` und das liest sich wie dieses Laufwerk kann
das nicht". Man sucht dann tagelang am Gerät statt an einer Zahl.
Die Werte rechts sind die in `winioctl.h` / `ntddcdrm.h` und auf Microsoft
Learn dokumentierten Konstanten. Links steht, was `ctl_code()` aus den
Bestandteilen bildet. Stimmen beide überein, ist die Herleitung richtig.
Läuft auf JEDER Plattform: Hier wird nur gerechnet, nichts aufgerufen. Der
Zahlendreher fällt damit schon auf dem Entwicklungsrechner auf und nicht erst
an einem echten Laufwerk.
"""
import pytest
from rippy.drives import win_ioctl as w
def test_ctl_code_bildet_das_makro_aus_winioctl_h_ab():
"""CTL_CODE(DeviceType, Function, Method, Access) =
(DeviceType << 16) | (Access << 14) | (Function << 2) | Method"""
assert w.ctl_code(0, 0, 0, 0) == 0
assert w.ctl_code(1, 0, 0, 0) == 1 << 16
assert w.ctl_code(0, 1, 0, 0) == 1 << 2
assert w.ctl_code(0, 0, 3, 0) == 3
assert w.ctl_code(0, 0, 0, 1) == 1 << 14
@pytest.mark.parametrize("name,erwartet", [
# Die Zahlen aus der Microsoft-Dokumentation bzw. den SDK-Headern.
("IOCTL_STORAGE_CHECK_VERIFY2", 0x002D0800),
("IOCTL_STORAGE_MEDIA_REMOVAL", 0x002D4804),
("IOCTL_STORAGE_EJECT_MEDIA", 0x002D4808),
("IOCTL_STORAGE_LOAD_MEDIA", 0x002D480C),
("IOCTL_CDROM_DISK_TYPE", 0x00020040),
("IOCTL_DISK_GET_LENGTH_INFO", 0x0007405C),
])
def test_steuercode_stimmt_mit_der_dokumentation_ueberein(name, erwartet):
assert getattr(w, name) == erwartet, (
f"{name} ist {getattr(w, name):#010x}, dokumentiert ist {erwartet:#010x}. "
"Ein falscher Steuercode meldet ERROR_INVALID_FUNCTION — das sieht aus "
"wie 'das Laufwerk kann das nicht' und schickt die Fehlersuche in die "
"falsche Richtung."
)
# ── Gerätepfad ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("eingabe", ["D", "D:", "d:", "D:\\", "d:/", " D: "])
def test_geraetepfad_ist_immer_dieselbe_form(eingabe):
assert w.geraetepfad(eingabe) == r"\\.\D:"
def test_geraetepfad_haengt_keinen_backslash_an():
"""Der entscheidende Punkt: MIT abschliessendem Backslash oeffnet
CreateFileW das WURZELVERZEICHNIS des Dateisystems statt des Geraets.
Die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION."""
assert not w.geraetepfad("E:\\").endswith("\\")
assert w.geraetepfad("E:\\") == r"\\.\E:"
def test_geraetepfad_ohne_angabe_wirft():
for leer in ("", " ", ":", "\\"):
with pytest.raises(ValueError):
w.geraetepfad(leer)
def test_drive_cdrom_ist_fuenf():
"""GetDriveTypeW meldet optische Laufwerke als 5. Ein Zahlendreher hier
hiesse: Rippy findet entweder gar kein Laufwerk oder haelt die
Systemplatte fuer eines."""
assert w.DRIVE_CDROM == 5
assert w.DRIVE_FIXED == 3
assert w.DRIVE_REMOVABLE == 2
def test_error_not_ready_ist_einundzwanzig():
"""Das ist die Antwort von CHECK_VERIFY2 bei leerem Laufwerk — und damit
die Windows-Entsprechung von CDS_NO_DISC. Sie ENTSCHEIDET, ob ein
Auswurf als geglueckt gilt."""
assert w.ERROR_NOT_READY == 21
-264
View File
@@ -1,264 +0,0 @@
"""Der Windows-Treiber gegen ein nachgebautes Laufwerk.
## Was diese Tests beweisen — und was nicht
**Sie beweisen den ABLAUF:** dass erst entriegelt und dann ausgeworfen wird,
dass danach nachgesehen statt geglaubt wird, dass ein verriegeltes Laufwerk als
Fehlschlag gilt, dass ein unzugängliches Laufwerk unbekannt" ergibt und nicht
leer", und dass beide Verträge (werfend / nicht werfend) stimmen.
**Sie beweisen NICHT, dass echte Hardware sich so verhält.** Dafür braucht es
ein angestecktes Laufwerk und eine Disc. Bis das gemessen ist, gilt der Treiber
als gebaut, nicht als bewiesen.
Das nachgebaute Laufwerk unten bildet genau das Verhalten ab, das am
26.07.2026 am echten BU40N unter Linux GEMESSEN wurde: Der Auswurf-Befehl wird
auch bei verriegelter Tür mit Erfolg quittiert und tut nichts. Es gibt keinen
Anlass anzunehmen, dass Windows das anders macht beide Wege enden im selben
SCSI-Befehl an dasselbe Gerät.
"""
import pytest
from rippy.drives import win_ioctl as w
from rippy.drives import windows
class FakeLaufwerk:
"""Ein Laufwerk aus Papier — verhält sich wie das gemessene echte.
`verriegelt=True` bildet den Zustand nach einem MakeMKV-Rip ab: Die Tür ist
zu, und der Auswurf-Befehl wird trotzdem mit Erfolg quittiert.
"""
def __init__(self, medium=True, verriegelt=False, kennt_lockdoor=True,
buchstaben=("C", "D"), typen=None, disk_flags=None,
groesse=None, oeffnen_fehler=None):
self.medium = medium
self.verriegelt = verriegelt
self.kennt_lockdoor = kennt_lockdoor
self._buchstaben = list(buchstaben)
self._typen = typen or {"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM}
self.disk_flags = disk_flags
self.groesse = groesse
self.oeffnen_fehler = oeffnen_fehler
self.aufrufe = [] # Reihenfolge der Steuercodes
self.offen = 0
# -- Laufwerks-Auflistung
def laufwerksbuchstaben(self):
return list(self._buchstaben)
def laufwerkstyp(self, buchstabe):
return self._typen.get(buchstabe, w.DRIVE_UNKNOWN)
# -- Gerät
def oeffnen(self, pfad):
if self.oeffnen_fehler is not None:
raise self.oeffnen_fehler
self.offen += 1
return 4242
def schliessen(self, handle):
self.offen -= 1
def steuern(self, handle, code, eingabe=b"", ausgabe_bytes=0):
self.aufrufe.append(code)
if code == w.IOCTL_STORAGE_MEDIA_REMOVAL:
if not self.kennt_lockdoor:
raise windows.Win32Fehler("MEDIA_REMOVAL", w.ERROR_INVALID_FUNCTION)
self.verriegelt = eingabe[:1] == b"\x01"
return b""
if code == w.IOCTL_STORAGE_EJECT_MEDIA:
# ⚠️ Der gemessene Punkt: Erfolg wird IMMER gemeldet. Gewirkt hat
# er nur, wenn die Tuer nicht verriegelt ist.
if not self.verriegelt:
self.medium = False
return b""
if code == w.IOCTL_STORAGE_CHECK_VERIFY2:
if not self.medium:
raise windows.Win32Fehler("CHECK_VERIFY2", w.ERROR_NOT_READY)
return b""
if code == w.IOCTL_CDROM_DISK_TYPE:
if self.disk_flags is None:
raise windows.Win32Fehler("DISK_TYPE", w.ERROR_INVALID_FUNCTION)
return int(self.disk_flags).to_bytes(4, "little")
if code == w.IOCTL_DISK_GET_LENGTH_INFO:
if self.groesse is None:
raise windows.Win32Fehler("GET_LENGTH_INFO", w.ERROR_INVALID_FUNCTION)
return int(self.groesse).to_bytes(8, "little")
raise windows.Win32Fehler(f"unerwarteter Code {code:#x}", w.ERROR_INVALID_FUNCTION)
# ── Laufwerke finden ────────────────────────────────────────────────────
def test_nur_optische_laufwerke_werden_gelistet():
"""Waeren es alle, stuende die Systemplatte als „Laufwerk" im UI."""
api = FakeLaufwerk(buchstaben=("C", "D", "E", "Z"), typen={
"C": w.DRIVE_FIXED, "D": w.DRIVE_CDROM,
"E": w.DRIVE_CDROM, "Z": w.DRIVE_REMOTE,
})
assert windows.list_optical_devices(api) == [r"\\.\D:", r"\\.\E:"]
def test_ohne_optisches_laufwerk_ist_die_liste_leer():
api = FakeLaufwerk(buchstaben=("C",), typen={"C": w.DRIVE_FIXED})
assert windows.list_optical_devices(api) == []
# ── Auswurf: die Reihenfolge ist der Punkt ──────────────────────────────
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
api = FakeLaufwerk(medium=True, verriegelt=True)
assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is True
assert api.medium is False
# Entriegeln VOR Auswerfen — sonst quittiert das Laufwerk Erfolg und tut nichts.
assert api.aufrufe[0] == w.IOCTL_STORAGE_MEDIA_REMOVAL
assert api.aufrufe[1] == w.IOCTL_STORAGE_EJECT_MEDIA
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
"""Der teure Fehler aus v1, hier vorweggenommen: Ein Laufwerk, das das
Entriegeln nicht kennt und verriegelt bleibt, quittiert den Auswurf
trotzdem mit Erfolg. Wer dem Rueckgabewert glaubt, schreibt Disc
ausgeworfen" ins Log, waehrend die Schublade zu bleibt."""
api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False)
assert windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None) is False
assert api.medium is True # nie aufgegangen
def test_auswurf_ohne_laufwerk_wirft_nicht():
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_FILE_NOT_FOUND))
assert windows.auswerfen_versuchen(r"\\.\Z:", api=api, warten=lambda s: None) is False
def test_geraet_wird_immer_wieder_geschlossen():
"""Ein offenes Handle auf einem optischen Laufwerk blockiert den Auswurf
fuer JEDEN anderen auch fuer den Explorer."""
api = FakeLaufwerk(medium=True)
windows.auswerfen_versuchen(r"\\.\D:", api=api, warten=lambda s: None)
assert api.offen == 0
# ── Der andere Vertrag: eject() muss werfen ─────────────────────────────
def test_eject_ist_still_wenn_die_disc_rauskommt():
api = FakeLaufwerk(medium=True, verriegelt=True)
assert windows.eject(r"\\.\D:", api=api) is None
def test_eject_wirft_wenn_die_disc_drin_bleibt():
api = FakeLaufwerk(medium=True, verriegelt=True, kennt_lockdoor=False)
with pytest.raises(OSError, match="noch"):
windows.eject(r"\\.\D:", api=api)
def test_eject_reicht_den_urspruenglichen_fehler_weiter():
"""„Zugriff verweigert" und „Gerät nicht gefunden" sind zwei ganz
verschiedene Probleme. Wer sie hinter einer eigenen Meldung versteckt,
nimmt dem Nutzer die einzige brauchbare Auskunft."""
original = windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED)
api = FakeLaufwerk(oeffnen_fehler=original)
with pytest.raises(OSError) as fehler:
windows.eject(r"\\.\D:", api=api)
assert fehler.value is original
# `code` statt `winerror`: letzteres gibt es nur unter Windows, und der
# Treiber wird auch anderswo getestet.
assert fehler.value.code == w.ERROR_ACCESS_DENIED
# ── Zustand und Typ ─────────────────────────────────────────────────────
def test_leeres_laufwerk_ist_leer_und_nicht_unbekannt():
api = FakeLaufwerk(medium=False)
assert windows.detect_disc_type(r"\\.\D:", api=api) == "no_disc"
assert windows.device_info(r"\\.\D:", api=api)["status"] == "empty"
def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer():
"""DIE Regel des Ports. Ein Laufwerk, das gerade belegt ist, hat nicht
keine Disc" — man weiss es nur nicht. In v1 hat genau diese Vermischung
Listen im UI geleert."""
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler("CreateFileW", w.ERROR_ACCESS_DENIED))
assert windows.detect_disc_type(r"\\.\D:", api=api) == "unknown"
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
def test_unzugaengliches_laufwerk_sagt_auch_WARUM():
"""Befund 30.08.2026: „jetzt erkennt rippy die disk garnicht mehr (im
log steht zwar erkannt, aber ein start des rips ist nicht moeglich)".
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
Medien-Abfragen mehr (Win32-Fehler 1); die Geraete-Auskunft kam weiter
durch. Im UI stand eine vollstaendige Laufwerkskarte mit Modell und
Seriennummer und unknown" ohne ein Wort dazu. Rippy kannte den
Grund und behielt ihn fuer sich.
"""
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler(
"CreateFileW", w.ERROR_INVALID_FUNCTION))
info = windows.device_info(r"\\.\D:", api=api)
assert info["status"] == "unknown"
assert "auswerfen" in info["grund"]
def test_grund_bleibt_leer_solange_alles_geht():
"""Ein Feld, das immer gefuellt ist, sagt nichts mehr."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK,
groesse=25 * 1024**3)
assert windows.device_info(r"\\.\D:", api=api)["grund"] == ""
def test_unbekannte_fehlernummer_wird_GENANNT():
"""Eine Nummer, nach der man suchen kann, ist mehr als ein leerer Satz."""
text = windows.zugriffs_grund(windows.Win32Fehler("CreateFileW", 4711))
assert "4711" in text
assert windows.zugriffs_grund(OSError("ohne Nummer"))
@pytest.mark.parametrize("flags,groesse,erwartet", [
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
(w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"),
(w.CDROM_DISK_DATA_TRACK, 25 * 1024**3, "bluray"),
(w.CDROM_DISK_DATA_TRACK, 50 * 1000**3, "bluray"), # BD-50
(w.CDROM_DISK_DATA_TRACK, 66 * 1000**3, "uhd"), # BD-66
(w.CDROM_DISK_DATA_TRACK, 100 * 1000**3, "uhd"),
])
def test_disc_typ_kommt_aus_derselben_zuordnung_wie_unter_linux(flags, groesse, erwartet):
"""Die Einordnung liegt in cdrom.classify() — EINE Funktion fuer beide
Plattformen, und die ist eigenstaendig getestet. Hier wird nur geprueft,
dass Windows die beiden Eingangswerte richtig beschafft."""
api = FakeLaufwerk(medium=True, disk_flags=flags, groesse=groesse)
assert windows.detect_disc_type(r"\\.\D:", api=api) == erwartet
def test_verriegeln_schickt_das_richtige_byte():
api = FakeLaufwerk(medium=True)
windows.verriegeln(r"\\.\D:", True, api=api)
assert api.verriegelt is True
windows.verriegeln(r"\\.\D:", False, api=api)
assert api.verriegelt is False
def test_device_info_hat_dieselben_felder_wie_unter_linux():
"""Das UI unterscheidet nicht nach Plattform — fehlt ein Feld, bleibt die
Karte im Dashboard leer."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
eintrag = windows.device_info(r"\\.\D:", api=api)
assert set(eintrag) == {"id", "name", "type", "path", "status", "model",
"serial", "grund"}
assert eintrag["id"] == "D"
assert eintrag["status"] == "ready"
assert eintrag["type"] == "bluray"
def test_treiber_verweigert_sich_ausserhalb_von_windows():
"""Sonst waere der erste Fehler ein kryptischer ctypes-Absturz statt
einer Ansage."""
import sys as _sys
if _sys.platform.startswith("win"):
pytest.skip("laeuft nur, wenn NICHT unter Windows getestet wird")
with pytest.raises(RuntimeError, match="nur unter Windows"):
windows.Win32()
-177
View File
@@ -1,177 +0,0 @@
"""Die Win32-Steuercodes für optische Laufwerke — hergeleitet, nicht abgeschrieben.
## Warum sie hier ausgerechnet werden
`AGENTS.md` Regel D: *Externe Schnittstellen NIE aus dem Kopf. Vor Nutzung
fremder CLI-Flags oder Bibliotheks-APIs: --help/Doku prüfen und die Fundstelle
im Commit nennen."*
Zahlen wie `0x2D4808` sind genau der Fall, in dem sich ein Zahlendreher
lautlos versteckt: Ein falscher Code liefert kein unbekannter Befehl", sondern
`ERROR_INVALID_FUNCTION` und das sieht aus wie das Laufwerk kann das nicht".
Man sucht dann am Gerät statt an der Zahl.
Deshalb steht hier das Makro aus `winioctl.h` als Funktion, und jede Konstante
wird daraus gebildet. Wer nachrechnen will, sieht die Bestandteile.
#define CTL_CODE(DeviceType, Function, Method, Access) ( \\
((DeviceType) << 16) | ((Access) << 14) | \\
((Function) << 2) | (Method) \\
)
Fundstellen (Windows SDK / Microsoft Learn):
* CTL_CODE, FILE_DEVICE_*, METHOD_*, FILE_*_ACCESS winioctl.h
* IOCTL_STORAGE_CHECK_VERIFY2 learn.microsoft.com/windows/win32/api/
winioctl/ni-winioctl-ioctl_storage_check_verify2
* IOCTL_STORAGE_MEDIA_REMOVAL / _EJECT_MEDIA / _LOAD_MEDIA ebenda
* IOCTL_CDROM_DISK_TYPE, CDROM_DISK_AUDIO_TRACK/DATA_TRACK ntddcdrm.h
* IOCTL_DISK_GET_LENGTH_INFO winioctl.h (GET_LENGTH_INFORMATION)
## Gegenprobe
`test_win_ioctl.py` rechnet die hier gebildeten Werte gegen die Zahlen, die in
der Microsoft-Dokumentation stehen. Stimmt eine nicht, faellt das beim Testen
auf auf JEDER Plattform, auch ohne Windows und ohne Laufwerk.
"""
# ── Bausteine aus winioctl.h ────────────────────────────────────────────
FILE_DEVICE_CD_ROM = 0x00000002
FILE_DEVICE_DISK = 0x00000007
FILE_DEVICE_MASS_STORAGE = 0x0000002D # = IOCTL_STORAGE_BASE
METHOD_BUFFERED = 0
METHOD_IN_DIRECT = 1
METHOD_OUT_DIRECT = 2
METHOD_NEITHER = 3
FILE_ANY_ACCESS = 0
FILE_READ_ACCESS = 0x0001
FILE_WRITE_ACCESS = 0x0002
def ctl_code(geraetetyp: int, funktion: int, methode: int, zugriff: int) -> int:
"""Das CTL_CODE-Makro aus winioctl.h, eins zu eins."""
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode)
# ── Speicher (IOCTL_STORAGE_*) ──────────────────────────────────────────
# „Ist ein Medium eingelegt?" — die Windows-Entsprechung von
# CDROM_DRIVE_STATUS. Antwortet mit ERROR_NOT_READY (21), wenn nichts drin ist.
IOCTL_STORAGE_CHECK_VERIFY2 = ctl_code(
FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
# Tür verriegeln/entriegeln — die Entsprechung von CDROM_LOCKDOOR.
# Erwartet eine PREVENT_MEDIA_REMOVAL-Struktur (ein BOOLEAN).
IOCTL_STORAGE_MEDIA_REMOVAL = ctl_code(
FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
# Auswerfen — die Entsprechung von CDROMEJECT.
IOCTL_STORAGE_EJECT_MEDIA = ctl_code(
FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
# Schublade einziehen — die Entsprechung von CDROMCLOSETRAY.
IOCTL_STORAGE_LOAD_MEDIA = ctl_code(
FILE_DEVICE_MASS_STORAGE, 0x0203, METHOD_BUFFERED, FILE_READ_ACCESS)
# Hersteller, Modell und Seriennummer des LAUFWERKS (nicht der Disc).
#
# Die Entsprechung von `/sys/class/block/<name>/device/{vendor,model,wwid}`
# unter Linux. Ohne diesen Aufruf stand im UI „Modell: unbekannt,
# Seriennummer: " — der Commander hat es am 28.08.2026 gemeldet.
#
# Erwartet eine STORAGE_PROPERTY_QUERY (PropertyId=0 StorageDeviceProperty,
# QueryType=0 PropertyStandardQuery) und antwortet mit einem
# STORAGE_DEVICE_DESCRIPTOR: feste Kopfdaten mit OFFSETS auf die Zeichenketten,
# die dahinter im selben Puffer liegen.
IOCTL_STORAGE_QUERY_PROPERTY = ctl_code(
FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
STORAGE_DEVICE_PROPERTY = 0
PROPERTY_STANDARD_QUERY = 0
# Feldpositionen im STORAGE_DEVICE_DESCRIPTOR (winioctl.h), in Bytes.
SDD_VENDOR_ID_OFFSET = 12
SDD_PRODUCT_ID_OFFSET = 16
SDD_PRODUCT_REVISION_OFFSET = 20
SDD_SERIAL_NUMBER_OFFSET = 24
# ── CD-ROM (IOCTL_CDROM_*) ──────────────────────────────────────────────
# Audio- oder Datenspur? Grob, aber genau das, was CDROM_DISC_STATUS auch
# liefert — mehr braucht die Einordnung nicht (die Feinunterscheidung
# DVD/BD/UHD kommt über die Größe).
IOCTL_CDROM_DISK_TYPE = ctl_code(
FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
CDROM_DISK_AUDIO_TRACK = 0x00000001
CDROM_DISK_DATA_TRACK = 0x00000002
# ── Größe des Mediums ───────────────────────────────────────────────────
# Die Entsprechung von BLKGETSIZE64. Liefert GET_LENGTH_INFORMATION
# (ein LARGE_INTEGER, also 8 Byte).
IOCTL_DISK_GET_LENGTH_INFO = ctl_code(
FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
# ── Rückmeldungen von GetLastError() ────────────────────────────────────
ERROR_NOT_READY = 21 # kein Medium eingelegt
ERROR_ACCESS_DENIED = 5
ERROR_FILE_NOT_FOUND = 2
ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht
ERROR_NOT_SUPPORTED = 50 # Geraet lehnt den Steuercode gerade ab
ERROR_MEDIA_CHANGED = 1110
ERROR_NO_MEDIA_IN_DRIVE = 1112
# ── GetDriveTypeW ───────────────────────────────────────────────────────
DRIVE_UNKNOWN = 0
DRIVE_NO_ROOT_DIR = 1
DRIVE_REMOVABLE = 2
DRIVE_FIXED = 3
DRIVE_REMOTE = 4
DRIVE_CDROM = 5
DRIVE_RAMDISK = 6
# ── CreateFileW ─────────────────────────────────────────────────────────
GENERIC_READ = 0x80000000
FILE_SHARE_READ = 0x00000001
FILE_SHARE_WRITE = 0x00000002
OPEN_EXISTING = 3
INVALID_HANDLE_VALUE = -1
def geraetepfad(buchstabe: str) -> str:
r"""„D:" oder „D" oder „D:\" -> r"\\.\D:"
Der Win32-Gerätepfad hat KEINEN abschließenden Backslash. Mit einem
öffnet `CreateFileW` das Wurzelverzeichnis des Dateisystems statt des
Geräts die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION, was wie
das Laufwerk kann das nicht" aussieht.
"""
kern = (buchstabe or "").strip().rstrip("\\/").rstrip(":")
if not kern:
raise ValueError("Kein Laufwerksbuchstabe angegeben")
return "\\\\.\\" + kern[0].upper() + ":"
# ── Audio-CD lesen (29.08.2026) ─────────────────────────────────────────
#
# Beide aus ntddcdrm.h. Eine Audio-CD hat kein Dateisystem: Windows zeigt
# `Track01.cda`, das sind aber 44-Byte-Platzhalter. Die Musik liegt roh in
# 2352-Byte-Sektoren und muss sektorweise gelesen werden.
#: Inhaltsverzeichnis der CD (CDROM_TOC: je Track Startadresse als MSF).
IOCTL_CDROM_READ_TOC = ctl_code(
FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
#: Rohe Sektoren lesen. Eingabe ist RAW_READ_INFO:
#:
#: LARGE_INTEGER DiskOffset; Sektor * 2048 (NICHT * 2352 — dokumentiert)
#: ULONG SectorCount;
#: TRACK_MODE_TYPE TrackMode; CDDA = 2
#:
#: Der Ausgabepuffer muss SectorCount * 2352 gross sein.
#: learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info
IOCTL_CDROM_RAW_READ = ctl_code(
FILE_DEVICE_CD_ROM, 0x000F, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
#: Groesse von CDROM_TOC: 4 Byte Kopf + 100 Eintraege a 8 Byte.
CDROM_TOC_BYTES = 4 + 100 * 8
-684
View File
@@ -1,684 +0,0 @@
r"""Laufwerks-Treiber für Windows. Erfüllt `rippy.ports.Drives`.
Dieselben Verträge wie `linux.py`, nur mit Win32 statt ioctl:
auswerfen_versuchen(geraet) -> bool gibt False zurück, wirft nie
eject(geraet) -> None wirft OSError
## Warum ctypes und nicht pywin32
pywin32 wäre eine zusätzliche Abhängigkeit von rund 20 MB, die in ein
PyInstaller-Paket mit hinein muss und bei jedem Python-Wechsel neu passen
will. Alles, was hier gebraucht wird, sind fünf Funktionen aus `kernel32.dll`
die spricht `ctypes` direkt an, ohne irgendetwas mitzuliefern.
## Die zwei Regeln des Ports gelten hier genauso
**1. Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** Unter Linux wurde am
26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG
quittiert und nichts tut MakeMKV verriegelt die Tür während des Rips und
entriegelt sie nicht wieder. Es gibt keinen Grund anzunehmen, dass Windows das
anders handhabt: `IOCTL_STORAGE_EJECT_MEDIA` meldet ebenfalls nur, dass der
Befehl angenommen wurde. Deshalb wird auch hier nachgesehen der Beweis ist
`IOCTL_STORAGE_CHECK_VERIFY2`, das bei leerem Laufwerk `ERROR_NOT_READY`
liefert.
**2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer.** Wenn sich das Gerät
nicht öffnen lässt oder ein Steuercode scheitert, ist die Antwort ich weiß es
nicht" — nicht „da ist keine Disc". Genau diese Vermischung hat in v1 die
Listen im UI geleert.
## Was hier geprüft ist und was nicht
**Ohne Laufwerk geprüft:** die Steuercodes (`test_win_ioctl.py` rechnet sie
gegen die Dokumentation), der Ablauf des Auswurfs, die Fehlerunterscheidung,
die Typ-Zuordnung alles über eine eingespritzte Win32-Nachbildung
(`test_windows.py`). Diese Tests laufen auf jeder Plattform.
**An echter Hardware gemessen (28.08.2026, LG BU40N extern am Windows-PC,
Laufwerk G:, LEER):**
GetDriveTypeW("G:\") -> 5 (DRIVE_CDROM)
list_optical_devices() -> ['\.\G:']
CreateFileW(r"\.\G:") -> Handle 380
CHECK_VERIFY2 bei leerem Laufwerk -> ERROR_NOT_READY (21)
drive_status() -> 1 (CDS_NO_DISC)
detect_disc_type() -> 'no_disc'
device_info()['status'] -> 'empty'
MEDIA_REMOVAL (entriegeln) -> Erfolg, 0,1 ms
EJECT_MEDIA -> Erfolg, 664,8 ms
Die 664,8 ms sind der eigentliche Beleg, dass etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurück (siehe die Fehler oben).
Eine mechanische Bewegung dauert.
** DIESES LAUFWERK KANN DIE SCHUBLADE NICHT SELBST EINZIEHEN.**
`IOCTL_STORAGE_LOAD_MEDIA` antwortet mit `ERROR_INVALID_FUNCTION` (1) bei
flachen und extern angeschlossenen Laufwerken ist das die Regel, nicht die
Ausnahme. Deshalb gibt es hier bewusst KEINE `einziehen()`-Funktion: Ein Knopf
Schublade schließen", der auf der Hälfte aller Laufwerke stillschweigend
nichts tut, wäre genau die Sorte Placebo, die in Etappe 19 aufgeräumt wurde.
Die Konstante steht in `win_ioctl.py` wer sie je benutzt, muss den Fehlschlag
sichtbar machen.
**MIT EINGELEGTER DISC gemessen (28.08.2026, dasselbe Laufwerk, BD-50):**
drive_status() -> 4 (CDS_DISC_OK), sofort
IOCTL_CDROM_DISK_TYPE -> Bytes 02 00 00 00
= DATA_TRACK (0x2)
IOCTL_DISK_GET_LENGTH_INFO -> 48 149 364 736 Bytes
= 44,84 GiB
disc_status() -> 101 (CDS_DATA_1)
detect_disc_type() -> 'bluray'
device_info()['status'] -> 'ready'
device_info()['type'] -> 'bluray'
auswerfen_versuchen() -> True, 2,41 s
drive_status() davor / danach -> 4 / 1
**Die letzte Zeile ist der eigentliche Beweis.** Der Auswurf wurde nicht am
Rückgabewert des Steuercodes festgemacht, sondern am ZUSTAND davor und
danach: Die Disc war drin (4) und ist danach draußen (1). Genau das, was in
v1 monatelang gefehlt hat dort quittierte das ioctl Erfolg, die Schublade
blieb zu, und im Log stand Disc ausgeworfen".
Die 44,84 GiB prüfen nebenbei die Schwelle mit: Eine BD-50 liegt knapp unter
den 55 GiB, ab denen `cdrom.classify()` auf `uhd` geht und wird korrekt als
`bluray` eingeordnet.
**Damit gilt die Hardware-Schicht dieses Treibers als BEWIESEN**, nicht mehr
nur als gebaut. Offen bleibt allein die Ereignis-Erkennung
(`WM_DEVICECHANGE`) die wird erst gebaut.
"""
import sys
from rippy.drives import win_ioctl as w
from rippy.drives.cdrom import (
CDS_AUDIO,
CDS_DATA_1,
CDS_DISC_OK,
CDS_NO_DISC,
classify,
)
# Wie lange nach dem Auswurf-Befehl auf die Schublade gewartet wird.
# Gleicher Wert wie unter Linux — ein Laufwerk braucht ein bis zwei Sekunden.
AUSWURF_WARTEN_SEKUNDEN = 5
# Ergebnisse von auswerfen_mit_grund — dieselben wie im Linux-Treiber, damit
# aufrufender Code nicht wissen muss, auf welcher Plattform er läuft.
AUSWURF_OK = "ok"
AUSWURF_KEIN_ZUGRIFF = "kein-zugriff"
AUSWURF_ABGELEHNT = "abgelehnt"
AUSWURF_BLEIBT_DRIN = "bleibt-drin"
class Win32Fehler(OSError):
"""Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError().
Die Zuweisung MUSS nach `super().__init__()` stehen. Nachgemessen:
Ein zweiargumentiges `OSError.__init__(code, text)` setzt `winerror`
wieder auf `None` ein davor gesetzter Wert ist danach fort. Genau
daran sind beim Bau dieser Datei vier Tests gescheitert: `_medium_da`
erkannte `ERROR_NOT_READY` nicht mehr, hielt jedes leere Laufwerk für
einen echten Fehler und meldete unbekannt" statt „leer".
`code` steht zusätzlich als eigener Name bereit `winerror` gibt es nur
unter Windows, und dieser Treiber wird auch anderswo getestet.
"""
def __init__(self, was: str, code: int):
super().__init__(code, f"{was} scheiterte (Win32-Fehler {code})")
self.code = code
self.winerror = code
class Win32:
"""Die echten kernel32-Aufrufe.
In Tests wird stattdessen eine Nachbildung eingesetzt deshalb steckt
hier NUR die Anbindung und keine Entscheidung. Was der Treiber tut, steht
in den Funktionen darunter und ist damit ohne Windows prüfbar.
"""
def __init__(self):
if not sys.platform.startswith("win"):
raise RuntimeError(
"Der Windows-Treiber laeuft nur unter Windows. Auf Linux ist "
"rippy.drives.linux zustaendig."
)
import ctypes
from ctypes import wintypes
self._ctypes = ctypes
self._wintypes = wintypes
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_typen_erklaeren(ctypes, wintypes, self._k32)
# ── Laufwerke finden ────────────────────────────────────────────────
def laufwerksbuchstaben(self) -> list:
maske = self._k32.GetLogicalDrives()
return [chr(ord("A") + i) for i in range(26) if maske & (1 << i)]
def laufwerkstyp(self, buchstabe: str) -> int:
return self._k32.GetDriveTypeW(f"{buchstabe}:\\")
# ── Gerät öffnen ────────────────────────────────────────────────────
def oeffnen(self, geraetepfad: str):
handle = self._k32.CreateFileW(
geraetepfad,
w.GENERIC_READ,
w.FILE_SHARE_READ | w.FILE_SHARE_WRITE,
None,
w.OPEN_EXISTING,
0,
None,
)
# Mit erklaertem `restype` ist der Fehlerwert nicht mehr -1, sondern
# 0xFFFFFFFFFFFFFFFF — siehe `_typen_erklaeren`. Beides pruefen: die
# nackte -1 bliebe sonst als tote Bedingung stehen und taeuschte
# Absicherung vor.
ungueltig = self._ctypes.c_void_p(-1).value
if handle in (None, 0, ungueltig, w.INVALID_HANDLE_VALUE):
raise Win32Fehler("CreateFileW", self._ctypes.get_last_error())
return handle
def schliessen(self, handle) -> None:
self._k32.CloseHandle(handle)
# ── Steuercode senden ───────────────────────────────────────────────
def steuern(self, handle, code: int, eingabe: bytes = b"",
ausgabe_bytes: int = 0) -> bytes:
"""DeviceIoControl. Wirft Win32Fehler, gibt sonst die Antwort zurück."""
ctypes = self._ctypes
ein_puffer = ctypes.create_string_buffer(eingabe) if eingabe else None
aus_puffer = ctypes.create_string_buffer(ausgabe_bytes) if ausgabe_bytes else None
zurueck = self._wintypes.DWORD(0)
erfolg = self._k32.DeviceIoControl(
handle, code,
ein_puffer, len(eingabe),
aus_puffer, ausgabe_bytes,
ctypes.byref(zurueck), None,
)
if not erfolg:
raise Win32Fehler(f"DeviceIoControl({code:#x})", ctypes.get_last_error())
return aus_puffer.raw[:zurueck.value] if aus_puffer else b""
def _typen_erklaeren(ctypes, wintypes, k) -> None:
r"""Den kernel32-Funktionen ihre echten Typen beibringen.
## Warum das keine Formsache ist (Befund 30.08.2026)
Ohne `restype` nimmt ctypes `c_int` an 32 Bit, mit Vorzeichen. Ein
Windows-HANDLE ist auf einem 64-Bit-System aber ein Zeiger, und dasselbe
gilt fuer den Weg HINEIN: Ein Handle, das ohne `argtypes` uebergeben wird,
geht als `c_int` durch. Solange Windows kleine Handle-Werte vergibt das
tut es meistens faellt nichts auf. Oberhalb von 2^31 wird still
abgeschnitten, und das folgende `DeviceIoControl` arbeitet auf einem
Handle, das es nie gab.
Dieselbe Lehre steht seit dem 29.08.2026 im Kopf von
`platform/winlauf.py` (dort die Job-Objekte). Hier war sie noch nicht
angekommen und das ist der Treiber, durch den JEDE Disc-Erkennung und
jeder Auswurf laeuft.
Mit `restype` aendert sich der FEHLERWERT von `CreateFileW`: Es gibt
`(HANDLE)-1` zurueck, und als Zeiger gelesen ist das
0xFFFFFFFFFFFFFFFF, nicht -1. Wer nur die Typen erklaert und die alte
Pruefung stehen laesst, legt damit die Fehlerbehandlung still ein
fehlgeschlagenes Oeffnen saehe aus wie ein Erfolg. Siehe `Win32.oeffnen`.
"""
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD,
wintypes.HANDLE]
k.CreateFileW.restype = wintypes.HANDLE
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.CloseHandle.restype = wintypes.BOOL
k.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p]
k.DeviceIoControl.restype = wintypes.BOOL
k.GetLogicalDrives.argtypes = []
k.GetLogicalDrives.restype = wintypes.DWORD
k.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
k.GetDriveTypeW.restype = wintypes.UINT
def _api(api):
return api if api is not None else Win32()
# ── Laufwerke finden ────────────────────────────────────────────────────
def list_optical_devices(api=None) -> list:
"""Alle optischen Laufwerke — als Gerätepfade wie r"\\\\.\\D:".
Kein Scan über alle 26 Buchstaben mit Öffnen-Versuch: `GetDriveTypeW`
beantwortet die Frage ohne jeden Zugriff auf das Gerät. Ein Öffnen-Versuch
auf einem leeren Laufwerk kann sonst mehrere Sekunden dauern (das Laufwerk
fährt hoch), und das mal 26.
"""
api = _api(api)
return sorted(
w.geraetepfad(b) for b in api.laufwerksbuchstaben()
if api.laufwerkstyp(b) == w.DRIVE_CDROM
)
# ── Zustand ─────────────────────────────────────────────────────────────
def _medium_da(api, handle) -> bool:
"""Liegt eine Disc ein? Wirft Win32Fehler bei allem außer NOT_READY."""
try:
api.steuern(handle, w.IOCTL_STORAGE_CHECK_VERIFY2)
return True
except Win32Fehler as e:
if e.code in (w.ERROR_NOT_READY, w.ERROR_NO_MEDIA_IN_DRIVE):
return False
raise
def drive_status(geraet: str, api=None) -> int:
"""Wie `linux.detection.drive_status`: CDS_DISC_OK oder CDS_NO_DISC.
Wirft, wenn der Zustand NICHT feststellbar ist. Der Aufrufer muss
entscheiden, was er damit macht ein stilles leer" wäre eine falsche
Aussage über die Welt.
"""
api = _api(api)
handle = api.oeffnen(geraet)
try:
return CDS_DISC_OK if _medium_da(api, handle) else CDS_NO_DISC
finally:
api.schliessen(handle)
def disc_status(geraet: str, api=None) -> int:
"""Audio- oder Datenträger — übersetzt in die CDS_*-Werte von cdrom.py.
Damit passt das Ergebnis in dieselbe `classify()`, die der Linux-Treiber
benutzt: EINE Zuordnungslogik für beide Plattformen, und die ist getestet.
"""
api = _api(api)
handle = api.oeffnen(geraet)
try:
antwort = api.steuern(handle, w.IOCTL_CDROM_DISK_TYPE, ausgabe_bytes=4)
flags = int.from_bytes(antwort[:4], "little") if antwort else 0
finally:
api.schliessen(handle)
if flags & w.CDROM_DISK_AUDIO_TRACK:
return CDS_AUDIO
if flags & w.CDROM_DISK_DATA_TRACK:
return CDS_DATA_1
return 0 # unbekannt -> classify() sagt "unknown"
def disc_size_bytes(geraet: str, api=None) -> int:
"""Größe des eingelegten Mediums — die Entsprechung von BLKGETSIZE64.
Sie entscheidet zwischen DVD, Blu-ray und 4K-UHD (Schwellen in cdrom.py).
"""
api = _api(api)
handle = api.oeffnen(geraet)
try:
antwort = api.steuern(handle, w.IOCTL_DISK_GET_LENGTH_INFO, ausgabe_bytes=8)
return int.from_bytes(antwort[:8], "little") if antwort else 0
finally:
api.schliessen(handle)
def detect_disc_type(geraet: str, api=None) -> str:
"""cd | dvd | bluray | uhd | no_disc | unknown."""
api = _api(api)
try:
if drive_status(geraet, api) != CDS_DISC_OK:
return "no_disc"
return classify(disc_status(geraet, api), disc_size_bytes(geraet, api))
except OSError:
# „konnte nicht nachsehen" — ausdruecklich NICHT "no_disc".
return "unknown"
# ── Verriegeln ──────────────────────────────────────────────────────────
def verriegeln(geraet: str, an: bool, api=None) -> None:
"""Tür verriegeln (True) oder entriegeln (False).
`PREVENT_MEDIA_REMOVAL` ist eine Struktur mit genau einem BOOLEAN.
"""
api = _api(api)
handle = api.oeffnen(geraet)
try:
api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL,
eingabe=b"\x01" if an else b"\x00")
finally:
api.schliessen(handle)
# ── Auswerfen ───────────────────────────────────────────────────────────
def auswerfen_mit_grund(geraet: str, api=None, warten=None):
"""Die gemeinsame Mechanik. Wirft NIE.
Rückgabe `(grund, fehler)` wie im Linux-Treiber. Reihenfolge ist
dieselbe und aus demselben Grund: **entriegeln, auswerfen, nachsehen.**
"""
api = _api(api)
if warten is None:
import time
warten = time.sleep
try:
handle = api.oeffnen(geraet)
except OSError as e:
return AUSWURF_KEIN_ZUGRIFF, e
try:
# Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
# bei einem nicht verriegelten Laufwerk klappt er ohnehin.
try:
api.steuern(handle, w.IOCTL_STORAGE_MEDIA_REMOVAL, eingabe=b"\x00")
except OSError:
pass
try:
api.steuern(handle, w.IOCTL_STORAGE_EJECT_MEDIA)
except OSError as e:
return AUSWURF_ABGELEHNT, e
# NACHSEHEN. Der Befehl oben meldet nur, dass er angenommen wurde.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
try:
if not _medium_da(api, handle):
return AUSWURF_OK, None
except OSError as e:
return AUSWURF_ABGELEHNT, e
warten(1)
return AUSWURF_BLEIBT_DRIN, None
finally:
try:
api.schliessen(handle)
except OSError:
pass
def auswerfen_versuchen(geraet: str, api=None, warten=None) -> bool:
"""Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers."""
grund, _ = auswerfen_mit_grund(geraet, api=api, warten=warten)
return grund == AUSWURF_OK
def eject(geraet: str, api=None) -> None:
"""Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API."""
grund, fehler = auswerfen_mit_grund(geraet, api=api)
if grund == AUSWURF_OK:
return
if fehler is not None:
raise fehler
if grund == AUSWURF_KEIN_ZUGRIFF:
raise OSError(f"Das Laufwerk {geraet} ließ sich nicht ansprechen.")
raise OSError(
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
)
# ── Für das UI ──────────────────────────────────────────────────────────
def _text_bei(puffer: bytes, offset_pos: int) -> str:
"""Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
Reine Funktion, damit die Auswertung ohne Laufwerk prüfbar ist. Offset 0
heißt gibt es nicht" — das ist der dokumentierte Weg, wie ein
STORAGE_DEVICE_DESCRIPTOR ein fehlendes Feld meldet.
"""
if len(puffer) < offset_pos + 4:
return ""
offset = int.from_bytes(puffer[offset_pos:offset_pos + 4], "little")
if not offset or offset >= len(puffer):
return ""
ende = puffer.find(b"\x00", offset)
roh = puffer[offset:ende if ende >= 0 else len(puffer)]
return roh.decode("latin-1", errors="replace").strip()
def geraeteangaben(geraet: str, api=None) -> dict:
"""Hersteller, Modell und Seriennummer des LAUFWERKS.
## Warum es das gibt (Commander-Befund 28.08.2026)
Im UI stand:
Modell unbekannt
Seriennummer
Der Linux-Treiber liest beides aus `/sys/class/block/<name>/device/`; der
Windows-Treiber lieferte schlicht leere Felder. Die Entsprechung ist
`IOCTL_STORAGE_QUERY_PROPERTY` es antwortet mit einem
`STORAGE_DEVICE_DESCRIPTOR`, dessen Kopfdaten OFFSETS auf die
Zeichenketten dahinter enthalten.
Wirft nicht: Ein Laufwerk ohne Angaben ist ärgerlich, aber kein Grund,
die ganze Geräteliste scheitern zu lassen.
"""
api = _api(api)
abfrage = (w.STORAGE_DEVICE_PROPERTY.to_bytes(4, "little")
+ w.PROPERTY_STANDARD_QUERY.to_bytes(4, "little")
+ b"\x00" * 4)
try:
handle = api.oeffnen(geraet)
except OSError:
return {}
try:
puffer = api.steuern(handle, w.IOCTL_STORAGE_QUERY_PROPERTY,
abfrage, 1024)
except OSError:
return {}
finally:
api.schliessen(handle)
return {
"hersteller": _text_bei(puffer, w.SDD_VENDOR_ID_OFFSET),
"modell": _text_bei(puffer, w.SDD_PRODUCT_ID_OFFSET),
"fassung": _text_bei(puffer, w.SDD_PRODUCT_REVISION_OFFSET),
"seriennummer": _text_bei(puffer, w.SDD_SERIAL_NUMBER_OFFSET),
}
def kennung(geraet: str) -> str:
r"""Der kurze Name eines Laufwerks — `\.\G:` wird zu `G`.
## Warum das eine benannte Funktion ist (Befund 29.08.2026)
In `main.py` stand an DREI Stellen der Rueckweg als feste Zeile:
device_path = f"/dev/{name}"
Das UI ruft diese Endpunkte mit der Kennung aus der Geraeteliste auf
unter Windows also `G`. Gebaut wurde daraus `/dev/G`, und das steht in
keiner Laufwerksliste. Ergebnis: **Auswerfen und Disc scannen" antworten
unter Windows immer mit 404**, ohne dass irgendwo stuende, warum.
Hin und zurueck muessen zusammenpassen. Deshalb gehoert beides zum
Treiber: Er vergibt die Kennung, und er loest sie wieder auf.
"""
return geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
def pfad_zu_kennung(name: str, geraete=None) -> str:
"""Kennung -> Geraetepfad, oder "" wenn es dieses Laufwerk nicht gibt."""
for pfad in (geraete if geraete is not None else list_optical_devices()):
if pfad == name or kennung(pfad) == name:
return pfad
return ""
#: Klartext zu den Win32-Fehlern, an denen ein Laufwerks-Zugriff scheitert.
#:
#: ## Warum der Grund nicht im „unknown" verschwinden darf (30.08.2026)
#:
#: Nach einem Rip mit 29 Lesefehlern und einem gescheiterten Auswurf
#: antwortete das Laufwerk des Commanders auf nichts mehr, was mit dem
#: MEDIUM zu tun hat — an seinem Geraet gemessen:
#:
#: CreateFileW mit GENERIC_READ -> Win32-Fehler 1
#: IOCTL_STORAGE_CHECK_VERIFY2 -> Win32-Fehler 1
#: IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
#: CreateFileW mit Zugriff 0 -> geht
#: IOCTL_STORAGE_QUERY_PROPERTY -> geht
#:
#: Die GERAETE-Auskunft kam also weiter durch: Modell und Seriennummer
#: standen im UI, waehrend Typ und Status auf „unknown" fielen und sich
#: kein Rip mehr starten liess. Im Protokoll stand als letztes die laengst
#: veraltete Zeile „Disc erkannt". Sein Befund dazu: „jetzt erkennt rippy
#: die disk garnicht mehr (im log steht zwar erkannt, aber ein start des
#: rips ist nicht moeglich)".
#:
#: Rippy KANNTE die Ursache und behielt sie fuer sich. Das ist dieselbe
#: Sorte Nichtauskunft wie „Platz fuer Rippy: unbekannt" (28.08.2026) —
#: sie sieht aus wie eine Auskunft.
ZUGRIFFS_GRUENDE = {
w.ERROR_INVALID_FUNCTION:
"Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert "
"nach abgebrochenen Lesevorgaengen. Abhilfe: Disc ueber die Taste am "
"Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner "
"neu starten.",
w.ERROR_NOT_SUPPORTED:
"Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen "
"und neu einlegen.",
w.ERROR_ACCESS_DENIED:
"Ein anderes Programm haelt das Laufwerk fest.",
w.ERROR_FILE_NOT_FOUND:
"Dieses Laufwerk gibt es nicht mehr.",
w.ERROR_NOT_READY:
"Es liegt keine Disc im Laufwerk.",
}
def zugriffs_grund(fehler) -> str:
"""Klartext zu einem fehlgeschlagenen Laufwerks-Zugriff (pure Funktion).
Unbekannte Nummern werden GENANNT, nicht verschwiegen eine Nummer,
nach der man suchen kann, ist mehr als ein leerer Satz.
"""
code = getattr(fehler, "winerror", None) or getattr(fehler, "code", None)
if code in ZUGRIFFS_GRUENDE:
return ZUGRIFFS_GRUENDE[code]
if code:
return ("Das Laufwerk antwortet nicht (Win32-Fehler %s). Abhilfe: "
"Disc auswerfen und neu einlegen." % code)
return "Das Laufwerk antwortet nicht."
def device_info(geraet: str, api=None) -> dict:
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
`status` kennt hier drei Werte, nicht zwei: `ready`, `empty` und
**`unknown`**. Der dritte ist Absicht ein Laufwerk, das sich nicht
ansprechen lässt (belegt, gerade angesteckt, defekt), ist nicht leer.
"""
api = _api(api)
status = "unknown"
disc_typ = "unknown"
grund = ""
try:
if drive_status(geraet, api) == CDS_DISC_OK:
status = "ready"
try:
disc_typ = classify(disc_status(geraet, api),
disc_size_bytes(geraet, api))
except OSError as e:
disc_typ = "unknown"
grund = zugriffs_grund(e)
else:
status = "empty"
except OSError as e:
status = "unknown"
grund = zugriffs_grund(e)
buchstabe = kennung(geraet)
angaben = _angaben_gemerkt(geraet, api)
modell = " ".join(t for t in (angaben.get("hersteller"),
angaben.get("modell")) if t)
return {
"id": buchstabe,
"name": f"Laufwerk {buchstabe}:",
"type": disc_typ,
"path": geraet,
"status": status,
"model": modell,
"serial": angaben.get("seriennummer", ""),
# Leer, solange alles geht. Sonst steht hier, WARUM „unknown"
# dasteht — siehe ZUGRIFFS_GRUENDE.
"grund": grund,
}
# Die Angaben eines Laufwerks aendern sich nicht, solange es dasselbe
# Laufwerk ist -- die Disc-Wache ruft `device_info` aber alle drei Sekunden.
# Ohne dieses Gedaechtnis waere das alle drei Sekunden ein CreateFileW plus
# DeviceIoControl auf ein Geraet, das gerade rippt.
_ANGABEN_SPEICHER: dict = {}
def _angaben_gemerkt(geraet: str, api=None) -> dict:
if geraet not in _ANGABEN_SPEICHER:
_ANGABEN_SPEICHER[geraet] = geraeteangaben(geraet, api) or {}
return _ANGABEN_SPEICHER[geraet]
# ── Audio-CD (29.08.2026) ───────────────────────────────────────────────
#
# Die LETZTE offene Luecke des Windows-Betriebs: `cdparanoia` und `abcde`
# sind Linux-Werkzeuge, es gab sie hier nie. Windows bietet stattdessen zwei
# Steuercodes an — die Rechnung dazu steht in `rippy.rip.audio_cd`, damit sie
# ohne Laufwerk pruefbar ist.
def audio_toc(geraet: str, api=None) -> list:
"""Das Inhaltsverzeichnis einer Audio-CD — `[]`, wenn keine drin ist.
Gibt die Spuren aus `audio_cd.toc_zerlegen()` zurueck.
"""
from rippy.rip import audio_cd
api = _api(api)
handle = api.oeffnen(geraet)
try:
roh = api.steuern(handle, w.IOCTL_CDROM_READ_TOC,
ausgabe_bytes=w.CDROM_TOC_BYTES)
finally:
api.schliessen(handle)
return audio_cd.toc_zerlegen(roh)
def audio_spur_lesen(geraet: str, start: int, sektoren: int, schreiben,
fortschritt=None, api=None) -> int:
"""Eine Audio-Spur sektorweise lesen. Gibt die gelesenen Bytes zurueck.
`schreiben(bytes)` bekommt jeden Block so wandert eine 47-MB-Spur nie
komplett in den Speicher. `fortschritt(anteil)` bekommt 0..1.
`DiskOffset` zaehlt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
Bytes hat. Das ist dokumentiert und sieht falsch aus; wer hier 2352
einsetzt, liest an der falschen Stelle.
"""
import struct
from rippy.rip import audio_cd
api = _api(api)
handle = api.oeffnen(geraet)
gelesen = 0
try:
teile = audio_cd.bloecke(start, sektoren)
for i, (von, anzahl) in enumerate(teile):
eingabe = struct.pack("<qII", von * audio_cd.OFFSET_EINHEIT,
anzahl, audio_cd.CDDA)
daten = api.steuern(handle, w.IOCTL_CDROM_RAW_READ, eingabe,
anzahl * audio_cd.SEKTOR_BYTES)
schreiben(daten)
gelesen += len(daten)
if fortschritt and teile:
fortschritt((i + 1) / len(teile))
finally:
api.schliessen(handle)
return gelesen
-417
View File
@@ -1,417 +0,0 @@
"""Der Einrichtungs-Assistent: prüfen, fragen, installieren.
## Der Befund des Commanders (28.08.2026)
> Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend
nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts auch nicht, ob sein Rechner überhaupt kann, was
Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht,
seit wann.
## Was hier passiert und was NICHT
Dieses Modul enthält die **Prüfungen und die Entscheidungen** keine
Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne
WebView2, ohne Fenster und ohne Installation prüfbar bleibt.
## Die drei Sorten Befund
ok geht
warnung geht, aber der Nutzer sollte es wissen
fehler geht NICHT die Installation würde nichts nützen
Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für
jede Sorte:
* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine
reine Komprimier-Maschine ein vorgesehener Betriebsfall (`install.sh` sagt
das seit Langem: läuft trotzdem durch").
* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann.
* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden.
## Warum jede Prüfung sagt, was zu tun ist
Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder
Eintrag ein `abhilfe`-Feld leer nur dann, wenn wirklich nichts zu tun ist.
"""
import os
import shutil
import socket
import sys
from rippy import pfade
OK = "ok"
WARNUNG = "warnung"
FEHLER = "fehler"
# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist
# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der
# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft.
MINDESTPLATZ_MB = 500
STANDARD_PORT = 7788
def _eintrag(name, zustand, text, abhilfe=""):
return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe}
# ── Die einzelnen Prüfungen ─────────────────────────────────────────────
def pruefe_windows(version=None) -> dict:
"""Windows 10 oder neuer. Darunter gibt es kein WebView2.
Ein eingespritzter `version`-Wert wird BEACHTET, auch auf Linux. Vorher
stand die Plattform-Prüfung davor, und damit war jeder eingespritzte Wert
wirkungslos: Auf dem Linux-Runner der Ampel gab `pruefe_windows(22631)`
ein FEHLER zurück, obwohl der Test genau eine gültige Build-Nummer
übergeben hatte. Ein Einspritzpunkt, der ignoriert wird, ist keiner.
"""
if version is None and os.name != "nt":
return _eintrag("Betriebssystem", FEHLER, "Kein Windows",
"Für Linux gibt es die Docker- und die Headless-Fassung.")
bau = version if version is not None else sys.getwindowsversion().build
if bau >= 22000:
return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau)
if bau >= 10240:
return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau)
return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau,
"Rippy braucht mindestens Windows 10.")
def pruefe_webview(version=None) -> dict:
"""Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem.
Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser
funktioniert, er ist nur weniger schön.
"""
if version is None:
from rippy import fenster
version = fenster.webview2_version()
if version:
return _eintrag("Fenster (WebView2)", OK, version)
return _eintrag(
"Fenster (WebView2)", WARNUNG, "nicht gefunden",
'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos '
'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").')
def pruefe_platz(ziel: str, frei_mb=None) -> dict:
"""Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner."""
if frei_mb is None:
pfad = pfade.naechster_vorhandener(ziel)
if not pfad:
return _eintrag("Speicherplatz", FEHLER,
"Der Ort ist nicht erreichbar: %s" % ziel,
"Einen anderen Zielordner wählen.")
try:
frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024)
except OSError as e:
return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e,
"Einen anderen Zielordner wählen.")
if frei_mb >= MINDESTPLATZ_MB:
return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024))
return _eintrag("Speicherplatz", FEHLER,
"nur %d MB frei, gebraucht werden %d MB"
% (frei_mb, MINDESTPLATZ_MB),
"Platz schaffen oder einen anderen Zielordner wählen.")
def laeuft_erhoeht(erhoeht=None) -> bool:
"""Läuft dieser Prozess mit Administratorrechten?
Wichtig für die Diagnose, nicht als Anforderung: **Rippy braucht keine.**
Erhöht gestartet handelt es sich sogar Nachteile ein siehe unten.
"""
if erhoeht is not None:
return bool(erhoeht)
if os.name != "nt":
return False
import ctypes
try:
return bool(ctypes.WinDLL("shell32").IsUserAnAdmin())
except (AttributeError, OSError):
return False
def netzlaufwerke(pruefer=None) -> list:
"""Die eingebundenen Netzlaufwerke, wie DIESER Prozess sie sieht."""
if pruefer is not None:
return list(pruefer)
if os.name != "nt":
return []
import ctypes
import string
DRIVE_REMOTE = 4
try:
k = ctypes.WinDLL("kernel32")
maske = k.GetLogicalDrives()
return [b + ":" for i, b in enumerate(string.ascii_uppercase)
if (maske & (1 << i))
and k.GetDriveTypeW(b + ":\\") == DRIVE_REMOTE]
except (AttributeError, OSError):
return []
def pruefe_administrator(erhoeht=None, laufwerke=None) -> dict:
r"""Als Administrator gestartet? Dann fehlen die Netzlaufwerke.
## Der Befund des Commanders (28.08.2026)
> Wenn man das Tool als Admin startet, kann man im explorer kein
> netzlaufwerk wählen. Wenn man im explorer ein via windows eingebundenes
> netzlaufwerk einbindet dann kommt der fehler das man nicht installieren
> kann, weil die schreibrechte fehlen."
Beides ist Windows-Verhalten, kein Rippy-Fehler aber Rippy hat ihn
hineinlaufen lassen. Ein erhöhter Prozess bekommt ein ANDERES
Zugriffstoken, und eingebundene Netzlaufwerke hängen am Token der
Sitzung. `X:` existiert dort schlicht nicht. (Auf diesem Rechner
nachgesehen: `EnableLinkedConnections` ist nicht gesetzt der Standard.)
Der Teufelskreis, in den das führt:
C:\Program Files\ verlangt Administrator
Administrator versteckt die Netzlaufwerke
Netzlaufwerk wirkt dann wie keine Schreibrechte"
**Rippy braucht überhaupt keine Administratorrechte.** Der Vorgabeordner
liegt unter `%LOCALAPPDATA%`, der Autostart unter `HKCU`.
"""
if not laeuft_erhoeht(erhoeht):
return _eintrag("Rechte", OK, "normal gestartet (so ist es richtig)")
gefunden = netzlaufwerke(laufwerke)
text = "als Administrator gestartet"
return _eintrag(
"Rechte", WARNUNG, text,
"Rippy braucht keine Administratorrechte — und erhöht sieht es die "
"eingebundenen Netzlaufwerke NICHT" +
(" (%s fehlen hier)" % ", ".join(gefunden) if gefunden else "") +
". Für ein Netzlaufwerk den UNC-Pfad eintippen "
"(\\\\Server\\Freigabe) oder Rippy ohne Administrator starten.")
def laufwerk_fehlt(ziel: str, vorhanden=None) -> bool:
r"""Zeigt der Pfad auf einen Laufwerksbuchstaben, den es hier nicht gibt?
`vorhanden` ist einspritzbar und das ist kein Beiwerk: Die Frage
existiert Laufwerk Q:" laesst sich auf dem Linux-Runner der Ampel gar
nicht beantworten, dort gibt es ueberhaupt keine Laufwerksbuchstaben.
Ohne Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit
genau dem passenden fehlenden Laufwerk pruefbar also nirgends.
"""
laufwerk = pfade.laufwerk_von(ziel or "")
if len(laufwerk) != 2 or not laufwerk.endswith(":"):
return False # UNC-Pfad oder POSIX
if vorhanden is not None:
return not vorhanden
if os.name != "nt":
return False
return not os.path.isdir(laufwerk + "\\")
def pruefe_schreibrecht(ziel: str, probe=None, erhoeht=None,
laufwerk_da=None) -> dict:
"""Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten.
Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage
nach dem Nur-Lesen-Flag, nicht nach den Zugriffsrechten. Also wird eine
Datei angelegt und wieder gelöscht.
Und der Grund wird BENANNT. Schreibrechte fehlen" war die Meldung, die
der Commander bei einem Netzlaufwerk bekam richtig im Wortlaut,
unbrauchbar als Auskunft: Das Laufwerk war für den erhöhten Prozess gar
nicht da.
"""
if probe is not None:
geklappt, grund = probe
else:
geklappt, grund = _schreibprobe(ziel)
if geklappt:
return _eintrag("Schreibrecht", OK, ziel)
# ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel
# kennt `splitdrive` kein "Q:" und gab einen leeren String zurueck --
# dieselbe Falle, fuer die es `rippy/pfade.py` gibt. Zum vierten Mal.
if laufwerk_fehlt(ziel, laufwerk_da):
laufwerk = pfade.laufwerk_von(ziel or "")
# Ein Laufwerksbuchstabe, den es in DIESEM Prozess nicht gibt.
zusatz = (" Als Administrator gestartete Programme sehen die "
"eingebundenen Netzlaufwerke der Sitzung nicht."
if laeuft_erhoeht(erhoeht) else "")
return _eintrag(
"Schreibrecht", FEHLER,
"%s — Laufwerk %s ist hier nicht vorhanden" % (ziel, laufwerk),
("Statt des Laufwerksbuchstabens den UNC-Pfad eintragen "
"(\\\\Server\\Freigabe)." + zusatz))
if (ziel or "").startswith("\\\\") and laeuft_erhoeht(erhoeht):
# Auch Netz-ANMELDUNGEN hängen am Token der Sitzung, nicht nur die
# Laufwerksbuchstaben. Ein erhöhter Prozess kennt die Zugangsdaten
# zur Freigabe deshalb nicht und bekommt „Zugriff verweigert" —
# dieselbe Meldung wie bei einem echten Rechteproblem.
return _eintrag(
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
"Als Administrator gestartete Programme kennen die Anmeldung an "
"der Freigabe nicht. Rippy ohne Administrator starten — dann "
"geht der Netzwerkpfad.")
if _unter_programme(ziel):
return _eintrag(
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
'Der Ordner „Programme" verlangt Administratorrechte — und die '
'verstecken wiederum die Netzlaufwerke. Rippy braucht sie nicht: '
'Der Vorgabeordner unter „Benutzer" funktioniert ohne.')
return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
'Einen Ordner unterhalb von „Benutzer" wählen — dort '
'braucht Rippy keine Administratorrechte.')
def _unter_programme(ziel: str) -> bool:
"""Liegt das Ziel unter „Programme"? (reine Funktion)"""
unten = (ziel or "").lower().replace("/", "\\")
return ("\\program files" in unten or unten.startswith("program files")
or "\\programme\\" in unten)
def _schreibprobe(ziel: str):
import tempfile
try:
os.makedirs(ziel, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe",
delete=True):
pass
return True, ""
except OSError as e:
return False, e.strerror or str(e)
def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict:
"""Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen."""
if belegt is None:
belegt = _port_belegt(port)
if not belegt:
return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port)
# Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist
# eine Neuinstallation genau richtig.
return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port,
"Läuft Rippy schon? Sonst im Setup einen anderen Port "
"wählen.")
def _port_belegt(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
def pruefe_laufwerk(laufwerke=None) -> dict:
"""Ein optisches Laufwerk. WARNUNG, kein Fehler.
Rippy ohne Laufwerk ist eine reine Komprimier-Maschine ein vorgesehener
Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt.
"""
if laufwerke is None:
try:
from rippy import drives
laufwerke = drives.treiber().list_optical_devices()
except Exception: # noqa: BLE001
laufwerke = []
if laufwerke:
return _eintrag("Optisches Laufwerk", OK,
", ".join(str(x) for x in laufwerke))
return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden",
"Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. "
"Ein USB-Brenner reicht.")
def pruefe_werkzeuge(bestand=None) -> list:
"""MakeMKV und HandBrake — je ein eigener Eintrag.
MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre
es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`).
"""
from rippy.tools import katalog
bestand = katalog.bestand() if bestand is None else bestand
ergebnis = []
for name, eintrag in sorted(bestand.items()):
titel = eintrag.get("titel", name)
if eintrag.get("da"):
ergebnis.append(_eintrag(titel, OK,
eintrag.get("version") or "gefunden"))
elif name == "handbrake":
ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert"))
else:
ergebnis.append(_eintrag(
titel, WARNUNG, "nicht gefunden",
"Das Setup lädt es von makemkv.com und startet den Installer "
"des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen."))
return ergebnis
# ── Alles zusammen ──────────────────────────────────────────────────────
def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list:
"""Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge."""
return ([pruefe_windows(), pruefe_webview(), pruefe_administrator(),
pruefe_laufwerk()]
+ pruefe_werkzeuge()
+ [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)])
def darf_installieren(pruefungen: list) -> bool:
"""Blockiert NUR bei einem echten Fehler. (reine Funktion)
Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler,
der nur warnt, ist eine Falle.
"""
return not any(p["zustand"] == FEHLER for p in pruefungen)
def zusammenfassung(pruefungen: list) -> str:
"""Ein Satz über den Gesamtzustand. (reine Funktion)"""
fehler = [p for p in pruefungen if p["zustand"] == FEHLER]
warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG]
if fehler:
return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler)
if warnungen:
return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen)
return "Alles bereit."
def vorgaben() -> dict:
"""Womit die Maske vorbelegt wird."""
from rippy import betrieb
from rippy.platform import win_registry as reg
return {
"ziel": reg.standard_installationsordner(),
"ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"),
# Der Arbeitsordner gehoert ins Setup, nicht erst in die Einstellungen
# (Commander-Wunsch 28.08.2026). Grund: Der Roh-Rip einer 4K-UHD ist
# bis zu 100 GB gross. Wer das erst NACH der ersten vollen Platte
# erfaehrt, hat es zu spaet erfahren — genau so lief am 25.07.2026
# die VM-Platte voll.
"arbeit": betrieb.arbeits_vorgabe({}, container=False,
plattform_name="win32"),
"port": STANDARD_PORT,
"autostart": True,
"verknuepfungen": True,
"werkzeuge_holen": True,
}
-281
View File
@@ -1,281 +0,0 @@
"""Rippy als echtes Fenster — nicht als Browser-Tab.
## Die Frage des Commanders (28.08.2026)
> Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir
> kein Electron oder sowas und machen daraus einen echten Client?"
Die erste Hälfte ist berechtigt: Ein Browser-Tab IST kein Client. Er hat eine
Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat kein
eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er
habe Rippy beendet.
Die zweite Hälfte wäre die teure Antwort darauf. Gemessen, nicht geschätzt:
Electron ~150-210 MB (Chromium + Node komplett mit)
pywebview + WebView2 8,0 MB (in die Bau-Umgebung gemessen)
Electron brächte eine ZWEITE komplette Laufzeitumgebung neben Python mit
und ein zweites Chromium neben dem, das auf diesem Rechner ohnehin schon
liegt. Denn Windows 11 liefert die WebView2-Laufzeit mit; auf diesem Rechner
am 28.08.2026 nachgesehen: 151.0.4129.107.
Nachgemessen, was sie wirklich rendert nicht angenommen:
navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
fetch ja EventSource ja
CSS Grid ja Pfeilfunktionen ja
Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also
denselben Renderer nur ohne ihn ein zweites Mal mitzuschleppen.
## Warum nicht MSHTML als Rückfall
`pywebview` kann unter Windows auch auf den alten IE-Renderer zurückfallen.
Eine React-Oberfläche mit CSS Grid stellt der NICHT dar sie erschiene leer
oder zerfallen. Ein leeres Fenster ist schlimmer als ein Browser-Tab.
Deshalb wird die Laufzeit VORHER geprüft, und ohne sie geht Rippy in den
Browser statt in ein Fenster, das nichts zeigen kann.
## Warum das Fenster ein eigener Prozess ist
Das Tray-Symbol (`pystray`) braucht unter Windows den HAUPT-Thread, sonst
bekommt es keine Fensternachrichten. Das Fenster (`pywebview`/WinForms)
braucht ihn genauso. Zwei Nachrichtenschleifen passen nicht in einen Thread.
Also: `Rippy.exe --dienst` hält Server und Tray, `Rippy.exe --fenster` ist
der Client davor. Beide heißen im Taskmanager Rippy.exe" — was der
Commander wollte , und das Fenster lässt sich schließen, ohne den Dienst
mitzureißen.
## Was hier NICHT passiert
Kein Aufräumen von Ausnahmen zu einem stillen `False`. Geht das Fenster
nicht auf, muss der Aufrufer das ERFAHREN, damit er den Browser öffnen kann.
Ein stiller Fehlschlag wäre ein Doppelklick, nach dem nichts passiert.
"""
import os
import sys
TITEL = "Rippy"
BREITE = 1280
HOEHE = 860
MINDESTBREITE = 900
MINDESTHOEHE = 600
# Die Evergreen-Laufzeit von WebView2 trägt sich unter dieser festen Kennung
# bei EdgeUpdate ein. Sie ist von Microsoft vergeben und ändert sich nicht.
WEBVIEW2_KENNUNG = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
# Die Laufzeit kann pro Maschine oder pro Nutzer liegen — beides ist gültig.
_SCHLUESSEL = (
("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients"),
("HKLM", r"SOFTWARE\Microsoft\EdgeUpdate\Clients"),
("HKCU", r"Software\Microsoft\EdgeUpdate\Clients"),
)
def echte_version(wert) -> str:
"""Prüft einen `pv`-Wert aus der Registry (reine Funktion).
EdgeUpdate trägt bei einer DEINSTALLIERTEN Laufzeit `0.0.0.0` ein, statt
den Schlüssel zu löschen. Wer das für vorhanden hält, baut ein Fenster,
das nie etwas anzeigt und der Nutzer sieht einen weißen Kasten ohne
jede Erklärung.
"""
text = str(wert or "").strip()
return "" if not text or text == "0.0.0.0" else text
def webview2_version() -> str:
"""Die Version der WebView2-Laufzeit. Leer, wenn sie nicht da ist.
Die Version statt eines bloßen ja/nein": Steht in einem Fehlerbericht
`151.0.4129.107`, ist die Lage klar. Steht dort `True`, fängt das Raten
an.
"""
if os.name != "nt":
return ""
import winreg
wurzeln = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}
for name, pfad in _SCHLUESSEL:
try:
with winreg.OpenKey(wurzeln[name], pfad + "\\" + WEBVIEW2_KENNUNG) as k:
wert, _ = winreg.QueryValueEx(k, "pv")
except OSError:
continue
version = echte_version(wert)
if version:
return version
return ""
def webview2_vorhanden() -> bool:
return bool(webview2_version())
def bibliothek_vorhanden() -> bool:
"""Ist `pywebview` überhaupt im Paket? (Ohne es zu starten.)"""
import importlib.util
try:
return importlib.util.find_spec("webview") is not None
except (ImportError, ValueError):
return False
def verfuegbar() -> tuple:
"""Kann ein echtes Fenster aufgehen? (ja/nein, Begründung)
Gibt IMMER eine Begründung zurück, auch beim Ja. Wer nur `False`
zurückgibt, zwingt den Nächsten zum Nachforschen und genau solche
stillen Fehlschläge sind in diesem Projekt schon zweimal teuer geworden.
"""
if os.name != "nt":
return False, "kein Windows"
if not bibliothek_vorhanden():
return False, "pywebview ist nicht mit im Paket"
version = webview2_version()
if not version:
return False, "die WebView2-Laufzeit fehlt (ohne sie bliebe das Fenster leer)"
return True, "WebView2 " + version
def speicherort() -> str:
"""Wo WebView2 seinen Zwischenspeicher ablegt.
Ohne eigene Angabe landet er im TEMP und ist bei jedem Aufräumen weg.
Neben der Datenbank gehört er zu Rippy und verschwindet beim
Deinstallieren mit.
"""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
ordner = os.path.join(basis, "Rippy", "fenster")
try:
os.makedirs(ordner, exist_ok=True)
except OSError:
return ""
return ordner
def helfer_skript(speicherort: str) -> str:
"""Das PowerShell-Skript, das NUR unsere WebView2-Helfer beendet.
Reine Funktion, damit der Filter prüfbar ist, ohne etwas zu beenden.
## Warum das nötig ist (gemessen 28.08.2026)
Beim Deinstallieren blieben **148 Dateien** liegen, alle unter
`fenster\\EBWebView\\`. Der Grund: WebView2 startet eigene Prozesse
(`msedgewebview2.exe` Renderer, GPU, Netz, Crashpad). Stirbt Rippy,
laufen sie als Waisen weiter und halten den Zwischenspeicher offen.
`shutil.rmtree` scheiterte, der Fehler wurde verschluckt, und der
Deinstallierer meldete trotzdem Rippy wurde entfernt."
## Warum über den Speicherort gefiltert wird
`msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu beenden,
wäre ein Übergriff der Nutzer verlöre die Fenster fremder Anwendungen.
Jeder Helfer trägt seinen `--user-data-dir` in der Befehlszeile; nur wer
auf UNSEREN Ordner zeigt, gehört uns.
"""
ort = (speicherort or "").replace("'", "''")
return (
"Get-CimInstance Win32_Process -Filter \"Name='msedgewebview2.exe'\" "
"| Where-Object { $_.CommandLine -like '*%s*' } "
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
"-ErrorAction SilentlyContinue }" % ort
)
def helfer_beenden(speicherort: str = None) -> bool:
"""Beendet die WebView2-Helfer, die zu UNSEREM Speicherort gehören.
True, wenn der Versuch durchlief. Wirft nicht ein Deinstallieren darf
daran nicht scheitern.
"""
if os.name != "nt":
return False
import subprocess
ort = speicherort or speicherort_lesen()
if not ort:
return False
from rippy.platform.winlauf import OHNE_FENSTER
try:
ergebnis = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
helfer_skript(ort)],
capture_output=True, text=True, timeout=60,
creationflags=OHNE_FENSTER)
except (OSError, subprocess.SubprocessError):
return False
return ergebnis.returncode == 0
def speicherort_lesen() -> str:
"""Der Speicherort OHNE ihn anzulegen — fürs Aufräumen."""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy", "fenster")
def oeffnen(adresse: str, titel: str = TITEL, breite: int = BREITE,
hoehe: int = HOEHE) -> None:
"""Öffnet das Fenster und kehrt erst zurück, wenn es geschlossen wird.
Wirft, wenn es nicht geht der Aufrufer entscheidet dann über den
Browser. Diese Funktion trifft die Entscheidung NICHT selbst, weil sie
sie nicht mitteilen könnte: Sie läuft, bis das Fenster zu ist.
"""
import webview
webview.create_window(
titel, adresse,
width=breite, height=hoehe,
min_size=(MINDESTBREITE, MINDESTHOEHE),
text_select=True, # sonst ließe sich kein Log-Text kopieren
confirm_close=False, # der Dienst läuft weiter; nichts geht verloren
)
# gui="edgechromium" erzwingt WebView2 und verbietet den Rückfall auf
# MSHTML. Lieber ein Fehler, den der Aufrufer sieht, als ein Fenster,
# das die Oberfläche nicht darstellen kann.
webview.start(gui="edgechromium", private_mode=False,
storage_path=speicherort())
def oeffnen_oder_browser(adresse: str, titel: str = TITEL) -> str:
"""Fenster, sonst Browser. Gibt zurück, was WIRKLICH passiert ist.
Der Rückgabewert ist `"fenster"`, `"browser"` oder `"nichts"` keine
Erfolgsmeldung. Der Aufrufer soll dem Nutzer sagen können, was er sieht,
statt zu behaupten, es sei alles gut gegangen.
"""
geht, grund = verfuegbar()
if geht:
try:
oeffnen(adresse, titel)
return "fenster"
except Exception as e: # noqa: BLE001
# Bewusst breit: Was WinForms und die CLR beim Start werfen
# können, ist nicht abschließend bekannt — und keine dieser
# Ausnahmen darf den Doppelklick ins Leere laufen lassen. Sie
# wird gemeldet, nicht verschluckt.
melden("Das Fenster ging nicht auf (%s: %s) — es öffnet der Browser."
% (type(e).__name__, e))
else:
melden("Fenster nicht möglich (%s) — es öffnet der Browser." % grund)
import webbrowser
return "browser" if webbrowser.open(adresse) else "nichts"
def melden(text: str) -> None:
"""Ausgabe, die auch ohne Konsole nicht abstürzt (siehe windows_app)."""
try:
sys.stdout.write(text + "\n")
sys.stdout.flush()
except Exception: # noqa: BLE001
pass
-129
View File
@@ -1,129 +0,0 @@
"""Verknuepfungen: der Ordner, das Skript, das Zurueckraeumen.
Das PowerShell-Skript wird als reine Funktion gebaut und geprueft so faellt
ein Zitier-Fehler auf, ohne dass eine .lnk entstehen muss. Die Tests, die
wirklich eine anlegen, laufen nur unter Windows und raeumen hinter sich auf.
Hinweis zu den Pfaden: Windows-Pfade stehen hier in ROHEN Zeichenketten oder
werden aus `B` (dem Backslash) zusammengesetzt. In normalen Zeichenketten
waeren `C:\\D` und `C:\\Users` ungueltige Escape-Sequenzen heute eine
DeprecationWarning, in einer kuenftigen Python-Version ein harter Fehler.
"""
import os
import pytest
from rippy.platform import verknuepfungen as vk
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
B = chr(92) # Backslash — haelt jede Escape-Falle aus den Tests raus
# ── Das Skript ──────────────────────────────────────────────────────────
def test_skript_setzt_ziel_und_argumente():
lnk = "C:" + B + "D" + B + "Rippy.lnk"
exe = "C:" + B + "R" + B + "Rippy.exe"
s = vk.skript(lnk, exe, "--oeffnen")
assert "CreateShortcut('" + lnk + "')" in s
assert "$s.TargetPath = '" + exe + "'" in s
assert "$s.Arguments = '--oeffnen'" in s
assert s.rstrip().endswith("$s.Save()")
def test_apostroph_im_pfad_bricht_das_skript_nicht():
"""In PowerShell wird ein Apostroph innerhalb einfacher Anfuehrungszeichen
durch VERDOPPELN maskiert. Ohne das bricht ein Pfad mit Apostroph das
Skript mitten entzwei und der Fehler sieht aus wie ein Syntaxfehler in
Rippy statt wie ein Pfad, der ein Sonderzeichen enthaelt."""
s = vk.skript("C:" + B + "Users" + B + "O'Brien" + B + "Rippy.lnk",
"C:" + B + "R" + B + "Rippy.exe")
assert "O''Brien" in s
# Gerade Zahl von Anfuehrungszeichen — sonst ist ein String offen.
assert s.count("'") % 2 == 0
def test_ohne_arbeitsordner_wird_der_ordner_des_ziels_genommen():
ordner = "C:" + B + "Programme" + B + "Rippy"
s = vk.skript("C:" + B + "D" + B + "R.lnk", ordner + B + "Rippy.exe")
assert "$s.WorkingDirectory = '" + ordner + "'" in s
def test_leere_angaben_erzeugen_keine_leeren_zeilen():
"""Ein `$s.IconLocation = ''` wuerde das Symbol LOESCHEN statt es
unveraendert zu lassen."""
s = vk.skript("C:" + B + "D" + B + "R.lnk", "C:" + B + "R" + B + "R.exe")
assert "IconLocation" not in s
assert "Description" not in s
# ── Die Orte ────────────────────────────────────────────────────────────
@nur_windows
def test_desktop_und_startmenue_kommen_aus_der_registry():
"""Ein fest verdrahtetes %USERPROFILE%/Desktop waere eine ANNAHME. Wer
OneDrive benutzt oder seine Ordner verschoben hat, dessen Desktop liegt
woanders die Verknuepfung landete dann in einem Ordner, den niemand
sieht, und der Nutzer suchte ein Symbol, das es nirgends gibt."""
desktop = vk.shell_ordner("Desktop")
programme = vk.shell_ordner("Programs")
assert desktop and os.path.isdir(desktop)
assert programme and os.path.isdir(programme)
assert vk.desktop_pfad().endswith("Rippy.lnk")
assert "Start Menu" in vk.startmenue_pfad()
def test_unbekannter_ordner_ergibt_leer_statt_zu_raten():
assert vk.shell_ordner("GibtEsNicht") == ""
# ── Wirklich anlegen ────────────────────────────────────────────────────
@nur_windows
def test_verknuepfung_entsteht_wirklich(tmp_path):
"""Nachsehen statt glauben: PowerShell meldet auch dann Erfolg, wenn
Save() still scheitert. `anlegen()` prueft deshalb die Datei."""
exe = tmp_path / "Rippy.exe"
exe.write_bytes(b"x" * 512)
lnk = str(tmp_path / "Rippy.lnk")
assert vk.anlegen(lnk, str(exe), "--oeffnen", beschreibung="Test") is True
assert os.path.isfile(lnk)
assert os.path.getsize(lnk) > 0
assert vk.entfernen(lnk) is True
assert not os.path.exists(lnk)
def _freier_laufwerksbuchstabe() -> str:
"""Ein Buchstabe, den es auf DIESER Maschine wirklich nicht gibt.
Der erste Anlauf nahm fest `Z:` auf dem Commander-PC ist das ein
NETZLAUFWERK (X, Y und Z sind dort belegt). Der Test legte damit
froehlich eine Verknuepfung auf einer Freigabe an und schlug fehl, weil
er einen Misserfolg erwartete. Ein fest verdrahteter Laufwerksbuchstabe
ist eine Annahme ueber eine fremde Maschine.
"""
import ctypes
maske = ctypes.WinDLL("kernel32").GetLogicalDrives()
for i in range(25, -1, -1): # von Z rueckwaerts
if not (maske & (1 << i)):
return chr(ord("A") + i)
return ""
@nur_windows
def test_anlegen_auf_unmoeglichem_pfad_wirft_nicht(tmp_path):
"""Eine fehlende Verknuepfung ist aergerlich, aber kein Grund, eine sonst
geglueckte Installation scheitern zu lassen."""
buchstabe = _freier_laufwerksbuchstabe()
if not buchstabe:
pytest.skip("Alle 26 Laufwerksbuchstaben sind belegt")
exe = tmp_path / "Rippy.exe"
exe.write_bytes(b"x")
assert vk.anlegen(buchstabe + ":" + B + "gibt-es-nicht" + B + "R.lnk",
str(exe)) is False
def test_entfernen_auf_nichts_ist_kein_fehler():
assert vk.entfernen("C:" + B + "gibt-es-nicht" + B + "R.lnk") is False
-128
View File
@@ -1,128 +0,0 @@
"""Die Werte für „Programme und Features" — geprüft, bevor sie geschrieben werden.
Die reinen Funktionen laufen auf jeder Plattform. Die Tests, die wirklich in
die Registry schreiben, laufen nur unter Windows und räumen hinter sich auf
sie benutzen einen eigenen Schlüsselnamen, damit eine echte Installation
niemals angefasst wird.
"""
import os
import pytest
from rippy.platform import win_registry as reg
TEST_SCHLUESSEL = "RippyTestEintrag" # NIE der echte
nur_windows = pytest.mark.skipif(os.name != "nt", reason="Registry gibt es nur unter Windows")
# ── Die Werte selbst ────────────────────────────────────────────────────
def test_pflichtwerte_sind_gesetzt():
"""Windows zeigt einen Eintrag OHNE DisplayName gar nicht an, und ohne
UninstallString ist der Deinstallieren-Knopf tot."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\RippySetup.exe", "2.0.0")
assert werte["DisplayName"]
assert werte["UninstallString"]
def test_uninstallstring_ist_gequotet():
"""DER klassische Fehler. Ohne Anfuehrungszeichen liest Windows
C:\\Program Files\\Rippy\\x.exe" als „C:\\Program" mit Argumenten
beim Deinstallieren passiert dann nichts oder etwas Falsches.
"""
werte = reg.eintrag_werte(
r"C:\Program Files\Rippy", r"C:\Program Files\Rippy\RippySetup.exe", "2.0.0")
assert werte["UninstallString"].startswith('"C:\\Program Files\\Rippy\\RippySetup.exe"')
assert werte["UninstallString"].endswith("--deinstallieren")
def test_stille_deinstallation_ist_vorgesehen():
"""Windows benutzt QuietUninstallString bei automatisierten Entfernungen.
Fehlt er, faellt Windows auf den lauten zurueck und ein Dialog blockiert
eine unbeaufsichtigte Abmeldung."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert "--still" in werte["QuietUninstallString"]
def test_reparieren_und_aendern_sind_ausgeblendet():
"""Rippy kann beides nicht. Knoepfe, die ins Leere fuehren, sind
dieselbe Sorte Placebo wie ein Fortschrittsbalken ohne Fortschritt."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert werte["NoModify"] == 1
assert werte["NoRepair"] == 1
def test_groesse_wird_in_kib_erwartet():
"""EstimatedSize ist in KiB. Wer Bytes eintraegt, meldet in „Programme und
Features" das Tausendfache — aus 90 MB werden 90 GB."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0", groesse_kib=92_160)
assert werte["EstimatedSize"] == 92_160 # = 90 MiB
def test_ohne_icon_kein_leerer_eintrag():
"""Ein leerer DisplayIcon laesst Windows das Standard-Symbol zeigen —
aber ein Wert, der da ist und ins Leere zeigt, macht ein kaputtes Bild."""
assert "DisplayIcon" not in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0")
assert "DisplayIcon" in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0", icon=r"C:\R\r.ico")
def test_uninstall_pfad_liegt_unter_dem_richtigen_schluessel():
pfad = reg.uninstall_pfad("Rippy")
assert pfad.startswith(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
assert pfad.endswith(r"\Rippy")
def test_installationsordner_braucht_keine_adminrechte():
"""Program Files waere gewohnter, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage."""
ordner = reg.standard_installationsordner()
assert "Rippy" in ordner
if os.name == "nt":
assert "Program Files" not in ordner
def test_ordnergroesse_zaehlt_wirklich(tmp_path):
(tmp_path / "a.bin").write_bytes(b"x" * 4096)
unter = tmp_path / "unter"
unter.mkdir()
(unter / "b.bin").write_bytes(b"y" * 4096)
assert reg.ordnergroesse_kib(str(tmp_path)) == 8
# ── Wirklich schreiben (nur Windows) ────────────────────────────────────
@nur_windows
def test_eintrag_landet_wirklich_in_der_registry(tmp_path):
"""Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis. Genau diese Lehre steht in AGENTS.md."""
ordner = str(tmp_path)
(tmp_path / "RippySetup.exe").write_bytes(b"x" * 1024)
deinstaller = str(tmp_path / "RippySetup.exe")
try:
reg.eintragen(ordner, deinstaller, "2.0.0-test", schluessel=TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is True
gelesen = reg.gelesene_werte(TEST_SCHLUESSEL)
assert gelesen["DisplayName"] == "Rippy"
assert gelesen["DisplayVersion"] == "2.0.0-test"
assert gelesen["UninstallString"].startswith('"' + deinstaller + '"')
assert gelesen["NoModify"] == 1
finally:
reg.austragen(TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is False
@nur_windows
def test_austragen_auf_nichts_ist_kein_fehler():
assert reg.austragen("RippyGibtEsNicht") is False
@nur_windows
def test_autostart_setzen_lesen_entfernen(tmp_path):
name = "RippyTestAutostart"
befehl = '"' + str(tmp_path / "Rippy.exe") + '"'
try:
reg.autostart_setzen(befehl, name=name)
assert reg.autostart_wert(name) == befehl
finally:
reg.autostart_entfernen(name)
assert reg.autostart_wert(name) == ""
-20
View File
@@ -161,26 +161,6 @@ def test_die_elternumgebung_wird_nicht_veraendert():
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
def test_beide_startwege_reichen_den_zeiger_nicht_weiter():
"""Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=`
setzen. Sonst kommen die Reste zurueck, und zwar still."""
import re
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
with open(os.path.abspath(quelle), encoding="utf-8") as f:
text = f.read()
# Seit dem 29.08.2026 gehen beide Wege ueber `eigenstaendig_starten` —
# der Dienst und das Fenster muessen sich aus Rippys Arbeitsgruppe
# herausloesen (winlauf.kinder_an_die_leine). Am `env=` aendert das nichts,
# und genau darum geht es hier weiterhin.
stellen = [m.start() for m in re.finditer(
r"(subprocess\.Popen|eigenstaendig_starten)\(\s*\[programm", text)]
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
for stelle in stellen:
aufruf = text[stelle:stelle + 300]
assert "env=umgebung_ohne_bundle()" in aufruf, (
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
-20
View File
@@ -144,26 +144,6 @@ def test_die_struktur_hat_die_groesse_die_windows_erwartet():
assert ctypes.sizeof(winlauf._ERWEITERTE_GRENZEN()) == 144
def test_die_drei_dauerlaeufer_loesen_sich_heraus():
"""Dienst, Fenster und Aufraeum-Skript MUESSEN Rippy ueberleben.
Wuerden sie in der Arbeitsgruppe haengen, wuerde die Deinstallation ihr
eigenes Aufraeum-Skript umbringen und der Dienst mit seinem Starter sterben.
"""
import inspect
from rippy import windows_app
for funktion in (windows_app.starte_hintergrund, # der Dienst
windows_app.starte_fensterprozess, # das Fenster
windows_app.selbst_loeschen): # das Aufraeum-Skript
quelle = inspect.getsource(funktion)
assert "eigenstaendig_starten" in quelle, (
f"{funktion.__name__} startet noch direkt — der Prozess wuerde"
" mit Rippy sterben")
assert "subprocess.Popen" not in quelle, (
f"{funktion.__name__} hat noch einen direkten Start daneben")
def test_der_rueckfall_ohne_die_fahne_ist_vorhanden():
"""Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis,
-178
View File
@@ -1,178 +0,0 @@
"""Verknüpfungen auf dem Desktop und im Startmenü.
## Warum über PowerShell und nicht über pywin32
Eine `.lnk` ist keine Textdatei, sondern ein binäres Shell-Objekt. Sie von
Hand zu schreiben ist Bastelei; `pywin32` könnte es, wäre aber eine
20-MB-Abhängigkeit für vier Zeilen. Windows bringt die Fähigkeit selbst mit:
`WScript.Shell` ist seit Jahrzehnten da und braucht keine Installation.
Dieses Projekt geht den Weg schon einmal der alte Worker-Installer legt
seine Verknüpfungen ebenfalls nativ via PowerShell (kein win32com mehr
nötig)" an.
## Warum die Ordner aus der Registry kommen
`%USERPROFILE%\\Desktop` ist eine ANNAHME. Wer OneDrive benutzt oder seine
Ordner verschoben hat, dessen Desktop liegt woanders die Verknüpfung
landete dann in einem Ordner, den niemand sieht, und der Nutzer suchte ein
Symbol, das es nirgends gibt.
Die Wahrheit steht unter `Shell Folders` in der Registry. Auf diesem Rechner
am 28.08.2026 nachgesehen:
Desktop C:\\Users\\\\Desktop
Programs C:\\Users\\\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs
## Worauf die Verknüpfung zeigt
Nicht auf `--dienst`. Ein Doppelklick auf ein Desktop-Symbol soll Rippy
ÖFFNEN und dabei ist es dem Nutzer egal, ob der Dienst schon läuft. Die
Verknüpfung ruft deshalb `Rippy.exe --oeffnen`: Läuft der Dienst, geht nur
der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das
beim zweiten Klick einen zweiten Server startet, wäre eine Falle.
"""
import os
import subprocess
from rippy import pfade
def shell_ordner(name: str) -> str:
"""Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs").
Aus der Registry, nicht geraten. Leerer String, wenn er nicht zu
ermitteln ist dann wird eben keine Verknüpfung angelegt, statt eine
an einer erfundenen Stelle.
"""
if os.name != "nt":
return ""
import winreg
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
) as k:
wert, _ = winreg.QueryValueEx(k, name)
return os.path.expandvars(str(wert))
except OSError:
return ""
def _ps_text(wert: str) -> str:
"""Text für ein einfach gequotetes PowerShell-Argument.
In PowerShell wird ein Apostroph innerhalb einfacher Anführungszeichen
durch VERDOPPELN maskiert. Ohne das bricht ein Pfad wie
`C:\\Users\\O'Brien\\…` das Skript mitten entzwei — und der Fehler sieht
aus wie ein Syntaxfehler in Rippy.
"""
return (wert or "").replace("'", "''")
def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "",
beschreibung: str = "", arbeitsordner: str = "") -> str:
"""Das PowerShell-Skript, das die Verknüpfung anlegt (reine Funktion)."""
zeilen = [
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('%s')" % _ps_text(lnk),
"$s.TargetPath = '%s'" % _ps_text(ziel),
]
if argumente:
zeilen.append("$s.Arguments = '%s'" % _ps_text(argumente))
if icon:
zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon))
if beschreibung:
zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung))
# ntpath, nicht os.path: Eine .lnk zeigt IMMER auf einen
# Windows-Pfad — auch wenn dieser Code gerade auf dem Linux-Runner
# der Ampel laeuft. `os.path.dirname` gab dort fuer
# C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der
# Test schlug nur dort fehl.
zeilen.append("$s.WorkingDirectory = '%s'"
% _ps_text(arbeitsordner or pfade.ordner_von(ziel)))
zeilen.append("$s.Save()")
return "; ".join(zeilen)
def anlegen(lnk: str, ziel: str, argumente: str = "", icon: str = "",
beschreibung: str = "", arbeitsordner: str = "") -> bool:
"""Legt eine Verknüpfung an. False, wenn es nicht geklappt hat.
Wirft nicht: Eine fehlende Verknüpfung ist ärgerlich, aber kein Grund,
eine sonst geglückte Installation scheitern zu lassen. Der Aufrufer
sagt es dem Nutzer.
"""
if os.name != "nt":
return False
from rippy.platform.winlauf import OHNE_FENSTER
try:
# os.makedirs MUSS mit in den try: Auf einem Laufwerk, das es nicht
# gibt, wirft es — und dann waere aus "Verknuepfung konnte nicht
# angelegt werden" ein Abbruch der ganzen Installation geworden.
os.makedirs(os.path.dirname(lnk), exist_ok=True)
ergebnis = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
skript(lnk, ziel, argumente, icon, beschreibung, arbeitsordner)],
capture_output=True, text=True, timeout=60,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.SubprocessError):
return False
# Nachsehen statt dem Rueckgabewert glauben: PowerShell meldet auch dann
# Erfolg, wenn Save() still scheitert.
return ergebnis.returncode == 0 and os.path.isfile(lnk)
def entfernen(lnk: str) -> bool:
try:
os.remove(lnk)
return True
except OSError:
return False
# ── Die beiden Orte ─────────────────────────────────────────────────────
def desktop_pfad(name: str = "Rippy") -> str:
ordner = shell_ordner("Desktop")
return os.path.join(ordner, name + ".lnk") if ordner else ""
def startmenue_pfad(name: str = "Rippy") -> str:
ordner = shell_ordner("Programs")
return os.path.join(ordner, name + ".lnk") if ordner else ""
def alle_anlegen(programm: str, icon: str = "", desktop: bool = True,
startmenue: bool = True) -> dict:
"""Legt beide Verknüpfungen an. Gibt zurück, was WIRKLICH entstanden ist.
Der Rückgabewert ist die Liste der angelegten Dateien nicht eine
Erfolgsmeldung. So kann der Installer sagen, was er getan hat, statt
zu behaupten, es sei alles gut gegangen.
"""
gemacht = {}
ziele = []
if desktop:
ziele.append(("desktop", desktop_pfad()))
if startmenue:
ziele.append(("startmenue", startmenue_pfad()))
for schluessel, lnk in ziele:
if not lnk:
continue
if anlegen(lnk, programm, argumente="--oeffnen", icon=icon or programm,
beschreibung="Rippy öffnen — automatisches Ripping"):
gemacht[schluessel] = lnk
return gemacht
def alle_entfernen() -> list:
"""Nimmt beide Verknüpfungen zurück. Gibt zurück, was entfernt wurde."""
entfernt = []
for lnk in (desktop_pfad(), startmenue_pfad()):
if lnk and os.path.isfile(lnk) and entfernen(lnk):
entfernt.append(lnk)
return entfernt
-230
View File
@@ -1,230 +0,0 @@
"""Windows-Registrierung: „Programme und Features" und Autostart.
## Wie ein Programm in „Programme und Features" landet
Nicht durch das Kopieren von Dateien. Windows liest diese Liste ausschließlich
aus der Registry, unter:
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKCU ist die Installation für DIESEN Benutzer, HKLM die für alle letztere
braucht Administratorrechte. Rippy nimmt HKCU: Der Eintrag erscheint genauso in
Apps & Features", aber die Installation läuft ohne UAC-Abfrage durch.
Pflicht sind genau zwei Werte:
DisplayName was in der Liste steht
UninstallString was Windows startet, wenn man Deinstallieren" klickt
Alles andere ist Kür, aber es macht den Unterschied zwischen einem
gepflegten Eintrag und einem, der aussieht wie Schadsoftware:
`DisplayVersion`, `Publisher`, `InstallLocation`, `DisplayIcon`,
`EstimatedSize` (in KiB!), `NoModify`, `NoRepair`.
## Autostart
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run
Bewusst HKCU und nicht der `Startup`-Ordner: Ein Eintrag in Run ist mit einem
Registry-Zugriff wieder weg, eine Verknüpfung im Startup-Ordner muss man
suchen. Und bewusst NICHT als Windows-Dienst der bräuchte Administrator­
rechte bei jeder Installation und jedem Update.
## Warum die Werte hier getrennt gebaut werden
`eintrag_werte()` und `uninstall_pfad()` sind reine Funktionen. Damit lässt
sich prüfen, dass die Pflichtwerte gesetzt sind und der Deinstallations-Befehl
richtig gequotet ist ohne in die Registry zu schreiben. Ein falsch
gequoteter `UninstallString` ist ein klassischer Fehler: Steht der Pfad mit
Leerzeichen ohne Anführungszeichen drin, startet Windows beim Deinstallieren
das falsche Programm oder gar nichts.
"""
import os
import sys
# Der Schlüsselname unter Uninstall. Muss über Versionen hinweg GLEICH bleiben —
# ändert er sich, entsteht bei einem Update ein zweiter Eintrag, und der alte
# bleibt als Leiche stehen.
# ## Warum der Name ueberschreibbar sein MUSS (28.08.2026)
#
# Er stand als Vorgabewert direkt in jeder Signatur. Vorgabewerte wertet
# Python zur DEFINITIONSZEIT aus — ein Test konnte den Namen also gar nicht
# umbiegen und arbeitete zwangslaeufig am ECHTEN System.
#
# Gemessen, was das anrichtet: Nach `Rippy.exe` (installiert) stand der
# Eintrag da; nach EINEM `pytest -q` war er weg. Der Commander sah Rippy
# danach nicht mehr unter „Installierte Apps" — sein Autostart-Eintrag ging
# denselben Weg. Der Test raeumte auf, was er nie angelegt hatte.
#
# Deshalb: `None` in der Signatur, aufgeloest zur LAUFZEIT. Damit wirkt ein
# `monkeypatch.setattr(reg, "SCHLUESSEL", ...)` ueberall.
SCHLUESSEL = "Rippy"
UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall"
RUN_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Run"
ANZEIGENAME = "Rippy"
HERAUSGEBER = "Rippy"
def uninstall_pfad(schluessel: str = None) -> str:
return UNINSTALL_PFAD + "\\" + (schluessel or SCHLUESSEL)
def _quoten(pfad: str) -> str:
"""Pfad für die Kommandozeile in Anführungszeichen — immer.
C:\\Program Files\\Rippy\\" ohne Anführungszeichen liest Windows als
C:\\Program" mit den Argumenten „Files\\Rippy\\". Beim Deinstallieren
passiert dann entweder nichts oder etwas Falsches.
"""
return '"' + pfad.strip('"') + '"'
def eintrag_werte(installationsordner: str, deinstaller: str, version: str,
icon: str = "", groesse_kib: int = 0) -> dict:
"""Die Werte für „Programme und Features" (reine Funktion).
`groesse_kib` ist in KiB Windows zeigt sie als Größe" an. Wer Bytes
einträgt, meldet 1000-mal zu viel; das ist ein häufiger Fehler.
"""
werte = {
"DisplayName": ANZEIGENAME,
"DisplayVersion": version,
"Publisher": HERAUSGEBER,
"InstallLocation": installationsordner,
"UninstallString": _quoten(deinstaller) + " --deinstallieren",
"QuietUninstallString": _quoten(deinstaller) + " --deinstallieren --still",
# Rippy kann sich nicht selbst reparieren oder teilweise installieren —
# die Knöpfe dafür gehören also ausgeblendet, statt ins Leere zu führen.
"NoModify": 1,
"NoRepair": 1,
}
if icon:
werte["DisplayIcon"] = icon
if groesse_kib:
werte["EstimatedSize"] = int(groesse_kib)
return werte
def ordnergroesse_kib(ordner: str) -> int:
"""Größe eines Ordners in KiB — für den EstimatedSize-Wert."""
summe = 0
for wurzel, _, dateien in os.walk(ordner):
for name in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, name))
except OSError:
pass
return summe // 1024
# ── Schreiben und Entfernen (nur unter Windows) ─────────────────────────
def _winreg():
if os.name != "nt":
raise RuntimeError("Registry-Zugriff gibt es nur unter Windows.")
import winreg
return winreg
def eintragen(installationsordner: str, deinstaller: str, version: str,
icon: str = "", schluessel: str = None) -> None:
"""Schreibt den Eintrag für „Programme und Features"."""
winreg = _winreg()
werte = eintrag_werte(
installationsordner, deinstaller, version, icon,
ordnergroesse_kib(installationsordner),
)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
for name, wert in werte.items():
if isinstance(wert, int):
winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, wert)
else:
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert))
def austragen(schluessel: str = None) -> bool:
"""Entfernt den Eintrag. False, wenn es ihn gar nicht gab."""
winreg = _winreg()
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel))
return True
except FileNotFoundError:
return False
def ist_eingetragen(schluessel: str = None) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)):
return True
except FileNotFoundError:
return False
def gelesene_werte(schluessel: str = None) -> dict:
"""Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests.
Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis dafür, dass der Eintrag angekommen ist.
"""
winreg = _winreg()
ergebnis = {}
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
i = 0
while True:
try:
name, wert, _ = winreg.EnumValue(k, i)
except OSError:
break
ergebnis[name] = wert
i += 1
except FileNotFoundError:
pass
return ergebnis
# ── Autostart ───────────────────────────────────────────────────────────
def autostart_setzen(befehl: str, name: str = None) -> None:
winreg = _winreg()
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
winreg.SetValueEx(k, name or SCHLUESSEL, 0, winreg.REG_SZ, befehl)
def autostart_entfernen(name: str = None) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0,
winreg.KEY_ALL_ACCESS) as k:
winreg.DeleteValue(k, name or SCHLUESSEL)
return True
except FileNotFoundError:
return False
def autostart_wert(name: str = None) -> str:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
return winreg.QueryValueEx(k, name or SCHLUESSEL)[0]
except (FileNotFoundError, OSError):
return ""
def standard_installationsordner() -> str:
"""%LOCALAPPDATA%\\Rippy — dorthin darf ohne Administratorrechte geschrieben werden.
`Program Files` wäre der gewohnte Ort, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage. Für ein Programm, das sich selbst
aktualisieren soll, ist das der falsche Handel.
"""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy")
def laeuft_als_gebuendelte_exe() -> bool:
return getattr(sys, "frozen", False)
-10
View File
@@ -1,10 +0,0 @@
"""Queue-Schicht: Aufträge verteilen (KONZEPT-V2.md § 3.4).
Zwei Treiber, ein Port (`rippy.ports.Queue`):
lokal.py Auftragstabelle + Lease, ohne Broker Standalone-Betrieb
celery.py Celery über Redis verteilter Betrieb (V2-5)
Beide stehen auf demselben Grundsatz: Die Datenbank ist die Wahrheit über den
Job-Zustand, der Broker ist nur der Wecker.
"""
-152
View File
@@ -1,152 +0,0 @@
"""Der Läufer: nimmt Aufträge aus der LocalQueue und arbeitet sie ab.
## Wofür er da ist
Im verteilten Betrieb macht Celery das: Ein Worker-Prozess wartet auf
Nachrichten und ruft die Task-Funktion auf. Im Standalone-Betrieb gibt es
keinen Broker und keinen zweiten Prozess also übernimmt dieser Läufer die
Rolle. Er ist das letzte Stück, das gefehlt hat, damit Rippy unter Windows
nicht nur anzeigen, sondern auch **arbeiten** kann.
## Die Lease ist der Kern, nicht ein Detail
Ein Rip läuft 30 bis 90 Minuten. In dieser Zeit muss zweierlei stimmen:
* **Niemand sonst darf denselben Auftrag anfassen.** Zwei Rips auf einem
Laufwerk wären zwei kaputte Dateien.
* **Stürzt der Läufer ab, muss der Auftrag zurückfallen.** Sonst bliebe er
für immer als läuft" stehen — genau der Zustand, den v1 mit
`zombies.py` nachträglich einsammeln musste.
Beides erledigt die Lease aus `rippy.queue.lokal`: Der Läufer verlängert sie
alle 15 Sekunden aus einem eigenen Thread. Hört er auf zu leben, läuft sie
nach 60 Sekunden ab und der Auftrag ist wieder frei.
## Warum der Herzschlag NICHT im Arbeits-Thread liegt
Weil er dann mit der Arbeit stillstünde. Der Rip verbringt seine Zeit in
`subprocess.communicate()` dort läuft kein Python-Code, der nebenbei eine
Lease verlängern könnte. Der Herzschlag braucht deshalb einen eigenen Thread,
der nichts anderes tut.
## Was der Läufer NICHT tut
Er kennt den Rip-Vorgang nicht. Er bekommt eine Funktion `ausfuehren(auftrag)`
und ruft sie auf. Das hält ihn testbar (die Tests hier rippen nichts) und
erlaubt dieselbe Mechanik später für andere Auftragsarten.
"""
import threading
import time
import traceback
from rippy.queue import lokal
class Laeufer:
"""Ein Arbeiter: holt einen Auftrag, hält ihn, arbeitet ihn ab."""
def __init__(self, ausfuehren, knoten: str, kann: set = None,
bus=None, store=None,
herzschlag_sekunden: float = lokal.LEASE_ERNEUERN_SEKUNDEN):
self._ausfuehren = ausfuehren
self.knoten = knoten
self.kann = kann or set()
self._bus = bus
self._store = store
self._herzschlag_sekunden = herzschlag_sekunden
self._laeuft = False
self.aktueller_auftrag = None
# ── Ein Auftrag ─────────────────────────────────────────────────────
def einmal(self) -> bool:
"""Holt EINEN Auftrag und arbeitet ihn ab. False, wenn nichts da war.
Blockiert für die Dauer der Arbeit bei einem Rip also Stunden. Das
ist Absicht: Der Aufrufer entscheidet, ob das in einem eigenen Thread
passiert.
"""
auftrag = lokal.uebernehmen(self.knoten, self.kann)
if auftrag is None:
return False
self.aktueller_auftrag = auftrag
schlagen = self._herzschlag_starten(auftrag["id"])
try:
ergebnis = self._ausfuehren(auftrag)
lokal.abschliessen(auftrag["id"], ergebnis if isinstance(ergebnis, dict) else {})
self._melden("job.finished", auftrag, {"status": "fertig"})
except Exception as e:
# Ein Fehlschlag darf den Laeufer NICHT mitnehmen — sonst bleibt
# nach einem kaputten Auftrag jeder weitere liegen. Der Auftrag
# geht zurueck in die Queue (bis MAX_VERSUCHE), der Grund wird
# protokolliert, und zwar mit Rueckverfolgung: Ein "Fehler" ohne
# Stelle ist beim naechsten Mal wertlos.
self._protokollieren(auftrag, e)
lokal.fehlgeschlagen(auftrag["id"], f"{type(e).__name__}: {e}")
self._melden("job.finished", auftrag, {"status": "fehler", "fehler": str(e)})
finally:
schlagen.set()
self.aktueller_auftrag = None
return True
def _herzschlag_starten(self, auftrag_id: str) -> threading.Event:
"""Verlängert die Lease, solange gearbeitet wird. Eigener Thread."""
fertig = threading.Event()
def schlagen():
while not fertig.wait(self._herzschlag_sekunden):
if not lokal.lebenszeichen(auftrag_id, self.knoten):
# Der Auftrag gehoert uns nicht mehr — jemand anderes hat
# ihn uebernommen, weil unsere Lease abgelaufen war. Dann
# ist Weiterarbeiten falsch, aber Abbrechen mitten im Rip
# waere schlimmer. Also: laut sagen und weiterlaufen.
self._protokollieren_text(
f"Auftrag {auftrag_id} ist an einen anderen Knoten "
"gefallen — die Lease war abgelaufen.")
return
threading.Thread(target=schlagen, daemon=True,
name=f"lease-{auftrag_id[:8]}").start()
return fertig
# ── Die Schleife ────────────────────────────────────────────────────
def schleife(self, takt: float = 1.0) -> None:
"""Läuft, bis `stoppen()` gerufen wird. Für einen eigenen Thread."""
self._laeuft = True
while self._laeuft:
try:
if not self.einmal():
time.sleep(takt)
except Exception as e: # die Schleife selbst darf nie sterben
self._protokollieren_text(f"Läufer-Fehler: {e}")
time.sleep(takt * 5)
def stoppen(self) -> None:
self._laeuft = False
# ── Melden ──────────────────────────────────────────────────────────
def _melden(self, typ: str, auftrag: dict, daten: dict) -> None:
if not self._bus:
return
try:
self._bus.senden(typ, {**daten, "art": auftrag.get("art")},
entitaet="job", entitaet_id=auftrag.get("job_id"))
except Exception:
pass # eine Meldung darf die Arbeit nicht aufhalten
def _protokollieren(self, auftrag: dict, fehler: Exception) -> None:
self._protokollieren_text(
f"Auftrag {auftrag.get('art')} für Job {auftrag.get('job_id')} "
f"gescheitert: {type(fehler).__name__}: {fehler}\n"
+ "".join(traceback.format_exception_only(type(fehler), fehler)).strip()
)
def _protokollieren_text(self, text: str) -> None:
if self._store:
try:
self._store.add_log("error", "laeufer", text)
return
except Exception:
pass
print("[Läufer] " + text)
-290
View File
@@ -1,290 +0,0 @@
"""LocalQueue — Aufträge ohne Broker. Erfüllt `rippy.ports.Queue`.
## Der Grundsatz, aus dem alles folgt (KONZEPT-V2.md § 3.1)
Die Datenbank ist die Wahrheit über den Job-Zustand.
Der Broker ist nur der Wecker.
In v1 war es umgekehrt gedacht: Celery hielt den Auftrag, die Datenbank
spiegelte ihn nach. Deshalb gibt es dort `zombies.py` (206 Zeilen) plus
`test_zombies.py` (263 Zeilen) einen nachträglichen Reparaturmechanismus für
Jobs, die in der Datenbank laufen, während in Celery niemand mehr an ihnen
arbeitet. Nach einer Gnadenfrist werden sie ehrlich auf failed gesetzt".
Wenn die Datenbank die Wahrheit ist, verschwindet dieses Problem, statt
repariert zu werden:
* Ein Auftrag ist eine Zeile mit `status`, `claimed_by`, `lease_until`.
* Ein Knoten übernimmt ihn per **bedingtem UPDATE** atomar, es gewinnt genau
einer, auch wenn zehn gleichzeitig fragen.
* Er hält ihn per Lease am Leben: alle `LEASE_ERNEUERN_SEKUNDEN` wird
`lease_until` vorgerückt.
* Läuft die Lease ab, ist der Auftrag frei. Egal ob der Knoten abgestürzt ist,
das Netz weg war oder jemand den Stecker gezogen hat.
Es gibt also keinen Zustand läuft, aber niemand arbeitet daran" mehr, den
jemand erkennen müsste. Er kann höchstens `LEASE_DAUER_SEKUNDEN` lang bestehen
und heilt sich dann selbst.
## Warum keine Queue-Bibliothek (Taskiq, ARQ, Celery-lite)
Drei Gründe, alle am konkreten Fall gemessen:
1. **Der teure Teil ist kein Task, sondern ein Subprozess.** Ein Rip ist ein
3090-Minuten-`makemkvcon`-Aufruf, dessen stdout zeilenweise geparst wird
(`ripping.get_progress_from_prgv`). Was die Bibliotheken liefern
Serialisierung, Retry, Broker-Anbindung löst davon nichts. Was gebraucht
wird Fortschritts-Streaming, Abbruch mitten im Lauf, Lease muss man
ohnehin selbst bauen.
2. **Zwei Bibliotheken heißen zwei Programmiermodelle.** Der verteilte Modus
behält Celery (funktioniert, der Remote-Windows-Worker ist bewiesen). Eine
zweite Queue-Bibliothek daneben bedeutet zwei Fehlerbilder, zwei
Retry-Semantiken, zwei Sorten Doku.
3. **Mit dem Grundsatz oben ist der Treiber trivial** die eigentliche
Auswahl unten ist ein SELECT und ein bedingtes UPDATE.
## Wozu die Zeitgrenzen
`LEASE_DAUER_SEKUNDEN` muss deutlich größer sein als
`LEASE_ERNEUERN_SEKUNDEN`, sonst gilt ein arbeitender Knoten bei jedem
Aussetzer als tot und ein zweiter greift sich denselben Auftrag. Faktor 4 ist
reichlich: Drei Erneuerungen dürfen ausfallen, bevor jemand übernimmt.
"""
import json
import uuid
from datetime import timedelta
from sqlalchemy import or_
from rippy import store
from rippy.store import auftraege
# ⚠️ NICHT `from rippy.store import engine`. Ein Import bindet den Wert EINMAL,
# beim Laden des Moduls — ein späteres `store.verbinden(...)` (Standalone-Start,
# Tests) käme hier nie an, und dieses Modul spräche weiter mit der alten
# Datenbank. Über `store.engine` wird bei jedem Aufruf nachgesehen.
# Wie lange ein übernommener Auftrag als „in Arbeit" gilt, ohne Lebenszeichen.
LEASE_DAUER_SEKUNDEN = 60
# In welchem Abstand ein arbeitender Knoten die Lease verlängert.
LEASE_ERNEUERN_SEKUNDEN = 15
# Zustände
WARTEND = "wartend"
LAUFEND = "laufend"
FERTIG = "fertig"
FEHLER = "fehler"
ABGEBROCHEN = "abgebrochen"
# Wie oft ein Auftrag höchstens neu vergeben wird, bevor er als Fehler gilt.
# Ohne Obergrenze würde ein Auftrag, der den bearbeitenden Knoten jedes Mal
# umbringt, endlos im Kreis laufen und dabei jeden freien Knoten mitnehmen.
MAX_VERSUCHE = 3
def _jetzt():
return store.utcnow()
def einreihen(job_id: str, art: str, faehigkeiten: dict = None,
payload: dict = None, prioritaet: int = 0) -> str:
"""Legt einen Auftrag an und gibt seine Kennung zurück."""
auftrag_id = str(uuid.uuid4())
with store.engine.begin() as conn:
conn.execute(auftraege.insert().values(
id=auftrag_id,
job_id=job_id,
art=art,
status=WARTEND,
faehigkeiten=json.dumps(faehigkeiten or {}),
payload=json.dumps(payload or {}),
prioritaet=prioritaet,
versuche=0,
created_at=_jetzt(),
))
return auftrag_id
def _passt(auftrag_faehigkeiten: str, knoten_kann: set) -> bool:
"""Kann dieser Knoten den Auftrag bedienen? (pure Funktion)
Ein Auftrag verlangt Fähigkeiten als Schlüssel-Wert-Paare, z. B.
`{"art": "rip", "drive": "sr0"}`. Der Knoten meldet, was er kann, als
Menge von `"schluessel=wert"`-Strings bzw. blanken Schlüsseln.
Leere Anforderung heißt: jeder darf. Das ist Absicht ein Auftrag ohne
besondere Ansprüche soll nicht liegen bleiben, nur weil niemand explizit
kann nichts Besonderes" gemeldet hat.
"""
try:
verlangt = json.loads(auftrag_faehigkeiten or "{}")
except (ValueError, TypeError):
verlangt = {}
if not verlangt:
return True
for schluessel, wert in verlangt.items():
if f"{schluessel}={wert}" in knoten_kann or schluessel in knoten_kann:
continue
return False
return True
def uebernehmen(knoten: str, kann: set = None):
"""Holt EINEN passenden Auftrag und markiert ihn als übernommen.
Gibt den Auftrag als dict zurück oder None, wenn nichts passt.
## Warum das UPDATE eine Bedingung trägt
Zwischen dem SELECT und dem UPDATE kann ein anderer Knoten denselben
Auftrag genommen haben. Das `WHERE AND claimed_by IS NULL` (bzw. eine
abgelaufene Lease) sorgt dafür, dass genau EINER gewinnt: Der Verlierer
ändert null Zeilen und sucht weiter. Das ist derselbe Mechanismus auf
SQLite wie auf PostgreSQL beide führen ein UPDATE atomar aus.
Ohne die Bedingung hätte man das klassische Doppel-Rip: zwei Knoten am
selben Laufwerk, und der zweite überschreibt die Ausgabe des ersten.
"""
kann = kann or set()
jetzt = _jetzt()
with store.engine.begin() as conn:
kandidaten = conn.execute(
auftraege.select()
.where(auftraege.c.status.in_((WARTEND, LAUFEND)))
.where(or_(
auftraege.c.claimed_by.is_(None),
auftraege.c.lease_until.is_(None),
auftraege.c.lease_until < jetzt, # Lease abgelaufen = frei
))
.where(auftraege.c.versuche < MAX_VERSUCHE)
.order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at)
.limit(20)
).mappings().all()
for kandidat in kandidaten:
if not _passt(kandidat["faehigkeiten"], kann):
continue
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.id == kandidat["id"])
.where(or_(
auftraege.c.claimed_by.is_(None),
auftraege.c.lease_until.is_(None),
auftraege.c.lease_until < jetzt,
))
.values(
status=LAUFEND,
claimed_by=knoten,
lease_until=jetzt + timedelta(seconds=LEASE_DAUER_SEKUNDEN),
versuche=(kandidat["versuche"] or 0) + 1,
)
).rowcount
if betroffen == 1:
eintrag = dict(kandidat)
eintrag.update({
"status": LAUFEND,
"claimed_by": knoten,
"versuche": (kandidat["versuche"] or 0) + 1,
})
return eintrag
return None
def lebenszeichen(auftrag_id: str, knoten: str) -> bool:
"""Verlängert die Lease. False, wenn der Auftrag inzwischen weg ist.
Der `knoten`-Vergleich ist kein Beiwerk: Hat ein anderer den Auftrag
übernommen (weil diese Lease einmal abgelaufen war), darf der alte
Bearbeiter ihn NICHT wieder an sich ziehen. Ein False heißt für den
Aufrufer: aufhören, jemand anderes macht das jetzt.
"""
with store.engine.begin() as conn:
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.where(auftraege.c.claimed_by == knoten)
.where(auftraege.c.status == LAUFEND)
.values(lease_until=_jetzt() + timedelta(seconds=LEASE_DAUER_SEKUNDEN))
).rowcount
return betroffen == 1
def abschliessen(auftrag_id: str, ergebnis: dict = None) -> None:
with store.engine.begin() as conn:
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(status=FERTIG, finished_at=_jetzt(),
payload=json.dumps(ergebnis or {}), lease_until=None)
)
def fehlgeschlagen(auftrag_id: str, fehler: str, erneut: bool = True) -> None:
"""Auftrag ist gescheitert.
`erneut=True` gibt ihn wieder frei, solange die Versuche reichen ein
abgestürzter Encoder soll den Job nicht endgültig verlieren. Ist
MAX_VERSUCHE erreicht, bleibt er auf `fehler` stehen: Ein Auftrag, der
dreimal denselben Knoten umgebracht hat, wird beim vierten Mal nicht
plötzlich funktionieren.
"""
with store.engine.begin() as conn:
zeile = conn.execute(
auftraege.select().where(auftraege.c.id == auftrag_id)
).mappings().first()
if not zeile:
return
wieder_frei = erneut and (zeile["versuche"] or 0) < MAX_VERSUCHE
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(
status=WARTEND if wieder_frei else FEHLER,
claimed_by=None,
lease_until=None,
fehler=(fehler or "")[:2000],
finished_at=None if wieder_frei else _jetzt(),
)
)
def abbrechen(auftrag_id: str) -> None:
with store.engine.begin() as conn:
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(status=ABGEBROCHEN, claimed_by=None, lease_until=None,
finished_at=_jetzt())
)
def offene_auftraege(job_id: str = None) -> list:
"""Alles, was noch nicht durch ist — für `rippy queue` und das UI."""
abfrage = auftraege.select().where(
auftraege.c.status.in_((WARTEND, LAUFEND))
).order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at)
if job_id:
abfrage = abfrage.where(auftraege.c.job_id == job_id)
with store.engine.connect() as conn:
return [dict(z) for z in conn.execute(abfrage).mappings().all()]
def verwaiste_freigeben() -> int:
"""Gibt Aufträge frei, deren Lease abgelaufen ist. Gibt die Anzahl zurück.
Braucht man streng genommen nicht `uebernehmen` behandelt abgelaufene
Leases ohnehin als frei. Diese Funktion macht es nur SICHTBAR: Nach einem
Absturz soll im UI und im Log stehen, dass etwas zurückgefallen ist, statt
dass es stillschweigend wieder auftaucht.
"""
jetzt = _jetzt()
with store.engine.begin() as conn:
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.status == LAUFEND)
.where(auftraege.c.lease_until.isnot(None))
.where(auftraege.c.lease_until < jetzt)
.values(status=WARTEND, claimed_by=None, lease_until=None)
).rowcount
return betroffen or 0
-185
View File
@@ -1,185 +0,0 @@
"""Der Läufer: hält er den Auftrag, gibt er ihn zurück, überlebt er Fehler?
Gerippt wird hier nichts die Arbeit ist eine eingespritzte Funktion. Geprüft
wird das Drumherum, und genau dort sitzen die Fehler, die man im Betrieb
teuer bezahlt: ein Läufer, der nach dem ersten kaputten Auftrag stehenbleibt,
oder eine Lease, die während eines dreistündigen Encodes abläuft.
"""
import threading
import time
import pytest
from rippy import store
from rippy.queue import laeufer as laeufer_modul
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'l.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
class FakeBus:
def __init__(self):
self.gesendet = []
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
self.gesendet.append((typ, entitaet_id, daten or {}))
class FakeStore:
def __init__(self):
self.protokoll = []
def add_log(self, level, quelle, text):
self.protokoll.append((level, quelle, text))
# ── Grundfall ───────────────────────────────────────────────────────────
def test_ohne_auftrag_passiert_nichts(q):
lauf = laeufer_modul.Laeufer(lambda a: {}, "knoten-a")
assert lauf.einmal() is False
def test_auftrag_wird_geholt_und_abgearbeitet(q):
gesehen = []
q.einreihen("job1", "rip", payload={"device": "/dev/sr0"})
lauf = laeufer_modul.Laeufer(lambda a: gesehen.append(a) or {"ok": True}, "knoten-a")
assert lauf.einmal() is True
assert len(gesehen) == 1
assert gesehen[0]["art"] == "rip"
assert q.offene_auftraege() == [] # abgeschlossen, nicht mehr offen
def test_zweiter_laeufer_bekommt_ihn_nicht(q):
"""Die zentrale Zusage — sonst zwei Rips auf einem Laufwerk."""
q.einreihen("job1", "rip")
lauf_a = laeufer_modul.Laeufer(lambda a: {}, "knoten-a")
lauf_b = laeufer_modul.Laeufer(lambda a: {}, "knoten-b")
langsam = threading.Event()
lauf_a._ausfuehren = lambda a: langsam.wait(2) or {}
t = threading.Thread(target=lauf_a.einmal, daemon=True)
t.start()
time.sleep(0.3)
try:
assert lauf_b.einmal() is False, "Knoten B hat denselben Auftrag bekommen"
finally:
langsam.set()
t.join(timeout=5)
# ── Fehler ──────────────────────────────────────────────────────────────
def test_ein_kaputter_auftrag_nimmt_den_laeufer_nicht_mit(q):
"""Ohne das bliebe nach dem ersten Fehlschlag JEDER weitere Auftrag
liegen und im UI saehe es aus, als tue Rippy nichts mehr."""
protokoll = FakeStore()
q.einreihen("job1", "rip")
def kaputt(_):
raise RuntimeError("MakeMKV ist abgestuerzt")
lauf = laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll)
assert lauf.einmal() is True # er hat gearbeitet, nicht geworfen
assert any("MakeMKV ist abgestuerzt" in text for _, _, text in protokoll.protokoll)
def test_gescheiterter_auftrag_geht_zurueck_in_die_queue(q):
"""Ein abgestuerzter Encoder soll den Job nicht endgueltig verlieren."""
auftrag_id = q.einreihen("job1", "transcode")
def kaputt(_):
raise RuntimeError("weg")
laeufer_modul.Laeufer(kaputt, "knoten-a", store=FakeStore()).einmal()
offen = q.offene_auftraege()
assert len(offen) == 1 and offen[0]["id"] == auftrag_id
def test_fehler_wird_mit_typ_gemeldet(q):
"""„Fehler" allein ist beim naechsten Mal wertlos. Der Typ gehoert dazu."""
q.einreihen("job1", "rip")
protokoll = FakeStore()
def kaputt(_):
raise FileNotFoundError("makemkvcon fehlt")
laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll).einmal()
text = " ".join(t for _, _, t in protokoll.protokoll)
assert "FileNotFoundError" in text
assert "makemkvcon fehlt" in text
# ── Lease ───────────────────────────────────────────────────────────────
def test_lease_wird_waehrend_der_arbeit_verlaengert(q):
"""DER Punkt bei einem Rip: Er dauert Stunden, die Lease 60 Sekunden.
Ohne Herzschlag fiele der Auftrag mitten im Lauf zurueck und ein
zweiter Laeufer finge an, dieselbe Disc zu lesen.
"""
from datetime import timedelta
auftrag_id = q.einreihen("job1", "rip")
weiter = threading.Event()
def langsam(_):
# Waehrend der Arbeit die Lease kuenstlich ablaufen lassen —
# der Herzschlag muss sie zurueckholen.
with store.engine_holen().begin() as conn:
conn.execute(
store.auftraege.update()
.where(store.auftraege.c.id == auftrag_id)
.values(lease_until=store.utcnow() - timedelta(seconds=10)))
weiter.wait(3)
return {}
lauf = laeufer_modul.Laeufer(langsam, "knoten-a", herzschlag_sekunden=0.2)
t = threading.Thread(target=lauf.einmal, daemon=True)
t.start()
time.sleep(1.0) # dem Herzschlag Zeit geben
try:
assert laeufer_modul.lokal.uebernehmen("knoten-b") is None, \
"Die Lease wurde nicht verlaengert — ein zweiter Knoten kam ran"
finally:
weiter.set()
t.join(timeout=5)
def test_aktueller_auftrag_ist_sichtbar_und_danach_wieder_leer(q):
"""Fuer das UI und fuer die Diagnose: Woran arbeitet dieser Knoten gerade?"""
q.einreihen("job1", "rip")
gesehen = {}
lauf = laeufer_modul.Laeufer(
lambda a: gesehen.update(id=lauf.aktueller_auftrag["id"]) or {}, "knoten-a")
lauf.einmal()
assert gesehen["id"]
assert lauf.aktueller_auftrag is None
# ── Meldungen ───────────────────────────────────────────────────────────
def test_abschluss_wird_gemeldet(q):
bus = FakeBus()
q.einreihen("job1", "rip")
laeufer_modul.Laeufer(lambda a: {}, "knoten-a", bus=bus).einmal()
assert [t for t, _, _ in bus.gesendet] == ["job.finished"]
assert bus.gesendet[0][1] == "job1"
def test_ein_kaputter_bus_haelt_die_arbeit_nicht_auf(q):
"""Eine Meldung ist Beiwerk. Wenn sie scheitert, ist der Rip trotzdem
fertig und darf nicht als Fehlschlag gelten."""
class KaputterBus:
def senden(self, *a, **k):
raise RuntimeError("Bus weg")
q.einreihen("job1", "rip")
lauf = laeufer_modul.Laeufer(lambda a: {"ok": True}, "knoten-a", bus=KaputterBus())
assert lauf.einmal() is True
assert q.offene_auftraege() == []
-215
View File
@@ -1,215 +0,0 @@
"""LocalQueue: übernehmen, Lease, Rückfall — gegen echtes SQLite (V2-2).
Der Grundsatz, den diese Tests prüfen: **Die Datenbank ist die Wahrheit, der
Broker ist nur der Wecker.** Konkret heißt das, dass zwei Dinge stimmen müssen,
und beide sind hier nachgestellt:
1. Zwei Knoten dürfen sich denselben Auftrag NICHT teilen. Ohne diese Zusage
liefen zwei Rips auf demselben Laufwerk, und der zweite überschriebe die
Ausgabe des ersten.
2. Ein abgestürzter Knoten muss seinen Auftrag von SELBST wieder hergeben.
Genau das konnte v1 nicht dort brauchte es `zombies.py` (206 Zeilen plus
263 Zeilen Tests), um hinterher aufzuräumen.
"""
from datetime import timedelta
import pytest
from rippy import store
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
"""Frische SQLite-Datenbank je Test, danach zurückdrehen."""
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def _lease_zuruecksetzen(auftrag_id, sekunden):
"""Schiebt die Lease künstlich in die Vergangenheit — so sieht ein
abgestürzter Knoten aus, ohne dass ein Test eine Minute warten muss."""
with store.engine.begin() as conn:
conn.execute(
store.auftraege.update()
.where(store.auftraege.c.id == auftrag_id)
.values(lease_until=store.utcnow() - timedelta(seconds=sekunden))
)
# ── Grundfall ───────────────────────────────────────────────────────────
def test_eingereihter_auftrag_wird_uebernommen(q):
q.einreihen("job1", "rip")
auftrag = q.uebernehmen("knoten-a")
assert auftrag is not None
assert auftrag["art"] == "rip"
assert auftrag["claimed_by"] == "knoten-a"
assert auftrag["status"] == lokal.LAUFEND
def test_ohne_auftrag_kommt_none(q):
assert q.uebernehmen("knoten-a") is None
def test_hoehere_prioritaet_kommt_zuerst(q):
q.einreihen("job1", "transcode", prioritaet=0)
q.einreihen("job2", "rip", prioritaet=5)
assert q.uebernehmen("knoten-a")["job_id"] == "job2"
# ── Die zentrale Zusage: genau EINER gewinnt ────────────────────────────
def test_zwei_knoten_bekommen_nicht_denselben_auftrag(q):
"""DIE Zusage der ganzen Queue. Ohne sie: zwei Rips auf einem Laufwerk."""
q.einreihen("job1", "rip")
erster = q.uebernehmen("knoten-a")
zweiter = q.uebernehmen("knoten-b")
assert erster is not None
assert zweiter is None, "Der zweite Knoten hat denselben Auftrag bekommen"
def test_zwei_auftraege_gehen_an_zwei_knoten(q):
"""Die Gegenprobe — sonst wäre die Sperre oben nur eine kaputte Queue."""
q.einreihen("job1", "rip")
q.einreihen("job2", "rip")
a = q.uebernehmen("knoten-a")
b = q.uebernehmen("knoten-b")
assert a and b and a["id"] != b["id"]
# ── Lease: ein abgestürzter Knoten gibt von selbst her ──────────────────
def test_abgelaufene_lease_macht_den_auftrag_wieder_frei(q):
"""Das ist der Ersatz für zombies.py.
In v1 blieb ein Job nach einem Worker-Absturz auf `transcoding` stehen,
und ein eigener Mechanismus musste ihn nach einer Gnadenfrist ehrlich auf
failed setzen". Hier passiert nichts dergleichen: Die Lease läuft ab, und
der Auftrag ist wieder zu haben.
"""
auftrag_id = q.einreihen("job1", "rip")
erster = q.uebernehmen("knoten-a")
assert erster is not None
assert q.uebernehmen("knoten-b") is None # solange die Lease lebt
_lease_zuruecksetzen(auftrag_id, 10) # Knoten A ist abgestürzt
zweiter = q.uebernehmen("knoten-b")
assert zweiter is not None
assert zweiter["claimed_by"] == "knoten-b"
assert zweiter["versuche"] == 2
def test_lebenszeichen_haelt_den_auftrag(q):
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
_lease_zuruecksetzen(auftrag_id, 10)
assert q.lebenszeichen(auftrag_id, "knoten-a") is True
assert q.uebernehmen("knoten-b") is None, "Lease wurde nicht verlaengert"
def test_fremder_knoten_kann_die_lease_nicht_verlaengern(q):
"""Sonst könnte ein Knoten, dessen Lease einmal abgelaufen ist, den Auftrag
dem neuen Bearbeiter wieder wegnehmen und beide arbeiteten daran."""
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
assert q.lebenszeichen(auftrag_id, "knoten-b") is False
def test_lebenszeichen_auf_unbekannten_auftrag_ist_false(q):
assert q.lebenszeichen("gibt-es-nicht", "knoten-a") is False
def test_verwaiste_freigeben_macht_den_rueckfall_sichtbar(q):
"""Nötig ist die Funktion nicht — uebernehmen() behandelt abgelaufene
Leases ohnehin als frei. Sie macht den Rückfall aber SICHTBAR, statt ihn
stillschweigend passieren zu lassen."""
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
assert q.verwaiste_freigeben() == 0
_lease_zuruecksetzen(auftrag_id, 10)
assert q.verwaiste_freigeben() == 1
assert q.offene_auftraege()[0]["status"] == lokal.WARTEND
# ── Fähigkeiten ─────────────────────────────────────────────────────────
def test_passt_ohne_anforderung_darf_jeder():
assert lokal._passt("{}", set()) is True
assert lokal._passt(None, set()) is True
def test_passt_verlangt_alle_faehigkeiten():
verlangt = '{"art": "transcode", "encoder": "nvenc"}'
assert lokal._passt(verlangt, {"art=transcode", "encoder=nvenc"}) is True
assert lokal._passt(verlangt, {"art=transcode"}) is False
def test_passt_bei_kaputtem_json_nicht_abstuerzen():
"""Ein beschädigtes Feld darf die Auftragsvergabe nicht anhalten —
sonst blockiert eine einzige kaputte Zeile die ganze Queue."""
assert lokal._passt("{kein json", {"art=rip"}) is True
def test_knoten_ohne_die_faehigkeit_bekommt_nichts(q):
q.einreihen("job1", "transcode", faehigkeiten={"encoder": "nvenc"})
assert q.uebernehmen("nur-cpu", kann={"encoder=x265"}) is None
assert q.uebernehmen("gpu-knoten", kann={"encoder=nvenc"}) is not None
def test_passender_auftrag_wird_gefunden_obwohl_ein_fremder_vorne_liegt(q):
"""Der GPU-Knoten darf nicht leer ausgehen, nur weil ein CPU-Auftrag mit
höherer Prioritaet davor steht."""
q.einreihen("job1", "transcode", faehigkeiten={"encoder": "x265"}, prioritaet=9)
q.einreihen("job2", "transcode", faehigkeiten={"encoder": "nvenc"})
auftrag = q.uebernehmen("gpu", kann={"encoder=nvenc"})
assert auftrag is not None and auftrag["job_id"] == "job2"
# ── Abschluss und Fehlschlag ────────────────────────────────────────────
def test_abgeschlossener_auftrag_kommt_nicht_zurueck(q):
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
q.abschliessen(auftrag_id, {"pfad": "/app/media/Akira"})
assert q.uebernehmen("knoten-b") is None
assert q.offene_auftraege() == []
def test_fehlschlag_gibt_den_auftrag_wieder_frei(q):
auftrag_id = q.einreihen("job1", "transcode")
q.uebernehmen("knoten-a")
q.fehlgeschlagen(auftrag_id, "Encoder abgestuerzt")
zweiter = q.uebernehmen("knoten-b")
assert zweiter is not None and zweiter["id"] == auftrag_id
def test_nach_max_versuchen_ist_schluss(q):
"""Ohne Obergrenze liefe ein Auftrag, der jeden Knoten umbringt, endlos im
Kreis und nähme dabei jeden freien Knoten mit."""
auftrag_id = q.einreihen("job1", "transcode")
for _ in range(lokal.MAX_VERSUCHE):
q.uebernehmen("knoten-a")
q.fehlgeschlagen(auftrag_id, "immer derselbe Fehler")
assert q.uebernehmen("knoten-a") is None
with store.engine.connect() as conn:
zeile = conn.execute(
store.auftraege.select().where(store.auftraege.c.id == auftrag_id)
).mappings().first()
assert zeile["status"] == lokal.FEHLER
assert "immer derselbe Fehler" in zeile["fehler"]
def test_abbrechen_nimmt_den_auftrag_aus_dem_verkehr(q):
auftrag_id = q.einreihen("job1", "rip")
q.abbrechen(auftrag_id)
assert q.uebernehmen("knoten-a") is None
def test_offene_auftraege_lassen_sich_nach_job_filtern(q):
q.einreihen("job1", "rip")
q.einreihen("job2", "rip")
assert len(q.offene_auftraege()) == 2
assert len(q.offene_auftraege(job_id="job1")) == 1
-292
View File
@@ -1,292 +0,0 @@
r"""Audio-CDs unter Windows lesen — ohne cdparanoia.
## Warum es diese Datei gibt (Commander, 29.08.2026)
Nach dem Rundgang durch die Docker-Reste blieb genau eine Lücke offen, und
sie stand seit Wochen so im SAVEPOINT:
> **BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT.** `cdparanoia`
> (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge.
Auf die Ansage mach es" wird sie hier geschlossen. Übernommen wird nichts
von abcde es gibt keine Windows-Fassung, und ein Nachbau seiner Shell-Logik
wäre ein zweites Projekt. Gebaut wird der Weg, den Windows selbst anbietet.
## Warum eine Audio-CD nicht einfach kopiert werden kann
Windows zeigt auf einer Audio-CD `Track01.cda` & Co. Das sind **keine
Audiodateien**, sondern 44 Byte große Platzhalter mit der Track-Nummer darin.
Die Musik liegt roh auf der Disc, in 2352-Byte-Sektoren ohne Dateisystem
44100 Hz, 16 Bit, Stereo, little endian. Wer sie will, muss sie sektorweise
lesen.
## Die zwei Win32-Aufrufe
`IOCTL_CDROM_READ_TOC` liefert das Inhaltsverzeichnis: je Track die
Startadresse als MSF (Minute/Sekunde/Frame) und ob er Audio ist.
`IOCTL_CDROM_RAW_READ` liest die Sektoren. Zwei Fallen, beide dokumentiert
(learn.microsoft.com, `RAW_READ_INFO`):
* `DiskOffset` zählt in **2048er**-Einheiten, obwohl ein Audio-Sektor 2352
Bytes hat. `sektor * 2048` ist also richtig und sieht falsch aus.
* Der Ausgabepuffer muss `SectorCount * 2352` groß sein.
## Was hier PUR ist — und warum das der Punkt ist
Ein Laufwerk lässt sich in der Ampel nicht herstellen. Deshalb steht alles,
was sich rechnen lässt, in reinen Funktionen: MSFLBA, das Zerlegen der
TOC-Bytes, der WAV-Kopf, die Aufteilung in Lese-Blöcke. Geprüft wird damit
das, woran solche Arbeit erfahrungsgemäß scheitert die Rechnung, nicht der
Treiber.
"""
import struct
#: Ein Audio-Sektor auf einer CD: 588 Sample-Paare à 4 Byte.
SEKTOR_BYTES = 2352
#: `RAW_READ_INFO.DiskOffset` zählt in diesen Einheiten — NICHT in 2352.
#: (learn.microsoft.com/windows-hardware/drivers/ddi/ntddcdrm/ns-ntddcdrm-_raw_read_info)
OFFSET_EINHEIT = 2048
#: 75 Frames je Sekunde, und die ersten 2 Sekunden sind Vorlauf.
FRAMES_JE_SEKUNDE = 75
VORLAUF_FRAMES = 150
#: Wie viele Sektoren auf einmal gelesen werden. 27 * 2352 = 63504 Bytes —
#: knapp unter der 64-KB-Grenze, die viele Treiber je Anfrage setzen.
BLOCK_SEKTOREN = 27
#: Audioformat einer CD: 44100 Hz, 16 Bit, Stereo.
RATE = 44100
BITS = 16
KANAELE = 2
#: TRACK_MODE_TYPE.CDDA aus ntddcdrm.h
CDDA = 2
def msf_zu_lba(minute: int, sekunde: int, frame: int) -> int:
"""MSF-Adresse → logische Sektornummer (pure Funktion).
Die Rechnung ist Standard (ECMA-130): Jede Sekunde hat 75 Frames, und die
Adressierung beginnt bei 00:02:00 die ersten zwei Sekunden sind
Vorlauf. Wer die 150 vergisst, liest jeden Track zwei Sekunden zu früh.
"""
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
def dauer_text(sektoren: int) -> str:
"""Sektoren → „M:SS" (pure Funktion)."""
gesamt = max(0, sektoren) // FRAMES_JE_SEKUNDE
return "%d:%02d" % (gesamt // 60, gesamt % 60)
def toc_zerlegen(rohdaten: bytes) -> list:
"""Die Bytes von IOCTL_CDROM_READ_TOC → Track-Liste (pure Funktion).
Aufbau von `CDROM_TOC` (ntddcdrm.h):
0..1 Length (big endian, ohne diese zwei Bytes)
2 FirstTrack
3 LastTrack
4.. TrackData[], je 8 Byte:
0 Reserved
1 Adr:4 / Control:4 Control-Bit 2 gesetzt = DATEN-Track
2 TrackNumber 0xAA ist der Lead-Out
3 Reserved
4..7 Address 0, M, S, F
Rückgabe: `[{"nr", "start", "sektoren", "audio", "dauer"}, ]`. Die Länge
eines Tracks ergibt sich aus dem Start des NÄCHSTEN deshalb muss der
Lead-Out mitgelesen werden, sonst fehlt beim letzten Track die Länge.
"""
if not rohdaten or len(rohdaten) < 12:
return []
eintraege = []
stelle = 4
while stelle + 8 <= len(rohdaten):
_res, adr_control, nummer, _res2, _null, m, s, f = struct.unpack(
">BBBBBBBB", rohdaten[stelle:stelle + 8])
eintraege.append({
"nr": nummer,
"lba": msf_zu_lba(m, s, f),
# Control-Bit 2 (0x04) heisst „Daten-Track". Eine Misch-CD hat
# beides; wir rippen nur die Audio-Spuren.
"audio": not (adr_control & 0x04),
})
stelle += 8
spuren = []
for i, eintrag in enumerate(eintraege):
if eintrag["nr"] == 0xAA: # Lead-Out: nur als Endmarke
continue
naechster = eintraege[i + 1]["lba"] if i + 1 < len(eintraege) else None
if naechster is None:
continue # ohne Endmarke keine Laenge
laenge = naechster - eintrag["lba"]
if laenge <= 0:
continue
spuren.append({
"nr": eintrag["nr"],
"start": eintrag["lba"],
"sektoren": laenge,
"audio": eintrag["audio"],
"dauer": dauer_text(laenge),
})
return spuren
def bloecke(start: int, sektoren: int, je_block: int = BLOCK_SEKTOREN) -> list:
"""Einen Track in Lese-Blöcke zerlegen (pure Funktion).
`[(start_sektor, anzahl), ]`. Am Stück zu lesen scheitert: Viele Treiber
nehmen je Anfrage höchstens 64 KB, und ein Track hat schnell 20 000
Sektoren (47 MB).
"""
if sektoren <= 0 or je_block <= 0:
return []
teile = []
gelesen = 0
while gelesen < sektoren:
anzahl = min(je_block, sektoren - gelesen)
teile.append((start + gelesen, anzahl))
gelesen += anzahl
return teile
def wav_kopf(daten_bytes: int, rate: int = RATE, kanaele: int = KANAELE,
bits: int = BITS) -> bytes:
"""Der 44-Byte-WAV-Kopf für rohe CD-Audiodaten (pure Funktion).
CD-Audio ist bereits PCM in genau dem Format, das WAV erwartet es fehlt
nur der Kopf. Deshalb wird nichts umgerechnet: Die Sektoren wandern
unverändert hinter diese 44 Byte.
"""
byte_rate = rate * kanaele * bits // 8
block = kanaele * bits // 8
return (b"RIFF" + struct.pack("<I", 36 + daten_bytes) + b"WAVE"
+ b"fmt " + struct.pack("<IHHIIHH", 16, 1, kanaele, rate,
byte_rate, block, bits)
+ b"data" + struct.pack("<I", daten_bytes))
#: Zeichen, die Windows in Datei- und Ordnernamen verbietet.
VERBOTEN = '<>:"/\\|?*'
def sauberer_name(text: str) -> str:
"""Ein Datei- oder Ordnername ohne verbotene Zeichen (pure Funktion).
Das ist keine Kosmetik. Beim Beweis der MusicBrainz-Abfrage kam als
Ordnername `AC/DC - Back in Black` heraus (29.08.2026) der Schrägstrich
hätte unter Windows einen Unterordner `DC - Back in Black` in einem Ordner
`AC` aufgemacht. Die Band heißt nun mal so.
Ersetzt wird durch nichts; ein Bindestrich wäre geraten. Punkte und
Leerzeichen am Ende fallen weg die verweigert der Explorer.
"""
sauber = "".join(z for z in (text or "") if z not in VERBOTEN)
return " ".join(sauber.split()).rstrip(". ")
def dateiname(nr: int, titel: str = "") -> str:
"""`01 - Titel.flac` — oder `Track 01.flac`, wenn kein Titel bekannt ist."""
sauber = sauberer_name(titel)
return ("%02d - %s" % (nr, sauber)) if sauber else ("Track %02d" % nr)
def flac_befehl(programm: str, wav: str, ziel: str, tags: dict = None) -> list:
"""Der FLAC-Aufruf (pure Funktion, testbar).
`-8` ist die beste Kompression; sie kostet bei Audio-CD-Material Sekunden
und spart gegenüber `-5` spürbar Platz. `--totally-silent`, weil Rippy den
Fortschritt selbst meldet.
"""
befehl = [programm, "-8", "--totally-silent", "-o", ziel]
for name, wert in (tags or {}).items():
if wert:
befehl += ["--tag", "%s=%s" % (name, wert)]
befehl.append(wav)
return befehl
def rippen(geraet: str, ziel_ordner: str, leser, encoder: str,
fortschritt=None, laufen=None, tags_je_spur=None) -> dict:
"""Eine Audio-CD nach FLAC rippen. Der Windows-Weg.
## Warum das hier steht und nicht in `ripping.py`
`ripping.py` ist der Worker-Code mit abcde und makemkvcon beides
Linux-Werkzeuge oder Windows-Programme mit eigener Logik. Dieser Weg
gehört zum gemeinsamen Paket, weil er nur zwei Dinge braucht: einen
`leser` (die Win32-Seite) und einen Encoder.
Alles Fremde ist eingespritzt:
* `leser.toc(geraet)` -> Spurliste
* `leser.spur(geraet, start, n, schreiben)` -> Sektoren
* `laufen(befehl)` -> ruft FLAC auf
Damit ist der ganze Ablauf ohne Laufwerk und ohne Encoder prüfbar und
genau das war bei jedem Fehler dieser Sitzung der Unterschied zwischen
gefunden" und „übersehen".
"""
import os
import subprocess
import tempfile
def melde(anteil, text=""):
if fortschritt:
fortschritt(anteil, text)
spuren = [s for s in leser.toc(geraet) if s["audio"]]
if not spuren:
return {"status": "error",
"error": "Keine Audio-Spuren gefunden. Ist das wirklich eine "
"Audio-CD? (Daten-Discs kommen hier nicht an.)"}
if not encoder:
return {"status": "error",
"error": "Der FLAC-Encoder fehlt. Er wird beim Einrichten "
"geholt — Einstellungen → System zeigt den Stand."}
os.makedirs(ziel_ordner, exist_ok=True)
laufen = laufen or (lambda b: subprocess.run(
b, capture_output=True, timeout=600,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)))
dateien = []
for i, spur in enumerate(spuren):
tags = (tags_je_spur or {}).get(spur["nr"]) or {}
name = dateiname(spur["nr"], tags.get("TITLE", ""))
ziel = os.path.join(ziel_ordner, name + ".flac")
melde(int(i * 100 / len(spuren)),
"Spur %d von %d (%s)" % (i + 1, len(spuren), spur["dauer"]))
# Erst nach WAV, dann kodieren. Direkt in FLAC zu leiten waere
# eleganter — aber ein abgebrochener Lesevorgang hinterliesse dann
# eine halbe FLAC-Datei, die wie eine ganze aussieht.
with tempfile.TemporaryDirectory(prefix="rippy-cd-") as arbeit:
wav = os.path.join(arbeit, "spur.wav")
bytes_gesamt = spur["sektoren"] * SEKTOR_BYTES
with open(wav, "wb") as f:
f.write(wav_kopf(bytes_gesamt))
anteil_basis = i / len(spuren)
def teil(anteil, _b=anteil_basis, _n=len(spuren)):
melde(int((_b + anteil / _n) * 100))
leser.spur(geraet, spur["start"], spur["sektoren"], f.write, teil)
ergebnis = laufen(flac_befehl(encoder, wav, ziel, tags))
if getattr(ergebnis, "returncode", 0) != 0:
fehler = getattr(ergebnis, "stderr", b"") or b""
return {"status": "error",
"error": "FLAC brach bei Spur %d ab: %s"
% (spur["nr"],
fehler.decode("utf-8", "replace")[:200])}
dateien.append(ziel)
melde(100, "Fertig")
return {"status": "success", "output_dir": ziel_ordner, "files": dateien}
-178
View File
@@ -1,178 +0,0 @@
"""Die Disc-Kennung einer Audio-CD und die Tags dazu.
## Warum es das gibt (Commander, 29.08.2026)
Audio-CDs laufen unter Windows seit heute die Dateien hiessen aber
`Track 01.flac`. Eine Musiksammlung ohne Titel ist eine Sammlung von Nummern.
## Wie MusicBrainz eine CD wiedererkennt
Nicht am Namen (den kennt die CD nicht) und nicht an einer Kennung auf der
Disc (die gibt es nicht), sondern an der **Lage ihrer Spuren**. Zwei Pressungen
desselben Albums haben verschiedene Abstaende, zwei Exemplare derselben
Pressung dieselben. Daraus wird ein Fingerabdruck gerechnet:
Text = erste Spur (2 Hex, gross)
+ letzte Spur (2 Hex)
+ Lead-Out-Versatz (8 Hex)
+ 99 Spur-Versaetze (je 8 Hex, fehlende als Nullen)
Kennung = base64(sha1(Text)) mit + / = ersetzt durch . _ -
Quelle: musicbrainz.org/doc/Disc_ID_Calculation (AGENTS Regel D die
Rechnung steht dort, nicht in meinem Kopf).
## Die Falle: der Vorlauf
Die Versaetze in dieser Rechnung zaehlen **mit** den 150 Frames Vorlauf es
sind MSF-Adressen, keine LBA-Nummern. `rippy.rip.audio_cd` rechnet den
Vorlauf beim Lesen bewusst heraus (dort ist er falsch). Wer hier dieselben
Zahlen einsetzt, bekommt eine Kennung, die MusicBrainz nicht kennt und zwar
ohne Fehlermeldung, denn die Antwort ist dann einfach unbekannte Disc".
"""
import base64
import hashlib
import json
import urllib.parse
import urllib.request
from rippy.rip.audio_cd import VORLAUF_FRAMES
#: Der Webdienst. `fmt=json` spart uns einen XML-Leser.
DIENST = "https://musicbrainz.org/ws/2/discid/"
#: MusicBrainz verlangt eine sprechende Kennung mit Kontakt (Richtlinie auf
#: musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting). Ohne sie wird gedrosselt
#: oder geblockt.
KOPF = {"User-Agent": "Rippy/2.0 (https://github.com/Hitonabi/rippy)"}
#: Wie viele Spur-Versaetze im Text stehen — immer 99, auch bei 12 Spuren.
PLAETZE = 99
def disc_id(spur_versaetze, lead_out: int, erste: int = 1,
letzte: int = None) -> str:
"""Die MusicBrainz-Kennung einer CD (pure Funktion).
`spur_versaetze` sind die Startadressen der Spuren **einschliesslich**
Vorlauf, `lead_out` das Ende der letzten Spur ebenfalls mit Vorlauf.
"""
if not spur_versaetze:
return ""
letzte = letzte if letzte is not None else erste + len(spur_versaetze) - 1
teile = ["%02X" % erste, "%02X" % letzte, "%08X" % lead_out]
for platz in range(PLAETZE):
wert = spur_versaetze[platz] if platz < len(spur_versaetze) else 0
teile.append("%08X" % wert)
roh = hashlib.sha1("".join(teile).encode("ascii")).digest()
# Base64 mit MusicBrainz' eigenem Alphabet: + / = werden zu . _ -
return base64.b64encode(roh).decode("ascii").translate(
str.maketrans("+/=", "._-"))
def kennung_aus_spuren(spuren) -> str:
"""Kennung direkt aus `audio_cd.toc_zerlegen()` (pure Funktion).
Rechnet den Vorlauf wieder HINZU: `audio_cd` liefert LBA-Nummern (fuer
das Lesen richtig), MusicBrainz will MSF-Adressen. Genau hier gehen
solche Rechnungen sonst schief.
"""
if not spuren:
return ""
geordnet = sorted(spuren, key=lambda s: s["nr"])
versaetze = [s["start"] + VORLAUF_FRAMES for s in geordnet]
letzte_spur = geordnet[-1]
lead_out = letzte_spur["start"] + letzte_spur["sektoren"] + VORLAUF_FRAMES
return disc_id(versaetze, lead_out,
erste=geordnet[0]["nr"], letzte=letzte_spur["nr"])
def abfragen(kennung: str, laden=None, timeout: int = 15):
"""Die Disc bei MusicBrainz nachschlagen. `None`, wenn unbekannt.
`laden` ist einspritzbar damit ist alles darunter ohne Netz pruefbar.
"""
if not kennung:
return None
url = (DIENST + urllib.parse.quote(kennung, safe="._-")
+ "?fmt=json&inc=recordings+artist-credits")
if laden is None:
def laden(u):
anfrage = urllib.request.Request(u, headers=KOPF)
with urllib.request.urlopen(anfrage, timeout=timeout) as antwort:
return antwort.read()
try:
return json.loads(laden(url).decode("utf-8", "replace"))
except Exception: # noqa: BLE001
# Unbekannte Disc, kein Netz, geaendertes Format — alles dasselbe
# Ergebnis: keine Tags. Ein Rip darf daran nicht scheitern.
return None
def _kuenstler(eintrag) -> str:
"""„Artist Credit" zu einem Namen zusammensetzen (pure Funktion).
MusicBrainz gibt Beteiligungen als Liste mit Bindewoertern zurueck
Simon" + " & " + „Garfunkel". Wer nur den ersten Namen nimmt, verliert
die Haelfte.
"""
teile = []
for stueck in eintrag or []:
teile.append((stueck.get("name") or "")
+ (stueck.get("joinphrase") or ""))
return "".join(teile).strip()
def tags_je_spur(antwort) -> dict:
"""MusicBrainz-Antwort → `{spur_nr: {TAG: wert}}` (pure Funktion).
Genommen wird die ERSTE Veroeffentlichung, die diese Disc enthaelt. Es
koennen mehrere sein (Wiederveroeffentlichungen mit identischer
Spurlage) dann ist jede gleich richtig, und eine Auswahl waere geraten.
"""
if not antwort:
return {}
releases = antwort.get("releases") or []
if not releases:
return {}
release = releases[0]
album = release.get("title") or ""
jahr = (release.get("date") or "")[:4]
album_kuenstler = _kuenstler(release.get("artist-credit"))
tags = {}
for medium in release.get("media") or []:
for spur in medium.get("tracks") or []:
try:
nr = int(spur.get("position") or 0)
except (TypeError, ValueError):
continue
if nr <= 0:
continue
kuenstler = (_kuenstler(spur.get("artist-credit"))
or album_kuenstler)
tags[nr] = {
"TITLE": spur.get("title") or "",
"ARTIST": kuenstler,
"ALBUM": album,
"ALBUMARTIST": album_kuenstler,
"DATE": jahr,
"TRACKNUMBER": str(nr),
}
return tags
def album_ordner(antwort) -> str:
"""`Künstler - Album` für den Zielordner — "" wenn nichts bekannt ist."""
tags = tags_je_spur(antwort)
if not tags:
return ""
erste = tags[min(tags)]
kuenstler = erste.get("ALBUMARTIST") or erste.get("ARTIST") or ""
album = erste.get("ALBUM") or ""
from rippy.rip.audio_cd import sauberer_name
roh = "%s - %s" % (kuenstler, album) if (kuenstler and album) else (album or kuenstler)
# ⚠️ „AC/DC - Back in Black" (beim Beweis herausgekommen) haette unter
# Windows einen Unterordner aufgemacht. Die Band heisst nun mal so.
return sauberer_name(roh)
-243
View File
@@ -1,243 +0,0 @@
"""Audio-CD unter Windows — die Rechnung, ohne Laufwerk geprüft.
## Warum es das gibt (Commander, 29.08.2026)
Nach dem Rundgang durch die Docker-Reste blieb eine Lücke offen, die seit
Wochen so im SAVEPOINT stand: **Audio-CDs laufen unter Windows nicht**
`cdparanoia` und `abcde` sind Linux-Werkzeuge. Auf mach es" wird sie
geschlossen.
Ein Laufwerk lässt sich in der Ampel nicht herstellen, ein Encoder auch
nicht. Beides ist deshalb eingespritzt, und geprüft wird das, woran solche
Arbeit wirklich scheitert: die Adressrechnung, das Zerlegen der TOC-Bytes,
der WAV-Kopf, die Blockaufteilung.
"""
import struct
import pytest
from rippy.rip import audio_cd
# ── Adressrechnung ──────────────────────────────────────────────────────
@pytest.mark.parametrize("m,s,f,erwartet", [
(0, 2, 0, 0), # der Anfang der ersten Spur
(0, 3, 0, 75), # eine Sekunde spaeter
(1, 2, 0, 4500), # eine Minute
# (3*60+25)*75 + 40 - 150 = 15265 — nachgerechnet, nicht geschaetzt.
(3, 25, 40, 15265),
])
def test_msf_wird_zur_richtigen_sektornummer(m, s, f, erwartet):
"""Die 150 Frames Vorlauf sind der Klassiker: Wer sie vergisst, liest
jede Spur zwei Sekunden zu frueh."""
assert audio_cd.msf_zu_lba(m, s, f) == erwartet
def test_der_vorlauf_ist_wirklich_abgezogen():
assert audio_cd.msf_zu_lba(0, 0, 0) == -audio_cd.VORLAUF_FRAMES
@pytest.mark.parametrize("sektoren,text", [
(0, "0:00"),
(75, "0:01"),
(75 * 61, "1:01"),
(75 * 214, "3:34"),
])
def test_dauer_wird_lesbar(sektoren, text):
assert audio_cd.dauer_text(sektoren) == text
# ── Das Inhaltsverzeichnis ──────────────────────────────────────────────
def _eintrag(nr, m, s, f, daten=False):
return struct.pack(">BBBBBBBB", 0, 0x14 if daten else 0x10, nr, 0, 0, m, s, f)
def _toc(*eintraege):
rumpf = b"".join(eintraege)
return struct.pack(">HBB", len(rumpf) + 2, 1, len(eintraege) - 1) + rumpf
def test_eine_normale_audio_cd_wird_zerlegt():
roh = _toc(_eintrag(1, 0, 2, 0),
_eintrag(2, 3, 27, 0),
_eintrag(0xAA, 7, 12, 0)) # Lead-Out
spuren = audio_cd.toc_zerlegen(roh)
assert [s["nr"] for s in spuren] == [1, 2]
assert spuren[0]["start"] == 0
assert spuren[0]["sektoren"] == audio_cd.msf_zu_lba(3, 27, 0)
assert all(s["audio"] for s in spuren)
def test_die_letzte_spur_bekommt_ihre_laenge_aus_dem_lead_out():
"""Ohne den Lead-Out fehlt beim letzten Track die Laenge — er waere dann
entweder 0 lang oder wuerde ueber das Disc-Ende hinaus gelesen."""
roh = _toc(_eintrag(1, 0, 2, 0), _eintrag(0xAA, 5, 2, 0))
spuren = audio_cd.toc_zerlegen(roh)
assert len(spuren) == 1
assert spuren[0]["sektoren"] == 5 * 60 * 75
def test_daten_spuren_werden_als_solche_erkannt():
"""Eine Misch-CD hat beides. Nur die Audio-Spuren werden gerippt — und
genau daran haengt, dass eine Blu-ray hier nicht als Musik durchgeht."""
roh = _toc(_eintrag(1, 0, 2, 0, daten=True), _eintrag(0xAA, 9, 9, 0))
assert audio_cd.toc_zerlegen(roh)[0]["audio"] is False
def test_leere_oder_kaputte_daten_ergeben_keine_spuren():
"""Ohne Disc kommt Unsinn zurueck — das darf nichts umwerfen."""
for roh in (b"", b"\x00" * 4, None):
assert audio_cd.toc_zerlegen(roh) == []
# ── Lesen in Bloecken ───────────────────────────────────────────────────
def test_ein_track_wird_in_bloecke_zerlegt():
"""Am Stueck zu lesen scheitert: Viele Treiber nehmen je Anfrage
hoechstens 64 KB."""
teile = audio_cd.bloecke(100, 60, je_block=27)
assert teile == [(100, 27), (127, 27), (154, 6)]
assert sum(n for _, n in teile) == 60
def test_der_block_bleibt_unter_64_kb():
"""27 * 2352 = 63504 — knapp darunter. Wer hier hoeher geht, bekommt vom
Treiber einen Fehler statt Daten."""
assert audio_cd.BLOCK_SEKTOREN * audio_cd.SEKTOR_BYTES < 65536
def test_keine_bloecke_ohne_laenge():
assert audio_cd.bloecke(0, 0) == []
assert audio_cd.bloecke(0, -5) == []
# ── Der WAV-Kopf ────────────────────────────────────────────────────────
def test_wav_kopf_ist_44_byte_und_beschreibt_cd_audio():
kopf = audio_cd.wav_kopf(1000)
assert len(kopf) == 44
assert kopf[:4] == b"RIFF" and kopf[8:12] == b"WAVE"
kanaele, rate, _byte_rate, _block, bits = struct.unpack("<HIIHH", kopf[22:36])
assert (kanaele, rate, bits) == (2, 44100, 16)
def test_die_groessen_im_kopf_stimmen():
"""Ein falscher Wert hier ergibt eine Datei, die jeder Spieler ablehnt."""
kopf = audio_cd.wav_kopf(2352 * 10)
assert struct.unpack("<I", kopf[4:8])[0] == 36 + 23520
assert struct.unpack("<I", kopf[40:44])[0] == 23520
# ── Dateinamen ──────────────────────────────────────────────────────────
def test_ohne_titel_heisst_es_track():
assert audio_cd.dateiname(3) == "Track 03"
def test_mit_titel_wird_nummeriert():
assert audio_cd.dateiname(7, "Back in Black") == "07 - Back in Black"
def test_verbotene_zeichen_fliegen_raus():
"""„AC/DC: Back in Black" darf keinen Ordner aufmachen."""
name = audio_cd.dateiname(1, 'AC/DC: Back "in" Black?')
for zeichen in '<>:"/\\|?*':
assert zeichen not in name
# ── Der FLAC-Aufruf ─────────────────────────────────────────────────────
def test_flac_bekommt_ziel_und_quelle():
befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac")
assert befehl[0] == "flac.exe"
assert befehl[-1] == "a.wav"
assert "-o" in befehl and "b.flac" in befehl
def test_tags_wandern_als_schalter_mit():
befehl = audio_cd.flac_befehl("flac.exe", "a.wav", "b.flac",
{"TITLE": "Lied", "ARTIST": ""})
assert "TITLE=Lied" in befehl
assert not any(t.startswith("ARTIST=") for t in befehl), "leere Tags weglassen"
# ── Der ganze Ablauf, ohne Laufwerk und ohne Encoder ────────────────────
class FakeLeser:
"""Ein Laufwerk mit zwei Audio-Spuren und einer Daten-Spur."""
def __init__(self):
self.gelesen = []
def toc(self, geraet):
return [
{"nr": 1, "start": 0, "sektoren": 54, "audio": True, "dauer": "0:00"},
{"nr": 2, "start": 54, "sektoren": 27, "audio": True, "dauer": "0:00"},
{"nr": 3, "start": 81, "sektoren": 99, "audio": False, "dauer": "0:01"},
]
def spur(self, geraet, start, sektoren, schreiben, fortschritt=None):
self.gelesen.append((start, sektoren))
schreiben(b"\0" * (sektoren * audio_cd.SEKTOR_BYTES))
if fortschritt:
fortschritt(1.0)
class FertigMelder:
returncode = 0
stderr = b""
def test_ein_ganzer_rip_laeuft_durch(tmp_path):
aufrufe = []
def laufen(befehl):
aufrufe.append(befehl)
# FLAC schreibt normalerweise die Zieldatei — hier nachgestellt.
with open(befehl[befehl.index("-o") + 1], "wb") as f:
f.write(b"fLaC")
return FertigMelder()
ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(),
"flac.exe", laufen=laufen)
assert ergebnis["status"] == "success"
assert len(ergebnis["files"]) == 2, "die Daten-Spur gehoert nicht dazu"
assert all(p.endswith(".flac") for p in ergebnis["files"])
assert len(aufrufe) == 2
def test_eine_daten_disc_wird_nicht_gerippt(tmp_path):
"""Genau das ist beim Messen passiert: Im Laufwerk lag eine Blu-ray, und
die meldet sich als EIN Daten-Track ueber 1,1 Millionen Sektoren."""
class NurDaten(FakeLeser):
def toc(self, geraet):
return [{"nr": 1, "start": 0, "sektoren": 1154141,
"audio": False, "dauer": "256:28"}]
ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), NurDaten(), "flac.exe",
laufen=lambda b: FertigMelder())
assert ergebnis["status"] == "error"
assert "Audio-CD" in ergebnis["error"]
def test_ohne_encoder_wird_nichts_behauptet(tmp_path):
ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "")
assert ergebnis["status"] == "error"
assert "FLAC-Encoder" in ergebnis["error"]
def test_ein_encoder_fehlschlag_wird_gemeldet(tmp_path):
class Kaputt:
returncode = 1
stderr = b"ERROR: no space left"
ergebnis = audio_cd.rippen(r"\\.\G:", str(tmp_path), FakeLeser(), "flac.exe",
laufen=lambda b: Kaputt())
assert ergebnis["status"] == "error"
assert "no space left" in ergebnis["error"]
def test_die_spuren_werden_vollstaendig_gelesen(tmp_path):
leser = FakeLeser()
audio_cd.rippen(r"\\.\G:", str(tmp_path), leser, "flac.exe",
laufen=lambda b: FertigMelder())
assert leser.gelesen == [(0, 54), (54, 27)], \
"jede Audio-Spur genau einmal, von ihrem Start bis zu ihrer Laenge"
-179
View File
@@ -1,179 +0,0 @@
"""Die Disc-Kennung und die Tags — an echten MusicBrainz-Daten geprüft.
## Warum das ohne Audio-CD geht
MusicBrainz gibt zu jeder bekannten Kennung die **Spurlage** heraus, aus der
sie gerechnet wurde. Wer die Lage zurückbekommt und daraus dieselbe Kennung
rechnet, hat die Rechnung belegt ganz ohne Disc.
Genau so wurde sie am 29.08.2026 abgenommen, gegen Back in Black" von
AC/DC. Die Zahlen unten sind **nicht ausgedacht**, sie stammen aus dieser
Abfrage:
https://musicbrainz.org/ws/2/release/<id>?inc=discids&fmt=json
Kennung 3KVSIWn_ewv9z0cCizmOJzDWtsQ-
Spuren 10
Lead-Out 189281
Der Test läuft ohne Netz: Die Antwort ist hier festgehalten, nicht abgerufen.
"""
import pytest
from rippy.rip import audio_cd
from rippy.rip import musicbrainz_cd as mb
#: Aus der echten MusicBrainz-Antwort (29.08.2026, AC/DC „Back in Black"),
#: abgerufen über `https://musicbrainz.org/ws/2/discid/<kennung>?fmt=json`.
#:
#: ⚠️ Beim ersten Anlauf standen hier ausgedachte Zahlen — ich hatte beim
#: Beweis nur Spurzahl und Lead-Out ausgegeben und den Rest „passend"
#: ergänzt. Der Test wurde prompt rot, und zwar zu Recht. Diese Zahlen sind
#: abgefragt, nicht erinnert.
ACDC_VERSAETZE = [182, 23637, 47507, 63692, 79615, 98742, 117937,
133712, 151660, 170112]
ACDC_LEAD_OUT = 189281
ACDC_KENNUNG = "3KVSIWn_ewv9z0cCizmOJzDWtsQ-"
def test_die_kennung_stimmt_mit_musicbrainz_ueberein():
"""DER Beleg. Weicht auch nur ein Zeichen ab, findet MusicBrainz die Disc
nicht und zwar ohne Fehlermeldung, denn die Antwort waere dann schlicht
unbekannte Disc"."""
assert mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT,
erste=1, letzte=10) == ACDC_KENNUNG
def test_der_vorlauf_wird_wieder_hinzugerechnet():
"""Die Falle: `audio_cd` liefert LBA-Nummern (fuer das LESEN richtig),
MusicBrainz will MSF-Adressen also 150 mehr. Wer das verwechselt,
bekommt eine Kennung, die niemand kennt."""
# Dieselbe Disc, aber so, wie `toc_zerlegen` sie liefert.
spuren = []
for i, versatz in enumerate(ACDC_VERSAETZE):
naechster = (ACDC_VERSAETZE[i + 1] if i + 1 < len(ACDC_VERSAETZE)
else ACDC_LEAD_OUT)
spuren.append({"nr": i + 1,
"start": versatz - audio_cd.VORLAUF_FRAMES,
"sektoren": naechster - versatz,
"audio": True, "dauer": ""})
assert mb.kennung_aus_spuren(spuren) == ACDC_KENNUNG
def test_ohne_spuren_gibt_es_keine_kennung():
assert mb.disc_id([], 1000) == ""
assert mb.kennung_aus_spuren([]) == ""
def test_die_kennung_benutzt_das_musicbrainz_alphabet():
"""Nicht das normale Base64: + / = werden zu . _ - — sonst passt sie
nicht in eine URL und der Dienst antwortet mit 404."""
kennung = mb.disc_id(ACDC_VERSAETZE, ACDC_LEAD_OUT, letzte=10)
assert not set("+/=") & set(kennung)
def test_es_stehen_immer_99_plaetze_im_text():
"""Auch bei 10 Spuren. Wer nur die vorhandenen einsetzt, rechnet einen
anderen Hash."""
assert mb.PLAETZE == 99
# Zwei Discs mit gleicher Spurzahl, aber anderer Lage -> andere Kennung.
a = mb.disc_id([150, 20000], 40000, letzte=2)
b = mb.disc_id([150, 20001], 40000, letzte=2)
assert a != b
# ── Die Tags ────────────────────────────────────────────────────────────
ANTWORT = {
"releases": [{
"title": "Back in Black",
"date": "1980-07-25",
"artist-credit": [{"name": "AC/DC"}],
"media": [{"tracks": [
{"position": 1, "title": "Hells Bells"},
{"position": 2, "title": "Shoot to Thrill"},
]}],
}]
}
def test_tags_kommen_je_spur():
tags = mb.tags_je_spur(ANTWORT)
assert tags[1]["TITLE"] == "Hells Bells"
assert tags[2]["TRACKNUMBER"] == "2"
assert tags[1]["ALBUM"] == "Back in Black"
assert tags[1]["DATE"] == "1980", "nur das Jahr, nicht das ganze Datum"
def test_ohne_antwort_gibt_es_keine_tags():
"""Eine unbekannte Disc ist kein Fehler — die Dateien heissen dann
`Track 01.flac`, und das ist die Wahrheit."""
assert mb.tags_je_spur(None) == {}
assert mb.tags_je_spur({}) == {}
assert mb.tags_je_spur({"releases": []}) == {}
def test_mehrere_beteiligte_werden_zusammengesetzt():
"""MusicBrainz gibt „Simon" + „ & " + „Garfunkel". Wer nur den ersten
Namen nimmt, verliert die Haelfte der Band."""
antwort = {"releases": [{
"title": "X", "artist-credit": [
{"name": "Simon", "joinphrase": " & "}, {"name": "Garfunkel"}],
"media": [{"tracks": [{"position": 1, "title": "Y"}]}]}]}
assert mb.tags_je_spur(antwort)[1]["ARTIST"] == "Simon & Garfunkel"
def test_ein_abweichender_spur_kuenstler_gewinnt():
"""Sampler: Jede Spur hat ihren eigenen Interpreten, das Album einen
Sammelnamen."""
antwort = {"releases": [{
"title": "Sampler", "artist-credit": [{"name": "Various Artists"}],
"media": [{"tracks": [
{"position": 1, "title": "A", "artist-credit": [{"name": "Nena"}]}]}]}]}
tags = mb.tags_je_spur(antwort)[1]
assert tags["ARTIST"] == "Nena"
assert tags["ALBUMARTIST"] == "Various Artists"
def test_der_ordnername_ist_windows_tauglich():
"""`AC/DC - Back in Black` haette einen Unterordner aufgemacht — genau
das kam beim Beweis am 29.08.2026 heraus."""
name = mb.album_ordner(ANTWORT)
assert name == "ACDC - Back in Black"
for zeichen in audio_cd.VERBOTEN:
assert zeichen not in name
def test_ohne_treffer_kein_ordnername():
assert mb.album_ordner(None) == ""
# ── Die Abfrage ─────────────────────────────────────────────────────────
def test_die_abfrage_baut_die_richtige_adresse():
gesehen = {}
def laden(url):
gesehen["url"] = url
return b'{"releases": []}'
mb.abfragen(ACDC_KENNUNG, laden=laden)
assert ACDC_KENNUNG in gesehen["url"]
assert "fmt=json" in gesehen["url"]
assert "recordings" in gesehen["url"], "ohne Aufnahmen gibt es keine Titel"
def test_ein_netzfehler_ist_kein_grund_zu_scheitern():
"""Ein Rip darf nicht daran haengen, ob MusicBrainz gerade antwortet."""
def kaputt(url):
raise OSError("kein Netz")
assert mb.abfragen(ACDC_KENNUNG, laden=kaputt) is None
def test_ohne_kennung_wird_gar_nicht_gefragt():
assert mb.abfragen("", laden=lambda u: 1 / 0) is None
@pytest.mark.parametrize("muell", [b"kein json", b"", b"<html>"])
def test_eine_unlesbare_antwort_wirft_nicht(muell):
assert mb.abfragen("x", laden=lambda u: muell) is None
-698
View File
@@ -1,698 +0,0 @@
"""Das Fenster des Einrichtungs-Assistenten.
## Warum es das gibt
Commander am 28.08.2026:
> Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
## Warum WebView2 und nicht ein Windows-Dialog
Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit
(Entscheid 4 in `KONZEPT-V2.md` § 10) der Assistent kostet damit **null
zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen
ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau
ausdrücklich ausgeschlossen.
## Warum die Prüfungen NICHT hier stehen
Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar.
Was hier steht, lässt sich nicht testen also steht hier so wenig wie
möglich: Aufbau, Anzeige, und die Brücke zwischen beidem.
## Was passiert, wenn das Fenster nicht aufgeht
Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer
als eines, das nicht fragt.
"""
import threading
from rippy import einrichtung
TITEL = "Rippy einrichten"
BREITE = 860
HOEHE = 760
# Die Abschnitte der Installation und was sie am Balken belegen.
#
# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert
# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen
# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der
# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei
# 90 % minutenlang steht, ist schlimmer als gar keiner.
ABSCHNITTE = {
"dateien": (0.00, 0.08, "Dateien werden abgelegt"),
"ablage": (0.08, 0.10, "Ablage wird gespeichert"),
"werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"),
"fertig": (0.97, 1.00, "Fertig"),
}
class Fortschritt:
"""Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um.
Reine Rechnerei, damit sie ohne Fenster prüfbar ist und weil ein
Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als
keiner.
Zwei Regeln:
* **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads
enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der
Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang.
* **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine
Aussage über den Fortschritt.
"""
def __init__(self):
self.prozent = 0
self.abschnitt = ""
self.zeilen = []
def beginne(self, name: str) -> None:
von, _bis, titel = ABSCHNITTE[name]
self.abschnitt = titel
self._von, self._bis, _ = ABSCHNITTE[name]
self.setze(von)
def melde(self, text, anteil=None) -> None:
if text:
self.zeilen.append(str(text))
if anteil is None:
return
anteil = min(1.0, max(0.0, float(anteil)))
self.setze(self._von + (self._bis - self._von) * anteil)
def setze(self, anteil: float) -> None:
self.prozent = max(self.prozent, int(round(anteil * 100)))
def abholen(self) -> dict:
"""Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich."""
zeilen, self.zeilen = self.zeilen, []
return {"zeilen": zeilen, "prozent": self.prozent,
"abschnitt": self.abschnitt}
#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen`
#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10,
#: FOLDER=20, SAVE=30).
FOLDER_DIALOG = 20
class Bruecke:
"""Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML.
Jede Methode gibt Daten zurück, keine Erfolgsmeldungen dieselbe Regel
wie überall sonst in diesem Projekt.
"""
def __init__(self):
self.ergebnis = None
self._fenster = None
self._lauf = Fortschritt()
# ── Auskunft ────────────────────────────────────────────────────────
def vorgaben(self) -> dict:
return einrichtung.vorgaben()
def pruefen(self, ziel: str = "", port: int = 0) -> dict:
"""Der Voraussetzungs-Bericht für die angegebenen Werte."""
vorgabe = einrichtung.vorgaben()
befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"],
int(port or vorgabe["port"]))
return {
"befunde": befunde,
"darf": einrichtung.darf_installieren(befunde),
"text": einrichtung.zusammenfassung(befunde),
}
def updates(self) -> dict:
"""Gibt es für die installierten Werkzeuge etwas Neueres?
## Warum das ein EIGENER Aufruf ist (28.08.2026)
Commander: *bitte baue ein, das wenn MakeMKV oder Handbrake schon
installiert sind ein update verfügbar ist."*
Die Auskunft kostet Netz und makemkv.com braucht dafür im
schlimmsten Fall gut zwanzig Sekunden (gemessen). In der
Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben.
Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und
diese Auskunft trägt die Update-Hinweise nach.
Leere Werte heißen nicht erreichbar", nicht „kein Update" eine
Nichtauskunft ist keine Aussage.
"""
from rippy.tools import beschaffen, einrichten as werkzeuge, katalog
try:
bestand = katalog.bestand()
neueste = beschaffen.neueste_versionen()
except Exception: # noqa: BLE001
return {"erreichbar": False, "offen": []}
offen = [{"name": name,
"titel": eintrag.get("titel", name),
"installiert": eintrag.get("version", ""),
"neueste": neu}
for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)]
return {"erreichbar": any(neueste.values()), "offen": offen}
def ordner_waehlen(self, vorschlag: str = "") -> str:
"""Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde."""
# Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer
# da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in
# jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der
# nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und
# am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen
# eigenen Fehler.
try:
import webview
art = webview.FOLDER_DIALOG
except ImportError:
art = FOLDER_DIALOG
try:
gewaehlt = self._fenster.create_file_dialog(
art, directory=vorschlag or "")
except Exception: # noqa: BLE001
return ""
if not gewaehlt:
return ""
return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt)
def fortschritt(self) -> dict:
"""Was seit dem letzten Abruf passiert ist — Zeilen UND Stand.
Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl,
und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text
und Stand auseinanderlaufen.
"""
return self._lauf.abholen()
# ── Handeln ─────────────────────────────────────────────────────────
def installieren(self, optionen: dict) -> dict:
"""Installiert im Hintergrund-Thread und gibt sofort zurück.
Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter
Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster
sieht aus wie ein Absturz.
"""
threading.Thread(target=self._installieren_wirklich,
args=(optionen or {},), daemon=True).start()
return {"gestartet": True}
def _installieren_wirklich(self, optionen: dict) -> None:
from rippy import windows_app
melden = self._lauf.melde
try:
self._lauf.beginne("dateien")
melden("Dateien werden abgelegt …")
ziel = windows_app.installieren(
ziel=optionen.get("ziel") or None,
autostart=bool(optionen.get("autostart", True)),
starten_danach=False,
verknuepfen=bool(optionen.get("verknuepfungen", True)),
)
orte = {}
if optionen.get("ablage"):
orte["outputDir"] = optionen["ablage"]
# Der Arbeitsordner gehoert mit ins Setup (Commander 28.08.2026).
# Leer heisst „nimm die Vorgabe" — dann steht hier nichts, und der
# Betrieb rechnet ihn sich neben der Ablage aus.
if optionen.get("arbeit"):
orte["workDir"] = optionen["arbeit"]
if orte:
self._lauf.beginne("ablage")
melden("Ordner werden gespeichert …")
_einstellungen_schreiben(orte)
self._lauf.beginne("werkzeuge")
if optionen.get("werkzeuge_holen", True):
melden("Werkzeuge werden geprüft …")
from rippy.tools import einrichten as werkzeug_einrichtung
bericht = werkzeug_einrichtung.sicherstellen(
fortschritt=melden,
aktualisieren=bool(optionen.get("aktualisieren", False)))
melden(werkzeug_einrichtung.bericht(bericht))
bereit = bericht["bereit"]
_beta_key_holen(melden)
else:
bereit = True
self._lauf.beginne("fertig")
self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit}
melden("Fertig.", 1.0)
except Exception as ex: # noqa: BLE001
# Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig
# unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr
# passiert.
self.ergebnis = {"ok": False,
"fehler": "%s: %s" % (type(ex).__name__, ex)}
melden("Fehlgeschlagen: %s" % ex)
def stand(self) -> dict:
"""None, solange es läuft. Danach das Ergebnis."""
return self.ergebnis or {}
def als_admin(self) -> dict:
"""Startet das Setup mit Administratorrechten neu.
Nur auf Knopfdruck, nie von selbst. Der Grund steht in
`winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen
Netzlaufwerke NICHT mehr für eine Ablage auf einer Freigabe wäre das
genau der falsche Weg.
Gibt zurück, ob der Neustart angenommen wurde. Ein nein" ist der
Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt dann bleibt
das Fenster einfach offen.
"""
from rippy import windows_app
from rippy.platform import winlauf
geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei())
if geklappt:
self.schliessen()
return {"gestartet": geklappt}
def schliessen(self) -> None:
try:
self._fenster.destroy()
except Exception: # noqa: BLE001
pass
def _ablage_speichern(pfad: str) -> None:
"""Die gewählte Ablage in die Einstellungen schreiben.
## Der Fehler, der das Setup abgebrochen hat (28.08.2026)
Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den
Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit:
Fehlgeschlagen: ModuleNotFoundError: No module named 'db'
Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der
Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt
seitdem `from rippy import store as db`. Ich habe ein Modul importiert,
das ich mir aus einem Alias zusammengereimt habe genau die Sorte
erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal
im eigenen Code.
Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war
ohnehin überflüssig.
"""
_einstellungen_schreiben({"outputDir": pfad})
def _einstellungen_schreiben(neu: dict) -> None:
"""Werte in die Einstellungen mischen — lesen, setzen, zurückschreiben.
`save_settings` überschreibt das GANZE JSON, deshalb erst lesen.
"""
from rippy import config, store
werte = config.laden()
store.verbinden(config.datenbank_url(werte))
store.init_db()
aktuell = store.get_settings() or {}
aktuell.update(neu)
store.save_settings(aktuell)
def _beta_key_holen(melden=None) -> str:
"""Den kostenlosen MakeMKV-Beta-Key aus dem Forum holen und ablegen.
## Warum das ins Setup gehört (Commander 28.08.2026)
> Bezüglich des MKV Beta Keys der könnte theoretisch auch automatisch
> ausgelesen werden, ich glaube das web rippy kann das"
Er hat recht, es kann: `docker/api/makemkv_key.py` holt den Key aus dem
Forum-Thread t=1053 und läuft seit jeher täglich mit. Nachgemessen am
28.08.2026: Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen.
Nur half das beim ERSTEN Rip nicht: Die Schleife startet 60 Sekunden nach
dem Serverstart. Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt
ohne Key und MakeMKV fällt in den 30-Tage-Testmodus.
Ohne Key ist Rippy nicht kaputt; DVDs gehen weiterhin. Deshalb ist ein
Fehlschlag hier auch kein Abbruch, sondern eine Zeile im Protokoll.
Rechtlich unverändert: Das ist die öffentliche Beta-LIZENZ der Software,
NICHT ein Disc-Schlüssel. Rippy liefert und verteilt keine Disc-Schlüssel.
"""
melden = melden or (lambda *a, **k: None)
try:
import sys
from rippy.daemon import _api_pfad
# `_api_pfad` gibt den Pfad NUR zurueck — eingehaengt wird er in
# daemon.starten(). Hier laeuft der Assistent, nicht der Dienst.
pfad = _api_pfad()
if pfad and pfad not in sys.path:
sys.path.insert(0, pfad)
import makemkv_key
key = makemkv_key.fetch_current_key()
except Exception as ex: # noqa: BLE001
melden(" Beta-Key nicht erreichbar (%s) — nachtragbar unter "
"Einstellungen." % type(ex).__name__)
return ""
if not key:
melden(" Kein Beta-Key im Forum gefunden — nachtragbar unter "
"Einstellungen.")
return ""
_einstellungen_schreiben({"makemkvAppKey": key})
# Und jetzt nachsehen, ob MakeMKV ihn ueberhaupt annimmt. Am 29.08.2026
# tat es das nicht: Version 1.18.4 ist aelter als der aktuelle Schluessel
# verlangt (MSG 5020 + 5021). Ein abgelehnter Schluessel ist SCHLIMMER als
# gar keiner — ohne las MakeMKV die Disc noch, mit dem abgelehnten
# verweigerte es alles. `key_setzen_und_pruefen` nimmt ihn deshalb wieder
# weg. Begruendung in `rippy.rip.makemkv_aufruf.key_pruefen`.
from rippy.rip import makemkv_aufruf
from rippy.tools import katalog
# Was der Docker-Betrieb beim Start ausdruecklich setzt, gilt hier auch —
# uebersetzt in die Windows-Form (Registry statt settings.conf). Ohne den
# Web-Kontakt holt MakeMKV keine Disc-Schluessel fuer 4K-UHD nach.
for name in makemkv_aufruf.einstellungen_sicherstellen():
melden(" MakeMKV-Einstellung gesetzt: %s" % name)
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, katalog.finden("makemkv"))
if urteil["ok"]:
melden(" MakeMKV-Beta-Key geholt und angenommen (…%s)." % key[-6:])
else:
melden(" MakeMKV-Beta-Key geholt, aber NICHT angenommen: %s"
% urteil["grund"])
return key
def starten() -> dict:
"""Öffnet den Assistenten. Gibt zurück, was daraus geworden ist.
`{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann,
ob er ohne Assistent installiert.
"""
import webview
bruecke = Bruecke()
fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke,
width=BREITE, height=HOEHE,
min_size=(720, 560), text_select=True)
bruecke._fenster = fenster
webview.start(gui="edgechromium", private_mode=False)
return bruecke.ergebnis or {}
def moeglich() -> tuple:
"""Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)"""
from rippy import fenster as fenster_modul
return fenster_modul.verfuegbar()
# ── Die Seite ───────────────────────────────────────────────────────────
# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine
# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne
# Internet aufgehen — dort wird er am dringendsten gebraucht.
SEITE = r"""<!DOCTYPE html>
<html lang="de"><head><meta charset="utf-8">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; background:#0b0f19; color:#e2e8f0;
font:14px/1.55 "Segoe UI",system-ui,sans-serif; }
header { padding:22px 28px 16px; border-bottom:1px solid #1e293b; }
h1 { margin:0; font-size:19px; font-weight:650; }
h1 span { color:#f59e0b; }
.unter { color:#94a3b8; font-size:12.5px; margin-top:4px; }
main { padding:20px 28px 96px; }
section { margin-bottom:22px; }
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.09em;
color:#94a3b8; margin:0 0 10px; font-weight:600; }
.zeile { display:flex; gap:10px; padding:7px 0;
border-bottom:1px solid #131c2e; align-items:flex-start; }
.zeile:last-child { border-bottom:0; }
.marke { width:17px; flex:0 0 17px; font-weight:700; text-align:center; }
.ok .marke { color:#34d399; } .warnung .marke { color:#fbbf24; }
.fehler .marke { color:#f87171; }
.inhalt { flex:1; min-width:0; }
.name { font-weight:600; }
.wert { color:#cbd5e1; font-family:ui-monospace,Consolas,monospace;
font-size:12.5px; word-break:break-all; }
.abhilfe { color:#94a3b8; font-size:12px; margin-top:2px; }
label.feld { display:block; margin-bottom:14px; }
label.feld > span { display:block; font-size:12.5px; color:#cbd5e1;
margin-bottom:5px; }
.mitknopf { display:flex; gap:8px; }
input[type=text], input[type=number] {
flex:1; background:#0f1626; border:1px solid #24324a; border-radius:7px;
color:#e2e8f0; padding:9px 11px; font-size:13px; font-family:inherit; }
input:focus { outline:2px solid #f59e0b55; border-color:#f59e0b; }
button { background:#1c2740; border:1px solid #2e3d5c; color:#e2e8f0;
border-radius:7px; padding:9px 15px; font-size:13px; cursor:pointer;
font-family:inherit; }
button:hover:not(:disabled) { background:#25324f; }
button.los { background:#f59e0b; border-color:#f59e0b; color:#1a1206;
font-weight:650; padding:11px 26px; }
button.los:hover:not(:disabled) { background:#fbbf24; }
button:disabled { opacity:.45; cursor:not-allowed; }
.schalter { display:flex; gap:9px; align-items:flex-start; margin-bottom:11px;
cursor:pointer; }
.schalter input { margin:3px 0 0; accent-color:#f59e0b; }
.schalter .hinweis { color:#94a3b8; font-size:12px; }
footer { position:fixed; left:0; right:0; bottom:0; background:#0b0f19f2;
border-top:1px solid #1e293b; padding:14px 28px;
display:flex; align-items:center; gap:16px;
backdrop-filter:blur(6px); }
#urteil { flex:1; font-size:12.5px; color:#94a3b8; }
.balken-rahmen { height:9px; background:#111a2b; border-radius:99px;
overflow:hidden; margin:2px 0 10px; }
#balken { height:100%; width:0%; border-radius:99px; background:#f59e0b;
transition:width .35s ease; }
.balken-zeile { display:flex; justify-content:space-between;
font-size:12.5px; color:#cbd5e1; margin-bottom:4px; }
#prozent { font-family:ui-monospace,Consolas,monospace; color:#f59e0b;
font-variant-numeric:tabular-nums; }
#protokoll { background:#080c15; border:1px solid #1a2438; border-radius:8px;
padding:11px 13px; font-family:ui-monospace,Consolas,monospace;
font-size:12px; color:#94a3b8; max-height:190px; overflow:auto;
white-space:pre-wrap; }
.weg { display:none; }
</style></head><body>
<header>
<h1><span>&#9673;</span> Rippy einrichten</h1>
<div class="unter">Wird geprüft, was dein Rechner mitbringt danach entscheidest du, wohin.</div>
</header>
<main>
<section id="teil-pruefung">
<h2>Voraussetzungen</h2>
<div id="pruefungen"><div class="zeile"><div class="inhalt wert">wird geprüft </div></div></div>
</section>
<section id="teil-orte">
<h2>Wohin</h2>
<label class="feld"><span>Rippy selbst (Programmordner)</span>
<div class="mitknopf">
<input type="text" id="ziel">
<button onclick="waehlen('ziel')">Durchsuchen </button>
</div>
</label>
<label class="feld"><span>Fertige Filme (Ablage) darf auch ein Netzwerkpfad sein</span>
<div class="mitknopf">
<input type="text" id="ablage">
<button onclick="waehlen('ablage')">Durchsuchen </button>
</div>
</label>
<label class="feld"><span>Arbeitsordner für die Rohdaten
<div class="hinweis">Hier liegt die Disc zwischendurch am Stück bei einer
4K-UHD bis zu 100 GB. Am besten dasselbe Laufwerk wie die Ablage: dann muss
Rippy die fertige Datei am Ende nur umhängen statt sie zu kopieren.</div></span>
<div class="mitknopf">
<input type="text" id="arbeit">
<button onclick="waehlen('arbeit')">Durchsuchen </button>
</div>
</label>
<label class="feld" style="max-width:190px"><span>Port</span>
<input type="number" id="port" min="1024" max="65535">
</label>
</section>
<section id="teil-optionen">
<h2>Sonst noch</h2>
<label class="schalter"><input type="checkbox" id="autostart" checked>
<span>Mit Windows starten<div class="hinweis">Rippy läuft dann im Hintergrund und erkennt eingelegte Discs von allein.</div></span></label>
<label class="schalter"><input type="checkbox" id="verknuepfungen" checked>
<span>Symbol auf dem Desktop und im Startmenü<div class="hinweis">Ohne das ist Rippy praktisch unauffindbar.</div></span></label>
<label class="schalter"><input type="checkbox" id="werkzeuge_holen" checked>
<span>Fehlende Werkzeuge holen<div class="hinweis">HandBrake liegt bei. MakeMKV wird von makemkv.com geladen und mit dem Installer des Herstellers installiert dafür fragt Windows einmal nach Administratorrechten. Ohne MakeMKV kann Rippy keine Disc lesen.</div></span></label>
<label class="schalter"><input type="checkbox" id="aktualisieren" checked>
<span>Vorhandene Werkzeuge aktualisieren<div class="hinweis" id="update-hinweis">wird geprüft </div></span></label>
</section>
<section id="teil-lauf" class="weg">
<h2>Läuft</h2>
<div class="balken-zeile">
<span id="abschnitt">wird vorbereitet </span>
<span id="prozent">0 %</span>
</div>
<div class="balken-rahmen"><div id="balken"></div></div>
<div id="protokoll"></div>
</section>
</main>
<footer>
<div id="urteil">wird geprüft </div>
<button id="knopf-admin" class="weg" onclick="alsAdmin()">Als Administrator neu starten</button>
<button id="knopf-abbruch" onclick="pywebview.api.schliessen()">Abbrechen</button>
<button id="knopf-los" class="los" onclick="losgehen()" disabled>Installieren</button>
</footer>
<script>
const $ = (id) => document.getElementById(id);
let laeuft = false;
function zeichnePruefungen(bericht) {
const marken = { ok: '&#10003;', warnung: '!', fehler: '&#10007;' };
$('pruefungen').innerHTML = bericht.befunde.map(b =>
'<div class="zeile ' + b.zustand + '">'
+ '<div class="marke">' + (marken[b.zustand] || '?') + '</div>'
+ '<div class="inhalt"><div class="name">' + b.name + '</div>'
+ '<div class="wert">' + b.text + '</div>'
+ (b.abhilfe ? '<div class="abhilfe">' + b.abhilfe + '</div>' : '')
+ '</div></div>').join('');
$('urteil').textContent = bericht.text;
$('knopf-los').disabled = !bericht.darf || laeuft;
/* Der Knopf für Administratorrechte erscheint NUR, wenn das Schreibrecht
am gewählten Ziel fehlt. Nicht dauerhaft: Erhöht sieht Rippy die
eingebundenen Netzlaufwerke nicht mehr wer eine Freigabe als Ablage
will, darf ihn gerade NICHT drücken. */
const rechteProblem = bericht.befunde.some(
b => b.name === 'Schreibrecht' && b.zustand === 'fehler');
$('knopf-admin').classList.toggle('weg', !rechteProblem || laeuft);
}
async function updatesNachtragen() {
/* Getrennt vom Voraussetzungs-Bericht: Die Auskunft kostet Netz, und
makemkv.com braucht dafuer im schlimmsten Fall gut zwanzig Sekunden.
Solange steht "wird geprueft" da, statt das Fenster leer zu lassen. */
const stand = await pywebview.api.updates();
const hinweis = $('update-hinweis');
if (!stand.erreichbar) {
hinweis.textContent = 'Update-Quellen nicht erreichbar — es wird nichts aktualisiert.';
$('aktualisieren').checked = false;
return;
}
if (!stand.offen.length) {
hinweis.textContent = 'Alles auf dem neuesten Stand.';
return;
}
hinweis.textContent = stand.offen
.map(o => o.titel + ' ' + o.installiert + '' + o.neueste)
.join(' · ');
}
async function alsAdmin() {
$('knopf-admin').disabled = true;
const antwort = await pywebview.api.als_admin();
if (!antwort.gestartet) {
$('knopf-admin').disabled = false;
$('urteil').textContent = 'Der Neustart als Administrator wurde abgebrochen.';
}
}
async function pruefen() {
if (laeuft) return;
zeichnePruefungen(await pywebview.api.pruefen($('ziel').value, Number($('port').value)));
}
async function waehlen(feld) {
const ordner = await pywebview.api.ordner_waehlen($(feld).value);
if (ordner) { $(feld).value = ordner; if (feld === 'ziel') pruefen(); }
}
async function losgehen() {
laeuft = true;
$('knopf-los').disabled = true;
$('knopf-los').textContent = 'Läuft …';
$('teil-lauf').classList.remove('weg');
$('urteil').textContent = 'Rippy wird eingerichtet …';
await pywebview.api.installieren({
ziel: $('ziel').value,
ablage: $('ablage').value,
arbeit: $('arbeit').value,
port: Number($('port').value),
autostart: $('autostart').checked,
verknuepfungen: $('verknuepfungen').checked,
werkzeuge_holen: $('werkzeuge_holen').checked,
aktualisieren: $('aktualisieren').checked,
});
verfolgen();
}
async function verfolgen() {
const takt = await pywebview.api.fortschritt();
if (takt.zeilen && takt.zeilen.length) {
const p = $('protokoll');
p.textContent += takt.zeilen.join('\n') + '\n';
p.scrollTop = p.scrollHeight;
}
if (typeof takt.prozent === 'number') {
$('balken').style.width = takt.prozent + '%';
$('prozent').textContent = takt.prozent + ' %';
}
if (takt.abschnitt) $('abschnitt').textContent = takt.abschnitt;
const ergebnis = await pywebview.api.stand();
if (ergebnis && ergebnis.ok !== undefined) {
// Am Ende ist der Balken voll und die Farbe sagt, wie es ausging.
$('balken').style.width = '100%';
$('prozent').textContent = '100 %';
$('balken').style.background = ergebnis.ok ? '#34d399' : '#f87171';
$('abschnitt').textContent = ergebnis.ok ? 'Fertig' : 'Abgebrochen';
$('knopf-abbruch').classList.add('weg');
$('knopf-los').disabled = false;
$('knopf-los').textContent = 'Rippy öffnen';
$('knopf-los').onclick = () => pywebview.api.schliessen();
$('urteil').textContent = ergebnis.ok
? (ergebnis.bereit ? 'Fertig — Rippy ist einsatzbereit.'
: 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).')
: ('Fehlgeschlagen: ' + ergebnis.fehler);
return;
}
setTimeout(verfolgen, 700);
}
window.addEventListener('pywebviewready', async () => {
const v = await pywebview.api.vorgaben();
$('ziel').value = v.ziel;
$('ablage').value = v.ablage;
$('arbeit').value = v.arbeit;
$('port').value = v.port;
$('autostart').checked = v.autostart;
$('verknuepfungen').checked = v.verknuepfungen;
$('werkzeuge_holen').checked = v.werkzeuge_holen;
pruefen();
updatesNachtragen();
$('port').addEventListener('change', pruefen);
$('ziel').addEventListener('change', pruefen);
});
</script>
</body></html>
"""
# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests).
SEITE_LAENGE = len(SEITE)
def seite_enthaelt(text: str) -> bool:
"""Kleine Hilfe für Tests: Steht etwas in der Seite?"""
return text in SEITE
-243
View File
@@ -1,243 +0,0 @@
"""Standalone-Betrieb: Auftraege lokal zustellen und im selben Prozess abarbeiten.
## Was hier zusammenkommt
Das ist das letzte Stueck, damit Rippy unter Windows nicht nur ANZEIGEN,
sondern auch ARBEITEN kann:
celery_client.zusteller_setzen() die API reiht lokal ein statt an Celery
rippy.queue.lokal die Auftragstabelle mit Lease
rippy.queue.laeufer holt sie und arbeitet sie ab
ablauf.rippen / .komprimieren der Ablauf selbst, ohne Celery
Der Ablauf ist derselbe wie im Docker-Betrieb Zeile fuer Zeile dieselbe
Datei. Was sich unterscheidet, ist allein die Zustellung.
## Warum die Task-NAMEN erhalten bleiben
Die API schickt `"worker.tasks.rip_disc"` los. Dieser Name ist der Vertrag
zwischen API und Worker; `test_api_smoke.py` nagelt ihn ausdruecklich fest.
Hier wird er deshalb NICHT durch etwas Eigenes ersetzt, sondern uebersetzt
so bleibt beides gueltig, und ein Betrieb laesst sich umstellen, ohne dass
die API etwas davon merkt.
## Warum die Kompression NICHT weitergereicht wird
`ablauf.rippen()` nimmt einen Rueckruf `weiterreichen`, mit dem die
Kompression an einen anderen Knoten geht. Im Standalone-Betrieb gibt es
keinen anderen Knoten hier komprimiert derselbe Prozess weiter. Deshalb
wird der Rueckruf bewusst NICHT gestellt: `ablauf` ruft dann selbst
`komprimieren()` auf, im selben Auftrag, ohne Umweg ueber die Queue.
Das ist auch das Ehrlichere: Eine Queue mit genau einem Bearbeiter, die
Arbeit an sich selbst weiterreicht, ist nur Zeremonie.
"""
import os
import sys
import threading
# Die Auftragsarten, die die API kennt — auf die Formen, die `ablauf` versteht.
AUFTRAGSARTEN = {
"worker.tasks.rip_disc": "rip",
"worker.tasks.transcode_files": "transcode",
"worker.tasks.scan_tracks": "scan",
}
def _worker_pfad() -> str:
"""Wo liegen die Worker-Module (ablauf.py, ripping.py, medien.py)?
Dieselben drei Faelle wie bei der API (siehe `daemon._api_pfad`): aus dem
Repo gestartet, im PyInstaller-Paket, oder installiert.
"""
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
return os.path.join(gebuendelt, "worker")
hier = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(os.path.dirname(hier))
kandidaten = [
os.path.join(repo, "docker", "worker"),
os.path.join(os.path.dirname(sys.executable), "worker"),
]
for pfad in kandidaten:
if os.path.isfile(os.path.join(pfad, "ablauf.py")):
return pfad
raise RuntimeError(
"Die Worker-Module wurden nicht gefunden. Gesucht wurde in:\n "
+ "\n ".join(kandidaten)
+ "\nOhne sie kann Rippy nicht rippen."
)
def _ablauf():
"""`ablauf` importieren — erst beim ersten Bedarf.
Nicht auf Modulebene: Das Modul zieht `ripping`, `medien` und `db` nach,
und die brauchen einen gesetzten sys.path. Ausserdem soll der Import
nicht schon beim Start des Servers passieren, sondern dann, wenn wirklich
gearbeitet wird.
"""
pfad = _worker_pfad()
if pfad not in sys.path:
sys.path.insert(0, pfad)
import ablauf
return ablauf
# ── Zustellung ──────────────────────────────────────────────────────────
def zustellung_bauen(bus=None):
"""Gibt die Funktion zurueck, die `celery_client.zusteller_setzen` erwartet."""
from rippy.queue import lokal
def zustellen(task_name: str, args: list, queue: str = None):
art = AUFTRAGSARTEN.get(task_name)
if art is None:
raise ValueError(
f"Unbekannte Auftragsart {task_name!r}. Bekannt sind: "
+ ", ".join(sorted(AUFTRAGSARTEN))
+ ". Wer eine neue einfuehrt, traegt sie in "
"rippy/standalone.py AUFTRAGSARTEN ein — sonst landet sie "
"im Standalone-Betrieb nirgends, und zwar lautlos."
)
if art == "rip":
device_path, job_id, target_dir = (list(args) + [None, None, None])[:3]
auftrag_id = lokal.einreihen(
job_id, "rip", faehigkeiten={"art": "rip"},
payload={"device_path": device_path, "target_dir": target_dir})
elif art == "transcode":
job_id, raw_dir, final_dir = (list(args) + [None, None, None])[:3]
auftrag_id = lokal.einreihen(
job_id, "transcode", faehigkeiten={"art": "transcode"},
payload={"raw_dir": raw_dir, "final_dir": final_dir})
else: # scan
device_path = args[0] if args else ""
auftrag_id = lokal.einreihen(
"", "scan", faehigkeiten={"art": "scan"},
payload={"device_path": device_path})
if bus:
try:
bus.senden("system.notice",
{"level": "info", "text": f"{art} eingereiht"})
except Exception:
pass
return auftrag_id
return zustellen
# ── Ausfuehrung ─────────────────────────────────────────────────────────
def ausfuehren(auftrag: dict):
"""Arbeitet EINEN Auftrag ab — der Laeufer ruft das auf."""
import json
ablauf = _ablauf()
try:
payload = json.loads(auftrag.get("payload") or "{}")
except (ValueError, TypeError):
payload = {}
art = auftrag.get("art")
if art == "rip":
# KEIN `weiterreichen`: Im Standalone-Betrieb komprimiert derselbe
# Prozess weiter (siehe Modul-Docstring).
return ablauf.rippen(payload.get("device_path"), auftrag["job_id"],
payload.get("target_dir"))
if art == "transcode":
return ablauf.komprimieren(auftrag["job_id"], payload.get("raw_dir"),
payload.get("final_dir"))
if art == "scan":
return ablauf.scan_tracks(payload.get("device_path"))
raise ValueError(f"Unbekannte Auftragsart im Auftrag: {art!r}")
# ── Herzschlag ──────────────────────────────────────────────────────────
HERZSCHLAG_SEKUNDEN = 60
def herzschlag_starten(knoten: str, store, takt: float = HERZSCHLAG_SEKUNDEN):
"""Meldet DIESEN Prozess als Arbeiter — mit seinen Faehigkeiten.
## Warum das noetig ist
`/capabilities` liest die `workers`-Tabelle. Gefuellt hat sie bisher der
Celery-Herzschlag in `celery_app.py`. Im Standalone-Betrieb gibt es den
nicht also stand dort 0 Worker", und im UI blieb die Encoder-Auswahl
LEER. Rippy haette auf einem Rechner mit AMD-Hardwarebeschleunigung
behauptet, es koenne nichts.
Gemeldet wird, was `caps.py` MISST: die Encoder, die HandBrake auf dieser
Maschine wirklich anbietet, die Preset-Namen dieser HandBrake-Fassung,
CPU-Modell, Kerne und Vektorbefehle. Nichts davon wird behauptet das
ist die Lehre aus Etappe 19 (Encoder wurden behauptet statt gemessen").
"""
import threading
import time
def schlagen():
while True:
try:
pfad = _worker_pfad()
if pfad not in sys.path:
sys.path.insert(0, pfad)
import caps
store.save_worker(knoten, caps.erkenne_encoder(),
caps.werkzeug_versionen())
except Exception as e:
# Nicht sterben — aber auch nicht schweigen.
try:
store.add_log("warning", "standalone",
f"Faehigkeiten-Meldung fehlgeschlagen: {e}")
except Exception:
pass
time.sleep(takt)
threading.Thread(target=schlagen, daemon=True, name="rippy-herzschlag").start()
# ── Alles zusammen ──────────────────────────────────────────────────────
def starten(knoten: str = None, bus=None, store=None, slots: int = 1) -> list:
"""Zustellung umbiegen und die Laeufer starten. Gibt die Laeufer zurueck.
`slots` ist die Zahl gleichzeitiger Auftraege. Vorgabe 1: Ein Rip belegt
das Laufwerk, und zwei gleichzeitige Encodes auf einem Rechner sind
langsamer als zwei nacheinander.
"""
import socket
from rippy.queue.laeufer import Laeufer
knoten = knoten or socket.gethostname()
# Die API reiht ab jetzt lokal ein statt an Celery.
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "docker", "api"))
try:
import celery_client
except ImportError: # im Paket liegt sie woanders
from rippy.daemon import _api_pfad
sys.path.insert(0, _api_pfad())
import celery_client
celery_client.zusteller_setzen(zustellung_bauen(bus))
# Sich selbst als Arbeiter melden — sonst zeigt das UI "0 Worker" und
# eine leere Encoder-Auswahl, obwohl dieser Rechner alles kann.
if store is not None:
herzschlag_starten(knoten, store)
laeufer = []
for nummer in range(max(1, slots)):
lauf = Laeufer(ausfuehren, f"{knoten}#{nummer}",
kann={"art=rip", "art=transcode", "art=scan"},
bus=bus, store=store)
threading.Thread(target=lauf.schleife, daemon=True,
name=f"rippy-laeufer-{nummer}").start()
laeufer.append(lauf)
return laeufer
-13
View File
@@ -105,19 +105,6 @@ def test_die_auskunft_im_container_nennt_die_container_orte():
# ── Die Laufwerke ───────────────────────────────────────────────────────
def test_nur_feste_platten_und_netzlaufwerke():
"""Ein CD-Laufwerk als Arbeitsverzeichnis waere Unsinn — dort liegt die
Disc, die gerade gerippt wird. Ein USB-Stick fuer 100 GB eine Falle."""
arten = {"C:\\": 3, "D:\\": 3, "E:\\": 5, "F:\\": 2, "Z:\\": 4}
gefunden = betrieb.windows_laufwerke(art=lambda p: arten.get(p, 1),
buchstaben=list(arten))
assert gefunden == ["C:\\", "D:\\", "Z:\\"]
def test_ohne_windows_gibt_es_keine_laufwerke():
"""Auf dem Linux-Runner der Ampel gibt es kein GetDriveTypeW. Das muss
eine leere Liste sein, kein Fehler."""
assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == []
@pytest.mark.parametrize("werte,container", [
-139
View File
@@ -1,139 +0,0 @@
"""Der Daemon-Start — die Teile, die ohne laufenden Server pruefbar sind.
Nicht geprueft wird hier, dass uvicorn hochkommt; das ist ein Rauchtest fuer
den Betrieb. Geprueft wird, was davor entschieden wird und genau dort
sitzen die Fehler, die man spaeter nicht mehr findet:
* Ein argparse-Vorgabewert, der die Konfigurationsdatei schlaegt.
* Eine Datenbank, die am falschen Ort landet, weil der Store erst NACH dem
Import der API umgestellt wird.
"""
import os
import sys
from rippy import config, daemon
# ── Argumente ───────────────────────────────────────────────────────────
def test_ohne_argumente_bleibt_alles_offen():
"""DIE Falle bei argparse: Ein Vorgabewert im Parser sieht aus wie eine
Angabe des Nutzers und schlaegt damit die Konfigurationsdatei. Der
Eintrag dort waere wirkungslos, und niemand wuesste warum.
"""
assert daemon.flags_aus_argumenten(daemon.argumente([])) == {}
def test_port_kommt_als_flag_durch():
flags = daemon.flags_aus_argumenten(daemon.argumente(["--port", "9000"]))
assert flags == {"server": {"port": 9000}}
def test_mehrere_angaben_landen_im_richtigen_abschnitt():
flags = daemon.flags_aus_argumenten(
daemon.argumente(["--host", "127.0.0.1", "--port", "8080",
"--profil", "standalone", "--kein-ui"]))
assert flags["profil"] == "standalone"
assert flags["server"] == {"host": "127.0.0.1", "port": 8080, "ui": False}
def test_db_flag_stellt_auf_sqlite_um():
flags = daemon.flags_aus_argumenten(daemon.argumente(["--db", "C:/tmp/r.db"]))
assert flags["store"] == {"treiber": "sqlite", "pfad": "C:/tmp/r.db"}
def test_flag_schlaegt_die_konfigurationsdatei():
"""Die Praezedenz aus config.py — hier am echten Daemon-Weg nachgeprueft."""
werte = config.laden(
umgebung={"RIPPY_SERVER__PORT": "5000"},
flags=daemon.flags_aus_argumenten(daemon.argumente(["--port", "9999"])),
)
assert werte["server"]["port"] == 9999
# ── Pfade ───────────────────────────────────────────────────────────────
def test_api_pfad_wird_gefunden():
"""Ohne die API-Module startet gar nichts. Wenn sich die Verzeichnis-
struktur aendert, soll das HIER auffallen und nicht beim Nutzer."""
pfad = daemon._api_pfad()
assert os.path.isfile(os.path.join(pfad, "main.py"))
def test_ui_pfad_ist_entweder_da_oder_ehrlich_leer():
"""Leerer String heisst „kein UI mitgeliefert" — das ist ein gueltiger
Zustand (Kopfloser Betrieb). Was NICHT passieren darf: ein Pfad, den es
nicht gibt; dann liefe StaticFiles ins Leere."""
pfad = daemon._ui_pfad()
assert pfad == "" or os.path.isdir(pfad)
def test_kopie_neben_dem_programm_geht_vor_dem_paket(tmp_path, monkeypatch):
"""DER Befund vom 28.08.2026. Ein Dienst lief eine Stunde:
`/api/health` gab 200, `/` gab 404, im Fenster stand
`{"detail":"Not Found"}`.
Der Server war in Ordnung sein Entpack-Verzeichnis unter `%TEMP%` war
es nicht: 31 Eintraege statt 44, kein `ui`. `StaticFiles` schlaegt bei
JEDER Anfrage dort nach, also wurde aus einer laufenden Oberflaeche eine
404, waehrend die API weiter gesund meldete.
Die installierte Kopie hat deshalb Vorrang. Faellt dieser Test aus, ist
der Dienst wieder darauf angewiesen, dass niemand das Temp aufraeumt.
"""
installiert = tmp_path / "installiert"
(installiert / "ui").mkdir(parents=True)
(installiert / "ui" / "index.html").write_text("<!DOCTYPE html>",
encoding="utf-8")
paket = tmp_path / "temp" / "_MEI4711"
(paket / "ui").mkdir(parents=True)
monkeypatch.setattr(daemon, "_programmordner",
lambda: str(installiert / "Rippy.exe"))
monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False)
assert daemon._ui_pfad() == str(installiert / "ui")
def test_ohne_kopie_bleibt_das_paket_der_rueckfall(tmp_path, monkeypatch):
"""Wer die Setup-Datei direkt startet, hat keinen Installationsordner.
Dann ist das Entpack-Verzeichnis richtig nur eben nicht auf Dauer."""
paket = tmp_path / "_MEI4711"
(paket / "ui").mkdir(parents=True)
monkeypatch.setattr(daemon, "_programmordner",
lambda: str(tmp_path / "leer" / "RippySetup.exe"))
monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False)
assert daemon._ui_pfad() == str(paket / "ui")
def test_standard_konfigpfad_passt_zur_plattform():
pfad = daemon.standard_konfigpfad()
if os.name == "nt":
assert pfad.endswith(os.path.join("Rippy", "rippy.toml"))
else:
assert pfad == "/etc/rippy/rippy.toml"
# ── Konfiguration einlesen ──────────────────────────────────────────────
def test_fehlende_konfigdatei_ist_kein_fehler(tmp_path, monkeypatch):
monkeypatch.delenv("RIPPY_CONFIG", raising=False)
args = daemon.argumente(["--config", str(tmp_path / "gibt-es-nicht.toml")])
werte, datei, unbekannt = daemon.einstellungen_laden(args)
assert werte["server"]["port"] == 7788 # Vorgabe
assert unbekannt == []
def test_unbekannte_eintraege_werden_gemeldet(tmp_path, monkeypatch):
"""Sie werden NICHT uebernommen (config.py) — aber sie muessen sichtbar
sein. Ein Tippfehler, der stillschweigend wirkungslos bleibt, kostet
Stunden."""
if config.tomllib is None:
import pytest
pytest.skip("tomllib gibt es erst ab Python 3.11")
datei = tmp_path / "rippy.toml"
datei.write_text("[server]\nport = 8080\nprot = 9090\n", encoding="utf-8")
monkeypatch.delenv("RIPPY_CONFIG", raising=False)
werte, _, unbekannt = daemon.einstellungen_laden(
daemon.argumente(["--config", str(datei)]))
assert werte["server"]["port"] == 8080
assert unbekannt == ["server.prot"]
-281
View File
@@ -1,281 +0,0 @@
"""Der Einrichtungs-Assistent — die Pruefungen, ohne etwas zu installieren.
## Warum es diese Tests gibt
Commander am 28.08.2026: *Dann haette ich beim Setup' auch erwartet das nen
echtes setup passiert wo will ich das hinspeichern, nen pre requirement
check usw. Da kommt garnichts Rippy geht einfach auf."*
Er hatte recht: Ein Doppelklick installierte stillschweigend und oeffnete das
Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts.
Alle Pruefungen sind eingespritzt kein Fenster, keine Installation, kein
WebView2. Sonst waere kein Laufwerk" nur auf einem Rechner ohne Laufwerk
pruefbar, und genau dort sieht es niemand.
"""
import os
from rippy import einrichtung as e
def _p(zustand, name="X"):
return {"name": name, "zustand": zustand, "text": "", "abhilfe": ""}
# ── Was blockiert und was nicht ─────────────────────────────────────────
def test_ohne_fehler_darf_installiert_werden():
assert e.darf_installieren([_p(e.OK), _p(e.WARNUNG)]) is True
def test_ein_fehler_blockiert():
assert e.darf_installieren([_p(e.OK), _p(e.FEHLER)]) is False
def test_eine_warnung_blockiert_NICHT():
"""Eine Warnung, die den Knopf sperrt, ist eine Bevormundung. Rippy ohne
optisches Laufwerk ist ein vorgesehener Betriebsfall eine reine
Komprimier-Maschine."""
assert e.darf_installieren([_p(e.WARNUNG), _p(e.WARNUNG)]) is True
# ── Die einzelnen Pruefungen ────────────────────────────────────────────
def test_windows_10_und_11_gehen():
assert e.pruefe_windows(22631)["zustand"] == e.OK
assert e.pruefe_windows(19045)["zustand"] == e.OK
def test_zu_altes_windows_ist_ein_fehler():
befund = e.pruefe_windows(7601) # Windows 7
assert befund["zustand"] == e.FEHLER
assert befund["abhilfe"], "ein Befund ohne Abhilfe laesst den Nutzer ratlos"
def test_fehlendes_webview_ist_nur_eine_warnung():
"""Ohne WebView2 faellt Rippy auf den Browser zurueck. Das ist weniger
schoen, aber es LAEUFT ein Fehler waere hier eine Luege."""
befund = e.pruefe_webview(version="")
assert befund["zustand"] == e.WARNUNG
assert "Browser" in befund["abhilfe"]
def test_vorhandenes_webview_ist_ok():
assert e.pruefe_webview(version="151.0.4129.107")["zustand"] == e.OK
def test_zu_wenig_platz_ist_ein_fehler():
befund = e.pruefe_platz("C:\\egal", frei_mb=100)
assert befund["zustand"] == e.FEHLER
assert "500" in befund["text"]
def test_genug_platz_ist_ok():
assert e.pruefe_platz("C:\\egal", frei_mb=5000)["zustand"] == e.OK
def test_fehlendes_schreibrecht_ist_ein_fehler():
"""`os.access(..., W_OK)` luegt unter Windows — es beantwortet die Frage
anhand des Nur-Lesen-Flags, nicht anhand der Zugriffsrechte. Deshalb wird
wirklich geschrieben."""
befund = e.pruefe_schreibrecht("C:\\Programme\\Rippy",
probe=(False, "Zugriff verweigert"))
assert befund["zustand"] == e.FEHLER
assert "Benutzer" in befund["abhilfe"]
def test_schreibprobe_am_echten_ordner(tmp_path):
befund = e.pruefe_schreibrecht(str(tmp_path))
assert befund["zustand"] == e.OK
# Und die Probe darf nichts hinterlassen.
assert list(tmp_path.iterdir()) == []
# ── Administrator und Netzlaufwerke ─────────────────────────────────────
def test_normal_gestartet_ist_der_richtige_zustand():
"""Rippy braucht KEINE Administratorrechte — Vorgabeordner unter
%LOCALAPPDATA%, Autostart unter HKCU."""
befund = e.pruefe_administrator(erhoeht=False)
assert befund["zustand"] == e.OK
def test_als_administrator_wird_vor_den_fehlenden_netzlaufwerken_gewarnt():
"""DER Befund des Commanders (28.08.2026): „Wenn man das Tool als Admin
startet, kann man im explorer kein netzlaufwerk waehlen."
Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene
Netzlaufwerke haengen am Token der Sitzung. Windows-Verhalten, kein
Rippy-Fehler aber Rippy hat ihn hineinlaufen lassen."""
befund = e.pruefe_administrator(erhoeht=True, laufwerke=["X:", "Y:", "Z:"])
assert befund["zustand"] == e.WARNUNG
assert "X:" in befund["abhilfe"]
assert "UNC" in befund["abhilfe"] or "Server" in befund["abhilfe"]
def test_fehlendes_laufwerk_wird_beim_namen_genannt():
"""„Schreibrechte fehlen" war richtig im Wortlaut und unbrauchbar als
Auskunft: Das Laufwerk war fuer den erhoehten Prozess gar nicht da."""
# `laufwerk_da=False` eingespritzt: Die Frage "existiert Q:" laesst sich
# auf dem Linux-Runner der Ampel gar nicht beantworten. Ohne den
# Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit
# genau diesem fehlenden Laufwerk pruefbar -- also nirgends.
befund = e.pruefe_schreibrecht(
"Q:" + chr(92) + "Rippy", probe=(False, "Pfad nicht gefunden"),
erhoeht=True, laufwerk_da=False)
assert befund["zustand"] == e.FEHLER
assert "Q:" in befund["text"]
assert "nicht vorhanden" in befund["text"]
assert "Administrator" in befund["abhilfe"]
def test_programme_ordner_erklaert_den_teufelskreis():
"""Program Files verlangt Administrator, Administrator versteckt die
Netzlaufwerke und Rippy braucht beides nicht."""
befund = e.pruefe_schreibrecht(
"C:" + chr(92) + "Program Files (x86)" + chr(92) + "Rippy",
probe=(False, "Zugriff verweigert"))
assert befund["zustand"] == e.FEHLER
assert "Administratorrechte" in befund["abhilfe"]
assert "Vorgabeordner" in befund["abhilfe"]
def test_programme_wird_erkannt():
B = chr(92)
assert e._unter_programme("C:" + B + "Program Files" + B + "Rippy") is True
assert e._unter_programme("C:" + B + "Program Files (x86)" + B + "R") is True
assert e._unter_programme("C:" + B + "Users" + B + "X" + B + "Rippy") is False
def test_belegter_port_ist_nur_eine_warnung():
"""Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine
Neuinstallation genau richtig."""
befund = e.pruefe_port(7788, belegt=True)
assert befund["zustand"] == e.WARNUNG
assert "Rippy" in befund["abhilfe"]
def test_freier_port_ist_ok():
assert e.pruefe_port(7788, belegt=False)["zustand"] == e.OK
def test_kein_laufwerk_ist_nur_eine_warnung():
"""install.sh sagt seit Langem: ohne Laufwerk „laeuft trotzdem durch, dann
ist das eine reine KOMPRIMIER-Maschine"."""
befund = e.pruefe_laufwerk(laufwerke=[])
assert befund["zustand"] == e.WARNUNG
assert "komprimieren" in befund["abhilfe"]
def test_gefundenes_laufwerk_wird_genannt():
befund = e.pruefe_laufwerk(laufwerke=["\\\\.\\G:"])
assert befund["zustand"] == e.OK
assert "G:" in befund["text"]
# ── Die Werkzeuge ───────────────────────────────────────────────────────
def _bestand(makemkv, handbrake):
return {
"makemkv": {"titel": "MakeMKV", "da": makemkv, "version": "1.18.4",
"pflicht": True},
"handbrake": {"titel": "HandBrakeCLI", "da": handbrake,
"version": "1.11.2", "pflicht": False},
}
def test_fehlendes_makemkv_ist_eine_warnung_mit_ausweg():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(False, True))}
assert befunde["MakeMKV"]["zustand"] == e.WARNUNG
assert "makemkv.com" in befunde["MakeMKV"]["abhilfe"]
def test_fehlendes_handbrake_ist_kein_problem():
"""Es liegt im Paket — das Setup legt es beim Installieren daneben."""
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, False))}
assert befunde["HandBrakeCLI"]["zustand"] == e.OK
assert "mitgeliefert" in befunde["HandBrakeCLI"]["text"]
def test_vorhandene_werkzeuge_zeigen_ihre_version():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, True))}
assert befunde["MakeMKV"]["text"] == "1.18.4"
# ── Die Zusammenfassung ─────────────────────────────────────────────────
def test_zusammenfassung_nennt_die_fehler_beim_namen():
text = e.zusammenfassung([_p(e.FEHLER, "Speicherplatz"), _p(e.OK, "Port")])
assert "nicht möglich" in text
assert "Speicherplatz" in text
def test_zusammenfassung_nennt_die_hinweise():
text = e.zusammenfassung([_p(e.WARNUNG, "MakeMKV"), _p(e.OK, "Port")])
assert "MakeMKV" in text
assert "nicht möglich" not in text
def test_alles_gruen_sagt_es_auch_so():
assert e.zusammenfassung([_p(e.OK), _p(e.OK)]) == "Alles bereit."
def test_jede_pruefung_hat_dieselbe_form():
"""Ein fehlendes Feld waere in der Oberflaeche ein `undefined`."""
for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""),
e.pruefe_administrator(erhoeht=True, laufwerke=["X:"]),
e.pruefe_administrator(erhoeht=False),
e.pruefe_laufwerk(laufwerke=[]),
e.pruefe_platz("C:\\x", frei_mb=9999),
e.pruefe_port(7788, belegt=False)]
+ e.pruefe_werkzeuge(_bestand(False, False))):
assert set(befund) == {"name", "zustand", "text", "abhilfe"}, befund
assert befund["zustand"] in (e.OK, e.WARNUNG, e.FEHLER)
if befund["zustand"] != e.OK:
assert befund["abhilfe"], (
"%s sagt nicht, was zu tun ist" % befund["name"])
# ── Die Vorgaben ────────────────────────────────────────────────────────
def test_vorgaben_sind_vollstaendig():
if os.name != "nt":
import pytest
pytest.skip("Registry-Vorgaben gibt es nur unter Windows")
v = e.vorgaben()
assert set(v) == {"ziel", "ablage", "arbeit", "port", "autostart",
"verknuepfungen", "werkzeuge_holen"}
assert v["port"] == 7788
assert v["ziel"] and v["ablage"] and v["arbeit"]
for feld in ("ablage", "arbeit"):
assert "/app/" not in v[feld], "Container-Pfad in einer Windows-Vorgabe"
def test_netzwerkpfad_als_administrator_wird_erklaert():
"""Der zweite Teil des Befunds: Auch Netz-ANMELDUNGEN haengen am Token
der Sitzung. Ein erhoehter Prozess kennt die Zugangsdaten zur Freigabe
nicht und bekommt 'Zugriff verweigert' -- dieselbe Meldung wie bei einem
echten Rechteproblem."""
B = chr(92)
befund = e.pruefe_schreibrecht(
B + B + "NAS" + B + "Filme", probe=(False, "Zugriff verweigert"),
erhoeht=True)
assert befund["zustand"] == e.FEHLER
assert "ohne Administrator" in befund["abhilfe"]
def test_netzwerkpfad_ohne_administrator_bekommt_die_normale_meldung():
B = chr(92)
befund = e.pruefe_schreibrecht(
B + B + "NAS" + B + "Filme", probe=(False, "Netzwerkpfad nicht gefunden"),
erhoeht=False)
assert befund["zustand"] == e.FEHLER
assert "Administrator" not in befund["abhilfe"] or "keine" in befund["abhilfe"]
def test_ein_vorhandenes_laufwerk_ist_kein_laufwerks_problem():
"""Sonst waere JEDER Schreibfehler auf C: ein 'Laufwerk fehlt'."""
B = chr(92)
assert e.laufwerk_fehlt("C:" + B + "Programme", vorhanden=True) is False
assert e.laufwerk_fehlt("Q:" + B + "Rippy", vorhanden=False) is True
def test_unc_und_posix_sind_keine_laufwerksbuchstaben():
B = chr(92)
assert e.laufwerk_fehlt(B + B + "NAS" + B + "Filme") is False
assert e.laufwerk_fehlt("/srv/rippy") is False
-198
View File
@@ -1,198 +0,0 @@
"""Das native Fenster: Verfuegbarkeit, Rueckfall, Speicherort.
Ein Fenster laesst sich in einem Test nicht oeffnen es wuerde auf einen
Doppelklick warten, den niemand macht. Geprueft wird deshalb alles DAVOR und
DANEBEN: die Entscheidung, ob ein Fenster moeglich ist, und der Rueckfall auf
den Browser. Genau dort sassen die Fehler, die dieses Projekt schon zweimal
gekostet hat.
"""
import os
import pytest
from rippy import fenster
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Der Registry-Wert ───────────────────────────────────────────────────
def test_null_version_gilt_als_nicht_vorhanden():
"""DER Fallstrick bei WebView2: EdgeUpdate LOESCHT den Schluessel bei
einer Deinstallation nicht, sondern setzt `pv` auf `0.0.0.0`. Wer nur
auf die Existenz des Schluessels prueft, oeffnet ein Fenster, das nichts
anzeigen kann und der Nutzer sieht einen weissen Kasten."""
assert fenster.echte_version("0.0.0.0") == ""
def test_echte_version_kommt_durch():
assert fenster.echte_version("151.0.4129.107") == "151.0.4129.107"
@pytest.mark.parametrize("wert", ["", None, " "])
def test_leere_werte_gelten_nicht(wert):
assert fenster.echte_version(wert) == ""
# ── Die Entscheidung ────────────────────────────────────────────────────
def test_verfuegbar_gibt_immer_eine_begruendung():
"""Auch beim Ja. Ein blosses False zwingt den Naechsten zum Nachforschen
und ein Fehlschlag ohne Begruendung ist in diesem Projekt schon
zweimal teuer geworden."""
geht, grund = fenster.verfuegbar()
assert isinstance(geht, bool)
assert grund, "auch ein Ja braucht eine Begruendung"
@pytest.mark.skipif(os.name == "nt", reason="prueft das Verhalten auf Nicht-Windows")
def test_ohne_windows_kein_fenster():
geht, grund = fenster.verfuegbar()
assert geht is False
assert "Windows" in grund
@pytest.mark.skipif(os.name == "nt", reason="winreg gibt es nur unter Windows")
def test_version_ohne_windows_ist_leer_statt_zu_werfen():
"""Der Kern laeuft auch unter Linux (Docker, headless). Ein ImportError
auf winreg wuerde dort jeden Aufruf sprengen."""
assert fenster.webview2_version() == ""
def test_fehlende_bibliothek_verhindert_das_fenster(monkeypatch):
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: False)
geht, grund = fenster.verfuegbar()
assert geht is False
assert "pywebview" in grund
def test_fehlende_laufzeit_verhindert_das_fenster(monkeypatch):
"""Lieber Browser als ein leeres Fenster: Ohne WebView2 faellt pywebview
auf MSHTML zurueck, und der stellt die React-Oberflaeche nicht dar."""
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: True)
monkeypatch.setattr(fenster, "webview2_version", lambda: "")
geht, grund = fenster.verfuegbar()
assert geht is False
assert "WebView2" in grund
# ── Der Rueckfall ───────────────────────────────────────────────────────
def test_ohne_fenster_geht_der_browser_auf(monkeypatch):
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows"))
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser"
assert gerufen == ["http://127.0.0.1:7788"]
def test_geplatztes_fenster_faellt_auf_den_browser_zurueck(monkeypatch, capsys):
"""Der wichtigste Test hier. Was WinForms und die CLR beim Start werfen
koennen, ist nicht abschliessend bekannt. Faellt das nicht zurueck, ist
der Doppelklick auf das Desktop-Symbol wirkungslos und der Nutzer
haelt Rippy fuer kaputt."""
def platzt(*a, **k):
raise RuntimeError("edgechromium nicht gefunden")
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151"))
monkeypatch.setattr(fenster, "oeffnen", platzt)
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser"
assert gerufen == ["http://127.0.0.1:7788"]
# Und der Grund muss SICHTBAR sein, nicht verschluckt.
assert "edgechromium nicht gefunden" in capsys.readouterr().out
def test_wenn_gar_nichts_geht_wird_das_auch_gesagt(monkeypatch):
"""`webbrowser.open` gibt False zurueck, wenn kein Browser gefunden wird.
Ein `return "browser"` waere dann eine Luege."""
monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows"))
monkeypatch.setattr("webbrowser.open", lambda a: False)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "nichts"
def test_erfolgreiches_fenster_oeffnet_keinen_browser(monkeypatch):
"""Sonst haette der Nutzer BEIDES auf — Fenster und Tab."""
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151"))
monkeypatch.setattr(fenster, "oeffnen", lambda *a, **k: None)
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "fenster"
assert gerufen == []
# ── Die WebView2-Helfer ─────────────────────────────────────────────────
def test_helfer_werden_ueber_den_speicherort_gefiltert():
"""DER Befund vom 28.08.2026: Nach dem Deinstallieren blieben 148 Dateien
liegen, alle unter `fenster\\EBWebView\\`. WebView2 startet eigene
Prozesse (Renderer, GPU, Netz, Crashpad); stirbt Rippy, laufen sie als
Waisen weiter und halten den Zwischenspeicher offen."""
s = fenster.helfer_skript(r"C:\Users\X\AppData\Local\Rippy\fenster")
assert "msedgewebview2.exe" in s
assert r"C:\Users\X\AppData\Local\Rippy\fenster" in s
assert "Stop-Process" in s
def test_der_filter_trifft_NUR_unsere_helfer():
"""`msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu
beenden waere ein Uebergriff der Nutzer verloere die Fenster fremder
Anwendungen. Ohne `Where-Object` auf den Speicherort passiert genau das."""
s = fenster.helfer_skript(r"C:\Rippy\fenster")
assert "Where-Object" in s, "ohne Filter wuerden FREMDE Fenster sterben"
assert s.index("Where-Object") < s.index("Stop-Process"), \
"erst filtern, dann beenden"
def test_apostroph_im_pfad_bricht_das_skript_nicht():
"""In PowerShell wird ein Apostroph durch VERDOPPELN maskiert. Ohne das
bricht ein Pfad wie C:\\Users\\O'Brien\\… das Skript mitten entzwei."""
s = fenster.helfer_skript(r"C:\Users\O'Brien\Rippy\fenster")
assert "O''Brien" in s
assert s.count("'") % 2 == 0
def test_ohne_windows_werden_keine_helfer_beendet():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert fenster.helfer_beenden("/tmp/x") is False
def test_leerer_speicherort_beendet_nichts(monkeypatch):
"""Ein leerer Filter wuerde auf ALLE msedgewebview2-Prozesse passen."""
monkeypatch.setattr(fenster, "speicherort_lesen", lambda: "")
assert fenster.helfer_beenden("") is False
# ── Der Speicherort ─────────────────────────────────────────────────────
def test_speicherort_liegt_bei_rippy(monkeypatch, tmp_path):
"""Im TEMP waere er bei jedem Aufraeumen weg — und beim Deinstallieren
bliebe er liegen."""
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
ordner = fenster.speicherort()
assert ordner.startswith(str(tmp_path))
assert os.path.isdir(ordner)
def test_unbeschreibbarer_ort_wirft_nicht(monkeypatch):
"""Ein fehlender Zwischenspeicher ist aergerlich, kein Grund, das
Fenster gar nicht erst zu oeffnen."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(os, "makedirs", geht_nicht)
assert fenster.speicherort() == ""
# ── Auf dieser Maschine wirklich nachsehen ──────────────────────────────
@nur_windows
def test_webview2_ist_auf_diesem_rechner_da():
"""Kein Muss-Test, sondern ein Messpunkt: Faellt er aus, laeuft Rippy im
Browser statt im Fenster und man weiss auch gleich, warum."""
version = fenster.webview2_version()
if not version:
pytest.skip("Keine WebView2-Laufzeit auf dieser Maschine")
assert version.count(".") >= 2, version
-179
View File
@@ -1,179 +0,0 @@
"""Der Wächter gegen Container-Annahmen im Windows-Betrieb.
## Warum es diesen Test gibt (Commander, 29.08.2026)
> Bro, du musst alles was rippy jetzt im code hat für Windows Bauen! Jeden
> pfad, alles wo die tools drauf zugreifen. Diese Rippy version MUSS 100%
> Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach Docker
> Resten."
Der Rundgang dazu hat an einem Tag gefunden:
/app/media als Pfadgrenze -> Auswahl blieb leer, Browser tot
/app/temp/raw als Arbeitsordner -> 436 MB landeten in F:\\app\\temp
/root/.MakeMKV fuer den Beta-Key -> Key wurde nie gelesen
/dev/{name} in drei Endpunkten -> Auswerfen und Disc-Scan gaben 404
os.path.isdir("/app") zweimal -> Rippy hielt sich fuer fremd
shutil.which fuer makemkvcon -> Schluessel-Automatik lief nie an
posixpath auf echten Pfaden -> C:\\Roh/datei.mkv
Jeder einzelne Fall sah gleich aus: kein Absturz, keine Meldung, nur etwas,
das still nicht tat, was es sollte. Genau deshalb steht hier ein MECHANISCHER
Waechter und keine Merkregel.
## Die Regel
In allem, was der Windows-Betrieb laedt, darf ein Container- oder Linux-Pfad
nur stehen, wenn er in einem Zweig steht, der auf Windows nicht laeuft. Diese
Zweige stehen unten namentlich in `ERLAUBT` mit Begruendung. Wer eine neue
Fundstelle hinzufuegt, muss sie dort begruenden; das ist der eigentliche
Zweck.
"""
import io
import os
import re
import pytest
WURZEL = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
#: Was der Windows-Betrieb wirklich laedt.
GEPRUEFT = ("src/rippy", "docker/api", "docker/worker")
#: Module, die es unter Windows GAR NICHT gibt — dort sind Linux-Pfade richtig.
NUR_LINUX = (
"src/rippy/drives/linux.py", # der Linux-Treiber
"docker/api/mounts.py", # Freigaben einhaengen macht der Wirt
)
#: Begruendete Ausnahmen: Datei -> warum der Pfad dort stehen DARF.
ERLAUBT = {
"src/rippy/betrieb.py":
"Container-Zweige von ablage_vorgabe/arbeits_vorgabe/platz_orte — "
"sie laufen nur mit container=True",
"src/rippy/config.py":
"Linux-Zweig von standard_datenbankpfad (Windows: %LOCALAPPDATA%)",
"src/rippy/daemon.py":
"Linux-Zweig von standard_konfigpfad (Windows: %PROGRAMDATA%)",
"src/rippy/rip/makemkv_daten.py":
"Linux-Zweig von daten_verzeichnis (Windows: %USERPROFILE%\\.MakeMKV)",
"docker/api/config.py":
"Pydantic-Vorgaben ohne Leser — kein Aufrufer im Quellcode",
"docker/api/main.py":
"MEDIA_ROOT als benannter Rueckfall; die Wurzel liefert medien_wurzel()",
"docker/api/rohdaten.py":
"RAW_STANDARD/MEDIA_ROOT als Rueckfall; die Wurzeln liefert wurzeln()",
"docker/api/prescan/prescan.py":
"Linux-Zweig von disc_wurzel (Windows haengt die Disc selbst ein) "
"und die Audio-CD-Werkzeuge, die es unter Windows nicht gibt",
"docker/worker/ablauf.py":
"RAW_DIR/MEDIA_ROOT als Rueckfall; die Wurzeln liefert _betriebs_wurzeln()",
"docker/worker/ripping.py":
"RIP_OUTPUT_DIR als Rueckfall und die Audio-CD-Werkzeuge (Linux-only)",
}
PFAD_MUSTER = re.compile(r'["\'](/(?:app|root|dev|mnt|srv|etc|var|usr)(?:/[^"\']*)?)["\']')
def _dateien():
for teil in GEPRUEFT:
for ordner, unter, namen in os.walk(os.path.join(WURZEL, teil)):
unter[:] = [u for u in unter if u != "__pycache__"]
for name in namen:
if not name.endswith(".py") or name.startswith("test_"):
continue
voll = os.path.join(ordner, name)
rel = os.path.relpath(voll, WURZEL).replace("\\", "/")
if rel in NUR_LINUX:
continue
yield rel, voll
def _code_zeilen(voll: str):
"""Zeilen OHNE Kommentare und Docstrings — nur was wirklich laeuft.
Ueber den TOKENIZER, nicht ueber faengt mit Anfuehrungszeichen an": Ein
Docstring kann `r\"\"\"` oder `f\"\"\"` heissen, und der erste Anlauf dieses
Tests ist prompt an seinem eigenen `r\"\"\"`-Docstring haengengeblieben.
Python weiss selbst am besten, was Code ist und was Text.
"""
import tokenize
with tokenize.open(voll) as f:
try:
marken = list(tokenize.generate_tokens(f.readline))
except (tokenize.TokenError, IndentationError, SyntaxError):
return
# Zeilen, in denen echter Code steht (alles ausser Kommentar, Text,
# Einrueckung und Zeilenende).
egal = {tokenize.COMMENT, tokenize.STRING, tokenize.NL, tokenize.NEWLINE,
tokenize.INDENT, tokenize.DEDENT, tokenize.ENDMARKER}
zeilen = {}
for marke in marken:
if marke.type in egal:
continue
zeilen[marke.start[0]] = marke.line.rstrip("\n")
# Zeichenketten IM Code (kein Docstring) muessen mit — dort stehen die
# Pfade. Erkennbar daran, dass in derselben Zeile auch Code steht.
for marke in marken:
if marke.type == tokenize.STRING and marke.start[0] in zeilen:
zeilen[marke.start[0]] = marke.line.rstrip("\n")
for nr in sorted(zeilen):
yield nr, zeilen[nr]
def test_kein_unbegruendeter_container_pfad():
"""DER Waechter. Neue Fundstelle -> hier begruenden oder beheben."""
offen = []
for rel, voll in _dateien():
for nr, zeile in _code_zeilen(voll):
treffer = PFAD_MUSTER.search(zeile)
if treffer and rel not in ERLAUBT:
offen.append("%s:%d %s" % (rel, nr, treffer.group(1)))
assert not offen, (
"Container-Pfad im Windows-Betrieb ohne Begruendung:\n "
+ "\n ".join(offen)
+ "\n\nEntweder fuer Windows umschreiben (betrieb.medien_wurzel, "
"rippy.pfade) oder in ERLAUBT begruenden.")
def test_die_ausnahmen_sind_noch_noetig():
"""Eine Ausnahme, die niemand mehr braucht, ist eine Unwahrheit im Test."""
ungenutzt = []
for rel in ERLAUBT:
voll = os.path.join(WURZEL, rel)
if not os.path.isfile(voll):
ungenutzt.append(rel + " (Datei gibt es nicht mehr)")
continue
if not any(PFAD_MUSTER.search(z) for _, z in _code_zeilen(voll)):
ungenutzt.append(rel + " (kein Container-Pfad mehr drin)")
assert not ungenutzt, "Ausnahme ueberfluessig:\n " + "\n ".join(ungenutzt)
@pytest.mark.parametrize("modul,name", [
("src/rippy/drives/windows.py", "kennung"),
("src/rippy/drives/windows.py", "pfad_zu_kennung"),
("src/rippy/drives/linux.py", "kennung"),
("src/rippy/drives/linux.py", "pfad_zu_kennung"),
])
def test_beide_treiber_koennen_die_kennung_in_beide_richtungen(modul, name):
"""Hin und zurueck muessen zusammenpassen.
`main.py` baute den Geraetepfad aus der Kennung selbst zusammen
(`/dev/{name}`) unter Windows heisst das Laufwerk aber `\\\\.\\G:`.
Auswerfen und Disc scannen" antworteten deshalb IMMER mit 404.
"""
text = io.open(os.path.join(WURZEL, modul), encoding="utf-8",
errors="replace").read()
assert "def %s(" % name in text
def test_niemand_baut_den_geraetepfad_mehr_selbst():
"""Der konkrete Fehler: `f"/dev/{name}"` in drei Endpunkten."""
voll = os.path.join(WURZEL, "docker/api/main.py")
gebaut = [nr for nr, z in _code_zeilen(voll) if 'f"/dev/{' in z]
assert not gebaut, (
"main.py:%s baut den Geraetepfad selbst — device_discovery."
"pfad_zu_kennung() benutzen" % gebaut)
-137
View File
@@ -1,137 +0,0 @@
"""Die ganze Kette im Standalone-Betrieb — von der Zustellung bis zum Job-Ende.
## Was hier bewiesen wird
celery_client.abschicken(...) die API stellt zu
-> standalone.zustellung uebersetzt in einen Auftrag
-> lokal.einreihen Auftragstabelle mit Lease
-> Laeufer.einmal() holt ihn und arbeitet ihn ab
-> ablauf.rippen() der echte Ablauf, ohne Celery
-> db.update_job() der Job endet in einem ehrlichen Zustand
Das ist der Weg, der unter Windows bis V2-4 gar nicht existierte: Dort haette
die API einen Rip eingereiht, und er waere fuer immer liegengeblieben, weil
niemand ihn holt.
## Warum ohne Laufwerk getestet wird
Es gibt hier keins (es haengt an der VM). Das ist kein Mangel, sondern der
wichtigere Fall: Ein Rip auf ein Geraet, das nicht antwortet, muss **ehrlich
scheitern** mit einem Zustand und einer Begruendung, die im UI ankommen.
Was NICHT passieren darf: dass der Job auf pending" stehenbleibt und
niemand erfaehrt, warum nichts geschieht.
"""
import time
import pytest
from rippy import standalone, store
from rippy.queue import laeufer as laeufer_modul
from rippy.queue import lokal
@pytest.fixture
def umgebung(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'kette.db').as_posix()}")
store.init_db()
yield store
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
class FakeBus:
def __init__(self):
self.gesendet = []
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
self.gesendet.append((typ, entitaet_id, daten or {}))
def test_die_ganze_kette_bis_zum_ehrlichen_fehlschlag(umgebung):
"""Ein Rip auf ein Geraet, das es nicht gibt.
Erwartet wird KEIN Erfolg erwartet wird, dass der Job in einem
ENDZUSTAND landet und die Begruendung im Log steht. Ein Job, der auf
pending" haengenbleibt, waere der schlimmere Ausgang: Der Nutzer saehe
einen Balken, der sich nie bewegt, und nirgends stuende warum.
"""
umgebung.insert_job("job-kette", "/dev/gibt-es-nicht", disc_type=None)
bus = FakeBus()
zustellen = standalone.zustellung_bauen(bus)
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-kette", None])
assert len(lokal.offene_auftraege()) == 1, "Der Auftrag wurde nicht eingereiht"
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
kann={"art=rip"}, bus=bus, store=umgebung)
assert lauf.einmal() is True, "Der Laeufer hat den Auftrag nicht geholt"
job = umgebung.get_job("job-kette")
assert job["status"] in ("failed", "completed"), (
f"Der Job steht auf {job['status']!r} — er muss in einem Endzustand "
"landen, sonst wartet der Nutzer auf etwas, das nie passiert.")
assert job["status"] == "failed"
assert job["error"], "Ein Fehlschlag ohne Begruendung ist im UI wertlos"
meldungen = " ".join(z["message"] for z in umgebung.list_logs(limit=50))
assert "job-kette" in meldungen, "Im Log steht nichts ueber diesen Job"
assert lokal.offene_auftraege() == [], "Der Auftrag blieb in der Queue liegen"
def test_der_laeufer_bleibt_danach_arbeitsfaehig(umgebung):
"""Nach einem Fehlschlag muss der naechste Auftrag trotzdem laufen —
sonst legt ein einziger kaputter Rip die ganze Installation lahm."""
umgebung.insert_job("job-a", "/dev/gibt-es-nicht")
umgebung.insert_job("job-b", "/dev/gibt-es-auch-nicht")
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-a", None])
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-auch-nicht", "job-b", None])
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
kann={"art=rip"}, store=umgebung)
assert lauf.einmal() is True
assert lauf.einmal() is True
assert umgebung.get_job("job-a")["status"] == "failed"
assert umgebung.get_job("job-b")["status"] == "failed"
def test_die_api_stellt_ueber_dieselbe_stelle_zu(umgebung, monkeypatch):
"""`celery_client.start_rip` ist der Weg, den POST /jobs geht. Er MUSS
im Standalone-Betrieb in der lokalen Queue landen sonst reiht die API
an einen Broker ein, den es nicht gibt, und der Job verschwindet."""
import sys
from rippy.daemon import _api_pfad
if _api_pfad() not in sys.path:
sys.path.insert(0, _api_pfad())
import celery_client
umgebung.insert_job("job-api", "/dev/sr0")
monkeypatch.setattr(celery_client, "_zusteller", standalone.zustellung_bauen())
celery_client.start_rip("/dev/sr0", "job-api", "/app/media/x")
offen = lokal.offene_auftraege()
assert len(offen) == 1
assert offen[0]["job_id"] == "job-api"
assert offen[0]["art"] == "rip"
def test_ein_rip_haengt_nicht_ewig(umgebung):
"""Zeitgrenze als Zusage: Ein Auftrag auf ein totes Geraet muss ZUEGIG
scheitern. Haengt er, merkt es im Betrieb niemand der Job steht auf
laeuft", und der Laeufer nimmt keinen weiteren an."""
umgebung.insert_job("job-zeit", "/dev/nichts")
standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/nichts", "job-zeit", None])
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "k", kann={"art=rip"},
store=umgebung)
start = time.monotonic()
lauf.einmal()
dauer = time.monotonic() - start
assert dauer < 30, f"Der Fehlschlag brauchte {dauer:.1f}s — das ist zu lang"
-379
View File
@@ -1,379 +0,0 @@
"""Der Einrichtungs-Assistent: Bruecke und Seite, ohne ein Fenster zu oeffnen.
Ein Fenster laesst sich im Test nicht bedienen. Geprueft wird deshalb das,
was DAHINTER haengt: was die Seite abfragen darf, was beim Installieren
passiert, und dass ein Fehlschlag sichtbar wird statt zu verschwinden.
Hinweis zu den Backslashes: Windows-Pfade stehen hier in ROHEN Zeichenketten
(`r"..."`). In einer normalen waere `C:\\Rippy` die ungueltige Escape-Sequenz
Backslash-R.
"""
from rippy import setup_fenster
class LeeresFenster:
def __init__(self, antwort=None):
self.antwort = antwort
self.zerstoert = False
def create_file_dialog(self, *a, **k):
if self.antwort is None:
raise RuntimeError("kein Dialog")
return self.antwort
def destroy(self):
self.zerstoert = True
# ── Die Seite ───────────────────────────────────────────────────────────
def test_die_seite_fragt_nach_dem_ablageort():
"""Die ausdrueckliche Frage des Commanders: „wo will ich das
hinspeichern"."""
assert setup_fenster.seite_enthaelt('id="ablage"')
assert setup_fenster.seite_enthaelt("Fertige Filme")
def test_die_seite_zeigt_die_voraussetzungen():
"""„nen pre requirement check usw." — vorher kam da gar nichts."""
assert setup_fenster.seite_enthaelt("Voraussetzungen")
assert setup_fenster.seite_enthaelt("pywebview.api.pruefen")
def test_die_seite_laedt_nichts_aus_dem_netz():
"""Der Assistent muss auf einem Rechner OHNE Internet aufgehen — dort
wird er am dringendsten gebraucht."""
for muster in ("http://", "https://", "<script src", "<link "):
assert not setup_fenster.seite_enthaelt(muster), muster
def test_die_seite_kann_den_ordner_waehlen_lassen():
assert setup_fenster.seite_enthaelt("pywebview.api.ordner_waehlen")
# ── Die Bruecke ─────────────────────────────────────────────────────────
def test_abgebrochener_ordner_dialog_gibt_leer_zurueck():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=None)
assert b.ordner_waehlen("C:") == ""
def test_gewaehlter_ordner_kommt_als_text():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=(r"D:\Filme",))
assert b.ordner_waehlen() == r"D:\Filme"
def test_fortschritt_leert_sich_beim_abholen():
"""Sonst waechst das Protokoll bei jedem Abruf um alles Bisherige."""
b = setup_fenster.Bruecke()
b._lauf.melde("eins")
b._lauf.melde("zwei")
assert b.fortschritt()["zeilen"] == ["eins", "zwei"]
assert b.fortschritt()["zeilen"] == []
def test_stand_ist_leer_solange_nichts_fertig_ist():
assert setup_fenster.Bruecke().stand() == {}
def test_ein_fehlschlag_beim_installieren_wird_SICHTBAR(monkeypatch):
"""Ohne Konsole waere ein Absturz hier voellig unsichtbar — der Nutzer
saehe ein Fenster, in dem nichts mehr passiert."""
from rippy import windows_app
def platzt(**k):
raise OSError("Zugriff verweigert")
monkeypatch.setattr(windows_app, "installieren", platzt)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\x", "werkzeuge_holen": False})
assert b.ergebnis["ok"] is False
assert "Zugriff verweigert" in b.ergebnis["fehler"]
assert any("Fehlgeschlagen" in z for z in b._lauf.zeilen)
def test_erfolgreiche_installation_meldet_den_ordner(monkeypatch):
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\Rippy", "werkzeuge_holen": False})
assert b.ergebnis == {"ok": True, "ziel": r"C:\Rippy", "bereit": True}
def test_werkzeuge_koennen_uebersprungen_werden(monkeypatch):
"""Sonst wartete eine unbeaufsichtigte Installation auf einen Download."""
from rippy import windows_app
from rippy.tools import einrichten
def haette_nicht(**k):
raise AssertionError("haette nicht laufen duerfen")
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
monkeypatch.setattr(einrichten, "sicherstellen", haette_nicht)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"werkzeuge_holen": False})
assert b.ergebnis["ok"] is True
def test_schliessen_ohne_fenster_wirft_nicht():
setup_fenster.Bruecke().schliessen()
# -- Der Fortschrittsbalken ---------------------------------------------
def test_der_balken_faengt_bei_null_an():
f = setup_fenster.Fortschritt()
assert f.abholen() == {"zeilen": [], "prozent": 0, "abschnitt": ""}
def test_ein_abschnitt_setzt_den_anfang():
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
stand = f.abholen()
assert stand["prozent"] == 10
assert "Werkzeuge" in stand["abschnitt"]
def test_der_anteil_wird_in_den_abschnitt_gerechnet():
"""Werkzeuge belegen 10 bis 97 Prozent. Halb fertig heisst also rund 54."""
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("laeuft", 0.5)
assert 50 <= f.prozent <= 58, f.prozent
def test_der_balken_laeuft_NIE_zurueck():
"""Der Werkzeug-Abschnitt kann mehrere Downloads enthalten, jeder mit
eigenem 0-bis-1. Ohne diese Regel spraenge der Balken bei jedem neuen
Werkzeug zurueck an den Abschnittsanfang."""
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("erstes Werkzeug fertig", 0.9)
hoch = f.prozent
f.melde("zweites Werkzeug faengt an", 0.0)
assert f.prozent == hoch, "der Balken ist zurueckgesprungen"
def test_eine_meldung_ohne_zahl_bewegt_nichts():
"""Eine Textmeldung ist keine Aussage ueber den Fortschritt."""
f = setup_fenster.Fortschritt()
f.beginne("dateien")
f.melde("irgendwas passiert")
assert f.prozent == 0
def test_zeilen_leeren_sich_beim_abholen_der_stand_nicht():
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("eins", 0.5)
erst = f.abholen()
zweit = f.abholen()
assert erst["zeilen"] == ["eins"]
assert zweit["zeilen"] == []
assert zweit["prozent"] == erst["prozent"], "der Stand darf nicht vergessen werden"
def test_ein_verrutschter_anteil_sprengt_nichts():
f = setup_fenster.Fortschritt()
f.beginne("dateien")
f.melde("x", 5.0)
assert f.prozent <= 100
f.melde("y", -1.0)
assert f.prozent <= 100
def test_die_abschnitte_ueberlappen_nicht_und_enden_bei_hundert():
grenzen = sorted(setup_fenster.ABSCHNITTE.values())
assert grenzen[0][0] == 0.0
assert grenzen[-1][1] == 1.0
for (_, ende_a, _), (von_b, _, _) in zip(grenzen, grenzen[1:]):
assert abs(ende_a - von_b) < 1e-9, (ende_a, von_b)
def test_die_seite_zeigt_einen_balken():
assert setup_fenster.seite_enthaelt('id="balken"')
assert setup_fenster.seite_enthaelt("prozent")
# -- Administratorrechte auf Abruf --------------------------------------
def test_der_admin_knopf_ist_da_aber_nicht_vorgewaehlt():
"""Commander: "lass das setup sonst immer mit erhoehten rechten starten".
Immer erhoeht waere genau der Fehler, den er selbst gemeldet hat: Ein
erhoehter Prozess sieht die eingebundenen Netzlaufwerke NICHT (auf diesem
Rechner gemessen: X:, Y:, Z: ohne Erhoehung sichtbar,
EnableLinkedConnections nicht gesetzt). Deshalb ein Knopf statt einer
Voreinstellung -- und der ist anfangs verborgen."""
assert setup_fenster.seite_enthaelt('id="knopf-admin"')
assert setup_fenster.seite_enthaelt('class="weg"')
assert setup_fenster.seite_enthaelt("pywebview.api.als_admin")
def test_der_admin_knopf_erscheint_nur_beim_rechteproblem():
"""Sonst laedt er dazu ein, sich die Netzlaufwerke wegzunehmen."""
assert setup_fenster.seite_enthaelt("b.name === 'Schreibrecht'")
assert setup_fenster.seite_enthaelt("b.zustand === 'fehler'")
def test_ein_abgebrochener_uac_dialog_schliesst_das_fenster_nicht(monkeypatch):
"""Wer die UAC-Abfrage wegklickt, will weitermachen -- nicht von vorn
anfangen."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: False)
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster()
assert b.als_admin() == {"gestartet": False}
assert b._fenster.zerstoert is False
def test_ein_angenommener_neustart_schliesst_das_fenster(monkeypatch):
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: True)
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster()
assert b.als_admin() == {"gestartet": True}
assert b._fenster.zerstoert is True
# -- Updates im Setup ----------------------------------------------------
def test_die_seite_bietet_updates_an():
"""Commander: "bitte baue ein, das wenn MakeMKV oder Handbrake schon
installiert sind ein update verfuegbar ist. Das kann ja theoretisch direkt
im setup abgehandelt werden.\""""
assert setup_fenster.seite_enthaelt('id="aktualisieren"')
assert setup_fenster.seite_enthaelt("pywebview.api.updates")
assert setup_fenster.seite_enthaelt("aktualisieren: $('aktualisieren').checked")
def test_nicht_erreichbare_quellen_schalten_das_update_AB():
"""Eine Nichtauskunft ist keine Aussage -- und ein Haken, der nichts tun
kann, ist eine Falle."""
assert setup_fenster.seite_enthaelt("stand.erreichbar")
assert setup_fenster.seite_enthaelt("$('aktualisieren').checked = false")
def test_updates_ohne_netz_melden_nicht_erreichbar(monkeypatch):
from rippy.tools import beschaffen
def platzt():
raise OSError("kein Netz")
monkeypatch.setattr(beschaffen, "neueste_versionen", platzt)
stand = setup_fenster.Bruecke().updates()
assert stand == {"erreichbar": False, "offen": []}
def test_updates_melden_was_offen_ist(monkeypatch):
from rippy.tools import beschaffen, katalog
monkeypatch.setattr(katalog, "bestand", lambda *a, **k: {
"makemkv": {"titel": "MakeMKV", "da": True, "version": "1.18.2",
"pflicht": True},
"handbrake": {"titel": "HandBrakeCLI", "da": True, "version": "1.11.2",
"pflicht": False}})
monkeypatch.setattr(beschaffen, "neueste_versionen",
lambda: {"makemkv": "1.18.4", "handbrake": "1.11.2"})
stand = setup_fenster.Bruecke().updates()
assert stand["erreichbar"] is True
assert [o["titel"] for o in stand["offen"]] == ["MakeMKV"]
assert stand["offen"][0]["installiert"] == "1.18.2"
assert stand["offen"][0]["neueste"] == "1.18.4"
# ── Der Arbeitsordner im Setup (Commander 28.08.2026) ───────────────────
#
# > „kannst du noch einbauen das man den arbeitsordner usw. in den
# > Einstellungen setzen kann und direkt im setup?"
#
# Warum das nicht Kosmetik ist: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
# gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat
# es zu spaet erfahren — am 25.07.2026 lief genau so die VM-Platte voll.
def test_die_seite_fragt_nach_dem_arbeitsordner():
assert setup_fenster.seite_enthaelt('id="arbeit"')
assert setup_fenster.seite_enthaelt("Arbeitsordner für die Rohdaten")
def test_der_arbeitsordner_hat_einen_durchsuchen_knopf():
"""Einen Pfad abzutippen ist die Sorte Bedienung, die niemand macht."""
assert setup_fenster.seite_enthaelt("waehlen('arbeit')")
def test_jede_vorgabe_hat_ein_feld_UND_wird_mitgeschickt():
"""Der Waechter gegen halb angeschlossene Felder.
Ein Feld kann auf drei Arten kaputt sein, und alle drei sehen im Fenster
gleich aus: kein Eingabefeld, keine Vorbelegung, oder es wird beim
Installieren nicht mitgeschickt. Der dritte Fall ist der gemeinste man
waehlt etwas, es passiert nichts, und niemand sagt warum.
"""
from rippy import einrichtung
# Schalter heissen im HTML anders (checkbox) und werden separat geprueft.
pfade_und_zahlen = {"ziel", "ablage", "arbeit", "port"}
for name in pfade_und_zahlen:
assert name in einrichtung.vorgaben(), "%s fehlt in vorgaben()" % name
assert setup_fenster.seite_enthaelt('id="%s"' % name), \
"kein Eingabefeld fuer %s" % name
assert setup_fenster.seite_enthaelt("$('%s').value = v.%s" % (name, name)), \
"%s wird nicht vorbelegt" % name
assert setup_fenster.seite_enthaelt("%s: " % name), \
"%s wird beim Installieren nicht mitgeschickt" % name
def test_der_arbeitsordner_landet_als_workDir_in_den_einstellungen(monkeypatch):
"""Der Name muss stimmen: Die Oberflaeche und der Worker lesen `workDir`."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
class StilleWerkzeuge:
@staticmethod
def sicherstellen(**k):
return {"bereit": True}
@staticmethod
def bericht(b):
return "ok"
import sys
import types
modul = types.ModuleType("rippy.tools.einrichten")
modul.sicherstellen = StilleWerkzeuge.sicherstellen
modul.bericht = StilleWerkzeuge.bericht
monkeypatch.setitem(sys.modules, "rippy.tools.einrichten", modul)
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": r"D:\Filme", "arbeit": r"E:\Roh",
"werkzeuge_holen": True})
assert geschrieben.get("workDir") == r"E:\Roh"
assert geschrieben.get("outputDir") == r"D:\Filme"
assert b.ergebnis["ok"] is True
def test_ohne_angabe_wird_NICHTS_geschrieben(monkeypatch):
"""Leer heisst „nimm die Vorgabe" — dann darf kein leerer Pfad in die
Einstellungen wandern und die Berechnung ueberschreiben."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": "", "arbeit": "", "werkzeuge_holen": False})
assert geschrieben == {}
-100
View File
@@ -1,100 +0,0 @@
"""Die lokale Zustellung: kommt der Auftrag an, und in der richtigen Form?
Gerippt wird nichts geprueft wird die Uebersetzung von Celery-Task-Namen in
Auftraege und die Frage, was bei einem unbekannten Namen passiert.
"""
import json
import pytest
from rippy import standalone, store
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 's.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def test_rip_wird_zum_rip_auftrag(q):
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/sr0", "job1", "/app/media/x"])
offen = q.offene_auftraege()
assert len(offen) == 1
auftrag = offen[0]
assert auftrag["art"] == "rip"
assert auftrag["job_id"] == "job1"
nutzlast = json.loads(auftrag["payload"])
assert nutzlast["device_path"] == "/dev/sr0"
assert nutzlast["target_dir"] == "/app/media/x"
def test_transcode_wird_zum_transcode_auftrag(q):
standalone.zustellung_bauen()(
"worker.tasks.transcode_files", ["job2", "/raw", "/final"])
auftrag = q.offene_auftraege()[0]
assert auftrag["art"] == "transcode"
nutzlast = json.loads(auftrag["payload"])
assert nutzlast["raw_dir"] == "/raw" and nutzlast["final_dir"] == "/final"
def test_scan_braucht_keinen_job(q):
"""Ein Track-Scan gehoert zu keinem Job — er passiert VOR dem Anlegen."""
standalone.zustellung_bauen()("worker.tasks.scan_tracks", ["/dev/sr0"])
auftrag = q.offene_auftraege()[0]
assert auftrag["art"] == "scan"
assert auftrag["job_id"] == ""
def test_fehlendes_zielverzeichnis_ist_kein_fehler(q):
"""POST /jobs darf target_dir weglassen — dann gilt der Standard."""
standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/sr0", "job3"])
assert json.loads(q.offene_auftraege()[0]["payload"])["target_dir"] is None
def test_unbekannter_taskname_fliegt_auf(q):
"""Wuerde er stillschweigend verworfen, waere der Auftrag weg und niemand
wuesste warum der Nutzer sieht nur einen Job, der nie anfaengt."""
with pytest.raises(ValueError, match="Unbekannte Auftragsart"):
standalone.zustellung_bauen()("worker.tasks.gibts_nicht", ["x"])
def test_faehigkeiten_werden_gesetzt(q):
"""Damit ein reiner Encode-Knoten spaeter keine Rip-Auftraege bekommt."""
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/sr0", "job1"])
zustellen("worker.tasks.transcode_files", ["job1", "/raw", "/final"])
arten = {a["art"]: json.loads(a["faehigkeiten"]) for a in q.offene_auftraege()}
assert arten["rip"] == {"art": "rip"}
assert arten["transcode"] == {"art": "transcode"}
def test_ein_kaputter_bus_haelt_die_zustellung_nicht_auf(q):
class KaputterBus:
def senden(self, *a, **k):
raise RuntimeError("weg")
standalone.zustellung_bauen(KaputterBus())(
"worker.tasks.rip_disc", ["/dev/sr0", "job1"])
assert len(q.offene_auftraege()) == 1
def test_ausfuehren_lehnt_unbekannte_art_ab():
with pytest.raises(ValueError, match="Unbekannte Auftragsart"):
standalone.ausfuehren({"art": "quatsch", "job_id": "j", "payload": "{}"})
def test_worker_pfad_wird_gefunden():
"""Ohne die Worker-Module kann Rippy nicht rippen. Wenn sich die
Verzeichnisstruktur aendert, soll das HIER auffallen."""
import os
pfad = standalone._worker_pfad()
assert os.path.isfile(os.path.join(pfad, "ablauf.py"))
-584
View File
@@ -1,584 +0,0 @@
"""Die Windows-App: Installation, Deinstallation, Aufraeum-Skript.
Die reinen Funktionen laufen ueberall. Was wirklich installiert, laeuft nur
unter Windows und in einem Testordner die echte Installation wird nie
angefasst.
Hinweis zu den Backslashes: Windows-Pfade stehen hier durchweg in ROHEN
Zeichenketten. In einer normalen waere ein Pfad wie C:\\Program die
ungueltige Escape-Sequenz Backslash-P heute eine DeprecationWarning, in
einer kuenftigen Python-Version ein harter Fehler.
"""
import os
import pytest
from rippy import windows_app
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
BACKSLASH = chr(92)
# ── Das Aufraeum-Skript ─────────────────────────────────────────────────
def test_pfade_stehen_in_anfuehrungszeichen():
"""DER Fehler vom 28.08.2026.
Der Loeschbefehl ging als EIN Argument an cmd. Python maskiert dabei die
inneren Anfuehrungszeichen, bei cmd kam ein Dateiname MIT vorangestellten
Backslashes an. `del` loeschte nichts und meldete nichts (die Ausgabe war
nach nul umgeleitet) Registry-Eintrag und Autostart waren weg, die
28-MB-Datei blieb liegen. Ein Skript hat das Problem nicht: Sein Inhalt
geht durch keine Argument-Zerlegung.
"""
s = windows_app.aufraeum_skript(
r"C:\Program Files\Rippy\Rippy.exe", r"C:\Program Files\Rippy")
assert '"' + r"C:\Program Files\Rippy\Rippy.exe" + '"' in s
assert BACKSLASH + '"' not in s, "Anfuehrungszeichen duerfen NICHT maskiert sein"
def test_skript_versucht_es_mehrfach():
"""Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse; der Starter
haelt die Datei nach dem Ende noch offen. Ein einziger Versuch schlaegt
dann manchmal fehl und zwar unsichtbar."""
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
assert "for /l" in s
assert "(1,1,15)" in s
def test_skript_loescht_sich_selbst():
"""Sonst bleibt nach jeder Deinstallation eine .bat im Temp liegen."""
assert "%~f0" in windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
def test_ordner_ohne_abschliessenden_trenner():
"""rmdir will den Ordner OHNE Schluss-Backslash — mit scheitert es."""
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", "C:" + BACKSLASH + "R" + BACKSLASH)
assert 'rmdir /s /q "' + r"C:\R" + '"' in s
def test_skript_raeumt_den_ordner_MIT_inhalt():
"""DER Befund vom 28.08.2026: Nach dem Deinstallieren blieben 148 Dateien
liegen (WebView2-Zwischenspeicher, dessen Prozesse Rippy ueberlebt
hatten). Ein blosses `rmdir` scheitert an JEDER verbliebenen Datei und
laesst den ganzen Ordner stehen."""
s = windows_app.aufraeum_skript(r"C:\R\Rippy.exe", r"C:\R")
assert "rmdir /s /q" in s, "ohne /s /q bleibt jeder nicht leere Ordner stehen"
def test_laufwerks_wurzel_wird_nicht_geloescht():
"""Notbremse (30.08.2026): `ordner` stammt aus `InstallLocation` in der
Registry, gesetzt aus dem `--ziel` beim Installieren also aus fremdem
Text. Waere er ein Laufwerks-Stammverzeichnis, loeschte die
Deinstallation das Laufwerk. Der noetige rstrip macht den Fall erst
scharf: aus "F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was
Windows gerade fuer den aktuellen Ordner auf F haelt.
"""
for wurzel in ("F:" + BACKSLASH, "F:", BACKSLASH + BACKSLASH + "srv" +
BACKSLASH + "freigabe" + BACKSLASH, "", "/"):
s = windows_app.aufraeum_skript("F:" + BACKSLASH + "Rippy.exe", wurzel)
assert "rmdir" not in s, "Wurzel %r haette geloescht werden koennen" % wurzel
assert "NICHT geloescht" in s
# Die EXE geht trotzdem weg, und das Skript raeumt sich selbst auf
assert "%~f0" in s
def test_echter_programmordner_wird_weiterhin_geloescht():
"""Die Bremse darf den Normalfall nicht treffen."""
s = windows_app.aufraeum_skript("C:" + BACKSLASH + "R" + BACKSLASH +
"Rippy.exe", "C:" + BACKSLASH + "R")
assert ('rmdir /s /q "C:' + BACKSLASH + 'R"') in s
# ── Installation (echt, in einem Testordner) ────────────────────────────
@nur_windows
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
"""Der ganze Kreislauf — vollstaendig in einem eigenen Sandkasten.
## ⚠️ WARUM HIER EIN EIGENER REGISTRY-SCHLUESSEL STEHT (28.08.2026)
Weil hier vorher der ECHTE stand. Der Test schrieb hinein und loeschte ihn
im `finally` wieder auch den des Commanders. Gemessen:
Rippy.exe --nicht-starten -> Eintrag: DA (Rippy 2.0.0)
pytest -q -> Eintrag: WEG
Rippy verschwand danach aus Installierte Apps", und der
Autostart-Eintrag ging denselben Weg. Der Commander hat es gemeldet,
bevor ich es bemerkt habe.
Das ist derselbe Fehler wie unten bei den Verknuepfungen, nur an einer
anderen Stelle: **Ein Test raeumt nur weg, was er selbst angelegt hat.**
Wenn aufraeumen" fremden Zustand trifft, ist es kein Aufraeumen.
## ⚠️ WARUM HIER KEINE VERKNUEPFUNGEN ANGELEGT WERDEN (28.08.2026)
Genau das ist einmal passiert, und es hat den Rechner des Commanders
kaputtgemacht: `installieren()` legt Verknuepfungen auf dem ECHTEN
Desktop und im ECHTEN Startmenue an die kennen kein `tmp_path`. Der
Test ueberschrieb damit die funktionierende Desktop-Verknuepfung durch
eine, die auf `\\Temp\\pytest-of-\\installiert\\Rippy.exe` zeigte. Und
dort liegt die 2-KB-ATTRAPPE von unten, kein Programm.
Der Commander klickte darauf und bekam von Windows:
Diese App kann auf dem PC nicht ausgefuehrt werden."
Aufgeraeumt hatte der Test nur Registry und Autostart die
Verknuepfungen nicht, weil es sie beim Schreiben des Tests noch nicht
gab. Ein Test, der Spuren ausserhalb von `tmp_path` hinterlaesst, ist
kein Test, sondern ein Eingriff.
Deshalb jetzt ZWEI Sicherungen statt einer:
1. `verknuepfen=False` die Funktion legt gar keine an.
2. Die Anlege-Funktion ist trotzdem ersetzt. Wer den Schalter
irgendwann umdreht oder die Vorgabe aendert, kann damit trotzdem
nichts am echten System anrichten.
"""
from rippy.platform import verknuepfungen as vk
from rippy.platform import win_registry as reg
# Sicherung 3 (28.08.2026): ein EIGENER Registry-Schluessel.
# Vorher schrieb dieser Test in den ECHTEN Eintrag und loeschte ihn im
# finally wieder -- also auch den des Commanders. Gemessen: nach
# `Rippy.exe` stand der Eintrag da, nach EINEM `pytest -q` war er weg,
# und Rippy verschwand aus "Installierte Apps". Der Autostart-Eintrag
# ging denselben Weg.
monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestInstallation")
# Sicherung 2: Selbst ein versehentliches `verknuepfen=True` bliebe folgenlos.
angelegt = []
monkeypatch.setattr(
vk, "alle_anlegen",
lambda programm, icon="", desktop=True, startmenue=True:
angelegt.append(programm) or {})
# Nicht die eigene Datei kopieren (das waere python.exe), sondern eine
# Attrappe: geprueft wird die Verdrahtung, nicht shutil.copy2.
attrappe = tmp_path / "quelle" / "RippySetup.exe"
attrappe.parent.mkdir()
attrappe.write_bytes(b"x" * 2048)
monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe))
ziel = str(tmp_path / "installiert")
try:
ordner = windows_app.installieren(ziel=ziel, autostart=True,
starten_danach=False,
verknuepfen=False)
assert os.path.isfile(os.path.join(ordner, "Rippy.exe"))
assert reg.ist_eingetragen() is True
werte = reg.gelesene_werte()
assert werte["InstallLocation"] == ziel
assert werte["DisplayName"] == "Rippy"
assert reg.autostart_wert().endswith("--dienst")
finally:
reg.autostart_entfernen()
reg.austragen()
assert reg.ist_eingetragen() is False
assert reg.autostart_wert() == ""
assert angelegt == [], "Es haette KEINE Verknuepfung angelegt werden duerfen"
@nur_windows
def test_installation_ruehrt_die_echten_verknuepfungen_nicht_an(tmp_path, monkeypatch):
"""Der Waechter gegen genau den Vorfall vom 28.08.2026.
Er prueft, dass eine Installation mit `verknuepfen=False` die
Verknuepfungs-Funktionen NICHT aufruft unabhaengig davon, was sie
tun wuerden. Waere dieser Test frueher dagewesen, haette der Commander
keine kaputte Desktop-Verknuepfung bekommen.
"""
from rippy.platform import verknuepfungen as vk
from rippy.platform import win_registry as reg
monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestWaechter")
aufrufe = []
monkeypatch.setattr(vk, "anlegen",
lambda *a, **k: aufrufe.append(a) or True)
monkeypatch.setattr(vk, "alle_anlegen",
lambda *a, **k: aufrufe.append(a) or {})
attrappe = tmp_path / "q" / "RippySetup.exe"
attrappe.parent.mkdir()
attrappe.write_bytes(b"x" * 16)
monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe))
try:
windows_app.installieren(ziel=str(tmp_path / "i"), autostart=False,
starten_danach=False, verknuepfen=False)
finally:
reg.austragen()
assert aufrufe == []
@nur_windows
def test_die_testsuite_ruehrt_den_ECHTEN_eintrag_nicht_an(tmp_path, monkeypatch):
"""Der Waechter gegen den Vorfall vom 28.08.2026.
Er sieht VORHER nach, ob Rippy auf dieser Maschine installiert ist,
laesst dann eine vollstaendige Test-Installation samt Autostart laufen
und prueft danach, dass sich am echten Eintrag NICHTS geaendert hat.
Ohne diesen Waechter reicht ein vergessenes `monkeypatch` in einem
kuenftigen Test, und Rippy verschwindet beim naechsten `pytest -q`
wieder aus Installierte Apps". Das ist schon einmal passiert, und der
Commander musste es melden.
"""
from rippy.platform import verknuepfungen as vk
from rippy.platform import win_registry as reg
echt_vorher = reg.ist_eingetragen("Rippy")
autostart_vorher = reg.autostart_wert("Rippy")
monkeypatch.setattr(reg, "SCHLUESSEL", "RippyTestUnberuehrt")
monkeypatch.setattr(vk, "alle_anlegen", lambda *a, **k: {})
attrappe = tmp_path / "q" / "RippySetup.exe"
attrappe.parent.mkdir()
attrappe.write_bytes(b"x" * 16)
monkeypatch.setattr(windows_app, "_eigene_datei", lambda: str(attrappe))
try:
windows_app.installieren(ziel=str(tmp_path / "i"), autostart=True,
starten_danach=False, verknuepfen=False)
finally:
reg.autostart_entfernen()
reg.austragen()
assert reg.ist_eingetragen("Rippy") == echt_vorher, (
"Die Testsuite hat den ECHTEN Uninstall-Eintrag veraendert — genau "
"dadurch verschwand Rippy aus 'Installierte Apps'."
)
assert reg.autostart_wert("Rippy") == autostart_vorher, (
"Die Testsuite hat den ECHTEN Autostart-Eintrag veraendert."
)
# ── Den laufenden Dienst zuerst beenden ─────────────────────────────────
def test_beenden_filtert_ueber_den_ordner():
"""DER Befund vom 28.08.2026: Beim Deinstallieren im laufenden Betrieb
blieben rippy.db, rippy.db-shm und rippy.db-wal liegen von einem
anderen Prozess verwendet". Der Dienst lief weiter."""
s = windows_app.beenden_skript("C:" + BACKSLASH + "Rippy", [123, 456])
assert "Rippy.exe" in s
assert "C:" + BACKSLASH + "Rippy" + BACKSLASH + "*" in s
assert "Stop-Process" in s
def test_der_eigene_prozess_wird_ausgenommen():
"""`Rippy.exe` heisst auch die Datei, die den Auftrag gerade ausfuehrt —
die darf sich nicht selbst abschiessen."""
s = windows_app.beenden_skript("C:" + BACKSLASH + "R", [4711, 4712])
assert "4711" in s and "4712" in s
assert s.index("notin") < s.index("Stop-Process"), "erst filtern, dann beenden"
def test_ohne_eigene_pids_wird_nichts_wildes_erzeugt():
"""Eine leere Liste duerfte keine kaputte Bedingung ergeben."""
s = windows_app.beenden_skript("C:" + BACKSLASH + "R", [])
assert "@()" not in s, "leere Menge waere eine unbrauchbare Bedingung"
assert "@(0)" in s
def test_apostroph_im_ordner_bricht_das_skript_nicht():
s = windows_app.beenden_skript("C:" + BACKSLASH + "O'Brien", [1])
assert "O''Brien" in s
assert s.count("'") % 2 == 0
# ── Die Datenbank am richtigen Ort ──────────────────────────────────────
def test_datenbank_liegt_im_installationsordner():
"""DER Befund vom 28.08.2026: „Es fehlt der 1st run wizzard wenn man es
installiert."
Die Datenbank lag unter %ProgramData%\\Rippy, die Installation unter
%LOCALAPPDATA%\\Rippy. Beim Deinstallieren blieb sie liegen, und die
naechste Installation erbte `setup.done = true` der
Ersteinrichtungs-Assistent erschien nie wieder.
Der Grund fuer den alten Ort war ein Windows-DIENST, den es nie gab.
"""
from rippy import config
pfad = config.standard_datenbankpfad()
if os.name != "nt":
assert pfad == "/var/lib/rippy/rippy.db"
return
assert "ProgramData" not in pfad, (
"Die Datenbank liegt wieder ausserhalb des Installationsordners — "
"dann ueberlebt sie jede Deinstallation."
)
assert pfad.lower().startswith(
(os.environ.get("LOCALAPPDATA") or "").lower())
@nur_windows
def test_alte_datenbank_wird_uebernommen(tmp_path, monkeypatch):
"""Wer Rippy schon benutzt hat, soll Jobs und Einstellungen behalten.
Die Datei wird verschoben, nicht ignoriert und nicht geloescht."""
from rippy import config
alt = tmp_path / "alt" / "Rippy" / "rippy.db"
alt.parent.mkdir(parents=True)
alt.write_bytes(b"alte-daten")
neu = tmp_path / "neu" / "Rippy" / "rippy.db"
monkeypatch.setattr(config, "alter_datenbankpfad", lambda: str(alt))
monkeypatch.setattr(config, "standard_datenbankpfad", lambda: str(neu))
assert windows_app.datenbank_umziehen() == str(neu)
assert neu.read_bytes() == b"alte-daten"
assert not alt.exists()
@nur_windows
def test_vorhandene_datenbank_wird_NICHT_ueberschrieben(tmp_path, monkeypatch):
"""Die BENUTZTE gewinnt, nicht die aeltere."""
from rippy import config
alt = tmp_path / "alt" / "rippy.db"
alt.parent.mkdir(parents=True)
alt.write_bytes(b"alt")
neu = tmp_path / "neu" / "rippy.db"
neu.parent.mkdir(parents=True)
neu.write_bytes(b"in-benutzung")
monkeypatch.setattr(config, "alter_datenbankpfad", lambda: str(alt))
monkeypatch.setattr(config, "standard_datenbankpfad", lambda: str(neu))
assert windows_app.datenbank_umziehen() == ""
assert neu.read_bytes() == b"in-benutzung"
def test_alte_orte_fassen_nur_rippy_an(monkeypatch):
"""Ein `%ProgramData%` ohne unseren Namen darin waere ein fremder Ordner."""
from rippy import config
monkeypatch.setattr(config, "alter_datenbankpfad",
lambda: "C:" + BACKSLASH + "ProgramData" + BACKSLASH
+ "Fremd" + BACKSLASH + "rippy.db")
assert windows_app.alte_orte() == []
# ── Rueckmeldung ohne Konsole ───────────────────────────────────────────
def test_ohne_konsole_kommt_ein_fenster(monkeypatch):
"""Seit die EXE ohne Konsole gebaut wird (Wunsch des Commanders am
28.08.2026: Unterbinde das CMD Fenster"), geht `melden()` beim
Doppelklick in eine Protokolldatei die niemand aufschlaegt. Ein
gescheiterter Installer saehe damit exakt aus wie einer, der nichts tut."""
from rippy.platform import winlauf
gezeigt = []
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda text, titel="Rippy", fehler=False:
gezeigt.append((text, fehler)) or True)
assert windows_app.abschluss_melden("Fertig: C:\\Rippy") is True
assert gezeigt == [("Fertig: C:\\Rippy", False)]
def test_mit_konsole_kommt_KEIN_fenster(monkeypatch):
"""Wer das Setup in PowerShell aufruft, will keinen Klick wegdruecken,
sondern die Zeile lesen."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: False)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda *a, **k: pytest.fail("kein Fenster erwartet"))
assert windows_app.abschluss_melden("Fertig") is False
def test_still_heisst_still(monkeypatch):
"""`--still` ist fuer unbeaufsichtigte Installationen. Ein Meldungsfenster
wuerde dort ewig auf einen Klick warten, den niemand macht."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "ohne_konsole", lambda: True)
monkeypatch.setattr(winlauf, "meldung_zeigen",
lambda *a, **k: pytest.fail("kein Fenster bei --still"))
assert windows_app.abschluss_melden("Fertig", still=True) is False
# ── Die Oberflaeche neben dem Programm ──────────────────────────────────
def test_ui_wird_neben_das_programm_gelegt(tmp_path, monkeypatch):
"""DER Befund vom 28.08.2026: Ein Dienst lief eine Stunde, `/api/health`
gab 200, `/` gab 404. Die Onefile-EXE liest ihre Oberflaeche aus
`%TEMP%\\_MEIxxxxx`, und von diesem Ordner waren 31 statt 44 Eintraege
uebrig kein `ui`. Die API antwortete weiter, der Dienst galt als
gesund, nur die Oberflaeche war weg."""
paket = tmp_path / "paket" / "ui"
(paket / "assets").mkdir(parents=True)
(paket / "index.html").write_text("<!DOCTYPE html>", encoding="utf-8")
(paket / "assets" / "haupt.js").write_text("//", encoding="utf-8")
monkeypatch.setattr(windows_app, "_mitgeliefert",
lambda name: str(tmp_path / "paket" / name))
ziel = tmp_path / "installiert"
ziel.mkdir()
ordner = windows_app.ui_auspacken(str(ziel))
assert ordner == str(ziel / "ui")
assert os.path.isfile(os.path.join(ordner, "index.html"))
assert os.path.isfile(os.path.join(ordner, "assets", "haupt.js"))
def test_alter_stand_wird_beim_update_nicht_vermischt(tmp_path, monkeypatch):
"""Ein Update mit weniger Dateien liesse sonst Reste liegen — die
Oberflaeche waere ein Gemisch aus zwei Fassungen, und der Fehler saehe
aus wie ein Zufall."""
paket = tmp_path / "paket" / "ui"
paket.mkdir(parents=True)
(paket / "index.html").write_text("neu", encoding="utf-8")
monkeypatch.setattr(windows_app, "_mitgeliefert",
lambda name: str(tmp_path / "paket" / name))
ziel = tmp_path / "installiert"
(ziel / "ui").mkdir(parents=True)
(ziel / "ui" / "veraltet.js").write_text("alt", encoding="utf-8")
windows_app.ui_auspacken(str(ziel))
assert not os.path.exists(str(ziel / "ui" / "veraltet.js"))
def test_ohne_gebuendeltes_ui_passiert_nichts(tmp_path, monkeypatch):
"""Aus dem Repo gestartet liegt das UI schon an fester Stelle."""
monkeypatch.setattr(windows_app, "_mitgeliefert",
lambda name: str(tmp_path / "gibt-es-nicht"))
assert windows_app.ui_auspacken(str(tmp_path)) == ""
def test_fehlschlag_beim_ablegen_bricht_die_installation_nicht_ab(tmp_path, monkeypatch, capsys):
"""Ohne Kopie laeuft Rippy aus dem Temp weiter — schlechter, aber nicht
kaputt. Gesagt werden muss es trotzdem, sonst sucht spaeter jemand den
404 im Server."""
paket = tmp_path / "paket" / "ui"
paket.mkdir(parents=True)
(paket / "index.html").write_text("x", encoding="utf-8")
monkeypatch.setattr(windows_app, "_mitgeliefert",
lambda name: str(tmp_path / "paket" / name))
def geht_nicht(*a, **k):
raise OSError("Zugriff verweigert")
monkeypatch.setattr(windows_app.shutil, "copytree", geht_nicht)
assert windows_app.ui_auspacken(str(tmp_path / "ziel")) == ""
assert "Oberflaeche konnte nicht abgelegt werden" in capsys.readouterr().out
# ── Oeffnen: Fenster statt Browser-Tab ──────────────────────────────────
def test_oeffnen_nimmt_das_fenster_nicht_den_browser(monkeypatch):
"""Die Antwort auf die Frage des Commanders vom 28.08.2026: „Warum
nutzen wir fuer Windows weiterhin einen Browser?" — tun wir nicht mehr.
Ein Doppelklick fuehrt in ein eigenes Fenster mit eigenem Symbol in der
Taskleiste, nicht in einen Tab zwischen fremden Tabs."""
from rippy import fenster
gerufen = []
monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True)
monkeypatch.setattr(fenster, "oeffnen_oder_browser",
lambda a, t="Rippy": gerufen.append(a) or "fenster")
monkeypatch.setattr("webbrowser.open",
lambda a: pytest.fail("Der Browser darf hier nicht aufgehen"))
assert windows_app.oeffnen(7788) == 0
assert gerufen == ["http://127.0.0.1:7788"]
def test_im_browser_geht_wirklich_in_den_browser(monkeypatch):
"""Der Schalter muss die Bibliothek gar nicht erst anfassen — sonst
haette man auf einem Rechner ohne WebView2 keinen Ausweg."""
from rippy import fenster
gerufen = []
monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True)
monkeypatch.setattr(fenster, "oeffnen_oder_browser",
lambda *a, **k: pytest.fail("kein Fenster erwartet"))
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert windows_app.oeffnen(7788, als_fenster=False) == 0
assert gerufen == ["http://127.0.0.1:7788"]
def test_ohne_antwortenden_dienst_geht_gar_nichts_auf(monkeypatch):
"""Ein Fenster auf eine tote Adresse zeigt einen Verbindungsfehler —
und der sieht aus, als sei Rippy kaputt. Lieber nichts oeffnen und einen
Fehlercode zurueckgeben."""
from rippy import fenster
monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: False)
monkeypatch.setattr(fenster, "oeffnen_oder_browser",
lambda *a, **k: pytest.fail("nichts darf aufgehen"))
monkeypatch.setattr("webbrowser.open",
lambda a: pytest.fail("nichts darf aufgehen"))
assert windows_app.oeffnen(7788) == 1
def test_wenn_weder_fenster_noch_browser_geht_ist_das_ein_fehler(monkeypatch):
"""`oeffnen_oder_browser` gibt „nichts" zurueck, wenn beide Wege
scheitern. Ein Rueckgabewert 0 waere dann eine Luege."""
from rippy import fenster
monkeypatch.setattr(windows_app, "bereitstellen", lambda port=0: True)
monkeypatch.setattr(fenster, "oeffnen_oder_browser", lambda *a, **k: "nichts")
assert windows_app.oeffnen(7788) == 1
def test_laufender_dienst_wird_nicht_noch_einmal_gestartet(monkeypatch):
"""Sonst startete jeder Klick einen ZWEITEN Server, der am belegten Port
scheitert genau die Falle, wegen der die Verknuepfungen ueberhaupt auf
--oeffnen zeigen."""
monkeypatch.setattr(windows_app.Dienst, "erreichbar",
lambda self, timeout=1.0: True)
monkeypatch.setattr(windows_app, "starte_hintergrund",
lambda p: pytest.fail("es laeuft doch schon einer"))
assert windows_app.bereitstellen(7788) is True
# ── Drueberinstallieren (Befund 29.08.2026) ─────────────────────────────
#
# Commander: „Was passiert wenn man die Setup.exe einfach drüber installiert?
# Geht das, ist es dann die aktuellste version?"
#
# Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
# immer — dann ist `Rippy.exe` gesperrt, und die neue Fassung landete als
# `Rippy.exe.neu` daneben, mit der Meldung „wird beim naechsten Start
# uebernommen". **Diese Zusage hat niemand eingeloest**: `.neu` kam im ganzen
# Projekt genau einmal vor, naemlich an der Stelle, die es schrieb.
def test_eine_liegengebliebene_fassung_wird_uebernommen(tmp_path):
from rippy import windows_app
(tmp_path / "Rippy.exe").write_bytes(b"alt")
(tmp_path / "Rippy.exe.neu").write_bytes(b"neu")
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path))
assert (tmp_path / "Rippy.exe").read_bytes() == b"neu"
assert not (tmp_path / "Rippy.exe.neu").exists()
def test_ohne_liegengebliebene_fassung_passiert_nichts(tmp_path):
from rippy import windows_app
(tmp_path / "Rippy.exe").write_bytes(b"alt")
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path)) == ""
assert (tmp_path / "Rippy.exe").read_bytes() == b"alt"
def test_ein_fehlschlag_beim_tausch_wirft_nicht(tmp_path):
"""Rippy laeuft dann mit der alten Fassung weiter — besser als gar nicht."""
from rippy import windows_app
(tmp_path / "Rippy.exe").write_bytes(b"alt")
(tmp_path / "Rippy.exe.neu").write_bytes(b"neu")
def gesperrt(a, b):
raise OSError(32, "in Benutzung")
assert windows_app.ausstehende_fassung_uebernehmen(str(tmp_path), gesperrt) == ""
assert (tmp_path / "Rippy.exe").read_bytes() == b"alt"
-241
View File
@@ -1,241 +0,0 @@
"""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 veraltete(bestand: dict, neueste: dict) -> list:
"""Welche Werkzeuge sind DA, aber überholt? (reine Funktion)
Commander am 28.08.2026: *bitte baue ein, das wenn MakeMKV oder Handbrake
schon installiert sind ein update verfügbar ist. Das kann ja theoretisch
direkt im setup abgehandelt werden."*
Verglichen wird über `beschaffen.ist_neuer` also nach ZAHLEN. Ein
Zeichenvergleich hielte 1.9.2" für neuer als „1.18.4", und HandBrake ist
genau dort.
"""
offen = []
for name, eintrag in sorted((bestand or {}).items()):
if not eintrag.get("da"):
continue
neu = (neueste or {}).get(name, "")
if beschaffen.ist_neuer(neu, eintrag.get("version", "")):
offen.append((name, eintrag, neu))
return offen
def sicherstellen(fortschritt=None, eingestellt: dict = None,
handbrake_holen=None, makemkv_holen=None, flac_holen=None,
bestand_lesen=None, aktualisieren: bool = False,
neueste_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
flac_holen = flac_holen or beschaffen.flac_holen
vorher = bestand_lesen()
geholt, fehler = [], {}
offen = fehlende(vorher)
# Auf Wunsch auch das, was DA ist, aber überholt. Absichtlich hinter den
# fehlenden: Ein Update ist Komfort, ein fehlendes Pflichtwerkzeug ist ein
# Hindernis — bei wackliger Verbindung soll zuerst ankommen, was zählt.
if aktualisieren:
neueste_lesen = neueste_lesen or beschaffen.neueste_versionen
try:
neueste = neueste_lesen()
except Exception: # noqa: BLE001
# Ein Update-Check ist Komfort; er darf nichts blockieren.
neueste = {}
for name, eintrag, neu in veraltete(vorher, neueste):
melden("%s %s -> %s wird aktualisiert …"
% (eintrag.get("titel", name), eintrag.get("version", "?"), neu))
offen.append((name, eintrag))
for nummer, (name, eintrag) in enumerate(offen):
titel = eintrag.get("titel", name)
def teil_melden(text, anteil=None, _n=nummer, _gesamt=len(offen)):
"""Den Anteil EINES Werkzeugs auf die ganze Strecke umrechnen.
Ohne das meldet jeder Download 0 bis 1, und ein Fortschrittsbalken
spränge bei jedem neuen Werkzeug zurück an den Anfang. Zwei
Werkzeuge: das erste füllt 050 %, das zweite 50100 %.
"""
if fortschritt:
fortschritt(text, None if anteil is None
else (_n + anteil) / max(1, _gesamt))
melden("%s fehlt — wird geholt …" % titel)
try:
if name == "handbrake":
handbrake_holen(fortschritt=teil_melden)
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=teil_melden, still=False)
elif name == "flac":
# Der Encoder fuer Audio-CDs. Kein Pflichtwerkzeug: Ohne ihn
# laeuft alles ausser Audio-CDs, und ein Fehlschlag hier darf
# das Einrichten nicht truebe machen.
flac_holen(fortschritt=teil_melden)
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
-271
View File
@@ -1,271 +0,0 @@
"""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)) == []
# ── Updates schon vorhandener Werkzeuge ─────────────────────────────────
#
# Commander am 28.08.2026: "bitte baue ein, das wenn MakeMKV oder Handbrake
# schon installiert sind ein update verfuegbar ist. Das kann ja theoretisch
# direkt im setup abgehandelt werden."
def test_ueberholtes_werkzeug_wird_erkannt():
veraltet = einrichten.veraltete(_bestand(),
{"makemkv": "1.19.0", "handbrake": "1.11.2"})
assert [n for n, _, _ in veraltet] == ["makemkv"]
assert veraltet[0][2] == "1.19.0"
def test_zweistellige_versionen_kippen_nicht():
"""DER Klassiker: Als Zeichenkette waere 1.9.2 groesser als 1.11.2 --
und HandBrake ist genau dort."""
bestand = {"handbrake": {"titel": "HandBrakeCLI", "da": True,
"version": "1.11.2", "pflicht": False}}
assert einrichten.veraltete(bestand, {"handbrake": "1.9.2"}) == []
assert len(einrichten.veraltete(bestand, {"handbrake": "1.11.3"})) == 1
def test_ohne_auskunft_gibt_es_kein_update():
"""Antwortet die Quelle nicht, darf NICHT 'Update verfuegbar' dastehen.
Eine Nichtauskunft ist keine Aussage."""
assert einrichten.veraltete(_bestand(), {}) == []
assert einrichten.veraltete(_bestand(), {"makemkv": ""}) == []
def test_fehlende_werkzeuge_zaehlen_hier_nicht_mit():
"""Die holt `fehlende()` -- sonst stuende ein fehlendes Werkzeug zweimal
in der Liste und wuerde zweimal geholt."""
assert einrichten.veraltete(_bestand(makemkv=False),
{"makemkv": "1.19.0"}) == []
def test_ohne_flag_wird_NICHT_aktualisiert():
"""Eine unbeaufsichtigte Installation soll nicht ploetzlich Downloads
anstossen."""
gerufen = []
einrichten.sicherstellen(
bestand_lesen=lambda: _bestand(),
neueste_lesen=lambda: {"makemkv": "1.19.0"},
handbrake_holen=lambda **k: gerufen.append("hb"),
makemkv_holen=lambda **k: gerufen.append("mkv"))
assert gerufen == []
def test_mit_flag_wird_aktualisiert():
gerufen = []
einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(),
neueste_lesen=lambda: {"makemkv": "1.19.0", "handbrake": "1.11.2"},
handbrake_holen=lambda **k: gerufen.append("hb"),
makemkv_holen=lambda **k: gerufen.append("mkv"))
assert gerufen == ["mkv"]
def test_fehlendes_kommt_VOR_dem_update():
"""Bei wackliger Verbindung soll zuerst ankommen, was zaehlt: Ein
fehlendes Pflichtwerkzeug ist ein Hindernis, ein Update ist Komfort."""
reihenfolge = []
einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(makemkv=False),
neueste_lesen=lambda: {"handbrake": "1.99.0"},
handbrake_holen=lambda **k: reihenfolge.append("handbrake"),
makemkv_holen=lambda **k: reihenfolge.append("makemkv"))
assert reihenfolge == ["makemkv", "handbrake"]
def test_ein_kaputter_update_check_bricht_nichts_ab():
"""Ein Update-Check ist Komfort; er darf nichts blockieren."""
def platzt():
raise OSError("kein Netz")
ergebnis = einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(),
neueste_lesen=platzt,
handbrake_holen=lambda **k: None,
makemkv_holen=lambda **k: None)
assert ergebnis["bereit"] is True
File diff suppressed because it is too large Load Diff