20 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5 2b77ab0302 fix(tests): Vorgabe-Test ohne den entfernten USERPROFILE-Zweig
Ampel / ampel (push) Successful in 42s
Der Test verlangte den Windows-Sonderzweig von ablage_vorgabe, der mit
Entscheid 7 gegangen ist — lokal (Windows) blieb er zufaellig gruen,
weil expanduser('~') dort auf USERPROFILE zeigt; die Linux-Ampel hat es
gemerkt. Er prueft jetzt die neue Wirklichkeit: nativ = Benutzerordner
DIESER Maschine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:21:00 +02:00
HitonabiandClaude Fable 5 cedab61ddf chore(main): Der Windows-Anteil ist raus — Entscheid 7, § 11 umgesetzt
Ampel / ampel (push) Failing after 41s
Teil A (ganze Dateien): windows_app, fenster, setup_fenster,
einrichtung, standalone, daemon, drives/windows + win_ioctl,
platform/verknuepfungen + win_registry, tools/einrichten,
rip/audio_cd + musicbrainz_cd, queue/ (lokal + laeufer),
packaging/windows/, test_keine_container_reste — samt Tests.
Bilanz: +51 / -10.039 Zeilen in 48 Dateien.

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:18:10 +02:00
HitonabiandClaude Fable 5 6f230cbd7f fix(tests): Linux-only-Waechter pruefte die Welt vor d3e86d2
Die Ampel war rot, und die Ursache war NICHT Rippy v5: d3e86d2 hat den
makemkvcon-Zweig aus dem Prescan bewusst entfernt (einer der 15 Funde),
aber test_makemkvcon_wird_ueber_den_katalog_gesucht verlangte weiter den
alten Katalog-Aufruf. Der Test laeuft nur in der CI (fcntl, lokal
ausgeschlossen) — deshalb fiel es erst beim ersten Push des Branches auf.
Der Waechter passt jetzt auf die NEUE Wirklichkeit auf: kein shutil.which
UND makemkvcon bleibt draussen. Unter Linux nachgemessen: 16/16 gruen,
Gesamtlauf der Nachstellung 995 passed + dieser Fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:04:54 +02:00
HitonabiandClaude Opus 5 9050fea3f0 docs: SAVEPOINT v4.0-rc11
Der Schalter, den es nicht gibt — und vierzehn weitere Funde aus demselben
Rundgang. Jeder mit der Messung, an der er haengt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:08 +02:00
HitonabiandClaude Opus 5 d3e86d2641 fix(windows): Der Schalter, den es nicht gibt, und vierzehn weitere Funde
Commander: „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake
endete mit Code 0" — 16,5 GB fertiger Rohschnitt, und die Kompression war in
derselben Sekunde vorbei, in der sie begann.

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`. Ein unbekannter Schalter ist fuer HandBrake kein Fehler,
der Rueckgabewert ist 0. Der Test dazu forderte den falschen Namen sogar ein.

Daraus wurde ein Rundgang durch den Windows-Pfad. Alles unten ist gemessen,
nichts vermutet (Regel D).

## Die Kompression

1. `--aencoder` statt `--audio-codec`. Am mitgelieferten HandBrakeCLI 1.11.2
   gemessen, mit einem 5-Sekunden-Encode auf der echten Roh-Datei bestaetigt.

2. HandBrakes letzte Zeilen werden aufgehoben (12 gepuffert, 4 in der
   Meldung) und `unknown option (...)` wird als eigener Fall erkannt, VOR
   allen anderen. Vorher wurde jede Zeile weggeworfen, die kein Fortschritt
   war — bei Rueckgabewert 0 blieb damit keine Auskunft uebrig. Die geratene
   Zeile „Meist ist der Zielordner nicht beschreibbar" ist raus; sie war
   falsch und hat die Suche in die falsche Richtung geschickt.

3. Ein `ue`-Umlaut im Pfad toetete die Kompression. HandBrake schreibt zwei
   Kodierungen in denselben Strom (derselbe Pfad einmal UTF-8, einmal CP850).
   In CP850 ist das Byte 0x81, und das ist in cp1252 — was `text=True` auf
   deutschem Windows waehlt — undefiniert:

       UnicodeDecodeError: charmap codec can't decode byte 0x81

   Neu: `rip/handbrake_aufruf.py` mit `HB_LESEN`, benutzt von ripping.py und
   caps.py. Bewusst nicht binaer wie bei makemkvcon: HandBrake trennt
   Fortschrittszeilen mit CR, im Binaermodus waere der Balken weg.

## Die Rohdaten

4. `rohdaten.kandidaten` machte aus dem Arbeitsordner `F:\` ein `F:` und
   verband damit weiter. Das ist unter Windows der aktuelle Ordner auf
   Laufwerk F, nicht dessen Wurzel — 16,5 GB waren unsichtbar, und der
   Wiederholen-Dialog bot nur „Neu rippen" an. Die Falle steht woertlich im
   Kopf von `pfade.verbinden`.

5. Gesucht wurde unter der heutigen Einstellung statt unter der Wahl DIESES
   Rips (`meta["work_dir"]`). Genau dafuer wurde rohdaten.py am 26.07.
   gebaut; repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
   Neu: `_arbeitsverzeichnis_des_jobs`, benutzt an vier Stellen.

6. Zwei Speicher fuer dieselben Ordner: Die Oberflaeche schreibt
   `outputDir`/`workDir` in die Datenbank, `betrieb` liest `storage.*` aus
   der Konfigurationsdatei, und die schreibt niemand. Gemessen: eingestellt
   `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. Neu:
   `betrieb.mit_einstellungen`.

## Das Laufwerk

7. `device_info` fing den OSError ab und lieferte „unknown" ohne den Grund.
   Nach einem Rip mit Lesefehlern beantwortete das Laufwerk keine
   Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon —
   im UI stand eine volle Laufwerkskarte, kein Rip startbar, und im
   Protokoll das laengst veraltete „Disc erkannt". Neu: `ZUGRIFFS_GRUENDE`,
   ein Feld `grund` im Laufwerks-Eintrag und eine Protokollzeile je Wechsel.
   Eine fehlgeschlagene Disc-Erkennung wird ebenfalls protokolliert.

8. Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty", waehrend
   Windows richtig „unknown" sagt. Angeglichen, samt Feld-Paritaet.

9. `CreateFileW`, `DeviceIoControl` und `CloseHandle` hatten weder `restype`
   noch `argtypes` — 32-Bit-`c_int` fuer einen 64-Bit-HANDLE, in beide
   Richtungen. Mit `restype` aendert sich der Fehlerwert von -1 auf
   0xFFFFFFFFFFFFFFFF; die Pruefung deckt jetzt beides ab. Am echten
   Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.

10. Der Vor-Scan lief bei JEDER eingelegten Disc ein `makemkvcon info` mit
    120 s Zeitgrenze — 20 bis 120 Sekunden „Disc wird gelesen". Frueher war
    das schnell, weil der Zweig unter Windows nie lief (`shutil.which`,
    repariert am 28.08.). 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. Entfernt.

## Notbremsen

11. `_frei_bytes` suchte den naechsten vorhandenen Ordner selbst.
    `os.path.dirname("Q:\\")` gibt sich selbst zurueck — ein
    Arbeitsverzeichnis auf einer abgezogenen Platte haette den Job vor dem
    Rip stumm haengen lassen. Benutzt jetzt
    `pfade.naechster_vorhandener`, das den Abbruch seit V2-1 hat.

12. `naechster_vorhandener` haelt Laufwerks- und UNC-Wurzeln jetzt absolut.

13. `aufraeum_skript` baut sein `rmdir /s /q` aus `InstallLocation` in der
    Registry. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
    das Laufwerk. Nicht beobachtet, aber nicht wiedergutzumachen — der
    Loeschbefehl bleibt in dem Fall weg.

## Lesefehler

MakeMKV sicherte 1 von 2 Titeln, endete mit 0, und Rippy schrieb „Rip
fertig". Jetzt gibt es eine Warnung, auch wenn der Rip als Erfolg endet, und
die MSG-Nummer steht im Protokoll: MakeMKVs Texte sind uebersetzt, die
Nummern nicht.

## Aus der Gegenprobe am laufenden Rippy

Die erste Fassung dieses Standes war installiert, als der Commander meldete:
„nun oeffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hoert auch einfach auf zu lesen." Beides Altlasten,
die erst durch die neue Protokollzeile sichtbar wurden.

14. Prozesserzeugung mitgeschnitten:

        14:54:40  timeout.exe          timeout 4 ls -d C:\Users\...\d7ee6c06-...
        14:54:40  WindowsTerminal.exe

    `rohdaten.pruefen` fragt mit `timeout N ls -d`, ob es ein Verzeichnis
    gibt. Unter Linux ist das richtig (os.path.isdir kann an einem toten
    CIFS-Mount im Kernel haengen, ein Kindprozess laesst sich abbrechen).
    Unter Windows ist es dreifach falsch: timeout.exe gibt es dort, kennt
    aber weder `ls` noch `-d`; sie braucht eine Konsole, und die reisst
    Windows auf; und ihr Rueckgabewert ist nie 0, die Antwort lautete also
    „weg" fuer JEDES Verzeichnis. Rohdaten waren unter Windows
    grundsaetzlich unsichtbar. Neu: `nativ_nachsehen()`. Die zwei
    gleichartigen Aufrufe in mounts.py bekommen dieselbe Absicherung.

15. Der Waechter fragte das Laufwerk alle drei Sekunden ab — auch mitten im
    Rip, also drei CreateFileW plus IOCTLs auf ein Geraet, das makemkvcon
    gerade liest:

        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

    `_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel „waehrend
    eines Rips wird nicht gescannt"; die Laufwerksabfrage tat es nicht.
    Jetzt gilt in der Zeit der letzte bekannte Stand.

## Zwei Tests, die gelogen haben

* `assert "--audio-codec" in cmd` schrieb den Fehler fest.
* `lambda: {}` als Doppelgaenger fuer `get_settings(key, bei_fehler_leer)`
  brach, sobald ein Aufrufer einen Parameter benutzte — und zeigte dann auf
  den Code statt auf sich selbst.

977 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:11:00 +02:00
HitonabiandClaude Opus 5 b6a93aa726 docs: SAVEPOINT v4.0-rc10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:55:44 +02:00
HitonabiandClaude Opus 5 fed24237c9 fix(windows): makemkvcon ueberlebte Rippy und hielt das Laufwerk fest
Commander: „jetzt hast du den makemkvcon kram dir nicht angeguckt".
Stimmt — es stand als Frage im Notizbuch statt als Riegel im Code.

## Gemessen, nicht vermutet

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

Windows raeumt Kindprozesse nicht auf. Eine solche Waise HAELT DAS LAUFWERK —
jeder spaetere Rip scheitert dann mit „Das Öffnen der Disk schlug fehl". Zwei
davon standen waehrend der Messungen auf diesem Rechner.

## Zwei Riegel

* **Die Leine** (`winlauf.kinder_an_die_leine`) — eine Arbeitsgruppe (Job
  Object) mit KILL_ON_JOB_CLOSE. Stirbt Rippy, sterben makemkvcon, HandBrake
  und flac mit. Auch beim Absturz, auch per Taskmanager. Einmal beim
  Dienststart gesetzt, deckt sie JEDEN Werkzeugaufruf ab.
* **Der Aufraeumer** (`winlauf.waisen_beenden`) — beendet beim Start
  Werkzeug-Prozesse, deren Elternprozess es nicht mehr gibt. Fuer das, was
  eine aeltere Fassung oder ein Absturz hinterlassen hat. Nur ELTERNLOSE:
  Ein makemkvcon eines laufenden Rippy bleibt unangetastet, `makemkv.exe`
  (die Oberflaeche) steht gar nicht erst auf der Liste.

Am echten Fall nachgestellt: Waise erzeugt, Rippy gestartet, Waise weg.

## Drei Prozesse duerfen NICHT mitsterben

Dienst, Fensterprogramm und das Aufraeum-Skript der Deinstallation loesen sich
ueber `eigenstaendig_starten` heraus (CREATE_BREAKAWAY_FROM_JOB). Mit
Rueckfall ohne die Fahne: 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.

## Die Falle beim Bauen

Der erste Anlauf meldete nur „ging nicht". Ursache: Ohne `argtypes` reicht
ctypes einen Griff als 32-Bit-int weiter. `GetCurrentProcess()` liefert aber
(HANDLE)-1 = 0xFFFFFFFFFFFFFFFF, ctypes wirft `ArgumentError: int too long to
convert`, und das breite `except` verschluckte es. `kernel32()` meldet jetzt
jede Signatur an; ein Test wacht darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:54:44 +02:00
HitonabiandClaude Opus 5 21c626eb4e docs: SAVEPOINT v4.0-rc9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:40:25 +02:00
HitonabiandClaude Opus 5 f0f719ca12 fix(windows): Zweiter Rip startete waehrend der Kompression ins leere Laufwerk
Der Commander meldete einen Rip-Fehlschlag „bei der Komprimierung", obwohl
der Rip laengst durch war:

    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.

Der Ablauf dahinter:
  1. Rip fertig → Rippy wirft die Disc aus (Standardeinstellung)
  2. Status wird auf `transcoding` gesetzt — ab hier sagte `has_active_job`
     NEIN, das Laufwerk sei frei; es kennt nur pending/running
  3. Die Disc-Wache sieht beim Auswurf einen Statuswechsel → „eingelegt"
  4. Die Vollautomatik startet einen ZWEITEN Rip — auf ein Laufwerk, dessen
     Schublade gerade herausfaehrt

Der zweite Rip lief in ein leeres Laufwerk. Auf dem Bildschirm sah das aus,
als sei die Kompression gescheitert — sie lief ungestoert weiter.

Drei Aenderungen:

* `store.job_offen` — der weitere Riegel (pending/running/transcoding/
  canceling) fuer die Vollautomatik. `has_active_job` bleibt unveraendert:
  Das fragt „haelt gerade jemand das Laufwerk?", und waehrend der Kompression
  tut das niemand — ein Auswurf von Hand bleibt erlaubt.
* `ripping.disc_fehlt` — vor dem makemkvcon-Start nachsehen, ob ueberhaupt
  eine Disc drin liegt. Statt zwei Minuten Warten und Code 11 gibt es einen
  Satz, den man versteht. Ein FEHLGESCHLAGENER Blick verweigert nichts:
  „ich weiss es nicht" darf nie zu „es geht nicht" werden.
* MSG 5010 in KRITISCHE_CODES — MakeMKVs Sammelmeldung sagt fuer sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:36:59 +02:00
HitonabiandClaude Opus 5 f6f0a4ccb6 feat(windows): MusicBrainz-Tags fuer Audio-CDs
Ampel / ampel (push) Successful in 1m25s
Commander: „go"

Die Dateien hiessen `Track 01.flac`. Eine Musiksammlung ohne Titel ist eine
Sammlung von Nummern.

## Wie MusicBrainz eine CD wiedererkennt

Nicht am Namen (den kennt die CD nicht) und nicht an einer Kennung auf der
Disc (die gibt es nicht), sondern an der LAGE ihrer Spuren. Daraus wird ein
Fingerabdruck gerechnet — SHA-1 ueber die Versaetze, base64 mit eigenem
Alphabet (musicbrainz.org/doc/Disc_ID_Calculation).

## Belegt OHNE Audio-CD

MusicBrainz gibt zu jeder bekannten Kennung die Spurlage heraus, aus der sie
gerechnet wurde. Wer die Lage zurueckbekommt und daraus dieselbe Kennung
rechnet, hat die Rechnung belegt:

    Spurlage „Back in Black" (abgefragt)  ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-
    MusicBrainz sagt dazu                 ->  3KVSIWn_ewv9z0cCizmOJzDWtsQ-

Und die ganze Kette am Stueck, mit echtem 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 zurueckgelesen

## Zwei Fallen

**Der Vorlauf.** Die Kennung rechnet MIT den 150 Frames, das Lesen OHNE. Wer
das verwechselt, bekommt eine Kennung, die niemand kennt — und zwar ohne
Fehlermeldung, denn die Antwort ist dann schlicht „unbekannte Disc".

**`AC/DC`.** Als Ordnername haette der Schraegstrich unter Windows einen
Unterordner aufgemacht. Aufgefallen NUR, weil der Beweis den Namen ausgegeben
hat. Jetzt saeubert `sauberer_name` beides — Datei und Ordner.

## ⚠️ Mein Fehler dabei

Die Spurlage im Test hatte ich zuerst ERFUNDEN: Beim Beweis hatte ich nur
Spurzahl und Lead-Out ausgegeben und den Rest „passend" ergaenzt. Der Test
wurde prompt rot — zu Recht. Die Zahlen stehen jetzt so drin, wie MusicBrainz
sie herausgibt (erste Spur bei 182, nicht bei 150: diese Pressung hat einen
laengeren Vorlauf).

Ohne Treffer bleibt es bei `Track 01.flac`. Eine unbekannte Disc ist kein
Fehler, und ein Netzausfall darf keinen Rip umwerfen.

932 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:33:46 +02:00
HitonabiandClaude Opus 5 986fcf19b1 feat(windows): Audio-CDs rippen — die letzte Luecke ist zu
Ampel / ampel (push) Successful in 1m24s
Commander: „mach es"

Gemeint war die Lücke, die seit Wochen so im SAVEPOINT stand: **Audio-CDs
laufen unter Windows nicht.** `cdparanoia` und `abcde` sind Linux-Werkzeuge.

## Nicht abcde nachbauen — den Windows-Weg gehen

Eine Audio-CD hat kein Dateisystem. Die `Track01.cda`, die Windows zeigt,
sind 44 Byte grosse Platzhalter; die Musik liegt roh in 2352-Byte-Sektoren.
Windows bietet dafuer zwei Steuercodes an:

    IOCTL_CDROM_READ_TOC   Inhaltsverzeichnis (MSF je Spur, Audio/Daten)
    IOCTL_CDROM_RAW_READ   die Sektoren selbst

Gegengeprueft, dass die berechneten Codes den dokumentierten entsprechen:
0x24000 und 0x2403e.

Kodiert wird mit dem FLAC-Encoder vom offiziellen Xiph-Spiegel (1.5.0), den
Rippy beim Einrichten holt — wie MakeMKV. KEIN Pflichtwerkzeug: Ohne ihn
laeuft alles ausser Audio-CDs, und ein Fehlschlag darf das Einrichten nicht
truebe machen.

## Zwei Fallen, beide gemessen

**Die Leseadresse zaehlt in 2048er-Einheiten**, obwohl ein Audio-Sektor 2352
Bytes hat. Das ist dokumentiert und sieht falsch aus; mit 2352 liest man an
der falschen Stelle.

**`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. Jetzt wird der ganze Win64-Ordner ausgepackt.

## Was geprueft ist

Ein Laufwerk und eine Audio-CD lassen sich in der Ampel nicht herstellen.
Deshalb steht alles Rechenbare in reinen Funktionen — MSF↔LBA, das Zerlegen
der TOC-Bytes, WAV-Kopf, Blockaufteilung, Dateinamen — und der Ablauf
bekommt Laufwerk und Encoder eingespritzt. 28 Tests dafuer.

Zusaetzlich mit dem ECHTEN Encoder gemessen: zwei Spuren erzeugten Tons
gerippt, und **FLAC bestaetigt seine eigenen Dateien** (`flac -t`, Code 0).
Am echten Laufwerk gegengeprueft, dass das Inhaltsverzeichnis gelesen wird —
die eingelegte Blu-ray meldet sich korrekt als DATEN-Track und wird nicht als
Musik behandelt.

Was noch fehlt: MusicBrainz-Tags. Die Dateien heissen `Track 01.flac`. Der
Weg dafuer steht (`tags_je_spur`), die Disc-Kennung fuer die Abfrage nicht.

915 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:26:23 +02:00
HitonabiandClaude Opus 5 1b84ec2a45 fix(windows): Vollstaendiger Rundgang durch die Docker-Reste
Ampel / ampel (push) Successful in 1m20s
Commander: „Bro, du musst alles was rippy jetzt im code hat für Windows
Bauen! Jeden pfad, alles wo die tools drauf zugreifen. Diese Rippy version
MUSS 100% Windows Kompatibel sein. Prüfe bitte den kompletten Quellcode nach
Docker Resten."

Systematisch gesucht statt Fundstelle fuer Fundstelle: feste POSIX-Pfade,
Linux-Programme, POSIX-eigene Aufrufe, `shutil.which`, `posixpath` auf echten
Pfaden, Container-Texte. Sechs echte Fehler dabei.

## 1. `/dev/{name}` in drei Endpunkten — der schwerste

Das UI ruft `/devices/{id}/eject`, `/scan-tracks` und `/tracks` mit der
Kennung aus der Geraeteliste auf, unter Windows also `G`. Gebaut wurde daraus
`/dev/G` — steht in keiner Laufwerksliste. **Auswerfen und „Disc scannen"
antworteten unter Windows IMMER mit 404**, ohne dass irgendwo stand, warum.

Hin- und Rueckweg gehoeren zusammen: Beide Treiber haben jetzt `kennung()`
und `pfad_zu_kennung()`. Wer die Kennung vergibt, loest sie auch auf.

## 2. `os.path.isdir("/app")` — zum zweiten Mal

Nach `caps.py` (heute frueh) auch in `ablauf.py`: Der eigenstaendige
Windows-Rippy hielt sich fuer einen FREMDEN Worker und haette sich selbst
vorgeworfen, Container-Pfade nicht zu erreichen — auf einer Maschine ohne
Container. Die Entscheidung ist jetzt einspritzbar; vorher hing der Test
daran, ob es einen Ordner `/app` gibt.

## 3. `shutil.which` in `schluessel.py`

Ausgerechnet im Modul, das es NUR unter Windows gibt: Es suchte makemkvcon im
PATH, wo unter Windows nie ein Programm aus „Programme" steht. Die
Schluessel-Automatik fuer 4K-UHD lief damit nie an.

## 4. `posixpath.join` auf echten Pfaden

`rohdaten.py` baute `C:\Roh/datei.mkv` — gemischte Trenner, die im UI falsch
aussehen und jeden Vergleich brechen.

## 5. Container-Pfad in einer Nutzermeldung

„Roh-Datei bleibt in /app/temp erhalten" nennt jetzt den echten Ordner. Wer
die Datei retten will, sucht sonst am falschen Ort.

## 6. Container-Pfade als UI-Vorbelegung

Rip-Dialog und `useBetrieb` starteten mit `/app/media`, bis die Antwort da
war. Leer ist ehrlicher: Es behauptet nichts.

## Und HandBrakes „Code 0"

Code 0 heisst ERFOLG. Rippy meldete trotzdem „fehlgeschlagen", weil die Datei
nicht am erwarteten Ort lag: **HandBrake bestimmt den Container aus dem
PRESET, nicht aus der Endung** — ein MP4-Preset schreibt `.mp4` neben das
verlangte `.mkv`. Jetzt erzwingt `--format` den Container passend zur Endung
(an HandBrake 1.11.2 gegengeprueft), und falls doch etwas daneben liegt, wird
es gefunden statt weggeworfen.

## Der Waechter

`test_keine_container_reste.py` prueft mechanisch, dass im Windows-Weg kein
Container-Pfad ohne Begruendung steht. Die Ausnahmen stehen namentlich mit
Grund da (Linux-Zweige, benannte Rueckfaelle) — und ein zweiter Test wirft
jede Ausnahme raus, die niemand mehr braucht.

Ueber den Tokenizer, nicht ueber „faengt mit Anfuehrungszeichen an": Der
erste Anlauf blieb prompt an seinem eigenen `r\"\"\"`-Docstring haengen.

887 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 16:14:10 +02:00
HitonabiandClaude Opus 5 1a529f4e75 fix(windows): „Disc wird gelesen" blieb waehrend des ganzen Rips stehen
Ampel / ampel (push) Successful in 1m13s
Commander: „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.

## Warum

`_auto_prescan` hatte GENAU EINE Absicherung: nicht zweimal gleichzeitig
(`_laeuft`). Ob auf dem Laufwerk gerade ein Rip laeuft, hat es nie gefragt.

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, der `info`-Aufruf laeuft in
seine 120 s). Solange steht die Marke `_laeuft` — und damit das Panel, das
ich heute frueh genau dafuer gebaut habe.

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.

## Zwei Stellen, weil es zwei Wege hinein gibt

1. `_auto_prescan` bricht ab, wenn auf dem Geraet ein Job laeuft. Das
   verhindert jeden Scan, der NACH dem Rip-Start angestossen wird.
2. Der Job-Start raeumt eine haengende Marke weg. Ein Scan, der KURZ VORHER
   begann, haelt sie sonst bis zu seinem Ende fest. Verloren geht dabei
   nichts: Unter `_laeuft` steht nur der Platzhalter, und der laufende Scan
   traegt sein Ergebnis spaeter selbst nach.

880 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:54:48 +02:00
HitonabiandClaude Opus 5 548742e771 fix(windows): Restzeit blieb ewig „wird gemessen" — zwei Docker-Annahmen
Ampel / ampel (push) Successful in 1m32s
Commander: „Über den kompletten vorgang steht dort ‚Restzeit wird gemessen'
aber messung wird nicht abgeschlossen. Das heißt man hat kein ETA"

Zwei Ursachen, beide unabhaengig, beide toedlich fuer sich allein.

## 1. Die Messreihe lag nur in Redis

`eta.py` schrieb sie ausschliesslich in den Cache — mit der Begruendung im
Modul-Kopf: „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
zurueck, `beobachtung_hinzufuegen` legte also jedes Mal eine frische Reihe mit
EINEM Punkt an — und `restzeit_sekunden` braucht `MINDEST_PUNKTE = 2`.

Jetzt wird in beide Ablagen geschrieben: in den Cache, wo es einen gibt (er
ueberlebt einen API-Neustart), und in ein Woerterbuch im Prozess. Das ist ein
paar Zahlen gross, gilt nur fuer die Dauer eines Jobs, und im eigenstaendigen
Betrieb gibt es ohnehin nur diesen einen Prozess. Alte Reihen werden nach
einem Tag weggeraeumt.

## 2. Der Ereignisstrom rechnete die Restzeit gar nicht

Die 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 Oberflaeche aber genau diesen
Schnappschuss und nicht mehr `/jobs`.** Die Restzeit wurde also brav berechnet
und niemandem gezeigt.

Beides jetzt in `jobs_fuer_ui()` — dieselbe Lehre wie bei
`laufwerke_mit_disc` heute frueh: Eine Auskunft in zwei Fassungen ist eine
Fassung zu viel.

Nebenbei: Unlesbare Einstellungen duerfen die Jobliste nicht umwerfen. Seit
sie auch den Schnappschuss baut, haengt daran die ganze Oberflaeche — zwei
Snapshot-Tests wurden davon prompt rot.

## Beweis

Job in der Datenbank, Fortschritt 10 % -> 25 % ueber 130 s:

    nach 1. Messpunkt : eta_text=''            (richtig, eine Messung reicht nicht)
    nach 2. Messpunkt : 674 s, „noch ca. 11 min"

877 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:49:59 +02:00
HitonabiandClaude Opus 5 aeb3128a85 fix(windows): Dauerscan der Disc, aufblitzende Fenster, Docker-Einstellung uebernommen
Ampel / ampel (push) Successful in 1m30s
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

In der Disc-Wache stand:

    except OSError:
        continue

Damit blieb `bekannt[pfad]` ungesetzt, und im naechsten Durchlauf war
`vorher is None` — also wieder „Erststart, liegt schon eine Disc drin". **Ein
einziger fehlgeschlagener Lesevorgang loeste einen neuen Vor-Scan aus.**

Und der scheitert regelmaessig: Waehrend der Vor-Scan laeuft, haelt
makemkvcon das Laufwerk (gemessen: /devices braucht dann 14 s statt 5). Also
Scan haelt das Laufwerk -> Statusabfrage scheitert -> naechster Durchlauf
haelt es fuer den ersten -> neuer Scan. Eine Schleife, die sich selbst am
Leben haelt.

Und weil derselbe makemkvcon-Aufruf in `prescan.py` als EINZIGER kein
`creationflags=OHNE_FENSTER` hatte, blitzte bei jeder Runde eine Konsole auf.
Das war meine Zeile von heute Vormittag.

Die Entscheidung steckt jetzt in `disc_entscheidung()` — pure Funktion, also
ohne Laufwerk pruefbar. Zwei getrennte Fragen statt einer: Haben wir dieses
Laufwerk je gelesen, und was war zuletzt drin? Ein Fehlschlag beantwortet die
zweite nicht und darf die erste nicht zuruecksetzen.

Nachgemessen: Ein Tabwechsel loest KEINEN neuen Scan aus (8 Erkennungen
vorher, 8 nachher). Meine erste Zaehlung von „7 vs 8" war ein Messfehler —
zwei verschieden grosse Abfragefenster.

## Aus dem Docker-Betrieb uebernommen

`docker/worker/entrypoint.sh` setzt vor jedem Worker-Start zwei Dinge:
`app_Key` und `app_UpdateEnable`. Der Key war schon uebernommen, die zweite
nicht — auf dem Rechner des Commanders nachgesehen: NICHT gesetzt.

Das ist MakeMKVs Web-Kontakt; darueber holt es die Disc-Schluessel fuer
4K-UHD nach (Meldung 3338, auf Windows gemessen). „Fuer Windows umschreiben"
heisst hier: Registry statt settings.conf, DWORD statt Text — die
Nachbarwerte (`app_BackupDecrypted`) zeigen die Form.

Gesetzt wird NUR, was fehlt: Wer den Web-Kontakt bewusst abgeschaltet hat,
behaelt ihn abgeschaltet. Ein Waechter-Test vergleicht kuenftig die
`app_*`-Namen aus der entrypoint.sh gegen die Windows-Seite — sonst driften
die beiden Betriebe auseinander.

871 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:30:29 +02:00
HitonabiandClaude Opus 5 f0d39b4d04 docs(savepoint): laufende Disc-Erkennung sichtbar, verwaiste makemkvcon notiert
Ampel / ampel (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:13:43 +02:00
HitonabiandClaude Opus 5 e67138e9ca feat(ui): Laufende Disc-Erkennung ist jetzt sichtbar
Ampel / ampel (push) Successful in 1m26s
Commander: „das die disc erkennung noch läuft muss sichtbar sein"

Er hat recht, und die Wartezeit ist gemessen: `makemkvcon info` laeuft an
einer Blu-ray in seine 120-Sekunden-Grenze (Disc nach 119 s erkannt). Zwei
Minuten, in denen NICHTS zu sehen war — weder die Disc noch ein Hinweis. Das
sah aus wie ein leeres Laufwerk, also wie ein Fehler.

## Der Zustand war da, er wurde nur weggeworfen

`_auto_prescan` legt beim Start `{"_laeuft": True}` in den Vorrat.
`laufwerke_mit_disc` liess so einen Eintrag KOMPLETT weg — damit keine
halbfertige Disc-Karte mit „Wird erkannt…" als Titel und einem „Rippen
starten"-Knopf erscheint. Richtig gedacht, falsch geloest.

Jetzt gibt es ein eigenes Feld `disc_wird_erkannt`. Die Oberflaeche kann
„wird gelesen" zeigen, ohne einen Titel zu behaupten, den es noch nicht gibt:
eine Karte mit Spinner ueber der Laufwerksliste und eine eigene Zeile im
Server-Status.

## Und der Haken dahinter

Genau waehrend der Erkennung ist die Laufwerks-Auskunft am teuersten:
makemkvcon haelt das Laufwerk, und `device_info` wartet mit. Gemessen:

    /devices waehrend des Scans     14,0 s
    Zeitgrenze des Schnappschusses   5,0 s   ->  devices = None

`None` heisst „konnte nicht nachsehen", und die Oberflaeche behaelt dann
ihren Stand — nach einem frischen Laden also GAR NICHTS. Die Anzeige waere
ausgerechnet in ihrer eigenen Phase leer geblieben.

`laufwerke_notdurft()` antwortet deshalb ohne ioctl: letzter bekannter Stand
plus die aktuelle Erkennungs-Marke aus dem Vorrat. Beim Kaltstart wenigstens
der Laufwerksname — die LISTE der Laufwerke ist billig, teuer ist erst das
Anfassen. Nichts wird erfunden: kein Typ, kein Modell. Und wer noch nie
erfolgreich gelesen hat, schweigt weiter mit `None`.

Im Browser gegengeprueft: „Disc wird gelesen — Laufwerk G:" mit Spinner,
Server-Status „Disc wird gelesen — das dauert bis zu zwei Minuten", danach
der richtige Titel.

859 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:12:35 +02:00
HitonabiandClaude Opus 5 dbb934d146 docs(savepoint): v4.0-rc7 — Drueberinstallieren, Linux-Reste, Ordner-Waehler, Jobzeile
Ampel / ampel (push) Successful in 1m9s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:01:34 +02:00
HitonabiandClaude Opus 5 7b0c41ddfe fix(ui): Datum 1.1.1970, Disc nicht erkannt, kein Abbrechen — eine Ursache je Fall
Ampel / ampel (push) Successful in 1m16s
Vier Befunde aus einem Bildschirmfoto vom 29.08.2026.

## 1. „das Datum ist falsch" — und der Typ auch

    TYP „DISC"   STARTZEIT „1.1.1970, 01:00:00"   STATUS „running"

⚠️ Das war MEIN Fehler von heute Vormittag. Ich hatte `type`, `device` und
`startTime` in das Job-Ereignis aufgenommen — mit Feldnamen, **die es in der
Datenbankzeile nicht gibt**. Dort heissen sie `disc_type` und `created_at`;
die Uebersetzung macht `_job_row_to_model`, und sie bildet auch `running` auf
`processing` ab.

Die Folge war schlimmer als das Problem davor: Statt eines FEHLENDEN Feldes
kam ein LEERES. `new Date(null)` ist der 1.1.1970, und „DISC" war der
Rueckfall, den ich zwei Stunden vorher fuer 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
erfunden habe — mit meinen falschen Feldnamen. Ein Test, der dieselbe Annahme
macht wie der Code, prueft nichts. Der neue geht von der ECHTEN Zeile aus.

Der Waechter bekommt jetzt dieselbe Uebersetzung eingespritzt, die auch
`/jobs` benutzt.

## 2. „Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht"

Die Laufwerksliste wurde an DREI Stellen gebaut: `/devices` haengte die
erkannte Disc an, der Ereignis-Waechter und der SSE-Schnappschuss nicht. Das
Dashboard liest den Schnappschuss — und schloss aus der fehlenden Disc auf ein
leeres Laufwerk, waehrend ihr Titel eine Zeile weiter oben stand.

Jetzt gibt es `laufwerke_mit_disc()`, und ein Test zaehlt die Aufrufe von
`device_info` — bei zwei ist er rot.

## 3. „man kann einen rip garnicht abbrechen"

Dieselbe Ursache wie 1: Es gab genau EINEN Abbrechen-Knopf, in der Kachel
„laufender Job". Die erscheint nur bei Status `processing`; der Strom lieferte
`running`. Keine Kachel, kein Knopf, kein Weg zurueck — und aus demselben
Grund stand „Aktiv (0)", waehrend der Rip lief.

Zusaetzlich gibt es den Knopf jetzt in der Job-Zeile selbst, wo man ihn sucht.

## 4. „bei ‚Platz für rippy' sollte eher das arbeitsverzeichnis und der
##     Ablagepfad sein"

Dort stand eine nackte Zahl fuer den ERSTEN Ort. Welcher Ordner das war, sah
man nicht — und die zweite Zeile fiel ganz weg, wenn beide auf derselben
Platte lagen. Das stimmt fuer die ZAHL und war der falsche Schluss fuer den
ORT. Jetzt stehen beide da, mit Pfad, plus der Hinweis, dass der Platz
geteilt wird.

Im Browser gegengeprueft: „Disc erkannt — wartet auf ‚Rippen starten'",
BLU-RAY mit richtigem Datum, beide Pfade, keine Konsolenfehler.

852 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 15:00:11 +02:00
HitonabiandClaude Opus 5 b2acddbdfa fix(windows): Drueberinstallieren, Linux-Reste im Windows-Betrieb, Ordner-Waehler
Ampel / ampel (push) Successful in 1m27s
Vier Fragen des Commanders vom 29.08.2026, drei davon mit Codefolge.

## 1. „Was passiert wenn man die Setup.exe einfach drueber installiert?"

Bis hierher: nicht zuverlaessig. Rippy startet mit Windows, laeuft also fast
immer — dann ist `Rippy.exe` gesperrt, `shutil.copy2` warf PermissionError,
und die neue Fassung landete als `Rippy.exe.neu` daneben. Dazu die Meldung
„wird beim naechsten Start uebernommen".

**Diese Zusage hat niemand eingeloest.** `.neu` kam im ganzen Projekt genau
einmal vor: an der Stelle, die es schrieb. Wer drueberinstallierte, behielt
still die alte Fassung, und das Setup meldete Erfolg.

Jetzt wird der laufende Rippy vorher beendet (`dienst_beenden` gibt es seit
der Deinstallation und wartet auch die zwei Sekunden ab, die Windows fuer die
Dateihandles braucht). Eine von einer aelteren Setup-Fassung liegengelassene
`.neu` wird dabei uebernommen. Bleibt die Datei DANN noch gesperrt, gibt es
einen klaren Fehler statt einer Zusage — Rippy im Infobereich beenden und das
Setup erneut starten.

Der Tausch laeuft bewusst im SETUP und nicht beim Dienststart: Windows sperrt
eine laufende .exe, und `Rippy.exe` waere genau die zu ersetzende Datei.

## 3. Linux-Reste im Windows-Betrieb (Docker/Headless unveraendert)

**`caps.py`: `os.path.isdir("/app")`.** Damit hielt sich der eigenstaendige
Windows-Rippy fuer einen FREMDEN Worker — und das UI warnte vor fehlender
Pfad-Uebersetzung auf einer Maschine ohne Container und ohne Freigabe.
„Extern" heisst jetzt, was es meint: Rippy laeuft woanders als dieser Worker.

**`caps.py`: `shutil.which("makemkvcon")` + `os.path.ismount(daten_dir)`.**
Beide unter Windows immer falsch (Programme liegen nicht im PATH, ein
normaler Ordner ist kein Mount). Die Schluessel-Auskunft blieb dauerhaft
„unbekannt", obwohl MakeMKV samt Datenverzeichnis da war. Der Mount-Test
bleibt fuer den Container, wo er einen Zweck hat.

**`rohdaten.py`: `/app/temp/raw` und `/app/media` fest.** Dieses Modul findet
die Rohdaten eines Jobs wieder — fuer den Wiederholen-Dialog und fuer
„Rohdaten mitloeschen". Unter Windows fand es NIE etwas: Der Dialog meldete
„keine Rohdaten", das Aufraeumen loeschte nichts, und die Bruchstuecke eines
abgebrochenen Rips blieben liegen (bei 4K-UHD bis 100 GB).

Sieben Tests wurden dabei rot, und zwar zu Recht: Sie pruefen Container-Regeln,
liefen aber unter Windows. Die Wurzeln sind jetzt einspritzbar — beide
Betriebsfaelle auf jedem Rechner pruefbar statt vom laufenden abhaengig.

## 4. „Der Durchsuchen button fehlt. Wie es der Installer auch macht"

Neu: `OrdnerWaehler` — Pfadfeld plus „Durchsuchen …", benutzt fuer Ablage und
Arbeitsverzeichnis. Es waere der DRITTE fest eingebaute Ordner-Browser
geworden (RipTargetModal, StorageMounts); dieser hier ist wiederverwendbar.
`/browse` weiss seit dem 28.08. selbst, in welchem Betrieb es laeuft.

⚠️ Beim Einbau fiel der Import unter den Tisch. `vite` pruefte das NICHT — das
Buendel blieb byte-gleich gross, und zur Laufzeit waere es der naechste leere
Bildschirm gewesen. Aufgefallen nur, weil die erwartete Anzahl Ersetzungen
nicht stimmte. Im Browser gegengeprueft: alle sieben Laufwerke, Navigation in
D:\, keine Konsolenfehler.

843 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:28:01 +02:00
79 changed files with 3910 additions and 8603 deletions
+528 -4
View File
@@ -1,6 +1,528 @@
# SAVEPOINT — Rippy # SAVEPOINT — Rippy
## Aktueller Stand: v4.0-rc6 — leerer Bildschirm behoben (29.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 > **Dein Befund war zweimal richtig und einmal irreführend:** Der Bildschirm
> war wirklich leer — aber der Rip lief. Man sah ihn nur nie. > war wirklich leer — aber der Rip lief. Man sah ihn nur nie.
@@ -325,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
View File
@@ -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)}
+454 -68
View File
@@ -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,23 +356,43 @@ 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 status != vorher: elif was == "eingelegt":
if status == CDS_DISC_OK:
db.add_log("info", "watcher", f"Disc eingelegt: {pfad}") db.add_log("info", "watcher", f"Disc eingelegt: {pfad}")
asyncio.create_task(_auto_prescan(pfad)) asyncio.create_task(_auto_prescan(pfad))
elif status in (CDS_NO_DISC, CDS_TRAY_OPEN) and vorher == CDS_DISC_OK: elif was == "entfernt":
db.add_log("info", "watcher", f"Disc entfernt: {pfad}") db.add_log("info", "watcher", f"Disc entfernt: {pfad}")
DISC_CACHE.pop(pfad, None) DISC_CACHE.pop(pfad, None)
bekannt[pfad] = status bekannt[pfad] = status
@@ -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,11 +1022,43 @@ 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 "/")
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() jetzt = time.monotonic()
modelle = [] modelle = []
for z in db.list_jobs(): for z in db.list_jobs(limit=limit):
modell = _job_row_to_model(z) modell = _job_row_to_model(z)
modell.can_retry = _kann_neu_komprimieren(z, work_dir) modell.can_retry = _kann_neu_komprimieren(z, work_dir)
modell.retry_art = phasen.retry_art(z) modell.retry_art = phasen.retry_art(z)
@@ -867,8 +1071,6 @@ async def get_jobs():
modelle.append(modell) modelle.append(modell)
return modelle return modelle
return await asyncio.to_thread(sammle)
def _rohdaten_groesse(pfade: list) -> tuple: def _rohdaten_groesse(pfade: list) -> tuple:
"""(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py). """(Bytes, Dateizahl) der Roh-MKVs (Details in rohdaten.py).
@@ -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 20120 s. GET /devices/{name}/tracks. Dauert je nach Disc 20120 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),
}) })
@@ -2721,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
+6 -2
View File
@@ -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"
+7 -3
View File
@@ -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:
+36 -28
View File
@@ -10,9 +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.rip.makemkv_aufruf import quelle as makemkv_quelle from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.makemkv_aufruf import text_von
from rippy.tools import katalog as werkzeug_katalog
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
@@ -115,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
@@ -331,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():
@@ -355,30 +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", makemkv_quelle(device_path)], # Dass es frueher schnell war, hat einen unschoenen Grund:
capture_output=True, # Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
timeout=120 # binaer lesen — Begruendung in text_von # `shutil.which`, und das findet unter Windows nichts
) # (Programme liegen nicht im PATH). `be3fac5` hat das am
for line in text_von(result.stdout).split('\n'): # 28.08.2026 richtig repariert — und damit erst die Kosten
if line.startswith('TINFO:'): # sichtbar gemacht, die hier immer schon standen.
parts = line.split(',') #
if len(parts) >= 5: # Der Aufwand war umsonst: Das Ergebnis landete allein in
toc["tracks"].append({ # `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title", # holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
"duration": int(parts[2]) if len(parts) > 2 else 0 # und `GET /devices/{id}/tracks` — also dann, wenn sie
}) # 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
View File
@@ -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)
+522 -1
View File
@@ -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"]
+70
View File
@@ -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
-3
View File
@@ -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)
+13 -5
View File
@@ -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.")
+81 -7
View File
@@ -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"
@@ -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
+162
View File
@@ -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>
)
}
+10 -6
View File
@@ -162,11 +162,15 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt. // auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
const betrieb = useBetrieb() const betrieb = useBetrieb()
const [targets, setTargets] = useState<TargetConfig[]>([ /*
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, * Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, *
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true }, * Hier standen `/app/media/movies` & Co. als Anfangswert. Auf einem
]) * Windows-PC gibt es die nicht und fuer den Augenblick zwischen Oeffnen
* des Dialogs und der Antwort von /settings stand dort ein Pfad, den es
* nirgends gibt. Ein leerer Wert ist ehrlicher: Er behauptet nichts.
*/
const [targets, setTargets] = useState<TargetConfig[]>([])
const [browsePath, setBrowsePath] = useState<string>('') const [browsePath, setBrowsePath] = useState<string>('')
const [browseParent, setBrowseParent] = useState<string | null>(null) const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([]) const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
@@ -223,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean) .split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen)) setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen)) setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || betrieb.ablage_vorgabe || '/app/media' const basis = s.outputDir || betrieb.ablage_vorgabe
setTargets([ setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true }, { id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true }, { id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
+5 -2
View File
@@ -77,8 +77,11 @@ const VORGABE: Betrieb = {
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
// auf einem Windows-PC falsch ist (Befund 29.08.2026).
ablage_vorgabe: '',
arbeits_vorgabe: '',
hilfe_befehl: 'docker compose -p rippy ps', hilfe_befehl: 'docker compose -p rippy ps',
geladen: false, geladen: false,
} }
+80 -3
View File
@@ -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,6 +332,12 @@ 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"'
// 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' : 'Bereit — kein Datenträger im Laufwerk'
const filteredJobs = jobs.filter(j => { const filteredJobs = jobs.filter(j => {
@@ -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"> <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:'} {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">
<HardDrive size={14} className="text-amber-400" />
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'}
+5 -4
View File
@@ -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'
@@ -1000,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
@@ -1097,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
+71 -7
View File
@@ -256,12 +256,33 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
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
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
An dieser Stelle stand sie als eigene Schleife:
while kandidat and not os.path.exists(kandidat): while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(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
@@ -310,6 +331,9 @@ def _makemkv_key_anwenden(einstellungen: dict) -> None:
# laeuft und sonst am ersten Titel scheitert. # laeuft und sonst am ersten Titel scheitert.
from ripping import werkzeug 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")) urteil = makemkv_aufruf.key_setzen_und_pruefen(key, werkzeug("makemkv"))
if not urteil["ok"]: if not urteil["ok"]:
db.add_log("warning", "worker", db.add_log("warning", "worker",
@@ -609,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")
@@ -737,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.
# #
@@ -864,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
@@ -886,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),
@@ -1064,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
View File
@@ -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:
+266 -7
View File
@@ -37,6 +37,7 @@ 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 KRITISCHE_CODES, text_von
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.tools import katalog as werkzeuge from rippy.tools import katalog as werkzeuge
@@ -88,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).
@@ -273,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:
@@ -280,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):
@@ -362,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):
@@ -476,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:
@@ -501,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 = [
@@ -510,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)]
@@ -579,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:
@@ -602,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:
@@ -631,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
@@ -642,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
@@ -659,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",
@@ -673,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,
} }
@@ -711,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.
@@ -725,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:
@@ -747,6 +991,13 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
) )
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
@@ -776,6 +1027,8 @@ 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 LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES: if code in KRITISCHE_CODES:
kritische_meldungen.append( kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip())) "%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
@@ -807,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 "")
@@ -862,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",
+8 -1
View File
@@ -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:
+30
View File
@@ -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"
+9
View File
@@ -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
+160 -3
View File
@@ -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]
-254
View File
@@ -1,254 +0,0 @@
"""Baut RippySetup.exe — eine Datei, die installiert, laeuft und sich entfernt.
## Aufruf
python packaging/windows/build.py [--ausgabe ORDNER] [--version 2.0.0]
Voraussetzungen (in der Bau-Umgebung, nicht beim Nutzer):
* Python 3.12 mit den API-Abhaengigkeiten (docker/api/requirements.txt)
* pyinstaller, pystray, pillow
* Das UI muss gebaut sein: cd docker/ui && npm run build
## Warum die Versions-Ressource nicht optional ist
Ohne sie steht im Taskmanager unter Beschreibung" nichts, und in den
Datei-Eigenschaften fehlt jede Angabe zu Herausgeber und Version. Eine EXE
ohne diese Angaben sieht aus wie etwas, das man nicht ausfuehren sollte
und Windows SmartScreen sieht das genauso.
Der Commander hat ausdruecklich einen Eintrag im Taskmanager gewuenscht.
Der Prozessname kommt vom Dateinamen (`Rippy.exe`), die BESCHREIBUNG
daneben aus dieser Ressource.
## Was mit hinein muss
docker/api/ die API-Module (main.py und Nachbarn)
docker/ui/dist/ die gebaute Weboberflaeche
src/rippy/ der Kern (kommt ueber den Import automatisch mit)
rippy.ico Symbol fuer Tray, Verknuepfung und Programmliste
`docker/api` liegt im Paket unter `api/`, `docker/ui/dist` unter `ui/`
genau dort sucht `rippy/daemon.py` sie, wenn `sys._MEIPASS` gesetzt ist.
"""
import argparse
import os
import shutil
import subprocess
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
VERSION_VORLAGE = """
VSVersionInfo(
ffi=FixedFileInfo(
filevers=({v0}, {v1}, {v2}, 0),
prodvers=({v0}, {v1}, {v2}, 0),
mask=0x3f, flags=0x0, OS=0x40004, fileType=0x1, subtype=0x0,
date=(0, 0)
),
kids=[
StringFileInfo([
StringTable(u'040704b0', [
StringStruct(u'CompanyName', u'Rippy'),
StringStruct(u'FileDescription', u'Rippy — automatisches Ripping'),
StringStruct(u'FileVersion', u'{version}'),
StringStruct(u'InternalName', u'Rippy'),
StringStruct(u'OriginalFilename', u'Rippy.exe'),
StringStruct(u'ProductName', u'Rippy'),
StringStruct(u'ProductVersion', u'{version}')])
]),
# 0x0407 = Deutsch, 0x04b0 = Unicode. Ohne den passenden Eintrag zeigt
# Windows die Angaben je nach Systemsprache gar nicht an.
VarFileInfo([VarStruct(u'Translation', [1031, 1200])])
]
)
"""
def versionsdatei(version: str, ordner: str) -> str:
teile = (version.split("-")[0].split(".") + ["0", "0", "0"])[:3]
pfad = os.path.join(ordner, "version_info.txt")
with open(pfad, "w", encoding="utf-8") as f:
f.write(VERSION_VORLAGE.format(
version=version, v0=int(teile[0]), v1=int(teile[1]), v2=int(teile[2])))
return pfad
def pruefen() -> list:
"""Alles da? Erst pruefen, dann bauen — wie install.sh es macht.
Ein Bau, der auf halber Strecke abbricht, hinterlaesst eine EXE, die
aussieht wie fertig und es nicht ist.
"""
maengel = []
if sys.platform != "win32":
maengel.append("Eine Windows-.exe laesst sich nur unter Windows bauen.")
if not os.path.isfile(os.path.join(REPO, "docker", "api", "main.py")):
maengel.append("docker/api/main.py fehlt.")
if not os.path.isfile(os.path.join(REPO, "docker", "worker", "ablauf.py")):
maengel.append("docker/worker/ablauf.py fehlt — ohne den Ablauf kann "
"die App nicht rippen.")
if not os.path.isdir(os.path.join(REPO, "docker", "ui", "dist")):
maengel.append(
"docker/ui/dist fehlt — die Oberflaeche ist nicht gebaut. "
"Abhilfe: cd docker/ui && npm ci && npm run build")
icon = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
if not os.path.isfile(icon):
maengel.append("deploy/worker-windows/rippy.ico fehlt.")
else:
# Nicht nur DA, sondern VOLLSTAENDIG. Am 28.08.2026 steckte genau ein
# Bild darin (256x256), und Windows zeigte auf Desktop, im Startmenue
# und in der Taskleiste ein leeres Blatt. Eine Datei, die es gibt, ist
# noch keine Datei, die funktioniert.
import sys as _sys
_sys.path.insert(0, HIER)
from icon import fehlende_pflichtgroessen
fehlt = fehlende_pflichtgroessen(icon)
if fehlt:
maengel.append(
"rippy.ico fehlen die Groessen %s — Windows zeigt dann ein "
"leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (fehlt,))
try:
import PyInstaller # noqa: F401
except ImportError:
maengel.append("pyinstaller fehlt (pip install pyinstaller).")
for modul, paket, wofuer in (
("pystray", "pystray", "kein Tray-Symbol"),
("PIL", "pillow", "kein Tray-Symbol"),
("webview", "pywebview", "kein eigenes Fenster, nur ein Browser-Tab"),
("clr", "pythonnet", "kein eigenes Fenster (WebView2 laeuft ueber .NET)"),
):
try:
__import__(modul)
except ImportError:
maengel.append(
f"{modul} fehlt (pip install {paket}) — ohne das {wofuer}.")
return maengel
def bauen(ausgabe: str, version: str) -> str:
arbeit = os.path.join(ausgabe, "_bau")
os.makedirs(arbeit, exist_ok=True)
trenner = ";" # Windows-Trennzeichen fuer --add-data
# HandBrakeCLI ins Paket holen. Ohne das kann Rippy auf einem Rechner
# ohne Internet nicht komprimieren -- und der Commander war da deutlich:
# "Ohne das ist das tool NICHT einsatzfaehig." MakeMKV geht diesen Weg
# NICHT mit (proprietaer, keine Weitergabe erlaubt); es wird beim
# Einrichten vom Hersteller geladen. Siehe packaging/windows/werkzeuge.py.
import sys as _sys
_sys.path.insert(0, HIER)
from werkzeuge import paket_ordner_bauen
werkzeug_ordner = paket_ordner_bauen(ausgabe)
if not werkzeug_ordner:
print(" ! HandBrakeCLI ist NICHT im Paket. Rippy holt es dann beim")
print(" Einrichten selbst -- das braucht aber eine Verbindung.")
befehl = [
sys.executable, "-m", "PyInstaller",
"--noconfirm", "--clean",
"--onefile",
# KEINE Konsole. Wunsch des Commanders am 28.08.2026: "Unterbinde
# das CMD Fenster was sich beim Oeffnen mitoeffnet." Ein Verstecken
# zur Laufzeit reicht nicht -- eine Onefile-EXE laeuft als ZWEI
# Prozesse (die Pruefung "haengt genau einer an der Konsole" ist damit
# nie wahr), und selbst wenn, blitzte das Fenster ein bis zwei
# Sekunden auf, bis Python so weit ist.
#
# Was dafuer noetig ist, steht in packaging/windows/einstieg.py:
# ohne Konsole ist sys.stdout None, und uvicorn wuerde daran sterben.
"--windowed",
"--name", "RippySetup",
"--icon", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico"),
"--version-file", versionsdatei(version, arbeit),
"--distpath", ausgabe,
"--workpath", os.path.join(arbeit, "work"),
"--specpath", arbeit,
"--paths", os.path.join(REPO, "src"),
"--paths", os.path.join(REPO, "docker", "api"),
"--paths", os.path.join(REPO, "docker", "worker"),
"--add-data", os.path.join(REPO, "docker", "api") + trenner + "api",
# Die Worker-Module — ohne sie kann Rippy anzeigen, aber nicht rippen.
# ablauf.py, ripping.py, medien.py, caps.py und Nachbarn.
"--add-data", os.path.join(REPO, "docker", "worker") + trenner + "worker",
"--add-data", os.path.join(REPO, "docker", "ui", "dist") + trenner + "ui",
"--add-data", os.path.join(REPO, "deploy", "worker-windows", "rippy.ico") + trenner + ".",
# Was PyInstaller nicht von allein findet: dynamisch importierte Module.
"--hidden-import", "uvicorn.logging",
"--hidden-import", "uvicorn.loops.auto",
"--hidden-import", "uvicorn.protocols.http.auto",
"--hidden-import", "uvicorn.protocols.websockets.auto",
"--hidden-import", "uvicorn.lifespan.on",
"--hidden-import", "rippy.drives.windows",
"--hidden-import", "pystray._win32",
# pywebview waehlt seine Plattform zur LAUFZEIT aus (importlib), also
# sieht PyInstaller den Import nicht. Ohne diese drei geht das Fenster
# nicht auf und Rippy faellt still auf den Browser zurueck -- also
# genau dorthin, wovon wir gerade weg wollten.
"--hidden-import", "webview",
"--hidden-import", "webview.platforms.winforms",
"--hidden-import", "webview.platforms.edgechromium",
# Nicht mitnehmen: Der Windows-Treiber braucht kein psycopg2/celery,
# und beides zieht Ballast nach.
"--exclude-module", "psycopg2",
"--exclude-module", "tkinter",
os.path.join(REPO, "packaging", "windows", "einstieg.py"),
]
if werkzeug_ordner:
# Vor dem Einstiegspunkt einfuegen -- PyInstaller will das Skript zuletzt.
befehl[-1:-1] = ["--add-data", werkzeug_ordner + trenner + "tools"]
print("PyInstaller wird aufgerufen …\n")
ergebnis = subprocess.run(befehl, cwd=REPO)
if ergebnis.returncode != 0:
raise SystemExit("PyInstaller ist gescheitert (Code %d)." % ergebnis.returncode)
exe = os.path.join(ausgabe, "RippySetup.exe")
if not os.path.isfile(exe):
raise SystemExit("PyInstaller meldete Erfolg, aber %s gibt es nicht." % exe)
return exe
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut RippySetup.exe")
p.add_argument("--ausgabe", default=os.path.join(REPO, "dist", "windows"))
p.add_argument("--version", default=os.environ.get("RIPPY_VERSION", "2.0.0"))
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
print("Rippy — Windows-Bau")
print(" Repo %s" % REPO)
print(" Ausgabe %s" % args.ausgabe)
print(" Version %s" % args.version)
print()
maengel = pruefen()
if maengel:
print("Der Bau kann so nicht laufen:")
for m in maengel:
print(" X %s" % m)
return 1
print("Alle Voraussetzungen erfuellt.")
if args.nur_pruefen:
return 0
os.makedirs(args.ausgabe, exist_ok=True)
exe = bauen(args.ausgabe, args.version)
groesse = os.path.getsize(exe) / 1024**2
print()
print("Fertig: %s (%.1f MB)" % (exe, groesse))
arbeit = os.path.join(args.ausgabe, "_bau")
shutil.rmtree(arbeit, ignore_errors=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-61
View File
@@ -1,61 +0,0 @@
"""Einstiegspunkt der gebuendelten RippySetup.exe.
Bewusst winzig: PyInstaller braucht EINE Datei als Startpunkt, und alles
Weitere gehoert ins Paket `rippy`, wo es getestet werden kann. Was hier
steht, laesst sich nicht testen also steht hier so wenig wie moeglich.
## Warum hier trotzdem etwas zur Ausgabe steht
Die EXE ist als Fenster-Programm gebaut (`--windowed`), damit beim Start
KEINE Konsole aufgeht ausdruecklicher Wunsch des Commanders am 28.08.2026:
Unterbinde das CMD Fenster was sich beim Oeffnen mitoeffnet."
Der Preis: `sys.stdout` ist dann `None`. Jede Bibliothek, die auf `stderr`
schreibt uvicorn tut das , wuerde mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben, und
zwar mitten im Start, ohne dass irgendwo etwas stuende.
Deshalb ganz zu Anfang, VOR allen anderen Importen aus `rippy`:
1. In einem Terminal gestartet? -> dort hineinschreiben
2. Sonst -> in %LOCALAPPDATA%\\Rippy\\rippy.log
"""
import multiprocessing
import sys
def _ausgabe_richten() -> None:
"""Dafuer sorgen, dass es eine Ausgabe GIBT — Konsole oder Datei."""
from rippy.platform import winlauf
if not winlauf.ohne_konsole():
return # aus dem Quellcode gestartet
if winlauf.an_elternkonsole_haengen():
# Ein Terminal ist da. Die Stroeme neu oeffnen, sonst bleibt
# sys.stdout trotz Konsole None.
try:
sys.stdout = open("CONOUT$", "w", encoding="utf-8", errors="replace",
buffering=1)
sys.stderr = sys.stdout
return
except OSError:
pass
winlauf.ausgabe_umleiten()
def main() -> int:
# Pflicht in gebuendelten Programmen: Ohne diesen Aufruf startet ein
# Kindprozess unter Windows die ganze EXE erneut — eine Startschleife,
# die sich als "das Programm oeffnet sich immer wieder" zeigt.
multiprocessing.freeze_support()
_ausgabe_richten()
from rippy.windows_app import main as rippy_main
return rippy_main()
if __name__ == "__main__":
sys.exit(main())
-142
View File
@@ -1,142 +0,0 @@
"""Ein Windows-Symbol, das Windows auch wirklich anzeigt.
## Der Befund (28.08.2026)
Commander: *Startmenü, Desktop und Taskleisten-Icon ohne icon file (Weißes
Leeres Blatt)."*
Nachgesehen, was in `deploy/worker-windows/rippy.ico` steckte:
Bilder = 1
256x256 32 bit 5657 Bytes PNG
**Genau ein Bild, und zwar das größte.** Windows holt sich für jede Stelle
die passende Größe aus der Datei:
Taskleiste, Verknüpfung klein 16x16
Desktop, Startmenü 32x32 / 48x48
Große Kacheln, Alt-Tab 256x256
Fehlt die passende Größe, skaliert die Shell nicht immer selbst sie zeigt
das leere Blatt. Bei einer Datei, die NUR ein PNG-komprimiertes 256er
enthält, passiert das zuverlässig.
## Was diese Datei tut
Sie baut aus dem vorhandenen Symbol eine `.ico` mit allen gebrauchten Größen.
Die kleinen Größen werden mit `LANCZOS` verkleinert bei 16x16 ist das der
Unterschied zwischen einem erkennbaren Zeichen und Pixelbrei.
Aufruf (macht der Bau selbst, siehe `build.py`):
python packaging/windows/icon.py [--quelle DATEI] [--ziel DATEI]
"""
import argparse
import os
# Was Windows an den einzelnen Stellen holt. 24 und 64 sind fuer hohe
# Bildschirmaufloesungen (150 %/200 % Skalierung) — ohne sie skaliert die
# Shell aus 32 bzw. 48 hoch, und das sieht ausgefranst aus.
GROESSEN = (16, 20, 24, 32, 40, 48, 64, 128, 256)
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
QUELLE = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def groessen_in(pfad: str) -> list:
"""Welche Bildgroessen stecken in einer .ico? (ohne Pillow, reines Lesen)
Fuer die Pruefung im Bau: Eine Datei mit nur EINER Groesse ist genau der
Fehler, den es hier zu verhindern gilt.
"""
import struct
with open(pfad, "rb") as f:
roh = f.read()
if len(roh) < 6:
return []
reserviert, typ, anzahl = struct.unpack("<HHH", roh[:6])
if reserviert != 0 or typ != 1:
return []
gefunden = []
for i in range(anzahl):
block = roh[6 + i * 16: 22 + i * 16]
if len(block) < 16:
break
breite = block[0] or 256 # 0 heisst 256 (passt nicht in ein Byte)
gefunden.append(breite)
return sorted(gefunden)
# Ohne diese Groessen sieht der Nutzer an der jeweiligen Stelle ein leeres
# Blatt. Gehoert hierher und nicht in den Test, damit der BAU sie kennt.
PFLICHTGROESSEN = (16, 32, 48, 256)
def fehlende_pflichtgroessen(pfad: str) -> tuple:
"""Welche Pflichtgroessen fehlen? Leer heisst: alles da."""
vorhanden = set(groessen_in(pfad))
return tuple(k for k in PFLICHTGROESSEN if k not in vorhanden)
def bauen(quelle: str = None, ziel: str = None) -> str:
"""Baut die vollstaendige .ico. Gibt den Zielpfad zurueck.
Wirft, wenn es nicht geht ein Symbol, das still fehlschlaegt, fuehrt
genau zu dem leeren Blatt, das hier abgestellt werden soll.
"""
from PIL import Image
quelle = quelle or QUELLE
ziel = ziel or quelle
bild = Image.open(quelle)
bild = bild.convert("RGBA")
# Aus dem groessten vorhandenen Bild verkleinern, nicht aus einem
# zufaellig ausgewaehlten: Pillow oeffnet bei einer .ico sonst die erste
# Groesse, und aus 16x16 laesst sich kein 256er machen.
if hasattr(bild, "size") and bild.size[0] < 256:
try:
bild = Image.open(quelle)
bild.size = (256, 256) # Pillow: gewuenschte Groesse waehlen
bild = bild.convert("RGBA")
except Exception: # noqa: BLE001
pass
ebenen = []
for kante in GROESSEN:
if kante >= bild.size[0]:
ebenen.append(bild.copy().resize((kante, kante), Image.LANCZOS))
else:
# LANCZOS auch beim Verkleinern: bei 16x16 ist das der Unterschied
# zwischen einem erkennbaren Zeichen und Pixelbrei.
ebenen.append(bild.resize((kante, kante), Image.LANCZOS))
ebenen[-1].save(ziel, format="ICO",
sizes=[(k, k) for k in GROESSEN])
return ziel
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="Baut eine vollstaendige rippy.ico")
p.add_argument("--quelle", default=QUELLE)
p.add_argument("--ziel", default=None)
p.add_argument("--nur-pruefen", action="store_true")
args = p.parse_args(argv)
vorher = groessen_in(args.quelle)
print("Vorher: %s" % (vorher or "keine lesbare .ico"))
if args.nur_pruefen:
return 0 if len(vorher) > 1 else 1
ziel = bauen(args.quelle, args.ziel)
print("Nachher: %s" % groessen_in(ziel))
print("Geschrieben: %s (%d Bytes)" % (ziel, os.path.getsize(ziel)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
-25
View File
@@ -1,25 +0,0 @@
# Was die BAU-Umgebung braucht, zusaetzlich zu docker/api/requirements.txt.
# Der Nutzer installiert davon nichts — alles landet in der RippySetup.exe.
#
# py -3.12 -m venv venv
# venv\Scripts\pip install -r docker/api/requirements.txt
# venv\Scripts\pip install -r packaging/windows/requirements-bau.txt
# venv\Scripts\python packaging/windows/build.py
#
# Warum die Fassungen festgenagelt sind: Ein Bau, der gestern ging und heute
# nicht mehr, kostet mehr Zeit als jede Aktualisierung einbringt. Ein Wechsel
# ist eine Entscheidung, kein Zufall.
pyinstaller==6.22.2
# Tray-Symbol — ohne das laeuft Rippy zwar, ist aber unsichtbar und nicht
# zu beenden, ausser ueber den Taskmanager.
pystray==0.19.5
pillow==12.3.0
# Das eigene Fenster (WebView2). Gemessen am 28.08.2026: 8,0 MB in der
# Bau-Umgebung. Electron waere an derselben Stelle 150-210 MB gewesen und
# haette eine zweite komplette Laufzeitumgebung neben Python mitgebracht.
# Siehe src/rippy/fenster.py fuer die vollstaendige Begruendung.
pywebview==6.2.1
pythonnet==3.1.0
-76
View File
@@ -1,76 +0,0 @@
"""Das mitgelieferte Symbol muss alle Groessen haben, die Windows holt.
## Der Befund (28.08.2026)
Commander: *Startmenue, Desktop und Taskleisten-Icon ohne icon file (Weisses
Leeres Blatt)."*
In `deploy/worker-windows/rippy.ico` steckte GENAU EIN Bild: 256x256 als PNG.
Windows holt sich fuer jede Stelle die passende Groesse:
Taskleiste, kleine Verknuepfung 16x16
Desktop, Startmenue 32x32 / 48x48
Alt-Tab, grosse Kacheln 256x256
Fehlt sie, skaliert die Shell nicht zuverlaessig selbst sie zeigt das leere
Blatt. Bei einer Datei mit nur einem PNG-komprimierten 256er passiert das
regelmaessig.
Diese Pruefung braucht kein Pillow und kein Windows: Sie liest den
ICO-Verzeichniskopf direkt. Damit laeuft sie auch auf der Ampel.
"""
import os
import pytest
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
ICON = os.path.join(REPO, "deploy", "worker-windows", "rippy.ico")
def _icon_modul():
import sys
sys.path.insert(0, HIER)
import icon
return icon
def _groessen():
return _icon_modul().groessen_in(ICON)
# Aus icon.py geholt, nicht abgeschrieben: Sonst koennte der Bau eine Groesse
# fordern, die dieser Test nicht prueft — oder umgekehrt.
PFLICHTGROESSEN = _icon_modul().PFLICHTGROESSEN
def test_icon_ist_da():
assert os.path.isfile(ICON), ICON
def test_icon_ist_eine_gueltige_ico():
assert _groessen(), "Kein lesbarer ICO-Verzeichniskopf"
@pytest.mark.parametrize("kante", PFLICHTGROESSEN)
def test_pflichtgroesse_ist_enthalten(kante):
"""DER Fehler vom 28.08.2026: nur 256x256 drin, alles andere leer."""
vorhanden = _groessen()
assert kante in vorhanden, (
"%dx%d fehlt in rippy.ico (drin: %s). Windows zeigt an dieser Stelle "
"ein leeres Blatt. Abhilfe: python packaging/windows/icon.py"
% (kante, kante, vorhanden)
)
def test_eine_einzige_groesse_reicht_nie():
"""Der Zustand, in dem die Datei war. Als eigener Test, damit die
Fehlermeldung die Ursache nennt und nicht nur eine fehlende Zahl."""
assert len(_groessen()) > 1, (
"rippy.ico enthaelt nur EINE Groesse — genau der Zustand, der zu den "
"leeren Symbolen auf Desktop, im Startmenue und in der Taskleiste "
"gefuehrt hat."
)
-135
View File
@@ -1,135 +0,0 @@
"""HandBrakeCLI für den Bau beschaffen — damit Rippy ohne Netz arbeiten kann.
## Warum HandBrake mitgeliefert wird und MakeMKV nicht
Commander am 28.08.2026: *handbrake und makemkv MÜSSEN mitgeliefert werden
oder während des Setups separat installiert werden! Ohne das ist das tool
NICHT einsatzfähig."*
Er hat recht. Die beiden gehen aber unterschiedliche Wege, und der Grund ist
die Lizenz, nicht die Bequemlichkeit:
* **HandBrake ist GPL-2.** Weitergabe ist ausdrücklich erlaubt, solange
Lizenztext und Quellverweis dabei sind. Also kommt es mit ins Paket, und
Rippy kann auch auf einem Rechner ohne Internet komprimieren.
* **MakeMKV ist proprietär.** Die Lizenz erlaubt keine Weitergabe durch
Dritte. Rippy lädt die offizielle Datei beim Einrichten vom Hersteller und
startet dessen Installer der Nutzer bezieht sie also weiterhin von
MakeMKV, Rippy nimmt ihm nur die Handgriffe ab. Dieselbe Black-Box-Trennung
wie in `KONZEPT.md` § 6 und `KONZEPT-V2.md` § 4.1.
## Warum die Datei nicht im Repo liegt
24,5 MB Binärdatei in git wären bei jedem Klon dabei und bei jedem
HandBrake-Update ein zweites Mal. Der Bau holt sie stattdessen nach
`dist/windows/vendor/` (nicht versioniert) und nimmt sie von dort ins Paket
dasselbe Muster wie der `vendor/`-Ordner für MakeMKV auf der VM.
Beim zweiten Bau liegt sie schon da und wird nicht erneut geladen.
"""
import os
import shutil
import sys
HIER = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HIER))
# Der Lizenztext gehört ins Paket, nicht nur in einen Kommentar. GPL-2
# verlangt ihn bei jeder Weitergabe.
GPL_HINWEIS = """HandBrakeCLI — mitgeliefert unter der GNU General Public License v2
Rippy liefert HandBrakeCLI unveraendert mit. HandBrake ist freie Software und
steht unter der GPL-2. Das bedeutet fuer dich:
* Du darfst es weitergeben und veraendern.
* Der Quelltext ist oeffentlich verfuegbar unter:
https://github.com/HandBrake/HandBrake
* Der vollstaendige Lizenztext steht dort in COPYING, ausserdem unter:
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
Mitgelieferte Fassung: {version}
Bezogen von: {url}
MakeMKV ist NICHT Teil dieser Lieferung. Es ist proprietaer und wird beim
Einrichten von makemkv.com geladen und mit dem Installer des Herstellers
installiert.
"""
def vendor_ordner(basis: str = None) -> str:
return os.path.join(basis or os.path.join(REPO, "dist", "windows"), "vendor")
def handbrake_bereitstellen(ziel_basis: str = None, melden=print) -> dict:
"""Sorgt dafür, dass HandBrakeCLI im vendor-Ordner liegt.
Gibt `{"pfad": , "version": , "geladen": bool}` zurück; `pfad` ist
leer, wenn es nicht geklappt hat. Wirft NICHT ein Bau ohne Netz soll
möglich bleiben, muss dann aber laut sagen, was fehlt.
"""
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import beschaffen
ordner = os.path.join(vendor_ordner(ziel_basis), "handbrake")
exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.isfile(exe) and os.path.getsize(exe) > 1024 * 1024:
return {"pfad": exe, "version": _version_von(exe), "geladen": False}
melden(" HandBrakeCLI wird geholt …")
try:
pfad = beschaffen.handbrake_holen(
ziel_ordner=ordner,
fortschritt=lambda t, anteil=None: melden(" " + t))
except Exception as e: # noqa: BLE001
melden(" X HandBrakeCLI konnte nicht geholt werden: %s" % e)
return {"pfad": "", "version": "", "geladen": False}
return {"pfad": pfad, "version": _version_von(pfad), "geladen": True}
def _version_von(exe: str) -> str:
sys.path.insert(0, os.path.join(REPO, "src"))
from rippy.tools import katalog
try:
return katalog.version_von("handbrake", exe) or ""
except Exception: # noqa: BLE001
return ""
def paket_ordner_bauen(ziel_basis: str = None, melden=print) -> str:
"""Baut den `tools`-Ordner, der ins Paket wandert. "" wenn nichts drin ist.
Struktur genau die, in der `katalog.kandidaten()` sucht:
tools/handbrake/HandBrakeCLI.exe
tools/handbrake/LIZENZ-HandBrake.txt
"""
hb = handbrake_bereitstellen(ziel_basis, melden)
if not hb["pfad"]:
return ""
basis = os.path.join(vendor_ordner(ziel_basis), "paket")
ordner = os.path.join(basis, "handbrake")
os.makedirs(ordner, exist_ok=True)
ziel_exe = os.path.join(ordner, "HandBrakeCLI.exe")
if os.path.abspath(hb["pfad"]) != os.path.abspath(ziel_exe):
shutil.copy2(hb["pfad"], ziel_exe)
from rippy.tools import beschaffen
with open(os.path.join(ordner, "LIZENZ-HandBrake.txt"), "w",
encoding="utf-8") as f:
f.write(GPL_HINWEIS.format(version=hb["version"] or "unbekannt",
url=beschaffen.HANDBRAKE_API))
melden(" HandBrakeCLI %s liegt im Paket (%.1f MB)"
% (hb["version"] or "?", os.path.getsize(ziel_exe) / 1024**2))
return basis
if __name__ == "__main__":
ordner = paket_ordner_bauen()
print("Paket-Ordner:", ordner or "(leer)")
raise SystemExit(0 if ordner else 1)
+51 -41
View File
@@ -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")
+25 -1
View File
@@ -301,13 +301,37 @@ def test_laufwerke_werden_seltener_abgefragt_als_jobs():
def _db_zeile(): def _db_zeile():
"""Eine Job-Zeile, wie die API sie liefert (Felder aus /jobs).""" """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:", return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
"startTime": "2026-08-29T12:07:02", "endTime": None, "startTime": "2026-08-29T12:07:02", "endTime": None,
"status": "processing", "progress": 12, "title": "Evangelion 2.22", "status": "processing", "progress": 12, "title": "Evangelion 2.22",
"error": None, "meta": {"confidence": 0.3}} "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(): def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander """DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
leer ohne Fehlermeldung.""" leer ohne Fehlermeldung."""
+31 -2
View File
@@ -106,6 +106,27 @@ def _job_kurz(zeile: dict) -> dict:
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit 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 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. 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"), "type": zeile.get("type"),
@@ -214,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
@@ -267,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):
-338
View File
@@ -1,338 +0,0 @@
"""`rippyd` — Rippy als EIN Prozess, ohne Docker, ohne Postgres, ohne Redis.
## Was hier zusammenkommt
Alles, was die Etappen V2-1 bis V2-3 vorbereitet haben:
Store -> SQLite (V2-2) statt PostgreSQL
Queue -> LocalQueue (V2-2) statt Celery/Redis
Bus -> In-Process (V2-3) statt Redis Pub/Sub
Drives -> der Treiber dieser Maschine (V2-1/V2-4)
UI -> von FastAPI ausgeliefert statt von nginx
Dieselbe API, dasselbe UI, derselbe Rip-Code wie im Docker-Betrieb. Was sich
unterscheidet, ist allein die Verdrahtung genau das war der Punkt.
## Warum das UI unter `/` und die API unter `/api` haengt
Das UI ruft seine Endpunkte als `/api/jobs`, `/api/events` usw. auf. Im
Docker-Betrieb entfernt der nginx dieses Praefix und reicht an die API weiter
(`ui/nginx.conf`). Ohne nginx muss jemand anderes das tun.
Statt das UI umzubauen 30+ Aufrufstellen, und die Docker-Variante muesste
weiter funktionieren haengt hier die bestehende App als Unteranwendung unter
`/api`. Ein `mount()`, und beide Betriebsarten sehen fuer den Browser identisch
aus.
## Was `--profil` bedeutet
standalone alles in einem Prozess (Windows-App, Headless-Linux)
api nur die Weboberflaeche, Arbeit machen andere Knoten
node nur Arbeit, keine Weboberflaeche
Fuer V2-4 zaehlt `standalone`; die anderen beiden kommen mit V2-5.
"""
import argparse
import os
import sys
def ausgabe_auf_utf8_stellen() -> None:
"""stdout/stderr auf UTF-8 zwingen — sonst stirbt Rippy an einer Log-Zeile.
## Der Vorfall (28.08.2026, erster Standalone-Start unter Windows)
Der Start brach ab mit:
File "docker/api/main.py", line 81, in startup_event
print(f"⚠️ Konfigurations-Warnung: {e}")
UnicodeEncodeError: 'charmap' codec can't encode characters
ERROR: Application startup failed. Exiting.
Windows-Konsolen arbeiten standardmaessig mit cp1252. Rippys Meldungen
sind durchgehend deutsch und enthalten Umlaute, Anfuehrungszeichen und
Warnzeichen unter Linux nie ein Thema, unter Windows toedlich.
Das Bittere daran: Es war nicht die WARNUNG, die den Start verhindert hat,
sondern der VERSUCH, sie auszugeben. Eine Meldung ueber ein kleines Problem
hat das ganze Programm umgebracht.
Deshalb zwei Dinge, nicht eins:
* `encoding="utf-8"` damit die Zeichen ueberhaupt durchgehen.
* `errors="replace"` damit ein Zeichen, das trotzdem klemmt, ein
Fragezeichen wird und NICHT eine Ausnahme. Eine Ausgabe darf unter
keinen Umstaenden etwas abbrechen koennen.
"""
for strom in (sys.stdout, sys.stderr):
umstellen = getattr(strom, "reconfigure", None)
if umstellen is None:
continue # umgeleitet, kein TextIOWrapper
try:
umstellen(encoding="utf-8", errors="replace")
except (ValueError, OSError):
pass # dann eben nicht — aber nie werfen
ausgabe_auf_utf8_stellen()
def _api_pfad() -> str:
"""Wo liegen die API-Module (main.py, db.py, mounts.py, …)?
Drei Faelle, und alle drei kommen wirklich vor:
* **Aus dem Repo gestartet** `docker/api` neben `src/`.
* **Im PyInstaller-Paket** alles unter `sys._MEIPASS`.
* **Installiert** neben dem Programm.
Der Umweg ist noetig, weil die API-Module sich flach importieren
(`import db`, `import mounts`). Das aufzuloesen ist der naechste
Umbauschritt; bis dahin wird der Pfad hier gesetzt statt an drei Stellen
geraten.
"""
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
return os.path.join(gebuendelt, "api")
hier = os.path.dirname(os.path.abspath(__file__)) # …/src/rippy
repo = os.path.dirname(os.path.dirname(hier)) # …/
kandidaten = [
os.path.join(repo, "docker", "api"),
os.path.join(os.path.dirname(sys.executable), "api"),
]
for pfad in kandidaten:
if os.path.isfile(os.path.join(pfad, "main.py")):
return pfad
raise RuntimeError(
"Die API-Module wurden nicht gefunden. Gesucht wurde in:\n "
+ "\n ".join(kandidaten)
+ "\nOhne sie kann Rippy nicht starten."
)
def _ui_pfad() -> str:
"""Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert.
## Warum die installierte Kopie VOR dem Paket kommt (28.08.2026)
Gemessen an einem Dienst, der seit einer Stunde lief: `/api/health` gab
HTTP 200, `/` gab **404**. Im Fenster stand `{"detail":"Not Found"}`.
Der Grund lag nicht im Server, sondern unter ihm. Eine
PyInstaller-Onefile-EXE entpackt sich beim Start nach `%TEMP%\\_MEIxxxxx`
und liest von dort weiter `StaticFiles` schlaegt bei JEDER Anfrage in
diesem Ordner nach. Nachgesehen, was davon uebrig war:
_MEI000074b02 31 Eintraege, 5 Ordner kein `ui`, kein `api`
_MEI000082e82 44 Eintraege, 16 Ordner vollstaendig
Der halbe Ordner gehoerte dem laufenden Dienst. Ein Temp-Verzeichnis ist
kein Ort, an dem etwas eine Woche lang liegen bleibt: Aufraeumwerkzeuge,
Storage Sense und die Aufraeumroutine anderer Onefile-Prozesse greifen
dort hinein. Und der Ausfall ist der schlimmstmoegliche die API
antwortet weiter, der Dienst gilt als gesund, nur die Oberflaeche ist
weg. Genau die Sorte stiller Fehlschlag, vor der AGENTS.md warnt.
Deshalb legt der Installer eine Kopie neben das Programm, und die hat
hier Vorrang. Sie liegt in `%LOCALAPPDATA%\\Rippy`, gehoert Rippy und
verschwindet beim Deinstallieren mit.
"""
neben_programm = os.path.join(os.path.dirname(_programmordner()), "ui")
if os.path.isdir(neben_programm):
return neben_programm
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
pfad = os.path.join(gebuendelt, "ui")
return pfad if os.path.isdir(pfad) else ""
hier = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(os.path.dirname(hier))
pfad = os.path.join(repo, "docker", "ui", "dist")
return pfad if os.path.isdir(pfad) else ""
def _programmordner() -> str:
"""Die laufende Programmdatei — im Paket die EXE, sonst der Interpreter.
Gibt eine DATEI zurueck, nicht den Ordner; der Aufrufer nimmt sich mit
`dirname` was er braucht. `sys.executable` ist unter PyInstaller die
Rippy.exe selbst, nicht das ausgepackte Python.
"""
return sys.executable
def argumente(argv=None) -> argparse.Namespace:
p = argparse.ArgumentParser(
prog="rippyd",
description="Rippy als eigenstaendiger Dienst — ohne Docker.",
)
p.add_argument("--profil", default=None,
choices=["standalone", "api", "node"],
help="Betriebsart (Vorgabe: standalone)")
p.add_argument("--config", default=None,
help="Pfad zur rippy.toml")
p.add_argument("--host", default=None)
p.add_argument("--port", type=int, default=None)
p.add_argument("--db", default=None,
help="Pfad zur SQLite-Datei (ueberschreibt die Konfiguration)")
p.add_argument("--kein-ui", action="store_true",
help="Weboberflaeche nicht ausliefern")
return p.parse_args(argv)
def flags_aus_argumenten(args) -> dict:
"""Nur das, was WIRKLICH angegeben wurde — sonst schlaegt ein
argparse-Vorgabewert die Konfigurationsdatei, und niemand versteht,
warum der Eintrag dort nicht greift."""
flags: dict = {}
if args.profil:
flags["profil"] = args.profil
server = {}
if args.host:
server["host"] = args.host
if args.port:
server["port"] = args.port
if args.kein_ui:
server["ui"] = False
if server:
flags["server"] = server
if args.db:
flags["store"] = {"treiber": "sqlite", "pfad": args.db}
return flags
def einstellungen_laden(args):
from rippy import config
datei = args.config or os.environ.get("RIPPY_CONFIG") or standard_konfigpfad()
werte = config.laden(datei if os.path.isfile(datei) else None,
flags=flags_aus_argumenten(args))
unbekannt = config.unbekannte_schluessel(
config.aus_datei(datei) if os.path.isfile(datei) else {})
return werte, datei, unbekannt
def standard_konfigpfad() -> str:
if os.name == "nt":
basis = os.environ.get("PROGRAMDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy", "rippy.toml")
return "/etc/rippy/rippy.toml"
def anwendung_bauen(werte: dict):
"""Baut die ASGI-Anwendung: API unter /api, UI unter /.
Gibt `(app, ui_pfad)` zurueck `ui_pfad` ist leer, wenn kein UI
ausgeliefert wird.
"""
sys.path.insert(0, _api_pfad())
from contextlib import asynccontextmanager
from fastapi import FastAPI
import main as rippy_api # noqa: E402
@asynccontextmanager
async def lebenszyklus(_):
"""Den Start der EINGEHAENGTEN Anwendung mit ausloesen.
Starlette reicht Lifespan-Ereignisse NICHT an `mount()`-Unteranwen-
dungen weiter. Ohne diese Zeilen laeuft `startup_event()` in main.py
nie und dann fehlt alles, was dort passiert:
* `db.init_db()` legt die Tabellen an. Ohne das antwortet /settings
mit `sqlite3.OperationalError: no such table: settings`.
* Der Ereignis-Waechter startet nicht, der SSE-Strom bleibt stumm.
* Gespeicherte Netzwerk-Ziele werden nicht wieder eingehaengt.
Am 28.08.2026 beim ersten Standalone-Start genau so gemessen: HTTP 200
auf /api/health (die Route braucht keine Datenbank), HTTP 500 auf
/api/jobs und /api/settings, und der Ereignis-Strom lieferte in sechs
Sekunden nichts.
"""
async with rippy_api.app.router.lifespan_context(rippy_api.app):
yield
aussen = FastAPI(title="Rippy", version="2.0", lifespan=lebenszyklus)
aussen.mount("/api", rippy_api.app)
ui = _ui_pfad() if werte.get("server", {}).get("ui", True) else ""
if ui:
from fastapi.staticfiles import StaticFiles
# html=True laesst StaticFiles bei unbekannten Pfaden index.html
# ausliefern — noetig, weil das UI seine Ansichten selbst verwaltet
# (im Docker-Betrieb macht das `try_files $uri /index.html`).
aussen.mount("/", StaticFiles(directory=ui, html=True), name="ui")
return aussen, ui
def starten(argv=None) -> int:
args = argumente(argv)
werte, konfigdatei, unbekannt = einstellungen_laden(args)
from rippy import config, drives, store
# ⚠️ VOR dem Import der API: main.py legt beim Start Tabellen an, und der
# Store muss bis dahin auf der richtigen Datenbank stehen. Andersherum
# laege die SQLite-Datei am Vorgabeort und die Konfiguration waere
# wirkungslos — ohne dass irgendwo etwas stuende.
url = config.datenbank_url(werte)
store.verbinden(url)
os.environ.setdefault("DATABASE_URL", url)
print("Rippy startet")
print(f" Profil {werte.get('profil')}")
print(f" Konfiguration {konfigdatei if os.path.isfile(konfigdatei) else '(keine — Vorgaben)'}")
print(f" Datenbank {url}")
print(f" Laufwerke {drives.treiber().__name__}")
for schluessel in unbekannt:
# Ein stillschweigend ignorierter Schluessel ist dieselbe Fehlerklasse
# wie ein verschluckter Fehler: sieht gesund aus, wirkt aber nicht.
print(f" ⚠️ Unbekannter Eintrag in der Konfiguration: {schluessel}")
app, ui = anwendung_bauen(werte)
print(f" Oberflaeche {ui or '(nicht mitgeliefert)'}")
# Im Standalone-Betrieb arbeitet DIESER Prozess die Auftraege ab —
# es gibt keinen Worker daneben. Ohne diesen Schritt koennte Rippy alles
# anzeigen und nichts tun: Ein Rip wuerde eingereiht und laege dann fuer
# immer da, weil niemand ihn holt.
if werte.get("profil") == "standalone" and werte["queue"]["treiber"] == "lokal":
from rippy import standalone
from rippy.bus.memory import bus as ereignis_bus
laeufer = standalone.starten(
knoten=werte["queue"].get("knoten") or None,
bus=ereignis_bus, store=store,
slots=werte["queue"].get("rip_slots", 1),
)
print(f" Laeufer {len(laeufer)} (Auftraege werden hier abgearbeitet)")
else:
print(" Laeufer keiner — Auftraege gehen an Celery")
host = werte["server"]["host"]
port = werte["server"]["port"]
print(f" Adresse http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}")
print()
import uvicorn
uvicorn.run(app, host=host, port=port, log_level="info")
return 0
def main() -> int:
try:
return starten()
except KeyboardInterrupt:
print("\nRippy beendet.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6 -2
View File
@@ -41,8 +41,12 @@ def treiber(plattform: str = None):
""" """
plattform = plattform if plattform is not None else sys.platform 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
+34
View File
@@ -0,0 +1,34 @@
"""Der Platzhalter, der unter Windows die Wahrheit sagt.
Der echte Windows-Laufwerkstreiber ist mit der Standalone-App gegangen
Rippy für Windows ist seit dem 30.08.2026 ein eigenes Produkt (Rippy v5,
KONZEPT-WINDOWS.md Entscheid 7). Diese Codebasis ist die Docker/Linux-
Fassung; der Remote-Encode-Worker auf Windows rippt nicht (Entscheid 8).
Warum ein Modul statt eines Fehlers in `treiber()`: Entwicklungs- und
Test-Läufe auf einem Windows-PC müssen IMPORTIERBAR bleiben (die Ampel-
Praxis misst lokal, bevor sie pusht). Erst die BENUTZUNG eines Laufwerks
ist hier ein Fehler und zwar einer mit Ansage.
"""
MELDUNG = (
"Kein Windows-Laufwerkstreiber in der Docker-Fassung — fuer Windows "
"gibt es Rippy v5 (Branch worktree-windows-electron)."
)
def _fehlt(*_args, **_kwargs):
raise RuntimeError(MELDUNG)
# Dieselben Namen wie linux.py — test_treiberwahl wacht darüber.
list_optical_devices = _fehlt
device_info = _fehlt
drive_status = _fehlt
disc_status = _fehlt
detect_disc_type = _fehlt
disc_size_bytes = _fehlt
eject = _fehlt
auswerfen_versuchen = _fehlt
auswerfen_mit_grund = _fehlt
verriegeln = _fehlt
+33 -4
View File
@@ -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,
} }
-84
View File
@@ -1,84 +0,0 @@
"""Die Win32-Steuercodes gegen die dokumentierten Zahlen rechnen.
## Warum dieser Test wichtig ist
Ein falscher IOCTL-Code liefert kein unbekannter Befehl". Windows antwortet
mit `ERROR_INVALID_FUNCTION` und das liest sich wie dieses Laufwerk kann
das nicht". Man sucht dann tagelang am Gerät statt an einer Zahl.
Die Werte rechts sind die in `winioctl.h` / `ntddcdrm.h` und auf Microsoft
Learn dokumentierten Konstanten. Links steht, was `ctl_code()` aus den
Bestandteilen bildet. Stimmen beide überein, ist die Herleitung richtig.
Läuft auf JEDER Plattform: Hier wird nur gerechnet, nichts aufgerufen. Der
Zahlendreher fällt damit schon auf dem Entwicklungsrechner auf und nicht erst
an einem echten Laufwerk.
"""
import pytest
from rippy.drives import win_ioctl as w
def test_ctl_code_bildet_das_makro_aus_winioctl_h_ab():
"""CTL_CODE(DeviceType, Function, Method, Access) =
(DeviceType << 16) | (Access << 14) | (Function << 2) | Method"""
assert w.ctl_code(0, 0, 0, 0) == 0
assert w.ctl_code(1, 0, 0, 0) == 1 << 16
assert w.ctl_code(0, 1, 0, 0) == 1 << 2
assert w.ctl_code(0, 0, 3, 0) == 3
assert w.ctl_code(0, 0, 0, 1) == 1 << 14
@pytest.mark.parametrize("name,erwartet", [
# Die Zahlen aus der Microsoft-Dokumentation bzw. den SDK-Headern.
("IOCTL_STORAGE_CHECK_VERIFY2", 0x002D0800),
("IOCTL_STORAGE_MEDIA_REMOVAL", 0x002D4804),
("IOCTL_STORAGE_EJECT_MEDIA", 0x002D4808),
("IOCTL_STORAGE_LOAD_MEDIA", 0x002D480C),
("IOCTL_CDROM_DISK_TYPE", 0x00020040),
("IOCTL_DISK_GET_LENGTH_INFO", 0x0007405C),
])
def test_steuercode_stimmt_mit_der_dokumentation_ueberein(name, erwartet):
assert getattr(w, name) == erwartet, (
f"{name} ist {getattr(w, name):#010x}, dokumentiert ist {erwartet:#010x}. "
"Ein falscher Steuercode meldet ERROR_INVALID_FUNCTION — das sieht aus "
"wie 'das Laufwerk kann das nicht' und schickt die Fehlersuche in die "
"falsche Richtung."
)
# ── Gerätepfad ──────────────────────────────────────────────────────────
@pytest.mark.parametrize("eingabe", ["D", "D:", "d:", "D:\\", "d:/", " D: "])
def test_geraetepfad_ist_immer_dieselbe_form(eingabe):
assert w.geraetepfad(eingabe) == r"\\.\D:"
def test_geraetepfad_haengt_keinen_backslash_an():
"""Der entscheidende Punkt: MIT abschliessendem Backslash oeffnet
CreateFileW das WURZELVERZEICHNIS des Dateisystems statt des Geraets.
Die IOCTLs scheitern dann mit ERROR_INVALID_FUNCTION."""
assert not w.geraetepfad("E:\\").endswith("\\")
assert w.geraetepfad("E:\\") == r"\\.\E:"
def test_geraetepfad_ohne_angabe_wirft():
for leer in ("", " ", ":", "\\"):
with pytest.raises(ValueError):
w.geraetepfad(leer)
def test_drive_cdrom_ist_fuenf():
"""GetDriveTypeW meldet optische Laufwerke als 5. Ein Zahlendreher hier
hiesse: Rippy findet entweder gar kein Laufwerk oder haelt die
Systemplatte fuer eines."""
assert w.DRIVE_CDROM == 5
assert w.DRIVE_FIXED == 3
assert w.DRIVE_REMOVABLE == 2
def test_error_not_ready_ist_einundzwanzig():
"""Das ist die Antwort von CHECK_VERIFY2 bei leerem Laufwerk — und damit
die Windows-Entsprechung von CDS_NO_DISC. Sie ENTSCHEIDET, ob ein
Auswurf als geglueckt gilt."""
assert w.ERROR_NOT_READY == 21
-232
View File
@@ -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()
-151
View File
@@ -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() + ":"
-487
View File
@@ -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]
-417
View File
@@ -1,417 +0,0 @@
"""Der Einrichtungs-Assistent: prüfen, fragen, installieren.
## Der Befund des Commanders (28.08.2026)
> Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
Er hat recht. Ein Doppelklick auf `RippySetup.exe` installierte stillschweigend
nach `%LOCALAPPDATA%\\Rippy` und öffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts auch nicht, ob sein Rechner überhaupt kann, was
Rippy braucht. Wenn hinterher etwas fehlte, stand er davor und wusste nicht,
seit wann.
## Was hier passiert und was NICHT
Dieses Modul enthält die **Prüfungen und die Entscheidungen** keine
Oberfläche. Das Fenster steckt in `setup_fenster.py`, damit hier alles ohne
WebView2, ohne Fenster und ohne Installation prüfbar bleibt.
## Die drei Sorten Befund
ok geht
warnung geht, aber der Nutzer sollte es wissen
fehler geht NICHT die Installation würde nichts nützen
Nur `fehler` blockiert. Eine Warnung, die den Knopf sperrt, ist eine
Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Ein Beispiel für
jede Sorte:
* **Kein optisches Laufwerk** ist eine WARNUNG. Rippy ohne Laufwerk ist eine
reine Komprimier-Maschine ein vorgesehener Betriebsfall (`install.sh` sagt
das seit Langem: läuft trotzdem durch").
* **Kein MakeMKV** ist eine WARNUNG, solange Rippy es holen kann.
* **Kein Schreibrecht am Zielordner** ist ein FEHLER. Da hilft kein Zureden.
## Warum jede Prüfung sagt, was zu tun ist
Ein Befund ohne Abhilfe lässt den Nutzer ratlos zurück. Deshalb hat jeder
Eintrag ein `abhilfe`-Feld leer nur dann, wenn wirklich nichts zu tun ist.
"""
import os
import shutil
import socket
import sys
from rippy import pfade
OK = "ok"
WARNUNG = "warnung"
FEHLER = "fehler"
# Was Rippy mindestens braucht. Gemessen, nicht geschätzt: Das Paket allein ist
# 56 MB, dazu die entpackte HandBrake-Fassung (~74 MB), die Datenbank und der
# WebView2-Zwischenspeicher. 500 MB sind reichlich und lassen Luft.
MINDESTPLATZ_MB = 500
STANDARD_PORT = 7788
def _eintrag(name, zustand, text, abhilfe=""):
return {"name": name, "zustand": zustand, "text": text, "abhilfe": abhilfe}
# ── Die einzelnen Prüfungen ─────────────────────────────────────────────
def pruefe_windows(version=None) -> dict:
"""Windows 10 oder neuer. Darunter gibt es kein WebView2.
Ein eingespritzter `version`-Wert wird BEACHTET, auch auf Linux. Vorher
stand die Plattform-Prüfung davor, und damit war jeder eingespritzte Wert
wirkungslos: Auf dem Linux-Runner der Ampel gab `pruefe_windows(22631)`
ein FEHLER zurück, obwohl der Test genau eine gültige Build-Nummer
übergeben hatte. Ein Einspritzpunkt, der ignoriert wird, ist keiner.
"""
if version is None and os.name != "nt":
return _eintrag("Betriebssystem", FEHLER, "Kein Windows",
"Für Linux gibt es die Docker- und die Headless-Fassung.")
bau = version if version is not None else sys.getwindowsversion().build
if bau >= 22000:
return _eintrag("Betriebssystem", OK, "Windows 11 (Build %d)" % bau)
if bau >= 10240:
return _eintrag("Betriebssystem", OK, "Windows 10 (Build %d)" % bau)
return _eintrag("Betriebssystem", FEHLER, "Windows-Build %d ist zu alt" % bau,
"Rippy braucht mindestens Windows 10.")
def pruefe_webview(version=None) -> dict:
"""Ohne WebView2 gibt es kein Fenster — aber Rippy läuft trotzdem.
Deshalb WARNUNG und nicht Fehler: Der Rückfall auf den Browser
funktioniert, er ist nur weniger schön.
"""
if version is None:
from rippy import fenster
version = fenster.webview2_version()
if version:
return _eintrag("Fenster (WebView2)", OK, version)
return _eintrag(
"Fenster (WebView2)", WARNUNG, "nicht gefunden",
'Rippy öffnet sich dann im Browser. Die Laufzeit gibt es kostenlos '
'bei Microsoft (Suchwort „WebView2 Evergreen Runtime").')
def pruefe_platz(ziel: str, frei_mb=None) -> dict:
"""Genug Platz am Zielort? Gemessen am nächsten vorhandenen Ordner."""
if frei_mb is None:
pfad = pfade.naechster_vorhandener(ziel)
if not pfad:
return _eintrag("Speicherplatz", FEHLER,
"Der Ort ist nicht erreichbar: %s" % ziel,
"Einen anderen Zielordner wählen.")
try:
frei_mb = shutil.disk_usage(pfad).free // (1024 * 1024)
except OSError as e:
return _eintrag("Speicherplatz", FEHLER, "Nicht lesbar (%s)" % e,
"Einen anderen Zielordner wählen.")
if frei_mb >= MINDESTPLATZ_MB:
return _eintrag("Speicherplatz", OK, "%.1f GB frei" % (frei_mb / 1024))
return _eintrag("Speicherplatz", FEHLER,
"nur %d MB frei, gebraucht werden %d MB"
% (frei_mb, MINDESTPLATZ_MB),
"Platz schaffen oder einen anderen Zielordner wählen.")
def laeuft_erhoeht(erhoeht=None) -> bool:
"""Läuft dieser Prozess mit Administratorrechten?
Wichtig für die Diagnose, nicht als Anforderung: **Rippy braucht keine.**
Erhöht gestartet handelt es sich sogar Nachteile ein siehe unten.
"""
if erhoeht is not None:
return bool(erhoeht)
if os.name != "nt":
return False
import ctypes
try:
return bool(ctypes.WinDLL("shell32").IsUserAnAdmin())
except (AttributeError, OSError):
return False
def netzlaufwerke(pruefer=None) -> list:
"""Die eingebundenen Netzlaufwerke, wie DIESER Prozess sie sieht."""
if pruefer is not None:
return list(pruefer)
if os.name != "nt":
return []
import ctypes
import string
DRIVE_REMOTE = 4
try:
k = ctypes.WinDLL("kernel32")
maske = k.GetLogicalDrives()
return [b + ":" for i, b in enumerate(string.ascii_uppercase)
if (maske & (1 << i))
and k.GetDriveTypeW(b + ":\\") == DRIVE_REMOTE]
except (AttributeError, OSError):
return []
def pruefe_administrator(erhoeht=None, laufwerke=None) -> dict:
r"""Als Administrator gestartet? Dann fehlen die Netzlaufwerke.
## Der Befund des Commanders (28.08.2026)
> Wenn man das Tool als Admin startet, kann man im explorer kein
> netzlaufwerk wählen. Wenn man im explorer ein via windows eingebundenes
> netzlaufwerk einbindet dann kommt der fehler das man nicht installieren
> kann, weil die schreibrechte fehlen."
Beides ist Windows-Verhalten, kein Rippy-Fehler aber Rippy hat ihn
hineinlaufen lassen. Ein erhöhter Prozess bekommt ein ANDERES
Zugriffstoken, und eingebundene Netzlaufwerke hängen am Token der
Sitzung. `X:` existiert dort schlicht nicht. (Auf diesem Rechner
nachgesehen: `EnableLinkedConnections` ist nicht gesetzt der Standard.)
Der Teufelskreis, in den das führt:
C:\Program Files\ verlangt Administrator
Administrator versteckt die Netzlaufwerke
Netzlaufwerk wirkt dann wie keine Schreibrechte"
**Rippy braucht überhaupt keine Administratorrechte.** Der Vorgabeordner
liegt unter `%LOCALAPPDATA%`, der Autostart unter `HKCU`.
"""
if not laeuft_erhoeht(erhoeht):
return _eintrag("Rechte", OK, "normal gestartet (so ist es richtig)")
gefunden = netzlaufwerke(laufwerke)
text = "als Administrator gestartet"
return _eintrag(
"Rechte", WARNUNG, text,
"Rippy braucht keine Administratorrechte — und erhöht sieht es die "
"eingebundenen Netzlaufwerke NICHT" +
(" (%s fehlen hier)" % ", ".join(gefunden) if gefunden else "") +
". Für ein Netzlaufwerk den UNC-Pfad eintippen "
"(\\\\Server\\Freigabe) oder Rippy ohne Administrator starten.")
def laufwerk_fehlt(ziel: str, vorhanden=None) -> bool:
r"""Zeigt der Pfad auf einen Laufwerksbuchstaben, den es hier nicht gibt?
`vorhanden` ist einspritzbar und das ist kein Beiwerk: Die Frage
existiert Laufwerk Q:" laesst sich auf dem Linux-Runner der Ampel gar
nicht beantworten, dort gibt es ueberhaupt keine Laufwerksbuchstaben.
Ohne Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit
genau dem passenden fehlenden Laufwerk pruefbar also nirgends.
"""
laufwerk = pfade.laufwerk_von(ziel or "")
if len(laufwerk) != 2 or not laufwerk.endswith(":"):
return False # UNC-Pfad oder POSIX
if vorhanden is not None:
return not vorhanden
if os.name != "nt":
return False
return not os.path.isdir(laufwerk + "\\")
def pruefe_schreibrecht(ziel: str, probe=None, erhoeht=None,
laufwerk_da=None) -> dict:
"""Darf Rippy dorthin schreiben? Wirklich probiert, nicht geraten.
Ein `os.access(..., W_OK)` lügt unter Windows: Es beantwortet die Frage
nach dem Nur-Lesen-Flag, nicht nach den Zugriffsrechten. Also wird eine
Datei angelegt und wieder gelöscht.
Und der Grund wird BENANNT. Schreibrechte fehlen" war die Meldung, die
der Commander bei einem Netzlaufwerk bekam richtig im Wortlaut,
unbrauchbar als Auskunft: Das Laufwerk war für den erhöhten Prozess gar
nicht da.
"""
if probe is not None:
geklappt, grund = probe
else:
geklappt, grund = _schreibprobe(ziel)
if geklappt:
return _eintrag("Schreibrecht", OK, ziel)
# ueber `pfade`, nicht ueber `os.path`: Auf dem Linux-Runner der Ampel
# kennt `splitdrive` kein "Q:" und gab einen leeren String zurueck --
# dieselbe Falle, fuer die es `rippy/pfade.py` gibt. Zum vierten Mal.
if laufwerk_fehlt(ziel, laufwerk_da):
laufwerk = pfade.laufwerk_von(ziel or "")
# Ein Laufwerksbuchstabe, den es in DIESEM Prozess nicht gibt.
zusatz = (" Als Administrator gestartete Programme sehen die "
"eingebundenen Netzlaufwerke der Sitzung nicht."
if laeuft_erhoeht(erhoeht) else "")
return _eintrag(
"Schreibrecht", FEHLER,
"%s — Laufwerk %s ist hier nicht vorhanden" % (ziel, laufwerk),
("Statt des Laufwerksbuchstabens den UNC-Pfad eintragen "
"(\\\\Server\\Freigabe)." + zusatz))
if (ziel or "").startswith("\\\\") and laeuft_erhoeht(erhoeht):
# Auch Netz-ANMELDUNGEN hängen am Token der Sitzung, nicht nur die
# Laufwerksbuchstaben. Ein erhöhter Prozess kennt die Zugangsdaten
# zur Freigabe deshalb nicht und bekommt „Zugriff verweigert" —
# dieselbe Meldung wie bei einem echten Rechteproblem.
return _eintrag(
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
"Als Administrator gestartete Programme kennen die Anmeldung an "
"der Freigabe nicht. Rippy ohne Administrator starten — dann "
"geht der Netzwerkpfad.")
if _unter_programme(ziel):
return _eintrag(
"Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
'Der Ordner „Programme" verlangt Administratorrechte — und die '
'verstecken wiederum die Netzlaufwerke. Rippy braucht sie nicht: '
'Der Vorgabeordner unter „Benutzer" funktioniert ohne.')
return _eintrag("Schreibrecht", FEHLER, "%s (%s)" % (ziel, grund),
'Einen Ordner unterhalb von „Benutzer" wählen — dort '
'braucht Rippy keine Administratorrechte.')
def _unter_programme(ziel: str) -> bool:
"""Liegt das Ziel unter „Programme"? (reine Funktion)"""
unten = (ziel or "").lower().replace("/", "\\")
return ("\\program files" in unten or unten.startswith("program files")
or "\\programme\\" in unten)
def _schreibprobe(ziel: str):
import tempfile
try:
os.makedirs(ziel, exist_ok=True)
with tempfile.NamedTemporaryFile(dir=ziel, prefix=".rippy-probe",
delete=True):
pass
return True, ""
except OSError as e:
return False, e.strerror or str(e)
def pruefe_port(port: int = STANDARD_PORT, belegt=None) -> dict:
"""Ist der Port frei? Wenn nicht, kann Rippy nicht bedienen."""
if belegt is None:
belegt = _port_belegt(port)
if not belegt:
return _eintrag("Netzwerk-Port", OK, "%d ist frei" % port)
# Kein Fehler: Sehr oft ist es ein bereits laufendes Rippy, und dann ist
# eine Neuinstallation genau richtig.
return _eintrag("Netzwerk-Port", WARNUNG, "%d ist belegt" % port,
"Läuft Rippy schon? Sonst im Setup einen anderen Port "
"wählen.")
def _port_belegt(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.5)
try:
s.bind(("127.0.0.1", port))
return False
except OSError:
return True
def pruefe_laufwerk(laufwerke=None) -> dict:
"""Ein optisches Laufwerk. WARNUNG, kein Fehler.
Rippy ohne Laufwerk ist eine reine Komprimier-Maschine ein vorgesehener
Betriebsfall, den `install.sh` seit Langem ausdrücklich zulässt.
"""
if laufwerke is None:
try:
from rippy import drives
laufwerke = drives.treiber().list_optical_devices()
except Exception: # noqa: BLE001
laufwerke = []
if laufwerke:
return _eintrag("Optisches Laufwerk", OK,
", ".join(str(x) for x in laufwerke))
return _eintrag("Optisches Laufwerk", WARNUNG, "keins gefunden",
"Ohne Laufwerk kann Rippy nur komprimieren, nicht rippen. "
"Ein USB-Brenner reicht.")
def pruefe_werkzeuge(bestand=None) -> list:
"""MakeMKV und HandBrake — je ein eigener Eintrag.
MakeMKV fehlt => WARNUNG, weil das Setup es holen kann. Ein Fehler wäre
es erst, wenn es danach immer noch fehlt (siehe `tools/einrichten.py`).
"""
from rippy.tools import katalog
bestand = katalog.bestand() if bestand is None else bestand
ergebnis = []
for name, eintrag in sorted(bestand.items()):
titel = eintrag.get("titel", name)
if eintrag.get("da"):
ergebnis.append(_eintrag(titel, OK,
eintrag.get("version") or "gefunden"))
elif name == "handbrake":
ergebnis.append(_eintrag(titel, OK, "wird mitgeliefert"))
else:
ergebnis.append(_eintrag(
titel, WARNUNG, "nicht gefunden",
"Das Setup lädt es von makemkv.com und startet den Installer "
"des Herstellers. Ohne MakeMKV kann Rippy keine Disc lesen."))
return ergebnis
# ── Alles zusammen ──────────────────────────────────────────────────────
def alle_pruefungen(ziel: str, port: int = STANDARD_PORT) -> list:
"""Der vollständige Voraussetzungs-Bericht, in Anzeigereihenfolge."""
return ([pruefe_windows(), pruefe_webview(), pruefe_administrator(),
pruefe_laufwerk()]
+ pruefe_werkzeuge()
+ [pruefe_platz(ziel), pruefe_schreibrecht(ziel), pruefe_port(port)])
def darf_installieren(pruefungen: list) -> bool:
"""Blockiert NUR bei einem echten Fehler. (reine Funktion)
Eine Warnung, die den Knopf sperrt, ist eine Bevormundung; ein Fehler,
der nur warnt, ist eine Falle.
"""
return not any(p["zustand"] == FEHLER for p in pruefungen)
def zusammenfassung(pruefungen: list) -> str:
"""Ein Satz über den Gesamtzustand. (reine Funktion)"""
fehler = [p for p in pruefungen if p["zustand"] == FEHLER]
warnungen = [p for p in pruefungen if p["zustand"] == WARNUNG]
if fehler:
return "Installation nicht möglich: " + ", ".join(p["name"] for p in fehler)
if warnungen:
return "Bereit — mit Hinweisen zu: " + ", ".join(p["name"] for p in warnungen)
return "Alles bereit."
def vorgaben() -> dict:
"""Womit die Maske vorbelegt wird."""
from rippy import betrieb
from rippy.platform import win_registry as reg
return {
"ziel": reg.standard_installationsordner(),
"ablage": betrieb.ablage_vorgabe({}, container=False, system="windows"),
# Der Arbeitsordner gehoert ins Setup, nicht erst in die Einstellungen
# (Commander-Wunsch 28.08.2026). Grund: Der Roh-Rip einer 4K-UHD ist
# bis zu 100 GB gross. Wer das erst NACH der ersten vollen Platte
# erfaehrt, hat es zu spaet erfahren — genau so lief am 25.07.2026
# die VM-Platte voll.
"arbeit": betrieb.arbeits_vorgabe({}, container=False,
plattform_name="win32"),
"port": STANDARD_PORT,
"autostart": True,
"verknuepfungen": True,
"werkzeuge_holen": True,
}
-281
View File
@@ -1,281 +0,0 @@
"""Rippy als echtes Fenster — nicht als Browser-Tab.
## Die Frage des Commanders (28.08.2026)
> Warum nutzen wir für Windows weiterhin einen Browser? Warum nutzen wir
> kein Electron oder sowas und machen daraus einen echten Client?"
Die erste Hälfte ist berechtigt: Ein Browser-Tab IST kein Client. Er hat eine
Adresszeile, die niemand braucht, liegt zwischen fremden Tabs, hat kein
eigenes Symbol in der Taskleiste, und wer den Browser schließt, glaubt, er
habe Rippy beendet.
Die zweite Hälfte wäre die teure Antwort darauf. Gemessen, nicht geschätzt:
Electron ~150-210 MB (Chromium + Node komplett mit)
pywebview + WebView2 8,0 MB (in die Bau-Umgebung gemessen)
Electron brächte eine ZWEITE komplette Laufzeitumgebung neben Python mit
und ein zweites Chromium neben dem, das auf diesem Rechner ohnehin schon
liegt. Denn Windows 11 liefert die WebView2-Laufzeit mit; auf diesem Rechner
am 28.08.2026 nachgesehen: 151.0.4129.107.
Nachgemessen, was sie wirklich rendert nicht angenommen:
navigator.userAgent ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
fetch ja EventSource ja
CSS Grid ja Pfeilfunktionen ja
Das ist dasselbe Chromium, das auch in Electron steckt. Rippy bekommt also
denselben Renderer nur ohne ihn ein zweites Mal mitzuschleppen.
## Warum nicht MSHTML als Rückfall
`pywebview` kann unter Windows auch auf den alten IE-Renderer zurückfallen.
Eine React-Oberfläche mit CSS Grid stellt der NICHT dar sie erschiene leer
oder zerfallen. Ein leeres Fenster ist schlimmer als ein Browser-Tab.
Deshalb wird die Laufzeit VORHER geprüft, und ohne sie geht Rippy in den
Browser statt in ein Fenster, das nichts zeigen kann.
## Warum das Fenster ein eigener Prozess ist
Das Tray-Symbol (`pystray`) braucht unter Windows den HAUPT-Thread, sonst
bekommt es keine Fensternachrichten. Das Fenster (`pywebview`/WinForms)
braucht ihn genauso. Zwei Nachrichtenschleifen passen nicht in einen Thread.
Also: `Rippy.exe --dienst` hält Server und Tray, `Rippy.exe --fenster` ist
der Client davor. Beide heißen im Taskmanager Rippy.exe" — was der
Commander wollte , und das Fenster lässt sich schließen, ohne den Dienst
mitzureißen.
## Was hier NICHT passiert
Kein Aufräumen von Ausnahmen zu einem stillen `False`. Geht das Fenster
nicht auf, muss der Aufrufer das ERFAHREN, damit er den Browser öffnen kann.
Ein stiller Fehlschlag wäre ein Doppelklick, nach dem nichts passiert.
"""
import os
import sys
TITEL = "Rippy"
BREITE = 1280
HOEHE = 860
MINDESTBREITE = 900
MINDESTHOEHE = 600
# Die Evergreen-Laufzeit von WebView2 trägt sich unter dieser festen Kennung
# bei EdgeUpdate ein. Sie ist von Microsoft vergeben und ändert sich nicht.
WEBVIEW2_KENNUNG = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
# Die Laufzeit kann pro Maschine oder pro Nutzer liegen — beides ist gültig.
_SCHLUESSEL = (
("HKLM", r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients"),
("HKLM", r"SOFTWARE\Microsoft\EdgeUpdate\Clients"),
("HKCU", r"Software\Microsoft\EdgeUpdate\Clients"),
)
def echte_version(wert) -> str:
"""Prüft einen `pv`-Wert aus der Registry (reine Funktion).
EdgeUpdate trägt bei einer DEINSTALLIERTEN Laufzeit `0.0.0.0` ein, statt
den Schlüssel zu löschen. Wer das für vorhanden hält, baut ein Fenster,
das nie etwas anzeigt und der Nutzer sieht einen weißen Kasten ohne
jede Erklärung.
"""
text = str(wert or "").strip()
return "" if not text or text == "0.0.0.0" else text
def webview2_version() -> str:
"""Die Version der WebView2-Laufzeit. Leer, wenn sie nicht da ist.
Die Version statt eines bloßen ja/nein": Steht in einem Fehlerbericht
`151.0.4129.107`, ist die Lage klar. Steht dort `True`, fängt das Raten
an.
"""
if os.name != "nt":
return ""
import winreg
wurzeln = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}
for name, pfad in _SCHLUESSEL:
try:
with winreg.OpenKey(wurzeln[name], pfad + "\\" + WEBVIEW2_KENNUNG) as k:
wert, _ = winreg.QueryValueEx(k, "pv")
except OSError:
continue
version = echte_version(wert)
if version:
return version
return ""
def webview2_vorhanden() -> bool:
return bool(webview2_version())
def bibliothek_vorhanden() -> bool:
"""Ist `pywebview` überhaupt im Paket? (Ohne es zu starten.)"""
import importlib.util
try:
return importlib.util.find_spec("webview") is not None
except (ImportError, ValueError):
return False
def verfuegbar() -> tuple:
"""Kann ein echtes Fenster aufgehen? (ja/nein, Begründung)
Gibt IMMER eine Begründung zurück, auch beim Ja. Wer nur `False`
zurückgibt, zwingt den Nächsten zum Nachforschen und genau solche
stillen Fehlschläge sind in diesem Projekt schon zweimal teuer geworden.
"""
if os.name != "nt":
return False, "kein Windows"
if not bibliothek_vorhanden():
return False, "pywebview ist nicht mit im Paket"
version = webview2_version()
if not version:
return False, "die WebView2-Laufzeit fehlt (ohne sie bliebe das Fenster leer)"
return True, "WebView2 " + version
def speicherort() -> str:
"""Wo WebView2 seinen Zwischenspeicher ablegt.
Ohne eigene Angabe landet er im TEMP und ist bei jedem Aufräumen weg.
Neben der Datenbank gehört er zu Rippy und verschwindet beim
Deinstallieren mit.
"""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
ordner = os.path.join(basis, "Rippy", "fenster")
try:
os.makedirs(ordner, exist_ok=True)
except OSError:
return ""
return ordner
def helfer_skript(speicherort: str) -> str:
"""Das PowerShell-Skript, das NUR unsere WebView2-Helfer beendet.
Reine Funktion, damit der Filter prüfbar ist, ohne etwas zu beenden.
## Warum das nötig ist (gemessen 28.08.2026)
Beim Deinstallieren blieben **148 Dateien** liegen, alle unter
`fenster\\EBWebView\\`. Der Grund: WebView2 startet eigene Prozesse
(`msedgewebview2.exe` Renderer, GPU, Netz, Crashpad). Stirbt Rippy,
laufen sie als Waisen weiter und halten den Zwischenspeicher offen.
`shutil.rmtree` scheiterte, der Fehler wurde verschluckt, und der
Deinstallierer meldete trotzdem Rippy wurde entfernt."
## Warum über den Speicherort gefiltert wird
`msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu beenden,
wäre ein Übergriff der Nutzer verlöre die Fenster fremder Anwendungen.
Jeder Helfer trägt seinen `--user-data-dir` in der Befehlszeile; nur wer
auf UNSEREN Ordner zeigt, gehört uns.
"""
ort = (speicherort or "").replace("'", "''")
return (
"Get-CimInstance Win32_Process -Filter \"Name='msedgewebview2.exe'\" "
"| Where-Object { $_.CommandLine -like '*%s*' } "
"| ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
"-ErrorAction SilentlyContinue }" % ort
)
def helfer_beenden(speicherort: str = None) -> bool:
"""Beendet die WebView2-Helfer, die zu UNSEREM Speicherort gehören.
True, wenn der Versuch durchlief. Wirft nicht ein Deinstallieren darf
daran nicht scheitern.
"""
if os.name != "nt":
return False
import subprocess
ort = speicherort or speicherort_lesen()
if not ort:
return False
from rippy.platform.winlauf import OHNE_FENSTER
try:
ergebnis = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
helfer_skript(ort)],
capture_output=True, text=True, timeout=60,
creationflags=OHNE_FENSTER)
except (OSError, subprocess.SubprocessError):
return False
return ergebnis.returncode == 0
def speicherort_lesen() -> str:
"""Der Speicherort OHNE ihn anzulegen — fürs Aufräumen."""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy", "fenster")
def oeffnen(adresse: str, titel: str = TITEL, breite: int = BREITE,
hoehe: int = HOEHE) -> None:
"""Öffnet das Fenster und kehrt erst zurück, wenn es geschlossen wird.
Wirft, wenn es nicht geht der Aufrufer entscheidet dann über den
Browser. Diese Funktion trifft die Entscheidung NICHT selbst, weil sie
sie nicht mitteilen könnte: Sie läuft, bis das Fenster zu ist.
"""
import webview
webview.create_window(
titel, adresse,
width=breite, height=hoehe,
min_size=(MINDESTBREITE, MINDESTHOEHE),
text_select=True, # sonst ließe sich kein Log-Text kopieren
confirm_close=False, # der Dienst läuft weiter; nichts geht verloren
)
# gui="edgechromium" erzwingt WebView2 und verbietet den Rückfall auf
# MSHTML. Lieber ein Fehler, den der Aufrufer sieht, als ein Fenster,
# das die Oberfläche nicht darstellen kann.
webview.start(gui="edgechromium", private_mode=False,
storage_path=speicherort())
def oeffnen_oder_browser(adresse: str, titel: str = TITEL) -> str:
"""Fenster, sonst Browser. Gibt zurück, was WIRKLICH passiert ist.
Der Rückgabewert ist `"fenster"`, `"browser"` oder `"nichts"` keine
Erfolgsmeldung. Der Aufrufer soll dem Nutzer sagen können, was er sieht,
statt zu behaupten, es sei alles gut gegangen.
"""
geht, grund = verfuegbar()
if geht:
try:
oeffnen(adresse, titel)
return "fenster"
except Exception as e: # noqa: BLE001
# Bewusst breit: Was WinForms und die CLR beim Start werfen
# können, ist nicht abschließend bekannt — und keine dieser
# Ausnahmen darf den Doppelklick ins Leere laufen lassen. Sie
# wird gemeldet, nicht verschluckt.
melden("Das Fenster ging nicht auf (%s: %s) — es öffnet der Browser."
% (type(e).__name__, e))
else:
melden("Fenster nicht möglich (%s) — es öffnet der Browser." % grund)
import webbrowser
return "browser" if webbrowser.open(adresse) else "nichts"
def melden(text: str) -> None:
"""Ausgabe, die auch ohne Konsole nicht abstürzt (siehe windows_app)."""
try:
sys.stdout.write(text + "\n")
sys.stdout.flush()
except Exception: # noqa: BLE001
pass
+10 -1
View File
@@ -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):
-129
View File
@@ -1,129 +0,0 @@
"""Verknuepfungen: der Ordner, das Skript, das Zurueckraeumen.
Das PowerShell-Skript wird als reine Funktion gebaut und geprueft so faellt
ein Zitier-Fehler auf, ohne dass eine .lnk entstehen muss. Die Tests, die
wirklich eine anlegen, laufen nur unter Windows und raeumen hinter sich auf.
Hinweis zu den Pfaden: Windows-Pfade stehen hier in ROHEN Zeichenketten oder
werden aus `B` (dem Backslash) zusammengesetzt. In normalen Zeichenketten
waeren `C:\\D` und `C:\\Users` ungueltige Escape-Sequenzen heute eine
DeprecationWarning, in einer kuenftigen Python-Version ein harter Fehler.
"""
import os
import pytest
from rippy.platform import verknuepfungen as vk
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
B = chr(92) # Backslash — haelt jede Escape-Falle aus den Tests raus
# ── Das Skript ──────────────────────────────────────────────────────────
def test_skript_setzt_ziel_und_argumente():
lnk = "C:" + B + "D" + B + "Rippy.lnk"
exe = "C:" + B + "R" + B + "Rippy.exe"
s = vk.skript(lnk, exe, "--oeffnen")
assert "CreateShortcut('" + lnk + "')" in s
assert "$s.TargetPath = '" + exe + "'" in s
assert "$s.Arguments = '--oeffnen'" in s
assert s.rstrip().endswith("$s.Save()")
def test_apostroph_im_pfad_bricht_das_skript_nicht():
"""In PowerShell wird ein Apostroph innerhalb einfacher Anfuehrungszeichen
durch VERDOPPELN maskiert. Ohne das bricht ein Pfad mit Apostroph das
Skript mitten entzwei und der Fehler sieht aus wie ein Syntaxfehler in
Rippy statt wie ein Pfad, der ein Sonderzeichen enthaelt."""
s = vk.skript("C:" + B + "Users" + B + "O'Brien" + B + "Rippy.lnk",
"C:" + B + "R" + B + "Rippy.exe")
assert "O''Brien" in s
# Gerade Zahl von Anfuehrungszeichen — sonst ist ein String offen.
assert s.count("'") % 2 == 0
def test_ohne_arbeitsordner_wird_der_ordner_des_ziels_genommen():
ordner = "C:" + B + "Programme" + B + "Rippy"
s = vk.skript("C:" + B + "D" + B + "R.lnk", ordner + B + "Rippy.exe")
assert "$s.WorkingDirectory = '" + ordner + "'" in s
def test_leere_angaben_erzeugen_keine_leeren_zeilen():
"""Ein `$s.IconLocation = ''` wuerde das Symbol LOESCHEN statt es
unveraendert zu lassen."""
s = vk.skript("C:" + B + "D" + B + "R.lnk", "C:" + B + "R" + B + "R.exe")
assert "IconLocation" not in s
assert "Description" not in s
# ── Die Orte ────────────────────────────────────────────────────────────
@nur_windows
def test_desktop_und_startmenue_kommen_aus_der_registry():
"""Ein fest verdrahtetes %USERPROFILE%/Desktop waere eine ANNAHME. Wer
OneDrive benutzt oder seine Ordner verschoben hat, dessen Desktop liegt
woanders die Verknuepfung landete dann in einem Ordner, den niemand
sieht, und der Nutzer suchte ein Symbol, das es nirgends gibt."""
desktop = vk.shell_ordner("Desktop")
programme = vk.shell_ordner("Programs")
assert desktop and os.path.isdir(desktop)
assert programme and os.path.isdir(programme)
assert vk.desktop_pfad().endswith("Rippy.lnk")
assert "Start Menu" in vk.startmenue_pfad()
def test_unbekannter_ordner_ergibt_leer_statt_zu_raten():
assert vk.shell_ordner("GibtEsNicht") == ""
# ── Wirklich anlegen ────────────────────────────────────────────────────
@nur_windows
def test_verknuepfung_entsteht_wirklich(tmp_path):
"""Nachsehen statt glauben: PowerShell meldet auch dann Erfolg, wenn
Save() still scheitert. `anlegen()` prueft deshalb die Datei."""
exe = tmp_path / "Rippy.exe"
exe.write_bytes(b"x" * 512)
lnk = str(tmp_path / "Rippy.lnk")
assert vk.anlegen(lnk, str(exe), "--oeffnen", beschreibung="Test") is True
assert os.path.isfile(lnk)
assert os.path.getsize(lnk) > 0
assert vk.entfernen(lnk) is True
assert not os.path.exists(lnk)
def _freier_laufwerksbuchstabe() -> str:
"""Ein Buchstabe, den es auf DIESER Maschine wirklich nicht gibt.
Der erste Anlauf nahm fest `Z:` auf dem Commander-PC ist das ein
NETZLAUFWERK (X, Y und Z sind dort belegt). Der Test legte damit
froehlich eine Verknuepfung auf einer Freigabe an und schlug fehl, weil
er einen Misserfolg erwartete. Ein fest verdrahteter Laufwerksbuchstabe
ist eine Annahme ueber eine fremde Maschine.
"""
import ctypes
maske = ctypes.WinDLL("kernel32").GetLogicalDrives()
for i in range(25, -1, -1): # von Z rueckwaerts
if not (maske & (1 << i)):
return chr(ord("A") + i)
return ""
@nur_windows
def test_anlegen_auf_unmoeglichem_pfad_wirft_nicht(tmp_path):
"""Eine fehlende Verknuepfung ist aergerlich, aber kein Grund, eine sonst
geglueckte Installation scheitern zu lassen."""
buchstabe = _freier_laufwerksbuchstabe()
if not buchstabe:
pytest.skip("Alle 26 Laufwerksbuchstaben sind belegt")
exe = tmp_path / "Rippy.exe"
exe.write_bytes(b"x")
assert vk.anlegen(buchstabe + ":" + B + "gibt-es-nicht" + B + "R.lnk",
str(exe)) is False
def test_entfernen_auf_nichts_ist_kein_fehler():
assert vk.entfernen("C:" + B + "gibt-es-nicht" + B + "R.lnk") is False
-128
View File
@@ -1,128 +0,0 @@
"""Die Werte für „Programme und Features" — geprüft, bevor sie geschrieben werden.
Die reinen Funktionen laufen auf jeder Plattform. Die Tests, die wirklich in
die Registry schreiben, laufen nur unter Windows und räumen hinter sich auf
sie benutzen einen eigenen Schlüsselnamen, damit eine echte Installation
niemals angefasst wird.
"""
import os
import pytest
from rippy.platform import win_registry as reg
TEST_SCHLUESSEL = "RippyTestEintrag" # NIE der echte
nur_windows = pytest.mark.skipif(os.name != "nt", reason="Registry gibt es nur unter Windows")
# ── Die Werte selbst ────────────────────────────────────────────────────
def test_pflichtwerte_sind_gesetzt():
"""Windows zeigt einen Eintrag OHNE DisplayName gar nicht an, und ohne
UninstallString ist der Deinstallieren-Knopf tot."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\RippySetup.exe", "2.0.0")
assert werte["DisplayName"]
assert werte["UninstallString"]
def test_uninstallstring_ist_gequotet():
"""DER klassische Fehler. Ohne Anfuehrungszeichen liest Windows
C:\\Program Files\\Rippy\\x.exe" als „C:\\Program" mit Argumenten
beim Deinstallieren passiert dann nichts oder etwas Falsches.
"""
werte = reg.eintrag_werte(
r"C:\Program Files\Rippy", r"C:\Program Files\Rippy\RippySetup.exe", "2.0.0")
assert werte["UninstallString"].startswith('"C:\\Program Files\\Rippy\\RippySetup.exe"')
assert werte["UninstallString"].endswith("--deinstallieren")
def test_stille_deinstallation_ist_vorgesehen():
"""Windows benutzt QuietUninstallString bei automatisierten Entfernungen.
Fehlt er, faellt Windows auf den lauten zurueck und ein Dialog blockiert
eine unbeaufsichtigte Abmeldung."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert "--still" in werte["QuietUninstallString"]
def test_reparieren_und_aendern_sind_ausgeblendet():
"""Rippy kann beides nicht. Knoepfe, die ins Leere fuehren, sind
dieselbe Sorte Placebo wie ein Fortschrittsbalken ohne Fortschritt."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0")
assert werte["NoModify"] == 1
assert werte["NoRepair"] == 1
def test_groesse_wird_in_kib_erwartet():
"""EstimatedSize ist in KiB. Wer Bytes eintraegt, meldet in „Programme und
Features" das Tausendfache — aus 90 MB werden 90 GB."""
werte = reg.eintrag_werte(r"C:\Rippy", r"C:\Rippy\s.exe", "2.0.0", groesse_kib=92_160)
assert werte["EstimatedSize"] == 92_160 # = 90 MiB
def test_ohne_icon_kein_leerer_eintrag():
"""Ein leerer DisplayIcon laesst Windows das Standard-Symbol zeigen —
aber ein Wert, der da ist und ins Leere zeigt, macht ein kaputtes Bild."""
assert "DisplayIcon" not in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0")
assert "DisplayIcon" in reg.eintrag_werte(r"C:\R", r"C:\R\s.exe", "2.0.0", icon=r"C:\R\r.ico")
def test_uninstall_pfad_liegt_unter_dem_richtigen_schluessel():
pfad = reg.uninstall_pfad("Rippy")
assert pfad.startswith(r"Software\Microsoft\Windows\CurrentVersion\Uninstall")
assert pfad.endswith(r"\Rippy")
def test_installationsordner_braucht_keine_adminrechte():
"""Program Files waere gewohnter, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage."""
ordner = reg.standard_installationsordner()
assert "Rippy" in ordner
if os.name == "nt":
assert "Program Files" not in ordner
def test_ordnergroesse_zaehlt_wirklich(tmp_path):
(tmp_path / "a.bin").write_bytes(b"x" * 4096)
unter = tmp_path / "unter"
unter.mkdir()
(unter / "b.bin").write_bytes(b"y" * 4096)
assert reg.ordnergroesse_kib(str(tmp_path)) == 8
# ── Wirklich schreiben (nur Windows) ────────────────────────────────────
@nur_windows
def test_eintrag_landet_wirklich_in_der_registry(tmp_path):
"""Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis. Genau diese Lehre steht in AGENTS.md."""
ordner = str(tmp_path)
(tmp_path / "RippySetup.exe").write_bytes(b"x" * 1024)
deinstaller = str(tmp_path / "RippySetup.exe")
try:
reg.eintragen(ordner, deinstaller, "2.0.0-test", schluessel=TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is True
gelesen = reg.gelesene_werte(TEST_SCHLUESSEL)
assert gelesen["DisplayName"] == "Rippy"
assert gelesen["DisplayVersion"] == "2.0.0-test"
assert gelesen["UninstallString"].startswith('"' + deinstaller + '"')
assert gelesen["NoModify"] == 1
finally:
reg.austragen(TEST_SCHLUESSEL)
assert reg.ist_eingetragen(TEST_SCHLUESSEL) is False
@nur_windows
def test_austragen_auf_nichts_ist_kein_fehler():
assert reg.austragen("RippyGibtEsNicht") is False
@nur_windows
def test_autostart_setzen_lesen_entfernen(tmp_path):
name = "RippyTestAutostart"
befehl = '"' + str(tmp_path / "Rippy.exe") + '"'
try:
reg.autostart_setzen(befehl, name=name)
assert reg.autostart_wert(name) == befehl
finally:
reg.autostart_entfernen(name)
assert reg.autostart_wert(name) == ""
-15
View File
@@ -161,21 +161,6 @@ def test_die_elternumgebung_wird_nicht_veraendert():
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=`
setzen. Sonst kommen die Reste zurueck, und zwar still."""
import re
quelle = os.path.join(os.path.dirname(__file__), "..", "windows_app.py")
with open(os.path.abspath(quelle), encoding="utf-8") as f:
text = f.read()
stellen = [m.start() for m in re.finditer(r"subprocess\.Popen\(\[programm", text)]
assert len(stellen) == 2, "Startwege haben sich geaendert: %d gefunden" % len(stellen)
for stelle in stellen:
aufruf = text[stelle:stelle + 300]
assert "env=umgebung_ohne_bundle()" in aufruf, (
"Dieser Start reicht den Auspack-Zeiger weiter:\n" + aufruf[:120])
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path): def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren. """Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
+156
View File
@@ -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"
-178
View File
@@ -1,178 +0,0 @@
"""Verknüpfungen auf dem Desktop und im Startmenü.
## Warum über PowerShell und nicht über pywin32
Eine `.lnk` ist keine Textdatei, sondern ein binäres Shell-Objekt. Sie von
Hand zu schreiben ist Bastelei; `pywin32` könnte es, wäre aber eine
20-MB-Abhängigkeit für vier Zeilen. Windows bringt die Fähigkeit selbst mit:
`WScript.Shell` ist seit Jahrzehnten da und braucht keine Installation.
Dieses Projekt geht den Weg schon einmal der alte Worker-Installer legt
seine Verknüpfungen ebenfalls nativ via PowerShell (kein win32com mehr
nötig)" an.
## Warum die Ordner aus der Registry kommen
`%USERPROFILE%\\Desktop` ist eine ANNAHME. Wer OneDrive benutzt oder seine
Ordner verschoben hat, dessen Desktop liegt woanders die Verknüpfung
landete dann in einem Ordner, den niemand sieht, und der Nutzer suchte ein
Symbol, das es nirgends gibt.
Die Wahrheit steht unter `Shell Folders` in der Registry. Auf diesem Rechner
am 28.08.2026 nachgesehen:
Desktop C:\\Users\\\\Desktop
Programs C:\\Users\\\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs
## Worauf die Verknüpfung zeigt
Nicht auf `--dienst`. Ein Doppelklick auf ein Desktop-Symbol soll Rippy
ÖFFNEN und dabei ist es dem Nutzer egal, ob der Dienst schon läuft. Die
Verknüpfung ruft deshalb `Rippy.exe --oeffnen`: Läuft der Dienst, geht nur
der Browser auf; läuft er nicht, wird er zuerst gestartet. Ein Symbol, das
beim zweiten Klick einen zweiten Server startet, wäre eine Falle.
"""
import os
import subprocess
from rippy import pfade
def shell_ordner(name: str) -> str:
"""Der ECHTE Pfad eines Windows-Sonderordners („Desktop", „Programs").
Aus der Registry, nicht geraten. Leerer String, wenn er nicht zu
ermitteln ist dann wird eben keine Verknüpfung angelegt, statt eine
an einer erfundenen Stelle.
"""
if os.name != "nt":
return ""
import winreg
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders",
) as k:
wert, _ = winreg.QueryValueEx(k, name)
return os.path.expandvars(str(wert))
except OSError:
return ""
def _ps_text(wert: str) -> str:
"""Text für ein einfach gequotetes PowerShell-Argument.
In PowerShell wird ein Apostroph innerhalb einfacher Anführungszeichen
durch VERDOPPELN maskiert. Ohne das bricht ein Pfad wie
`C:\\Users\\O'Brien\\…` das Skript mitten entzwei — und der Fehler sieht
aus wie ein Syntaxfehler in Rippy.
"""
return (wert or "").replace("'", "''")
def skript(lnk: str, ziel: str, argumente: str = "", icon: str = "",
beschreibung: str = "", arbeitsordner: str = "") -> str:
"""Das PowerShell-Skript, das die Verknüpfung anlegt (reine Funktion)."""
zeilen = [
"$s = (New-Object -ComObject WScript.Shell).CreateShortcut('%s')" % _ps_text(lnk),
"$s.TargetPath = '%s'" % _ps_text(ziel),
]
if argumente:
zeilen.append("$s.Arguments = '%s'" % _ps_text(argumente))
if icon:
zeilen.append("$s.IconLocation = '%s'" % _ps_text(icon))
if beschreibung:
zeilen.append("$s.Description = '%s'" % _ps_text(beschreibung))
# ntpath, nicht os.path: Eine .lnk zeigt IMMER auf einen
# Windows-Pfad — auch wenn dieser Code gerade auf dem Linux-Runner
# der Ampel laeuft. `os.path.dirname` gab dort fuer
# C:\Programme\Rippy\Rippy.exe einen LEEREN String zurueck, und der
# Test schlug nur dort fehl.
zeilen.append("$s.WorkingDirectory = '%s'"
% _ps_text(arbeitsordner or pfade.ordner_von(ziel)))
zeilen.append("$s.Save()")
return "; ".join(zeilen)
def anlegen(lnk: str, ziel: str, argumente: str = "", icon: str = "",
beschreibung: str = "", arbeitsordner: str = "") -> bool:
"""Legt eine Verknüpfung an. False, wenn es nicht geklappt hat.
Wirft nicht: Eine fehlende Verknüpfung ist ärgerlich, aber kein Grund,
eine sonst geglückte Installation scheitern zu lassen. Der Aufrufer
sagt es dem Nutzer.
"""
if os.name != "nt":
return False
from rippy.platform.winlauf import OHNE_FENSTER
try:
# os.makedirs MUSS mit in den try: Auf einem Laufwerk, das es nicht
# gibt, wirft es — und dann waere aus "Verknuepfung konnte nicht
# angelegt werden" ein Abbruch der ganzen Installation geworden.
os.makedirs(os.path.dirname(lnk), exist_ok=True)
ergebnis = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
skript(lnk, ziel, argumente, icon, beschreibung, arbeitsordner)],
capture_output=True, text=True, timeout=60,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.SubprocessError):
return False
# Nachsehen statt dem Rueckgabewert glauben: PowerShell meldet auch dann
# Erfolg, wenn Save() still scheitert.
return ergebnis.returncode == 0 and os.path.isfile(lnk)
def entfernen(lnk: str) -> bool:
try:
os.remove(lnk)
return True
except OSError:
return False
# ── Die beiden Orte ─────────────────────────────────────────────────────
def desktop_pfad(name: str = "Rippy") -> str:
ordner = shell_ordner("Desktop")
return os.path.join(ordner, name + ".lnk") if ordner else ""
def startmenue_pfad(name: str = "Rippy") -> str:
ordner = shell_ordner("Programs")
return os.path.join(ordner, name + ".lnk") if ordner else ""
def alle_anlegen(programm: str, icon: str = "", desktop: bool = True,
startmenue: bool = True) -> dict:
"""Legt beide Verknüpfungen an. Gibt zurück, was WIRKLICH entstanden ist.
Der Rückgabewert ist die Liste der angelegten Dateien nicht eine
Erfolgsmeldung. So kann der Installer sagen, was er getan hat, statt
zu behaupten, es sei alles gut gegangen.
"""
gemacht = {}
ziele = []
if desktop:
ziele.append(("desktop", desktop_pfad()))
if startmenue:
ziele.append(("startmenue", startmenue_pfad()))
for schluessel, lnk in ziele:
if not lnk:
continue
if anlegen(lnk, programm, argumente="--oeffnen", icon=icon or programm,
beschreibung="Rippy öffnen — automatisches Ripping"):
gemacht[schluessel] = lnk
return gemacht
def alle_entfernen() -> list:
"""Nimmt beide Verknüpfungen zurück. Gibt zurück, was entfernt wurde."""
entfernt = []
for lnk in (desktop_pfad(), startmenue_pfad()):
if lnk and os.path.isfile(lnk) and entfernen(lnk):
entfernt.append(lnk)
return entfernt
-230
View File
@@ -1,230 +0,0 @@
"""Windows-Registrierung: „Programme und Features" und Autostart.
## Wie ein Programm in „Programme und Features" landet
Nicht durch das Kopieren von Dateien. Windows liest diese Liste ausschließlich
aus der Registry, unter:
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\<Schlüssel>
HKCU ist die Installation für DIESEN Benutzer, HKLM die für alle letztere
braucht Administratorrechte. Rippy nimmt HKCU: Der Eintrag erscheint genauso in
Apps & Features", aber die Installation läuft ohne UAC-Abfrage durch.
Pflicht sind genau zwei Werte:
DisplayName was in der Liste steht
UninstallString was Windows startet, wenn man Deinstallieren" klickt
Alles andere ist Kür, aber es macht den Unterschied zwischen einem
gepflegten Eintrag und einem, der aussieht wie Schadsoftware:
`DisplayVersion`, `Publisher`, `InstallLocation`, `DisplayIcon`,
`EstimatedSize` (in KiB!), `NoModify`, `NoRepair`.
## Autostart
HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run
Bewusst HKCU und nicht der `Startup`-Ordner: Ein Eintrag in Run ist mit einem
Registry-Zugriff wieder weg, eine Verknüpfung im Startup-Ordner muss man
suchen. Und bewusst NICHT als Windows-Dienst der bräuchte Administrator­
rechte bei jeder Installation und jedem Update.
## Warum die Werte hier getrennt gebaut werden
`eintrag_werte()` und `uninstall_pfad()` sind reine Funktionen. Damit lässt
sich prüfen, dass die Pflichtwerte gesetzt sind und der Deinstallations-Befehl
richtig gequotet ist ohne in die Registry zu schreiben. Ein falsch
gequoteter `UninstallString` ist ein klassischer Fehler: Steht der Pfad mit
Leerzeichen ohne Anführungszeichen drin, startet Windows beim Deinstallieren
das falsche Programm oder gar nichts.
"""
import os
import sys
# Der Schlüsselname unter Uninstall. Muss über Versionen hinweg GLEICH bleiben —
# ändert er sich, entsteht bei einem Update ein zweiter Eintrag, und der alte
# bleibt als Leiche stehen.
# ## Warum der Name ueberschreibbar sein MUSS (28.08.2026)
#
# Er stand als Vorgabewert direkt in jeder Signatur. Vorgabewerte wertet
# Python zur DEFINITIONSZEIT aus — ein Test konnte den Namen also gar nicht
# umbiegen und arbeitete zwangslaeufig am ECHTEN System.
#
# Gemessen, was das anrichtet: Nach `Rippy.exe` (installiert) stand der
# Eintrag da; nach EINEM `pytest -q` war er weg. Der Commander sah Rippy
# danach nicht mehr unter „Installierte Apps" — sein Autostart-Eintrag ging
# denselben Weg. Der Test raeumte auf, was er nie angelegt hatte.
#
# Deshalb: `None` in der Signatur, aufgeloest zur LAUFZEIT. Damit wirkt ein
# `monkeypatch.setattr(reg, "SCHLUESSEL", ...)` ueberall.
SCHLUESSEL = "Rippy"
UNINSTALL_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Uninstall"
RUN_PFAD = r"Software\Microsoft\Windows\CurrentVersion\Run"
ANZEIGENAME = "Rippy"
HERAUSGEBER = "Rippy"
def uninstall_pfad(schluessel: str = None) -> str:
return UNINSTALL_PFAD + "\\" + (schluessel or SCHLUESSEL)
def _quoten(pfad: str) -> str:
"""Pfad für die Kommandozeile in Anführungszeichen — immer.
C:\\Program Files\\Rippy\\" ohne Anführungszeichen liest Windows als
C:\\Program" mit den Argumenten „Files\\Rippy\\". Beim Deinstallieren
passiert dann entweder nichts oder etwas Falsches.
"""
return '"' + pfad.strip('"') + '"'
def eintrag_werte(installationsordner: str, deinstaller: str, version: str,
icon: str = "", groesse_kib: int = 0) -> dict:
"""Die Werte für „Programme und Features" (reine Funktion).
`groesse_kib` ist in KiB Windows zeigt sie als Größe" an. Wer Bytes
einträgt, meldet 1000-mal zu viel; das ist ein häufiger Fehler.
"""
werte = {
"DisplayName": ANZEIGENAME,
"DisplayVersion": version,
"Publisher": HERAUSGEBER,
"InstallLocation": installationsordner,
"UninstallString": _quoten(deinstaller) + " --deinstallieren",
"QuietUninstallString": _quoten(deinstaller) + " --deinstallieren --still",
# Rippy kann sich nicht selbst reparieren oder teilweise installieren —
# die Knöpfe dafür gehören also ausgeblendet, statt ins Leere zu führen.
"NoModify": 1,
"NoRepair": 1,
}
if icon:
werte["DisplayIcon"] = icon
if groesse_kib:
werte["EstimatedSize"] = int(groesse_kib)
return werte
def ordnergroesse_kib(ordner: str) -> int:
"""Größe eines Ordners in KiB — für den EstimatedSize-Wert."""
summe = 0
for wurzel, _, dateien in os.walk(ordner):
for name in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, name))
except OSError:
pass
return summe // 1024
# ── Schreiben und Entfernen (nur unter Windows) ─────────────────────────
def _winreg():
if os.name != "nt":
raise RuntimeError("Registry-Zugriff gibt es nur unter Windows.")
import winreg
return winreg
def eintragen(installationsordner: str, deinstaller: str, version: str,
icon: str = "", schluessel: str = None) -> None:
"""Schreibt den Eintrag für „Programme und Features"."""
winreg = _winreg()
werte = eintrag_werte(
installationsordner, deinstaller, version, icon,
ordnergroesse_kib(installationsordner),
)
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
for name, wert in werte.items():
if isinstance(wert, int):
winreg.SetValueEx(k, name, 0, winreg.REG_DWORD, wert)
else:
winreg.SetValueEx(k, name, 0, winreg.REG_SZ, str(wert))
def austragen(schluessel: str = None) -> bool:
"""Entfernt den Eintrag. False, wenn es ihn gar nicht gab."""
winreg = _winreg()
try:
winreg.DeleteKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel))
return True
except FileNotFoundError:
return False
def ist_eingetragen(schluessel: str = None) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)):
return True
except FileNotFoundError:
return False
def gelesene_werte(schluessel: str = None) -> dict:
"""Was WIRKLICH in der Registry steht — für `rippy doctor` und Tests.
Nachsehen statt glauben: Ein Schreibvorgang, der quittiert wurde, ist
kein Beweis dafür, dass der Eintrag angekommen ist.
"""
winreg = _winreg()
ergebnis = {}
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, uninstall_pfad(schluessel)) as k:
i = 0
while True:
try:
name, wert, _ = winreg.EnumValue(k, i)
except OSError:
break
ergebnis[name] = wert
i += 1
except FileNotFoundError:
pass
return ergebnis
# ── Autostart ───────────────────────────────────────────────────────────
def autostart_setzen(befehl: str, name: str = None) -> None:
winreg = _winreg()
with winreg.CreateKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
winreg.SetValueEx(k, name or SCHLUESSEL, 0, winreg.REG_SZ, befehl)
def autostart_entfernen(name: str = None) -> bool:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD, 0,
winreg.KEY_ALL_ACCESS) as k:
winreg.DeleteValue(k, name or SCHLUESSEL)
return True
except FileNotFoundError:
return False
def autostart_wert(name: str = None) -> str:
winreg = _winreg()
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, RUN_PFAD) as k:
return winreg.QueryValueEx(k, name or SCHLUESSEL)[0]
except (FileNotFoundError, OSError):
return ""
def standard_installationsordner() -> str:
"""%LOCALAPPDATA%\\Rippy — dorthin darf ohne Administratorrechte geschrieben werden.
`Program Files` wäre der gewohnte Ort, verlangt aber bei JEDER Installation
und jedem Update eine UAC-Abfrage. Für ein Programm, das sich selbst
aktualisieren soll, ist das der falsche Handel.
"""
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy")
def laeuft_als_gebuendelte_exe() -> bool:
return getattr(sys, "frozen", False)
+263
View File
@@ -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?
-10
View File
@@ -1,10 +0,0 @@
"""Queue-Schicht: Aufträge verteilen (KONZEPT-V2.md § 3.4).
Zwei Treiber, ein Port (`rippy.ports.Queue`):
lokal.py Auftragstabelle + Lease, ohne Broker Standalone-Betrieb
celery.py Celery über Redis verteilter Betrieb (V2-5)
Beide stehen auf demselben Grundsatz: Die Datenbank ist die Wahrheit über den
Job-Zustand, der Broker ist nur der Wecker.
"""
-152
View File
@@ -1,152 +0,0 @@
"""Der Läufer: nimmt Aufträge aus der LocalQueue und arbeitet sie ab.
## Wofür er da ist
Im verteilten Betrieb macht Celery das: Ein Worker-Prozess wartet auf
Nachrichten und ruft die Task-Funktion auf. Im Standalone-Betrieb gibt es
keinen Broker und keinen zweiten Prozess also übernimmt dieser Läufer die
Rolle. Er ist das letzte Stück, das gefehlt hat, damit Rippy unter Windows
nicht nur anzeigen, sondern auch **arbeiten** kann.
## Die Lease ist der Kern, nicht ein Detail
Ein Rip läuft 30 bis 90 Minuten. In dieser Zeit muss zweierlei stimmen:
* **Niemand sonst darf denselben Auftrag anfassen.** Zwei Rips auf einem
Laufwerk wären zwei kaputte Dateien.
* **Stürzt der Läufer ab, muss der Auftrag zurückfallen.** Sonst bliebe er
für immer als läuft" stehen — genau der Zustand, den v1 mit
`zombies.py` nachträglich einsammeln musste.
Beides erledigt die Lease aus `rippy.queue.lokal`: Der Läufer verlängert sie
alle 15 Sekunden aus einem eigenen Thread. Hört er auf zu leben, läuft sie
nach 60 Sekunden ab und der Auftrag ist wieder frei.
## Warum der Herzschlag NICHT im Arbeits-Thread liegt
Weil er dann mit der Arbeit stillstünde. Der Rip verbringt seine Zeit in
`subprocess.communicate()` dort läuft kein Python-Code, der nebenbei eine
Lease verlängern könnte. Der Herzschlag braucht deshalb einen eigenen Thread,
der nichts anderes tut.
## Was der Läufer NICHT tut
Er kennt den Rip-Vorgang nicht. Er bekommt eine Funktion `ausfuehren(auftrag)`
und ruft sie auf. Das hält ihn testbar (die Tests hier rippen nichts) und
erlaubt dieselbe Mechanik später für andere Auftragsarten.
"""
import threading
import time
import traceback
from rippy.queue import lokal
class Laeufer:
"""Ein Arbeiter: holt einen Auftrag, hält ihn, arbeitet ihn ab."""
def __init__(self, ausfuehren, knoten: str, kann: set = None,
bus=None, store=None,
herzschlag_sekunden: float = lokal.LEASE_ERNEUERN_SEKUNDEN):
self._ausfuehren = ausfuehren
self.knoten = knoten
self.kann = kann or set()
self._bus = bus
self._store = store
self._herzschlag_sekunden = herzschlag_sekunden
self._laeuft = False
self.aktueller_auftrag = None
# ── Ein Auftrag ─────────────────────────────────────────────────────
def einmal(self) -> bool:
"""Holt EINEN Auftrag und arbeitet ihn ab. False, wenn nichts da war.
Blockiert für die Dauer der Arbeit bei einem Rip also Stunden. Das
ist Absicht: Der Aufrufer entscheidet, ob das in einem eigenen Thread
passiert.
"""
auftrag = lokal.uebernehmen(self.knoten, self.kann)
if auftrag is None:
return False
self.aktueller_auftrag = auftrag
schlagen = self._herzschlag_starten(auftrag["id"])
try:
ergebnis = self._ausfuehren(auftrag)
lokal.abschliessen(auftrag["id"], ergebnis if isinstance(ergebnis, dict) else {})
self._melden("job.finished", auftrag, {"status": "fertig"})
except Exception as e:
# Ein Fehlschlag darf den Laeufer NICHT mitnehmen — sonst bleibt
# nach einem kaputten Auftrag jeder weitere liegen. Der Auftrag
# geht zurueck in die Queue (bis MAX_VERSUCHE), der Grund wird
# protokolliert, und zwar mit Rueckverfolgung: Ein "Fehler" ohne
# Stelle ist beim naechsten Mal wertlos.
self._protokollieren(auftrag, e)
lokal.fehlgeschlagen(auftrag["id"], f"{type(e).__name__}: {e}")
self._melden("job.finished", auftrag, {"status": "fehler", "fehler": str(e)})
finally:
schlagen.set()
self.aktueller_auftrag = None
return True
def _herzschlag_starten(self, auftrag_id: str) -> threading.Event:
"""Verlängert die Lease, solange gearbeitet wird. Eigener Thread."""
fertig = threading.Event()
def schlagen():
while not fertig.wait(self._herzschlag_sekunden):
if not lokal.lebenszeichen(auftrag_id, self.knoten):
# Der Auftrag gehoert uns nicht mehr — jemand anderes hat
# ihn uebernommen, weil unsere Lease abgelaufen war. Dann
# ist Weiterarbeiten falsch, aber Abbrechen mitten im Rip
# waere schlimmer. Also: laut sagen und weiterlaufen.
self._protokollieren_text(
f"Auftrag {auftrag_id} ist an einen anderen Knoten "
"gefallen — die Lease war abgelaufen.")
return
threading.Thread(target=schlagen, daemon=True,
name=f"lease-{auftrag_id[:8]}").start()
return fertig
# ── Die Schleife ────────────────────────────────────────────────────
def schleife(self, takt: float = 1.0) -> None:
"""Läuft, bis `stoppen()` gerufen wird. Für einen eigenen Thread."""
self._laeuft = True
while self._laeuft:
try:
if not self.einmal():
time.sleep(takt)
except Exception as e: # die Schleife selbst darf nie sterben
self._protokollieren_text(f"Läufer-Fehler: {e}")
time.sleep(takt * 5)
def stoppen(self) -> None:
self._laeuft = False
# ── Melden ──────────────────────────────────────────────────────────
def _melden(self, typ: str, auftrag: dict, daten: dict) -> None:
if not self._bus:
return
try:
self._bus.senden(typ, {**daten, "art": auftrag.get("art")},
entitaet="job", entitaet_id=auftrag.get("job_id"))
except Exception:
pass # eine Meldung darf die Arbeit nicht aufhalten
def _protokollieren(self, auftrag: dict, fehler: Exception) -> None:
self._protokollieren_text(
f"Auftrag {auftrag.get('art')} für Job {auftrag.get('job_id')} "
f"gescheitert: {type(fehler).__name__}: {fehler}\n"
+ "".join(traceback.format_exception_only(type(fehler), fehler)).strip()
)
def _protokollieren_text(self, text: str) -> None:
if self._store:
try:
self._store.add_log("error", "laeufer", text)
return
except Exception:
pass
print("[Läufer] " + text)
-290
View File
@@ -1,290 +0,0 @@
"""LocalQueue — Aufträge ohne Broker. Erfüllt `rippy.ports.Queue`.
## Der Grundsatz, aus dem alles folgt (KONZEPT-V2.md § 3.1)
Die Datenbank ist die Wahrheit über den Job-Zustand.
Der Broker ist nur der Wecker.
In v1 war es umgekehrt gedacht: Celery hielt den Auftrag, die Datenbank
spiegelte ihn nach. Deshalb gibt es dort `zombies.py` (206 Zeilen) plus
`test_zombies.py` (263 Zeilen) einen nachträglichen Reparaturmechanismus für
Jobs, die in der Datenbank laufen, während in Celery niemand mehr an ihnen
arbeitet. Nach einer Gnadenfrist werden sie ehrlich auf failed gesetzt".
Wenn die Datenbank die Wahrheit ist, verschwindet dieses Problem, statt
repariert zu werden:
* Ein Auftrag ist eine Zeile mit `status`, `claimed_by`, `lease_until`.
* Ein Knoten übernimmt ihn per **bedingtem UPDATE** atomar, es gewinnt genau
einer, auch wenn zehn gleichzeitig fragen.
* Er hält ihn per Lease am Leben: alle `LEASE_ERNEUERN_SEKUNDEN` wird
`lease_until` vorgerückt.
* Läuft die Lease ab, ist der Auftrag frei. Egal ob der Knoten abgestürzt ist,
das Netz weg war oder jemand den Stecker gezogen hat.
Es gibt also keinen Zustand läuft, aber niemand arbeitet daran" mehr, den
jemand erkennen müsste. Er kann höchstens `LEASE_DAUER_SEKUNDEN` lang bestehen
und heilt sich dann selbst.
## Warum keine Queue-Bibliothek (Taskiq, ARQ, Celery-lite)
Drei Gründe, alle am konkreten Fall gemessen:
1. **Der teure Teil ist kein Task, sondern ein Subprozess.** Ein Rip ist ein
3090-Minuten-`makemkvcon`-Aufruf, dessen stdout zeilenweise geparst wird
(`ripping.get_progress_from_prgv`). Was die Bibliotheken liefern
Serialisierung, Retry, Broker-Anbindung löst davon nichts. Was gebraucht
wird Fortschritts-Streaming, Abbruch mitten im Lauf, Lease muss man
ohnehin selbst bauen.
2. **Zwei Bibliotheken heißen zwei Programmiermodelle.** Der verteilte Modus
behält Celery (funktioniert, der Remote-Windows-Worker ist bewiesen). Eine
zweite Queue-Bibliothek daneben bedeutet zwei Fehlerbilder, zwei
Retry-Semantiken, zwei Sorten Doku.
3. **Mit dem Grundsatz oben ist der Treiber trivial** die eigentliche
Auswahl unten ist ein SELECT und ein bedingtes UPDATE.
## Wozu die Zeitgrenzen
`LEASE_DAUER_SEKUNDEN` muss deutlich größer sein als
`LEASE_ERNEUERN_SEKUNDEN`, sonst gilt ein arbeitender Knoten bei jedem
Aussetzer als tot und ein zweiter greift sich denselben Auftrag. Faktor 4 ist
reichlich: Drei Erneuerungen dürfen ausfallen, bevor jemand übernimmt.
"""
import json
import uuid
from datetime import timedelta
from sqlalchemy import or_
from rippy import store
from rippy.store import auftraege
# ⚠️ NICHT `from rippy.store import engine`. Ein Import bindet den Wert EINMAL,
# beim Laden des Moduls — ein späteres `store.verbinden(...)` (Standalone-Start,
# Tests) käme hier nie an, und dieses Modul spräche weiter mit der alten
# Datenbank. Über `store.engine` wird bei jedem Aufruf nachgesehen.
# Wie lange ein übernommener Auftrag als „in Arbeit" gilt, ohne Lebenszeichen.
LEASE_DAUER_SEKUNDEN = 60
# In welchem Abstand ein arbeitender Knoten die Lease verlängert.
LEASE_ERNEUERN_SEKUNDEN = 15
# Zustände
WARTEND = "wartend"
LAUFEND = "laufend"
FERTIG = "fertig"
FEHLER = "fehler"
ABGEBROCHEN = "abgebrochen"
# Wie oft ein Auftrag höchstens neu vergeben wird, bevor er als Fehler gilt.
# Ohne Obergrenze würde ein Auftrag, der den bearbeitenden Knoten jedes Mal
# umbringt, endlos im Kreis laufen und dabei jeden freien Knoten mitnehmen.
MAX_VERSUCHE = 3
def _jetzt():
return store.utcnow()
def einreihen(job_id: str, art: str, faehigkeiten: dict = None,
payload: dict = None, prioritaet: int = 0) -> str:
"""Legt einen Auftrag an und gibt seine Kennung zurück."""
auftrag_id = str(uuid.uuid4())
with store.engine.begin() as conn:
conn.execute(auftraege.insert().values(
id=auftrag_id,
job_id=job_id,
art=art,
status=WARTEND,
faehigkeiten=json.dumps(faehigkeiten or {}),
payload=json.dumps(payload or {}),
prioritaet=prioritaet,
versuche=0,
created_at=_jetzt(),
))
return auftrag_id
def _passt(auftrag_faehigkeiten: str, knoten_kann: set) -> bool:
"""Kann dieser Knoten den Auftrag bedienen? (pure Funktion)
Ein Auftrag verlangt Fähigkeiten als Schlüssel-Wert-Paare, z. B.
`{"art": "rip", "drive": "sr0"}`. Der Knoten meldet, was er kann, als
Menge von `"schluessel=wert"`-Strings bzw. blanken Schlüsseln.
Leere Anforderung heißt: jeder darf. Das ist Absicht ein Auftrag ohne
besondere Ansprüche soll nicht liegen bleiben, nur weil niemand explizit
kann nichts Besonderes" gemeldet hat.
"""
try:
verlangt = json.loads(auftrag_faehigkeiten or "{}")
except (ValueError, TypeError):
verlangt = {}
if not verlangt:
return True
for schluessel, wert in verlangt.items():
if f"{schluessel}={wert}" in knoten_kann or schluessel in knoten_kann:
continue
return False
return True
def uebernehmen(knoten: str, kann: set = None):
"""Holt EINEN passenden Auftrag und markiert ihn als übernommen.
Gibt den Auftrag als dict zurück oder None, wenn nichts passt.
## Warum das UPDATE eine Bedingung trägt
Zwischen dem SELECT und dem UPDATE kann ein anderer Knoten denselben
Auftrag genommen haben. Das `WHERE AND claimed_by IS NULL` (bzw. eine
abgelaufene Lease) sorgt dafür, dass genau EINER gewinnt: Der Verlierer
ändert null Zeilen und sucht weiter. Das ist derselbe Mechanismus auf
SQLite wie auf PostgreSQL beide führen ein UPDATE atomar aus.
Ohne die Bedingung hätte man das klassische Doppel-Rip: zwei Knoten am
selben Laufwerk, und der zweite überschreibt die Ausgabe des ersten.
"""
kann = kann or set()
jetzt = _jetzt()
with store.engine.begin() as conn:
kandidaten = conn.execute(
auftraege.select()
.where(auftraege.c.status.in_((WARTEND, LAUFEND)))
.where(or_(
auftraege.c.claimed_by.is_(None),
auftraege.c.lease_until.is_(None),
auftraege.c.lease_until < jetzt, # Lease abgelaufen = frei
))
.where(auftraege.c.versuche < MAX_VERSUCHE)
.order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at)
.limit(20)
).mappings().all()
for kandidat in kandidaten:
if not _passt(kandidat["faehigkeiten"], kann):
continue
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.id == kandidat["id"])
.where(or_(
auftraege.c.claimed_by.is_(None),
auftraege.c.lease_until.is_(None),
auftraege.c.lease_until < jetzt,
))
.values(
status=LAUFEND,
claimed_by=knoten,
lease_until=jetzt + timedelta(seconds=LEASE_DAUER_SEKUNDEN),
versuche=(kandidat["versuche"] or 0) + 1,
)
).rowcount
if betroffen == 1:
eintrag = dict(kandidat)
eintrag.update({
"status": LAUFEND,
"claimed_by": knoten,
"versuche": (kandidat["versuche"] or 0) + 1,
})
return eintrag
return None
def lebenszeichen(auftrag_id: str, knoten: str) -> bool:
"""Verlängert die Lease. False, wenn der Auftrag inzwischen weg ist.
Der `knoten`-Vergleich ist kein Beiwerk: Hat ein anderer den Auftrag
übernommen (weil diese Lease einmal abgelaufen war), darf der alte
Bearbeiter ihn NICHT wieder an sich ziehen. Ein False heißt für den
Aufrufer: aufhören, jemand anderes macht das jetzt.
"""
with store.engine.begin() as conn:
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.where(auftraege.c.claimed_by == knoten)
.where(auftraege.c.status == LAUFEND)
.values(lease_until=_jetzt() + timedelta(seconds=LEASE_DAUER_SEKUNDEN))
).rowcount
return betroffen == 1
def abschliessen(auftrag_id: str, ergebnis: dict = None) -> None:
with store.engine.begin() as conn:
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(status=FERTIG, finished_at=_jetzt(),
payload=json.dumps(ergebnis or {}), lease_until=None)
)
def fehlgeschlagen(auftrag_id: str, fehler: str, erneut: bool = True) -> None:
"""Auftrag ist gescheitert.
`erneut=True` gibt ihn wieder frei, solange die Versuche reichen ein
abgestürzter Encoder soll den Job nicht endgültig verlieren. Ist
MAX_VERSUCHE erreicht, bleibt er auf `fehler` stehen: Ein Auftrag, der
dreimal denselben Knoten umgebracht hat, wird beim vierten Mal nicht
plötzlich funktionieren.
"""
with store.engine.begin() as conn:
zeile = conn.execute(
auftraege.select().where(auftraege.c.id == auftrag_id)
).mappings().first()
if not zeile:
return
wieder_frei = erneut and (zeile["versuche"] or 0) < MAX_VERSUCHE
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(
status=WARTEND if wieder_frei else FEHLER,
claimed_by=None,
lease_until=None,
fehler=(fehler or "")[:2000],
finished_at=None if wieder_frei else _jetzt(),
)
)
def abbrechen(auftrag_id: str) -> None:
with store.engine.begin() as conn:
conn.execute(
auftraege.update()
.where(auftraege.c.id == auftrag_id)
.values(status=ABGEBROCHEN, claimed_by=None, lease_until=None,
finished_at=_jetzt())
)
def offene_auftraege(job_id: str = None) -> list:
"""Alles, was noch nicht durch ist — für `rippy queue` und das UI."""
abfrage = auftraege.select().where(
auftraege.c.status.in_((WARTEND, LAUFEND))
).order_by(auftraege.c.prioritaet.desc(), auftraege.c.created_at)
if job_id:
abfrage = abfrage.where(auftraege.c.job_id == job_id)
with store.engine.connect() as conn:
return [dict(z) for z in conn.execute(abfrage).mappings().all()]
def verwaiste_freigeben() -> int:
"""Gibt Aufträge frei, deren Lease abgelaufen ist. Gibt die Anzahl zurück.
Braucht man streng genommen nicht `uebernehmen` behandelt abgelaufene
Leases ohnehin als frei. Diese Funktion macht es nur SICHTBAR: Nach einem
Absturz soll im UI und im Log stehen, dass etwas zurückgefallen ist, statt
dass es stillschweigend wieder auftaucht.
"""
jetzt = _jetzt()
with store.engine.begin() as conn:
betroffen = conn.execute(
auftraege.update()
.where(auftraege.c.status == LAUFEND)
.where(auftraege.c.lease_until.isnot(None))
.where(auftraege.c.lease_until < jetzt)
.values(status=WARTEND, claimed_by=None, lease_until=None)
).rowcount
return betroffen or 0
-185
View File
@@ -1,185 +0,0 @@
"""Der Läufer: hält er den Auftrag, gibt er ihn zurück, überlebt er Fehler?
Gerippt wird hier nichts die Arbeit ist eine eingespritzte Funktion. Geprüft
wird das Drumherum, und genau dort sitzen die Fehler, die man im Betrieb
teuer bezahlt: ein Läufer, der nach dem ersten kaputten Auftrag stehenbleibt,
oder eine Lease, die während eines dreistündigen Encodes abläuft.
"""
import threading
import time
import pytest
from rippy import store
from rippy.queue import laeufer as laeufer_modul
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'l.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
class FakeBus:
def __init__(self):
self.gesendet = []
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
self.gesendet.append((typ, entitaet_id, daten or {}))
class FakeStore:
def __init__(self):
self.protokoll = []
def add_log(self, level, quelle, text):
self.protokoll.append((level, quelle, text))
# ── Grundfall ───────────────────────────────────────────────────────────
def test_ohne_auftrag_passiert_nichts(q):
lauf = laeufer_modul.Laeufer(lambda a: {}, "knoten-a")
assert lauf.einmal() is False
def test_auftrag_wird_geholt_und_abgearbeitet(q):
gesehen = []
q.einreihen("job1", "rip", payload={"device": "/dev/sr0"})
lauf = laeufer_modul.Laeufer(lambda a: gesehen.append(a) or {"ok": True}, "knoten-a")
assert lauf.einmal() is True
assert len(gesehen) == 1
assert gesehen[0]["art"] == "rip"
assert q.offene_auftraege() == [] # abgeschlossen, nicht mehr offen
def test_zweiter_laeufer_bekommt_ihn_nicht(q):
"""Die zentrale Zusage — sonst zwei Rips auf einem Laufwerk."""
q.einreihen("job1", "rip")
lauf_a = laeufer_modul.Laeufer(lambda a: {}, "knoten-a")
lauf_b = laeufer_modul.Laeufer(lambda a: {}, "knoten-b")
langsam = threading.Event()
lauf_a._ausfuehren = lambda a: langsam.wait(2) or {}
t = threading.Thread(target=lauf_a.einmal, daemon=True)
t.start()
time.sleep(0.3)
try:
assert lauf_b.einmal() is False, "Knoten B hat denselben Auftrag bekommen"
finally:
langsam.set()
t.join(timeout=5)
# ── Fehler ──────────────────────────────────────────────────────────────
def test_ein_kaputter_auftrag_nimmt_den_laeufer_nicht_mit(q):
"""Ohne das bliebe nach dem ersten Fehlschlag JEDER weitere Auftrag
liegen und im UI saehe es aus, als tue Rippy nichts mehr."""
protokoll = FakeStore()
q.einreihen("job1", "rip")
def kaputt(_):
raise RuntimeError("MakeMKV ist abgestuerzt")
lauf = laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll)
assert lauf.einmal() is True # er hat gearbeitet, nicht geworfen
assert any("MakeMKV ist abgestuerzt" in text for _, _, text in protokoll.protokoll)
def test_gescheiterter_auftrag_geht_zurueck_in_die_queue(q):
"""Ein abgestuerzter Encoder soll den Job nicht endgueltig verlieren."""
auftrag_id = q.einreihen("job1", "transcode")
def kaputt(_):
raise RuntimeError("weg")
laeufer_modul.Laeufer(kaputt, "knoten-a", store=FakeStore()).einmal()
offen = q.offene_auftraege()
assert len(offen) == 1 and offen[0]["id"] == auftrag_id
def test_fehler_wird_mit_typ_gemeldet(q):
"""„Fehler" allein ist beim naechsten Mal wertlos. Der Typ gehoert dazu."""
q.einreihen("job1", "rip")
protokoll = FakeStore()
def kaputt(_):
raise FileNotFoundError("makemkvcon fehlt")
laeufer_modul.Laeufer(kaputt, "knoten-a", store=protokoll).einmal()
text = " ".join(t for _, _, t in protokoll.protokoll)
assert "FileNotFoundError" in text
assert "makemkvcon fehlt" in text
# ── Lease ───────────────────────────────────────────────────────────────
def test_lease_wird_waehrend_der_arbeit_verlaengert(q):
"""DER Punkt bei einem Rip: Er dauert Stunden, die Lease 60 Sekunden.
Ohne Herzschlag fiele der Auftrag mitten im Lauf zurueck und ein
zweiter Laeufer finge an, dieselbe Disc zu lesen.
"""
from datetime import timedelta
auftrag_id = q.einreihen("job1", "rip")
weiter = threading.Event()
def langsam(_):
# Waehrend der Arbeit die Lease kuenstlich ablaufen lassen —
# der Herzschlag muss sie zurueckholen.
with store.engine_holen().begin() as conn:
conn.execute(
store.auftraege.update()
.where(store.auftraege.c.id == auftrag_id)
.values(lease_until=store.utcnow() - timedelta(seconds=10)))
weiter.wait(3)
return {}
lauf = laeufer_modul.Laeufer(langsam, "knoten-a", herzschlag_sekunden=0.2)
t = threading.Thread(target=lauf.einmal, daemon=True)
t.start()
time.sleep(1.0) # dem Herzschlag Zeit geben
try:
assert laeufer_modul.lokal.uebernehmen("knoten-b") is None, \
"Die Lease wurde nicht verlaengert — ein zweiter Knoten kam ran"
finally:
weiter.set()
t.join(timeout=5)
def test_aktueller_auftrag_ist_sichtbar_und_danach_wieder_leer(q):
"""Fuer das UI und fuer die Diagnose: Woran arbeitet dieser Knoten gerade?"""
q.einreihen("job1", "rip")
gesehen = {}
lauf = laeufer_modul.Laeufer(
lambda a: gesehen.update(id=lauf.aktueller_auftrag["id"]) or {}, "knoten-a")
lauf.einmal()
assert gesehen["id"]
assert lauf.aktueller_auftrag is None
# ── Meldungen ───────────────────────────────────────────────────────────
def test_abschluss_wird_gemeldet(q):
bus = FakeBus()
q.einreihen("job1", "rip")
laeufer_modul.Laeufer(lambda a: {}, "knoten-a", bus=bus).einmal()
assert [t for t, _, _ in bus.gesendet] == ["job.finished"]
assert bus.gesendet[0][1] == "job1"
def test_ein_kaputter_bus_haelt_die_arbeit_nicht_auf(q):
"""Eine Meldung ist Beiwerk. Wenn sie scheitert, ist der Rip trotzdem
fertig und darf nicht als Fehlschlag gelten."""
class KaputterBus:
def senden(self, *a, **k):
raise RuntimeError("Bus weg")
q.einreihen("job1", "rip")
lauf = laeufer_modul.Laeufer(lambda a: {"ok": True}, "knoten-a", bus=KaputterBus())
assert lauf.einmal() is True
assert q.offene_auftraege() == []
-215
View File
@@ -1,215 +0,0 @@
"""LocalQueue: übernehmen, Lease, Rückfall — gegen echtes SQLite (V2-2).
Der Grundsatz, den diese Tests prüfen: **Die Datenbank ist die Wahrheit, der
Broker ist nur der Wecker.** Konkret heißt das, dass zwei Dinge stimmen müssen,
und beide sind hier nachgestellt:
1. Zwei Knoten dürfen sich denselben Auftrag NICHT teilen. Ohne diese Zusage
liefen zwei Rips auf demselben Laufwerk, und der zweite überschriebe die
Ausgabe des ersten.
2. Ein abgestürzter Knoten muss seinen Auftrag von SELBST wieder hergeben.
Genau das konnte v1 nicht dort brauchte es `zombies.py` (206 Zeilen plus
263 Zeilen Tests), um hinterher aufzuräumen.
"""
from datetime import timedelta
import pytest
from rippy import store
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
"""Frische SQLite-Datenbank je Test, danach zurückdrehen."""
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'q.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def _lease_zuruecksetzen(auftrag_id, sekunden):
"""Schiebt die Lease künstlich in die Vergangenheit — so sieht ein
abgestürzter Knoten aus, ohne dass ein Test eine Minute warten muss."""
with store.engine.begin() as conn:
conn.execute(
store.auftraege.update()
.where(store.auftraege.c.id == auftrag_id)
.values(lease_until=store.utcnow() - timedelta(seconds=sekunden))
)
# ── Grundfall ───────────────────────────────────────────────────────────
def test_eingereihter_auftrag_wird_uebernommen(q):
q.einreihen("job1", "rip")
auftrag = q.uebernehmen("knoten-a")
assert auftrag is not None
assert auftrag["art"] == "rip"
assert auftrag["claimed_by"] == "knoten-a"
assert auftrag["status"] == lokal.LAUFEND
def test_ohne_auftrag_kommt_none(q):
assert q.uebernehmen("knoten-a") is None
def test_hoehere_prioritaet_kommt_zuerst(q):
q.einreihen("job1", "transcode", prioritaet=0)
q.einreihen("job2", "rip", prioritaet=5)
assert q.uebernehmen("knoten-a")["job_id"] == "job2"
# ── Die zentrale Zusage: genau EINER gewinnt ────────────────────────────
def test_zwei_knoten_bekommen_nicht_denselben_auftrag(q):
"""DIE Zusage der ganzen Queue. Ohne sie: zwei Rips auf einem Laufwerk."""
q.einreihen("job1", "rip")
erster = q.uebernehmen("knoten-a")
zweiter = q.uebernehmen("knoten-b")
assert erster is not None
assert zweiter is None, "Der zweite Knoten hat denselben Auftrag bekommen"
def test_zwei_auftraege_gehen_an_zwei_knoten(q):
"""Die Gegenprobe — sonst wäre die Sperre oben nur eine kaputte Queue."""
q.einreihen("job1", "rip")
q.einreihen("job2", "rip")
a = q.uebernehmen("knoten-a")
b = q.uebernehmen("knoten-b")
assert a and b and a["id"] != b["id"]
# ── Lease: ein abgestürzter Knoten gibt von selbst her ──────────────────
def test_abgelaufene_lease_macht_den_auftrag_wieder_frei(q):
"""Das ist der Ersatz für zombies.py.
In v1 blieb ein Job nach einem Worker-Absturz auf `transcoding` stehen,
und ein eigener Mechanismus musste ihn nach einer Gnadenfrist ehrlich auf
failed setzen". Hier passiert nichts dergleichen: Die Lease läuft ab, und
der Auftrag ist wieder zu haben.
"""
auftrag_id = q.einreihen("job1", "rip")
erster = q.uebernehmen("knoten-a")
assert erster is not None
assert q.uebernehmen("knoten-b") is None # solange die Lease lebt
_lease_zuruecksetzen(auftrag_id, 10) # Knoten A ist abgestürzt
zweiter = q.uebernehmen("knoten-b")
assert zweiter is not None
assert zweiter["claimed_by"] == "knoten-b"
assert zweiter["versuche"] == 2
def test_lebenszeichen_haelt_den_auftrag(q):
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
_lease_zuruecksetzen(auftrag_id, 10)
assert q.lebenszeichen(auftrag_id, "knoten-a") is True
assert q.uebernehmen("knoten-b") is None, "Lease wurde nicht verlaengert"
def test_fremder_knoten_kann_die_lease_nicht_verlaengern(q):
"""Sonst könnte ein Knoten, dessen Lease einmal abgelaufen ist, den Auftrag
dem neuen Bearbeiter wieder wegnehmen und beide arbeiteten daran."""
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
assert q.lebenszeichen(auftrag_id, "knoten-b") is False
def test_lebenszeichen_auf_unbekannten_auftrag_ist_false(q):
assert q.lebenszeichen("gibt-es-nicht", "knoten-a") is False
def test_verwaiste_freigeben_macht_den_rueckfall_sichtbar(q):
"""Nötig ist die Funktion nicht — uebernehmen() behandelt abgelaufene
Leases ohnehin als frei. Sie macht den Rückfall aber SICHTBAR, statt ihn
stillschweigend passieren zu lassen."""
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
assert q.verwaiste_freigeben() == 0
_lease_zuruecksetzen(auftrag_id, 10)
assert q.verwaiste_freigeben() == 1
assert q.offene_auftraege()[0]["status"] == lokal.WARTEND
# ── Fähigkeiten ─────────────────────────────────────────────────────────
def test_passt_ohne_anforderung_darf_jeder():
assert lokal._passt("{}", set()) is True
assert lokal._passt(None, set()) is True
def test_passt_verlangt_alle_faehigkeiten():
verlangt = '{"art": "transcode", "encoder": "nvenc"}'
assert lokal._passt(verlangt, {"art=transcode", "encoder=nvenc"}) is True
assert lokal._passt(verlangt, {"art=transcode"}) is False
def test_passt_bei_kaputtem_json_nicht_abstuerzen():
"""Ein beschädigtes Feld darf die Auftragsvergabe nicht anhalten —
sonst blockiert eine einzige kaputte Zeile die ganze Queue."""
assert lokal._passt("{kein json", {"art=rip"}) is True
def test_knoten_ohne_die_faehigkeit_bekommt_nichts(q):
q.einreihen("job1", "transcode", faehigkeiten={"encoder": "nvenc"})
assert q.uebernehmen("nur-cpu", kann={"encoder=x265"}) is None
assert q.uebernehmen("gpu-knoten", kann={"encoder=nvenc"}) is not None
def test_passender_auftrag_wird_gefunden_obwohl_ein_fremder_vorne_liegt(q):
"""Der GPU-Knoten darf nicht leer ausgehen, nur weil ein CPU-Auftrag mit
höherer Prioritaet davor steht."""
q.einreihen("job1", "transcode", faehigkeiten={"encoder": "x265"}, prioritaet=9)
q.einreihen("job2", "transcode", faehigkeiten={"encoder": "nvenc"})
auftrag = q.uebernehmen("gpu", kann={"encoder=nvenc"})
assert auftrag is not None and auftrag["job_id"] == "job2"
# ── Abschluss und Fehlschlag ────────────────────────────────────────────
def test_abgeschlossener_auftrag_kommt_nicht_zurueck(q):
auftrag_id = q.einreihen("job1", "rip")
q.uebernehmen("knoten-a")
q.abschliessen(auftrag_id, {"pfad": "/app/media/Akira"})
assert q.uebernehmen("knoten-b") is None
assert q.offene_auftraege() == []
def test_fehlschlag_gibt_den_auftrag_wieder_frei(q):
auftrag_id = q.einreihen("job1", "transcode")
q.uebernehmen("knoten-a")
q.fehlgeschlagen(auftrag_id, "Encoder abgestuerzt")
zweiter = q.uebernehmen("knoten-b")
assert zweiter is not None and zweiter["id"] == auftrag_id
def test_nach_max_versuchen_ist_schluss(q):
"""Ohne Obergrenze liefe ein Auftrag, der jeden Knoten umbringt, endlos im
Kreis und nähme dabei jeden freien Knoten mit."""
auftrag_id = q.einreihen("job1", "transcode")
for _ in range(lokal.MAX_VERSUCHE):
q.uebernehmen("knoten-a")
q.fehlgeschlagen(auftrag_id, "immer derselbe Fehler")
assert q.uebernehmen("knoten-a") is None
with store.engine.connect() as conn:
zeile = conn.execute(
store.auftraege.select().where(store.auftraege.c.id == auftrag_id)
).mappings().first()
assert zeile["status"] == lokal.FEHLER
assert "immer derselbe Fehler" in zeile["fehler"]
def test_abbrechen_nimmt_den_auftrag_aus_dem_verkehr(q):
auftrag_id = q.einreihen("job1", "rip")
q.abbrechen(auftrag_id)
assert q.uebernehmen("knoten-a") is None
def test_offene_auftraege_lassen_sich_nach_job_filtern(q):
q.einreihen("job1", "rip")
q.einreihen("job2", "rip")
assert len(q.offene_auftraege()) == 2
assert len(q.offene_auftraege(job_id="job1")) == 1
+44
View File
@@ -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"}
+75
View File
@@ -119,6 +119,17 @@ def text_von(roh) -> str:
#: Aktivierungsschlüssel …" #: Aktivierungsschlüssel …"
KRITISCHE_CODES = { KRITISCHE_CODES = {
2024: "MakeMKV kennt dieses Laufwerk nicht", 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", 5020: "Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen",
5021: "MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV " 5021: "MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV "
"aktualisieren", "aktualisieren",
@@ -198,6 +209,70 @@ def _in_registry(key: str) -> str:
return "HKCU\\" + REGISTRY_PFAD 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: def key_zuruecknehmen(plattform_name: str = None, entferner=None) -> bool:
"""Einen abgelehnten Schlüssel wieder entfernen. True, wenn etwas wegging.""" """Einen abgelehnten Schlüssel wieder entfernen. True, wenn etwas wegging."""
if not plattform(plattform_name).startswith("win"): if not plattform(plattform_name).startswith("win"):
+32
View File
@@ -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
+73
View File
@@ -213,3 +213,76 @@ def test_die_codes_stehen_so_da_wie_gemessen():
assert "zu alt" in auf.KRITISCHE_CODES[5021] assert "zu alt" in auf.KRITISCHE_CODES[5021]
assert "Volume" not in auf.KRITISCHE_CODES[5021] assert "Volume" not in auf.KRITISCHE_CODES[5021]
assert auf.KEY_ABGELEHNT == (5020, 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)
-692
View File
@@ -1,692 +0,0 @@
"""Das Fenster des Einrichtungs-Assistenten.
## Warum es das gibt
Commander am 28.08.2026:
> Dann hätte ich beim Setup' auch erwartet das nen echtes setup passiert —
> wo will ich das hinspeichern, nen pre requirement check usw. Da kommt
> garnichts Rippy geht einfach auf."
## Warum WebView2 und nicht ein Windows-Dialog
Weil es schon da ist. Rippy bringt WebView2 ohnehin für sein Hauptfenster mit
(Entscheid 4 in `KONZEPT-V2.md` § 10) der Assistent kostet damit **null
zusätzliche Bytes**. Ein Dialog aus Win32-Bausteinen wäre hunderte Zeilen
ctypes für etwas, das hier fünfzig Zeilen HTML sind; `tkinter` ist im Bau
ausdrücklich ausgeschlossen.
## Warum die Prüfungen NICHT hier stehen
Sie stehen in `einrichtung.py`, und zwar vollständig ohne Fenster prüfbar.
Was hier steht, lässt sich nicht testen also steht hier so wenig wie
möglich: Aufbau, Anzeige, und die Brücke zwischen beidem.
## Was passiert, wenn das Fenster nicht aufgeht
Dann wird ohne Assistent installiert, mit den Vorgaben, und gesagt warum. Ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, wäre schlimmer
als eines, das nicht fragt.
"""
import threading
from rippy import einrichtung
TITEL = "Rippy einrichten"
BREITE = 860
HOEHE = 760
# Die Abschnitte der Installation und was sie am Balken belegen.
#
# Die Gewichte sind gemessen, nicht geraten: Das Ablegen der Dateien dauert
# rund drei Sekunden (56 MB kopieren, UI und HandBrake auspacken), das Holen
# von MakeMKV je nach Quelle sechs Sekunden bis mehrere Minuten. Der
# Werkzeug-Abschnitt bekommt deshalb den Löwenanteil — ein Balken, der bei
# 90 % minutenlang steht, ist schlimmer als gar keiner.
ABSCHNITTE = {
"dateien": (0.00, 0.08, "Dateien werden abgelegt"),
"ablage": (0.08, 0.10, "Ablage wird gespeichert"),
"werkzeuge": (0.10, 0.97, "Werkzeuge werden geprüft"),
"fertig": (0.97, 1.00, "Fertig"),
}
class Fortschritt:
"""Rechnet Abschnitt + Teil-Anteil in einen Gesamtstand um.
Reine Rechnerei, damit sie ohne Fenster prüfbar ist und weil ein
Fortschrittsbalken, der springt oder zurückläuft, schlimmer aussieht als
keiner.
Zwei Regeln:
* **Er läuft nie zurück.** Der Werkzeug-Abschnitt kann mehrere Downloads
enthalten, jeder mit eigenem 0-bis-1. Ohne diese Regel spränge der
Balken bei jedem neuen Werkzeug zurück an den Abschnittsanfang.
* **Ohne Anteil bleibt er stehen.** Eine Textmeldung ohne Zahl ist keine
Aussage über den Fortschritt.
"""
def __init__(self):
self.prozent = 0
self.abschnitt = ""
self.zeilen = []
def beginne(self, name: str) -> None:
von, _bis, titel = ABSCHNITTE[name]
self.abschnitt = titel
self._von, self._bis, _ = ABSCHNITTE[name]
self.setze(von)
def melde(self, text, anteil=None) -> None:
if text:
self.zeilen.append(str(text))
if anteil is None:
return
anteil = min(1.0, max(0.0, float(anteil)))
self.setze(self._von + (self._bis - self._von) * anteil)
def setze(self, anteil: float) -> None:
self.prozent = max(self.prozent, int(round(anteil * 100)))
def abholen(self) -> dict:
"""Was seit dem letzten Abruf passiert ist. Die Zeilen leeren sich."""
zeilen, self.zeilen = self.zeilen, []
return {"zeilen": zeilen, "prozent": self.prozent,
"abschnitt": self.abschnitt}
#: pywebview.FOLDER_DIALOG. Steht hier doppelt, damit `ordner_waehlen`
#: auch ohne pywebview pruefbar bleibt (webview/util.py: OPEN=10,
#: FOLDER=20, SAVE=30).
FOLDER_DIALOG = 20
class Bruecke:
"""Was die Seite aufrufen darf. Bewusst klein und ohne Zustand im HTML.
Jede Methode gibt Daten zurück, keine Erfolgsmeldungen dieselbe Regel
wie überall sonst in diesem Projekt.
"""
def __init__(self):
self.ergebnis = None
self._fenster = None
self._lauf = Fortschritt()
# ── Auskunft ────────────────────────────────────────────────────────
def vorgaben(self) -> dict:
return einrichtung.vorgaben()
def pruefen(self, ziel: str = "", port: int = 0) -> dict:
"""Der Voraussetzungs-Bericht für die angegebenen Werte."""
vorgabe = einrichtung.vorgaben()
befunde = einrichtung.alle_pruefungen(ziel or vorgabe["ziel"],
int(port or vorgabe["port"]))
return {
"befunde": befunde,
"darf": einrichtung.darf_installieren(befunde),
"text": einrichtung.zusammenfassung(befunde),
}
def updates(self) -> dict:
"""Gibt es für die installierten Werkzeuge etwas Neueres?
## Warum das ein EIGENER Aufruf ist (28.08.2026)
Commander: *bitte baue ein, das wenn MakeMKV oder Handbrake schon
installiert sind ein update verfügbar ist."*
Die Auskunft kostet Netz und makemkv.com braucht dafür im
schlimmsten Fall gut zwanzig Sekunden (gemessen). In der
Voraussetzungs-Prüfung würde das Fenster so lange leer bleiben.
Deshalb getrennt: Die Prüfung zeigt sofort, was installiert ist, und
diese Auskunft trägt die Update-Hinweise nach.
Leere Werte heißen nicht erreichbar", nicht „kein Update" eine
Nichtauskunft ist keine Aussage.
"""
from rippy.tools import beschaffen, einrichten as werkzeuge, katalog
try:
bestand = katalog.bestand()
neueste = beschaffen.neueste_versionen()
except Exception: # noqa: BLE001
return {"erreichbar": False, "offen": []}
offen = [{"name": name,
"titel": eintrag.get("titel", name),
"installiert": eintrag.get("version", ""),
"neueste": neu}
for name, eintrag, neu in werkzeuge.veraltete(bestand, neueste)]
return {"erreichbar": any(neueste.values()), "offen": offen}
def ordner_waehlen(self, vorschlag: str = "") -> str:
"""Ein echter Ordner-Dialog. Leer, wenn abgebrochen wurde."""
# Der Rueckfall ist NICHT fuer den Betrieb — dort ist pywebview immer
# da. Er ist fuer die Ampel: Ohne ihn scheiterten diese zwei Tests in
# jeder Umgebung ohne pywebview mit ModuleNotFoundError. Ein Test, der
# nur an einer Stelle greift, ist eine halbe Zusage (AGENTS.md) — und
# am 28.08.2026 hielt ich die beiden erst faelschlich fuer meinen
# eigenen Fehler.
try:
import webview
art = webview.FOLDER_DIALOG
except ImportError:
art = FOLDER_DIALOG
try:
gewaehlt = self._fenster.create_file_dialog(
art, directory=vorschlag or "")
except Exception: # noqa: BLE001
return ""
if not gewaehlt:
return ""
return gewaehlt[0] if isinstance(gewaehlt, (list, tuple)) else str(gewaehlt)
def fortschritt(self) -> dict:
"""Was seit dem letzten Abruf passiert ist — Zeilen UND Stand.
Gibt ein Wörterbuch zurück, keine Liste: Der Balken braucht eine Zahl,
und zwei getrennte Abrufe dafür wären zwei Gelegenheiten, dass Text
und Stand auseinanderlaufen.
"""
return self._lauf.abholen()
# ── Handeln ─────────────────────────────────────────────────────────
def installieren(self, optionen: dict) -> dict:
"""Installiert im Hintergrund-Thread und gibt sofort zurück.
Sofort, weil das Holen von MakeMKV Minuten dauern kann: Ein blockierter
Aufruf ließe das Fenster einfrieren, und ein eingefrorenes Fenster
sieht aus wie ein Absturz.
"""
threading.Thread(target=self._installieren_wirklich,
args=(optionen or {},), daemon=True).start()
return {"gestartet": True}
def _installieren_wirklich(self, optionen: dict) -> None:
from rippy import windows_app
melden = self._lauf.melde
try:
self._lauf.beginne("dateien")
melden("Dateien werden abgelegt …")
ziel = windows_app.installieren(
ziel=optionen.get("ziel") or None,
autostart=bool(optionen.get("autostart", True)),
starten_danach=False,
verknuepfen=bool(optionen.get("verknuepfungen", True)),
)
orte = {}
if optionen.get("ablage"):
orte["outputDir"] = optionen["ablage"]
# Der Arbeitsordner gehoert mit ins Setup (Commander 28.08.2026).
# Leer heisst „nimm die Vorgabe" — dann steht hier nichts, und der
# Betrieb rechnet ihn sich neben der Ablage aus.
if optionen.get("arbeit"):
orte["workDir"] = optionen["arbeit"]
if orte:
self._lauf.beginne("ablage")
melden("Ordner werden gespeichert …")
_einstellungen_schreiben(orte)
self._lauf.beginne("werkzeuge")
if optionen.get("werkzeuge_holen", True):
melden("Werkzeuge werden geprüft …")
from rippy.tools import einrichten as werkzeug_einrichtung
bericht = werkzeug_einrichtung.sicherstellen(
fortschritt=melden,
aktualisieren=bool(optionen.get("aktualisieren", False)))
melden(werkzeug_einrichtung.bericht(bericht))
bereit = bericht["bereit"]
_beta_key_holen(melden)
else:
bereit = True
self._lauf.beginne("fertig")
self.ergebnis = {"ok": True, "ziel": ziel, "bereit": bereit}
melden("Fertig.", 1.0)
except Exception as ex: # noqa: BLE001
# Nicht verschlucken: Ohne Konsole waere ein Absturz hier voellig
# unsichtbar, und der Nutzer saehe ein Fenster, in dem nichts mehr
# passiert.
self.ergebnis = {"ok": False,
"fehler": "%s: %s" % (type(ex).__name__, ex)}
melden("Fehlgeschlagen: %s" % ex)
def stand(self) -> dict:
"""None, solange es läuft. Danach das Ergebnis."""
return self.ergebnis or {}
def als_admin(self) -> dict:
"""Startet das Setup mit Administratorrechten neu.
Nur auf Knopfdruck, nie von selbst. Der Grund steht in
`winlauf.als_admin_neu_starten`: Erhöht sieht Rippy die eingebundenen
Netzlaufwerke NICHT mehr für eine Ablage auf einer Freigabe wäre das
genau der falsche Weg.
Gibt zurück, ob der Neustart angenommen wurde. Ein nein" ist der
Normalfall, wenn der Nutzer die UAC-Abfrage wegklickt dann bleibt
das Fenster einfach offen.
"""
from rippy import windows_app
from rippy.platform import winlauf
geklappt = winlauf.als_admin_neu_starten(windows_app._eigene_datei())
if geklappt:
self.schliessen()
return {"gestartet": geklappt}
def schliessen(self) -> None:
try:
self._fenster.destroy()
except Exception: # noqa: BLE001
pass
def _ablage_speichern(pfad: str) -> None:
"""Die gewählte Ablage in die Einstellungen schreiben.
## Der Fehler, der das Setup abgebrochen hat (28.08.2026)
Hier stand `import db as api_db`, nachdem `daemon._api_pfad()` in den
Suchpfad gehängt worden war. Beim Commander brach die Installation ab mit:
Fehlgeschlagen: ModuleNotFoundError: No module named 'db'
Und zwar zu Recht: **`docker/api/db.py` gibt es nicht.** Seit der
Zusammenlegung in V2-1 ist der Store `rippy.store`; `main.py` schreibt
seitdem `from rippy import store as db`. Ich habe ein Modul importiert,
das ich mir aus einem Alias zusammengereimt habe genau die Sorte
erfundener Schnittstelle, vor der AGENTS.md Regel D warnt, nur diesmal
im eigenen Code.
Der Store wird direkt benutzt. Ein Umweg über den Suchpfad der API war
ohnehin überflüssig.
"""
_einstellungen_schreiben({"outputDir": pfad})
def _einstellungen_schreiben(neu: dict) -> None:
"""Werte in die Einstellungen mischen — lesen, setzen, zurückschreiben.
`save_settings` überschreibt das GANZE JSON, deshalb erst lesen.
"""
from rippy import config, store
werte = config.laden()
store.verbinden(config.datenbank_url(werte))
store.init_db()
aktuell = store.get_settings() or {}
aktuell.update(neu)
store.save_settings(aktuell)
def _beta_key_holen(melden=None) -> str:
"""Den kostenlosen MakeMKV-Beta-Key aus dem Forum holen und ablegen.
## Warum das ins Setup gehört (Commander 28.08.2026)
> Bezüglich des MKV Beta Keys der könnte theoretisch auch automatisch
> ausgelesen werden, ich glaube das web rippy kann das"
Er hat recht, es kann: `docker/api/makemkv_key.py` holt den Key aus dem
Forum-Thread t=1053 und läuft seit jeher täglich mit. Nachgemessen am
28.08.2026: Antwort in 3,3 Sekunden, gültiger Key mit 62 Zeichen.
Nur half das beim ERSTEN Rip nicht: Die Schleife startet 60 Sekunden nach
dem Serverstart. Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt
ohne Key und MakeMKV fällt in den 30-Tage-Testmodus.
Ohne Key ist Rippy nicht kaputt; DVDs gehen weiterhin. Deshalb ist ein
Fehlschlag hier auch kein Abbruch, sondern eine Zeile im Protokoll.
Rechtlich unverändert: Das ist die öffentliche Beta-LIZENZ der Software,
NICHT ein Disc-Schlüssel. Rippy liefert und verteilt keine Disc-Schlüssel.
"""
melden = melden or (lambda *a, **k: None)
try:
import sys
from rippy.daemon import _api_pfad
# `_api_pfad` gibt den Pfad NUR zurueck — eingehaengt wird er in
# daemon.starten(). Hier laeuft der Assistent, nicht der Dienst.
pfad = _api_pfad()
if pfad and pfad not in sys.path:
sys.path.insert(0, pfad)
import makemkv_key
key = makemkv_key.fetch_current_key()
except Exception as ex: # noqa: BLE001
melden(" Beta-Key nicht erreichbar (%s) — nachtragbar unter "
"Einstellungen." % type(ex).__name__)
return ""
if not key:
melden(" Kein Beta-Key im Forum gefunden — nachtragbar unter "
"Einstellungen.")
return ""
_einstellungen_schreiben({"makemkvAppKey": key})
# Und jetzt nachsehen, ob MakeMKV ihn ueberhaupt annimmt. Am 29.08.2026
# tat es das nicht: Version 1.18.4 ist aelter als der aktuelle Schluessel
# verlangt (MSG 5020 + 5021). Ein abgelehnter Schluessel ist SCHLIMMER als
# gar keiner — ohne las MakeMKV die Disc noch, mit dem abgelehnten
# verweigerte es alles. `key_setzen_und_pruefen` nimmt ihn deshalb wieder
# weg. Begruendung in `rippy.rip.makemkv_aufruf.key_pruefen`.
from rippy.rip import makemkv_aufruf
from rippy.tools import katalog
urteil = makemkv_aufruf.key_setzen_und_pruefen(key, katalog.finden("makemkv"))
if urteil["ok"]:
melden(" MakeMKV-Beta-Key geholt und angenommen (…%s)." % key[-6:])
else:
melden(" MakeMKV-Beta-Key geholt, aber NICHT angenommen: %s"
% urteil["grund"])
return key
def starten() -> dict:
"""Öffnet den Assistenten. Gibt zurück, was daraus geworden ist.
`{}` heißt: abgebrochen oder nicht möglich. Der Aufrufer entscheidet dann,
ob er ohne Assistent installiert.
"""
import webview
bruecke = Bruecke()
fenster = webview.create_window(TITEL, html=SEITE, js_api=bruecke,
width=BREITE, height=HOEHE,
min_size=(720, 560), text_select=True)
bruecke._fenster = fenster
webview.start(gui="edgechromium", private_mode=False)
return bruecke.ergebnis or {}
def moeglich() -> tuple:
"""Kann der Assistent überhaupt aufgehen? (ja/nein, Begründung)"""
from rippy import fenster as fenster_modul
return fenster_modul.verfuegbar()
# ── Die Seite ───────────────────────────────────────────────────────────
# Bewusst EINE Datei ohne Abhängigkeiten: kein Vite-Bau, kein Nachladen, keine
# Schriftart aus dem Netz. Der Assistent muss auch auf einem Rechner ohne
# Internet aufgehen — dort wird er am dringendsten gebraucht.
SEITE = r"""<!DOCTYPE html>
<html lang="de"><head><meta charset="utf-8">
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; background:#0b0f19; color:#e2e8f0;
font:14px/1.55 "Segoe UI",system-ui,sans-serif; }
header { padding:22px 28px 16px; border-bottom:1px solid #1e293b; }
h1 { margin:0; font-size:19px; font-weight:650; }
h1 span { color:#f59e0b; }
.unter { color:#94a3b8; font-size:12.5px; margin-top:4px; }
main { padding:20px 28px 96px; }
section { margin-bottom:22px; }
h2 { font-size:12px; text-transform:uppercase; letter-spacing:.09em;
color:#94a3b8; margin:0 0 10px; font-weight:600; }
.zeile { display:flex; gap:10px; padding:7px 0;
border-bottom:1px solid #131c2e; align-items:flex-start; }
.zeile:last-child { border-bottom:0; }
.marke { width:17px; flex:0 0 17px; font-weight:700; text-align:center; }
.ok .marke { color:#34d399; } .warnung .marke { color:#fbbf24; }
.fehler .marke { color:#f87171; }
.inhalt { flex:1; min-width:0; }
.name { font-weight:600; }
.wert { color:#cbd5e1; font-family:ui-monospace,Consolas,monospace;
font-size:12.5px; word-break:break-all; }
.abhilfe { color:#94a3b8; font-size:12px; margin-top:2px; }
label.feld { display:block; margin-bottom:14px; }
label.feld > span { display:block; font-size:12.5px; color:#cbd5e1;
margin-bottom:5px; }
.mitknopf { display:flex; gap:8px; }
input[type=text], input[type=number] {
flex:1; background:#0f1626; border:1px solid #24324a; border-radius:7px;
color:#e2e8f0; padding:9px 11px; font-size:13px; font-family:inherit; }
input:focus { outline:2px solid #f59e0b55; border-color:#f59e0b; }
button { background:#1c2740; border:1px solid #2e3d5c; color:#e2e8f0;
border-radius:7px; padding:9px 15px; font-size:13px; cursor:pointer;
font-family:inherit; }
button:hover:not(:disabled) { background:#25324f; }
button.los { background:#f59e0b; border-color:#f59e0b; color:#1a1206;
font-weight:650; padding:11px 26px; }
button.los:hover:not(:disabled) { background:#fbbf24; }
button:disabled { opacity:.45; cursor:not-allowed; }
.schalter { display:flex; gap:9px; align-items:flex-start; margin-bottom:11px;
cursor:pointer; }
.schalter input { margin:3px 0 0; accent-color:#f59e0b; }
.schalter .hinweis { color:#94a3b8; font-size:12px; }
footer { position:fixed; left:0; right:0; bottom:0; background:#0b0f19f2;
border-top:1px solid #1e293b; padding:14px 28px;
display:flex; align-items:center; gap:16px;
backdrop-filter:blur(6px); }
#urteil { flex:1; font-size:12.5px; color:#94a3b8; }
.balken-rahmen { height:9px; background:#111a2b; border-radius:99px;
overflow:hidden; margin:2px 0 10px; }
#balken { height:100%; width:0%; border-radius:99px; background:#f59e0b;
transition:width .35s ease; }
.balken-zeile { display:flex; justify-content:space-between;
font-size:12.5px; color:#cbd5e1; margin-bottom:4px; }
#prozent { font-family:ui-monospace,Consolas,monospace; color:#f59e0b;
font-variant-numeric:tabular-nums; }
#protokoll { background:#080c15; border:1px solid #1a2438; border-radius:8px;
padding:11px 13px; font-family:ui-monospace,Consolas,monospace;
font-size:12px; color:#94a3b8; max-height:190px; overflow:auto;
white-space:pre-wrap; }
.weg { display:none; }
</style></head><body>
<header>
<h1><span>&#9673;</span> Rippy einrichten</h1>
<div class="unter">Wird geprüft, was dein Rechner mitbringt danach entscheidest du, wohin.</div>
</header>
<main>
<section id="teil-pruefung">
<h2>Voraussetzungen</h2>
<div id="pruefungen"><div class="zeile"><div class="inhalt wert">wird geprüft </div></div></div>
</section>
<section id="teil-orte">
<h2>Wohin</h2>
<label class="feld"><span>Rippy selbst (Programmordner)</span>
<div class="mitknopf">
<input type="text" id="ziel">
<button onclick="waehlen('ziel')">Durchsuchen </button>
</div>
</label>
<label class="feld"><span>Fertige Filme (Ablage) darf auch ein Netzwerkpfad sein</span>
<div class="mitknopf">
<input type="text" id="ablage">
<button onclick="waehlen('ablage')">Durchsuchen </button>
</div>
</label>
<label class="feld"><span>Arbeitsordner für die Rohdaten
<div class="hinweis">Hier liegt die Disc zwischendurch am Stück bei einer
4K-UHD bis zu 100 GB. Am besten dasselbe Laufwerk wie die Ablage: dann muss
Rippy die fertige Datei am Ende nur umhängen statt sie zu kopieren.</div></span>
<div class="mitknopf">
<input type="text" id="arbeit">
<button onclick="waehlen('arbeit')">Durchsuchen </button>
</div>
</label>
<label class="feld" style="max-width:190px"><span>Port</span>
<input type="number" id="port" min="1024" max="65535">
</label>
</section>
<section id="teil-optionen">
<h2>Sonst noch</h2>
<label class="schalter"><input type="checkbox" id="autostart" checked>
<span>Mit Windows starten<div class="hinweis">Rippy läuft dann im Hintergrund und erkennt eingelegte Discs von allein.</div></span></label>
<label class="schalter"><input type="checkbox" id="verknuepfungen" checked>
<span>Symbol auf dem Desktop und im Startmenü<div class="hinweis">Ohne das ist Rippy praktisch unauffindbar.</div></span></label>
<label class="schalter"><input type="checkbox" id="werkzeuge_holen" checked>
<span>Fehlende Werkzeuge holen<div class="hinweis">HandBrake liegt bei. MakeMKV wird von makemkv.com geladen und mit dem Installer des Herstellers installiert dafür fragt Windows einmal nach Administratorrechten. Ohne MakeMKV kann Rippy keine Disc lesen.</div></span></label>
<label class="schalter"><input type="checkbox" id="aktualisieren" checked>
<span>Vorhandene Werkzeuge aktualisieren<div class="hinweis" id="update-hinweis">wird geprüft </div></span></label>
</section>
<section id="teil-lauf" class="weg">
<h2>Läuft</h2>
<div class="balken-zeile">
<span id="abschnitt">wird vorbereitet </span>
<span id="prozent">0 %</span>
</div>
<div class="balken-rahmen"><div id="balken"></div></div>
<div id="protokoll"></div>
</section>
</main>
<footer>
<div id="urteil">wird geprüft </div>
<button id="knopf-admin" class="weg" onclick="alsAdmin()">Als Administrator neu starten</button>
<button id="knopf-abbruch" onclick="pywebview.api.schliessen()">Abbrechen</button>
<button id="knopf-los" class="los" onclick="losgehen()" disabled>Installieren</button>
</footer>
<script>
const $ = (id) => document.getElementById(id);
let laeuft = false;
function zeichnePruefungen(bericht) {
const marken = { ok: '&#10003;', warnung: '!', fehler: '&#10007;' };
$('pruefungen').innerHTML = bericht.befunde.map(b =>
'<div class="zeile ' + b.zustand + '">'
+ '<div class="marke">' + (marken[b.zustand] || '?') + '</div>'
+ '<div class="inhalt"><div class="name">' + b.name + '</div>'
+ '<div class="wert">' + b.text + '</div>'
+ (b.abhilfe ? '<div class="abhilfe">' + b.abhilfe + '</div>' : '')
+ '</div></div>').join('');
$('urteil').textContent = bericht.text;
$('knopf-los').disabled = !bericht.darf || laeuft;
/* Der Knopf für Administratorrechte erscheint NUR, wenn das Schreibrecht
am gewählten Ziel fehlt. Nicht dauerhaft: Erhöht sieht Rippy die
eingebundenen Netzlaufwerke nicht mehr wer eine Freigabe als Ablage
will, darf ihn gerade NICHT drücken. */
const rechteProblem = bericht.befunde.some(
b => b.name === 'Schreibrecht' && b.zustand === 'fehler');
$('knopf-admin').classList.toggle('weg', !rechteProblem || laeuft);
}
async function updatesNachtragen() {
/* Getrennt vom Voraussetzungs-Bericht: Die Auskunft kostet Netz, und
makemkv.com braucht dafuer im schlimmsten Fall gut zwanzig Sekunden.
Solange steht "wird geprueft" da, statt das Fenster leer zu lassen. */
const stand = await pywebview.api.updates();
const hinweis = $('update-hinweis');
if (!stand.erreichbar) {
hinweis.textContent = 'Update-Quellen nicht erreichbar — es wird nichts aktualisiert.';
$('aktualisieren').checked = false;
return;
}
if (!stand.offen.length) {
hinweis.textContent = 'Alles auf dem neuesten Stand.';
return;
}
hinweis.textContent = stand.offen
.map(o => o.titel + ' ' + o.installiert + '' + o.neueste)
.join(' · ');
}
async function alsAdmin() {
$('knopf-admin').disabled = true;
const antwort = await pywebview.api.als_admin();
if (!antwort.gestartet) {
$('knopf-admin').disabled = false;
$('urteil').textContent = 'Der Neustart als Administrator wurde abgebrochen.';
}
}
async function pruefen() {
if (laeuft) return;
zeichnePruefungen(await pywebview.api.pruefen($('ziel').value, Number($('port').value)));
}
async function waehlen(feld) {
const ordner = await pywebview.api.ordner_waehlen($(feld).value);
if (ordner) { $(feld).value = ordner; if (feld === 'ziel') pruefen(); }
}
async function losgehen() {
laeuft = true;
$('knopf-los').disabled = true;
$('knopf-los').textContent = 'Läuft …';
$('teil-lauf').classList.remove('weg');
$('urteil').textContent = 'Rippy wird eingerichtet …';
await pywebview.api.installieren({
ziel: $('ziel').value,
ablage: $('ablage').value,
arbeit: $('arbeit').value,
port: Number($('port').value),
autostart: $('autostart').checked,
verknuepfungen: $('verknuepfungen').checked,
werkzeuge_holen: $('werkzeuge_holen').checked,
aktualisieren: $('aktualisieren').checked,
});
verfolgen();
}
async function verfolgen() {
const takt = await pywebview.api.fortschritt();
if (takt.zeilen && takt.zeilen.length) {
const p = $('protokoll');
p.textContent += takt.zeilen.join('\n') + '\n';
p.scrollTop = p.scrollHeight;
}
if (typeof takt.prozent === 'number') {
$('balken').style.width = takt.prozent + '%';
$('prozent').textContent = takt.prozent + ' %';
}
if (takt.abschnitt) $('abschnitt').textContent = takt.abschnitt;
const ergebnis = await pywebview.api.stand();
if (ergebnis && ergebnis.ok !== undefined) {
// Am Ende ist der Balken voll und die Farbe sagt, wie es ausging.
$('balken').style.width = '100%';
$('prozent').textContent = '100 %';
$('balken').style.background = ergebnis.ok ? '#34d399' : '#f87171';
$('abschnitt').textContent = ergebnis.ok ? 'Fertig' : 'Abgebrochen';
$('knopf-abbruch').classList.add('weg');
$('knopf-los').disabled = false;
$('knopf-los').textContent = 'Rippy öffnen';
$('knopf-los').onclick = () => pywebview.api.schliessen();
$('urteil').textContent = ergebnis.ok
? (ergebnis.bereit ? 'Fertig — Rippy ist einsatzbereit.'
: 'Installiert, aber es fehlt noch ein Werkzeug (siehe oben).')
: ('Fehlgeschlagen: ' + ergebnis.fehler);
return;
}
setTimeout(verfolgen, 700);
}
window.addEventListener('pywebviewready', async () => {
const v = await pywebview.api.vorgaben();
$('ziel').value = v.ziel;
$('ablage').value = v.ablage;
$('arbeit').value = v.arbeit;
$('port').value = v.port;
$('autostart').checked = v.autostart;
$('verknuepfungen').checked = v.verknuepfungen;
$('werkzeuge_holen').checked = v.werkzeuge_holen;
pruefen();
updatesNachtragen();
$('port').addEventListener('change', pruefen);
$('ziel').addEventListener('change', pruefen);
});
</script>
</body></html>
"""
# Damit die Seite auch ohne laufendes Fenster prüfbar ist (siehe Tests).
SEITE_LAENGE = len(SEITE)
def seite_enthaelt(text: str) -> bool:
"""Kleine Hilfe für Tests: Steht etwas in der Seite?"""
return text in SEITE
-243
View File
@@ -1,243 +0,0 @@
"""Standalone-Betrieb: Auftraege lokal zustellen und im selben Prozess abarbeiten.
## Was hier zusammenkommt
Das ist das letzte Stueck, damit Rippy unter Windows nicht nur ANZEIGEN,
sondern auch ARBEITEN kann:
celery_client.zusteller_setzen() die API reiht lokal ein statt an Celery
rippy.queue.lokal die Auftragstabelle mit Lease
rippy.queue.laeufer holt sie und arbeitet sie ab
ablauf.rippen / .komprimieren der Ablauf selbst, ohne Celery
Der Ablauf ist derselbe wie im Docker-Betrieb Zeile fuer Zeile dieselbe
Datei. Was sich unterscheidet, ist allein die Zustellung.
## Warum die Task-NAMEN erhalten bleiben
Die API schickt `"worker.tasks.rip_disc"` los. Dieser Name ist der Vertrag
zwischen API und Worker; `test_api_smoke.py` nagelt ihn ausdruecklich fest.
Hier wird er deshalb NICHT durch etwas Eigenes ersetzt, sondern uebersetzt
so bleibt beides gueltig, und ein Betrieb laesst sich umstellen, ohne dass
die API etwas davon merkt.
## Warum die Kompression NICHT weitergereicht wird
`ablauf.rippen()` nimmt einen Rueckruf `weiterreichen`, mit dem die
Kompression an einen anderen Knoten geht. Im Standalone-Betrieb gibt es
keinen anderen Knoten hier komprimiert derselbe Prozess weiter. Deshalb
wird der Rueckruf bewusst NICHT gestellt: `ablauf` ruft dann selbst
`komprimieren()` auf, im selben Auftrag, ohne Umweg ueber die Queue.
Das ist auch das Ehrlichere: Eine Queue mit genau einem Bearbeiter, die
Arbeit an sich selbst weiterreicht, ist nur Zeremonie.
"""
import os
import sys
import threading
# Die Auftragsarten, die die API kennt — auf die Formen, die `ablauf` versteht.
AUFTRAGSARTEN = {
"worker.tasks.rip_disc": "rip",
"worker.tasks.transcode_files": "transcode",
"worker.tasks.scan_tracks": "scan",
}
def _worker_pfad() -> str:
"""Wo liegen die Worker-Module (ablauf.py, ripping.py, medien.py)?
Dieselben drei Faelle wie bei der API (siehe `daemon._api_pfad`): aus dem
Repo gestartet, im PyInstaller-Paket, oder installiert.
"""
gebuendelt = getattr(sys, "_MEIPASS", None)
if gebuendelt:
return os.path.join(gebuendelt, "worker")
hier = os.path.dirname(os.path.abspath(__file__))
repo = os.path.dirname(os.path.dirname(hier))
kandidaten = [
os.path.join(repo, "docker", "worker"),
os.path.join(os.path.dirname(sys.executable), "worker"),
]
for pfad in kandidaten:
if os.path.isfile(os.path.join(pfad, "ablauf.py")):
return pfad
raise RuntimeError(
"Die Worker-Module wurden nicht gefunden. Gesucht wurde in:\n "
+ "\n ".join(kandidaten)
+ "\nOhne sie kann Rippy nicht rippen."
)
def _ablauf():
"""`ablauf` importieren — erst beim ersten Bedarf.
Nicht auf Modulebene: Das Modul zieht `ripping`, `medien` und `db` nach,
und die brauchen einen gesetzten sys.path. Ausserdem soll der Import
nicht schon beim Start des Servers passieren, sondern dann, wenn wirklich
gearbeitet wird.
"""
pfad = _worker_pfad()
if pfad not in sys.path:
sys.path.insert(0, pfad)
import ablauf
return ablauf
# ── Zustellung ──────────────────────────────────────────────────────────
def zustellung_bauen(bus=None):
"""Gibt die Funktion zurueck, die `celery_client.zusteller_setzen` erwartet."""
from rippy.queue import lokal
def zustellen(task_name: str, args: list, queue: str = None):
art = AUFTRAGSARTEN.get(task_name)
if art is None:
raise ValueError(
f"Unbekannte Auftragsart {task_name!r}. Bekannt sind: "
+ ", ".join(sorted(AUFTRAGSARTEN))
+ ". Wer eine neue einfuehrt, traegt sie in "
"rippy/standalone.py AUFTRAGSARTEN ein — sonst landet sie "
"im Standalone-Betrieb nirgends, und zwar lautlos."
)
if art == "rip":
device_path, job_id, target_dir = (list(args) + [None, None, None])[:3]
auftrag_id = lokal.einreihen(
job_id, "rip", faehigkeiten={"art": "rip"},
payload={"device_path": device_path, "target_dir": target_dir})
elif art == "transcode":
job_id, raw_dir, final_dir = (list(args) + [None, None, None])[:3]
auftrag_id = lokal.einreihen(
job_id, "transcode", faehigkeiten={"art": "transcode"},
payload={"raw_dir": raw_dir, "final_dir": final_dir})
else: # scan
device_path = args[0] if args else ""
auftrag_id = lokal.einreihen(
"", "scan", faehigkeiten={"art": "scan"},
payload={"device_path": device_path})
if bus:
try:
bus.senden("system.notice",
{"level": "info", "text": f"{art} eingereiht"})
except Exception:
pass
return auftrag_id
return zustellen
# ── Ausfuehrung ─────────────────────────────────────────────────────────
def ausfuehren(auftrag: dict):
"""Arbeitet EINEN Auftrag ab — der Laeufer ruft das auf."""
import json
ablauf = _ablauf()
try:
payload = json.loads(auftrag.get("payload") or "{}")
except (ValueError, TypeError):
payload = {}
art = auftrag.get("art")
if art == "rip":
# KEIN `weiterreichen`: Im Standalone-Betrieb komprimiert derselbe
# Prozess weiter (siehe Modul-Docstring).
return ablauf.rippen(payload.get("device_path"), auftrag["job_id"],
payload.get("target_dir"))
if art == "transcode":
return ablauf.komprimieren(auftrag["job_id"], payload.get("raw_dir"),
payload.get("final_dir"))
if art == "scan":
return ablauf.scan_tracks(payload.get("device_path"))
raise ValueError(f"Unbekannte Auftragsart im Auftrag: {art!r}")
# ── Herzschlag ──────────────────────────────────────────────────────────
HERZSCHLAG_SEKUNDEN = 60
def herzschlag_starten(knoten: str, store, takt: float = HERZSCHLAG_SEKUNDEN):
"""Meldet DIESEN Prozess als Arbeiter — mit seinen Faehigkeiten.
## Warum das noetig ist
`/capabilities` liest die `workers`-Tabelle. Gefuellt hat sie bisher der
Celery-Herzschlag in `celery_app.py`. Im Standalone-Betrieb gibt es den
nicht also stand dort 0 Worker", und im UI blieb die Encoder-Auswahl
LEER. Rippy haette auf einem Rechner mit AMD-Hardwarebeschleunigung
behauptet, es koenne nichts.
Gemeldet wird, was `caps.py` MISST: die Encoder, die HandBrake auf dieser
Maschine wirklich anbietet, die Preset-Namen dieser HandBrake-Fassung,
CPU-Modell, Kerne und Vektorbefehle. Nichts davon wird behauptet das
ist die Lehre aus Etappe 19 (Encoder wurden behauptet statt gemessen").
"""
import threading
import time
def schlagen():
while True:
try:
pfad = _worker_pfad()
if pfad not in sys.path:
sys.path.insert(0, pfad)
import caps
store.save_worker(knoten, caps.erkenne_encoder(),
caps.werkzeug_versionen())
except Exception as e:
# Nicht sterben — aber auch nicht schweigen.
try:
store.add_log("warning", "standalone",
f"Faehigkeiten-Meldung fehlgeschlagen: {e}")
except Exception:
pass
time.sleep(takt)
threading.Thread(target=schlagen, daemon=True, name="rippy-herzschlag").start()
# ── Alles zusammen ──────────────────────────────────────────────────────
def starten(knoten: str = None, bus=None, store=None, slots: int = 1) -> list:
"""Zustellung umbiegen und die Laeufer starten. Gibt die Laeufer zurueck.
`slots` ist die Zahl gleichzeitiger Auftraege. Vorgabe 1: Ein Rip belegt
das Laufwerk, und zwei gleichzeitige Encodes auf einem Rechner sind
langsamer als zwei nacheinander.
"""
import socket
from rippy.queue.laeufer import Laeufer
knoten = knoten or socket.gethostname()
# Die API reiht ab jetzt lokal ein statt an Celery.
sys.path.insert(0, os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "docker", "api"))
try:
import celery_client
except ImportError: # im Paket liegt sie woanders
from rippy.daemon import _api_pfad
sys.path.insert(0, _api_pfad())
import celery_client
celery_client.zusteller_setzen(zustellung_bauen(bus))
# Sich selbst als Arbeiter melden — sonst zeigt das UI "0 Worker" und
# eine leere Encoder-Auswahl, obwohl dieser Rechner alles kann.
if store is not None:
herzschlag_starten(knoten, store)
laeufer = []
for nummer in range(max(1, slots)):
lauf = Laeufer(ausfuehren, f"{knoten}#{nummer}",
kann={"art=rip", "art=transcode", "art=scan"},
bus=bus, store=store)
threading.Thread(target=lauf.schleife, daemon=True,
name=f"rippy-laeufer-{nummer}").start()
laeufer.append(lauf)
return laeufer
+41
View File
@@ -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.
+13 -21
View File
@@ -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
+75 -8
View File
@@ -20,6 +20,8 @@ nie auf.
from rippy import betrieb from rippy import betrieb
B = chr(92) # Backslash, nie woertlich (siehe test_pfade)
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}} DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}} WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
@@ -126,10 +128,15 @@ def test_eingestellte_ablage_schlaegt_jede_vorgabe():
assert a["ablage_vorgabe"] == "D:\\Filme" assert a["ablage_vorgabe"] == "D:\\Filme"
def test_windows_vorgabe_liegt_beim_nutzer(monkeypatch): def test_native_vorgabe_liegt_beim_nutzer():
monkeypatch.setenv("USERPROFILE", "C:\\Users\\Test") """Der USERPROFILE-Sonderzweig ist mit der Standalone-App gegangen
(Rippy v5, Entscheid 7) nativ heisst hier nur noch: ein
Entwickler-Lauf ausserhalb des Containers, und die Vorgabe liegt im
Benutzerordner dieser Maschine."""
import os
a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32") a = betrieb.auskunft(WINDOWS, container=False, plattform_name="win32")
assert a["ablage_vorgabe"].startswith("C:\\Users\\Test") assert a["ablage_vorgabe"].startswith(os.path.expanduser("~"))
assert a["ablage_vorgabe"].endswith("Rippy") assert a["ablage_vorgabe"].endswith("Rippy")
@@ -165,19 +172,45 @@ def test_docker_misst_weiter_die_container_pfade():
assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"] assert [o["pfad"] for o in orte] == ["/app/media", "/app/temp"]
def test_gleiches_laufwerk_wird_nicht_doppelt_gezaehlt(): def test_beide_orte_stehen_da_MIT_pfad():
"""Zweimal dieselbe Zahl unter zwei Namen sieht aus wie zwei Auskuenfte """Geaenderte Regel (Commander 29.08.2026).
und ist eine."""
> bei Platz für rippy' sollte eher das arbeitsverzeichnis und der
> Ablagepfad sein."
Vorher fiel die zweite Zeile weg, wenn beide auf demselben Laufwerk
lagen mit der Begruendung, zweimal dieselbe Zahl sei eine Auskunft. Das
stimmt fuer die ZAHL und war der falsche Schluss fuer den ORT: Auf seinem
Bildschirm stand nur Platz für Rippy: 59,9 von 232 GB frei", und welche
Ordner gemeint waren, sah man nicht.
"""
orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}}, orte = betrieb.platz_orte({"storage": {"medien": r"D:\Filme"}},
container=False, plattform_name="win32") container=False, plattform_name="win32")
assert len(orte) == 1, orte assert [o["name"] for o in orte] == ["Ablage", "Arbeitsverzeichnis"]
assert orte[0]["pfad"] == r"D:\Filme"
assert orte[1]["pfad"] == r"D:\Filme\_arbeit"
def test_zweites_laufwerk_bekommt_eine_eigene_zeile(): 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( orte = betrieb.platz_orte(
{"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}}, {"storage": {"medien": r"D:\Filme", "temp": r"E:\Arbeit"}},
container=False, plattform_name="win32") container=False, plattform_name="win32")
assert len(orte) == 2, orte 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(): def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
@@ -191,3 +224,37 @@ def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet(): def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht", assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == "" 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"] == {}
-139
View File
@@ -1,139 +0,0 @@
"""Der Daemon-Start — die Teile, die ohne laufenden Server pruefbar sind.
Nicht geprueft wird hier, dass uvicorn hochkommt; das ist ein Rauchtest fuer
den Betrieb. Geprueft wird, was davor entschieden wird und genau dort
sitzen die Fehler, die man spaeter nicht mehr findet:
* Ein argparse-Vorgabewert, der die Konfigurationsdatei schlaegt.
* Eine Datenbank, die am falschen Ort landet, weil der Store erst NACH dem
Import der API umgestellt wird.
"""
import os
import sys
from rippy import config, daemon
# ── Argumente ───────────────────────────────────────────────────────────
def test_ohne_argumente_bleibt_alles_offen():
"""DIE Falle bei argparse: Ein Vorgabewert im Parser sieht aus wie eine
Angabe des Nutzers und schlaegt damit die Konfigurationsdatei. Der
Eintrag dort waere wirkungslos, und niemand wuesste warum.
"""
assert daemon.flags_aus_argumenten(daemon.argumente([])) == {}
def test_port_kommt_als_flag_durch():
flags = daemon.flags_aus_argumenten(daemon.argumente(["--port", "9000"]))
assert flags == {"server": {"port": 9000}}
def test_mehrere_angaben_landen_im_richtigen_abschnitt():
flags = daemon.flags_aus_argumenten(
daemon.argumente(["--host", "127.0.0.1", "--port", "8080",
"--profil", "standalone", "--kein-ui"]))
assert flags["profil"] == "standalone"
assert flags["server"] == {"host": "127.0.0.1", "port": 8080, "ui": False}
def test_db_flag_stellt_auf_sqlite_um():
flags = daemon.flags_aus_argumenten(daemon.argumente(["--db", "C:/tmp/r.db"]))
assert flags["store"] == {"treiber": "sqlite", "pfad": "C:/tmp/r.db"}
def test_flag_schlaegt_die_konfigurationsdatei():
"""Die Praezedenz aus config.py — hier am echten Daemon-Weg nachgeprueft."""
werte = config.laden(
umgebung={"RIPPY_SERVER__PORT": "5000"},
flags=daemon.flags_aus_argumenten(daemon.argumente(["--port", "9999"])),
)
assert werte["server"]["port"] == 9999
# ── Pfade ───────────────────────────────────────────────────────────────
def test_api_pfad_wird_gefunden():
"""Ohne die API-Module startet gar nichts. Wenn sich die Verzeichnis-
struktur aendert, soll das HIER auffallen und nicht beim Nutzer."""
pfad = daemon._api_pfad()
assert os.path.isfile(os.path.join(pfad, "main.py"))
def test_ui_pfad_ist_entweder_da_oder_ehrlich_leer():
"""Leerer String heisst „kein UI mitgeliefert" — das ist ein gueltiger
Zustand (Kopfloser Betrieb). Was NICHT passieren darf: ein Pfad, den es
nicht gibt; dann liefe StaticFiles ins Leere."""
pfad = daemon._ui_pfad()
assert pfad == "" or os.path.isdir(pfad)
def test_kopie_neben_dem_programm_geht_vor_dem_paket(tmp_path, monkeypatch):
"""DER Befund vom 28.08.2026. Ein Dienst lief eine Stunde:
`/api/health` gab 200, `/` gab 404, im Fenster stand
`{"detail":"Not Found"}`.
Der Server war in Ordnung sein Entpack-Verzeichnis unter `%TEMP%` war
es nicht: 31 Eintraege statt 44, kein `ui`. `StaticFiles` schlaegt bei
JEDER Anfrage dort nach, also wurde aus einer laufenden Oberflaeche eine
404, waehrend die API weiter gesund meldete.
Die installierte Kopie hat deshalb Vorrang. Faellt dieser Test aus, ist
der Dienst wieder darauf angewiesen, dass niemand das Temp aufraeumt.
"""
installiert = tmp_path / "installiert"
(installiert / "ui").mkdir(parents=True)
(installiert / "ui" / "index.html").write_text("<!DOCTYPE html>",
encoding="utf-8")
paket = tmp_path / "temp" / "_MEI4711"
(paket / "ui").mkdir(parents=True)
monkeypatch.setattr(daemon, "_programmordner",
lambda: str(installiert / "Rippy.exe"))
monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False)
assert daemon._ui_pfad() == str(installiert / "ui")
def test_ohne_kopie_bleibt_das_paket_der_rueckfall(tmp_path, monkeypatch):
"""Wer die Setup-Datei direkt startet, hat keinen Installationsordner.
Dann ist das Entpack-Verzeichnis richtig nur eben nicht auf Dauer."""
paket = tmp_path / "_MEI4711"
(paket / "ui").mkdir(parents=True)
monkeypatch.setattr(daemon, "_programmordner",
lambda: str(tmp_path / "leer" / "RippySetup.exe"))
monkeypatch.setattr(sys, "_MEIPASS", str(paket), raising=False)
assert daemon._ui_pfad() == str(paket / "ui")
def test_standard_konfigpfad_passt_zur_plattform():
pfad = daemon.standard_konfigpfad()
if os.name == "nt":
assert pfad.endswith(os.path.join("Rippy", "rippy.toml"))
else:
assert pfad == "/etc/rippy/rippy.toml"
# ── Konfiguration einlesen ──────────────────────────────────────────────
def test_fehlende_konfigdatei_ist_kein_fehler(tmp_path, monkeypatch):
monkeypatch.delenv("RIPPY_CONFIG", raising=False)
args = daemon.argumente(["--config", str(tmp_path / "gibt-es-nicht.toml")])
werte, datei, unbekannt = daemon.einstellungen_laden(args)
assert werte["server"]["port"] == 7788 # Vorgabe
assert unbekannt == []
def test_unbekannte_eintraege_werden_gemeldet(tmp_path, monkeypatch):
"""Sie werden NICHT uebernommen (config.py) — aber sie muessen sichtbar
sein. Ein Tippfehler, der stillschweigend wirkungslos bleibt, kostet
Stunden."""
if config.tomllib is None:
import pytest
pytest.skip("tomllib gibt es erst ab Python 3.11")
datei = tmp_path / "rippy.toml"
datei.write_text("[server]\nport = 8080\nprot = 9090\n", encoding="utf-8")
monkeypatch.delenv("RIPPY_CONFIG", raising=False)
werte, _, unbekannt = daemon.einstellungen_laden(
daemon.argumente(["--config", str(datei)]))
assert werte["server"]["port"] == 8080
assert unbekannt == ["server.prot"]
-281
View File
@@ -1,281 +0,0 @@
"""Der Einrichtungs-Assistent — die Pruefungen, ohne etwas zu installieren.
## Warum es diese Tests gibt
Commander am 28.08.2026: *Dann haette ich beim Setup' auch erwartet das nen
echtes setup passiert wo will ich das hinspeichern, nen pre requirement
check usw. Da kommt garnichts Rippy geht einfach auf."*
Er hatte recht: Ein Doppelklick installierte stillschweigend und oeffnete das
Fenster. Der Nutzer wurde nichts gefragt und erfuhr nichts.
Alle Pruefungen sind eingespritzt kein Fenster, keine Installation, kein
WebView2. Sonst waere kein Laufwerk" nur auf einem Rechner ohne Laufwerk
pruefbar, und genau dort sieht es niemand.
"""
import os
from rippy import einrichtung as e
def _p(zustand, name="X"):
return {"name": name, "zustand": zustand, "text": "", "abhilfe": ""}
# ── Was blockiert und was nicht ─────────────────────────────────────────
def test_ohne_fehler_darf_installiert_werden():
assert e.darf_installieren([_p(e.OK), _p(e.WARNUNG)]) is True
def test_ein_fehler_blockiert():
assert e.darf_installieren([_p(e.OK), _p(e.FEHLER)]) is False
def test_eine_warnung_blockiert_NICHT():
"""Eine Warnung, die den Knopf sperrt, ist eine Bevormundung. Rippy ohne
optisches Laufwerk ist ein vorgesehener Betriebsfall eine reine
Komprimier-Maschine."""
assert e.darf_installieren([_p(e.WARNUNG), _p(e.WARNUNG)]) is True
# ── Die einzelnen Pruefungen ────────────────────────────────────────────
def test_windows_10_und_11_gehen():
assert e.pruefe_windows(22631)["zustand"] == e.OK
assert e.pruefe_windows(19045)["zustand"] == e.OK
def test_zu_altes_windows_ist_ein_fehler():
befund = e.pruefe_windows(7601) # Windows 7
assert befund["zustand"] == e.FEHLER
assert befund["abhilfe"], "ein Befund ohne Abhilfe laesst den Nutzer ratlos"
def test_fehlendes_webview_ist_nur_eine_warnung():
"""Ohne WebView2 faellt Rippy auf den Browser zurueck. Das ist weniger
schoen, aber es LAEUFT ein Fehler waere hier eine Luege."""
befund = e.pruefe_webview(version="")
assert befund["zustand"] == e.WARNUNG
assert "Browser" in befund["abhilfe"]
def test_vorhandenes_webview_ist_ok():
assert e.pruefe_webview(version="151.0.4129.107")["zustand"] == e.OK
def test_zu_wenig_platz_ist_ein_fehler():
befund = e.pruefe_platz("C:\\egal", frei_mb=100)
assert befund["zustand"] == e.FEHLER
assert "500" in befund["text"]
def test_genug_platz_ist_ok():
assert e.pruefe_platz("C:\\egal", frei_mb=5000)["zustand"] == e.OK
def test_fehlendes_schreibrecht_ist_ein_fehler():
"""`os.access(..., W_OK)` luegt unter Windows — es beantwortet die Frage
anhand des Nur-Lesen-Flags, nicht anhand der Zugriffsrechte. Deshalb wird
wirklich geschrieben."""
befund = e.pruefe_schreibrecht("C:\\Programme\\Rippy",
probe=(False, "Zugriff verweigert"))
assert befund["zustand"] == e.FEHLER
assert "Benutzer" in befund["abhilfe"]
def test_schreibprobe_am_echten_ordner(tmp_path):
befund = e.pruefe_schreibrecht(str(tmp_path))
assert befund["zustand"] == e.OK
# Und die Probe darf nichts hinterlassen.
assert list(tmp_path.iterdir()) == []
# ── Administrator und Netzlaufwerke ─────────────────────────────────────
def test_normal_gestartet_ist_der_richtige_zustand():
"""Rippy braucht KEINE Administratorrechte — Vorgabeordner unter
%LOCALAPPDATA%, Autostart unter HKCU."""
befund = e.pruefe_administrator(erhoeht=False)
assert befund["zustand"] == e.OK
def test_als_administrator_wird_vor_den_fehlenden_netzlaufwerken_gewarnt():
"""DER Befund des Commanders (28.08.2026): „Wenn man das Tool als Admin
startet, kann man im explorer kein netzlaufwerk waehlen."
Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene
Netzlaufwerke haengen am Token der Sitzung. Windows-Verhalten, kein
Rippy-Fehler aber Rippy hat ihn hineinlaufen lassen."""
befund = e.pruefe_administrator(erhoeht=True, laufwerke=["X:", "Y:", "Z:"])
assert befund["zustand"] == e.WARNUNG
assert "X:" in befund["abhilfe"]
assert "UNC" in befund["abhilfe"] or "Server" in befund["abhilfe"]
def test_fehlendes_laufwerk_wird_beim_namen_genannt():
"""„Schreibrechte fehlen" war richtig im Wortlaut und unbrauchbar als
Auskunft: Das Laufwerk war fuer den erhoehten Prozess gar nicht da."""
# `laufwerk_da=False` eingespritzt: Die Frage "existiert Q:" laesst sich
# auf dem Linux-Runner der Ampel gar nicht beantworten. Ohne den
# Einspritzpunkt waere dieser Zweig nur auf einem Windows-Rechner mit
# genau diesem fehlenden Laufwerk pruefbar -- also nirgends.
befund = e.pruefe_schreibrecht(
"Q:" + chr(92) + "Rippy", probe=(False, "Pfad nicht gefunden"),
erhoeht=True, laufwerk_da=False)
assert befund["zustand"] == e.FEHLER
assert "Q:" in befund["text"]
assert "nicht vorhanden" in befund["text"]
assert "Administrator" in befund["abhilfe"]
def test_programme_ordner_erklaert_den_teufelskreis():
"""Program Files verlangt Administrator, Administrator versteckt die
Netzlaufwerke und Rippy braucht beides nicht."""
befund = e.pruefe_schreibrecht(
"C:" + chr(92) + "Program Files (x86)" + chr(92) + "Rippy",
probe=(False, "Zugriff verweigert"))
assert befund["zustand"] == e.FEHLER
assert "Administratorrechte" in befund["abhilfe"]
assert "Vorgabeordner" in befund["abhilfe"]
def test_programme_wird_erkannt():
B = chr(92)
assert e._unter_programme("C:" + B + "Program Files" + B + "Rippy") is True
assert e._unter_programme("C:" + B + "Program Files (x86)" + B + "R") is True
assert e._unter_programme("C:" + B + "Users" + B + "X" + B + "Rippy") is False
def test_belegter_port_ist_nur_eine_warnung():
"""Sehr oft ist es ein bereits laufendes Rippy — und dann ist eine
Neuinstallation genau richtig."""
befund = e.pruefe_port(7788, belegt=True)
assert befund["zustand"] == e.WARNUNG
assert "Rippy" in befund["abhilfe"]
def test_freier_port_ist_ok():
assert e.pruefe_port(7788, belegt=False)["zustand"] == e.OK
def test_kein_laufwerk_ist_nur_eine_warnung():
"""install.sh sagt seit Langem: ohne Laufwerk „laeuft trotzdem durch, dann
ist das eine reine KOMPRIMIER-Maschine"."""
befund = e.pruefe_laufwerk(laufwerke=[])
assert befund["zustand"] == e.WARNUNG
assert "komprimieren" in befund["abhilfe"]
def test_gefundenes_laufwerk_wird_genannt():
befund = e.pruefe_laufwerk(laufwerke=["\\\\.\\G:"])
assert befund["zustand"] == e.OK
assert "G:" in befund["text"]
# ── Die Werkzeuge ───────────────────────────────────────────────────────
def _bestand(makemkv, handbrake):
return {
"makemkv": {"titel": "MakeMKV", "da": makemkv, "version": "1.18.4",
"pflicht": True},
"handbrake": {"titel": "HandBrakeCLI", "da": handbrake,
"version": "1.11.2", "pflicht": False},
}
def test_fehlendes_makemkv_ist_eine_warnung_mit_ausweg():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(False, True))}
assert befunde["MakeMKV"]["zustand"] == e.WARNUNG
assert "makemkv.com" in befunde["MakeMKV"]["abhilfe"]
def test_fehlendes_handbrake_ist_kein_problem():
"""Es liegt im Paket — das Setup legt es beim Installieren daneben."""
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, False))}
assert befunde["HandBrakeCLI"]["zustand"] == e.OK
assert "mitgeliefert" in befunde["HandBrakeCLI"]["text"]
def test_vorhandene_werkzeuge_zeigen_ihre_version():
befunde = {b["name"]: b for b in e.pruefe_werkzeuge(_bestand(True, True))}
assert befunde["MakeMKV"]["text"] == "1.18.4"
# ── Die Zusammenfassung ─────────────────────────────────────────────────
def test_zusammenfassung_nennt_die_fehler_beim_namen():
text = e.zusammenfassung([_p(e.FEHLER, "Speicherplatz"), _p(e.OK, "Port")])
assert "nicht möglich" in text
assert "Speicherplatz" in text
def test_zusammenfassung_nennt_die_hinweise():
text = e.zusammenfassung([_p(e.WARNUNG, "MakeMKV"), _p(e.OK, "Port")])
assert "MakeMKV" in text
assert "nicht möglich" not in text
def test_alles_gruen_sagt_es_auch_so():
assert e.zusammenfassung([_p(e.OK), _p(e.OK)]) == "Alles bereit."
def test_jede_pruefung_hat_dieselbe_form():
"""Ein fehlendes Feld waere in der Oberflaeche ein `undefined`."""
for befund in ([e.pruefe_windows(22631), e.pruefe_webview(version=""),
e.pruefe_administrator(erhoeht=True, laufwerke=["X:"]),
e.pruefe_administrator(erhoeht=False),
e.pruefe_laufwerk(laufwerke=[]),
e.pruefe_platz("C:\\x", frei_mb=9999),
e.pruefe_port(7788, belegt=False)]
+ e.pruefe_werkzeuge(_bestand(False, False))):
assert set(befund) == {"name", "zustand", "text", "abhilfe"}, befund
assert befund["zustand"] in (e.OK, e.WARNUNG, e.FEHLER)
if befund["zustand"] != e.OK:
assert befund["abhilfe"], (
"%s sagt nicht, was zu tun ist" % befund["name"])
# ── Die Vorgaben ────────────────────────────────────────────────────────
def test_vorgaben_sind_vollstaendig():
if os.name != "nt":
import pytest
pytest.skip("Registry-Vorgaben gibt es nur unter Windows")
v = e.vorgaben()
assert set(v) == {"ziel", "ablage", "arbeit", "port", "autostart",
"verknuepfungen", "werkzeuge_holen"}
assert v["port"] == 7788
assert v["ziel"] and v["ablage"] and v["arbeit"]
for feld in ("ablage", "arbeit"):
assert "/app/" not in v[feld], "Container-Pfad in einer Windows-Vorgabe"
def test_netzwerkpfad_als_administrator_wird_erklaert():
"""Der zweite Teil des Befunds: Auch Netz-ANMELDUNGEN haengen am Token
der Sitzung. Ein erhoehter Prozess kennt die Zugangsdaten zur Freigabe
nicht und bekommt 'Zugriff verweigert' -- dieselbe Meldung wie bei einem
echten Rechteproblem."""
B = chr(92)
befund = e.pruefe_schreibrecht(
B + B + "NAS" + B + "Filme", probe=(False, "Zugriff verweigert"),
erhoeht=True)
assert befund["zustand"] == e.FEHLER
assert "ohne Administrator" in befund["abhilfe"]
def test_netzwerkpfad_ohne_administrator_bekommt_die_normale_meldung():
B = chr(92)
befund = e.pruefe_schreibrecht(
B + B + "NAS" + B + "Filme", probe=(False, "Netzwerkpfad nicht gefunden"),
erhoeht=False)
assert befund["zustand"] == e.FEHLER
assert "Administrator" not in befund["abhilfe"] or "keine" in befund["abhilfe"]
def test_ein_vorhandenes_laufwerk_ist_kein_laufwerks_problem():
"""Sonst waere JEDER Schreibfehler auf C: ein 'Laufwerk fehlt'."""
B = chr(92)
assert e.laufwerk_fehlt("C:" + B + "Programme", vorhanden=True) is False
assert e.laufwerk_fehlt("Q:" + B + "Rippy", vorhanden=False) is True
def test_unc_und_posix_sind_keine_laufwerksbuchstaben():
B = chr(92)
assert e.laufwerk_fehlt(B + B + "NAS" + B + "Filme") is False
assert e.laufwerk_fehlt("/srv/rippy") is False
-198
View File
@@ -1,198 +0,0 @@
"""Das native Fenster: Verfuegbarkeit, Rueckfall, Speicherort.
Ein Fenster laesst sich in einem Test nicht oeffnen es wuerde auf einen
Doppelklick warten, den niemand macht. Geprueft wird deshalb alles DAVOR und
DANEBEN: die Entscheidung, ob ein Fenster moeglich ist, und der Rueckfall auf
den Browser. Genau dort sassen die Fehler, die dieses Projekt schon zweimal
gekostet hat.
"""
import os
import pytest
from rippy import fenster
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Der Registry-Wert ───────────────────────────────────────────────────
def test_null_version_gilt_als_nicht_vorhanden():
"""DER Fallstrick bei WebView2: EdgeUpdate LOESCHT den Schluessel bei
einer Deinstallation nicht, sondern setzt `pv` auf `0.0.0.0`. Wer nur
auf die Existenz des Schluessels prueft, oeffnet ein Fenster, das nichts
anzeigen kann und der Nutzer sieht einen weissen Kasten."""
assert fenster.echte_version("0.0.0.0") == ""
def test_echte_version_kommt_durch():
assert fenster.echte_version("151.0.4129.107") == "151.0.4129.107"
@pytest.mark.parametrize("wert", ["", None, " "])
def test_leere_werte_gelten_nicht(wert):
assert fenster.echte_version(wert) == ""
# ── Die Entscheidung ────────────────────────────────────────────────────
def test_verfuegbar_gibt_immer_eine_begruendung():
"""Auch beim Ja. Ein blosses False zwingt den Naechsten zum Nachforschen
und ein Fehlschlag ohne Begruendung ist in diesem Projekt schon
zweimal teuer geworden."""
geht, grund = fenster.verfuegbar()
assert isinstance(geht, bool)
assert grund, "auch ein Ja braucht eine Begruendung"
@pytest.mark.skipif(os.name == "nt", reason="prueft das Verhalten auf Nicht-Windows")
def test_ohne_windows_kein_fenster():
geht, grund = fenster.verfuegbar()
assert geht is False
assert "Windows" in grund
@pytest.mark.skipif(os.name == "nt", reason="winreg gibt es nur unter Windows")
def test_version_ohne_windows_ist_leer_statt_zu_werfen():
"""Der Kern laeuft auch unter Linux (Docker, headless). Ein ImportError
auf winreg wuerde dort jeden Aufruf sprengen."""
assert fenster.webview2_version() == ""
def test_fehlende_bibliothek_verhindert_das_fenster(monkeypatch):
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: False)
geht, grund = fenster.verfuegbar()
assert geht is False
assert "pywebview" in grund
def test_fehlende_laufzeit_verhindert_das_fenster(monkeypatch):
"""Lieber Browser als ein leeres Fenster: Ohne WebView2 faellt pywebview
auf MSHTML zurueck, und der stellt die React-Oberflaeche nicht dar."""
monkeypatch.setattr(os, "name", "nt")
monkeypatch.setattr(fenster, "bibliothek_vorhanden", lambda: True)
monkeypatch.setattr(fenster, "webview2_version", lambda: "")
geht, grund = fenster.verfuegbar()
assert geht is False
assert "WebView2" in grund
# ── Der Rueckfall ───────────────────────────────────────────────────────
def test_ohne_fenster_geht_der_browser_auf(monkeypatch):
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows"))
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser"
assert gerufen == ["http://127.0.0.1:7788"]
def test_geplatztes_fenster_faellt_auf_den_browser_zurueck(monkeypatch, capsys):
"""Der wichtigste Test hier. Was WinForms und die CLR beim Start werfen
koennen, ist nicht abschliessend bekannt. Faellt das nicht zurueck, ist
der Doppelklick auf das Desktop-Symbol wirkungslos und der Nutzer
haelt Rippy fuer kaputt."""
def platzt(*a, **k):
raise RuntimeError("edgechromium nicht gefunden")
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151"))
monkeypatch.setattr(fenster, "oeffnen", platzt)
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "browser"
assert gerufen == ["http://127.0.0.1:7788"]
# Und der Grund muss SICHTBAR sein, nicht verschluckt.
assert "edgechromium nicht gefunden" in capsys.readouterr().out
def test_wenn_gar_nichts_geht_wird_das_auch_gesagt(monkeypatch):
"""`webbrowser.open` gibt False zurueck, wenn kein Browser gefunden wird.
Ein `return "browser"` waere dann eine Luege."""
monkeypatch.setattr(fenster, "verfuegbar", lambda: (False, "kein Windows"))
monkeypatch.setattr("webbrowser.open", lambda a: False)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "nichts"
def test_erfolgreiches_fenster_oeffnet_keinen_browser(monkeypatch):
"""Sonst haette der Nutzer BEIDES auf — Fenster und Tab."""
gerufen = []
monkeypatch.setattr(fenster, "verfuegbar", lambda: (True, "WebView2 151"))
monkeypatch.setattr(fenster, "oeffnen", lambda *a, **k: None)
monkeypatch.setattr("webbrowser.open", lambda a: gerufen.append(a) or True)
assert fenster.oeffnen_oder_browser("http://127.0.0.1:7788") == "fenster"
assert gerufen == []
# ── Die WebView2-Helfer ─────────────────────────────────────────────────
def test_helfer_werden_ueber_den_speicherort_gefiltert():
"""DER Befund vom 28.08.2026: Nach dem Deinstallieren blieben 148 Dateien
liegen, alle unter `fenster\\EBWebView\\`. WebView2 startet eigene
Prozesse (Renderer, GPU, Netz, Crashpad); stirbt Rippy, laufen sie als
Waisen weiter und halten den Zwischenspeicher offen."""
s = fenster.helfer_skript(r"C:\Users\X\AppData\Local\Rippy\fenster")
assert "msedgewebview2.exe" in s
assert r"C:\Users\X\AppData\Local\Rippy\fenster" in s
assert "Stop-Process" in s
def test_der_filter_trifft_NUR_unsere_helfer():
"""`msedgewebview2.exe` benutzen auch andere Programme. Sie alle zu
beenden waere ein Uebergriff der Nutzer verloere die Fenster fremder
Anwendungen. Ohne `Where-Object` auf den Speicherort passiert genau das."""
s = fenster.helfer_skript(r"C:\Rippy\fenster")
assert "Where-Object" in s, "ohne Filter wuerden FREMDE Fenster sterben"
assert s.index("Where-Object") < s.index("Stop-Process"), \
"erst filtern, dann beenden"
def test_apostroph_im_pfad_bricht_das_skript_nicht():
"""In PowerShell wird ein Apostroph durch VERDOPPELN maskiert. Ohne das
bricht ein Pfad wie C:\\Users\\O'Brien\\… das Skript mitten entzwei."""
s = fenster.helfer_skript(r"C:\Users\O'Brien\Rippy\fenster")
assert "O''Brien" in s
assert s.count("'") % 2 == 0
def test_ohne_windows_werden_keine_helfer_beendet():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert fenster.helfer_beenden("/tmp/x") is False
def test_leerer_speicherort_beendet_nichts(monkeypatch):
"""Ein leerer Filter wuerde auf ALLE msedgewebview2-Prozesse passen."""
monkeypatch.setattr(fenster, "speicherort_lesen", lambda: "")
assert fenster.helfer_beenden("") is False
# ── Der Speicherort ─────────────────────────────────────────────────────
def test_speicherort_liegt_bei_rippy(monkeypatch, tmp_path):
"""Im TEMP waere er bei jedem Aufraeumen weg — und beim Deinstallieren
bliebe er liegen."""
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
ordner = fenster.speicherort()
assert ordner.startswith(str(tmp_path))
assert os.path.isdir(ordner)
def test_unbeschreibbarer_ort_wirft_nicht(monkeypatch):
"""Ein fehlender Zwischenspeicher ist aergerlich, kein Grund, das
Fenster gar nicht erst zu oeffnen."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(os, "makedirs", geht_nicht)
assert fenster.speicherort() == ""
# ── Auf dieser Maschine wirklich nachsehen ──────────────────────────────
@nur_windows
def test_webview2_ist_auf_diesem_rechner_da():
"""Kein Muss-Test, sondern ein Messpunkt: Faellt er aus, laeuft Rippy im
Browser statt im Fenster und man weiss auch gleich, warum."""
version = fenster.webview2_version()
if not version:
pytest.skip("Keine WebView2-Laufzeit auf dieser Maschine")
assert version.count(".") >= 2, version
-137
View File
@@ -1,137 +0,0 @@
"""Die ganze Kette im Standalone-Betrieb — von der Zustellung bis zum Job-Ende.
## Was hier bewiesen wird
celery_client.abschicken(...) die API stellt zu
-> standalone.zustellung uebersetzt in einen Auftrag
-> lokal.einreihen Auftragstabelle mit Lease
-> Laeufer.einmal() holt ihn und arbeitet ihn ab
-> ablauf.rippen() der echte Ablauf, ohne Celery
-> db.update_job() der Job endet in einem ehrlichen Zustand
Das ist der Weg, der unter Windows bis V2-4 gar nicht existierte: Dort haette
die API einen Rip eingereiht, und er waere fuer immer liegengeblieben, weil
niemand ihn holt.
## Warum ohne Laufwerk getestet wird
Es gibt hier keins (es haengt an der VM). Das ist kein Mangel, sondern der
wichtigere Fall: Ein Rip auf ein Geraet, das nicht antwortet, muss **ehrlich
scheitern** mit einem Zustand und einer Begruendung, die im UI ankommen.
Was NICHT passieren darf: dass der Job auf pending" stehenbleibt und
niemand erfaehrt, warum nichts geschieht.
"""
import time
import pytest
from rippy import standalone, store
from rippy.queue import laeufer as laeufer_modul
from rippy.queue import lokal
@pytest.fixture
def umgebung(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 'kette.db').as_posix()}")
store.init_db()
yield store
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
class FakeBus:
def __init__(self):
self.gesendet = []
def senden(self, typ, daten=None, entitaet=None, entitaet_id=None):
self.gesendet.append((typ, entitaet_id, daten or {}))
def test_die_ganze_kette_bis_zum_ehrlichen_fehlschlag(umgebung):
"""Ein Rip auf ein Geraet, das es nicht gibt.
Erwartet wird KEIN Erfolg erwartet wird, dass der Job in einem
ENDZUSTAND landet und die Begruendung im Log steht. Ein Job, der auf
pending" haengenbleibt, waere der schlimmere Ausgang: Der Nutzer saehe
einen Balken, der sich nie bewegt, und nirgends stuende warum.
"""
umgebung.insert_job("job-kette", "/dev/gibt-es-nicht", disc_type=None)
bus = FakeBus()
zustellen = standalone.zustellung_bauen(bus)
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-kette", None])
assert len(lokal.offene_auftraege()) == 1, "Der Auftrag wurde nicht eingereiht"
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
kann={"art=rip"}, bus=bus, store=umgebung)
assert lauf.einmal() is True, "Der Laeufer hat den Auftrag nicht geholt"
job = umgebung.get_job("job-kette")
assert job["status"] in ("failed", "completed"), (
f"Der Job steht auf {job['status']!r} — er muss in einem Endzustand "
"landen, sonst wartet der Nutzer auf etwas, das nie passiert.")
assert job["status"] == "failed"
assert job["error"], "Ein Fehlschlag ohne Begruendung ist im UI wertlos"
meldungen = " ".join(z["message"] for z in umgebung.list_logs(limit=50))
assert "job-kette" in meldungen, "Im Log steht nichts ueber diesen Job"
assert lokal.offene_auftraege() == [], "Der Auftrag blieb in der Queue liegen"
def test_der_laeufer_bleibt_danach_arbeitsfaehig(umgebung):
"""Nach einem Fehlschlag muss der naechste Auftrag trotzdem laufen —
sonst legt ein einziger kaputter Rip die ganze Installation lahm."""
umgebung.insert_job("job-a", "/dev/gibt-es-nicht")
umgebung.insert_job("job-b", "/dev/gibt-es-auch-nicht")
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-nicht", "job-a", None])
zustellen("worker.tasks.rip_disc", ["/dev/gibt-es-auch-nicht", "job-b", None])
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "test-knoten",
kann={"art=rip"}, store=umgebung)
assert lauf.einmal() is True
assert lauf.einmal() is True
assert umgebung.get_job("job-a")["status"] == "failed"
assert umgebung.get_job("job-b")["status"] == "failed"
def test_die_api_stellt_ueber_dieselbe_stelle_zu(umgebung, monkeypatch):
"""`celery_client.start_rip` ist der Weg, den POST /jobs geht. Er MUSS
im Standalone-Betrieb in der lokalen Queue landen sonst reiht die API
an einen Broker ein, den es nicht gibt, und der Job verschwindet."""
import sys
from rippy.daemon import _api_pfad
if _api_pfad() not in sys.path:
sys.path.insert(0, _api_pfad())
import celery_client
umgebung.insert_job("job-api", "/dev/sr0")
monkeypatch.setattr(celery_client, "_zusteller", standalone.zustellung_bauen())
celery_client.start_rip("/dev/sr0", "job-api", "/app/media/x")
offen = lokal.offene_auftraege()
assert len(offen) == 1
assert offen[0]["job_id"] == "job-api"
assert offen[0]["art"] == "rip"
def test_ein_rip_haengt_nicht_ewig(umgebung):
"""Zeitgrenze als Zusage: Ein Auftrag auf ein totes Geraet muss ZUEGIG
scheitern. Haengt er, merkt es im Betrieb niemand der Job steht auf
laeuft", und der Laeufer nimmt keinen weiteren an."""
umgebung.insert_job("job-zeit", "/dev/nichts")
standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/nichts", "job-zeit", None])
lauf = laeufer_modul.Laeufer(standalone.ausfuehren, "k", kann={"art=rip"},
store=umgebung)
start = time.monotonic()
lauf.einmal()
dauer = time.monotonic() - start
assert dauer < 30, f"Der Fehlschlag brauchte {dauer:.1f}s — das ist zu lang"
+33
View File
@@ -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) == ""
-379
View File
@@ -1,379 +0,0 @@
"""Der Einrichtungs-Assistent: Bruecke und Seite, ohne ein Fenster zu oeffnen.
Ein Fenster laesst sich im Test nicht bedienen. Geprueft wird deshalb das,
was DAHINTER haengt: was die Seite abfragen darf, was beim Installieren
passiert, und dass ein Fehlschlag sichtbar wird statt zu verschwinden.
Hinweis zu den Backslashes: Windows-Pfade stehen hier in ROHEN Zeichenketten
(`r"..."`). In einer normalen waere `C:\\Rippy` die ungueltige Escape-Sequenz
Backslash-R.
"""
from rippy import setup_fenster
class LeeresFenster:
def __init__(self, antwort=None):
self.antwort = antwort
self.zerstoert = False
def create_file_dialog(self, *a, **k):
if self.antwort is None:
raise RuntimeError("kein Dialog")
return self.antwort
def destroy(self):
self.zerstoert = True
# ── Die Seite ───────────────────────────────────────────────────────────
def test_die_seite_fragt_nach_dem_ablageort():
"""Die ausdrueckliche Frage des Commanders: „wo will ich das
hinspeichern"."""
assert setup_fenster.seite_enthaelt('id="ablage"')
assert setup_fenster.seite_enthaelt("Fertige Filme")
def test_die_seite_zeigt_die_voraussetzungen():
"""„nen pre requirement check usw." — vorher kam da gar nichts."""
assert setup_fenster.seite_enthaelt("Voraussetzungen")
assert setup_fenster.seite_enthaelt("pywebview.api.pruefen")
def test_die_seite_laedt_nichts_aus_dem_netz():
"""Der Assistent muss auf einem Rechner OHNE Internet aufgehen — dort
wird er am dringendsten gebraucht."""
for muster in ("http://", "https://", "<script src", "<link "):
assert not setup_fenster.seite_enthaelt(muster), muster
def test_die_seite_kann_den_ordner_waehlen_lassen():
assert setup_fenster.seite_enthaelt("pywebview.api.ordner_waehlen")
# ── Die Bruecke ─────────────────────────────────────────────────────────
def test_abgebrochener_ordner_dialog_gibt_leer_zurueck():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=None)
assert b.ordner_waehlen("C:") == ""
def test_gewaehlter_ordner_kommt_als_text():
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster(antwort=(r"D:\Filme",))
assert b.ordner_waehlen() == r"D:\Filme"
def test_fortschritt_leert_sich_beim_abholen():
"""Sonst waechst das Protokoll bei jedem Abruf um alles Bisherige."""
b = setup_fenster.Bruecke()
b._lauf.melde("eins")
b._lauf.melde("zwei")
assert b.fortschritt()["zeilen"] == ["eins", "zwei"]
assert b.fortschritt()["zeilen"] == []
def test_stand_ist_leer_solange_nichts_fertig_ist():
assert setup_fenster.Bruecke().stand() == {}
def test_ein_fehlschlag_beim_installieren_wird_SICHTBAR(monkeypatch):
"""Ohne Konsole waere ein Absturz hier voellig unsichtbar — der Nutzer
saehe ein Fenster, in dem nichts mehr passiert."""
from rippy import windows_app
def platzt(**k):
raise OSError("Zugriff verweigert")
monkeypatch.setattr(windows_app, "installieren", platzt)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\x", "werkzeuge_holen": False})
assert b.ergebnis["ok"] is False
assert "Zugriff verweigert" in b.ergebnis["fehler"]
assert any("Fehlgeschlagen" in z for z in b._lauf.zeilen)
def test_erfolgreiche_installation_meldet_den_ordner(monkeypatch):
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ziel": r"C:\Rippy", "werkzeuge_holen": False})
assert b.ergebnis == {"ok": True, "ziel": r"C:\Rippy", "bereit": True}
def test_werkzeuge_koennen_uebersprungen_werden(monkeypatch):
"""Sonst wartete eine unbeaufsichtigte Installation auf einen Download."""
from rippy import windows_app
from rippy.tools import einrichten
def haette_nicht(**k):
raise AssertionError("haette nicht laufen duerfen")
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
monkeypatch.setattr(einrichten, "sicherstellen", haette_nicht)
b = setup_fenster.Bruecke()
b._installieren_wirklich({"werkzeuge_holen": False})
assert b.ergebnis["ok"] is True
def test_schliessen_ohne_fenster_wirft_nicht():
setup_fenster.Bruecke().schliessen()
# -- Der Fortschrittsbalken ---------------------------------------------
def test_der_balken_faengt_bei_null_an():
f = setup_fenster.Fortschritt()
assert f.abholen() == {"zeilen": [], "prozent": 0, "abschnitt": ""}
def test_ein_abschnitt_setzt_den_anfang():
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
stand = f.abholen()
assert stand["prozent"] == 10
assert "Werkzeuge" in stand["abschnitt"]
def test_der_anteil_wird_in_den_abschnitt_gerechnet():
"""Werkzeuge belegen 10 bis 97 Prozent. Halb fertig heisst also rund 54."""
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("laeuft", 0.5)
assert 50 <= f.prozent <= 58, f.prozent
def test_der_balken_laeuft_NIE_zurueck():
"""Der Werkzeug-Abschnitt kann mehrere Downloads enthalten, jeder mit
eigenem 0-bis-1. Ohne diese Regel spraenge der Balken bei jedem neuen
Werkzeug zurueck an den Abschnittsanfang."""
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("erstes Werkzeug fertig", 0.9)
hoch = f.prozent
f.melde("zweites Werkzeug faengt an", 0.0)
assert f.prozent == hoch, "der Balken ist zurueckgesprungen"
def test_eine_meldung_ohne_zahl_bewegt_nichts():
"""Eine Textmeldung ist keine Aussage ueber den Fortschritt."""
f = setup_fenster.Fortschritt()
f.beginne("dateien")
f.melde("irgendwas passiert")
assert f.prozent == 0
def test_zeilen_leeren_sich_beim_abholen_der_stand_nicht():
f = setup_fenster.Fortschritt()
f.beginne("werkzeuge")
f.melde("eins", 0.5)
erst = f.abholen()
zweit = f.abholen()
assert erst["zeilen"] == ["eins"]
assert zweit["zeilen"] == []
assert zweit["prozent"] == erst["prozent"], "der Stand darf nicht vergessen werden"
def test_ein_verrutschter_anteil_sprengt_nichts():
f = setup_fenster.Fortschritt()
f.beginne("dateien")
f.melde("x", 5.0)
assert f.prozent <= 100
f.melde("y", -1.0)
assert f.prozent <= 100
def test_die_abschnitte_ueberlappen_nicht_und_enden_bei_hundert():
grenzen = sorted(setup_fenster.ABSCHNITTE.values())
assert grenzen[0][0] == 0.0
assert grenzen[-1][1] == 1.0
for (_, ende_a, _), (von_b, _, _) in zip(grenzen, grenzen[1:]):
assert abs(ende_a - von_b) < 1e-9, (ende_a, von_b)
def test_die_seite_zeigt_einen_balken():
assert setup_fenster.seite_enthaelt('id="balken"')
assert setup_fenster.seite_enthaelt("prozent")
# -- Administratorrechte auf Abruf --------------------------------------
def test_der_admin_knopf_ist_da_aber_nicht_vorgewaehlt():
"""Commander: "lass das setup sonst immer mit erhoehten rechten starten".
Immer erhoeht waere genau der Fehler, den er selbst gemeldet hat: Ein
erhoehter Prozess sieht die eingebundenen Netzlaufwerke NICHT (auf diesem
Rechner gemessen: X:, Y:, Z: ohne Erhoehung sichtbar,
EnableLinkedConnections nicht gesetzt). Deshalb ein Knopf statt einer
Voreinstellung -- und der ist anfangs verborgen."""
assert setup_fenster.seite_enthaelt('id="knopf-admin"')
assert setup_fenster.seite_enthaelt('class="weg"')
assert setup_fenster.seite_enthaelt("pywebview.api.als_admin")
def test_der_admin_knopf_erscheint_nur_beim_rechteproblem():
"""Sonst laedt er dazu ein, sich die Netzlaufwerke wegzunehmen."""
assert setup_fenster.seite_enthaelt("b.name === 'Schreibrecht'")
assert setup_fenster.seite_enthaelt("b.zustand === 'fehler'")
def test_ein_abgebrochener_uac_dialog_schliesst_das_fenster_nicht(monkeypatch):
"""Wer die UAC-Abfrage wegklickt, will weitermachen -- nicht von vorn
anfangen."""
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: False)
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster()
assert b.als_admin() == {"gestartet": False}
assert b._fenster.zerstoert is False
def test_ein_angenommener_neustart_schliesst_das_fenster(monkeypatch):
from rippy.platform import winlauf
monkeypatch.setattr(winlauf, "als_admin_neu_starten", lambda *a, **k: True)
b = setup_fenster.Bruecke()
b._fenster = LeeresFenster()
assert b.als_admin() == {"gestartet": True}
assert b._fenster.zerstoert is True
# -- Updates im Setup ----------------------------------------------------
def test_die_seite_bietet_updates_an():
"""Commander: "bitte baue ein, das wenn MakeMKV oder Handbrake schon
installiert sind ein update verfuegbar ist. Das kann ja theoretisch direkt
im setup abgehandelt werden.\""""
assert setup_fenster.seite_enthaelt('id="aktualisieren"')
assert setup_fenster.seite_enthaelt("pywebview.api.updates")
assert setup_fenster.seite_enthaelt("aktualisieren: $('aktualisieren').checked")
def test_nicht_erreichbare_quellen_schalten_das_update_AB():
"""Eine Nichtauskunft ist keine Aussage -- und ein Haken, der nichts tun
kann, ist eine Falle."""
assert setup_fenster.seite_enthaelt("stand.erreichbar")
assert setup_fenster.seite_enthaelt("$('aktualisieren').checked = false")
def test_updates_ohne_netz_melden_nicht_erreichbar(monkeypatch):
from rippy.tools import beschaffen
def platzt():
raise OSError("kein Netz")
monkeypatch.setattr(beschaffen, "neueste_versionen", platzt)
stand = setup_fenster.Bruecke().updates()
assert stand == {"erreichbar": False, "offen": []}
def test_updates_melden_was_offen_ist(monkeypatch):
from rippy.tools import beschaffen, katalog
monkeypatch.setattr(katalog, "bestand", lambda *a, **k: {
"makemkv": {"titel": "MakeMKV", "da": True, "version": "1.18.2",
"pflicht": True},
"handbrake": {"titel": "HandBrakeCLI", "da": True, "version": "1.11.2",
"pflicht": False}})
monkeypatch.setattr(beschaffen, "neueste_versionen",
lambda: {"makemkv": "1.18.4", "handbrake": "1.11.2"})
stand = setup_fenster.Bruecke().updates()
assert stand["erreichbar"] is True
assert [o["titel"] for o in stand["offen"]] == ["MakeMKV"]
assert stand["offen"][0]["installiert"] == "1.18.2"
assert stand["offen"][0]["neueste"] == "1.18.4"
# ── Der Arbeitsordner im Setup (Commander 28.08.2026) ───────────────────
#
# > „kannst du noch einbauen das man den arbeitsordner usw. in den
# > Einstellungen setzen kann und direkt im setup?"
#
# Warum das nicht Kosmetik ist: Der Roh-Rip einer 4K-UHD ist bis zu 100 GB
# gross. Wer erst NACH der ersten vollen Platte erfaehrt, wo die liegen, hat
# es zu spaet erfahren — am 25.07.2026 lief genau so die VM-Platte voll.
def test_die_seite_fragt_nach_dem_arbeitsordner():
assert setup_fenster.seite_enthaelt('id="arbeit"')
assert setup_fenster.seite_enthaelt("Arbeitsordner für die Rohdaten")
def test_der_arbeitsordner_hat_einen_durchsuchen_knopf():
"""Einen Pfad abzutippen ist die Sorte Bedienung, die niemand macht."""
assert setup_fenster.seite_enthaelt("waehlen('arbeit')")
def test_jede_vorgabe_hat_ein_feld_UND_wird_mitgeschickt():
"""Der Waechter gegen halb angeschlossene Felder.
Ein Feld kann auf drei Arten kaputt sein, und alle drei sehen im Fenster
gleich aus: kein Eingabefeld, keine Vorbelegung, oder es wird beim
Installieren nicht mitgeschickt. Der dritte Fall ist der gemeinste man
waehlt etwas, es passiert nichts, und niemand sagt warum.
"""
from rippy import einrichtung
# Schalter heissen im HTML anders (checkbox) und werden separat geprueft.
pfade_und_zahlen = {"ziel", "ablage", "arbeit", "port"}
for name in pfade_und_zahlen:
assert name in einrichtung.vorgaben(), "%s fehlt in vorgaben()" % name
assert setup_fenster.seite_enthaelt('id="%s"' % name), \
"kein Eingabefeld fuer %s" % name
assert setup_fenster.seite_enthaelt("$('%s').value = v.%s" % (name, name)), \
"%s wird nicht vorbelegt" % name
assert setup_fenster.seite_enthaelt("%s: " % name), \
"%s wird beim Installieren nicht mitgeschickt" % name
def test_der_arbeitsordner_landet_als_workDir_in_den_einstellungen(monkeypatch):
"""Der Name muss stimmen: Die Oberflaeche und der Worker lesen `workDir`."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
class StilleWerkzeuge:
@staticmethod
def sicherstellen(**k):
return {"bereit": True}
@staticmethod
def bericht(b):
return "ok"
import sys
import types
modul = types.ModuleType("rippy.tools.einrichten")
modul.sicherstellen = StilleWerkzeuge.sicherstellen
modul.bericht = StilleWerkzeuge.bericht
monkeypatch.setitem(sys.modules, "rippy.tools.einrichten", modul)
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": r"D:\Filme", "arbeit": r"E:\Roh",
"werkzeuge_holen": True})
assert geschrieben.get("workDir") == r"E:\Roh"
assert geschrieben.get("outputDir") == r"D:\Filme"
assert b.ergebnis["ok"] is True
def test_ohne_angabe_wird_NICHTS_geschrieben(monkeypatch):
"""Leer heisst „nimm die Vorgabe" — dann darf kein leerer Pfad in die
Einstellungen wandern und die Berechnung ueberschreiben."""
geschrieben = {}
monkeypatch.setattr(setup_fenster, "_einstellungen_schreiben",
lambda neu: geschrieben.update(neu))
monkeypatch.setattr(setup_fenster, "_beta_key_holen", lambda *a: "")
from rippy import windows_app
monkeypatch.setattr(windows_app, "installieren", lambda **k: r"C:\Rippy")
b = setup_fenster.Bruecke()
b._installieren_wirklich({"ablage": "", "arbeit": "", "werkzeuge_holen": False})
assert geschrieben == {}
-100
View File
@@ -1,100 +0,0 @@
"""Die lokale Zustellung: kommt der Auftrag an, und in der richtigen Form?
Gerippt wird nichts geprueft wird die Uebersetzung von Celery-Task-Namen in
Auftraege und die Frage, was bei einem unbekannten Namen passiert.
"""
import json
import pytest
from rippy import standalone, store
from rippy.queue import lokal
@pytest.fixture
def q(tmp_path):
vorher = store.zustand_sichern()
store.verbinden(f"sqlite:///{(tmp_path / 's.db').as_posix()}")
store.init_db()
yield lokal
store.engine_holen().dispose()
store.zustand_wiederherstellen(vorher)
def test_rip_wird_zum_rip_auftrag(q):
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/sr0", "job1", "/app/media/x"])
offen = q.offene_auftraege()
assert len(offen) == 1
auftrag = offen[0]
assert auftrag["art"] == "rip"
assert auftrag["job_id"] == "job1"
nutzlast = json.loads(auftrag["payload"])
assert nutzlast["device_path"] == "/dev/sr0"
assert nutzlast["target_dir"] == "/app/media/x"
def test_transcode_wird_zum_transcode_auftrag(q):
standalone.zustellung_bauen()(
"worker.tasks.transcode_files", ["job2", "/raw", "/final"])
auftrag = q.offene_auftraege()[0]
assert auftrag["art"] == "transcode"
nutzlast = json.loads(auftrag["payload"])
assert nutzlast["raw_dir"] == "/raw" and nutzlast["final_dir"] == "/final"
def test_scan_braucht_keinen_job(q):
"""Ein Track-Scan gehoert zu keinem Job — er passiert VOR dem Anlegen."""
standalone.zustellung_bauen()("worker.tasks.scan_tracks", ["/dev/sr0"])
auftrag = q.offene_auftraege()[0]
assert auftrag["art"] == "scan"
assert auftrag["job_id"] == ""
def test_fehlendes_zielverzeichnis_ist_kein_fehler(q):
"""POST /jobs darf target_dir weglassen — dann gilt der Standard."""
standalone.zustellung_bauen()("worker.tasks.rip_disc", ["/dev/sr0", "job3"])
assert json.loads(q.offene_auftraege()[0]["payload"])["target_dir"] is None
def test_unbekannter_taskname_fliegt_auf(q):
"""Wuerde er stillschweigend verworfen, waere der Auftrag weg und niemand
wuesste warum der Nutzer sieht nur einen Job, der nie anfaengt."""
with pytest.raises(ValueError, match="Unbekannte Auftragsart"):
standalone.zustellung_bauen()("worker.tasks.gibts_nicht", ["x"])
def test_faehigkeiten_werden_gesetzt(q):
"""Damit ein reiner Encode-Knoten spaeter keine Rip-Auftraege bekommt."""
zustellen = standalone.zustellung_bauen()
zustellen("worker.tasks.rip_disc", ["/dev/sr0", "job1"])
zustellen("worker.tasks.transcode_files", ["job1", "/raw", "/final"])
arten = {a["art"]: json.loads(a["faehigkeiten"]) for a in q.offene_auftraege()}
assert arten["rip"] == {"art": "rip"}
assert arten["transcode"] == {"art": "transcode"}
def test_ein_kaputter_bus_haelt_die_zustellung_nicht_auf(q):
class KaputterBus:
def senden(self, *a, **k):
raise RuntimeError("weg")
standalone.zustellung_bauen(KaputterBus())(
"worker.tasks.rip_disc", ["/dev/sr0", "job1"])
assert len(q.offene_auftraege()) == 1
def test_ausfuehren_lehnt_unbekannte_art_ab():
with pytest.raises(ValueError, match="Unbekannte Auftragsart"):
standalone.ausfuehren({"art": "quatsch", "job_id": "j", "payload": "{}"})
def test_worker_pfad_wird_gefunden():
"""Ohne die Worker-Module kann Rippy nicht rippen. Wenn sich die
Verzeichnisstruktur aendert, soll das HIER auffallen."""
import os
pfad = standalone._worker_pfad()
assert os.path.isfile(os.path.join(pfad, "ablauf.py"))
-515
View File
@@ -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
+91
View File
@@ -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")
-235
View File
@@ -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 050 %, das zweite 50100 %.
"""
if fortschritt:
fortschritt(text, None if anteil is None
else (_n + anteil) / max(1, _gesamt))
melden("%s fehlt — wird geholt …" % titel)
try:
if name == "handbrake":
handbrake_holen(fortschritt=teil_melden)
elif name == "makemkv":
# still=False: Der Nutzer SOLL den Installer von MakeMKV
# sehen. Ein fremdes Programm still im Hintergrund zu
# installieren, ist ein Ubergriff — und wenn es hakt, sieht
# er wenigstens, woran.
makemkv_holen(fortschritt=teil_melden, still=False)
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
+14
View File
@@ -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.",
-271
View File
@@ -1,271 +0,0 @@
"""Das Einrichten der Werkzeuge — ohne einen einzigen Download.
## Warum es diese Tests gibt
Commander am 28.08.2026: *handbrake und makemkv MUESSEN mitgeliefert werden
oder waehrend des Setups separat installiert werden! Ohne das ist das tool
NICHT einsatzfaehig."*
Er hatte recht: `katalog.py` konnte finden, `beschaffen.py` konnte holen
**das Setup rief beides nie auf.** Auf einem frischen Rechner bekam der
Nutzer eine Oberflaeche, die ihm sagte, was fehlt, und keinen Weg, es zu
aendern.
Alle Pruefungen hier sind eingespritzt: kein Netz, kein Installer, keine
Plattformabhaengigkeit.
"""
import os
from rippy.tools import einrichten
def _eintrag(da, pflicht=True, titel="Werkzeug", version="1.0"):
return {"da": da, "pflicht": pflicht, "titel": titel,
"version": version if da else "", "pfad": "X" if da else ""}
def _bestand(makemkv=True, handbrake=True):
return {
"makemkv": _eintrag(makemkv, True, "MakeMKV", "1.18.4"),
"handbrake": _eintrag(handbrake, False, "HandBrakeCLI", "1.11.2"),
}
# ── Was fehlt ───────────────────────────────────────────────────────────
def test_nichts_fehlt():
assert einrichten.fehlende(_bestand()) == []
def test_pflicht_kommt_zuerst():
"""Wer die Liste abarbeitet, soll das Wichtigste zuerst holen — bei einer
wackligen Verbindung entscheidet das, was ankommt."""
fehlt = einrichten.fehlende(_bestand(makemkv=False, handbrake=False))
assert [name for name, _ in fehlt] == ["makemkv", "handbrake"]
def test_ohne_handbrake_ist_rippy_trotzdem_bereit():
"""Ein Rip laeuft ohne HandBrake durch, nur eben ohne Kompression. Das
ist ein vorgesehener Betriebsfall und kein Grund, dem Nutzer zu sagen,
Rippy sei nicht einsatzfaehig."""
assert einrichten.einsatzbereit(_bestand(handbrake=False)) is True
def test_ohne_makemkv_ist_rippy_NICHT_bereit():
"""Genau der Punkt des Commanders. Ohne MakeMKV kann Rippy keine Disc
lesen dann ist es ein Anzeigeprogramm."""
assert einrichten.einsatzbereit(_bestand(makemkv=False)) is False
# ── Der Ablauf ──────────────────────────────────────────────────────────
def test_es_wird_nur_geholt_was_fehlt():
"""Eine funktionierende Installation darf nicht durch einen Download
ersetzt werden."""
gerufen = []
ergebnis = einrichten.sicherstellen(
bestand_lesen=lambda: _bestand(),
handbrake_holen=lambda **k: gerufen.append("hb"),
makemkv_holen=lambda **k: gerufen.append("mkv"))
assert gerufen == []
assert ergebnis["bereit"] is True
def test_fehlendes_werkzeug_wird_geholt():
stand = {"n": 0}
def lesen():
stand["n"] += 1
return _bestand(makemkv=stand["n"] > 1) # beim zweiten Mal da
ergebnis = einrichten.sicherstellen(
bestand_lesen=lesen,
handbrake_holen=lambda **k: None,
makemkv_holen=lambda **k: None)
assert ergebnis["geholt"] == ["makemkv"]
assert ergebnis["bereit"] is True
def test_ein_gescheiterter_download_wirft_nicht():
"""Am 28.08.2026 antwortete makemkv.com mit HTTP 525. Das darf keine
kaputte Installation ergeben Rippy ist dann installiert und sagt, was
noch fehlt."""
def platzt(**k):
raise OSError("HTTP 525")
ergebnis = einrichten.sicherstellen(
bestand_lesen=lambda: _bestand(makemkv=False),
handbrake_holen=lambda **k: None,
makemkv_holen=platzt)
assert ergebnis["bereit"] is False
assert "HTTP 525" in ergebnis["fehler"]["makemkv"]
def test_der_zustand_wird_NACH_dem_versuch_gelesen():
"""Ein Setup, das meldet „ich habe es versucht", ist wertlos. Es muss
melden, was DANACH da ist."""
versuche = {"n": 0}
def lesen():
versuche["n"] += 1
return _bestand(makemkv=False) # bleibt weg
ergebnis = einrichten.sicherstellen(
bestand_lesen=lesen,
handbrake_holen=lambda **k: None,
makemkv_holen=lambda **k: None) # meldet Erfolg …
assert versuche["n"] == 2, "vorher UND nachher lesen"
assert ergebnis["bereit"] is False, "… ist aber trotzdem nicht da"
def test_fortschritt_wird_durchgereicht():
meldungen = []
einrichten.sicherstellen(
fortschritt=lambda text, anteil=None: meldungen.append(text),
bestand_lesen=lambda: _bestand(makemkv=False),
handbrake_holen=lambda **k: None,
makemkv_holen=lambda **k: None)
assert any("MakeMKV" in m for m in meldungen)
# ── Der Bericht ─────────────────────────────────────────────────────────
def test_bericht_nennt_das_fehlende_UND_den_ausweg():
"""Ein Bericht, der nur ein Problem meldet, laesst den Nutzer ratlos."""
text = einrichten.bericht({
"bestand": _bestand(makemkv=False),
"geholt": [], "fehler": {"makemkv": "HTTP 525"}, "bereit": False})
assert "NICHT rippen" in text
assert "MakeMKV FEHLT" in text
assert "HTTP 525" in text
assert "makemkv.com" in text
def test_bericht_sagt_klar_wenn_alles_da_ist():
text = einrichten.bericht({
"bestand": _bestand(), "geholt": [], "fehler": {}, "bereit": True})
assert "einsatzbereit" in text
assert "FEHLT" not in text
def test_bericht_markiert_was_neu_dazukam():
text = einrichten.bericht({
"bestand": _bestand(), "geholt": ["handbrake"], "fehler": {},
"bereit": True})
assert "neu geholt" in text
# ── Mitgelieferte Werkzeuge ─────────────────────────────────────────────
def test_mitgeliefertes_wird_neben_das_programm_gelegt(tmp_path):
quelle = tmp_path / "paket" / "tools" / "handbrake"
quelle.mkdir(parents=True)
(quelle / "HandBrakeCLI.exe").write_bytes(b"x" * 64)
ziel = tmp_path / "installiert"
ziel.mkdir()
assert einrichten.mitgelieferte_werkzeuge(
str(tmp_path / "paket" / "tools"), str(ziel)) == ["handbrake"]
assert os.path.isfile(str(ziel / "tools" / "handbrake" / "HandBrakeCLI.exe"))
def test_eine_neuere_fassung_wird_nicht_zurueckgedreht(tmp_path):
"""Wer HandBrake selbst aktualisiert hat, darf das durch ein Rippy-Update
nicht verlieren."""
quelle = tmp_path / "paket" / "tools" / "handbrake"
quelle.mkdir(parents=True)
(quelle / "HandBrakeCLI.exe").write_bytes(b"alt")
ziel = tmp_path / "installiert"
(ziel / "tools" / "handbrake").mkdir(parents=True)
(ziel / "tools" / "handbrake" / "HandBrakeCLI.exe").write_bytes(b"neuer")
einrichten.mitgelieferte_werkzeuge(str(tmp_path / "paket" / "tools"), str(ziel))
assert (ziel / "tools" / "handbrake" / "HandBrakeCLI.exe").read_bytes() == b"neuer"
def test_ohne_mitgeliefertes_passiert_nichts(tmp_path):
assert einrichten.mitgelieferte_werkzeuge(
str(tmp_path / "gibt-es-nicht"), str(tmp_path)) == []
# ── Updates schon vorhandener Werkzeuge ─────────────────────────────────
#
# Commander am 28.08.2026: "bitte baue ein, das wenn MakeMKV oder Handbrake
# schon installiert sind ein update verfuegbar ist. Das kann ja theoretisch
# direkt im setup abgehandelt werden."
def test_ueberholtes_werkzeug_wird_erkannt():
veraltet = einrichten.veraltete(_bestand(),
{"makemkv": "1.19.0", "handbrake": "1.11.2"})
assert [n for n, _, _ in veraltet] == ["makemkv"]
assert veraltet[0][2] == "1.19.0"
def test_zweistellige_versionen_kippen_nicht():
"""DER Klassiker: Als Zeichenkette waere 1.9.2 groesser als 1.11.2 --
und HandBrake ist genau dort."""
bestand = {"handbrake": {"titel": "HandBrakeCLI", "da": True,
"version": "1.11.2", "pflicht": False}}
assert einrichten.veraltete(bestand, {"handbrake": "1.9.2"}) == []
assert len(einrichten.veraltete(bestand, {"handbrake": "1.11.3"})) == 1
def test_ohne_auskunft_gibt_es_kein_update():
"""Antwortet die Quelle nicht, darf NICHT 'Update verfuegbar' dastehen.
Eine Nichtauskunft ist keine Aussage."""
assert einrichten.veraltete(_bestand(), {}) == []
assert einrichten.veraltete(_bestand(), {"makemkv": ""}) == []
def test_fehlende_werkzeuge_zaehlen_hier_nicht_mit():
"""Die holt `fehlende()` -- sonst stuende ein fehlendes Werkzeug zweimal
in der Liste und wuerde zweimal geholt."""
assert einrichten.veraltete(_bestand(makemkv=False),
{"makemkv": "1.19.0"}) == []
def test_ohne_flag_wird_NICHT_aktualisiert():
"""Eine unbeaufsichtigte Installation soll nicht ploetzlich Downloads
anstossen."""
gerufen = []
einrichten.sicherstellen(
bestand_lesen=lambda: _bestand(),
neueste_lesen=lambda: {"makemkv": "1.19.0"},
handbrake_holen=lambda **k: gerufen.append("hb"),
makemkv_holen=lambda **k: gerufen.append("mkv"))
assert gerufen == []
def test_mit_flag_wird_aktualisiert():
gerufen = []
einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(),
neueste_lesen=lambda: {"makemkv": "1.19.0", "handbrake": "1.11.2"},
handbrake_holen=lambda **k: gerufen.append("hb"),
makemkv_holen=lambda **k: gerufen.append("mkv"))
assert gerufen == ["mkv"]
def test_fehlendes_kommt_VOR_dem_update():
"""Bei wackliger Verbindung soll zuerst ankommen, was zaehlt: Ein
fehlendes Pflichtwerkzeug ist ein Hindernis, ein Update ist Komfort."""
reihenfolge = []
einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(makemkv=False),
neueste_lesen=lambda: {"handbrake": "1.99.0"},
handbrake_holen=lambda **k: reihenfolge.append("handbrake"),
makemkv_holen=lambda **k: reihenfolge.append("makemkv"))
assert reihenfolge == ["makemkv", "handbrake"]
def test_ein_kaputter_update_check_bricht_nichts_ab():
"""Ein Update-Check ist Komfort; er darf nichts blockieren."""
def platzt():
raise OSError("kein Netz")
ergebnis = einrichten.sicherstellen(
aktualisieren=True,
bestand_lesen=lambda: _bestand(),
neueste_lesen=platzt,
handbrake_holen=lambda **k: None,
makemkv_holen=lambda **k: None)
assert ergebnis["bereit"] is True
-916
View File
@@ -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())