Compare commits
28
Commits
6881a63fb7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b77ab0302 | ||
|
|
cedab61ddf | ||
|
|
6f230cbd7f | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 | ||
|
|
b6a93aa726 | ||
|
|
fed24237c9 | ||
|
|
21c626eb4e | ||
|
|
f0f719ca12 | ||
|
|
f6f0a4ccb6 | ||
|
|
986fcf19b1 | ||
|
|
1b84ec2a45 | ||
|
|
1a529f4e75 | ||
|
|
548742e771 | ||
|
|
aeb3128a85 | ||
|
|
f0d39b4d04 | ||
|
|
e67138e9ca | ||
|
|
dbb934d146 | ||
|
|
7b0c41ddfe | ||
|
|
b2acddbdfa | ||
|
|
21b1757aa4 | ||
|
|
423374e65a | ||
|
|
27c9d9a4bb | ||
|
|
d9780a4951 | ||
|
|
954f293871 | ||
|
|
73ed104bbc | ||
|
|
678fe276f9 | ||
|
|
d0281f7a1b |
+709
-5
@@ -1,6 +1,672 @@
|
|||||||
# SAVEPOINT — Rippy
|
# SAVEPOINT — Rippy
|
||||||
|
|
||||||
## Aktueller Stand: v4.0-rc4 — Cover, Arbeitsordner, aufgeräumt (28.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
|
||||||
|
> demselben Rundgang. 977 Tests gruen.
|
||||||
|
|
||||||
|
### Der Befund
|
||||||
|
|
||||||
|
Spartacus Disc 2, eine Blu-ray mit Kratzern. MakeMKV sichert 1 von 2 Titeln,
|
||||||
|
die Kompression startet — und ist in derselben Sekunde vorbei:
|
||||||
|
|
||||||
|
```
|
||||||
|
13:18:57 Kompression gestartet (1 Datei, Preset 'H.265 VCN 1080p')
|
||||||
|
13:18:57 Kompression fehlgeschlagen: HandBrake endete mit Code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Nachgestellt mit genau der Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
```
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht** — er heisst
|
||||||
|
`-E` / `--aencoder`. Und ein unbekannter Schalter ist fuer HandBrake kein
|
||||||
|
Fehler: Rueckgabewert **0**. Rippy sah nur „Code 0" und keine Datei, riet auf
|
||||||
|
„Zielordner nicht beschreibbar" — und schickte die Suche in die falsche
|
||||||
|
Richtung.
|
||||||
|
|
||||||
|
Ein Test hat den Fehler festgeschrieben statt ihn zu finden:
|
||||||
|
`assert "--audio-codec" in cmd`. Ein Kommandozeilen-Schalter ist eine externe
|
||||||
|
Schnittstelle (Regel D) — er gehoert am echten Programm gemessen.
|
||||||
|
|
||||||
|
### Die Kompression
|
||||||
|
|
||||||
|
* **`--aencoder` statt `--audio-codec`.** Am mitgelieferten HandBrakeCLI
|
||||||
|
1.11.2 gemessen, danach mit einem 5-Sekunden-Encode auf der echten
|
||||||
|
Roh-Datei gegengeprueft.
|
||||||
|
* **HandBrakes letzte Worte werden aufgehoben** (12 Zeilen gepuffert, 4 in der
|
||||||
|
Meldung). Vorher wurde jede Zeile weggeworfen, die kein Fortschritt war —
|
||||||
|
bei Rueckgabewert 0 blieb damit gar keine Auskunft uebrig. Dazu eine eigene
|
||||||
|
Erkennung fuer `unknown option (...)`, die VOR allen anderen greift.
|
||||||
|
* **Ein `ü` im Pfad toetete die Kompression.** HandBrake schreibt zwei
|
||||||
|
Kodierungen in denselben Strom — denselben Pfad einmal als UTF-8, einmal als
|
||||||
|
CP850. In CP850 ist `ü` das Byte 0x81, und das ist in cp1252 (was
|
||||||
|
`text=True` auf einem deutschen Windows waehlt) **undefiniert**:
|
||||||
|
|
||||||
|
```
|
||||||
|
UnicodeDecodeError: charmap codec can't decode byte 0x81 in position 785
|
||||||
|
```
|
||||||
|
|
||||||
|
Betroffen war jeder Film mit „Glück", „Tür", „München", „Über", „Grün" —
|
||||||
|
dazu `ì`, `Å`, `É`, `Ø`. Die Entscheidung liegt jetzt als `HB_LESEN` im
|
||||||
|
gemeinsamen `rip/handbrake_aufruf.py`, weil `caps.py` HandBrake ebenfalls
|
||||||
|
aufruft. Bewusst NICHT binaer wie bei makemkvcon: HandBrake trennt seine
|
||||||
|
Fortschrittszeilen mit CR.
|
||||||
|
|
||||||
|
### Die Rohdaten
|
||||||
|
|
||||||
|
* **`F:\` wurde zu `F:`.** `rohdaten.kandidaten` strich den Schluss-Trenner ab
|
||||||
|
und verband mit dem Rest weiter. `F:` ohne Trenner ist unter Windows der
|
||||||
|
AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel:
|
||||||
|
|
||||||
|
```
|
||||||
|
verbinden("F:", job_id) -> F:1aa41fef-... isdir: False
|
||||||
|
verbinden("F:\", job_id) -> F:\1aa41fef-... isdir: True
|
||||||
|
```
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||||
|
rippen" an: Stunden am beschaedigten Datentraeger fuer etwas, das dalag. Die
|
||||||
|
Falle steht woertlich im Kopf von `pfade.verbinden`.
|
||||||
|
* **Gesucht wurde unter der heutigen Einstellung, nicht unter der Wahl des
|
||||||
|
Jobs.** Der Rip-Dialog laesst pro Rip waehlen (seit v3.15); die Wahl steht in
|
||||||
|
`meta["work_dir"]`. Genau dafuer wurde `rohdaten.py` am 26.07. gebaut —
|
||||||
|
repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
|
||||||
|
* **Zwei Speicher fuer dieselben Ordner.** Die Oberflaeche schreibt
|
||||||
|
`outputDir`/`workDir` in die Datenbank, `betrieb` liest
|
||||||
|
`storage.medien`/`storage.temp` aus der Konfigurationsdatei — und die
|
||||||
|
schreibt niemand. An der laufenden Instanz gemessen:
|
||||||
|
|
||||||
|
```
|
||||||
|
eingestellt E:\Rippy (beides)
|
||||||
|
angezeigt C:\Users\TobisPC\Videos\Rippy und ...\_arbeit
|
||||||
|
```
|
||||||
|
|
||||||
|
Neue Bruecke `betrieb.mit_einstellungen` — dieselbe Reihenfolge, die der
|
||||||
|
Worker seit jeher benutzt.
|
||||||
|
|
||||||
|
### Das Laufwerk
|
||||||
|
|
||||||
|
* **Rippy kannte den Grund und behielt ihn fuer sich.** Nach dem Rip mit 29
|
||||||
|
Lesefehlern und einem gescheiterten Auswurf beantwortete das Laufwerk nichts
|
||||||
|
mehr, was mit dem MEDIUM zu tun hat — die Geraete-Auskunft aber schon:
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Im UI stand eine vollstaendige Laufwerkskarte mit Modell und Seriennummer,
|
||||||
|
daneben „unknown" — und kein Rip startbar. Commander: *„jetzt erkennt rippy
|
||||||
|
die disk garnicht mehr (im log steht zwar erkannt, aber ein start des rips
|
||||||
|
ist nicht möglich)"*. Jetzt gibt es `ZUGRIFFS_GRUENDE` mit gemessenem
|
||||||
|
Klartext je Fehlernummer, der Grund reist im Laufwerks-Eintrag mit, und die
|
||||||
|
Wache schreibt ihn einmal je Wechsel ins Protokoll — samt „antwortet
|
||||||
|
wieder". Auch eine fehlgeschlagene Disc-Erkennung landet dort, statt nur auf
|
||||||
|
einer Konsole, die niemand sieht.
|
||||||
|
* **Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty"**, waehrend
|
||||||
|
Windows richtig „unknown" sagt. Dieselbe Lage, zwei Antworten. Angeglichen;
|
||||||
|
der Feld-Paritaetstest deckt das neue Feld mit ab.
|
||||||
|
* **ctypes ohne Typangaben.** `CreateFileW`, `DeviceIoControl` und
|
||||||
|
`CloseHandle` hatten weder `restype` noch `argtypes` — ctypes nimmt dann
|
||||||
|
32-Bit-`c_int` fuer einen 64-Bit-HANDLE, hin wie zurueck. Die Tuecke: Mit
|
||||||
|
`restype` aendert sich der Fehlerwert von -1 auf 0xFFFFFFFFFFFFFFFF, die
|
||||||
|
alte Pruefung haette stillschweigend aufgehoert zu greifen. Am echten
|
||||||
|
Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.
|
||||||
|
* **Der teure Vor-Scan, den niemand las.** Bei jeder eingelegten Disc lief ein
|
||||||
|
`makemkvcon info` mit 120 s Zeitgrenze — auf einer Blu-ray 20 bis 120
|
||||||
|
Sekunden „Disc wird gelesen". Commander: *„das erkennen der disk dauert sehr
|
||||||
|
sehr lange. Das ging mal viel schneller."* Es ging schneller, weil der Zweig
|
||||||
|
unter Windows NIE lief (`shutil.which`, repariert am 28.08.). Und das
|
||||||
|
Ergebnis landete allein in `toc["tracks"]`, das niemand liest — der
|
||||||
|
Rip-Dialog holt seine Liste ueber `/devices/{id}/scan-tracks`, wenn sie
|
||||||
|
gebraucht wird.
|
||||||
|
|
||||||
|
### Drei Notbremsen
|
||||||
|
|
||||||
|
* **Endlosschleife vor jedem Rip.** `_frei_bytes` suchte den naechsten
|
||||||
|
vorhandenen Ordner selbst. `os.path.dirname("Q:\\")` gibt sich SELBST
|
||||||
|
zurueck — an einem freien Laufwerksbuchstaben gemessen, nach drei Runden
|
||||||
|
festgefahren. Ein Arbeitsverzeichnis auf einer abgezogenen Platte haette den
|
||||||
|
Job stumm haengen lassen, in `_platz_pruefen`, also VOR dem Rip.
|
||||||
|
`pfade.naechster_vorhandener` macht es seit V2-1 richtig; es war die ganze
|
||||||
|
Zeit da.
|
||||||
|
* **Laufwerks- und UNC-Wurzeln** bleiben in `naechster_vorhandener` jetzt
|
||||||
|
absolut — dieselbe Falle wie bei den Rohdaten, zweiter Fundort.
|
||||||
|
* **`rmdir /s /q` aus der Registry.** Das Aufraeum-Skript der Deinstallation
|
||||||
|
baut seinen Loeschbefehl aus `InstallLocation`, also aus dem `--ziel` beim
|
||||||
|
Installieren. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
|
||||||
|
das Laufwerk; der noetige rstrip macht den Fall erst scharf. Nicht
|
||||||
|
beobachtet — aber nicht wiedergutzumachen.
|
||||||
|
|
||||||
|
### Lesefehler heissen jetzt Lesefehler
|
||||||
|
|
||||||
|
MakeMKV sicherte 1 von 2 Titeln, beendete sich mit 0, und Rippy schrieb „Rip
|
||||||
|
fertig". Dass ein Titel fehlt, stand nur in Zeilen, die niemand liest. Jetzt
|
||||||
|
gibt es eine Warnung nach dem Rip — auch und gerade dann, wenn er als Erfolg
|
||||||
|
endet — mit Abhilfe: reinigen, anderes Laufwerk. Und **die MSG-Nummer steht im
|
||||||
|
Protokoll**: MakeMKVs Texte sind uebersetzt, die Nummern nicht.
|
||||||
|
|
||||||
|
### Zwei Funde aus der Gegenprobe am laufenden Rippy
|
||||||
|
|
||||||
|
Die erste Fassung von rc11 war installiert, als der Commander meldete: *„nun
|
||||||
|
öffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
|
||||||
|
wieder zu. Das laufwerk hört auch einfach auf zu lesen."* Beides waren
|
||||||
|
Altlasten, die erst jetzt sichtbar wurden.
|
||||||
|
|
||||||
|
**Die aufblitzenden Fenster.** Prozesserzeugung 20 Sekunden lang mitgeschnitten:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:54:40 timeout.exe timeout 4 ls -d C:\Users\...\d7ee6c06-...
|
||||||
|
14:54:40 WindowsTerminal.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
`rohdaten.pruefen` fragt „gibt es dieses Verzeichnis?" mit `timeout N ls -d`.
|
||||||
|
Unter Linux ist das genau richtig: `os.path.isdir` kann an einem toten
|
||||||
|
CIFS-Mount im Kernel haengen (Zustand D, 26.07.2026), ein Kind-PROZESS laesst
|
||||||
|
sich abbrechen. Unter Windows ist es dreifach falsch — `timeout.exe` gibt es
|
||||||
|
dort, sie wartet aber nur Sekunden ab und kennt weder `ls` noch `-d`; sie
|
||||||
|
braucht eine Konsole, und die reisst Windows auf; und ihr Rueckgabewert ist
|
||||||
|
nie 0, also lautete die Antwort **„weg" fuer jedes Verzeichnis**. Rohdaten
|
||||||
|
waren unter Windows grundsaetzlich unsichtbar — der zweite, tiefere Grund fuer
|
||||||
|
„Auf der Platte liegt zu diesem Job nichts (mehr)". Neu: `nativ_nachsehen()`,
|
||||||
|
und dieselbe Absicherung fuer die zwei gleichartigen Aufrufe in `mounts.py`.
|
||||||
|
|
||||||
|
**Das Laufwerk hoert auf zu lesen.** Aus dem Protokoll:
|
||||||
|
|
||||||
|
```
|
||||||
|
12:49:52 bluray-Rip gestartet
|
||||||
|
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen mehr
|
||||||
|
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||||
|
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden `device_info` ab — drei `CreateFileW`
|
||||||
|
plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon gehoert.
|
||||||
|
`_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel *„Es gibt keinen
|
||||||
|
Grund, waehrend eines Rips zu scannen"*; die Laufwerksabfrage tat es nicht.
|
||||||
|
Jetzt gilt waehrend eines Rips der letzte bekannte Stand — das ist keine
|
||||||
|
Notluege, denn am Laufwerk aendert sich in der Zeit nichts.
|
||||||
|
|
||||||
|
Sichtbar wurden beide erst durch die neue Protokollzeile aus demselben
|
||||||
|
Rundgang. Ein Fehler, der sich meldet, sieht aus wie ein neuer.
|
||||||
|
|
||||||
|
### Geprueft, nichts gefunden
|
||||||
|
|
||||||
|
FLAC samt MusicBrainz-Tags mit Umlauten (landen korrekt als UTF-8) · die
|
||||||
|
makemkvcon-Aufrufe in `schluessel.py` (haben `errors="replace"` bereits) · die
|
||||||
|
Laufwerksliste in `main.py` (rstrip nur fuer den Anzeigenamen) · die uebrigen
|
||||||
|
`dirname`-Schleifen (es gab nur die eine) · `unbekanntes_preset` (Wortlaut und
|
||||||
|
Rueckgabewert 2 gemessen — korrekt).
|
||||||
|
|
||||||
|
### Zwei Tests, die gelogen haben
|
||||||
|
|
||||||
|
* `assert "--audio-codec" in cmd` — schrieb den Fehler fest, statt ihn zu
|
||||||
|
finden.
|
||||||
|
* `lambda: {}` als Doppelgaenger fuer
|
||||||
|
`get_settings(key="ui", bei_fehler_leer=False)` — brach, sobald ein Aufrufer
|
||||||
|
einen Parameter benutzte, und zeigte dann auf den Code statt auf sich
|
||||||
|
selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er ersetzt,
|
||||||
|
nicht nur den einen Aufruf, den es gerade gibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0-rc10 — makemkvcon ueberlebt Rippy nicht mehr (29.08.2026)
|
||||||
|
|
||||||
|
> **Ein liegengebliebener `makemkvcon` haelt das Laufwerk fest — jeder spaetere
|
||||||
|
> Rip scheitert dann.** 954 Tests gruen.
|
||||||
|
|
||||||
|
### Gemessen, nicht vermutet
|
||||||
|
|
||||||
|
Elternprozess startet makemkvcon, dann wird er hart beendet (`taskkill /F`
|
||||||
|
ohne `/T` — genau das, was beim Dienst-Stopp und beim Drueber-Installieren
|
||||||
|
passiert):
|
||||||
|
|
||||||
|
```
|
||||||
|
ohne Leine makemkvcon PID 15368 vor dem Kill: True danach: True
|
||||||
|
mit Leine makemkvcon PID 43608 vor dem Kill: True danach: False
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows raeumt Kindprozesse nicht auf. Zwei solche Waisen standen waehrend
|
||||||
|
der Messungen auf diesem Rechner.
|
||||||
|
|
||||||
|
### Zwei Riegel
|
||||||
|
|
||||||
|
* **Die Leine** — eine Arbeitsgruppe (Job Object). Stirbt Rippy, sterben
|
||||||
|
makemkvcon, HandBrake und flac mit. Auch beim Absturz, auch per
|
||||||
|
Taskmanager. Einmal beim Start gesetzt, deckt sie jeden Werkzeugaufruf ab.
|
||||||
|
* **Der Aufraeumer** — beendet beim Start alles, was kein Elternprozess mehr
|
||||||
|
haelt. Fuer das, was eine aeltere Fassung hinterlassen hat. Nur
|
||||||
|
**elternlose**: Ein makemkvcon eines laufenden Rippy bleibt unangetastet,
|
||||||
|
und `makemkv.exe` (die Oberflaeche) steht gar nicht erst auf der Liste.
|
||||||
|
|
||||||
|
Am echten Fall nachgestellt — Waise erzeugt, Rippy gestartet, Waise weg:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Waise nach dem Drueber-Installieren: PID 36400 lebt = True
|
||||||
|
2. Rippy erkennt sie als herrenlos: True
|
||||||
|
3. Beim Start weggeraeumt: makemkvcon64.exe
|
||||||
|
4. Waise lebt danach noch: False
|
||||||
|
```
|
||||||
|
|
||||||
|
Dienst, Fenster und das Aufraeum-Skript der Deinstallation loesen sich aus der
|
||||||
|
Gruppe heraus — die drei muessen Rippy ueberleben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0-rc9 — der Rip, der sich selbst dazwischenfunkte (29.08.2026)
|
||||||
|
|
||||||
|
> **Ein zweiter Rip startete waehrend der Kompression — in ein Laufwerk, dessen
|
||||||
|
> Schublade gerade herausfuhr.** 941 Tests gruen.
|
||||||
|
|
||||||
|
### Was der Commander sah
|
||||||
|
|
||||||
|
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||||
|
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||||
|
|
||||||
|
Und dazu: *„Der Rip an sich war bereits fertig, bei der Komprimierung passiert
|
||||||
|
das."* Das klang nach einem Fehler in der Kompression. Es war keiner.
|
||||||
|
|
||||||
|
Entscheidend war, was in der Meldung **fehlte**: kein „Ursache:". Haette
|
||||||
|
MakeMKV den Geraetepfad nicht gekannt, stuende dort Meldung 2024. Bleibt genau
|
||||||
|
eine Erklaerung — es lag keine Disc im Laufwerk.
|
||||||
|
|
||||||
|
### Der Ablauf
|
||||||
|
|
||||||
|
1. Rip fertig → Rippy wirft die Disc aus (so ist es eingestellt)
|
||||||
|
2. Der Job geht auf `transcoding` — **und ab hier meldete Rippy das Laufwerk
|
||||||
|
als frei.** Die Pruefung kannte nur „wartet" und „laeuft"
|
||||||
|
3. Die Disc-Wache sieht den Auswurf als Statuswechsel und meldet „eingelegt"
|
||||||
|
4. Die Vollautomatik startet einen **zweiten** Rip — auf die herausfahrende
|
||||||
|
Schublade
|
||||||
|
|
||||||
|
Der zweite Rip lief ins Leere. Auf dem Bildschirm sah das aus, als sei die
|
||||||
|
Kompression gescheitert. Sie lief die ganze Zeit ungestoert weiter.
|
||||||
|
|
||||||
|
### Drei Aenderungen
|
||||||
|
|
||||||
|
* **Die Automatik haelt sich zurueck, bis der Vorgang durch ist** — die
|
||||||
|
Kompression zaehlt jetzt dazu. Von Hand darf der Commander weiterhin alles,
|
||||||
|
und die Disc laesst sich waehrend der Kompression weiterhin auswerfen: Da
|
||||||
|
haelt niemand das Laufwerk.
|
||||||
|
* **Erst nachsehen, dann rippen.** Liegt keine Disc drin, sagt Rippy das in
|
||||||
|
einem Satz — statt zwei Minuten in makemkvcon zu laufen und mit Code 11 zu
|
||||||
|
enden. Wenn das Nachsehen selbst scheitert, wird trotzdem gerippt: „ich
|
||||||
|
weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||||
|
* **Meldung 5010 wird uebersetzt.** MakeMKVs Sammelmeldung „Das Oeffnen der
|
||||||
|
Disk schlug fehl" sagt fuer sich nichts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0-rc8 — Windows vollständig (29.08.2026)
|
||||||
|
|
||||||
|
> **Der Rundgang durch die Docker-Reste ist durch, und die Audio-CD-Lücke ist
|
||||||
|
> zu.** 915 Tests grün.
|
||||||
|
|
||||||
|
### Die letzte Lücke: Audio-CDs
|
||||||
|
|
||||||
|
`cdparanoia` und `abcde` gibt es für Windows nicht — ein Nachbau ihrer
|
||||||
|
Shell-Logik wäre ein zweites Projekt gewesen. Gebaut ist stattdessen der Weg,
|
||||||
|
den Windows selbst anbietet:
|
||||||
|
|
||||||
|
* **Lesen** macht Rippy selbst über zwei Win32-Steuercodes. Eine Audio-CD hat
|
||||||
|
kein Dateisystem; die `Track01.cda`, die Windows zeigt, sind 44 Byte große
|
||||||
|
Platzhalter. Die Musik liegt roh in 2352-Byte-Sektoren.
|
||||||
|
* **Kodieren** macht der FLAC-Encoder, den Rippy beim Einrichten holt
|
||||||
|
(offizieller Xiph-Spiegel, Fassung 1.5.0). Kein Pflichtwerkzeug: Ohne ihn
|
||||||
|
läuft alles außer Audio-CDs.
|
||||||
|
|
||||||
|
Gemessen: FLAC 1.5.0 geladen, zwei Spuren gerippt, **und FLAC selbst bestätigt
|
||||||
|
seine Dateien** (`flac -t`, Code 0). Am echten Laufwerk gegengeprüft, dass das
|
||||||
|
Inhaltsverzeichnis gelesen wird — die eingelegte Blu-ray meldet sich korrekt
|
||||||
|
als *Daten*-Track und wird nicht als Musik behandelt.
|
||||||
|
|
||||||
|
### Und die Titel dazu
|
||||||
|
|
||||||
|
Die Dateien heißen nicht mehr `Track 01.flac`. MusicBrainz erkennt eine CD an
|
||||||
|
der **Lage ihrer Spuren** — daraus wird ein Fingerabdruck gerechnet, und der
|
||||||
|
führt zu Album, Interpret, Jahr und jedem Titel.
|
||||||
|
|
||||||
|
Belegt ohne Audio-CD, weil MusicBrainz zu jeder bekannten Kennung die
|
||||||
|
Spurlage herausgibt, aus der sie gerechnet wurde:
|
||||||
|
|
||||||
|
```
|
||||||
|
Spurlage von „Back in Black" (abgefragt) → 3KVSIWn_ewv9z0cCizmOJzDWtsQ-
|
||||||
|
MusicBrainz sagt dazu → 3KVSIWn_ewv9z0cCizmOJzDWtsQ-
|
||||||
|
```
|
||||||
|
|
||||||
|
Die ganze Kette am Stück gemessen — echte Spurlage, echte Abfrage, echter
|
||||||
|
Encoder:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ordner ACDC - Back in Black
|
||||||
|
Dateien 01 - Hells Bells.flac, 02 - Shoot to Thrill.flac, …
|
||||||
|
Tags TITLE / ARTIST / ALBUM / ALBUMARTIST / DATE / TRACKNUMBER
|
||||||
|
(mit metaflac aus der fertigen Datei zurückgelesen)
|
||||||
|
```
|
||||||
|
|
||||||
|
Zwei Fallen dabei: Die Kennung rechnet **mit** den 150 Frames Vorlauf, das
|
||||||
|
Lesen **ohne** — wer das verwechselt, bekommt eine Kennung, die niemand kennt,
|
||||||
|
und zwar ohne Fehlermeldung. Und `AC/DC - Back in Black` hätte als Ordner
|
||||||
|
einen Unterordner aufgemacht; die Band heißt nun mal so.
|
||||||
|
|
||||||
|
Zwei Fallen stecken in der Sache, beide dokumentiert und beide im Code
|
||||||
|
begründet: Die Leseadresse zählt in **2048er**-Einheiten, obwohl ein
|
||||||
|
Audio-Sektor 2352 Bytes hat. Und `flac.exe` braucht `libFLAC.dll` daneben —
|
||||||
|
mit nur der exe endet jeder Aufruf mit „DLL nicht gefunden", ohne eine Zeile
|
||||||
|
Ausgabe.
|
||||||
|
|
||||||
|
### Was der Rundgang sonst noch fand
|
||||||
|
|
||||||
|
**`/dev/{name}` in drei Endpunkten.** Das UI ruft sie mit der Kennung `G` auf,
|
||||||
|
gebaut wurde `/dev/G` — **Auswerfen und „Disc scannen" antworteten unter
|
||||||
|
Windows immer mit 404.** Beide Treiber lösen die Kennung jetzt selbst auf.
|
||||||
|
|
||||||
|
**`os.path.isdir("/app")` zum zweiten Mal**, jetzt in `ablauf.py`: Rippy hielt
|
||||||
|
sich für einen *fremden* Worker.
|
||||||
|
|
||||||
|
**`shutil.which` in `schluessel.py`** — ausgerechnet im Modul, das es nur unter
|
||||||
|
Windows gibt. Die Disc-Schlüssel-Automatik für 4K-UHD lief nie an.
|
||||||
|
|
||||||
|
Dazu ein Wächter-Test: Er prüft ab sofort **mechanisch**, dass im Windows-Weg
|
||||||
|
kein Container-Pfad ohne Begründung steht.
|
||||||
|
|
||||||
|
### HandBrakes „Code 0"
|
||||||
|
|
||||||
|
Code 0 heißt **Erfolg**. Rippy meldete trotzdem „fehlgeschlagen", weil die
|
||||||
|
Datei nicht am erwarteten Ort lag: HandBrake bestimmt den Container aus dem
|
||||||
|
**Preset**, nicht aus der Endung. Jetzt wird er erzwungen.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-rc7 — vier Befunde, drei mit derselben Wurzel (29.08.2026)
|
||||||
|
|
||||||
|
> Setup auf dem Desktop, `7b0c41d`, **852 Tests grün**.
|
||||||
|
|
||||||
|
### Deine vier Fragen
|
||||||
|
|
||||||
|
**Drüberinstallieren ging nicht zuverlässig.** Rippy startet mit Windows, läuft
|
||||||
|
also fast immer — dann ist `Rippy.exe` gesperrt, und die neue Fassung landete
|
||||||
|
als `Rippy.exe.neu` daneben, mit der Meldung *„wird beim nächsten Start
|
||||||
|
übernommen"*. Die hat niemand eingelöst: `.neu` kam im ganzen Projekt genau
|
||||||
|
einmal vor, an der Stelle, die es schrieb. Jetzt wird der laufende Rippy
|
||||||
|
vorher beendet; erhalten bleiben Einstellungen, Jobs und Keys.
|
||||||
|
|
||||||
|
**Updater auf dein Gitea:** sinnvoll, aber erst nach einem echten
|
||||||
|
Release-Weg — und mit leer vorbelegter Quell-Adresse, damit ein
|
||||||
|
weitergegebener Rippy nicht bei einem Fremden nach `192.168.178.153` sucht.
|
||||||
|
Wartet auf dein Ja.
|
||||||
|
|
||||||
|
**Drei Linux-Reste** im Windows-Betrieb: Rippy hielt sich für einen *fremden*
|
||||||
|
Worker (`/app` als Container-Kennzeichen), die Schlüssel-Auskunft blieb
|
||||||
|
dauerhaft „unbekannt", und die Rohdaten-Suche fand nie etwas — „Rohdaten
|
||||||
|
mitlöschen" löschte nichts.
|
||||||
|
|
||||||
|
**Durchsuchen-Knopf** in Ablage und Arbeitsverzeichnis, wie im Installer.
|
||||||
|
|
||||||
|
### Das Bildschirmfoto: drei Fehler, eine Ursache
|
||||||
|
|
||||||
|
TYP „DISC" STARTZEIT „1.1.1970" STATUS „running" Aktiv (0)
|
||||||
|
|
||||||
|
⚠️ **Mein Fehler vom selben Vormittag.** Ich hatte `type` und `startTime` ins
|
||||||
|
Job-Ereignis aufgenommen — mit Feldnamen, die es in der Datenbankzeile nicht
|
||||||
|
gibt (dort: `disc_type`, `created_at`). Statt eines *fehlenden* Feldes kam ein
|
||||||
|
*leeres*, und `new Date(null)` ist der 1.1.1970. Aus „offensichtlich kaputt"
|
||||||
|
wurde „sieht plausibel aus".
|
||||||
|
|
||||||
|
Daran hingen zwei weitere Symptome: Weil der Status roh als `running` ankam
|
||||||
|
statt als `processing`, gab es keinen „aktiven Job" — also keine Kachel, also
|
||||||
|
**keinen Abbrechen-Knopf** und „Aktiv (0)". Der Knopf steht jetzt zusätzlich
|
||||||
|
in der Job-Zeile.
|
||||||
|
|
||||||
|
Mein Test hat nichts davon gefunden: Ich hatte seine Beispielzeile selbst
|
||||||
|
erfunden, mit meinen falschen Feldnamen. **Ein Test, der dieselbe Annahme
|
||||||
|
macht wie der Code, prüft nichts.**
|
||||||
|
|
||||||
|
### Und die Disc: drei Listen, nur eine mit Disc
|
||||||
|
|
||||||
|
`/devices` hängte die erkannte Disc an, der Ereignis-Wächter und der
|
||||||
|
Schnappschuss nicht — und das Dashboard liest den Schnappschuss. Ein Test
|
||||||
|
zählt jetzt die Aufrufe; bei zwei ist er rot.
|
||||||
|
|
||||||
|
### Nachtrag: die Wartezeit ist jetzt sichtbar
|
||||||
|
|
||||||
|
> „das die disc erkennung noch läuft muss sichtbar sein"
|
||||||
|
|
||||||
|
Die Erkennung dauert rund zwei Minuten (`makemkvcon info` läuft in seine
|
||||||
|
120-Sekunden-Grenze; gemessen: Disc nach 119 s da). Zwei Minuten, in denen
|
||||||
|
**nichts** zu sehen war — das sah aus wie ein leeres Laufwerk.
|
||||||
|
|
||||||
|
Der Zustand war sogar schon da: Rippy merkt sich beim Start „läuft gerade".
|
||||||
|
Nur ließ die Laufwerksliste so einen Eintrag komplett weg, damit keine
|
||||||
|
halbfertige Disc-Karte erscheint. Richtig gedacht, falsch gelöst.
|
||||||
|
|
||||||
|
Jetzt: eine Karte mit Spinner („Disc wird gelesen — Laufwerk G:") und eine
|
||||||
|
eigene Zeile im Server-Status — ohne einen Titel zu behaupten, den es noch
|
||||||
|
nicht gibt.
|
||||||
|
|
||||||
|
**Der Haken dahinter:** Genau während der Erkennung hält makemkvcon das
|
||||||
|
Laufwerk, `/devices` braucht dann **14 s** statt der 5 s Zeitgrenze. Der
|
||||||
|
Schnappschuss meldete „konnte nicht nachsehen", und nach einem frischen Laden
|
||||||
|
blieb der Bildschirm leer — ausgerechnet in der Phase, die sichtbar sein
|
||||||
|
soll. Rippy antwortet dort jetzt ohne Laufwerkszugriff: letzter bekannter
|
||||||
|
Stand plus die aktuelle Marke.
|
||||||
|
|
||||||
|
### Beobachtung am Rande
|
||||||
|
|
||||||
|
Beim Testen blieben zweimal **verwaiste `makemkvcon64`-Prozesse** aus
|
||||||
|
abgebrochenen Läufen stehen. Solange die leben, blockieren sie **jeden**
|
||||||
|
Laufwerkszugriff. Nicht angefasst — sag Bescheid, wenn Rippy die beim Start
|
||||||
|
aufräumen soll.
|
||||||
|
|
||||||
|
### Was offen bleibt
|
||||||
|
|
||||||
|
**Dein MakeMKV 1.18.4 ist zu alt** für den aktuellen Beta-Key (unverändert
|
||||||
|
seit rc5); makemkv.com antwortet weiter mit HTTP 525.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-rc6 — leerer Bildschirm behoben (29.08.2026)
|
||||||
|
|
||||||
|
> **Dein Befund war zweimal richtig und einmal irreführend:** Der Bildschirm
|
||||||
|
> war wirklich leer — aber der Rip lief. Man sah ihn nur nie.
|
||||||
|
|
||||||
|
### Der leere Hintergrund
|
||||||
|
|
||||||
|
Nachgestellt auf einem Testdienst hier, nicht bei dir. Nach dem Klick auf
|
||||||
|
„Rippen starten" stand in der Browser-Konsole:
|
||||||
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
|
||||||
|
Und währenddessen, direkt an der Schnittstelle gemessen:
|
||||||
|
|
||||||
|
status: processing · progress: 12
|
||||||
|
|
||||||
|
**Er startet also sehr wohl.** Die Oberfläche war nur weg, bevor sie es zeigen
|
||||||
|
konnte.
|
||||||
|
|
||||||
|
Die Ursache: Das Ereignis „Job angelegt" trug nur Status, Fortschritt, Titel
|
||||||
|
und Fehler — **keinen Typ**. Die Oberfläche fügt so einen halben Job in ihre
|
||||||
|
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und React baut bei
|
||||||
|
einem Fehler im Zeichnen den **ganzen** Baum ab. Eine Fehlergrenze, die das
|
||||||
|
auffängt, gab es in diesem Projekt nirgends — deshalb leerer Bildschirm ohne
|
||||||
|
jede Meldung.
|
||||||
|
|
||||||
|
Drei Reparaturen, weil es drei Fehler waren:
|
||||||
|
|
||||||
|
1. **Das Ereignis trägt den Job** — Typ, Laufwerk und Startzeit fahren mit.
|
||||||
|
Sie ändern sich nie, kosten also kein zusätzliches Ereignis; ohne die
|
||||||
|
Startzeit stand in der Jobliste sekundenlang „Invalid Date".
|
||||||
|
2. **Die Oberfläche verträgt sein Fehlen.** Zeile 108 derselben Datei hatte
|
||||||
|
die Absicherung längst, Zeile 48 nicht.
|
||||||
|
3. **Eine Fehlergrenze.** Jetzt steht da, was los ist, die Navigation bleibt
|
||||||
|
bedienbar, und der Hinweis sagt das Wichtigste: *laufende Rips gehen
|
||||||
|
weiter*.
|
||||||
|
|
||||||
|
Warum es niemand gefunden hat: Die Tests reichten der Vergleichsfunktion
|
||||||
|
immer ihre eigenen Wörterbücher herein — **die Kurzform selbst war nie
|
||||||
|
geprüft.** Jetzt bewacht ein Vertrag die Felder mechanisch.
|
||||||
|
|
||||||
|
### Der zweite Fund: `F:\app\temp`
|
||||||
|
|
||||||
|
Beim Aufräumen des Testlaufs fand ich 436 MB Rohdaten in einem Ordner namens
|
||||||
|
`app` auf dem Laufwerk, von dem Rippy gerade lief. Zwei weitere
|
||||||
|
Container-Wurzeln:
|
||||||
|
|
||||||
|
RAW_DIR = /app/temp/raw
|
||||||
|
MEDIA_ROOT = /app/media
|
||||||
|
|
||||||
|
Und schlimmer als der falsche Standard: Die Prüfung verwarf **auch eine
|
||||||
|
ausdrückliche Wahl**. `D:\Roh` liegt nicht unter `/app/media`, also fiel es
|
||||||
|
still zurück.
|
||||||
|
|
||||||
|
**Damit kam der Arbeitsordner, den du gestern bestellt hast, unter Windows nie
|
||||||
|
an.** Der Dialog zeigte ihn, das Setzen ging, der Worker ignorierte ihn — ohne
|
||||||
|
ein Wort. Dasselbe galt fürs Ziel: Deine UNC-Freigabe liegt unter keiner
|
||||||
|
lokalen Wurzel, die fertige Datei wäre in `X:\app\media\bluray` gelandet.
|
||||||
|
|
||||||
|
Jetzt kommen beide Wurzeln aus dem Betrieb. Im Container ändert sich nichts.
|
||||||
|
|
||||||
|
### Aufgeräumt
|
||||||
|
|
||||||
|
Ich habe für die Prüfung zweimal einen echten Rip auf deinem Laufwerk
|
||||||
|
gestartet und beide sofort abgebrochen. Die 436 MB Rohdaten und der Ordner
|
||||||
|
`F:\app` sind entfernt, die Testdienste beendet.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-rc5 — **Rippy rippt** (29.08.2026)
|
||||||
|
|
||||||
|
> **Der erste bewiesene Rip unter Windows.** Dein Fehlerbericht enthielt drei
|
||||||
|
> Fehler auf einmal — alle drei gefunden, behoben und an deinem Laufwerk
|
||||||
|
> nachgemessen.
|
||||||
|
|
||||||
|
### Der Beweis
|
||||||
|
|
||||||
|
ripping.run_makemkv(\\.\G:, …, titel="3")
|
||||||
|
Status success · Code 0 · „Evangelion 2.22_t03.mkv" 217,9 MB
|
||||||
|
MSG:5036 „Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert."
|
||||||
|
|
||||||
|
Bewusst der kürzeste Titel (127 s), damit die Probe Sekunden dauert und deine
|
||||||
|
Platte nicht 33 GB kostet. Die Probedatei ist wieder gelöscht.
|
||||||
|
|
||||||
|
### 1. Die Quellenangabe — das war der Abbruch
|
||||||
|
|
||||||
|
dev:\\.\G: → „Unknown device" → Das Öffnen der Disk schlug fehl
|
||||||
|
dev:G: → 68 Titel, „erfolgreich abgeschlossen"
|
||||||
|
|
||||||
|
Rippy übergab MakeMKV den Geräte-Namensraum `\\.\G:`. Den braucht Windows für
|
||||||
|
die Laufwerksabfragen — MakeMKV kennt ihn nicht, es will den Buchstaben. Unter
|
||||||
|
Linux (`/dev/sr0`) war dieselbe Zeile richtig. Sie stand an **vier** Stellen.
|
||||||
|
|
||||||
|
### 2. „Ö" statt „Ö"
|
||||||
|
|
||||||
|
Das ist `Ö` als UTF-8, gelesen als Windows-Zeichensatz. Rippy stellt seine
|
||||||
|
Ausgabe beim Start auf UTF-8 (sonst stirbt der Start an einer Umlaut-Zeile),
|
||||||
|
las MakeMKVs Antwort aber im Zeichensatz des Systems. Jetzt wird die Kodierung
|
||||||
|
bestimmt statt angenommen.
|
||||||
|
|
||||||
|
### 3. Die Ursache fehlte im Fehlertext
|
||||||
|
|
||||||
|
Rippy suchte in MakeMKVs Meldungen nach **englischen** Textbausteinen. Bei dir
|
||||||
|
meldet MakeMKV deutsch — also traf keiner, und übrig blieb die nichtssagende
|
||||||
|
letzte Zeile. Jetzt zählen die Meldungs-Nummern; die gelten in jeder Sprache.
|
||||||
|
|
||||||
|
### Nebenbefund: der Beta-Key lag zweimal am falschen Ort
|
||||||
|
|
||||||
|
Erst schrieb Rippy ihn nach `/root/.MakeMKV` — ein Container-Pfad. Nach dessen
|
||||||
|
Reparatur blieb „Testzeitraum abgelaufen" trotzdem stehen. Nachgesehen:
|
||||||
|
|
||||||
|
C:\Users\TobisPC\.MakeMKV\ nur _private_data.tar
|
||||||
|
HKCU\Software\MakeMKV app_UpdateLastCheck, app_SiteInfoString, …
|
||||||
|
|
||||||
|
**Unter Windows hält MakeMKV seine Einstellungen in der Registry.** Rippy hat
|
||||||
|
den Key also zweimal brav gespeichert — beide Male dorthin, wo ihn niemand
|
||||||
|
liest.
|
||||||
|
|
||||||
|
### ⚠️ Was du wissen musst: dein MakeMKV ist zu alt für den Beta-Key
|
||||||
|
|
||||||
|
Mit dem Key an der richtigen Stelle antwortete MakeMKV:
|
||||||
|
|
||||||
|
5020 Der hinterlegte Aktivierungsschlüssel ist ungültig.
|
||||||
|
5021 Diese Programmversion ist zu alt.
|
||||||
|
|
||||||
|
Dein MakeMKV **1.18.4** ist älter als der aktuelle Beta-Key verlangt. Ein
|
||||||
|
Update ist derzeit nicht zu bekommen: makemkv.com antwortet weiter mit HTTP
|
||||||
|
525, und die Ausweichquellen kennen als höchste Fassung genau 1.18.4.
|
||||||
|
|
||||||
|
**Das blockiert dich nicht** — der Beweis-Rip oben lief ohne Key. Aber für
|
||||||
|
4K-UHD wird der Key gebraucht.
|
||||||
|
|
||||||
|
Wichtiger noch: **ein abgelehnter Schlüssel ist schlimmer als gar keiner.**
|
||||||
|
Ohne Key las MakeMKV die Disc noch, mit dem abgelehnten verweigerte es alles
|
||||||
|
(0 Titel). Rippy prüft deshalb jetzt nach dem Ablegen und nimmt einen
|
||||||
|
abgelehnten Schlüssel wieder zurück. Deine Registry ist wieder so, wie sie
|
||||||
|
vorher war.
|
||||||
|
|
||||||
|
### Mein Fehler in dieser Sitzung
|
||||||
|
|
||||||
|
Ich hatte die Meldungsnummer 5021 aus dem Kopf mit „Volume-Key unbekannt"
|
||||||
|
beschriftet — falsch, sie heißt „Programmversion zu alt". Und meine eigene
|
||||||
|
Messausgabe zeigte nur **meine Beschriftung** statt MakeMKVs Text, sodass es
|
||||||
|
fast durchgegangen wäre. Genau der Fehler, den ich sonst anprangere. Der echte
|
||||||
|
Wortlaut steht jetzt im Code.
|
||||||
|
|
||||||
|
## Letzter Stand davor: v4.0-rc4 — Cover, Arbeitsordner, aufgeräumt (28.08.2026)
|
||||||
|
|
||||||
> **Drei Befunde aus deinem ersten echten Durchlauf, alle drei behoben und
|
> **Drei Befunde aus deinem ersten echten Durchlauf, alle drei behoben und
|
||||||
> gemessen.** Neues Setup liegt auf dem Desktop.
|
> gemessen.** Neues Setup liegt auf dem Desktop.
|
||||||
@@ -86,12 +752,48 @@ Jeder Prozess packt jetzt seinen eigenen Ordner aus und räumt ihn selbst
|
|||||||
weg; beim Start werden liegengebliebene entfernt — aber nur, wenn sie
|
weg; beim Start werden liegengebliebene entfernt — aber nur, wenn sie
|
||||||
nachweislich niemandem mehr gehören. **Deine 1.074 MB sind schon weg.**
|
nachweislich niemandem mehr gehören. **Deine 1.074 MB sind schon weg.**
|
||||||
|
|
||||||
### Nebenbefund
|
### 4. Arbeitsordner jetzt auch IM Setup
|
||||||
|
|
||||||
|
Bisher fragte das Setup nur nach Programmordner und Ablage — der Arbeitsordner
|
||||||
|
tauchte erst nach der Installation auf. Das ist die falsche Reihenfolge: Wer
|
||||||
|
erst nach der ersten vollen Platte erfährt, wo 100 GB Rohdaten landen, hat es
|
||||||
|
zu spät erfahren.
|
||||||
|
|
||||||
|
Jetzt steht das Feld im Setup, mit Durchsuchen-Knopf. Leer lassen heißt
|
||||||
|
weiterhin „neben die Ablage".
|
||||||
|
|
||||||
|
### 5. Der MakeMKV-Beta-Key — du hattest recht
|
||||||
|
|
||||||
|
> „der könnte theoretisch auch automatisch ausgelesen werden, ich glaube das
|
||||||
|
> web rippy kann das"
|
||||||
|
|
||||||
|
Kann es, seit es die Datei gibt: `makemkv_key.py` holt den Key täglich aus dem
|
||||||
|
Forum. Nachgemessen: **Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen**,
|
||||||
|
und in deinen Einstellungen lag schon einer.
|
||||||
|
|
||||||
|
Zu sehen war davon nichts — kein Knopf, keine Meldung. Eine Automatik, die man
|
||||||
|
nicht sehen kann, ist für dich keine. Dazu eine echte Lücke: Die Schleife
|
||||||
|
startet erst 60 Sekunden nach dem Server. Wer gleich nach dem Einrichten eine
|
||||||
|
Blu-ray einlegt, rippt ohne Key.
|
||||||
|
|
||||||
|
Jetzt: **das Setup holt ihn selbst**, und in den Einstellungen steht ein Knopf
|
||||||
|
„Jetzt aus dem Forum holen" samt Rückmeldung, ob sich etwas geändert hat.
|
||||||
|
|
||||||
|
Das ist die kostenlose öffentliche Beta-**Lizenz der Software** — kein
|
||||||
|
Disc-Schlüssel. Rippy liefert und verteilt keine.
|
||||||
|
|
||||||
|
### Nebenbefunde
|
||||||
|
|
||||||
Zwei Tests scheiterten in jeder Umgebung ohne `pywebview` — und ich hielt sie
|
Zwei Tests scheiterten in jeder Umgebung ohne `pywebview` — und ich hielt sie
|
||||||
erst für meinen eigenen Fehler. Ein Test, der nur an einer Stelle greift, ist
|
erst für meinen eigenen Fehler. Ein Test, der nur an einer Stelle greift, ist
|
||||||
eine halbe Zusage.
|
eine halbe Zusage.
|
||||||
|
|
||||||
|
Und ich habe in `35370fd` fünf Dateien versehentlich von LF auf CRLF
|
||||||
|
umgestellt: 1363 geänderte Zeilen für 30 echte. Inhaltlich war nichts kaputt,
|
||||||
|
aber ein unlesbarer Diff ist eine verlorene Prüfmöglichkeit. In `d0281f7`
|
||||||
|
zurückgedreht — als reiner Zeilenenden-Commit, damit die Funktionsänderung
|
||||||
|
daneben lesbar bleibt.
|
||||||
|
|
||||||
## Letzter Stand davor: v4.0-rc3 — Windows testbereit (28.08.2026)
|
## Letzter Stand davor: v4.0-rc3 — Windows testbereit (28.08.2026)
|
||||||
|
|
||||||
> **Der Commander testet gerade selbst.** Setup auf dem Desktop, Rechner
|
> **Der Commander testet gerade selbst.** Setup auf dem Desktop, Rechner
|
||||||
@@ -145,11 +847,13 @@ und `shutil.which("makemkvcon")` — das findet unter Windows nie etwas.
|
|||||||
Ergebnis an der echten Disc: `Neon Genesis Evangelion` (1995), Confidence
|
Ergebnis an der echten Disc: `Neon Genesis Evangelion` (1995), Confidence
|
||||||
0,8, Fingerabdruck `BD_EVG_D2|48149364736`.
|
0,8, Fingerabdruck `BD_EVG_D2|48149364736`.
|
||||||
|
|
||||||
### ⚠️ BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT
|
### ~~BEKANNTE LÜCKE: Audio-CDs laufen unter Windows NICHT~~ — geschlossen (29.08.2026)
|
||||||
|
|
||||||
`cdparanoia` (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge und
|
`cdparanoia` (Titelliste) und `abcde` (Rippen) sind Linux-Werkzeuge und
|
||||||
werden nicht mitgeliefert. Die CD wird erkannt, aber nicht gerippt. Steht
|
werden nicht mitgeliefert. Die CD wurde erkannt, aber nicht gerippt.
|
||||||
bewusst hier statt versteckt im Code.
|
|
||||||
|
**Seit dem 29.08.2026 geht es** — nicht mit einem Nachbau von abcde, sondern
|
||||||
|
über den Weg, den Windows selbst anbietet. Siehe den aktuellen Stand oben.
|
||||||
|
|
||||||
### Die Lehre dieser Sitzung, viermal bezahlt
|
### Die Lehre dieser Sitzung, viermal bezahlt
|
||||||
|
|
||||||
|
|||||||
+42
-5
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
|
|||||||
return f"eta:{job_id}"
|
return f"eta:{job_id}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Messreihen im eigenen Prozess — der Rückfall, wenn es kein Redis gibt.
|
||||||
|
#:
|
||||||
|
#: ## Der Befund des Commanders (29.08.2026)
|
||||||
|
#:
|
||||||
|
#: > „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||||
|
#: > messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#:
|
||||||
|
#: Die Messreihe lag ausschließlich im Cache, und der Modul-Kopf sagte dazu:
|
||||||
|
#: „Der Cache (Redis) ist schon da". Im Container stimmt das. Auf einem
|
||||||
|
#: Windows-PC gibt es kein Redis — `cache_get` gab bei JEDEM Aufruf `None`
|
||||||
|
#: zurück, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe
|
||||||
|
#: mit EINEM Punkt an, und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.
|
||||||
|
#: Ergebnis: über den ganzen Rip hinweg „noch keine Aussage".
|
||||||
|
#:
|
||||||
|
#: Ein Wörterbuch im Prozess reicht hier vollkommen: Die Reihe ist ein paar
|
||||||
|
#: Zahlen, sie gilt nur für die Dauer eines Jobs, und im eigenständigen
|
||||||
|
#: Betrieb gibt es ohnehin nur diesen einen Prozess. Redis bleibt der bessere
|
||||||
|
#: Ort, wo es eins gibt — es überlebt einen API-Neustart.
|
||||||
|
_REIHEN: dict = {}
|
||||||
|
|
||||||
|
#: Wie lange eine Reihe im Prozess aufgehoben wird (wie `expire` im Cache).
|
||||||
|
REIHE_HALTBAR_SEKUNDEN = 86400
|
||||||
|
|
||||||
|
|
||||||
|
def _aufraeumen(jetzt: float) -> None:
|
||||||
|
"""Alte Reihen wegwerfen — sonst waechst das Woerterbuch unbegrenzt."""
|
||||||
|
for k, (stand, _) in list(_REIHEN.items()):
|
||||||
|
if jetzt - stand > REIHE_HALTBAR_SEKUNDEN:
|
||||||
|
_REIHEN.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
||||||
jetzt: float, cache_get, cache_set) -> dict:
|
jetzt: float, cache_get, cache_set) -> dict:
|
||||||
"""Messreihe im Cache fortschreiben und die Restzeit zurückgeben.
|
"""Messreihe fortschreiben und die Restzeit zurückgeben.
|
||||||
|
|
||||||
Der Cache (Redis) ist schon da und überlebt einen Neustart des UI. Fällt er
|
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
|
||||||
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
|
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
|
||||||
keine ETA, aber nichts scheitert.
|
ohne Redis zustande kommt. Siehe `_REIHEN`.
|
||||||
"""
|
"""
|
||||||
if status not in LAUFENDE_STATUS or progress <= 0:
|
if status not in LAUFENDE_STATUS or progress <= 0:
|
||||||
return {"sekunden": -1, "text": ""}
|
return {"sekunden": -1, "text": ""}
|
||||||
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
|
|||||||
reihe = cache_get(k)
|
reihe = cache_get(k)
|
||||||
except Exception:
|
except Exception:
|
||||||
reihe = None
|
reihe = None
|
||||||
|
if not reihe:
|
||||||
|
# Kein Cache (oder leer) — dann der eigene Vorrat.
|
||||||
|
eintrag = _REIHEN.get(k)
|
||||||
|
reihe = eintrag[1] if eintrag else None
|
||||||
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
reihe = beobachtung_hinzufuegen(reihe, status, progress, jetzt)
|
||||||
try:
|
try:
|
||||||
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
# Eine Reihe ohne Fortschritt ist nach einem Tag wertlos.
|
||||||
cache_set(k, reihe, expire=86400)
|
cache_set(k, reihe, expire=REIHE_HALTBAR_SEKUNDEN)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
_REIHEN[k] = (jetzt, reihe)
|
||||||
|
_aufraeumen(jetzt)
|
||||||
sekunden = restzeit_sekunden(reihe, jetzt)
|
sekunden = restzeit_sekunden(reihe, jetzt)
|
||||||
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
|
||||||
|
|||||||
+536
-85
@@ -6,7 +6,6 @@ from typing import List, Optional, Dict
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import posixpath
|
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -122,11 +121,14 @@ async def startup_event():
|
|||||||
|
|
||||||
ereignis_waechter = Waechter(
|
ereignis_waechter = Waechter(
|
||||||
db, ereignis_bus,
|
db, ereignis_bus,
|
||||||
laufwerke_lesen=lambda: [
|
# MIT der erkannten Disc — sonst meldet die Server-Status-Kachel
|
||||||
device_discovery.device_info(p)
|
# „kein Datentraeger", waehrend die Disc erkannt ist (29.08.2026).
|
||||||
for p in device_discovery.list_optical_devices()
|
laufwerke_lesen=laufwerke_mit_disc,
|
||||||
],
|
|
||||||
system_lesen=system_stand_sync,
|
system_lesen=system_stand_sync,
|
||||||
|
# DIESELBE Uebersetzung wie /jobs — sonst tragen die Live-Ereignisse
|
||||||
|
# andere Feldnamen als der Schnappschuss (Befund 29.08.2026:
|
||||||
|
# „1.1.1970" und „DISC" in der Jobliste).
|
||||||
|
job_form=lambda z: _job_row_to_model(z).model_dump(),
|
||||||
)
|
)
|
||||||
asyncio.create_task(ereignis_waechter.schleife())
|
asyncio.create_task(ereignis_waechter.schleife())
|
||||||
|
|
||||||
@@ -184,9 +186,27 @@ def _duplikat_suchen(fingerprint: str):
|
|||||||
|
|
||||||
|
|
||||||
async def _auto_prescan(pfad: str):
|
async def _auto_prescan(pfad: str):
|
||||||
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis."""
|
"""Identifiziert die eingelegte Disc im Hintergrund und cached das Ergebnis.
|
||||||
|
|
||||||
|
## Warum ein laufender Job hier alles stoppt (Befund 29.08.2026)
|
||||||
|
|
||||||
|
> „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist bereits bei
|
||||||
|
> 30% und er liest immernoch."
|
||||||
|
|
||||||
|
Genau so war es — und die Erkennung kam auch nie zum Ende. Waehrend eines
|
||||||
|
Rips haelt `makemkvcon` das Laufwerk; ein zweites `makemkvcon info`
|
||||||
|
daneben wartet, bis es seine Zeitgrenze erreicht (gemessen: eine
|
||||||
|
Laufwerks-Abfrage braucht dann 14 s statt 5). Die Marke `_laeuft` blieb
|
||||||
|
solange stehen, also stand auch das Panel.
|
||||||
|
|
||||||
|
Es gibt keinen Grund, waehrend eines Rips zu scannen: Das Laufwerk ist
|
||||||
|
belegt, und **welche Disc drin ist, wissen wir bereits** — der Job laeuft
|
||||||
|
ja auf ihr.
|
||||||
|
"""
|
||||||
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
if DISC_CACHE.get(pfad, {}).get("_laeuft"):
|
||||||
return
|
return
|
||||||
|
if await asyncio.to_thread(db.has_active_job, pfad):
|
||||||
|
return
|
||||||
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
|
DISC_CACHE[pfad] = {"_laeuft": True, "title": "Wird erkannt…"}
|
||||||
try:
|
try:
|
||||||
prescan = PreScan()
|
prescan = PreScan()
|
||||||
@@ -213,6 +233,19 @@ async def _auto_prescan(pfad: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
DISC_CACHE.pop(pfad, None)
|
DISC_CACHE.pop(pfad, None)
|
||||||
print(f"Auto-Pre-Scan {pfad}: {e}")
|
print(f"Auto-Pre-Scan {pfad}: {e}")
|
||||||
|
# ... und ins PROTOKOLL, nicht nur auf die Konsole
|
||||||
|
# (Befund 30.08.2026). Der Commander: „im log steht zwar
|
||||||
|
# erkannt, aber ein start des rips ist nicht moeglich."
|
||||||
|
# Genau so war es: Die letzte Zeile war das erfolgreiche
|
||||||
|
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
|
||||||
|
# danach standen nur auf einer Konsole, die niemand sieht.
|
||||||
|
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
|
||||||
|
try:
|
||||||
|
db.add_log("warning", "watcher",
|
||||||
|
"Disc-Erkennung auf %s fehlgeschlagen: %s"
|
||||||
|
% (pfad, e))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Protokollieren darf die Wache nie anhalten
|
||||||
|
|
||||||
|
|
||||||
async def _auto_rip_wenn_aktiviert(pfad: str):
|
async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||||
@@ -225,7 +258,10 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
if not einstellungen.get("autoRipStart"):
|
if not einstellungen.get("autoRipStart"):
|
||||||
return
|
return
|
||||||
if await asyncio.to_thread(db.has_active_job, pfad):
|
# `job_offen`, nicht `has_active_job`: Waehrend der Kompression ist das
|
||||||
|
# Laufwerk zwar frei, der Vorgang aber nicht durch — und die Disc wurde
|
||||||
|
# soeben ausgeworfen. Herleitung in store.job_offen.
|
||||||
|
if await asyncio.to_thread(db.job_offen, pfad):
|
||||||
return
|
return
|
||||||
disc = DISC_CACHE.get(pfad) or {}
|
disc = DISC_CACHE.get(pfad) or {}
|
||||||
if disc.get("_laeuft"):
|
if disc.get("_laeuft"):
|
||||||
@@ -266,6 +302,51 @@ async def _auto_rip_wenn_aktiviert(pfad: str):
|
|||||||
start_rip(pfad, job_id, ziel)
|
start_rip(pfad, job_id, ziel)
|
||||||
|
|
||||||
|
|
||||||
|
def disc_entscheidung(schon_gesehen: bool, vorher, status) -> str:
|
||||||
|
"""Was ist zu tun? `""` | `"erststart"` | `"eingelegt"` | `"entfernt"`.
|
||||||
|
|
||||||
|
Pure Funktion — deshalb ohne Laufwerk prüfbar, und genau daran hing der
|
||||||
|
Fehler unten.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn er sie
|
||||||
|
> bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche fenster
|
||||||
|
> ganz kurz im hintergrund."
|
||||||
|
|
||||||
|
Beides war dieselbe Schleife. In der Disc-Wache stand:
|
||||||
|
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
Damit blieb `bekannt[pfad]` ungesetzt, und im nächsten Durchlauf war
|
||||||
|
`vorher is None` — also wieder „Erststart, liegt schon eine Disc drin".
|
||||||
|
**Ein einziger fehlgeschlagener Lesevorgang löste einen neuen Vor-Scan
|
||||||
|
aus.**
|
||||||
|
|
||||||
|
Und der scheitert regelmäßig: Während der Vor-Scan läuft, hält
|
||||||
|
`makemkvcon` das Laufwerk (gemessen: `/devices` braucht dann 14 s statt
|
||||||
|
5). Also: Scan hält das Laufwerk → Statusabfrage scheitert → nächster
|
||||||
|
Durchlauf hält es für den ersten → neuer Scan. Eine Schleife, die sich
|
||||||
|
selbst am Leben hält — und weil derselbe Aufruf sein Konsolenfenster
|
||||||
|
nicht unterdrückte, blitzte bei jeder Runde eines auf.
|
||||||
|
|
||||||
|
Deshalb zwei getrennte Fragen statt einer: **Haben wir dieses Laufwerk je
|
||||||
|
gelesen?** (`schon_gesehen`) und **was war zuletzt drin?** (`vorher`). Ein
|
||||||
|
Fehlschlag beantwortet die zweite nicht — und darf die erste nicht
|
||||||
|
zurücksetzen.
|
||||||
|
"""
|
||||||
|
if not schon_gesehen:
|
||||||
|
return "erststart" if status == CDS_DISC_OK else ""
|
||||||
|
if status == vorher:
|
||||||
|
return ""
|
||||||
|
if status == CDS_DISC_OK:
|
||||||
|
return "eingelegt"
|
||||||
|
if status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
|
||||||
|
return "entfernt"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def disc_watcher():
|
async def disc_watcher():
|
||||||
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
|
"""Disc-Wache: pollt die Laufwerke, protokolliert Einwurf/Auswurf und
|
||||||
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
|
stößt beim Einlegen automatisch den Pre-Scan an (Dashboard-Disc-Karte).
|
||||||
@@ -275,25 +356,45 @@ async def disc_watcher():
|
|||||||
für den Heim-Use-Case gleichwertig und läuft überall.
|
für den Heim-Use-Case gleichwertig und läuft überall.
|
||||||
"""
|
"""
|
||||||
bekannt: Dict[str, int] = {}
|
bekannt: Dict[str, int] = {}
|
||||||
|
# Laufwerke, die schon einmal ERFOLGREICH gelesen wurden. Getrennt von
|
||||||
|
# `bekannt`, weil ein fehlgeschlagener Lesevorgang sonst wie ein
|
||||||
|
# Erststart aussieht — siehe unten.
|
||||||
|
gesehen: set = set()
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
for pfad in device_discovery.list_optical_devices():
|
for pfad in device_discovery.list_optical_devices():
|
||||||
try:
|
try:
|
||||||
status = await asyncio.to_thread(drive_status, pfad)
|
status = await asyncio.to_thread(drive_status, pfad)
|
||||||
except OSError:
|
except OSError:
|
||||||
|
# ⚠️ Hier stand nur `continue` (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# `bekannt[pfad]` blieb damit ungesetzt, und beim nächsten
|
||||||
|
# Durchlauf war `vorher is None` — also wieder „Erststart,
|
||||||
|
# liegt schon eine Disc drin". Ein einziger fehlgeschlagener
|
||||||
|
# Lesevorgang bewirkte einen NEUEN Vor-Scan.
|
||||||
|
#
|
||||||
|
# Und genau das passiert regelmäßig: Während der Vor-Scan
|
||||||
|
# läuft, hält `makemkvcon` das Laufwerk, und die
|
||||||
|
# Statusabfrage kommt nicht durch. Ergebnis: Scan hält das
|
||||||
|
# Laufwerk → Statusabfrage scheitert → nächster Durchlauf
|
||||||
|
# hält es für den ersten → neuer Scan. Eine Schleife, die
|
||||||
|
# sich selbst am Leben hält, und bei jeder Runde blitzte
|
||||||
|
# eine Konsole auf.
|
||||||
|
#
|
||||||
|
# `gesehen` merkt sich, ob wir dieses Laufwerk je gelesen
|
||||||
|
# haben. Ein Fehlschlag heißt jetzt „nichts Neues erfahren"
|
||||||
|
# — nicht „zum ersten Mal gesehen".
|
||||||
continue
|
continue
|
||||||
vorher = bekannt.get(pfad)
|
was = disc_entscheidung(pfad in gesehen, bekannt.get(pfad), status)
|
||||||
if vorher is None:
|
gesehen.add(pfad)
|
||||||
# Erststart: liegt schon eine Disc drin, direkt erkennen
|
if was == "erststart":
|
||||||
if status == CDS_DISC_OK:
|
asyncio.create_task(_auto_prescan(pfad))
|
||||||
asyncio.create_task(_auto_prescan(pfad))
|
elif was == "eingelegt":
|
||||||
elif status != vorher:
|
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
|
||||||
if status == CDS_DISC_OK:
|
asyncio.create_task(_auto_prescan(pfad))
|
||||||
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
|
elif was == "entfernt":
|
||||||
asyncio.create_task(_auto_prescan(pfad))
|
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
||||||
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK:
|
DISC_CACHE.pop(pfad, None)
|
||||||
db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
|
|
||||||
DISC_CACHE.pop(pfad, None)
|
|
||||||
bekannt[pfad] = status
|
bekannt[pfad] = status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Disc-Wache: {e}")
|
print(f"Disc-Wache: {e}")
|
||||||
@@ -380,7 +481,28 @@ class Device(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
serial: Optional[str] = None
|
serial: Optional[str] = None
|
||||||
|
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
|
||||||
|
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
|
||||||
|
grund: str = ""
|
||||||
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
||||||
|
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
||||||
|
# erkennung noch läuft muss sichtbar sein")
|
||||||
|
#
|
||||||
|
# Der Zustand war schon da — `_auto_prescan` legt beim Start
|
||||||
|
# `{"_laeuft": True}` in den Vorrat. Nur wurde er nie herausgegeben:
|
||||||
|
# `laufwerke_mit_disc` ließ so einen Eintrag KOMPLETT weg, damit keine
|
||||||
|
# halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem
|
||||||
|
# „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch gelöst — für
|
||||||
|
# die Oberfläche sah es dadurch aus, als läge gar keine Disc im Laufwerk.
|
||||||
|
#
|
||||||
|
# Und das dauert: `makemkvcon info` läuft an einer Blu-ray in seine
|
||||||
|
# 120-Sekunden-Grenze (gemessen 29.08.2026 — Disc nach 119 s erkannt).
|
||||||
|
# Zwei Minuten Schweigen sehen aus wie ein Fehler.
|
||||||
|
#
|
||||||
|
# Ein eigenes Feld statt der halben Disc: Die Oberfläche kann „wird
|
||||||
|
# erkannt" zeigen, ohne einen Titel behaupten zu müssen, den es noch
|
||||||
|
# nicht gibt.
|
||||||
|
disc_wird_erkannt: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _job_row_to_model(zeile: dict) -> Job:
|
def _job_row_to_model(zeile: dict) -> Job:
|
||||||
@@ -434,7 +556,10 @@ async def _snapshot() -> dict:
|
|||||||
"""
|
"""
|
||||||
def sammeln():
|
def sammeln():
|
||||||
return {
|
return {
|
||||||
"jobs": [_job_row_to_model(z).model_dump() for z in db.list_jobs(limit=50)],
|
# DIESELBE Funktion wie /jobs — sonst fehlt hier die
|
||||||
|
# Restzeit, und die Oberflaeche liest seit V2-3 nur noch
|
||||||
|
# diesen Schnappschuss (Befund 29.08.2026).
|
||||||
|
"jobs": [m.model_dump() for m in jobs_fuer_ui(limit=50)],
|
||||||
"workers": db.list_workers(),
|
"workers": db.list_workers(),
|
||||||
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
|
"logs": [_log_zeile(z) for z in db.list_logs(limit=50)],
|
||||||
}
|
}
|
||||||
@@ -443,16 +568,25 @@ async def _snapshot() -> dict:
|
|||||||
# Laufwerke getrennt: device_info macht ioctls, die hängen können —
|
# Laufwerke getrennt: device_info macht ioctls, die hängen können —
|
||||||
# ein defektes Laufwerk darf den Snapshot nicht aufhalten.
|
# ein defektes Laufwerk darf den Snapshot nicht aufhalten.
|
||||||
try:
|
try:
|
||||||
|
# MIT der erkannten Disc. Hier stand dieselbe Schleife ein DRITTES
|
||||||
|
# Mal (Befund 29.08.2026) — `/devices` hängte die Disc an, der
|
||||||
|
# Ereignis-Wächter und dieser Schnappschuss nicht. Das Dashboard
|
||||||
|
# liest genau diesen Schnappschuss und schloss aus der fehlenden
|
||||||
|
# Disc auf ein leeres Laufwerk: „Bereit — kein Datenträger im
|
||||||
|
# Laufwerk", während der Titel eine Zeile weiter oben stand.
|
||||||
zustand["devices"] = await asyncio.wait_for(
|
zustand["devices"] = await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(laufwerke_mit_disc), timeout=5)
|
||||||
lambda: [device_discovery.device_info(p)
|
|
||||||
for p in device_discovery.list_optical_devices()]),
|
|
||||||
timeout=5,
|
|
||||||
)
|
|
||||||
except (asyncio.TimeoutError, OSError):
|
except (asyncio.TimeoutError, OSError):
|
||||||
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||||
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
|
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
|
||||||
zustand["devices"] = None
|
#
|
||||||
|
# Vorher stand hier NUR `None`. Waehrend der Disc-Erkennung ist das
|
||||||
|
# aber der Normalfall (makemkvcon haelt das Laufwerk, /devices
|
||||||
|
# braucht dann 14 s statt 5) — und nach einem frischen Laden hatte
|
||||||
|
# das UI keinen alten Stand, den es haette behalten koennen. Der
|
||||||
|
# Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||||
|
# sein soll. `laufwerke_notdurft` antwortet ohne ioctl.
|
||||||
|
zustand["devices"] = laufwerke_notdurft()
|
||||||
|
|
||||||
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
|
# ⚠️ Der Server-Zustand gehört MIT in den Schnappschuss (Befund 28.08.2026).
|
||||||
#
|
#
|
||||||
@@ -671,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
|
|||||||
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
|
||||||
|
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
|
||||||
|
|
||||||
|
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
|
||||||
|
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
|
||||||
|
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
|
||||||
|
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
|
||||||
|
HEUTIGEN Wert der Einstellung `workDir`.
|
||||||
|
|
||||||
|
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
|
||||||
|
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
|
||||||
|
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
|
||||||
|
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
|
||||||
|
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
|
||||||
|
|
||||||
|
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
|
||||||
|
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
|
||||||
|
wurde), statt nachzusehen. Der Job weiß es selbst.
|
||||||
|
"""
|
||||||
|
holen = getattr(job, "get", None)
|
||||||
|
eigen = ""
|
||||||
|
if holen:
|
||||||
|
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
|
||||||
|
if not eigen:
|
||||||
|
werte = einstellungen if einstellungen is not None else db.get_settings()
|
||||||
|
eigen = ((werte or {}).get("workDir") or "").strip()
|
||||||
|
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
|
||||||
|
# gemeint. "/" fällt in `kandidaten()` sauber durch.
|
||||||
|
return os.path.normpath(eigen or "/")
|
||||||
|
|
||||||
|
|
||||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||||
@@ -797,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
|
|||||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||||
"""
|
"""
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
einstellungen = db.get_settings()
|
||||||
alt = _ROHDATEN["treffer"]
|
alt = _ROHDATEN["treffer"]
|
||||||
treffer = {}
|
treffer = {}
|
||||||
for zeile in db.list_jobs():
|
for zeile in db.list_jobs():
|
||||||
if zeile.get("status") != "failed":
|
if zeile.get("status") != "failed":
|
||||||
continue
|
continue
|
||||||
job_id = zeile["id"]
|
job_id = zeile["id"]
|
||||||
|
# Je Job SEINE Wahl — nicht die heutige Einstellung.
|
||||||
|
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
|
||||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||||
@@ -836,8 +1005,11 @@ def _kann_neu_komprimieren(job: dict, work_dir: str) -> bool:
|
|||||||
if job["id"] in vorrat:
|
if job["id"] in vorrat:
|
||||||
return bool(vorrat[job["id"]])
|
return bool(vorrat[job["id"]])
|
||||||
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
# Nur der lokale Ort: /app/temp ist ein Docker-Volume, os.path.isdir kann
|
||||||
# dort nicht hängen (im Gegensatz zu allem unter /app/media).
|
# dort nicht hängen (im Gegensatz zu allem unter /app/media). Die Wurzel
|
||||||
return os.path.isdir(posixpath.join(rohdaten.RAW_STANDARD, job["id"]))
|
# kommt vom Betrieb — auf Windows gibt es kein /app/temp (29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.isdir(pfade.verbinden(rohdaten.wurzeln()[0], job["id"]))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/jobs", response_model=List[Job])
|
@app.get("/jobs", response_model=List[Job])
|
||||||
@@ -850,24 +1022,54 @@ async def get_jobs():
|
|||||||
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
Reihe zurück, zwei Tabs zeigten verschiedene Zahlen, und für einen externen
|
||||||
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
Encoder-Worker gäbe es gar keine (genau dort wollte der Commander sie).
|
||||||
"""
|
"""
|
||||||
def sammle():
|
return await asyncio.to_thread(jobs_fuer_ui)
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
|
||||||
jetzt = time.monotonic()
|
|
||||||
modelle = []
|
|
||||||
for z in db.list_jobs():
|
|
||||||
modell = _job_row_to_model(z)
|
|
||||||
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
|
||||||
modell.retry_art = phasen.retry_art(z)
|
|
||||||
schaetzung = eta.aktualisiere_und_schaetze(
|
|
||||||
z["id"], z.get("status") or "", z.get("progress") or 0,
|
|
||||||
jetzt, cache_get, cache_set,
|
|
||||||
)
|
|
||||||
modell.eta_sekunden = schaetzung["sekunden"]
|
|
||||||
modell.eta_text = schaetzung["text"]
|
|
||||||
modelle.append(modell)
|
|
||||||
return modelle
|
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
|
||||||
|
def jobs_fuer_ui(limit: int = 100) -> list:
|
||||||
|
"""Die Jobs in UI-Form — MIT Restzeit, Wiederhol-Art und Rohdaten-Frage.
|
||||||
|
|
||||||
|
## Warum das eine Funktion ist (Befund 29.08.2026)
|
||||||
|
|
||||||
|
> „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen' aber
|
||||||
|
> messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
|
||||||
|
Zwei Ursachen, beide davon, dass es die Jobliste ZWEIMAL gab.
|
||||||
|
|
||||||
|
**1.** Diese Rechnung stand nur in `/jobs`. Der SSE-Schnappschuss baute
|
||||||
|
seine Jobs mit dem nackten `_job_row_to_model` — also ohne Restzeit. Seit
|
||||||
|
der Umstellung auf den Ereignisstrom (V2-3) liest die Oberfläche aber
|
||||||
|
genau diesen Schnappschuss und nicht mehr `/jobs`. Die Restzeit wurde also
|
||||||
|
weiterhin brav berechnet und niemandem gezeigt.
|
||||||
|
|
||||||
|
**2.** Die Messreihe lag ausschließlich in Redis, das es unter Windows
|
||||||
|
nicht gibt — Begründung und Rückfall in `eta.py`.
|
||||||
|
|
||||||
|
Dieselbe Lehre wie bei `laufwerke_mit_disc`: Eine Auskunft in zwei
|
||||||
|
Fassungen ist eine Fassung zu viel.
|
||||||
|
"""
|
||||||
|
# Unlesbare Einstellungen duerfen die Jobliste NICHT umwerfen — sie
|
||||||
|
# steuern hier nur, ob „Neu komprimieren" angeboten wird. Seit diese
|
||||||
|
# Funktion auch den SSE-Schnappschuss baut, haengt daran die ganze
|
||||||
|
# Oberflaeche (Befund 29.08.2026: zwei Snapshot-Tests wurden dadurch rot).
|
||||||
|
try:
|
||||||
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
einstellungen = {}
|
||||||
|
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
modelle = []
|
||||||
|
for z in db.list_jobs(limit=limit):
|
||||||
|
modell = _job_row_to_model(z)
|
||||||
|
modell.can_retry = _kann_neu_komprimieren(z, work_dir)
|
||||||
|
modell.retry_art = phasen.retry_art(z)
|
||||||
|
schaetzung = eta.aktualisiere_und_schaetze(
|
||||||
|
z["id"], z.get("status") or "", z.get("progress") or 0,
|
||||||
|
jetzt, cache_get, cache_set,
|
||||||
|
)
|
||||||
|
modell.eta_sekunden = schaetzung["sekunden"]
|
||||||
|
modell.eta_text = schaetzung["text"]
|
||||||
|
modelle.append(modell)
|
||||||
|
return modelle
|
||||||
|
|
||||||
|
|
||||||
def _rohdaten_groesse(pfade: list) -> tuple:
|
def _rohdaten_groesse(pfade: list) -> tuple:
|
||||||
@@ -899,8 +1101,7 @@ async def job_rohdaten(job_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
|
||||||
def sammle():
|
def sammle():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||||
return {
|
return {
|
||||||
"pfade": pfade,
|
"pfade": pfade,
|
||||||
@@ -929,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
|||||||
geloescht_gb = 0.0
|
geloescht_gb = 0.0
|
||||||
if rohdaten:
|
if rohdaten:
|
||||||
def raeume():
|
def raeume():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||||
for pfad in pfade:
|
for pfad in pfade:
|
||||||
shutil.rmtree(pfad, ignore_errors=True)
|
shutil.rmtree(pfad, ignore_errors=True)
|
||||||
@@ -1124,6 +1324,14 @@ async def create_job(request: JobCreateRequest):
|
|||||||
meta_json = json.dumps(meta_dict) if meta_dict else None
|
meta_json = json.dumps(meta_dict) if meta_dict else None
|
||||||
|
|
||||||
job_id = str(uuid.uuid4())
|
job_id = str(uuid.uuid4())
|
||||||
|
# Eine noch laufende Disc-Erkennung ist ab jetzt gegenstandslos: Das
|
||||||
|
# Laufwerk gehoert dem Rip, und WELCHE Disc drin ist, wissen wir. Ohne
|
||||||
|
# das Wegraeumen stuende „Disc wird gelesen" bis zum Ende des Rips
|
||||||
|
# (Commander 29.08.2026: „Der Rip ist bereits bei 30% und er liest
|
||||||
|
# immernoch"). Der laufende Scan traegt sein Ergebnis spaeter ohnehin
|
||||||
|
# selbst nach.
|
||||||
|
if (DISC_CACHE.get(device_path) or {}).get("_laeuft"):
|
||||||
|
DISC_CACHE.pop(device_path, None)
|
||||||
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
await asyncio.to_thread(db.insert_job, job_id, device_path, None, titel, ziel, meta_json)
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
db.add_log, "info", "api",
|
db.add_log, "info", "api",
|
||||||
@@ -1299,30 +1507,43 @@ async def storage_targets():
|
|||||||
"free_gb": frei_gb,
|
"free_gb": frei_gb,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Die Laufwerke — nur nativ. Im Container gäbe es nichts zu holen.
|
# Der Laufwerksbuchstaben-Block stand hier für die Windows-
|
||||||
if frei_blaettern():
|
# Standalone-App — sie ist mit v5 ein eigenes Produkt (KONZEPT-
|
||||||
from rippy import betrieb
|
# WINDOWS.md Entscheid 7); im Container gab es hier nie etwas.
|
||||||
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),
|
|
||||||
})
|
|
||||||
return ziele
|
return ziele
|
||||||
|
|
||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
def geraetepfad(name: str) -> str:
|
||||||
|
"""Die Kennung aus der URL zum echten Geraetepfad — oder 404.
|
||||||
|
|
||||||
|
## Der Befund (29.08.2026)
|
||||||
|
|
||||||
|
Hier stand an DREI Stellen:
|
||||||
|
|
||||||
|
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. **Auswerfen und „Disc scannen" antworteten unter
|
||||||
|
Windows also immer mit 404**, ohne dass irgendwo stuende, warum.
|
||||||
|
|
||||||
|
Aufgeloest wird jetzt vom TREIBER: Er vergibt die Kennung (`kennung`),
|
||||||
|
also loest er sie auch wieder auf. Hin- und Rueckweg koennen damit nicht
|
||||||
|
mehr auseinanderlaufen.
|
||||||
|
"""
|
||||||
|
pfad = device_discovery.pfad_zu_kennung(name)
|
||||||
|
if not pfad:
|
||||||
|
raise HTTPException(status_code=404,
|
||||||
|
detail=f"Laufwerk {name} nicht gefunden")
|
||||||
|
return pfad
|
||||||
|
|
||||||
|
|
||||||
@app.post("/devices/{name}/eject")
|
@app.post("/devices/{name}/eject")
|
||||||
async def eject_device(name: str):
|
async def eject_device(name: str):
|
||||||
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
|
"""Wirft die Disc aus. Verweigert, wenn auf dem Gerät gerade ein Job läuft."""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
if device_path not in device_discovery.list_optical_devices():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
|
||||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
|
status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job"
|
||||||
@@ -1342,9 +1563,7 @@ async def scan_tracks_starten(name: str):
|
|||||||
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
Läuft als Worker-Task (nur der hat makemkvcon + Laufwerk); das UI pollt
|
||||||
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
GET /devices/{name}/tracks. Dauert je nach Disc 20–120 s.
|
||||||
"""
|
"""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
if device_path not in device_discovery.list_optical_devices():
|
|
||||||
raise HTTPException(status_code=404, detail=f"Laufwerk {device_path} nicht gefunden")
|
|
||||||
if await asyncio.to_thread(db.has_active_job, device_path):
|
if await asyncio.to_thread(db.has_active_job, device_path):
|
||||||
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
raise HTTPException(status_code=409, detail="Auf diesem Laufwerk läuft gerade ein Job")
|
||||||
|
|
||||||
@@ -1356,7 +1575,7 @@ async def scan_tracks_starten(name: str):
|
|||||||
@app.get("/devices/{name}/tracks")
|
@app.get("/devices/{name}/tracks")
|
||||||
async def scan_tracks_ergebnis(name: str):
|
async def scan_tracks_ergebnis(name: str):
|
||||||
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
"""Ergebnis des Titel-Scans (Polling-Ziel des UI)."""
|
||||||
device_path = f"/dev/{name}"
|
device_path = geraetepfad(name)
|
||||||
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
daten = await asyncio.to_thread(db.get_settings, f"tracks:{device_path}")
|
||||||
if not daten:
|
if not daten:
|
||||||
return {"status": "none"}
|
return {"status": "none"}
|
||||||
@@ -1384,7 +1603,7 @@ async def retry_transcode(job_id: str):
|
|||||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||||
|
|
||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
|
||||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||||
if not gefunden:
|
if not gefunden:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1916,7 +2135,7 @@ async def browse(path: str = ""):
|
|||||||
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
|
einem anderen Laufwerk — und genau dort ist Platz für 100 GB Rohdaten.
|
||||||
"""
|
"""
|
||||||
if not path.strip():
|
if not path.strip():
|
||||||
laufwerke = betrieb_laufwerke() if frei_blaettern() else []
|
laufwerke = []
|
||||||
if laufwerke:
|
if laufwerke:
|
||||||
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
|
return {"path": "", "parent": None, "dirs": laufwerke, "files": []}
|
||||||
path = medien_wurzel()
|
path = medien_wurzel()
|
||||||
@@ -1974,16 +2193,6 @@ def eltern_von(pfad: str, wurzel: str = None, frei: bool = None) -> Optional[str
|
|||||||
return "" if oben == pfad else oben
|
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):
|
class MkdirRequest(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
name: str
|
name: str
|
||||||
@@ -2348,6 +2557,13 @@ async def system_info():
|
|||||||
werte = rippy_config.laden()
|
werte = rippy_config.laden()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
werte = {}
|
werte = {}
|
||||||
|
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
|
||||||
|
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
|
||||||
|
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
|
||||||
|
# was eingestellt war (Befund 30.08.2026, siehe
|
||||||
|
# `betrieb.mit_einstellungen`).
|
||||||
|
werte = betriebs_auskunft.mit_einstellungen(
|
||||||
|
werte, db.get_settings(bei_fehler_leer=True))
|
||||||
for ort in betriebs_auskunft.platz_orte(werte):
|
for ort in betriebs_auskunft.platz_orte(werte):
|
||||||
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
||||||
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
||||||
@@ -2361,6 +2577,12 @@ async def system_info():
|
|||||||
continue
|
continue
|
||||||
info["plaetze"].append({
|
info["plaetze"].append({
|
||||||
"name": ort["name"],
|
"name": ort["name"],
|
||||||
|
# Der PFAD gehoert dazu (Commander 29.08.2026): „bei ‚Platz
|
||||||
|
# fuer rippy' sollte eher das arbeitsverzeichnis und der
|
||||||
|
# Ablagepfad sein." Eine Zahl ohne Ort sagt nicht, WO der
|
||||||
|
# Platz knapp wird.
|
||||||
|
"pfad": ort["pfad"],
|
||||||
|
"gleiches_laufwerk": bool(ort.get("gleiches_laufwerk")),
|
||||||
"frei_gb": round(nutzung.free / 1024**3, 1),
|
"frei_gb": round(nutzung.free / 1024**3, 1),
|
||||||
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
"gesamt_gb": round(nutzung.total / 1024**3, 1),
|
||||||
})
|
})
|
||||||
@@ -2406,6 +2628,71 @@ async def get_keydb_status():
|
|||||||
return await asyncio.to_thread(sammle)
|
return await asyncio.to_thread(sammle)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/system/makemkv-key/holen")
|
||||||
|
async def makemkv_key_holen():
|
||||||
|
"""Holt den kostenlosen MakeMKV-Beta-Key JETZT aus dem Forum.
|
||||||
|
|
||||||
|
## Warum es diesen Knopf gibt (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 hatte recht: `makemkv_key.refresh_loop()` läuft seit jeher täglich mit
|
||||||
|
(main.py, Startereignis). Nachgemessen: Forum antwortet in 3,3 Sekunden,
|
||||||
|
Key mit 62 Zeichen.
|
||||||
|
|
||||||
|
Nur war davon **nichts zu sehen**. Kein Knopf, keine Zeitangabe, kein
|
||||||
|
Hinweis — im Einstellungsfeld stand ein Key, und ob der von Hand kam oder
|
||||||
|
von selbst, wusste niemand. Eine Automatik, die man nicht sehen kann, ist
|
||||||
|
für den Benutzer keine.
|
||||||
|
|
||||||
|
Der Key wechselt etwa monatlich. Wer nicht warten will, bis die Schleife
|
||||||
|
das nächste Mal nachsieht, drückt hier.
|
||||||
|
|
||||||
|
Rechtlich unverändert: öffentliche Beta-LIZENZ der Software, KEIN
|
||||||
|
Disc-Schlüssel.
|
||||||
|
"""
|
||||||
|
import makemkv_key as key_modul
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = await asyncio.to_thread(key_modul.fetch_current_key)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=f"Das MakeMKV-Forum antwortet nicht ({type(e).__name__}). "
|
||||||
|
"Der Key lässt sich weiter von Hand eintragen.")
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Im Forum-Beitrag stand kein Key im erwarteten Format. "
|
||||||
|
"Vermutlich hat sich die Seite geändert.")
|
||||||
|
geaendert = await asyncio.to_thread(key_modul._apply_key, key)
|
||||||
|
|
||||||
|
# Nimmt MakeMKV ihn an? Nur MakeMKV selbst weiss das.
|
||||||
|
#
|
||||||
|
# Am 29.08.2026 auf dem Rechner des Commanders: der frische Schluessel
|
||||||
|
# wurde mit MSG 5020 („ungueltig") und 5021 („Programmversion zu alt")
|
||||||
|
# abgelehnt — sein MakeMKV 1.18.4 ist aelter als der Schluessel verlangt.
|
||||||
|
# Ohne diese Rueckfrage haette hier „erfolgreich geholt" gestanden, und
|
||||||
|
# der naechste Rip waere trotzdem gescheitert.
|
||||||
|
from rippy.rip import makemkv_aufruf
|
||||||
|
from rippy.tools import katalog as werkzeug_katalog
|
||||||
|
|
||||||
|
urteil = await asyncio.to_thread(
|
||||||
|
makemkv_aufruf.key_setzen_und_pruefen, key,
|
||||||
|
werkzeug_katalog.finden("makemkv"))
|
||||||
|
await asyncio.to_thread(
|
||||||
|
db.add_log, "success" if urteil["ok"] else "warning", "makemkv-key",
|
||||||
|
"Beta-Key aus dem Forum geholt (…%s)%s%s"
|
||||||
|
% (key[-6:], "" if geaendert else " — war schon der aktuelle",
|
||||||
|
"" if urteil["ok"] else " — ABGELEHNT: " + urteil["grund"]))
|
||||||
|
# Der Key selbst wird NICHT zurueckgegeben: Er steht in den Einstellungen,
|
||||||
|
# und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn zu
|
||||||
|
# verlieren.
|
||||||
|
return {"geholt": True, "geaendert": geaendert, "endet_auf": key[-6:],
|
||||||
|
"angenommen": urteil["ok"], "grund": urteil["grund"]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/system/keydb")
|
@app.post("/system/keydb")
|
||||||
async def set_keydb(request: KeydbRequest):
|
async def set_keydb(request: KeydbRequest):
|
||||||
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
|
"""Legt die vom Nutzer mitgebrachte KEYDB.cfg ab (atomar, ersetzt die alte).
|
||||||
@@ -2656,16 +2943,180 @@ async def get_devices():
|
|||||||
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
|
prinzipbedingt nichts: kein udevd, keine udev-Datenbank, kein Daemon,
|
||||||
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
|
der Symlinks anlegt. Jetzt: /sys fürs Modell, ioctl für den Disc-Status.
|
||||||
"""
|
"""
|
||||||
|
return [Device(**info) for info in
|
||||||
|
await asyncio.to_thread(laufwerke_mit_disc)]
|
||||||
|
|
||||||
|
|
||||||
|
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
|
||||||
|
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
|
||||||
|
_LETZTER_GRUND: Dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _grund_melden(pfad: str, grund: str) -> None:
|
||||||
|
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
|
||||||
|
Wechsel.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (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 aber schon.
|
||||||
|
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
|
||||||
|
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
|
||||||
|
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
|
||||||
|
|
||||||
|
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
|
||||||
|
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
|
||||||
|
beschreiben, sondern weiterhelfen.
|
||||||
|
"""
|
||||||
|
if _LETZTER_GRUND.get(pfad, "") == grund:
|
||||||
|
return
|
||||||
|
vorher = _LETZTER_GRUND.get(pfad, "")
|
||||||
|
_LETZTER_GRUND[pfad] = grund
|
||||||
|
try:
|
||||||
|
if grund:
|
||||||
|
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
|
||||||
|
elif vorher:
|
||||||
|
db.add_log("info", "watcher",
|
||||||
|
"Laufwerk %s antwortet wieder." % pfad)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Protokollieren darf die Laufwerksliste nie aufhalten
|
||||||
|
|
||||||
|
|
||||||
|
def _job_haelt_das_laufwerk(pfad: str) -> bool:
|
||||||
|
"""Laeuft auf diesem Laufwerk gerade ein Rip?
|
||||||
|
|
||||||
|
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
|
||||||
|
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
|
||||||
|
gehoert. Am Protokoll des Commanders abgelesen:
|
||||||
|
|
||||||
|
12:49:52 bluray-Rip gestartet
|
||||||
|
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||||
|
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||||
|
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
|
||||||
|
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
|
||||||
|
|
||||||
|
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
|
||||||
|
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
|
||||||
|
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
|
||||||
|
bereits, was drinliegt, der Job laeuft ja darauf.
|
||||||
|
|
||||||
|
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
|
||||||
|
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return bool(db.has_active_job(pfad))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False # im Zweifel nachsehen, wie bisher
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerke_mit_disc() -> list:
|
||||||
|
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht"
|
||||||
|
|
||||||
|
Auf der Server-Status-Kachel stand „Bereit — kein Datenträger im
|
||||||
|
Laufwerk", während die Disc erkannt war und ihr Titel eine Zeile weiter
|
||||||
|
oben in der Jobliste stand.
|
||||||
|
|
||||||
|
Die Ursache: Es gab ZWEI Wege zu den Laufwerken. `/devices` hängte die
|
||||||
|
erkannte Disc aus `DISC_CACHE` an; der Ereignis-Wächter rief nur
|
||||||
|
`device_info()` und ließ sie weg. Das UI liest seit V2-3 den Ereignisstrom
|
||||||
|
— also die Fassung ohne Disc. Es fragte `l.disc?.title`, bekam nichts, und
|
||||||
|
schloss daraus auf ein leeres Laufwerk.
|
||||||
|
|
||||||
|
Eine Auskunft in zwei Fassungen ist eine Auskunft zu viel. Jetzt gibt es
|
||||||
|
nur diese hier.
|
||||||
|
"""
|
||||||
geraete = []
|
geraete = []
|
||||||
|
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
|
||||||
for pfad in device_discovery.list_optical_devices():
|
for pfad in device_discovery.list_optical_devices():
|
||||||
info = await asyncio.to_thread(device_discovery.device_info, pfad)
|
if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
|
||||||
|
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
|
||||||
|
info = {k: v for k, v in letzte[pfad].items()
|
||||||
|
if k not in ("disc", "disc_wird_erkannt")}
|
||||||
|
else:
|
||||||
|
info = device_discovery.device_info(pfad)
|
||||||
|
_grund_melden(pfad, info.get("grund") or "")
|
||||||
disc = DISC_CACHE.get(pfad)
|
disc = DISC_CACHE.get(pfad)
|
||||||
if disc and not disc.get("_laeuft"):
|
if disc and disc.get("_laeuft"):
|
||||||
|
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
||||||
|
# sagen, dass gerade gearbeitet wird: Zwei Minuten Schweigen
|
||||||
|
# sehen aus wie ein leeres Laufwerk (Commander 29.08.2026).
|
||||||
|
info["disc_wird_erkannt"] = True
|
||||||
|
elif disc:
|
||||||
info["disc"] = disc
|
info["disc"] = disc
|
||||||
geraete.append(Device(**info))
|
geraete.append(info)
|
||||||
|
LETZTE_LAUFWERKE[:] = geraete
|
||||||
return geraete
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
|
#: Der zuletzt vollständig gelesene Laufwerks-Stand. Siehe `laufwerke_notdurft`.
|
||||||
|
LETZTE_LAUFWERKE: List[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def laufwerke_notdurft() -> Optional[list]:
|
||||||
|
"""Was sich OHNE Laufwerks-Abfrage sagen lässt — oder None.
|
||||||
|
|
||||||
|
## Warum es diesen Rückfall gibt (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander wollte sehen, dass die Disc-Erkennung noch läuft. Genau
|
||||||
|
dann ist die Auskunft aber am teuersten: Während `makemkvcon info` die
|
||||||
|
Disc liest, hält es das Laufwerk, und `device_info` wartet mit. Gemessen:
|
||||||
|
|
||||||
|
/devices waehrend des Scans 14,0 s
|
||||||
|
Zeitgrenze des Schnappschusses 5,0 s -> devices = None
|
||||||
|
|
||||||
|
`None` heißt „konnte nicht nachsehen", und die Oberfläche behält dann
|
||||||
|
ihren Stand — nach einem frischen Laden also **gar nichts**. Ausgerechnet
|
||||||
|
in der Phase, die sichtbar werden sollte, war der Bildschirm leer.
|
||||||
|
|
||||||
|
Der Ausweg braucht kein ioctl: Dass gerade erkannt wird, steht im
|
||||||
|
Vorrat (`DISC_CACHE`), und wie die Laufwerke heißen, wissen wir vom
|
||||||
|
letzten vollständigen Lauf. Beides zusammen ist eine ehrliche Auskunft —
|
||||||
|
„das sahen wir zuletzt, und an DIESEM Laufwerk arbeiten wir gerade".
|
||||||
|
|
||||||
|
`None`, wenn wir noch nie erfolgreich gelesen haben: Dann ist Schweigen
|
||||||
|
richtig, denn behaupten ließe sich nichts.
|
||||||
|
"""
|
||||||
|
bekannt = LETZTE_LAUFWERKE
|
||||||
|
if not bekannt:
|
||||||
|
# Kaltstart mitten in der Erkennung: Wir haben noch keinen Stand.
|
||||||
|
# Die LISTE der Laufwerke ist trotzdem billig (sie zaehlt nur
|
||||||
|
# Buchstaben bzw. /dev-Knoten auf) — teuer ist erst `device_info`,
|
||||||
|
# das das Laufwerk selbst anfasst. Also das Wenige melden, das
|
||||||
|
# sicher ist, und nichts erfinden: kein Typ, kein Modell.
|
||||||
|
try:
|
||||||
|
pfade_jetzt = device_discovery.list_optical_devices()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
bekannt = [{"id": p.rstrip(":\\").rsplit("\\", 1)[-1].rstrip(":") or p,
|
||||||
|
"name": p, "path": p, "type": "unknown", "status": "ready"}
|
||||||
|
for p in pfade_jetzt if (DISC_CACHE.get(p) or {}).get("_laeuft")]
|
||||||
|
if not bekannt:
|
||||||
|
return None
|
||||||
|
frisch = []
|
||||||
|
for alt in bekannt:
|
||||||
|
eintrag = dict(alt)
|
||||||
|
disc = DISC_CACHE.get(eintrag.get("path")) or {}
|
||||||
|
eintrag.pop("disc_wird_erkannt", None)
|
||||||
|
eintrag.pop("disc", None)
|
||||||
|
if disc.get("_laeuft"):
|
||||||
|
eintrag["disc_wird_erkannt"] = True
|
||||||
|
elif disc:
|
||||||
|
eintrag["disc"] = disc
|
||||||
|
frisch.append(eintrag)
|
||||||
|
return frisch
|
||||||
|
|
||||||
|
|
||||||
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
# GET /stream/jobs (SSE) entfernt am 25.07.2026. Der Stream war zweimal falsch:
|
||||||
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
# erst ein Placebo (er sendete nur, wenn eine Liste `sse_connections` gefüllt
|
||||||
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
# war, und nichts füllte sie je), dann am 23.07. funktionsfähig gemacht — aber
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
["timeout", "3", "ls", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=5
|
||||||
)
|
)
|
||||||
return ergebnis.returncode == 0
|
return ergebnis.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
["timeout", "4", "ls", "-d", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=6
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unklar"
|
return "unklar"
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
|||||||
NICHTS = ""
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
def _meta(meta_json) -> dict:
|
def meta_von(meta_json) -> dict:
|
||||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||||
|
|
||||||
|
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||||
|
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||||
|
"""
|
||||||
if isinstance(meta_json, dict):
|
if isinstance(meta_json, dict):
|
||||||
return meta_json
|
return meta_json
|
||||||
if not meta_json:
|
if not meta_json:
|
||||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
|||||||
"""
|
"""
|
||||||
if (job.get("status") or "") != "failed":
|
if (job.get("status") or "") != "failed":
|
||||||
return NICHTS
|
return NICHTS
|
||||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||||
if marke is True:
|
if marke is True:
|
||||||
return NEU_KOMPRIMIEREN
|
return NEU_KOMPRIMIEREN
|
||||||
if marke is False:
|
if marke is False:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import subprocess
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from rippy import drives as _laufwerks_schicht
|
from rippy import drives as _laufwerks_schicht
|
||||||
from rippy.tools import katalog as werkzeug_katalog
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
from clients.tmdb import TMDBClient
|
from clients.tmdb import TMDBClient
|
||||||
from clients.jikan import JikanClient
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
@@ -113,12 +113,14 @@ def disc_wurzel(device_path: str):
|
|||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, text=True, timeout=30,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
if ergebnis.returncode != 0:
|
if ergebnis.returncode != 0:
|
||||||
return None, lambda: None
|
return None, lambda: None
|
||||||
|
|
||||||
def abhaengen():
|
def abhaengen():
|
||||||
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
|
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
|
||||||
|
creationflags=OHNE_FENSTER)
|
||||||
|
|
||||||
return mountpoint, abhaengen
|
return mountpoint, abhaengen
|
||||||
|
|
||||||
@@ -329,7 +331,8 @@ class PreScan:
|
|||||||
["cdparanoia", "-Q", device_path],
|
["cdparanoia", "-Q", device_path],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=10
|
timeout=10,
|
||||||
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
for line in result.stdout.split('\n'):
|
for line in result.stdout.split('\n'):
|
||||||
if 'track' in line.lower():
|
if 'track' in line.lower():
|
||||||
@@ -353,31 +356,37 @@ class PreScan:
|
|||||||
if label:
|
if label:
|
||||||
toc["title"] = normalize_disc_label(label)
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 3: die Titelliste von makemkvcon (optional).
|
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||||
#
|
#
|
||||||
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
|
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||||
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
|
#
|
||||||
# Windows liegen Programme in „Programme", nicht im PATH.
|
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||||
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
|
# lange. Das ging mal viel schneller."
|
||||||
# installiertem MakeMKV None zurück, und dieser Zweig lief
|
#
|
||||||
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
|
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||||
# gibt.
|
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||||
makemkv = werkzeug_katalog.finden("makemkv")
|
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||||
if makemkv:
|
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||||
result = subprocess.run(
|
#
|
||||||
[makemkv, "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
|
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||||
capture_output=True,
|
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||||
text=True,
|
# `shutil.which`, und das findet unter Windows nichts
|
||||||
timeout=120
|
# (Programme liegen nicht im PATH). `be3fac5` hat das am
|
||||||
)
|
# 28.08.2026 richtig repariert — und damit erst die Kosten
|
||||||
for line in result.stdout.split('\n'):
|
# sichtbar gemacht, die hier immer schon standen.
|
||||||
if line.startswith('TINFO:'):
|
#
|
||||||
parts = line.split(',')
|
# Der Aufwand war umsonst: Das Ergebnis landete allein in
|
||||||
if len(parts) >= 5:
|
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
|
||||||
toc["tracks"].append({
|
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
# und `GET /devices/{id}/tracks` — also dann, wenn sie
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
|
||||||
})
|
# Der Weg fuer eine Disc, die man gar nicht rippen will,
|
||||||
|
# sind so zwei Minuten Warten fuer nichts.
|
||||||
|
#
|
||||||
|
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
|
||||||
|
# billig. Wer den Aufruf je wieder braucht, braucht dazu
|
||||||
|
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
|
||||||
|
# `tools.katalog` — die Importe sind mit ihm gegangen.
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
|
|||||||
+137
-15
@@ -38,18 +38,71 @@ Verwechslungsgefahr gibt es dabei nicht: Roh-Verzeichnisse heißen exakt wie die
|
|||||||
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
# Container-Standard für Roh-Rips (RAW_DIR im Worker).
|
from rippy import pfade as _pfade
|
||||||
|
|
||||||
|
# Container-Standard für Roh-Rips (RAW_DIR im Worker). Bleibt als Rueckfall
|
||||||
|
# stehen — die WURZELN dieses Betriebs liefert `wurzeln()`.
|
||||||
RAW_STANDARD = "/app/temp/raw"
|
RAW_STANDARD = "/app/temp/raw"
|
||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_einstellungen() -> dict:
|
||||||
|
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||||
|
gerade nicht antwortet.
|
||||||
|
|
||||||
|
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||||
|
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||||
|
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||||
|
Vorgabe, wie bisher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rippy import store as db
|
||||||
|
|
||||||
|
return db.get_settings(bei_fehler_leer=True) or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def wurzeln(werte=None) -> tuple:
|
||||||
|
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das nicht fest sein darf (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Dieses Modul findet die Rohdaten eines Jobs wieder — fuer den
|
||||||
|
Wiederholen-Dialog („auf der Platte liegen X GB Rohdaten") und fuer
|
||||||
|
„Rohdaten mitloeschen". Es suchte fest unter `/app/temp/raw` und
|
||||||
|
`/app/media`.
|
||||||
|
|
||||||
|
Auf einem Windows-PC gibt es beides nicht. Also fand es NIE etwas: Der
|
||||||
|
Dialog meldete „keine Rohdaten", das Aufraeumen loeschte nichts, und die
|
||||||
|
Bruchstuecke eines abgebrochenen Rips blieben unbemerkt liegen — bei einer
|
||||||
|
4K-UHD bis zu 100 GB.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
# Dieselbe Bruecke wie in /system/info: Die Oberflaeche legt
|
||||||
|
# ihre Orte in der Datenbank ab, nicht in der Datei. Ohne sie
|
||||||
|
# suchte die Rohdaten-Suche unter der Vorgabe statt unter dem,
|
||||||
|
# was eingestellt ist (Befund 30.08.2026).
|
||||||
|
werte = betrieb.mit_einstellungen(werte, _ui_einstellungen())
|
||||||
|
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||||
|
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
# Harte Obergrenze für EINE Verzeichnis-Prüfung. Siehe verzeichnis_da().
|
||||||
PRUEF_TIMEOUT_SEKUNDEN = 4
|
PRUEF_TIMEOUT_SEKUNDEN = 4
|
||||||
|
|
||||||
|
|
||||||
def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
"""Alle Orte, an denen die Roh-MKVs dieses Jobs liegen KÖNNTEN (pure).
|
||||||
|
|
||||||
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
`media_unterordner` sind die Namen der obersten Ebene unter /app/media
|
||||||
@@ -65,13 +118,37 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
"""
|
"""
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return []
|
return []
|
||||||
orte = [posixpath.join(RAW_STANDARD, job_id)]
|
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
|
||||||
wahl = (work_dir or "").strip().rstrip("/")
|
from rippy import pfade
|
||||||
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
|
|
||||||
orte.append(posixpath.join(wahl, job_id))
|
orte = [pfade.verbinden(roh, job_id)]
|
||||||
|
# Zum VERGLEICHEN ohne Schluss-Trenner, zum VERBINDEN mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, am Rechner des Commanders nachgerechnet:
|
||||||
|
# Hier stand `wahl = (work_dir or "").strip().rstrip("/\\")`, und mit
|
||||||
|
# dieser einen abgestreiften Zeichenkette wurde dann auch VERBUNDEN.
|
||||||
|
# Sein Arbeitsordner ist `F:\` — ein Laufwerks-Stammverzeichnis:
|
||||||
|
#
|
||||||
|
# "F:\\".rstrip("/\\") -> "F:"
|
||||||
|
# verbinden("F:", job_id) -> "F:1aa41fef-…" isdir: False
|
||||||
|
# verbinden("F:\\", job_id) -> "F:\1aa41fef-…" isdir: True
|
||||||
|
#
|
||||||
|
# `F:` ohne Trenner heisst unter Windows „der aktuelle Ordner auf
|
||||||
|
# Laufwerk F", nicht die Wurzel — die Falle steht woertlich im Kopf von
|
||||||
|
# `pfade.verbinden`, und diese Zeile ist hineingetreten. Folge: 16,5 GB
|
||||||
|
# Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
|
||||||
|
# rippen" an — Stunden am beschaedigten Datentraeger fuer nichts.
|
||||||
|
wahl = (work_dir or "").strip()
|
||||||
|
vergleich = wahl.rstrip("/\\")
|
||||||
|
grenze = (medien or "").rstrip("/\\")
|
||||||
|
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||||
|
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
||||||
|
if vergleich and (frei or vergleich == grenze
|
||||||
|
or vergleich.startswith(grenze + "/")):
|
||||||
|
orte.append(pfade.verbinden(wahl, job_id))
|
||||||
for name in media_unterordner or []:
|
for name in media_unterordner or []:
|
||||||
if name:
|
if name:
|
||||||
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
|
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
|
||||||
gesehen, eindeutig = set(), []
|
gesehen, eindeutig = set(), []
|
||||||
for ort in orte:
|
for ort in orte:
|
||||||
if ort not in gesehen:
|
if ort not in gesehen:
|
||||||
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def nativ_nachsehen() -> bool:
|
||||||
|
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||||
|
|
||||||
|
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||||
|
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||||
|
`pruefen`.
|
||||||
|
"""
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def pruefen(pfad: str, laufen=None) -> str:
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
|||||||
"""
|
"""
|
||||||
if not pfad:
|
if not pfad:
|
||||||
return "weg"
|
return "weg"
|
||||||
|
if laufen is None and nativ_nachsehen():
|
||||||
|
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
#
|
||||||
|
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||||
|
# 14:54:40 WindowsTerminal.exe
|
||||||
|
#
|
||||||
|
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||||
|
# gehen ganz kurz auf und dann wieder zu."
|
||||||
|
#
|
||||||
|
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||||
|
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||||
|
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||||
|
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||||
|
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||||
|
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||||
|
# unter Windows grundsaetzlich unsichtbar.
|
||||||
|
#
|
||||||
|
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||||
|
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||||
|
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||||
|
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||||
|
try:
|
||||||
|
return "da" if os.path.isdir(pfad) else "weg"
|
||||||
|
except OSError:
|
||||||
|
return "unklar"
|
||||||
starten = laufen or subprocess.run
|
starten = laufen or subprocess.run
|
||||||
try:
|
try:
|
||||||
ergebnis = starten(
|
ergebnis = starten(
|
||||||
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
|
|||||||
return pruefen(pfad, laufen) == "da"
|
return pruefen(pfad, laufen) == "da"
|
||||||
|
|
||||||
|
|
||||||
def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
def suche(job_id: str, work_dir: str, listdir, isdir,
|
||||||
|
orte_wurzeln=None) -> list:
|
||||||
"""Die Orte, an denen wirklich etwas liegt.
|
"""Die Orte, an denen wirklich etwas liegt.
|
||||||
|
|
||||||
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
`listdir` und `isdir` werden übergeben statt importiert — so ist die Suche
|
||||||
@@ -156,12 +271,13 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
`listdir` darf os.listdir bleiben: Gelistet wird nur /app/media selbst, und
|
||||||
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
das ist ein lokales Verzeichnis — die Freigaben sind Unterordner davon.
|
||||||
"""
|
"""
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden = []
|
gefunden = []
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
try:
|
try:
|
||||||
if isdir(ort):
|
if isdir(ort):
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None,
|
||||||
|
orte_wurzeln=None) -> dict:
|
||||||
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
"""Wie suche(), aber sagt auch, ob etwas UNGEPRÜFT geblieben ist.
|
||||||
|
|
||||||
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
Rückgabe: {"pfade": [...], "unklar": bool}. `unklar` heißt: Mindestens ein
|
||||||
@@ -181,12 +298,13 @@ def suche_mit_status(job_id: str, work_dir: str, listdir, pruefer=None) -> dict:
|
|||||||
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
behalten, statt Abwesenheit zu behaupten (siehe pruefen()).
|
||||||
"""
|
"""
|
||||||
pruefe = pruefer or pruefen
|
pruefe = pruefer or pruefen
|
||||||
|
orte_wurzeln = orte_wurzeln or wurzeln()
|
||||||
try:
|
try:
|
||||||
unterordner = sorted(listdir(MEDIA_ROOT))
|
unterordner = sorted(listdir(orte_wurzeln[1]))
|
||||||
except OSError:
|
except OSError:
|
||||||
unterordner = []
|
unterordner = []
|
||||||
gefunden, unklar = [], False
|
gefunden, unklar = [], False
|
||||||
for ort in kandidaten(job_id, work_dir, unterordner):
|
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
|
||||||
antwort = pruefe(ort)
|
antwort = pruefe(ort)
|
||||||
if antwort == "da":
|
if antwort == "da":
|
||||||
gefunden.append(ort)
|
gefunden.append(ort)
|
||||||
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for name in namen:
|
for name in namen:
|
||||||
voll = posixpath.join(pfad, name)
|
# `pfade.verbinden` statt posixpath: Auf Windows ist `pfad`
|
||||||
|
# ein echter Windows-Pfad, und `posixpath.join` baute daraus
|
||||||
|
# `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
|
||||||
|
# aussehen und jeden Vergleich brechen (Befund 29.08.2026).
|
||||||
|
voll = _pfade.verbinden(pfad, name)
|
||||||
try:
|
try:
|
||||||
if isfile(voll):
|
if isfile(voll):
|
||||||
bytes_gesamt += getsize(voll)
|
bytes_gesamt += getsize(voll)
|
||||||
|
|||||||
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
|||||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||||
monkeypatch.setattr(main.db, "get_settings", lambda: {})
|
#
|
||||||
|
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||||
|
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||||
|
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||||
|
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||||
|
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||||
|
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||||
|
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||||
|
|
||||||
zustand = asyncio.run(main._snapshot())
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
@@ -411,3 +419,516 @@ def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
|
|||||||
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
|
||||||
if not daten["im_container"]:
|
if not daten["im_container"]:
|
||||||
assert daten["hilfe_befehl"] == ""
|
assert daten["hilfe_befehl"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
|
||||||
|
#
|
||||||
|
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||||
|
#
|
||||||
|
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
|
||||||
|
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
|
||||||
|
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
|
||||||
|
# auch `running` auf `processing` ab.
|
||||||
|
#
|
||||||
|
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
|
||||||
|
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
|
||||||
|
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
|
||||||
|
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
|
||||||
|
|
||||||
|
|
||||||
|
def _echte_db_zeile():
|
||||||
|
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": "j1",
|
||||||
|
"disc_type": "bluray", # NICHT "type"
|
||||||
|
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
|
||||||
|
"finished_at": None,
|
||||||
|
"status": "running", # wird zu "processing"
|
||||||
|
"device": r"\.\G:",
|
||||||
|
"progress": 12,
|
||||||
|
"title": "Evangelion 2.22",
|
||||||
|
"error": None,
|
||||||
|
"meta": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
|
||||||
|
import main
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
assert ui["type"] == "bluray"
|
||||||
|
assert ui["startTime"].startswith("2026-08-29")
|
||||||
|
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
|
||||||
|
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
|
||||||
|
Pflichtfelder leer — und leer sieht im Browser aus wie eine Auskunft."""
|
||||||
|
import main
|
||||||
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
|
||||||
|
|
||||||
|
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
|
||||||
|
kurz = _job_kurz(ui)
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
|
||||||
|
assert kurz["type"] == "bluray"
|
||||||
|
assert kurz["status"] == "processing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
|
||||||
|
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.startup_event)
|
||||||
|
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
|
||||||
|
assert "_job_row_to_model" in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
|
||||||
|
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
|
||||||
|
|
||||||
|
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
|
||||||
|
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
|
||||||
|
(beide ohne). Das Dashboard liest den Schnappschuss — und schloss aus der
|
||||||
|
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
|
||||||
|
weiter oben stand.
|
||||||
|
|
||||||
|
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
# Die eine erlaubte Fundstelle ist die Funktion selbst.
|
||||||
|
stellen = quelle.count("device_discovery.device_info(")
|
||||||
|
assert stellen == 1, (
|
||||||
|
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
|
||||||
|
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
|
||||||
|
for name in ("_snapshot", "startup_event", "get_devices"):
|
||||||
|
text = inspect.getsource(getattr(main, name))
|
||||||
|
assert "device_info(" not in text, \
|
||||||
|
"%s baut die Laufwerksliste selbst" % name
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_mit_disc()
|
||||||
|
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
|
||||||
|
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
assert "disc" not in main.laufwerke_mit_disc()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
|
||||||
|
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
|
||||||
|
sein."
|
||||||
|
|
||||||
|
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg —
|
||||||
|
damit keine halbfertige Disc-Karte mit „Wird erkannt…" als Titel und
|
||||||
|
einem „Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
|
||||||
|
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
|
||||||
|
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
|
||||||
|
(gemessen: Disc nach 119 s erkannt).
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
|
||||||
|
{"_laeuft": True, "title": "Wird erkannt…"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert geraet["disc_wird_erkannt"] is True
|
||||||
|
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
|
||||||
|
assert "disc" not in geraet
|
||||||
|
|
||||||
|
|
||||||
|
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
|
||||||
|
"type": "bluray", "status": "ready"})
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_mit_disc()[0]
|
||||||
|
assert not geraet.get("disc_wird_erkannt")
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
|
||||||
|
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
|
||||||
|
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
assert "disc_wird_erkannt" in main.Device.model_fields
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
|
||||||
|
#
|
||||||
|
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
|
||||||
|
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
|
||||||
|
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
|
||||||
|
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
|
||||||
|
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
|
||||||
|
# sein soll.
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready"}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
|
||||||
|
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
|
||||||
|
„behalte deinen Stand", und das ist die ehrliche Antwort."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
|
assert main.laufwerke_notdurft() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
|
||||||
|
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
|
||||||
|
nur das Abfragen des Laufwerks ist teuer."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: [r"\.\G:"])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
|
||||||
|
|
||||||
|
geraete = main.laufwerke_notdurft()
|
||||||
|
assert geraete[0]["disc_wird_erkannt"] is True
|
||||||
|
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
|
||||||
|
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
|
||||||
|
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
|
||||||
|
"type": "bluray", "status": "ready",
|
||||||
|
"disc_wird_erkannt": True}])
|
||||||
|
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
|
||||||
|
|
||||||
|
geraet = main.laufwerke_notdurft()[0]
|
||||||
|
assert "disc_wird_erkannt" not in geraet
|
||||||
|
assert geraet["disc"]["title"] == "Evangelion 2.22"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
|
||||||
|
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
|
||||||
|
# fenster ganz kurz im hintergrund."
|
||||||
|
#
|
||||||
|
# Beides war dieselbe Schleife: `except OSError: continue` liess
|
||||||
|
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
|
||||||
|
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
|
||||||
|
# naechste Statusabfrage scheitert — und so weiter.
|
||||||
|
|
||||||
|
|
||||||
|
def _CDS():
|
||||||
|
import main
|
||||||
|
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, ok) == "erststart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
|
||||||
|
import main
|
||||||
|
_, leer, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(False, None, leer) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
|
||||||
|
"""Der Kern des Befundes."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
|
||||||
|
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
|
||||||
|
„noch nie gesehen" verwechselt, scannt endlos.
|
||||||
|
|
||||||
|
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
|
||||||
|
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
|
||||||
|
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
|
||||||
|
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
|
||||||
|
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
|
||||||
|
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
|
||||||
|
assert main.disc_entscheidung(True, ok, ok) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_wird_erkannt():
|
||||||
|
import main
|
||||||
|
ok, leer, offen = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
|
||||||
|
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
|
||||||
|
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
|
||||||
|
weg, sobald das Laufwerk kurz beschaeftigt ist."""
|
||||||
|
import main
|
||||||
|
ok, _, _ = _CDS()
|
||||||
|
assert main.disc_entscheidung(True, ok, 3) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
|
||||||
|
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.disc_watcher)
|
||||||
|
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
|
||||||
|
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
|
||||||
|
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
|
||||||
|
|
||||||
|
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
gemessen' aber messung wird nicht abgeschlossen."
|
||||||
|
|
||||||
|
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
|
||||||
|
seine Jobs mit dem nackten `_job_row_to_model` — also ohne. Seit V2-3
|
||||||
|
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
|
||||||
|
also brav berechnet und niemandem gezeigt.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main._snapshot)
|
||||||
|
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
|
||||||
|
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_jobliste_traegt_die_restzeit_felder():
|
||||||
|
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
|
||||||
|
assert feld in main.Job.model_fields, feld
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
|
||||||
|
#
|
||||||
|
# Commander: „der ‚Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
|
||||||
|
# bereits bei 30% und er liest immernoch."
|
||||||
|
#
|
||||||
|
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
|
||||||
|
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
|
||||||
|
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
|
||||||
|
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
|
||||||
|
# wissen wir — der Job laeuft ja auf ihr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gescannt = []
|
||||||
|
|
||||||
|
class NieBenutzt:
|
||||||
|
def scan(self, pfad):
|
||||||
|
gescannt.append(pfad)
|
||||||
|
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", NieBenutzt)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert gescannt == []
|
||||||
|
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
class Ergebnis:
|
||||||
|
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"title": "Akira"}
|
||||||
|
|
||||||
|
class Scanner:
|
||||||
|
def scan(self, pfad):
|
||||||
|
return Ergebnis()
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "PreScan", Scanner)
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
|
||||||
|
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
|
||||||
|
lambda p: asyncio.sleep(0))
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
asyncio.run(main._auto_prescan(r"\.\G:"))
|
||||||
|
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
|
||||||
|
main.DISC_CACHE.pop(r"\.\G:", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
|
||||||
|
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
|
||||||
|
Ende stehen — der Scan davor haelt sie ja fest."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
|
||||||
|
if not quelle:
|
||||||
|
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
|
||||||
|
quelle = inspect.getsource(main)
|
||||||
|
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
|
||||||
|
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
|
||||||
|
#
|
||||||
|
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
|
||||||
|
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
|
||||||
|
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
|
||||||
|
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
|
||||||
|
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
|
||||||
|
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
|
||||||
|
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
|
||||||
|
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
|
||||||
|
|
||||||
|
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
|
||||||
|
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
|
||||||
|
|
||||||
|
|
||||||
|
def test_job_offen_zaehlt_die_kompression_mit():
|
||||||
|
from rippy.store import JOB_OFFEN
|
||||||
|
|
||||||
|
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
|
||||||
|
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
|
||||||
|
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
|
||||||
|
|
||||||
|
|
||||||
|
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
||||||
|
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
|
||||||
|
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
|
||||||
|
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from rippy import store
|
||||||
|
|
||||||
|
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
|
||||||
|
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
|
||||||
|
|
||||||
|
12:49:52 bluray-Rip gestartet
|
||||||
|
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
|
||||||
|
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
|
||||||
|
12:50:12 makemkvcon endete mit Code 11
|
||||||
|
|
||||||
|
Der Waechter fragt alle drei Sekunden ab — drei CreateFileW plus IOCTLs
|
||||||
|
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
|
||||||
|
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
|
||||||
|
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
|
||||||
|
"""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
|
||||||
|
|
||||||
|
stand = main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
|
||||||
|
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
|
||||||
|
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
|
||||||
|
import main
|
||||||
|
|
||||||
|
gefragt = []
|
||||||
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
|
||||||
|
lambda: ["/dev/sr0"])
|
||||||
|
monkeypatch.setattr(main.device_discovery, "device_info",
|
||||||
|
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
|
||||||
|
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
|
||||||
|
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
|
||||||
|
|
||||||
|
main.laufwerke_mit_disc()
|
||||||
|
assert gefragt == ["/dev/sr0"]
|
||||||
|
|||||||
@@ -157,3 +157,73 @@ def test_fertige_und_wartende_jobs_bekommen_keine_eta():
|
|||||||
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
speicher.get, lambda k, v, expire=None: speicher.__setitem__(k, v))
|
||||||
assert ergebnis == {"sekunden": -1, "text": ""}
|
assert ergebnis == {"sekunden": -1, "text": ""}
|
||||||
assert speicher == {} # nichts geschrieben
|
assert speicher == {} # nichts geschrieben
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ohne Redis muss die Schaetzung trotzdem zustande kommen ─────────────
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Über den kompletten vorgang steht dort ‚Restzeit wird
|
||||||
|
# gemessen' aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"
|
||||||
|
#
|
||||||
|
# Die Messreihe lag nur im Cache, und der Modul-Kopf sagte „Der Cache (Redis)
|
||||||
|
# ist schon da". Im Container stimmt das. Auf einem Windows-PC gab `cache_get`
|
||||||
|
# bei JEDEM Aufruf None zurueck — also jedes Mal eine frische Reihe mit EINEM
|
||||||
|
# Punkt, und `restzeit_sekunden` braucht zwei.
|
||||||
|
|
||||||
|
|
||||||
|
def _ohne_cache():
|
||||||
|
"""Ein Cache, der nichts behaelt — genau wie Redis, das es nicht gibt."""
|
||||||
|
return (lambda k: None), (lambda k, v, expire=None: False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_cache_kommt_trotzdem_eine_schaetzung():
|
||||||
|
"""DER Fall des Commanders."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
# Zwei Messpunkte, 120 s auseinander, 10 % Fortschritt.
|
||||||
|
eta.aktualisiere_und_schaetze("j1", "running", 10, 1000.0, lesen, schreiben)
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze("j1", "running", 20, 1120.0, lesen, schreiben)
|
||||||
|
|
||||||
|
assert ergebnis["sekunden"] > 0, "ohne Redis kam nie eine Zahl heraus"
|
||||||
|
assert ergebnis["text"], "und damit stand dauerhaft „wird gemessen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_cache_hat_weiterhin_vorrang():
|
||||||
|
"""Wo es Redis gibt, bleibt Redis die Quelle — es ueberlebt einen
|
||||||
|
API-Neustart, das Woerterbuch im Prozess nicht."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
aus_dem_cache = {"status": "running", "punkte": [[500.0, 5], [560.0, 15]]}
|
||||||
|
ergebnis = eta.aktualisiere_und_schaetze(
|
||||||
|
"j2", "running", 25, 620.0,
|
||||||
|
lambda k: aus_dem_cache, lambda k, v, expire=None: True)
|
||||||
|
# Drei Punkte, 120 s fuer 20 % -> die Reihe aus dem Cache wurde benutzt.
|
||||||
|
assert ergebnis["sekunden"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_statuswechsel_beginnt_auch_ohne_cache_neu():
|
||||||
|
"""Rip und Kompression haben nichts miteinander zu tun."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 50, 1000.0, lesen, schreiben)
|
||||||
|
eta.aktualisiere_und_schaetze("j3", "running", 90, 1100.0, lesen, schreiben)
|
||||||
|
neu = eta.aktualisiere_und_schaetze("j3", "transcoding", 5, 1110.0, lesen, schreiben)
|
||||||
|
assert neu["sekunden"] == -1, "nach dem Wechsel gibt es noch keine Aussage"
|
||||||
|
|
||||||
|
|
||||||
|
def test_alte_reihen_wachsen_nicht_unbegrenzt():
|
||||||
|
"""Wer einen Vorrat anlegt, raeumt ihn auch weg (AGENTS.md)."""
|
||||||
|
import eta
|
||||||
|
|
||||||
|
eta._REIHEN.clear()
|
||||||
|
lesen, schreiben = _ohne_cache()
|
||||||
|
eta.aktualisiere_und_schaetze("alt", "running", 10, 0.0, lesen, schreiben)
|
||||||
|
# Einen Tag spaeter ein anderer Job -> der alte fliegt raus.
|
||||||
|
eta.aktualisiere_und_schaetze("neu", "running", 10,
|
||||||
|
eta.REIHE_HALTBAR_SEKUNDEN + 10.0, lesen, schreiben)
|
||||||
|
assert eta.schluessel("alt") not in eta._REIHEN
|
||||||
|
assert eta.schluessel("neu") in eta._REIHEN
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
from makemkv_key import extract_key
|
from makemkv_key import extract_key
|
||||||
|
|
||||||
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
|
||||||
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
|
|||||||
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
|
||||||
assert treffer == _KEY
|
assert treffer == _KEY
|
||||||
assert treffer.startswith("T-")
|
assert treffer.startswith("T-")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Knopf „Jetzt aus dem Forum holen" (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 hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
|
||||||
|
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
|
||||||
|
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def klient():
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 200
|
||||||
|
daten = antwort.json()
|
||||||
|
assert daten["geholt"] is True
|
||||||
|
assert daten["geaendert"] is True
|
||||||
|
assert daten["endet_auf"] == _KEY[-6:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
|
||||||
|
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
|
||||||
|
ein zweiter Weg, ihn zu verlieren."""
|
||||||
|
import main as api
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
|
||||||
|
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
|
||||||
|
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
|
||||||
|
|
||||||
|
text = klient.post("/system/makemkv-key/holen").text
|
||||||
|
assert _KEY not in text
|
||||||
|
assert _KEY[:20] not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
|
||||||
|
monkeypatch):
|
||||||
|
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
|
||||||
|
def wirft(*a, **k):
|
||||||
|
raise OSError("Verbindung abgelehnt")
|
||||||
|
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "Forum" in antwort.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
|
||||||
|
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
|
||||||
|
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
|
||||||
|
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
|
||||||
|
antwort = klient.post("/system/makemkv-key/holen")
|
||||||
|
assert antwort.status_code == 502
|
||||||
|
assert "geändert" in antwort.json()["detail"]
|
||||||
|
|||||||
@@ -77,6 +77,3 @@ def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
|
|||||||
assert main.medien_wurzel()
|
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"
|
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
|
||||||
|
|
||||||
|
|
||||||
def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
|
||||||
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei
|
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
|
||||||
installiertem MakeMKV None zurueck -- Windows-Programme liegen in
|
ENTFERNT — er lief unter Windows nie (`shutil.which` findet dort nichts,
|
||||||
„Programme", nicht im PATH. Dieser Zweig lief dort also NIE."""
|
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
|
import os
|
||||||
|
|
||||||
hier = os.path.dirname(os.path.abspath(__file__))
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
|
|||||||
quelle = "".join(code)
|
quelle = "".join(code)
|
||||||
assert "shutil.which(" not in quelle, (
|
assert "shutil.which(" not in quelle, (
|
||||||
"shutil.which findet unter Windows keine Programme aus 'Programme'")
|
"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.")
|
||||||
|
|||||||
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
|
|||||||
assert orte[0] == f"/app/temp/raw/{JOB}"
|
assert orte[0] == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln des CONTAINER-Betriebs, eingespritzt. Ohne sie hingen diese
|
||||||
|
# Tests am laufenden Rechner: Unter Windows liefert `wurzeln()` echte
|
||||||
|
# Windows-Pfade, und die Erwartungen hier gelten dort nicht (am 29.08.2026
|
||||||
|
# prompt sieben Tests rot geworden).
|
||||||
|
CONTAINER = ("/app/temp/raw", "/app/media", False)
|
||||||
|
|
||||||
|
|
||||||
def test_suche_liefert_nur_was_existiert():
|
def test_suche_liefert_nur_was_existiert():
|
||||||
vorhanden = {f"/app/media/rippy/{JOB}"}
|
vorhanden = {f"/app/media/rippy/{JOB}"}
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["movies", "rippy"],
|
listdir=lambda p: ["movies", "rippy"],
|
||||||
isdir=lambda p: p in vorhanden,
|
isdir=lambda p: p in vorhanden,
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
|||||||
assert f"/app/media/{JOB}" in orte
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||||
|
#: Gegenstück zu CONTAINER weiter oben.
|
||||||
|
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||||
|
|
||||||
|
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||||
|
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||||
|
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||||
|
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||||
|
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||||
|
beschädigten Datenträger für etwas, das schon dalag.
|
||||||
|
"""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||||
|
assert "F:" + chr(92) + JOB in orte
|
||||||
|
assert "F:" + JOB not in orte
|
||||||
|
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||||
|
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||||
|
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||||
|
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||||
|
nicht am Trenner scheitern."""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||||
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_job_id_nichts():
|
def test_ohne_job_id_nichts():
|
||||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||||
|
|
||||||
@@ -64,7 +101,7 @@ def test_kaputter_mount_reisst_die_suche_nicht_mit():
|
|||||||
return p == f"/app/temp/raw/{JOB}"
|
return p == f"/app/temp/raw/{JOB}"
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt)
|
JOB, "", listdir=lambda p: ["totes-nas", "movies"], isdir=isdir_kaputt, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
|
|||||||
raise OSError("kein /app/media")
|
raise OSError("kein /app/media")
|
||||||
|
|
||||||
gefunden = rohdaten.suche(
|
gefunden = rohdaten.suche(
|
||||||
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True)
|
JOB, "", listdir=listdir_kaputt, isdir=lambda p: True, orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
assert gefunden == [f"/app/temp/raw/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
|
|||||||
JOB, "",
|
JOB, "",
|
||||||
listdir=lambda p: ["bluray", "movies", "rippy"],
|
listdir=lambda p: ["bluray", "movies", "rippy"],
|
||||||
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
|
||||||
)
|
orte_wurzeln=CONTAINER)
|
||||||
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
assert gefunden == [f"/app/media/rippy/{JOB}"]
|
||||||
|
|
||||||
|
|
||||||
@@ -206,7 +243,7 @@ def test_suche_mit_status_meldet_ungepruefte_orte():
|
|||||||
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
return "unklar" if pfad.startswith("/app/media/rippy/") else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [], "unklar": True}
|
assert e == {"pfade": [], "unklar": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
|
|||||||
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["movies", "rippy"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
assert e == {"pfade": [f"/app/media/rippy/{JOB}"], "unklar": False}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
|
|||||||
return "unklar" if "totes-nas" in pfad else "weg"
|
return "unklar" if "totes-nas" in pfad else "weg"
|
||||||
|
|
||||||
e = rohdaten.suche_mit_status(
|
e = rohdaten.suche_mit_status(
|
||||||
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe)
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
assert e["unklar"] is True
|
assert e["unklar"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
|
||||||
|
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
|
||||||
|
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
|
||||||
|
14:54:40 WindowsTerminal.exe
|
||||||
|
|
||||||
|
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
|
||||||
|
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
|
||||||
|
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
|
||||||
|
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
|
||||||
|
die Antwort weg fuer JEDES Verzeichnis.
|
||||||
|
"""
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: gestartet.append(a))
|
||||||
|
assert rohdaten.pruefen(str(tmp_path)) == "da"
|
||||||
|
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
|
||||||
|
assert gestartet == [], "es darf kein Prozess gestartet werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_bleibt_der_kindprozess(monkeypatch):
|
||||||
|
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
|
||||||
|
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
class Antwort:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
|
||||||
|
assert rohdaten.pruefen("/app/media/x") == "da"
|
||||||
|
assert aufrufe[0][0] == "timeout"
|
||||||
|
assert aufrufe[0][-1] == "/app/media/x"
|
||||||
|
|||||||
+21
-9
@@ -8,6 +8,7 @@ import FirstRunWizard from './components/FirstRunWizard'
|
|||||||
import { api } from './lib/api'
|
import { api } from './lib/api'
|
||||||
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
import { EventStreamProvider, useStrom } from './lib/useEventStream'
|
||||||
import { BetriebProvider } from './lib/useBetrieb'
|
import { BetriebProvider } from './lib/useBetrieb'
|
||||||
|
import { Fehlergrenze } from './components/Fehlergrenze'
|
||||||
|
|
||||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||||
|
|
||||||
@@ -161,10 +162,19 @@ function AppInhalt() {
|
|||||||
|
|
||||||
{/* Main Content Viewport */}
|
{/* Main Content Viewport */}
|
||||||
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{currentPage === 'dashboard' && <Dashboard />}
|
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
|
||||||
{currentPage === 'anleitung' && <AnleitungPage />}
|
Navigation stehen und man kommt woanders hin. Ohne das war am
|
||||||
{currentPage === 'logs' && <LogsPage />}
|
29.08.2026 nach einem Klick auf „Rippen starten" der ganze
|
||||||
{currentPage === 'settings' && <SettingsPage />}
|
Bildschirm leer — samt Kopfzeile. */}
|
||||||
|
<Fehlergrenze bereich={
|
||||||
|
currentPage === 'dashboard' ? 'Das Dashboard'
|
||||||
|
: currentPage === 'anleitung' ? 'Die Anleitung'
|
||||||
|
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
|
||||||
|
{currentPage === 'dashboard' && <Dashboard />}
|
||||||
|
{currentPage === 'anleitung' && <AnleitungPage />}
|
||||||
|
{currentPage === 'logs' && <LogsPage />}
|
||||||
|
{currentPage === 'settings' && <SettingsPage />}
|
||||||
|
</Fehlergrenze>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -178,10 +188,12 @@ export default function App() {
|
|||||||
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
|
||||||
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
// compose ps" — einen Rat, den dort niemand befolgen kann.
|
||||||
return (
|
return (
|
||||||
<BetriebProvider>
|
<Fehlergrenze>
|
||||||
<EventStreamProvider>
|
<BetriebProvider>
|
||||||
<AppInhalt />
|
<EventStreamProvider>
|
||||||
</EventStreamProvider>
|
<AppInhalt />
|
||||||
</BetriebProvider>
|
</EventStreamProvider>
|
||||||
|
</BetriebProvider>
|
||||||
|
</Fehlergrenze>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface Device {
|
|||||||
serial?: string
|
serial?: string
|
||||||
model?: string
|
model?: string
|
||||||
disc?: DiscInfo
|
disc?: DiscInfo
|
||||||
|
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
|
||||||
|
disc_wird_erkannt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function posterUrl(disc?: DiscInfo): string | null {
|
function posterUrl(disc?: DiscInfo): string | null {
|
||||||
@@ -179,6 +181,34 @@ export default function DeviceDiscovery() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{/*
|
||||||
|
Die Erkennung LÄUFT NOCH — das muss man sehen.
|
||||||
|
|
||||||
|
Commander 29.08.2026: „das die disc erkennung noch läuft muss
|
||||||
|
sichtbar sein". An einer Blu-ray dauert sie rund zwei Minuten
|
||||||
|
(`makemkvcon info` läuft in seine 120-Sekunden-Grenze; gemessen:
|
||||||
|
Disc nach 119 s erkannt). Vorher war in dieser Zeit NICHTS zu
|
||||||
|
sehen — weder die Disc noch ein Hinweis. Das sah aus wie ein
|
||||||
|
leeres Laufwerk, also wie ein Fehler.
|
||||||
|
*/}
|
||||||
|
{devices.filter(d => d.disc_wird_erkannt).map(device => (
|
||||||
|
<div
|
||||||
|
key={`erkennung-${device.id}`}
|
||||||
|
className="mb-6 rounded-2xl border border-slate-700 bg-slate-900/60 px-5 py-4 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className="animate-spin text-amber-400 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-slate-200">
|
||||||
|
Disc wird gelesen — {device.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Rippy liest Titel und Laufzeiten von der Disc. Das dauert bis
|
||||||
|
zu zwei Minuten; danach steht hier, was drin ist.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
|
||||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Eine Fehlergrenze — damit ein Fehler nicht die ganze Oberfläche verschluckt.
|
||||||
|
*
|
||||||
|
* ## Der Befund des Commanders (29.08.2026)
|
||||||
|
*
|
||||||
|
* > „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||||
|
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||||
|
* > auch keine fehlermeldung."
|
||||||
|
*
|
||||||
|
* Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
*
|
||||||
|
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
*
|
||||||
|
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
|
||||||
|
* 12 %). Nur zeigte die Oberfläche das nie — sie war weg. Grund: React baut
|
||||||
|
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
|
||||||
|
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
|
||||||
|
* einzige Fehlergrenze.
|
||||||
|
*
|
||||||
|
* ## Warum die eigentliche Reparatur woanders sitzt
|
||||||
|
*
|
||||||
|
* Die Ursache war ein Ereignis, das den halben Job schickte
|
||||||
|
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
|
||||||
|
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
|
||||||
|
*
|
||||||
|
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
|
||||||
|
* mitnehmen — und schon gar nicht schweigend. Der nächste Fehler dieser Art
|
||||||
|
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
|
||||||
|
* Oberfläche bleibt bedienbar.
|
||||||
|
*
|
||||||
|
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
|
||||||
|
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
/** Was hier kaputtgehen kann — steht in der Meldung. */
|
||||||
|
bereich?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
fehler: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Fehlergrenze extends Component<Props, State> {
|
||||||
|
state: State = { fehler: null }
|
||||||
|
|
||||||
|
static getDerivedStateFromError(fehler: Error): State {
|
||||||
|
return { fehler }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(fehler: Error, info: ErrorInfo) {
|
||||||
|
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
|
||||||
|
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
|
||||||
|
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
|
||||||
|
fehler, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
neuLaden = () => {
|
||||||
|
this.setState({ fehler: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.fehler) return this.props.children
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
|
||||||
|
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
|
||||||
|
{this.props.bereich
|
||||||
|
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
|
||||||
|
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
|
||||||
|
Der Fehler betrifft nur die Anzeige.{' '}
|
||||||
|
<strong>Laufende Rips gehen weiter</strong> — Rippy arbeitet im
|
||||||
|
Hintergrund, auch wenn dieser Bereich leer bleibt.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
|
||||||
|
{this.state.fehler.message || String(this.state.fehler)}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={this.neuLaden}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
|
||||||
|
>
|
||||||
|
Nochmal versuchen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
Seite neu laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,7 +45,10 @@ export default function LiveLogSection() {
|
|||||||
<CardTitle>Live-Log</CardTitle>
|
<CardTitle>Live-Log</CardTitle>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
{currentJob
|
{currentJob
|
||||||
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
|
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
|
||||||
|
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
|
||||||
|
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
|
||||||
|
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
|
||||||
: 'Kein aktiver Job'}
|
: 'Kein aktiver Job'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Ein Pfadfeld mit „Durchsuchen …" — wie im Installer.
|
||||||
|
*
|
||||||
|
* ## Der Wunsch des Commanders (29.08.2026)
|
||||||
|
*
|
||||||
|
* > „Wenn man im Nachhinein noch die Verzeichnisse ändern will, geht das
|
||||||
|
* > heute nur manuell über die einstellungen. Der Durchsuchen button fehlt.
|
||||||
|
* > Wie es der Installer auch macht"
|
||||||
|
*
|
||||||
|
* Er hat recht, und der Unterschied ist größer als ein Knopf: Im Setup wählt
|
||||||
|
* man den Ordner, danach musste man ihn abtippen. Einen Pfad wie
|
||||||
|
* `\\192.168.179.62\rippy\movies` tippt niemand zweimal richtig.
|
||||||
|
*
|
||||||
|
* ## Warum eine eigene Datei
|
||||||
|
*
|
||||||
|
* Weil es der DRITTE Ordner-Browser in diesem Projekt gewesen wäre:
|
||||||
|
* `RipTargetModal` hat einen, `StorageMounts` hat einen. Beide sind fest in
|
||||||
|
* ihre Karte eingebaut. Diesen hier kann jedes Feld benutzen.
|
||||||
|
*
|
||||||
|
* Der Browser selbst ist die API — `/browse` weiß seit dem 28.08.2026, in
|
||||||
|
* welchem Betrieb es läuft: im Container die Medien-Wurzel, nativ die Liste
|
||||||
|
* der Laufwerke. Diese Komponente muss darüber nichts wissen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { ArrowUp, Folder, FolderOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { Modal } from './ui/Modal'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
import { Input } from './ui/Input'
|
||||||
|
|
||||||
|
interface Eintrag {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
onChange: (pfad: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrdnerWaehler({ label, value, placeholder, onChange }: Props) {
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const [pfad, setPfad] = useState('')
|
||||||
|
const [eltern, setEltern] = useState<string | null>(null)
|
||||||
|
const [ordner, setOrdner] = useState<Eintrag[]>([])
|
||||||
|
const [fehler, setFehler] = useState('')
|
||||||
|
const [laedt, setLaedt] = useState(false)
|
||||||
|
|
||||||
|
const laden = async (ziel: string) => {
|
||||||
|
setLaedt(true)
|
||||||
|
setFehler('')
|
||||||
|
try {
|
||||||
|
const r = await api.get('/browse', { params: { path: ziel } })
|
||||||
|
setPfad(r.data.path || '')
|
||||||
|
setEltern(r.data.parent)
|
||||||
|
setOrdner(Array.isArray(r.data.dirs) ? r.data.dirs : [])
|
||||||
|
} catch (e: any) {
|
||||||
|
// Ein nicht lesbarer Ordner ist kein Grund, den Dialog zu schließen —
|
||||||
|
// man kommt mit „nach oben" wieder heraus.
|
||||||
|
setFehler(e?.response?.data?.detail || 'Ordner nicht lesbar')
|
||||||
|
setOrdner([])
|
||||||
|
} finally {
|
||||||
|
setLaedt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offen) return
|
||||||
|
// Beim eingetragenen Wert einsteigen, sonst ganz oben. Leerer Pfad heißt
|
||||||
|
// für /browse „oberste Ebene" — nativ also die Laufwerksliste.
|
||||||
|
laden(value || '')
|
||||||
|
}, [offen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => setOffen(true)}>
|
||||||
|
Durchsuchen …
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={offen}
|
||||||
|
onClose={() => setOffen(false)}
|
||||||
|
title={label}
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||||
|
<button
|
||||||
|
onClick={() => eltern !== null && laden(eltern)}
|
||||||
|
disabled={eltern === null}
|
||||||
|
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||||
|
title="Eine Ebene höher"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-600 dark:text-slate-300">
|
||||||
|
{pfad || 'Laufwerke'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-xl border border-slate-200 dark:border-slate-800 p-1">
|
||||||
|
{laedt && (
|
||||||
|
<p className="px-3 py-2 text-xs text-slate-400">wird gelesen …</p>
|
||||||
|
)}
|
||||||
|
{!laedt && fehler && (
|
||||||
|
<p className="px-3 py-2 text-xs text-amber-600 dark:text-amber-400">{fehler}</p>
|
||||||
|
)}
|
||||||
|
{!laedt && !fehler && ordner.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-slate-400">Keine Unterordner.</p>
|
||||||
|
)}
|
||||||
|
{ordner.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.path}
|
||||||
|
onClick={() => laden(o.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Folder size={14} className="text-slate-400" />
|
||||||
|
{o.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
||||||
|
<FolderOpen size={13} />
|
||||||
|
Ein Netzwerkpfad lässt sich auch direkt ins Feld schreiben.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setOffen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
disabled={!pfad}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(pfad)
|
||||||
|
setOffen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diesen Ordner nutzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
|
||||||
|
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
|
||||||
|
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
|
||||||
|
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
|
||||||
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
|
||||||
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
|
||||||
label: type.toUpperCase(),
|
label: type ? type.toUpperCase() : 'DISC',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+144
-141
@@ -1,141 +1,144 @@
|
|||||||
/*
|
/*
|
||||||
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
* Worauf läuft Rippy — und was kann dieser Betrieb überhaupt?
|
||||||
*
|
*
|
||||||
* ## Der Befund des Commanders (28.08.2026)
|
* ## Der Befund des Commanders (28.08.2026)
|
||||||
*
|
*
|
||||||
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
* Im Windows-Fenster stand auf der Server-Status-Kachel:
|
||||||
*
|
*
|
||||||
* Worker erreichbar: 0 von 1
|
* Worker erreichbar: 0 von 1
|
||||||
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
* Kein Worker antwortet — ohne ihn läuft kein Rip. Prüfen: docker compose ps
|
||||||
* Container-Platte: unbekannt
|
* Container-Platte: unbekannt
|
||||||
* Freigaben: keine eingehängt
|
* Freigaben: keine eingehängt
|
||||||
*
|
*
|
||||||
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
|
||||||
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
* Container, kein `docker compose`, keinen zweiten Worker — Rippy rippt dort
|
||||||
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
* selbst. Sein Urteil: „Du hast ja quasi nur rippy genommen und die docker
|
||||||
* installation für Windows gebaut."
|
* installation für Windows gebaut."
|
||||||
*
|
*
|
||||||
* ## Warum ein Provider und kein Abruf je Seite
|
* ## Warum ein Provider und kein Abruf je Seite
|
||||||
*
|
*
|
||||||
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
|
||||||
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
* dasselbe abfragen, sind vier Abrufe — und vier Gelegenheiten, dass eine
|
||||||
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
|
||||||
* Laufzeit nicht; er wird EINMAL geholt.
|
* Laufzeit nicht; er wird EINMAL geholt.
|
||||||
*
|
*
|
||||||
* ## Warum die Vorgabe „Docker" ist
|
* ## Warum die Vorgabe „Docker" ist
|
||||||
*
|
*
|
||||||
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
|
||||||
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
|
* Docker-Annahme ist hier die richtige Vorgabe — sie zeigt MEHR, und ein kurz
|
||||||
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
|
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
|
||||||
* einen Augenblick verschwindet und wieder auftaucht.
|
* einen Augenblick verschwindet und wieder auftaucht.
|
||||||
*
|
*
|
||||||
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als „noch unterwegs":
|
||||||
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
|
||||||
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
|
||||||
*/
|
*/
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
|
|
||||||
export interface BetriebsFaehigkeiten {
|
export interface BetriebsFaehigkeiten {
|
||||||
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
/** Gibt es andere Maschinen, die Jobs übernehmen? */
|
||||||
externe_worker: boolean
|
externe_worker: boolean
|
||||||
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
|
||||||
freigaben_einhaengen: boolean
|
freigaben_einhaengen: boolean
|
||||||
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
/** Sind Pfade wie /app/media überhaupt gemeint? */
|
||||||
container_pfade: boolean
|
container_pfade: boolean
|
||||||
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
|
||||||
werkzeuge_verwalten: boolean
|
werkzeuge_verwalten: boolean
|
||||||
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
|
||||||
frei_blaettern: boolean
|
frei_blaettern: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Betrieb {
|
export interface Betrieb {
|
||||||
modus: 'standalone' | 'verteilt'
|
modus: 'standalone' | 'verteilt'
|
||||||
plattform: 'windows' | 'linux' | 'macos'
|
plattform: 'windows' | 'linux' | 'macos'
|
||||||
im_container: boolean
|
im_container: boolean
|
||||||
kann: BetriebsFaehigkeiten
|
kann: BetriebsFaehigkeiten
|
||||||
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
|
||||||
ablage_vorgabe: string
|
ablage_vorgabe: string
|
||||||
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
|
||||||
arbeits_vorgabe: string
|
arbeits_vorgabe: string
|
||||||
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
|
||||||
* ausführen könnte — dann darf auch keiner dastehen. */
|
* ausführen könnte — dann darf auch keiner dastehen. */
|
||||||
hilfe_befehl: string
|
hilfe_befehl: string
|
||||||
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
/** Ist die Auskunft schon da? False heißt „noch unterwegs ODER nicht
|
||||||
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
* erreichbar" — nicht „es gibt keinen Betrieb". */
|
||||||
geladen: boolean
|
geladen: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const VORGABE: Betrieb = {
|
const VORGABE: Betrieb = {
|
||||||
modus: 'verteilt',
|
modus: 'verteilt',
|
||||||
plattform: 'linux',
|
plattform: 'linux',
|
||||||
im_container: true,
|
im_container: true,
|
||||||
kann: {
|
kann: {
|
||||||
externe_worker: true,
|
externe_worker: true,
|
||||||
freigaben_einhaengen: true,
|
freigaben_einhaengen: true,
|
||||||
container_pfade: true,
|
container_pfade: true,
|
||||||
werkzeuge_verwalten: false,
|
werkzeuge_verwalten: false,
|
||||||
frei_blaettern: false,
|
frei_blaettern: false,
|
||||||
},
|
},
|
||||||
ablage_vorgabe: '/app/media',
|
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
|
||||||
arbeits_vorgabe: '/app/temp',
|
// Ort nicht. Ein leeres Feld sagt das; „/app/media" behauptet etwas, das
|
||||||
hilfe_befehl: 'docker compose -p rippy ps',
|
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
|
||||||
geladen: false,
|
ablage_vorgabe: '',
|
||||||
}
|
arbeits_vorgabe: '',
|
||||||
|
hilfe_befehl: 'docker compose -p rippy ps',
|
||||||
const BetriebContext = createContext<Betrieb>(VORGABE)
|
geladen: false,
|
||||||
|
}
|
||||||
export function BetriebProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
const BetriebContext = createContext<Betrieb>(VORGABE)
|
||||||
|
|
||||||
useEffect(() => {
|
export function BetriebProvider({ children }: { children: ReactNode }) {
|
||||||
let abgemeldet = false
|
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
|
||||||
api.get('/betrieb')
|
|
||||||
.then(antwort => {
|
useEffect(() => {
|
||||||
const d = antwort.data
|
let abgemeldet = false
|
||||||
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
api.get('/betrieb')
|
||||||
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
.then(antwort => {
|
||||||
// verschwände ein Bereich stillschweigend.
|
const d = antwort.data
|
||||||
if (abgemeldet || !d || !d.kann) return
|
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
|
||||||
setBetrieb({
|
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
|
||||||
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
// verschwände ein Bereich stillschweigend.
|
||||||
plattform: d.plattform || 'linux',
|
if (abgemeldet || !d || !d.kann) return
|
||||||
im_container: !!d.im_container,
|
setBetrieb({
|
||||||
kann: {
|
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
|
||||||
externe_worker: !!d.kann.externe_worker,
|
plattform: d.plattform || 'linux',
|
||||||
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
im_container: !!d.im_container,
|
||||||
container_pfade: !!d.kann.container_pfade,
|
kann: {
|
||||||
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
externe_worker: !!d.kann.externe_worker,
|
||||||
frei_blaettern: !!d.kann.frei_blaettern,
|
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
|
||||||
},
|
container_pfade: !!d.kann.container_pfade,
|
||||||
ablage_vorgabe: d.ablage_vorgabe || '',
|
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
|
||||||
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
frei_blaettern: !!d.kann.frei_blaettern,
|
||||||
hilfe_befehl: d.hilfe_befehl || '',
|
},
|
||||||
geladen: true,
|
ablage_vorgabe: d.ablage_vorgabe || '',
|
||||||
})
|
arbeits_vorgabe: d.arbeits_vorgabe || '',
|
||||||
})
|
hilfe_befehl: d.hilfe_befehl || '',
|
||||||
.catch(() => {
|
geladen: true,
|
||||||
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
})
|
||||||
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
})
|
||||||
})
|
.catch(() => {
|
||||||
return () => { abgemeldet = true }
|
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
|
||||||
}, [])
|
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
|
||||||
|
})
|
||||||
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
return () => { abgemeldet = true }
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
export function useBetrieb(): Betrieb {
|
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
|
||||||
return useContext(BetriebContext)
|
}
|
||||||
}
|
|
||||||
|
export function useBetrieb(): Betrieb {
|
||||||
/**
|
return useContext(BetriebContext)
|
||||||
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
|
}
|
||||||
* (Windows-Client oder Docker-All-in-One)?
|
|
||||||
*
|
/**
|
||||||
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
|
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
|
||||||
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
|
* (Windows-Client oder Docker-All-in-One)?
|
||||||
* die nächste falsche Annahme ein.
|
*
|
||||||
*/
|
* Bewusst NICHT „ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
|
||||||
export function useAlleinbetrieb(): boolean {
|
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
|
||||||
return !useBetrieb().kann.externe_worker
|
* die nächste falsche Annahme ein.
|
||||||
}
|
*/
|
||||||
|
export function useAlleinbetrieb(): boolean {
|
||||||
|
return !useBetrieb().kann.externe_worker
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ interface Job {
|
|||||||
|
|
||||||
interface SystemInfo {
|
interface SystemInfo {
|
||||||
api_version: string
|
api_version: string
|
||||||
plaetze: { name: string; frei_gb: number; gesamt_gb: number }[]
|
plaetze: { name: string; pfad?: string; gleiches_laufwerk?: boolean;
|
||||||
|
frei_gb: number; gesamt_gb: number }[]
|
||||||
workers: { name: string; encoders: string[] }[]
|
workers: { name: string; encoders: string[] }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ interface LaufwerkLive {
|
|||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
disc?: { title?: string, year?: number | null, disc_type?: string }
|
disc?: { title?: string, year?: number | null, disc_type?: string }
|
||||||
|
/** Die Erkennung laeuft noch — es gibt noch keinen Titel, aber auch kein
|
||||||
|
* leeres Laufwerk. Dauert an einer Blu-ray rund zwei Minuten. */
|
||||||
|
disc_wird_erkannt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
// Ein Ablageziel aus GET /storage-targets — Verzeichnisse unter /app/media
|
||||||
@@ -328,7 +332,13 @@ export default function Dashboard() {
|
|||||||
: 'Rip läuft (MakeMKV, verlustfrei)')
|
: 'Rip läuft (MakeMKV, verlustfrei)')
|
||||||
: discImLaufwerk
|
: discImLaufwerk
|
||||||
? 'Disc erkannt — wartet auf „Rippen starten"'
|
? 'Disc erkannt — wartet auf „Rippen starten"'
|
||||||
: 'Bereit — kein Datenträger im Laufwerk'
|
// Zwischen „Disc eingelegt" und „erkannt" liegen rund zwei Minuten
|
||||||
|
// (makemkvcon info laeuft in seine 120-Sekunden-Grenze). Ohne diese
|
||||||
|
// Zeile stand dort „kein Datentraeger", und das sah aus wie ein
|
||||||
|
// Fehler (Commander 29.08.2026).
|
||||||
|
: laufwerke.some(l => l.disc_wird_erkannt)
|
||||||
|
? 'Disc wird gelesen — das dauert bis zu zwei Minuten'
|
||||||
|
: 'Bereit — kein Datenträger im Laufwerk'
|
||||||
|
|
||||||
const filteredJobs = jobs.filter(j => {
|
const filteredJobs = jobs.filter(j => {
|
||||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||||
@@ -584,6 +594,38 @@ export default function Dashboard() {
|
|||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
Abbrechen DIREKT in der Zeile (Commander
|
||||||
|
29.08.2026: „man kann einen rip garnicht
|
||||||
|
abbrechen").
|
||||||
|
|
||||||
|
Es gab genau einen Abbrechen-Knopf, und der
|
||||||
|
steckte in der Kachel „laufender Job". Die
|
||||||
|
erschien nur bei Status `processing` — der
|
||||||
|
Ereignisstrom lieferte aber den rohen
|
||||||
|
DB-Status `running`. Also keine Kachel, also
|
||||||
|
kein Knopf, also kein Weg zurueck.
|
||||||
|
Die Ursache ist behoben; ein zweiter Weg zum
|
||||||
|
Abbrechen ist trotzdem richtig, denn genau
|
||||||
|
hier sucht man ihn.
|
||||||
|
*/}
|
||||||
|
{['processing', 'transcoding', 'pending'].includes(job.status) && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => api.post(`/jobs/${job.id}/cancel`)
|
||||||
|
.then(() => toast('info', 'Abbruch angefordert — der Rip haelt beim naechsten Zwischenschritt an.'))
|
||||||
|
.catch((e: any) => toast('error',
|
||||||
|
e?.response?.data?.detail || 'Abbrechen ging nicht'))}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{job.status === 'canceling' && (
|
||||||
|
<span className="text-xs font-mono text-slate-400">bricht ab …</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{job.status === 'completed' && (
|
{job.status === 'completed' && (
|
||||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
<Download size={13} /> Download
|
<Download size={13} /> Download
|
||||||
@@ -749,10 +791,45 @@ export default function Dashboard() {
|
|||||||
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
wollte, ob die NAS noch Platz für eine Disc hat, sah die
|
||||||
falsche Zahl (Commander-Befund 26.07.2026). */}
|
falsche Zahl (Commander-Befund 26.07.2026). */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
{/*
|
||||||
|
Jeder Ort mit seinem PFAD (Commander 29.08.2026): „bei
|
||||||
|
‚Platz für rippy' sollte eher das arbeitsverzeichnis und
|
||||||
|
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
|
||||||
|
für den ERSTEN Ort — welcher Ordner das war, sah man nicht.
|
||||||
|
*/}
|
||||||
|
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
|
||||||
|
<div key={ort.name + ort.pfad} className="mb-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
|
{ort.name}:
|
||||||
|
</span>
|
||||||
|
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
|
||||||
|
? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
|
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ort.pfad && (
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
|
||||||
|
{ort.pfad}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!betrieb.kann.container_pfade
|
||||||
|
&& (systemInfo?.plaetze || []).length > 1
|
||||||
|
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
|
||||||
|
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist —
|
||||||
|
statt die zweite Zeile wegzulassen. */
|
||||||
|
<p className="text-[11px] font-mono text-slate-500 mb-1">
|
||||||
|
Beide auf demselben Laufwerk — der Platz wird geteilt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
|
||||||
|
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
|
||||||
<span className="text-slate-400 flex items-center gap-1.5">
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
<HardDrive size={14} className="text-amber-400" />
|
<HardDrive size={14} className="text-amber-400" />
|
||||||
{betrieb.kann.container_pfade ? 'Container-Platte:' : 'Platz für Rippy:'}
|
Container-Platte:
|
||||||
</span>
|
</span>
|
||||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { PageHeader } from '../components/ui/PageHeader'
|
|||||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
import { Button } from '../components/ui/Button'
|
import { Button } from '../components/ui/Button'
|
||||||
import { Input, Select, Toggle } from '../components/ui/Input'
|
import { Input, Select, Toggle } from '../components/ui/Input'
|
||||||
|
import { OrdnerWaehler } from '../components/OrdnerWaehler'
|
||||||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
|
||||||
@@ -213,6 +214,9 @@ export default function SettingsPage() {
|
|||||||
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
|
||||||
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
|
||||||
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
const [ziele, setZiele] = useState<StorageZiel[]>([])
|
||||||
|
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
|
||||||
|
const [keyHolt, setKeyHolt] = useState(false)
|
||||||
|
const [keyMeldung, setKeyMeldung] = useState('')
|
||||||
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
|
||||||
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
const [keystoreBusy, setKeystoreBusy] = useState(false)
|
||||||
const keystoreInput = useRef<HTMLInputElement>(null)
|
const keystoreInput = useRef<HTMLInputElement>(null)
|
||||||
@@ -253,6 +257,43 @@ export default function SettingsPage() {
|
|||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
|
||||||
|
*
|
||||||
|
* Der geholte Key wird nicht angezeigt — er wandert in die Einstellungen,
|
||||||
|
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
|
||||||
|
* zwei Wege, ihn zu verlieren.
|
||||||
|
*/
|
||||||
|
const keyJetztHolen = async () => {
|
||||||
|
setKeyHolt(true)
|
||||||
|
setKeyMeldung('')
|
||||||
|
try {
|
||||||
|
const r = await api.post('/system/makemkv-key/holen')
|
||||||
|
const antwort = await api.get('/settings')
|
||||||
|
setSettings({ ...defaultSettings, ...antwort.data })
|
||||||
|
if (r.data?.angenommen === false) {
|
||||||
|
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
|
||||||
|
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
|
||||||
|
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
|
||||||
|
// trotzdem.
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
|
||||||
|
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
|
||||||
|
} else {
|
||||||
|
setKeyMeldung(r.data?.geaendert
|
||||||
|
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
|
||||||
|
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
|
||||||
|
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
const grund = e?.response?.data?.detail
|
||||||
|
|| 'Das Forum ist nicht erreichbar.'
|
||||||
|
setKeyMeldung('Fehlgeschlagen: ' + grund)
|
||||||
|
toast('error', grund)
|
||||||
|
} finally {
|
||||||
|
setKeyHolt(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const keydbHochladen = async (datei: File) => {
|
const keydbHochladen = async (datei: File) => {
|
||||||
setKeydbBusy(true)
|
setKeydbBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -960,11 +1001,11 @@ export default function SettingsPage() {
|
|||||||
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
* Liste kennt — D:\Rippy-Arbeit oder eine UNC-Freigabe.
|
||||||
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
* Deshalb hier ein Feld, in das man ihn schreiben kann.
|
||||||
*/
|
*/
|
||||||
<Input
|
<OrdnerWaehler
|
||||||
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
|
||||||
value={settings.workDir}
|
value={settings.workDir}
|
||||||
placeholder={betrieb.arbeits_vorgabe}
|
placeholder={betrieb.arbeits_vorgabe}
|
||||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
onChange={(pfad) => handleChange('workDir', pfad)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
@@ -1057,11 +1098,11 @@ export default function SettingsPage() {
|
|||||||
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
der man waehlen koennte — es gibt Laufwerke, Ordner und
|
||||||
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
|
||||||
Bedienung, die nichts bedient. */
|
Bedienung, die nichts bedient. */
|
||||||
<Input
|
<OrdnerWaehler
|
||||||
label="Ordner"
|
label="Ordner"
|
||||||
value={settings.outputDir}
|
value={settings.outputDir}
|
||||||
placeholder={betrieb.ablage_vorgabe}
|
placeholder={betrieb.ablage_vorgabe}
|
||||||
onChange={(e) => handleChange('outputDir', e.target.value)}
|
onChange={(pfad) => handleChange('outputDir', pfad)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
@@ -1295,13 +1336,44 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Input
|
{/*
|
||||||
type="password"
|
Der Beta-Key holt sich selbst — man sah es nur nirgends.
|
||||||
label="MakeMKV Beta-Key"
|
|
||||||
value={settings.makemkvAppKey}
|
Commander am 28.08.2026: „Bezüglich des MKV Beta Keys — der
|
||||||
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
könnte theoretisch auch automatisch ausgelesen werden, ich
|
||||||
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
glaube das web rippy kann das." Er hatte recht:
|
||||||
/>
|
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
|
||||||
|
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
|
||||||
|
Zeichen). Hier stand aber nur ein Passwortfeld — ob der Wert
|
||||||
|
darin von Hand kam oder von selbst, war nicht zu erkennen.
|
||||||
|
Eine Automatik, die man nicht sehen kann, ist für den
|
||||||
|
Benutzer keine.
|
||||||
|
*/}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="MakeMKV Beta-Key"
|
||||||
|
value={settings.makemkvAppKey}
|
||||||
|
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
|
||||||
|
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={keyHolt}
|
||||||
|
onClick={keyJetztHolen}
|
||||||
|
>
|
||||||
|
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
|
||||||
|
</Button>
|
||||||
|
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'}`}>
|
||||||
|
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
|
||||||
|
+ 'der Key wechselt etwa monatlich.'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
Schlüsselspeicher — der Hauptweg für 4K-UHD (Befund 25.07.2026,
|
||||||
|
|||||||
+161
-35
@@ -47,7 +47,7 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from rippy import store as db
|
from rippy import store as db
|
||||||
from rippy.rip import makemkv_daten
|
from rippy.rip import makemkv_aufruf, makemkv_daten
|
||||||
import medien
|
import medien
|
||||||
from rippy.core import notify
|
from rippy.core import notify
|
||||||
|
|
||||||
@@ -157,7 +157,7 @@ def unter_wurzel(pfad: str, wurzel: str) -> bool:
|
|||||||
return pfad == sauber or pfad.startswith(sauber + "/")
|
return pfad == sauber or pfad.startswith(sauber + "/")
|
||||||
|
|
||||||
|
|
||||||
def _zielbasis(target_dir, disc_type: str) -> str:
|
def _zielbasis(target_dir, disc_type: str, wurzeln=None) -> str:
|
||||||
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
"""Ablagebasis: vom Nutzer gewähltes Ziel (validiert) oder Standard.
|
||||||
|
|
||||||
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
posixpath statt os.path — aus demselben Grund wie in _arbeitsverzeichnis:
|
||||||
@@ -167,16 +167,70 @@ def _zielbasis(target_dir, disc_type: str) -> str:
|
|||||||
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
als der Test dafür erstmals unter Windows lief. Live war es nie: aufgerufen
|
||||||
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
wird nur aus rip_disc, und das ist auf Windows-Workern verriegelt.
|
||||||
"""
|
"""
|
||||||
|
wurzel, _vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
if target_dir:
|
if target_dir:
|
||||||
normalisiert = posixpath.normpath(target_dir)
|
normalisiert = _normalisiert(target_dir)
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
return normalisiert
|
return normalisiert
|
||||||
return posixpath.join(RIP_OUTPUT_DIR, disc_type)
|
# Ohne Wahl: die Wurzel dieses Betriebs, nicht die des Containers. Sonst
|
||||||
|
# landete die fertige Datei unter Windows in `X:\app\media\bluray` —
|
||||||
|
# einem Ordner, den niemand gesucht hat (gemessen 29.08.2026).
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return pfade.verbinden(wurzel if frei else RIP_OUTPUT_DIR, disc_type)
|
||||||
|
|
||||||
|
|
||||||
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
def _betriebs_wurzeln() -> tuple:
|
||||||
|
"""`(medien_wurzel, arbeits_vorgabe, frei)` für DIESEN Betrieb.
|
||||||
|
|
||||||
|
## Warum das hier gebraucht wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch —
|
||||||
|
und die Rohdaten landeten in **`F:\\app\\temp\\raw`**. Also in einem
|
||||||
|
Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.
|
||||||
|
|
||||||
|
`RAW_DIR` ist `/app/temp/raw` und `MEDIA_ROOT` ist `/app/media`; unter
|
||||||
|
Windows sind das keine Pfade, sondern Unfälle. Schlimmer noch: Die
|
||||||
|
Prüfung `unter_wurzel(wahl, MEDIA_ROOT)` verwarf **auch eine ausdrückliche
|
||||||
|
Wahl** — ein Arbeitsordner wie `D:\\Roh` liegt nicht unter `/app/media`,
|
||||||
|
also fiel er still auf den Container-Standard zurück.
|
||||||
|
|
||||||
|
Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdrücklich
|
||||||
|
bestellt hat („kannst du noch einbauen das man den arbeitsordner … setzen
|
||||||
|
kann"), unter Windows nie an. Der Dialog zeigte ihn, das Setzen ging, und
|
||||||
|
der Worker ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
Im Container ändert sich nichts: Dort ist `/app/media` die Wurzel, und
|
||||||
|
`frei` ist falsch.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return (betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
|
betrieb.arbeits_vorgabe(werte) or RAW_DIR,
|
||||||
|
betrieb.frei_blaettern(werte))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalisiert(wert: str) -> str:
|
||||||
|
"""Pfad säubern — nach seiner FORM, nicht nach dem laufenden Rechner.
|
||||||
|
|
||||||
|
`posixpath` für Container-Pfade (sonst macht Windows Backslashes daraus
|
||||||
|
und die Wurzelprüfung greift nicht mehr), `os.path` für echte
|
||||||
|
Windows-Pfade. Dieselbe Regel wie in `rippy.pfade`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
|
return os.path.normpath(wert) if pfade.ist_windows_pfad(wert) \
|
||||||
|
else posixpath.normpath(wert)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
||||||
|
wurzeln=None) -> str:
|
||||||
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
"""Basis für Roh-Rips. Reihenfolge: Wahl DIESES Rips → UI-Setting
|
||||||
`workDir` → Container-Default /app/temp/raw.
|
`workDir` → Vorgabe dieses Betriebs (Container: /app/temp/raw).
|
||||||
|
|
||||||
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
Hintergrund (Commander 24.07.): Die VM-Platte (150 GB) reicht für BD-50,
|
||||||
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
aber eine 4K-UHD (bis 100 GB roh + Kompression daneben) sprengt sie —
|
||||||
@@ -187,26 +241,48 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "") -> str:
|
|||||||
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
Standard — und ist damit der Wert, der bei Vollautomatik-Rips greift, bei
|
||||||
denen niemand gefragt wird.
|
denen niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
# posixpath statt os.path: Das sind IMMER Container-Pfade (/app/media/...),
|
wurzel, vorgabe, frei = wurzeln or _betriebs_wurzeln()
|
||||||
# auch wenn ein nativer Windows-Worker dieses Modul lädt — der übersetzt
|
|
||||||
# sie erst später mit pfad_lokal(). os.path.normpath macht unter Windows
|
|
||||||
# Backslashes daraus, und dann greift die MEDIA_ROOT-Prüfung nicht mehr.
|
|
||||||
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
for kandidat in (job_wahl, einstellungen.get("workDir")):
|
||||||
wert = (kandidat or "").strip()
|
wert = (kandidat or "").strip()
|
||||||
if wert:
|
if not wert:
|
||||||
normalisiert = posixpath.normpath(wert)
|
continue
|
||||||
if unter_wurzel(normalisiert, MEDIA_ROOT):
|
normalisiert = _normalisiert(wert)
|
||||||
return normalisiert
|
# Nativ zählt die Wahl des Nutzers — dort IST sein Laufwerk die
|
||||||
return RAW_DIR
|
# Grenze. Im Container bleibt die Wurzelprüfung: Ein Pfad ausserhalb
|
||||||
|
# von /app/media wäre dort ein Pfad ins Nichts.
|
||||||
|
if frei or unter_wurzel(normalisiert, wurzel):
|
||||||
|
return normalisiert
|
||||||
|
return vorgabe
|
||||||
|
|
||||||
|
|
||||||
def _frei_bytes(pfad: str) -> int:
|
def _frei_bytes(pfad: str) -> int:
|
||||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||||||
kandidat = pfad
|
|
||||||
while kandidat and not os.path.exists(kandidat):
|
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
|
||||||
kandidat = os.path.dirname(kandidat)
|
|
||||||
|
An dieser Stelle stand sie als eigene Schleife:
|
||||||
|
|
||||||
|
while kandidat and not os.path.exists(kandidat):
|
||||||
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
|
||||||
|
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an
|
||||||
|
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
|
||||||
|
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
|
||||||
|
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
|
||||||
|
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
|
||||||
|
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
|
||||||
|
Meldung stehen, und im Protokoll stuende nichts, womit man das
|
||||||
|
aufklären koennte.
|
||||||
|
|
||||||
|
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit
|
||||||
|
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
|
||||||
|
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
|
||||||
|
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return shutil.disk_usage(kandidat or "/").free
|
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||||||
except OSError:
|
except OSError:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -243,17 +319,27 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
|
|||||||
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
key = (einstellungen.get("makemkvAppKey") or "").strip()
|
||||||
if not key:
|
if not key:
|
||||||
return
|
return
|
||||||
pfad = os.path.join(makemkv_daten.DATEN_DIR, "settings.conf")
|
|
||||||
try:
|
try:
|
||||||
os.makedirs(makemkv_daten.DATEN_DIR, exist_ok=True)
|
# WOHIN, entscheidet der Rechner — unter Windows die Registry, sonst
|
||||||
try:
|
# die settings.conf. Begruendung und Messung in
|
||||||
with open(pfad, encoding="utf-8", errors="replace") as f:
|
# `rippy.rip.makemkv_aufruf.key_ablegen`.
|
||||||
alt = f.read()
|
#
|
||||||
except OSError:
|
# UND nachsehen, ob MakeMKV ihn annimmt: Ein abgelehnter Schluessel
|
||||||
alt = ""
|
# ist schlimmer als gar keiner — ohne las MakeMKV die Disc noch, mit
|
||||||
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
# einem abgelehnten verweigerte es alles (gemessen 29.08.2026). Die
|
||||||
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
# paar Sekunden Rueckfrage sind nichts gegen einen Rip, der Stunden
|
||||||
except OSError as e:
|
# laeuft und sonst am ersten Titel scheitert.
|
||||||
|
from ripping import werkzeug
|
||||||
|
|
||||||
|
# Dasselbe, was `docker/worker/entrypoint.sh` vor jedem Worker-Start
|
||||||
|
# tut — nur in der Windows-Form.
|
||||||
|
makemkv_aufruf.einstellungen_sicherstellen()
|
||||||
|
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
|
||||||
|
if not urteil["ok"]:
|
||||||
|
db.add_log("warning", "worker",
|
||||||
|
"MakeMKV nimmt den hinterlegten Beta-Key nicht an: %s"
|
||||||
|
% urteil["grund"])
|
||||||
|
except (OSError, ImportError) as e:
|
||||||
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
db.add_log("warning", "worker", f"MakeMKV-Key konnte nicht gesetzt werden: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -547,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||||
return
|
return
|
||||||
gesehen.add(text)
|
gesehen.add(text)
|
||||||
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
|
||||||
|
# sind uebersetzt, die Nummern nicht — sie sind die einzige
|
||||||
|
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
|
||||||
|
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
|
||||||
|
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
|
||||||
|
# ersatzweise an einer URL im Text festhalten.
|
||||||
|
db.add_log("info", "makemkv",
|
||||||
|
f"Job {job_id}: MSG {code} — {text[:300]}")
|
||||||
|
|
||||||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
@@ -675,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
"Normale BD/DVD gehen weiterhin."
|
"Normale BD/DVD gehen weiterhin."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
|
||||||
|
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
|
||||||
|
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
|
||||||
|
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
|
||||||
|
# eine halbe Disc für eine ganze.
|
||||||
|
if ergebnis.get("lesefehler"):
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
|
||||||
|
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
|
||||||
|
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
|
||||||
|
"von innen nach außen, nicht kreisend) oder ein anderes "
|
||||||
|
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
|
||||||
|
)
|
||||||
|
|
||||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||||
#
|
#
|
||||||
@@ -802,7 +910,8 @@ def erster_vorhandener_ordner(pfad: str, isdir=None, dirname=None) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
||||||
final_container: str, final_lokal: str) -> str:
|
final_container: str, final_lokal: str,
|
||||||
|
fremd=None) -> str:
|
||||||
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
"""Kann DIESER Worker Quelle und Ziel überhaupt sehen? Klartext oder "".
|
||||||
|
|
||||||
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
Befund 25.07.2026 am Job 95afdc89, live: Das gezielte Routing an den
|
||||||
@@ -824,7 +933,20 @@ def _erreichbarkeit_pruefen(raw_container: str, raw_lokal: str,
|
|||||||
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
Quelle — sonst scheitert es erst beim Schreiben, nach Stunden Rechenzeit.
|
||||||
"""
|
"""
|
||||||
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
mapping = os.getenv("RIPPY_PATH_MAP", "")
|
||||||
fremder_worker = not os.path.isdir("/app")
|
# ⚠️ Hier stand `not os.path.isdir("/app")` (Befund 29.08.2026) — dasselbe
|
||||||
|
# Muster wie in `caps.py`. Im Image stimmt es; auf einem Windows-PC gibt
|
||||||
|
# es `/app` nicht, und der eigenstaendige Rippy hielt sich fuer einen
|
||||||
|
# FREMDEN Worker. Er haette sich dann selbst vorgeworfen, die
|
||||||
|
# Container-Pfade nicht zu erreichen — auf einer Maschine ohne Container.
|
||||||
|
#
|
||||||
|
# `fremd` ist einspritzbar, damit BEIDE Faelle ueberall pruefbar sind:
|
||||||
|
# Vorher haing der Test daran, ob es einen Ordner `/app` gibt, und war
|
||||||
|
# damit an den laufenden Rechner gebunden.
|
||||||
|
if fremd is None:
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
fremd = _extern() == "ja"
|
||||||
|
fremder_worker = fremd
|
||||||
|
|
||||||
for zweck, container, lokal in (
|
for zweck, container, lokal in (
|
||||||
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
("die Quelle (Rohdaten)", raw_container, raw_lokal),
|
||||||
@@ -1002,7 +1124,11 @@ def komprimieren(job_id: str, raw_dir: str, final_dir: str):
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"error": (
|
"error": (
|
||||||
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
f"Kompression fehlgeschlagen bei {os.path.basename(quelle)}: "
|
||||||
f"{hb.get('error')} — Roh-Datei bleibt in /app/temp erhalten"
|
# Den ECHTEN Ordner nennen, nicht „/app/temp" (Befund
|
||||||
|
# 29.08.2026). Auf Windows gibt es den nicht, und wer die
|
||||||
|
# Roh-Datei retten will, sucht sonst am falschen Ort.
|
||||||
|
f"{hb.get('error')} — Roh-Datei bleibt unter "
|
||||||
|
f"{os.path.dirname(quelle)} erhalten"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
_job_abschliessen(job_id, ergebnis)
|
_job_abschliessen(job_id, ergebnis)
|
||||||
|
|||||||
+58
-8
@@ -8,13 +8,39 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
|
|||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
import shutil
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
|
||||||
|
def _extern(werte=None, container=None) -> str:
|
||||||
|
"""Läuft Rippy woanders als dieser Worker? „ja"/„nein" (einspritzbar).
|
||||||
|
|
||||||
|
Drei Fälle, und nur einer davon ist „ja":
|
||||||
|
|
||||||
|
* **Im Container** — der Worker ist Teil von Rippy. Nein.
|
||||||
|
* **Nativ und eigenständig** (Windows-App) — dieser Prozess IST Rippy.
|
||||||
|
Nein. Es gibt hier weder Container-Pfade noch eine Freigabe, über die
|
||||||
|
etwas zu übersetzen wäre.
|
||||||
|
* **Nativ und verteilt** — ein Worker auf einem anderen Rechner, der sich
|
||||||
|
bei einem Docker-Rippy meldet. Ja; er braucht RIPPY_PATH_MAP.
|
||||||
|
"""
|
||||||
|
from rippy import betrieb, config
|
||||||
|
|
||||||
|
if container is None:
|
||||||
|
container = betrieb.im_container()
|
||||||
|
if container:
|
||||||
|
return "nein"
|
||||||
|
if werte is None:
|
||||||
|
try:
|
||||||
|
werte = config.laden()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
werte = {}
|
||||||
|
return "ja" if betrieb.modus(werte) == "verteilt" else "nein"
|
||||||
|
|
||||||
|
|
||||||
def _hb() -> str:
|
def _hb() -> str:
|
||||||
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
|
||||||
|
|
||||||
@@ -166,7 +192,7 @@ def hole_handbrake_presets() -> list:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--preset-list"],
|
[_hb(), "--preset-list"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, timeout=30, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -279,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
|
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER
|
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||||
)
|
)
|
||||||
return (aus.stdout or "") + (aus.stderr or "")
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -343,8 +369,18 @@ def werkzeug_versionen() -> dict:
|
|||||||
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
|
||||||
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
|
||||||
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
# den Nutzer eine Stunde rippen zu lassen (Vorfall 25.07.2026).
|
||||||
# /app ist im Rippy-Image immer vorhanden — kein Ratespiel.
|
#
|
||||||
"extern": "nein" if os.path.isdir("/app") else "ja",
|
# ⚠️ Hier stand `os.path.isdir("/app")` (Befund 29.08.2026). Im Image
|
||||||
|
# stimmt das. Auf einem Windows-PC gibt es `/app` nicht — und damit
|
||||||
|
# hielt sich der eigenständige Windows-Rippy für einen FREMDEN Worker.
|
||||||
|
# Folge: Das UI warnte vor fehlender Pfad-Übersetzung auf einer
|
||||||
|
# Maschine, auf der es weder Container noch Freigabe gibt.
|
||||||
|
#
|
||||||
|
# „Extern" heißt: Rippy läuft woanders als dieser Worker. In der
|
||||||
|
# eigenständigen Installation IST dieser Worker Rippy — dort ist die
|
||||||
|
# Frage gegenstandslos. Deshalb entscheidet der Betrieb, nicht ein
|
||||||
|
# Ordnername.
|
||||||
|
"extern": _extern(),
|
||||||
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
# Ist die Pfad-Übersetzung gesetzt? Ohne sie kann ein externer Worker
|
||||||
# grundsätzlich nicht komprimieren.
|
# grundsätzlich nicht komprimieren.
|
||||||
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
|
||||||
@@ -368,7 +404,7 @@ def werkzeug_versionen() -> dict:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--version"],
|
[_hb(), "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, timeout=15, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -402,7 +438,21 @@ def werkzeug_versionen() -> dict:
|
|||||||
daten_dir = makemkv_daten.DATEN_DIR
|
daten_dir = makemkv_daten.DATEN_DIR
|
||||||
except Exception:
|
except Exception:
|
||||||
daten_dir = ""
|
daten_dir = ""
|
||||||
if shutil.which("makemkvcon") and daten_dir and os.path.ismount(daten_dir):
|
# ⚠️ `shutil.which` und `os.path.ismount` sind hier BEIDE Linux-Annahmen
|
||||||
|
# (Befund 29.08.2026): Unter Windows liegt makemkvcon in „Programme" und
|
||||||
|
# nicht im PATH, und ein normaler Ordner ist kein Mount. Beide Prüfungen
|
||||||
|
# waren dort also immer falsch — die Schlüssel-Auskunft blieb dauerhaft
|
||||||
|
# „unbekannt", obwohl MakeMKV samt Datenverzeichnis da war.
|
||||||
|
#
|
||||||
|
# Der Mount-Test bleibt für den Container: Dort teilen sich Rippy und ein
|
||||||
|
# reiner Encoder-Worker dasselbe Image, aber nur einer bekommt den Mount.
|
||||||
|
# Nativ zählt stattdessen, ob der Ordner überhaupt existiert.
|
||||||
|
from rippy import betrieb
|
||||||
|
|
||||||
|
_im_container = betrieb.im_container()
|
||||||
|
daten_da = bool(daten_dir) and (os.path.ismount(daten_dir) if _im_container
|
||||||
|
else os.path.isdir(daten_dir))
|
||||||
|
if werkzeuge.finden("makemkv") and daten_da:
|
||||||
try:
|
try:
|
||||||
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
+298
-18
@@ -37,6 +37,9 @@ from rippy.drives.linux import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||||
|
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
|
||||||
@@ -86,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
|
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||||
|
#:
|
||||||
|
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||||
|
#: deutschsprachiges MakeMKV):
|
||||||
|
#:
|
||||||
|
#: Encountered 29 errors of type 'Read Error' - see
|
||||||
|
#: http://www.makemkv.com/errors/read/
|
||||||
|
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||||
|
#:
|
||||||
|
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||||
|
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||||
|
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||||
|
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||||
|
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||||
|
#: hierher statt dieser Textsuche.
|
||||||
|
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -102,7 +123,7 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
|
|||||||
"--noscan",
|
"--noscan",
|
||||||
"--progress=-same",
|
"--progress=-same",
|
||||||
"mkv",
|
"mkv",
|
||||||
f"dev:{device_path}",
|
makemkv_quelle(device_path),
|
||||||
str(titel),
|
str(titel),
|
||||||
output_dir,
|
output_dir,
|
||||||
]
|
]
|
||||||
@@ -257,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
|
|||||||
wäre eine Minute Wartezeit für nichts.
|
wäre eine Minute Wartezeit für nichts.
|
||||||
"""
|
"""
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
ausgabe = ergebnis.stdout or ""
|
ausgabe = text_von(ergebnis.stdout)
|
||||||
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
|
||||||
|
|
||||||
|
|
||||||
@@ -271,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
alle_dateien = []
|
alle_dateien = []
|
||||||
|
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||||
|
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||||
|
lesefehler = False
|
||||||
for index, nr in enumerate(titel_liste):
|
for index, nr in enumerate(titel_liste):
|
||||||
def anteilig(p, _i=index):
|
def anteilig(p, _i=index):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -278,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
ergebnis["lesefehler"] = lesefehler
|
||||||
return ergebnis
|
return ergebnis
|
||||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||||
|
"lesefehler": lesefehler}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict, meta: dict = None):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
@@ -330,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
|
|||||||
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("makemkv"), "-r", "--noscan", "info", f"dev:{device_path}"],
|
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, # binaer: siehe text_von
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return {}
|
return {}
|
||||||
return parse_tinfo_dauern(ergebnis.stdout or "")
|
return parse_tinfo_dauern(text_von(ergebnis.stdout))
|
||||||
|
|
||||||
|
|
||||||
def parse_scan_dauer(ausgabe: str) -> int:
|
def parse_scan_dauer(ausgabe: str) -> int:
|
||||||
@@ -360,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -474,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
|
|||||||
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
return (einstellungen.get("transcodePreset") or "").strip() or DEFAULT_HB_PRESET
|
||||||
|
|
||||||
|
|
||||||
|
#: Endung -> HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
|
||||||
|
#: gegengeprueft (`--help`, Abschnitt `-f, --format`).
|
||||||
|
FORMATE = {".mkv": "av_mkv", ".mp4": "av_mp4", ".m4v": "av_mp4",
|
||||||
|
".mov": "av_mov", ".webm": "av_webm"}
|
||||||
|
|
||||||
|
|
||||||
def build_handbrake_cmd(input_path: str, output_path: str,
|
def build_handbrake_cmd(input_path: str, output_path: str,
|
||||||
preset: str = DEFAULT_HB_PRESET,
|
preset: str = DEFAULT_HB_PRESET,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
audio_sprachen=None, untertitel_sprachen=None) -> list:
|
||||||
@@ -499,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||||
|
|
||||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
@@ -508,11 +541,46 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
"--output", output_path,
|
"--output", output_path,
|
||||||
"--preset", preset,
|
"--preset", preset,
|
||||||
]
|
]
|
||||||
|
# Den Container zur Endung erzwingen (Befund 29.08.2026).
|
||||||
|
#
|
||||||
|
# Commander: „Kompression fehlgeschlagen bei title_t00.mkv: HandBrake
|
||||||
|
# endete mit Code 0"
|
||||||
|
#
|
||||||
|
# Code 0 heisst bei HandBrake ERFOLG — und trotzdem lag am erwarteten Ort
|
||||||
|
# keine Datei. Der Grund: **HandBrake bestimmt den Container aus dem
|
||||||
|
# Preset, nicht aus der Endung.** Steht ein MP4-Preset ein, schreibt es
|
||||||
|
# `title_t00.mp4` neben das verlangte `title_t00.mkv`, meldet „Output
|
||||||
|
# format changed" — und beendet sich mit 0. Rippy sah an seiner Stelle
|
||||||
|
# nichts und nannte das „fehlgeschlagen".
|
||||||
|
#
|
||||||
|
# `-f/--format` ist der dokumentierte Schalter dafuer (an dem
|
||||||
|
# mitgelieferten HandBrake 1.11.2 gegengeprueft: av_mp4, av_mov, av_mkv,
|
||||||
|
# av_webm). Damit sind Endung und Container EINE Entscheidung statt zwei,
|
||||||
|
# die auseinanderlaufen koennen.
|
||||||
|
format_name = FORMATE.get(os.path.splitext(output_path)[1].lower())
|
||||||
|
if format_name:
|
||||||
|
befehl += ["--format", format_name]
|
||||||
audio = [s for s in (audio_sprachen or []) if s]
|
audio = [s for s in (audio_sprachen or []) if s]
|
||||||
if audio:
|
if audio:
|
||||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||||
befehl.append("--first-audio")
|
befehl.append("--first-audio")
|
||||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||||
|
#
|
||||||
|
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||||
|
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||||
|
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||||
|
#
|
||||||
|
# unknown option (--audio-codec)
|
||||||
|
# HandBrake has exited. $? = 0
|
||||||
|
#
|
||||||
|
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||||
|
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||||
|
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||||
|
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||||
|
# festschreibt, statt ihn zu finden.
|
||||||
|
#
|
||||||
|
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||||
|
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||||
if untertitel:
|
if untertitel:
|
||||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||||
@@ -577,6 +645,23 @@ def sprachliste(wert) -> list:
|
|||||||
return sauber
|
return sauber
|
||||||
|
|
||||||
|
|
||||||
|
def _datei_daneben(erwartet: str) -> str:
|
||||||
|
"""Dieselbe Datei mit anderer Endung im selben Ordner — oder "".
|
||||||
|
|
||||||
|
HandBrake waehlt den Container nach dem Preset. Passt er nicht zur
|
||||||
|
verlangten Endung, liegt das Ergebnis unter demselben Namen mit anderer
|
||||||
|
Endung daneben (Befund 29.08.2026). Gesucht wird nur in den Endungen, die
|
||||||
|
HandBrake ueberhaupt schreiben kann.
|
||||||
|
"""
|
||||||
|
ordner = os.path.dirname(erwartet)
|
||||||
|
stamm = os.path.splitext(os.path.basename(erwartet))[0]
|
||||||
|
for endung in FORMATE:
|
||||||
|
kandidat = os.path.join(ordner, stamm + endung)
|
||||||
|
if kandidat != erwartet and os.path.isfile(kandidat):
|
||||||
|
return kandidat
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PRESET,
|
||||||
progress_cb=None, abbruch_cb=None,
|
progress_cb=None, abbruch_cb=None,
|
||||||
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
audio_sprachen=None, untertitel_sprachen=None) -> dict:
|
||||||
@@ -600,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
audio_sprachen, untertitel_sprachen),
|
audio_sprachen, untertitel_sprachen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
|
**HB_LESEN,
|
||||||
)
|
)
|
||||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -629,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
|||||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||||
|
HB_ZEILEN_PUFFER = 12
|
||||||
|
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||||
|
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||||
|
HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannter_schalter(zeile: str) -> str:
|
||||||
|
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||||
|
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||||
|
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||||
|
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||||
|
Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited.
|
||||||
|
$? = 0
|
||||||
|
|
||||||
|
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||||
|
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||||
|
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||||
|
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||||
|
|
||||||
|
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||||
|
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||||
|
statt wieder einen ganzen Rip zu kosten.
|
||||||
|
"""
|
||||||
|
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||||
|
return treffer.group(1).strip() if treffer else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hb_schluss(zeilen) -> str:
|
||||||
|
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||||
|
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||||
|
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||||
|
Ausgabe, nur hoerte niemand zu.
|
||||||
|
"""
|
||||||
|
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||||
|
if not sauber:
|
||||||
|
return ""
|
||||||
|
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
falsches_preset = ""
|
falsches_preset = ""
|
||||||
|
falscher_schalter = ""
|
||||||
|
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||||
|
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||||
|
letzte_zeilen = []
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -640,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if line.strip():
|
||||||
|
letzte_zeilen.append(line)
|
||||||
|
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||||
|
del letzte_zeilen[0]
|
||||||
if not falsches_preset:
|
if not falsches_preset:
|
||||||
falsches_preset = unbekanntes_preset(line)
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
if not falscher_schalter:
|
||||||
|
falscher_schalter = unbekannter_schalter(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -657,6 +805,57 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
|
|
||||||
if process.returncode == 0 and os.path.exists(output_path):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
|
||||||
|
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||||
|
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||||
|
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||||
|
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||||
|
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||||
|
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||||
|
if falscher_schalter:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||||
|
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||||
|
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||||
|
+ hb_schluss(letzte_zeilen)
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
||||||
|
# HandBrake endete mit Code 0". Code 0 heisst ERFOLG — HandBrake meldet
|
||||||
|
# Fehler notorisch nicht ueber den Rueckgabewert. Der haeufigste Fall ist
|
||||||
|
# der Container: Er kommt aus dem PRESET, nicht aus der Endung. Ein
|
||||||
|
# MP4-Preset schreibt `title_t00.mp4` neben das verlangte `.mkv`.
|
||||||
|
#
|
||||||
|
# Verhindert wird das jetzt mit `--format` (siehe build_handbrake_cmd).
|
||||||
|
# Dieser Zweig bleibt trotzdem: Er FINDET die Datei, statt einen
|
||||||
|
# erfolgreichen Lauf wegzuwerfen — und sagt, was passiert ist.
|
||||||
|
if process.returncode == 0:
|
||||||
|
daneben = _datei_daneben(output_path)
|
||||||
|
if daneben:
|
||||||
|
return {"status": "success", "output_path": daneben,
|
||||||
|
"hinweis": "HandBrake hat %s geschrieben statt %s "
|
||||||
|
"(der Container kommt aus dem Preset)."
|
||||||
|
% (os.path.basename(daneben),
|
||||||
|
os.path.basename(output_path))}
|
||||||
|
# Frueher stand hier geraten „Meist ist der Zielordner nicht
|
||||||
|
# beschreibbar". Am 30.08.2026 war das falsch (der Ordner war da
|
||||||
|
# und leer, der Grund ein falscher Schalter) — und die Vermutung
|
||||||
|
# schickte die Suche in die falsche Richtung. Jetzt wird der
|
||||||
|
# Ordner GENANNT und HandBrake selbst zitiert.
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||||
|
"entstanden (Zielordner: %s)."
|
||||||
|
% os.path.dirname(output_path)
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
|
"return_code": 0,
|
||||||
|
}
|
||||||
if falsches_preset:
|
if falsches_preset:
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
@@ -671,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
|
|||||||
return tmp.name
|
return tmp.name
|
||||||
|
|
||||||
|
|
||||||
|
def disc_fehlt(device_path: str, zustand=None) -> str:
|
||||||
|
"""Liegt ueberhaupt eine Disc drin? Klartext-Grund oder "" (alles gut).
|
||||||
|
|
||||||
|
## Warum vorher nachgesehen wird (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander bekam:
|
||||||
|
|
||||||
|
makemkvcon endete mit Code 11 — letzte Meldung:
|
||||||
|
Das Öffnen der Disk schlug fehl — keine MKV-Datei entstanden
|
||||||
|
|
||||||
|
Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger im Laufwerk.
|
||||||
|
Nach einem fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch
|
||||||
|
trifft dann ein leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein
|
||||||
|
und meldet einen Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
Nachsehen kostet Millisekunden und erspart genau diesen Satz.
|
||||||
|
|
||||||
|
⚠️ Ein FEHLGESCHLAGENER Blick ist kein Grund, den Rip zu verweigern. Wer
|
||||||
|
das Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist — und
|
||||||
|
„ich weiss es nicht" darf nie zu „es geht nicht" werden.
|
||||||
|
"""
|
||||||
|
if zustand is None:
|
||||||
|
try:
|
||||||
|
from rippy import drives as _schicht
|
||||||
|
|
||||||
|
zustand = _schicht.treiber().drive_status
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
stand = zustand(device_path)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ""
|
||||||
|
if stand == CDS_NO_DISC:
|
||||||
|
return ("Kein Datentraeger im Laufwerk %s. Nach einem fertigen Rip "
|
||||||
|
"wirft Rippy die Disc aus — fuer einen neuen Rip muss sie "
|
||||||
|
"wieder hinein." % device_path)
|
||||||
|
if stand == CDS_TRAY_OPEN:
|
||||||
|
return "Die Schublade von %s ist offen." % device_path
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str = "all",
|
||||||
log_cb=None) -> dict:
|
log_cb=None) -> dict:
|
||||||
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
|
||||||
@@ -723,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if not check_makemkv_installed():
|
if not check_makemkv_installed():
|
||||||
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
return {"status": "error", "error": "makemkvcon ist nicht installiert"}
|
||||||
|
|
||||||
|
fehlt = disc_fehlt(device_path)
|
||||||
|
if fehlt:
|
||||||
|
return {"status": "error", "error": fehlt}
|
||||||
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -730,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
build_makemkv_cmd(device_path, output_dir, titel),
|
build_makemkv_cmd(device_path, output_dir, titel),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
# BINAER lesen und selbst dekodieren (siehe `text_von`):
|
||||||
bufsize=1,
|
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
|
||||||
|
# schrieb aber UTF-8 — daraus wurde beim Commander
|
||||||
|
# „Das Öffnen der Disk schlug fehl".
|
||||||
|
#
|
||||||
|
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
|
||||||
|
# nicht (Python warnt und puffert doch). Ungepuffert kommt
|
||||||
|
# jede Fortschrittszeile sofort an — bei einem Rip, der
|
||||||
|
# Stunden laeuft, ist das der Unterschied zwischen einem
|
||||||
|
# Balken und einem eingefrorenen Fenster.
|
||||||
|
bufsize=0,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||||
|
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||||
|
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||||
|
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||||
|
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||||
|
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||||
|
lesefehler = False
|
||||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
# (Befund 24.07., Summer-Wars-UHD).
|
# (Befund 24.07., Summer-Wars-UHD).
|
||||||
kritische_meldungen = []
|
kritische_meldungen = []
|
||||||
|
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
|
||||||
|
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
|
||||||
|
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
|
||||||
|
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
|
||||||
|
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
|
||||||
|
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
|
||||||
KRITISCH = (
|
KRITISCH = (
|
||||||
"volume key is unknown",
|
"volume key is unknown",
|
||||||
"evaluation period has expired",
|
"evaluation period has expired",
|
||||||
@@ -748,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"Too old version",
|
"Too old version",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for rohzeile in process.stdout:
|
||||||
|
line = text_von(rohzeile)
|
||||||
progress = get_progress_from_prgv(line)
|
progress = get_progress_from_prgv(line)
|
||||||
if progress >= 0:
|
if progress >= 0:
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -758,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if meldung is None:
|
if meldung is None:
|
||||||
continue
|
continue
|
||||||
code, letzte_meldung = meldung
|
code, letzte_meldung = meldung
|
||||||
if any(muster in letzte_meldung for muster in KRITISCH):
|
if LESEFEHLER_MARKE in letzte_meldung:
|
||||||
|
lesefehler = True
|
||||||
|
if code in KRITISCHE_CODES:
|
||||||
|
kritische_meldungen.append(
|
||||||
|
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||||
|
elif any(muster in letzte_meldung for muster in KRITISCH):
|
||||||
kritische_meldungen.append(letzte_meldung)
|
kritische_meldungen.append(letzte_meldung)
|
||||||
if log_cb:
|
if log_cb:
|
||||||
try:
|
try:
|
||||||
@@ -786,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"files": mkv_dateien,
|
"files": mkv_dateien,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
|
"lesefehler": lesefehler,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"lesefehler": lesefehler,
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
@@ -841,7 +1117,11 @@ def rip_video(device_path: str, disc_id: str, disc_type: str = "dvd", progress_c
|
|||||||
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
def rip_cd(device_path: str, disc_id: str, progress_cb=None, output_dir: str = None,
|
||||||
auswerfen: bool = True) -> dict:
|
auswerfen: bool = True) -> dict:
|
||||||
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
"""Rippt eine CD mit abcde (FLAC). `auswerfen` = Einstellung „Automatischer
|
||||||
Auswurf" (abcde macht das selbst per -x)."""
|
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():
|
if not check_abcde_installed():
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
|||||||
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
|
|||||||
for pfad in fest:
|
for pfad in fest:
|
||||||
if os.path.isfile(pfad):
|
if os.path.isfile(pfad):
|
||||||
return pfad
|
return pfad
|
||||||
return shutil.which("makemkvcon64") or shutil.which("makemkvcon") or ""
|
# ⚠️ `shutil.which` sucht NUR im PATH (Befund 29.08.2026). Unter Windows
|
||||||
|
# liegt MakeMKV in „Programme" und steht dort nie — ausgerechnet in dem
|
||||||
|
# Modul, das es NUR unter Windows gibt. Die Schluessel-Automatik lief
|
||||||
|
# damit nie an. Der Werkzeug-Katalog kennt die echten Orte.
|
||||||
|
from rippy.tools import katalog
|
||||||
|
|
||||||
|
return (katalog.finden("makemkv")
|
||||||
|
or shutil.which("makemkvcon64") or shutil.which("makemkvcon") or "")
|
||||||
|
|
||||||
|
|
||||||
def parse_laufwerke(ausgabe: str) -> list:
|
def parse_laufwerke(ausgabe: str) -> list:
|
||||||
|
|||||||
@@ -214,3 +214,33 @@ def test_hardware_presets_stehen_auch_ohne_hardware_in_der_liste():
|
|||||||
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
assert caps.leite_backends_ab(caps.parse_encoder_liste(HB_HILFE_ECHT)) == [
|
||||||
"cpu-x264", "cpu-x265", "cpu-av1",
|
"cpu-x264", "cpu-x265", "cpu-av1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── „Extern" heisst: Rippy laeuft woanders (Befund 29.08.2026) ──────────
|
||||||
|
#
|
||||||
|
# Hier stand `os.path.isdir("/app")`. Im Image stimmt das. Auf einem
|
||||||
|
# Windows-PC gibt es `/app` nicht — und damit hielt sich der eigenstaendige
|
||||||
|
# Windows-Rippy fuer einen FREMDEN Worker. Folge: Das UI warnte vor fehlender
|
||||||
|
# Pfad-Uebersetzung auf einer Maschine, auf der es weder Container noch
|
||||||
|
# Freigabe gibt.
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_ist_nichts_extern():
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({}, container=True) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_eigenstaendige_windows_app_ist_NICHT_extern():
|
||||||
|
"""Dieser Prozess IST Rippy — die Frage ist dort gegenstandslos."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "standalone"}, container=False) == "nein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_worker_bei_einem_docker_rippy_ist_extern():
|
||||||
|
"""Der Fall, fuer den die Auskunft gemacht ist: Er braucht RIPPY_PATH_MAP."""
|
||||||
|
from caps import _extern
|
||||||
|
|
||||||
|
assert _extern({"profil": "node", "queue": {"treiber": "celery"}},
|
||||||
|
container=False) == "ja"
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
|
|||||||
return tasks._erreichbarkeit_pruefen(
|
return tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
|
||||||
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
"/app/media/rippy/movies/Akira (1988)", "\\\\NAS\\rippy\\movies\\Akira (1988)",
|
||||||
|
# „Fremd" heisst: Rippy laeuft woanders als dieser Worker. Bis zum
|
||||||
|
# 29.08.2026 wurde das an der Existenz von `/app` festgemacht — damit
|
||||||
|
# hing dieser Test am laufenden Rechner, und unter Windows hielt sich
|
||||||
|
# der eigenstaendige Rippy fuer einen fremden Worker.
|
||||||
|
fremd=not im_container,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +129,10 @@ def test_nicht_uebersetzter_pfad_beschuldigt_sehr_wohl_die_karte(monkeypatch):
|
|||||||
meldung = tasks._erreichbarkeit_pruefen(
|
meldung = tasks._erreichbarkeit_pruefen(
|
||||||
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
"/app/media/rippy/job1", "/app/media/rippy/job1",
|
||||||
"/app/media/movies/X", "/app/media/movies/X",
|
"/app/media/movies/X", "/app/media/movies/X",
|
||||||
|
# Ausdruecklich ein FREMDER Worker — nur den betrifft die Pfad-Karte.
|
||||||
|
# Vorher ergab sich das aus „es gibt kein /app", und genau daran hing
|
||||||
|
# der Test am laufenden Rechner (Befund 29.08.2026).
|
||||||
|
fremd=True,
|
||||||
)
|
)
|
||||||
assert "deckt diesen Pfad aber nicht ab" in meldung
|
assert "deckt diesen Pfad aber nicht ab" in meldung
|
||||||
assert "von keinem Eintrag übersetzt" in meldung
|
assert "von keinem Eintrag übersetzt" in meldung
|
||||||
|
|||||||
@@ -121,24 +121,33 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
|
|||||||
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
|
||||||
|
|
||||||
|
|
||||||
|
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
|
||||||
|
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
|
||||||
|
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
|
||||||
|
# prompt rot geworden).
|
||||||
|
CONTAINER = ("/app/media", "/app/temp/raw", False)
|
||||||
|
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
|
||||||
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
|
||||||
|
|
||||||
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
|
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
|
||||||
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
|
||||||
niemand gefragt wird.
|
niemand gefragt wird.
|
||||||
"""
|
"""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
einst = {"workDir": "/app/media/movies"}
|
einst = {"workDir": "/app/media/movies"}
|
||||||
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
|
||||||
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
|
||||||
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
|
||||||
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
|
||||||
# Leere Wahl fällt sauber auf die Einstellung zurück
|
# Leere Wahl fällt sauber auf die Einstellung zurück
|
||||||
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
|
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
|
||||||
|
|
||||||
|
|
||||||
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
||||||
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
|
|||||||
|
|
||||||
|
|
||||||
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
def test_zielbasis_lehnt_praefix_ausbruch_ab():
|
||||||
|
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
|
||||||
|
Windows-Reparatur nichts."""
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
|
w = CONTAINER
|
||||||
|
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
|
||||||
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
|
||||||
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
|
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
|
||||||
assert tasks._zielbasis("/etc", "bluray") != "/etc"
|
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
|
||||||
|
|
||||||
|
|
||||||
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
|
||||||
import ablauf as tasks
|
import ablauf as tasks
|
||||||
|
|
||||||
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
|
w = CONTAINER
|
||||||
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
|
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
|
||||||
|
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
|
||||||
|
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
|
||||||
|
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
|
||||||
|
# sogar eine AUSDRUECKLICHE Wahl.
|
||||||
|
#
|
||||||
|
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
|
||||||
|
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
|
||||||
|
# ignorierte ihn — ohne ein Wort.
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_zaehlt_die_wahl_des_nutzers():
|
||||||
|
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
|
||||||
|
deshalb still verworfen."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
|
||||||
|
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
|
||||||
|
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
|
||||||
|
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
|
||||||
|
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
|
||||||
|
Wurzel."""
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
unc = r"\\192.168.179.62\rippy\movies"
|
||||||
|
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
|
||||||
|
import ablauf as tasks
|
||||||
|
|
||||||
|
ziel = tasks._zielbasis("", "bluray", NATIV)
|
||||||
|
assert ziel.startswith(NATIV[0])
|
||||||
|
assert "/app/" not in ziel
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
hb_schluss,
|
||||||
parse_msg,
|
parse_msg,
|
||||||
|
unbekannter_schalter,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
assert "--preset" in cmd
|
||||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||||
assert "--audio-codec" in cmd
|
assert "--aencoder" in cmd
|
||||||
assert "--all-subtitles" in cmd
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_kennt_keinen_schalter_audio_codec():
|
||||||
|
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
|
||||||
|
|
||||||
|
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
|
||||||
|
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
|
||||||
|
das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit
|
||||||
|
ins Leere, ohne dass irgendwo ein Grund stand.
|
||||||
|
|
||||||
|
⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
|
||||||
|
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
|
||||||
|
ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten
|
||||||
|
Programm gemessen, nicht aus dem Gedächtnis behauptet.
|
||||||
|
"""
|
||||||
|
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
|
||||||
|
assert "--audio-codec" not in cmd
|
||||||
|
assert cmd[cmd.index("--aencoder") + 1] == "copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_wird_erkannt():
|
||||||
|
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"unknown option (--audio-codec)") == "--audio-codec"
|
||||||
|
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
|
||||||
|
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekannter_schalter("") == ""
|
||||||
|
assert unbekannter_schalter(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
|
||||||
|
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
|
||||||
|
0 ist, sagt er nichts (Befund 30.08.2026)."""
|
||||||
|
assert hb_schluss([]) == ""
|
||||||
|
assert hb_schluss(None) == ""
|
||||||
|
assert hb_schluss([" ", " "]) == ""
|
||||||
|
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
|
||||||
|
assert "fünf" in text and "vier" in text
|
||||||
|
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
|
||||||
|
assert "eins" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
@@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|||||||
assert ergebnis["return_code"] == 3
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
|
||||||
|
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und
|
||||||
|
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
|
||||||
|
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
|
||||||
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
|
||||||
|
assert "irgendwas ganz anderes" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
|
||||||
|
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
|
||||||
|
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
|
||||||
|
|
||||||
|
Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg,
|
||||||
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
|
"""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"[13:30:54] hb_init: starting libhb thread\n",
|
||||||
|
"unknown option (--audio-codec)\n",
|
||||||
|
"HandBrake has exited.\n",
|
||||||
|
])
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
@@ -519,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
|
|||||||
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
|
||||||
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
|
||||||
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
assert "--first-audio" in cmd and "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ── Erst nachsehen, dann rippen (Befund 29.08.2026) ─────────────────────
|
||||||
|
#
|
||||||
|
# Commander: „makemkvcon endete mit Code 11 — letzte Meldung: Das Öffnen der
|
||||||
|
# Disk schlug fehl — keine MKV-Datei entstanden"
|
||||||
|
#
|
||||||
|
# Auffaellig war, was FEHLTE: kein „Ursache:". Waere der Geraetepfad schuld
|
||||||
|
# gewesen, stuende dort MSG 2024. Bleibt: kein Datentraeger. Nach einem
|
||||||
|
# fertigen Rip wirft Rippy die Disc aus — ein zweiter Versuch trifft dann ein
|
||||||
|
# leeres Laufwerk, laeuft zwei Minuten in makemkvcon hinein und meldet einen
|
||||||
|
# Satz, aus dem niemand das schliessen kann.
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_leeres_laufwerk_wird_vorher_erkannt():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_NO_DISC
|
||||||
|
|
||||||
|
grund = ripping.disc_fehlt("G:", zustand=lambda p: CDS_NO_DISC)
|
||||||
|
assert "Kein Datentraeger" in grund
|
||||||
|
assert "wirft Rippy die Disc aus" in grund, "der Grund muss erklaert sein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_offene_schublade_auch():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_TRAY_OPEN
|
||||||
|
|
||||||
|
assert "Schublade" in ripping.disc_fehlt("G:", zustand=lambda p: CDS_TRAY_OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_disc_wird_nicht_gemeckert():
|
||||||
|
import ripping
|
||||||
|
from rippy.drives.linux import CDS_DISC_OK
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=lambda p: CDS_DISC_OK) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_fehlgeschlagener_blick_verweigert_den_rip_NICHT():
|
||||||
|
"""„Ich weiss es nicht" darf nie zu „es geht nicht" werden. Wer das
|
||||||
|
Laufwerk gerade nicht lesen kann, weiss nicht, dass es leer ist."""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
def wirft(p):
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
|
||||||
|
assert ripping.disc_fehlt("G:", zustand=wirft) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_makemkv_sieht_vorher_nach():
|
||||||
|
"""Waechter: Die Pruefung muss VOR dem Prozessstart stehen, sonst laeuft
|
||||||
|
man weiterhin zwei Minuten ins Leere."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
quelle = inspect.getsource(ripping.run_makemkv)
|
||||||
|
vor_popen = quelle.index("subprocess.Popen")
|
||||||
|
assert "disc_fehlt(" in quelle[:vor_popen], \
|
||||||
|
"die Disc-Pruefung gehoert vor den makemkvcon-Start"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_sammelmeldung_5010_wird_erklaert():
|
||||||
|
"""5010 ist MakeMKVs „ging nicht" und sagt fuer sich nichts."""
|
||||||
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES
|
||||||
|
|
||||||
|
assert 5010 in KRITISCHE_CODES
|
||||||
|
assert "Disc" in KRITISCHE_CODES[5010]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
+51
-41
@@ -233,53 +233,63 @@ def platz_orte(werte: dict, container: bool = None,
|
|||||||
ablage = ablage_vorgabe(werte, container, system)
|
ablage = ablage_vorgabe(werte, container, system)
|
||||||
temp = arbeits_vorgabe(werte, container, system)
|
temp = arbeits_vorgabe(werte, container, system)
|
||||||
orte = [{"name": "Ablage", "pfad": ablage}]
|
orte = [{"name": "Ablage", "pfad": ablage}]
|
||||||
# Nur wenn es ein ANDERES Laufwerk ist, lohnt eine zweite Zeile. Zweimal
|
# ## Warum beide Orte IMMER dastehen (Commander 29.08.2026)
|
||||||
# dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskünfte und ist eine.
|
|
||||||
#
|
#
|
||||||
# Ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel
|
# > „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der
|
||||||
# kannte `splitdrive` kein `D:` und hielt zwei verschiedene Laufwerke
|
# > Ablagepfad sein."
|
||||||
# fuer eines.
|
#
|
||||||
if not pfade.gleiches_laufwerk(temp, ablage):
|
# Hier stand vorher: zweite Zeile nur, wenn es ein ANDERES Laufwerk ist —
|
||||||
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp})
|
# mit der Begründung, zweimal dieselbe Zahl unter zwei Namen sehe aus wie
|
||||||
|
# zwei Auskünfte und sei eine. Das stimmt für die ZAHL und war der falsche
|
||||||
|
# Schluss für den ORT: Auf seinem Bildschirm stand nur „Platz für Rippy:
|
||||||
|
# 59,9 von 232 GB frei" — welche Ordner das sind, war nicht zu sehen.
|
||||||
|
#
|
||||||
|
# Beides gehört hin, und beides mit Pfad. Dass die Zahl dieselbe ist,
|
||||||
|
# steht jetzt ausdrücklich dabei (`gleiches_laufwerk`), statt die Zeile
|
||||||
|
# wegzulassen.
|
||||||
|
gleich = pfade.gleiches_laufwerk(temp, ablage)
|
||||||
|
orte.append({"name": "Arbeitsverzeichnis", "pfad": temp,
|
||||||
|
"gleiches_laufwerk": gleich})
|
||||||
|
orte[0]["gleiches_laufwerk"] = gleich
|
||||||
return orte
|
return orte
|
||||||
|
|
||||||
|
|
||||||
#: Rückgabewerte von GetDriveTypeW, die als Ablage taugen (learn.microsoft.com
|
def mit_einstellungen(werte: dict, einstellungen: dict) -> dict:
|
||||||
#: /windows/win32/api/fileapi/nf-fileapi-getdrivetypew): 3 = feste Platte,
|
r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen.
|
||||||
#: 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)
|
|
||||||
|
|
||||||
|
## Warum es diese Bruecke braucht (Befund 30.08.2026)
|
||||||
|
|
||||||
def windows_laufwerke(art=None, buchstaben=None) -> list:
|
Der Commander: „die verzeichnise sind andere als dort steht."
|
||||||
"""Welche Laufwerksbuchstaben taugen als Ablage? — gemessen, nicht geraten.
|
|
||||||
|
|
||||||
Warum das gebraucht wird: Auf Windows ist die Antwort auf „wohin mit 100 GB
|
Rippy hat ZWEI Speicher fuer dieselbe Frage:
|
||||||
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
|
Oberflaeche -> Datenbank, Schluessel `outputDir` und `workDir`
|
||||||
der Ampel prüfbar bleibt — dort gibt es kein `GetDriveTypeW`.
|
Betrieb -> Konfigurationsdatei, `storage.medien` / `storage.temp`
|
||||||
|
|
||||||
|
Geschrieben wird nur der erste — die Einstellungsseite kennt die
|
||||||
|
Konfigurationsdatei gar nicht. `ablage_vorgabe` und `arbeits_vorgabe`
|
||||||
|
lesen aber den zweiten, und der ist leer. Sie fielen deshalb IMMER auf
|
||||||
|
die Vorgabe zurueck: In „Einstellungen -> System" standen dauerhaft
|
||||||
|
`…\Videos\Rippy` und `…\Videos\Rippy\_arbeit`, egal was
|
||||||
|
eingestellt war — waehrend der Rip in Wahrheit nach `F:\` lief.
|
||||||
|
|
||||||
|
Der Worker macht es richtig herum: `_arbeitsverzeichnis()` liest
|
||||||
|
`workDir` aus der Datenbank und faellt erst DANN auf die Vorgabe
|
||||||
|
zurueck. Diese Funktion stellt dieselbe Reihenfolge fuer alle her, die
|
||||||
|
ueber `betrieb` fragen — statt sie ein drittes Mal nachzubauen.
|
||||||
|
|
||||||
|
Pure Funktion: Sie nimmt beide Woerterbuecher und gibt ein neues zurueck.
|
||||||
"""
|
"""
|
||||||
if buchstaben is None:
|
lager = dict(((werte or {}).get("storage") or {}))
|
||||||
buchstaben = [c + ":\\" for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"]
|
ablage = ((einstellungen or {}).get("outputDir") or "").strip()
|
||||||
if art is None:
|
arbeit = ((einstellungen or {}).get("workDir") or "").strip()
|
||||||
try:
|
if ablage:
|
||||||
import ctypes
|
lager["medien"] = ablage
|
||||||
art = ctypes.WinDLL("kernel32").GetDriveTypeW
|
if arbeit:
|
||||||
art.argtypes = [ctypes.c_wchar_p]
|
lager["temp"] = arbeit
|
||||||
except (OSError, AttributeError):
|
zusammen = dict(werte or {})
|
||||||
return []
|
zusammen["storage"] = lager
|
||||||
gefunden = []
|
return zusammen
|
||||||
for pfad in buchstaben:
|
|
||||||
try:
|
|
||||||
if art(pfad) in LAUFWERKSARTEN:
|
|
||||||
gefunden.append(pfad)
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
return gefunden
|
|
||||||
|
|
||||||
|
|
||||||
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
||||||
@@ -289,7 +299,7 @@ def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
|||||||
return eigen
|
return eigen
|
||||||
if container:
|
if container:
|
||||||
return "/app/media"
|
return "/app/media"
|
||||||
if system == "windows":
|
# Der Windows-Sonderzweig ist mit der Standalone-App gegangen (v5 ist
|
||||||
basis = os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
# ein eigenes Produkt, KONZEPT-WINDOWS.md Entscheid 7) — nativ heisst
|
||||||
return os.path.join(basis, "Videos", "Rippy")
|
# hier nur noch: ein Entwickler-Lauf ausserhalb des Containers.
|
||||||
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
|
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
|
|||||||
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rippy.bus.waechter import Waechter, laufwerks_unterschiede, unterschiede
|
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
|
||||||
|
|
||||||
|
|
||||||
class FakeBus:
|
class FakeBus:
|
||||||
@@ -277,3 +277,94 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
|
|||||||
w.einmal()
|
w.einmal()
|
||||||
w.einmal()
|
w.einmal()
|
||||||
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
|
||||||
|
#
|
||||||
|
# Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
|
||||||
|
# Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
|
||||||
|
# gibt auch keine fehlermeldung."
|
||||||
|
#
|
||||||
|
# Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
#
|
||||||
|
# TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
#
|
||||||
|
# Der Job lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
|
||||||
|
# trug keinen `type`; das UI fuegte einen halben Job ein, las
|
||||||
|
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
|
||||||
|
# Fehler die GANZE Oberflaeche mit.
|
||||||
|
#
|
||||||
|
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
|
||||||
|
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
|
||||||
|
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _db_zeile():
|
||||||
|
"""Eine Job-Zeile, NACHDEM `job_form` sie umgewandelt hat.
|
||||||
|
|
||||||
|
⚠️ Hier stand dieselbe Zeile mit denselben Feldnamen — nur habe ich sie
|
||||||
|
mir ausgedacht. Die echte Datenbankzeile heisst `disc_type` und
|
||||||
|
`created_at`, nicht `type` und `startTime`. Der Test hat meine Annahme
|
||||||
|
also bestaetigt statt sie zu pruefen, und beim Commander stand danach
|
||||||
|
„1.1.1970" in der Jobliste.
|
||||||
|
|
||||||
|
Der Waechter bekommt seit dem 29.08.2026 die UMGEWANDELTE Zeile — die
|
||||||
|
Umwandlung selbst prueft `test_api_smoke.py`, wo sie wohnt.
|
||||||
|
"""
|
||||||
|
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
|
||||||
|
"startTime": "2026-08-29T12:07:02", "endTime": None,
|
||||||
|
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
|
||||||
|
"error": None, "meta": {"confidence": 0.3}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_umwandlung_kommt_die_rohe_zeile_NICHT_durch():
|
||||||
|
"""Der Waechter darf DB-Feldnamen nicht weiterreichen.
|
||||||
|
|
||||||
|
Die rohe Zeile hat `disc_type`/`created_at`. Wer sie ungewandelt
|
||||||
|
durchreicht, schickt dem UI lauter leere Pflichtfelder — und leer ist
|
||||||
|
schlimmer als fehlend: `new Date(null)` ist der 1.1.1970, sieht also aus
|
||||||
|
wie eine Auskunft.
|
||||||
|
"""
|
||||||
|
roh = {"id": "j1", "disc_type": "bluray", "status": "running",
|
||||||
|
"created_at": "2026-08-29T12:07:02", "progress": 12}
|
||||||
|
kurz = _job_kurz(roh)
|
||||||
|
assert kurz["type"] is None and kurz["startTime"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
|
||||||
|
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
|
||||||
|
leer — ohne Fehlermeldung."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
for feld in UI_PFLICHTFELDER:
|
||||||
|
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
|
||||||
|
assert kurz[feld] is not None, "%s ist leer" % feld
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_typ_kommt_wirklich_durch():
|
||||||
|
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
|
||||||
|
assert _job_kurz(_db_zeile())["type"] == "bluray"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
|
||||||
|
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
|
||||||
|
laeuft in der Ereignisschleife des Servers."""
|
||||||
|
kurz = _job_kurz({"id": "j1"})
|
||||||
|
assert kurz["type"] is None and kurz["status"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
|
||||||
|
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
|
||||||
|
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
|
||||||
|
kurz = _job_kurz(_db_zeile())
|
||||||
|
assert "meta" not in kurz
|
||||||
|
assert "endTime" not in kurz
|
||||||
|
|
||||||
|
|
||||||
|
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
|
||||||
|
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten also nichts, obwohl sie mitfahren."""
|
||||||
|
eins = _job_kurz(_db_zeile())
|
||||||
|
zwei = _job_kurz(dict(_db_zeile(), progress=13))
|
||||||
|
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
|
||||||
|
("job.progress", "j1", zwei)]
|
||||||
|
|||||||
@@ -79,8 +79,59 @@ ENDE = ("completed", "failed", "canceled")
|
|||||||
|
|
||||||
|
|
||||||
def _job_kurz(zeile: dict) -> dict:
|
def _job_kurz(zeile: dict) -> dict:
|
||||||
"""Nur die Felder, deren Änderung ein Ereignis wert ist."""
|
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
|
||||||
|
|
||||||
|
## Warum `type` dazugehört (Befund des Commanders, 29.08.2026)
|
||||||
|
|
||||||
|
> „wenn man auf rippen starten klickt passiert irgendwas, was das Programm
|
||||||
|
> nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
|
||||||
|
> auch keine fehlermeldung."
|
||||||
|
|
||||||
|
Nachgestellt und in der Browser-Konsole gemessen:
|
||||||
|
|
||||||
|
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
|
||||||
|
|
||||||
|
Der Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
|
||||||
|
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
|
||||||
|
`error` — **keinen `type`**. Das UI fügt so einen halben Job in seine
|
||||||
|
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
|
||||||
|
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
|
||||||
|
Bildschirm, keine Meldung.
|
||||||
|
|
||||||
|
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
|
||||||
|
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
|
||||||
|
fängt den nächsten Fall dieser Art ab.
|
||||||
|
|
||||||
|
**Ein Ereignis „Job angelegt", das den halben Job weglässt, zwingt jeden
|
||||||
|
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
|
||||||
|
eines Jobs nie — sie kosten hier nichts und ersparen dem Empfänger die
|
||||||
|
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
|
||||||
|
|
||||||
|
## ⚠️ NACHTRAG 29.08.2026 — beim ersten Anlauf falsch gemacht
|
||||||
|
|
||||||
|
Hier stand `zeile.get("startTime")` und `zeile.get("type")`. **Diese
|
||||||
|
Felder gibt es in der Datenbankzeile nicht.** Sie heißen dort `created_at`
|
||||||
|
und `disc_type`; die Übersetzung macht `_job_row_to_model` in `main.py`,
|
||||||
|
und sie bildet auch `running` auf `processing` ab.
|
||||||
|
|
||||||
|
Die Folge war schlimmer als das Problem davor: Statt eines fehlenden
|
||||||
|
Feldes kam ein leeres. Im Bildschirmfoto des Commanders stand
|
||||||
|
|
||||||
|
TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
|
||||||
|
|
||||||
|
— `new Date(null)` ist der 1. Januar 1970, und „DISC" war der Rückfall,
|
||||||
|
den ich zwei Stunden vorher für genau diesen Fall eingebaut hatte. Aus
|
||||||
|
„offensichtlich kaputt" war „sieht plausibel aus" geworden.
|
||||||
|
|
||||||
|
Und mein Test hat es NICHT gefunden, weil ich seine Beispielzeile selbst
|
||||||
|
geschrieben habe — mit meinen erfundenen Feldnamen. Ein Test, der dieselbe
|
||||||
|
Annahme macht wie der Code, prüft nichts. Deshalb wird die Zeile jetzt
|
||||||
|
zuerst durch dieselbe Umwandlung geschickt, die auch `/jobs` benutzt.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
|
"type": zeile.get("type"),
|
||||||
|
"device": zeile.get("device"),
|
||||||
|
"startTime": zeile.get("startTime"),
|
||||||
"status": zeile.get("status"),
|
"status": zeile.get("status"),
|
||||||
"progress": zeile.get("progress"),
|
"progress": zeile.get("progress"),
|
||||||
"title": zeile.get("title"),
|
"title": zeile.get("title"),
|
||||||
@@ -88,6 +139,17 @@ def _job_kurz(zeile: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
|
||||||
|
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
|
||||||
|
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
|
||||||
|
#: hier und wird mechanisch bewacht.
|
||||||
|
#:
|
||||||
|
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
|
||||||
|
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
|
||||||
|
#: naechste Schnappschuss kam.
|
||||||
|
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
|
||||||
|
|
||||||
|
|
||||||
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
def unterschiede(vorher: dict, jetzt: dict) -> list:
|
||||||
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
|
||||||
|
|
||||||
@@ -173,7 +235,14 @@ class Waechter:
|
|||||||
|
|
||||||
def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN,
|
def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN,
|
||||||
laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_TAKT_SEKUNDEN,
|
laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_TAKT_SEKUNDEN,
|
||||||
system_lesen=None, system_takt: float = SYSTEM_TAKT_SEKUNDEN):
|
system_lesen=None, system_takt: float = SYSTEM_TAKT_SEKUNDEN,
|
||||||
|
job_form=None):
|
||||||
|
# `job_form` uebersetzt eine DB-Zeile in die Form, die das UI kennt
|
||||||
|
# (disc_type -> type, created_at -> startTime, running -> processing).
|
||||||
|
# Eingespritzt, weil die Uebersetzung in main.py wohnt und dieses
|
||||||
|
# Paket nichts von der API weiss. Ohne sie schickte der Waechter die
|
||||||
|
# ROHE Zeile — mit Feldnamen, die es im UI nicht gibt (29.08.2026).
|
||||||
|
self._job_form = job_form or (lambda z: z)
|
||||||
self._store = store
|
self._store = store
|
||||||
self._bus = bus
|
self._bus = bus
|
||||||
self._takt = takt
|
self._takt = takt
|
||||||
@@ -226,7 +295,8 @@ class Waechter:
|
|||||||
"""
|
"""
|
||||||
gesendet = 0
|
gesendet = 0
|
||||||
|
|
||||||
jetzt = {z["id"]: _job_kurz(z) for z in self._store.list_jobs(limit=100)}
|
jetzt = {z["id"]: _job_kurz(self._job_form(z))
|
||||||
|
for z in self._store.list_jobs(limit=100)}
|
||||||
erster_lauf = not self._jobs and self._letzte_log_id is None
|
erster_lauf = not self._jobs and self._letzte_log_id is None
|
||||||
if not erster_lauf:
|
if not erster_lauf:
|
||||||
for typ, job_id, daten in unterschiede(self._jobs, jetzt):
|
for typ, job_id, daten in unterschiede(self._jobs, jetzt):
|
||||||
|
|||||||
@@ -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
|
plattform = plattform if plattform is not None else sys.platform
|
||||||
if plattform.startswith("win"):
|
if plattform.startswith("win"):
|
||||||
from rippy.drives import windows
|
# Der Windows-Treiber ist mit der Standalone-App gegangen (Rippy v5,
|
||||||
return windows
|
# 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
|
from rippy.drives import linux
|
||||||
return 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
|
||||||
@@ -229,6 +229,24 @@ def read_sys_attr(device_name: str, attr: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def kennung(device_path: str) -> str:
|
||||||
|
"""Der kurze Name eines Laufwerks — `/dev/sr0` wird zu `sr0`.
|
||||||
|
|
||||||
|
Gegenstueck zu `pfad_zu_kennung`. Begruendung im Windows-Treiber: Hin-
|
||||||
|
und Rueckweg muessen zusammenpassen, sonst antworten die Endpunkte mit
|
||||||
|
404 (Befund 29.08.2026).
|
||||||
|
"""
|
||||||
|
return os.path.basename(device_path)
|
||||||
|
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
|
||||||
def device_info(device_path: str) -> dict:
|
def device_info(device_path: str) -> dict:
|
||||||
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
|
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
|
||||||
|
|
||||||
@@ -247,23 +265,31 @@ def device_info(device_path: str) -> dict:
|
|||||||
drive_status,
|
drive_status,
|
||||||
)
|
)
|
||||||
|
|
||||||
name = os.path.basename(device_path)
|
name = kennung(device_path)
|
||||||
vendor = read_sys_attr(name, "vendor")
|
vendor = read_sys_attr(name, "vendor")
|
||||||
model = read_sys_attr(name, "model")
|
model = read_sys_attr(name, "model")
|
||||||
|
|
||||||
|
grund = ""
|
||||||
try:
|
try:
|
||||||
status_code = drive_status(device_path)
|
status_code = drive_status(device_path)
|
||||||
except OSError:
|
except OSError as e:
|
||||||
status_code = -1
|
status_code = -1
|
||||||
|
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
disc_type = "unknown"
|
disc_type = "unknown"
|
||||||
status = "empty"
|
# Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER —
|
||||||
|
# man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1
|
||||||
|
# daran („DIE Regel des Ports", siehe dort); hier stand weiterhin
|
||||||
|
# „empty", und damit behauptete derselbe Ereignisstrom je nach
|
||||||
|
# Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026).
|
||||||
|
status = "unknown" if status_code < 0 else "empty"
|
||||||
if status_code == CDS_DISC_OK:
|
if status_code == CDS_DISC_OK:
|
||||||
status = "ready"
|
status = "ready"
|
||||||
try:
|
try:
|
||||||
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||||
except OSError:
|
except OSError as e:
|
||||||
disc_type = "unknown"
|
disc_type = "unknown"
|
||||||
|
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": name,
|
"id": name,
|
||||||
@@ -273,4 +299,7 @@ def device_info(device_path: str) -> dict:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"model": model,
|
"model": model,
|
||||||
"serial": read_sys_attr(name, "wwid"),
|
"serial": read_sys_attr(name, "wwid"),
|
||||||
|
# Gleiche Felder wie im Windows-Treiber — das UI unterscheidet
|
||||||
|
# nicht nach Plattform (siehe test_windows: Feld-Parität).
|
||||||
|
"grund": grund,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,232 +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"
|
|
||||||
|
|
||||||
|
|
||||||
@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"}
|
|
||||||
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,151 +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_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() + ":"
|
|
||||||
@@ -1,487 +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)
|
|
||||||
|
|
||||||
# ── 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,
|
|
||||||
)
|
|
||||||
if handle == w.INVALID_HANDLE_VALUE or handle in (0, None):
|
|
||||||
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 _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 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"
|
|
||||||
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:
|
|
||||||
disc_typ = "unknown"
|
|
||||||
else:
|
|
||||||
status = "empty"
|
|
||||||
except OSError:
|
|
||||||
status = "unknown"
|
|
||||||
|
|
||||||
buchstabe = geraet.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
|
|
||||||
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", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 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]
|
|
||||||
@@ -1,410 +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"),
|
|
||||||
"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
|
|
||||||
+10
-1
@@ -81,7 +81,16 @@ def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
|||||||
|
|
||||||
existiert = existiert or os.path.isdir
|
existiert = existiert or os.path.isdir
|
||||||
modul = _modul(pfad)
|
modul = _modul(pfad)
|
||||||
pfad = (pfad or "").rstrip("\\/")
|
pfad = (pfad or "").strip()
|
||||||
|
# Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als
|
||||||
|
# der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist
|
||||||
|
# unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel
|
||||||
|
# (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in
|
||||||
|
# `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe:
|
||||||
|
# aus "\\server\freigabe\" darf kein Ort ohne Trenner werden.
|
||||||
|
gekuerzt = pfad.rstrip("\\/")
|
||||||
|
if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]:
|
||||||
|
pfad = gekuerzt
|
||||||
gesehen = set()
|
gesehen = set()
|
||||||
while pfad and pfad not in gesehen:
|
while pfad and pfad not in gesehen:
|
||||||
if existiert(pfad):
|
if existiert(pfad):
|
||||||
|
|||||||
@@ -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) == ""
|
|
||||||
+209
-224
@@ -1,224 +1,209 @@
|
|||||||
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
|
||||||
|
|
||||||
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
|
||||||
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
|
||||||
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
wohin die Ausgabe geht, wenn es keine Konsole gibt.
|
||||||
|
|
||||||
## Warum das nicht Nebensache ist
|
## Warum das nicht Nebensache ist
|
||||||
|
|
||||||
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
|
||||||
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
|
||||||
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
|
||||||
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
`AttributeError: 'NoneType' object has no attribute 'write'` sterben — mitten
|
||||||
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
|
||||||
AGENTS.md warnt.
|
AGENTS.md warnt.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from rippy.platform import winlauf
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
|
||||||
|
|
||||||
|
|
||||||
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
# ── Kind-Prozesse ───────────────────────────────────────────────────────
|
||||||
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
|
||||||
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
|
||||||
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
|
||||||
else:
|
else:
|
||||||
assert winlauf.OHNE_FENSTER == 0
|
assert winlauf.OHNE_FENSTER == 0
|
||||||
|
|
||||||
|
|
||||||
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
|
||||||
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
|
||||||
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
|
||||||
monkeypatch.setattr(sys, "stdout", None)
|
monkeypatch.setattr(sys, "stdout", None)
|
||||||
assert winlauf.ohne_konsole() is True
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
|
||||||
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
|
||||||
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
`fileno` — und ein Meldungsfenster waere in einem Testlauf das Letzte,
|
||||||
was jemand gebrauchen kann."""
|
was jemand gebrauchen kann."""
|
||||||
class Puffer:
|
class Puffer:
|
||||||
write = staticmethod(lambda _: None)
|
write = staticmethod(lambda _: None)
|
||||||
|
|
||||||
monkeypatch.setattr(sys, "stdout", Puffer())
|
monkeypatch.setattr(sys, "stdout", Puffer())
|
||||||
assert winlauf.ohne_konsole() is True
|
assert winlauf.ohne_konsole() is True
|
||||||
|
|
||||||
|
|
||||||
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
|
||||||
datei = tmp_path / "echt.txt"
|
datei = tmp_path / "echt.txt"
|
||||||
with open(datei, "w", encoding="utf-8") as f:
|
with open(datei, "w", encoding="utf-8") as f:
|
||||||
monkeypatch.setattr(sys, "stdout", f)
|
monkeypatch.setattr(sys, "stdout", f)
|
||||||
assert winlauf.ohne_konsole() is False
|
assert winlauf.ohne_konsole() is False
|
||||||
|
|
||||||
|
|
||||||
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
# ── Die Protokolldatei ──────────────────────────────────────────────────
|
||||||
def test_protokoll_liegt_bei_rippy():
|
def test_protokoll_liegt_bei_rippy():
|
||||||
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
|
||||||
verschwindet beim Deinstallieren mit."""
|
verschwindet beim Deinstallieren mit."""
|
||||||
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
|
||||||
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
|
||||||
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
|
||||||
|
|
||||||
|
|
||||||
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
|
||||||
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
ziel = str(tmp_path / "unterordner" / "rippy.log")
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
assert winlauf.ausgabe_umleiten(ziel) == ziel
|
||||||
print("eine Zeile mit Umlaut: ä")
|
print("eine Zeile mit Umlaut: ä")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
|
||||||
|
|
||||||
|
|
||||||
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
|
||||||
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
|
||||||
— also genau das, was man dann sucht."""
|
— also genau das, was man dann sucht."""
|
||||||
ziel = str(tmp_path / "rippy.log")
|
ziel = str(tmp_path / "rippy.log")
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
winlauf.ausgabe_umleiten(ziel)
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
print("erster Start")
|
print("erster Start")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
winlauf.ausgabe_umleiten(ziel)
|
winlauf.ausgabe_umleiten(ziel)
|
||||||
print("zweiter Start")
|
print("zweiter Start")
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
inhalt = open(ziel, encoding="utf-8").read()
|
inhalt = open(ziel, encoding="utf-8").read()
|
||||||
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
assert "erster Start" in inhalt and "zweiter Start" in inhalt
|
||||||
|
|
||||||
|
|
||||||
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
|
||||||
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
|
||||||
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
|
||||||
ohne Protokoll."""
|
ohne Protokoll."""
|
||||||
def geht_nicht(*a, **k):
|
def geht_nicht(*a, **k):
|
||||||
raise OSError("kein Platz")
|
raise OSError("kein Platz")
|
||||||
|
|
||||||
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
|
||||||
vorher_out, vorher_err = sys.stdout, sys.stderr
|
vorher_out, vorher_err = sys.stdout, sys.stderr
|
||||||
try:
|
try:
|
||||||
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
|
||||||
print("das darf nicht werfen")
|
print("das darf nicht werfen")
|
||||||
finally:
|
finally:
|
||||||
sys.stdout, sys.stderr = vorher_out, vorher_err
|
sys.stdout, sys.stderr = vorher_out, vorher_err
|
||||||
|
|
||||||
|
|
||||||
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
# ── Das Meldungsfenster ─────────────────────────────────────────────────
|
||||||
def test_ohne_windows_kein_meldungsfenster():
|
def test_ohne_windows_kein_meldungsfenster():
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
pytest.skip("prueft das Verhalten auf Nicht-Windows")
|
||||||
assert winlauf.meldung_zeigen("egal") is False
|
assert winlauf.meldung_zeigen("egal") is False
|
||||||
assert winlauf.an_elternkonsole_haengen() is False
|
assert winlauf.an_elternkonsole_haengen() is False
|
||||||
|
|
||||||
|
|
||||||
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
|
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
|
||||||
#
|
#
|
||||||
# Commander, mit Bildschirmfoto:
|
# Commander, mit Bildschirmfoto:
|
||||||
#
|
#
|
||||||
# Failed to remove temporary directory:
|
# Failed to remove temporary directory:
|
||||||
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
|
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
|
||||||
#
|
#
|
||||||
# „Und manchmal kommt dieser fehler."
|
# „Und manchmal kommt dieser fehler."
|
||||||
#
|
#
|
||||||
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
|
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
|
||||||
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
|
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
|
||||||
# Sperre war also voruebergehend, es ist ein Wettlauf.
|
# Sperre war also voruebergehend, es ist ein Wettlauf.
|
||||||
#
|
#
|
||||||
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
|
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
|
||||||
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
|
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
|
||||||
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
|
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
|
||||||
# will loeschen — und die Kinder halten die DLLs offen.
|
# will loeschen — und die Kinder halten die DLLs offen.
|
||||||
|
|
||||||
|
|
||||||
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
|
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
|
||||||
"""Der Test, der 1,1 GB Reste verhindert haette."""
|
"""Der Test, der 1,1 GB Reste verhindert haette."""
|
||||||
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
|
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
|
||||||
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
|
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
|
||||||
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
|
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
|
||||||
"_PYI_PARENT_PROCESS_LEVEL": "1"}
|
"_PYI_PARENT_PROCESS_LEVEL": "1"}
|
||||||
kind = winlauf.umgebung_ohne_bundle(eltern)
|
kind = winlauf.umgebung_ohne_bundle(eltern)
|
||||||
for name in winlauf.PYI_ZEIGER:
|
for name in winlauf.PYI_ZEIGER:
|
||||||
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
|
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
|
||||||
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
|
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
|
||||||
|
|
||||||
|
|
||||||
def test_die_elternumgebung_wird_nicht_veraendert():
|
def test_die_elternumgebung_wird_nicht_veraendert():
|
||||||
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
|
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
|
||||||
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
|
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
|
||||||
winlauf.umgebung_ohne_bundle(eltern)
|
winlauf.umgebung_ohne_bundle(eltern)
|
||||||
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
|
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
|
||||||
|
|
||||||
|
|
||||||
def test_beide_startwege_reichen_den_zeiger_nicht_weiter():
|
|
||||||
"""Der Waechter: Wer kuenftig einen dritten Startweg baut, muss `env=`
|
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
|
||||||
setzen. Sonst kommen die Reste zurueck, und zwar still."""
|
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
|
||||||
import re
|
|
||||||
|
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
|
||||||
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
|
weggeraeumt, bevor es an der gesperrten DLL scheitert.
|
||||||
with open(os.path.abspath(quelle), encoding="utf-8") as f:
|
"""
|
||||||
text = f.read()
|
lebt = tmp_path / "_MEI111111"
|
||||||
stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)]
|
lebt.mkdir()
|
||||||
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
|
(lebt / "python312.dll").write_bytes(b"MZ")
|
||||||
for stelle in stellen:
|
(lebt / "wichtig.pyd").write_bytes(b"x")
|
||||||
aufruf = text[stelle:stelle + 300]
|
|
||||||
assert "env=umgebung_ohne_bundle()" in aufruf, (
|
def gesperrt(pfad):
|
||||||
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
|
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
|
||||||
|
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
|
||||||
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
|
jetzt_loeschen=gesperrt) == 0
|
||||||
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
|
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
|
||||||
|
|
||||||
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
|
|
||||||
weggeraeumt, bevor es an der gesperrten DLL scheitert.
|
def test_aufraeumen_entfernt_die_reste(tmp_path):
|
||||||
"""
|
for name in ("_MEI000035002", "_MEI0000b0882"):
|
||||||
lebt = tmp_path / "_MEI111111"
|
ordner = tmp_path / name
|
||||||
lebt.mkdir()
|
ordner.mkdir()
|
||||||
(lebt / "python312.dll").write_bytes(b"MZ")
|
(ordner / "python312.dll").write_bytes(b"MZ")
|
||||||
(lebt / "wichtig.pyd").write_bytes(b"x")
|
(ordner / "base_library.zip").write_bytes(b"PK")
|
||||||
|
|
||||||
def gesperrt(pfad):
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
|
||||||
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
|
assert not list(tmp_path.glob("_MEI*"))
|
||||||
|
|
||||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
|
|
||||||
jetzt_loeschen=gesperrt) == 0
|
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
|
||||||
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
|
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
|
||||||
|
eigen = tmp_path / "_MEI999999"
|
||||||
|
eigen.mkdir()
|
||||||
def test_aufraeumen_entfernt_die_reste(tmp_path):
|
(eigen / "python312.dll").write_bytes(b"MZ")
|
||||||
for name in ("_MEI000035002", "_MEI0000b0882"):
|
|
||||||
ordner = tmp_path / name
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
|
||||||
ordner.mkdir()
|
assert eigen.exists()
|
||||||
(ordner / "python312.dll").write_bytes(b"MZ")
|
|
||||||
(ordner / "base_library.zip").write_bytes(b"PK")
|
|
||||||
|
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
|
||||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
|
"""Was nicht `_MEI` heisst, geht uns nichts an."""
|
||||||
assert not list(tmp_path.glob("_MEI*"))
|
(tmp_path / "wichtige-daten").mkdir()
|
||||||
|
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
|
||||||
|
assert (tmp_path / "wichtige-daten").exists()
|
||||||
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
|
|
||||||
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
|
|
||||||
eigen = tmp_path / "_MEI999999"
|
|
||||||
eigen.mkdir()
|
|
||||||
(eigen / "python312.dll").write_bytes(b"MZ")
|
|
||||||
|
|
||||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
|
|
||||||
assert eigen.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
|
|
||||||
"""Was nicht `_MEI` heisst, geht uns nichts an."""
|
|
||||||
(tmp_path / "wichtige-daten").mkdir()
|
|
||||||
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
|
|
||||||
assert (tmp_path / "wichtige-daten").exists()
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Kein makemkvcon ueberlebt Rippy — und keine Waise haelt das Laufwerk fest.
|
||||||
|
|
||||||
|
## Der Befund (29.08.2026, auf dem Rechner des Commanders gemessen)
|
||||||
|
|
||||||
|
Ein Elternprozess startete `makemkvcon`, dann wurde er hart beendet
|
||||||
|
(`taskkill /F` ohne `/T` — genau das, was beim Dienst-Stopp und beim
|
||||||
|
Drueber-Installieren passiert):
|
||||||
|
|
||||||
|
ohne Leine makemkvcon PID 15368 vor dem Kill: True danach: True
|
||||||
|
mit Leine makemkvcon PID 43608 vor dem Kill: True danach: False
|
||||||
|
|
||||||
|
Ohne Leine bleibt die Waise stehen. **Und eine Waise haelt das Laufwerk fest**
|
||||||
|
— jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl".
|
||||||
|
|
||||||
|
Diese Tests pruefen die Logik, nicht das Betriebssystem: Sie laufen auch unter
|
||||||
|
Linux (Vertrag der Portschicht) und brauchen keine Windows-Aufrufe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.platform import winlauf
|
||||||
|
|
||||||
|
|
||||||
|
# ── Wen raeumt Rippy weg, und wen nicht ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_waise_wird_erkannt():
|
||||||
|
liste = [
|
||||||
|
(100, 4, "explorer.exe"),
|
||||||
|
(200, 999, "makemkvcon64.exe"), # Elternprozess 999 gibt es nicht
|
||||||
|
]
|
||||||
|
assert winlauf.waisen_finden(liste) == [(200, "makemkvcon64.exe")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_makemkvcon_mit_lebendem_eltern_bleibt_unangetastet():
|
||||||
|
"""Das gehoert zu einem laufenden Rip — oder zu einem zweiten Rippy."""
|
||||||
|
liste = [
|
||||||
|
(100, 4, "Rippy.exe"),
|
||||||
|
(200, 100, "makemkvcon64.exe"), # Kind eines lebenden Rippy
|
||||||
|
]
|
||||||
|
assert winlauf.waisen_finden(liste) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_makemkv_oberflaeche_wird_nie_abgeschossen():
|
||||||
|
"""`makemkv.exe` ist das Programm, das der Commander offen haben darf.
|
||||||
|
Nur `makemkvcon` ist das Kommandozeilen-Werkzeug, das Rippy startet."""
|
||||||
|
liste = [(200, 999, "makemkv.exe")]
|
||||||
|
assert winlauf.waisen_finden(liste) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_und_flac_zaehlen_mit():
|
||||||
|
liste = [(1, 999, "HandBrakeCLI.exe"), (2, 999, "flac.exe")]
|
||||||
|
assert {name for _, name in winlauf.waisen_finden(liste)} == {
|
||||||
|
"HandBrakeCLI.exe", "flac.exe"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_gross_und_kleinschreibung_ist_egal():
|
||||||
|
"""Windows meldet mal `makemkvcon64.exe`, mal anders geschrieben."""
|
||||||
|
assert winlauf.waisen_finden([(1, 999, "MakeMKVcon64.EXE")]) == [
|
||||||
|
(1, "MakeMKVcon64.EXE")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_beenden_meldet_was_es_beendet_hat():
|
||||||
|
liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")]
|
||||||
|
versucht = []
|
||||||
|
|
||||||
|
def toeten(pid):
|
||||||
|
versucht.append(pid)
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert winlauf.waisen_beenden(liste, toeten=toeten) == [
|
||||||
|
"makemkvcon64.exe", "flac.exe"]
|
||||||
|
assert versucht == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_prozess_der_sich_nicht_beenden_laesst_bricht_nichts_ab():
|
||||||
|
"""Ein fremder Prozess mit demselben Namen, an den Rippy nicht herankommt,
|
||||||
|
darf den Start nicht aufhalten — die anderen werden trotzdem weggeraeumt."""
|
||||||
|
liste = [(1, 999, "makemkvcon64.exe"), (2, 999, "flac.exe")]
|
||||||
|
|
||||||
|
def toeten(pid):
|
||||||
|
if pid == 1:
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
return True
|
||||||
|
|
||||||
|
assert winlauf.waisen_beenden(liste, toeten=toeten) == ["flac.exe"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Die Leine ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_windows_gibt_es_keine_leine():
|
||||||
|
"""Unter Linux uebernimmt das der Container bzw. systemd — nicht Rippy."""
|
||||||
|
if os.name == "nt":
|
||||||
|
pytest.skip("hier laeuft Windows")
|
||||||
|
assert winlauf.kinder_an_die_leine() is False
|
||||||
|
assert winlauf.prozessliste() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Arbeitsgruppen gibt es nur unter Windows")
|
||||||
|
def test_die_leine_haelt_und_bleibt_gehalten():
|
||||||
|
assert winlauf.kinder_an_die_leine() is True
|
||||||
|
griff = winlauf._ARBEITSGRUPPE
|
||||||
|
assert griff, "der Griff MUSS liegen bleiben — sonst sterben alle Kinder sofort"
|
||||||
|
assert winlauf.kinder_an_die_leine() is True, "ein zweiter Aufruf ist harmlos"
|
||||||
|
assert winlauf._ARBEITSGRUPPE == griff, "und legt keine zweite Gruppe an"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="braucht echte Prozesse")
|
||||||
|
def test_die_prozessliste_sieht_diesen_prozess():
|
||||||
|
eigene = {pid for pid, _, _ in winlauf.prozessliste()}
|
||||||
|
assert os.getpid() in eigene
|
||||||
|
|
||||||
|
|
||||||
|
# ── Waechter gegen die Falle, die beim Bauen zugeschnappt ist ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeder_kernel32_aufruf_hat_eine_angemeldete_signatur():
|
||||||
|
"""⚠️ Ohne `argtypes` reicht ctypes einen Griff als 32-Bit-int weiter.
|
||||||
|
|
||||||
|
`GetCurrentProcess()` liefert den Pseudogriff (HANDLE)-1, also
|
||||||
|
0xFFFFFFFFFFFFFFFF — ctypes wirft dann `ArgumentError: int too long to
|
||||||
|
convert`, das breite `except` verschluckt es, und `kinder_an_die_leine`
|
||||||
|
meldete nur „ging nicht". Genau so ist der erste Anlauf gescheitert.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
quelle = inspect.getsource(winlauf.kernel32)
|
||||||
|
for name in ("CreateJobObjectW", "SetInformationJobObject",
|
||||||
|
"AssignProcessToJobObject", "GetCurrentProcess",
|
||||||
|
"CreateToolhelp32Snapshot", "OpenProcess", "TerminateProcess"):
|
||||||
|
assert f"k.{name}.argtypes" in quelle, f"{name} ohne argtypes"
|
||||||
|
assert f"k.{name}.restype" in quelle, f"{name} ohne restype"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Strukturen nur unter Windows")
|
||||||
|
def test_die_struktur_hat_die_groesse_die_windows_erwartet():
|
||||||
|
"""144 Byte auf 64-Bit — am 29.08.2026 gegen Windows gemessen. Stimmt die
|
||||||
|
Groesse nicht, lehnt SetInformationJobObject stumm ab."""
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
assert ctypes.sizeof(winlauf._ERWEITERTE_GRENZEN()) == 144
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_rueckfall_ohne_die_fahne_ist_vorhanden():
|
||||||
|
"""Steckt Rippy in einer fremden Arbeitsgruppe ohne Herausloese-Erlaubnis,
|
||||||
|
verweigert Windows den Start rundweg. Ein Fenster, das gar nicht mehr
|
||||||
|
aufgeht, waere schlimmer als eines, das mitstirbt."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
quelle = inspect.getsource(winlauf.eigenstaendig_starten)
|
||||||
|
assert "except OSError" in quelle
|
||||||
|
assert quelle.count("subprocess.Popen") == 2, "mit Fahne und ohne"
|
||||||
@@ -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)
|
|
||||||
@@ -164,6 +164,269 @@ def reste_aufraeumen(ordner: str = None, eigener: str = None,
|
|||||||
return weg
|
return weg
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════ Kinder ueberleben Rippy nicht ══
|
||||||
|
#
|
||||||
|
# ## Der Befund (29.08.2026, auf diesem Rechner gemessen)
|
||||||
|
#
|
||||||
|
# Ein Python-Elternprozess startete `makemkvcon`, dann wurde der Elternprozess
|
||||||
|
# hart beendet (`taskkill /F`, ohne `/T` — genau das, was Windows beim
|
||||||
|
# Dienst-Stopp und beim Drueber-Installieren tut):
|
||||||
|
#
|
||||||
|
# vor dem Kill: Eltern True, Kind True
|
||||||
|
# danach: Eltern False, Kind True <-- die Waise
|
||||||
|
#
|
||||||
|
# Zwei `makemkvcon64.exe` blieben danach im Taskmanager stehen. **Eine solche
|
||||||
|
# Waise haelt das Laufwerk fest.** Jeder spaetere Rip scheitert dann mit
|
||||||
|
# „Das Oeffnen der Disk schlug fehl" — dem Satz, den der Commander gemeldet hat.
|
||||||
|
#
|
||||||
|
# Windows raeumt Kindprozesse nicht auf. Es gibt genau ein Mittel dagegen, und
|
||||||
|
# das ist die Arbeitsgruppe (Job Object): Prozesse darin sterben, wenn die
|
||||||
|
# Gruppe geschlossen wird — und geschlossen wird sie automatisch, wenn der
|
||||||
|
# letzte Griff darauf verschwindet, also wenn Rippy endet. Auch beim Absturz.
|
||||||
|
#
|
||||||
|
# ⚠️ `_ARBEITSGRUPPE` MUSS ein Modulwert bleiben. Wird der Griff eingesammelt,
|
||||||
|
# schliesst Windows die Gruppe — und bringt damit sofort alle Kinder um.
|
||||||
|
|
||||||
|
TH32CS_SNAPPROCESS = 0x00000002
|
||||||
|
JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
||||||
|
JOB_OBJECT_ERWEITERTE_GRENZEN = 9
|
||||||
|
CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||||
|
PROCESS_TERMINATE = 0x0001
|
||||||
|
|
||||||
|
# Nur diese werden je als Waise beendet. `makemkv.exe` steht bewusst NICHT
|
||||||
|
# dabei: Das ist die Oberflaeche von MakeMKV, die der Commander offen haben
|
||||||
|
# darf, ohne dass Rippy sie abschiesst.
|
||||||
|
WERKZEUG_PROZESSE = ("makemkvcon.exe", "makemkvcon64.exe",
|
||||||
|
"handbrakecli.exe", "flac.exe")
|
||||||
|
|
||||||
|
_ARBEITSGRUPPE = None
|
||||||
|
_K32 = None
|
||||||
|
|
||||||
|
|
||||||
|
if os.name == "nt":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
class _BASIS_GRENZEN(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
||||||
|
("PerJobUserTimeLimit", ctypes.c_longlong),
|
||||||
|
("LimitFlags", wintypes.DWORD),
|
||||||
|
("MinimumWorkingSetSize", ctypes.c_size_t),
|
||||||
|
("MaximumWorkingSetSize", ctypes.c_size_t),
|
||||||
|
("ActiveProcessLimit", wintypes.DWORD),
|
||||||
|
("Affinity", ctypes.c_size_t),
|
||||||
|
("PriorityClass", wintypes.DWORD),
|
||||||
|
("SchedulingClass", wintypes.DWORD),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _EA_ZAEHLER(ctypes.Structure):
|
||||||
|
_fields_ = [(name, ctypes.c_ulonglong) for name in (
|
||||||
|
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
||||||
|
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
||||||
|
|
||||||
|
class _ERWEITERTE_GRENZEN(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("BasicLimitInformation", _BASIS_GRENZEN),
|
||||||
|
("IoInfo", _EA_ZAEHLER),
|
||||||
|
("ProcessMemoryLimit", ctypes.c_size_t),
|
||||||
|
("JobMemoryLimit", ctypes.c_size_t),
|
||||||
|
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
||||||
|
("PeakJobMemoryUsed", ctypes.c_size_t),
|
||||||
|
]
|
||||||
|
|
||||||
|
class _PROZESSEINTRAG(ctypes.Structure):
|
||||||
|
_fields_ = [
|
||||||
|
("dwSize", wintypes.DWORD),
|
||||||
|
("cntUsage", wintypes.DWORD),
|
||||||
|
("th32ProcessID", wintypes.DWORD),
|
||||||
|
("th32DefaultHeapID", ctypes.c_size_t),
|
||||||
|
("th32ModuleID", wintypes.DWORD),
|
||||||
|
("cntThreads", wintypes.DWORD),
|
||||||
|
("th32ParentProcessID", wintypes.DWORD),
|
||||||
|
("pcPriClassBase", ctypes.c_long),
|
||||||
|
("dwFlags", wintypes.DWORD),
|
||||||
|
("szExeFile", wintypes.WCHAR * 260),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def eigenstaendig_starten(befehl, **kw):
|
||||||
|
"""Startet einen Prozess, der Rippy UEBERLEBEN soll.
|
||||||
|
|
||||||
|
Drei Prozesse duerfen nicht mitsterben: der Dienst (vom Starter aus), das
|
||||||
|
Fensterprogramm und das Aufraeum-Skript der Deinstallation. Sie loesen
|
||||||
|
sich mit `CREATE_BREAKAWAY_FROM_JOB` aus der Arbeitsgruppe.
|
||||||
|
|
||||||
|
⚠️ Der Rueckfall ohne die Fahne ist Pflicht, nicht Vorsicht: Steckt Rippy
|
||||||
|
in einer FREMDEN Arbeitsgruppe, die kein Herausloesen erlaubt, verweigert
|
||||||
|
Windows den Start rundweg (Zugriff verweigert). Ein Fenster, das gar nicht
|
||||||
|
mehr aufgeht, waere schlimmer als eines, das mitstirbt.
|
||||||
|
"""
|
||||||
|
fahnen = kw.pop("creationflags", 0)
|
||||||
|
try:
|
||||||
|
return subprocess.Popen(
|
||||||
|
befehl, creationflags=fahnen | CREATE_BREAKAWAY_FROM_JOB, **kw)
|
||||||
|
except OSError:
|
||||||
|
return subprocess.Popen(befehl, creationflags=fahnen, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def kernel32():
|
||||||
|
"""kernel32 mit vollstaendig angemeldeten Signaturen.
|
||||||
|
|
||||||
|
## Warum die `argtypes` hier keine Formsache sind (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Ohne sie reicht ctypes einen Python-Integer als **32-Bit-int** weiter.
|
||||||
|
`GetCurrentProcess()` liefert aber den Pseudogriff `(HANDLE)-1`, also
|
||||||
|
0xFFFFFFFFFFFFFFFF — und ctypes wirft beim Weiterreichen
|
||||||
|
|
||||||
|
ArgumentError: int too long to convert
|
||||||
|
|
||||||
|
Der erste Anlauf fing das mit einem breiten `except` ab und meldete
|
||||||
|
schlicht „ging nicht". Gemessen: alle drei Aufrufe gelingen, sobald die
|
||||||
|
Signaturen stehen. Ein Griff ist 64 Bit breit; das muss man Windows sagen.
|
||||||
|
"""
|
||||||
|
global _K32
|
||||||
|
if _K32 is not None:
|
||||||
|
return _K32
|
||||||
|
k = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
k.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR]
|
||||||
|
k.CreateJobObjectW.restype = wintypes.HANDLE
|
||||||
|
k.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int,
|
||||||
|
ctypes.c_void_p, wintypes.DWORD]
|
||||||
|
k.SetInformationJobObject.restype = wintypes.BOOL
|
||||||
|
k.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
||||||
|
k.AssignProcessToJobObject.restype = wintypes.BOOL
|
||||||
|
k.GetCurrentProcess.argtypes = []
|
||||||
|
k.GetCurrentProcess.restype = wintypes.HANDLE
|
||||||
|
k.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||||
|
k.CloseHandle.restype = wintypes.BOOL
|
||||||
|
k.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
||||||
|
k.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
||||||
|
k.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
||||||
|
k.Process32FirstW.restype = wintypes.BOOL
|
||||||
|
k.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
||||||
|
k.Process32NextW.restype = wintypes.BOOL
|
||||||
|
k.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||||
|
k.OpenProcess.restype = wintypes.HANDLE
|
||||||
|
k.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
|
||||||
|
k.TerminateProcess.restype = wintypes.BOOL
|
||||||
|
_K32 = k
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def kinder_an_die_leine() -> bool:
|
||||||
|
"""Bindet alle kuenftigen Kindprozesse an das Leben dieses Prozesses.
|
||||||
|
|
||||||
|
Stirbt Rippy — geordnet, abgestuerzt oder per Taskmanager —, sterben
|
||||||
|
makemkvcon, HandBrake und flac mit. Das Laufwerk ist danach frei.
|
||||||
|
|
||||||
|
Einmal beim Start aufrufen. Wirft nie; ein Rippy ohne Arbeitsgruppe laeuft
|
||||||
|
wie bisher weiter, nur ohne diesen Schutz.
|
||||||
|
|
||||||
|
`BREAKAWAY_OK` ist Absicht: Der Dienst startet das Fensterprogramm und das
|
||||||
|
Aufraeum-Skript der Deinstallation. Beide sollen Rippy ueberleben und
|
||||||
|
loesen sich mit `CREATE_BREAKAWAY_FROM_JOB` heraus.
|
||||||
|
"""
|
||||||
|
global _ARBEITSGRUPPE
|
||||||
|
if os.name != "nt":
|
||||||
|
return False
|
||||||
|
if _ARBEITSGRUPPE is not None:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
k32 = kernel32()
|
||||||
|
gruppe = k32.CreateJobObjectW(None, None)
|
||||||
|
if not gruppe:
|
||||||
|
return False
|
||||||
|
grenzen = _ERWEITERTE_GRENZEN()
|
||||||
|
grenzen.BasicLimitInformation.LimitFlags = (
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK)
|
||||||
|
if not k32.SetInformationJobObject(
|
||||||
|
gruppe, JOB_OBJECT_ERWEITERTE_GRENZEN,
|
||||||
|
ctypes.byref(grenzen), ctypes.sizeof(grenzen)):
|
||||||
|
k32.CloseHandle(gruppe)
|
||||||
|
return False
|
||||||
|
if not k32.AssignProcessToJobObject(gruppe, k32.GetCurrentProcess()):
|
||||||
|
# Steckt Rippy schon in einer fremden Gruppe, die kein Herausloesen
|
||||||
|
# erlaubt, geht das nicht. Kein Grund zu scheitern — nur kein Schutz.
|
||||||
|
k32.CloseHandle(gruppe)
|
||||||
|
return False
|
||||||
|
_ARBEITSGRUPPE = gruppe # Griff offenhalten, siehe oben
|
||||||
|
return True
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def prozessliste() -> list:
|
||||||
|
"""[(pid, eltern_pid, name), ...] aller laufenden Prozesse. Leer bei Fehlern."""
|
||||||
|
if os.name != "nt":
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
k32 = kernel32()
|
||||||
|
schnappschuss = k32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||||
|
if not schnappschuss or schnappschuss == wintypes.HANDLE(-1).value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
eintrag = _PROZESSEINTRAG()
|
||||||
|
eintrag.dwSize = ctypes.sizeof(_PROZESSEINTRAG)
|
||||||
|
liste = []
|
||||||
|
weiter = k32.Process32FirstW(schnappschuss, ctypes.byref(eintrag))
|
||||||
|
while weiter:
|
||||||
|
liste.append((eintrag.th32ProcessID,
|
||||||
|
eintrag.th32ParentProcessID,
|
||||||
|
eintrag.szExeFile))
|
||||||
|
weiter = k32.Process32NextW(schnappschuss, ctypes.byref(eintrag))
|
||||||
|
return liste
|
||||||
|
finally:
|
||||||
|
k32.CloseHandle(schnappschuss)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def waisen_finden(liste=None, namen=WERKZEUG_PROZESSE) -> list:
|
||||||
|
"""Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt.
|
||||||
|
|
||||||
|
Nur elternlose: Ein makemkvcon, das gerade zu einem laufenden Rippy
|
||||||
|
gehoert, hat einen lebenden Elternprozess und bleibt unangetastet. Das
|
||||||
|
gilt auch fuer ein zweites Rippy und fuer einen Aufruf von Hand.
|
||||||
|
"""
|
||||||
|
liste = prozessliste() if liste is None else liste
|
||||||
|
lebende = {pid for pid, _, _ in liste}
|
||||||
|
return [(pid, name) for pid, eltern, name in liste
|
||||||
|
if (name or "").lower() in namen and eltern not in lebende]
|
||||||
|
|
||||||
|
|
||||||
|
def waisen_beenden(liste=None, toeten=None) -> list:
|
||||||
|
"""Beendet elternlose Werkzeug-Prozesse. Gibt ihre Namen zurueck.
|
||||||
|
|
||||||
|
## Warum das beim Start passieren muss
|
||||||
|
|
||||||
|
Eine Waise haelt das Laufwerk fest — der naechste Rip scheitert dann mit
|
||||||
|
„Das Oeffnen der Disk schlug fehl", und niemand kann sich das erklaeren.
|
||||||
|
`kinder_an_die_leine` verhindert neue Waisen; diese hier raeumt weg, was
|
||||||
|
eine aeltere Fassung oder ein Absturz hinterlassen hat.
|
||||||
|
"""
|
||||||
|
if toeten is None:
|
||||||
|
def toeten(pid):
|
||||||
|
k32 = kernel32()
|
||||||
|
griff = k32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
||||||
|
if not griff:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(k32.TerminateProcess(griff, 1))
|
||||||
|
finally:
|
||||||
|
k32.CloseHandle(griff)
|
||||||
|
|
||||||
|
beendet = []
|
||||||
|
for pid, name in waisen_finden(liste):
|
||||||
|
try:
|
||||||
|
if toeten(pid):
|
||||||
|
beendet.append(name)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
return beendet
|
||||||
|
|
||||||
|
|
||||||
def ohne_konsole() -> bool:
|
def ohne_konsole() -> bool:
|
||||||
"""Läuft dieser Prozess ohne Standardausgabe?
|
"""Läuft dieser Prozess ohne Standardausgabe?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Wie HandBrakeCLI angesprochen und wie seine Ausgabe gelesen wird.
|
||||||
|
|
||||||
|
## Warum das im GEMEINSAMEN Paket liegt
|
||||||
|
|
||||||
|
Zwei Module rufen HandBrake auf: `docker/worker/ripping.py` (der Encode) und
|
||||||
|
`docker/worker/caps.py` (Preset-Liste, Hilfe, Version). `caps` wird auch vom
|
||||||
|
Standalone-Betrieb geladen und darf `ripping` nicht importieren, nur um an
|
||||||
|
eine Konstante zu kommen.
|
||||||
|
|
||||||
|
Dasselbe Muster wie bei `makemkv_aufruf.py`: Eine Entscheidung, zwei Orte —
|
||||||
|
einer altert. Dort kostete es die Formatzeile, hier waere es die Kodierung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Wie HandBrakes Ausgabe gelesen wird — und warum mit errors="replace".
|
||||||
|
#:
|
||||||
|
#: ## Der Befund (30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen)
|
||||||
|
#:
|
||||||
|
#: HandBrake schreibt **zwei Kodierungen in denselben Strom**. Eine Datei
|
||||||
|
#: namens „Glück über München.mkv" gescannt, die Bytes des Pfades in
|
||||||
|
#: derselben Ausgabe:
|
||||||
|
#:
|
||||||
|
#: Zeile „Opening ..." C3 BC = UTF-8
|
||||||
|
#: Zeile „..., title 1 ..." 81 = CP850 (OEM)
|
||||||
|
#:
|
||||||
|
#: Es gibt hier also keine richtige Kodierung, nur eine, die nicht
|
||||||
|
#: abstuerzt. Und ohne diese Zeile stuerzte es ab: In CP850 ist ü das Byte
|
||||||
|
#: **0x81**, und 0x81 ist in cp1252 — der Gebietsschema-Kodierung eines
|
||||||
|
#: deutschen Windows, die text=True von sich aus waehlt — **undefiniert**:
|
||||||
|
#:
|
||||||
|
#: UnicodeDecodeError: 'charmap' codec can't decode byte 0x81
|
||||||
|
#: in position 785: character maps to <undefined>
|
||||||
|
#:
|
||||||
|
#: In `run_handbrake` faellt das mitten in der Leseschleife an, wird von
|
||||||
|
#: `except Exception` eingefangen und landet als Fehlertext im Job. **Jeder
|
||||||
|
#: Film, dessen Pfad ein ü enthaelt, liess sich damit nicht komprimieren**
|
||||||
|
#: — „Glück", „Tür", „München", „Über", „Grün". Dasselbe gilt fuer
|
||||||
|
#: ì, Å, É, Ø (0x8D, 0x8F, 0x90, 0x9D).
|
||||||
|
#:
|
||||||
|
#: ⚠️ NICHT `text_von()` wie bei makemkvcon: Das liest binaer, und im
|
||||||
|
#: Binaermodus gibt es keine Universal-Newlines. HandBrake trennt seine
|
||||||
|
#: Fortschrittszeilen aber mit CR (0x0D) — der Balken waere weg. Alles, was
|
||||||
|
#: Rippy aus der Ausgabe liest, ist ASCII (Prozente, „Invalid preset",
|
||||||
|
#: „unknown option"); ersetzt wird also nur, was ohnehin nur Anzeige ist.
|
||||||
|
HB_LESEN = {"text": True, "errors": "replace"}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""Wie makemkvcon angesprochen und wie seine Antwort gelesen wird.
|
||||||
|
|
||||||
|
## Warum das im GEMEINSAMEN Paket liegt
|
||||||
|
|
||||||
|
Beide Seiten rufen makemkvcon auf: der Worker zum Rippen
|
||||||
|
(`docker/worker/ripping.py`) und die API beim Vor-Scan
|
||||||
|
(`docker/api/prescan/prescan.py`). Sie stecken in verschiedenen Images und
|
||||||
|
können einander nicht importieren — deshalb stand die Formatzeile zweimal da,
|
||||||
|
und beide Male falsch.
|
||||||
|
|
||||||
|
Genau das ist das Muster, das in diesem Projekt schon dreimal Geld gekostet
|
||||||
|
hat: `os.path` (viermal repariert, jetzt `rippy/pfade.py`), `/app/media` und
|
||||||
|
`/root/.MakeMKV`. Eine Entscheidung, zwei Orte — einer altert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def quelle(device_path: str, plattform_name: str = None) -> str:
|
||||||
|
"""Wie MakeMKV DIESES Laufwerk nennt (pure Funktion, testbar).
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „Das Rippen der disk funktioniert nicht. makemkvcon endete mit Code 11 —
|
||||||
|
> letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
|
||||||
|
> entstanden"
|
||||||
|
|
||||||
|
An seinem Laufwerk gemessen, mit `info` (öffnet die Disc genauso wie
|
||||||
|
`mkv`, rippt aber nichts):
|
||||||
|
|
||||||
|
dev:\\\\.\\G: MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
|
||||||
|
dev:G: 68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich
|
||||||
|
abgeschlossen"
|
||||||
|
|
||||||
|
Vorher stand überall `f"dev:{device_path}"`. Unter Linux ist das richtig:
|
||||||
|
dort heißt das Laufwerk `/dev/sr0`, und genau so will MakeMKV es haben.
|
||||||
|
Unter Windows meldet `rippy.drives.windows` den Geräte-Namensraum
|
||||||
|
`\\\\.\\G:` — den braucht `CreateFileW` für die IOCTLs, aber MakeMKV kennt
|
||||||
|
ihn nicht. Es will den Laufwerksbuchstaben.
|
||||||
|
|
||||||
|
`plattform_name` ist einspritzbar, damit beide Fälle überall prüfbar sind.
|
||||||
|
Entschieden wird aber nach der FORM des Pfades, nicht nach dem Rechner —
|
||||||
|
dieselbe Regel wie in `rippy.pfade`: Ein Windows-Pfad bleibt ein
|
||||||
|
Windows-Pfad, auch wenn gerade Linux danach fragt.
|
||||||
|
"""
|
||||||
|
pfad = (device_path or "").strip()
|
||||||
|
if not pfad:
|
||||||
|
return ""
|
||||||
|
# Geraete-Namensraum abstreifen: \\.\G: -> G: (auch \\?\ kommt vor)
|
||||||
|
for vorsatz in ("\\\\.\\", "\\\\?\\"):
|
||||||
|
if pfad.startswith(vorsatz):
|
||||||
|
pfad = pfad[len(vorsatz):]
|
||||||
|
break
|
||||||
|
# „G:\" -> „G:" — mit Trenner geht es zwar auch (gemessen), aber ohne ist
|
||||||
|
# es das, was MakeMKV in seiner eigenen Laufwerksliste ausgibt.
|
||||||
|
if len(pfad) == 3 and pfad[1] == ":" and pfad[2] in "\\/":
|
||||||
|
pfad = pfad[:2]
|
||||||
|
return "dev:%s" % pfad
|
||||||
|
|
||||||
|
|
||||||
|
def text_von(roh) -> str:
|
||||||
|
"""makemkvcon-Ausgabe lesbar machen — egal in welcher Kodierung sie kommt.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „letzte Meldung: Das Öffnen der Disk schlug fehl"
|
||||||
|
|
||||||
|
`Ö` ist `Ö` als UTF-8 (Bytes C3 96), gelesen als cp1252. Die Ausgabe kam
|
||||||
|
also in einer anderen Kodierung an, als sie gelesen wurde.
|
||||||
|
|
||||||
|
Beide Seiten waren für sich richtig: `subprocess` stand auf `text=True`
|
||||||
|
**ohne** `encoding=`, benutzt also die Gebietsschema-Kodierung — auf einem
|
||||||
|
deutschen Windows cp1252. Und Rippy stellt seine Konsole beim Start
|
||||||
|
ausdrücklich auf UTF-8 (`daemon.ausgabe_auf_utf8_stellen`, sonst stirbt
|
||||||
|
der Start an einer Umlaut-Logzeile). Ein Konsolenprogramm richtet sich
|
||||||
|
danach — makemkvcon schrieb UTF-8 in eine Leitung, an deren Ende cp1252
|
||||||
|
erwartet wurde.
|
||||||
|
|
||||||
|
Direkt an makemkvcon gemessen, ohne unsere Konsolen-Umstellung: die Bytes
|
||||||
|
sind dann **cp1252** (`Das Öffnen` liest sich nur so richtig). Die
|
||||||
|
Kodierung hängt also wirklich an der Umgebung und ist nicht festzulegen.
|
||||||
|
|
||||||
|
Deshalb wird sie nicht behauptet, sondern bestimmt: UTF-8 streng
|
||||||
|
versuchen, sonst cp1252. Streng ist hier der Punkt — mit `errors=replace`
|
||||||
|
ginge jeder Umlaut still in einem Fragezeichen unter, und wir würden es
|
||||||
|
nie erfahren.
|
||||||
|
"""
|
||||||
|
if isinstance(roh, str):
|
||||||
|
return roh
|
||||||
|
if not roh:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return roh.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# cp1252 kann jedes Byte abbilden -> hier kann nichts mehr scheitern.
|
||||||
|
return roh.decode("cp1252", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
#: Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen. Sie sind
|
||||||
|
#: sprachunabhängig — anders als die Texte, auf die vorher geprüft wurde.
|
||||||
|
#:
|
||||||
|
#: Bis zum 29.08.2026 suchte `ripping.py` nur englische Textbausteine
|
||||||
|
#: („evaluation period has expired", „volume key is unknown"). Auf dem
|
||||||
|
#: Commander-PC meldet MakeMKV deutsch, also traf keiner: Im Fehler stand nur
|
||||||
|
#: „Das Öffnen der Disk schlug fehl" — die Ursache eine Zeile darüber fiel
|
||||||
|
#: unter den Tisch.
|
||||||
|
#:
|
||||||
|
#: ⚠️ Jede Nummer hier ist an seinem Laufwerk GESEHEN worden, im Wortlaut.
|
||||||
|
#: Beim ersten Anlauf hatte ich 5021 aus dem Kopf mit „Volume-Key unbekannt"
|
||||||
|
#: beschriftet — falsch, und meine eigene Ausgabe zeigte nur meine
|
||||||
|
#: Beschriftung statt MakeMKVs Text, sodass es fast durchgegangen wäre.
|
||||||
|
#: Genau der Fehler, vor dem AGENTS Regel D warnt. Der echte Wortlaut:
|
||||||
|
#:
|
||||||
|
#: 2024 „Unknown device - '\\\\.\\G:'"
|
||||||
|
#: 5020 „Der hinterlegte Aktivierungsschlüssel ist ungültig."
|
||||||
|
#: 5021 „Diese Programmversion ist zu alt. Bitte laden Sie die aktuelle
|
||||||
|
#: Version von http://www.makemkv.com/ herunter."
|
||||||
|
#: 5051 „Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen
|
||||||
|
#: Aktivierungsschlüssel …"
|
||||||
|
KRITISCHE_CODES = {
|
||||||
|
2024: "MakeMKV kennt dieses Laufwerk nicht",
|
||||||
|
# 5010 stand bis zum 29.08.2026 NICHT hier — und genau das war das
|
||||||
|
# Problem: Der Commander bekam „makemkvcon endete mit Code 11 — letzte
|
||||||
|
# Meldung: Das Öffnen der Disk schlug fehl". Das ist MakeMKVs
|
||||||
|
# Sammelmeldung für „ging nicht" und sagt für sich genommen nichts.
|
||||||
|
#
|
||||||
|
# Auffällig war, was FEHLTE: kein „Ursache:" davor. Wäre der Gerätepfad
|
||||||
|
# schuld gewesen, stünde dort 2024. Bleibt: kein Datenträger, oder ein
|
||||||
|
# anderes Programm hält das Laufwerk. Nach einem fertigen Rip wirft Rippy
|
||||||
|
# die Disc aus — ein zweiter Versuch trifft dann ein leeres Laufwerk.
|
||||||
|
5010: "Das Laufwerk liess sich nicht öffnen — liegt die Disc noch drin? "
|
||||||
|
"Nach einem fertigen Rip wirft Rippy sie aus",
|
||||||
|
5020: "Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen",
|
||||||
|
5021: "MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV "
|
||||||
|
"aktualisieren",
|
||||||
|
5051: "MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht.
|
||||||
|
KEY_ABGELEHNT = (5020, 5021)
|
||||||
|
|
||||||
|
|
||||||
|
def plattform(name: str = None) -> str:
|
||||||
|
"""Nur für Tests, die den Rechner ausdrücklich benennen wollen."""
|
||||||
|
return (name if name is not None else sys.platform).lower()
|
||||||
|
|
||||||
|
|
||||||
|
#: Wo MakeMKV unter Windows SEINE Einstellungen hält — gemessen, nicht geraten.
|
||||||
|
REGISTRY_PFAD = r"Software\MakeMKV"
|
||||||
|
KEY_NAME = "app_Key"
|
||||||
|
|
||||||
|
|
||||||
|
def key_ablegen(key: str, plattform_name: str = None, schreiber=None,
|
||||||
|
datei_schreiber=None) -> str:
|
||||||
|
"""Den Beta-Key dorthin legen, wo makemkvcon ihn WIRKLICH sucht.
|
||||||
|
|
||||||
|
Gibt zurück, wohin geschrieben wurde (für die Protokollzeile).
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
Beim ersten echten Rip-Versuch meldete makemkvcon:
|
||||||
|
|
||||||
|
MSG:5051 „Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen
|
||||||
|
Aktivierungsschlüssel"
|
||||||
|
|
||||||
|
— obwohl in Rippys Einstellungen ein aktueller Beta-Key lag. Zwei Fehler
|
||||||
|
hintereinander, beide gemessen:
|
||||||
|
|
||||||
|
**1. Falscher Ordner.** `makemkv_daten.DATEN_DIR` war fest
|
||||||
|
`/root/.MakeMKV`, ein Container-Pfad. Repariert (siehe dort).
|
||||||
|
|
||||||
|
**2. Falsche ART von Ablage.** Auch mit richtigem Ordner blieb die Meldung
|
||||||
|
stehen. Nachgesehen, was auf seinem Rechner wirklich existiert:
|
||||||
|
|
||||||
|
C:\\Users\\TobisPC\\.MakeMKV\\ nur _private_data.tar (6,4 MB)
|
||||||
|
HKCU\\Software\\MakeMKV app_UpdateLastCheck, app_SiteInfoString,
|
||||||
|
app_BackupDecrypted, path_BackupDirMRU …
|
||||||
|
|
||||||
|
**Unter Windows hält MakeMKV seine Einstellungen in der Registry.** Die
|
||||||
|
`settings.conf` ist der Linux-Weg; im Container stimmt sie, hier nicht. Der
|
||||||
|
Ordner `.MakeMKV` existiert trotzdem — aber nur für den Schlüsselspeicher.
|
||||||
|
|
||||||
|
Rippy hat den Key also zweimal brav gespeichert und beide Male an einen
|
||||||
|
Ort, den niemand liest. Eine Automatik, die ins Leere schreibt, ist
|
||||||
|
schlimmer als keine: Sie sieht aus wie erledigt.
|
||||||
|
|
||||||
|
## Warum hier NUR ein einziger Wert geschrieben wird
|
||||||
|
|
||||||
|
Weil in dieser Registry die Arbeit des Commanders steckt —
|
||||||
|
Laufwerksprofile, Fensterlage, zuletzt benutzte Ordner. Es wird genau
|
||||||
|
`app_Key` gesetzt und sonst nichts angefasst; gelöscht wird nie. (Am
|
||||||
|
28.08.2026 hat eine zu großzügige Testsuite schon einmal echte
|
||||||
|
Registry-Einträge von ihm entfernt.)
|
||||||
|
"""
|
||||||
|
key = (key or "").strip()
|
||||||
|
if not key:
|
||||||
|
return ""
|
||||||
|
if not plattform(plattform_name).startswith("win"):
|
||||||
|
# Linux/Container: die settings.conf, wie gehabt.
|
||||||
|
return (datei_schreiber or _in_datei)(key)
|
||||||
|
return (schreiber or _in_registry)(key)
|
||||||
|
|
||||||
|
|
||||||
|
def _in_registry(key: str) -> str:
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD) as schluessel:
|
||||||
|
winreg.SetValueEx(schluessel, KEY_NAME, 0, winreg.REG_SZ, key)
|
||||||
|
return "HKCU\\" + REGISTRY_PFAD
|
||||||
|
|
||||||
|
|
||||||
|
#: Einstellungen, die der Docker-Betrieb beim Start AUSDRÜCKLICH setzt
|
||||||
|
#: (`docker/worker/entrypoint.sh`) — hier für Windows übersetzt.
|
||||||
|
#:
|
||||||
|
#: `app_UpdateEnable` ist MakeMKVs Web-Kontakt. Er ist ab Werk an, und genau
|
||||||
|
#: deshalb steht er in der entrypoint.sh: „damit die Einstellung
|
||||||
|
#: nachvollziehbar ist und nicht versehentlich kippt". Quelle dort:
|
||||||
|
#: forum.makemkv.com/forum/viewtopic.php?t=20364
|
||||||
|
#:
|
||||||
|
#: Für Rippy hängt daran mehr als Kosmetik: Über diesen Kanal holt MakeMKV die
|
||||||
|
#: Disc-Schlüssel für 4K-UHD nach (Meldung 3338 „Downloading latest HK to …",
|
||||||
|
#: am 25.07.2026 auf einem Windows-PC gemessen — unter Linux passiert es nie).
|
||||||
|
#: Ohne ihn scheitert jede unbekannte UHD-Pressung.
|
||||||
|
#:
|
||||||
|
#: Auf dem Rechner des Commanders nachgesehen (29.08.2026): **nicht gesetzt.**
|
||||||
|
#: Die Werte daneben (`app_UpdateLastCheck`, `app_BackupDecrypted`) zeigen die
|
||||||
|
#: native Form — Wahrheitswerte stehen in der Registry als DWORD, nicht als
|
||||||
|
#: Text wie in der settings.conf.
|
||||||
|
DOCKER_EINSTELLUNGEN = {"app_UpdateEnable": 1}
|
||||||
|
|
||||||
|
|
||||||
|
def einstellungen_sicherstellen(plattform_name: str = None, setzer=None,
|
||||||
|
lesen=None) -> list:
|
||||||
|
"""Setzt fehlende MakeMKV-Einstellungen. Gibt zurück, was gesetzt wurde.
|
||||||
|
|
||||||
|
**Nur was FEHLT.** Wer den Web-Kontakt bewusst abgeschaltet hat, soll ihn
|
||||||
|
abgeschaltet behalten — Rippy stellt einen Ausgangszustand her, es
|
||||||
|
überstimmt keine Entscheidung.
|
||||||
|
"""
|
||||||
|
if not plattform(plattform_name).startswith("win"):
|
||||||
|
# Im Container macht das die entrypoint.sh, und zwar in der
|
||||||
|
# settings.conf. Hier nichts tun statt es doppelt zu machen.
|
||||||
|
return []
|
||||||
|
lesen = lesen or _registry_hat
|
||||||
|
setzer = setzer or _registry_dword
|
||||||
|
gesetzt = []
|
||||||
|
for name, wert in DOCKER_EINSTELLUNGEN.items():
|
||||||
|
try:
|
||||||
|
if lesen(name):
|
||||||
|
continue
|
||||||
|
setzer(name, wert)
|
||||||
|
gesetzt.append(name)
|
||||||
|
except OSError:
|
||||||
|
continue # Registry nicht schreibbar — kein Beinbruch
|
||||||
|
return gesetzt
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_hat(name: str) -> bool:
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD) as s:
|
||||||
|
winreg.QueryValueEx(s, name)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _registry_dword(name: str, wert: int) -> None:
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD) as s:
|
||||||
|
winreg.SetValueEx(s, name, 0, winreg.REG_DWORD, int(wert))
|
||||||
|
|
||||||
|
|
||||||
|
def key_zuruecknehmen(plattform_name: str = None, entferner=None) -> bool:
|
||||||
|
"""Einen abgelehnten Schlüssel wieder entfernen. True, wenn etwas wegging."""
|
||||||
|
if not plattform(plattform_name).startswith("win"):
|
||||||
|
return False
|
||||||
|
return (entferner or _aus_registry)()
|
||||||
|
|
||||||
|
|
||||||
|
def _aus_registry() -> bool:
|
||||||
|
import winreg
|
||||||
|
|
||||||
|
try:
|
||||||
|
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, REGISTRY_PFAD, 0,
|
||||||
|
winreg.KEY_ALL_ACCESS) as schluessel:
|
||||||
|
winreg.DeleteValue(schluessel, KEY_NAME)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False # war nicht da — auch gut
|
||||||
|
|
||||||
|
|
||||||
|
def key_pruefen(programm: str, laufen=None) -> dict:
|
||||||
|
"""Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt.
|
||||||
|
|
||||||
|
`{"ok": bool, "code": int|None, "grund": str}`.
|
||||||
|
|
||||||
|
## Warum GEFRAGT und nicht gerechnet (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Rippy hat den frischen Beta-Key aus dem Forum geholt und abgelegt — alles
|
||||||
|
richtig. Auf dem Rechner des Commanders antwortete MakeMKV darauf:
|
||||||
|
|
||||||
|
5020 „Der hinterlegte Aktivierungsschlüssel ist ungültig."
|
||||||
|
5021 „Diese Programmversion ist zu alt."
|
||||||
|
|
||||||
|
Sein MakeMKV 1.18.4 ist älter als der Schlüssel verlangt. Rippys
|
||||||
|
Update-Prüfung kann das NICHT sehen: makemkv.com antwortet seit Tagen mit
|
||||||
|
HTTP 525, und die Ausweichquellen kennen als höchste Fassung genau 1.18.4
|
||||||
|
(gemessen). Die einzige Instanz, die die Wahrheit kennt, ist MakeMKV
|
||||||
|
selbst.
|
||||||
|
|
||||||
|
## Warum das wichtiger ist, als es klingt
|
||||||
|
|
||||||
|
**Ein abgelehnter Schlüssel ist schlimmer als gar keiner.** Ohne Schlüssel
|
||||||
|
lief MakeMKV im abgelaufenen Testmodus und las die Disc immerhin noch (68
|
||||||
|
TINFO-Zeilen). Mit dem abgelehnten verweigerte es alles — 0 Titel, kein
|
||||||
|
Laufwerk. Wer also blind schreibt, verschlechtert den Zustand.
|
||||||
|
|
||||||
|
Der Aufruf ist billig: kein Datenträger nötig, MakeMKV meldet die
|
||||||
|
Freischaltung schon beim Start.
|
||||||
|
"""
|
||||||
|
if not programm:
|
||||||
|
return {"ok": False, "code": None, "grund": "MakeMKV nicht gefunden"}
|
||||||
|
laufen = laufen or _makemkvcon_laufen
|
||||||
|
try:
|
||||||
|
ausgabe = laufen(programm)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {"ok": False, "code": None,
|
||||||
|
"grund": "MakeMKV nicht befragbar (%s)" % type(e).__name__}
|
||||||
|
|
||||||
|
for zeile in text_von(ausgabe).splitlines():
|
||||||
|
if not zeile.startswith("MSG:"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
code = int(zeile[4:].split(",", 1)[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if code in KEY_ABGELEHNT:
|
||||||
|
return {"ok": False, "code": code, "grund": KRITISCHE_CODES[code]}
|
||||||
|
return {"ok": True, "code": None, "grund": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def _makemkvcon_laufen(programm: str):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
|
||||||
|
# `info disc:9999` fragt nur die Laufwerksliste ab — kein Datenträger
|
||||||
|
# nötig, und die Freischaltungs-Meldung kommt trotzdem.
|
||||||
|
ergebnis = subprocess.run([programm, "-r", "--noscan", "info", "disc:9999"],
|
||||||
|
capture_output=True, timeout=120,
|
||||||
|
creationflags=OHNE_FENSTER)
|
||||||
|
return ergebnis.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def key_setzen_und_pruefen(key: str, programm: str, plattform_name: str = None,
|
||||||
|
schreiber=None, pruefer=None, entferner=None) -> dict:
|
||||||
|
"""Schlüssel ablegen — und wieder wegnehmen, wenn MakeMKV ihn ablehnt.
|
||||||
|
|
||||||
|
Gibt `{"ok", "wohin", "grund"}` zurück. Siehe `key_pruefen`, warum das
|
||||||
|
Zurücknehmen wichtig ist.
|
||||||
|
"""
|
||||||
|
wohin = key_ablegen(key, plattform_name, schreiber=schreiber)
|
||||||
|
if not wohin:
|
||||||
|
return {"ok": False, "wohin": "", "grund": "kein Schlüssel angegeben"}
|
||||||
|
urteil = (pruefer or key_pruefen)(programm)
|
||||||
|
if urteil["ok"]:
|
||||||
|
return {"ok": True, "wohin": wohin, "grund": ""}
|
||||||
|
key_zuruecknehmen(plattform_name, entferner=entferner)
|
||||||
|
return {"ok": False, "wohin": wohin, "grund": urteil["grund"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _in_datei(key: str) -> str:
|
||||||
|
import os
|
||||||
|
|
||||||
|
from rippy.rip import makemkv_daten
|
||||||
|
|
||||||
|
ordner = makemkv_daten.DATEN_DIR
|
||||||
|
pfad = os.path.join(ordner, "settings.conf")
|
||||||
|
os.makedirs(ordner, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(pfad, encoding="utf-8", errors="replace") as f:
|
||||||
|
alt = f.read()
|
||||||
|
except OSError:
|
||||||
|
alt = ""
|
||||||
|
with open(pfad, "w", encoding="utf-8", newline="\n") as f:
|
||||||
|
f.write(makemkv_daten.settings_conf_zusammenfuehren(alt, key))
|
||||||
|
return pfad
|
||||||
@@ -62,13 +62,68 @@ es eins: `rippy`. Die Kopie ist weg, der Wächter-Test damit gegenstandslos.
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
|
def daten_verzeichnis(umgebung=None, plattform_name=None, existiert=None) -> str:
|
||||||
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
|
"""Wo makemkvcon SEINE Einstellungen sucht — je Rechner ein anderer Ort.
|
||||||
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
|
|
||||||
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
Beim ersten echten Rip-Versuch meldete makemkvcon neben dem Öffnen-Fehler
|
||||||
|
auch das hier (an seiner Disc gemessen):
|
||||||
|
|
||||||
|
MSG:5051 „Der Testzeitraum ist abgelaufen. Erwerben Sie bitte einen
|
||||||
|
Aktivierungsschlüssel"
|
||||||
|
|
||||||
|
Und das, obwohl in seinen Einstellungen ein gültiger Beta-Key lag.
|
||||||
|
|
||||||
|
Hier stand `os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")`. Im Container
|
||||||
|
stimmt das. Auf einem Windows-PC schrieb Rippy den Key damit nach
|
||||||
|
`/root/.MakeMKV` — was dort ein Ordner namens `root\\.MakeMKV` auf dem
|
||||||
|
aktuellen Laufwerk ist. MakeMKV liest `%USERPROFILE%\\.MakeMKV`
|
||||||
|
(gemessen am 26.07.2026 auf genau diesem Rechner, MakeMKV 1.18.4), und
|
||||||
|
dort lag **keine settings.conf**.
|
||||||
|
|
||||||
|
Der Key wurde also brav geholt, brav gespeichert — nur an einer Stelle,
|
||||||
|
die niemand liest. Eine Automatik, die ins Leere schreibt, ist schlimmer
|
||||||
|
als keine: Sie sieht aus wie erledigt.
|
||||||
|
|
||||||
|
`docker/worker/schluessel.py` kannte den richtigen Ort seit einem Monat.
|
||||||
|
Zwei Module, zwei Antworten, eine davon falsch — dieselbe Sorte Fund wie
|
||||||
|
`os.path` und `/app/media`.
|
||||||
|
|
||||||
|
Alles einspritzbar, damit jeder Betriebsfall ohne ihn prüfbar ist.
|
||||||
|
"""
|
||||||
|
umgebung = os.environ if umgebung is None else umgebung
|
||||||
|
existiert = existiert or os.path.isdir
|
||||||
|
gesetzt = (umgebung.get("MAKEMKV_DATA_DIR") or "").strip()
|
||||||
|
if gesetzt:
|
||||||
|
return gesetzt # docker-compose entscheidet, wenn es das tut
|
||||||
|
|
||||||
|
system = (plattform_name if plattform_name is not None else sys.platform).lower()
|
||||||
|
if not system.startswith("win"):
|
||||||
|
return "/root/.MakeMKV"
|
||||||
|
|
||||||
|
# Windows: MakeMKV ist über die Versionen umgezogen. Geprüft wird, welcher
|
||||||
|
# Ort wirklich da ist, statt einen zu behaupten. Der erste ist zugleich
|
||||||
|
# der Ort zum ANLEGEN, wenn es noch gar keinen gibt — MakeMKV legt sein
|
||||||
|
# Verzeichnis erst beim ersten Speichern an.
|
||||||
|
profil = umgebung.get("USERPROFILE") or os.path.expanduser("~")
|
||||||
|
kandidaten = [
|
||||||
|
os.path.join(profil, ".MakeMKV"),
|
||||||
|
os.path.join(umgebung.get("APPDATA") or "", "MakeMKV"),
|
||||||
|
os.path.join(umgebung.get("LOCALAPPDATA") or "", "MakeMKV"),
|
||||||
|
]
|
||||||
|
for pfad in kandidaten:
|
||||||
|
if pfad and existiert(pfad):
|
||||||
|
return pfad
|
||||||
|
return kandidaten[0]
|
||||||
|
|
||||||
|
|
||||||
|
#: Beim Laden ausgerechnet — die Aufrufer kennen ihn als Konstante.
|
||||||
|
DATEN_DIR = daten_verzeichnis()
|
||||||
|
|
||||||
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
|
||||||
KEYDB_NAME = "KEYDB.cfg"
|
KEYDB_NAME = "KEYDB.cfg"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Warum HandBrakes Ausgabe mit errors="replace" gelesen wird.
|
||||||
|
|
||||||
|
Der Test prueft die VORAUSSETZUNG, nicht die Zuweisung: dass HandBrakes
|
||||||
|
OEM-Bytes in cp1252 wirklich nicht dekodierbar sind. Gemessen am 30.08.2026
|
||||||
|
am mitgelieferten HandBrakeCLI 1.11.2 (AGENTS Regel D).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rippy.rip import handbrake_aufruf
|
||||||
|
|
||||||
|
|
||||||
|
def test_oem_umlaute_sprengen_cp1252():
|
||||||
|
"""u-Umlaut ist in CP850 das Byte 0x81 — und in cp1252 undefiniert."""
|
||||||
|
oem = "Glück über München".encode("cp850")
|
||||||
|
assert b"\x81" in oem
|
||||||
|
try:
|
||||||
|
oem.decode("cp1252")
|
||||||
|
raise AssertionError("cp1252 haette 0x81 ablehnen muessen")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_mit_der_eingestellten_fehlerbehandlung_bricht_nichts_mehr():
|
||||||
|
oem = "Glück über München".encode("cp850")
|
||||||
|
text = oem.decode("cp1252", handbrake_aufruf.HB_LESEN["errors"])
|
||||||
|
assert text.startswith("Gl") # ASCII bleibt heil
|
||||||
|
assert "ber" in text and "nchen" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_universal_newlines_bleiben_an():
|
||||||
|
"""HandBrake trennt Fortschrittszeilen mit CR — ohne text=True waere der
|
||||||
|
Fortschrittsbalken weg (deshalb NICHT binaer wie bei makemkvcon)."""
|
||||||
|
assert handbrake_aufruf.HB_LESEN["text"] is True
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
"""Wie makemkvcon angesprochen und wie seine Antwort gelesen wird.
|
||||||
|
|
||||||
|
## Der Befund des Commanders (29.08.2026)
|
||||||
|
|
||||||
|
> „Das Rippen der disk funktioniert nicht. makemkvcon endete mit Code 11 —
|
||||||
|
> letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
|
||||||
|
> entstanden"
|
||||||
|
|
||||||
|
In dem einen Satz stecken DREI Fehler, und alle drei sind an seinem Laufwerk
|
||||||
|
gemessen worden:
|
||||||
|
|
||||||
|
**1. Die Quellenangabe.** Rippy uebergab `dev:\\\\.\\G:`.
|
||||||
|
|
||||||
|
dev:\\\\.\\G: MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
|
||||||
|
dev:G: 68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich abgeschlossen"
|
||||||
|
|
||||||
|
**2. `Ö` statt `Ö`.** Das ist `Ö` als UTF-8 (C3 96), gelesen als cp1252 —
|
||||||
|
also eine Ausgabe, die anders geschrieben als gelesen wurde.
|
||||||
|
|
||||||
|
**3. Die Ursache fehlte.** Im selben Lauf meldete makemkvcon MSG:5051 „Der
|
||||||
|
Testzeitraum ist abgelaufen". Gesucht wurde aber nur nach dem ENGLISCHEN
|
||||||
|
„evaluation period has expired" — auf einem deutschen Windows trifft das nie.
|
||||||
|
Uebrig blieb die nichtssagende letzte Zeile.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rippy.rip import makemkv_aufruf as auf
|
||||||
|
|
||||||
|
|
||||||
|
# ── 1. Die Quellenangabe ────────────────────────────────────────────────
|
||||||
|
@pytest.mark.parametrize("geraet,erwartet", [
|
||||||
|
# DER Fall des Commanders: so meldet rippy.drives.windows das Laufwerk.
|
||||||
|
(r"\\.\G:", "dev:G:"),
|
||||||
|
(r"\\?\G:", "dev:G:"),
|
||||||
|
("G:", "dev:G:"),
|
||||||
|
("G:\\", "dev:G:"),
|
||||||
|
("G:/", "dev:G:"),
|
||||||
|
# Linux bleibt unveraendert — die Reparatur darf den Container nicht anfassen.
|
||||||
|
("/dev/sr0", "dev:/dev/sr0"),
|
||||||
|
("/dev/sr1", "dev:/dev/sr1"),
|
||||||
|
])
|
||||||
|
def test_jede_schreibweise_wird_zu_dem_was_makemkv_versteht(geraet, erwartet):
|
||||||
|
assert auf.quelle(geraet) == erwartet
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_geraet_kommt_nichts():
|
||||||
|
"""Ein `dev:` ohne Pfad waere eine Quellenangabe, die nichts benennt."""
|
||||||
|
assert auf.quelle("") == ""
|
||||||
|
assert auf.quelle(None) == ""
|
||||||
|
assert auf.quelle(" ") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_langer_windows_pfad_wird_nicht_verstuemmelt():
|
||||||
|
"""Die Kuerzung gilt NUR fuer die nackte Laufwerkswurzel."""
|
||||||
|
assert auf.quelle(r"\\.\D:\Ordner") == "dev:D:\\Ordner"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 2. Die Kodierung ────────────────────────────────────────────────────
|
||||||
|
def test_utf8_ausgabe_wird_richtig_gelesen():
|
||||||
|
"""So kommt es an, wenn Rippy die Konsole auf UTF-8 gestellt hat."""
|
||||||
|
assert auf.text_von("Das Öffnen".encode("utf-8")) == "Das Öffnen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cp1252_ausgabe_wird_AUCH_richtig_gelesen():
|
||||||
|
"""So kommt es an, wenn nicht — direkt an makemkvcon gemessen."""
|
||||||
|
assert auf.text_von("Das Öffnen".encode("cp1252")) == "Das Öffnen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_fehler_des_commanders_kommt_NICHT_zurueck():
|
||||||
|
"""`Ö` darf nirgends mehr entstehen."""
|
||||||
|
for kodierung in ("utf-8", "cp1252"):
|
||||||
|
gelesen = auf.text_von("Das Öffnen der Disk schlug fehl".encode(kodierung))
|
||||||
|
assert "Ã" not in gelesen, "Doppelt kodiert: %r" % gelesen
|
||||||
|
assert gelesen == "Das Öffnen der Disk schlug fehl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_bleibt_text_und_leer_bleibt_leer():
|
||||||
|
assert auf.text_von("schon Text") == "schon Text"
|
||||||
|
assert auf.text_von(b"") == ""
|
||||||
|
assert auf.text_von(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlesbare_bytes_werfen_nicht():
|
||||||
|
"""Eine kaputte Zeile darf keinen laufenden Rip umwerfen."""
|
||||||
|
assert auf.text_von(b"\xff\xfe kaputt") is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ── 3. Die Ursache ──────────────────────────────────────────────────────
|
||||||
|
def test_die_gemessenen_ursachen_haben_eine_nummer():
|
||||||
|
"""Nummern gelten in jeder Sprache, Texte nicht."""
|
||||||
|
assert 2024 in auf.KRITISCHE_CODES, "Unknown device"
|
||||||
|
assert 5051 in auf.KRITISCHE_CODES, "Testzeitraum abgelaufen"
|
||||||
|
assert 5020 in auf.KRITISCHE_CODES, "Aktivierungsschluessel ungueltig"
|
||||||
|
assert 5021 in auf.KRITISCHE_CODES, "Programmversion zu alt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jede_ursache_ist_auf_deutsch_erklaert():
|
||||||
|
"""Der Commander liest diese Zeile — eine Nummer allein hilft ihm nicht."""
|
||||||
|
for code, text in auf.KRITISCHE_CODES.items():
|
||||||
|
assert len(text) > 20, "%d ist nicht erklaert: %r" % (code, text)
|
||||||
|
assert not text.startswith("MSG"), code
|
||||||
|
|
||||||
|
|
||||||
|
# ── 4. Wohin gehoert der Schluessel — und was, wenn er abgelehnt wird ────
|
||||||
|
#
|
||||||
|
# Weiterer Befund vom 29.08.2026, in zwei Schritten gemessen:
|
||||||
|
#
|
||||||
|
# Erst schrieb Rippy den Beta-Key nach `/root/.MakeMKV/settings.conf` — ein
|
||||||
|
# Container-Pfad. Nach der Reparatur des Ordners blieb MSG:5051
|
||||||
|
# („Testzeitraum abgelaufen") trotzdem stehen. Nachgesehen, was auf dem
|
||||||
|
# Rechner wirklich existiert:
|
||||||
|
#
|
||||||
|
# C:\Users\TobisPC\.MakeMKV\ nur _private_data.tar (6,4 MB)
|
||||||
|
# HKCU\Software\MakeMKV app_UpdateLastCheck, app_SiteInfoString, …
|
||||||
|
#
|
||||||
|
# Unter Windows haelt MakeMKV seine Einstellungen in der REGISTRY. Rippy hat
|
||||||
|
# den Key also zweimal brav gespeichert — beide Male an einen Ort, den
|
||||||
|
# niemand liest.
|
||||||
|
#
|
||||||
|
# Und dann der Nachschlag: Mit dem Key in der Registry verschwand 5051, dafuer
|
||||||
|
# kam 5020 („Aktivierungsschluessel ungueltig") und 5021 („Diese
|
||||||
|
# Programmversion ist zu alt"). Sein MakeMKV 1.18.4 ist aelter als der
|
||||||
|
# aktuelle Beta-Key verlangt. WICHTIG: Ohne Key las MakeMKV die Disc noch (68
|
||||||
|
# TINFO-Zeilen), mit dem abgelehnten verweigerte es alles — 0 Titel.
|
||||||
|
# **Ein abgelehnter Schluessel ist schlimmer als gar keiner.**
|
||||||
|
|
||||||
|
|
||||||
|
def test_unter_windows_geht_der_key_in_die_registry():
|
||||||
|
gelandet = []
|
||||||
|
auf.key_ablegen("T-abc", plattform_name="win32",
|
||||||
|
schreiber=lambda k: gelandet.append(k) or r"HKCU\Software\MakeMKV")
|
||||||
|
assert gelandet == ["T-abc"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unter_linux_bleibt_es_die_datei():
|
||||||
|
"""Der Container-Weg darf sich durch die Reparatur NICHT aendern."""
|
||||||
|
gelandet = []
|
||||||
|
wohin = auf.key_ablegen("T-abc", plattform_name="linux",
|
||||||
|
datei_schreiber=lambda k: gelandet.append(k) or "/root/.MakeMKV/settings.conf")
|
||||||
|
assert gelandet == ["T-abc"]
|
||||||
|
assert wohin.endswith("settings.conf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_key_wird_nichts_geschrieben():
|
||||||
|
"""Sonst stuende ein leerer app_Key da — und der ist ein ungueltiger."""
|
||||||
|
assert auf.key_ablegen("", plattform_name="win32",
|
||||||
|
schreiber=lambda k: 1 / 0) == ""
|
||||||
|
assert auf.key_ablegen(None, plattform_name="win32",
|
||||||
|
schreiber=lambda k: 1 / 0) == ""
|
||||||
|
|
||||||
|
|
||||||
|
AUSGABE_ABGELEHNT = (
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet"\n'
|
||||||
|
'MSG:5020,0,0,"Der hinterlegte Aktivierungsschlüssel ist ungültig."\n'
|
||||||
|
'MSG:5021,0,0,"Diese Programmversion ist zu alt."\n'
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
AUSGABE_GUT = (
|
||||||
|
'MSG:1005,0,1,"MakeMKV v1.18.4 win(x64-release) gestartet"\n'
|
||||||
|
'DRV:0,2,999,12,"BD-RE BU40N","","G:"\n'
|
||||||
|
).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_abgelehnter_schluessel_wird_erkannt():
|
||||||
|
urteil = auf.key_pruefen("makemkvcon", laufen=lambda p: AUSGABE_ABGELEHNT)
|
||||||
|
assert urteil["ok"] is False
|
||||||
|
assert urteil["code"] == 5020
|
||||||
|
assert "nicht angenommen" in urteil["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_angenommener_schluessel_auch():
|
||||||
|
assert auf.key_pruefen("makemkvcon", laufen=lambda p: AUSGABE_GUT)["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_makemkv_wird_nichts_behauptet():
|
||||||
|
"""„Nicht befragbar" ist keine Aussage ueber den Schluessel."""
|
||||||
|
urteil = auf.key_pruefen("")
|
||||||
|
assert urteil["ok"] is False
|
||||||
|
assert "nicht gefunden" in urteil["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_abgelehnter_schluessel_wird_WIEDER_ENTFERNT():
|
||||||
|
"""Der Kern des Befundes: Ohne Key las MakeMKV die Disc noch, mit dem
|
||||||
|
abgelehnten verweigerte es alles. Blind schreiben verschlechtert."""
|
||||||
|
geschrieben, entfernt = [], []
|
||||||
|
ergebnis = auf.key_setzen_und_pruefen(
|
||||||
|
"T-zualt", "makemkvcon", plattform_name="win32",
|
||||||
|
schreiber=lambda k: geschrieben.append(k) or r"HKCU\Software\MakeMKV",
|
||||||
|
pruefer=lambda p: {"ok": False, "code": 5021,
|
||||||
|
"grund": auf.KRITISCHE_CODES[5021]},
|
||||||
|
entferner=lambda: entfernt.append(True) or True)
|
||||||
|
assert geschrieben == ["T-zualt"]
|
||||||
|
assert entfernt == [True], "der abgelehnte Schluessel muss wieder weg"
|
||||||
|
assert ergebnis["ok"] is False
|
||||||
|
assert "zu alt" in ergebnis["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ein_angenommener_schluessel_bleibt_liegen():
|
||||||
|
entfernt = []
|
||||||
|
ergebnis = auf.key_setzen_und_pruefen(
|
||||||
|
"T-gut", "makemkvcon", plattform_name="win32",
|
||||||
|
schreiber=lambda k: r"HKCU\Software\MakeMKV",
|
||||||
|
pruefer=lambda p: {"ok": True, "code": None, "grund": ""},
|
||||||
|
entferner=lambda: entfernt.append(True) or True)
|
||||||
|
assert ergebnis["ok"] is True
|
||||||
|
assert entfernt == [], "ein gueltiger Schluessel darf nicht angefasst werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_codes_stehen_so_da_wie_gemessen():
|
||||||
|
"""Waechter gegen den Fehler, den ich am 29.08.2026 selbst gemacht habe:
|
||||||
|
5021 aus dem Kopf mit „Volume-Key unbekannt" zu beschriften."""
|
||||||
|
assert "zu alt" in auf.KRITISCHE_CODES[5021]
|
||||||
|
assert "Volume" not in auf.KRITISCHE_CODES[5021]
|
||||||
|
assert auf.KEY_ABGELEHNT == (5020, 5021)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 5. Was der Docker-Betrieb setzt, gilt auch auf Windows ──────────────
|
||||||
|
#
|
||||||
|
# Commander 29.08.2026: „Prüfe doch einfach mal wie die Docker Rippy heute
|
||||||
|
# funktioniert. Schau was du davon abschließend übernehmen kannst. Wichtig ist
|
||||||
|
# das du das für Windows Umschreibst."
|
||||||
|
#
|
||||||
|
# `docker/worker/entrypoint.sh` setzt vor JEDEM Worker-Start zwei Dinge in
|
||||||
|
# MakeMKVs settings.conf: `app_Key` und `app_UpdateEnable`. Der Key war schon
|
||||||
|
# uebernommen — `app_UpdateEnable` nicht.
|
||||||
|
#
|
||||||
|
# Das ist MakeMKVs Web-Kontakt. Ueber ihn holt MakeMKV die Disc-Schluessel
|
||||||
|
# fuer 4K-UHD nach (Meldung 3338, auf Windows gemessen; unter Linux passiert
|
||||||
|
# es nie). Auf dem Rechner des Commanders nachgesehen: NICHT gesetzt.
|
||||||
|
#
|
||||||
|
# „Fuer Windows umschreiben" heisst hier konkret: Registry statt Datei, und
|
||||||
|
# DWORD statt Text — die Nachbarwerte (`app_BackupDecrypted`) zeigen die Form.
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_setzt_was_der_entrypoint_setzt():
|
||||||
|
gesetzt = {}
|
||||||
|
namen = auf.einstellungen_sicherstellen(
|
||||||
|
plattform_name="win32",
|
||||||
|
lesen=lambda n: False,
|
||||||
|
setzer=lambda n, w: gesetzt.__setitem__(n, w))
|
||||||
|
assert "app_UpdateEnable" in namen
|
||||||
|
assert gesetzt["app_UpdateEnable"] == 1, "in der Registry ein DWORD, kein Text"
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_vorhandene_einstellung_wird_NICHT_ueberschrieben():
|
||||||
|
"""Wer den Web-Kontakt bewusst abgeschaltet hat, behaelt ihn abgeschaltet.
|
||||||
|
Rippy stellt einen Ausgangszustand her, es ueberstimmt keine Entscheidung."""
|
||||||
|
angefasst = []
|
||||||
|
namen = auf.einstellungen_sicherstellen(
|
||||||
|
plattform_name="win32",
|
||||||
|
lesen=lambda n: True,
|
||||||
|
setzer=lambda n, w: angefasst.append(n))
|
||||||
|
assert namen == [] and angefasst == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_macht_das_der_entrypoint():
|
||||||
|
"""Dort steht es in der settings.conf — doppelt waere zwei Wahrheiten."""
|
||||||
|
assert auf.einstellungen_sicherstellen(plattform_name="linux",
|
||||||
|
lesen=lambda n: False,
|
||||||
|
setzer=lambda n, w: 1 / 0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_eine_gesperrte_registry_wirft_nicht():
|
||||||
|
"""Ein Einstellungswert ist kein Grund, warum nichts geht."""
|
||||||
|
def sperrt(n, w):
|
||||||
|
raise OSError(5, "Zugriff verweigert")
|
||||||
|
|
||||||
|
assert auf.einstellungen_sicherstellen(plattform_name="win32",
|
||||||
|
lesen=lambda n: False,
|
||||||
|
setzer=sperrt) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_der_entrypoint_und_windows_kennen_DIESELBEN_namen():
|
||||||
|
"""Waechter: Kommt in der entrypoint.sh eine Einstellung dazu, muss sie
|
||||||
|
hier auftauchen — sonst driften Docker und Windows auseinander."""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
pfad = os.path.join(hier, "..", "..", "..", "docker", "worker", "entrypoint.sh")
|
||||||
|
with io.open(os.path.abspath(pfad), encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
im_entrypoint = set(re.findall(r"\bapp_[A-Za-z]+", text))
|
||||||
|
# app_Key laeuft ueber `key_ablegen`, der Rest ueber DOCKER_EINSTELLUNGEN.
|
||||||
|
offen = im_entrypoint - {"app_Key"} - set(auf.DOCKER_EINSTELLUNGEN)
|
||||||
|
assert not offen, "entrypoint.sh setzt %s — Windows nicht" % sorted(offen)
|
||||||
@@ -1,600 +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)),
|
|
||||||
)
|
|
||||||
if optionen.get("ablage"):
|
|
||||||
self._lauf.beginne("ablage")
|
|
||||||
melden("Ablage wird gespeichert …")
|
|
||||||
_ablage_speichern(optionen["ablage"])
|
|
||||||
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"]
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
from rippy import config, store
|
|
||||||
|
|
||||||
werte = config.laden()
|
|
||||||
store.verbinden(config.datenbank_url(werte))
|
|
||||||
store.init_db()
|
|
||||||
aktuell = store.get_settings() or {}
|
|
||||||
aktuell["outputDir"] = pfad
|
|
||||||
store.save_settings(aktuell)
|
|
||||||
|
|
||||||
|
|
||||||
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" 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,
|
|
||||||
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;
|
|
||||||
$('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
|
|
||||||
@@ -361,6 +361,47 @@ def has_active_job(device: str) -> bool:
|
|||||||
return zeile is not None
|
return zeile is not None
|
||||||
|
|
||||||
|
|
||||||
|
# Ein Job ist erst fertig, wenn er FERTIG ist. Die Kompression gehoert dazu.
|
||||||
|
#
|
||||||
|
# ⚠️ Nicht dasselbe wie `has_active_job`. Das fragt „haelt gerade jemand das
|
||||||
|
# Laufwerk?" — waehrend der Kompression tut das niemand, ein Auswurf ist dann
|
||||||
|
# erlaubt. Hier wird gefragt „ist der Vorgang durch?", und das ist er nicht.
|
||||||
|
JOB_OFFEN = ("pending", "running", "transcoding", "canceling")
|
||||||
|
|
||||||
|
|
||||||
|
def job_offen(device: str) -> bool:
|
||||||
|
"""True, solange auf dem Geraet ein Job noch nicht abgeschlossen ist.
|
||||||
|
|
||||||
|
## Warum es diese zweite Frage gibt (Befund 29.08.2026)
|
||||||
|
|
||||||
|
Der Commander meldete einen Rip-Fehlschlag „bei der Komprimierung", obwohl
|
||||||
|
der Rip laengst durch war. Der Ablauf dahinter:
|
||||||
|
|
||||||
|
1. Rip fertig → Rippy wirft die Disc aus (Standardeinstellung)
|
||||||
|
2. Status wird auf `transcoding` gesetzt — und ab hier sagte
|
||||||
|
`has_active_job` NEIN, das Laufwerk sei frei
|
||||||
|
3. Die Disc-Wache sieht beim Auswurf einen Statuswechsel und meldet
|
||||||
|
„eingelegt"
|
||||||
|
4. Die Vollautomatik (`autoRipStart`) startet einen ZWEITEN Rip — auf
|
||||||
|
ein Laufwerk, dessen Schublade gerade herausfaehrt
|
||||||
|
|
||||||
|
Der zweite Rip lief in ein leeres Laufwerk und endete mit „Das Oeffnen der
|
||||||
|
Disk schlug fehl". Auf dem Bildschirm sah das aus, als sei die Kompression
|
||||||
|
gescheitert — sie lief in Wahrheit ungestoert weiter.
|
||||||
|
|
||||||
|
Solange ein Vorgang auf diesem Laufwerk laeuft, faengt die Automatik
|
||||||
|
keinen zweiten an. Von Hand darf der Commander weiterhin alles.
|
||||||
|
"""
|
||||||
|
with engine_holen().connect() as conn:
|
||||||
|
zeile = conn.execute(
|
||||||
|
select(jobs.c.id)
|
||||||
|
.where(jobs.c.device == device)
|
||||||
|
.where(jobs.c.status.in_(JOB_OFFEN))
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return zeile is not None
|
||||||
|
|
||||||
|
|
||||||
def meta_merken(job_id: str, **felder) -> None:
|
def meta_merken(job_id: str, **felder) -> None:
|
||||||
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
|
||||||
|
|
||||||
|
|||||||
@@ -105,28 +105,20 @@ def test_die_auskunft_im_container_nennt_die_container_orte():
|
|||||||
|
|
||||||
|
|
||||||
# ── Die Laufwerke ───────────────────────────────────────────────────────
|
# ── 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():
|
@pytest.mark.parametrize("werte,container", [
|
||||||
"""Auf dem Linux-Runner der Ampel gibt es kein GetDriveTypeW. Das muss
|
({}, True), # Media + /app/temp
|
||||||
eine leere Liste sein, kein Fehler."""
|
({"storage": {"medien": "D:\\M"}}, False), # gleiches Laufwerk
|
||||||
assert betrieb.windows_laufwerke(art=None, buchstaben=[]) == []
|
({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False),
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("werte,container,erwartet", [
|
|
||||||
({}, True, 2), # Media + /app/temp
|
|
||||||
({"storage": {"medien": "D:\\M"}}, False, 1), # gleiches Laufwerk
|
|
||||||
({"storage": {"medien": "D:\\M", "temp": "E:\\R"}}, False, 2),
|
|
||||||
])
|
])
|
||||||
def test_platz_orte_zaehlen_nicht_doppelt(werte, container, erwartet):
|
def test_es_stehen_immer_beide_orte_da(werte, container):
|
||||||
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
"""Ablage UND Arbeitsverzeichnis — in jedem Betrieb, mit Pfad.
|
||||||
und ist eine."""
|
|
||||||
|
Geaendert am 29.08.2026 auf Wunsch des Commanders: „bei ‚Platz für rippy'
|
||||||
|
sollte eher das arbeitsverzeichnis und der Ablagepfad sein." Vorher fiel
|
||||||
|
die zweite Zeile weg, wenn beide auf derselben Platte lagen.
|
||||||
|
"""
|
||||||
orte = betrieb.platz_orte(werte, container=container, plattform_name="win32")
|
orte = betrieb.platz_orte(werte, container=container, plattform_name="win32")
|
||||||
assert len(orte) == erwartet
|
assert len(orte) == 2
|
||||||
|
assert all(o["pfad"] for o in orte), orte
|
||||||
|
|||||||
+260
-193
@@ -1,193 +1,260 @@
|
|||||||
"""Der Betriebsmodus und seine Faehigkeiten.
|
"""Der Betriebsmodus und seine Faehigkeiten.
|
||||||
|
|
||||||
## Warum es diese Tests gibt
|
## Warum es diese Tests gibt
|
||||||
|
|
||||||
Commander am 28.08.2026, zum Windows-Fenster:
|
Commander am 28.08.2026, zum Windows-Fenster:
|
||||||
|
|
||||||
Worker erreichbar: 0 von 1
|
Worker erreichbar: 0 von 1
|
||||||
Kein Worker antwortet — Pruefen: docker compose ps
|
Kein Worker antwortet — Pruefen: docker compose ps
|
||||||
Container-Platte: unbekannt
|
Container-Platte: unbekannt
|
||||||
Freigaben: keine eingehaengt
|
Freigaben: keine eingehaengt
|
||||||
|
|
||||||
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Die Oberflaeche hatte
|
||||||
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
nie erfahren, worauf sie laeuft — also hat sie angenommen.
|
||||||
|
|
||||||
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
Alle Pruefungen hier sind eingespritzt: Jeder Betriebsfall ist pruefbar, ohne
|
||||||
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
ihn herzustellen. Das ist der Punkt — sonst waere „laeuft im Container" nur
|
||||||
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
auf einem Container-Rechner testbar, und genau dort faellt der Windows-Fehler
|
||||||
nie auf.
|
nie auf.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from rippy import betrieb
|
from rippy import betrieb
|
||||||
|
|
||||||
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
B = chr(92) # Backslash, nie woertlich (siehe test_pfade)
|
||||||
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
|
||||||
|
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
||||||
|
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
||||||
# ── Plattform ───────────────────────────────────────────────────────────
|
|
||||||
def test_plattform_wird_zu_einem_klaren_namen():
|
|
||||||
assert betrieb.plattform("win32") == "windows"
|
# ── Plattform ───────────────────────────────────────────────────────────
|
||||||
assert betrieb.plattform("linux") == "linux"
|
def test_plattform_wird_zu_einem_klaren_namen():
|
||||||
assert betrieb.plattform("darwin") == "macos"
|
assert betrieb.plattform("win32") == "windows"
|
||||||
|
assert betrieb.plattform("linux") == "linux"
|
||||||
|
assert betrieb.plattform("darwin") == "macos"
|
||||||
def test_unbekannte_plattform_gilt_als_linux():
|
|
||||||
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
|
||||||
etwas Bekanntes ist besser als ein leeres Feld."""
|
def test_unbekannte_plattform_gilt_als_linux():
|
||||||
assert betrieb.plattform("freebsd14") == "linux"
|
"""FreeBSD und Verwandte verhalten sich wie Linux — und ein Rueckfall auf
|
||||||
|
etwas Bekanntes ist besser als ein leeres Feld."""
|
||||||
|
assert betrieb.plattform("freebsd14") == "linux"
|
||||||
# ── Container ───────────────────────────────────────────────────────────
|
|
||||||
def test_container_wird_gemessen_nicht_geraten():
|
|
||||||
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
# ── Container ───────────────────────────────────────────────────────────
|
||||||
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
def test_container_wird_gemessen_nicht_geraten():
|
||||||
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
"""`/.dockerenv` legt Docker selbst an — verlaesslicher als eine
|
||||||
assert betrieb.im_container(lambda p: False) is False
|
Umgebungsvariable, die jeder setzen und vergessen kann."""
|
||||||
|
assert betrieb.im_container(lambda p: p == "/.dockerenv") is True
|
||||||
|
assert betrieb.im_container(lambda p: False) is False
|
||||||
# ── Modus ───────────────────────────────────────────────────────────────
|
|
||||||
def test_celery_heisst_verteilt():
|
|
||||||
assert betrieb.modus(DOCKER) == "verteilt"
|
# ── Modus ───────────────────────────────────────────────────────────────
|
||||||
|
def test_celery_heisst_verteilt():
|
||||||
|
assert betrieb.modus(DOCKER) == "verteilt"
|
||||||
def test_lokale_queue_heisst_standalone():
|
|
||||||
assert betrieb.modus(WINDOWS) == "standalone"
|
|
||||||
|
def test_lokale_queue_heisst_standalone():
|
||||||
|
assert betrieb.modus(WINDOWS) == "standalone"
|
||||||
def test_ohne_angaben_gilt_standalone():
|
|
||||||
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
|
||||||
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
def test_ohne_angaben_gilt_standalone():
|
||||||
assert betrieb.modus({}) == "standalone"
|
"""Der harmlosere Rueckfall: Ein standalone-Rippy zeigt nichts an, was es
|
||||||
|
nicht gibt. Andersherum entstuenden die Phantom-Worker wieder."""
|
||||||
|
assert betrieb.modus({}) == "standalone"
|
||||||
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
|
||||||
def test_windows_hat_KEINE_externen_worker():
|
|
||||||
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
# ── Die Faehigkeiten — hier sass der gemeldete Fehler ───────────────────
|
||||||
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
def test_windows_hat_KEINE_externen_worker():
|
||||||
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
"""DER Befund: „Worker erreichbar: 0 von 1 — Kein Worker antwortet."
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
Unter Windows rippt Rippy selbst; es gibt keinen zweiten, auf den man
|
||||||
assert a["kann"]["externe_worker"] is False
|
warten koennte. Die Meldung war nicht nur unpassend, sie war falsch."""
|
||||||
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert a["kann"]["externe_worker"] is False
|
||||||
def test_windows_kennt_KEINE_container_pfade():
|
|
||||||
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
|
||||||
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
def test_windows_kennt_KEINE_container_pfade():
|
||||||
falsch."""
|
"""`/app/media` ist ein Pfad IM Container. Auf Windows heisst der Ordner
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
anders und liegt woanders — ein damit vorbelegtes Feld ist schlicht
|
||||||
assert a["kann"]["container_pfade"] is False
|
falsch."""
|
||||||
assert "/app/" not in a["ablage_vorgabe"]
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert a["kann"]["container_pfade"] is False
|
||||||
|
assert "/app/" not in a["ablage_vorgabe"]
|
||||||
def test_windows_haengt_keine_freigaben_ein():
|
|
||||||
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
|
||||||
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
def test_windows_haengt_keine_freigaben_ein():
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
"""Unter Windows gibt man einen UNC-Pfad an, fertig. Eine Maske zum
|
||||||
assert a["kann"]["freigaben_einhaengen"] is False
|
Einhaengen waere eine Bedienung, die ins Leere fuehrt."""
|
||||||
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert a["kann"]["freigaben_einhaengen"] is False
|
||||||
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
|
||||||
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
|
||||||
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
def test_windows_darf_seine_werkzeuge_selbst_pflegen():
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
"""Im Image steckt alles fest, ein Update ist ein Rebuild. Nur die native
|
||||||
assert a["kann"]["werkzeuge_verwalten"] is True
|
Installation kann MakeMKV/HandBrake selbst beschaffen."""
|
||||||
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert a["kann"]["werkzeuge_verwalten"] is True
|
||||||
def test_docker_behaelt_alles_was_es_hatte():
|
|
||||||
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
|
||||||
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
def test_docker_behaelt_alles_was_es_hatte():
|
||||||
assert a["kann"]["externe_worker"] is True
|
"""Die Windows-Reparatur darf dem Docker-Betrieb nichts wegnehmen."""
|
||||||
assert a["kann"]["container_pfade"] is True
|
a = betrieb.auskunft(DOCKER, container=True, plattform_name="linux")
|
||||||
assert a["kann"]["freigaben_einhaengen"] is True
|
assert a["kann"]["externe_worker"] is True
|
||||||
assert a["ablage_vorgabe"] == "/app/media"
|
assert a["kann"]["container_pfade"] is True
|
||||||
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
assert a["kann"]["freigaben_einhaengen"] is True
|
||||||
|
assert a["ablage_vorgabe"] == "/app/media"
|
||||||
|
assert a["hilfe_befehl"] == "docker compose -p rippy ps"
|
||||||
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
|
||||||
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
|
||||||
niemand befolgen kann."""
|
def test_der_docker_hinweis_taucht_ausserhalb_von_docker_NICHT_auf():
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
"""„Pruefen: docker compose ps" auf einem Windows-PC ist ein Rat, den
|
||||||
assert a["hilfe_befehl"] == ""
|
niemand befolgen kann."""
|
||||||
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert a["hilfe_befehl"] == ""
|
||||||
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
|
||||||
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
|
||||||
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
def test_docker_allinone_hat_container_aber_keine_externen_worker():
|
||||||
der von aussen Jobs uebernimmt."""
|
"""Der Fall, an dem ein blosser Modus-Name zerbrechen wuerde: EIN
|
||||||
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
Container, der alles selbst macht. Er hat Container-Pfade, aber niemanden,
|
||||||
container=True, plattform_name="linux")
|
der von aussen Jobs uebernimmt."""
|
||||||
assert a["kann"]["container_pfade"] is True
|
a = betrieb.auskunft({"profil": "standalone", "queue": {"treiber": "lokal"}},
|
||||||
assert a["kann"]["externe_worker"] is False
|
container=True, plattform_name="linux")
|
||||||
|
assert a["kann"]["container_pfade"] is True
|
||||||
|
assert a["kann"]["externe_worker"] is False
|
||||||
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
|
||||||
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
|
||||||
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
# ── Die Ablage-Vorgabe ──────────────────────────────────────────────────
|
||||||
container=False, plattform_name="win32")
|
def test_eingestellte_ablage_schlaegt_jede_vorgabe():
|
||||||
assert a["ablage_vorgabe"] == "D:\\Filme"
|
a = betrieb.auskunft({"storage": {"medien": "D:\\Filme"}},
|
||||||
|
container=False, plattform_name="win32")
|
||||||
|
assert a["ablage_vorgabe"] == "D:\\Filme"
|
||||||
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch):
|
|
||||||
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test")
|
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
def test_native_vorgabe_liegt_beim_nutzer():
|
||||||
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test")
|
"""Der USERPROFILE-Sonderzweig ist mit der Standalone-App gegangen
|
||||||
assert a["ablage_vorgabe"].endswith("Rippy")
|
(Rippy v5, Entscheid 7) — nativ heisst hier nur noch: ein
|
||||||
|
Entwickler-Lauf ausserhalb des Containers, und die Vorgabe liegt im
|
||||||
|
Benutzerordner dieser Maschine."""
|
||||||
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
import os
|
||||||
def test_die_auskunft_ist_vollstaendig():
|
|
||||||
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
assert a["ablage_vorgabe"].startswith(os.path.expanduser("~"))
|
||||||
einen Bereich. Lieber hier rot."""
|
assert a["ablage_vorgabe"].endswith("Rippy")
|
||||||
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
|
||||||
assert set(a) == {"modus", "plattform", "im_container", "kann",
|
|
||||||
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
|
# ── Der Vertrag als Ganzes ──────────────────────────────────────────────
|
||||||
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
def test_die_auskunft_ist_vollstaendig():
|
||||||
"container_pfade", "werkzeuge_verwalten",
|
"""Ein fehlendes Feld waere im UI ein `undefined` — und `undefined` ist
|
||||||
"frei_blaettern"}
|
in JavaScript falsch, also verschwaende die Oberflaeche stillschweigend
|
||||||
assert all(isinstance(v, bool) for v in a["kann"].values())
|
einen Bereich. Lieber hier rot."""
|
||||||
|
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert set(a) == {"modus", "plattform", "im_container", "kann",
|
||||||
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
|
"ablage_vorgabe", "arbeits_vorgabe", "hilfe_befehl"}
|
||||||
def test_windows_misst_NICHT_in_app_media():
|
assert set(a["kann"]) == {"externe_worker", "freigaben_einhaengen",
|
||||||
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
|
"container_pfade", "werkzeuge_verwalten",
|
||||||
|
"frei_blaettern"}
|
||||||
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
|
assert all(isinstance(v, bool) for v in a["kann"].values())
|
||||||
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
|
|
||||||
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
|
|
||||||
"""
|
# ── Wo Platz gemessen wird ──────────────────────────────────────────────
|
||||||
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
|
def test_windows_misst_NICHT_in_app_media():
|
||||||
assert orte, "es muss mindestens ein Ort gemessen werden"
|
"""DER Befund: Im Windows-Fenster stand „Platz fuer Rippy: unbekannt".
|
||||||
assert all("/app/" not in o["pfad"] for o in orte), orte
|
|
||||||
|
`main.py` mass fest an `/app/media` und `/app/temp`. Beide gibt es unter
|
||||||
|
Windows nicht, `shutil.disk_usage` warf, die Liste blieb leer — und aus
|
||||||
def test_docker_misst_weiter_die_container_pfade():
|
einer Nichtauskunft wurde eine Anzeige, die wie eine Auskunft aussieht.
|
||||||
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
|
"""
|
||||||
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
orte = betrieb.platz_orte(WINDOWS, container=False, plattform_name="win32")
|
||||||
|
assert orte, "es muss mindestens ein Ort gemessen werden"
|
||||||
|
assert all("/app/" not in o["pfad"] for o in orte), orte
|
||||||
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt():
|
|
||||||
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte
|
|
||||||
und ist eine."""
|
def test_docker_misst_weiter_die_container_pfade():
|
||||||
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
orte = betrieb.platz_orte(DOCKER, container=True, plattform_name="linux")
|
||||||
container=False, plattform_name="win32")
|
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
||||||
assert len(orte) == 1, orte
|
|
||||||
|
|
||||||
|
def test_beide_orte_stehen_da_MIT_pfad():
|
||||||
def test_zweites_laufwerk_bekommt_eine_eigene_zeile():
|
"""Geaenderte Regel (Commander 29.08.2026).
|
||||||
orte = betrieb.platz_orte(
|
|
||||||
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
> „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der
|
||||||
container=False, plattform_name="win32")
|
> Ablagepfad sein."
|
||||||
assert len(orte) == 2, orte
|
|
||||||
|
Vorher fiel die zweite Zeile weg, wenn beide auf demselben Laufwerk
|
||||||
|
lagen — mit der Begruendung, zweimal dieselbe Zahl sei eine Auskunft. Das
|
||||||
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
stimmt fuer die ZAHL und war der falsche Schluss fuer den ORT: Auf seinem
|
||||||
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
|
Bildschirm stand nur „Platz für Rippy: 59,9 von 232 GB frei", und welche
|
||||||
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
Ordner gemeint waren, sah man nicht.
|
||||||
da = r"C:\Users\Test"
|
"""
|
||||||
assert betrieb.naechster_vorhandener(
|
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
||||||
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
|
container=False, plattform_name="win32")
|
||||||
|
assert [o["name"] for o in orte] == ["Ablage", "Arbeitsverzeichnis"]
|
||||||
|
assert orte[0]["pfad"] == r"D:\Filme"
|
||||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
assert orte[1]["pfad"] == r"D:\Filme\_arbeit"
|
||||||
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
|
||||||
existiert=lambda p: False) == ""
|
|
||||||
|
def test_gleiches_laufwerk_wird_als_solches_GEMELDET():
|
||||||
|
"""Statt die Zeile wegzulassen: ausdruecklich sagen, dass es dieselbe
|
||||||
|
Platte ist. Sonst wirken zwei gleiche Zahlen wie ein Fehler."""
|
||||||
|
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
|
||||||
|
container=False, plattform_name="win32")
|
||||||
|
assert all(o["gleiches_laufwerk"] for o in orte)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zwei_laufwerke_werden_als_getrennt_gemeldet():
|
||||||
|
orte = betrieb.platz_orte(
|
||||||
|
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
|
||||||
|
container=False, plattform_name="win32")
|
||||||
|
assert len(orte) == 2, orte
|
||||||
|
assert not any(o["gleiches_laufwerk"] for o in orte)
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_bleibt_es_bei_den_zwei_container_orten():
|
||||||
|
"""Der Docker-Weg darf sich durch die Aenderung NICHT verschieben."""
|
||||||
|
orte = betrieb.platz_orte({}, container=True, plattform_name="linux")
|
||||||
|
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
||||||
|
"""Frisch installiert gibt es den Ablage-Ordner noch nicht. Der Nutzer
|
||||||
|
will trotzdem wissen, ob auf dem LAUFWERK Platz ist."""
|
||||||
|
da = r"C:\Users\Test"
|
||||||
|
assert betrieb.naechster_vorhandener(
|
||||||
|
r"C:\Users\Test\Videos\Rippy", existiert=lambda p: p == da) == da
|
||||||
|
|
||||||
|
|
||||||
|
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||||
|
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
||||||
|
existiert=lambda p: False) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_eingestellte_orte_schlagen_die_vorgabe():
|
||||||
|
"""Der Befund vom 30.08.2026: „die verzeichnise sind andere als dort
|
||||||
|
steht."
|
||||||
|
|
||||||
|
Die Oberflaeche schreibt `outputDir`/`workDir` in die Datenbank,
|
||||||
|
`betrieb` las `storage.*` aus der Konfigurationsdatei — und die ist
|
||||||
|
leer. In „Einstellungen -> System" stand deshalb dauerhaft die Vorgabe,
|
||||||
|
waehrend der Rip woanders hin lief.
|
||||||
|
"""
|
||||||
|
werte = betrieb.mit_einstellungen(
|
||||||
|
{"storage": {"medien": "", "temp": ""}},
|
||||||
|
{"outputDir": "F:" + B + "Filme", "workDir": "F:" + B})
|
||||||
|
assert werte["storage"]["medien"] == "F:" + B + "Filme"
|
||||||
|
assert werte["storage"]["temp"] == "F:" + B
|
||||||
|
assert betrieb.ablage_vorgabe(werte, False, "windows") == "F:" + B + "Filme"
|
||||||
|
assert betrieb.arbeits_vorgabe(werte, False, "windows") == "F:" + B
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_einstellung_bleibt_die_vorgabe():
|
||||||
|
"""Leere Felder duerfen NICHTS ueberschreiben — sonst waere ein
|
||||||
|
ungesetztes Feld schlimmer als gar keine Bruecke."""
|
||||||
|
vorher = {"storage": {"medien": "/app/media", "temp": "/app/temp"}}
|
||||||
|
werte = betrieb.mit_einstellungen(vorher, {"outputDir": "", "workDir": " "})
|
||||||
|
assert werte["storage"]["medien"] == "/app/media"
|
||||||
|
assert werte["storage"]["temp"] == "/app/temp"
|
||||||
|
# ... und das Original bleibt unangetastet (pure Funktion)
|
||||||
|
assert vorher["storage"]["medien"] == "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bruecke_vertraegt_leere_eingaben():
|
||||||
|
assert betrieb.mit_einstellungen({}, {})["storage"] == {}
|
||||||
|
assert betrieb.mit_einstellungen(None, None)["storage"] == {}
|
||||||
|
|||||||
@@ -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,280 +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", "port", "autostart",
|
|
||||||
"verknuepfungen", "werkzeuge_holen"}
|
|
||||||
assert v["port"] == 7788
|
|
||||||
assert v["ziel"] and v["ablage"]
|
|
||||||
assert "/app/" not in v["ablage"], "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,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"
|
|
||||||
@@ -52,3 +52,36 @@ def test_naechster_vorhandener_auf_posix():
|
|||||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||||
assert pfade.naechster_vorhandener("Z:" + B + "nix",
|
assert pfade.naechster_vorhandener("Z:" + B + "nix",
|
||||||
existiert=lambda p: False) == ""
|
existiert=lambda p: False) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Befund 30.08.2026: Der Schluss-Trenner wurde immer abgestreift.
|
||||||
|
|
||||||
|
Aus "F:\" wurde "F:" — unter Windows der AKTUELLE Ordner auf
|
||||||
|
Laufwerk F, nicht dessen Wurzel. Wer das Ergebnis weiterverbindet,
|
||||||
|
landet woanders; genau so verschwanden in `rohdaten.kandidaten` 16,5 GB.
|
||||||
|
"""
|
||||||
|
assert pfade.naechster_vorhandener(
|
||||||
|
"F:" + B, existiert=lambda p: True) == "F:" + B
|
||||||
|
# UNC-Wurzel genauso
|
||||||
|
unc = B + B + "server" + B + "freigabe" + B
|
||||||
|
assert pfade.naechster_vorhandener(unc, existiert=lambda p: True) == unc
|
||||||
|
# Und POSIX bleibt POSIX
|
||||||
|
assert pfade.naechster_vorhandener("/", existiert=lambda p: True) == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unterordner_verliert_seinen_schluss_trenner_weiterhin():
|
||||||
|
"""Das Abstreifen war ja richtig — nur nicht bis auf den Laufwerksnamen."""
|
||||||
|
da = "F:" + B + "Roh"
|
||||||
|
assert pfade.naechster_vorhandener(da + B, existiert=lambda p: p == da) == da
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlendes_laufwerk_terminiert():
|
||||||
|
"""Die Endlosschleife, an der `ablauf._frei_bytes` haengen blieb.
|
||||||
|
|
||||||
|
`ntpath.dirname("Q:\\")` gibt sich selbst zurueck (30.08.2026 an
|
||||||
|
einem freien Laufwerksbuchstaben gemessen). Ohne den Abbruch bei
|
||||||
|
`eltern == pfad` dreht die Suche fuer immer — vor dem Rip, ohne Meldung.
|
||||||
|
"""
|
||||||
|
assert pfade.naechster_vorhandener("Q:" + B + "Rippy" + B + "_arbeit",
|
||||||
|
existiert=lambda p: False) == ""
|
||||||
|
|||||||
@@ -1,286 +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"
|
|
||||||
@@ -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,515 +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"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 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
|
|
||||||
@@ -48,6 +48,7 @@ mehr geht.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -60,6 +61,16 @@ from rippy.tools import katalog
|
|||||||
|
|
||||||
HANDBRAKE_API = "https://api.github.com/repos/HandBrake/HandBrake/releases/latest"
|
HANDBRAKE_API = "https://api.github.com/repos/HandBrake/HandBrake/releases/latest"
|
||||||
|
|
||||||
|
# FLAC-Encoder fuer Audio-CDs (Befund 29.08.2026). Der offizielle Xiph-Spiegel
|
||||||
|
# listet die Windows-Pakete als `flac-<version>-win.zip`; am 29.08.2026
|
||||||
|
# gemessen: HTTP 200, hoechste Fassung 1.5.0.
|
||||||
|
#
|
||||||
|
# Warum ein eigenes Werkzeug und nicht HandBrake: HandBrake schreibt FLAC nur
|
||||||
|
# IN einen Container (MKV/MKA), nie als `.flac`-Datei. Eine Musiksammlung will
|
||||||
|
# aber `.flac`.
|
||||||
|
FLAC_SPIEGEL = "https://ftp.osuosl.org/pub/xiph/releases/flac/"
|
||||||
|
FLAC_DATEI = re.compile(r"flac-(\d+\.\d+\.\d+)-win\.zip")
|
||||||
|
|
||||||
# Wie lange auf eine Antwort gewartet wird. Großzügig für den Download,
|
# Wie lange auf eine Antwort gewartet wird. Großzügig für den Download,
|
||||||
# knapp für die Versionsabfrage — die soll das UI nicht aufhalten.
|
# knapp für die Versionsabfrage — die soll das UI nicht aufhalten.
|
||||||
ABFRAGE_TIMEOUT = 20
|
ABFRAGE_TIMEOUT = 20
|
||||||
@@ -160,6 +171,86 @@ def waehle_handbrake_datei(assets: list, maschine: str = None) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def neueste_flac_fassung(seite: str) -> str:
|
||||||
|
"""Hoechste Windows-Fassung aus dem Verzeichnis-HTML (pure Funktion).
|
||||||
|
|
||||||
|
Der Xiph-Spiegel listet die Pakete als `flac-1.5.0-win.zip`. Sortiert
|
||||||
|
wird NUMERISCH — alphabetisch waere 1.4.10 kleiner als 1.4.2.
|
||||||
|
"""
|
||||||
|
fassungen = set(FLAC_DATEI.findall(seite or ""))
|
||||||
|
if not fassungen:
|
||||||
|
return ""
|
||||||
|
return max(fassungen, key=lambda v: [int(t) for t in v.split(".")])
|
||||||
|
|
||||||
|
|
||||||
|
def _flac_ordner(eintraege) -> str:
|
||||||
|
"""Der Ordner im Archiv, der flac.exe enthaelt (pure Funktion).
|
||||||
|
|
||||||
|
Bevorzugt `Win64`. Zurueck kommt das Praefix samt Schraegstrich, damit
|
||||||
|
der Aufrufer alle Dateien daraus greifen kann — die DLLs eingeschlossen.
|
||||||
|
"""
|
||||||
|
kandidaten = [n for n in eintraege if n.lower().endswith("/flac.exe")]
|
||||||
|
bevorzugt = [n for n in kandidaten if "win64" in n.lower()] or kandidaten
|
||||||
|
return bevorzugt[0].rsplit("/", 1)[0] + "/" if bevorzugt else ""
|
||||||
|
|
||||||
|
|
||||||
|
def flac_holen(ziel_ordner: str = None, fortschritt=None, seite_laden=None) -> str:
|
||||||
|
"""Laedt den FLAC-Encoder und legt ihn in Rippys Werkzeug-Ordner.
|
||||||
|
|
||||||
|
Fuer Audio-CDs (Befund 29.08.2026): Windows hat weder `cdparanoia` noch
|
||||||
|
`abcde`. Das LESEN macht Rippy selbst ueber Win32 (`rippy.rip.audio_cd`),
|
||||||
|
zum Kodieren fehlte ein Encoder.
|
||||||
|
"""
|
||||||
|
ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "flac")
|
||||||
|
|
||||||
|
def melden(text, anteil=None):
|
||||||
|
if fortschritt:
|
||||||
|
fortschritt(text, anteil)
|
||||||
|
|
||||||
|
melden("FLAC-Encoder wird gesucht …", 0.0)
|
||||||
|
seite = (seite_laden or (lambda: _laden(FLAC_SPIEGEL).decode("utf-8", "replace")))()
|
||||||
|
version = neueste_flac_fassung(seite)
|
||||||
|
if not version:
|
||||||
|
raise BeschaffungsFehler(
|
||||||
|
"Im Verzeichnis %s steht kein Windows-Paket (flac-*-win.zip). "
|
||||||
|
"Hat sich der Spiegel geaendert?" % FLAC_SPIEGEL)
|
||||||
|
|
||||||
|
url = "%sflac-%s-win.zip" % (FLAC_SPIEGEL, version)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="rippy-flac-") as arbeit:
|
||||||
|
archiv = os.path.join(arbeit, "flac.zip")
|
||||||
|
melden("FLAC %s wird geladen …" % version, 0.1)
|
||||||
|
_datei_laden(url, archiv, 0, melden)
|
||||||
|
|
||||||
|
melden("Wird entpackt …", 0.9)
|
||||||
|
with zipfile.ZipFile(archiv) as z:
|
||||||
|
eintraege = z.namelist()
|
||||||
|
# ⚠️ Den GANZEN Ordner auspacken, nicht nur die exe (Befund
|
||||||
|
# 29.08.2026): `flac.exe` braucht `libFLAC.dll` daneben. Mit nur
|
||||||
|
# der exe endete jeder Aufruf mit 0xC0000135 — „DLL nicht
|
||||||
|
# gefunden" — und zwar ohne eine einzige Zeile Ausgabe.
|
||||||
|
#
|
||||||
|
# Das Paket bringt 32- und 64-Bit mit. `Win64/` ist die richtige
|
||||||
|
# Wahl; ohne sie liefe auf einem 64-Bit-System die 32-Bit-Fassung.
|
||||||
|
ordner = _flac_ordner(eintraege)
|
||||||
|
if not ordner:
|
||||||
|
raise BeschaffungsFehler(
|
||||||
|
"Im FLAC-Archiv steckt keine flac.exe. Format geaendert?")
|
||||||
|
os.makedirs(ziel_ordner, exist_ok=True)
|
||||||
|
for name in eintraege:
|
||||||
|
if not name.startswith(ordner) or name.endswith("/"):
|
||||||
|
continue
|
||||||
|
z.extract(name, arbeit)
|
||||||
|
quelle = os.path.join(arbeit, name)
|
||||||
|
ziel_datei = os.path.join(ziel_ordner, os.path.basename(name))
|
||||||
|
vorlaeufig = ziel_datei + ".neu"
|
||||||
|
shutil.copy2(quelle, vorlaeufig)
|
||||||
|
os.replace(vorlaeufig, ziel_datei)
|
||||||
|
ziel = os.path.join(ziel_ordner, "flac.exe")
|
||||||
|
|
||||||
|
melden("FLAC %s ist einsatzbereit." % version, 1.0)
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
def handbrake_holen(ziel_ordner: str = None, fortschritt=None) -> str:
|
def handbrake_holen(ziel_ordner: str = None, fortschritt=None) -> str:
|
||||||
"""Lädt HandBrakeCLI und legt es in Rippys Werkzeug-Ordner. Gibt den Pfad zurück."""
|
"""Lädt HandBrakeCLI und legt es in Rippys Werkzeug-Ordner. Gibt den Pfad zurück."""
|
||||||
ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "handbrake")
|
ziel_ordner = ziel_ordner or os.path.join(katalog.werkzeug_ordner(), "handbrake")
|
||||||
|
|||||||
@@ -1,235 +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,
|
|
||||||
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
|
|
||||||
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
@@ -61,6 +61,20 @@ WERKZEUGE = {
|
|||||||
"version_flag": None, # kennt keinen Versions-Schalter
|
"version_flag": None, # kennt keinen Versions-Schalter
|
||||||
"pflicht": True,
|
"pflicht": True,
|
||||||
},
|
},
|
||||||
|
"flac": {
|
||||||
|
"titel": "FLAC",
|
||||||
|
"zweck": "Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.",
|
||||||
|
"programme": ("flac.exe", "flac"),
|
||||||
|
"orte": (
|
||||||
|
r"%ProgramFiles%\FLAC",
|
||||||
|
r"%ProgramFiles(x86)%\FLAC",
|
||||||
|
),
|
||||||
|
"anzeige": "FLAC",
|
||||||
|
"version_flag": "--version",
|
||||||
|
# KEINE Pflicht: Ohne FLAC laeuft alles ausser Audio-CDs. Wer nie eine
|
||||||
|
# CD rippt, soll deswegen keine Fehlermeldung im Setup sehen.
|
||||||
|
"pflicht": False,
|
||||||
|
},
|
||||||
"handbrake": {
|
"handbrake": {
|
||||||
"titel": "HandBrakeCLI",
|
"titel": "HandBrakeCLI",
|
||||||
"zweck": "Komprimiert den verlustfreien Rip auf Arbeitsgröße.",
|
"zweck": "Komprimiert den verlustfreien Rip auf Arbeitsgröße.",
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,916 +0,0 @@
|
|||||||
"""Rippy für Windows: Installer, Hintergrunddienst und Deinstallation in EINER Datei.
|
|
||||||
|
|
||||||
## Warum eine Datei und nicht drei
|
|
||||||
|
|
||||||
Die naheliegende Aufteilung wäre `RippySetup.exe`, `Rippy.exe` und
|
|
||||||
`RippyUninstall.exe`. Drei PyInstaller-Bauten, drei Signaturen, drei
|
|
||||||
Versionsstände, die auseinanderlaufen können — und ein Deinstaller, der sich
|
|
||||||
selbst löschen müsste.
|
|
||||||
|
|
||||||
Stattdessen: **ein Programm, drei Betriebsarten.**
|
|
||||||
|
|
||||||
RippySetup.exe installiert (Doppelklick)
|
|
||||||
Rippy.exe --dienst laeuft im Hintergrund
|
|
||||||
Rippy.exe --deinstallieren raeumt sich weg
|
|
||||||
|
|
||||||
Bei der Installation kopiert sich die Datei nach
|
|
||||||
`%LOCALAPPDATA%\\Rippy\\Rippy.exe`. Der Name ist Absicht: **Im Taskmanager
|
|
||||||
steht dann „Rippy.exe"** — was der Commander ausdrücklich wollte. Ein Prozess,
|
|
||||||
den man nicht wiederfindet, kann man auch nicht beenden.
|
|
||||||
|
|
||||||
## Warum kein Windows-Dienst
|
|
||||||
|
|
||||||
Ein echter Dienst startet vor der Anmeldung — klingt besser, kostet aber bei
|
|
||||||
JEDER Installation und jedem Update eine Administrator-Abfrage, und ein Dienst
|
|
||||||
kann kein Tray-Symbol zeigen (er hat keine Benutzersitzung). Rippy nimmt den
|
|
||||||
Autostart-Eintrag unter `HKCU\\...\\Run`: kein UAC, Tray funktioniert, und mit
|
|
||||||
einem Registry-Eintrag wieder weg.
|
|
||||||
|
|
||||||
## Warum das Tray-Symbol keine Nebensache ist
|
|
||||||
|
|
||||||
Ein Hintergrundprozess ohne sichtbaren Zugang ist eine Zumutung: Man weiß
|
|
||||||
nicht, ob er läuft, kommt nicht an die Oberfläche und kann ihn nicht beenden,
|
|
||||||
ohne den Taskmanager zu bemühen. Das Symbol beantwortet alle drei Fragen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import webbrowser
|
|
||||||
|
|
||||||
from rippy.platform import verknuepfungen
|
|
||||||
from rippy.platform import win_registry as reg
|
|
||||||
|
|
||||||
VERSION = os.environ.get("RIPPY_VERSION", "2.0.0")
|
|
||||||
STANDARD_PORT = 7788
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════ Hilfen ═════
|
|
||||||
def _eigene_datei() -> str:
|
|
||||||
"""Der Pfad DIESER ausführbaren Datei (auch im PyInstaller-Paket)."""
|
|
||||||
if getattr(sys, "frozen", False):
|
|
||||||
return sys.executable
|
|
||||||
return os.path.abspath(sys.argv[0])
|
|
||||||
|
|
||||||
|
|
||||||
def _mitgeliefert(name: str) -> str:
|
|
||||||
"""Eine mitgepackte Datei (Icon, UI, API) im Paket oder im Repo finden."""
|
|
||||||
gebuendelt = getattr(sys, "_MEIPASS", None)
|
|
||||||
if gebuendelt:
|
|
||||||
return os.path.join(gebuendelt, name)
|
|
||||||
hier = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
repo = os.path.dirname(os.path.dirname(hier))
|
|
||||||
return os.path.join(repo, name)
|
|
||||||
|
|
||||||
|
|
||||||
def _icon_pfad() -> str:
|
|
||||||
for kandidat in (_mitgeliefert("rippy.ico"),
|
|
||||||
_mitgeliefert(os.path.join("deploy", "worker-windows", "rippy.ico"))):
|
|
||||||
if os.path.isfile(kandidat):
|
|
||||||
return kandidat
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def melden(text: str) -> None:
|
|
||||||
"""Ausgabe, die auch ohne Konsole nicht abstürzt.
|
|
||||||
|
|
||||||
Eine als `--windowed` gebaute EXE hat keine Standardausgabe; `print()`
|
|
||||||
schreibt dann ins Leere oder wirft. Und unter Windows kann eine
|
|
||||||
Konsolen-Ausgabe an einem Umlaut sterben (cp1252) — genau daran ist der
|
|
||||||
erste Standalone-Start gescheitert. Deshalb hier: nie werfen.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
sys.stdout.write(text + "\n")
|
|
||||||
sys.stdout.flush()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def abschluss_melden(text: str, still: bool = False, fehler: bool = False) -> bool:
|
|
||||||
"""Das Ergebnis so melden, dass der Nutzer es WIRKLICH sieht.
|
|
||||||
|
|
||||||
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 aufschlägt. Ein
|
|
||||||
gescheiterter Installer sähe damit exakt aus wie einer, der nichts tut.
|
|
||||||
|
|
||||||
Deshalb hier zusätzlich ein Meldungsfenster, und zwar NUR wenn keine
|
|
||||||
Konsole da ist: Wer das Setup in PowerShell aufruft, will keinen Klick
|
|
||||||
wegdrücken, sondern die Zeile lesen.
|
|
||||||
|
|
||||||
Gibt zurück, ob ein Fenster gezeigt wurde.
|
|
||||||
"""
|
|
||||||
melden(text.replace("\n\n", "\n"))
|
|
||||||
if still:
|
|
||||||
return False
|
|
||||||
from rippy.platform import winlauf
|
|
||||||
|
|
||||||
if not winlauf.ohne_konsole():
|
|
||||||
return False
|
|
||||||
return winlauf.meldung_zeigen(text, fehler=fehler)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════ Installation ═══════
|
|
||||||
def installieren(ziel: str = None, autostart: bool = True,
|
|
||||||
starten_danach: bool = True, verknuepfen: bool = True) -> str:
|
|
||||||
"""Kopiert Rippy an seinen Platz und meldet es bei Windows an.
|
|
||||||
|
|
||||||
Gibt den Installationsordner zurück. Wirft bei echten Fehlern — eine
|
|
||||||
halb geglückte Installation soll NICHT wie ein Erfolg aussehen.
|
|
||||||
"""
|
|
||||||
ziel = ziel or reg.standard_installationsordner()
|
|
||||||
os.makedirs(ziel, exist_ok=True)
|
|
||||||
|
|
||||||
quelle = _eigene_datei()
|
|
||||||
programm = os.path.join(ziel, "Rippy.exe")
|
|
||||||
|
|
||||||
# Sich selbst kopieren. Läuft gerade eine ältere Fassung, ist die Datei
|
|
||||||
# gesperrt — dann daneben legen und beim nächsten Start tauschen.
|
|
||||||
if os.path.abspath(quelle).lower() != os.path.abspath(programm).lower():
|
|
||||||
try:
|
|
||||||
shutil.copy2(quelle, programm)
|
|
||||||
except PermissionError:
|
|
||||||
ersatz = programm + ".neu"
|
|
||||||
shutil.copy2(quelle, ersatz)
|
|
||||||
melden("Rippy läuft gerade — die neue Fassung wird beim nächsten "
|
|
||||||
"Start übernommen.")
|
|
||||||
programm = ersatz
|
|
||||||
|
|
||||||
icon = _icon_pfad()
|
|
||||||
if icon and os.path.isfile(icon):
|
|
||||||
try:
|
|
||||||
shutil.copy2(icon, os.path.join(ziel, "rippy.ico"))
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ui_auspacken(ziel)
|
|
||||||
datenbank_umziehen()
|
|
||||||
|
|
||||||
# Mitgelieferte Werkzeuge (HandBrakeCLI) neben das Programm legen. Dort
|
|
||||||
# sucht katalog.kandidaten() VOR allen Systempfaden.
|
|
||||||
from rippy.tools import einrichten
|
|
||||||
|
|
||||||
for name in einrichten.mitgelieferte_werkzeuge(_mitgeliefert("tools"), ziel):
|
|
||||||
melden(" Werkzeug bereitgestellt: %s" % name)
|
|
||||||
|
|
||||||
# Der Eintrag in „Programme und Features" — ohne ihn ist Rippy ein
|
|
||||||
# Programm, das man nur von Hand wieder loswird.
|
|
||||||
reg.eintragen(ziel, programm, VERSION,
|
|
||||||
icon=os.path.join(ziel, "rippy.ico") if icon else "")
|
|
||||||
|
|
||||||
if autostart:
|
|
||||||
reg.autostart_setzen('"%s" --dienst' % programm)
|
|
||||||
|
|
||||||
if verknuepfen:
|
|
||||||
# Ohne Symbol auf dem Desktop und ohne Startmenue-Eintrag ist ein
|
|
||||||
# Hintergrundprogramm praktisch unauffindbar: Es laeuft, aber der
|
|
||||||
# Nutzer hat keinen Weg hinein ausser der Adresse im Kopf.
|
|
||||||
angelegt = verknuepfungen.alle_anlegen(
|
|
||||||
programm, icon=os.path.join(ziel, "rippy.ico"))
|
|
||||||
for ort in ("desktop", "startmenue"):
|
|
||||||
if ort in angelegt:
|
|
||||||
melden(" Verknuepfung angelegt: %s" % angelegt[ort])
|
|
||||||
else:
|
|
||||||
# Sagen, was NICHT geklappt hat — sonst sucht der Nutzer ein
|
|
||||||
# Symbol, das es nie gab.
|
|
||||||
melden(" Verknuepfung (%s) konnte nicht angelegt werden." % ort)
|
|
||||||
|
|
||||||
if starten_danach:
|
|
||||||
starte_hintergrund(programm)
|
|
||||||
|
|
||||||
return ziel
|
|
||||||
|
|
||||||
|
|
||||||
def beenden_skript(ordner: str, eigene_pids: list) -> str:
|
|
||||||
"""Das PowerShell-Skript, das die ANDEREN Rippy-Prozesse beendet.
|
|
||||||
|
|
||||||
Reine Funktion, damit der Filter prüfbar ist, ohne etwas zu beenden.
|
|
||||||
|
|
||||||
## Warum das nötig ist (gemessen 28.08.2026)
|
|
||||||
|
|
||||||
Beim Deinstallieren im laufenden Betrieb blieben liegen:
|
|
||||||
|
|
||||||
rippy.db, rippy.db-shm, rippy.db-wal
|
|
||||||
(von einem anderen Prozess verwendet)
|
|
||||||
|
|
||||||
Der Dienst lief weiter und hielt die Datenbank offen. Damit wäre der
|
|
||||||
Fehler von vorhin durch eine andere Tür zurückgekommen: Die nächste
|
|
||||||
Installation hätte wieder den alten Zustand geerbt.
|
|
||||||
|
|
||||||
## Warum über den ORDNER gefiltert wird
|
|
||||||
|
|
||||||
`Rippy.exe` heißt auch die Setup-Datei, die den Auftrag gerade ausführt —
|
|
||||||
und die darf sich nicht selbst abschießen. Beendet wird nur, was aus dem
|
|
||||||
Installationsordner läuft, und die eigenen Prozesskennungen sind
|
|
||||||
ausgenommen.
|
|
||||||
|
|
||||||
## Hinweis für spätere Sitzungen
|
|
||||||
|
|
||||||
In der Entwicklungsumgebung schlägt der Vergleich zuverlässig fehl, und
|
|
||||||
das ist KEIN Fehler im Code: Die Werkzeuge laufen dort in einem
|
|
||||||
App-Container, der Schreibzugriffe auf `%LOCALAPPDATA%` nach
|
|
||||||
|
|
||||||
…\AppData\Local\Packages\<app>\LocalCache\Local\Rippy
|
|
||||||
|
|
||||||
umleitet. Der Registry-Eintrag `InstallLocation` zeigt dagegen auf den
|
|
||||||
ECHTEN Pfad — Prozess und Ordner passen dann nie zusammen. Auf einer
|
|
||||||
normalen Installation liegen beide am selben Ort. Die Mechanik wurde am
|
|
||||||
28.08.2026 mit dem tatsächlichen Ordner nachgewiesen: Prozess beendet,
|
|
||||||
Datenbankdateien danach löschbar.
|
|
||||||
"""
|
|
||||||
ort = (ordner or "").replace("'", "''").rstrip("\\/")
|
|
||||||
pids = ",".join(str(int(p)) for p in eigene_pids if p)
|
|
||||||
return (
|
|
||||||
"Get-CimInstance Win32_Process -Filter \"Name='Rippy.exe'\" "
|
|
||||||
"| Where-Object { $_.ExecutablePath -like '%s\\*' "
|
|
||||||
"-and $_.ProcessId -notin @(%s) } "
|
|
||||||
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
|
|
||||||
"-ErrorAction SilentlyContinue }" % (ort, pids or "0")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dienst_beenden(ordner: str) -> bool:
|
|
||||||
"""Beendet die anderen Rippy-Prozesse aus diesem Ordner. Wirft nicht."""
|
|
||||||
if os.name != "nt" or not ordner:
|
|
||||||
return False
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
|
||||||
|
|
||||||
eigene = [os.getpid()]
|
|
||||||
try:
|
|
||||||
eigene.append(os.getppid()) # der PyInstaller-Starter
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
ergebnis = subprocess.run(
|
|
||||||
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
|
||||||
beenden_skript(ordner, eigene)],
|
|
||||||
capture_output=True, text=True, timeout=60,
|
|
||||||
creationflags=OHNE_FENSTER)
|
|
||||||
except (OSError, subprocess.SubprocessError):
|
|
||||||
return False
|
|
||||||
# Kurz Luft lassen: Windows gibt die Dateihandles nicht im selben
|
|
||||||
# Augenblick frei, in dem der Prozess endet.
|
|
||||||
time.sleep(2)
|
|
||||||
return ergebnis.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def alte_orte() -> list:
|
|
||||||
"""Ordner ausserhalb des Installationsordners, die Rippy einmal angelegt hat.
|
|
||||||
|
|
||||||
Reine Aufzaehlung, damit `deinstallieren()` pruefbar bleibt und niemand
|
|
||||||
versehentlich einen fremden Ordner in die Liste schreibt.
|
|
||||||
"""
|
|
||||||
from rippy import config
|
|
||||||
|
|
||||||
alt = config.alter_datenbankpfad()
|
|
||||||
if not alt:
|
|
||||||
return []
|
|
||||||
ordner = os.path.dirname(alt)
|
|
||||||
# Nur wenn er wirklich Rippy gehoert. Ein `%ProgramData%` ohne unseren
|
|
||||||
# Namen darin waere ein fremder Ordner, und den fasst niemand an.
|
|
||||||
if os.path.basename(ordner).lower() != "rippy" or not os.path.isdir(ordner):
|
|
||||||
return []
|
|
||||||
return [ordner]
|
|
||||||
|
|
||||||
|
|
||||||
def datenbank_umziehen() -> str:
|
|
||||||
r"""Holt eine Datenbank vom alten Ort ab. Gibt den Zielpfad zurück, sonst "".
|
|
||||||
|
|
||||||
Bis zum 28.08.2026 lag sie unter `%ProgramData%\Rippy\rippy.db` — also
|
|
||||||
AUSSERHALB des Installationsordners. Die Deinstallation räumte sie damit
|
|
||||||
nicht weg, und eine „frische" Installation erbte den alten Zustand samt
|
|
||||||
`setup.done = true`. Genau deshalb fehlte dem Commander der
|
|
||||||
Ersteinrichtungs-Assistent.
|
|
||||||
|
|
||||||
Wer Rippy schon benutzt hat, soll seine Jobs und Einstellungen behalten:
|
|
||||||
Die Datei wird verschoben, nicht ignoriert und nicht gelöscht. Gibt es am
|
|
||||||
neuen Ort schon eine, bleibt sie unangetastet — die neuere gewinnt nicht
|
|
||||||
automatisch, aber die BENUTZTE.
|
|
||||||
"""
|
|
||||||
from rippy import config
|
|
||||||
|
|
||||||
alt = config.alter_datenbankpfad()
|
|
||||||
neu = config.standard_datenbankpfad()
|
|
||||||
if not alt or not os.path.isfile(alt) or os.path.isfile(neu):
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(neu), exist_ok=True)
|
|
||||||
shutil.move(alt, neu)
|
|
||||||
except OSError as e:
|
|
||||||
melden(" Alte Datenbank konnte nicht umgezogen werden (%s)." % e)
|
|
||||||
return ""
|
|
||||||
melden(" Datenbank uebernommen: %s -> %s" % (alt, neu))
|
|
||||||
return neu
|
|
||||||
|
|
||||||
|
|
||||||
def ui_auspacken(ziel: str) -> str:
|
|
||||||
r"""Legt die Weboberfläche NEBEN das Programm. Gibt den Ordner zurück.
|
|
||||||
|
|
||||||
## Warum das sein muss (gemessen 28.08.2026)
|
|
||||||
|
|
||||||
Ein Dienst lief eine Stunde. `/api/health` gab HTTP 200, `/` gab **404**
|
|
||||||
— im Fenster stand `{"detail":"Not Found"}`. Der Server war in Ordnung;
|
|
||||||
was fehlte, lag unter ihm.
|
|
||||||
|
|
||||||
Eine PyInstaller-Onefile-EXE entpackt sich beim Start nach
|
|
||||||
`%TEMP%\_MEIxxxxx` und liest von dort weiter. `StaticFiles` schlägt bei
|
|
||||||
JEDER Anfrage dort nach. Nachgesehen, was vom Ordner des laufenden
|
|
||||||
Dienstes übrig war: 31 Einträge statt 44, kein `ui`, kein `api`.
|
|
||||||
|
|
||||||
Ein Temp-Verzeichnis ist kein Ort für etwas, das eine Woche liegen
|
|
||||||
bleiben soll. Und der Ausfall ist der schlimmstmögliche: Die API
|
|
||||||
antwortet weiter, der Dienst gilt als gesund, nur die Oberfläche ist weg.
|
|
||||||
|
|
||||||
Die Kopie hier gehört Rippy, liegt im Installationsordner und wird beim
|
|
||||||
Deinstallieren mit entfernt (`deinstallieren()` räumt den ganzen Ordner).
|
|
||||||
|
|
||||||
Leerer Rückgabewert heißt: keine Kopie angelegt — dann greift der
|
|
||||||
Rückfall auf `sys._MEIPASS` in `daemon._ui_pfad()`.
|
|
||||||
"""
|
|
||||||
quelle = _mitgeliefert("ui")
|
|
||||||
if not os.path.isdir(quelle):
|
|
||||||
# Aus dem Repo gestartet: Da liegt das UI schon an fester Stelle.
|
|
||||||
return ""
|
|
||||||
|
|
||||||
ordner = os.path.join(ziel, "ui")
|
|
||||||
try:
|
|
||||||
# Erst weg, dann neu: Ein Update mit weniger Dateien ließe sonst
|
|
||||||
# Reste des alten Standes liegen, und die Oberfläche wäre ein
|
|
||||||
# Gemisch aus zwei Fassungen.
|
|
||||||
shutil.rmtree(ordner, ignore_errors=True)
|
|
||||||
shutil.copytree(quelle, ordner)
|
|
||||||
except OSError as e:
|
|
||||||
# Nicht werfen: Ohne Kopie läuft Rippy aus dem Temp weiter — schlechter,
|
|
||||||
# aber nicht kaputt. Gesagt wird es trotzdem.
|
|
||||||
melden(" Oberflaeche konnte nicht abgelegt werden (%s)." % e)
|
|
||||||
return ""
|
|
||||||
return ordner
|
|
||||||
|
|
||||||
|
|
||||||
def starte_hintergrund(programm: str) -> None:
|
|
||||||
"""Startet Rippy als Hintergrundprozess — ohne aufblitzendes Fenster."""
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
|
||||||
|
|
||||||
# `env=` ist hier kein Beiwerk: Ohne die Bereinigung liefe dieses Kind im
|
|
||||||
# Auspack-Ordner des Elternprozesses. Begruendung in winlauf.py.
|
|
||||||
subprocess.Popen([programm, "--dienst"], creationflags=OHNE_FENSTER,
|
|
||||||
close_fds=True, env=umgebung_ohne_bundle())
|
|
||||||
|
|
||||||
|
|
||||||
def starte_fensterprozess(programm: str = None) -> None:
|
|
||||||
"""Startet die Oberflaeche als EIGENEN Prozess.
|
|
||||||
|
|
||||||
Warum nicht einfach hier ein Fenster oeffnen: Das Tray-Symbol (pystray)
|
|
||||||
belegt unter Windows den Haupt-Thread — es braucht ihn fuer die
|
|
||||||
Fensternachrichten. Das WebView2-Fenster braucht ihn genauso. Zwei
|
|
||||||
Nachrichtenschleifen passen nicht in einen Thread.
|
|
||||||
|
|
||||||
Zwei Prozesse loesen das sauber: Der Dienst haelt Server und Tray, das
|
|
||||||
Fenster ist der Client davor. Beide heissen im Taskmanager „Rippy.exe",
|
|
||||||
und das Fenster laesst sich schliessen, ohne den Dienst mitzureissen.
|
|
||||||
"""
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER, umgebung_ohne_bundle
|
|
||||||
|
|
||||||
subprocess.Popen([programm or _eigene_datei(), "--oeffnen"],
|
|
||||||
creationflags=OHNE_FENSTER, close_fds=True,
|
|
||||||
env=umgebung_ohne_bundle())
|
|
||||||
|
|
||||||
|
|
||||||
def deinstallieren(still: bool = False) -> None:
|
|
||||||
"""Nimmt alle Spuren zurück: Autostart, Registry-Eintrag, Dateien."""
|
|
||||||
# WOHIN wurde installiert? Das steht in der Registry, nicht im Code.
|
|
||||||
# Vorher stand hier `reg.standard_installationsordner()` — wer nach
|
|
||||||
# `--ziel D:\Rippy` installiert hatte, dessen Dateien waeren geblieben,
|
|
||||||
# waehrend ein fremder Ordner unter %LOCALAPPDATA% angefasst worden waere.
|
|
||||||
# InstallLocation wird genau dafuer beim Eintragen mitgeschrieben.
|
|
||||||
ordner = (reg.gelesene_werte().get("InstallLocation")
|
|
||||||
or reg.standard_installationsordner())
|
|
||||||
|
|
||||||
for lnk in verknuepfungen.alle_entfernen():
|
|
||||||
if not still:
|
|
||||||
melden(" Verknuepfung entfernt: %s" % lnk)
|
|
||||||
|
|
||||||
reg.autostart_entfernen()
|
|
||||||
reg.austragen()
|
|
||||||
|
|
||||||
# ⚠️ ZUERST die WebView2-Helfer beenden (Befund 28.08.2026).
|
|
||||||
#
|
|
||||||
# Beim ersten echten Deinstallieren blieben 148 Dateien liegen, alle
|
|
||||||
# unter `fenster\EBWebView\`. WebView2 startet eigene Prozesse
|
|
||||||
# (msedgewebview2.exe: Renderer, GPU, Netz, Crashpad); stirbt Rippy,
|
|
||||||
# laufen sie als Waisen weiter und halten den Zwischenspeicher offen.
|
|
||||||
# Danach ging `shutil.rmtree` nicht mehr — und der Fehler wurde
|
|
||||||
# verschluckt, waehrend „Rippy wurde entfernt" gemeldet wurde.
|
|
||||||
from rippy import fenster as fenster_modul
|
|
||||||
|
|
||||||
fenster_modul.helfer_beenden(os.path.join(ordner, "fenster"))
|
|
||||||
|
|
||||||
# ⚠️ Und den DIENST selbst (Befund 28.08.2026). Lief er weiter, blieben
|
|
||||||
# rippy.db, rippy.db-shm und rippy.db-wal liegen — „von einem anderen
|
|
||||||
# Prozess verwendet". Die naechste Installation haette damit wieder den
|
|
||||||
# alten Zustand geerbt, also genau den Fehler von vorhin durch eine
|
|
||||||
# andere Tuer.
|
|
||||||
dienst_beenden(ordner)
|
|
||||||
|
|
||||||
# Der ALTE Datenbank-Ort (bis 28.08.2026 %ProgramData%\Rippy) liegt
|
|
||||||
# ausserhalb des Installationsordners. Blieb er stehen, erbte die naechste
|
|
||||||
# Installation den alten Zustand — genau daran fehlte dem Commander der
|
|
||||||
# Ersteinrichtungs-Assistent. Ein Deinstallieren, das Zustand stehen
|
|
||||||
# laesst, ist kein Deinstallieren.
|
|
||||||
for rest in alte_orte():
|
|
||||||
try:
|
|
||||||
shutil.rmtree(rest)
|
|
||||||
if not still:
|
|
||||||
melden(" Alter Datenordner entfernt: %s" % rest)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
laeuft_von_hier = os.path.abspath(_eigene_datei()).lower().startswith(
|
|
||||||
os.path.abspath(ordner).lower())
|
|
||||||
|
|
||||||
# Die eigene Datei kann sich nicht selbst loeschen, solange sie laeuft.
|
|
||||||
# Also: alles andere jetzt, die EXE per Aufraeum-Befehl danach.
|
|
||||||
geblieben = []
|
|
||||||
for name in os.listdir(ordner) if os.path.isdir(ordner) else []:
|
|
||||||
pfad = os.path.join(ordner, name)
|
|
||||||
if laeuft_von_hier and os.path.abspath(pfad).lower() == os.path.abspath(_eigene_datei()).lower():
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
shutil.rmtree(pfad) if os.path.isdir(pfad) else os.remove(pfad)
|
|
||||||
except OSError as e:
|
|
||||||
# NICHT verschlucken. Ein „Rippy wurde entfernt" ueber einem
|
|
||||||
# halb geleerten Ordner ist eine Falschaussage.
|
|
||||||
geblieben.append("%s (%s)" % (name, e.strerror or e))
|
|
||||||
|
|
||||||
if laeuft_von_hier:
|
|
||||||
selbst_loeschen(_eigene_datei(), ordner)
|
|
||||||
|
|
||||||
if not still:
|
|
||||||
if geblieben:
|
|
||||||
melden("Rippy wurde entfernt — diese Reste waren gesperrt und "
|
|
||||||
"werden gleich nachgeholt:")
|
|
||||||
for eintrag in geblieben:
|
|
||||||
melden(" " + eintrag)
|
|
||||||
else:
|
|
||||||
melden("Rippy wurde entfernt.")
|
|
||||||
|
|
||||||
|
|
||||||
AUFRAEUM_SKRIPT = """@echo off
|
|
||||||
rem Erzeugt von Rippy beim Deinstallieren. Loescht sich selbst am Ende.
|
|
||||||
for /l %%n in (1,1,15) do (
|
|
||||||
ping 127.0.0.1 -n 3 >nul
|
|
||||||
del /q "{exe}" >nul 2>&1
|
|
||||||
if not exist "{exe}" goto weg
|
|
||||||
)
|
|
||||||
goto ende
|
|
||||||
:weg
|
|
||||||
rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
|
|
||||||
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
|
|
||||||
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
|
|
||||||
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
|
|
||||||
rmdir /s /q "{ordner}" >nul 2>&1
|
|
||||||
:ende
|
|
||||||
del /q "%~f0" >nul 2>&1
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def aufraeum_skript(exe: str, ordner: str) -> str:
|
|
||||||
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
|
|
||||||
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
|
|
||||||
|
|
||||||
|
|
||||||
def selbst_loeschen(exe: str, ordner: str) -> str:
|
|
||||||
r"""Loescht die eigene EXE und den Ordner — nachdem dieser Prozess weg ist.
|
|
||||||
|
|
||||||
Gibt den Pfad des Aufraeum-Skripts zurueck (fuer Tests und Diagnose).
|
|
||||||
|
|
||||||
## Warum ein Skript und kein cmd-Einzeiler (gemessen 28.08.2026)
|
|
||||||
|
|
||||||
Der erste Versuch uebergab den ganzen Befehl als EIN Argument an
|
|
||||||
`cmd /c`. Python maskiert dabei die inneren Anfuehrungszeichen — bei cmd
|
|
||||||
kam an:
|
|
||||||
|
|
||||||
del /q \"C:\Users\...\Rippy.exe\"
|
|
||||||
|
|
||||||
Mit den Backslashes davor ist das ein anderer Dateiname, also loeschte
|
|
||||||
`del` nichts und meldete auch nichts (die Ausgabe war nach >nul
|
|
||||||
umgeleitet). Ergebnis: Registry-Eintrag und Autostart waren weg, die
|
|
||||||
28-MB-Datei blieb liegen — und der Fehlschlag war unsichtbar. Genau die
|
|
||||||
Sorte still scheiternder Hintergrundprozess, vor der AGENTS.md warnt.
|
|
||||||
|
|
||||||
Ein Skript hat das Problem nicht: Sein Inhalt geht durch keine
|
|
||||||
Argument-Zerlegung.
|
|
||||||
|
|
||||||
## Warum es fuenfzehnmal versucht
|
|
||||||
|
|
||||||
Eine PyInstaller-Onefile-EXE laeuft als ZWEI Prozesse — ein Starter und
|
|
||||||
die eigentliche Anwendung. Der Starter raeumt nach dem Ende noch sein
|
|
||||||
Entpack-Verzeichnis auf und haelt die Datei so lange offen. Zwei Sekunden
|
|
||||||
sind dafuer mal genug und mal nicht.
|
|
||||||
|
|
||||||
Klappt es in einer halben Minute nicht, bleibt eine Datei liegen. Das ist
|
|
||||||
unschoen, aber harmlos: Registry-Eintrag und Autostart sind da schon weg,
|
|
||||||
Rippy startet also nicht mehr mit.
|
|
||||||
"""
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
|
||||||
|
|
||||||
skript = os.path.join(tempfile.gettempdir(), "rippy-aufraeumen.bat")
|
|
||||||
# ascii + replace: Der Pfad kann Umlaute enthalten, und cmd liest .bat
|
|
||||||
# in der Konsolen-Codepage. Ein Fragezeichen im Pfad ist besser als ein
|
|
||||||
# Skript, das cmd gar nicht erst annimmt.
|
|
||||||
with open(skript, "w", encoding="ascii", errors="replace", newline="\r\n") as f:
|
|
||||||
f.write(aufraeum_skript(exe, ordner))
|
|
||||||
|
|
||||||
subprocess.Popen(["cmd", "/c", skript], creationflags=OHNE_FENSTER,
|
|
||||||
close_fds=True)
|
|
||||||
return skript
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════ Der Hintergrunddienst ══
|
|
||||||
class Dienst:
|
|
||||||
"""Rippy im Hintergrund: der Server in einem Thread, das Tray-Symbol davor."""
|
|
||||||
|
|
||||||
def __init__(self, port: int = STANDARD_PORT):
|
|
||||||
self.port = port
|
|
||||||
self._server = None
|
|
||||||
self._laeuft = False
|
|
||||||
|
|
||||||
# ── Server ──────────────────────────────────────────────────────────
|
|
||||||
def server_starten(self) -> None:
|
|
||||||
from rippy import config, daemon, store
|
|
||||||
|
|
||||||
werte = config.laden(flags={"server": {"port": self.port}})
|
|
||||||
store.verbinden(config.datenbank_url(werte))
|
|
||||||
app, _ = daemon.anwendung_bauen(werte)
|
|
||||||
|
|
||||||
# Ohne diesen Schritt koennte Rippy alles anzeigen und nichts tun.
|
|
||||||
from rippy import standalone
|
|
||||||
from rippy.bus.memory import bus as ereignis_bus
|
|
||||||
|
|
||||||
standalone.starten(bus=ereignis_bus, store=store,
|
|
||||||
slots=werte["queue"].get("rip_slots", 1))
|
|
||||||
|
|
||||||
import uvicorn
|
|
||||||
|
|
||||||
self._server = uvicorn.Server(uvicorn.Config(
|
|
||||||
app, host="127.0.0.1", port=self.port, log_level="warning"))
|
|
||||||
self._laeuft = True
|
|
||||||
self._server.run()
|
|
||||||
|
|
||||||
def stoppen(self) -> None:
|
|
||||||
self._laeuft = False
|
|
||||||
if self._server is not None:
|
|
||||||
self._server.should_exit = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def adresse(self) -> str:
|
|
||||||
return "http://127.0.0.1:%d" % self.port
|
|
||||||
|
|
||||||
def erreichbar(self, timeout: float = 1.0) -> bool:
|
|
||||||
"""Antwortet der Server WIRKLICH? Nicht: läuft der Thread noch.
|
|
||||||
|
|
||||||
Ein lebender Thread ist kein Beweis dafür, dass jemand bedient wird —
|
|
||||||
dieselbe Unterscheidung wie überall sonst in diesem Projekt.
|
|
||||||
"""
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(self.adresse + "/api/health", timeout=timeout):
|
|
||||||
return True
|
|
||||||
except (urllib.error.URLError, OSError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _reste_wegraeumen() -> None:
|
|
||||||
"""Zurueckgelassene Auspack-Ordner entfernen. Darf nie stoeren."""
|
|
||||||
try:
|
|
||||||
from rippy.platform.winlauf import reste_aufraeumen
|
|
||||||
|
|
||||||
anzahl = reste_aufraeumen()
|
|
||||||
if anzahl:
|
|
||||||
melden(" %d zurueckgelassene Auspack-Ordner entfernt." % anzahl)
|
|
||||||
except Exception: # noqa: BLE001
|
|
||||||
pass # Aufraeumen ist Kuer. Es darf den Dienst nicht kosten.
|
|
||||||
|
|
||||||
|
|
||||||
def dienst_starten(port: int = STANDARD_PORT) -> int:
|
|
||||||
"""Server im Hintergrund-Thread, Tray-Symbol im Vordergrund.
|
|
||||||
|
|
||||||
Die Reihenfolge ist unter Windows Pflicht: `pystray` muss im HAUPT-Thread
|
|
||||||
laufen, sonst bekommt es keine Fensternachrichten und das Symbol
|
|
||||||
erscheint nie.
|
|
||||||
"""
|
|
||||||
# Reste frueherer Laeufe wegraeumen — nebenher, damit der Start nicht
|
|
||||||
# darauf wartet. Beim Commander lagen 20 solcher Ordner mit 1,1 GB
|
|
||||||
# herum (Befund 28.08.2026); die Ursache ist mit `umgebung_ohne_bundle`
|
|
||||||
# behoben, das Liegengebliebene raeumt niemand sonst weg.
|
|
||||||
threading.Thread(target=_reste_wegraeumen, daemon=True,
|
|
||||||
name="rippy-aufraeumen").start()
|
|
||||||
|
|
||||||
dienst = Dienst(port)
|
|
||||||
threading.Thread(target=dienst.server_starten, daemon=True,
|
|
||||||
name="rippy-server").start()
|
|
||||||
|
|
||||||
# Kurz warten und NACHSEHEN, ob der Server wirklich antwortet.
|
|
||||||
for _ in range(30):
|
|
||||||
if dienst.erreichbar(timeout=0.5):
|
|
||||||
break
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
symbol = tray_bauen(dienst)
|
|
||||||
if symbol is None:
|
|
||||||
# Kein Tray möglich (fehlende Bibliothek): Dann eben ohne — der
|
|
||||||
# Dienst ist wichtiger als sein Symbol. Aber es wird gesagt.
|
|
||||||
melden("Tray-Symbol nicht verfügbar — Rippy läuft trotzdem auf "
|
|
||||||
+ dienst.adresse)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(3600)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
return 0
|
|
||||||
|
|
||||||
symbol.run()
|
|
||||||
dienst.stoppen()
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def bereitstellen(port: int = STANDARD_PORT) -> bool:
|
|
||||||
"""Sorgt dafuer, dass der Dienst ANTWORTET. Startet ihn notfalls.
|
|
||||||
|
|
||||||
Getrennt von `oeffnen()`, weil beide Wege dorthin — Fenster und Browser —
|
|
||||||
genau dieselbe Vorbedingung haben: Ohne antwortenden Server zeigt das
|
|
||||||
eine wie das andere nur einen Verbindungsfehler.
|
|
||||||
"""
|
|
||||||
dienst = Dienst(port)
|
|
||||||
if dienst.erreichbar(timeout=1.5):
|
|
||||||
return True
|
|
||||||
|
|
||||||
melden("Rippy wird gestartet …")
|
|
||||||
starte_hintergrund(_eigene_datei())
|
|
||||||
# Warten, bis er ANTWORTET — nicht bis der Prozess existiert.
|
|
||||||
for _ in range(60):
|
|
||||||
if dienst.erreichbar(timeout=1.0):
|
|
||||||
return True
|
|
||||||
time.sleep(0.5)
|
|
||||||
melden("Rippy antwortet nicht. Laeuft schon etwas auf Port %d?" % port)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def oeffnen(port: int = STANDARD_PORT, als_fenster: bool = True) -> int:
|
|
||||||
"""Rippy oeffnen — Dienst starten, falls noetig, dann die Oberflaeche.
|
|
||||||
|
|
||||||
## Warum die Verknuepfungen hierher zeigen und nicht auf --dienst
|
|
||||||
|
|
||||||
Ein Doppelklick auf ein Desktop-Symbol soll Rippy OEFFNEN. Ob der Dienst
|
|
||||||
schon laeuft, ist dem Nutzer egal — und er kann es auch nicht wissen.
|
|
||||||
|
|
||||||
Zeigte die Verknuepfung auf `--dienst`, startete jeder Klick einen
|
|
||||||
ZWEITEN Server. Der zweite scheiterte am belegten Port, das Fenster
|
|
||||||
blitzte auf und verschwand, und im Browser passierte nichts. Ein Symbol,
|
|
||||||
das beim zweiten Klick nichts tut, ist schlimmer als keins.
|
|
||||||
|
|
||||||
Deshalb: erst NACHSEHEN, ob jemand antwortet. Nur wenn nicht, starten —
|
|
||||||
und dann warten, bis er wirklich da ist, statt sofort ein Fenster auf
|
|
||||||
eine tote Adresse zu schicken.
|
|
||||||
|
|
||||||
## Warum hier ein Fenster und kein Browser-Tab
|
|
||||||
|
|
||||||
Siehe `rippy/fenster.py`. Kurz: Ein Tab hat eine Adresszeile, die
|
|
||||||
niemand braucht, kein eigenes Symbol in der Taskleiste, und wer den
|
|
||||||
Browser schliesst, glaubt, er habe Rippy beendet. Geht das Fenster nicht
|
|
||||||
auf, faellt Rippy auf den Browser zurueck und SAGT, warum.
|
|
||||||
"""
|
|
||||||
if not bereitstellen(port):
|
|
||||||
return 1
|
|
||||||
|
|
||||||
adresse = Dienst(port).adresse
|
|
||||||
if not als_fenster:
|
|
||||||
return 0 if webbrowser.open(adresse) else 1
|
|
||||||
|
|
||||||
from rippy import fenster as fenster_modul
|
|
||||||
|
|
||||||
# Der Rueckgabewert sagt, was WIRKLICH passiert ist — nicht, dass es
|
|
||||||
# geklappt hat. „nichts" heisst: weder Fenster noch Browser.
|
|
||||||
return 0 if fenster_modul.oeffnen_oder_browser(adresse) != "nichts" else 1
|
|
||||||
|
|
||||||
|
|
||||||
def tray_bauen(dienst):
|
|
||||||
"""Das Tray-Symbol. None, wenn pystray/Pillow fehlen."""
|
|
||||||
try:
|
|
||||||
import pystray
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
icon = _icon_pfad()
|
|
||||||
try:
|
|
||||||
bild = Image.open(icon) if icon and os.path.isfile(icon) else None
|
|
||||||
except OSError:
|
|
||||||
bild = None
|
|
||||||
if bild is None:
|
|
||||||
from PIL import ImageDraw
|
|
||||||
|
|
||||||
bild = Image.new("RGB", (64, 64), (16, 20, 32))
|
|
||||||
zeichner = ImageDraw.Draw(bild)
|
|
||||||
zeichner.ellipse((8, 8, 56, 56), outline=(245, 158, 11), width=6)
|
|
||||||
zeichner.ellipse((26, 26, 38, 38), fill=(245, 158, 11))
|
|
||||||
|
|
||||||
def oeffnen(_=None, __=None):
|
|
||||||
# Eigener Prozess: pystray haelt hier den Haupt-Thread, das Fenster
|
|
||||||
# braucht ihn aber selbst (siehe starte_fensterprozess).
|
|
||||||
starte_fensterprozess()
|
|
||||||
|
|
||||||
def beenden(symbol, _=None):
|
|
||||||
dienst.stoppen()
|
|
||||||
symbol.stop()
|
|
||||||
|
|
||||||
def zustand(_=None):
|
|
||||||
return "Läuft — " + dienst.adresse if dienst.erreichbar() else "Startet …"
|
|
||||||
|
|
||||||
menue = pystray.Menu(
|
|
||||||
pystray.MenuItem("Rippy öffnen", oeffnen, default=True),
|
|
||||||
pystray.MenuItem(zustand, None, enabled=False),
|
|
||||||
pystray.Menu.SEPARATOR,
|
|
||||||
pystray.MenuItem("Beenden", beenden),
|
|
||||||
)
|
|
||||||
return pystray.Icon("Rippy", bild, "Rippy", menue)
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════ Einstieg ═══════
|
|
||||||
HILFE = """Rippy für Windows
|
|
||||||
|
|
||||||
RippySetup.exe installieren (Doppelklick)
|
|
||||||
Rippy.exe --oeffnen oeffnen (startet den Dienst, falls noetig)
|
|
||||||
Rippy.exe --dienst im Hintergrund laufen lassen
|
|
||||||
Rippy.exe --deinstallieren entfernen
|
|
||||||
Rippy.exe --status nachsehen, was installiert ist
|
|
||||||
|
|
||||||
Optionen:
|
|
||||||
--port N Port (Vorgabe: 7788)
|
|
||||||
--ziel PFAD Installationsordner
|
|
||||||
--kein-autostart
|
|
||||||
--keine-verknuepfungen ohne Desktop-Symbol und Startmenue-Eintrag
|
|
||||||
--ohne-werkzeuge MakeMKV/HandBrake NICHT pruefen und nicht holen
|
|
||||||
--ohne-assistent ohne Einrichtungs-Fenster, mit den Vorgaben
|
|
||||||
--im-browser im Browser statt im eigenen Fenster
|
|
||||||
--still ohne Rückmeldungen
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def status() -> int:
|
|
||||||
melden("Rippy %s" % VERSION)
|
|
||||||
melden(" Diese Datei %s" % _eigene_datei())
|
|
||||||
melden(" Installationsordner %s" % reg.standard_installationsordner())
|
|
||||||
if os.name != "nt":
|
|
||||||
melden(" (kein Windows — Registry wird nicht gelesen)")
|
|
||||||
return 0
|
|
||||||
melden(" In Programme+Features %s" % ("ja" if reg.ist_eingetragen() else "nein"))
|
|
||||||
melden(" Autostart %s" % (reg.autostart_wert() or "nein"))
|
|
||||||
for name, pfad in (("Desktop-Symbol", verknuepfungen.desktop_pfad()),
|
|
||||||
("Startmenue", verknuepfungen.startmenue_pfad())):
|
|
||||||
melden(" %-21s %s" % (name, pfad if pfad and os.path.isfile(pfad)
|
|
||||||
else "nein"))
|
|
||||||
for name, wert in sorted(reg.gelesene_werte().items()):
|
|
||||||
melden(" %-18s %s" % (name, wert))
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
|
||||||
argv = list(sys.argv[1:] if argv is None else argv)
|
|
||||||
|
|
||||||
def wert(flag, standard=None):
|
|
||||||
return argv[argv.index(flag) + 1] if flag in argv else standard
|
|
||||||
|
|
||||||
if "--hilfe" in argv or "--help" in argv or "-h" in argv:
|
|
||||||
melden(HILFE)
|
|
||||||
return 0
|
|
||||||
if "--status" in argv:
|
|
||||||
return status()
|
|
||||||
if "--deinstallieren" in argv:
|
|
||||||
deinstallieren(still="--still" in argv)
|
|
||||||
return 0
|
|
||||||
if "--oeffnen" in argv:
|
|
||||||
return oeffnen(int(wert("--port", STANDARD_PORT)),
|
|
||||||
als_fenster="--im-browser" not in argv)
|
|
||||||
if "--dienst" in argv:
|
|
||||||
return dienst_starten(int(wert("--port", STANDARD_PORT)))
|
|
||||||
|
|
||||||
# Ohne Argument: der Doppelklick-Fall — und der gehoert in den
|
|
||||||
# Einrichtungs-Assistenten.
|
|
||||||
#
|
|
||||||
# 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."
|
|
||||||
#
|
|
||||||
# `--still` und `--ohne-assistent` gehen weiterhin den stummen Weg: fuer
|
|
||||||
# unbeaufsichtigte Installationen und fuer die eigenen Tests.
|
|
||||||
if "--still" not in argv and "--ohne-assistent" not in argv:
|
|
||||||
ergebnis = assistent_starten()
|
|
||||||
if ergebnis.get("ok"):
|
|
||||||
# ⚠️ Und jetzt Rippy auch WIRKLICH starten.
|
|
||||||
#
|
|
||||||
# Der Knopf am Ende des Assistenten heisst „Rippy oeffnen". Bis
|
|
||||||
# hierher schloss er nur das Fenster, und danach passierte nichts
|
|
||||||
# mehr: Der Dienst lief nicht, kein Fenster ging auf. Ein Knopf,
|
|
||||||
# der etwas anderes tut als sein Beschriftung sagt, ist schlimmer
|
|
||||||
# als keiner.
|
|
||||||
programm = os.path.join(ergebnis.get("ziel") or
|
|
||||||
reg.standard_installationsordner(),
|
|
||||||
"Rippy.exe")
|
|
||||||
starte_fensterprozess(programm)
|
|
||||||
return 0
|
|
||||||
if ergebnis.get("abgebrochen"):
|
|
||||||
melden("Abgebrochen — es wurde nichts veraendert.")
|
|
||||||
return 1
|
|
||||||
# Weder erledigt noch abgebrochen: Der Assistent ging gar nicht auf.
|
|
||||||
# Dann wird ohne ihn installiert, statt gar nichts zu tun.
|
|
||||||
melden("Der Einrichtungs-Assistent ging nicht auf (%s) — es wird mit "
|
|
||||||
"den Vorgaben installiert." % (ergebnis.get("grund") or "?"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
ziel = installieren(
|
|
||||||
ziel=wert("--ziel"),
|
|
||||||
autostart="--kein-autostart" not in argv,
|
|
||||||
starten_danach="--nicht-starten" not in argv,
|
|
||||||
verknuepfen="--keine-verknuepfungen" not in argv,
|
|
||||||
)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
# Ohne Konsole waere ein Absturz hier VOLLKOMMEN unsichtbar: Der
|
|
||||||
# Doppelklick tut nichts, und niemand erfaehrt warum. Erst zeigen,
|
|
||||||
# dann weiterwerfen — der Rueckgabewert bleibt ein Fehler.
|
|
||||||
abschluss_melden("Die Installation ist gescheitert:\n\n%s: %s"
|
|
||||||
% (type(e).__name__, e), still="--still" in argv,
|
|
||||||
fehler=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
melden("Rippy wurde nach %s installiert." % ziel)
|
|
||||||
|
|
||||||
# Ohne MakeMKV kann Rippy keine Disc lesen — dann ist es ein
|
|
||||||
# Anzeigeprogramm. Der Commander am 28.08.2026: „Ohne das ist das tool
|
|
||||||
# NICHT einsatzfaehig." Also gehoert die Beschaffung ins Setup und nicht
|
|
||||||
# in eine Meldung im UI, die niemand befolgen kann.
|
|
||||||
text = "Rippy wurde installiert:\n\n%s" % ziel
|
|
||||||
if "--ohne-werkzeuge" not in argv:
|
|
||||||
text = werkzeuge_einrichten()
|
|
||||||
|
|
||||||
if "--nicht-starten" not in argv:
|
|
||||||
# Als eigener Prozess und aus dem INSTALLIERTEN Programm heraus:
|
|
||||||
# Das Setup soll sich beenden koennen, ohne das Fenster mitzunehmen.
|
|
||||||
starte_fensterprozess(os.path.join(ziel, "Rippy.exe"))
|
|
||||||
abschluss_melden(text, still="--still" in argv)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def assistent_starten() -> dict:
|
|
||||||
"""Oeffnet den Einrichtungs-Assistenten. Sagt, was daraus wurde.
|
|
||||||
|
|
||||||
Rueckgabe:
|
|
||||||
{"ok": True, ...} installiert
|
|
||||||
{"abgebrochen": True} Fenster zugemacht, nichts veraendert
|
|
||||||
{"grund": "..."} ging nicht auf — der Aufrufer entscheidet
|
|
||||||
|
|
||||||
Wirft nicht. Ein Setup, das gar nichts tut, weil sein Fenster nicht
|
|
||||||
aufging, waere schlimmer als eines, das nicht fragt.
|
|
||||||
"""
|
|
||||||
from rippy import setup_fenster
|
|
||||||
|
|
||||||
geht, grund = setup_fenster.moeglich()
|
|
||||||
if not geht:
|
|
||||||
return {"grund": grund}
|
|
||||||
try:
|
|
||||||
ergebnis = setup_fenster.starten()
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return {"grund": "%s: %s" % (type(e).__name__, e)}
|
|
||||||
if not ergebnis:
|
|
||||||
return {"abgebrochen": True}
|
|
||||||
return ergebnis
|
|
||||||
|
|
||||||
|
|
||||||
def werkzeuge_einrichten() -> str:
|
|
||||||
"""Holt fehlende Werkzeuge und gibt den Bericht als Text zurück.
|
|
||||||
|
|
||||||
Wirft nicht: Ein nicht erreichbarer Download-Server ist kein Grund, eine
|
|
||||||
sonst geglückte Installation scheitern zu lassen. Was fehlt, steht dann
|
|
||||||
im Bericht — mitsamt dem Weg, es von Hand zu beschaffen.
|
|
||||||
"""
|
|
||||||
from rippy.tools import einrichten
|
|
||||||
|
|
||||||
try:
|
|
||||||
ergebnis = einrichten.sicherstellen(
|
|
||||||
fortschritt=lambda t, anteil=None: melden(" " + t))
|
|
||||||
return einrichten.bericht(ergebnis)
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return ('Rippy ist installiert. Die Werkzeug-Prüfung ist gescheitert '
|
|
||||||
'(%s: %s) — sieh in den Einstellungen unter „System" nach.'
|
|
||||||
% (type(e).__name__, e))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user