Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77ab0302 | ||
|
|
cedab61ddf | ||
|
|
6f230cbd7f |
+54
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -197,10 +197,16 @@ def test_englisch_wird_bevorzugt(tmp_path):
|
||||
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||
|
||||
|
||||
def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
||||
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei
|
||||
installiertem MakeMKV None zurueck -- Windows-Programme liegen in
|
||||
„Programme", nicht im PATH. Dieser Zweig lief dort also NIE."""
|
||||
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||
Programme liegen in „Programme", nicht im PATH) und war unter Docker
|
||||
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
|
||||
der Aufruf noch die shutil.which-Suche zurueckkommen.
|
||||
|
||||
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden` —
|
||||
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
|
||||
die Entfernung deshalb erst dort gemeldet.)"""
|
||||
import os
|
||||
|
||||
hier = os.path.dirname(os.path.abspath(__file__))
|
||||
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
||||
quelle = "".join(code)
|
||||
assert "shutil.which(" not in quelle, (
|
||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
||||
assert 'werkzeug_katalog.finden("makemkv")' in quelle
|
||||
assert "makemkvcon" not in quelle, (
|
||||
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
|
||||
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) == ""
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
30–90-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
|
||||
@@ -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() == []
|
||||
@@ -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
|
||||
@@ -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: MSF↔LBA, 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}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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>◉</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: '✓', warnung: '!', fehler: '✗' };
|
||||
$('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
|
||||
@@ -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
|
||||
@@ -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", [
|
||||
|
||||
@@ -128,10 +128,15 @@ def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
||||
assert a["ablage_vorgabe"] == "D:\\Filme"
|
||||
|
||||
|
||||
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
||||
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
||||
def test_native_vorgabe_liegt_beim_nutzer():
|
||||
"""Der USERPROFILE-Sonderzweig ist mit der Standalone-App gegangen
|
||||
(Rippy v5, Entscheid 7) — nativ heisst hier nur noch: ein
|
||||
Entwickler-Lauf ausserhalb des Containers, und die Vorgabe liegt im
|
||||
Benutzerordner dieser Maschine."""
|
||||
import os
|
||||
|
||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
||||
assert a["ablage_vorgabe"].startswith(os.path.expanduser("~"))
|
||||
assert a["ablage_vorgabe"].endswith("Rippy")
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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 == {}
|
||||
@@ -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"))
|
||||
@@ -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"
|
||||
@@ -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 0–50 %, das zweite 50–100 %.
|
||||
"""
|
||||
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
|
||||
@@ -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
Reference in New Issue
Block a user