75 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
HitonabiandClaude Opus 5 21b1757aa4 docs(savepoint): v4.0-rc6 — leerer Bildschirm und zwei weitere Container-Wurzeln
Ampel / ampel (push) Successful in 1m43s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:15:58 +02:00
HitonabiandClaude Opus 5 423374e65a fix(windows): Roh- und Zielordner landeten in einem Ordner namens „app"
Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch. Die
Rohdaten landeten in:

    F:\app\temp\raw\<job>\Evangelion 2.22_t00.mkv   (436 MB)

Also in einem Ordner namens `app` auf dem Laufwerk, von dem Rippy gerade lief.

## Zwei Container-Wurzeln im Worker

    RAW_DIR    = /app/temp/raw
    MEDIA_ROOT = /app/media

Unter Windows sind das keine Pfade, sondern Unfaelle. Schlimmer: Die Pruefung
`unter_wurzel(wahl, MEDIA_ROOT)` verwarf auch eine AUSDRUECKLICHE Wahl — ein
Arbeitsordner wie `D:\Roh` liegt nicht unter `/app/media`, also fiel er still
auf den Container-Standard zurueck.

**Damit kam der Arbeitsordner, den der Commander am 28.08.2026 ausdruecklich
bestellt hat, unter Windows nie an.** Der Dialog zeigte ihn, das Setzen ging,
und der Worker ignorierte ihn — ohne ein Wort. Dasselbe galt fuer das Ziel:
Eine UNC-Freigabe liegt unter gar keiner lokalen Wurzel, also waere die
fertige Datei in `X:\app\media\bluray` gelandet.

## Die Wurzeln kommen jetzt aus dem Betrieb

Im Container aendert sich NICHTS: dort ist `/app/media` die Wurzel und `frei`
falsch. Nativ zaehlt die Wahl des Nutzers — dort IST sein Laufwerk die Grenze.

Die drei bestehenden Tests wurden rot, und zwar zu Recht: Sie pruefen die
Container-Regel, liefen aber unter Windows, wo `frei` gilt. Die Wurzeln sind
deshalb einspritzbar — beide Betriebsfaelle sind jetzt auf jedem Rechner
pruefbar, statt vom laufenden abzuhaengen.

837 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:15:17 +02:00
HitonabiandClaude Opus 5 27c9d9a4bb fix(ui): Leerer Bildschirm nach „Rippen starten" — ein halber Job riss alles mit
Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
gibt auch keine fehlermeldung."

Nachgestellt: Dienst lokal gestartet, Disc-Karte geoeffnet, „Rippen starten"
geklickt. Browser-Konsole:

    TypeError: Cannot read properties of undefined (reading 'toUpperCase')

## Er startet sehr wohl — man sieht es nur nie

Waehrend die Seite leer war, lief der Job (per API gemessen: status
`processing`, progress 12). Das „er startet nix" ist also der zweite Teil
desselben Fehlers: Die Oberflaeche war weg, bevor sie ihn zeigen konnte.

## Die Ursache

`_job_kurz` in `bus/waechter.py` trug nur `status`, `progress`, `title` und
`error` — **keinen `type`**. Das UI fuegt aus `job.created` einen halben Job
in seine Liste ein, `LiveLogSection` liest `job.type.toUpperCase()`, und React
baut bei einem Fehler im Zeichnen den GESAMTEN Baum ab. Es gab in diesem
Projekt keine einzige Fehlergrenze — also blieb ein leerer Bildschirm ohne
jede Meldung.

## Drei Reparaturen, weil es drei Fehler waren

1. **Das Ereignis traegt den Job.** `type`, `device` und `startTime` fahren
   mit. Sie aendern sich ueber die Lebenszeit eines Jobs nie, kosten also kein
   zusaetzliches Ereignis — und ohne `startTime` stand in der Jobliste
   sekundenlang „Invalid Date".
2. **Das UI vertraegt sein Fehlen.** `LiveLogSection` und `TypeBadge` nahmen
   einen vollstaendigen Job an. Zeile 108 derselben Datei hatte das
   Fragezeichen laengst, Zeile 48 nicht.
3. **Eine Fehlergrenze.** Ein Fehler in einer Karte darf nicht den ganzen
   Bildschirm mitnehmen — und schon gar nicht schweigend. Jetzt steht da, was
   los ist, die Navigation bleibt bedienbar, und der Hinweis sagt das
   Wichtigste: laufende Rips gehen weiter.

## Warum es niemand gefunden hat

`unterschiede()` bekommt die Kurzform schon fertig, und alle Tests reichen
ihre eigenen Woerterbuecher herein. **`_job_kurz` selbst war nie geprueft.**
Jetzt bewacht `UI_PFLICHTFELDER` den Vertrag mechanisch — es gibt kein
gemeinsames Typsystem zwischen Python und dem UI.

Gegengeprueft im Browser: derselbe Klick, keine Konsolenfehler, Job erscheint
als „Alle (1)".

833 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 14:10:12 +02:00
HitonabiandClaude Opus 5 d9780a4951 docs(savepoint): v4.0-rc5 — Rippy rippt, drei Fehler im Rip-Pfad behoben
Ampel / ampel (push) Successful in 1m38s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:52:52 +02:00
HitonabiandClaude Opus 5 954f293871 fix(windows): Rippen ging nicht — drei Container-Annahmen im Rip-Pfad
Commander: „Das Rippen der disk funktioniert nicht. makemkvcon endete mit
Code 11 — letzte Meldung: Das Öffnen der Disk schlug fehl — keine MKV-Datei
entstanden"

In diesem einen Satz steckten drei Fehler. Alle drei an seinem Laufwerk
gemessen, nicht hergeleitet.

## 1. Die Quellenangabe — das war der Abbruch

    dev:\.\G:  MSG:2024 „Unknown device" -> MSG:5010 Öffnen schlug fehl
    dev:G:      68 TINFO-Zeilen, „Die Aufgabe wurde erfolgreich abgeschlossen"

`f"dev:{device_path}"` stand an VIER Stellen. Unter Linux richtig (/dev/sr0),
unter Windows nicht: `rippy.drives.windows` meldet den Geraete-Namensraum
`\.\G:`, den `CreateFileW` fuer die IOCTLs braucht — MakeMKV kennt ihn
nicht.

## 2. „Ö" statt „Ö"

`Ö` als UTF-8 (C3 96), gelesen als cp1252. `subprocess` stand auf `text=True`
ohne `encoding=`, nahm also die Gebietsschema-Kodierung; Rippy stellt seine
Konsole beim Start aber auf UTF-8 (sonst stirbt der Start an einer
Umlaut-Logzeile). Direkt gemessen kommen die Bytes je nach Umgebung als UTF-8
ODER cp1252 — deshalb wird jetzt bestimmt statt behauptet.

## 3. Die Ursache fehlte im Fehlertext

Gesucht wurde nach ENGLISCHEN Textbausteinen („evaluation period has
expired"). Auf einem deutschen Windows trifft keiner. Jetzt zaehlen die
Meldungs-NUMMERN, die sprachunabhaengig sind.

⚠️ Beim ersten Anlauf hatte ich 5021 aus dem Kopf mit „Volume-Key unbekannt"
beschriftet — falsch. Meine eigene Ausgabe zeigte nur meine Beschriftung
statt MakeMKVs Text, sodass es fast durchgegangen waere. Der echte Wortlaut
steht jetzt im Code.

## Nebenbefund: der Beta-Key lag zweimal am falschen Ort

`makemkv_daten.DATEN_DIR` war fest `/root/.MakeMKV`. Nach der Reparatur des
Ordners blieb MSG:5051 trotzdem stehen — weil MakeMKV unter Windows seine
Einstellungen in der REGISTRY haelt (HKCU\Software\MakeMKV), nicht in einer
settings.conf. Nachgesehen statt vermutet.

Und der wichtigste Teil davon: **Ein abgelehnter Schluessel ist schlimmer als
gar keiner.** Ohne Key las MakeMKV die Disc noch (68 TINFO), mit dem
abgelehnten verweigerte es alles (0 Titel). Deshalb wird jetzt geprueft und
bei Ablehnung zurueckgenommen.

## Beweis

Echter Rip ueber `ripping.run_makemkv`, kuerzester Titel:

    Status success, Code 0, „Evangelion 2.22_t03.mkv" 217,9 MB
    5036 „Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert."

828 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:52:06 +02:00
HitonabiandClaude Opus 5 73ed104bbc docs(savepoint): Arbeitsordner im Setup, Beta-Key auf Knopfdruck
Ampel / ampel (push) Successful in 1m15s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:54:18 +02:00
HitonabiandClaude Opus 5 678fe276f9 feat(windows): Arbeitsordner im Setup, Beta-Key auf Knopfdruck
Ampel / ampel (push) Successful in 1m18s
Zwei Wuensche des Commanders vom 28.08.2026.

## 1. „kannst du noch einbauen das man den arbeitsordner usw. in den
##     Einstellungen setzen kann und direkt im setup?"

In den Einstellungen ging es seit 35370fd (freies Pfadfeld plus die Laufwerke
als Ein-Klick-Wahl). Im SETUP wurde bisher nur nach Programmordner und Ablage
gefragt — der Arbeitsordner tauchte erst auf, wenn schon installiert war.

Das ist die falsche Reihenfolge: 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 — genau so lief am 25.07.2026 die VM-Platte voll.

Neu ist auch ein Waechter, der alle drei Arten von halb angeschlossenem Feld
faengt (kein Eingabefeld, keine Vorbelegung, wird beim Installieren nicht
mitgeschickt). Der dritte Fall ist der gemeinste: Man waehlt etwas, es
passiert nichts, und niemand sagt warum.

## 2. „der könnte theoretisch auch automatisch ausgelesen werden, ich glaube
##     das web rippy kann das"

Er hatte recht. `makemkv_key.refresh_loop()` laeuft seit jeher taeglich mit
(main.py, Startereignis). GEMESSEN am 28.08.2026: Forum antwortet in 3,3 s,
gueltiger Key mit 62 Zeichen, und in seinen Einstellungen lag bereits einer.

Es war nur nichts davon zu sehen. Kein Knopf, keine Meldung, keine Zeitangabe
— im Feld stand ein Key, und ob der von Hand kam oder von selbst, wusste
niemand. Eine Automatik, die man nicht sehen kann, ist fuer den Benutzer
keine.

Dazu kam eine echte Luecke: Die Schleife startet 60 Sekunden nach dem Server.
Wer gleich nach dem Einrichten eine Blu-ray einlegt, rippt ohne Key — MakeMKV
faellt in den 30-Tage-Testmodus. Das Setup holt ihn jetzt selbst.

Der geholte Key wandert NICHT ueber die Leitung zurueck: Er steht in den
Einstellungen, und ein zweiter Weg zu demselben Wert ist ein zweiter Weg, ihn
zu verlieren.

Rechtlich unveraendert: oeffentliche Beta-LIZENZ der Software, KEIN
Disc-Schluessel. Rippy liefert und verteilt keine Disc-Schluessel.

803 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:53:12 +02:00
HitonabiandClaude Opus 5 d0281f7a1b style: Zeilenenden zurueck auf LF (kein Inhalt geaendert)
In 35370fd habe ich fuenf Dateien versehentlich von LF auf CRLF umgestellt.
Der Inhalt stimmte, aber der Diff war unlesbar: 1363 geaenderte Zeilen fuer
30 echte in RipTargetModal.tsx, 1184 fuer 29 in setup_fenster.py. Nachgewiesen
mit `git diff --ignore-cr-at-eol` — damit blieben 56/9 uebrig.

Ursache: Ein Rueckschreiben im Textmodus stellt die ganze Datei um. Die Regel
stand als Warnung schon in meinem Gedaechtnis, dort aber nur in der anderen
Richtung (CRLF -> LF). Sie gilt in beide.

Dieser Commit aendert AUSSCHLIESSLICH Zeilenenden. Nachgewiesen:
`git diff --ignore-cr-at-eol` listet keine dieser fuenf Dateien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:52:08 +02:00
HitonabiandClaude Opus 5 6881a63fb7 docs(savepoint): v4.0-rc4 — Cover, Arbeitsverzeichnis, aufgeraeumte Temp-Ordner
Ampel / ampel (push) Successful in 55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:45:52 +02:00
HitonabiandClaude Opus 5 35370fd552 fix(windows): Cover, Arbeitsverzeichnis und die zurueckgelassenen _MEI-Ordner
Drei Befunde des Commanders vom 28.08.2026, alle drei gemessen.

## 1. „Was ist mit dem Cover auf Windows Rippy?"

Es gab keins, weil es keinen Treffer gab. `_scan_video` verlangte woertliche
Gleichheit:

    if movie.get("title", "").lower() == kandidat.lower():

An seiner Disc gemessen:

    'Evangelion 2.22'    1 Treffer   Evangelion: 2.0 You Can (Not) Advance
    'Evangelion'        20 Treffer   irgendein Evangelion

Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster — und wurde verworfen. Jetzt zaehlt die SPEZIFITAET der Anfrage: Wer
auf den vollen Disc-Titel hoechstens drei Treffer bekommt, hat gefragt wie
jemand, der weiss was er sucht. Ergebnis an derselben Disc:

    Evangelion: 2.0 You Can (Not) Advance / 2009 / 80 % / Poster + dt. Text

## 2. „Warum heisst das hier noch container platte? … Waere es moeglich das
##     Arbeitsverzeichnis zu aendern? momentan geht das nicht."

Beide Haelften gehen auf EINE Zeile zurueck: `MEDIA_ROOT = "/app/media"` war
zugleich Vorgabe UND Pfadgrenze. Auf Windows gibt es den Ordner nicht:

* `/storage-targets` fing den OSError und gab still [] zurueck — die Auswahl
  hatte genau einen Eintrag. Das ist „momentan geht das nicht".
* Dessen Text war fest verdrahtet „(Container-Platte)".
* `/browse` antwortete auf jeden Pfad mit 422.

Die Grenze faellt nicht weg, sie wird betriebsabhaengig: Container und
Kopflos-Betrieb bedienen ein Netz, die native App den Menschen davor.
Gemessen auf seinem PC: 7 Laufwerke zur Auswahl, X/Y/Z mit je 2,2 TB frei.

## 3. „Failed to remove temporary directory: …_MEI0000b0882"

GEMESSEN: 20 zurueckgelassene _MEI-Ordner mit 1,1 GB. Ursache: `Popen` ohne
`env=` reicht PyInstallers Auspack-Zeiger an die Kinder weiter (--dienst und
--oeffnen). Beide laufen dann im Ordner des Elternprozesses, der sich zuerst
beendet und ihn loeschen will. Waere das TEILWEISE geglueckt, haetten Dienst
und Fenster mitten im Betrieb ihre Dateien verloren.

Nebenbefund: test_setup_fenster scheiterte in jeder Umgebung ohne pywebview.

794 Tests gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:43:41 +02:00
HitonabiandClaude Opus 5 11001a443e docs: SAVEPOINT v4.0-rc3 — Windows testbereit
Ampel / ampel (push) Successful in 55s
Stand vor dem Test des Commanders. Zwei Dinge stehen bewusst gross drin:
die seit V2-1 tote Metadaten-Suche (betrifft die VM genauso) und die
bekannte Luecke bei Audio-CDs unter Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:09:24 +02:00
HitonabiandClaude Opus 5 be3fac53af fix(prescan): makemkvcon ueber den Katalog suchen, nicht ueber shutil.which
Ampel / ampel (push) Successful in 55s
Auf die Frage des Commanders: "du hast jetzt nur mein laufwerk analysiert
oder? wenn ich rippy weitergebe wie laeuft es dort?"

Berechtigt -- und beim Nachsehen fiel derselbe Fehler auf wie heute Vormittag
im Werkzeug-Katalog:

    shutil.which("makemkvcon")   ->  None, auch bei installiertem MakeMKV

`which` sucht nur im PATH. Windows-Programme liegen in "Programme". Der
Zweig, der die Titelliste von makemkvcon holt, lief unter Windows also NIE --
unabhaengig vom Laufwerk, auf jedem Rechner. Jetzt ueber
`katalog.finden("makemkv")`, das genau dafuer da ist.

Dazu vier Tests fuer Disc-Arten, die ICH NICHT HABE, damit die Zusage nicht
an einer einzigen Disc haengt:

    Blu-ray ohne BDMV/META   -> None (sehr haeufig; Rueckfall aufs Label)
    DVD (nur VIDEO_TS)       -> None
    bdmt_deu.xml vorhanden   -> Titel wird gelesen
    deu + eng vorhanden      -> eng gewinnt (APIs suchen damit besser)

Und ein Waechter gegen shutil.which in prescan.py. Der erste Anlauf davon
fiel ueber den eigenen Erklaertext, in dem der alte Aufruf zitiert wird --
er prueft jetzt nur Code, keine Kommentare.

Ampel lokal: 774 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 16:05:25 +02:00
HitonabiandClaude Opus 5 878b24c43b fix: die Metadaten-Suche war seit V2-1 tot — auf Windows UND auf der VM
Ampel / ampel (push) Successful in 54s
Der Commander: "TMDB und OMDB Key sind hinterlegt, das laufwerk wird aber
auch nicht korrekt ausgelesen. Eigentlich sollte er direkt bei TMDB oder OMDB
oder JIKAN anfragen nach metadaten."

Er hat das Symptom gemeldet. Darunter lagen VIER Fehler.

## 1. Ein Phantom-Modul (der schwerste)

    clients/tmdb.py:27     from db import get_settings
    clients/omdb.py:36     from db import get_settings
    clients/thetvdb.py:16  from db import get_settings

`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr; main.py schreibt seitdem `from rippy import store as db`. Diese drei
Module haben es nie mitbekommen.

Damit starb JEDE Metadaten-Abfrage schon beim Erzeugen des Clients mit
ModuleNotFoundError -- und `_auto_prescan` verschluckte das in seinem
`except Exception`. Die Disc blieb namenlos, und niemand sah warum.

**Das betrifft die VM genauso.** Sie steht auf 05ab655, also NACH dd1d0b7 --
dort laeuft seit V2-1 dieselbe tote Metadaten-Suche.

## 2. Redis war Pflicht statt Beschleunigung

`cache/cache.py` sprach `redis:6379` an -- den Dienstnamen aus
docker-compose.yml. Auf einem Windows-PC gibt es kein Redis:

    redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379

Das riss den Pre-Scan mit. Ein Zwischenspeicher ist eine Beschleunigung,
keine Voraussetzung: Jetzt faengt jeder Zugriff den Verbindungsfehler ab und
meldet "nicht vorhanden" -- was der Wahrheit entspricht. Gemeldet wird der
Ausfall EINMAL, nicht bei jeder Anfrage.

## 3. Der Klartext-Titel war unter Windows nicht erreichbar

`read_disc_title_via_mount` machte fest `mount -t udf` -- also Linux. Unter
Windows haengt Windows die Disc selbst ein. An der echten Disc gemessen:

    Volume-Label (UDF)          BD_EVG_D2      <- damit findet keine API etwas
    BDMV/META/DL/bdmt_deu.xml   Evangelion 2.22

`disc_wurzel()` liefert jetzt je Plattform einen lesbaren Einstieg, und
`titel_aus_bdmt()` ist davon getrennt (und damit ohne Disc pruefbar).

## 4. Modell und Seriennummer fehlten

Im UI stand "Modell: unbekannt, Seriennummer: -". Der Linux-Treiber liest
beides aus /sys; der Windows-Treiber lieferte leere Felder. Die Entsprechung
ist IOCTL_STORAGE_QUERY_PROPERTY. An echter Hardware gemessen:

    hersteller     'HL-DT-ST'
    modell         'BD-RE BU40N'
    fassung        '1.03'
    seriennummer   '0025114C0149'

Gemerkt statt jedes Mal abgefragt: Die Disc-Wache ruft device_info alle drei
Sekunden, und die Angaben eines Laufwerks aendern sich nicht.

## Ergebnis, an der eingelegten Disc gemessen

    title        'Neon Genesis Evangelion'
    year         1995
    disc_type    'Blu-ray'
    confidence   0.8
    fingerprint  'BD_EVG_D2|48149364736'

(Jikan antwortete waehrend der Messung mit 504 -- deren Ausfall, nicht
unserer. Der Treffer kam von TMDB.)

Zwei Waechter, beide beim Zurueckdrehen rot gesehen: Die Clients muessen sich
OHNE Datenbank und OHNE Redis bauen lassen, und ein `from db import` in
clients/ faellt mechanisch auf.

Ampel lokal: 769 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:59:47 +02:00
HitonabiandClaude Opus 5 ea8a043643 feat(setup): Fortschrittsbalken, MakeMKV-Installer mit Rechteabfrage, Updates
Ampel / ampel (push) Successful in 55s
Drei Punkte des Commanders vom 28.08.2026.

## 1. "bei der installation eine progress bar waere gut"

Die Daten dafuer waren schon da -- der Fortschritts-Rueckruf traegt seit
jeher einen Anteil, die Bruecke warf ihn nur weg.

Jetzt rechnet `Fortschritt` Abschnitt + Teil-Anteil in einen Gesamtstand um.
Die Gewichte sind gemessen: Dateien ablegen 0-8 % (rund drei Sekunden),
Ablage 8-10 %, Werkzeuge 10-97 % (sechs Sekunden bis mehrere Minuten). Ein
Balken, der bei 90 % minutenlang steht, ist schlimmer als gar keiner.

Zwei Regeln, beide mit Test:

* Er laeuft NIE zurueck. Der Werkzeug-Abschnitt kann mehrere Downloads
  enthalten, jeder mit eigenem 0-bis-1 -- ohne die Regel spraenge der Balken
  bei jedem neuen Werkzeug an den Abschnittsanfang. Zusaetzlich rechnet
  `sicherstellen` den Anteil eines Werkzeugs auf die ganze Strecke um.
* Eine Textmeldung ohne Zahl bewegt ihn nicht. Sie ist keine Aussage ueber
  den Fortschritt.

Am laufenden Fenster nachgewiesen: "Werkzeuge werden geprueft -- 36 %",
Balken bernsteinfarben, am Ende gruen bei 100 %.

## 2. "make MKV laesst sich nicht installieren weil der vorgang erhoehte
##     rechte benoetigt"

Der Kern, und der Fehler lag bei uns: `subprocess.Popen` benutzt
`CreateProcess`, und das ZEIGT KEINE UAC-Abfrage -- es bricht mit
ERROR_ELEVATION_REQUIRED (740) ab. MakeMKV installiert nach Program Files und
fordert im Manifest Administratorrechte an.

`ShellExecuteW` ist der dokumentierte Weg, der die Abfrage ausloest. Damit
braucht NUR dieser eine Aufruf die Erhoehung.

Und deshalb laeuft das Setup NICHT dauerhaft erhoeht (dazu die Antwort im
Chat): Ein erhoehter Prozess sieht die eingebundenen Netzlaufwerke nicht --
auf diesem Rechner gemessen, X:, Y:, Z: ohne Erhoehung sichtbar,
EnableLinkedConnections nicht gesetzt. Genau das hatte der Commander vorher
selbst als Fehler gemeldet. Wer trotzdem erhoeht starten will, bekommt einen
Knopf -- aber nur, wenn das Schreibrecht am gewaehlten Ziel wirklich fehlt.

## 3. "wenn MakeMKV oder Handbrake schon installiert sind ein update
##     verfuegbar ist. Das kann ja direkt im setup abgehandelt werden."

`veraltete()` vergleicht nach ZAHLEN (ein Zeichenvergleich hielte 1.9.2 fuer
neuer als 1.11.2 -- und HandBrake ist genau dort). `sicherstellen` holt sie
auf Wunsch mit, aber IMMER nach den fehlenden: Ein Update ist Komfort, ein
fehlendes Pflichtwerkzeug ist ein Hindernis.

Der Assistent zeigt es als eigenen Schalter mit Klartext ("MakeMKV 1.18.2 ->
1.18.4"). Der Update-Check laeuft als GETRENNTER Aufruf nach der
Voraussetzungs-Pruefung: Er kostet Netz, und makemkv.com braucht dafuer im
schlimmsten Fall gut zwanzig Sekunden -- in der Pruefung bliebe das Fenster
so lange leer. Sind die Quellen nicht erreichbar, schaltet sich der Haken ab
und sagt warum; ein Haken, der nichts tun kann, ist eine Falle.

Ampel lokal: 758 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:38:51 +02:00
HitonabiandClaude Opus 5 95beb91364 feat(tools): Ausweichquellen fuer MakeMKV — recherchiert und gemessen
Ampel / ampel (push) Successful in 1m1s
Commander: "bitte baue fuer MakeMKV fallback seiten ein."

Anlass war ein echter Ausfall. Am 28.08.2026 gemessen:

    https://www.makemkv.com/download/   HTTP 525   (dauerhaft)
    https://makemkv.com/download/       HTTP 525
    http://www.makemkv.com/download/    HTTP 403
    https://forum.makemkv.com/          200 / 522 / Zeitablauf (wechselnd)
    web.archive.org/web/2025id_/...exe  200, 16.432.607 Bytes

525/522 heissen: Cloudflare erreicht den Ursprungsserver nicht. Eine Stoerung
beim Hersteller -- dieselbe, die im Projekt schon einmal jeden Worker-Build
lahmgelegt hat.

## Die Kette

Version:  Hersteller-Seite (massgeblich) -> Forum-Ankuendigungen -> Archiv
Datei:    eigene Quelle -> Hersteller -> Internet Archive

Der Hersteller zuerst, immer. Das Archiv ist Rueckfallebene, und Rippy nennt
hinterher die Quelle, aus der die Datei kam.

Zwei Feinheiten, beide aus Messungen statt aus dem Kopf:

* Die HOECHSTE Nummer gewinnt, nicht die erste. Antwortet das Forum nicht,
  meldet das Archiv einen aelteren Stand (1.18.2 statt 1.18.4) -- "neueste
  Fassung" waere dann eine falsche Aussage.
* Ein zweiter Anlauf, mit knapper Zeitgrenze. Drei Abrufe am Forum:
  TimeoutError, HTTP 522, dann Erfolg. Ohne Wiederholung fiel die Quelle in
  zwei von drei Faellen aus; mit 20-Sekunden-Grenzen dauerte der schlimmste
  Fall 46 Sekunden, in denen das Setup eingefroren aussah. Jetzt acht
  Sekunden je Versuch, schlimmstenfalls gut zwanzig.

## Warum eine fremde Quelle trotzdem sicher ist

Der naheliegende Schutz geht NICHT: MakeMKV signiert seinen Installer nicht
(Get-AuthenticodeSignature -> NotSigned, an der echten Datei gemessen). Eine
Signaturpruefung waere eine, die immer fehlschlaegt -- schlimmer als keine,
weil sie Sicherheit vortaeuscht.

Geprueft wird die Versions-Ressource, ebenfalls gemessen:

    CompanyName      GuinpinSoft inc
    FileDescription  MakeMKV installer
    FileVersion      v1.18.4

Das ersetzt keine Signatur, faengt aber ab, was hier wirklich droht: eine
Fehlerseite mit .exe-Namen, ein abgebrochener Download, eine falsche Fassung.

Ende zu Ende nachgewiesen, ohne etwas zu installieren:

    Version laut Kette: 1.18.4
    Hersteller:         HTTP 525 -> uebersprungen
    Internet Archive:   15,7 MB in 6,1s
    Pruefung:           BESTANDEN (MakeMKV v1.18.4)

Die Grenze aus KONZEPT.md 6 bleibt: Rippy liefert MakeMKV weiterhin NICHT
mit. Es holt die Datei des Herstellers -- im Rueckfall aus einem Archiv, das
genau diese Datei aufbewahrt.

Ampel lokal: 733 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 15:19:46 +02:00
HitonabiandClaude Opus 5 480b658294 fix: die Laufwerks-Pruefung muss selbst einspritzbar sein
Ampel / ampel (push) Successful in 54s
Ampel-Lauf zu 16df61e rot, und diesmal am Gegenteil:

    assert 'Administratorrechte' in 'Statt des Laufwerksbuchstabens den
    UNC-Pfad eintragen (\Server\Freigabe).'

`pfade.laufwerk_von` zerlegte den Windows-Pfad jetzt richtig -- aber
`os.path.isdir("C:\")` ist auf Linux immer False, also galt JEDES Laufwerk
als fehlend und der falsche Zweig griff.

Die Frage "existiert Laufwerk Q:" laesst sich auf dem Linux-Runner
ueberhaupt nicht beantworten, dort gibt es keine Laufwerksbuchstaben. Also
gehoert sie in eine eigene, einspritzbare Funktion (`laufwerk_fehlt`) --
ohne die waere der Zweig nur auf einem Windows-Rechner mit genau diesem
fehlenden Laufwerk pruefbar, also nirgends.

Dieselbe Lehre wie bei pruefe_windows heute Vormittag: **Ein
Plattform-Vergleich mitten in einer Funktion macht jeden Einspritzpunkt
davor wertlos.**

Vor dem Push den Linux-Fall lokal nachgestellt (os.name auf "posix"
gesetzt): Program Files, Programme und ein fehlendes Q: landen jetzt alle
im richtigen Zweig.

Ampel lokal: 715 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:56:10 +02:00
HitonabiandClaude Opus 5 16df61ea3a fix: pruefe_schreibrecht nahm os.path statt pfade — zum vierten Mal dieselbe Falle
Ampel / ampel (push) Failing after 54s
Ampel-Lauf zu 3d5f82b rot:

    assert 'nicht vorhanden' in 'Q:\Rippy (Pfad nicht gefunden)'

`os.path.splitdrive` kennt auf Linux kein "Q:" und gab einen leeren String
zurueck, also fiel die Pruefung in den allgemeinen Zweig. Genau dafuer gibt
es seit einer Stunde `rippy/pfade.py` -- und ich habe es hier nicht benutzt.

Das ist das vierte Vorkommen desselben Musters an einem Tag (katalog,
verknuepfungen, betrieb, jetzt einrichtung). Die Regel steht im Kopf von
pfade.py und gilt ohne Ausnahme: **Der Pfad entscheidet, nicht der Rechner.**

Gegengeprueft, dass es keine fuenfte Stelle gibt: kein os.path.splitdrive und
kein direkter ntpath-Aufruf mehr ausserhalb von pfade.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:53:26 +02:00
HitonabiandClaude Opus 5 3d5f82b786 fix(windows): der Deinstallierer beendet jetzt auch den laufenden Dienst
Ampel / ampel (push) Failing after 54s
Beim Deinstallieren im laufenden Betrieb blieben liegen:

    rippy.db, rippy.db-shm, rippy.db-wal
    (Der Prozess kann nicht auf die Datei zugreifen, da sie von einem
     anderen Prozess verwendet wird)

Der Dienst lief weiter und hielt die Datenbank offen. Damit waere der Fehler
von vorhin durch eine andere Tuer zurueckgekommen: Die naechste Installation
haette wieder den alten Zustand samt setup.done geerbt, und der
Ersteinrichtungs-Assistent waere wieder verschwunden.

Aufgefallen ist es nur, weil der Deinstallierer seit heute NENNT, was er
nicht wegbekommen hat. Vorher stand dort ein `except OSError: pass` und die
Meldung "Rippy wurde entfernt".

Gefiltert wird ueber den Installationsordner, nicht ueber den Namen:
`Rippy.exe` heisst auch die Datei, die den Auftrag gerade ausfuehrt. Die
eigene Prozesskennung und die des PyInstaller-Starters sind ausgenommen.

Nachgewiesen am laufenden Prozess: beendet, Datenbankdateien danach
loeschbar. In der Entwicklungsumgebung schlaegt der Pfadvergleich uebrigens
immer fehl -- die Werkzeuge laufen in einem App-Container, der
%LOCALAPPDATA% umleitet, waehrend die Registry den echten Pfad meldet. Das
steht jetzt als Hinweis im Docstring, damit es niemand ein zweites Mal sucht.

Ampel lokal: 713 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:50:50 +02:00
HitonabiandClaude Opus 5 8e65411189 fix(windows): die Datenbank lag AUSSERHALB der Installation — daher fehlte
Ampel / ampel (push) Failing after 54s
der Ersteinrichtungs-Assistent

Der Befund des Commanders: "Ausserdem fehlt der 1st run wizzard wenn man es
installiert." Meine erste Reparatur (App.tsx las einen gescheiterten Abruf
als "erledigt") war richtig, aber nicht die Ursache. Nachgemessen:

    Installation      %LOCALAPPDATA%\Rippy      wird deinstalliert
    Datenbank         %ProgramData%\Rippy       BLEIBT LIEGEN

Eine "frische" Installation erbte damit die alte Datenbank samt
`setup.done = true` -- der Assistent erschien nie wieder. Gegengeprueft:
nach dem Aufraeumen meldet /api/setup wieder {"done": false}, und das
Fenster zeigt "Willkommen bei Rippy".

Der Grund fuer den alten Ort stand im Docstring: "Ein Programmordner ist
unter Windows fuer einen DIENST nicht zuverlaessig beschreibbar." Das stimmte
-- fuer den Windows-Dienst, den es nie gab. Rippy laeuft als der angemeldete
Benutzer (Autostart unter HKCU). Jetzt liegt alles, was Rippy gehoert, in
EINEM Ordner: Programm, UI, Werkzeuge, Protokoll, WebView2-Zwischenspeicher
und die Datenbank.

Dazu:

* `datenbank_umziehen()` holt eine vorhandene Datenbank vom alten Ort ab --
  wer Rippy schon benutzt hat, behaelt Jobs und Einstellungen. Gibt es am
  neuen Ort schon eine, bleibt sie unangetastet: Die BENUTZTE gewinnt.
* `deinstallieren()` raeumt den alten Ort mit weg, und `alte_orte()` fasst
  nur einen Ordner an, der wirklich "Rippy" heisst.
* Der Ersteinrichtungs-Assistent spricht nicht mehr von "Encoding-Worker",
  wenn es keine gibt -- dort steht jetzt "Kompression". Gegengeprueft: kein
  "docker", kein "Container", kein "Worker" mehr im Text.

Ampel lokal: 709 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:46:02 +02:00
HitonabiandClaude Opus 5 082e8b8b55 fix(windows): der Knopf "Rippy oeffnen" oeffnete Rippy nicht
Ampel / ampel (push) Failing after 55s
Nach einer erfolgreichen Einrichtung schloss er nur das Fenster, und danach
passierte nichts mehr: Der Dienst lief nicht, kein Fenster ging auf. Der
Commander hat Rippy anschliessend selbst gestartet ("das tool selbst laesst
sich starten") -- was den Fehler verdeckt hat.

Ein Knopf, der etwas anderes tut als seine Beschriftung sagt, ist schlimmer
als keiner. Jetzt startet main() nach einem geglueckten Assistenten das
installierte Programm.

Dazu der zweite Teil des Netzlaufwerk-Befunds: Auch Netz-ANMELDUNGEN haengen
am Token der Sitzung, nicht nur die Laufwerksbuchstaben. Ein erhoehter Prozess
kennt die Zugangsdaten zur Freigabe nicht und bekommt "Zugriff verweigert" --
also dieselbe Meldung wie bei einem echten Rechteproblem. Ein UNC-Pfad mit
Administratorrechten wird jetzt als solcher erkannt und erklaert.

Ampel lokal: 705 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:38:17 +02:00
HitonabiandClaude Opus 5 6dfff5c99d fix(windows): vier Befunde aus dem ersten echten Installationslauf
Alle vom Commander gemeldet, alle nachgestellt.

## 1. Der Installer brach ab: No module named 'db'

    Fehlgeschlagen: ModuleNotFoundError: No module named 'db'

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 mir aus einem ALIAS ein Modul
zusammengereimt und importiert -- genau die erfundene Schnittstelle, vor der
AGENTS.md Regel D warnt, nur diesmal im eigenen Code. Der Store wird jetzt
direkt benutzt; der Umweg ueber den API-Suchpfad war ohnehin ueberfluessig.

## 2. Der Ersteinrichtungs-Assistent fehlte nach der Installation

In App.tsx stand:

    .catch(() => setSetupDone(true))  // API/Setup nicht erreichbar -> nicht blockieren

Das Fenster geht unmittelbar nach dem Setup auf, der Dienst braucht ein bis
zwei Sekunden bis zur ersten Antwort -- der erste Abruf scheitert also fast
immer. Aus "ich konnte nicht nachsehen" wurde "ist schon erledigt", und der
Assistent war fuer immer weg.

Dasselbe Prinzip steht nebenan in useEventStream.tsx: Ein Verbindungsabriss
ist KEINE Aussage ueber die Welt. Jetzt wird nachgefragt, bis der Server
ANTWORTET (zwei Minuten lang alle zwei Sekunden), und solange steht
"Rippy startet ..." im Fenster statt einer leeren Flaeche.

## 3. + 4. Als Administrator keine Netzlaufwerke, Netzlaufwerk = "keine Rechte"

Beides Windows-Verhalten, kein Rippy-Fehler -- aber Rippy hat ihn
hineinlaufen lassen. Auf dem Rechner nachgemessen:

    X:, Y:, Z:                 Netzlaufwerke
    EnableLinkedConnections    nicht gesetzt (Standard)
    C:\Program Files (x86)     ohne Administrator nicht beschreibbar

Ein erhoehter Prozess bekommt ein anderes Zugriffstoken; eingebundene
Netzlaufwerke haengen am Token der Sitzung und existieren dort schlicht
nicht. Daraus wird ein Teufelskreis:

    Program Files    verlangt Administrator
    Administrator    versteckt die Netzlaufwerke
    Netzlaufwerk     wirkt dann wie "keine Schreibrechte"

Rippy braucht ueberhaupt keine Administratorrechte (Vorgabeordner unter
%LOCALAPPDATA%, Autostart unter HKCU). Der Assistent sagt das jetzt:

* eine eigene Pruefung "Rechte", die bei erhoehtem Start warnt und die
  unsichtbaren Laufwerke beim Namen nennt
* ein fehlender Laufwerksbuchstabe wird als solcher gemeldet, nicht als
  fehlendes Schreibrecht ("Laufwerk Q: ist hier nicht vorhanden")
* ein Ziel unter "Programme" erklaert den Kreis und verweist auf den
  Vorgabeordner

## Dazu: zwei Schreibfallen in AGENTS.md

Deutsche Anfuehrungszeichen in doppelt gequoteten Zeichenketten (vier Mal an
einem Tag) und Bash-Heredocs, die Backslashes halbieren (fuenf Mal, zuletzt
beim Schreiben genau dieses Absatzes). Ein Test dafuer waere Rauschen gewesen
-- Python faengt beides bereits beim Import, nur mit verwirrender Meldung.
Die Regel gehoert in die Konventionen, nicht in eine Pruefung.

Ampel lokal: 703 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:36:56 +02:00
HitonabiandClaude Opus 5 8eca982e68 docs: SAVEPOINT v4.0-rc2 — Windows ist ein eigenes Produkt
Ampel / ampel (push) Successful in 55s
Die drei Befunde des Commanders und was dahinter steckte, plus der teuerste
Fund des Tages: %ProgramFiles(x86)% wurde auf einer echten Maschine NIE
aufgeloest, weil der Test genau die Schreibweise einspritzte, die im Muster
stand. Er war gruener als die Wirklichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:03:04 +02:00
HitonabiandClaude Opus 5 151376aee3 fix: Pfad-Regeln an EINE Stelle — dreimal dieselbe Falle war zweimal zu viel
Ampel / ampel (push) Successful in 54s
Ampel-Lauf zu b47d215 rot, drei Fehlschlaege, alle mein eigener Code:

    platz_orte             os.path.splitdrive kannte auf Linux kein "D:"
                           -> zwei Laufwerke galten als eines
    naechster_vorhandener  os.path.dirname zerlegte C:\Users\Test\... nicht
    pruefe_windows         die Plattform-Pruefung stand VOR der eingespritzten
                           Version -> pruefe_windows(22631) gab auf Linux FEHLER

`os.path` richtet sich nach der Maschine, auf der es laeuft. Das ist fast
immer richtig -- und an jeder Stelle falsch, an der ueber Pfade einer ANDEREN
Maschine gerechnet wird. Genau das war heute schon zweimal da:

    tools/katalog.py         C:\Program Files (x86)\MakeMKV/makemkvcon64.exe
    platform/verknuepfungen  dirname() einer .lnk gab einen leeren String

Zweimal einzeln repariert, beim dritten Mal gehoert es an EINE Stelle:
`rippy/pfade.py` mit `ist_windows_pfad`, `verbinden`, `ordner_von`,
`laufwerk_von`, `gleiches_laufwerk`, `naechster_vorhandener`. Die Regel steht
dort im Kopf: **Der Pfad entscheidet, nicht der Rechner.** katalog,
verknuepfungen, betrieb und einrichtung benutzen jetzt alle dasselbe.

Und `pruefe_windows` beachtet einen eingespritzten Wert wieder. Ein
Einspritzpunkt, der ignoriert wird, ist keiner -- dann sind die Tests
daneben, die ihn benutzen.

Sieben neue Tests in test_pfade.py, die BEIDE Zweige pruefen und deshalb
ueberall laufen. Das war der eigentliche Mangel: Jede dieser drei Fallen war
nur auf der jeweils anderen Plattform sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:00:34 +02:00
HitonabiandClaude Opus 5 b47d21510d docs: Entscheid 6 — Faehigkeiten statt Modus-Name, und ein Setup das fragt
Ampel / ampel (push) Failing after 54s
Beide Befunde des Commanders vom 28.08.2026 festgehalten, mitsamt der
Begruendung, warum ein 'if (windows)' an dreissig Stellen die falsche
Reparatur gewesen waere: Die Oberflaeche haette weiterhin nichts ueber ihren
Betrieb gewusst, nur eine zweite Sorte Vermutung gehabt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:56:41 +02:00
HitonabiandClaude Opus 5 a4a045f303 feat(windows): ein echtes Setup — Voraussetzungs-Pruefung, Zielordner, Ablage
Commander: "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 nach
%LOCALAPPDATA%\Rippy und oeffnete das Fenster. Der Nutzer wurde nichts
gefragt und erfuhr nichts -- auch nicht, ob sein Rechner ueberhaupt kann, was
Rippy braucht.

Jetzt: ein Assistent mit acht Pruefungen, zwei Ordner-Wahlen und drei
Schaltern. Auf diesem Rechner nachgemessen:

    OK  Betriebssystem      Windows 11 (Build 26200)
    OK  Fenster (WebView2)  151.0.4129.107
    OK  Optisches Laufwerk  \.\G:
    OK  HandBrakeCLI        1.11.2
     !  MakeMKV             nicht gefunden
                            -> wird von makemkv.com geholt
    OK  Speicherplatz       59.1 GB frei
    OK  Schreibrecht        C:\Users\...\AppData\Local\Rippy
    OK  Netzwerk-Port       7788 ist frei

Drei Entwurfsentscheidungen, die dazugehoeren:

* Nur ein FEHLER blockiert, eine Warnung nicht. Eine Warnung, die den Knopf
  sperrt, ist eine Bevormundung; ein Fehler, der nur warnt, ist eine Falle.
  Kein Laufwerk = Warnung (reine Komprimier-Maschine ist ein vorgesehener
  Betriebsfall). Kein Schreibrecht = Fehler.
* Jeder Befund sagt, was zu tun ist. Ein Befund ohne Abhilfe laesst den
  Nutzer ratlos zurueck -- ein Test haelt das fuer jede Pruefung fest.
* Die Pruefungen stehen in einrichtung.py, komplett ohne Fenster pruefbar.
  Im Fenstermodul steht nur Aufbau und Bruecke.

WebView2 statt Win32-Dialog: Es ist ohnehin da (Entscheid 4), der Assistent
kostet damit NULL zusaetzliche Bytes. Die Seite laedt nichts aus dem Netz --
sie muss auf einem Rechner ohne Internet aufgehen, dort wird sie am
dringendsten gebraucht. Ein Test haelt das fest.

--still und --ohne-assistent gehen weiterhin den stummen Weg. Geht das
Fenster nicht auf, wird mit den Vorgaben installiert und gesagt warum -- ein
Setup, das gar nichts tut, weil sein Fenster nicht aufging, waere schlimmer
als eines, das nicht fragt.

fix(tools): %ProgramFiles(x86)% wurde auf echten Rechnern NIE aufgeloest

Beim Bauen der Pruefung aufgefallen, und es ist ein Lehrstueck:

    Testumgebung   {"ProgramFiles(x86)": ...}  ->  C:\Program Files (x86)\...
    echtes Windows {"PROGRAMFILES(X86)": ...}  ->  ''

Windows behandelt Umgebungsvariablen ohne Ruecksicht auf die Schreibweise und
legt sie in os.environ GROSS ab. Der Vergleich in _entfalten war exakt, fand
nie eine Uebereinstimmung, und die bekannten Installationsorte fielen aus der
Kandidatenliste. MakeMKV wurde also NUR ueber die Registry-Rueckfallebene
gefunden -- wo die fehlt oder anders aussieht, fand Rippy nichts.

Aufgefallen ist es nur, weil hier zum ersten Mal eine ECHTE Umgebung benutzt
wurde. Der Test spritzte genau die Schreibweise ein, die im Muster stand: Er
war gruener als die Wirklichkeit. Beide Tests nehmen jetzt die echte
Schreibweise, und einer prueft direkt gegen os.environ. Beim Zurueckdrehen
des Fehlers rot gesehen (5 Fehlschlaege).

Ampel lokal: 691 gruen, ruff sauber. Assistent im Bildschirmfoto belegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:54:59 +02:00
HitonabiandClaude Opus 5 66642b8d93 feat(ui): die Oberflaeche weiss jetzt, worauf sie laeuft
Commander-Befund 28.08.2026, zum Windows-Fenster:

    Worker erreichbar:   0 von 1
    Kein Worker antwortet — Pruefen: docker compose ps
    Container-Platte:    unbekannt
    Freigaben:           keine eingehaengt

Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Sein Urteil: "Du hast
ja quasi nur rippy genommen und die docker installation fuer Windows gebaut.
Das gilt fuer die ganze standalone version fuer Windows, auch fuer die
settings und die Anleitung usw."

## Die Ursache war nicht die Anzeige

Die naheliegende Reparatur waere ein `if (windows)` an dreissig Stellen
gewesen -- dieselbe Falle noch einmal, nur mit einer zweiten Sorte Vermutung.

Es fehlte etwas anderes: Das UI hat nie erfahren, worauf es laeuft. config.py
kennt das Profil seit V2-2, weitergegeben wurde es nie. Also hat das UI
angenommen.

Neu: GET /betrieb meldet FAEHIGKEITEN, keinen Modus-Namen.

    externe_worker        Gibt es andere Maschinen, die Jobs uebernehmen?
    freigaben_einhaengen  Kann Rippy Netzwerk-Freigaben selbst einhaengen?
    container_pfade       Sind Pfade wie /app/media ueberhaupt gemeint?
    werkzeuge_verwalten   Kann Rippy MakeMKV/HandBrake selbst beschaffen?

Ein Modus-Name wuerde das UI zwingen, aus einem Namen auf Verhalten zu
schliessen -- und das bricht beim naechsten Betriebsfall: Ein
Docker-All-in-One hat Container-Pfade, aber keinen zweiten Worker.

## Was sich sichtbar aendert (auf Windows nachgemessen)

    Server-Status  "Rippy arbeitet: auf diesem Rechner" statt Worker-Zaehler
                   "Platz fuer Rippy: 59.2 von 232 GB frei" statt "unbekannt"
                   Freigaben-Block faellt weg
    Einstellungen  kein Reiter "Worker"; Speicherziele BLEIBT (dort steht die
                   Ablage -- "wo will ich das hinspeichern" war die Frage),
                   aber mit Pfadfeld statt Container-Auswahl und ohne die
                   Maske zum Einhaengen
    Anleitung      kein docker, kein Encoding-Worker-Abschnitt, stattdessen
                   der echte Ordner (C:\Users\...\Videos\Rippy)

## Zwei Fehler, die dabei aufgefallen sind

* MEDIA_ROOT = "/app/media" war in main.py fest verdrahtet. shutil.disk_usage
  warf unter Windows, die Liste blieb leer -- daher "unbekannt", obwohl auf
  dem Laufwerk 59 GB frei waren. Eine Nichtauskunft, die wie eine Auskunft
  aussieht. platz_orte() liefert die Orte jetzt je Betrieb, und ein noch
  nicht angelegter Ordner faellt auf das naechste vorhandene Elternteil
  zurueck.
* Der SSE-Schnappschuss enthielt den Server-Zustand NICHT, und der Waechter
  schickt ihn nur alle 15 Sekunden. Nach jedem Neuladen stand deshalb bis zu
  eine Viertelminute "unbekannt" da. Jetzt ist er im Schnappschuss, und das
  UI uebernimmt ihn auch von dort.

Dazu: der Windows-Skip in test_api_smoke.py ist weg. Er stammte aus der Zeit
vor V2-4, als main.py fcntl brauchte; seit der Treiberwahl ueber den Port
laedt es auf beiden Plattformen (57 Routen, gemessen). Damit laufen 20 Tests
mehr auch lokal statt nur auf der Ampel.

Ampel lokal: 654 gruen, ruff sauber. Docker-Zweig durch Unit-Tests gedeckt,
am echten Container noch nicht gegengeprueft -- das kommt beim Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:45:00 +02:00
HitonabiandClaude Opus 5 a5e64f8e56 fix(windows): der Deinstallierer liess 148 Dateien liegen und meldete Erfolg
Ampel / ampel (push) Successful in 54s
Beim ersten echten Deinstallieren auf dem Rechner des Commanders gemessen:

    Uninstall-Eintrag   weg
    Autostart           weg
    Verknuepfungen      weg
    Dateien             149 NOCH DA  (davon 148 in fenster\EBWebView\)

Gemeldet wurde: "Rippy wurde entfernt."

Zwei Ursachen, beide behoben:

## 1. WebView2 ueberlebt Rippy

WebView2 startet eigene Prozesse (msedgewebview2.exe: Renderer, GPU, Netz,
Crashpad). Stirbt Rippy, laufen sie als Waisen weiter und halten den
Zwischenspeicher offen -- acht davon liefen noch, als ich nachsah. shutil.rmtree
scheiterte an jeder gesperrten Datei.

fenster.helfer_beenden() beendet sie jetzt VOR dem Loeschen. Gefiltert wird
ueber den --user-data-dir in der Befehlszeile: msedgewebview2.exe benutzen
auch andere Programme, und die alle zu beenden waere ein Uebergriff -- der
Nutzer verloere die Fenster fremder Anwendungen. Ein Test haelt fest, dass
erst gefiltert und dann beendet wird.

## 2. Der Fehler wurde verschluckt

`except OSError: pass` in der Loeschschleife. Ein "Rippy wurde entfernt" ueber
einem halb geleerten Ordner ist eine Falschaussage -- genau die Sorte stiller
Fehlschlag, vor der AGENTS.md warnt. Jetzt wird gesammelt, was nicht ging, und
mit dem Grund genannt.

Dazu im Aufraeum-Skript: `rmdir /s /q` statt `rmdir`. Ein blosses rmdir
scheitert an JEDER verbliebenen Datei und laesst den ganzen Ordner stehen --
selbst wenn spaeter nur noch eine Sperrdatei uebrig ist.

Ampel lokal: 612 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:58 +02:00
HitonabiandClaude Opus 5 ce4e7e6b8a feat(windows): das Setup richtet MakeMKV und HandBrake wirklich ein
Ampel / ampel (push) Successful in 54s
Commander: "handbrake und makemkv MUESSEN mitgeliefert werden oder waehrend
des Setups separat installiert werden! Ohne das ist das tool NICHT
einsatzfaehig."

Er hat recht, und die Luecke war real: katalog.py konnte die Werkzeuge
finden, beschaffen.py konnte sie holen -- das Setup rief beides nie auf. Wer
Rippy auf einem frischen Rechner installierte, bekam eine Oberflaeche, die
ihm sagte was fehlt, und keinen Weg, es zu aendern.

Die beiden gehen unterschiedliche Wege, und der Grund ist die LIZENZ:

* HandBrakeCLI ist GPL-2 -> mitgeliefert. Weitergabe ausdruecklich erlaubt,
  solange Lizenztext und Quellverweis dabei sind (LIZENZ-HandBrake.txt liegt
  daneben). Damit komprimiert Rippy auch ohne Internet.
* MakeMKV ist proprietaer -> beim Einrichten vom Hersteller geholt und mit
  DESSEN Installer installiert. Weitergabe durch Dritte ist nicht erlaubt.
  Das ist dieselbe Black-Box-Trennung wie in KONZEPT.md 6 und deckt sich mit
  KONZEPT-V2.md 4.1 ("MakeMKV wird NICHT mitgeliefert").

Drei Regeln, die dazugehoeren:

1. Ein Setup meldet NIE Erfolg, waehrend ein Pflichtwerkzeug fehlt.
   sicherstellen() liest den Bestand VOR und NACH dem Versuch und gibt
   zurueck, was danach wirklich da ist -- nicht, dass es versucht wurde.
2. Ein nicht erreichbarer Download bricht die Installation nicht ab. Am
   28.08.2026 antwortete makemkv.com mit HTTP 525. Rippy ist dann trotzdem
   installiert und sagt im Klartext, was fehlt und wie man es beschafft.
3. Ohne HandBrake ist Rippy einsatzbereit (Rip laeuft, nur ohne Kompression),
   ohne MakeMKV nicht. Nur Pflichtwerkzeuge entscheiden ueber "bereit".

Nachgemessen am fertigen Setup: HandBrake geloescht, installiert, nach DREI
Sekunden wieder da (74 MB, also aus dem Paket und nicht geladen), Meldung
"Rippy ist einsatzbereit. HandBrakeCLI 1.11.2 / MakeMKV 1.18.4".
RippySetup.exe waechst von 32,0 auf 56,4 MB.

fix(tools): der Fortschritts-Rueckruf bekommt immer einen Text

_datei_laden schickte `None` als Text, wenn sich nur der Fortschritt geaendert
hatte. Der erste echte Aufrufer starb daran:

    can only concatenate str (not "NoneType") to str

Ein Rueckruf, den man nur mit einer nirgends dokumentierten Sonderbehandlung
benutzen kann, ist eine Falle. Jetzt kommt immer ein Satz, gedrosselt auf
jedes zehnte Prozent -- 24 MB in 256-KB-Haeppchen waeren sonst hundert Zeilen
im Protokoll. Zwei Tests halten beides fest.

Nicht im Repo: Die 70,9 MB HandBrakeCLI holt der BAU nach
dist/windows/vendor/ (nicht versioniert) -- dasselbe Muster wie der
vendor/-Ordner fuer MakeMKV auf der VM.

Ampel lokal: 607 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:09:01 +02:00
HitonabiandClaude Opus 5 60c0f93237 fix(windows): leere Symbole und der verschwundene Eintrag in "Installierte Apps"
Ampel / ampel (push) Successful in 54s
Beides vom Commander gemeldet, beides gemessen statt vermutet.

## 1. Weisses leeres Blatt statt Symbol

In deploy/worker-windows/rippy.ico steckte GENAU EIN Bild:

    Bilder = 1
      256x256   32 bit   5657 Bytes   PNG

Windows holt sich fuer jede Stelle die passende Groesse -- 16x16 fuer die
Taskleiste, 32/48 fuer Desktop und Startmenue. Fehlt sie, skaliert die Shell
nicht zuverlaessig selbst, sondern zeigt das leere Blatt. Bei einer Datei mit
nur einem PNG-komprimierten 256er passiert das regelmaessig.

packaging/windows/icon.py baut die Datei jetzt mit neun Groessen
(16/20/24/32/40/48/64/128/256, LANCZOS beim Verkleinern). Nachgemessen an
der neuen EXE: 16x16 mit 138 Farben, 32x32 mit 264 -- kein leeres Blatt mehr.
build.py laesst ein unvollstaendiges Symbol gar nicht mehr durch, und
test_icon.py haelt die Pflichtgroessen fest (mit dem alten Stand rot gesehen).

## 2. Rippy stand nicht in "Installierte Apps"

Die Ursache war MEINE Testsuite. Gemessen:

    Rippy.exe --nicht-starten   ->  Eintrag: DA  (Rippy 2.0.0)
    pytest -q                   ->  Eintrag: WEG

test_installation_legt_die_dateien_an_... schrieb in den ECHTEN
Uninstall-Schluessel und loeschte ihn im finally wieder -- also auch den des
Commanders. Der Autostart-Eintrag ging denselben Weg.

Der Schluesselname stand als VORGABEWERT in jeder Signatur, und Vorgabewerte
wertet Python zur Definitionszeit aus: Ein Test konnte ihn gar nicht umbiegen.
Jetzt steht dort None, aufgeloest zur Laufzeit -- damit wirkt ein
monkeypatch.setattr(reg, "SCHLUESSEL", ...) ueberall.

Dazu ein Waechter (test_die_testsuite_ruehrt_den_ECHTEN_eintrag_nicht_an):
Er merkt sich den echten Zustand vorher, laesst eine vollstaendige
Test-Installation samt Autostart laufen und prueft danach, dass sich am
echten Eintrag nichts geaendert hat.

Das ist derselbe Fehler wie vorhin bei den Verknuepfungen, nur an anderer
Stelle. Die Regel gehoert in den Code, nicht in meinen Kopf: **Ein Test
raeumt nur weg, was er selbst angelegt hat.**

Ampel lokal: 590 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:57:50 +02:00
HitonabiandClaude Opus 5 e1984dc543 docs: SAVEPOINT — Ampel wieder gruen, und warum sie fuenf Laeufe rot war
Ampel / ampel (push) Successful in 54s
Nachgetragen: die 28 Linux-Fehler, der schwerste davon (der API-Container
waere auf der VM gar nicht hochgekommen), und die Lehre daraus — ein Test,
der nur auf einer Plattform greift, ist eine halbe Zusage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:46:22 +02:00
HitonabiandClaude Opus 5 ca212ff835 fix(test): die detection-Attrappe muss auch das PAKET-Attribut ersetzen
Ampel / ampel (push) Successful in 54s
Ampel-Lauf 186: Die 28 echten Linux-Fehler waren weg, uebrig blieben fuenf
— alle in meinem neuen Test:

    TypeError: drive_status() missing 1 required positional argument

`from rippy.drives import detection` sieht zuerst im PAKET nach
(`_handle_fromlist` prueft `hasattr`) und greift erst dann auf sys.modules
zurueck. Auf Linux ist das echte Modul dort laengst gesetzt, weil es
importierbar ist — die Attrappe blieb wirkungslos, und der Test lief in die
echten Funktionen.

Unter Windows fiel das nicht auf: Dort gibt es das Attribut mangels fcntl
gar nicht, also griff sys.modules. Derselbe Test, zwei Plattformen, zwei
Ergebnisse — genau die Sorte Luecke, die dieser Test eigentlich schliessen
soll.

Jetzt werden beide ersetzt. Gegengeprueft: mit kaputter Durchreiche fuenf
Fehlschlaege, danach wieder gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:44:05 +02:00
HitonabiandClaude Opus 5 bfc0cada62 fix(windows): kein CMD-Fenster mehr — die EXE ist ein Fenster-Programm
Ampel / ampel (push) Failing after 55s
Commander: "Unterbinde das CMD Fenster was sich beim oeffnen mitoeffnet."

Mein erster Anlauf wollte die Konsole VERSTECKEN und war falsch, aus zwei
Gruenden:

* Eine Onefile-EXE ist ZWEI Prozesse (gemessen: PID 33172 mit 8 MB, PID
  33580 mit 103 MB, beide Rippy.exe). Die Pruefung "haengt genau EIN Prozess
  an der Konsole" war damit nie wahr, das Fenster blieb einfach stehen.
* Selbst wenn sie ginge, waere das Fenster erst nach ein bis zwei Sekunden
  weg. Ein Aufblitzen ist kein "unterbunden".

Jetzt wird die Konsole gar nicht erst erzeugt: --windowed statt --console.
Nachgemessen am fertigen Bau -- PE-Subsystem 2 (GUI) statt 3 (Konsole), und
beim Klick auf die Desktop-Verknuepfung ist genau EIN sichtbares Fenster da:
die WinForms-Oberflaeche. Kein ConsoleWindowClass-Fenster von Rippy, weder
sichtbar noch unsichtbar.

Was dazugehoert, damit daraus kein stiller Fehlschlag wird:

* Ohne Konsole ist sys.stdout None. uvicorn schreibt auf stderr und waere
  mitten im Start gestorben, ohne dass irgendwo etwas stuende. einstieg.py
  haengt sich deshalb an die Konsole des Aufrufers (--status in PowerShell
  schreibt weiter dorthin) oder leitet nach %LOCALAPPDATA%\Rippy\rippy.log um.
* Der Installer meldet sein Ergebnis in einem Fenster, wenn keine Konsole da
  ist -- sonst saehe ein gescheiterter Doppelklick aus wie einer, der nichts
  tut. Bei --still und mit Konsole bleibt es bei der Textzeile.

Gegengeprueft am laufenden Programm: /api/health, / und /api/capabilities
antworten mit 200, und rippy.log faengt auf, was vorher ins Leere ging.

fix(drives): linux.py reicht die detection-Funktionen wieder durch

Die Ampel war seit Lauf 181 rot -- vier Commits lang, und ich hatte es
uebersehen. Ursache:

    docker/api/main.py:54
    AttributeError: module 'rippy.drives.linux' has no attribute 'drive_status'

main.py holt sich beim Import device_discovery.drive_status. Der
Windows-Treiber hat die Funktion; linux.py hatte sie nicht mehr, seit
detection (und damit fcntl) bewusst nicht mehr oben importiert wird -- sonst
waere main.py unter Windows nicht ladbar. Unter Windows lief also alles, auf
Linux waere der API-Container gar nicht hochgekommen.

Behoben mit PEP 562 (__getattr__), demselben Kniff wie in store/__init__.py:
Der Import bleibt ohne fcntl moeglich, wer die Funktionen BENUTZT bekommt sie.

Dazu drei weitere Fehler, die nur auf Linux auftraten:

* katalog.py baute Windows-Pfade mit os.path.join. Auf dem Linux-Runner wurde
  daraus C:\Program Files (x86)\MakeMKV/makemkvcon64.exe. Jetzt entscheidet
  der Pfad selbst ueber den Trenner, nicht die rechnende Maschine.
* verknuepfungen.py nahm os.path.dirname fuer den Arbeitsordner einer .lnk.
  Eine .lnk zeigt IMMER auf einen Windows-Pfad -- also ntpath.
* waechter.py las 0.0 als Zeitpunkt statt als "noch nie geprueft". Auf einer
  frisch gestarteten Maschine ist time.monotonic() klein, also fiel die erste
  Laufwerks-Abfrage aus. None statt 0.0.

Alle vier haben jetzt Tests, die auf JEDER Plattform laufen -- der
Treiber-Test schiebt eine detection-Attrappe unter, der Waechter-Test stellt
eine Uhr bei 0,5 s nach, die Pfad-Tests pruefen beide Zweige. Beim
Wegnehmen der Durchreiche rot gesehen (5 Fehlschlaege), danach wieder gruen.
Damit faengt die Ampel so etwas kuenftig, ohne dass es eine Linux-Umgebung
auf dem Entwicklungsrechner braucht.

Ampel lokal: 582 gruen, ruff sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:41:10 +02:00
HitonabiandClaude Opus 5 e1577a4f47 docs: SAVEPOINT v4.0-rc — Windows ist ein echtes Programm
Ampel / ampel (push) Failing after 55s
Der SAVEPOINT stand seit V2-3 still, waehrend elf Commits dazukamen.
Nachgetragen mit den gemessenen Zahlen: 562 gruen, RippySetup.exe 31,7 MB,
WebView2 151.0.4129.107, 5 Encoder auf diesem PC.

Zwei Dinge stehen bewusst gross drin, weil sie sonst verloren gehen:

* Der Dienst, der /api/health mit 200 beantwortete und / mit 404 -- und
  warum ein %TEMP%-Verzeichnis kein Ort fuer eine Oberflaeche ist.
* Der Test, der den PC des Commanders getroffen hat. Ein Test, der Spuren
  ausserhalb von tmp_path hinterlaesst, ist kein Test, sondern ein Eingriff.

Und was NICHT bewiesen ist, steht als solches da: Ein echter Rip unter
Windows ist nie gelaufen, die VM haengt elf Commits zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:15:29 +02:00
HitonabiandClaude Opus 5 c01ef1081c feat(windows): echtes Fenster statt Browser-Tab (WebView2 statt Electron)
Auf die Frage des Commanders: "Warum nutzen wir fuer Windows weiterhin
einen Browser? Warum nutzen wir kein Electron oder sowas und machen daraus
einen echten Client."

Die erste Haelfte trifft zu und ist umgesetzt. Die zweite ist abgelehnt --
gemessen, nicht geschaetzt:

    pywebview + WebView2     8,0 MB in der Bau-Umgebung, 2,7 MB in der EXE
    Electron               150-210 MB, dazu eine zweite Laufzeitumgebung

Windows 11 bringt die WebView2-Laufzeit mit (hier 151.0.4129.107).
Nachgemessen statt angenommen, was sie rendert:

    navigator.userAgent  ...Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0
    fetch ja   EventSource ja   CSS Grid ja   Pfeilfunktionen ja

Dasselbe Chromium, das auch in Electron steckt -- nur ohne es ein zweites
Mal mitzuschleppen. RippySetup.exe waechst von 29,0 auf 31,7 MB.

Drei Dinge gehoeren dazu, nicht als Beiwerk:

* Kein stiller Rueckfall auf MSHTML. Der alte IE-Renderer stellt die
  React-Oberflaeche nicht dar. Fehlt WebView2, oeffnet Rippy den Browser
  und SAGT warum -- ein leeres Fenster waere schlimmer als ein Tab.
* Fenster und Tray sind getrennte Prozesse. pystray belegt unter Windows
  den Haupt-Thread, das Fenster braucht ihn genauso.
* Die eigene Konsole wird versteckt, eine geerbte nie. GetConsoleProcessList
  unterscheidet beides: genau ein Prozess an der Konsole heisst, sie
  gehoert uns. Ohne diese Unterscheidung haette "Rippy.exe --status" im
  Terminal das Terminal des Nutzers verschwinden lassen.

fix(windows): Oberflaeche neben das Programm legen statt aus %TEMP% bedienen

Beim Nachsehen im laufenden Betrieb gefunden: Ein Dienst lief eine Stunde,
/api/health gab HTTP 200, / gab 404. Im Fenster stand {"detail":"Not Found"}.

Der Server war in Ordnung. Sein Entpack-Verzeichnis war es nicht:

    _MEI000074b02   31 Eintraege,  5 Ordner   -- kein ui, kein api
    _MEI000082e82   44 Eintraege, 16 Ordner   -- vollstaendig

Eine PyInstaller-Onefile-EXE liest bei JEDER Anfrage aus %TEMP%\_MEIxxxxx.
Ein Temp-Verzeichnis ist kein Ort fuer etwas, das eine Woche liegen bleibt.
Und der Ausfall ist der schlimmstmoegliche: Die API antwortet weiter, der
Dienst gilt als gesund, nur die Oberflaeche ist weg.

Genau davor warnte ROADMAP.md beim Bau-Verfahren ("one-dir statt one-file").
Die Abweichung bleibt, die Luecke wird geschlossen: Der Installer legt die
Oberflaeche neben das Programm, daemon._ui_pfad() nimmt diese Kopie zuerst.
Zeigt sich derselbe Ausfall an den API-Modulen, ist one-dir die Antwort.

Ampel: 562 gruen, ruff sauber. Fenster mit der echten Oberflaeche im
Bildschirmfoto nachgewiesen, nicht nur der Titel geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 12:14:15 +02:00
HitonabiandClaude Opus 5 069fc46f44 fix(test): der Installations-Test hat die ECHTEN Verknuepfungen ueberschrieben
Ampel / ampel (push) Failing after 47s
WAS: `test_installation_legt_die_dateien_an_...` installiert in ein
tmp_path — legt aber seit dem Verknuepfungs-Einbau auch Symbole auf dem
ECHTEN Desktop und im ECHTEN Startmenue an. Die kennen kein tmp_path.

DIE FOLGE, vom Commander gemeldet: Er klickte auf das Desktop-Symbol und
bekam von Windows "Diese App kann auf dem PC nicht ausgefuehrt werden".
Nachgemessen zeigte die Verknuepfung auf

  ...\Temp\pytest-of-TobisPC\pytest-142\test_installation_..._0\installiert\Rippy.exe

— und dort liegt die 2-KB-ATTRAPPE aus dem Test, kein Programm. Windows
hatte voellig recht.

Der `finally`-Block raeumte nur Registry und Autostart auf; die
Verknuepfungen nicht, weil es sie beim Schreiben des Tests noch nicht gab.
Als sie dazukamen, wuchs der Test stillschweigend ueber sein tmp_path
hinaus. Ein Test, der Spuren ausserhalb seines Temp-Ordners hinterlaesst,
ist kein Test, sondern ein Eingriff.

BEHOBEN mit ZWEI Sicherungen statt einer:
  1. verknuepfen=False — die Funktion legt gar keine an.
  2. vk.alle_anlegen ist trotzdem ersetzt. Wer den Schalter spaeter
     umdreht oder die Vorgabe aendert, kann damit trotzdem nichts am
     echten System anrichten.

Dazu ein Waechter: test_installation_ruehrt_die_echten_verknuepfungen_
nicht_an prueft, dass die Anlege-Funktionen bei verknuepfen=False GAR
NICHT gerufen werden. Waere er frueher dagewesen, haette der Commander
keine kaputte Verknuepfung bekommen.

DER RECHNER IST REPARIERT: neu installiert, beide Verknuepfungen zeigen
wieder auf C:\Users\...\AppData\Local\Rippy\Rippy.exe (29 MB, existiert),
Programme+Features-Eintrag ist zurueck. Start ueber die Verknuepfung:
HTTP 200 nach 2 s, Worker TobisNicerPC mit cpu-x264/x265/av1 + vce/vce-av1,
MakeMKV 1.18.4 und HandBrake 1.11.2 gefunden.

Nebenbei ausgeschlossen, bevor die Ursache klar war: Die EXE selbst ist in
Ordnung (gueltiges x64-PE, MZ/PE-Kopf, 29 MB, Hash identisch mit dem
Installat), kein Mark-of-the-Web, kein Defender-Fund, Smart App Control
aus. Der Fehler lag nicht an der Datei, sondern daran, WORAUF gezeigt wurde.

GEMESSEN: ruff sauber, 522 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:47:11 +02:00
HitonabiandClaude Opus 5 99c05865af feat(windows): Desktop-Symbol und Startmenue-Eintrag — plus --oeffnen
Ampel / ampel (push) Failing after 47s
WAS: Das Setup legt jetzt eine Verknuepfung auf dem Desktop UND im
Startmenue an, die Deinstallation nimmt beide zurueck, und `--status`
zeigt, ob sie da sind. Abwaehlbar mit --keine-verknuepfungen.

WARUM (Commander-Meldung): "es gibt kein icon aufm desktop und keinen
startmenü eintrag". Zu Recht — ein Hintergrundprogramm ohne Symbol ist
praktisch unauffindbar: Es laeuft, aber es gibt keinen Weg hinein ausser
der Adresse im Kopf.

DIE VERKNUEPFUNG ZEIGT AUF --oeffnen, NICHT AUF --dienst. Ein Doppelklick
soll Rippy OEFFNEN; ob der Dienst laeuft, ist dem Nutzer egal und er kann
es auch nicht wissen. Zeigte das Symbol auf --dienst, startete jeder Klick
einen ZWEITEN Server, der am belegten Port scheitert — das Fenster blitzt
auf, im Browser passiert nichts. Ein Symbol, das beim zweiten Klick nichts
tut, ist schlimmer als keins.

`--oeffnen` sieht deshalb erst NACH, ob jemand antwortet, startet nur wenn
noetig, und wartet dann, bis der Dienst wirklich da ist — statt sofort
einen Browser auf eine tote Adresse zu schicken. Gemessen: erster Aufruf
2 s bis HTTP 200, zweiter Aufruf keine zusaetzlichen Prozesse.

DIE ORDNER KOMMEN AUS DER REGISTRY, nicht aus %USERPROFILE%. 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.

UEBER POWERSHELL statt pywin32: Eine .lnk ist ein binaeres Shell-Objekt.
pywin32 koennte es, waere aber 20 MB fuer vier Zeilen. Denselben Weg geht
der alte Worker-Installer schon ("nativ via PowerShell").

ZWEI FEHLER, DIE DIE TESTS GEFUNDEN HABEN:
- os.makedirs stand AUSSERHALB des try. Auf einem Laufwerk, das es nicht
  gibt, wirft es — aus "Verknuepfung konnte nicht angelegt werden" waere
  ein Abbruch der ganzen Installation geworden.
- Der Test dafuer nahm fest Z: als "gibt es nicht". Auf dem Commander-PC
  ist Z: ein NETZLAUFWERK (X, Y, Z sind belegt). Ein fest verdrahteter
  Laufwerksbuchstabe ist eine Annahme ueber eine fremde Maschine; jetzt
  wird zur Laufzeit ein freier gesucht.

Und der Apostroph: In PowerShell wird er innerhalb einfacher
Anfuehrungszeichen durch VERDOPPELN maskiert. Ohne das braeche ein Pfad wie
C:\Users\O'Brien\... das Skript entzwei, und der Fehler saehe aus wie ein
Syntaxfehler in Rippy.

GEMESSEN, nach echter Installation nach %LOCALAPPDATA%\Rippy:
  Desktop-Symbol   -> Rippy.exe --oeffnen, mit Symbol und Beschreibung
  Startmenue       -> ebenso
  Programme+Feat.  -> Rippy 2.0.0, 100 MB, Deinstallieren funktioniert
  Autostart        -> "...\Rippy.exe" --dienst
  Start ueber das Symbol: HTTP 200 nach 2 s
  Worker: TobisNicerPC, Encoder cpu-x264/x265/av1, vce, vce-av1

GEMESSEN: ruff sauber, 521 Tests gruen + 15 uebersprungen (vorher 512).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:40:39 +02:00
HitonabiandClaude Opus 5 f4a8d77598 feat(windows): Rippy arbeitet eigenstaendig — Rippen, Werkzeuge, Fähigkeiten
Ampel / ampel (push) Failing after 48s
WAS: Der Ablauf ist aus tasks.py heraus (ablauf.py, ohne Celery), die
LocalQueue wird bedient, ein Laeufer arbeitet Auftraege im selben Prozess
ab, und Rippy meldet sich mit gemessenen Faehigkeiten selbst als Arbeiter.

WARUM: "Rippy fuer Windows soll standalone funktionieren" (Commander). Bis
hierher konnte die Windows-App alles ANZEIGEN und nichts TUN — ein Rip waere
eingereiht worden und fuer immer liegengeblieben, weil niemand ihn holt.

DIE TRENNUNG: rip_disc hing an GENAU DREI Celery-Stellen in 234 Zeilen —
self.update_state, _transcode_queue, transcode_files.apply_async. Alle drei
sind Fragen der ZUSTELLUNG, nicht des Ablaufs. Sie sind jetzt Rueckrufe:
tasks.py reicht die Celery-Fassung herein, standalone.py die lokale. OHNE
Rueckruf komprimiert derselbe Prozess weiter — genau das, was ein
Ein-Prozess-Rippy braucht. Der Ablauf selbst ist Zeile fuer Zeile derselbe;
der Docker-Betrieb merkt vom Umbau nichts (Task-Namen, Argumente, Queues
unveraendert).

EINE ZUSTELL-STELLE statt drei: celery_client.abschicken() bedient alle
Auftragsarten. Vorher rief jede Stelle send_task selbst auf — der
Standalone-Betrieb haette an drei Stellen umgebogen werden muessen, beim
naechsten Auftragstyp an einer vierten.

WEITERER BLOCKER GEFUNDEN: ablauf.py holte detect_disc_type fest aus dem
LINUX-Treiber, in einem try/except. Unter Windows waere es damit IMMER None
gewesen und Rippen "hart verriegelt" — Rippy haette alles angezeigt und
nichts gerippt, ohne dass irgendwo ein Fehler stuende. Jetzt fragt es den
Treiber-Port.

WERKZEUGE: ripping.py und caps.py suchten nur im PATH. Auf dem Commander-PC
gemessen, vorher/nachher:

  vorher   check_makemkv_installed()  -> False (obwohl installiert)
           erkenne_encoder()          -> nur CPU
  nachher  MakeMKV   1.18.4  C:\Program Files (x86)\MakeMKV\makemkvcon64.exe
           HandBrake 1.11.2  ueber die API geholt, in 2,7 s
           Encoder   cpu-x264, cpu-x265, cpu-av1, VCE, VCE-AV1
           107 Presets, Ryzen 7 9700X, 16 Kerne, avx512f

VCE ist die Hardwarebeschleunigung der Radeon — die hat Rippy auf diesem
Rechner vorher nie gesehen, weil es HandBrake gar nicht fand.

NEUE ROUTEN: GET /system/werkzeuge (was liegt wo, in welcher Fassung, gibt
es Neueres) und POST /system/werkzeuge/{name}/holen. HandBrake kommt
vollautomatisch von GitHub. MakeMKV wird NICHT mitgeliefert — Rippy laedt
die offizielle Datei und startet sie (Black-Box-Trennung, KONZEPT.md § 6).
makemkv.com antwortete beim Bauen mit HTTP 525; das wird im Klartext
gemeldet, und eine selbst geholte Datei bleibt moeglich.

HERZSCHLAG: /capabilities las die workers-Tabelle, die bisher nur der
Celery-Herzschlag fuellte. Im Standalone-Betrieb stand dort "0 Worker" und
die Encoder-Auswahl im UI blieb LEER — auf einem Rechner, der alles kann.
Jetzt meldet sich der Prozess selbst, mit dem, was caps.py MISST.

GEMESSEN, aus der fertigen EXE (29,0 MB):
  bereit nach 1 s, keine Fehler im Log
  Werkzeuge: beide gefunden, mit Version und Pfad
  Worker: 1 (TobisNicerPC), 5 Encoder, 107 Presets

Die ganze Kette ist als Test festgehalten (test_kette.py): zustellen ->
einreihen -> Laeufer -> ablauf -> Job endet in einem EHRLICHEN Zustand.
Ohne Laufwerk geprueft, und das ist der wichtigere Fall: Ein Rip auf ein
totes Geraet muss zuegig scheitern, nicht auf "pending" haengenbleiben.

GEMESSEN: ruff sauber, 512 Tests gruen + 15 uebersprungen (vorher 489).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:27:54 +02:00
HitonabiandClaude Opus 5 288f9eeb8d feat(tools): Werkzeuge finden, holen und aktuell halten — Grundlage fuer Windows
Ampel / ampel (push) Failing after 47s
WAS: rippy/tools/ mit katalog.py (finden + Version) und beschaffen.py
(herunterladen, installieren, Update-Stand). ripping.py und caps.py fragen
den Katalog statt shutil.which.

DER BEFUND, DER DAS NOETIG MACHT: Der Bestand suchte AUSSCHLIESSLICH mit
shutil.which(). Im Container stimmt das — dort liegen beide Werkzeuge in
/usr/local/bin. Unter Windows findet es NICHTS, auch wenn alles installiert
ist: Windows-Programme liegen in Program Files, nicht im PATH. Am
28.08.2026 auf dem Commander-PC nachgesehen — MakeMKV lag unter
"C:\Program Files (x86)\MakeMKV\makemkvcon64.exe", which sah es nicht.

Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das der
Unterschied zwischen "laeuft" und "laeuft nicht". Vorher/nachher gemessen:

  vorher   check_makemkv_installed() -> False   (obwohl installiert)
  nachher  check_makemkv_installed() -> True
           build_makemkv_cmd()[0]    -> C:\Program Files (x86)\MakeMKV\makemkvcon64.exe

SUCHREIHENFOLGE, mit Begruendung im Modul: eingestellt > Rippys eigener
Werkzeug-Ordner > PATH > bekannte Installationsorte > Uninstall-Zweig der
Registry. Rippys eigener Ordner steht VOR dem System, damit eine selbst
gepflegte Fassung eine alte Systeminstallation schlaegt.

VERSION: HandBrakeCLI kann --version. makemkvcon KANN DAS NICHT (usage.txt
kennt keinen solchen Schalter) — die Version kommt aus dem Uninstall-Zweig.
Dort steht bei MakeMKV zwar eine Version, aber InstallLocation ist LEER
(nachgesehen); deshalb wird der Ordner ersatzweise aus DisplayIcon bzw.
UninstallString abgeleitet. Der Test dafuer hat sofort einen Fehler
gefunden: '"C:\...\uninstall.exe" /S' liess sich mit einem blossen
strip('"') nicht aufloesen.

BESCHAFFUNG, an echter Hardware gemessen:
  HandBrake  GitHub-Release-API -> 1.11.2, HandBrakeCLI-1.11.2-win-x86_64.zip
             geladen, entpackt, gestartet: meldet sich als 1.11.2. 2,8 s.
  MakeMKV    makemkv.com antwortete mit HTTP 525 (Cloudflare) — auch mit
             Browser-Kennung. Dieselbe Sperre, die im Projekt schon den
             Docker-Bau lahmlegt. Deshalb: Abruf wird versucht, ein
             Fehlschlag wird im Klartext gemeldet, und eine selbst geholte
             Datei laesst sich weiterhin verwenden. Ein Update-Server, der
             heute antwortet, darf keine Startbedingung fuer morgen sein.

DREI FALLEN, GEGEN DIE ES TESTS GIBT:
- Die .sig-Datei liegt im Release direkt neben dem ZIP und ist 566 Bytes
  gross. Wer nur nach "win" filtert, laedt sie.
- "1.9.2" ist als Zeichenkette GROESSER als "1.11.2". Ein Update-Hinweis, der
  ab Version zehn dauerhaft in die Irre fuehrt — und genau dort ist
  HandBrake gerade.
- Eine Fehlerseite kommt oft mit HTTP 200. Sie darf keine funktionierende
  Installation ersetzen: erst laden, pruefen, dann tauschen.

Und: Ist die neueste Version unbekannt (Quelle tot), steht NICHT "Update
verfuegbar" da. Eine Nichtauskunft ist keine Aussage.

GEMESSEN: ruff sauber, 489 Tests gruen + 15 uebersprungen (vorher 456).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:12:21 +02:00
HitonabiandClaude Opus 5 059183c651 feat(windows): V2-4 fertig — RippySetup.exe, Taskmanager-Eintrag, Deinstallation
Ampel / ampel (push) Failing after 46s
WAS: Eine 28,6-MB-Datei, drei Betriebsarten. Doppelklick installiert,
`--dienst` laeuft im Hintergrund, `--deinstallieren` raeumt sich weg.
Dazu packaging/windows/build.py, das sie baut.

DIE ZWEI COMMANDER-WUENSCHE, GEMESSEN:

1. EINTRAG IM TASKMANAGER
     ProcessName   Rippy
     Beschreibung  Rippy — automatisches Ripping
     Produkt       Rippy
     Version       2.0.0
   Der Prozessname kommt vom Dateinamen (bei der Installation kopiert sich
   die Datei als Rippy.exe), die Beschreibung aus einer eingebetteten
   Versions-Ressource. Ohne die steht dort nichts, und Windows SmartScreen
   sieht eine EXE ohne Herausgeberangabe genauso misstrauisch wie ein Mensch.

2. EINTRAG IN PROGRAMME UND FEATURES
   Aus der Registry zurueckgelesen, nicht behauptet:
     DisplayName      Rippy
     DisplayVersion   2.0.0
     Publisher        Rippy
     InstallLocation  <Zielordner>
     UninstallString  "<pfad>\Rippy.exe" --deinstallieren
     EstimatedSize    29241  (KiB)
     NoModify/NoRepair 1
   HKCU statt HKLM: Der Eintrag erscheint genauso in "Apps & Features", die
   Installation laeuft aber ohne UAC-Abfrage durch.

VOLLER KREISLAUF GEMESSEN: installieren -> Rippy.exe + rippy.ico liegen da,
Registry-Eintrag da, Autostart da; Dienst starten -> nach 1 s bereit, alle
sieben Endpunkte HTTP 200, Oberflaeche kommt; deinstallieren -> nach ~4 s
sind Datei, Ordner, Registry-Eintrag, Autostart UND das Aufraeum-Skript weg.

VIER FEHLER, DIE NUR DIE FERTIGE EXE ZEIGT:

1. rippy/store baute die Engine BEIM IMPORT, mit PostgreSQL als Vorgabe.
   Im Windows-Paket gibt es psycopg2 bewusst nicht -> die EXE starb sofort.
   Die Engine entsteht jetzt beim ersten Zugriff. Und der Fehler war
   grundsaetzlicher als der fehlende Treiber: Ein Modul, das beim Import
   schon eine Verbindung aufbaut, laesst sich gar nicht mehr umstellen —
   store.verbinden() kaeme immer zu spaet.

2. celery_client baute den Broker-Client beim Import. Dasselbe Muster, und
   `from celery_client import celery_client` loeste es sogar dann aus, wenn
   nie ein Rip angestossen wird. Jetzt traege, mit KeinBroker als klarer
   Ansage statt eines Importfehlers.

3. Die Selbstloeschung beim Deinstallieren ging als EIN Argument an cmd.
   Python maskiert dabei die inneren Anfuehrungszeichen — cmd suchte einen
   Dateinamen mit Backslashes davor, fand nichts und meldete nichts (>nul).
   Registry und Autostart waren weg, die 28-MB-Datei blieb liegen: ein still
   scheiternder Hintergrundprozess, wie ihn AGENTS.md beschreibt. Jetzt ein
   Aufraeum-Skript, das 15-mal versucht (die Onefile-EXE laeuft als ZWEI
   Prozesse; der Starter haelt die Datei nach dem Ende noch offen).

4. deinstallieren() raeumte den STANDARD-Ordner auf statt des tatsaechlichen.
   Wer nach --ziel D:\Rippy installiert hatte, dessen Dateien waeren
   geblieben, waehrend ein fremder Ordner angefasst worden waere. Der Pfad
   steht in der Registry (InstallLocation) und wird jetzt dort gelesen.

WAS NOCH NICHT GEHT, ausdruecklich: Ein RIP laesst sich unter Windows noch
nicht anstossen — die Zustellung laeuft weiter ueber Celery. Die Umstellung
auf die LocalQueue ist V2-5. Oberflaeche, Laufwerks-Erkennung, Auswurf und
alles Lesende laufen.

GEMESSEN: ruff sauber, 456 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:00:39 +02:00
HitonabiandClaude Opus 5 e5254c23f0 feat(daemon): V2-4 (Teil 3) — rippyd laeuft unter Windows, ohne Docker
Ampel / ampel (push) Failing after 46s
WAS: `rippyd` startet Rippy als EINEN Prozess — SQLite statt Postgres,
LocalQueue statt Celery/Redis, In-Process-Bus statt Redis Pub/Sub, UI von
FastAPI statt von nginx. Dieselbe API, dasselbe UI, derselbe Rip-Code.

GEMESSEN (Windows 11, frische Python-3.12-Umgebung, ohne Docker/Postgres/Redis):

  bereit nach            1 s
  /api/health            200      /api/settings          200
  /api/devices           200      /api/logs              200
  /api/jobs              200      /api/capabilities      200
  /api/health/vorraete   200      /  (Weboberflaeche)    200

  Ereignis-Waechter      gesund: true, alter_sekunden: 0.5
  SSE-Strom              liefert `event: snapshot` mit vollem Zustand
  SQLite                 angelegt, WAL aktiv, alle Tabellen da

ZWEI FEHLER, DIE NUR DER ECHTE LAUF ZEIGT:

1. STARLETTE REICHT LIFESPAN NICHT AN MOUNT-UNTERANWENDUNGEN WEITER.
   Das UI ruft /api/..., im Docker-Betrieb entfernt der nginx das Praefix.
   Ohne nginx haengt die API als Unteranwendung unter /api — und ihr
   startup_event lief nie. Folge: db.init_db() nicht ausgefuehrt,
   "no such table: settings", HTTP 500 auf /jobs, /settings, /logs,
   /capabilities, und der SSE-Strom blieb stumm. /api/health antwortete
   trotzdem mit 200, weil die Route keine Datenbank braucht — der
   Fehlschlag sah also aus wie "laeuft". Behoben ueber einen eigenen
   lifespan, der den der Unteranwendung mit ausloest.

2. EIN print() HAT DEN GANZEN START UMGEBRACHT.
   UnicodeEncodeError: 'charmap' codec can't encode characters
   -> ERROR: Application startup failed. Exiting.
   Windows-Konsolen arbeiten mit cp1252; Rippys Meldungen sind deutsch und
   voller Umlaute und Warnzeichen. Das Bittere: Es war nicht die WARNUNG,
   die den Start verhindert hat, sondern der VERSUCH, sie auszugeben.
   Behoben mit encoding="utf-8" UND errors="replace" — eine Ausgabe darf
   unter keinen Umstaenden etwas abbrechen koennen.

BEFUND ZUR HARDWARE (kein Codefehler): Nach dem Auswurf-Test hat sich das
USB-Laufwerk vom Bus getrennt. Windows kennt es nur noch als Karteileiche
(Get-PnpDevice -Class CDROM -> Status "Unknown", Win32_CDROMDrive leer),
weder ein Laufwerksbuchstabe noch \.\CdRom0..4 sind da. Die leere
Geraeteliste ist damit die RICHTIGE Antwort. Bei bus-versorgten
USB-Laufwerken ist das bekannt — der Auswurf zieht kurz mehr Strom.

GEMESSEN: ruff sauber, 440 Tests gruen + 15 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:34:51 +02:00
HitonabiandClaude Opus 5 9d95c95d79 feat(drives): Windows-Treiber an echter Disc BEWIESEN
Ampel / ampel (push) Failing after 46s
WAS: Die Hardware-Schicht des Windows-Treibers ist nicht mehr nur gebaut,
sondern gemessen. Messwerte stehen im Modul-Docstring, der gemessene
BD-50-Wert als Testfall in test_cdrom.py.

GEMESSEN (28.08.2026, LG BU40N extern, Laufwerk G:, echte BD-50):

  drive_status()                 -> 4 (CDS_DISC_OK), sofort
  IOCTL_CDROM_DISK_TYPE          -> DATA_TRACK
  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 haengt nicht am
Rueckgabewert des Steuercodes, sondern am ZUSTAND davor und danach: Die
Disc war drin (4) und ist danach draussen (1). Genau das fehlte in v1
monatelang — dort quittierte das ioctl Erfolg, die Schublade blieb zu, und
im Log stand "Disc ausgeworfen".

Die 44,84 GiB pruefen die Schwelle gleich mit: Eine BD-50 liegt knapp unter
den 55 GiB, ab denen classify() auf 'uhd' geht, und wird korrekt als
'bluray' eingeordnet. Wer die Schwelle senkt, macht aus jeder BD-50 eine
UHD — und Rippy waehlte dann das falsche Kompressions-Preset. Der Testfall
haelt den gemessenen Wert fest.

Offen bleibt allein die Ereignis-Erkennung (WM_DEVICECHANGE) — die wird
erst gebaut.

Nebenbei: Beim Eintragen der Messwerte sind drei echte NUL-Bytes in die
Datei geraten (eine Escape-Ebene im Schreib-Skript verschluckt). Python
lehnt so eine Datei rundheraus ab ("source code string cannot contain null
bytes") — im Binaermodus repariert und gegengeprueft.

GEMESSEN: ruff sauber, 430 Tests gruen + 14 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 10:04:29 +02:00
HitonabiandClaude Opus 5 3d7f3b1680 feat(drives): V2-4 (Teil 2) — main.py laedt unter Windows, Treiber gemessen
Ampel / ampel (push) Failing after 46s
WAS: rippy/drives/__init__.py waehlt den Treiber fuer DIESE Maschine.
main.py und prescan.py fragen ihn, statt fest den Linux-Treiber zu
importieren. Damit ist der Blocker fuer den nativen Windows-Betrieb weg.

GEMESSEN, vorher (frische Python-3.12-Umgebung unter Windows):
  >>> import main
  ModuleNotFoundError: No module named 'fcntl'

GEMESSEN, nachher:
  main.py laedt unter Windows: JA
    Treiber:   rippy.drives.windows
    Routen:    61
    Laufwerke: ['\\.\G:']

Der Import stand in rippy/drives/linux.py -> detection.py -> fcntl. Genau
dafuer gibt es den Port: Der Aufrufer sagt WAS, nicht WIE. test_treiberwahl.py
haelt ausserdem fest, dass BEIDE Treiber denselben Satz Funktionen anbieten —
sonst faellt eine fehlende erst im Betrieb als AttributeError auf, und zwar
auf der anderen Plattform.

DER TREIBER AN ECHTER HARDWARE (LG BU40N extern, 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 Beleg, dass wirklich etwas passiert ist: Ein
abgelehnter Steuercode kommt in rund 2 ms zurueck.

BEFUND, DER EIN PLACEBO VERHINDERT HAT: IOCTL_STORAGE_LOAD_MEDIA (Schublade
einziehen) antwortet auf diesem Laufwerk mit ERROR_INVALID_FUNCTION. Bei
flachen und externen Laufwerken ist das die Regel. Deshalb gibt es bewusst
KEINE einziehen()-Funktion — ein Knopf "Schublade schliessen", der auf der
Haelfte aller Laufwerke stillschweigend nichts tut, waere genau die Sorte
Placebo, die in Etappe 19 aufgeraeumt wurde.

Dazu eine DeprecationWarning behoben: '\G' im Docstring ist keine gueltige
Escape-Sequenz und waere in einer kuenftigen Python-Version ein harter Fehler.

NOCH NICHT gemessen: das Verhalten MIT eingelegter Disc (Typ-Erkennung ueber
die Groesse, und ein Auswurf, bei dem wirklich etwas drin ist).

GEMESSEN: ruff sauber, 429 Tests gruen + 13 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:55:15 +02:00
HitonabiandClaude Opus 5 b723fee3a2 feat(drives): V2-4 (Teil 1) — Windows-Laufwerkstreiber, ohne Hardware geprueft
Ampel / ampel (push) Successful in 45s
WAS: rippy/drives/windows.py mit denselben zwei Vertraegen wie die
Linux-Fassung (auswerfen_versuchen gibt False zurueck, eject wirft).
Dazu win_ioctl.py: die Win32-Steuercodes, HERGELEITET statt abgeschrieben.

WARUM ctypes und nicht pywin32: 20 MB zusaetzliche Abhaengigkeit, die in
jedes PyInstaller-Paket muss und bei jedem Python-Wechsel neu passen will.
Gebraucht werden fuenf Funktionen aus kernel32 — die spricht ctypes direkt an.

DIE STEUERCODES WERDEN AUSGERECHNET, NICHT ABGETIPPT: win_ioctl.py enthaelt
das CTL_CODE-Makro aus winioctl.h als Funktion; jede Konstante wird daraus
gebildet. test_win_ioctl.py rechnet das Ergebnis gegen die in der
Microsoft-Dokumentation stehenden Zahlen. Grund: Ein falscher IOCTL-Code
meldet kein "unbekannter Befehl", sondern ERROR_INVALID_FUNCTION — und das
liest sich wie "dieses Laufwerk kann das nicht". Man sucht dann am Geraet
statt an einer Zahl. AGENTS Regel D, ernst genommen.

DIE ZWEI PORT-REGELN GELTEN AUCH HIER:
- Auswerfen heisst entriegeln, auswerfen, NACHSEHEN. Unter Linux wurde am
  26.07.2026 gemessen, dass ein verriegeltes Laufwerk den Auswurf mit ERFOLG
  quittiert und nichts tut. IOCTL_STORAGE_EJECT_MEDIA meldet ebenfalls nur
  die Annahme des Befehls — also wird auch hier nachgesehen
  (CHECK_VERIFY2 muss ERROR_NOT_READY liefern).
- Ein unzugaengliches Laufwerk ist UNBEKANNT, nicht leer. device_info kennt
  deshalb drei Zustaende, nicht zwei.

EIN FEHLER, DEN DIE TESTS SOFORT GEFANGEN HABEN: Win32Fehler setzte
self.winerror VOR super().__init__(). Nachgemessen: Ein zweiargumentiges
OSError.__init__(code, text) setzt winerror wieder auf None. Damit erkannte
_medium_da ERROR_NOT_READY nicht mehr, hielt jedes leere Laufwerk fuer einen
echten Fehler und meldete "unbekannt" statt "leer". Vier Tests rot, Ursache
in einer Minute gemessen statt geraten.

⚠️ WAS HIER NICHT BEWIESEN IST: dass echte Hardware sich so verhaelt. Das
Laufwerk ist derzeit nirgends angeschlossen. Der Ablauf, die
Fehlerunterscheidung und die Typ-Zuordnung sind gegen ein nachgebautes
Laufwerk geprueft (51 Tests, laufen auf jeder Plattform) — die Hardware-
Schicht gilt bis zu einer Messung als GEBAUT, nicht als BEWIESEN.

GEMESSEN: ruff sauber, 400 Tests gruen + 4 uebersprungen (vorher 378).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:45:43 +02:00
HitonabiandClaude Opus 5 4337253797 docs: SAVEPOINT v4.0-beta — V2-0 bis V2-3 stehen, Laufwerk fehlt an der VM
Ampel / ampel (push) Successful in 41s
Vier Etappen gebaut und deployt, Polling ist weg (121 Anfragen/min je Tab
-> ~2 einmalige Abrufe), SSE-Strom live belegt.

Wichtig fuer die naechste Sitzung: Das optische Laufwerk haengt NICHT mehr
an der VM. Rippy laeuft als reine Komprimier-Maschine. Und der Vorfall,
der das aufgedeckt hat, ist dokumentiert — ein devices:-Eintrag in compose
ist eine Startbedingung, und die hat api, worker und ui stillgelegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:37:23 +02:00
HitonabiandClaude Opus 5 05ab655116 fix: ein fehlendes Laufwerk darf Rippy nicht am Starten hindern
Ampel / ampel (push) Successful in 40s
WAS: Die `devices:`-Eintraege fliegen aus docker-compose.yml. Was dieser
Host wirklich hat, ermittelt deploy/geraete-override.sh und schreibt es in
docker-compose.override.yml — die zieht Compose von allein dazu, der
Befehl bleibt `docker compose up -d`.

WARUM (Vorfall heute, 28.08.2026): Das USB-Laufwerk haengt nicht mehr an
der VM. Ein ganz normaler Deploy legte daraufhin api, worker UND ui still:

  Error response from daemon: error gathering device information while
  adding custom device "/dev/sr0": no such file or directory

Ein `devices:`-Eintrag ist eine STARTBEDINGUNG — und keiner der drei
Container braucht zum STARTEN ein Laufwerk. Rippy war unten, und die
Meldung stand mitten im Build-Rauschen (dieselbe Klasse wie das geschluckte
`|| echo` beim .env-Kopieren am 26.07.).

DER WIDERSPRUCH WAR AELTER ALS DER VORFALL: install.sh sagt bei fehlendem
Laufwerk ausdruecklich "die Installation laeuft trotzdem durch; diese
Maschine dient dann als reine KOMPRIMIER-Maschine" — die Compose-Datei sah
das anders. Eine Maschine ohne Laufwerk ist ein VORGESEHENER Betriebsfall:
der Windows-Worker und der GPU-Encoder sind genau das.

Das Skript uebernimmt die Erkennung aus install.sh unveraendert: sr- und
sg-Knoten ueber die SCSI-Adresse in /sys abgeglichen, nicht geraten. Und
es schreibt die Override AUCH dann, wenn kein Laufwerk da ist — sonst
bliebe eine alte Datei mit /dev/sr0 liegen und der Fehler waere derselbe,
nur schwerer zu finden, weil sie gitignored ist und in keinem Diff auftaucht.

deploy.sh ruft es vor dem Start auf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:35:48 +02:00
HitonabiandClaude Opus 5 95705c8d88 feat(ui): V2-3 (Teil 2) — das UI haengt am Ereignis-Strom, Taktgeber raus
Ampel / ampel (push) Successful in 47s
WAS: EventStreamProvider haelt EINE SSE-Verbindung fuer die ganze
Anwendung. Dashboard, Log-Kasten, Log-Seite, Laufwerksliste und
Worker-Liste beziehen ihren Zustand daraus. Sieben von neun setInterval
sind weg.

GEMESSEN AN DEN TAKTGEBERN, je offenem Tab:
  Dashboard      5 Endpunkte / 4 s   75/min  ->  0
  Log-Kasten     2 Endpunkte / 5 s   24/min  ->  0
  Laufwerke      1 Endpunkt  / 5 s   12/min  ->  0
  Log-Seite      1 Endpunkt  /10 s    6/min  ->  0 (+1 Abruf beim Oeffnen)
  Worker-Liste   1 Endpunkt  /15 s    4/min  ->  0 (+1 Abruf beim Oeffnen)
                                     ------
                                     121/min ->  ~2 einmalige Abrufe

Zwei Taktgeber bleiben bewusst: FirstRunWizard (laeuft nur VOR der
Einrichtung) und RipTargetModal (nur solange der Dialog offen ist).

DIE REGEL IST UMGEZOGEN, NICHT VERSCHWUNDEN: Ein Abriss ist keine
Aussage ueber die Welt. Der Provider BEHAELT bei einem Fehler den letzten
Stand und setzt nur `verbunden` auf false; es wird nie eine Liste geleert.
Jede Komponente uebernimmt einen Wert nur, wenn er wirklich da ist —
`devices === null` heisst "konnte nicht nachsehen", nicht "keine
Laufwerke". Das war der Fehler hinter "wird oft neu geladen".

EIN PLACEBO WENIGER: Oben rechts stand ein fest verdrahtetes "ONLINE" mit
pulsierendem Punkt — es leuchtete gruen, auch wenn die API tot war. Jetzt
zeigt es LIVE oder VERBINDUNG WEG, und im Tooltip steht, wann die letzte
Meldung kam.

DER SERVER SCHIEBT JETZT AUCH DEN SERVER-ZUSTAND: Neuer Ereignistyp
system.status (Hardware, Worker, Ablageziele) im 15-Sekunden-Takt des
Waechters — EINMAL im Server statt 15/min je Tab. Nur mitgeschickte
Schluessel werden uebernommen; ein fehlender heisst "behalte deinen Stand".

DAZU EIN FORMATFEHLER GEFUNDEN UND BEHOBEN: /logs bildet ts -> timestamp
ab, mein Snapshot lieferte die rohe DB-Zeile. Das UI haette "Invalid Date"
gezeigt — und zwar NUR im Live-Betrieb, nicht beim manuellen Neuladen.
Jetzt gibt es _log_zeile() einmal, benutzt von beiden.

UND EINEN ZWEITEN: system_lesen lief per asyncio.get_event_loop() in einem
Worker-Thread — dort ist das NICHT die laufende Schleife. Die Coroutine
waere nie gelaufen. Die Schleife wird jetzt im Startup festgehalten.

GEMESSEN: ruff sauber, 378 Tests + 3 uebersprungen, `npm run build`
durch (1650 Module). Der Live-Beweis steht noch aus — er kommt mit dem
Deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:32:50 +02:00
HitonabiandClaude Opus 5 f097a0b59d feat(bus): Waechter — Worker-Aenderungen werden zu Ereignissen
Ampel / ampel (push) Successful in 43s
WAS: rippy/bus/waechter.py sieht im Takt von 1 s in der Datenbank nach und
meldet Aenderungen an den Bus: job.created / job.phase / job.progress /
job.finished und log.line. 12 Tests, ohne Datenbank lauffaehig.

WARUM ES IHN BRAUCHT: Der Bus verteilt innerhalb EINES Prozesses. Der
Worker ist aber ein eigener Prozess, im Docker-Betrieb ein eigener
Container, im verteilten Betrieb ein anderer Rechner. Ohne Bruecke wuesste
die API nichts vom Fortschritt eines Rips.

WARUM UEBER DIE DATENBANK und nicht per HTTP-Rueckruf oder Redis:
- HTTP-Rueckruf: jeder Fortschrittswert haengt an einer Verbindung, ein
  kurzer API-Neustart liesse Ereignisse verschwinden, und der Worker
  braeuchte zusaetzliche Konfiguration.
- Redis Pub/Sub: sauber, kommt im verteilten Betrieb (V2-5) — aber der
  Standalone-Betrieb hat bewusst KEIN Redis, das war der Punkt von V2-2.
- Die Datenbank ist ohnehin die Wahrheit ueber den Job-Zustand, ist in
  JEDEM Modus da, und der Worker schreibt dort sowieso hin.

"Ist das nicht wieder Polling?" Doch — aber einmal, lokal und billig:
vorher N Tabs x 9 Endpunkte alle 4-5 s ueber HTTP durch nginx durch das
Rate-Limit; jetzt EINE Abfrage pro Sekunde von vier Spalten. Das Ziel war
nie "nirgendwo nachfragen", sondern: der Browser fragt nicht mehr.

DER TEST HAT EINEN ECHTEN FEHLER GEFUNDEN: Der Docstring versprach "erst
neue Jobs, dann Aenderungen", die Schleife lieferte aber die Reihenfolge
des dicts — ein Client haette ein job.progress fuer einen Job bekommen
koennen, den er noch gar nicht kennt. Jetzt drei getrennte Durchlaeufe.
Das Versprechen war richtig, der Code nicht.

Und er stirbt nicht still: Fehler gehen ins Log UND als system.notice an
den Bus (erster Fehlschlag, danach jeder zehnte), und lebt_seit_sekunden()
macht sein Alter abfragbar — AGENTS.md, "wer einen Vorrat anlegt, macht
sein Alter abfragbar".

GEMESSEN: ruff sauber, 371 Tests gruen + 3 uebersprungen (vorher 359).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:23:03 +02:00
HitonabiandClaude Opus 5 1fbe2cfc55 fix(test): Snapshot-Tests brauchten eine Datenbank — die Ampel hat keine
Ampel / ampel (push) Successful in 40s
WAS: test_snapshot_* ersetzen den Store per monkeypatch, statt
db.list_jobs() wirklich aufzurufen.

WARUM ROT (Lauf 170): Die Ampel startet KEINE Postgres. Mein neuer Test
war der erste im ganzen Repo, der eine Datenbank angefasst hat — alle
anderen in test_api_smoke.py pruefen nur Importe und Routen. Auf der VM
und lokal waere es nie aufgefallen: dort ist eine DB da bzw. der ganze
Test wird uebersprungen (kein fcntl unter Windows).

Geprueft wird die Snapshot-LOGIK ("konnte nicht nachsehen" ergibt None,
nicht []), nicht die Datenbank. Also gehoert die Datenbank da nicht rein.

Dazu ein zweiter Test: der Snapshot muss alle vier Bereiche liefern
(jobs/workers/logs/devices) — ein Client, dem einer fehlt, muesste den
Rest raten und fiele auf Polling zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:20:58 +02:00
HitonabiandClaude Opus 5 55db9eb13f feat(api): V2-3 (Teil 1) — Ereignis-Bus und SSE-Endpunkt /api/v2/events
Ampel / ampel (push) Failing after 41s
WAS: rippy/bus mit Ereignis-Schema, In-Process-Treiber und Ringpuffer.
Dazu der SSE-Strom in der API: erst ein Snapshot, danach nur Deltas,
mit lueckenloser Wiederaufnahme ueber Last-Event-ID.

WARUM: Ein offener Tab plus ein Worker verursachen heute rund 133
Anfragen pro Minute (Dashboard 75 + Log-Kasten 24 + Laufwerke 12 +
Worker-Liste 4 + Log-Seite 6 + Tray 12). Das Rate-Limit stand einmal
UNTER dieser Zahl — daher die sich leerende Job-Liste im Sekundentakt.
Mit einer offenen Verbindung sind es null.

DREI EIGENSCHAFTEN, ALLE AUS v1-FEHLERN:

1. Der Bus traegt NUR Nachrichten ueber Aenderungen, nie den Zustand.
   Wer den Zustand will, fragt den Store. Damit kann ein verpasstes
   Ereignis auch keinen Zustand loeschen — anders als beim fuenffachen
   `catch(() => [])` im alten UI, wo jeder fehlgeschlagene Abruf
   "es gibt keine Jobs" bedeutete.
2. Eine zu grosse Luecke wird ANGESAGT, nicht verschluckt. nachliefern()
   gibt None ("hol dir ein ganzes Bild") statt [] ("nichts verpasst") —
   dieselbe Unterscheidung wie timeout-Rueckgabe 124 bei den Netzpfaden.
   Stillschweigend weiterzumachen waere schlimmer: Das UI hielte sich
   fuer aktuell und waere es nicht.
3. Der Snapshot meldet unlesbare Laufwerke als None, nicht als leere
   Liste. "Konnte nicht nachsehen" ist etwas anderes als "gibt es nicht".

Ein unbekannter Ereignistyp fliegt beim Senden HOCH statt durchzugehen.
Ein Tippfehler waere sonst der stillste aller Fehlschlaege: Nachricht
raus, kein Empfaenger, nirgends ein Hinweis.

Ein langsamer Zuhoerer (Tab im Hintergrund, lahmes Handy) bremst den
Sender nicht — er wird markiert und bekommt beim naechsten Mal einen
Snapshot. Ein Rip darf nicht auf einen Browser warten.

GEMESSEN: ruff sauber, 359 Tests gruen + 3 uebersprungen (vorher 346).
13 neue Bus-Tests laufen auf jeder Plattform; die vier SSE-Tests haengen
an main.py und laufen damit in der Ampel.

NOCH OFFEN in V2-3: das UI auf den Strom umstellen (neun setInterval)
und die Ereignisse an den Zustandsaenderungen ausloesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:09:33 +02:00
HitonabiandClaude Opus 5 d2645f87e8 feat(core): V2-2 — SQLite hinter demselben Port, Auftrags-Queue mit Lease
Ampel / ampel (push) Successful in 39s
WAS: Der Store spricht jetzt beide Datenbanken (PostgreSQL wie bisher,
SQLite fuer den Standalone-Betrieb). Dazu die LocalQueue: Auftraege als
Tabelle, Vergabe per bedingtem UPDATE, Lease statt Zombie-Jagd. Und die
Konfigurationsschicht mit EINER Praezedenz fuer alle drei Betriebsarten.

WARUM: Ohne SQLite und ohne broker-lose Queue gibt es keinen Standalone-
Betrieb — und ohne den keine Windows-App und keine Headless-Variante.
Beides haengt an dieser Etappe.

DER GRUNDSATZ (KONZEPT-V2.md §3.1): Die Datenbank ist die Wahrheit ueber
den Job-Zustand, der Broker ist nur der Wecker. Daraus folgt die ganze
Queue: Ein Auftrag ist eine Zeile mit claimed_by und lease_until, ein
Knoten uebernimmt ihn per bedingtem UPDATE (es gewinnt genau EINER, auch
wenn zehn gleichzeitig fragen), und er haelt ihn per Lease am Leben.
Laeuft die Lease ab, ist der Auftrag frei — egal ob Absturz, Netzausfall
oder gezogener Stecker.

Damit gibt es den Zustand "laeuft, aber niemand arbeitet daran" nicht
mehr, den v1 mit zombies.py (206 Zeilen + 263 Zeilen Tests) nachtraeglich
einsammeln musste. Er kann hoechstens 60 Sekunden bestehen und heilt sich
dann selbst. Beides ist geprueft: zwei Knoten bekommen NICHT denselben
Auftrag, und eine abgelaufene Lease gibt ihn wirklich wieder her.

KEINE QUEUE-BIBLIOTHEK (Taskiq/ARQ/Celery-lite), Begruendung im
Modul-Docstring: Der teure Teil ist kein Task, sondern ein 30-90-Minuten-
Subprozess mit Fortschritts-Parsing. Was die Bibliotheken loesen, ist
nicht das Problem; was das Problem ist, muss man ohnehin selbst bauen.

ABWEICHUNG VOM ENTWURF — KEIN ALEMBIC (in KONZEPT-V2.md §3.2 vermerkt):
Die Datenbank auf der VM gibt es schon, Alembic muesste sie erst
stempeln — ein Handgriff auf einer laufenden Installation, den beim
naechsten Deploy jemand vergisst. Und es gibt hier nichts zu
versionieren: drei nachgetragene Spalten. store.migrieren() fragt
stattdessen per inspect() nach und legt nur Fehlendes an; das laeuft auf
beiden Dialekten. Der alte Weg (ADD COLUMN IF NOT EXISTS) war korrekte
Postgres-Syntax und waere auf SQLite gebrochen.

EIN KONSTRUKTIONSFEHLER, SELBST GEFUNDEN: lokal.py hatte anfangs
`from rippy.store import engine` — ein Import bindet den Wert EINMAL, ein
spaeteres store.verbinden() waere nie angekommen, und das Modul haette
weiter mit der alten Datenbank gesprochen. Jetzt wird store.engine zur
Aufrufzeit gelesen; die Warnung steht an beiden Stellen im Code.

GEMESSEN: ruff sauber, 346 Tests gruen + 3 uebersprungen (vorher 301).
Neu: 30 Tests gegen ECHTES SQLite (kein Fake) — inklusive der Gegenprobe,
dass WAL und busy_timeout wirklich gesetzt sind, und dass migrieren()
eine weggenommene Spalte zurueckholt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:03:46 +02:00
HitonabiandClaude Opus 5 7558be4854 fix: CRLF in gui.py wiederherstellen (Zeilenenden-Falle in V2-1)
Ampel / ampel (push) Successful in 39s
WAS: docker/worker/gui.py war CRLF und ist beim Umstellen des db-Imports
komplett auf LF gekippt — 1520 Zeilen Diff-Rauschen fuer EINE geaenderte
Zeile. Zeilenenden zurueckgedreht, der Inhalt bleibt.

WARUM ES PASSIERT IST: Mein Umschreib-Helfer las mit
io.open(p, encoding="utf-8") — im Textmodus wandelt Python CRLF beim LESEN
zu LF — und schrieb mit newline="" zurueck, also LF. Betroffen war nur
gui.py; die uebrigen angefassten Dateien sind ohnehin LF.

Richtig waere newline="" AUCH beim Lesen gewesen (dann bleiben die
Zeilenenden im String stehen) oder gleich der Binaermodus.

Das steht so schon in den Projekt-Notizen und ist mir trotzdem passiert,
weil der Helfer generisch war und die Datei nicht danach aussah.
Gegenprobe: `file docker/worker/gui.py` sagt wieder "with CRLF line
terminators", und `git diff HEAD~2 -- gui.py` zeigt genau eine Zeile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:21 +02:00
HitonabiandClaude Opus 5 dd1d0b7365 refactor(core): V2-1 — die vier Ports, ein Store, ein Laufwerks-Treiber
Ampel / ampel (push) Successful in 40s
WAS: rippy/ports.py beschreibt Store/Queue/Bus/Drives als Protocol. Zwei
weitere Doppelungen sind zusammengelegt: db.py (API+Worker) wird
rippy/store, und der Auswurf (api/devices.py + ripping.wirf_disc_aus)
wird rippy/drives/linux. Verhalten unveraendert.

WARUM: Drei Betriebsarten tragen nur, wenn ein Modus die Auswahl der
Treiber hinter vier Nahtstellen ist statt ein eigener Codestand
(KONZEPT-V2.md §1). Diese Etappe zieht die Nahtstellen ein, ohne schon
einen zweiten Treiber zu haben — die kommen in V2-2 (SQLite/LocalQueue)
und V2-4 (Windows).

DIE UNANGENEHMERE DOPPELUNG WAR DER AUSWURF: Er stand zweimal da, mit
UNTERSCHIEDLICHEN Vertraegen — devices.eject wirft OSError, ripping.
wirf_disc_aus gibt False zurueck und wirft nie. Beides ist richtig fuer
seine Seite (Browser-Meldung gegen "ein Rip stirbt nicht an einer
klemmenden Schublade"). Jetzt liegt EINE Mechanik darunter
(auswerfen_mit_grund) und beide Vertraege unveraendert darueber.
Die API-Fassung war ausserdem NIE getestet — jetzt schon, inklusive
"reicht ENOENT/EPERM unveraendert weiter".

get_settings hatte den einzigen echten Verhaltensunterschied der beiden
db.py: die Worker-Fassung schluckte jeden Fehler und gab {} zurueck.
Nicht still entschieden, sondern sichtbar gemacht — der Parameter
bei_fehler_leer steht jetzt in der Signatur, mit der offenen Frage im
Docstring. {} heisst fuer den Aufrufer "nichts gesetzt", nicht "konnte
nicht nachsehen"; das ist dieselbe Klasse wie catch(() => []) im alten
UI. Zu entscheiden in V2-2.

ZWEITER BEINAHE-FEHLER DIESER ETAPPE: linux.py importierte detection
auf Modulebene — und das zieht fcntl. Damit waere ripping.py und ueber
es der NATIVE WINDOWS-WORKER nicht mehr ladbar gewesen. Diesmal haben
die Tests es sofort gefangen (4 Sammelfehler). Behoben an der Wurzel:
Konstanten und die reine classify() leben jetzt in drives/cdrom.py,
ganz ohne fcntl. Nebengewinn — die classify-Tests liefen bisher NUR in
der Ampel ("erst nach dem Push bewiesen") und laufen jetzt ueberall.

Ausserdem: .dockerignore-Testmuster brauchen **, sonst greifen sie nur
in der obersten Ebene. Im laufenden Container nachgezaehlt: 30 test_*.py
lagen in den Images.

GEMESSEN: ruff sauber, 301 Tests gruen + 1 uebersprungen (vorher 290;
+4 neue eject-Tests, +7 classify-Tests die jetzt lokal laufen). Kein
Modul liegt mehr doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:54:28 +02:00
HitonabiandClaude Opus 5 edfe0d4313 docs: SAVEPOINT fuer v4.0-alpha — v2-Konzept entschieden, Etappe V2-0 gruen
Ampel / ampel (push) Successful in 39s
WAS: Neuer Kopfeintrag mit dem gemessenen Zustand (b98dc5e, Ampel gruen,
290 Tests, 0 doppelte Module), den drei Commander-Entscheiden, der
gebauten Etappe V2-0 und dem naechsten Schritt.

WARUM: AGENTS-Workflow Punkt 5 — die naechste Sitzung faengt nicht bei
Null an. Besonders wichtig diesmal, weil der Deploy auf die VM bewusst
aussteht und beim ersten Deploy nach V2-0 zwei Dinge gezielt zu pruefen
sind (liegt /app/rippy in beiden Containern, enthaelt das Worker-Zip
den Ordner).

Dokumentiert ist ausserdem der Fehler, den der Umbau fast ausgeliefert
haette: ein "try/except ImportError" um den detection-Import in
tasks.py haette einen falschen Modulpfad STILL geschluckt und auf Linux
jeden Rip verweigert. Die Lehre steht dabei — bei einem Modul-Umzug
reicht grep auf Zeilenanfaenge nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:41:29 +02:00
HitonabiandClaude Opus 5 b98dc5eef5 refactor(core): V2-0 — gemeinsames Paket rippy statt drei Zwillingsdateien
Ampel / ampel (push) Successful in 41s
WAS: Neues Paket src/rippy (core/drives/rip). detection.py,
makemkv_daten.py und notify.py lagen je ZWEIMAL im Repo — unter
docker/api UND docker/worker, byte-identisch. Jetzt gibt es sie einmal;
beide Container importieren dieselbe Datei. Verhalten unveraendert.

WARUM: Es gab kein geteiltes Paket zwischen den Containern, deshalb die
Kopien. docker/api/db.py sagt es im Kopfkommentar selbst: "Wer die
Struktur aendert, aendert BEIDE Dateien." Ein Waechter-Test
(test_zwillinge_sind_byteweise_identisch) hat das mechanisch
abgesichert — er war noetig, weil die Konstruktion falsch war. Erste
Etappe des v2-Plans (KONZEPT-V2.md §8.3).

IM EINZELNEN:
- src/rippy/{core/notify, drives/detection, rip/makemkv_daten}.py
- conftest.py in der Wurzel legt src/ auf den sys.path (die Ampel ruft
  pytest dort auf).
- Beide Dockerfiles kopieren src/rippy nach /app/rippy — /app ist
  Arbeitsverzeichnis und uvicorn-App-Dir, also ohne PYTHONPATH findbar.
- worker_setup_paket packt das Paket ausdruecklich mit ins Zip: die
  Schleife sah nur die oberste Ebene, ein Unterordner waere nie
  mitgekommen und der Windows-Worker beim Start gestorben.
- Die zwei Test-Dateien fuer makemkv_daten sind zu einer verschmolzen
  (die Faelle aus beiden). Damit entfaellt auch der importlib-Umweg im
  Worker-Test: der war noetig, weil bei "pytest -q" aus der Wurzel
  docker/api zuerst eingesammelt wird und jeder weitere Import nur noch
  den sys.modules-Cache trifft — die Worker-Kopie wurde also nie
  angefasst. Netto -13 doppelte Tests, +2 neue (siehe unten).

EIN FEHLER, DEN DER UMBAU FAST AUSGELIEFERT HAETTE: In tasks.py steht
der detection-Import in einem "try/except ImportError" — absichtlich,
denn der native Windows-Worker hat kein fcntl und soll trotzdem
starten. Das except verschluckt aber JEDEN ImportError, auch einen
falschen Modulpfad. Auf Linux haette der Worker ab sofort still
detect_disc_type=None gesetzt und jeden Rip verweigert, ohne dass
irgendwo ein Fehler stuende — genau die Klasse "still scheiternder
Hintergrund-Prozess" aus AGENTS.md. Gefunden ueber eine zweite Suche
mit eingerueckten Treffern.

Waechter dagegen: src/rippy/test_paket.py prueft mit
importlib.util.find_spec (fuehrt nichts aus, laeuft also auch auf
Windows ohne fcntl), dass es die drei Modulpfade wirklich gibt. Beim
Wegnehmen von detection.py gegengeprueft: Test wird rot, nach
Wiederherstellen gruen.

GEMESSEN: ruff sauber, 290 Tests gruen + 1 uebersprungen (lokal, ohne
die zwei Linux-only-Module). Kein Modul liegt noch doppelt im Repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:29 +02:00
HitonabiandClaude Opus 5 20675a98fa docs: Konzept fuer Rippy v2 + drei Commander-Entscheide (28.08.2026)
WAS: KONZEPT-V2.md neu — Systemarchitektur, Daten-/Queue-Strategie, die
drei Betriebsmodi, API-/Event-Design, Migrationsplan V2-0 bis V2-7.
KONZEPT.md §10 und ROADMAP.md ziehen nach.

WARUM: Rippy soll drei Betriebsarten bekommen statt einer — Docker,
native Windows-App ohne Docker, Headless-Linux-Dienst; alle mit
demselben Webinterface. Das traegt technisch nur, wenn es EINEN Kern
gibt, dessen Betriebsmodus nur die Auswahl der Treiber hinter vier
Ports ist (Store, Queue, Bus, Drives).

Drei Entscheide des Commanders sind eingearbeitet:

- Reihenfolge: Echtzeit (SSE statt Polling) VOR der Windows-App. Die
  Windows-App braucht SQLite und die lokale Queue ohnehin.
- Disc-Schluessel: automatischer Abruf MIT Rueckfallebene, als Kette in
  drei Stufen (eigener Bestand -> Abruf -> Import von Hand). Das
  verschiebt die Grenze aus KONZEPT.md §10 vom 25.07.2026 bewusst —
  deshalb steht sie dort jetzt ausdruecklich fortgeschrieben statt
  still ersetzt (AGENTS Regel B). Bezugsadresse leer vorbelegt,
  Fehlschlag laut, Bestand wird nie still ueberschrieben.
- Speicherziele: der Host mountet, Rippy prueft und erzeugt die
  kopierbare Zeile. Raeumt die URSACHE der CIFS-Ausfaelle ab (Befund
  26.07.2026: die Verbindung lebt in der Netz-Namespace des
  api-Containers und stirbt mit ihm) statt weiter das Symptom zu
  heilen. SYS_ADMIN, DAC_READ_SEARCH, apparmor:unconfined und die
  Mount-Wache fallen damit weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:03 +02:00
120 changed files with 17694 additions and 3075 deletions
+11 -1
View File
@@ -29,11 +29,21 @@ Thumbs.db
.dockerignore
.docker/
# Test
# Test — ** ist Pflicht, sonst greifen die Muster NUR in der obersten Ebene
# des Bau-Kontexts. Ohne ** landeten am 28.08.2026 dreissig test_*.py in beiden
# Laufzeit-Images (im laufenden Container nachgezaehlt: find /app -name
# "test_*.py" | wc -l -> 30). Schadet nichts, gehoert aber nicht ins Image.
tests/
**/tests/
test_*.py
**/test_*.py
*_test.py
**/*_test.py
.pytest_cache/
**/.pytest_cache/
**/__pycache__/
conftest.py
**/conftest.py
# Build
dist/
+1
View File
@@ -54,3 +54,4 @@ logs/
# Data
*.db
*.sqlite
dist/
+23
View File
@@ -29,6 +29,29 @@ rot heißt: nicht deployen). Nie freihändig per SSH auf der VM bauen.
Bibliotheks-APIs: `--help`/Doku prüfen und die Fundstelle im Commit nennen.
(Vorgefallen: erfundene Celery-Methode `self.send_task`, erfundene abcde-Flags.)
### Zwei Schreibfallen, die am 28.08.2026 mehrfach gekostet haben
**1. Deutsche Anführungszeichen in doppelt gequoteten Zeichenketten.**
Das öffnende `„` ist U+201E und harmlos. Das schließende ist das **gerade
ASCII-Zeichen** `"` — es beendet die Python-Zeichenkette. Der Fehler erscheint
dann am Gedankenstrich dahinter, also an einer Stelle, an der nichts falsch
ist. An einem Tag vier Mal passiert.
falsch: "einen Ordner unter „Benutzer" wählen"
richtig: 'einen Ordner unter „Benutzer" wählen'
**Regel: Steht ein `„` in der Zeichenkette, gehört sie in EINFACHE
Anführungszeichen.**
**2. Bash-Heredocs mit Windows-Pfaden.** `<<'EOF'` sollte literal sein,
halbiert in dieser Umgebung aber Backslashes — aus `C:\\Users` wird `C:\Users`,
und das ist eine ungültige Escape-Sequenz. An einem Tag fünf Mal passiert,
zuletzt beim Schreiben genau dieses Absatzes.
**Regel: Dateien mit Windows-Pfaden oder Umlauten mit dem Write-Werkzeug
schreiben, nicht per Heredoc.** Wo es doch sein muss: Backslashes über
`chr(92)` zusammensetzen.
## Workflow
1. **Read:** KONZEPT.md + ROADMAP.md lesen. Verstehen, welche Etappe dran ist.
+1493
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -220,3 +220,52 @@ Ein modular aufgebautes System, das bei Disc-Einwurf automatisch den Typ erkennt
Episoden-Zuordnung per Laufzeitabgleich (TMDB) — erfüllt Etappe-12-Ziel
„Serien-Episoden-Erkennung" in der ersten Ausbaustufe (nur bei
EINDEUTIGER Zuordnung wird umbenannt).
- **28.08.2026 — DISC-SCHLÜSSEL: die Grenze vom 25.07. ist verschoben
(Commander-Entscheid).** Der Eintrag vom 25.07.2026 oben sagt: *„Rippy
liefert und verteilt KEINE Disc-Schlüssel und lädt auch keine herunter."*
**Das gilt ab jetzt nicht mehr unverändert.** Auf ausdrückliche Entscheidung
des Commanders bekommt Rippy v2 einen automatischen Abruf — mit den
bisherigen Wegen als Rückfallebene. Umgesetzt als Kette in DREI Stufen, die
der Reihe nach abgearbeitet wird und anhält, sobald eine trägt:
1. **Eigener Bestand** (Schlüsselspeicher der Installation) — immer zuerst.
Gefüllt vom Windows-Knoten, der die Schlüssel über die eigene
MakeMKV-Lizenz selbst abruft (unter Linux tut `makemkvcon` das nie,
Messung 25.07.2026), und von jedem Import.
2. **Automatischer Abruf** von der konfigurierten Quelle — wenn Stufe 1
die Disc nicht kennt.
3. **Import von Hand** (`_private_data.tar`, `KEYDB.cfg` über das UI) —
unverändert aus v1.
Fünf Regeln gehören zum Entscheid dazu: (a) die Bezugsadresse steht in der
KONFIGURATION und ist LEER vorbelegt — ohne Eintrag ist Stufe 2
übersprungen; eine vorbelegte Adresse, die irgendwann tot ist, wäre genau
die Falle aus `.env.example` („lässt die Konfiguration gesund aussehen und
den Bau später scheitern"). (b) Ein funktionierender Bestand wird NIE still
überschrieben — neue Datei daneben, prüfen, dann tauschen. (c) Ein
Fehlschlag ist LAUT (kein `except: pass`, Meldung im Log und im UI).
(d) Das UI zeigt Herkunft und Alter jedes Eintrags. (e) **Rippy bringt
selbst nichts mit** — weder Installer noch Docker-Image enthalten Schlüssel
oder eine vorbelegte Bezugsadresse; was abgerufen wird, trägt der Betreiber
der Installation ein. Ausführlich in `KONZEPT-V2.md` § 7.5 und § 10.
- **28.08.2026 — SPEICHERZIELE: der Host mountet, Rippy prüft
(Commander-Entscheid).** Das Muss-Feature „NFS/Bind-Mount für Medien-Store"
(§ 6) bleibt; was wegfällt, ist das Mounten DURCH Rippy. Begründung ist der
Befund vom 26.07.2026: Die CIFS-Verbindung lebt in der Netz-Namespace des
api-Containers und stirbt mit ihm — dagegen läuft heute eine Mount-Wache.
In v2 hängt der Host ein (fstab, `.mount`-Unit oder Compose-Volume-Treiber),
und Rippy erzeugt dafür die fertige, kopierbare Zeile. Die Eingabemaske im
UI bleibt; der Knopf „Verbinden" wird zu „Zeile kopieren". Die gesamte
PRÜF-Logik aus `mounts.py` bleibt erhalten (Erreichbarkeit mit Zeitgrenze
im Kindprozess, SMB-Klartextfehler, Pfad-Map-Vorschlag). Folge:
`CAP_SYS_ADMIN`, `DAC_READ_SEARCH`, `apparmor:unconfined`,
`propagation: rshared` und die Mount-Wache fallen ersatzlos weg.
- **28.08.2026 — RIPPY v2: drei Betriebsmodi statt eines
(Commander-Auftrag).** Die Multi-Container-Architektur aus § 6 bleibt als
EINER von drei Modi bestehen. Dazu kommen eine native Windows-Installation
ohne Docker und eine Headless-Linux-Anwendung — beide mit demselben
Webinterface. Technisch tragen alle drei denselben Kern; ein Modus ist nur
die Auswahl der Treiber hinter vier Ports (Store, Queue, Bus, Drives).
Damit sind Postgres und Redis für Einzelinstallationen keine Pflicht mehr
(SQLite + lokale Queue), bleiben im verteilten Modus aber unverändert.
Vollständige Spezifikation: **`KONZEPT-V2.md`**, Etappen in `ROADMAP.md`.
+140
View File
@@ -570,8 +570,148 @@ heruntergerechnet und der Rohschnitt gelöscht worden (`keepOriginal: False`).
---
## Rippy v2 — Etappen V2-0 bis V2-7 (Plan vom 28.08.2026)
**Vollständige Spezifikation: `KONZEPT-V2.md`.** Hier stehen nur die Etappen
und ihre Abnahmekriterien.
**Ziel:** drei Betriebsmodi statt eines — Docker (wie heute, aufgeräumt), eine
native Windows-App ohne Docker, eine Headless-Linux-Anwendung. Alle drei mit
demselben Webinterface, alle drei aus EINEM Kern.
**Grundregel für den ganzen Weg:** Jede Etappe endet mit grüner Ampel und einem
lauffähigen System. v1 läuft bis V2-5 produktiv weiter — auf der VM liegen echte
Medien.
### V2-0: Monorepo — ein Paket statt Zwillingen
- [ ] `src/rippy/` als gemeinsames Paket anlegen (api UND worker importieren daraus)
- [ ] Die byte-identischen Zwillinge zusammenführen: `detection.py`,
`makemkv_daten.py`, `notify.py` — je zweimal im Repo
- [ ] Tests wandern mit; `test_zwillinge_sind_byteweise_identisch` wird
gegenstandslos und weicht einem Test, der die EINE Quelle prüft
- [ ] Beide Dockerfiles kopieren `src/rippy`, `PYTHONPATH` gesetzt
- **Verhalten unverändert.** Kein Funktionsgewinn, reine Struktur.
- **Fertig, wenn:** Ampel grün, `docker compose up` verhält sich wie vorher,
kein Modul mehr doppelt im Repo.
### V2-1: Ports einziehen
- [ ] `Store`, `Queue`, `Bus`, `Drives` als Python-`Protocol`
- [ ] v1-Verhalten läuft über die Treiber Postgres / Celery / Redis / Linux
- [ ] Kein Funktionsgewinn — reine Verdrahtung
- **Fertig, wenn:** Ampel grün und ein Rip auf der VM durchläuft.
### V2-2: Standalone (SQLite + lokale Queue)
- [ ] SQLite-Treiber mit WAL, `busy_timeout`, ein Schreiber-Kontext
- [ ] Alembic statt handgeschriebener `ALTER TABLE … IF NOT EXISTS`
(das ist Postgres-only und bricht auf SQLite)
- [ ] LocalQueue: Auftrags-Tabelle + Lease + Prozesspool, kein Broker
- [ ] Lease ersetzt `zombies.py` — abgelaufene Lease = Auftrag ist frei
- [ ] `rippyd --profil standalone`
- **Fertig, wenn:** ein DVD-Rip komplett ohne Postgres, Redis und Docker läuft.
### V2-3: Echtzeit — Polling raus
- [ ] Bus-Treiber (asyncio in-process / Redis Pub/Sub)
- [ ] `GET /api/v2/events` als SSE-Strom, `snapshot` beim Verbinden,
lückenlose `seq` für Wiederaufnahme
- [ ] UI auf einen `useEventStream`-Haken; die neun `setInterval` fliegen raus
- [ ] `test_grenze_deckt_die_eigene_last_ab` auf die neuen Zahlen ziehen
- **Fertig, wenn:** Grundlast ≈ 0/min gemessen (heute ≈ 133/min bei einem Tab
plus Worker) UND ein Verbindungsabriss keine Liste leert.
### V2-4: Windows nativ
- [x] `drives/windows.py` — Win32 statt ioctl (`IOCTL_STORAGE_CHECK_VERIFY2`,
`IOCTL_STORAGE_MEDIA_REMOVAL`, `IOCTL_STORAGE_EJECT_MEDIA`,
MMC `GET CONFIGURATION` für den Disc-Typ)
- [ ] Disc-Einwurf per `WM_DEVICECHANGE` statt Polling — **offen**, läuft
vorerst über den Poll aus `drives/detection.py`
- [x] Dienst + Fenster als GETRENNTE Prozesse; **WebView2-Fenster**
(`fenster.py`, Entscheid 4 in `KONZEPT-V2.md` § 10)
- [x] Eintrag in „Programme und Features" und im Taskmanager als `Rippy.exe`
(ausdrücklicher Wunsch des Commanders)
- [x] Desktop-Symbol und Startmenü-Eintrag, im Setup abschaltbar
- [x] Werkzeug-Erkennung und -Beschaffung (`tools/katalog.py`,
`tools/beschaffen.py`) — Rippy findet, holt und aktualisiert
HandBrake/MakeMKV selbst
- [x] **Das Setup richtet die Werkzeuge wirklich ein** (`tools/einrichten.py`,
Entscheid 5) — HandBrakeCLI liegt im Paket (GPL-2), MakeMKV wird beim
Einrichten vom Hersteller geholt. Ein Setup, dem ein Pflichtwerkzeug
fehlt, meldet das im Klartext statt Erfolg.
- [x] Symbol mit allen Größen, die Windows holt (16/32/48/256) — vorher steckte
nur 256×256 in der `.ico`, und Desktop, Startmenü und Taskleiste zeigten
ein leeres Blatt
- [x] **Ein echtes Setup** (`einrichtung.py`, `setup_fenster.py`): acht
Voraussetzungs-Prüfungen, Zielordner UND Ablage zur Wahl, Port,
Autostart/Verknüpfungen/Werkzeuge als Schalter. Vorher installierte der
Doppelklick stillschweigend und öffnete das Fenster.
- [x] **Die Oberfläche kennt ihren Betrieb** (`betrieb.py`, `GET /betrieb`,
`useBetrieb.tsx`): keine Worker-Zähler, keine Container-Platte, kein
`docker compose ps` mehr im Windows-Client — und der Docker-Betrieb
behält alles.
- [ ] Standby blocken via `SetThreadExecutionState`, ohne `ES_DISPLAY_REQUIRED`
**offen**
- **Fertig, wenn:** auf einem frischen Win-11-Rechner gilt: Installer →
Disc rein → MKV raus. Und der UHD-Schlüssel kommt automatisch.
**Noch offen:** ein echter Rip auf Windows ist ungeprüft — das Laufwerk
hängt an der VM.
> **Abweichung vom Plan, bewusst und mit Folgen.** Statt Nuitka `--standalone`
> (one-dir) + Inno Setup wurde **PyInstaller onefile** genommen: ein Programm,
> drei Betriebsarten (`RippySetup.exe`, `--dienst`, `--deinstallieren`), kein
> zweites Werkzeug in der Kette.
>
> Der Preis stand oben schon im Plan — *„ein Onefile-Paket entpackt sich bei
> jedem Start neu nach `%TEMP%`"* — und er ist am 28.08.2026 fällig geworden:
> Ein Dienst lief eine Stunde, `/api/health` gab 200, `/` gab **404**. Vom
> Entpack-Verzeichnis des laufenden Prozesses waren 31 statt 44 Einträge übrig,
> `ui` und `api` fehlten. Die API meldete sich gesund, während die Oberfläche
> weg war.
>
> **Gegenmaßnahme:** Der Installer legt die Oberfläche als Kopie NEBEN das
> Programm (`windows_app.ui_auspacken`), und `daemon._ui_pfad()` nimmt diese
> Kopie vor dem Entpack-Verzeichnis. Zwei Tests halten das fest. Sollte sich
> derselbe Ausfall an den API-Modulen zeigen, ist one-dir die richtige Antwort
> — dann ist diese Abweichung zurückzunehmen.
### V2-5: Docker neu
- [ ] EIN Image, drei Profile (`standalone`, `api`, `node`)
- [ ] GPU-Durchreichung: `/dev/dri` für QSV/VAAPI, `runtime: nvidia` für NVENC
- [ ] **Host-Mounts statt Container-Mounts** (Entscheid 28.08.2026)
- [ ] Multi-Arch; ARM64 ist Encode-/API-Knoten (MakeMKV hat kein ARM64-Binary)
- **Fertig, wenn:** All-in-One und verteilt laufen und `SYS_ADMIN`,
`DAC_READ_SEARCH`, `apparmor:unconfined` weg sind.
### V2-6: CLI & Pakete
- [ ] `rippy status / drives / scan / rip / queue / logs --follow / doctor`
- [ ] `rippy doctor` = die Prüfphase aus `install.sh` als Befehl (erst alles
prüfen, dann berichten, nichts ändern)
- [ ] systemd-Unit mit `SupplementaryGroups=cdrom` und `DeviceAllow` für
block-sr UND char-sg (ohne sg findet MakeMKV kein Laufwerk)
- [ ] udev-Regel für echte Disc-Ereignisse; ioctl-Poll bleibt Rückfallebene
- [ ] `.deb` / AppImage
- **Fertig, wenn:** ein Headless-Server ohne Browser bedienbar ist.
### V2-7: Neue Features
- [ ] Multi-Drive Parallel-Ripping (Rip parallel, **Schlüssel-Phase
serialisiert** — vorher an zwei Laufwerken MESSEN, nicht annehmen)
- [ ] Zero-Click gegen Interaktiv, je Laufwerk und Disc-Typ
- [ ] Auto-Presets nach gemessener Hardware (Vorschlag, kein Zwang)
- [ ] Ton-/Untertitel-Regelwerk (Originalton, Wunschsprachen, erzwungene
Untertitel behalten, Kommentarspuren verwerfen, HD-Ton durchreichen)
- [ ] **Schlüsselkette in drei Stufen** (Entscheid 28.08.2026, `KONZEPT.md` § 10)
- [ ] Anime über AniList zusätzlich zu Jikan
- [ ] Medienserver-Refresh als Auftrag MIT Wiederholung (heute still scheiternd)
- [ ] FFmpeg-Direktpfad für reines Remuxen
- **Fertig, wenn:** je Feature ein Nachweis vorliegt.
---
## Ideen-Katalog (Rest) — bewusst offen
> **Stand 28.08.2026:** Punkt 3 (Kodi-Refresh) und Punkt 4 (Windows-Worker als
> Dienst) sind in den v2-Plan aufgegangen — Punkt 4 ist V2-4, Punkt 3 steckt in
> V2-7 („Medienserver-Refresh"). Punkt 2 (AI-Box als Transcode-Worker) wird von
> V2-5 abgedeckt, sobald der Host-Mount-Weg steht.
1. **Design 2.0** — an Gemini übergeben (24.07.2026). Vollständiges
Briefing: `docs/DESIGN-2.0-BRIEFING.md`. Arbeitsbranch: `design-2.0`
(deployt bewusst NICHT, nur `main` wird befördert). Kern: 428
+1307 -1
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
"""Pytest-Setup für das ganze Repo: das gemeinsame Paket `rippy` findbar machen.
Die Ampel ruft `pytest -q` in der Repo-Wurzel auf (.gitea/workflows/ci.yml).
Ohne diesen Eintrag scheitert JEDER Import von `rippy.*` — das Paket liegt
unter `src/`, und `src/` steht in keinem Standardpfad.
Warum `src/` und nicht `rippy/` direkt in der Wurzel: Sonst würde `pytest`
beim Einsammeln in dasselbe Verzeichnis schauen, in dem auch `docker/` liegt,
und ein zufällig gleichnamiges Modul könnte gewinnen. Ein eigenes Quellen-
Verzeichnis macht die Grenze eindeutig.
Die beiden conftest.py unter docker/api und docker/worker bleiben bestehen —
sie machen die dortigen FLACHEN Modul-Importe möglich (`import ripping`,
`import db`). Beide Mechanismen greifen nebeneinander.
"""
import os
import sys
_QUELLEN = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
if _QUELLEN not in sys.path:
sys.path.insert(0, _QUELLEN)
+12
View File
@@ -95,6 +95,18 @@ fi
sed -i '/^RIPPY_VERSION=/d' .env
echo "RIPPY_VERSION=$(git rev-parse --short HEAD)" >> .env
# Geraete dieses Hosts ermitteln, BEVOR gestartet wird.
# Ohne diesen Schritt haengt der Start an einem `devices:`-Eintrag, den es
# vielleicht nicht mehr gibt — am 28.08.2026 legte genau das die ganze
# Installation stillt, weil das USB-Laufwerk abgezogen war (Herleitung im Kopf
# von docker-compose.yml). Das Skript schreibt die Override immer neu, auch
# wenn kein Laufwerk da ist.
if [ -x ./deploy/geraete-override.sh ]; then
./deploy/geraete-override.sh .
else
echo "WARNUNG: deploy/geraete-override.sh fehlt — starte ohne Geraete-Erkennung."
fi
docker compose -p rippy up -d --build $DIENST
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
REMOTE
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
#
# Erzeugt docker-compose.override.yml mit den optischen Laufwerken DIESES Hosts.
#
# ## Warum es das gibt (Vorfall 28.08.2026)
#
# In docker-compose.yml stand:
#
# devices:
# - ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
#
# Das ist eine STARTBEDINGUNG. Fehlt das Laufwerk, startet der Container nicht:
#
# Error response from daemon: error gathering device information while
# adding custom device "/dev/sr0": no such file or directory
#
# Genau das ist passiert, als das USB-Laufwerk von der VM abgezogen wurde: Ein
# ganz normaler Deploy legte api, worker UND ui still — obwohl keiner der drei
# ein Laufwerk zum Starten braucht. Rippy war unten, und die Meldung stand
# mitten im Build-Rauschen.
#
# Der Widerspruch war schon vorher da: `install.sh` sagt bei fehlendem Laufwerk
# ausdrücklich „die Installation läuft trotzdem durch; diese Maschine dient dann
# als reine KOMPRIMIER-Maschine" — die Compose-Datei sah das anders. Eine
# Maschine ohne Laufwerk ist ein vorgesehener Betriebsfall (der Windows-Worker
# und der GPU-Encoder sind genau das).
#
# Seither steht in docker-compose.yml KEIN devices-Block mehr. Was dieser Host
# wirklich hat, landet in docker-compose.override.yml — die zieht Compose von
# allein dazu (`docker compose up` braucht keinen extra Schalter), und sie ist
# gitignored, weil die Geräteknoten je Rechner anders heißen.
#
# Aufruf: ./deploy/geraete-override.sh [zielverzeichnis]
# (ohne Argument: das aktuelle Verzeichnis)
#
set -u
ZIEL="${1:-.}"
DATEI="$ZIEL/docker-compose.override.yml"
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an und braucht deshalb
# ZWEI Knoten: /dev/srN und den passenden /dev/sgM. Welche sg-Nummer dazugehört,
# ist je Host anders — sie wird über die SCSI-Adresse ermittelt statt geraten.
# Beide Knoten zeigen in /sys auf dasselbe Geräteverzeichnis (z. B. 3:0:0:0).
# (Dieselbe Herleitung wie in install.sh, Prüfung 2/5.)
SR=""
SG=""
LAUFWERK=""
for srpfad in /sys/block/sr*; do
[ -e "$srpfad" ] || continue
srname=$(basename "$srpfad")
adresse=$(basename "$(readlink -f "$srpfad/device" 2>/dev/null)" 2>/dev/null)
[ -n "$adresse" ] || continue
for sgpfad in /sys/class/scsi_generic/sg*; do
[ -e "$sgpfad" ] || continue
if [ "$(basename "$(readlink -f "$sgpfad/device" 2>/dev/null)" 2>/dev/null)" = "$adresse" ]; then
SR="/dev/$srname"
SG="/dev/$(basename "$sgpfad")"
LAUFWERK="$(cat "$srpfad/device/vendor" 2>/dev/null) $(cat "$srpfad/device/model" 2>/dev/null)"
break 2
fi
done
done
kopf() {
cat <<KOPF
# ERZEUGT von deploy/geraete-override.sh — nicht von Hand ändern.
# Diese Datei ist gitignored: Die Geräteknoten heißen auf jedem Rechner anders.
# Neu erzeugen nach jedem An- oder Abstecken eines Laufwerks:
# ./deploy/geraete-override.sh && docker compose up -d
KOPF
}
if [ -z "$SR" ]; then
# KEIN Laufwerk. Wichtig: trotzdem eine gültige Datei schreiben und die alte
# damit überschreiben. Bliebe eine alte Override mit /dev/sr0 liegen, wäre der
# Fehler beim nächsten Start genau derselbe — nur schwerer zu finden, weil die
# Datei gitignored ist und in keinem Diff auftaucht.
{
kopf
echo "#"
echo "# Auf diesem Host wurde KEIN optisches Laufwerk gefunden."
echo "# Rippy läuft damit als reine Komprimier-Maschine — das ist ein"
echo "# vorgesehener Betriebsfall, kein Fehler. Zum Rippen das Laufwerk"
echo "# anstecken (in einer VM: USB-Passthrough) und dieses Skript erneut"
echo "# aufrufen."
echo "services: {}"
} > "$DATEI"
echo "Kein optisches Laufwerk gefunden — $DATEI ohne Geräte geschrieben."
echo "Rippy startet und kann komprimieren; Rippen geht erst mit Laufwerk."
exit 0
fi
{
kopf
echo "#"
echo "# Gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
echo "# $SR + $SG (über die SCSI-Adresse abgeglichen, nicht geraten)"
cat <<YAML
services:
api:
devices:
- $SR:/dev/sr0
worker:
devices:
- $SR:/dev/sr0
- $SG:/dev/sg1
YAML
} > "$DATEI"
echo "Laufwerk gefunden: $(echo "$LAUFWERK" | tr -s ' ')"
echo " $SR + $SG -> $DATEI"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+24 -12
View File
@@ -3,7 +3,24 @@
# Geräte-Zugriff (22.07./23.07.-Lehre): Optische Laufwerke MÜSSEN als devices:
# eingebunden werden. Bind-Mounts unter volumes: geben dem Container zwar den
# Geräteknoten, aber KEINE Berechtigung im Device-Cgroup → jedes open() scheitert
# mit EPERM. Voraussetzung: Die VM sieht das Laufwerk (USB-Passthrough auf pve).
# mit EPERM.
#
# ⚠️ ABER NICHT HIER (Vorfall 28.08.2026): Ein `devices:`-Eintrag ist eine
# STARTBEDINGUNG. Als das USB-Laufwerk von der VM abgezogen wurde, legte ein
# ganz normaler Deploy api, worker UND ui still — obwohl keiner der drei zum
# Starten ein Laufwerk braucht:
#
# Error response from daemon: error gathering device information while
# adding custom device "/dev/sr0": no such file or directory
#
# Der Widerspruch war älter: install.sh sagt bei fehlendem Laufwerk
# ausdrücklich „läuft trotzdem durch, dann ist das eine reine
# KOMPRIMIER-Maschine" — diese Datei sah das anders.
#
# Deshalb stehen die Geräte jetzt in docker-compose.override.yml, erzeugt von
# `deploy/geraete-override.sh`. Compose zieht die Override von allein dazu;
# `docker compose up -d` bleibt derselbe Befehl. Nach jedem An- oder Abstecken
# eines Laufwerks das Skript erneut aufrufen.
services:
api:
@@ -54,8 +71,7 @@ services:
- temp:/app/temp
# MakeMKV-Datenverzeichnis (dasselbe wie im Worker, siehe dort).
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/app/makemkv-data
devices:
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
# devices: siehe Kopf — kommen aus docker-compose.override.yml
networks:
- rippy-net
depends_on:
@@ -118,15 +134,11 @@ services:
# Ohne diesen Mount löschte JEDER `up -d --build` beides; die
# Fehlermeldung schickte den Nutzer zu einer Datei, die es nicht mehr gab.
- ${MAKEMKV_DATA_HOST:-/srv/rippy/makemkv}:/root/.MakeMKV
devices:
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
# devices: siehe Kopf dieser Datei — die optischen Knoten dieses Hosts
# stehen in docker-compose.override.yml (deploy/geraete-override.sh).
# MakeMKV braucht BEIDE: /dev/srN und den passenden /dev/sgM; welche
# sg-Nummer dazugehoert, ist je Host anders und wird dort ueber die
# SCSI-Adresse abgeglichen statt geraten.
networks:
- rippy-net
depends_on:
+9
View File
@@ -17,10 +17,19 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den API-Modulen. /app ist Arbeitsverzeichnis und uvicorn-App-Dir,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
# Worker-Selbstversorgung: die API liefert Installer + Worker-Code an
# native Worker aus (GET /worker-setup/windows bzw. /worker-setup/paket) —
# die Zielmaschine braucht weder git noch Docker.
COPY docker/worker/*.py docker/worker/requirements.txt worker_dist/
# Der Worker importiert seit V2-0 aus dem gemeinsamen Paket (tasks.py, caps.py).
# Ohne diese Zeile fehlte es im Zip und der Windows-Worker stürbe beim Start
# mit ModuleNotFoundError — worker_setup_paket packt genau diesen Ordner mit ein.
COPY src/rippy worker_dist/rippy
COPY deploy/worker-windows/installer.py worker_dist/
# Rippy-Icon: Der Installer legt damit eine Verknüpfung auf den Desktop
# (Commander-Wunsch 26.07.2026). Ohne die Datei auf der Zielmaschine trüge die
+94 -14
View File
@@ -1,42 +1,122 @@
"""Cache module."""
"""Zwischenspeicher für API-Antworten — mit Redis, wenn es eins gibt.
## Warum das nicht mehr scheitern darf (Befund 28.08.2026)
Hier stand ein nacktes `redis_client.get(key)`. Der Vorgabe-Host ist `redis`
— der Dienstname aus `docker-compose.yml`. Im Container stimmt das; auf einem
Windows-PC gibt es kein Redis, und jeder Aufruf endete mit:
redis.exceptions.ConnectionError: Error 11001 connecting to redis:6379
Das riss den Pre-Scan mit. Der Commander sah die Folge, nicht die Ursache:
Eine eingelegte Blu-ray blieb namenlos, und die Metadaten-Suche lief nie an.
**Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.** Ist
keiner da, wird eben jedes Mal neu gefragt — TMDB und OMDb halten das aus.
Deshalb fängt jeder Zugriff hier den Verbindungsfehler ab und tut so, als
wäre der Eintrag nicht vorhanden. Das ist genau die Wahrheit: Er ist es
nicht.
## Warum trotzdem einmal gemeldet wird
Ein Zwischenspeicher, der still nie greift, ist eine unsichtbare
Verlangsamung — und auf der VM wäre ein weggefallenes Redis ein echter
Befund. Deshalb sagt `zustand()`, woran man ist, und die erste fehlgeschlagene
Verbindung schreibt eine Zeile. Danach Ruhe: Eine Meldung je Anfrage wäre
Lärm.
"""
from redis import Redis
from typing import Any, Optional
import json
import os
from typing import Any, Optional
from redis import Redis
from redis.exceptions import RedisError
redis_host = os.getenv("REDIS_HOST", "redis")
redis_port = int(os.getenv("REDIS_PORT", "6379"))
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True)
# Kurze Zeitgrenzen: Ein Zwischenspeicher, auf den man wartet, ist keiner.
# Ohne sie hing jeder Aufruf am Standard-Timeout der Bibliothek.
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True,
socket_connect_timeout=1.5, socket_timeout=1.5)
# Erst beim ersten Zugriff bekannt. None = noch nichts versucht.
_erreichbar: Optional[bool] = None
def _melde_einmal(fehler: Exception) -> None:
"""Beim ERSTEN Fehlschlag eine Zeile, danach Ruhe."""
global _erreichbar
if _erreichbar is False:
return
_erreichbar = False
print("Zwischenspeicher (%s:%s) nicht erreichbar — es wird ohne "
"gearbeitet: %s" % (redis_host, redis_port, fehler))
def zustand() -> dict:
"""Woran man ist. Für Diagnose und Selbstauskunft."""
return {"host": redis_host, "port": redis_port, "erreichbar": _erreichbar}
def init_cache() -> None:
"""Initialize cache connection."""
pass
"""Einmal anklopfen, damit der Zustand bekannt ist. Wirft nie."""
global _erreichbar
try:
redis_client.ping()
_erreichbar = True
except (RedisError, OSError) as e:
_melde_einmal(e)
def get(key: str) -> Optional[Any]:
"""Get value from cache."""
"""Wert aus dem Zwischenspeicher. None = nicht da ODER kein Speicher."""
global _erreichbar
try:
value = redis_client.get(key)
except (RedisError, OSError) as e:
_melde_einmal(e)
return None
_erreichbar = True
if value:
try:
return json.loads(value)
except (TypeError, ValueError):
# Ein kaputter Eintrag ist kein Grund, den Aufrufer scheitern zu
# lassen — er holt sich die Antwort dann eben frisch.
return None
return None
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
"""Set value in cache."""
"""Wert ablegen. False heißt: ging nicht — und das ist in Ordnung."""
global _erreichbar
try:
serialized = json.dumps(value)
if expire:
return redis_client.setex(key, expire, serialized)
return redis_client.set(key, serialized)
return bool(redis_client.setex(key, expire, serialized))
return bool(redis_client.set(key, serialized))
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
except (TypeError, ValueError):
# Nicht serialisierbar. Das ist ein Fehler des Aufrufers, aber keiner,
# der eine Metadaten-Abfrage umwerfen darf.
return False
def delete(key: str) -> bool:
"""Delete value from cache."""
return redis_client.delete(key)
try:
return bool(redis_client.delete(key))
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
def clear() -> bool:
"""Clear all cache."""
return redis_client.flushdb()
try:
return bool(redis_client.flushdb())
except (RedisError, OSError) as e:
_melde_einmal(e)
return False
+98 -9
View File
@@ -2,16 +2,105 @@
Bis 23.07. gab es überhaupt keinen Code-Pfad, der je einen Rip auslöste —
kein POST /jobs, kein udev-Daemon. Dieser Client schließt die Lücke.
## Warum Celery hier erst bei Bedarf entsteht (V2-4, 28.08.2026)
Hier stand `celery_client = Celery(...)` auf Modulebene. Damit brauchte JEDER
Import von `main.py` ein funktionierendes Celery — auch dann, wenn nie ein Rip
angestoßen wird. Im Windows-Paket ist Celery bewusst NICHT enthalten (der
Standalone-Betrieb hat keinen Broker), und die fertige EXE starb sofort beim
Start:
File "celery_client.py", ...
celery_client = Celery("rippy_api", broker=REDIS_URL, ...)
ModuleNotFoundError: No module named 'celery.fixups'
Dasselbe Muster wie beim Store, der seine Engine beim Import baute: Was erst
bei der ersten Benutzung gebraucht wird, soll auch erst dann entstehen.
**Was das für den Windows-Betrieb bedeutet — ehrlich gesagt:** Die Oberfläche,
die Laufwerks-Erkennung und alles Lesende laufen dort. Ein RIP anzustoßen geht
noch nicht, weil die Zustellung weiterhin über Celery läuft; die Umstellung auf
die LocalQueue steht in Etappe V2-5. Bis dahin sagt `start_rip()` das
ausdrücklich, statt mit einem Importfehler zu sterben oder still nichts zu tun.
"""
import os
from celery import Celery
from celery.utils import worker_direct
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
celery_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
_client = None
# Wer Auftraege zustellt. None = Celery (verteilter Betrieb).
# Der Standalone-Betrieb setzt hier seine eigene Zustellung ein — siehe
# `zusteller_setzen()`.
_zusteller = None
class KeinBroker(RuntimeError):
"""Es gibt hier kein Celery — mit Ansage statt mit Importfehler."""
def hole_client():
"""Der Celery-Client, beim ERSTEN Zugriff gebaut."""
global _client
if _client is None:
try:
from celery import Celery
except ImportError as e:
raise KeinBroker(
"Celery ist in dieser Installation nicht enthalten. Rippy läuft "
"hier im Standalone-Betrieb; das Anstoßen von Rips über einen "
"Broker ist damit nicht möglich (Umstellung auf die lokale "
"Auftrags-Queue: Etappe V2-5)."
) from e
_client = Celery("rippy_api", broker=REDIS_URL, backend=REDIS_URL)
return _client
def __getattr__(name):
"""`celery_client` von außen — baut den Client bei Bedarf.
Damit bleiben die bestehenden `from celery_client import celery_client`
unverändert gültig, ohne dass der Import schon einen Broker verlangt.
"""
if name == "celery_client":
return hole_client()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def zusteller_setzen(funktion) -> None:
"""Legt fest, WER Auftraege bekommt.
Ohne Aufruf geht alles an Celery — das ist der Docker-Betrieb, unveraendert.
Der Standalone-Betrieb (Windows-App, Headless-Linux) setzt hier seine
lokale Auftrags-Queue ein; dann laeuft die Arbeit im selben Prozess.
Die Unterschrift ist die von `abschicken`:
funktion(task_name, args, queue=None) -> irgendetwas
Das ist bewusst dieselbe Form, die Celery hat. So muss keine Aufrufstelle
wissen, in welchem Betrieb sie gerade laeuft.
"""
global _zusteller
_zusteller = funktion
def abschicken(task_name: str, args: list, queue: str = None):
"""Einen Auftrag zustellen — an Celery oder an die lokale Queue.
EINE Stelle fuer alle drei Auftragsarten (rip_disc, transcode_files,
scan_tracks). Vorher rief jede Aufrufstelle `send_task` selbst auf; damit
haette der Standalone-Betrieb an drei Stellen umgebogen werden muessen —
und beim naechsten Auftragstyp an einer vierten.
"""
if _zusteller is not None:
return _zusteller(task_name, args, queue)
kwargs = {"args": args}
if queue:
kwargs["queue"] = queue
return hole_client().send_task(task_name, **kwargs)
def transcode_queue(node: str = None):
@@ -25,7 +114,9 @@ def transcode_queue(node: str = None):
if not node:
return "transcode"
try:
antworten = celery_client.control.ping(timeout=1.0) or []
from celery.utils import worker_direct
antworten = hole_client().control.ping(timeout=1.0) or []
online = {k for antwort in antworten for k in antwort.keys()}
if node in online:
return worker_direct(node)
@@ -35,7 +126,5 @@ def transcode_queue(node: str = None):
def start_rip(device_path: str, job_id: str, target_dir: str = None):
"""Schickt den Rip-Task an den Worker (Task-Name aus worker/tasks.py)."""
return celery_client.send_task(
"worker.tasks.rip_disc", args=[device_path, job_id, target_dir]
)
"""Schickt den Rip-Auftrag los (Task-Name aus worker/tasks.py)."""
return abschicken("worker.tasks.rip_disc", [device_path, job_id, target_dir])
+7 -2
View File
@@ -33,8 +33,13 @@ def parse_year(year: str) -> Optional[int]:
class OMDbClient:
def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("omdbApiKey") or settings.omdb_api_key
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
# gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
# "from rippy import store as db", dieses Modul hat es nie
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("omdbApiKey") or settings.omdb_api_key
self.session = requests.Session()
def suche(self, title: str) -> list:
+7 -2
View File
@@ -13,8 +13,13 @@ THETVDB_BASE_URL = "https://api.thetvdb.com"
class TheTVDBClient:
def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable
from db import get_settings
self.api_key = get_settings().get("tvdbApiKey") or settings.thetvdb_api_key
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026). docker/api/db.py
# gibt es seit V2-1 (dd1d0b7) nicht mehr — main.py schreibt seitdem
# "from rippy import store as db", dieses Modul hat es nie
# mitbekommen. Jede Abfrage starb beim Erzeugen des Clients, und
# _auto_prescan verschluckte es. Siehe clients/tmdb.py.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("tvdbApiKey") or settings.thetvdb_api_key
self.base_url = THETVDB_BASE_URL
self.session = requests.Session()
self.session.headers.update({
+14 -2
View File
@@ -24,8 +24,20 @@ class TMDBClient:
def __init__(self):
# DB-Einstellung (Settings-UI/Wizard) gewinnt gegen die Env-Variable —
# vorher war das Settings-Feld reine Dekoration (Fix 23.07.).
from db import get_settings
self.api_key = get_settings().get("tmdbApiKey") or settings.tmdb_api_key
# ⚠️ rippy.store, NICHT db (Befund 28.08.2026).
#
# Hier stand "from db import get_settings". docker/api/db.py gibt es
# seit der Zusammenlegung in V2-1 (dd1d0b7) nicht mehr — main.py
# schreibt seitdem "from rippy import store as db", aber dieses Modul
# hat das nie mitbekommen. Die Folge: JEDE Metadaten-Abfrage starb
# schon beim Erzeugen des Clients mit ModuleNotFoundError, und
# _auto_prescan verschluckte das in seinem "except Exception". Discs
# blieben namenlos — auf Windows UND auf der VM.
#
# bei_fehler_leer=True: Ohne Datenbank gilt der Schlüssel aus der
# Umgebung. Ein Metadaten-Client ist kein Grund, warum nichts geht.
from rippy.store import get_settings
self.api_key = get_settings(bei_fehler_leer=True).get("tmdbApiKey") or settings.tmdb_api_key
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
# Beide Key-Arten unterstützen (developer.themoviedb.org: v3 als
-284
View File
@@ -1,284 +0,0 @@
"""Job-, Log- und Settings-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
Die jobs/logs-Tabellendefinition existiert bewusst identisch im Worker
(docker/worker/db.py) — es gibt kein geteiltes Paket zwischen den Containern.
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
Bis 23.07. lag Postgres komplett brach: GET /jobs gab hart [] zurück, nichts
schrieb je eine Zeile — der Job-Verlauf im UI war ein Placebo.
"""
import json
import os
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
select,
)
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
jobs = Table(
"jobs",
metadata,
Column("id", String(36), primary_key=True),
Column("disc_type", String(16)),
Column("device", String(64)),
Column("title", String(255)),
Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON: Jahr/Poster/Plot) für Detail-Popup + NFO
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
logs = Table(
"logs",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", DateTime(timezone=True)),
Column("level", String(16)),
Column("source", String(32)),
Column("message", Text),
)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
storage_mounts = Table(
"storage_mounts",
metadata,
Column("name", String(64), primary_key=True),
Column("typ", String(8)), # nfs | cifs
Column("quelle", String(255)), # host:/export bzw. //host/share
Column("optionen", String(255)),
Column("username", String(128)),
Column("passwort", String(255)), # Klartext — Heimnetz-Kompromiss, siehe README
)
def list_workers() -> list:
import json
with engine.connect() as conn:
zeilen = conn.execute(select(workers)).mappings().all()
ergebnis = []
for z in zeilen:
eintrag = dict(z)
try:
eintrag["encoders"] = json.loads(eintrag.get("encoders") or "[]")
except ValueError:
eintrag["encoders"] = []
try:
eintrag["info"] = json.loads(eintrag.get("info") or "{}")
except ValueError:
eintrag["info"] = {}
if eintrag.get("last_seen"):
eintrag["last_seen"] = eintrag["last_seen"].isoformat()
ergebnis.append(eintrag)
return ergebnis
def list_mounts() -> list:
with engine.connect() as conn:
return [dict(z) for z in conn.execute(select(storage_mounts)).mappings().all()]
def save_mount(name: str, typ: str, quelle: str, optionen: str, username: str, passwort: str) -> None:
with engine.begin() as conn:
conn.execute(
storage_mounts.insert().values(
name=name, typ=typ, quelle=quelle,
optionen=optionen, username=username, passwort=passwort,
)
)
def delete_mount(name: str) -> None:
with engine.begin() as conn:
conn.execute(storage_mounts.delete().where(storage_mounts.c.name == name))
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach."""
metadata.create_all(engine)
# create_all ändert BESTEHENDE Tabellen nicht — neue Spalten hier nachziehen:
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
def insert_job(
job_id: str, device: str, disc_type: str = None, title: str = None,
target_dir: str = None, meta: str = None,
) -> None:
with engine.begin() as conn:
conn.execute(
jobs.insert().values(
id=job_id,
device=device,
disc_type=disc_type,
title=title,
target_dir=target_dir,
meta=meta,
status="pending",
progress=0,
created_at=utcnow(),
)
)
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def get_job(job_id: str) -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def list_jobs(limit: int = 100) -> list:
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs).order_by(jobs.c.created_at.desc()).limit(limit)
).mappings().all()
return [dict(z) for z in zeilen]
def delete_job(job_id: str) -> None:
"""Entfernt EINEN Job-Eintrag (nur die DB-Zeile — Dateien bleiben)."""
with engine.begin() as conn:
conn.execute(jobs.delete().where(jobs.c.id == job_id))
def delete_finished_jobs() -> int:
"""Räumt alle erledigten Jobs (completed/failed) aus der Liste. Dateien bleiben."""
with engine.begin() as conn:
ergebnis = conn.execute(
jobs.delete().where(jobs.c.status.in_(("completed", "failed")))
)
return ergebnis.rowcount or 0
def delete_worker(name: str) -> None:
"""Entfernt einen (verwaisten) Worker-Eintrag aus der Liste."""
with engine.begin() as conn:
conn.execute(workers.delete().where(workers.c.name == name))
def hat_arbeit() -> bool:
"""True, wenn IRGENDEIN Job noch nicht durch ist (egal auf welchem Gerät).
Gebraucht von der Mount-Wache: Eine Freigabe neu zu verbinden bedeutet ein
`umount -l` — mitten in einem laufenden Rip oder Encode wäre das ein
Datenverlust. `pending` zählt bewusst mit: so ein Job kann jeden Moment
anlaufen.
"""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.status.in_(
("pending", "running", "ripping", "transcoding", "canceling")))
.limit(1)
).first()
return zeile is not None
def has_active_job(device: str) -> bool:
"""True, wenn auf dem Gerät ein Job läuft oder wartet (Eject-Schutz)."""
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.id)
.where(jobs.c.device == device)
.where(jobs.c.status.in_(("pending", "running")))
.limit(1)
).first()
return zeile is not None
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
def list_logs(limit: int = 200) -> list:
with engine.connect() as conn:
zeilen = conn.execute(
select(logs).order_by(logs.c.id.desc()).limit(limit)
).mappings().all()
return [dict(z) for z in zeilen]
def get_settings(key: str = "ui") -> dict:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if not zeile or not zeile[0]:
return {}
try:
return json.loads(zeile[0])
except ValueError:
return {}
def save_settings(werte: dict, key: str = "ui") -> None:
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
-110
View File
@@ -1,110 +0,0 @@
"""Laufwerks-Discovery über /sys und ioctls — ehrlich, ohne udev.
Warum kein udevadm mehr: Im Container läuft kein udevd, die udev-Datenbank ist
leer — `udevadm info` lieferte schlicht nichts (der alte Weg zeigte deshalb nie
ein Laufwerk an). /sys/class/block/<name>/device/{vendor,model} kommt dagegen
direkt vom Kernel und funktioniert überall.
"""
import glob
import os
from fcntl import ioctl
from detection import (
CDS_DISC_OK,
classify,
disc_size_bytes,
disc_status,
drive_status,
)
# include/uapi/linux/cdrom.h
CDROMEJECT = 0x5309
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
CDROM_DRIVE_STATUS = 0x5326
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
AUSWURF_WARTEN_SEKUNDEN = 5
def eject(device_path: str) -> None:
"""Wirft die Disc aus und prüft es nach. Wirft OSError, wenn sie drin bleibt.
⚠️ ERST ENTRIEGELN (Befund 26.07.2026, am laufenden System gemessen): Ein
nacktes CDROMEJECT wird von einem verriegelten Laufwerk mit ERFOLG quittiert
und tut nichts. MakeMKV verriegelt die Tür während des Rips
(`CDROM_LOCKDOOR 1`) und entriegelt sie nicht wieder — danach blieb die
Schublade zu, während Rippy „Disc ausgeworfen" ins Log schrieb. Gegenprobe
an derselben Disc: mit `CDROM_LOCKDOOR 0` davor geht sie auf (Status 2).
Deshalb macht das Werkzeug `eject` immer beides.
Gleichlautend in worker/ripping.wirf_disc_aus — es gibt kein geteiltes Paket
zwischen den Containern; dort steht die ausführliche Herleitung.
"""
import time
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
try:
ioctl(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass # nicht verriegelt oder ioctl unbekannt — Auswurf trotzdem versuchen
ioctl(fd, CDROMEJECT, 0)
# Nachsehen statt hoffen. „Kein Datenträger" zählt mit: ein
# Slot-Laufwerk hat keine Schublade.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
if ioctl(fd, CDROM_DRIVE_STATUS, 0) in (CDS_TRAY_OPEN, CDS_NO_DISC):
return
time.sleep(1)
raise OSError(
"Das Laufwerk hat den Auswurf angenommen, die Disc ist aber noch "
"drin. Blockiert etwas die Schublade, oder läuft noch ein Zugriff?"
)
finally:
os.close(fd)
def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die der Container sieht (devices: in compose)."""
return sorted(glob.glob("/dev/sr[0-9]*"))
def read_sys_attr(device_name: str, attr: str) -> str:
pfad = f"/sys/class/block/{device_name}/device/{attr}"
try:
with open(pfad, encoding="ascii", errors="replace") as f:
return f.read().strip()
except OSError:
return ""
def device_info(device_path: str) -> dict:
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl."""
name = os.path.basename(device_path)
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
try:
status_code = drive_status(device_path)
except OSError:
status_code = -1
disc_type = "unknown"
status = "empty"
if status_code == CDS_DISC_OK:
status = "ready"
try:
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
disc_type = "unknown"
return {
"id": name,
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
"type": disc_type,
"path": device_path,
"status": status,
"model": model,
"serial": read_sys_attr(name, "wwid"),
}
+42 -5
View File
@@ -111,13 +111,44 @@ def schluessel(job_id: str) -> str:
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,
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
aus, kommt bei jedem Aufruf eine leere Reihe zurück — dann gibt es eben
keine ETA, aber nichts scheitert.
Gespeichert wird in BEIDEN Ablagen: im Cache, wo es einen gibt (er
überlebt einen API-Neustart), und im Prozess, damit die Schätzung auch
ohne Redis zustande kommt. Siehe `_REIHEN`.
"""
if status not in LAUFENDE_STATUS or progress <= 0:
return {"sekunden": -1, "text": ""}
@@ -126,11 +157,17 @@ def aktualisiere_und_schaetze(job_id: str, status: str, progress: int,
reihe = cache_get(k)
except Exception:
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)
try:
# 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:
pass
_REIHEN[k] = (jetzt, reihe)
_aufraeumen(jetzt)
sekunden = restzeit_sekunden(reihe, jetzt)
return {"sekunden": sekunden, "text": formatiere_restzeit(sekunden)}
+1086 -83
View File
File diff suppressed because it is too large Load Diff
-375
View File
@@ -1,375 +0,0 @@
"""MakeMKV-Datenverzeichnis: Schlüsselspeicher, KEYDB.cfg, AACS-Dumps.
WARUM ES DIESE DATEI GIBT (Befund 25.07.2026, auf BEIDEN Maschinen gemessen):
4K-UHD-Discs scheiterten mit "The volume key is unknown for this disc". Die
Ursache ist weder die Disc noch das Laufwerk LibreDrive v06.3 läuft und
MakeMKV liest die Disc sondern:
makemkvcon unter LINUX ruft die Disc-Schluessel nie ab.
Die Windows-Version tut es.
Gemessen, nicht vermutet:
* Linux: in KEINEM Lauf auch nur eine Verbindung nach draussen. Geprueft mit
leerem UND mit gefülltem Schlüsselspeicher, mit und ohne --noscan, mit
dev:/dev/sr0 und mit disc:0, und mit erzwungener frischer Prüfung
(update.conf gelöscht, Meldung 5074 belegt den Web-Kontakt). Immer:
keine Verbindung, kein Schluessel.
* Windows, dieselbe Disc, dasselbe Laufwerk: Meldung 3338 "Downloading
latest HK to ...", Verbindung nach 185.84.108.20:443
(web33.majordomo.ru), _private_data.tar wächst und die Disc geht auf
(TCOUNT:5, "Operation successfully completed").
* Der Code dafür steckt auch im Linux-Binary: die Meldungsvorlage
"Downloading latest %1 to %2 ..." steht in makemkvcon. Sie löst nur nie
aus. Gleiches Symptom im Forum, seit Jahren offen und unbeantwortet.
FRUEHERE FEHLDIAGNOSE, bewusst festgehalten: An dieser Stelle stand zuerst,
MakeMKVs Schluessel-Kanal sei abgeschaltet. Das war FALSCH. Die Herleitung
stützte sich auf zwei Hostnamen aus alten Forumsbeitraegen
(hkdata.fairuse.org, hkdata.crabdance.com), die tatsaechlich nicht mehr
aufloesen MakeMKV benutzt sie aber laengst nicht mehr. Der Dienst lebt, der
Worker erreicht ihn sogar; er wird unter Linux nur nie gefragt.
WAS DARAUS FOLGT: Der Schlüsselspeicher (_private_data.tar) muss von einer
MakeMKV-Installation kommen, die ihn wirklich abruft praktisch von Windows.
Rippy nimmt ihn unter Einstellungen -> System entgegen. Die KEYDB.cfg bleibt
der Notnagel für Pressungen, die auch MakeMKV selbst nicht kennt.
Rippy liefert KEINE Schluessel mit, lädt keine herunter und verteilt keine.
Es verwaltet nur, was der Nutzer selbst mitbringt.
QUELLEN (AGENTS Regel D externe Schnittstellen nie aus dem Kopf):
* Linux lädt keine Hashed Keys dasselbe Symptom, mehrfach berichtet:
https://forum.makemkv.com/forum/viewtopic.php?t=25782
https://forum.makemkv.com/forum/viewtopic.php?t=34022
* Schluessel liegen als hkd_*.bin in _private_data.tar:
https://forum.makemkv.com/forum/viewtopic.php?t=32675
* Datenverzeichnis und Dateiname KEYDB.cfg GROSS geschrieben (unter Linux
case-sensitiv), dort geloest mit "cp KEYDB.cfg ~/.MakeMKV/":
https://forum.makemkv.com/forum/viewtopic.php?t=30636
* Zeilenformat der KEYDB.cfg (libaacs): Disc-Kennung = 40 Hex-Zeichen,
optional mit 0x-Praefix, dann "= Titel", danach optionale Felder wie
"| V | <32 Hex>"; Zeilen ab ";" sind Kommentare:
https://github.com/ShiftMediaProject/libaacs/blob/master/KEYDB.cfg
* Den AACS-Dump legt MakeMKV selbst ab, Meldung 3332 "Saved AACS dump file
as file:///root/.MakeMKV/<name>.tgz" (am 25.07.2026 so beobachtet).
ZWILLINGSDATEI: liegt identisch unter docker/api/ und docker/worker/ beide
Images brauchen sie, ein gemeinsames Paket gibt es in diesem Projekt nicht
(gleiche Lage wie bei db.py). Änderungen IMMER in BEIDEN Dateien nachziehen.
"""
import io
import os
import re
import tarfile
from datetime import datetime, timezone
# Container-Pfad aus der Umgebung: /root/.MakeMKV im Worker (nur dort sucht
# makemkvcon), /app/makemkv-data in der API. Beide zeigen laut
# docker-compose.yml auf DASSELBE Host-Verzeichnis.
DATEN_DIR = os.getenv("MAKEMKV_DATA_DIR", "/root/.MakeMKV")
# GROSS geschrieben — unter Linux case-sensitiv, "keydb.cfg" wird ignoriert.
KEYDB_NAME = "KEYDB.cfg"
# Obergrenze für den Upload. Eine vollständige oeffentliche KEYDB.cfg liegt
# im einstelligen MB-Bereich; 64 MB sind reichlich Luft und verhindern, dass
# eine versehentlich hochgeladene Riesendatei den Speicher vollschreibt.
MAX_KEYDB_BYTES = 64 * 1024 * 1024
# Eine Disc-Zeile beginnt mit der 40 Zeichen langen Hex-Kennung (optional mit
# 0x davor), danach folgt das Gleichheitszeichen. Alles andere (Kommentare ab
# ";", Leerzeilen, Fortsetzungsfelder) zählt nicht als Eintrag.
_DISC_ZEILE = re.compile(r"^\s*(?:0x)?[0-9a-fA-F]{40}\s*=")
def _iso(zeitstempel: float) -> str:
"""Unix-Zeit -> ISO-8601 in UTC (sekundengenau), wie db.utcnow() es tut."""
return datetime.fromtimestamp(zeitstempel, timezone.utc).isoformat(timespec="seconds")
def keydb_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zur KEYDB.cfg im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, KEYDB_NAME)
def zaehle_disc_eintraege(inhalt: str) -> int:
"""Zeilen mit Disc-Kennung zählen (pure Funktion, testbar).
Bewusst eine Heuristik und keine vollständige Auswertung: MakeMKV liest
die Datei mit seinem eigenen Parser, und wie viele Eintraege es daraus
macht, ist von aussen nicht sichtbar. Die Zahl dient nur dazu, im UI
"da liegt wirklich etwas drin" von "leere oder falsche Datei" zu
unterscheiden sie wird deshalb auch genau so beschriftet.
"""
return sum(1 for zeile in inhalt.splitlines() if _DISC_ZEILE.match(zeile))
def keydb_pruefen(inhalt: str) -> str:
"""Prüft hochgeladenen Inhalt; gibt deutschen Fehlertext oder "" zurück.
Verhindert den häufigsten Bedienfehler: statt der KEYDB.cfg landet die
HTML-Fehlerseite eines Downloads oder eine leere Datei im Verzeichnis
MakeMKV würde dann still weiter "volume key is unknown" melden.
"""
if not inhalt.strip():
return "Die Datei ist leer."
if len(inhalt.encode("utf-8")) > MAX_KEYDB_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_KEYDB_BYTES // (1024 * 1024)} MB — das ist keine KEYDB.cfg."
)
if inhalt.lstrip()[:1] == "<":
return (
"Das sieht nach HTML aus, nicht nach einer KEYDB.cfg — "
"vermutlich wurde eine Fehlerseite statt der Datei geladen."
)
if zaehle_disc_eintraege(inhalt) == 0:
return (
"Keine einzige Zeile mit Disc-Kennung gefunden (40 Hex-Zeichen, "
"dann ein Gleichheitszeichen). Das ist keine KEYDB.cfg."
)
return ""
def keydb_status(daten_dir: str = None) -> dict:
"""Zustand der KEYDB.cfg. Fehlt sie, ist das der Normalfall, kein Fehler."""
pfad = keydb_pfad(daten_dir)
try:
angaben = os.stat(pfad)
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"eintraege": 0,
"geaendert": "",
}
eintraege = 0
try:
with open(pfad, encoding="utf-8", errors="replace") as datei:
eintraege = zaehle_disc_eintraege(datei.read())
except OSError:
pass # Datei da, aber unlesbar: Größe/Datum stimmen trotzdem
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"eintraege": eintraege,
"geaendert": _iso(angaben.st_mtime),
}
def keydb_schreiben(inhalt: str, daten_dir: str = None) -> dict:
"""Schreibt die KEYDB.cfg atomar und meldet den neuen Zustand.
Erst in eine Nebendatei, dann os.replace: während ein Rip läuft, darf
makemkvcon niemals eine halb geschriebene Datei zu sehen bekommen.
"""
pfad = keydb_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "w", encoding="utf-8", newline="\n") as datei:
datei.write(inhalt)
os.replace(neben, pfad)
except OSError:
# Die Nebendatei nie liegen lassen: eine halb geschriebene
# KEYDB.cfg.neu verwirrt jeden, der ins Verzeichnis schaut, und
# belegt im Extremfall 64 MB, die niemand mehr aufräumt.
try:
os.remove(neben)
except OSError:
pass
raise
return keydb_status(daten_dir)
def keydb_loeschen(daten_dir: str = None) -> dict:
"""Entfernt die KEYDB.cfg (z. B. nach einem Fehlgriff beim Hochladen).
Nur "Datei war schon weg" wird geschluckt das ist das gewünschte
Ergebnis. Jeder andere Fehler (schreibgeschützter Mount, fremder
Eigentümer) MUSS nach oben durch: sonst meldete die API einen Erfolg,
den es nicht gab, und die Datei wirkte beim nächsten Rip weiter.
"""
try:
os.remove(keydb_pfad(daten_dir))
except FileNotFoundError:
pass
return keydb_status(daten_dir)
def ist_aacs_dump(name: str) -> bool:
"""Dateiname eines AACS-Dumps? (pure Funktion, testbar)
MakeMKV legt ihn als <MKB..._NAME_....>.tgz direkt im Datenverzeichnis ab
(Meldung 3332). Pfadtrenner und fuehrende Punkte werden hier schon
ausgeschlossen, damit der Download-Endpunkt keine Pfad-Tricks erlaubt.
"""
return (
name.endswith(".tgz")
and "/" not in name
and "\\" not in name
and not name.startswith(".")
)
def dumps_auflisten(daten_dir: str = None) -> list:
"""Alle AACS-Dumps im Datenverzeichnis, neueste zuerst."""
ordner = daten_dir or DATEN_DIR
try:
namen = os.listdir(ordner)
except OSError:
return []
liste = []
for name in namen:
if not ist_aacs_dump(name):
continue
try:
angaben = os.stat(os.path.join(ordner, name))
except OSError:
continue
liste.append(
{
"name": name,
"groesse_bytes": angaben.st_size,
"geaendert": _iso(angaben.st_mtime),
"_sort": angaben.st_mtime,
}
)
liste.sort(key=lambda eintrag: eintrag["_sort"], reverse=True)
for eintrag in liste:
del eintrag["_sort"]
return liste
# --- Schlüsselspeicher (_private_data.tar) -------------------------------
#
# Das ist MakeMKVs eigener Speicher für die "Hashed Keys": ein tar-Archiv mit
# hkd_*.bin-Eintraegen. Unter Windows füllt MakeMKV es selbst (Meldung 3338),
# unter Linux nie — siehe Modul-Kopf. Rippy nimmt die Datei deshalb entgegen
# und legt sie ins Datenverzeichnis; MakeMKV liest sie beim nächsten Start.
PRIVATE_DATA_NAME = "_private_data.tar"
# Der Speicher lag am 25.07.2026 bei rund 6 MB und wächst mit jeder neuen
# Pressung. 64 MB sind reichlich Luft — und derselbe Wert wie
# client_max_body_size in docker/ui/nginx.conf: wäre die Grenze hier höher,
# würde nginx den Upload abweisen, bevor die API ihn überhaupt sieht.
MAX_PRIVATE_DATA_BYTES = 64 * 1024 * 1024
def private_data_pfad(daten_dir: str = None) -> str:
"""Voller Pfad zum Schlüsselspeicher im Datenverzeichnis."""
return os.path.join(daten_dir or DATEN_DIR, PRIVATE_DATA_NAME)
def zaehle_schluessel(rohdaten: bytes) -> int:
"""Anzahl der hkd_*.bin-Eintraege im Archiv (pure Funktion, testbar).
Das ist die ehrliche Kennzahl für "wie viele Disc-Schluessel kennt diese
Installation". Ein frischer, leerer Speicher enthält nur eine
Index-Datei und kommt hier auf 0 genau der Zustand, in dem jede
unbekannte UHD-Disc scheitert.
"""
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
return sum(1 for name in archiv.getnames() if name.startswith("hkd_"))
except (tarfile.TarError, OSError, EOFError):
return 0
def private_data_pruefen(rohdaten: bytes) -> str:
"""Prüft hochgeladene Rohdaten; deutscher Fehlertext oder "".
Faengt die beiden Bedienfehler ab, die sonst still danebengehen: eine
voellig andere Datei hochladen, oder den Speicher einer Installation, die
selbst noch keine Schluessel geholt hat (dann ändert sich nichts, und
niemand versteht warum).
"""
if not rohdaten:
return "Die Datei ist leer."
if len(rohdaten) > MAX_PRIVATE_DATA_BYTES:
return (
"Die Datei ist größer als "
f"{MAX_PRIVATE_DATA_BYTES // (1024 * 1024)} MB — das ist kein "
"MakeMKV-Schlüsselspeicher."
)
try:
with tarfile.open(fileobj=io.BytesIO(rohdaten)) as archiv:
namen = archiv.getnames()
except (tarfile.TarError, OSError, EOFError):
return (
"Das ist kein tar-Archiv. Erwartet wird die Datei "
f"{PRIVATE_DATA_NAME} aus dem MakeMKV-Datenverzeichnis."
)
if not any(name.startswith("hkd_") for name in namen):
return (
"In dieser Datei steckt kein einziger Schluessel (kein hkd_*.bin). "
"Sie stammt vermutlich von einer MakeMKV-Installation, die selbst "
"noch keine geholt hat — öffne dort erst einmal eine Disc."
)
return ""
def schluesselspeicher_status(daten_dir: str = None) -> dict:
"""Zustand des Schlüsselspeichers. Fehlt er, ist das kein Fehler."""
pfad = private_data_pfad(daten_dir)
try:
angaben = os.stat(pfad)
with open(pfad, "rb") as datei:
schluessel = zaehle_schluessel(datei.read())
except OSError:
return {
"vorhanden": False,
"pfad": pfad,
"groesse_bytes": 0,
"schluessel": 0,
"geaendert": "",
}
return {
"vorhanden": True,
"pfad": pfad,
"groesse_bytes": angaben.st_size,
"schluessel": schluessel,
"geaendert": _iso(angaben.st_mtime),
}
def private_data_schreiben(rohdaten: bytes, daten_dir: str = None) -> dict:
"""Legt den Schlüsselspeicher atomar ab und meldet den neuen Zustand.
Atomar aus demselben Grund wie bei der KEYDB.cfg: während ein Rip läuft,
darf makemkvcon nie ein halb geschriebenes Archiv sehen.
"""
pfad = private_data_pfad(daten_dir)
os.makedirs(os.path.dirname(pfad), exist_ok=True)
neben = pfad + ".neu"
try:
with open(neben, "wb") as datei:
datei.write(rohdaten)
os.replace(neben, pfad)
except OSError:
try:
os.remove(neben)
except OSError:
pass
raise
return schluesselspeicher_status(daten_dir)
def settings_conf_zusammenfuehren(inhalt: str, key: str) -> str:
"""app_Key setzen, ohne den Rest der settings.conf zu verlieren (pure).
Bis zum 25.07.2026 haben entrypoint.sh UND tasks.py die Datei komplett
überschrieben. Mit dem jetzt persistenten Datenverzeichnis wäre damit
bei jedem Containerstart und vor jedem Rip alles andere weg z. B.
app_UpdateEnable. Leerer Key lässt die vorhandene Zeile ebenfalls fallen,
damit ein bewusst geleerter Key nicht heimlich weiterwirkt.
"""
zeilen = [z for z in inhalt.splitlines() if not z.lstrip().startswith("app_Key")]
if key:
zeilen.append('app_Key = "{}"'.format(key))
text = "\n".join(zeilen).strip("\n")
return text + "\n" if text else ""
+3 -3
View File
@@ -50,7 +50,7 @@ def fetch_current_key(timeout: int = 15) -> str | None:
def _apply_key(key: str) -> bool:
"""Merge den Key in die Settings. save_settings überschreibt das GANZE JSON,
darum erst lesen, dann setzen. Rueckgabe True, wenn sich der Key geaendert hat."""
import db
from rippy import store as db
einstellungen = db.get_settings()
if (einstellungen.get("makemkvAppKey") or "").strip() == key:
return False
@@ -61,7 +61,7 @@ def _apply_key(key: str) -> bool:
async def refresh_once() -> bool:
"""Einmal holen + anwenden. Best-effort, loggt in die App-Logs. True = geaendert."""
import db
from rippy import store as db
key = await asyncio.to_thread(fetch_current_key)
if not key:
db.add_log("warning", "makemkv-key",
@@ -77,7 +77,7 @@ async def refresh_once() -> bool:
async def refresh_loop(intervall_stunden: int = 24, start_verzoegerung_s: int = 60) -> None:
"""Periodische Erneuerung (Default: taeglich, damit ein Monatswechsel nie verpasst
wird). Fehler werden geloggt, aber nie geworfen -> die API läuft weiter."""
import db
from rippy import store as db
await asyncio.sleep(start_verzoegerung_s)
while True:
try:
+7 -3
View File
@@ -12,11 +12,13 @@ sondern über eine temporäre credentials-Datei).
import os
import posixpath
from rippy.platform.winlauf import OHNE_FENSTER
import re
import subprocess
import tempfile
import db
from rippy import store as db
MEDIA_ROOT = "/app/media"
NAME_MUSTER = re.compile(r"^[a-z0-9][a-z0-9-]{1,30}$")
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
ziel = _mountpoint(name)
try:
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
except (OSError, subprocess.TimeoutExpired):
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
"""
try:
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):
return "unklar"
+7 -3
View File
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
NICHTS = ""
def _meta(meta_json) -> dict:
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
def meta_von(meta_json) -> dict:
"""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):
return meta_json
if not meta_json:
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
"""
if (job.get("status") or "") != "failed":
return NICHTS
marke = _meta(job.get("meta")).get(RIP_FERTIG)
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
if marke is True:
return NEU_KOMPRIMIEREN
if marke is False:
+163 -38
View File
@@ -6,11 +6,11 @@ lieferte seitdem immer „Unknown Disc". Dies ist die echte Logik, bereinigt.
"""
import os
import shutil
import subprocess
from typing import Dict, List, Optional
import detection
from rippy import drives as _laufwerks_schicht
from rippy.platform.winlauf import OHNE_FENSTER
from clients.tmdb import TMDBClient
from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient
@@ -19,6 +19,19 @@ from clients.thetvdb import TheTVDBClient
from cache import get, set as cache_set
from cache.keys import generate_prescan_key
# Der Treiber DIESER Maschine statt fest der Linux-Fassung — sonst waere
# dieses Modul (und ueber es main.py) unter Windows nicht ladbar. Beide
# Treiber bieten disc_size_bytes() und detect_disc_type() an; darauf achtet
# test_treiberwahl.py.
detection = _laufwerks_schicht.treiber()
# Wie viele Treffer eine Suche hoechstens liefern darf, damit sie als
# SPEZIFISCH gilt. Gemessen am 28.08.2026: Der volle Disc-Titel
# „Evangelion 2.22" ergab bei TMDB 1 Treffer (den richtigen), die gekuerzte
# Variante „Evangelion" ergab 20 (beliebige). Drei ist grosszuegig genug fuer
# Neuauflagen und Regie-Fassungen desselben Films.
WENIGE_TREFFER = 3
def normalize_disc_label(label: str) -> str:
"""Disc-Labels wie 'PULP_FICTION_DE''Pulp Fiction De' (pure Funktion).
@@ -74,50 +87,95 @@ def parse_udf_dstring(data: bytes) -> str:
return ""
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
def disc_wurzel(device_path: str):
r"""Ein lesbarer Einstieg in die Disc — Windows braucht dafuer kein Mounten.
Das Volume-Label ist oft kryptisch (BD_EVG_D2) der echte Titel
(Evangelion: 2.22 ") steht in den Disc-Metadaten. Die API darf
read-only mounten (CAP_SYS_ADMIN ist für die Speicherziele ohnehin da).
Gibt None zurück, wenn kein BD-Metadatensatz existiert (z. B. DVD).
Gibt `(wurzel, aufraeumen)` zurueck. `aufraeumen` ist eine Funktion, die
nach dem Lesen aufgerufen wird; unter Windows tut sie nichts.
## Warum das getrennt ist (28.08.2026)
Der Weg hier war fest `mount -t udf` also Linux. Unter Windows haengt
Windows die Disc selbst ein: `\.\G:` entspricht `G:\`, und dort liegen
die Dateien einfach da. Ohne diese Unterscheidung fand Rippy unter
Windows NIE den Klartext-Titel und blieb beim Volume-Label haengen
(gemessen an einer echten Disc: `BD_EVG_D2` statt `Evangelion 2.22`) --
und mit so einem Label findet keine Metadaten-API etwas.
"""
import re as _re
if os.name == "nt":
# \.\G: -> G:\ (der Buchstabe ist alles, was wir brauchen)
buchstabe = device_path.rstrip(":").rsplit("\\", 1)[-1].rstrip(":")
wurzel = buchstabe + ":" + "\\"
return (wurzel, lambda: None) if os.path.isdir(wurzel) else (None, lambda: None)
mountpoint = "/mnt/rippy-disc"
os.makedirs(mountpoint, exist_ok=True)
gemountet = False
try:
ergebnis = subprocess.run(
["mount", "-t", "udf", "-o", "ro", device_path, mountpoint],
capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER,
)
if ergebnis.returncode != 0:
return None
gemountet = True
return None, lambda: None
meta_dir = os.path.join(mountpoint, "BDMV", "META", "DL")
def abhaengen():
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15,
creationflags=OHNE_FENSTER)
return mountpoint, abhaengen
def titel_aus_bdmt(wurzel: str) -> Optional[str]:
"""Der Klartext-Titel aus BDMV/META/DL/bdmt_*.xml. (ohne Mounten)
Getrennt von der Beschaffung der Wurzel, damit er ohne Disc pruefbar ist:
ein Ordner mit einer bdmt-Datei reicht.
"""
import re as _re
meta_dir = os.path.join(wurzel, "BDMV", "META", "DL")
if not os.path.isdir(meta_dir):
return None
try:
kandidaten = sorted(os.listdir(meta_dir))
# bdmt_eng.xml bevorzugen, sonst erste bdmt-Datei (bdmt_ger.xml, …)
except OSError:
return None
# bdmt_eng.xml bevorzugen, sonst die erste bdmt-Datei (bdmt_deu.xml, ...)
bdmt = next((k for k in kandidaten if k == "bdmt_eng.xml"), None) or next(
(k for k in kandidaten if k.startswith("bdmt_") and k.endswith(".xml")), None
)
if not bdmt:
return None
try:
with open(os.path.join(meta_dir, bdmt), "rb") as f:
inhalt = f.read(65536).decode("utf-8", errors="replace")
# <di:name>Titel</di:name> — bewusst per Regex statt XML-Parser
except OSError:
return None
# <di:name>Titel</di:name> - bewusst per Regex statt XML-Parser
# (Namespaces variieren je Authoring-Werkzeug)
treffer = _re.search(r"<di:name>([^<]{2,120})</di:name>", inhalt)
if treffer:
return treffer.group(1).strip()
return None
return treffer.group(1).strip() if treffer else None
def read_disc_title_via_mount(device_path: str) -> Optional[str]:
"""Liest den KLARTEXT-Titel einer Blu-ray aus BDMV/META/DL/bdmt_*.xml.
Das Volume-Label ist oft kryptisch (`BD_EVG_D2`) - der echte Titel
(Evangelion 2.22") steht in den Disc-Metadaten. Unter Linux wird dafuer
read-only gemountet, unter Windows haengt Windows die Disc selbst ein.
Gibt None zurueck, wenn kein BD-Metadatensatz existiert (z. B. DVD).
"""
wurzel, aufraeumen = None, lambda: None
try:
wurzel, aufraeumen = disc_wurzel(device_path)
return titel_aus_bdmt(wurzel) if wurzel else None
except Exception:
return None
finally:
if gemountet:
subprocess.run(["umount", mountpoint], capture_output=True, timeout=15)
try:
aufraeumen()
except Exception:
pass
def titel_kandidaten(titel: str) -> List[str]:
@@ -273,7 +331,8 @@ class PreScan:
["cdparanoia", "-Q", device_path],
capture_output=True,
text=True,
timeout=10
timeout=10,
creationflags=OHNE_FENSTER,
)
for line in result.stdout.split('\n'):
if 'track' in line.lower():
@@ -297,22 +356,37 @@ class PreScan:
if label:
toc["title"] = normalize_disc_label(label)
# Titel-Quelle 2 (optional, falls makemkvcon doch da ist):
if shutil.which("makemkvcon"):
result = subprocess.run(
["makemkvcon", "-r", "--noscan", "--minlength=300", "info", f"dev:{device_path}"],
capture_output=True,
text=True,
timeout=120
)
for line in result.stdout.split('\n'):
if line.startswith('TINFO:'):
parts = line.split(',')
if len(parts) >= 5:
toc["tracks"].append({
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
"duration": int(parts[2]) if len(parts) > 2 else 0
})
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
#
# ## Warum sie weg ist (Befund 30.08.2026)
#
# Der Commander: „das erkennen der disk dauert sehr sehr
# lange. Das ging mal viel schneller."
#
# Hier stand ein `makemkvcon -r --noscan --minlength=300
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
#
# Dass es frueher schnell war, hat einen unschoenen Grund:
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
# `shutil.which`, und das findet unter Windows nichts
# (Programme liegen nicht im PATH). `be3fac5` hat das am
# 28.08.2026 richtig repariert — und damit erst die Kosten
# sichtbar gemacht, die hier immer schon standen.
#
# Der Aufwand war umsonst: Das Ergebnis landete allein in
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
# 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:
print(f"Pre-Scan TOC Error: {e}")
return toc
@@ -389,11 +463,13 @@ class PreScan:
# probieren — Disc-Titel sind selten API-freundlich formatiert.
kandidaten = titel_kandidaten(title) or [title]
movies = []
movies_kandidat = "" # WELCHE Variante hat sie geliefert?
for kandidat in kandidaten:
treffer = self.tmdb.search_movie(kandidat)
if treffer and not movies:
movies = treffer # bester Rohtreffer für den Vorschlags-Fallback
movies_kandidat = kandidat
for movie in treffer:
if movie.get("title", "").lower() == kandidat.lower():
confidence = 0.95
@@ -440,6 +516,55 @@ class PreScan:
if matched:
break
# ── Ein spezifischer Treffer zählt, auch ohne exakten Titel ─────────
#
# ## Der Befund des Commanders (28.08.2026)
#
# > „Was ist mit dem Cover auf Windows Rippy? In der Web Version haben
# > wir ein cover."
#
# Es gab keins, weil es gar keinen Treffer gab — und der Grund war
# nicht Windows, sondern die Bedingung oben: `movie["title"].lower()
# == kandidat.lower()`. An seiner Disc gemessen:
#
# 'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
# 'Evangelion' 20 Treffer irgendein Evangelion
#
# Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film,
# mit Poster — und wurde verworfen, weil der Titel nicht wörtlich
# gleich war. Danach gewann eine schlechtere Quelle oder gar keine.
#
# ## Warum die Trefferzahl das bessere Kriterium ist als Ähnlichkeit
#
# Die Ähnlichkeit hilft hier nicht: „Evangelion 2.22" gegen
# „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51 — das steht als
# Messung schon im Jikan-Absatz unten und fällt durch jedes sinnvolle
# Gatter. Die SPEZIFITÄT der Anfrage sagt mehr: Wer auf den vollen,
# ungekürzten Disc-Titel eine Handvoll Treffer bekommt, hat gefragt
# wie jemand, der weiß, was er sucht. Wer 20 bekommt, hat geraten.
#
# Deshalb: nur der UNGEKÜRZTE Titel (kandidaten[0]) und nur wenige
# Treffer. Confidence 0,8 — sicherer als ein Vorschlag (0,6), aber
# ehrlich unter einem wörtlichen Treffer (0,95).
if not matched and movies and movies_kandidat == kandidaten[0] \
and len(movies) <= WENIGE_TREFFER:
details = self.tmdb.get_movie_details(movies[0]["id"])
if details:
confidence = 0.8
metadata = {
"type": "movie",
"id": movies[0]["id"],
"title": details.get("title", title),
"year": int(details.get("release_date", "0")[:4]) if details.get("release_date") else None,
"overview": details.get("overview", ""),
"poster_path": details.get("poster_path", ""),
"backdrop_path": details.get("backdrop_path", ""),
"runtime": details.get("runtime", 0),
"genres": [g["name"] for g in details.get("genres", [])],
"source": "tmdb",
}
matched = True
# Fallback 1: Jikan/MyAnimeList (kostenlos, KEIN Key) — für Anime die
# präziseste Quelle; wählt per Titel-Ähnlichkeit, nicht Treffer #1.
#
+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]`.
"""
import posixpath
import os
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"
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().
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).
`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:
return []
orte = [posixpath.join(RAW_STANDARD, job_id)]
wahl = (work_dir or "").strip().rstrip("/")
if wahl and (wahl == MEDIA_ROOT or wahl.startswith(MEDIA_ROOT + "/")):
orte.append(posixpath.join(wahl, job_id))
roh, medien, frei = orte_wurzeln or (RAW_STANDARD, MEDIA_ROOT, False)
from rippy import pfade
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 []:
if name:
orte.append(posixpath.join(MEDIA_ROOT, name, job_id))
orte.append(pfade.verbinden(pfade.verbinden(medien, name), job_id))
gesehen, eindeutig = set(), []
for ort in orte:
if ort not in gesehen:
@@ -80,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner) -> list:
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:
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
@@ -96,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
"""
if not pfad:
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
try:
ergebnis = starten(
@@ -145,7 +259,8 @@ def verzeichnis_da(pfad: str, laufen=None) -> bool:
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.
`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
das ist ein lokales Verzeichnis die Freigaben sind Unterordner davon.
"""
orte_wurzeln = orte_wurzeln or wurzeln()
try:
unterordner = sorted(listdir(MEDIA_ROOT))
unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError:
unterordner = []
gefunden = []
for ort in kandidaten(job_id, work_dir, unterordner):
for ort in kandidaten(job_id, work_dir, unterordner, orte_wurzeln):
try:
if isdir(ort):
gefunden.append(ort)
@@ -172,7 +288,8 @@ def suche(job_id: str, work_dir: str, listdir, isdir) -> list:
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.
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()).
"""
pruefe = pruefer or pruefen
orte_wurzeln = orte_wurzeln or wurzeln()
try:
unterordner = sorted(listdir(MEDIA_ROOT))
unterordner = sorted(listdir(orte_wurzeln[1]))
except OSError:
unterordner = []
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)
if antwort == "da":
gefunden.append(ort)
@@ -208,7 +326,11 @@ def groesse(pfade: list, listdir, isfile, getsize) -> tuple:
except OSError:
continue
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:
if isfile(voll):
bytes_gesamt += getsize(voll)
+694 -8
View File
@@ -2,16 +2,20 @@
Warum: Kein anderer Test importiert main.py ein Tippfehler dort fiele sonst
erst beim Container-Start auf (und die Ampel bliebe fälschlich grün).
Läuft nur unter Linux (detection.py nutzt fcntl/ioctl), also genau dort,
wo auch die Ampel läuft.
## Warum das hier nicht mehr uebersprungen wird (28.08.2026)
Bis V2-4 stand hier ein `pytest.skip` fuer Windows: `main.py` importierte
`rippy.drives.linux` direkt, und das braucht `fcntl`. Seit die Treiberwahl
ueber den Port `rippy.ports.Drives` laeuft, laedt `main.py` auf BEIDEN
Plattformen nachgemessen: 57 Routen, sauberer Import.
Das Ueberspringen war damit nicht mehr Vorsicht, sondern eine Luecke: Diese
siebzehn Tests liefen nur auf der Ampel. Und ein Test, der nur auf einer
Plattform greift, ist eine halbe Zusage genau daran ist die Ampel am
28.08.2026 fuenf Laeufe lang unbemerkt rot gewesen.
"""
import sys
import pytest
if sys.platform == "win32": # pragma: no cover
pytest.skip("detection.py braucht fcntl (Linux)", allow_module_level=True)
import pytest # noqa: F401 (einzelne Tests brauchen ihn fuer skipif)
def test_main_importierbar_und_routen_verdrahtet():
@@ -27,6 +31,9 @@ def test_main_importierbar_und_routen_verdrahtet():
# Der Hauptweg für 4K-UHD (25.07.2026): makemkvcon holt Disc-Schluessel
# unter Linux nie selbst, sie kommen von Hand über diesen Endpunkt.
"/system/keystore",
# Ohne die weiss das UI nicht, worauf es laeuft — und zeigt dann auf
# einem Windows-PC "Pruefen: docker compose ps" (Befund 28.08.2026).
"/betrieb",
):
assert pfad in routen, f"Route {pfad} fehlt"
@@ -246,3 +253,682 @@ def test_wache_meldet_wenn_es_wieder_geht(monkeypatch):
zustand["da"] = True
main._mounts_nachsehen()
assert any("antwortet wieder" in m for m in gelogged)
# --- Live-Ereignisse (SSE), Etappe V2-3 --------------------------------------
def test_events_route_ist_verdrahtet():
"""Ohne diese Route fällt das UI stumm auf seinen letzten Stand zurück —
und weil ein Abriss KEINE Aussage ist, sähe der Nutzer einfach nichts
Neues, ohne Fehlermeldung. Deshalb hier festgenagelt."""
from main import app
assert "/events" in {route.path for route in app.routes}
def test_sse_rahmen_hat_das_format_das_der_browser_erwartet():
"""`id:` ist nicht Kosmetik — der Browser schickt genau diesen Wert beim
Wiederverbinden als Last-Event-ID zurück. Fehlt er, gibt es keine
lückenlose Wiederaufnahme, und jeder WLAN-Wechsel reisst ein Loch."""
from main import _sse_rahmen
rahmen = _sse_rahmen({"seq": 42, "typ": "job.progress", "daten": {"prozent": 7}})
zeilen = rahmen.split("\n")
assert zeilen[0] == "id: 42"
assert zeilen[1] == "event: job.progress"
assert zeilen[2].startswith("data: {")
# Zwei Leerzeilen am Ende: eine schliesst das Ereignis, die zweite ist der
# Trenner. Ohne den doppelten Umbruch haelt der Browser das Ereignis fuer
# unvollstaendig und liefert es NIE aus.
assert rahmen.endswith("\n\n")
def test_sse_rahmen_uebersteht_umlaute():
"""Job-Titel und Log-Zeilen sind deutsch. Mit ensure_ascii=True kaeme
"Gr\u00f6\u00dfe" beim Nutzer an."""
from main import _sse_rahmen
rahmen = _sse_rahmen({"seq": 1, "typ": "log.line", "daten": {"text": "Größe"}})
assert "Größe" in rahmen
def test_snapshot_meldet_unlesbare_laufwerke_als_none(monkeypatch):
"""„konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
Genau diese Vermischung hat in v1 die Job-Liste im Sekundentakt geleert
(fuenfmal `catch(() => [])` im UI). Ein Snapshot mit devices=[] wuerde dem
UI sagen du hast kein Laufwerk"; None sagt „ich weiss es gerade nicht",
und das UI behaelt seinen Stand.
OHNE DATENBANK: Die Ampel hat keine Postgres der erste Anlauf dieses
Tests lief deshalb rot (Lauf 170). Der Store wird hier ersetzt, denn
geprueft wird die Snapshot-LOGIK, nicht die Datenbank.
"""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
def kaputt():
raise OSError("Laufwerk haengt")
monkeypatch.setattr(main.device_discovery, "list_optical_devices", kaputt)
zustand = asyncio.run(main._snapshot())
assert zustand["devices"] is None, "Ein unlesbares Laufwerk darf nicht als [] durchgehen"
assert zustand["jobs"] == []
def test_snapshot_liefert_das_ganze_bild(monkeypatch):
"""Ein Client, der sich verbindet, bekommt das GANZE Bild — sonst muesste
er den Rest raten und faellt auf Polling zurueck.
## Warum der Server-Zustand dazugehoert (Befund 28.08.2026)
Er fehlte im Schnappschuss, und der Waechter schickt ihn nur alle 15
Sekunden. Ein frisch geladenes Dashboard stand deshalb bis zu einer
Viertelminute auf Platz fuer Rippy: unbekannt" — und das sieht aus wie
eine Auskunft, obwohl es keine ist. Genau das hat der Commander im
Windows-Fenster gesehen.
"""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [{"name": "pc"}])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
#
# `*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())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
assert "info" in zustand, (
"Ohne den Server-Zustand steht das Dashboard nach jedem Neuladen "
"bis zu 15 Sekunden auf 'unbekannt'."
)
assert zustand["devices"] == [] # wirklich leer, nicht „unbekannt"
assert zustand["workers"] == [{"name": "pc"}]
def test_ein_kaputter_server_zustand_kippt_den_schnappschuss_nicht(monkeypatch):
"""Der Schnappschuss ist die Grundlage fuer ALLES im UI. Lieber ohne
Platzangabe (das UI behaelt seinen Stand) als gar kein Schnappschuss."""
import asyncio
import main
monkeypatch.setattr(main.db, "list_jobs", lambda limit=50: [])
monkeypatch.setattr(main.db, "list_workers", lambda: [])
monkeypatch.setattr(main.db, "list_logs", lambda limit=50: [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
async def platzt():
raise RuntimeError("Platte weg")
monkeypatch.setattr(main, "system_stand_fuer_snapshot", platzt)
zustand = asyncio.run(main._snapshot())
assert {"jobs", "workers", "logs", "devices"} <= set(zustand)
def test_betrieb_meldet_faehigkeiten_statt_nur_einen_namen():
"""Der Befund vom 28.08.2026: Das Windows-Fenster zeigte "Worker
erreichbar: 0 von 1", "Container-Platte" und "Pruefen: docker compose ps"
auf einem PC ohne Container und ohne zweiten Worker.
Das UI muss FAEHIGKEITEN bekommen, keinen Modus-Namen: Aus einem Namen
auf Verhalten zu schliessen bricht beim naechsten Betriebsfall (ein
Docker-All-in-One hat Container-Pfade, aber keine externen Worker).
"""
from fastapi.testclient import TestClient
from main import app
# OHNE `with`: Der Kontextmanager loest den Lebenszyklus aus, und der legt
# Tabellen an — auf der Ampel gibt es keine Datenbank. Genau daran ist
# Lauf 170 rot geworden. /betrieb braucht keine.
antwort = TestClient(app).get("/betrieb")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["modus"] in ("standalone", "verteilt")
assert daten["plattform"] in ("windows", "linux", "macos")
assert set(daten["kann"]) == {"externe_worker", "freigaben_einhaengen",
"container_pfade", "werkzeuge_verwalten",
"frei_blaettern"}
# Beide Orte muessen dabei sein. Fehlte einer, muesste die Oberflaeche
# wieder raten — und genau daraus wurde „Container-Platte" auf einem PC,
# der keinen Container hat (Befund 28.08.2026).
assert daten["ablage_vorgabe"] and daten["arbeits_vorgabe"]
assert all(isinstance(w, bool) for w in daten["kann"].values())
# Ein Hinweis auf docker compose darf NUR im Container erscheinen.
if not daten["im_container"]:
assert daten["hilfe_befehl"] == ""
# ── Die Grenze DB → Oberflaeche (Befund 29.08.2026) ─────────────────────
#
# Commander mit Bildschirmfoto: „schau mal hier, das Datum ist falsch."
#
# TYP „DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS „running"
#
# Der Waechter schickte die ROHE Datenbankzeile ueber den Ereignisstrom. Dort
# heissen die Spalten `disc_type` und `created_at`, im UI aber `type` und
# `startTime` — die Uebersetzung macht `_job_row_to_model`, und sie bildet
# auch `running` auf `processing` ab.
#
# Der Test, den ich beim ersten Anlauf geschrieben habe, hat das NICHT
# gefunden: Ich hatte seine Beispielzeile selbst erfunden, mit meinen
# falschen Feldnamen. Ein Test, der dieselbe Annahme macht wie der Code,
# prueft nichts. Dieser hier geht von der ECHTEN Zeile aus.
def _echte_db_zeile():
"""Eine Zeile mit den Spaltennamen, die wirklich in der Datenbank stehen."""
from datetime import datetime
return {
"id": "j1",
"disc_type": "bluray", # NICHT "type"
"created_at": datetime(2026, 8, 29, 12, 7, 2), # NICHT "startTime"
"finished_at": None,
"status": "running", # wird zu "processing"
"device": r"\.\G:",
"progress": 12,
"title": "Evangelion 2.22",
"error": None,
"meta": None,
}
def test_die_umwandlung_liefert_die_namen_die_das_ui_kennt():
import main
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
assert ui["type"] == "bluray"
assert ui["startTime"].startswith("2026-08-29")
assert ui["status"] == "processing", "der Worker-Status heisst im UI anders"
def test_das_live_ereignis_traegt_dieselben_namen_wie_die_jobliste():
"""DER Waechter gegen „1.1.1970". Ohne die Umwandlung sind alle
Pflichtfelder leer und leer sieht im Browser aus wie eine Auskunft."""
import main
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz
ui = main._job_row_to_model(_echte_db_zeile()).model_dump()
kurz = _job_kurz(ui)
for feld in UI_PFLICHTFELDER:
assert kurz.get(feld) is not None, "%s waere im Browser leer" % feld
assert kurz["type"] == "bluray"
assert kurz["status"] == "processing"
def test_der_waechter_ist_mit_der_umwandlung_verdrahtet():
"""Sonst waere die Reparatur wieder nur eine Moeglichkeit, keine Tatsache."""
import inspect
import main
quelle = inspect.getsource(main.startup_event)
assert "job_form=" in quelle, "Waechter bekommt die Umwandlung nicht"
assert "_job_row_to_model" in quelle
def test_die_laufwerksliste_kommt_aus_EINER_quelle():
"""Waechter gegen die vierte Kopie (Befund 29.08.2026).
Commander: Es ist eine Disk im laufwerk, aber er erkennt sie dort nicht."
Die Laufwerksliste wurde an DREI Stellen gebaut: im `/devices`-Endpunkt
(mit angehaengter Disc), im Ereignis-Waechter und im SSE-Schnappschuss
(beide ohne). Das Dashboard liest den Schnappschuss und schloss aus der
fehlenden Disc auf ein leeres Laufwerk, waehrend ihr Titel eine Zeile
weiter oben stand.
Eine Auskunft in drei Fassungen ist zwei Fassungen zu viel.
"""
import inspect
import main
quelle = inspect.getsource(main)
# Die eine erlaubte Fundstelle ist die Funktion selbst.
stellen = quelle.count("device_discovery.device_info(")
assert stellen == 1, (
"device_info wird an %d Stellen aufgerufen — die Liste gehoert in "
"laufwerke_mit_disc(), sonst fehlt irgendwo die Disc" % stellen)
for name in ("_snapshot", "startup_event", "get_devices"):
text = inspect.getsource(getattr(main, name))
assert "device_info(" not in text, \
"%s baut die Laufwerksliste selbst" % name
def test_laufwerke_mit_disc_haengt_die_erkannte_disc_an(monkeypatch):
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraete = main.laufwerke_mit_disc()
assert geraete[0]["disc"]["title"] == "Evangelion 2.22"
def test_eine_noch_laufende_erkennung_wird_NICHT_als_disc_gemeldet(monkeypatch):
"""`_laeuft` heisst „wird gerade erkannt" — das ist noch keine Auskunft."""
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
{"_laeuft": True, "title": "Wird erkannt…"})
assert "disc" not in main.laufwerke_mit_disc()[0]
def test_eine_laufende_erkennung_ist_SICHTBAR(monkeypatch):
"""Commander 29.08.2026: „das die disc erkennung noch läuft muss sichtbar
sein."
Vorher liess `laufwerke_mit_disc` einen laufenden Eintrag KOMPLETT weg
damit keine halbfertige Disc-Karte mit Wird erkannt" als Titel und
einem Rippen starten"-Knopf erscheint. Richtig gedacht, falsch geloest:
Fuer die Oberflaeche sah es aus wie ein leeres Laufwerk. Und das dauert:
`makemkvcon info` laeuft an einer Blu-ray in seine 120-Sekunden-Grenze
(gemessen: Disc nach 119 s erkannt).
"""
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:",
{"_laeuft": True, "title": "Wird erkannt…"})
geraet = main.laufwerke_mit_disc()[0]
assert geraet["disc_wird_erkannt"] is True
# UND weiterhin keinen Titel behaupten, den es noch nicht gibt.
assert "disc" not in geraet
def test_nach_der_erkennung_ist_die_marke_wieder_weg(monkeypatch):
import main
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: {"id": "G", "name": "Laufwerk G:", "path": p,
"type": "bluray", "status": "ready"})
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraet = main.laufwerke_mit_disc()[0]
assert not geraet.get("disc_wird_erkannt")
assert geraet["disc"]["title"] == "Evangelion 2.22"
def test_das_geraetemodell_gibt_die_marke_auch_heraus():
"""Ohne Feld im Modell schneidet FastAPI sie weg — und das UI saehe
wieder nichts (derselbe Fehler wie 25.07. bei `meta`)."""
import main
assert "disc_wird_erkannt" in main.Device.model_fields
# ── Die Notdurft-Auskunft (Befund 29.08.2026) ──────────────────────────
#
# Waehrend der Disc-Erkennung haelt makemkvcon das Laufwerk. Gemessen:
# /devices braucht dann 14,0 s, die Zeitgrenze des Schnappschusses ist 5,0 s.
# Der lieferte deshalb `devices = None` („konnte nicht nachsehen") — und nach
# einem frischen Laden hatte das UI keinen alten Stand, den es haette behalten
# koennen. Der Bildschirm blieb leer, ausgerechnet in der Phase, die sichtbar
# sein soll.
def test_notdurft_meldet_den_letzten_stand_plus_die_erkennung(monkeypatch):
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
"type": "bluray", "status": "ready"}])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
geraete = main.laufwerke_notdurft()
assert geraete[0]["disc_wird_erkannt"] is True
assert geraete[0]["type"] == "bluray", "der letzte bekannte Typ bleibt"
def test_notdurft_ohne_jeden_stand_erfindet_nichts(monkeypatch):
"""Wer nie erfolgreich gelesen hat, soll schweigen — `None` heisst
behalte deinen Stand", und das ist die ehrliche Antwort."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
assert main.laufwerke_notdurft() is None
def test_notdurft_beim_kaltstart_nennt_wenigstens_das_laufwerk(monkeypatch):
"""Kaltstart mitten in der Erkennung: Die LISTE der Laufwerke ist billig,
nur das Abfragen des Laufwerks ist teuer."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [])
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: [r"\.\G:"])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"_laeuft": True})
geraete = main.laufwerke_notdurft()
assert geraete[0]["disc_wird_erkannt"] is True
assert geraete[0]["type"] == "unknown", "kein Typ wird erfunden"
def test_ein_fertiges_ergebnis_ueberschreibt_die_alte_marke(monkeypatch):
"""Sonst bliebe „wird erkannt" stehen, nachdem die Disc laengst da ist."""
import main
monkeypatch.setattr(main, "LETZTE_LAUFWERKE",
[{"id": "G", "name": "Laufwerk G:", "path": r"\.\G:",
"type": "bluray", "status": "ready",
"disc_wird_erkannt": True}])
monkeypatch.setitem(main.DISC_CACHE, r"\.\G:", {"title": "Evangelion 2.22"})
geraet = main.laufwerke_notdurft()[0]
assert "disc_wird_erkannt" not in geraet
assert geraet["disc"]["title"] == "Evangelion 2.22"
# ── Die Disc-Wache: kein Dauerscan (Befund 29.08.2026) ─────────────────
#
# Commander: „liest er die disc NOCHMAL ein das macht aber keinen sinn, wenn
# er sie bereits erkannt hat. Außerdem öffnen sich nun immer irgendwelche
# fenster ganz kurz im hintergrund."
#
# Beides war dieselbe Schleife: `except OSError: continue` liess
# `bekannt[pfad]` ungesetzt, der naechste Durchlauf hielt das Laufwerk fuer
# neu und stiess einen weiteren Vor-Scan an. Der haelt das Laufwerk, die
# naechste Statusabfrage scheitert — und so weiter.
def _CDS():
import main
return main.CDS_DISC_OK, main.CDS_NO_DISC, main.CDS_TRAY_OPEN
def test_erster_blick_auf_eine_liegende_disc_erkennt_sie():
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(False, None, ok) == "erststart"
def test_ein_leeres_laufwerk_beim_start_loest_nichts_aus():
import main
_, leer, _ = _CDS()
assert main.disc_entscheidung(False, None, leer) == ""
def test_dieselbe_disc_wird_NICHT_nochmal_gelesen():
"""Der Kern des Befundes."""
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(True, ok, ok) == ""
def test_ein_fehlgeschlagener_lesevorgang_startet_KEINEN_neuen_scan():
"""DER Fehler: Nach einem Fehlschlag blieb `vorher` leer. Wer das mit
noch nie gesehen" verwechselt, scannt endlos.
Ablauf wie in echt: erst erfolgreich gelesen, dann scheitert der Lesevor-
gang (makemkvcon haelt das Laufwerk), dann klappt er wieder.
"""
import main
ok, _, _ = _CDS()
# Der Fehlschlag setzt `bekannt` nicht — `vorher` ist danach None.
# `schon_gesehen` bleibt aber True, und genau daran haengt alles.
assert main.disc_entscheidung(True, None, ok) == "eingelegt", \
"eine Statusaenderung nach unbekannt->Disc ist ein Einlegen …"
# … aber NICHT ein Erststart, der ohne jede Aenderung scannt:
assert main.disc_entscheidung(True, ok, ok) == ""
def test_auswurf_wird_erkannt():
import main
ok, leer, offen = _CDS()
assert main.disc_entscheidung(True, ok, leer) == "entfernt"
assert main.disc_entscheidung(True, ok, offen) == "entfernt"
def test_ein_wackliger_zwischenstatus_wirft_die_disc_nicht_weg():
"""3 = „nicht bereit" heisst nicht „ausgeworfen". Sonst faellt der Vorrat
weg, sobald das Laufwerk kurz beschaeftigt ist."""
import main
ok, _, _ = _CDS()
assert main.disc_entscheidung(True, ok, 3) == ""
def test_die_wache_setzt_gesehen_auch_nach_einem_fehlschlag_nicht_zurueck():
"""Waechter gegen die Rueckkehr des `continue`-Fehlers."""
import inspect
import main
quelle = inspect.getsource(main.disc_watcher)
assert "gesehen" in quelle, "die Wache muss sich merken, was sie je las"
assert "disc_entscheidung(" in quelle, "die Entscheidung gehoert in die pure Funktion"
def test_schnappschuss_und_jobs_liefern_DIESELBE_jobliste():
"""Waechter gegen die zweite Fassung (Befund 29.08.2026).
Commander: Über den kompletten vorgang steht dort Restzeit wird
gemessen' aber messung wird nicht abgeschlossen."
Die Restzeit wurde nur in `/jobs` berechnet. Der SSE-Schnappschuss baute
seine Jobs mit dem nackten `_job_row_to_model` also ohne. Seit V2-3
liest die Oberflaeche aber genau diesen Schnappschuss. Die Restzeit wurde
also brav berechnet und niemandem gezeigt.
"""
import inspect
import main
quelle = inspect.getsource(main._snapshot)
assert "jobs_fuer_ui(" in quelle, "der Schnappschuss baut die Jobs selbst"
assert "_job_row_to_model(z).model_dump() for z in db.list_jobs" not in quelle
def test_die_jobliste_traegt_die_restzeit_felder():
"""Ohne die Felder im Modell schneidet FastAPI sie weg."""
import main
for feld in ("eta_sekunden", "eta_text", "can_retry", "retry_art"):
assert feld in main.Job.model_fields, feld
# ── Kein Vor-Scan waehrend eines Rips (Befund 29.08.2026) ───────────────
#
# Commander: „der Disk wird gelesen' panel braucht eeeeewig. Der Rip ist
# bereits bei 30% und er liest immernoch."
#
# Waehrend eines Rips haelt makemkvcon das Laufwerk. Ein zweites
# `makemkvcon info` daneben wartet bis zu seiner Zeitgrenze — und die Marke
# `_laeuft` blieb solange stehen, also stand auch das Panel. Es gibt keinen
# Grund, dann zu scannen: Das Laufwerk ist belegt, und WELCHE Disc drin ist,
# wissen wir — der Job laeuft ja auf ihr.
def test_kein_vorscan_solange_ein_job_auf_dem_laufwerk_laeuft(monkeypatch):
import asyncio
import main
gescannt = []
class NieBenutzt:
def scan(self, pfad):
gescannt.append(pfad)
raise AssertionError("waehrend eines Rips darf nicht gescannt werden")
monkeypatch.setattr(main, "PreScan", NieBenutzt)
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
main.DISC_CACHE.pop(r"\.\G:", None)
asyncio.run(main._auto_prescan(r"\.\G:"))
assert gescannt == []
assert r"\.\G:" not in main.DISC_CACHE, "auch keine Marke setzen"
def test_ohne_laufenden_job_wird_normal_gescannt(monkeypatch):
import asyncio
import main
class Ergebnis:
title, year, disc_type, confidence, fingerprint = "Akira", 1988, "Blu-ray", 0.9, "x"
def to_dict(self):
return {"title": "Akira"}
class Scanner:
def scan(self, pfad):
return Ergebnis()
monkeypatch.setattr(main, "PreScan", Scanner)
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
monkeypatch.setattr(main.db, "add_log", lambda *a, **k: None)
monkeypatch.setattr(main, "_duplikat_suchen", lambda f: None)
monkeypatch.setattr(main, "_auto_rip_wenn_aktiviert",
lambda p: asyncio.sleep(0))
main.DISC_CACHE.pop(r"\.\G:", None)
asyncio.run(main._auto_prescan(r"\.\G:"))
assert main.DISC_CACHE[r"\.\G:"]["title"] == "Akira"
main.DISC_CACHE.pop(r"\.\G:", None)
def test_der_job_start_raeumt_eine_haengende_erkennung_weg():
"""Eine Marke, die KURZ VOR dem Rip gesetzt wurde, bliebe sonst bis zum
Ende stehen der Scan davor haelt sie ja fest."""
import inspect
import main
quelle = inspect.getsource(main.create_job) if hasattr(main, "create_job") else ""
if not quelle:
# Der Endpunkt heisst anders — dann ueber das Modul suchen.
quelle = inspect.getsource(main)
assert 'DISC_CACHE.pop(device_path, None)' in quelle, \
"beim Job-Start muss eine haengende Erkennungs-Marke weg"
# ── Kein zweiter Rip waehrend der Kompression (Befund 29.08.2026) ───────
#
# Der Commander: „Der Rip an sich war bereits fertig, bei der komprimierung
# passiert das" — Fehlertext: „Das Öffnen der Disk schlug fehl".
#
# Ablauf dahinter: Rip fertig → Disc ausgeworfen → Status `transcoding`.
# `has_active_job` kennt nur pending/running und meldete „Laufwerk frei".
# Die Disc-Wache sah den Auswurf als Statuswechsel, die Vollautomatik startete
# einen ZWEITEN Rip — in eine Schublade, die gerade herausfuhr.
def test_automatik_startet_nicht_waehrend_der_kompression(monkeypatch):
import asyncio
import main
gestartet = []
monkeypatch.setattr(main.db, "get_settings", lambda: {"autoRipStart": True})
monkeypatch.setattr(main.db, "job_offen", lambda p: True) # Job komprimiert
monkeypatch.setattr(main.db, "has_active_job", lambda p: False) # Laufwerk frei
monkeypatch.setattr(main, "start_rip", lambda *a, **k: gestartet.append(a))
main.DISC_CACHE[r"\.\G:"] = {"title": "Akira"}
asyncio.run(main._auto_rip_wenn_aktiviert(r"\.\G:"))
assert gestartet == [], "waehrend der Kompression faengt die Automatik nichts Neues an"
def test_job_offen_zaehlt_die_kompression_mit():
from rippy.store import JOB_OFFEN
assert "transcoding" in JOB_OFFEN, "die Kompression gehoert zum Vorgang"
assert "canceling" in JOB_OFFEN, "ein Abbruch ist auch noch nicht durch"
assert "completed" not in JOB_OFFEN and "failed" not in JOB_OFFEN
def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
"""Waechter gegen einen zu breiten Riegel: `has_active_job` fragt „haelt
jemand das Laufwerk?" — waehrend der Kompression tut das niemand, die Disc
darf raus. Nur die AUTOMATIK haelt sich zurueck."""
import inspect
from rippy import store
assert "transcoding" not in inspect.getsource(store.has_active_job)
def test_laufwerk_bleibt_waehrend_eines_rips_unberuehrt(monkeypatch):
"""Befund 30.08.2026, aus dem Protokoll des Commanders:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 makemkvcon endete mit Code 11
Der Waechter fragt alle drei Sekunden ab drei CreateFileW plus IOCTLs
auf ein Geraet, das makemkvcon gerade liest. Sein Befund: das Laufwerk
hoert einfach auf zu lesen. `_auto_prescan` haelt sich seit dem
29.08.2026 an dieselbe Regel; die Laufwerksabfrage tat es nicht.
"""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: True)
stand = main.laufwerke_mit_disc()
assert gefragt == [], "waehrend eines Rips darf niemand das Laufwerk anfassen"
assert stand[0]["type"] == "bluray" # letzter bekannter Stand gilt
def test_ohne_rip_wird_das_laufwerk_normal_abgefragt(monkeypatch):
"""Die Ausnahme darf nur fuer den laufenden Rip gelten."""
import main
gefragt = []
monkeypatch.setattr(main.device_discovery, "list_optical_devices",
lambda: ["/dev/sr0"])
monkeypatch.setattr(main.device_discovery, "device_info",
lambda p: gefragt.append(p) or dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""}))
monkeypatch.setattr(main, "LETZTE_LAUFWERKE", [dict({"id": "sr0", "name": "Laufwerk", "type": "bluray", "path": "/dev/sr0", "status": "ready", "model": "X", "serial": "Y", "grund": ""})])
monkeypatch.setattr(main.db, "has_active_job", lambda p: False)
main.laufwerke_mit_disc()
assert gefragt == ["/dev/sr0"]
+157
View File
@@ -0,0 +1,157 @@
"""Die Metadaten-Clients muessen sich OHNE Datenbank und OHNE Redis bauen lassen.
## Warum es diese Tests gibt (Befund 28.08.2026)
Der Commander meldete, dass eine eingelegte Blu-ray namenlos blieb und keine
Metadaten-Abfrage lief. Zwei Ursachen, beide unsichtbar:
**1. Ein Phantom-Modul.** `clients/tmdb.py`, `clients/omdb.py` und
`clients/thetvdb.py` machten im Konstruktor `from db import get_settings`.
`docker/api/db.py` gibt es seit der Zusammenlegung in V2-1 (dd1d0b7) nicht
mehr main.py schreibt seitdem `from rippy import store as db`, diese drei
Module haben es nie mitbekommen. Damit starb JEDE Metadaten-Abfrage schon
beim Erzeugen des Clients mit `ModuleNotFoundError`, auf Windows UND auf der
VM. `_auto_prescan` verschluckte es in seinem `except Exception`.
**2. Ein harter Redis-Zwang.** `cache/cache.py` sprach `redis:6379` an den
Dienstnamen aus `docker-compose.yml`. Auf einem Windows-PC gibt es kein
Redis, und jeder Zugriff endete mit `ConnectionError`. Das riss den Pre-Scan
mit.
Beide Male war der SYMPTOM sichtbar (keine Metadaten) und die URSACHE nicht.
Diese Tests machen die Ursache sichtbar: Sie bauen die Clients und benutzen
den Zwischenspeicher, ohne dass irgendetwas davon erreichbar sein muss.
"""
import pytest
def test_tmdb_client_baut_sich_ohne_datenbank():
"""Der Test, der den Phantom-Import gefangen haette."""
from clients.tmdb import TMDBClient
klient = TMDBClient()
assert hasattr(klient, "api_key")
def test_omdb_client_baut_sich_ohne_datenbank():
from clients.omdb import OMDbClient
assert OMDbClient() is not None
def test_thetvdb_client_baut_sich_ohne_datenbank():
from clients.thetvdb import TheTVDBClient
assert TheTVDBClient() is not None
def test_kein_client_importiert_das_verschwundene_db_modul():
"""Der Waechter gegen genau diesen Fehler, mechanisch.
Ein `from db import ...` faellt sonst erst auf, wenn jemand eine Disc
einlegt und dann als keine Metadaten", nicht als Import-Fehler.
"""
import os
hier = os.path.dirname(os.path.abspath(__file__))
fundstellen = []
for wurzel, ordner, dateien in os.walk(os.path.join(hier, "clients")):
ordner[:] = [o for o in ordner if o != "__pycache__"]
for name in dateien:
if not name.endswith(".py"):
continue
pfad = os.path.join(wurzel, name)
with open(pfad, encoding="utf-8") as f:
for nr, zeile in enumerate(f, 1):
if zeile.strip().startswith(("from db import", "import db")):
fundstellen.append("%s:%d" % (name, nr))
assert not fundstellen, (
"docker/api/db.py gibt es seit V2-1 nicht mehr — benutzt "
"rippy.store. Fundstellen: " + ", ".join(fundstellen))
# ── Der Zwischenspeicher ────────────────────────────────────────────────
def test_cache_ohne_redis_liefert_None_statt_zu_werfen():
"""Ein Zwischenspeicher ist eine Beschleunigung, keine Voraussetzung.
Auf einem Windows-PC gibt es kein Redis. Vorher riss jeder Zugriff den
Pre-Scan mit und die Disc blieb namenlos.
"""
from cache import cache
class ToterRedis:
def get(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def set(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def setex(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def delete(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def flushdb(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
def ping(self, *a, **k):
raise cache.RedisError("nicht erreichbar")
echt = cache.redis_client
cache.redis_client = ToterRedis()
try:
assert cache.get("egal") is None
assert cache.set("egal", {"a": 1}) is False
assert cache.delete("egal") is False
assert cache.clear() is False
cache.init_cache() # darf ebenfalls nicht werfen
assert cache.zustand()["erreichbar"] is False
finally:
cache.redis_client = echt
def test_cache_meldet_den_ausfall_nur_EINMAL(capsys):
"""Eine Meldung je Anfrage waere Laerm — und bei jedem Titel-Suchlauf
kaeme sie mehrfach."""
from cache import cache
class ToterRedis:
def get(self, *a, **k):
raise cache.RedisError("weg")
echt, zustand = cache.redis_client, cache._erreichbar
cache.redis_client = ToterRedis()
cache._erreichbar = None
try:
for _ in range(5):
cache.get("x")
ausgabe = capsys.readouterr().out
assert ausgabe.count("nicht erreichbar") == 1, ausgabe
finally:
cache.redis_client, cache._erreichbar = echt, zustand
def test_ein_kaputter_eintrag_wirft_nicht():
"""Ein unlesbarer Eintrag ist kein Grund zu scheitern — dann wird die
Antwort eben frisch geholt."""
from cache import cache
class KaputterRedis:
def get(self, *a, **k):
return "{kein json"
echt = cache.redis_client
cache.redis_client = KaputterRedis()
try:
assert cache.get("x") is None
finally:
cache.redis_client = echt
@pytest.mark.parametrize("name", ["tmdb", "omdb", "jikan", "musicbrainz"])
def test_jeder_client_ist_importierbar(name):
"""Ein Tippfehler in einem Modulpfad faellt sonst erst auf, wenn jemand
eine Disc einlegt."""
__import__("clients." + name)
+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))
assert ergebnis == {"sekunden": -1, "text": ""}
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
-118
View File
@@ -1,118 +0,0 @@
"""Tests für die puren Helfer aus makemkv_daten.
Bewusst OHNE Dateisystem, DB und fcntl deshalb laufen sie auch auf Windows
und nicht nur in der Ampel. Geprueft wird genau das, was ohne Container und
ohne echte Disc entscheidbar ist: das Zeilenformat der KEYDB.cfg, die
Plausibilitaetspruefung beim Hochladen, die Namenshaerte der AACS-Dumps und
das Zusammenfuehren der settings.conf.
"""
from makemkv_daten import (
ist_aacs_dump,
keydb_pruefen,
settings_conf_zusammenfuehren,
zaehle_disc_eintraege,
)
# Echte Beispielzeilen im libaacs-Format: 40 Hex-Zeichen Disc-Kennung, dann
# "= Titel". Zweite Zeile mit 0x-Praefix, weil die oeffentlichen Dateien beide
# Schreibweisen mischen (Fundstelle steht im Modul-Docstring von makemkv_daten).
_GUELTIG = """; KEYDB.cfg — Beispiel
0123456789ABCDEF0123456789ABCDEF01234567 = Akira
0xFEDCBA9876543210FEDCBA9876543210FEDCBA98 = Blade Runner | V | 00112233445566778899AABBCCDDEEFF
"""
def test_zaehle_disc_eintraege_zaehlt_nur_echte_disc_zeilen():
# Kommentar- und Leerzeilen dürfen NICHT mitgezaehlt werden, sonst meldet
# das UI "da liegt was drin", obwohl die Datei keinen Schluessel enthält.
assert zaehle_disc_eintraege(_GUELTIG) == 2
def test_zaehle_disc_eintraege_ohne_disc_zeile_ist_null():
nur_kommentare = "; nur ein Kommentar\n\n;noch einer\n"
assert zaehle_disc_eintraege(nur_kommentare) == 0
assert zaehle_disc_eintraege("") == 0
def test_zaehle_disc_eintraege_akzeptiert_0x_praefix_einzeln():
assert zaehle_disc_eintraege("0x0123456789abcdef0123456789abcdef01234567 = Tenet") == 1
def test_zaehle_disc_eintraege_lehnt_zu_kurze_kennung_ab():
# 39 statt 40 Hex-Zeichen: das ist keine Disc-Kennung, sondern Tippfehler
# oder eine abgeschnittene Datei — darf nicht als Eintrag durchgehen.
assert zaehle_disc_eintraege("0123456789ABCDEF0123456789ABCDEF0123456 = Kurz") == 0
def test_keydb_pruefen_meldet_leere_datei():
assert keydb_pruefen("") != ""
assert keydb_pruefen(" \n\n ") != ""
def test_keydb_pruefen_erkennt_html():
# Häufigster Bedienfehler: statt der Datei landet die HTML-Fehlerseite
# eines Downloads im Feld. MakeMKV würde dann still weiter meckern.
fehler = keydb_pruefen("<!DOCTYPE html>\n<html><body>404 Not Found</body></html>\n")
assert "HTML" in fehler
def test_keydb_pruefen_meldet_datei_ohne_disc_zeile():
# Text ist da, aber keine einzige Disc-Kennung — z. B. eine Liesmich-Datei.
assert keydb_pruefen("Das hier ist irgendein Text ohne Schluessel.\n") != ""
def test_keydb_pruefen_laesst_gueltige_datei_durch():
# "" heißt laut Vertrag: alles in Ordnung, darf geschrieben werden.
assert keydb_pruefen(_GUELTIG) == ""
def test_ist_aacs_dump_erkennt_echten_namen():
# So heißt der Dump, den MakeMKV am 25.07.2026 für Akira UHD abgelegt hat
# (Meldung 3332) — dieser Name MUSS zum Download durchkommen.
assert ist_aacs_dump("MKB20_v76_UHD_AKIRA_C02B.tgz") is True
def test_ist_aacs_dump_blockt_pfad_tricks():
# Der Download-Endpunkt hängt den Namen an das Datenverzeichnis — ein
# durchgelassenes ".." oder ein Pfadtrenner wäre ein Ausbruch.
assert ist_aacs_dump("../x.tgz") is False
assert ist_aacs_dump("../../etc/passwd.tgz") is False
assert ist_aacs_dump("unter/ordner.tgz") is False
assert ist_aacs_dump("unter\\ordner.tgz") is False
assert ist_aacs_dump(".versteckt.tgz") is False
def test_ist_aacs_dump_lehnt_andere_endungen_ab():
# Nur die Dumps sollen abholbar sein — nicht settings.conf, nicht
# _private_data.tar und schon gar nicht die KEYDB.cfg selbst.
assert ist_aacs_dump("KEYDB.cfg") is False
assert ist_aacs_dump("settings.conf") is False
assert ist_aacs_dump("_private_data.tar") is False
assert ist_aacs_dump("") is False
def test_settings_conf_ersetzt_alten_key_und_behaelt_den_rest():
# Regression: bis 25.07.2026 wurde die Datei komplett überschrieben. Mit
# dem jetzt persistenten Datenverzeichnis wäre app_UpdateEnable vor jedem
# Rip weg gewesen.
alt = 'app_UpdateEnable = "1"\napp_Key = "T-alt"\napp_DestinationDir = "/tmp"\n'
neu = settings_conf_zusammenfuehren(alt, "T-neu")
assert 'app_Key = "T-neu"' in neu
assert "T-alt" not in neu
assert 'app_UpdateEnable = "1"' in neu
assert 'app_DestinationDir = "/tmp"' in neu
def test_settings_conf_leerer_key_entfernt_die_zeile():
# Ein bewusst geleerter Key darf nicht heimlich weiterwirken.
neu = settings_conf_zusammenfuehren('app_Key = "T-alt"\napp_UpdateEnable = "1"\n', "")
assert "app_Key" not in neu
assert 'app_UpdateEnable = "1"' in neu
def test_settings_conf_aus_dem_nichts_endet_mit_zeilenumbruch():
# Erster Start: es gibt noch keine settings.conf. MakeMKV erwartet eine
# Datei mit abschliessendem Zeilenumbruch.
assert settings_conf_zusammenfuehren("", "T-neu") == 'app_Key = "T-neu"\n'
assert settings_conf_zusammenfuehren("", "") == ""
+70
View File
@@ -1,4 +1,6 @@
"""Tests für den MakeMKV-Beta-Key-Parser (dependency-frei, nur extract_key)."""
import pytest
from makemkv_key import extract_key
# Beispiel-Key im echten Format (T- + 64 Zeichen [A-Za-z0-9_]).
@@ -26,3 +28,71 @@ def test_extract_key_liefert_ganzen_key():
treffer = extract_key(f"Vorher-Text {_KEY} Nachher-Text")
assert treffer == _KEY
assert treffer.startswith("T-")
# ── Der Knopf „Jetzt aus dem Forum holen" (Commander 28.08.2026) ─────────
#
# > „Bezüglich des MKV Beta Keys - der könnte theoretisch auch automatisch
# > ausgelesen werden, ich glaube das web rippy kann das"
#
# Er hatte recht: `refresh_loop()` laeuft seit jeher taeglich mit (main.py,
# Startereignis). Gemessen am 28.08.2026: Forum antwortet in 3,3 s, Key mit 62
# Zeichen. Nur war davon NICHTS zu sehen — kein Knopf, keine Meldung. Eine
# Automatik, die man nicht sehen kann, ist fuer den Benutzer keine.
@pytest.fixture
def klient():
from fastapi.testclient import TestClient
from main import app
return TestClient(app)
def test_der_knopf_holt_und_meldet_was_passiert_ist(klient, monkeypatch):
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: True)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 200
daten = antwort.json()
assert daten["geholt"] is True
assert daten["geaendert"] is True
assert daten["endet_auf"] == _KEY[-6:]
def test_der_key_selbst_wandert_NICHT_ueber_die_leitung(klient, monkeypatch):
"""Er steht in den Einstellungen. Ein zweiter Weg zu demselben Wert ist
ein zweiter Weg, ihn zu verlieren."""
import main as api
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: _KEY)
monkeypatch.setattr("makemkv_key._apply_key", lambda key: False)
monkeypatch.setattr(api.db, "add_log", lambda *a, **k: None)
text = klient.post("/system/makemkv-key/holen").text
assert _KEY not in text
assert _KEY[:20] not in text
def test_ein_stummes_forum_ist_ein_klarer_fehler_kein_leerer_erfolg(klient,
monkeypatch):
"""Sonst hiesse „geholt: true" mit leerem Key, es sei alles in Ordnung."""
def wirft(*a, **k):
raise OSError("Verbindung abgelehnt")
monkeypatch.setattr("makemkv_key.fetch_current_key", wirft)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "Forum" in antwort.json()["detail"]
def test_geaendertes_seitenformat_wird_benannt(klient, monkeypatch):
"""Kein Treffer heisst NICHT „kein Key noetig" — es heisst, die Seite hat
sich geaendert. Das muss dastehen, sonst sucht niemand danach."""
monkeypatch.setattr("makemkv_key.fetch_current_key", lambda *a, **k: None)
antwort = klient.post("/system/makemkv-key/holen")
assert antwort.status_code == 502
assert "geändert" in antwort.json()["detail"]
+5 -1
View File
@@ -145,7 +145,11 @@ def test_erzeugtes_mapping_uebersetzt_den_echten_fehlerfall(monkeypatch):
# Der Worker liegt neben der API im Repo; kein geteiltes Paket zwischen
# den Containern, deshalb per Pfad laden statt importieren.
worker_tasks = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "tasks.py"
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "worker", "ablauf.py"
)
spec = importlib.util.spec_from_file_location("_worker_tasks_pfad", worker_tasks)
quelltext = open(worker_tasks, encoding="utf-8").read()
+79
View File
@@ -0,0 +1,79 @@
"""Welche Pfade darf die API anfassen — und wo ist „oben"?
## Warum diese Grenze verschiebbar sein muss (Befund 28.08.2026)
`MEDIA_ROOT = "/app/media"` war zugleich Vorgabe UND Pfadgrenze. Im Container
ist beides richtig. Auf dem Windows-PC des Commanders war beides falsch, und
die Folge war eine Oberflaeche, die stillschweigend nichts konnte:
* `/storage-targets` lieferte `[]` (der Ordner existiert dort nicht),
* `/browse` antwortete auf JEDEN Pfad mit 422,
* im Auswahlfeld fuer das Arbeitsverzeichnis stand genau ein Eintrag.
Sein Befund dazu: Wäre es möglich das Arbeitsverzeichnis zu ändern?
momentan geht das nicht."
Die Grenze faellt aber NICHT einfach weg. Im Container haengt die API im
Netz eine Weboberflaeche, die jeden Pfad des Wirts ausliefern kann, ist ein
Loch. Sie gilt nur dort nicht, wo Rippy den Menschen bedient, der vor dem
Rechner sitzt. Diese Tests halten beide Haelften fest.
"""
import main
# ── Die Grenze gilt, wo zugehoert wird ──────────────────────────────────
def test_im_container_gilt_die_wurzel_weiter():
"""Der Docker-Weg darf sich durch die Reparatur NICHT lockern."""
assert main.pfad_erlaubt("/app/media/movies", "/app/media", frei=False)
assert not main.pfad_erlaubt("/etc/passwd", "/app/media", frei=False)
# Der Klassiker: beginnt mit der Wurzel, liegt aber ausserhalb.
assert not main.pfad_erlaubt("/app/media-boese/x", "/app/media", frei=False)
def test_nativ_ist_auch_eine_freigabe_erlaubt():
"""Sein Ziel ist `\\\\192.168.179.62\\rippy\\movies` — das liegt unter gar
keiner lokalen Wurzel. Mit der alten Regel war es unerreichbar."""
assert main.pfad_erlaubt(r"\\192.168.179.62\rippy\movies", "/app/media", frei=True)
assert main.pfad_erlaubt(r"D:\Rippy-Arbeit", "/app/media", frei=True)
def test_ein_leerer_pfad_ist_nie_erlaubt():
"""Sonst wuerde aus einem vergessenen Feld ein Zugriff auf `/`."""
assert not main.pfad_erlaubt("", "/app/media", frei=True)
assert not main.pfad_erlaubt("", "/app/media", frei=False)
# ── Wo ist oben ─────────────────────────────────────────────────────────
def test_an_der_wurzel_ist_schluss():
"""Im Container. `None` heisst: kein Knopf „nach oben"."""
assert main.eltern_von("/app/media", "/app/media", frei=False) is None
assert main.eltern_von("/app/media/movies", "/app/media", frei=False) == "/app/media"
def test_ueber_der_laufwerkswurzel_steht_die_laufwerksliste():
"""Zwei Fallen auf einmal.
`os.path.dirname("C:\\\\")` ist wieder `"C:\\\\"` ein Knopf nach oben",
der auf denselben Ordner zeigt, sieht aus wie ein Fehler. Und ueber der
Laufwerkswurzel steht nicht *nichts*, sondern die Liste der Laufwerke:
Sonst kaeme man von `D:\\` nie zu `C:\\`, und genau dort ist Platz fuer
100 GB Rohdaten.
"""
import ntpath
import os
echt = os.path.dirname
os.path.dirname = ntpath.dirname # Windows-Pfade auch auf Linux
try:
assert main.eltern_von("C:\\", "D:\\Rippy", frei=True) == ""
assert main.eltern_von("C:\\Users\\Tobi", "D:\\Rippy", frei=True) == "C:\\Users"
finally:
os.path.dirname = echt
def test_die_wurzel_kommt_aus_dem_betrieb_und_ist_nie_leer():
"""Eine leere Wurzel wuerde jede Pruefung durchwinken."""
assert main.medien_wurzel()
+5 -1
View File
@@ -55,7 +55,11 @@ def test_der_schluessel_heisst_im_worker_genauso():
import re
quelle = (
pathlib.Path(__file__).resolve().parents[1] / "worker" / "tasks.py"
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
pathlib.Path(__file__).resolve().parents[1] / "worker" / "ablauf.py"
).read_text(encoding="utf-8")
# Der Worker schreibt die Marke über eine Konstante — deren Wert muss hier
# ankommen.
+77
View File
@@ -144,3 +144,80 @@ def test_ohne_jede_quelle_bleibt_es_unbekannt(monkeypatch):
assert ergebnis.confidence == 0.3
assert ergebnis.metadata["type"] == "unknown"
assert ergebnis.title == "Nix Da"
# ── Was auf FREMDEN Rechnern gilt (Befund 28.08.2026) ───────────────────
#
# Der Commander: "du hast jetzt nur mein laufwerk analysiert oder? wenn ich
# rippy weitergebe wie laeuft es dort?"
#
# Berechtigt. Gemessen wurde an EINEM Laufwerk (LG BU40N) und EINER Disc
# (BD-50 mit BDMV/META). Diese Tests halten fest, was bei ANDEREN Discs
# passieren muss -- ohne dass eine davon vorhanden sein muss.
def test_bluray_ohne_metadaten_gibt_ehrlich_nichts_zurueck(tmp_path):
"""Sehr viele Blu-rays haben kein BDMV/META. Dann greift der Rueckfall
auf das Volume-Label -- aber diese Funktion darf nichts erfinden."""
from prescan.prescan import titel_aus_bdmt
(tmp_path / "BDMV" / "STREAM").mkdir(parents=True)
assert titel_aus_bdmt(str(tmp_path)) is None
def test_dvd_hat_kein_bdmv(tmp_path):
from prescan.prescan import titel_aus_bdmt
(tmp_path / "VIDEO_TS").mkdir()
assert titel_aus_bdmt(str(tmp_path)) is None
def test_bdmt_wird_gelesen_wenn_da(tmp_path):
"""Der Weg, der bei der Disc des Commanders 'Evangelion 2.22' lieferte —
hier ohne Disc nachgestellt."""
from prescan.prescan import titel_aus_bdmt
meta = tmp_path / "BDMV" / "META" / "DL"
meta.mkdir(parents=True)
(meta / "bdmt_deu.xml").write_text(
'<?xml version="1.0"?><disclib><di:discinfo>'
'<di:title><di:name>Evangelion 2.22</di:name></di:title>'
'</di:discinfo></disclib>', encoding="utf-8")
assert titel_aus_bdmt(str(tmp_path)) == "Evangelion 2.22"
def test_englisch_wird_bevorzugt(tmp_path):
"""Die APIs suchen mit englischen Titeln besser."""
from prescan.prescan import titel_aus_bdmt
meta = tmp_path / "BDMV" / "META" / "DL"
meta.mkdir(parents=True)
for datei, name in (("bdmt_deu.xml", "Deutscher Titel"),
("bdmt_eng.xml", "English Title")):
(meta / datei).write_text(
"<x><di:name>%s</di:name></x>" % name, encoding="utf-8")
assert titel_aus_bdmt(str(tmp_path)) == "English Title"
def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
"""Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
ENTFERNT er lief unter Windows nie (`shutil.which` findet dort nichts,
Programme liegen in Programme", nicht im PATH) und war unter Docker
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
der Aufruf noch die shutil.which-Suche zurueckkommen.
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden`
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
die Entfernung deshalb erst dort gemeldet.)"""
import os
hier = os.path.dirname(os.path.abspath(__file__))
with open(os.path.join(hier, "prescan", "prescan.py"), encoding="utf-8") as f:
# NUR Code, keine Kommentare: Der erste Anlauf dieses Tests fiel ueber
# den eigenen Erklaertext, in dem der alte Aufruf zitiert wird. Ein
# Waechter, der an einer Erklaerung scheitert, prueft das Falsche.
code = [z for z in f if not z.strip().startswith("#")]
quelle = "".join(code)
assert "shutil.which(" not in quelle, (
"shutil.which findet unter Windows keine Programme aus 'Programme'")
assert "makemkvcon" not in quelle, (
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
+156
View File
@@ -0,0 +1,156 @@
"""Wann gilt ein TMDB-Treffer als sicher genug? — ohne Netz geprueft.
## Der Befund des Commanders (28.08.2026)
> Was ist mit dem Cover auf Windows Rippy? In der Web Version haben wir ein
> cover."
Es gab keins, weil es gar keinen Treffer gab. Der Grund war nicht Windows,
sondern eine Bedingung im Pre-Scan:
if movie.get("title", "").lower() == kandidat.lower():
An seiner Disc gemessen:
'Evangelion 2.22' 1 Treffer Evangelion: 2.0 You Can (Not) Advance
'Evangelion' 20 Treffer irgendein Evangelion
Der EINZIGE Treffer auf den vollen Disc-Titel war der richtige Film, mit
Poster und wurde verworfen, weil der Titel nicht woertlich gleich war.
## Warum die Trefferzahl und nicht die Aehnlichkeit
Evangelion 2.22" gegen „Evangelion: 2.0 You Can (Not) Advance" ergibt 0,51
Aehnlichkeit das steht als Messung schon laenger im Code und faellt durch
jedes sinnvolle Gatter. Die SPEZIFITAET der Anfrage sagt mehr: Wer auf den
vollen Disc-Titel eine Handvoll Treffer bekommt, hat gefragt wie jemand, der
weiss was er sucht. Wer 20 bekommt, hat geraten.
Diese Tests spritzen die Clients ein kein Netz, keine Schluessel, keine
Disc.
"""
import pytest
class FakeTMDB:
"""Ein TMDB, das genau die gemessenen Antworten liefert."""
def __init__(self, treffer_je_suche, details=None):
self.treffer = treffer_je_suche
self.details = details or {}
self.gefragt = []
def search_movie(self, begriff):
self.gefragt.append(begriff)
return self.treffer.get(begriff, [])
def search_tv(self, begriff):
return []
def get_movie_details(self, kennung):
return self.details.get(kennung)
def get_tv_details(self, kennung):
return None
def find_by_imdb(self, kennung):
return None
class StummerClient:
def lookup(self, *a, **k):
return None
DETAILS_2_0 = {
22843: {
"title": "Evangelion: 2.0 You Can (Not) Advance",
"release_date": "2009-06-27",
"overview": "Der Pilotin Mari gelingt es …",
"poster_path": "/hpChtHPoXGTfhphnHGXj2kGXZeH.jpg",
"backdrop_path": "/pzsVGcufDdBLmvagKyLFKaeA4O5.jpg",
"runtime": 112,
"genres": [{"name": "Animation"}],
}
}
def _prescan(tmdb):
from prescan.prescan import PreScan
p = PreScan()
p.tmdb = tmdb
p.jikan = StummerClient()
p.omdb = StummerClient()
return p
def _toc(titel):
return {"title": titel, "year": None, "tracks": [], "duration": 0,
"disc_type": "Blu-ray", "fingerprint": "TEST|1"}
def test_der_einzige_treffer_auf_den_vollen_titel_gilt():
"""DER Fall des Commanders. Vorher: kein Treffer, kein Cover, 30 %."""
tmdb = FakeTMDB(
{"Evangelion 2.22": [{"id": 22843,
"title": "Evangelion: 2.0 You Can (Not) Advance"}]},
DETAILS_2_0)
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
assert ergebnis.confidence == 0.8
assert ergebnis.title == "Evangelion: 2.0 You Can (Not) Advance"
assert ergebnis.year == 2009
assert ergebnis.metadata["poster_path"], "ohne poster_path gibt es kein Cover"
assert ergebnis.metadata["source"] == "tmdb"
def test_woertlich_gleicher_titel_bleibt_sicherer():
"""Ein exakter Treffer muss weiterhin hoeher stehen als ein spezifischer."""
tmdb = FakeTMDB(
{"Logan": [{"id": 22843, "title": "Logan"}]},
{22843: dict(DETAILS_2_0[22843], title="Logan")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Logan"))
assert ergebnis.confidence == 0.95
def test_zwanzig_treffer_gelten_NICHT_als_sicher():
"""Wer 20 Treffer bekommt, hat geraten — das darf kein 80-Prozent-Fund
werden. Es bleibt beim ehrlichen Vorschlag."""
viele = [{"id": 1000 + i, "title": "Evangelion %d" % i} for i in range(20)]
tmdb = FakeTMDB({"Evangelion": viele},
{1000: dict(DETAILS_2_0[22843], title="Irgendein Evangelion")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion"))
assert ergebnis.confidence == 0.6, "20 Treffer sind kein sicherer Fund"
def test_treffer_auf_eine_GEKUERZTE_variante_gilt_nicht_als_sicher():
"""Die Spezifitaet zaehlt nur, wenn der VOLLE Disc-Titel gefragt wurde.
Sonst wuerde Der Herr der Ringe: Die zwei Tuerme" ueber die Kuerzung
Der Herr" zu einem 80-Prozent-Fund fuer irgendetwas.
"""
tmdb = FakeTMDB(
{"Evangelion": [{"id": 22843, "title": "Neon Genesis Evangelion"}]},
{22843: dict(DETAILS_2_0[22843], title="Neon Genesis Evangelion")})
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Evangelion 2.22"))
# Der volle Titel lieferte nichts, die Kuerzung schon -> nur Vorschlag.
assert ergebnis.confidence == 0.6
def test_ohne_jeden_treffer_bleibt_es_ehrlich_bei_dreissig_prozent():
ergebnis = _prescan(FakeTMDB({}))._scan_video("/dev/x", _toc("Gibt Es Nicht"))
assert ergebnis.confidence == 0.3
assert ergebnis.metadata["type"] == "unknown"
@pytest.mark.parametrize("anzahl,erwartet", [(1, 0.8), (3, 0.8), (4, 0.6)])
def test_die_schwelle_liegt_bei_drei(anzahl, erwartet):
"""Drei ist grosszuegig genug fuer Neuauflagen und Regie-Fassungen
desselben Films, aber eng genug, um Raten auszuschliessen."""
treffer = [{"id": 22843 + i, "title": "Film %d" % i} for i in range(anzahl)]
details = {22843: dict(DETAILS_2_0[22843], title="Film 0")}
tmdb = FakeTMDB({"Ein Sehr Genauer Titel": treffer}, details)
ergebnis = _prescan(tmdb)._scan_video("/dev/x", _toc("Ein Sehr Genauer Titel"))
assert ergebnis.confidence == erwartet
+81 -7
View File
@@ -19,13 +19,20 @@ def test_der_echte_fall_wird_gefunden():
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():
vorhanden = {f"/app/media/rippy/{JOB}"}
gefunden = rohdaten.suche(
JOB, "",
listdir=lambda p: ["movies", "rippy"],
isdir=lambda p: p in vorhanden,
)
orte_wurzeln=CONTAINER)
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
#: 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():
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}"
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}"]
@@ -73,7 +110,7 @@ def test_listdir_kaputt_faellt_auf_den_standard_zurueck():
raise OSError("kein /app/media")
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}"]
@@ -177,7 +214,7 @@ def test_suche_mit_der_zeitgrenze_findet_den_echten_fall():
JOB, "",
listdir=lambda p: ["bluray", "movies", "rippy"],
isdir=lambda p: rohdaten.verzeichnis_da(p, laufen),
)
orte_wurzeln=CONTAINER)
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"
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}
@@ -215,7 +252,7 @@ def test_suche_mit_status_ohne_zweifel():
return "da" if pfad == f"/app/media/rippy/{JOB}" else "weg"
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}
@@ -227,6 +264,43 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
return "unklar" if "totes-nas" in pfad else "weg"
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["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"
+119 -8
View File
@@ -6,19 +6,91 @@ import LogsPage from './pages/Logs'
import AnleitungPage from './pages/Anleitung'
import FirstRunWizard from './components/FirstRunWizard'
import { api } from './lib/api'
import { EventStreamProvider, useStrom } from './lib/useEventStream'
import { BetriebProvider } from './lib/useBetrieb'
import { Fehlergrenze } from './components/Fehlergrenze'
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
export default function App() {
/**
* Zeigt, ob der Live-Strom steht.
*
* Hier stand bis V2-3 ein fest verdrahtetes ONLINE" mit pulsierendem Punkt
* es leuchtete auch dann gruen, wenn die API tot war. Genau die Sorte
* Placebo-Anzeige, die in Etappe 19 schon einmal aufgeraeumt wurde
* (Fortschritt log, Auswurf tat nichts, Encoder wurden behauptet statt
* gemessen"). Jetzt sagt sie die Wahrheit.
*/
function VerbindungsAnzeige() {
const { verbunden, zuletzt } = useStrom()
if (verbunden) {
return (
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>LIVE</span>
</div>
)
}
return (
<div
className="flex items-center gap-2 text-xs font-mono text-amber-400 bg-amber-500/10 px-3 py-1.5 rounded-xl border border-amber-500/30"
title={
zuletzt
? `Letzte Meldung um ${zuletzt.toLocaleTimeString()}. Die Anzeige zeigt diesen Stand, bis die Verbindung zurueck ist.`
: 'Noch keine Verbindung zum Server.'
}
>
<span className="w-2 h-2 rounded-full bg-amber-400" />
<span>VERBINDUNG WEG</span>
</div>
)
}
function AppInhalt() {
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
const [setupDone, setSetupDone] = useState<boolean | null>(null)
/*
* Ein fehlgeschlagener Abruf ist KEINE Aussage über die Einrichtung.
*
* Hier stand `.catch(() => setSetupDone(true))` mit der Begründung
* API/Setup nicht erreichbar nicht blockieren". Die Folge hat der
* Commander am 28.08.2026 gemeldet: **Nach einer frischen Installation
* fehlte der Ersteinrichtungs-Assistent.**
*
* Und zwar zwangsläufig: Das Fenster geht unmittelbar nach dem Setup auf,
* der Dienst braucht ein bis zwei Sekunden bis zur ersten Antwort der
* erste Abruf scheitert also fast immer. Aus ich konnte nicht nachsehen"
* wurde ist schon erledigt", und der Assistent war für immer weg.
*
* Dasselbe Prinzip steht nebenan in `useEventStream.tsx`: Wer nichts Neues
* weiß, behält, was er wusste und behauptet nichts.
*
* Deshalb: nachfragen, bis der Server ANTWORTET. Erst eine Antwort
* entscheidet.
*/
useEffect(() => {
let abgemeldet = false
let versuche = 0
const fragen = () => {
api.get('/setup')
.then((r) => setSetupDone(Boolean(r.data?.done)))
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
.then((r) => { if (!abgemeldet) setSetupDone(Boolean(r.data?.done)) })
.catch(() => {
if (abgemeldet) return
versuche += 1
// Zwei Minuten lang alle zwei Sekunden. Antwortet der Server bis
// dahin nicht, hat der Nutzer ein anderes Problem als den
// Assistenten — dann zeigen wir die Oberfläche, damit er die Logs
// sehen kann.
if (versuche < 60) setTimeout(fragen, 2000)
else setSetupDone(true)
})
}
fragen()
return () => { abgemeldet = true }
}, [])
const navItems: { id: Page; label: string; icon: any }[] = [
@@ -28,7 +100,22 @@ export default function App() {
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
]
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
// Solange /setup noch nicht geantwortet hat: sagen, dass gewartet wird.
// Ein leeres Fenster sieht aus wie ein Absturz — und nach einer frischen
// Installation ist genau das der erste Eindruck.
if (setupDone === null) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#0b0f19]">
<div className="text-center">
<div className="text-amber-400 text-3xl mb-3"></div>
<p className="text-slate-300 text-sm">Rippy startet </p>
<p className="text-slate-500 text-xs mt-1 font-mono">
warte auf den Dienst
</p>
</div>
</div>
)
}
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
return (
@@ -69,20 +156,44 @@ export default function App() {
})}
</nav>
<div className="flex items-center gap-2 text-xs font-mono text-emerald-400 bg-emerald-500/10 px-3 py-1.5 rounded-xl border border-emerald-500/20">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span>ONLINE</span>
</div>
<VerbindungsAnzeige />
</div>
</header>
{/* Main Content Viewport */}
<main className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Je Seite eine eigene Grenze: Faellt eine aus, bleibt die
Navigation stehen und man kommt woanders hin. Ohne das war am
29.08.2026 nach einem Klick auf Rippen starten" der ganze
Bildschirm leer samt Kopfzeile. */}
<Fehlergrenze bereich={
currentPage === 'dashboard' ? 'Das Dashboard'
: currentPage === 'anleitung' ? 'Die Anleitung'
: currentPage === 'logs' ? 'Die Protokolle' : 'Die Einstellungen'}>
{currentPage === 'dashboard' && <Dashboard />}
{currentPage === 'anleitung' && <AnleitungPage />}
{currentPage === 'logs' && <LogsPage />}
{currentPage === 'settings' && <SettingsPage />}
</Fehlergrenze>
</main>
</div>
)
}
export default function App() {
// Der Provider haelt GENAU EINE SSE-Verbindung fuer die ganze Anwendung.
// Ein Hook je Komponente waere eine Verbindung je Komponente — also das
// alte Problem in neuer Form.
// BetriebProvider ganz aussen: Was Rippy anzeigen DARF, entscheidet sich
// vor allem anderen. Ohne ihn zeigte das Windows-Fenster "Pruefen: docker
// compose ps" — einen Rat, den dort niemand befolgen kann.
return (
<Fehlergrenze>
<BetriebProvider>
<EventStreamProvider>
<AppInhalt />
</EventStreamProvider>
</BetriebProvider>
</Fehlergrenze>
)
}
+45 -4
View File
@@ -7,6 +7,7 @@ import { Button } from './ui/Button'
import { TypeBadge } from './ui/Badge'
import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
import { useStrom } from '../lib/useEventStream'
interface DiscInfo {
title: string
@@ -35,6 +36,8 @@ interface Device {
serial?: string
model?: string
disc?: DiscInfo
/** Erkennung laeuft — noch kein Titel, aber auch kein leeres Laufwerk. */
disc_wird_erkannt?: boolean
}
function posterUrl(disc?: DiscInfo): string | null {
@@ -63,6 +66,7 @@ export default function DeviceDiscovery() {
const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { toast } = useToast()
const strom = useStrom()
const refreshDevices = async (manuell = false) => {
if (manuell) setRefreshing(true)
@@ -124,11 +128,20 @@ export default function DeviceDiscovery() {
}
}
// Vorher: alle 5 s ein eigener Abruf von /devices (12 Anfragen pro Minute).
// Jetzt liefert der Server die Aenderung, sobald sie passiert.
//
// WICHTIG — nur uebernehmen, wenn der Server wirklich etwas WEISS:
// `strom.devices === null` heisst „konnte nicht nachsehen" (haengendes
// Laufwerk, Zeitgrenze) und NICHT „es gibt keine Laufwerke". In dem Fall
// bleibt der letzte bekannte Stand stehen. Genau diese Vermischung hat in
// v1 die Listen im Sekundentakt geleert.
useEffect(() => {
refreshDevices()
const interval = setInterval(refreshDevices, 5000)
return () => clearInterval(interval)
}, [])
if (strom.devices !== null) {
setDevices(strom.devices as Device[])
setLoading(false)
}
}, [strom.devices])
const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
@@ -168,6 +181,34 @@ export default function DeviceDiscovery() {
</CardHeader>
<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) */}
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div
+100
View File
@@ -0,0 +1,100 @@
/*
* Eine Fehlergrenze damit ein Fehler nicht die ganze Oberfläche verschluckt.
*
* ## Der Befund des Commanders (29.08.2026)
*
* > wenn man auf rippen starten klickt passiert irgendwas, was das Programm
* > nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
* > auch keine fehlermeldung."
*
* Nachgestellt und in der Browser-Konsole gemessen:
*
* TypeError: Cannot read properties of undefined (reading 'toUpperCase')
*
* Der Job war in Wirklichkeit angelegt und lief (gemessen: `processing`,
* 12 %). Nur zeigte die Oberfläche das nie sie war weg. Grund: React baut
* bei einem Fehler im Zeichnen den GESAMTEN Baum ab, wenn ihn niemand
* auffängt. Und aufgefangen hat ihn niemand: Es gab in diesem Projekt keine
* einzige Fehlergrenze.
*
* ## Warum die eigentliche Reparatur woanders sitzt
*
* Die Ursache war ein Ereignis, das den halben Job schickte
* (`rippy/bus/waechter.py`), und zwei Stellen, die einen vollständigen
* annahmen. Das ist behoben. Diese Datei behebt etwas anderes: die WIRKUNG.
*
* Ein Programmierfehler in einer Karte darf den Rest des Bildschirms nicht
* mitnehmen und schon gar nicht schweigend. Der nächste Fehler dieser Art
* kommt bestimmt; dann steht wenigstens da, was los ist, und der Rest der
* Oberfläche bleibt bedienbar.
*
* Deshalb wird sie ZWEIMAL eingesetzt: einmal um die ganze Anwendung, und
* einmal um den Job-Bereich allein. Fällt eine Karte aus, bleibt der Rest.
*/
import { Component, ErrorInfo, ReactNode } from 'react'
interface Props {
children: ReactNode
/** Was hier kaputtgehen kann — steht in der Meldung. */
bereich?: string
}
interface State {
fehler: Error | null
}
export class Fehlergrenze extends Component<Props, State> {
state: State = { fehler: null }
static getDerivedStateFromError(fehler: Error): State {
return { fehler }
}
componentDidCatch(fehler: Error, info: ErrorInfo) {
// In die Konsole, damit die Ursache auffindbar bleibt. Ein stiller
// Fehler ist genau das, was diesen Befund so schwer gemacht hat.
console.error('Rippy: Fehler im Bereich', this.props.bereich || 'Oberfläche',
fehler, info.componentStack)
}
neuLaden = () => {
this.setState({ fehler: null })
}
render() {
if (!this.state.fehler) return this.props.children
return (
<div className="m-4 p-5 rounded-xl border border-amber-500/40 bg-amber-500/10">
<h2 className="font-semibold text-amber-700 dark:text-amber-300">
{this.props.bereich
? `${this.props.bereich} lässt sich gerade nicht anzeigen`
: 'Diese Ansicht lässt sich gerade nicht anzeigen'}
</h2>
<p className="text-sm mt-2 text-slate-600 dark:text-slate-300">
Der Fehler betrifft nur die Anzeige.{' '}
<strong>Laufende Rips gehen weiter</strong> Rippy arbeitet im
Hintergrund, auch wenn dieser Bereich leer bleibt.
</p>
<p className="text-xs mt-3 font-mono text-slate-500 dark:text-slate-400 break-all">
{this.state.fehler.message || String(this.state.fehler)}
</p>
<div className="flex gap-2 mt-4">
<button
onClick={this.neuLaden}
className="px-3 py-1.5 text-sm rounded-lg bg-amber-500 text-white hover:bg-amber-600"
>
Nochmal versuchen
</button>
<button
onClick={() => window.location.reload()}
className="px-3 py-1.5 text-sm rounded-lg border border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300"
>
Seite neu laden
</button>
</div>
</div>
)
}
}
+11 -3
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
import { api } from '../lib/api'
import { useBetrieb } from '../lib/useBetrieb'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
import { Button } from './ui/Button'
@@ -74,6 +75,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [makemkvKey, setMakemkvKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const betrieb = useBetrieb()
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
@@ -197,7 +199,11 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
/>
<Zeile
titel="Encoding-Worker"
/* Auf einem Windows-PC gibt es keinen "Encoding-Worker" und
keinen Worker-Container -- Rippy komprimiert selbst. Der
Commander am 28.08.2026: "Das gilt fuer die ganze
standalone version fuer Windows." */
titel={betrieb.kann.externe_worker ? 'Encoding-Worker' : 'Kompression'}
geladen={geladen}
gut={encoderWorker.length > 0}
gutText={encoderWorker.map(w => {
@@ -207,7 +213,9 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
.filter(Boolean).join(', ')
return zusatz ? `${w.name} (${zusatz})` : w.name
}).join(' · ')}
schlechtText="Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht."
schlechtText={betrieb.kann.externe_worker
? 'Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht.'
: 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'}
/>
<Zeile
titel="Freier Platz"
@@ -351,7 +359,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
</p>
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
Keiner deiner Worker kann AVX2 die Vektorbefehle, von denen H.265
{betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein AVX2 die Vektorbefehle, von denen H.265
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
{' '}dauern. Deshalb wird eingestellt:
</p>
+13 -37
View File
@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
import { Card, CardHeader, CardTitle } from './ui/Card'
import { useStrom } from '../lib/useEventStream'
interface LiveLogEntry {
id: string
@@ -12,40 +11,14 @@ interface LiveLogEntry {
}
export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(null)
useEffect(() => {
const fetchJobs = async () => {
try {
const response = await api.get('/jobs')
const jobs = response.data
const current = jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding')
setCurrentJob(current || jobs.find((j: any) => j.status === 'pending'))
} catch (error) {
console.error('Fehler beim Laden der Jobs:', error)
}
}
const fetchLogs = async () => {
try {
const response = await api.get('/logs', { params: { limit: 50 } })
setLogs(response.data)
} catch (error) {
console.error('Fehler beim Laden der Logs:', error)
}
}
fetchJobs()
fetchLogs()
const jobInterval = setInterval(fetchJobs, 5000)
const logInterval = setInterval(fetchLogs, 5000)
return () => {
clearInterval(jobInterval)
clearInterval(logInterval)
}
}, [])
// Vorher: zwei eigene Taktgeber (alle 5 s /jobs und /logs) = 24 Anfragen pro
// Minute allein aus diesem Kasten. Jetzt kommt beides aus der einen offenen
// Verbindung, die der Provider haelt.
const { jobs, logs } = useStrom()
const currentJob =
jobs.find((j: any) => j.status === 'processing' || j.status === 'transcoding') ||
jobs.find((j: any) => j.status === 'pending') ||
null
const getStatusColor = (level: string) => {
switch (level) {
@@ -72,7 +45,10 @@ export default function LiveLogSection() {
<CardTitle>Live-Log</CardTitle>
<p className="text-sm text-slate-500 dark:text-slate-400">
{currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
// `type?.` ist hier kein Beiwerk: Ein frisch angelegter Job kann
// noch ohne Typ ankommen, und ohne das Fragezeichen riss diese
// eine Zeile am 29.08.2026 die GANZE Oberflaeche mit.
? `Aktiver Job: ${currentJob.type?.toUpperCase() || 'Disc'} (${currentJob.progress ?? 0}%)`
: 'Kein aktiver Job'}
</p>
</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>
)
}
+65 -14
View File
@@ -5,6 +5,7 @@ import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input, Select } from './ui/Input'
import { automatikWarnung, externWarnung, freigabenAusMapping, sprachName } from '../lib/encoder'
import { useBetrieb } from '../lib/useBetrieb'
interface TargetConfig {
id: string
@@ -157,12 +158,20 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
return () => clearInterval(interval)
}, [scanStatus, deviceId])
const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
const [browsePath, setBrowsePath] = useState<string>('/app/media')
// Welcher Betrieb? Ohne diese Auskunft stand hier „Container-Platte" —
// auf einem Windows-PC eine Ortsangabe fuer einen Ort, den es nicht gibt.
const betrieb = useBetrieb()
/*
* Leer starten statt mit Container-Pfaden (Befund 29.08.2026).
*
* 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 [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('')
@@ -181,6 +190,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [arbeitsZiele, setArbeitsZiele] = useState<StorageZiel[]>([])
const [arbeitsDir, setArbeitsDir] = useState('')
const [standardArbeitsDir, setStandardArbeitsDir] = useState('')
// Ein frei erblätterter Arbeitsordner. Er braucht einen eigenen Platz in
// der Liste, sonst zeigt das Auswahlfeld einen Wert an, den es nicht kennt
// — und stünde leer da, obwohl etwas gewählt ist.
const [eigenesArbeitsZiel, setEigenesArbeitsZiel] = useState('')
useEffect(() => {
if (!isOpen) return
@@ -194,6 +207,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setGewaehlt(new Set())
setEncoderNode('')
setArbeitsDir('')
setEigenesArbeitsZiel('')
setBrowserOffen(false)
setDiscSprachen(null)
setAudioWahl(new Set())
@@ -213,7 +227,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || '/app/media'
const basis = s.outputDir || betrieb.ablage_vorgabe
setTargets([
{ 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 },
@@ -240,6 +254,17 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
// Das effektive Arbeitsverzeichnis: Wahl für diesen Rip → Einstellung →
// leer (= Container-Platte /app/temp, für externe Worker unerreichbar).
const arbeitsPfadEffektiv = arbeitsDir || standardArbeitsDir
/*
* Was WIRKLICH benutzt wird, wenn niemand etwas wählt.
*
* Commander am 28.08.2026: Warum heißt das hier noch container platte? Er
* holt sich das Arbeitsverzeichnis ja von der Installation."
*
* Genau so ist es nur stand hier fest verdrahtet (Container-Platte)",
* sobald die Einstellung leer war. Auf seinem PC war das die Beschreibung
* eines Ortes, den es nicht gibt. Jetzt sagt der Betrieb, wohin es geht.
*/
const arbeitsVorgabe = standardArbeitsDir || betrieb.arbeits_vorgabe
// Warnung VOR dem Start, wenn der gewählte externe Encoder die Pfade nicht
// erreicht (Punkt 6 des Savepoints v3.16). Am 26.07.2026 fiel genau das erst
// NACH dem Rip auf, weil nur der Worker selbst prüfte.
@@ -378,8 +403,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onChange={e => setArbeitsDir(e.target.value)}
>
<option value="">
Standard aus den Einstellungen
{standardArbeitsDir ? ` (${standardArbeitsDir})` : ' (Container-Platte)'}
Standard{arbeitsVorgabe ? `${arbeitsVorgabe}` : ''}
</option>
{arbeitsZiele.map(z => (
<option key={z.path} value={z.path}>
@@ -387,10 +411,13 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</option>
))}
{eigenesArbeitsZiel && (
<option value={eigenesArbeitsZiel}>{eigenesArbeitsZiel}</option>
)}
</Select>
<p className="text-xs mt-1.5 text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<HardDrive size={13} />
Bei 4K-UHD bis zu 100 GB nimm eine Freigabe mit Platz, am besten dieselbe wie das Ziel oben.
Bei 4K-UHD bis zu 100 GB nimm ein Laufwerk mit Platz, am besten dasselbe wie das Ziel oben.
Dann muss Rippy am Ende nur umhängen statt zu kopieren.
</p>
</div>
@@ -588,12 +615,14 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onClick={() => {
const auf = !browserOffen
setBrowserOffen(auf)
if (auf && browseDirs.length === 0) laden('/app/media')
if (auf && browseDirs.length === 0) laden('')
}}
className="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
>
{browserOffen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
Anderen Zielordner wählen (nur für diesen Rip)
{selectedType === 'music'
? 'Anderen Zielordner wählen (nur für diesen Rip)'
: 'Ordner durchsuchen — Ziel oder Arbeitsordner (nur für diesen Rip)'}
</button>
{browserOffen && (
@@ -607,14 +636,36 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">
{browsePath || 'Laufwerke'}
</span>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => setCustomPath(customPath === browsePath ? '' : browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
{customPath === browsePath ? '✓ Ziel' : 'Als Ziel'}
</Button>
{/* Der zweite Knopf ist der Grund, warum es diesen Browser für
das Arbeitsverzeichnis überhaupt braucht: Die Schnellwahl
darüber kennt nur Laufwerke und Unterordner der Ablage. Ein
beliebiger Ordner etwa D:\Rippy-Arbeit ging bisher gar
nicht (momentan geht das nicht", 28.08.2026). */}
{selectedType !== 'music' && (
<Button
variant={arbeitsDir === browsePath ? 'amber' : 'secondary'}
size="sm"
disabled={!browsePath}
onClick={() => {
if (arbeitsDir === browsePath) { setArbeitsDir(''); return }
setEigenesArbeitsZiel(browsePath)
setArbeitsDir(browsePath)
}}
>
{arbeitsDir === browsePath ? '✓ Arbeitsordner' : 'Als Arbeitsordner'}
</Button>
)}
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
+11 -4
View File
@@ -6,6 +6,7 @@ import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
import { useStrom } from '../lib/useEventStream'
interface WorkerInfo {
name: string
@@ -54,6 +55,7 @@ export default function WorkerVerwaltung() {
// eintragen lassen als eine falsche Adresse vorgaukeln.
const [rippyHost, setRippyHost] = useState('')
const { toast } = useToast()
const strom = useStrom()
// Für die Befehle: leeres Feld → sichtbarer Platzhalter (Befehl ist dann
// erkennbar unvollständig, statt still auf die falsche Adresse zu zeigen).
@@ -76,11 +78,16 @@ export default function WorkerVerwaltung() {
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
}
// Die Worker-Liste kommt aus dem Live-Strom. /capabilities wird nur noch
// EINMAL geholt — dort steht ausser der Liste die Server-Version, und die
// aendert sich nur beim Deploy. Vorher lief der Abruf alle 15 s.
useEffect(() => { laden() }, [])
useEffect(() => {
laden()
const interval = setInterval(laden, 15000)
return () => clearInterval(interval)
}, [])
if (strom.workers.length || strom.verbunden) {
setWorkers(strom.workers as WorkerInfo[])
}
}, [strom.workers, strom.verbunden])
const installBefehl = variante === 'linux'
? [
+4 -1
View File
@@ -25,9 +25,12 @@ interface TypeBadgeProps {
}
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
// Ein fehlender Typ darf kein Absturz sein. Am 29.08.2026 hat genau diese
// Annahme (an anderer Stelle) die ganze Oberflaeche geleert: Ein frisch
// angelegter Job kommt ueber den Ereignisstrom ohne `type` an.
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: type.toUpperCase(),
label: type ? type.toUpperCase() : 'DISC',
}
return (
+144
View File
@@ -0,0 +1,144 @@
/*
* Worauf läuft Rippy und was kann dieser Betrieb überhaupt?
*
* ## Der Befund des Commanders (28.08.2026)
*
* Im Windows-Fenster stand auf der Server-Status-Kachel:
*
* Worker erreichbar: 0 von 1
* Kein Worker antwortet ohne ihn läuft kein Rip. Prüfen: docker compose ps
* Container-Platte: unbekannt
* Freigaben: keine eingehängt
*
* Kein Satz davon ergibt auf einem Windows-PC einen Sinn. Es gibt keinen
* Container, kein `docker compose`, keinen zweiten Worker Rippy rippt dort
* selbst. Sein Urteil: Du hast ja quasi nur rippy genommen und die docker
* installation für Windows gebaut."
*
* ## Warum ein Provider und kein Abruf je Seite
*
* Dieselbe Begründung wie beim Ereignis-Strom nebenan: Vier Seiten, die
* dasselbe abfragen, sind vier Abrufe und vier Gelegenheiten, dass eine
* davon einen anderen Stand hat als die anderen. Der Betrieb ändert sich zur
* Laufzeit nicht; er wird EINMAL geholt.
*
* ## Warum die Vorgabe Docker" ist
*
* Solange die Antwort noch unterwegs ist, muss irgendetwas gelten. Die
* Docker-Annahme ist hier die richtige Vorgabe sie zeigt MEHR, und ein kurz
* zu viel angezeigter Bereich ist harmloser als ein Bedienelement, das für
* einen Augenblick verschwindet und wieder auftaucht.
*
* Ein FEHLGESCHLAGENER Abruf ist etwas anderes als noch unterwegs":
* `geladen` bleibt dann false, und wer das wissen will, kann es abfragen. Ein
* Verbindungsabriss ist keine Aussage über die Welt (siehe useEventStream).
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
import { api } from './api'
export interface BetriebsFaehigkeiten {
/** Gibt es andere Maschinen, die Jobs übernehmen? */
externe_worker: boolean
/** Kann Rippy Netzwerk-Freigaben selbst einhängen? */
freigaben_einhaengen: boolean
/** Sind Pfade wie /app/media überhaupt gemeint? */
container_pfade: boolean
/** Kann Rippy MakeMKV/HandBrake selbst beschaffen? */
werkzeuge_verwalten: boolean
/** Darf die Oberflaeche ausserhalb der Medien-Wurzel blaettern? */
frei_blaettern: boolean
}
export interface Betrieb {
modus: 'standalone' | 'verteilt'
plattform: 'windows' | 'linux' | 'macos'
im_container: boolean
kann: BetriebsFaehigkeiten
/** Womit ein Ablage-Feld vorbelegt wird — je Betrieb ein anderer Ort. */
ablage_vorgabe: string
/** Wohin die Rohdaten wandern, wenn niemand etwas anderes waehlt. */
arbeits_vorgabe: string
/** Der Befehl zum Nachsehen. LEER heißt: es gibt keinen, den der Nutzer
* ausführen könnte dann darf auch keiner dastehen. */
hilfe_befehl: string
/** Ist die Auskunft schon da? False heißt noch unterwegs ODER nicht
* erreichbar" — nicht „es gibt keinen Betrieb". */
geladen: boolean
}
const VORGABE: Betrieb = {
modus: 'verteilt',
plattform: 'linux',
im_container: true,
kann: {
externe_worker: true,
freigaben_einhaengen: true,
container_pfade: true,
werkzeuge_verwalten: false,
frei_blaettern: false,
},
// Leer statt Container-Pfade: Bis /betrieb geantwortet hat, WISSEN wir den
// 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',
geladen: false,
}
const BetriebContext = createContext<Betrieb>(VORGABE)
export function BetriebProvider({ children }: { children: ReactNode }) {
const [betrieb, setBetrieb] = useState<Betrieb>(VORGABE)
useEffect(() => {
let abgemeldet = false
api.get('/betrieb')
.then(antwort => {
const d = antwort.data
// Nur übernehmen, was WIRKLICH ankommt. Ein halb gefülltes Objekt
// hieße in JavaScript `undefined` — und `undefined` ist falsch, also
// verschwände ein Bereich stillschweigend.
if (abgemeldet || !d || !d.kann) return
setBetrieb({
modus: d.modus === 'verteilt' ? 'verteilt' : 'standalone',
plattform: d.plattform || 'linux',
im_container: !!d.im_container,
kann: {
externe_worker: !!d.kann.externe_worker,
freigaben_einhaengen: !!d.kann.freigaben_einhaengen,
container_pfade: !!d.kann.container_pfade,
werkzeuge_verwalten: !!d.kann.werkzeuge_verwalten,
frei_blaettern: !!d.kann.frei_blaettern,
},
ablage_vorgabe: d.ablage_vorgabe || '',
arbeits_vorgabe: d.arbeits_vorgabe || '',
hilfe_befehl: d.hilfe_befehl || '',
geladen: true,
})
})
.catch(() => {
// Nichts tun. Ein misslungener Abruf ist keine Aussage über den
// Betrieb — die Vorgabe bleibt stehen, `geladen` bleibt false.
})
return () => { abgemeldet = true }
}, [])
return <BetriebContext.Provider value={betrieb}>{children}</BetriebContext.Provider>
}
export function useBetrieb(): Betrieb {
return useContext(BetriebContext)
}
/**
* Kurzform für den häufigsten Fall: Läuft Rippy als eigenständige App
* (Windows-Client oder Docker-All-in-One)?
*
* Bewusst NICHT ist Windows": Ein Docker-All-in-One hat auch keinen zweiten
* Worker. Wer nach der Plattform fragt, obwohl er die Fähigkeit meint, baut
* die nächste falsche Annahme ein.
*/
export function useAlleinbetrieb(): boolean {
return !useBetrieb().kann.externe_worker
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Der Live-Strom des UI EINE Verbindung für die ganze Anwendung.
*
* ## Was das ablöst
*
* Bis hierher hatte jede Komponente ihren eigenen Taktgeber. Nachgerechnet an
* den `setInterval`-Aufrufen verursachte EIN offener Tab plus ein Worker rund
* 133 Anfragen pro Minute:
*
* Dashboard 5 Endpunkte alle 4 s -> 75/min
* Log-Kasten 2 Endpunkte alle 5 s -> 24/min
* Laufwerke 1 Endpunkt alle 5 s -> 12/min
* Log-Seite 1 Endpunkt alle 10 s -> 6/min
* Worker-Liste 1 Endpunkt alle 15 s -> 4/min
* Windows-Tray /jobs alle 5 s -> 12/min
*
* Das Rate-Limit stand einmal UNTER dieser Zahl (100/min). Etwa jede vierte
* Anfrage bekam 429 und weil das UI einen Fehlschlag als es gibt nichts"
* verbuchte, leerte sich die Job-Liste im Sekundentakt. Der Commander meldete
* das als wird oft neu geladen".
*
* ## DIE Regel dieser Datei
*
* Ein Verbindungsabriss ist KEINE Aussage über die Welt.
*
* Bei einem Fehler wird der letzte bekannte Stand BEHALTEN und `verbunden`
* auf false gesetzt das UI zeigt dann ein Banner. Es wird niemals eine
* Liste auf leer gesetzt. Genau das war der alte Fehler: fünfmal stand
* `catch(() => [])` im Code, und jeder fehlgeschlagene Abruf hieß damit
* keine Jobs, keine Laufwerke, keine Ablagen".
*
* ## Warum ein Context und nicht ein Hook je Komponente
*
* `EventSource` öffnet je Aufruf eine eigene HTTP-Verbindung. Ein Hook, den
* fünf Komponenten benutzen, wären fünf Verbindungen und fünf Snapshots
* also genau das Problem in neuer Form. Der Provider hält eine.
*/
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
export type Job = {
id: string
status: string
progress: number
title?: string | null
type?: string
device?: string
error?: string | null
meta?: Record<string, any> | null
[k: string]: any
}
export type Device = { id: string; name: string; type: string; path: string; status: string; [k: string]: any }
export type Worker = { name: string; encoders?: string[]; info?: Record<string, any>; last_seen?: string | null }
/**
* Dieselbe Form, die auch `GET /logs` liefert (`_log_zeile` in main.py).
* `timestamp`, nicht `ts` sonst zeigt das Log-Fenster Invalid Date", und
* zwar NUR im Live-Betrieb.
*/
export type LogZeile = { id: string; timestamp?: string; level?: string; source?: string; message?: string }
export type StromZustand = {
jobs: Job[]
/** Server-Zustand aus dem langsamen Takt des Waechters. null = noch nichts gehoert. */
systemInfo: any | null
/** Ablageziele mit freiem Platz. null = noch nichts gehoert. */
ablagen: any[] | null
/** null heißt ausdrücklich „konnte nicht nachsehen" — NICHT „es gibt keine". */
devices: Device[] | null
workers: Worker[]
logs: LogZeile[]
/** false = Verbindung weg. Die Daten oben sind dann der letzte bekannte Stand. */
verbunden: boolean
/** Wie oft die Verbindung schon neu aufgebaut wurde — fürs Log, nicht fürs UI. */
neuverbindungen: number
/** Zeitpunkt des letzten empfangenen Ereignisses. */
zuletzt: Date | null
}
const LEER: StromZustand = {
jobs: [],
systemInfo: null,
ablagen: null,
devices: null,
workers: [],
logs: [],
verbunden: false,
neuverbindungen: 0,
zuletzt: null,
}
const StromContext = createContext<StromZustand>(LEER)
/** Wie viele Log-Zeilen im Speicher gehalten werden. */
const LOG_GRENZE = 300
export function EventStreamProvider({ children }: { children: ReactNode }) {
const [zustand, setZustand] = useState<StromZustand>(LEER)
// In einem Ref, damit die Handler nicht bei jedem Zustandswechsel neu
// gebunden werden müssen — sonst risse die Verbindung ständig ab.
const neuverbindungen = useRef(0)
useEffect(() => {
const quelle = new EventSource('/api/events')
const merken = (patch: Partial<StromZustand>) =>
setZustand((alt) => ({ ...alt, ...patch, zuletzt: new Date() }))
// ── Snapshot: das ganze Bild ──────────────────────────────────────
quelle.addEventListener('snapshot', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
merken({
jobs: d.jobs ?? [],
// devices === null kommt vom Server, wenn er die Laufwerke nicht
// lesen konnte. Das wird DURCHGEREICHT, nicht zu [] geglättet.
devices: d.devices === undefined ? null : d.devices,
workers: d.workers ?? [],
logs: d.logs ?? [],
// ⚠️ Server-Zustand MIT übernehmen (Befund 28.08.2026).
//
// Er stand hier nicht, obwohl der Schnappschuss ihn liefert — und
// `system.status` kommt erst nach bis zu 15 Sekunden. Nach jedem
// Neuladen stand deshalb „Platz für Rippy: unbekannt" auf dem
// Bildschirm. Das sieht aus wie eine Auskunft und ist keine.
//
// `undefined` heißt weiterhin „nicht mitgeschickt": dann bleibt der
// alte Stand, statt auf leer zu fallen.
systemInfo: d.info !== undefined ? d.info : null,
ablagen: d.ablagen !== undefined ? d.ablagen : null,
verbunden: true,
})
})
// ── Job-Deltas ────────────────────────────────────────────────────
const jobPatch = (e: Event) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const id = ereignis.entitaet_id
const daten = ereignis.daten ?? {}
setZustand((alt) => {
if (daten.status === 'geloescht') {
return { ...alt, jobs: alt.jobs.filter((j) => j.id !== id), verbunden: true, zuletzt: new Date() }
}
const bekannt = alt.jobs.some((j) => j.id === id)
const jobs = bekannt
? alt.jobs.map((j) => (j.id === id ? { ...j, ...daten } : j))
: [{ id, ...daten } as Job, ...alt.jobs]
return { ...alt, jobs, verbunden: true, zuletzt: new Date() }
})
}
for (const typ of ['job.created', 'job.progress', 'job.phase', 'job.finished']) {
quelle.addEventListener(typ, jobPatch)
}
// ── Laufwerke ─────────────────────────────────────────────────────
quelle.addEventListener('drive.changed', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
setZustand((alt) => {
if (alt.devices === null) return alt // noch kein Stand — auf Snapshot warten
const id = ereignis.entitaet_id
return {
...alt,
devices: alt.devices.map((d) => (d.id === id ? { ...d, ...ereignis.daten } : d)),
verbunden: true,
zuletzt: new Date(),
}
})
})
// ── Log-Zeilen ────────────────────────────────────────────────────
quelle.addEventListener('log.line', (e) => {
const ereignis = JSON.parse((e as MessageEvent).data)
const d = ereignis.daten ?? {}
const zeile: LogZeile = {
id: String(ereignis.entitaet_id),
timestamp: ereignis.ts,
level: d.level,
source: d.source,
message: d.message,
}
setZustand((alt) => ({
...alt,
logs: [zeile, ...alt.logs].slice(0, LOG_GRENZE),
verbunden: true,
zuletzt: new Date(),
}))
})
// ── Server-Zustand (langsamer Takt) ───────────────────────────────
quelle.addEventListener('system.status', (e) => {
const d = JSON.parse((e as MessageEvent).data)?.daten ?? {}
// Nur uebernehmen, was WIRKLICH mitgeschickt wurde. Ein fehlender
// Schluessel heisst „konnte ich diesmal nicht lesen" — dann bleibt der
// alte Stand stehen, statt auf leer zu fallen.
setZustand((alt) => ({
...alt,
systemInfo: d.info !== undefined ? d.info : alt.systemInfo,
workers: d.workers !== undefined ? d.workers : alt.workers,
ablagen: d.ablagen !== undefined ? d.ablagen : alt.ablagen,
verbunden: true,
zuletzt: new Date(),
}))
})
quelle.onopen = () => merken({ verbunden: true })
// ── Abriss: Stand BEHALTEN, nur die Verbindung melden ─────────────
quelle.onerror = () => {
// Kein Leeren, kein Zurücksetzen. Der Browser verbindet von selbst neu
// und schickt dabei Last-Event-ID mit; der Server liefert die Lücke nach
// oder schickt einen frischen Snapshot.
neuverbindungen.current += 1
setZustand((alt) => ({
...alt,
verbunden: false,
neuverbindungen: neuverbindungen.current,
}))
}
return () => quelle.close()
}, [])
const wert = useMemo(() => zustand, [zustand])
return <StromContext.Provider value={wert}>{children}</StromContext.Provider>
}
/** Der gemeinsame Live-Zustand. */
export function useStrom(): StromZustand {
return useContext(StromContext)
}
+34 -5
View File
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { useBetrieb } from '../lib/useBetrieb'
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
return (
@@ -21,6 +22,18 @@ function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string,
export default function AnleitungPage() {
const fett = "font-semibold text-slate-100"
/*
* Auch die Anleitung haengt am Betrieb.
*
* Commander-Befund 28.08.2026: Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Eine Anleitung, die einem Windows-Nutzer erklaert, wie er Freigaben
* einhaengt und weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
* das er nicht hat. Das ist schlimmer als gar keine Anleitung: Sie laesst
* ihn glauben, er habe etwas falsch gemacht.
*/
const betrieb = useBetrieb()
return (
<div className="space-y-6 max-w-6xl mx-auto">
@@ -67,18 +80,28 @@ export default function AnleitungPage() {
</p>
</Abschnitt>
<Abschnitt icon={HardDrive} titel="NAS &amp; Speicherziele">
<Abschnitt icon={HardDrive} titel="Wohin die Filme kommen">
{betrieb.kann.freigaben_einhaengen ? (
<p>
Einstellungen Speicherziele: Rechner/NAS eintragen, <span className={fett}>Benutzer +
Passwort</span> angeben (Windows und die meisten NAS lehnen Gast-Zugriffe ab!), Freigaben
auflisten", Freigabe wählen, „Einhängen &amp; speichern". Danach taucht das Ziel im
Rippen starten"-Dialog auf und wird beim Start automatisch wieder verbunden.
</p>
) : (
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn die
Rippy-Platte knapp ist (Dashboard zeigt den freien Platz), das Arbeitsverzeichnis unter
Einstellungen Verarbeitung auf eine große Freigabe legen. Rippy prüft den Platz vor
jedem Rip und bricht sonst mit Klartext ab.
Einstellungen Speicherziele: Bei <span className={fett}>Ablage</span> steht der Ordner,
in dem die fertigen Filme landen. Das darf ein normaler Ordner sein
{betrieb.ablage_vorgabe ? <> (Vorgabe: <code>{betrieb.ablage_vorgabe}</code>)</> : null} oder
ein Netzwerkpfad wie <code>\NAS\Filme</code> Rippy schreibt einfach dorthin.
Ein Einhängen wie unter Linux gibt es hier nicht und ist auch nicht nötig.
</p>
)}
<p>
<span className={fett}>4K-UHD-Tipp:</span> Rohdaten sind bis 100 GB groß. Wenn der Platz
knapp wird (das Dashboard zeigt ihn), das Arbeitsverzeichnis unter Einstellungen
Verarbeitung auf {betrieb.kann.freigaben_einhaengen ? 'eine große Freigabe' : 'ein großes Laufwerk'} legen.
Rippy prüft den Platz vor jedem Rip und bricht sonst mit Klartext ab.
</p>
</Abschnitt>
@@ -104,6 +127,11 @@ export default function AnleitungPage() {
</p>
</Abschnitt>
{/* Nur im verteilten Betrieb. Einem Windows-Nutzer zu erklaeren,
wie er weitere Encoding-Worker aufsetzt, beschreibt ein Programm,
das er nicht hat und laesst ihn glauben, er habe etwas falsch
gemacht (Commander-Befund 28.08.2026). */}
{betrieb.kann.externe_worker && (
<Abschnitt icon={Cpu} titel="Weitere Maschinen als Encoding-Worker">
<p>
Die Kompression kann jede Maschine im Netz übernehmen Einstellungen Worker.
@@ -126,6 +154,7 @@ export default function AnleitungPage() {
nie eine externe Domain (die läuft über den Reverse-Proxy und blockt).
</p>
</Abschnitt>
)}
<Abschnitt icon={Wrench} titel="MakeMKV-Beta-Key &amp; System">
<p>
+156 -57
View File
@@ -10,6 +10,8 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
import { useStrom } from '../lib/useEventStream'
import { useBetrieb } from '../lib/useBetrieb'
interface JobMeta {
year?: number
@@ -42,7 +44,8 @@ interface Job {
interface SystemInfo {
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[] }[]
}
@@ -63,6 +66,9 @@ interface LaufwerkLive {
id: string
status: 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
@@ -126,6 +132,10 @@ export default function Dashboard() {
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
const [ablagen, setAblagen] = useState<AblageZiel[]>([])
const strom = useStrom()
// Was hier ueberhaupt angezeigt werden DARF, haengt am Betrieb.
// Ohne das stand im Windows-Fenster "Pruefen: docker compose ps".
const betrieb = useBetrieb()
const { toast } = useToast()
/*
@@ -232,64 +242,43 @@ export default function Dashboard() {
}
/*
* ZWEI TAKTE statt einem.
* KEIN TAKTGEBER MEHR (Etappe V2-3).
*
* JEDER Abruf hier gibt bei Fehlschlag `null` und `null` bedeutet nichts
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
* hat der Commander als wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
* Hier standen zwei: alle 4 s Jobs und Laufwerke, alle 12 s Hardware,
* Worker und Ablagen zusammen 75 Anfragen pro Minute JE OFFENEM TAB. Bei
* der damaligen Grenze von 100/min wies die API laufend mit HTTP 429 ab
* (gemessen: 97 Abweisungen im Log), und weil ein Fehlschlag im UI als
* es gibt nichts" gelesen wurde, leerte sich die Job-Liste im Sekundentakt.
* Genau das hat der Commander als wird oft neu geladen" gemeldet.
*
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche die API wies bei
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
* 75/min sind damit 15 + 15/min geworden.
* Jetzt kommt alles aus der EINEN Verbindung, die App.tsx haelt.
*
* DIE REGEL BLEIBT sie ist nur umgezogen: Ein fehlender Wert im Strom
* heisst nichts Neues erfahren", NICHT „es gibt nichts". Deshalb wird unten
* jeder Wert nur uebernommen, wenn er wirklich da ist; sonst bleibt der
* letzte bekannte Stand stehen. Das UI zeigt dann oben rechts
* VERBINDUNG WEG" statt leerer Listen.
*/
useEffect(() => {
const nichts = () => null
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
const schnellLaden = async () => {
try {
const [jobsData, devData] = await Promise.all([
fetchJobs(),
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (jobsData) setJobs(jobsData)
if (devData) setLaufwerke(devData)
} finally {
setJobs(strom.jobs as Job[])
setLoading(false)
}
}
}, [strom.jobs])
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
const langsamLaden = async () => {
const [sysData, capsData, ablData] = await Promise.all([
api.get('/system/info').then(r => r.data).catch(nichts),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung.
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (sysData) setSystemInfo(sysData)
if (capsData) setWorkersLive(capsData)
if (ablData) setAblagen(ablData)
}
useEffect(() => {
if (strom.devices !== null) setLaufwerke(strom.devices as LaufwerkLive[])
}, [strom.devices])
schnellLaden()
langsamLaden()
const schnell = setInterval(schnellLaden, 4000)
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) }
}, [])
useEffect(() => {
if (strom.workers.length || strom.verbunden) setWorkersLive(strom.workers as WorkerLive[])
}, [strom.workers, strom.verbunden])
useEffect(() => {
if (strom.systemInfo) setSystemInfo(strom.systemInfo as SystemInfo)
}, [strom.systemInfo])
useEffect(() => {
if (strom.ablagen !== null) setAblagen(strom.ablagen as AblageZiel[])
}, [strom.ablagen])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending')
@@ -343,6 +332,12 @@ export default function Dashboard() {
: 'Rip läuft (MakeMKV, verlustfrei)')
: discImLaufwerk
? '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'
const filteredJobs = jobs.filter(j => {
@@ -599,6 +594,38 @@ export default function Dashboard() {
Details
</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' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
@@ -697,7 +724,30 @@ export default function Dashboard() {
)}
</div>
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
{/* 2. Wer arbeitet hier und das hängt am BETRIEB.
Commander-Befund 28.08.2026: Im Windows-Fenster stand
Worker erreichbar: 0 von 1 Kein Worker antwortet.
Prüfen: docker compose ps". Auf einem Windows-PC gibt es
keinen zweiten Worker, auf den man warten könnte, und
kein docker compose, das man prüfen könnte. Die Meldung
war nicht nur unpassend, sie war falsch. */}
{!betrieb.kann.externe_worker ? (
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
<Cpu size={14} className="text-amber-400" /> Rippy arbeitet:
</span>
<span className="text-slate-200">
auf diesem Rechner
</span>
</div>
<p className="text-[11px] font-mono text-slate-500">
{aktiverJob
? 'Rippen und Komprimieren laufen hier — kein zweiter Rechner nötig.'
: 'Rippen und Komprimieren erledigt Rippy selbst.'}
</p>
</div>
) : (
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
@@ -711,7 +761,9 @@ export default function Dashboard() {
{workerOnline.length === 0 ? (
<p className="text-[11px] font-mono text-rose-400">
Kein Worker antwortet ohne ihn läuft kein Rip.
Prüfen: <span className="text-slate-300">docker compose ps</span>
{betrieb.hilfe_befehl && (
<> Prüfen: <span className="text-slate-300">{betrieb.hilfe_befehl}</span></>
)}
</p>
) : (
<div className="space-y-0.5">
@@ -732,15 +784,52 @@ export default function Dashboard() {
</div>
)}
</div>
)}
{/* 3. Platz Container-Platte UND die eingehängten Freigaben.
Vorher stand hier nur die Container-Platte; wer wissen
wollte, ob die NAS noch Platz für eine Disc hat, sah die
falsche Zahl (Commander-Befund 26.07.2026). */}
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
{/*
Jeder Ort mit seinem PFAD (Commander 29.08.2026): bei
Platz für rippy' sollte eher das arbeitsverzeichnis und
der Ablagepfad sein." Vorher stand hier eine nackte Zahl
für den ERSTEN Ort welcher Ordner das war, sah man nicht.
*/}
{!betrieb.kann.container_pfade && (systemInfo?.plaetze || []).map(ort => (
<div key={ort.name + ort.pfad} className="mb-1.5">
<div className="flex items-center justify-between text-xs font-mono">
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" /> Container-Platte:
<HardDrive size={14} className="text-amber-400" />
{ort.name}:
</span>
<span className={ort.frei_gb > 0 && ort.frei_gb < 60
? 'text-amber-400 font-bold' : 'text-slate-200'}>
{ort.frei_gb > 0 ? `${ort.frei_gb} von ${ort.gesamt_gb} GB frei` : 'unbekannt'}
</span>
</div>
{ort.pfad && (
<p className="text-[11px] font-mono text-slate-500 truncate" title={ort.pfad}>
{ort.pfad}
</p>
)}
</div>
))}
{!betrieb.kann.container_pfade
&& (systemInfo?.plaetze || []).length > 1
&& systemInfo?.plaetze?.every(o => o.gleiches_laufwerk) && (
/* Ehrlich sagen, dass die Zahl zweimal dieselbe ist
statt die zweite Zeile wegzulassen. */
<p className="text-[11px] font-mono text-slate-500 mb-1">
Beide auf demselben Laufwerk der Platz wird geteilt.
</p>
)}
<div className={`flex items-center justify-between text-xs font-mono mb-1 ${
betrieb.kann.container_pfade ? '' : 'hidden'}`}>
<span className="text-slate-400 flex items-center gap-1.5">
<HardDrive size={14} className="text-amber-400" />
Container-Platte:
</span>
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
@@ -763,7 +852,16 @@ export default function Dashboard() {
</div>
{/* Die eingehängten Freigaben. Sie sind der Ort, an dem die
Rohdaten liegen und bei externem Encoden liegen MÜSSEN. */}
Rohdaten liegen und bei externem Encoden liegen MÜSSEN.
NUR im Container. Unter Windows hängt Rippy nichts ein:
Dort gibt man einen Ordner oder einen UNC-Pfad an, fertig.
Der Satz Ohne Freigabe kann ein externer Encoder nichts
tun er sieht die Container-Platte nicht" stand am
28.08.2026 im Windows-Fenster und ergab dort keinen Sinn:
Es gibt weder einen externen Encoder noch eine
Container-Platte. */}
{betrieb.kann.freigaben_einhaengen && (
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
<span className="text-slate-400 flex items-center gap-1.5">
@@ -797,6 +895,7 @@ export default function Dashboard() {
</div>
)}
</div>
)}
</div>
</CardContent>
</Card>
+15 -4
View File
@@ -5,6 +5,7 @@ import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { useStrom } from '../lib/useEventStream'
interface LogEntry {
id: string
@@ -21,6 +22,7 @@ export default function LogsPage() {
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const { toast } = useToast()
const strom = useStrom()
const refreshLogs = async (manuell = false) => {
if (manuell) setRefreshing(true)
@@ -37,11 +39,20 @@ export default function LogsPage() {
}
}
// Einmal die volle Historie holen (200 Zeilen — der Strom liefert im
// Snapshot nur die letzten 50), danach kommt alles Neue von selbst.
// Der Aktualisieren-Knopf bleibt: Er holt die Historie erneut.
useEffect(() => { refreshLogs() }, [])
useEffect(() => {
refreshLogs()
const interval = setInterval(refreshLogs, 10000)
return () => clearInterval(interval)
}, [])
if (!strom.logs.length) return
setLogs((alt) => {
const bekannt = new Set(alt.map((l) => String(l.id)))
const neue = strom.logs.filter((l) => !bekannt.has(String(l.id)))
return neue.length ? ([...neue, ...alt] as LogEntry[]) : alt
})
setLoading(false)
}, [strom.logs])
const getLevelColor = (level: string) => {
switch (level) {
+195 -16
View File
@@ -4,10 +4,12 @@ import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import StorageMounts from '../components/StorageMounts'
import WorkerVerwaltung from '../components/WorkerVerwaltung'
import { useBetrieb } from '../lib/useBetrieb'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input'
import { OrdnerWaehler } from '../components/OrdnerWaehler'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design'
import { PRESET_KEINE, schwacheEncoderCpu, simdWarnung } from '../lib/encoder'
@@ -48,7 +50,10 @@ const defaultSettings: SettingsState = {
tmdbApiKey: '',
tvdbApiKey: '',
omdbApiKey: '',
outputDir: '/app/media',
// Kein fester Container-Pfad: Auf Windows gibt es kein /app/media. Der
// echte Vorgabewert kommt aus GET /betrieb (ablage_vorgabe) und wird
// beim Laden gesetzt.
outputDir: '',
movieDir: 'movies',
seriesDir: 'series',
musicDir: 'music',
@@ -187,6 +192,7 @@ function zeitLesbar(iso: string): string {
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const betrieb = useBetrieb()
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
@@ -208,6 +214,9 @@ export default function SettingsPage() {
// Ablageziele unter /app/media inkl. eingehängter Freigaben — speist die
// Auswahl des Arbeitsverzeichnisses (vorher musste man den Pfad tippen).
const [ziele, setZiele] = useState<StorageZiel[]>([])
// Beta-Key: läuft gerade ein Abruf, und was kam zuletzt dabei heraus?
const [keyHolt, setKeyHolt] = useState(false)
const [keyMeldung, setKeyMeldung] = useState('')
const [keystore, setKeystore] = useState<KeystoreStatus | null>(null)
const [keystoreBusy, setKeystoreBusy] = useState(false)
const keystoreInput = useRef<HTMLInputElement>(null)
@@ -248,6 +257,43 @@ export default function SettingsPage() {
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => setWorkers([]))
}
/*
* Den Beta-Key JETZT holen, statt auf die tägliche Schleife zu warten.
*
* Der geholte Key wird nicht angezeigt er wandert in die Einstellungen,
* und die werden hier ohnehin neu geladen. Zwei Wege zu demselben Wert sind
* zwei Wege, ihn zu verlieren.
*/
const keyJetztHolen = async () => {
setKeyHolt(true)
setKeyMeldung('')
try {
const r = await api.post('/system/makemkv-key/holen')
const antwort = await api.get('/settings')
setSettings({ ...defaultSettings, ...antwort.data })
if (r.data?.angenommen === false) {
// Geholt ist nicht angenommen. Am 29.08.2026 lehnte MakeMKV 1.18.4
// den aktuellen Key ab („Programmversion zu alt") — ohne diesen
// Hinweis stünde hier ein Erfolg, und der nächste Rip scheiterte
// trotzdem.
setKeyMeldung('Fehlgeschlagen: ' + (r.data.grund || 'MakeMKV nimmt den Key nicht an.'))
toast('error', r.data.grund || 'MakeMKV nimmt den Key nicht an.')
} else {
setKeyMeldung(r.data?.geaendert
? `Neuer Key geholt und angenommen (endet auf …${r.data.endet_auf}).`
: `Schon aktuell (endet auf …${r.data?.endet_auf || '?'}).`)
toast('success', 'MakeMKV-Beta-Key aus dem Forum geholt.')
}
} catch (e: any) {
const grund = e?.response?.data?.detail
|| 'Das Forum ist nicht erreichbar.'
setKeyMeldung('Fehlgeschlagen: ' + grund)
toast('error', grund)
} finally {
setKeyHolt(false)
}
}
const keydbHochladen = async (datei: File) => {
setKeydbBusy(true)
try {
@@ -485,7 +531,12 @@ export default function SettingsPage() {
const istFreigabe = (pfad: string) =>
ziele.some(z => z.path === pfad && z.is_mount)
const ablageWarnung = (() => {
const ablage = settings.outputDir || '/app/media'
// ⚠️ Die ganze Warnung dreht sich um einen Encoder auf einem ANDEREN
// Rechner, der die Container-Platte nicht sieht. Ohne externe Worker gibt
// es weder den einen noch die andere — der Satz stand am 28.08.2026
// trotzdem im Windows-Fenster und riet dort zu etwas, das es nicht gibt.
if (!betrieb.kann.externe_worker) return ''
const ablage = settings.outputDir || betrieb.ablage_vorgabe
const arbeit = settings.workDir
if (!arbeit) {
return 'Das Arbeitsverzeichnis steht auf der Container-Platte. Ein Encoder auf '
@@ -505,16 +556,45 @@ export default function SettingsPage() {
return ''
})()
/*
* Die Reiter haengen am BETRIEB.
*
* Commander-Befund 28.08.2026: Das gilt fuer die ganze standalone version
* fuer Windows, auch fuer die settings und die Anleitung usw."
*
* Ein Reiter Worker" fuehrt in einer Standalone-Installation zu einer
* Maske, die Rechner in einem Netz verwaltet, das es nicht gibt. Ein Reiter
* Speicherziele" bietet dort das Einhaengen von Freigaben an etwas, das
* unter Windows niemand braucht (dort gibt man einen Ordner oder UNC-Pfad
* an). Beides ist keine kosmetische Frage: Wer darauf klickt, bekommt
* Bedienelemente, die ins Leere fuehren.
*/
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu },
...(betrieb.kann.externe_worker
? [{ id: 'worker' as SettingsTab, label: 'Worker', icon: Cpu }]
: []),
// Speicherziele bleibt IMMER. Hier steht die Ablage — „wo will ich das
// hinspeichern" war die ausdrueckliche Frage des Commanders. Nur das
// EINHAENGEN von Freigaben faellt weg, wenn der Betrieb es nicht kann;
// das steckt weiter unten in einer eigenen Bedingung.
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench },
]
/*
* Ein Reiter, den es nicht mehr gibt, darf nicht ausgewaehlt bleiben.
*
* Der Betrieb kommt ERST NACH dem ersten Rendern an (ein Abruf). Wer in der
* Zwischenzeit auf Worker" steht oder wer den Reiter beim Neuladen aus
* dem Zustand mitbringt saehe sonst eine leere Seite ohne jeden Hinweis.
*/
const sichtbareReiter = tabs.map(t => t.id)
const aktiverReiter = sichtbareReiter.includes(activeTab) ? activeTab : 'ripping'
return (
<div className="max-w-6xl mx-auto space-y-6">
<PageHeader
@@ -533,7 +613,7 @@ export default function SettingsPage() {
{/* Navigation Tabs */}
<div className="flex border-b border-slate-800 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden bg-[#0c101d]">
{tabs.map((tab) => {
const isActive = activeTab === tab.id
const isActive = aktiverReiter === tab.id
return (
<button
key={tab.id}
@@ -554,7 +634,7 @@ export default function SettingsPage() {
{/* Tab Content */}
<CardContent className="p-6 space-y-8">
{/* Ripping Settings */}
{activeTab === 'ripping' && (
{aktiverReiter === 'ripping' && (
<section className="space-y-6">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
@@ -697,7 +777,7 @@ export default function SettingsPage() {
)}
{/* Verarbeitung */}
{activeTab === 'verarbeitung' && (
{aktiverReiter === 'verarbeitung' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
@@ -901,12 +981,41 @@ export default function SettingsPage() {
/storage-targets dieselbe Liste wie bei den Speicherzielen,
inklusive freiem Platz.
*/}
{!betrieb.kann.container_pfade ? (
/*
* Ein echtes Pfadfeld statt einer Auswahl dieselbe
* Entscheidung wie bei der Ablage darüber, und aus demselben
* Grund.
*
* Commander am 28.08.2026: Wäre es möglich das
* Arbeitsverzeichnis zu ändern? momentan geht das nicht."
*
* Es ging nicht, weil `ziele` auf Windows LEER war: Die Liste
* kommt aus /storage-targets, und das las `/app/media` ein
* Ordner, den es dort nicht gibt. Übrig blieb eine Auswahl mit
* genau einem Eintrag, und der hieß Container-Platte".
*
* Die Liste ist inzwischen repariert (sie bringt jetzt die
* Laufwerke mit freiem Platz). Aber die richtige Antwort auf
* wohin mit 100 GB" ist auf Windows oft ein Ort, den keine
* Liste kennt D:\Rippy-Arbeit oder eine UNC-Freigabe.
* Deshalb hier ein Feld, in das man ihn schreiben kann.
*/
<OrdnerWaehler
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
placeholder={betrieb.arbeits_vorgabe}
onChange={(pfad) => handleChange('workDir', pfad)}
/>
) : (
<Select
label="Arbeitsverzeichnis für Roh-Rips (Standard)"
value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)}
>
<option value="">Container-Platte (Standard) klein, nur für DVD/Blu-ray</option>
<option value="">
Container-Platte (Standard) klein, nur für DVD/Blu-ray
</option>
{ziele.map(z => (
<option key={z.path} value={z.path}>
{z.name}{z.is_mount ? ' (Netzwerk-Freigabe)' : ''}
@@ -914,13 +1023,35 @@ export default function SettingsPage() {
</option>
))}
</Select>
)}
{!betrieb.kann.container_pfade && !!ziele.length && (
/* Die Laufwerke als Ein-Klick-Wahl. Wer den Platz sieht,
wählt anders genau daran ist am 25.07. eine Platte
vollgelaufen. */
<div className="flex flex-wrap gap-1.5">
{ziele.map(z => (
<button
key={z.path}
onClick={() => handleChange('workDir', z.path)}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${
settings.workDir === z.path
? 'border-amber-500 bg-amber-500/10 text-amber-700 dark:text-amber-300'
: 'border-slate-200 dark:border-slate-700 text-slate-500 dark:text-slate-400 hover:border-slate-400'
}`}
>
{z.name}{z.free_gb != null ? `${z.free_gb} GB frei` : ''}
</button>
))}
</div>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
<strong className="text-slate-700 dark:text-slate-300">Der Standard</strong> beim
Rippen starten" kannst du für jede Disc etwas anderes wählen. <strong className="text-slate-700 dark:text-slate-300">
Läuft die Vollautomatik</strong>, fragt dich niemand: dann gilt genau dieser Wert.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß und passen selten auf die
Container-Platte. Am besten dieselbe Freigabe wie das Ziel dann muss Rippy die
Rohdatei am Ende nur umhängen statt sie zu kopieren.
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
{betrieb.kann.container_pfade
? ' Auf die Container-Platte passen die selten. Am besten dieselbe Freigabe wie das Ziel — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie zu kopieren.'
: ' Am besten dasselbe Laufwerk wie die Ablage — dann muss Rippy die Rohdatei am Ende nur umhängen statt sie über die Platte zu kopieren.'}
{systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null}
@@ -930,7 +1061,7 @@ export default function SettingsPage() {
)}
{/* Worker */}
{activeTab === 'worker' && (
{aktiverReiter === 'worker' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
@@ -941,7 +1072,7 @@ export default function SettingsPage() {
)}
{/* Speicherziele */}
{activeTab === 'speicherziele' && (
{aktiverReiter === 'speicherziele' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" />
@@ -961,6 +1092,19 @@ export default function SettingsPage() {
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={16} className="text-amber-500" /> Ablage wohin die fertigen Filme kommen
</h3>
{!betrieb.kann.container_pfade ? (
/* Kein Auswahlfeld, sondern ein echtes Pfadfeld: Auf einem
Windows-PC gibt es keine Liste von Container-Zielen, aus
der man waehlen koennte es gibt Laufwerke, Ordner und
UNC-Pfade. Eine Auswahl mit einem Eintrag waere eine
Bedienung, die nichts bedient. */
<OrdnerWaehler
label="Ordner"
value={settings.outputDir}
placeholder={betrieb.ablage_vorgabe}
onChange={(pfad) => handleChange('outputDir', pfad)}
/>
) : (
<Select
label="Ablage"
value={settings.outputDir}
@@ -974,6 +1118,7 @@ export default function SettingsPage() {
</option>
))}
</Select>
)}
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für die Schnellwahl im Rip-Dialog <strong className="text-slate-700 dark:text-slate-300">und
für die Vollautomatik</strong> dort fragt niemand nach, also entscheidet dieser Wert.
@@ -1005,12 +1150,15 @@ export default function SettingsPage() {
</div>
</div>
<StorageMounts />
{/* Freigaben EINHAENGEN kann nur der Container-Betrieb.
Unter Windows gibt man einen Ordner oder einen UNC-Pfad an
eine Maske zum Einhaengen fuehrte dort ins Leere. */}
{betrieb.kann.freigaben_einhaengen && <StorageMounts />}
</section>
)}
{/* APIs */}
{activeTab === 'apis' && (
{aktiverReiter === 'apis' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" />
@@ -1070,7 +1218,7 @@ export default function SettingsPage() {
)}
{/* Benachrichtigungen */}
{activeTab === 'benachrichtigungen' && (
{aktiverReiter === 'benachrichtigungen' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" />
@@ -1120,7 +1268,7 @@ export default function SettingsPage() {
)}
{/* System */}
{activeTab === 'system' && (
{aktiverReiter === 'system' && (
<section className="space-y-6">
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" />
@@ -1188,6 +1336,20 @@ export default function SettingsPage() {
</p>
</div>
{/*
Der Beta-Key holt sich selbst man sah es nur nirgends.
Commander am 28.08.2026: Bezüglich des MKV Beta Keys der
könnte theoretisch auch automatisch ausgelesen werden, ich
glaube das web rippy kann das." Er hatte recht:
`makemkv_key.refresh_loop()` läuft täglich mit, seit es die
Datei gibt (gemessen: Forum antwortet in 3,3 s, Key mit 62
Zeichen). Hier stand aber nur ein Passwortfeld ob der Wert
darin von Hand kam oder von selbst, war nicht zu erkennen.
Eine Automatik, die man nicht sehen kann, ist für den
Benutzer keine.
*/}
<div className="space-y-2">
<Input
type="password"
label="MakeMKV Beta-Key"
@@ -1195,6 +1357,23 @@ export default function SettingsPage() {
onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
disabled={keyHolt}
onClick={keyJetztHolen}
>
{keyHolt ? 'wird geholt …' : 'Jetzt aus dem Forum holen'}
</Button>
<span className={`text-xs ${keyMeldung.startsWith('Fehlgeschlagen')
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'}`}>
{keyMeldung || 'Rippy holt ihn ohnehin täglich selbst — '
+ 'der Key wechselt etwa monatlich.'}
</span>
</div>
</div>
{/*
Schlüsselspeicher der Hauptweg für 4K-UHD (Befund 25.07.2026,
+6
View File
@@ -147,6 +147,12 @@ COPY docker/worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY docker/worker/ .
# Gemeinsamer Kern (Etappe V2-0): liegt im Repo unter src/rippy, im Image
# neben den Worker-Modulen. Celery startet mit /app als Arbeitsverzeichnis,
# damit findet "import rippy" das Paket ohne PYTHONPATH.
COPY src/rippy ./rippy
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
File diff suppressed because it is too large Load Diff
+81 -18
View File
@@ -8,10 +8,49 @@ Remote-GPU-Worker meldet sich hier genauso wie der eingebaute CPU-Worker.
import os
import platform
import re
import shutil
import subprocess
from 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
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:
"""Pfad zu HandBrakeCLI — "" wenn es nicht da ist.
Bis V2-4 stand hier `shutil.which("HandBrakeCLI")`. Das findet unter
Windows nichts, auch wenn HandBrake installiert ist (Program Files statt
PATH). Die Folge waere gewesen: Rippy meldet "keine Encoder gefunden" auf
einem Rechner, auf dem alles da ist und die Encoder-Auswahl im UI bliebe
leer, ohne dass jemand den Grund saehe.
"""
return werkzeuge.finden("handbrake")
HB_ENCODER_KOPF = re.compile(r"^-e,\s*--encoder\b")
@@ -148,12 +187,12 @@ def parse_preset_liste(text: str) -> list:
def hole_handbrake_presets() -> list:
"""`HandBrakeCLI --preset-list` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"):
if not _hb():
return []
try:
aus = subprocess.run(
["HandBrakeCLI", "--preset-list"],
capture_output=True, text=True, timeout=30,
[_hb(), "--preset-list"],
capture_output=True, timeout=30, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
@@ -262,12 +301,12 @@ def cpu_merkmale() -> tuple:
def hole_handbrake_hilfe() -> str:
"""`HandBrakeCLI --help` einmal abrufen (leer, wenn nicht installiert)."""
if not shutil.which("HandBrakeCLI"):
if not _hb():
return ""
try:
aus = subprocess.run(
["HandBrakeCLI", "--help"], capture_output=True, text=True, timeout=30,
creationflags=OHNE_FENSTER
[_hb(), "--help"], capture_output=True, timeout=30,
creationflags=OHNE_FENSTER, **HB_LESEN
)
return (aus.stdout or "") + (aus.stderr or "")
except (OSError, subprocess.TimeoutExpired):
@@ -330,8 +369,18 @@ def werkzeug_versionen() -> dict:
# Container-Pfade (/app/media, /app/temp) nur über eine Freigabe plus
# RIPPY_PATH_MAP erreicht. Das UI kann damit VOR dem Rip warnen, statt
# 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
# grundsätzlich nicht komprimieren.
"pfad_map": os.getenv("RIPPY_PATH_MAP", ""),
@@ -349,13 +398,13 @@ def werkzeug_versionen() -> dict:
presets = hole_handbrake_presets()
if presets:
info["presets"] = presets
if shutil.which("makemkvcon"):
if werkzeuge.finden("makemkv"):
info["makemkv"] = os.getenv("MAKEMKV_VERSION") or "installiert"
if shutil.which("HandBrakeCLI"):
if _hb():
try:
aus = subprocess.run(
["HandBrakeCLI", "--version"],
capture_output=True, text=True, timeout=15,
[_hb(), "--version"],
capture_output=True, timeout=15, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
@@ -364,8 +413,8 @@ def werkzeug_versionen() -> dict:
info["handbrake"] = "installiert"
# Woher kommt der MakeMKV-Key? UI-Setting schlägt Env — ehrlich anzeigen.
try:
import db
ui_key = (db.get_settings().get("makemkvAppKey") or "").strip()
from rippy import store as db
ui_key = (db.get_settings(bei_fehler_leer=True).get("makemkvAppKey") or "").strip()
except Exception:
ui_key = ""
if ui_key:
@@ -385,11 +434,25 @@ def werkzeug_versionen() -> dict:
# Ohne diese Prüfung trüge er im UI dauerhaft die Warnung "keine
# KEYDB.cfg", obwohl ihn das gar nichts angeht.
try:
import makemkv_daten
from rippy.rip import makemkv_daten
daten_dir = makemkv_daten.DATEN_DIR
except Exception:
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:
info["keydb"] = "ja" if makemkv_daten.keydb_status().get("vorhanden") else "nein"
except Exception:
+1 -1
View File
@@ -32,7 +32,7 @@ celery_app.conf.update(
# Import auf Modulebene: im worker_ready-Signal ist /app nicht mehr
# zuverlässig im sys.path (ModuleNotFoundError 'caps', Deploy 23.07.).
import caps # noqa: E402
import db # noqa: E402
from rippy import store as db # noqa: E402
import zombies # noqa: E402
-261
View File
@@ -1,261 +0,0 @@
"""Job- und Log-Persistenz in PostgreSQL (KONZEPT: Postgres für Job-Logs).
Die Tabellendefinition existiert bewusst identisch in API und Worker
(docker/api/db.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer die Struktur ändert, ändert BEIDE Dateien. create_all ist idempotent.
"""
import os
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
MetaData,
String,
Table,
Text,
create_engine,
)
DATABASE_URL = os.getenv(
"DATABASE_URL", "postgresql://rippy:rippy@localhost:5432/rippy"
)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
metadata = MetaData()
jobs = Table(
"jobs",
metadata,
Column("id", String(36), primary_key=True),
Column("disc_type", String(16)),
Column("device", String(64)),
Column("title", String(255)),
Column("status", String(16), nullable=False, server_default="pending"),
Column("progress", Integer, nullable=False, server_default="0"),
Column("output_path", Text),
Column("target_dir", String(255)),
Column("error", Text),
Column("meta", Text), # Disc-Metadaten (JSON) — Quelle für Ordnernamen + NFO
Column("created_at", DateTime(timezone=True)),
Column("finished_at", DateTime(timezone=True)),
)
logs = Table(
"logs",
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("ts", DateTime(timezone=True)),
Column("level", String(16)),
Column("source", String(32)),
Column("message", Text),
)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def init_db() -> None:
"""Legt fehlende Tabellen an (idempotent) und zieht Mini-Migrationen nach.
Die ALTERs stehen identisch in docker/api/db.py wer zuerst startet,
migriert; der andere findet die Spalten dann bereits vor.
"""
metadata.create_all(engine)
with engine.begin() as conn:
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_dir VARCHAR(255)"
)
conn.exec_driver_sql(
"ALTER TABLE jobs ADD COLUMN IF NOT EXISTS meta TEXT"
)
conn.exec_driver_sql(
"ALTER TABLE workers ADD COLUMN IF NOT EXISTS info TEXT"
)
settings_table = Table(
"settings",
metadata,
Column("key", String(64), primary_key=True),
Column("value", Text),
)
workers = Table(
"workers",
metadata,
Column("name", String(128), primary_key=True),
Column("encoders", Text),
Column("info", Text), # Werkzeug-Versionen (JSON: makemkv/handbrake/key-Quelle)
Column("last_seen", DateTime(timezone=True)),
)
def save_worker(name: str, encoder_liste: list, info: dict = None) -> None:
"""Worker meldet Name + Encoder-Fähigkeiten + Werkzeug-Versionen (Upsert)."""
import json
payload = json.dumps(encoder_liste)
info_payload = json.dumps(info or {})
with engine.begin() as conn:
vorhanden = conn.execute(
workers.select().where(workers.c.name == name)
).first()
if vorhanden:
conn.execute(
workers.update().where(workers.c.name == name).values(
encoders=payload, info=info_payload, last_seen=utcnow()
)
)
else:
conn.execute(
workers.insert().values(
name=name, encoders=payload, info=info_payload, last_seen=utcnow()
)
)
def get_settings(key: str = "ui") -> dict:
"""UI-Einstellungen lesen (der Worker respektiert Transcode-Optionen)."""
import json
from sqlalchemy import select
try:
with engine.connect() as conn:
zeile = conn.execute(
select(settings_table.c.value).where(settings_table.c.key == key)
).first()
if zeile and zeile[0]:
return json.loads(zeile[0])
except Exception:
pass
return {}
def get_job(job_id: str) -> dict:
"""Ganze Job-Zeile — der Worker braucht Titel + Metadaten für die
Ordner-Benennung und die Media-Server-Aufbereitung (NFO/Poster)."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs).where(jobs.c.id == job_id)
).mappings().first()
return dict(zeile) if zeile else None
def save_settings(werte: dict, key: str = "ui") -> None:
"""Upsert in die settings-Tabelle — der Worker legt hier z. B. die
Track-Scan-Ergebnisse ab (key 'tracks:<device>'), die API liest sie."""
import json
from sqlalchemy import select
payload = json.dumps(werte)
with engine.begin() as conn:
vorhanden = conn.execute(
select(settings_table.c.key).where(settings_table.c.key == key)
).first()
if vorhanden:
conn.execute(
settings_table.update()
.where(settings_table.c.key == key)
.values(value=payload)
)
else:
conn.execute(settings_table.insert().values(key=key, value=payload))
def get_job_status(job_id: str) -> str:
"""Nur der Status — der Worker prüft damit kooperative Abbruch-Anfragen."""
from sqlalchemy import select
with engine.connect() as conn:
zeile = conn.execute(
select(jobs.c.status).where(jobs.c.id == job_id)
).first()
return zeile[0] if zeile else ""
def list_jobs_mit_status(stati) -> list:
"""Alle Jobs in einem der genannten Zustände (id/status/title/created_at).
Basis der Zombie-Erkennung: Jobs, die behaupten, es arbeite gerade jemand
an ihnen. Bewusst NUR diese schmale Auswahl statt der ganzen Zeile die
Erkennung braucht nichts weiter.
"""
from sqlalchemy import select
with engine.connect() as conn:
zeilen = conn.execute(
select(jobs.c.id, jobs.c.status, jobs.c.title, jobs.c.created_at)
.where(jobs.c.status.in_(list(stati)))
).mappings().all()
return [dict(z) for z in zeilen]
def zaehle_online_worker(sekunden: int = 120) -> int:
"""Wie viele Worker gelten laut Herzschlag gerade als online?
Die Zombie-Erkennung vergleicht das mit der Zahl der Celery-Antworten:
melden sich weniger Worker als bekannt sind, ist die Auskunft
unvollständig dann wird NICHTS als Leiche gewertet.
"""
from datetime import timedelta
from sqlalchemy import func, select
grenze = utcnow() - timedelta(seconds=sekunden)
with engine.connect() as conn:
anzahl = conn.execute(
select(func.count()).select_from(workers).where(workers.c.last_seen >= grenze)
).scalar()
return int(anzahl or 0)
def meta_merken(job_id: str, **felder) -> None:
"""Ergänzt EINZELNE Schlüssel in den Job-Metadaten. Wirft nie.
`update_job(meta=...)` würde die Spalte ersetzen Poster, Jahr, Titel-Wahl
und Sprachwunsch dieses Rips wären damit fort. Also lesen, mischen,
schreiben.
Fehler werden geschluckt: Diese Funktion vermerkt nur, in welcher Phase ein
Job steht (api/phasen.py). Ein Rip darf daran nicht scheitern im
schlimmsten Fall fehlt die Marke und der Neu"-Knopf fragt nach.
"""
import json
try:
zeile = get_job(job_id)
if not zeile:
return
try:
vorher = json.loads(zeile.get("meta") or "{}")
except (ValueError, TypeError):
vorher = {}
if not isinstance(vorher, dict):
vorher = {}
vorher.update(felder)
update_job(job_id, meta=json.dumps(vorher))
except Exception as e:
try:
add_log("warning", "worker", f"Job {job_id}: Metadaten-Vermerk fehlgeschlagen: {e}")
except Exception:
pass
def update_job(job_id: str, **fields) -> None:
with engine.begin() as conn:
conn.execute(jobs.update().where(jobs.c.id == job_id).values(**fields))
def add_log(level: str, source: str, message: str) -> None:
with engine.begin() as conn:
conn.execute(
logs.insert().values(ts=utcnow(), level=level, source=source, message=message)
)
-97
View File
@@ -1,97 +0,0 @@
"""Disc-Erkennung über Kernel-ioctls — ohne `file`, ohne udev.
Warum neu (Review 23.07.): Die alte Erkennung rief `file -L /dev/sr0` auf.
`file` liest ohne `-s` aber NIE den Inhalt eines Block-Devices die Ausgabe ist
immer nur "block special", die Erkennung lieferte also strukturell IMMER
"unknown". Der alte Test hatte sich die `file`-Ausgabe passend gemockt und
bewies damit nichts.
Die ioctl-Konstanten stammen aus der Kernel-UAPI (include/uapi/linux/cdrom.h
bzw. linux/fs.h für BLKGETSIZE64) und sind seit Jahrzehnten stabil.
"""
import os
import struct
from fcntl import ioctl
# include/uapi/linux/cdrom.h
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def _open_nonblock(device_path: str) -> int:
"""O_NONBLOCK ist Pflicht: ohne blockiert open() bis eine Disc eingelegt ist."""
return os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
def drive_status(device_path: str) -> int:
"""CDROM_DRIVE_STATUS: 1=keine Disc, 2=Schublade offen, 3=nicht bereit, 4=Disc ok."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DRIVE_STATUS, 0)
finally:
os.close(fd)
def disc_status(device_path: str) -> int:
"""CDROM_DISC_STATUS: 100=Audio, 101-104=Daten, 105=Mixed."""
fd = _open_nonblock(device_path)
try:
return ioctl(fd, CDROM_DISC_STATUS, 0)
finally:
os.close(fd)
def disc_size_bytes(device_path: str) -> int:
"""Größe des eingelegten Mediums in Bytes (BLKGETSIZE64)."""
fd = _open_nonblock(device_path)
try:
buf = bytearray(8)
ioctl(fd, BLKGETSIZE64, buf)
return struct.unpack("Q", bytes(buf))[0]
finally:
os.close(fd)
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
def detect_disc_type(device_path: str) -> str:
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
try:
if drive_status(device_path) != CDS_DISC_OK:
return "no_disc"
return classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
return "unknown"
+1 -1
View File
@@ -188,7 +188,7 @@ def deinstallieren() -> None:
def _bruecke_bauen():
try:
import db
from rippy import store as db
import logbruecke
db.init_db()
-66
View File
@@ -1,66 +0,0 @@
"""Webhook-Benachrichtigungen bei Job-Ende (Discord, Slack, ntfy, generisch).
Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D):
- Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook
- Slack: POST JSON {"text": "..."} api.slack.com/messaging/webhooks
- ntfy: POST Roh-Text an https://ntfy.sh/<topic>, Titel via ?title=
docs.ntfy.sh/publish
- Generisch: POST JSON {"title", "message", "level"} für eigene Empfänger
(Home Assistant, n8n, eigene Skripte).
"""
import requests
TIMEOUT_SEKUNDEN = 10
def erkenne_webhook_typ(url: str) -> str:
"""Erkennt den Dienst an der URL — der Nutzer muss nichts konfigurieren."""
u = url.lower()
if "discord.com/api/webhooks" in u or "discordapp.com/api/webhooks" in u:
return "discord"
if "hooks.slack.com" in u:
return "slack"
if "ntfy" in u:
return "ntfy"
return "generisch"
def baue_payload(url: str, titel: str, text: str, level: str = "info"):
"""Pure Funktion (testbar): (typ, json_payload) — ntfy sendet Roh-Text."""
typ = erkenne_webhook_typ(url)
if typ == "discord":
return typ, {"content": f"**{titel}**\n{text}"}
if typ == "slack":
return typ, {"text": f"*{titel}*\n{text}"}
if typ == "ntfy":
return typ, None
return typ, {"title": titel, "message": text, "level": level}
def sende(url: str, titel: str, text: str, level: str = "info") -> None:
"""Schickt die Nachricht; wirft RuntimeError mit Klartext bei Fehlern."""
typ, payload = baue_payload(url, titel, text, level)
try:
if typ == "ntfy":
antwort = requests.post(
url, data=text.encode("utf-8"),
params={"title": titel}, timeout=TIMEOUT_SEKUNDEN,
)
else:
antwort = requests.post(url, json=payload, timeout=TIMEOUT_SEKUNDEN)
except requests.RequestException as e:
raise RuntimeError(f"Webhook nicht erreichbar: {e}") from e
if antwort.status_code >= 300:
raise RuntimeError(
f"Webhook antwortete mit HTTP {antwort.status_code}: "
f"{(antwort.text or '')[:200]}"
)
+348 -129
View File
@@ -17,7 +17,54 @@ import shutil
import subprocess
import tempfile
from winlauf import OHNE_FENSTER
# Auswurf und ioctl-Konstanten leben seit V2-1 im gemeinsamen Treiber
# (rippy/drives/linux.py). Vorher stand derselbe Ablauf ZWEIMAL im Repo —
# hier und in docker/api/devices.py, mit unterschiedlichen Vertraegen.
#
# Die Namen werden hier weiter angeboten, weil tasks.py und die Tests sie so
# kennen; `wirf_disc_aus` ist der Worker-Vertrag (gibt False zurueck, wirft nie).
from rippy.drives.linux import ( # noqa: F401
AUSWURF_WARTEN_SEKUNDEN,
CDROM_DRIVE_STATUS,
CDROM_LOCKDOOR,
CDROMCLOSETRAY,
CDROMEJECT,
CDS_DISC_OK,
CDS_DRIVE_NOT_READY,
CDS_NO_DISC,
CDS_TRAY_OPEN,
_auswurf_geglueckt,
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.tools import katalog as werkzeuge
_NACKTER_NAME = {"makemkv": "makemkvcon", "handbrake": "HandBrakeCLI"}
def werkzeug(name: str) -> str:
"""Der Pfad zu einem Werkzeug — oder sein blosser Name als Rueckfall.
## Warum hier nicht mehr `shutil.which` steht
Bis V2-4 stand ueberall in dieser Datei der nackte Programmname
("makemkvcon", "HandBrakeCLI"). Im Container stimmt das dort liegen
beide in /usr/local/bin. **Unter Windows findet es NICHTS**, auch wenn
beide installiert sind: Windows-Programme liegen in Program Files und
stehen nicht im PATH. Am 28.08.2026 auf dem Commander-PC nachgesehen
MakeMKV lag da, und `shutil.which` sah es nicht.
Fuer einen Rippy, der unter Windows eigenstaendig arbeiten soll, ist das
der Unterschied zwischen "laeuft" und "laeuft nicht".
Der Rueckfall auf den blossen Namen ist Absicht: Im Container aendert sich
damit NICHTS subprocess findet das Programm dort ueber den PATH wie
bisher.
"""
return werkzeuge.finden(name) or _NACKTER_NAME[name]
RIP_OUTPUT_DIR = os.getenv("RIP_OUTPUT_DIR", "/app/media")
@@ -27,114 +74,9 @@ class RipAbbruch(Exception):
Nutzer den Job abgebrochen hat (Status 'canceling' in der DB)."""
# include/uapi/linux/cdrom.h — dieselben ioctls wie in api/devices.py
CDROMEJECT = 0x5309
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
CDROM_DRIVE_STATUS = 0x5326
CDROMCLOSETRAY = 0x5319
# Antworten von CDROM_DRIVE_STATUS (cdrom.h)
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
AUSWURF_WARTEN_SEKUNDEN = 5
def _auswurf_geglueckt(status: int) -> bool:
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
Sowohl Schublade offen" als auch „kein Datenträger" zählen: Ein
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
"""
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
def wirf_disc_aus(device_path: str, ioctl_fn=None, oeffnen=None,
schliessen=None, warten=None) -> bool:
"""Wirft die Disc aus und PRÜFT, ob sie draußen ist. Wirft NIE.
## Warum `CDROMEJECT` allein nicht genügt (Befund 26.07.2026, gemessen)
Der Commander meldete: Der Button gibt es in den Settings, aber es passiert
nicht, das Laufwerk geht nicht auf." Am laufenden System nachgestellt:
wirf_disc_aus("/dev/sr0") True
CDROM_DRIVE_STATUS danach 4 (Disc drin)
Das ioctl wird also **angenommen und tut nichts**. Ursache: MakeMKV
verriegelt während des Rips die Laufwerkstür (`CDROM_LOCKDOOR 1`) und
entriegelt sie nicht wieder. Ein verriegeltes Laufwerk quittiert den Auswurf
trotzdem mit Erfolg. Deshalb macht das Werkzeug `eject` immer beides:
erst entriegeln, dann auswerfen. Gegenprobe an derselben Disc:
CDROM_LOCKDOOR 0 + CDROMEJECT Status 2 (SCHUBLADE OFFEN)
## Und deshalb wird das Ergebnis geprüft, nicht geglaubt
Genau diese Sorte Fehler ist zweimal durchgerutscht: In v3.14 stand hier
Auswurf tat nichts, jetzt entscheidet die Einstellung" — die Einstellung
wurde danach wirklich gelesen, nur ausgeworfen wurde weiterhin nicht, und im
Log stand Disc ausgeworfen". Ein Rückgabewert eines ioctls beweist nichts;
gefragt wird jetzt das Laufwerk.
Bewusst hier und nicht in detection.py: das Modul ist ein byteweiser
Zwilling der API-Kopie. `fcntl` gibt es nur unter Linux der native
Windows-Worker lädt ripping.py ebenfalls, rippt dort aber nie.
Die drei Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
"""
if ioctl_fn is None:
try:
from fcntl import ioctl
except ImportError: # Windows — dieser Worker rippt nie
return False
ioctl_fn = ioctl
if warten is None:
import time
warten = time.sleep
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
schliessen = schliessen or os.close
try:
fd = oeffnen(device_path)
except OSError:
return False
try:
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
try:
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass
try:
ioctl_fn(fd, CDROMEJECT, 0)
except OSError:
return False
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
try:
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
return True
except OSError:
return False
warten(1)
return False
finally:
try:
schliessen(fd)
except OSError:
pass
def check_makemkv_installed() -> bool:
"""Prüft, ob makemkvcon installiert ist."""
return shutil.which("makemkvcon") is not None
return bool(werkzeuge.finden("makemkv"))
def check_abcde_installed() -> bool:
@@ -147,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
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:
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
@@ -158,12 +118,12 @@ def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") ->
mkv dev:<pfad> <titel> <ziel> 'all' oder eine Titel-Nummer (Hauptfilm)
"""
return [
"makemkvcon",
werkzeug("makemkv"),
"-r",
"--noscan",
"--progress=-same",
"mkv",
f"dev:{device_path}",
makemkv_quelle(device_path),
str(titel),
output_dir,
]
@@ -318,11 +278,11 @@ def lies_titel_info(device_path: str, timeout: int = 300) -> tuple:
wäre eine Minute Wartezeit für nichts.
"""
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER,
)
ausgabe = ergebnis.stdout or ""
ausgabe = text_von(ergebnis.stdout)
return parse_titel_info(ausgabe), parse_stream_info(ausgabe)
@@ -332,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)."""
gesamt = len(titel_liste)
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):
def anteilig(p, _i=index):
if progress_cb:
@@ -339,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),
log_cb=log_cb)
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
if ergebnis.get("status") == "cancelled":
return ergebnis
if ergebnis.get("status") != "success":
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
ergebnis["lesefehler"] = lesefehler
return ergebnis
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):
@@ -391,13 +357,13 @@ def lies_titel_dauern(device_path: str, timeout: int = 300) -> dict:
"""Fragt die Titel-Laufzeiten der Disc ab (makemkvcon info, Robot-Mode)."""
try:
ergebnis = subprocess.run(
["makemkvcon", "-r", "--noscan", "info", f"dev:{device_path}"],
capture_output=True, text=True, timeout=timeout,
[werkzeug("makemkv"), "-r", "--noscan", "info", makemkv_quelle(device_path)],
capture_output=True, timeout=timeout, # binaer: siehe text_von
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
return {}
return parse_tinfo_dauern(ergebnis.stdout or "")
return parse_tinfo_dauern(text_von(ergebnis.stdout))
def parse_scan_dauer(ausgabe: str) -> int:
@@ -420,8 +386,8 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
return 0
try:
ergebnis = subprocess.run(
["HandBrakeCLI", "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
[werkzeug("handbrake"), "--scan", "-i", pfad],
capture_output=True, timeout=timeout, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
@@ -467,7 +433,7 @@ def get_progress_from_prgv(line: str) -> int:
def check_handbrake_installed() -> bool:
"""Prüft, ob HandBrakeCLI installiert ist."""
return shutil.which("HandBrakeCLI") is not None
return bool(werkzeuge.finden("handbrake"))
DEFAULT_HB_PRESET = "H.265 MKV 1080p30"
@@ -535,6 +501,12 @@ def preset_fuer(disc_type: str, einstellungen: dict) -> str:
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,
preset: str = DEFAULT_HB_PRESET,
audio_sprachen=None, untertitel_sprachen=None) -> list:
@@ -560,20 +532,55 @@ def build_handbrake_cmd(input_path: str, output_path: str,
Sprachen drin und das ist bei einer verlustfreien Ablage richtig.
`--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.
"""
befehl = [
"HandBrakeCLI",
werkzeug("handbrake"),
"--input", input_path,
"--output", output_path,
"--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]
if audio:
befehl += ["--audio-lang-list", ",".join(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]
if untertitel:
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
@@ -638,6 +645,23 @@ def sprachliste(wert) -> list:
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,
progress_cb=None, abbruch_cb=None,
audio_sprachen=None, untertitel_sprachen=None) -> dict:
@@ -661,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
audio_sprachen, untertitel_sprachen),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
creationflags=OHNE_FENSTER,
**HB_LESEN,
)
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
except Exception as e:
@@ -690,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
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:
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
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:
for line in process.stdout:
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
@@ -701,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
# sich die Prozentzahl bewegt (Befund 25.07.2026).
if 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:
falsches_preset = unbekanntes_preset(line)
if not falscher_schalter:
falscher_schalter = unbekannter_schalter(line)
progress = get_progress_from_line(line)
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
@@ -718,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):
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:
return {
"status": "error",
@@ -732,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
}
return {
"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,
}
@@ -770,6 +909,48 @@ def write_abcde_config(output_dir: str) -> str:
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",
log_cb=None) -> dict:
"""Rippt eine DVD/Blu-ray verlustfrei mit makemkvcon; meldet Fortschritt.
@@ -784,6 +965,10 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if not check_makemkv_installed():
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)
try:
@@ -791,17 +976,39 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
build_makemkv_cmd(device_path, output_dir, titel),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
# BINAER lesen und selbst dekodieren (siehe `text_von`):
# `text=True` nahm die Gebietsschema-Kodierung, makemkvcon
# schrieb aber UTF-8 — daraus wurde beim Commander
# „Das Öffnen der Disk schlug fehl".
#
# bufsize=0 statt 1: Zeilenpufferung gibt es im Binaermodus
# nicht (Python warnt und puffert doch). Ungepuffert kommt
# jede Fortschrittszeile sofort an — bei einem Rip, der
# Stunden laeuft, ist das der Unterschied zwischen einem
# Balken und einem eingefrorenen Fenster.
bufsize=0,
creationflags=OHNE_FENSTER,
)
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
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
# (Befund 24.07., Summer-Wars-UHD).
kritische_meldungen = []
# Die Textmuster bleiben als Rueckfall fuer englische Installationen —
# die MELDUNGSNUMMERN sind aber die verlaessliche Quelle. Beim
# Commander meldet MakeMKV deutsch, also traf hier am 29.08.2026 kein
# einziges Muster: Im Fehler stand nur „Das Öffnen der Disk schlug
# fehl", waehrend die Ursache eine Zeile darueber („Der Testzeitraum
# ist abgelaufen", MSG 5051) unter den Tisch fiel.
KRITISCH = (
"volume key is unknown",
"evaluation period has expired",
@@ -809,7 +1016,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
"Too old version",
)
try:
for line in process.stdout:
for rohzeile in process.stdout:
line = text_von(rohzeile)
progress = get_progress_from_prgv(line)
if progress >= 0:
if progress_cb:
@@ -819,7 +1027,12 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if meldung is None:
continue
code, letzte_meldung = meldung
if any(muster in letzte_meldung for muster in KRITISCH):
if LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES:
kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
elif any(muster in letzte_meldung for muster in KRITISCH):
kritische_meldungen.append(letzte_meldung)
if log_cb:
try:
@@ -847,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
"output_dir": output_dir,
"files": mkv_dateien,
"return_code": process.returncode,
"lesefehler": lesefehler,
}
return {
"status": "error",
"lesefehler": lesefehler,
"error": (
f"makemkvcon endete mit Code {process.returncode}"
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
@@ -902,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,
auswerfen: bool = True) -> dict:
"""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():
return {
"status": "error",
+9 -2
View File
@@ -45,7 +45,7 @@ import re
import subprocess
import urllib.request
from winlauf import OHNE_FENSTER
from rippy.platform.winlauf import OHNE_FENSTER
# `DRV:<index>,<zustand>,<flags>,<?>,"<Laufwerk>","<Disc>","<Gerät>"`
DRV_ZEILE = re.compile(r'^DRV:(\d+),(\d+),(\d+),(\d+),"([^"]*)","([^"]*)","([^"]*)"')
@@ -101,7 +101,14 @@ def makemkvcon_pfad() -> str:
for pfad in fest:
if os.path.isfile(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:
+60 -952
View File
File diff suppressed because it is too large Load Diff
+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)) == [
"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"
+10 -1
View File
@@ -7,7 +7,7 @@ erreicht das Ziel nicht", obwohl die Freigabe erreichbar war. Es fehlten nur
zwei noch nie angelegte Ordner.
"""
import tasks
import ablauf as tasks
# --- Der eigentliche Fehler: nur EINE fehlende Ebene war erlaubt -------------
@@ -84,6 +84,11 @@ def _pruefen(monkeypatch, vorhandene, mapping, im_container=False):
return tasks._erreichbarkeit_pruefen(
"/app/media/rippy/job1", "\\\\NAS\\rippy\\job1",
"/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(
"/app/media/rippy/job1", "/app/media/rippy/job1",
"/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 "von keinem Eintrag übersetzt" in meldung
+77 -17
View File
@@ -105,7 +105,7 @@ def test_matche_episoden_ohne_treffer_gibt_none():
def test_pfad_lokal_uebersetzt_fuer_windows_worker():
from tasks import pfad_lokal
from ablauf import pfad_lokal
mapping = "/app/media=Z:\\media;/app/temp=Y:\\temp"
assert pfad_lokal("/app/temp/raw/abc", mapping) == "Y:\\temp\\raw\\abc"
@@ -121,30 +121,39 @@ def test_pfad_lokal_uebersetzt_fuer_windows_worker():
# Praxis scheiterte (st_dev war identisch, os.rename trotzdem EXDEV).
# Die Wurzeln je Betrieb — eingespritzt, damit BEIDE Faelle ueberall pruefbar
# sind. Vorher hingen diese Tests am laufenden Rechner: Unter Windows ist
# `frei` wahr, und die Container-Regeln galten dort nicht mehr (am 29.08.2026
# prompt rot geworden).
CONTAINER = ("/app/media", "/app/temp/raw", False)
NATIV = (r"C:\Users\Tobi\Videos\Rippy", r"C:\Users\Tobi\Videos\Rippy\_arbeit", True)
def test_arbeitsverzeichnis_wahl_des_rips_schlaegt_die_einstellung():
"""Pro Rip wählbar (Commander 25.07.2026), Einstellung bleibt Standard.
Reihenfolge: Wahl dieses Rips -> Setting -> Container-Default. Der
Reihenfolge: Wahl dieses Rips -> Setting -> Vorgabe des Betriebs. Der
Setting-Wert ist genau der, der bei Vollautomatik-Rips greift, weil dort
niemand gefragt wird.
"""
import tasks
import ablauf as tasks
einst = {"workDir": "/app/media/movies"}
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy") == "/app/media/rippy"
assert tasks._arbeitsverzeichnis(einst) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}) == tasks.RAW_DIR
w = CONTAINER
assert tasks._arbeitsverzeichnis(einst, "/app/media/rippy", w) == "/app/media/rippy"
assert tasks._arbeitsverzeichnis(einst, "", w) == "/app/media/movies"
assert tasks._arbeitsverzeichnis({}, "", w) == "/app/temp/raw"
# Ausbruchsversuche und Pfade außerhalb /app/media fallen durch
assert tasks._arbeitsverzeichnis({}, "/etc") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({}, "/etc", w) == "/app/temp/raw"
assert tasks._arbeitsverzeichnis({}, "/app/media/../etc", w) == "/app/temp/raw"
# Leere Wahl fällt sauber auf die Einstellung zurück
assert tasks._arbeitsverzeichnis(einst, " ") == "/app/media/movies"
assert tasks._arbeitsverzeichnis(einst, " ", w) == "/app/media/movies"
def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
"""Befund 25.07.2026: Elf Stellen prüften mit nacktem startswith().
/app/media-boese/x" beginnt mit „/app/media", liegt aber außerhalb."""
import tasks
import ablauf as tasks
assert tasks.unter_wurzel("/app/media", "/app/media") is True
assert tasks.unter_wurzel("/app/media/movies", "/app/media") is True
@@ -160,16 +169,67 @@ def test_unter_wurzel_faellt_nicht_auf_praefix_namen_herein():
def test_zielbasis_lehnt_praefix_ausbruch_ab():
import tasks
"""Im CONTAINER bleibt die Wurzel eine Wurzel — daran ändert die
Windows-Reparatur nichts."""
import ablauf as tasks
assert tasks._zielbasis("/app/media/movies", "bluray") == "/app/media/movies"
w = CONTAINER
assert tasks._zielbasis("/app/media/movies", "bluray", w) == "/app/media/movies"
# Ausbruch per Praefix-Namen fällt auf den Standard zurück
assert tasks._zielbasis("/app/media-boese", "bluray") != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray") != "/etc"
assert tasks._zielbasis("/app/media-boese", "bluray", w) != "/app/media-boese"
assert tasks._zielbasis("/etc", "bluray", w) != "/etc"
def test_arbeitsverzeichnis_lehnt_praefix_ausbruch_ab():
import tasks
import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, "/app/media-boese") == tasks.RAW_DIR
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}) == tasks.RAW_DIR
w = CONTAINER
assert tasks._arbeitsverzeichnis({}, "/app/media-boese", w) == "/app/temp/raw"
assert tasks._arbeitsverzeichnis({"workDir": "/app/mediaX"}, "", w) == "/app/temp/raw"
# ── Nativ: der Befund vom 29.08.2026 ────────────────────────────────────
#
# Beim Nachstellen des leeren Bildschirms lief ein echter Test-Rip durch, und
# die Rohdaten landeten in `F:\app\temp\raw` — einem Ordner namens `app` auf
# dem Laufwerk, von dem Rippy gerade lief. Ursache: `RAW_DIR` ist
# `/app/temp/raw`, und die Pruefung `unter_wurzel(wahl, "/app/media")` verwarf
# sogar eine AUSDRUECKLICHE Wahl.
#
# Damit kam der Arbeitsordner, den der Commander am 28.08.2026 bestellt hat,
# unter Windows nie an: Der Dialog zeigte ihn, das Setzen ging, der Worker
# ignorierte ihn — ohne ein Wort.
def test_nativ_zaehlt_die_wahl_des_nutzers():
"""DER Befund. `D:\\Roh` liegt unter keiner Container-Wurzel und wurde
deshalb still verworfen."""
import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, r"D:\Roh", NATIV) == r"D:\Roh"
assert tasks._arbeitsverzeichnis({"workDir": r"E:\Arbeit"}, "", NATIV) == r"E:\Arbeit"
def test_nativ_faellt_auf_den_ort_aus_der_installation_zurueck():
"""Nicht auf `/app/temp/raw` — das wurde unter Windows zu `X:\\app\\temp`."""
import ablauf as tasks
assert tasks._arbeitsverzeichnis({}, "", NATIV) == NATIV[1]
assert "/app/" not in tasks._arbeitsverzeichnis({}, "", NATIV)
def test_nativ_nimmt_auch_eine_freigabe_als_ziel():
"""Sein Ziel ist eine UNC-Freigabe — die liegt unter gar keiner lokalen
Wurzel."""
import ablauf as tasks
unc = r"\\192.168.179.62\rippy\movies"
assert tasks._zielbasis(unc, "bluray", NATIV) == unc
def test_nativ_ohne_wahl_landet_unter_der_eigenen_ablage():
import ablauf as tasks
ziel = tasks._zielbasis("", "bluray", NATIV)
assert ziel.startswith(NATIV[0])
assert "/app/" not in ziel
+1 -1
View File
@@ -13,7 +13,7 @@ import os
import pytest
import tasks
import ablauf as tasks
class FakeDb:
+163 -80
View File
@@ -13,14 +13,22 @@ from ripping import (
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
hb_schluss,
parse_msg,
unbekannter_schalter,
write_abcde_config,
)
def test_makemkv_cmd_vollstaendig():
cmd = build_makemkv_cmd("/dev/sr0", "/app/media/dvd/x")
assert cmd[0] == "makemkvcon"
# Seit V2-4 steht hier der GEFUNDENE Pfad statt des blossen Namens: Unter
# Windows liegt makemkvcon in Program Files und nicht im PATH — mit dem
# nackten Namen faende `subprocess` es dort nie. Im Container bleibt es
# der blosse Name (der Katalog findet nichts, der Rueckfall greift), auf
# dem Entwicklungsrechner ist es der volle Pfad. Beides ist richtig;
# gepruefet wird deshalb, WORAUF der Befehl zeigt.
assert "makemkvcon" in cmd[0].lower()
assert "-r" in cmd # Robot-Mode: maschinenlesbar
assert "--noscan" in cmd # Scan hängt/crasht im Container (23.07.)
assert "--progress=-same" in cmd # Fortschritt im selben Stream
@@ -34,15 +42,65 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
MakeMKV-Rip, nie das Laufwerk (die alte Direkt-am-Gerät-Pipeline war
für Blu-rays prinzipiell funktionsunfähig)."""
cmd = build_handbrake_cmd("/app/temp/raw/x/t00.mkv", "/app/media/bluray/x/t00.mkv")
assert cmd[0] == "HandBrakeCLI"
# Wie beim MakeMKV-Befehl: seit V2-4 steht hier der GEFUNDENE Pfad. Unter
# Windows liegt HandBrakeCLI in Program Files oder in Rippys eigenem
# Werkzeug-Ordner und nicht im PATH — mit dem nackten Namen faende
# `subprocess` es dort nie.
assert "handbrakecli" in cmd[0].lower()
assert cmd[cmd.index("--input") + 1] == "/app/temp/raw/x/t00.mkv"
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
assert "--preset" in cmd
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
assert "--audio-codec" in cmd
assert "--aencoder" 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():
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
@@ -365,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
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
class FakeProcess:
@@ -380,91 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
return 1
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
# --- Auswurf: das ioctl meldet Erfolg und tut nichts (Befund 26.07.2026) -----
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.
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
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
zustand = {"verriegelt": verriegelt, "status": ripping.CDS_DISC_OK,
"aufrufe": []}
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 ioctl_fn(fd, befehl, arg=0):
zustand["aufrufe"].append(befehl)
if befehl == ripping.CDROM_LOCKDOOR:
if not kennt_lockdoor:
raise OSError("ioctl unbekannt")
zustand["verriegelt"] = bool(arg)
def kill(self):
pass
def wait(self):
return 0
if befehl == ripping.CDROMEJECT:
if not zustand["verriegelt"]:
zustand["status"] = ripping.CDS_TRAY_OPEN
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
if befehl == ripping.CDROM_DRIVE_STATUS:
return zustand["status"]
raise OSError("unerwartetes ioctl")
return zustand, ioctl_fn
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
import ripping
zustand, ioctl_fn = _laufwerk(verriegelt=True)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is True
assert zustand["status"] == ripping.CDS_TRAY_OPEN
# Reihenfolge: entriegeln VOR auswerfen
assert zustand["aufrufe"][0] == ripping.CDROM_LOCKDOOR
assert zustand["aufrufe"][1] == ripping.CDROMEJECT
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
import ripping
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
ok = ripping.wirf_disc_aus(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is False
assert zustand["status"] == ripping.CDS_DISC_OK # nie aufgegangen
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
import ripping
assert ripping._auswurf_geglueckt(ripping.CDS_NO_DISC) is True
assert ripping._auswurf_geglueckt(ripping.CDS_TRAY_OPEN) is True
assert ripping._auswurf_geglueckt(ripping.CDS_DISC_OK) is False
assert ripping._auswurf_geglueckt(ripping.CDS_DRIVE_NOT_READY) is False
def test_auswurf_ohne_laufwerk_wirft_nicht():
import ripping
def oeffnen_kaputt(p):
raise OSError("kein Laufwerk")
assert ripping.wirf_disc_aus(
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
schliessen=lambda fd: None, warten=lambda s: None) is False
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) -----------
@@ -593,3 +609,70 @@ def test_handbrake_kommando_mit_sprachauswahl():
assert cmd[cmd.index("--audio-lang-list") + 1] == "deu,jpn"
assert cmd[cmd.index("--subtitle-lang-list") + 1] == "deu"
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]
+5 -1
View File
@@ -221,7 +221,11 @@ def test_arbeitsstati_deckt_ab_was_der_worker_wirklich_schreibt():
import os
import re
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tasks.py")
# Seit V2-4 steht der Ablauf in ablauf.py; tasks.py ist nur noch
# die Celery-Huelle. Dieser Waechter muss dorthin schauen, wo der
# Code WIRKLICH steht — sonst prueft er eine leere Datei und ist
# gruen, ohne etwas zu beweisen.
pfad = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ablauf.py")
quelle = open(pfad, encoding="utf-8").read()
# Nur die Aufrufe, die wirklich die Job-Zeile aendern.
-32
View File
@@ -1,32 +0,0 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben genau das alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
"""
import subprocess
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
+35
View File
@@ -0,0 +1,35 @@
"""rippy — der gemeinsame Kern von API und Worker.
## Warum es dieses Paket gibt (Etappe V2-0, 28.08.2026)
Bis hierher gab es KEIN gemeinsames Paket zwischen den Containern. Drei Module
lagen deshalb zweimal im Repo, byte-identisch, und `db.py` sagte es sogar im
Kopfkommentar: Wer die Struktur ändert, ändert BEIDE Dateien."
Ein Wächter-Test (`test_zwillinge_sind_byteweise_identisch`) hat das mechanisch
abgesichert er war nötig, weil die Konstruktion selbst falsch war. Mit diesem
Paket entfällt beides: es gibt jede Datei genau einmal.
## Aufbau
Die Unterpakete folgen den Fachschichten aus KONZEPT-V2.md § 2.1:
core/ Job-Modell, Zustandsmaschine, Benachrichtigungen
drives/ Laufwerke: Erkennung, Disc-Status, Auswurf (ioctl / Win32)
rip/ MakeMKV, abcde Kommandobau und Datenverzeichnis
Weitere Schichten (metadata, transcode, library, storage) kommen mit den
Etappen V2-1 ff. dazu. Bis dahin leben sie unverändert in docker/api bzw.
docker/worker weiter V2-0 ändert bewusst KEIN Verhalten.
## Wo das Paket zur Laufzeit liegt
Repo src/rippy/ (auf sys.path über conftest.py in der Wurzel)
API-Image /app/rippy/ (COPY im Dockerfile, /app ist Arbeitsverzeichnis)
Worker-Image /app/rippy/ (dito)
Windows <Installationsordner>\\rippy\\ (im Zip von /worker-setup/paket)
Wer hier eine Datei ergänzt, prüft alle vier Wege der Windows-Weg ist der,
den man am leichtesten vergisst (das Zip nimmt nicht automatisch alles mit,
siehe `worker_setup_paket` in docker/api/main.py).
"""
+305
View File
@@ -0,0 +1,305 @@
"""In welchem Betrieb läuft Rippy — und was kann dieser Betrieb überhaupt?
## Der Befund des Commanders (28.08.2026)
Im Windows-Fenster stand auf der Server-Status-Kachel:
Worker erreichbar: 0 von 1
Kein Worker antwortet ohne ihn läuft kein Rip. Prüfen: docker compose ps
Container-Platte: unbekannt
Freigaben: keine eingehängt
Kein einziger dieser Sätze ergibt auf einem Windows-PC einen Sinn. Es gibt
keinen Container, kein `docker compose`, keinen zweiten Worker Rippy rippt
dort selbst. Sein Urteil:
> Du hast ja quasi nur rippy genommen und die docker installation für Windows
> gebaut. Das macht ja aber keinen sinn da auf Windows andere dinge relevant
> sind als für den Windows Client. Das gilt für die ganze standalone version
> für Windows, auch für die settings und die Anleitung usw."
## Warum das kein Anzeigefehler ist
Die naheliegende Reparatur wäre, an dreißig Stellen `if (windows)` einzubauen.
Das wäre dieselbe Falle noch einmal: Die Oberfläche wüsste weiterhin nichts
über ihren Betrieb, sie hätte nur eine zweite Sorte Vermutung.
Die Ursache liegt tiefer: **Die Oberfläche hat nie erfahren, worauf sie
läuft.** `config.py` kennt das Profil seit V2-2, die API hat es nie
weitergegeben. Also hat das UI angenommen und Annahmen sind in diesem
Projekt schon oft genug teuer geworden.
## Warum FÄHIGKEITEN und nicht ein Modus-Name
Ein `modus == "standalone"` würde die Oberfläche zwingen, aus einem Namen auf
Verhalten zu schließen genau die Sorte Ableitung, die bei jedem neuen
Betriebsfall bricht. Der Kopflos-Betrieb (V2-6) hat keinen Container, aber
sehr wohl externe Worker; ein Docker-All-in-One hat einen Container, aber
keinen zweiten Worker.
Deshalb sagt Rippy, **was geht**, nicht **wie es heißt**:
externe_worker Gibt es andere Maschinen, die Jobs übernehmen?
freigaben_einhaengen Kann Rippy Netzwerk-Freigaben selbst einhängen?
container_pfade Sind Pfade wie /app/media überhaupt gemeint?
werkzeuge_verwalten Kann Rippy MakeMKV/HandBrake selbst beschaffen?
Der Modus-Name bleibt trotzdem dabei für Fehlerberichte und Überschriften.
Aber gerechnet wird mit den Fähigkeiten.
## Was hier GEMESSEN und was gelesen wird
Wo es geht, wird nachgesehen statt geglaubt:
im_container `/.dockerenv` existiert (Docker legt sie an)
plattform `sys.platform`
externe_worker Queue-Treiber `celery` (ohne Broker gibt es keine)
`profil` aus der Konfiguration ist der Rückfall, nicht die erste Quelle.
"""
import os
import sys
from rippy import pfade
# Docker legt diese Datei in jedem Container an. Sie ist das verlässlichste
# Kennzeichen, das ohne Zusatzpakete zu haben ist — verlässlicher als eine
# Umgebungsvariable, die jeder setzen (und vergessen) kann.
DOCKER_KENNZEICHEN = "/.dockerenv"
def im_container(pruefen=None) -> bool:
"""Läuft dieser Prozess in einem Container? (gemessen, nicht geraten)"""
pruefen = pruefen or os.path.exists
return bool(pruefen(DOCKER_KENNZEICHEN))
def plattform(name: str = None) -> str:
"""`windows`, `linux` oder `macos` — nie der rohe sys.platform-String."""
roh = (name if name is not None else sys.platform).lower()
if roh.startswith("win"):
return "windows"
if roh == "darwin":
return "macos"
return "linux"
def modus(werte: dict, container: bool = None) -> str:
"""Der Name des Betriebs — für Überschriften und Fehlerberichte.
`standalone` Ein Prozess macht alles (Windows-App, Docker-All-in-One)
`verteilt` Mehrere Maschinen teilen sich die Arbeit (Celery/Redis)
"""
queue = (werte or {}).get("queue", {}) or {}
if queue.get("treiber") == "celery":
return "verteilt"
profil = (werte or {}).get("profil", "standalone")
return "verteilt" if profil in ("api", "node") else "standalone"
def auskunft(werte: dict, container: bool = None,
plattform_name: str = None) -> dict:
"""Was ist dieser Betrieb, und was kann er? — die Antwort für die API.
Reine Funktion: `container` und `plattform_name` sind einspritzbar, damit
jeder Betriebsfall prüfbar ist, ohne ihn herzustellen.
"""
werte = werte or {}
container = im_container() if container is None else container
system = plattform(plattform_name)
art = modus(werte, container)
verteilt = art == "verteilt"
return {
"modus": art,
"plattform": system,
"im_container": container,
"kann": {
# Ohne Celery gibt es niemanden, der von außen Jobs übernimmt.
# Die Worker-Liste, das Worker-Setup und jede Rede von
# „Kein Worker antwortet" gehören dann nicht auf den Bildschirm.
"externe_worker": verteilt,
# Einhängen macht seit dem Entscheid vom 28.08.2026 der HOST
# (KONZEPT-V2.md § 4.3) — im Container zeigt Rippy nur noch die
# Zeile zum Kopieren. Unter Windows hängt man gar nichts ein:
# Dort gibt man einen UNC-Pfad an, fertig.
"freigaben_einhaengen": container,
# `/app/media` ist ein Pfad IM Container. Auf Windows heißt der
# Ordner anders und liegt woanders.
"container_pfade": container,
# Im Image steckt alles fest; ein Update ist ein Rebuild. Nur die
# native Installation kann ihre Werkzeuge selbst pflegen.
"werkzeuge_verwalten": not container,
# Darf die Oberfläche außerhalb der Medien-Wurzel blättern?
"frei_blaettern": frei_blaettern(werte, container),
},
# Was in der Oberfläche als Vorgabe stehen soll. Ein Feld, das mit
# `/app/media` vorbelegt ist, ist auf Windows schlicht falsch.
"ablage_vorgabe": ablage_vorgabe(werte, container, system),
"arbeits_vorgabe": arbeits_vorgabe(werte, container, system),
"hilfe_befehl": "docker compose -p rippy ps" if container else "",
}
def frei_blaettern(werte: dict, container: bool = None) -> bool:
"""Darf die Oberfläche das ganze Dateisystem sehen? (Befund 28.08.2026)
## Warum diese Frage überhaupt gestellt werden muss
`MEDIA_ROOT = "/app/media"` war in `main.py` zugleich Vorgabe UND
Pfadgrenze: `/browse`, `/storage-targets` und jede Datei-Auslieferung
prüften `unter_wurzel(pfad, MEDIA_ROOT)`.
Im Container ist das genau richtig. Die API hängt dort im Netz, und eine
Weboberfläche, die jeden Pfad des Wirts ausliefern kann, ist ein Loch.
Auf dem Windows-PC des Commanders war dieselbe Zeile gleich doppelt
falsch: Den Ordner `/app/media` gibt es nicht (also war die Liste der
Arbeitsverzeichnisse LEER sein Befund momentan geht das nicht"), und
sein Ziel ist eine UNC-Freigabe, die unter gar keiner lokalen Wurzel
liegt.
Die Unterscheidung ist nicht Windows" — sie ist **wer hört zu**. Ein
Container und der Kopflos-Betrieb bedienen ein Netz; die native App
bedient den Menschen, der vor dem Rechner sitzt und dessen eigene Ordner
das sind. Deshalb: kein Container UND kein verteilter Betrieb.
"""
container = im_container() if container is None else container
return not container and modus(werte, container) == "standalone"
def medien_wurzel(werte: dict, container: bool = None,
plattform_name: str = None) -> str:
"""Die Wurzel, unter der Rippy ablegt — im Container `/app/media`.
Eigener Name statt `ablage_vorgabe`, weil die Rolle eine andere ist: Die
Vorgabe füllt ein Eingabefeld, die Wurzel begrenzt Pfade. Dass beide
denselben Ort meinen, ist Absicht und kein Zufall.
"""
return ablage_vorgabe(werte, im_container() if container is None else container,
plattform(plattform_name))
def arbeits_vorgabe(werte: dict, container: bool = None,
plattform_name: str = None) -> str:
"""Wohin die Rohdaten wandern, wenn niemand etwas anderes wählt.
Der Roh-Rip einer 4K-UHD ist bis zu 100 GB groß. Wo der landet, ist keine
Nebensache am 25.07.2026 lief damit die Container-Platte voll.
"""
werte = werte or {}
container = im_container() if container is None else container
system = plattform(plattform_name)
eigen = (werte.get("storage", {}) or {}).get("temp")
if eigen:
return eigen
if container:
return "/app/temp"
return pfade.verbinden(ablage_vorgabe(werte, container, system), "_arbeit")
def naechster_vorhandener(pfad: str, existiert=None) -> str:
"""Der naechste vorhandene Ordner nach oben — siehe `rippy.pfade`."""
return pfade.naechster_vorhandener(pfad, existiert)
def platz_orte(werte: dict, container: bool = None,
plattform_name: str = None) -> list:
"""Wo Platz gemessen werden soll — je Betrieb andere Orte.
## Warum das nicht fest verdrahtet sein darf (28.08.2026)
In `main.py` stand:
for name, pfad in (("Media (/app/media)", MEDIA_ROOT),
("Arbeitsverzeichnis (/app/temp)", "/app/temp")):
Beide Pfade gibt es unter Windows nicht. `shutil.disk_usage` warf, die
Liste blieb leer, und im Windows-Fenster stand **Platz für Rippy:
unbekannt"** — obwohl auf dem Laufwerk natürlich Platz war. Eine
Nichtauskunft, die wie eine Auskunft aussieht.
Gibt eine Liste von `{"name": , "pfad": }` zurück; der Aufrufer misst.
"""
werte = werte or {}
container = im_container() if container is None else container
system = plattform(plattform_name)
if container:
return [{"name": "Media (/app/media)", "pfad": "/app/media"},
{"name": "Arbeitsverzeichnis (/app/temp)", "pfad": "/app/temp"}]
ablage = ablage_vorgabe(werte, container, system)
temp = arbeits_vorgabe(werte, container, system)
orte = [{"name": "Ablage", "pfad": ablage}]
# ## Warum beide Orte IMMER dastehen (Commander 29.08.2026)
#
# > „bei Platz für rippy' sollte eher das arbeitsverzeichnis und der
# > Ablagepfad sein."
#
# Hier stand vorher: zweite Zeile nur, wenn es ein ANDERES Laufwerk ist —
# 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
def mit_einstellungen(werte: dict, einstellungen: dict) -> dict:
r"""Die in der Oberflaeche gesetzten Orte in die Betriebs-Werte legen.
## Warum es diese Bruecke braucht (Befund 30.08.2026)
Der Commander: die verzeichnise sind andere als dort steht."
Rippy hat ZWEI Speicher fuer dieselbe Frage:
Oberflaeche -> Datenbank, Schluessel `outputDir` und `workDir`
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.
"""
lager = dict(((werte or {}).get("storage") or {}))
ablage = ((einstellungen or {}).get("outputDir") or "").strip()
arbeit = ((einstellungen or {}).get("workDir") or "").strip()
if ablage:
lager["medien"] = ablage
if arbeit:
lager["temp"] = arbeit
zusammen = dict(werte or {})
zusammen["storage"] = lager
return zusammen
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
if eigen:
return eigen
if container:
return "/app/media"
# Der Windows-Sonderzweig ist mit der Standalone-App gegangen (v5 ist
# ein eigenes Produkt, KONZEPT-WINDOWS.md Entscheid 7) — nativ heisst
# hier nur noch: ein Entwickler-Lauf ausserhalb des Containers.
return os.path.join(os.path.expanduser("~"), "Videos", "Rippy")
+44
View File
@@ -0,0 +1,44 @@
"""Ereignis-Bus: Nachrichten über Änderungen — nie der Zustand selbst.
## Die eine Regel, aus der alles folgt
Der Bus überträgt nur NACHRICHTEN ÜBER ÄNDERUNGEN.
Wer den Zustand will, fragt den Store.
Das klingt nach Prinzipienreiterei und ist die Lehre aus dem teuersten
UI-Fehler von v1. Dort stand fünfmal `catch(() => [])`: Jeder fehlgeschlagene
Abruf hieß damit es gibt keine Jobs, keine Laufwerke, keine Ablagen". Die
Liste leerte sich für einen Takt und füllte sich vier Sekunden später wieder.
Der Commander meldete das als wird oft neu geladen", und die Ursache war
unsichtbar, weil der Fehlerzweig nichts protokollierte (AGENTS.md: Ein
verpasster Abruf ist keine Nachricht über die Welt").
Wenn der Bus keinen Zustand trägt, kann ein verpasstes Ereignis auch keinen
Zustand löschen. Ein Abriss heißt dann: Ich weiß gerade nichts Neues" — nicht
es gibt nichts".
## Drei Eigenschaften, die daraus folgen
1. **`snapshot` zuerst.** Wer sich verbindet, bekommt als Erstes den
vollständigen Zustand, danach nur noch Deltas. Ein Client sieht nie ein
halbes Bild.
2. **`seq` ist monoton und lückenlos.** Ein Reconnect mit `Last-Event-ID`
liefert alles Verpasste nach.
3. **Eine zu große Lücke wird ANGESAGT.** Ist das Verpasste aus dem Ringpuffer
gefallen, schickt der Bus ausdrücklich einen neuen `snapshot` statt
stillschweigend bei den neuesten Deltas weiterzumachen. Stillschweigend
wäre der schlimmere Fall: Das UI hielte sich für aktuell und wäre es nicht.
## Treiber
memory.py asyncio-Warteschlangen + Ringpuffer Standalone-Betrieb
redis.py Redis Pub/Sub, Ringpuffer in der Tabelle `ereignisse` (V2-5)
"""
from rippy.bus.schema import ( # noqa: F401
EREIGNIS_TYPEN,
baue_ereignis,
ist_gueltig,
)
__all__ = ["EREIGNIS_TYPEN", "baue_ereignis", "ist_gueltig"]
+152
View File
@@ -0,0 +1,152 @@
"""In-Process-Bus: asyncio-Warteschlangen plus Ringpuffer.
Erfüllt `rippy.ports.Bus`. Der Treiber für den Standalone-Betrieb API,
Queue und UI leben dort in EINEM Prozess, es braucht also nichts dazwischen.
Der Redis-Treiber für den verteilten Betrieb kommt in V2-5 und hat dieselbe
Form.
## Warum ein Ringpuffer und nicht nur Warteschlangen
Ein Browser verliert die SSE-Verbindung ständig beim Sperrbildschirm, beim
WLAN-Wechsel, beim Tab-Wechsel auf dem Handy. Ohne Puffer wäre jede dieser
Sekunden ein Loch: Der Fortschritt spränge von 12 % auf 40 %, ein
`job.finished` ginge verloren, und das UI zeigte einen Job als laufend, der
längst durch ist.
Mit Puffer sagt der Browser beim Wiederverbinden ich hatte zuletzt 1042", und
bekommt 1043 ff. nachgeliefert.
## Und warum eine zu große Lücke angesagt wird
Ist das Verpasste aus dem Puffer gefallen, gibt es zwei Möglichkeiten:
stillschweigend bei den neuesten Deltas weitermachen oder sagen, dass etwas
fehlt. Der erste Weg ist der gefährlichere: Das UI hielte sich für aktuell und
wäre es nicht, und niemand könnte den Unterschied sehen. Deshalb kommt in dem
Fall ein `snapshot`.
## Langsame Abonnenten
Jeder Abonnent hat eine eigene, BEGRENZTE Warteschlange. Läuft sie über ein
Browser auf einem lahmen Handy, ein Tab im Hintergrund , wird der Abonnent
markiert und bekommt beim nächsten Mal einen `snapshot` statt der verpassten
Deltas. Was NICHT passiert: dass der Sender wartet. Ein einziger langsamer
Zuhörer darf den Rip nicht bremsen.
"""
import asyncio
from collections import deque
from rippy.bus.schema import RINGPUFFER, baue_ereignis
# Wie viele Ereignisse ein einzelner Abonnent aufstauen darf, bevor er als
# „abgehängt" gilt. Großzügig: Ein normaler Client leert die Schlange sofort.
ABONNENT_PUFFER = 256
class MemoryBus:
"""Ereignisse verteilen — ohne Broker, ohne Netz."""
def __init__(self, ringpuffer: int = RINGPUFFER):
self._seq = 0
self._puffer: deque = deque(maxlen=ringpuffer)
self._abonnenten: set = set()
# ── Senden ──────────────────────────────────────────────────────────
def senden(self, typ: str, daten: dict = None, entitaet: str = None,
entitaet_id: str = None) -> dict:
"""Nimmt ein Ereignis an, nummeriert es und verteilt es.
Bewusst SYNCHRON: Gesendet wird aus Rip-Schleifen und aus DB-Callbacks,
also aus Code, der kein `await` kann. Die Zustellung ist ein
`put_nowait` je Abonnent es wartet nie jemand.
"""
self._seq += 1
ereignis = baue_ereignis(
typ, daten=daten, entitaet=entitaet,
entitaet_id=entitaet_id, seq=self._seq,
)
self._puffer.append(ereignis)
for abo in list(self._abonnenten):
abo.zustellen(ereignis)
return ereignis
# ── Empfangen ───────────────────────────────────────────────────────
def abonnieren(self, ab_seq: int = None):
"""Neuer Abonnent. `ab_seq` = die zuletzt gesehene Folgenummer."""
abo = _Abonnent(self, ab_seq)
self._abonnenten.add(abo)
return abo
def abmelden(self, abo) -> None:
self._abonnenten.discard(abo)
# ── Nachliefern ─────────────────────────────────────────────────────
def nachliefern(self, ab_seq: int):
"""Was seit `ab_seq` passiert ist — oder None, wenn die Lücke zu groß ist.
None heißt für den Aufrufer ausdrücklich: Ich kann die Lücke nicht
füllen, hol dir einen frischen Gesamtstand." Ein leeres Ergebnis
dagegen heißt nichts verpasst". Die beiden zu vermischen wäre genau
der v1-Fehler in neuer Kleidung.
"""
if ab_seq is None:
return []
if not self._puffer:
# Nichts im Puffer: Wenn der Client auf dem Stand ist, ist das
# in Ordnung; liegt er zurück, fehlt ihm etwas.
return [] if ab_seq >= self._seq else None
aeltestes = self._puffer[0]["seq"]
if ab_seq + 1 < aeltestes:
return None # aus dem Puffer gefallen
return [e for e in self._puffer if e["seq"] > ab_seq]
@property
def seq(self) -> int:
return self._seq
class _Abonnent:
"""Eine Warteschlange plus die Merkposten für einen Zuhörer."""
def __init__(self, bus: MemoryBus, ab_seq: int = None):
self._bus = bus
self._queue: asyncio.Queue = asyncio.Queue(maxsize=ABONNENT_PUFFER)
self.ab_seq = ab_seq
self.abgehaengt = False # Puffer lief über -> braucht snapshot
def zustellen(self, ereignis: dict) -> None:
try:
self._queue.put_nowait(ereignis)
except asyncio.QueueFull:
# Nicht warten, nicht das älteste stillschweigend wegwerfen:
# merken, dass dieser Zuhörer den Anschluss verloren hat.
self.abgehaengt = True
async def naechstes(self, timeout: float = None):
"""Nächstes Ereignis, oder None bei Zeitüberschreitung.
Das Timeout ist der Herzschlag: Eine SSE-Verbindung, über die minutenlang
nichts geht, wird von manchen Proxys geschlossen. Der Aufrufer schickt
dann einen Kommentar-Frame.
"""
if timeout is None:
return await self._queue.get()
try:
return await asyncio.wait_for(self._queue.get(), timeout)
except asyncio.TimeoutError:
return None
def schliessen(self) -> None:
self._bus.abmelden(self)
def __enter__(self):
return self
def __exit__(self, *_):
self.schliessen()
return False
# Der Bus des Prozesses. Ein Modul-Singleton, weil ihn API, Queue und
# Rip-Schleife gemeinsam benutzen und niemand ihn herumreichen soll.
bus = MemoryBus()
+85
View File
@@ -0,0 +1,85 @@
"""Das Ereignis-Schema — eine Form für alle Nachrichten (KONZEPT-V2.md § 6.3).
Ein Ereignis sieht IMMER so aus:
{
"seq": 1043, # monoton, lückenlos
"ts": "2026-08-28T10:14:22Z",
"typ": "job.progress",
"entitaet": "job",
"entitaet_id":"a4f…",
"daten": {"phase": "rip", "prozent": 37, "eta_sekunden": 1820}
}
## Warum die Typen hier als Liste stehen
Damit ein Tippfehler auffällt. Ein Ereignis mit `typ="job.progres"` würde sonst
gesendet, käme im UI an und träfe dort auf keinen einzigen Empfänger ohne
Fehlermeldung, ohne Log, ohne Hinweis. Diese Liste macht daraus einen
Programmfehler, der beim Bauen auffällt statt im Betrieb.
Wer einen Typ ergänzt, trägt ihn HIER ein. Der Test dazu prüft, dass jeder
gesendete Typ bekannt ist.
"""
from datetime import datetime, timezone
# Alle Ereignistypen, die es gibt. Kommentar = wann sie kommen.
EREIGNIS_TYPEN = {
# Zustand am Anfang jeder Verbindung — und nach einer zu großen Lücke.
"snapshot": "Vollständiger Zustand: Jobs, Laufwerke, Knoten, Mounts",
"drive.changed": "Laufwerks-Zustand hat sich geändert",
"disc.inserted": "Disc eingelegt",
"disc.removed": "Disc entnommen",
"job.created": "Neuer Job angelegt",
"job.phase": "Job wechselt die Phase (scan/rip/transcode/ablegen)",
"job.progress": "Fortschritt — gedrosselt auf höchstens 1/s",
"job.finished": "Job fertig, fehlgeschlagen oder abgebrochen",
"log.line": "Eine Log-Zeile",
"node.seen": "Ein Knoten hat sich gemeldet",
"node.lost": "Ein Knoten meldet sich nicht mehr",
"mount.changed": "Ein Speicherziel ist erreichbar geworden oder weggefallen",
"system.status": "Server-Zustand: Hardware, Worker, Ablageziele (langsamer Takt)",
"system.notice": "Hinweis an den Nutzer (Plattenplatz, Rate-Limit, …)",
}
# Wie viele Ereignisse der Ringpuffer hält. Bei ~1 Fortschritts-Ereignis pro
# Sekunde sind 512 gut acht Minuten Rückschau — mehr als genug für einen
# Browser-Reconnect (der binnen Sekunden passiert) und wenig genug, um im
# Speicher nicht aufzufallen.
RINGPUFFER = 512
def ist_gueltig(typ: str) -> bool:
return typ in EREIGNIS_TYPEN
def baue_ereignis(typ: str, daten: dict = None, entitaet: str = None,
entitaet_id: str = None, seq: int = 0, ts=None) -> dict:
"""Baut ein Ereignis und prüft den Typ. Wirft bei unbekanntem Typ.
Absichtlich hart: Ein unbekannter Typ ist ein Programmfehler, kein
Betriebszustand. Ihn durchzulassen hieße, eine Nachricht zu senden, die
garantiert niemand empfängt der stillste aller Fehlschläge.
"""
if not ist_gueltig(typ):
bekannt = ", ".join(sorted(EREIGNIS_TYPEN))
raise ValueError(
f"Unbekannter Ereignistyp {typ!r}. Bekannt sind: {bekannt}. "
"Neue Typen gehören in rippy/bus/schema.py — sonst kommt die "
"Nachricht nirgendwo an und niemand merkt es."
)
return {
"seq": seq,
"ts": (ts or datetime.now(timezone.utc)).isoformat(),
"typ": typ,
"entitaet": entitaet,
"entitaet_id": entitaet_id,
"daten": daten or {},
}
+151
View File
@@ -0,0 +1,151 @@
"""Der Bus muss drei Dinge können — und alle drei stammen aus v1-Fehlern.
1. **Nachliefern.** Ein Browser verliert die Verbindung ständig. Ohne
Nachlieferung springt der Fortschritt und ein `job.finished` geht verloren.
2. **Eine zu große Lücke ANSAGEN.** Stillschweigend bei den neuesten Deltas
weiterzumachen wäre der gefährlichere Weg: Das UI hielte sich für aktuell
und wäre es nicht.
3. **Einen langsamen Zuhörer nicht zum Problem aller machen.** Ein Tab im
Hintergrund darf den Rip nicht bremsen.
"""
import asyncio
import pytest
from rippy.bus import schema
from rippy.bus.memory import ABONNENT_PUFFER, MemoryBus
@pytest.fixture
def bus():
return MemoryBus(ringpuffer=10)
# ── Schema ──────────────────────────────────────────────────────────────
def test_unbekannter_typ_fliegt_sofort_auf(bus):
"""Ein Tippfehler im Typ waere sonst der stillste aller Fehlschlaege:
Die Nachricht ginge raus, traefe auf keinen Empfaenger, und nirgends
stuende etwas."""
with pytest.raises(ValueError, match="Unbekannter Ereignistyp"):
bus.senden("job.progres", {"prozent": 5})
def test_bekannter_typ_geht_durch(bus):
ereignis = bus.senden("job.progress", {"prozent": 5}, entitaet="job", entitaet_id="j1")
assert ereignis["typ"] == "job.progress"
assert ereignis["entitaet_id"] == "j1"
assert ereignis["seq"] == 1
def test_alle_typen_im_schema_sind_benutzbar(bus):
"""Wer einen Typ eintraegt, soll ihn auch senden koennen."""
for typ in schema.EREIGNIS_TYPEN:
bus.senden(typ, {})
# ── Folgenummern ────────────────────────────────────────────────────────
def test_seq_zaehlt_lueckenlos_hoch(bus):
nummern = [bus.senden("log.line", {"text": str(i)})["seq"] for i in range(5)]
assert nummern == [1, 2, 3, 4, 5]
# ── Nachliefern ─────────────────────────────────────────────────────────
def test_nachliefern_gibt_genau_das_verpasste(bus):
for i in range(5):
bus.senden("log.line", {"text": str(i)})
verpasst = bus.nachliefern(2)
assert [e["seq"] for e in verpasst] == [3, 4, 5]
def test_nachliefern_ohne_rueckstand_ist_leer(bus):
for i in range(3):
bus.senden("log.line", {"text": str(i)})
assert bus.nachliefern(3) == []
def test_zu_grosse_luecke_gibt_none_nicht_leer(bus):
"""DER Unterschied, auf den es ankommt.
`[]` heisst du hast nichts verpasst". `None` heisst „ich kann die Luecke
nicht fuellen, hol dir einen frischen Gesamtstand". Die beiden zu
vermischen waere derselbe Fehler wie `catch(() => [])` im alten UI: eine
Nichtauskunft, die als Aussage gelesen wird.
"""
for i in range(20): # Ringpuffer fasst nur 10
bus.senden("log.line", {"text": str(i)})
assert bus.nachliefern(2) is None
assert bus.nachliefern(15) is not None
def test_leerer_bus_meldet_rueckstand_ehrlich():
frisch = MemoryBus(ringpuffer=10)
assert frisch.nachliefern(0) == [] # nichts passiert, nichts verpasst
assert frisch.nachliefern(None) == []
# ── Zustellung ──────────────────────────────────────────────────────────
def test_abonnent_bekommt_was_nach_dem_abo_kommt():
async def lauf():
bus = MemoryBus()
with bus.abonnieren() as abo:
bus.senden("job.created", {"id": "j1"}, entitaet="job", entitaet_id="j1")
ereignis = await abo.naechstes(timeout=1)
return ereignis
ereignis = asyncio.run(lauf())
assert ereignis["typ"] == "job.created"
def test_zwei_abonnenten_bekommen_beide_alles():
async def lauf():
bus = MemoryBus()
with bus.abonnieren() as a, bus.abonnieren() as b:
bus.senden("disc.inserted", {"laufwerk_id": "sr0"})
return await a.naechstes(timeout=1), await b.naechstes(timeout=1)
erst, zweit = asyncio.run(lauf())
assert erst["seq"] == zweit["seq"] == 1
def test_abgemeldeter_abonnent_bekommt_nichts_mehr():
async def lauf():
bus = MemoryBus()
abo = bus.abonnieren()
abo.schliessen()
bus.senden("log.line", {"text": "danach"})
return await abo.naechstes(timeout=0.05)
assert asyncio.run(lauf()) is None
def test_timeout_gibt_none_statt_zu_haengen():
"""Der Herzschlag der SSE-Verbindung haengt daran: Kommt minutenlang
nichts, muss der Aufrufer trotzdem einen Kommentar-Frame schicken
koennen, sonst schliessen manche Proxys die Verbindung."""
async def lauf():
bus = MemoryBus()
with bus.abonnieren() as abo:
return await abo.naechstes(timeout=0.05)
assert asyncio.run(lauf()) is None
# ── Langsamer Zuhörer ───────────────────────────────────────────────────
def test_langsamer_abonnent_bremst_den_sender_nicht():
"""Ein Tab im Hintergrund darf den Rip nicht anhalten.
Der Sender laeuft hier ueber die Puffergrenze hinaus. Erwartet wird: Er
kommt durch, und der abgehaengte Zuhoerer ist MARKIERT nicht, dass
Ereignisse stillschweigend verschwinden.
"""
async def lauf():
bus = MemoryBus(ringpuffer=ABONNENT_PUFFER * 2)
with bus.abonnieren() as abo:
for i in range(ABONNENT_PUFFER + 20):
bus.senden("log.line", {"text": str(i)}) # niemand liest
return abo.abgehaengt, bus.seq
abgehaengt, seq = asyncio.run(lauf())
assert abgehaengt is True
assert seq == ABONNENT_PUFFER + 20 # der Sender ist durchgelaufen
+370
View File
@@ -0,0 +1,370 @@
"""Der Wächter macht Worker-Änderungen zu Ereignissen — ohne Datenbank getestet.
`unterschiede()` ist eine reine Funktion, `einmal()` bekommt Store und Bus
eingespritzt. Beides läuft deshalb auf jeder Plattform und ohne Postgres
genau die Eigenschaft, die dem ersten Anlauf der SSE-Tests gefehlt hat
(Ampel-Lauf 170 lief rot, weil ein Test eine Datenbank brauchte).
"""
from rippy.bus.waechter import UI_PFLICHTFELDER, _job_kurz, Waechter, laufwerks_unterschiede, unterschiede
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 {}))
return {"typ": typ}
@property
def typen(self):
return [t for t, _, _ in self.gesendet]
class FakeStore:
def __init__(self, jobs=None, logs=None):
self.jobs = jobs or []
self.logs = logs or []
self.protokoll = []
def list_jobs(self, limit=100):
return list(self.jobs)
def list_logs(self, limit=50):
return list(self.logs)
def add_log(self, level, quelle, text):
self.protokoll.append((level, quelle, text))
def _job(id_, status="pending", progress=0, title="Akira", error=None):
return {"id": id_, "status": status, "progress": progress,
"title": title, "error": error}
# ── unterschiede(): die reine Logik ─────────────────────────────────────
def test_neuer_job_ist_ein_created():
ereignisse = unterschiede({}, {"j1": {"status": "pending", "progress": 0}})
assert ereignisse == [("job.created", "j1", {"status": "pending", "progress": 0})]
def test_gleicher_stand_erzeugt_nichts():
stand = {"j1": {"status": "ripping", "progress": 10}}
assert unterschiede(stand, dict(stand)) == []
def test_fortschritt_ist_ein_progress():
vorher = {"j1": {"status": "ripping", "progress": 10}}
jetzt = {"j1": {"status": "ripping", "progress": 11}}
typ, job_id, _ = unterschiede(vorher, jetzt)[0]
assert (typ, job_id) == ("job.progress", "j1")
def test_statuswechsel_mitten_drin_ist_eine_phase():
vorher = {"j1": {"status": "ripping", "progress": 99}}
jetzt = {"j1": {"status": "transcoding", "progress": 0}}
typ, _, daten = unterschiede(vorher, jetzt)[0]
assert typ == "job.phase"
assert daten["von"] == "ripping" and daten["nach"] == "transcoding"
def test_endzustand_ist_ein_finished():
for ende in ("completed", "failed", "canceled"):
vorher = {"j1": {"status": "transcoding", "progress": 80}}
jetzt = {"j1": {"status": ende, "progress": 100}}
assert unterschiede(vorher, jetzt)[0][0] == "job.finished"
def test_geloeschter_job_wird_gemeldet():
"""Sonst bliebe er im UI stehen, bis jemand neu laedt — und genau das
Neuladen soll ja verschwinden."""
typ, job_id, daten = unterschiede({"j1": {"status": "completed"}}, {})[0]
assert (typ, job_id) == ("job.finished", "j1")
assert daten["status"] == "geloescht"
def test_neue_jobs_kommen_vor_aenderungen():
"""Ein Client darf einen Job nie „geaendert" sehen, bevor er ihn kennt."""
vorher = {"alt": {"status": "ripping", "progress": 1}}
jetzt = {"alt": {"status": "ripping", "progress": 2},
"neu": {"status": "pending", "progress": 0}}
typen = [t for t, _, _ in unterschiede(vorher, jetzt)]
assert typen.index("job.created") < typen.index("job.progress")
# ── einmal(): der Durchlauf ─────────────────────────────────────────────
def test_erster_lauf_meldet_nichts():
"""Sonst bekaeme jeder API-Neustart die gesamte Job-Historie als
gerade passiert" — und das UI zeigte hundert Meldungen auf einmal."""
bus = FakeBus()
store = FakeStore(jobs=[_job("j1", "completed", 100)],
logs=[{"id": 7, "level": "info", "source": "worker", "message": "alt"}])
w = Waechter(store, bus)
assert w.einmal() == 0
assert bus.gesendet == []
def test_zweiter_lauf_meldet_die_aenderung():
bus = FakeBus()
store = FakeStore(jobs=[_job("j1", "ripping", 10)])
w = Waechter(store, bus)
w.einmal()
store.jobs = [_job("j1", "ripping", 25)]
assert w.einmal() == 1
assert bus.typen == ["job.progress"]
assert bus.gesendet[0][1] == "j1"
def test_neue_logzeilen_kommen_aelteste_zuerst():
"""list_logs gibt neueste zuerst. Wuerde der Waechter das durchreichen,
liefe das Log-Fenster rueckwaerts."""
bus = FakeBus()
store = FakeStore(logs=[{"id": 5, "level": "info", "source": "w", "message": "alt"}])
w = Waechter(store, bus)
w.einmal()
store.logs = [
{"id": 7, "level": "info", "source": "w", "message": "zweite"},
{"id": 6, "level": "info", "source": "w", "message": "erste"},
{"id": 5, "level": "info", "source": "w", "message": "alt"},
]
w.einmal()
texte = [d["message"] for t, _, d in bus.gesendet if t == "log.line"]
assert texte == ["erste", "zweite"]
def test_dieselbe_logzeile_kommt_nur_einmal():
bus = FakeBus()
store = FakeStore(logs=[{"id": 1, "level": "info", "source": "w", "message": "a"}])
w = Waechter(store, bus)
w.einmal()
store.logs = [{"id": 2, "level": "info", "source": "w", "message": "b"},
{"id": 1, "level": "info", "source": "w", "message": "a"}]
w.einmal()
w.einmal()
assert [d["message"] for t, _, d in bus.gesendet if t == "log.line"] == ["b"]
def test_alter_ist_abfragbar():
"""AGENTS.md: „wer einen Vorrat anlegt, macht sein Alter abfragbar".
Ein Waechter, der still gestorben ist, sieht von aussen genauso aus wie
einer, bei dem gerade nichts passiert es sei denn, man kann fragen.
"""
w = Waechter(FakeStore(), FakeBus())
assert w.lebt_seit_sekunden() == -1.0 # noch nie gelaufen
assert w.gesund is False
w.einmal()
assert 0 <= w.lebt_seit_sekunden() < 1
assert w.gesund is True
# ── Laufwerke ───────────────────────────────────────────────────────────
def _lw(pfad, status="empty", typ="unknown"):
return {"path": pfad, "id": pfad.split("/")[-1], "status": status, "type": typ}
def test_eingelegte_disc_bekommt_ein_eigenes_ereignis():
"""Das UI reagiert darauf anders als auf eine bloße Zustandsaenderung —
bei einer eingelegten Disc springt der Rip-Dialog auf."""
vorher = {"/dev/sr0": _lw("/dev/sr0", "empty")}
jetzt = {"/dev/sr0": _lw("/dev/sr0", "ready", "bluray")}
typen = [t for t, _, _ in laufwerks_unterschiede(vorher, jetzt)]
assert "disc.inserted" in typen
assert typen.index("disc.inserted") < typen.index("drive.changed")
def test_entnommene_disc_ebenso():
vorher = {"/dev/sr0": _lw("/dev/sr0", "ready", "bluray")}
jetzt = {"/dev/sr0": _lw("/dev/sr0", "empty")}
typen = [t for t, _, _ in laufwerks_unterschiede(vorher, jetzt)]
assert "disc.removed" in typen
def test_unveraendertes_laufwerk_erzeugt_nichts():
stand = {"/dev/sr0": _lw("/dev/sr0", "ready")}
assert laufwerks_unterschiede(stand, dict(stand)) == []
def test_erstes_auftauchen_ist_kein_disc_ereignis():
"""Beim Start ist jedes Laufwerk „neu". Wuerde das als disc.inserted
zaehlen, spraenge nach jedem API-Neustart der Rip-Dialog auf."""
typen = [t for t, _, _ in laufwerks_unterschiede({}, {"/dev/sr0": _lw("/dev/sr0", "ready")})]
assert typen == ["drive.changed"]
def test_verschwundenes_laufwerk_wird_gemeldet():
typ, geraet, daten = laufwerks_unterschiede({"/dev/sr0": _lw("/dev/sr0")}, {})[0]
assert (typ, geraet) == ("drive.changed", "/dev/sr0")
assert daten["status"] == "weg"
def test_haengendes_laufwerk_haelt_den_waechter_nicht_an():
"""DIE Zusage: Ein defektes Laufwerk darf nicht den Job-Fortschritt
mitnehmen. Sonst waere ein klemmendes Laufwerk gleichbedeutend mit einem
eingefrorenen UI und niemand saehe, woran es liegt."""
bus = FakeBus()
store = FakeStore(jobs=[_job("j1", "ripping", 10)])
def kaputt():
raise OSError("Laufwerk haengt")
w = Waechter(store, bus, laufwerke_lesen=kaputt, laufwerks_takt=0)
w.einmal()
store.jobs = [_job("j1", "ripping", 20)]
assert w.einmal() == 1 # der Job kommt trotzdem durch
assert bus.typen == ["job.progress"]
class FrischGestartet:
"""Eine Uhr, die gerade erst angefangen hat zu zählen.
`time.monotonic()` zählt ab einem beliebigen Nullpunkt unter Linux ab
dem Start der Maschine. Auf einem Rechner, der seit Wochen läuft, ist der
Wert riesig; auf einem frisch gestarteten (oder in einem Container) ist er
klein. Genau dieser Unterschied hat die Ampel rot gemacht.
"""
def __init__(self, wert=0.5):
self.wert = wert
def monotonic(self):
return self.wert
def test_erste_abfrage_faellt_nicht_aus_wenn_die_uhr_bei_null_steht(monkeypatch):
"""DER Fehler aus den Ampel-Läufen 181184 (28.08.2026).
`_laufwerke_geprueft` stand auf `0.0` und sollte noch nie geprüft"
heißen. Verglichen wurde es aber mit `time.monotonic()`. Auf einer frisch
gestarteten Maschine ist `jetzt - 0.0` KLEIN also sah der Wächter aus,
als hätte er die Laufwerke gerade eben schon gelesen, und die erste
Abfrage fiel ersatzlos aus.
Unter Windows fiel das nie auf, weil der Zähler dort schon beim ersten
Start groß ist. Dieser Test stellt den kleinen Zähler nach und läuft
deshalb auf JEDER Plattform.
"""
from rippy.bus import waechter as modul
monkeypatch.setattr(modul, "time", FrischGestartet(0.5))
aufrufe = []
w = Waechter(FakeStore(), FakeBus(),
laufwerke_lesen=lambda: aufrufe.append(1) or [_lw("/dev/sr0")],
laufwerks_takt=999)
w.einmal()
assert len(aufrufe) == 1, (
"Die erste Laufwerks-Abfrage ist ausgefallen: 0.0 wurde als Zeitpunkt "
"gelesen statt als 'noch nie geprueft'."
)
def test_laufwerke_werden_seltener_abgefragt_als_jobs():
"""Ein ioctl kostet mehr als ein SELECT. Ohne den eigenen Takt liefe
jede Sekunde eine Laufwerksabfrage auf einem klemmenden Laufwerk waere
das ein Dauerproblem."""
aufrufe = []
def lesen():
aufrufe.append(1)
return [_lw("/dev/sr0")]
w = Waechter(FakeStore(), FakeBus(), laufwerke_lesen=lesen, laufwerks_takt=999)
w.einmal()
w.einmal()
w.einmal()
assert len(aufrufe) == 1, "Laufwerke wurden mehrfach im selben Takt gelesen"
# ── Der Vertrag mit der Oberflaeche (Befund 29.08.2026) ─────────────────
#
# Commander: „wenn man auf rippen starten klickt passiert irgendwas, was das
# Programm nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es
# gibt auch keine fehlermeldung."
#
# Nachgestellt und in der Browser-Konsole gemessen:
#
# TypeError: Cannot read properties of undefined (reading 'toUpperCase')
#
# Der Job lief in Wahrheit (gemessen: processing, 12 %). Aber `job.created`
# trug keinen `type`; das UI fuegte einen halben Job ein, las
# `job.type.toUpperCase()` — und weil es keine Fehlergrenze gab, riss der
# Fehler die GANZE Oberflaeche mit.
#
# ⚠️ Warum es niemand gefunden hat: `unterschiede()` bekommt die Kurzform
# schon fertig, die Tests oben reichen ihre eigenen Woerterbuecher herein.
# **`_job_kurz` selbst war nie geprueft.** Diese Tests schliessen die Luecke.
def _db_zeile():
"""Eine Job-Zeile, NACHDEM `job_form` sie umgewandelt hat.
Hier stand dieselbe Zeile mit denselben Feldnamen nur habe ich sie
mir ausgedacht. Die echte Datenbankzeile heisst `disc_type` und
`created_at`, nicht `type` und `startTime`. Der Test hat meine Annahme
also bestaetigt statt sie zu pruefen, und beim Commander stand danach
1.1.1970" in der Jobliste.
Der Waechter bekommt seit dem 29.08.2026 die UMGEWANDELTE Zeile die
Umwandlung selbst prueft `test_api_smoke.py`, wo sie wohnt.
"""
return {"id": "j1", "type": "bluray", "device": r"\\.\G:",
"startTime": "2026-08-29T12:07:02", "endTime": None,
"status": "processing", "progress": 12, "title": "Evangelion 2.22",
"error": None, "meta": {"confidence": 0.3}}
def test_ohne_umwandlung_kommt_die_rohe_zeile_NICHT_durch():
"""Der Waechter darf DB-Feldnamen nicht weiterreichen.
Die rohe Zeile hat `disc_type`/`created_at`. Wer sie ungewandelt
durchreicht, schickt dem UI lauter leere Pflichtfelder und leer ist
schlimmer als fehlend: `new Date(null)` ist der 1.1.1970, sieht also aus
wie eine Auskunft.
"""
roh = {"id": "j1", "disc_type": "bluray", "status": "running",
"created_at": "2026-08-29T12:07:02", "progress": 12}
kurz = _job_kurz(roh)
assert kurz["type"] is None and kurz["startTime"] is None
def test_ein_job_ereignis_traegt_alles_was_die_oberflaeche_braucht():
"""DER Waechter. Fehlt hier ein Feld, ist der Bildschirm beim Commander
leer ohne Fehlermeldung."""
kurz = _job_kurz(_db_zeile())
for feld in UI_PFLICHTFELDER:
assert feld in kurz, "%s fehlt — das UI muesste raten" % feld
assert kurz[feld] is not None, "%s ist leer" % feld
def test_der_typ_kommt_wirklich_durch():
"""Der konkrete Fehler: `type` fehlte, das UI rief .toUpperCase() darauf."""
assert _job_kurz(_db_zeile())["type"] == "bluray"
def test_fehlende_felder_werden_zu_None_statt_zu_werfen():
"""Eine unvollstaendige Zeile darf den Waechter nicht umbringen — er
laeuft in der Ereignisschleife des Servers."""
kurz = _job_kurz({"id": "j1"})
assert kurz["type"] is None and kurz["status"] is None
def test_die_kurzform_traegt_NUR_was_gebraucht_wird():
"""Kein Nachziehen der ganzen Zeile: `meta` und `endTime` gehoeren nicht
in ein Aenderungs-Ereignis, sonst feuert es bei jeder Kleinigkeit."""
kurz = _job_kurz(_db_zeile())
assert "meta" not in kurz
assert "endTime" not in kurz
def test_unveraenderliche_felder_erzeugen_keine_zusatz_ereignisse():
"""`type`, `device` und `startTime` aendern sich ueber die Lebenszeit
eines Jobs nie sie kosten also nichts, obwohl sie mitfahren."""
eins = _job_kurz(_db_zeile())
zwei = _job_kurz(dict(_db_zeile(), progress=13))
assert unterschiede({"j1": eins}, {"j1": zwei}) == [
("job.progress", "j1", zwei)]
+419
View File
@@ -0,0 +1,419 @@
"""Der Wächter: macht Änderungen des Workers zu Ereignissen.
## Das Problem, das er löst
Der Bus (`memory.py`) verteilt Ereignisse innerhalb EINES Prozesses. Der Worker
ist aber ein eigener Prozess im Docker-Betrieb sogar ein eigener Container,
im verteilten Betrieb ein anderer Rechner. Wenn dort der Fortschritt eines Rips
von 12 auf 13 Prozent geht, weiß die API davon nichts.
Drei Wege wären denkbar:
1. **Der Worker ruft die API an.** Dann hängt jeder Fortschrittswert an einer
HTTP-Verbindung, und ein kurzer API-Neustart ließe Ereignisse verschwinden.
Der Worker müsste außerdem wissen, wo die API steht im verteilten Betrieb
ist das eine zusätzliche Konfiguration, die schiefgehen kann.
2. **Ein gemeinsamer Broker (Redis Pub/Sub).** Sauber, und genau das kommt im
verteilten Betrieb (V2-5). Aber der Standalone-Betrieb hat bewusst KEIN
Redis das war der ganze Punkt von V2-2.
3. **Die Datenbank fragen.** Sie ist ohnehin die Wahrheit über den Job-Zustand
(KONZEPT-V2.md § 3.1), sie ist in JEDEM Betriebsmodus da, und der Worker
schreibt dort ohnehin hin.
Es wird Weg 3.
## „Ist das nicht wieder Polling?"
Doch aber an der Stelle, an der es billig ist, und genau einmal.
vorher N Browser-Tabs × 9 Endpunkte × alle 4-5 s über HTTP, durch nginx,
durch das Rate-Limit
jetzt 1 Abfrage/Sekunde lokal, indiziert,
im selben Netz wie die DB
Das Ziel war nie nirgendwo mehr nachfragen", sondern: **der Browser fragt
nicht mehr nach.** Ein offener Tab verursacht jetzt null Anfragen statt 121.
Und die Abfrage hier holt vier Spalten von höchstens ein paar Dutzend Zeilen.
## Was er NICHT tut
Er schickt keinen Zustand über den Bus, sondern nur die Nachricht, DASS sich
etwas geändert hat, plus die geänderten Felder. Wer den vollen Zustand will,
holt sich einen Snapshot. Sonst wäre ein verpasstes Ereignis wieder eine
Aussage über die Welt.
## Und wenn er stirbt?
Dann steht das UI still, ohne es zu merken der schlimmste Fall. Deshalb:
Jeder Fehler wird protokolliert UND als `system.notice` gesendet, und
`lebt_seit_sekunden()` macht sein Alter abfragbar. Das ist die Lehre aus
AGENTS.md: Ein Hintergrund-Prozess, der still scheitert, ist schlimmer als
einer, der laut scheitert" — dort hatte ein `except Exception: pass` in einer
Vorrats-Schleife eine Stunde gekostet.
"""
import asyncio
import time
# Wie oft nachgesehen wird. Eine Sekunde ist zugleich die Drosselung der
# Fortschritts-Ereignisse (KONZEPT-V2.md § 6.3: höchstens 1/s) — schneller
# könnte kein Auge folgen, und HandBrake meldet ohnehin nicht öfter.
TAKT_SEKUNDEN = 1.0
# Nach einem Fehler wird langsamer nachgesehen, damit ein dauerhaft kaputter
# Zustand (DB weg) nicht jede Sekunde eine Meldung erzeugt.
FEHLER_TAKT_SEKUNDEN = 5.0
# Laufwerke seltener: ein ioctl kostet mehr als ein SELECT, und eine Disc
# wird nicht mehrmals pro Sekunde gewechselt. Drei Sekunden entsprechen dem
# Takt, den die Disc-Wache in v1 schon hatte.
LAUFWERKS_TAKT_SEKUNDEN = 3.0
# Server-Zustand (Hardware, Worker-Liste, Ablageziele) noch seltener: Das
# aendert sich selten, und /storage-targets fasst Netzpfade an. 15 s
# entsprechen dem alten UI-Takt von 12 s, nur eben EINMAL statt je Tab.
SYSTEM_TAKT_SEKUNDEN = 15.0
# Endzustände: ab hier ist ein Job durch.
ENDE = ("completed", "failed", "canceled")
def _job_kurz(zeile: dict) -> dict:
"""Nur die Felder, deren Änderung ein Ereignis wert ist.
## Warum `type` dazugehört (Befund des Commanders, 29.08.2026)
> wenn man auf rippen starten klickt passiert irgendwas, was das Programm
> nicht mag. Der Hintergrund ist einfach leer und er startet nix. Es gibt
> auch keine fehlermeldung."
Nachgestellt und in der Browser-Konsole gemessen:
TypeError: Cannot read properties of undefined (reading 'toUpperCase')
Der Ablauf: Der Job startet in Wahrheit sehr wohl (gemessen: `processing`,
12 %). Aber `job.created` trug nur `status`, `progress`, `title` und
`error` **keinen `type`**. Das UI fügt so einen halben Job in seine
Liste ein, das Live-Log liest `job.type.toUpperCase()`, und weil es keine
Fehlergrenze gab, riss der Fehler die GESAMTE Oberfläche mit. Leerer
Bildschirm, keine Meldung.
Der Fehler ist damit an drei Stellen zugleich repariert: Das Ereignis
trägt den Typ (hier), das UI verträgt sein Fehlen, und eine Fehlergrenze
fängt den nächsten Fall dieser Art ab.
**Ein Ereignis Job angelegt", das den halben Job weglässt, zwingt jeden
Empfänger zum Raten.** `type` und `device` ändern sich über die Lebenszeit
eines Jobs nie sie kosten hier nichts und ersparen dem Empfänger die
Frage, ob er gerade einen ganzen oder einen halben Job vor sich hat.
## ⚠️ NACHTRAG 29.08.2026 — beim ersten Anlauf falsch gemacht
Hier stand `zeile.get("startTime")` und `zeile.get("type")`. **Diese
Felder gibt es in der Datenbankzeile nicht.** Sie heißen dort `created_at`
und `disc_type`; die Übersetzung macht `_job_row_to_model` in `main.py`,
und sie bildet auch `running` auf `processing` ab.
Die Folge war schlimmer als das Problem davor: Statt eines fehlenden
Feldes kam ein leeres. Im Bildschirmfoto des Commanders stand
TYP DISC" STARTZEIT „1.1.1970, 01:00:00" STATUS running"
`new Date(null)` ist der 1. Januar 1970, und DISC" war der Rückfall,
den ich zwei Stunden vorher für genau diesen Fall eingebaut hatte. Aus
offensichtlich kaputt" war „sieht plausibel aus" geworden.
Und mein Test hat es NICHT gefunden, weil ich seine Beispielzeile selbst
geschrieben habe mit meinen erfundenen Feldnamen. Ein Test, der dieselbe
Annahme macht wie der Code, prüft nichts. Deshalb wird die Zeile jetzt
zuerst durch dieselbe Umwandlung geschickt, die auch `/jobs` benutzt.
"""
return {
"type": zeile.get("type"),
"device": zeile.get("device"),
"startTime": zeile.get("startTime"),
"status": zeile.get("status"),
"progress": zeile.get("progress"),
"title": zeile.get("title"),
"error": zeile.get("error"),
}
#: Was ein Job-Ereignis mitbringen MUSS, damit die Oberflaeche ihn zeichnen
#: kann, ohne zu raten. Gegengeprueft von test_waechter.py — es gibt kein
#: gemeinsames Typsystem zwischen Python und dem UI, also steht der Vertrag
#: hier und wird mechanisch bewacht.
#:
#: `startTime` steht dabei, weil ohne ihn in der Jobliste „Invalid Date"
#: stand (am 29.08.2026 im Browser gesehen), bis Sekunden spaeter der
#: naechste Schnappschuss kam.
UI_PFLICHTFELDER = ("type", "startTime", "status", "progress")
def unterschiede(vorher: dict, jetzt: dict) -> list:
"""Was hat sich geändert? (pure Funktion — deshalb testbar ohne DB)
Gibt eine Liste von `(typ, job_id, daten)` zurück. Die Reihenfolge ist
festgelegt: erst neue Jobs, dann Änderungen, dann verschwundene. So sieht
ein Client einen Job nie geändert", bevor er ihn kennt.
"""
# DREI getrennte Durchläufe, nicht einer. Ein einzelner Durchlauf über
# `jetzt` würde die Reihenfolge der Einfügung durchreichen — und dann käme
# ein „geändert" vor dem „neu" eines anderen Jobs. Ein Client, der ein
# job.progress für einen Job bekommt, den er nicht kennt, kann damit
# nichts anfangen: Er müsste raten oder einen Snapshot nachfordern.
# (Der Test dafür hat genau diesen Fehler in der ersten Fassung gefunden.)
neu = []
geaendert = []
for job_id, stand in jetzt.items():
if job_id not in vorher:
neu.append(("job.created", job_id, stand))
continue
alt = vorher[job_id]
if alt == stand:
continue
if alt.get("status") != stand.get("status"):
typ = "job.finished" if stand.get("status") in ENDE else "job.phase"
geaendert.append((typ, job_id, {
"von": alt.get("status"), "nach": stand.get("status"),
**stand,
}))
elif alt.get("progress") != stand.get("progress"):
geaendert.append(("job.progress", job_id, stand))
else:
geaendert.append(("job.phase", job_id, stand))
verschwunden = [
("job.finished", job_id, {"status": "geloescht"})
for job_id in vorher if job_id not in jetzt
]
return neu + geaendert + verschwunden
def laufwerks_unterschiede(vorher: dict, jetzt: dict) -> list:
"""Was hat sich an den Laufwerken geändert? (pure Funktion)
Der Disc-Wechsel bekommt eigene Ereignisse (`disc.inserted` /
`disc.removed`), weil das UI darauf anders reagiert als auf eine bloße
Zustandsänderung: Bei einer eingelegten Disc springt der Rip-Dialog auf.
"""
ereignisse = []
for geraet, stand in jetzt.items():
alt = vorher.get(geraet)
if alt == stand:
continue
if alt is not None:
leer_vorher = alt.get("status") != "ready"
leer_jetzt = stand.get("status") != "ready"
if leer_vorher and not leer_jetzt:
ereignisse.append(("disc.inserted", geraet, stand))
elif not leer_vorher and leer_jetzt:
ereignisse.append(("disc.removed", geraet, stand))
ereignisse.append(("drive.changed", geraet, stand))
for geraet in vorher:
if geraet not in jetzt:
ereignisse.append(("drive.changed", geraet, {"status": "weg"}))
return ereignisse
class Waechter:
"""Sieht in der Datenbank nach und meldet Änderungen an den Bus.
`laufwerke_lesen` ist eine Funktion ohne Argumente, die die Laufwerksliste
liefert (in der API: `device_info` über alle gefundenen Geräte). Sie wird
eingespritzt statt importiert so ist der Wächter ohne echtes Laufwerk
testbar, und im Standalone-Betrieb kann derselbe Wächter den
Windows-Treiber benutzen.
Laufwerke werden SELTENER abgefragt als Jobs: Ein `ioctl` auf einem
optischen Laufwerk kostet spürbar mehr als ein SELECT, und eine Disc wird
nicht mehrmals pro Sekunde gewechselt. Drei Sekunden entsprechen dem Takt,
den die alte Disc-Wache hatte.
"""
def __init__(self, store, bus, takt: float = TAKT_SEKUNDEN,
laufwerke_lesen=None, laufwerks_takt: float = LAUFWERKS_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._bus = bus
self._takt = takt
self._laufwerke_lesen = laufwerke_lesen
self._laufwerks_takt = laufwerks_takt
self._jobs: dict = {}
self._laufwerke: dict = {}
# None heißt „noch nie geprüft" — NICHT 0.0. `time.monotonic()` zählt
# ab einem beliebigen Nullpunkt; unter Linux ist das der Start der
# Maschine. Auf einem frisch gestarteten System ist `jetzt - 0.0`
# deshalb KLEIN, und die erste Abfrage fiel schlicht aus.
#
# Auf dem Linux-Runner der Ampel genau so passiert (28.08.2026): null
# Laufwerks-Abfragen statt einer. Unter Windows fällt es nie auf, weil
# der Zähler dort schon beim ersten Start groß ist — ein Fehler, den
# nur die andere Plattform zeigt.
self._laufwerke_geprueft = None
self._system_lesen = system_lesen
self._system_takt = system_takt
self._system = None
self._system_geprueft = None
self._letzte_log_id = None
self._letzter_lauf = 0.0
self._fehler_in_folge = 0
self._laeuft = False
# ── Abfragbarkeit: wer einen Vorrat anlegt, macht sein Alter sichtbar ──
def lebt_seit_sekunden(self) -> float:
"""Wie lange ist der letzte erfolgreiche Durchlauf her?
Gedacht für `GET /health/vorraete`. Ein Wächter, der still gestorben
ist, sieht von außen genauso aus wie einer, bei dem gerade nichts
passiert es sei denn, man kann sein Alter erfragen.
"""
if not self._letzter_lauf:
return -1.0
return time.monotonic() - self._letzter_lauf
@property
def gesund(self) -> bool:
alter = self.lebt_seit_sekunden()
return alter >= 0 and alter < self._takt * 10
# ── Ein Durchlauf ───────────────────────────────────────────────────
def einmal(self) -> int:
"""Einmal nachsehen und melden. Gibt die Zahl der Ereignisse zurück.
Bewusst synchron und getrennt von der Schleife: So lässt sie sich
ohne asyncio testen.
"""
gesendet = 0
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
if not erster_lauf:
for typ, job_id, daten in unterschiede(self._jobs, jetzt):
self._bus.senden(typ, daten, entitaet="job", entitaet_id=job_id)
gesendet += 1
self._jobs = jetzt
zeilen = self._store.list_logs(limit=50)
if erster_lauf:
self._letzte_log_id = zeilen[0]["id"] if zeilen else 0
else:
neue = [z for z in zeilen if z["id"] > (self._letzte_log_id or 0)]
for zeile in reversed(neue): # älteste zuerst
# Feldnamen wie in der Datenbank (level/source/message) — nicht
# uebersetzt. Wer sie umbenennt, muss das UI und /logs mitziehen,
# und dann stehen zwei Formen nebeneinander.
self._bus.senden("log.line", {
"level": zeile.get("level"),
"source": zeile.get("source"),
"message": zeile.get("message"),
}, entitaet="log", entitaet_id=str(zeile["id"]))
gesendet += 1
if neue:
self._letzte_log_id = max(z["id"] for z in neue)
gesendet += self._laufwerke_pruefen(erster_lauf)
gesendet += self._system_pruefen()
self._letzter_lauf = time.monotonic()
return gesendet
def _system_pruefen(self) -> int:
"""Server-Zustand im langsamen Takt.
Anders als bei Jobs wird hier NICHT auf Unterschiede geprüft, sondern
der Stand jedes Mal geschickt: Es geht um Messwerte (freier Platz,
Auslastung), die sich praktisch immer ändern ein Vergleich würde nur
Rechenzeit kosten und trotzdem jedes Mal geändert" sagen.
Fehler werden geschluckt (siehe _laufwerke_pruefen): /storage-targets
fasst Netzpfade an, und ein weggebrochenes NAS darf den Job-Fortschritt
nicht mitnehmen.
"""
if self._system_lesen is None:
return 0
jetzt = time.monotonic()
if (self._system_geprueft is not None
and jetzt - self._system_geprueft < self._system_takt):
return 0
self._system_geprueft = jetzt
try:
stand = self._system_lesen()
except Exception:
return 0
if not stand:
return 0
self._system = stand
self._bus.senden("system.status", stand, entitaet="system")
return 1
def _laufwerke_pruefen(self, erster_lauf: bool) -> int:
"""Laufwerke im eigenen, langsameren Takt.
Fehler werden hier BEWUSST geschluckt und nicht weitergereicht: Ein
hängendes oder defektes Laufwerk darf nicht den ganzen Wächter
anhalten sonst käme auch kein Job-Fortschritt mehr durch. Das UI
behält dann seinen letzten Laufwerksstand, was richtig ist: konnte
nicht nachsehen" ist keine Aussage über die Welt.
"""
if self._laufwerke_lesen is None:
return 0
jetzt = time.monotonic()
if (self._laufwerke_geprueft is not None
and jetzt - self._laufwerke_geprueft < self._laufwerks_takt):
return 0
self._laufwerke_geprueft = jetzt
try:
stand = {g["path"]: g for g in self._laufwerke_lesen()}
except Exception:
return 0
gesendet = 0
if not erster_lauf and self._laufwerke:
for typ, geraet, daten in laufwerks_unterschiede(self._laufwerke, stand):
self._bus.senden(typ, daten, entitaet="drive", entitaet_id=geraet)
gesendet += 1
self._laufwerke = stand
return gesendet
# ── Die Schleife ────────────────────────────────────────────────────
async def schleife(self) -> None:
"""Läuft, bis sie abgebrochen wird. Meldet Fehler LAUT."""
self._laeuft = True
while self._laeuft:
try:
await asyncio.to_thread(self.einmal)
self._fehler_in_folge = 0
await asyncio.sleep(self._takt)
except asyncio.CancelledError:
raise
except Exception as e:
self._fehler_in_folge += 1
# Nur beim ERSTEN Fehler melden und danach alle zehn: Ein
# dauerhaft kaputter Zustand soll nicht das Log zumüllen —
# aber schweigen darf er auch nicht.
if self._fehler_in_folge == 1 or self._fehler_in_folge % 10 == 0:
text = (f"Ereignis-Wächter: {e} "
f"({self._fehler_in_folge}. Fehlschlag in Folge)")
try:
self._store.add_log("error", "waechter", text)
except Exception:
pass # DB weg — dann geht wenigstens der Bus
try:
self._bus.senden("system.notice",
{"level": "error", "text": text})
except Exception:
pass
await asyncio.sleep(FEHLER_TAKT_SEKUNDEN)
def stoppen(self) -> None:
self._laeuft = False
+269
View File
@@ -0,0 +1,269 @@
"""Konfiguration — eine Präzedenz, in allen drei Betriebsarten dieselbe.
CLI-Flag > Umgebungsvariable (RIPPY_*) > Konfigurationsdatei > Vorgabe
## Warum das ein eigenes Modul ist
v1 liest Einstellungen an drei verschiedenen Stellen und auf drei Arten: aus
`os.getenv` (verstreut über tasks.py, ripping.py, db.py), aus der
`settings`-Tabelle (`db.get_settings()`) und aus der `.env` über Compose. Wer
wissen will, woher ein Wert wirklich kommt, muss alle drei durchsuchen und
`.env.example` warnt an zwei Stellen davor, dass ein vorbelegter Wert gesund
aussieht" und trotzdem falsch ist.
v2 hat drei Betriebsarten, und jede hat eine andere natürliche Quelle:
Docker Umgebungsvariablen (kein Dateisystem-Zugriff nötig)
Headless /etc/rippy/rippy.toml (dort erwartet man Konfiguration)
Windows %ProgramData%\\Rippy\\rippy.toml (der Installer schreibt sie)
Ohne eine festgelegte Reihenfolge wäre nicht entscheidbar, wer gewinnt. Mit ihr
ist es eine Zeile Doku und ein Test.
## Verschachtelung in Umgebungsvariablen
`[store] pfad = ""` heißt `RIPPY_STORE__PFAD` doppelter Unterstrich als
Trenner. Der einfache scheidet aus, weil Schlüssel selbst welche enthalten
(`poll_sekunden`).
## Was hier NICHT hingehört
Laufzeit-Einstellungen, die der Nutzer im Browser ändert (Presets,
Sprachwünsche, Medienserver-Adresse), bleiben in der `settings`-Tabelle. Hier
steht nur, was VOR dem Start feststehen muss: wo die Datenbank liegt, welcher
Port, welche Treiber. Faustregel: Wenn es einen Neustart braucht, gehört es
hierher; wenn nicht, in die Datenbank.
"""
import os
from typing import Any
try: # Python 3.11+
import tomllib
except ModuleNotFoundError: # 3.10 — nur der Entwicklungsrechner
tomllib = None
PRAEFIX = "RIPPY_"
TRENNER = "__"
# Die Vorgaben. Sie sind zugleich die Liste dessen, was es überhaupt gibt —
# ein Schlüssel, der hier fehlt, wird aus Datei und Umgebung NICHT übernommen
# (siehe `_zusammenfuehren`). Das fängt Tippfehler ab, statt sie stillschweigend
# zu ignorieren: Ein `RIPPY_STORE__PAFD` soll auffallen, nicht wirkungslos sein.
VORGABEN: dict = {
"profil": "standalone", # standalone | api | node
"server": {
"host": "0.0.0.0",
"port": 7788,
"ui": True, # statische UI-Assets mitausliefern
},
"store": {
"treiber": "sqlite", # sqlite | postgres
"pfad": "", # bei sqlite; leer = Vorgabe je Plattform
"url": "", # bei postgres
},
"queue": {
"treiber": "lokal", # lokal | celery
"broker": "", # bei celery
"rip_slots": 1,
"encode_slots": 2,
"knoten": "", # Anzeigename; leer = Rechnername
},
"drives": {
"erkennung": "poll", # udev | poll
"poll_sekunden": 3,
"auto_auswurf": True,
},
"storage": {
"medien": "",
"temp": "",
"pfad_timeout_sekunden": 5,
},
"metadata": {
"tmdb_key": "",
"tvdb_key": "",
"omdb_key": "",
"sprachen": ["de", "en"],
},
"transcode": {
"engine": "handbrake", # handbrake | ffmpeg
"encoder": "auto",
},
"keys": {
# Bezugsadresse für Stufe 2 der Schlüsselkette (KONZEPT.md § 10,
# Commander-Entscheid 28.08.2026). BEWUSST LEER: Eine vorbelegte
# Adresse, die irgendwann tot ist, lässt die Konfiguration gesund
# aussehen und scheitert erst im Betrieb — genau die Falle, die in
# .env.example beim MakeMKV-Download dokumentiert ist. Ohne Eintrag
# ist Stufe 2 schlicht übersprungen.
"quelle_url": "",
},
}
def _als_typ(wert: str, vorbild: Any) -> Any:
"""String aus der Umgebung auf den Typ der Vorgabe bringen.
Ohne das wäre `RIPPY_SERVER__PORT=8000` der String "8000", und ein
Vergleich oder eine Bindung an den Port schlüge fehl mit einer Meldung,
die nach allem Möglichen aussieht, nur nicht nach einem Typfehler.
"""
if isinstance(vorbild, bool):
return wert.strip().lower() in ("1", "true", "ja", "yes", "on")
if isinstance(vorbild, int):
return int(wert)
if isinstance(vorbild, list):
return [teil.strip() for teil in wert.split(",") if teil.strip()]
return wert
def aus_umgebung(umgebung: dict = None) -> dict:
"""Baut aus RIPPY_*-Variablen einen verschachtelten dict.
Unbekannte Schlüssel werden ÜBERGANGEN, nicht übernommen sie tauchen in
`unbekannte_schluessel()` auf, damit ein Tippfehler sichtbar wird statt
wirkungslos zu bleiben.
"""
umgebung = os.environ if umgebung is None else umgebung
ergebnis: dict = {}
for name, wert in umgebung.items():
if not name.startswith(PRAEFIX):
continue
pfad = name[len(PRAEFIX):].lower().split(TRENNER)
vorbild = VORGABEN
for teil in pfad:
if not isinstance(vorbild, dict) or teil not in vorbild:
vorbild = None
break
vorbild = vorbild[teil]
if vorbild is None or isinstance(vorbild, dict):
continue # unbekannt oder kein Blattwert
ziel = ergebnis
for teil in pfad[:-1]:
ziel = ziel.setdefault(teil, {})
ziel[pfad[-1]] = _als_typ(wert, vorbild)
return ergebnis
def aus_datei(pfad: str) -> dict:
"""Liest eine TOML-Datei. Fehlt sie, ist das kein Fehler — leer zurück.
Eine KAPUTTE Datei ist dagegen sehr wohl ein Fehler und fliegt hoch: Wer
seine Konfiguration verschreibt, soll das beim Start erfahren und nicht
stundenlang rätseln, warum eine Einstellung nicht greift.
"""
if not pfad or not os.path.isfile(pfad):
return {}
if tomllib is None:
raise RuntimeError(
"TOML-Konfiguration braucht Python 3.11 oder neuer "
"(auf 3.10 fehlt tomllib). Bis dahin die RIPPY_*-Variablen nutzen."
)
with open(pfad, "rb") as f:
return tomllib.load(f)
def _zusammenfuehren(basis: dict, oben: dict, vorbild: dict = None) -> dict:
"""Legt `oben` über `basis` — rekursiv, und nur bekannte Schlüssel."""
vorbild = VORGABEN if vorbild is None else vorbild
ergebnis = dict(basis)
for schluessel, wert in (oben or {}).items():
if schluessel not in vorbild:
continue
if isinstance(vorbild[schluessel], dict) and isinstance(wert, dict):
ergebnis[schluessel] = _zusammenfuehren(
ergebnis.get(schluessel, {}), wert, vorbild[schluessel])
else:
ergebnis[schluessel] = wert
return ergebnis
def unbekannte_schluessel(quelle: dict, vorbild: dict = None, pfad: str = "") -> list:
"""Alles, was `_zusammenfuehren` verworfen hätte — für `rippy doctor`.
Ein stillschweigend ignorierter Schlüssel ist dieselbe Fehlerklasse wie ein
verschluckter Fehler: Die Konfiguration sieht gesund aus, wirkt aber nicht.
"""
vorbild = VORGABEN if vorbild is None else vorbild
gefunden = []
for schluessel, wert in (quelle or {}).items():
voll = f"{pfad}.{schluessel}" if pfad else schluessel
if schluessel not in vorbild:
gefunden.append(voll)
elif isinstance(vorbild[schluessel], dict) and isinstance(wert, dict):
gefunden.extend(unbekannte_schluessel(wert, vorbild[schluessel], voll))
return gefunden
def laden(datei: str = None, flags: dict = None, umgebung: dict = None) -> dict:
"""Die vollständige Konfiguration nach der Präzedenz oben."""
werte = _zusammenfuehren(VORGABEN, aus_datei(datei) if datei else {})
werte = _zusammenfuehren(werte, aus_umgebung(umgebung))
werte = _zusammenfuehren(werte, flags or {})
return werte
def datenbank_url(werte: dict, standard_pfad: str = None) -> str:
"""Übersetzt den store-Abschnitt in eine SQLAlchemy-URL.
Für `store.verbinden()` damit die Entscheidung welche Datenbank" an
genau EINER Stelle getroffen wird und nicht an jeder Aufrufstelle neu.
"""
store = werte.get("store", {})
if store.get("treiber") == "postgres":
url = (store.get("url") or "").strip()
if not url:
raise ValueError(
"store.treiber ist 'postgres', aber store.url ist leer. "
"Ohne Adresse kann Rippy die Datenbank nicht finden."
)
return url
pfad = (store.get("pfad") or "").strip() or standard_pfad or standard_datenbankpfad()
return "sqlite:///" + pfad.replace("\\", "/")
def standard_datenbankpfad() -> str:
r"""Wo die SQLite-Datei liegt, wenn niemand etwas anderes sagt.
## Warum unter Windows NICHT mehr %ProgramData% (28.08.2026)
Hier stand `%ProgramData%\Rippy\rippy.db`, mit der Begründung: Ein
Programmordner ist unter Windows für einen DIENST nicht zuverlässig
beschreibbar." Das stimmte — für den Windows-Dienst, den es nie gab.
Rippy läuft als der angemeldete Benutzer (Autostart unter `HKCU`, siehe
`windows_app.py`), und der Grund ist damit hinfällig.
Geblieben war der Schaden. Der Commander meldete: **Es fehlt der 1st run
wizzard wenn man es installiert."** Nachgemessen:
Installation %LOCALAPPDATA%\Rippy (wird deinstalliert)
Datenbank %ProgramData%\Rippy (bleibt liegen)
Eine frische" Installation erbte also die alte Datenbank samt
`setup.done = true`, und der Ersteinrichtungs-Assistent erschien nie
wieder. Ein Deinstallieren, das den Zustand stehen lässt, ist kein
Deinstallieren.
Jetzt liegt alles, was Rippy gehört, in EINEM Ordner: Programm, UI,
Werkzeuge, Protokoll, WebView2-Zwischenspeicher und die Datenbank.
Unter Linux bleibt `/var/lib/rippy`: Dort läuft Rippy wirklich als
Systemdienst, und der FHS sagt genau das.
"""
if os.name == "nt":
basis = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(basis, "Rippy", "rippy.db")
return "/var/lib/rippy/rippy.db"
def alter_datenbankpfad() -> str:
r"""Der Ort VOR dem 28.08.2026 — nur noch zum Umziehen und Aufräumen.
Wer Rippy schon installiert hatte, hat seine Daten dort. Sie kommentarlos
liegen zu lassen wäre ein stiller Verlust; sie zu löschen wäre schlimmer.
`windows_app.datenbank_umziehen()` holt sie ab.
"""
if os.name != "nt":
return ""
basis = os.environ.get("PROGRAMDATA") or ""
return os.path.join(basis, "Rippy", "rippy.db") if basis else ""
+1
View File
@@ -0,0 +1 @@
"""Kern-Bausteine: Zustand, Phasen, Benachrichtigungen (KONZEPT-V2.md § 2.1)."""
@@ -4,9 +4,9 @@ Bis 24.07. war das notificationWebhook-Setting ein Placebo: das UI speicherte
die URL, aber NICHTS hat je gesendet. Jetzt meldet der Worker Job-Ende
(fertig/fehlgeschlagen/abgebrochen) und die API bietet einen Test-Endpoint.
Das Modul existiert bewusst identisch in API und Worker
(docker/api/notify.py) es gibt kein geteiltes Paket zwischen den Containern.
Wer es ändert, ändert BEIDE Dateien.
Bis Etappe V2-0 (28.08.2026) lag das Modul zweimal im Repo (docker/api und
docker/worker), weil es kein geteiltes Paket gab. Seitdem gibt es `rippy`
die Datei existiert genau einmal, API und Worker importieren dieselbe.
Payload-Formate (dokumentiert, nicht geraten AGENTS Regel D):
- Discord: POST JSON {"content": "..."} discord.com/developers/docs/resources/webhook
@@ -1,6 +1,6 @@
"""Tests für die Webhook-Benachrichtigungen (Typ-Erkennung + Payload-Bau)."""
from notify import baue_payload, erkenne_webhook_typ
from rippy.core.notify import baue_payload, erkenne_webhook_typ
def test_discord_wird_an_url_erkannt():
+55
View File
@@ -0,0 +1,55 @@
"""Laufwerks-Schicht: Erkennung, Disc-Status, Verriegeln, Auswurf.
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen (KONZEPT-V2.md § 5).
## Wer wählt den Treiber aus
`treiber()`. Und zwar EINMAL, hier nicht an jeder Aufrufstelle.
Bis Etappe V2-4 importierte die API `rippy.drives.linux` direkt. Das war
solange harmlos, wie Rippy nur in Containern lief. Für den nativen
Windows-Betrieb ist es der Blocker: `linux.py` zieht über `detection.py` das
Modul `fcntl` nach, und das gibt es unter Windows nicht. Gemessen am
28.08.2026 in einer frischen Python-3.12-Umgebung:
>>> import main
ModuleNotFoundError: No module named 'fcntl'
Genau dafür ist der Port `rippy.ports.Drives` da: Der Aufrufer sagt, WAS er
will, und bekommt den Treiber, der auf DIESER Maschine passt.
## Warum der Import in der Funktion steht
Ein `import` auf Modulebene würde beide Treiber laden und damit unter
Windows wieder `fcntl` verlangen. Der Import muss also genau dann passieren,
wenn klar ist, welcher gebraucht wird.
"""
import sys
def treiber(plattform: str = None):
"""Der Laufwerks-Treiber für diese Maschine.
`plattform` ist nur zum Testen da (Werte wie `sys.platform`): So lässt
sich die AUSWAHL prüfen, ohne die Maschine zu wechseln.
Beide Treiber bieten dieselben Namen an `list_optical_devices`,
`device_info`, `drive_status`, `detect_disc_type`, `disc_size_bytes`,
`eject`, `auswerfen_versuchen`, `verriegeln`. Wer hier etwas ergänzt,
ergänzt es in BEIDEN; `test_treiberwahl.py` wacht darüber.
"""
plattform = plattform if plattform is not None else sys.platform
if plattform.startswith("win"):
# Der Windows-Treiber ist mit der Standalone-App gegangen (Rippy v5,
# Entscheid 7). Der Platzhalter hält Importe und Test-Sammlung auf
# Windows-Entwicklungsrechnern am Leben; jede BENUTZUNG wirft mit
# Klartext — siehe kein_windows.py.
from rippy.drives import kein_windows
return kein_windows
from rippy.drives import linux
return linux
def ist_windows(plattform: str = None) -> bool:
return (plattform if plattform is not None else sys.platform).startswith("win")
+68
View File
@@ -0,0 +1,68 @@
"""Konstanten und reine Logik rund um optische Laufwerke — OHNE `fcntl`.
## Warum es diese Datei gibt (Etappe V2-1, 28.08.2026)
Zwei Gründe, beide praktisch:
**1. `fcntl` gibt es nur unter Linux.** Solange die ioctl-Nummern in
`detection.py` standen, zog jeder Import dieser Nummern auch `fcntl` nach. Als
in V2-1 der Auswurf-Treiber (`linux.py`) sie brauchte, war `ripping.py` auf
einmal unter Windows nicht mehr ladbar und der native Windows-Worker lädt
genau dieses Modul. Die Tests haben es sofort gefangen; ohne die Trennung hier
wäre es beim nächsten Windows-Start aufgefallen.
**2. Die Zuordnung ist reine Logik und gehört jedem.** `classify()` ist eine
Funktion von zwei Zahlen auf einen String. Sie hing nur an Linux, weil sie in
derselben Datei stand wie die ioctls ihre Tests liefen deshalb ausschließlich
in der Ampel und nie auf dem Entwicklungsrechner (Tests, die dort landen, sind
erst nach dem Push bewiesen"). Jetzt laufen sie überall. Der Windows-Treiber
aus V2-4 benutzt dieselbe Funktion: Auch dort wird nach Disc-Status und Größe
eingeordnet, nur die Beschaffung der beiden Zahlen unterscheidet sich.
Die Werte stammen aus der Kernel-UAPI (`include/uapi/linux/cdrom.h`, für
BLKGETSIZE64 aus `include/uapi/linux/fs.h`) und sind seit Jahrzehnten stabil.
"""
# ── include/uapi/linux/cdrom.h ──────────────────────────────────────────
CDROMEJECT = 0x5309
CDROMCLOSETRAY = 0x5319
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDROM_LOCKDOOR = 0x5329 # 1 = Tür verriegeln, 0 = entriegeln
# Antworten von CDROM_DRIVE_STATUS
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
# Antworten von CDROM_DISC_STATUS
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# ── Schwellen für die Typ-Zuordnung ─────────────────────────────────────
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung: Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
@@ -14,32 +14,27 @@ import os
import struct
from fcntl import ioctl
# include/uapi/linux/cdrom.h
CDROM_DRIVE_STATUS = 0x5326
CDROM_DISC_STATUS = 0x5327
CDS_NO_DISC = 1
CDS_TRAY_OPEN = 2
CDS_DRIVE_NOT_READY = 3
CDS_DISC_OK = 4
CDS_AUDIO = 100
CDS_DATA_1 = 101
CDS_DATA_2 = 102
CDS_XA_2_1 = 103
CDS_XA_2_2 = 104
CDS_MIXED = 105
# include/uapi/linux/fs.h: BLKGETSIZE64 = _IOR(0x12, 114, size_t) auf 64-bit
BLKGETSIZE64 = 0x80081272
# Eine DVD9 fasst ~8,5 GB; Blu-ray beginnt bei 25 GB (Single Layer).
# Alles ab 10 GB ist also sicher eine Blu-ray.
BLURAY_MIN_BYTES = 10 * 1024**3
# 4K-UHD-Discs sind BD-66 (66 GB) oder BD-100 — eine normale BD-50 bleibt
# unter ~47 GiB. Ab 55 GiB ist es also sicher eine UHD. (Seltene 50-GB-UHDs
# laufen als "bluray" — der Rip-Weg ist ohnehin identisch.)
UHD_MIN_BYTES = 55 * 1024**3
# Konstanten und die reine Zuordnung leben in cdrom.py — ohne fcntl, damit
# der Windows-Treiber (V2-4) und der native Windows-Worker sie benutzen
# koennen. Hier werden sie weiter angeboten, weil Aufrufstellen sie so kennen.
from rippy.drives.cdrom import ( # noqa: F401
BLKGETSIZE64,
BLURAY_MIN_BYTES,
CDROM_DISC_STATUS,
CDROM_DRIVE_STATUS,
CDS_AUDIO,
CDS_DATA_1,
CDS_DATA_2,
CDS_DISC_OK,
CDS_DRIVE_NOT_READY,
CDS_MIXED,
CDS_NO_DISC,
CDS_TRAY_OPEN,
CDS_XA_2_1,
CDS_XA_2_2,
UHD_MIN_BYTES,
classify,
)
def _open_nonblock(device_path: str) -> int:
@@ -76,17 +71,6 @@ def disc_size_bytes(device_path: str) -> int:
os.close(fd)
def classify(disc_status_code: int, size_bytes: int) -> str:
"""Pure Zuordnung (testbar): Disc-Status + Größe → cd | dvd | bluray | uhd | unknown."""
if disc_status_code in (CDS_AUDIO, CDS_MIXED):
return "cd"
if disc_status_code in (CDS_DATA_1, CDS_DATA_2, CDS_XA_2_1, CDS_XA_2_2):
if size_bytes >= UHD_MIN_BYTES:
return "uhd"
return "bluray" if size_bytes >= BLURAY_MIN_BYTES else "dvd"
return "unknown"
def detect_disc_type(device_path: str) -> str:
"""Erkennt den Typ der eingelegten Disc; 'no_disc' wenn keine drin ist."""
try:
+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
+305
View File
@@ -0,0 +1,305 @@
"""Laufwerks-Treiber für Linux: finden, Zustand lesen, verriegeln, auswerfen.
Erfüllt `rippy.ports.Drives`. Der Windows-Treiber (Win32 statt ioctl) kommt in
Etappe V2-4 und implementiert denselben Port.
## Diese Datei war zwei — mit ZWEI VERSCHIEDENEN VERTRÄGEN (V2-1, 28.08.2026)
Der Auswurf existierte doppelt, und das war die unangenehmere Sorte Doppelung,
weil die beiden Fassungen sich nicht nur wiederholten, sondern unterschiedlich
ANTWORTETEN:
docker/api/devices.py eject(pfad) wirft OSError
docker/worker/ripping.py wirf_disc_aus(pfad) gibt False zurück, wirft nie
Beide Verhalten sind richtig für ihre Seite. Die API will einen Fehler, den
sie dem Browser zeigen kann. Der Worker will einen Rip nicht daran scheitern
lassen, dass die Schublade klemmt, und er will die ioctls in Tests einspritzen
können, ohne ein echtes Laufwerk zu haben.
Deshalb liegt hier jetzt EINE Mechanik (`auswerfen_mit_grund`) und darüber
beide Verträge unverändert. Was verschwindet, ist die dritte Kopie der
ioctl-Nummern und der Ablauf-Reihenfolge.
## Warum überhaupt entriegelt wird (Befund 26.07.2026, am System gemessen)
Der Commander meldete: Der Button gibt es in den Settings, aber es passiert
nicht, das Laufwerk geht nicht auf." Nachgestellt:
CDROMEJECT allein -> Erfolg gemeldet
CDROM_DRIVE_STATUS danach -> 4 (Disc ist noch drin)
CDROM_LOCKDOOR 0 + CDROMEJECT -> Status 2 (Schublade offen)
MakeMKV verriegelt die Tür während des Rips und entriegelt sie nicht wieder.
Ein verriegeltes Laufwerk quittiert den Auswurf **mit Erfolg** und tut nichts.
Deshalb gilt für jeden Treiber dieses Ports: erst entriegeln, dann auswerfen,
dann NACHSEHEN. Ein Rückgabewert ist kein Beweis, wo die Wirkung prüfbar ist.
"""
import glob
import os
# NUR aus cdrom.py — das Modul kommt ohne `fcntl` aus. Wuerde hier
# detection importiert, waere linux.py (und ueber ripping.py auch der native
# Windows-Worker) unter Windows nicht mehr ladbar. Genau das ist beim Bau
# dieser Etappe passiert; die Tests haben es gefangen.
from rippy.drives.cdrom import ( # noqa: F401
CDROM_DRIVE_STATUS,
CDROM_LOCKDOOR,
CDROMCLOSETRAY,
CDROMEJECT,
CDS_DISC_OK,
CDS_DRIVE_NOT_READY,
CDS_NO_DISC,
CDS_TRAY_OPEN,
)
# Was `detection.py` beisteuert — auf Anfrage, nicht beim Import.
#
# ## Warum das hier stehen MUSS (Ampel rot seit 28.08.2026, Lauf 181184)
#
# `docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
# Der Windows-Treiber hat die Funktion; dieses Modul hatte sie nicht mehr,
# seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
# wird. Folge: Unter Windows lief alles, auf Linux starb main.py beim Import
# mit `AttributeError: module 'rippy.drives.linux' has no attribute
# 'drive_status'` — **der API-Container wäre gar nicht hochgekommen**.
#
# Lokal fiel es nicht auf, weil hier Windows läuft und `treiber()` dann gar
# nicht zu diesem Modul greift. Genau dafür ist die Ampel da — und genau
# deshalb ist Regel A („grün, bevor irgendetwas fertig heißt") keine Formalie.
#
# PEP 562: `__getattr__` wird nur gefragt, wenn der Name nicht schon als
# Modul-Variable existiert. Damit bleibt `import rippy.drives.linux` unter
# Windows möglich (kein `fcntl` nötig), und wer die Funktionen wirklich
# BENUTZT, bekommt sie — oder einen lauten ImportError.
_AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
"detect_disc_type", "classify")
def __getattr__(name):
if name in _AUS_DETECTION:
from rippy.drives import detection
return getattr(detection, name)
raise AttributeError("module %r has no attribute %r" % (__name__, name))
# Wie lange auf die Schublade gewartet wird. Ein Laufwerk braucht dafür ein
# bis zwei Sekunden; fünf sind reichlich und blockieren nichts Wichtiges.
AUSWURF_WARTEN_SEKUNDEN = 5
# Ergebnisse von auswerfen_mit_grund
AUSWURF_OK = "ok"
AUSWURF_KEIN_ZUGRIFF = "kein-zugriff" # Gerät ließ sich nicht öffnen
AUSWURF_ABGELEHNT = "abgelehnt" # ioctl selbst scheiterte
AUSWURF_BLEIBT_DRIN = "bleibt-drin" # angenommen, aber Disc ist noch da
def _auswurf_geglueckt(status: int) -> bool:
"""Ist die Disc nach dem Auswurf wirklich draußen? (pure Funktion)
Sowohl Schublade offen" als auch „kein Datenträger" zählen: Ein
Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen CDS_NO_DISC.
"""
return status in (CDS_TRAY_OPEN, CDS_NO_DISC)
def auswerfen_mit_grund(device_path: str, ioctl_fn=None, oeffnen=None,
schliessen=None, warten=None):
"""Die gemeinsame Mechanik. Wirft NIE.
Rückgabe: `(grund, fehler)` `grund` ist eine der AUSWURF_*-Konstanten,
`fehler` der ursprüngliche OSError (oder None). Der Fehler wird
weitergereicht statt verschluckt, damit die API ihn unverändert
weiterwerfen kann; vorher stand dort ein eigener `os.open`-Aufruf nur
deshalb, weil diese Unterscheidung fehlte.
Die vier Parameter sind nur zum Testen einspritzbar (kein echtes Laufwerk).
"""
if ioctl_fn is None:
try:
from fcntl import ioctl
except ImportError: # Windows — dieser Worker rippt nie
return AUSWURF_KEIN_ZUGRIFF, None
ioctl_fn = ioctl
if warten is None:
import time
warten = time.sleep
oeffnen = oeffnen or (lambda p: os.open(p, os.O_RDONLY | os.O_NONBLOCK))
schliessen = schliessen or os.close
try:
fd = oeffnen(device_path)
except OSError as e:
return AUSWURF_KEIN_ZUGRIFF, e
try:
# Entriegeln ist der entscheidende Schritt. Scheitert er, wird der
# Auswurf trotzdem versucht — bei einem nicht verriegelten Laufwerk
# (oder einem, das das ioctl nicht kennt) klappt er ohnehin.
try:
ioctl_fn(fd, CDROM_LOCKDOOR, 0)
except OSError:
pass
try:
ioctl_fn(fd, CDROMEJECT, 0)
except OSError as e:
return AUSWURF_ABGELEHNT, e
# Nachsehen statt hoffen: Die Schublade braucht ein bis zwei Sekunden.
for _ in range(AUSWURF_WARTEN_SEKUNDEN):
try:
if _auswurf_geglueckt(ioctl_fn(fd, CDROM_DRIVE_STATUS, 0)):
return AUSWURF_OK, None
except OSError as e:
return AUSWURF_ABGELEHNT, e
warten(1)
return AUSWURF_BLEIBT_DRIN, None
finally:
try:
schliessen(fd)
except OSError:
pass
def auswerfen_versuchen(device_path: str, ioctl_fn=None, oeffnen=None,
schliessen=None, warten=None) -> bool:
"""Wirft die Disc aus und prüft nach. Wirft NIE — Vertrag des Workers.
Ein Rip soll nicht daran sterben, dass die Schublade klemmt: `tasks.py`
protokolliert das Ergebnis und macht weiter.
"""
grund, _ = auswerfen_mit_grund(
device_path, ioctl_fn=ioctl_fn, oeffnen=oeffnen,
schliessen=schliessen, warten=warten,
)
return grund == AUSWURF_OK
def eject(device_path: str) -> None:
"""Wirft die Disc aus. Wirft OSError, wenn sie drin bleibt — Vertrag der API.
Die API zeigt die Meldung im Browser, deshalb muss ein Fehlschlag hier
laut sein und darf nicht als stilles False untergehen.
"""
grund, fehler = auswerfen_mit_grund(device_path)
if grund == AUSWURF_OK:
return
if fehler is not None:
raise fehler # unverändert weiterreichen (ENOENT, EPERM, …)
if grund == AUSWURF_KEIN_ZUGRIFF:
raise OSError(f"Das Laufwerk {device_path} 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?"
)
def verriegeln(device_path: str, an: bool) -> None:
"""Tür verriegeln (True) oder entriegeln (False).
Bisher gab es das nur als Seitenschritt im Auswurf. Als eigene Operation
steht es im Port, weil der Windows-Treiber sie ebenfalls braucht
(IOCTL_STORAGE_MEDIA_REMOVAL) und weil ein Rip die Tür bewusst zuhalten
können soll.
"""
from fcntl import ioctl
fd = os.open(device_path, os.O_RDONLY | os.O_NONBLOCK)
try:
ioctl(fd, CDROM_LOCKDOOR, 1 if an else 0)
finally:
os.close(fd)
def list_optical_devices() -> list:
"""Alle optischen Laufwerke, die dieser Rechner sieht.
Im Container sind das die, die per `devices:` durchgereicht wurden.
"""
return sorted(glob.glob("/dev/sr[0-9]*"))
def read_sys_attr(device_name: str, attr: str) -> str:
pfad = f"/sys/class/block/{device_name}/device/{attr}"
try:
with open(pfad, encoding="ascii", errors="replace") as f:
return f.read().strip()
except OSError:
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:
"""Baut den Geräte-Eintrag fürs UI: Name aus /sys, Disc-Status per ioctl.
Kein udevadm: Im Container läuft kein udevd, die udev-Datenbank ist leer
`udevadm info` lieferte dort schlicht nichts (deshalb zeigte der alte Weg
nie ein Laufwerk an). `/sys/class/block/<name>/device/*` kommt direkt vom
Kernel und funktioniert überall.
"""
# Erst hier importiert: detection braucht `fcntl`. Auf einer Maschine, die
# ueberhaupt Laufwerke abfragt, ist das vorhanden — und wenn nicht, soll es
# LAUT scheitern statt beim Import des ganzen Moduls.
from rippy.drives.detection import (
classify,
disc_size_bytes,
disc_status,
drive_status,
)
name = kennung(device_path)
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
grund = ""
try:
status_code = drive_status(device_path)
except OSError as e:
status_code = -1
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
disc_type = "unknown"
# 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:
status = "ready"
try:
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError as e:
disc_type = "unknown"
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
return {
"id": name,
"name": " ".join(teil for teil in (vendor, model) if teil) or f"Laufwerk {name}",
"type": disc_type,
"path": device_path,
"status": status,
"model": model,
"serial": read_sys_attr(name, "wwid"),
# Gleiche Felder wie im Windows-Treiber — das UI unterscheidet
# nicht nach Plattform (siehe test_windows: Feld-Parität).
"grund": grund,
}
@@ -1,12 +1,16 @@
"""Tests für detection.py — die pure Zuordnung classify().
"""Tests für die reine Zuordnung classify() aus cdrom.py.
Der Vorgänger (`file -L` auf ein Block-Device) konnte strukturell nie etwas
erkennen; sein Test mockte sich die file-Ausgabe passend zurecht. Hier wird
nur echte, deterministische Logik getestet die ioctl-Aufrufe selbst sind
dünne Kernel-Durchreichen und werden im E2E-Test mit echter Disc bewiesen.
Seit V2-1 liegen Konstanten und classify() in cdrom.py ohne `fcntl`.
Damit laufen diese Tests auch auf dem Windows-Entwicklungsrechner und
nicht mehr nur in der Ampel (vorher: erst nach dem Push bewiesen").
"""
from detection import (
from rippy.drives.cdrom import (
BLURAY_MIN_BYTES,
CDS_AUDIO,
CDS_DATA_1,
@@ -50,3 +54,19 @@ def test_uhd_ab_schwelle():
def test_unbekannter_status():
assert classify(999, 5 * 1024**3) == "unknown"
assert classify(0, 0) == "unknown"
# ── An echter Hardware gemessen ─────────────────────────────────────────
def test_gemessene_bd50_wird_als_bluray_eingeordnet():
"""Kein ausgedachter Wert: Am 28.08.2026 an einer echten BD-50 im
LG BU40N gemessen (IOCTL_DISK_GET_LENGTH_INFO unter Windows).
Der Fall ist der interessanteste an der ganzen Zuordnung, weil er dicht
an der UHD-Schwelle liegt: 44,84 GiB gegen 55 GiB. Wer die Schwelle
senkt, macht aus jeder BD-50 eine UHD" — und Rippy waehlte dann das
falsche Kompressions-Preset.
"""
gemessen = 48_149_364_736 # Bytes, echte BD-50
assert gemessen / 1024**3 < 45 # 44,84 GiB
assert classify(CDS_DATA_1, gemessen) == "bluray"
assert gemessen < UHD_MIN_BYTES
+157
View File
@@ -0,0 +1,157 @@
"""Tests fuer den Linux-Laufwerks-Treiber — vor allem den Auswurf.
## Warum diese Tests hier liegen (Etappe V2-1, 28.08.2026)
Sie standen bis hierher in docker/worker/test_ripping_helpers.py, weil der
Auswurf in ripping.py lebte und ein zweites Mal, anders geschrieben, in
docker/api/devices.py. Der Code liegt jetzt an EINER Stelle
(rippy/drives/linux.py), also liegen die Tests daneben.
Neu gegenueber vorher: Auch der ANDERE Vertrag wird geprueft. Die API-Fassung
(`eject`) muss werfen, wo die Worker-Fassung (`auswerfen_versuchen`) False
zurueckgibt. Genau diese Unterscheidung war vorher ungetestet, weil sie in
einer Datei stand, die niemand testete.
"""
from rippy.drives import linux
def _laufwerk(verriegelt=True, kennt_lockdoor=True):
"""Ein nachgebautes Laufwerk, das sich wie das echte verhaelt.
Gemessen am BU40N der Rippy-VM: Nach einem MakeMKV-Rip ist die Tuer
verriegelt. CDROMEJECT wird dann ANGENOMMEN und tut nichts - der Status
bleibt auf 4 (Disc drin). Erst CDROM_LOCKDOOR 0 macht den Auswurf wirksam.
"""
zustand = {"verriegelt": verriegelt, "status": linux.CDS_DISC_OK,
"aufrufe": []}
def ioctl_fn(fd, befehl, arg=0):
zustand["aufrufe"].append(befehl)
if befehl == linux.CDROM_LOCKDOOR:
if not kennt_lockdoor:
raise OSError("ioctl unbekannt")
zustand["verriegelt"] = bool(arg)
return 0
if befehl == linux.CDROMEJECT:
if not zustand["verriegelt"]:
zustand["status"] = linux.CDS_TRAY_OPEN
return 0 # <- auch verriegelt: ERFOLG, aber ohne Wirkung
if befehl == linux.CDROM_DRIVE_STATUS:
return zustand["status"]
raise OSError("unerwartetes ioctl")
return zustand, ioctl_fn
def test_auswurf_entriegelt_zuerst_und_klappt_dann():
zustand, ioctl_fn = _laufwerk(verriegelt=True)
ok = linux.auswerfen_versuchen(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is True
assert zustand["status"] == linux.CDS_TRAY_OPEN
# Reihenfolge: entriegeln VOR auswerfen
assert zustand["aufrufe"][0] == linux.CDROM_LOCKDOOR
assert zustand["aufrufe"][1] == linux.CDROMEJECT
def test_auswurf_meldet_fehlschlag_wenn_die_disc_drin_bleibt():
"""Der eigentliche Fehler. Vorher gab wirf_disc_aus True zurueck, weil das
ioctl nicht geworfen hatte - und ins Log kam "Disc ausgeworfen", waehrend
die Schublade zu blieb. Ein ioctl-Rueckgabewert beweist nichts."""
# Ein Laufwerk, das LOCKDOOR nicht kennt und verriegelt bleibt
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
ok = linux.auswerfen_versuchen(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert ok is False
assert zustand["status"] == linux.CDS_DISC_OK # nie aufgegangen
def test_auswurf_bei_slot_laufwerk_ohne_schublade():
"""Ein Slot-Laufwerk hat keine Schublade und meldet nach dem Auswerfen
CDS_NO_DISC. Das muss als Erfolg zaehlen."""
assert linux._auswurf_geglueckt(linux.CDS_NO_DISC) is True
assert linux._auswurf_geglueckt(linux.CDS_TRAY_OPEN) is True
assert linux._auswurf_geglueckt(linux.CDS_DISC_OK) is False
assert linux._auswurf_geglueckt(linux.CDS_DRIVE_NOT_READY) is False
def test_auswurf_ohne_laufwerk_wirft_nicht():
def oeffnen_kaputt(p):
raise OSError("kein Laufwerk")
assert linux.auswerfen_versuchen(
"/dev/sr9", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
schliessen=lambda fd: None, warten=lambda s: None) is False
# --- Der ANDERE Vertrag: eject() der API muss WERFEN ------------------------
#
# Diese Fälle waren bis V2-1 ungetestet. Der Auswurf existierte zweimal, und
# die API-Fassung (docker/api/devices.py) hatte gar keine Testdatei — obwohl
# genau sie dem Nutzer im Browser eine Meldung zeigt. Jetzt liegt beides
# nebeneinander, also wird auch beides geprüft.
def test_eject_ist_still_wenn_die_disc_rauskommt(monkeypatch):
monkeypatch.setattr(linux, "auswerfen_mit_grund",
lambda pfad: (linux.AUSWURF_OK, None))
assert linux.eject("/dev/sr0") is None
def test_eject_wirft_wenn_die_disc_drin_bleibt(monkeypatch):
"""Der Unterschied zum Worker-Vertrag: hier ist ein stilles False falsch.
Der Commander drückt im Browser auf Auswerfen". Passiert nichts und die
API schweigt, sucht er den Fehler am Laufwerk genau der Verlauf vom
26.07.2026.
"""
monkeypatch.setattr(linux, "auswerfen_mit_grund",
lambda pfad: (linux.AUSWURF_BLEIBT_DRIN, None))
try:
linux.eject("/dev/sr0")
except OSError as e:
assert "noch" in str(e) and "drin" in str(e)
else:
raise AssertionError("eject haette werfen muessen")
def test_eject_reicht_den_urspruenglichen_fehler_unveraendert_weiter(monkeypatch):
"""ENOENT/EPERM sollen NICHT hinter einer eigenen Meldung verschwinden.
Vorher warf devices.eject den rohen OSError von os.open. Wer den ersetzt,
nimmt dem Nutzer die einzige brauchbare Auskunft (Datei nicht gefunden"
gegen keine Berechtigung" sind zwei ganz verschiedene Probleme).
"""
original = OSError(2, "No such file or directory")
monkeypatch.setattr(linux, "auswerfen_mit_grund",
lambda pfad: (linux.AUSWURF_KEIN_ZUGRIFF, original))
try:
linux.eject("/dev/sr9")
except OSError as e:
assert e is original
else:
raise AssertionError("eject haette werfen muessen")
def test_auswerfen_mit_grund_unterscheidet_die_fehlerarten():
"""Die gemeinsame Mechanik muss sagen KÖNNEN, was schiefging — sonst
kann eject() den Unterschied nicht machen."""
zustand, ioctl_fn = _laufwerk(verriegelt=True, kennt_lockdoor=False)
grund, fehler = linux.auswerfen_mit_grund(
"/dev/sr0", ioctl_fn=ioctl_fn, oeffnen=lambda p: 42,
schliessen=lambda fd: None, warten=lambda s: None)
assert grund == linux.AUSWURF_BLEIBT_DRIN
assert fehler is None
def oeffnen_kaputt(p):
raise OSError(13, "Permission denied")
grund, fehler = linux.auswerfen_mit_grund(
"/dev/sr0", ioctl_fn=lambda *a: 0, oeffnen=oeffnen_kaputt,
schliessen=lambda fd: None, warten=lambda s: None)
assert grund == linux.AUSWURF_KEIN_ZUGRIFF
assert fehler.errno == 13
+139
View File
@@ -0,0 +1,139 @@
"""Die Treiberwahl — und die Zusage, dass beide Treiber dasselbe können.
## Warum das ein eigener Test ist
Wenn ein Treiber eine Funktion hat und der andere nicht, merkt das niemand
beim Bauen. Es fällt erst auf der anderen Plattform auf, mitten im Betrieb,
als `AttributeError` und dann steht Rippy.
Genau das war bis V2-4 der Zustand: Die API importierte `rippy.drives.linux`
direkt, und unter Windows scheiterte schon der Import von `main.py` mit
`ModuleNotFoundError: No module named 'fcntl'` (am 28.08.2026 in einer
frischen Python-3.12-Umgebung gemessen).
"""
import sys
import pytest
from rippy import drives
# Was jeder Treiber können MUSS. Wer hier etwas ergänzt, ergänzt es in beiden
# Treibern — sonst wird dieser Test rot, und zwar auf jeder Plattform.
PFLICHT = (
"list_optical_devices",
"device_info",
"drive_status",
"disc_status",
"disc_size_bytes",
"detect_disc_type",
"eject",
"auswerfen_versuchen",
"auswerfen_mit_grund",
"verriegeln",
)
def test_windows_bekommt_den_windows_treiber():
assert drives.treiber("win32").__name__.endswith("windows")
def test_linux_bekommt_den_linux_treiber():
assert drives.treiber("linux").__name__.endswith("linux")
def test_unbekannte_plattform_bekommt_linux():
"""macOS und BSD landen bei Linux — die ioctls sind dort nicht identisch,
aber ein Treiber, der ehrlich scheitert, ist besser als gar keiner. Der
macOS-Treiber steht in KONZEPT-V2.md § 5 als eigener Punkt."""
assert drives.treiber("darwin").__name__.endswith("linux")
def test_ohne_angabe_gilt_diese_maschine():
erwartet = "windows" if sys.platform.startswith("win") else "linux"
assert drives.treiber().__name__.endswith(erwartet)
def test_ist_windows():
assert drives.ist_windows("win32") is True
assert drives.ist_windows("linux") is False
@pytest.mark.parametrize("name", PFLICHT)
def test_windows_treiber_kann_alles_pflichtgemaesse(name):
assert callable(getattr(drives.treiber("win32"), name, None)), (
f"Dem Windows-Treiber fehlt {name}(). Das faellt sonst erst im "
"Betrieb als AttributeError auf — auf der anderen Plattform."
)
@pytest.mark.skipif(sys.platform.startswith("win"),
reason="der Linux-Treiber braucht fcntl")
@pytest.mark.parametrize("name", PFLICHT)
def test_linux_treiber_kann_alles_pflichtgemaesse(name):
assert callable(getattr(drives.treiber("linux"), name, None)), (
f"Dem Linux-Treiber fehlt {name}(). Das faellt sonst erst im Betrieb "
"als AttributeError auf — auf der anderen Plattform."
)
# Was `linux.py` nicht selbst hat, sondern aus `detection.py` durchreicht.
# Getrennt gefuehrt, weil genau diese Trennung der Fehler war.
AUS_DETECTION = ("drive_status", "disc_status", "disc_size_bytes",
"detect_disc_type", "classify")
@pytest.mark.parametrize("name", AUS_DETECTION)
def test_linux_treiber_reicht_die_detection_funktionen_durch(name, monkeypatch):
"""DER Fehler, der die Ampel vier Commits lang rot hielt (28.08.2026).
`docker/api/main.py` holt sich beim Import `device_discovery.drive_status`.
Der Windows-Treiber hat die Funktion. `linux.py` hatte sie NICHT mehr,
seit `detection` (und damit `fcntl`) bewusst nicht mehr oben importiert
wird sonst waere `main.py` unter Windows nicht ladbar.
Folge: Unter Windows lief alles, auf Linux starb `main.py` beim Import
mit `AttributeError: module 'rippy.drives.linux' has no attribute
'drive_status'`. **Der API-Container waere gar nicht hochgekommen.**
Der Test oben faellt unter Windows aus (er braucht `fcntl`) dieser
hier NICHT: Er schiebt eine Attrappe von `detection` unter und prueft
nur die Durchreiche. Damit haette der Fehler auch hier auffallen muessen,
nicht erst auf dem Linux-Runner.
"""
import types
import rippy.drives
from rippy.drives import linux
attrappe = types.ModuleType("rippy.drives.detection")
for wie_es_heisst in AUS_DETECTION:
setattr(attrappe, wie_es_heisst,
lambda *a, _n=wie_es_heisst, **k: _n)
# BEIDES ersetzen, nicht nur sys.modules. `from rippy.drives import
# detection` sieht zuerst im PAKET nach (`_handle_fromlist` prueft
# `hasattr`) und greift erst dann auf sys.modules zurueck. Auf Linux ist
# das echte Modul dort laengst gesetzt, weil es importierbar ist — die
# Attrappe blieb wirkungslos und der Test lief in die echten Funktionen
# (Ampel-Lauf 186: `TypeError: drive_status() missing 1 required
# positional argument`). Unter Windows fiel das nicht auf: Dort gibt es
# das Attribut mangels `fcntl` gar nicht.
monkeypatch.setattr(rippy.drives, "detection", attrappe, raising=False)
monkeypatch.setitem(sys.modules, "rippy.drives.detection", attrappe)
durchgereicht = getattr(linux, name, None)
assert callable(durchgereicht), (
f"linux.py reicht {name}() nicht durch — genau daran ist die API "
"auf Linux beim Import gestorben."
)
assert durchgereicht() == name
def test_unbekannte_namen_werfen_weiter(monkeypatch):
"""Die Durchreiche darf kein Sammelbecken werden: Ein Tippfehler muss
ein AttributeError bleiben, sonst verschwindet er still."""
from rippy.drives import linux
with pytest.raises(AttributeError):
linux.gibt_es_nicht
+103
View File
@@ -0,0 +1,103 @@
r"""Pfade nach IHREN Regeln behandeln, nicht nach denen der laufenden Maschine.
## Warum es dieses Modul gibt (28.08.2026, zweimal bezahlt)
`os.path` richtet sich nach der Plattform, auf der es gerade läuft. Das ist
fast immer richtig und in diesem Projekt an drei Stellen falsch, weil dort
über Pfade einer ANDEREN Maschine gerechnet wird:
* `tools/katalog.py` baut Windows-Installationsorte (`C:\Program Files\`).
Auf dem Linux-Runner der Ampel wurde daraus
`C:\Program Files (x86)\MakeMKV/makemkvcon64.exe` ein Pfad, den es auf
keiner Maschine gibt. Fünf Tests rot, und zwar NUR auf Linux.
* `platform/verknuepfungen.py` leitet den Arbeitsordner einer `.lnk` ab. Eine
`.lnk` zeigt IMMER auf einen Windows-Pfad, auch wenn der Code gerade auf
Linux läuft. `os.path.dirname` gab dort einen leeren String.
* `betrieb.py` vergleicht Laufwerksbuchstaben und sucht das nächste
vorhandene Elternverzeichnis. Auf Linux kannte `splitdrive` kein `D:`, und
`dirname` zerlegte `C:\Users\Test\` nicht.
Dreimal dasselbe Muster, dreimal einzeln repariert. Beim dritten Mal gehört
es an EINE Stelle sonst kommt es ein viertes Mal wieder.
## Die Regel
**Der Pfad entscheidet, nicht der Rechner.** Ein Laufwerksbuchstabe oder ein
Backslash heißt Windows; alles andere heißt POSIX.
"""
import ntpath
import posixpath
def ist_windows_pfad(pfad: str) -> bool:
r"""`C:\…` oder irgendein Backslash — dann ist es ein Windows-Pfad."""
pfad = pfad or ""
return "\\" in pfad or (len(pfad) > 1 and pfad[1] == ":")
def _modul(pfad: str):
return ntpath if ist_windows_pfad(pfad) else posixpath
def verbinden(basis: str, *teile) -> str:
r"""Pfadteile mit dem Trenner der BASIS verbinden.
Achtung, Windows-Eigenheit: `verbinden("D:", "Rippy")` ergibt `D:Rippy`,
NICHT `D:\Rippy`. `D:` ohne Backslash bedeutet der aktuelle Ordner auf
Laufwerk D" — `ntpath.join` setzt deshalb absichtlich keinen Trenner. Das
ist richtig so, auch wenn es überrascht.
"""
return _modul(basis).join(basis, *teile)
def ordner_von(datei: str) -> str:
"""Der Ordner einer Datei — nach dem Trenner der DATEI."""
return _modul(datei).dirname(datei)
def laufwerk_von(pfad: str) -> str:
r"""Der Laufwerksteil (`C:`, `\\server\freigabe`) — leer bei POSIX-Pfaden.
Für die Frage liegen zwei Pfade auf demselben Laufwerk". Zwei leere
Ergebnisse heißen dabei beide POSIX" und damit ebenfalls: dasselbe.
"""
return _modul(pfad).splitdrive(pfad)[0]
def gleiches_laufwerk(a: str, b: str) -> bool:
"""Liegen beide auf demselben Laufwerk? (ohne Rücksicht auf Groß/Klein)"""
return laufwerk_von(a).lower() == laufwerk_von(b).lower()
def naechster_vorhandener(pfad: str, existiert=None) -> str:
"""Der nächste Ordner nach oben, den es WIRKLICH gibt. "" wenn keiner.
Frisch installiert gibt es den Ablage-Ordner noch nicht `disk_usage`
wirft dann, und im Dashboard stand unbekannt". Der Nutzer will aber
wissen, ob auf dem LAUFWERK Platz ist, und das lässt sich beantworten.
"""
import os
existiert = existiert or os.path.isdir
modul = _modul(pfad)
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()
while pfad and pfad not in gesehen:
if existiert(pfad):
return pfad
gesehen.add(pfad)
eltern = modul.dirname(pfad)
if eltern == pfad: # Wurzel erreicht
break
pfad = eltern
return ""
+5
View File
@@ -0,0 +1,5 @@
"""Plattform-Anbindung: Windows-Dienst/Tray/Registry, systemd, Prozessstart.
Alles, was NUR auf einer Plattform gilt und nichts mit dem Fachkern zu tun
hat. Der Kern kennt diese Module nicht er kennt die Ports.
"""
+124
View File
@@ -0,0 +1,124 @@
"""Was eine Windows-Datei über sich selbst sagt — und warum das hier zählt.
## Wozu (28.08.2026)
Rippy lädt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil
makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu
holen und ungeprüft auszuführen wäre leichtsinnig.
Der naheliegende Schutz die digitale Signatur geht NICHT:
Get-AuthenticodeSignature Setup_MakeMKV_v1.18.4.exe
Status: NotSigned
Am 28.08.2026 an der echten Datei gemessen: **MakeMKV signiert seinen
Installer nicht.** Eine Signaturprüfung wäre also eine Prüfung, die immer
fehlschlägt schlimmer als keine, weil sie Sicherheit vortäuscht und dann
den richtigen Weg blockiert.
Prüfbar ist die **Versions-Ressource**. An derselben Datei gemessen:
CompanyName GuinpinSoft inc
FileDescription MakeMKV installer
FileVersion v1.18.4
ProductName MakeMKV
Das ist kein Ersatz für eine Signatur wer die Datei fälscht, kann auch die
Ressource fälschen. Aber es fängt zuverlässig ab, was hier wirklich droht:
eine Fehlerseite, ein umbenanntes Archiv, eine falsche Version, ein
abgebrochener Download. Und es sagt dem Nutzer, WAS er da bekommen hat.
"""
import os
FELDER = ("CompanyName", "FileDescription", "FileVersion", "ProductName",
"ProductVersion")
def versionsangaben(pfad: str) -> dict:
"""Die Versions-Ressource einer Windows-Datei. Leer, wenn keine da ist.
Wirft nicht: Eine Datei ohne Ressource ist ein gültiges Ergebnis (und
genau das, was eine Fehlerseite mit `.exe`-Namen hätte).
"""
if os.name != "nt" or not os.path.isfile(pfad):
return {}
import ctypes
try:
version = ctypes.WinDLL("version")
groesse = version.GetFileVersionInfoSizeW(pfad, None)
if not groesse:
return {}
puffer = ctypes.create_string_buffer(groesse)
if not version.GetFileVersionInfoW(pfad, 0, groesse, puffer):
return {}
zeiger = ctypes.c_void_p()
laenge = ctypes.c_uint()
if not version.VerQueryValueW(puffer, r"\VarFileInfo\Translation",
ctypes.byref(zeiger), ctypes.byref(laenge)):
return {}
paar = ctypes.cast(zeiger, ctypes.POINTER(ctypes.c_uint16))
sprache = "%04x%04x" % (paar[0], paar[1])
ergebnis = {}
for feld in FELDER:
z = ctypes.c_void_p()
n = ctypes.c_uint()
pfad_im_block = "\\StringFileInfo\\%s\\%s" % (sprache, feld)
if version.VerQueryValueW(puffer, pfad_im_block,
ctypes.byref(z), ctypes.byref(n)) and n.value:
ergebnis[feld] = ctypes.wstring_at(z.value, n.value - 1).strip()
return ergebnis
except (AttributeError, OSError, ValueError):
return {}
def ist_programm(pfad: str) -> bool:
"""Fängt die Datei mit `MZ` an? (Das tut jedes Windows-Programm.)
Der billigste und wirksamste Test gegen das, was wirklich passiert: eine
HTML-Fehlerseite, die unter dem Namen `Setup_MakeMKV_v1.18.4.exe`
gespeichert wurde.
"""
try:
with open(pfad, "rb") as f:
return f.read(2) == b"MZ"
except OSError:
return False
# ── Die Prüfung als reine Funktion ──────────────────────────────────────
def passt(angaben: dict, firma: str = "", produkt: str = "",
version: str = "") -> tuple:
"""Passen die Angaben zu dem, was erwartet wird? (ja/nein, Begründung)
Reine Funktion damit jeder Fall prüfbar ist, ohne eine Datei zu haben.
Leere Erwartungen werden nicht geprüft: Wer nichts fordert, bekommt kein
Urteil vorgesetzt.
"""
if not angaben:
return False, "Die Datei hat keine Versionsangaben — das ist kein " \
"Installationsprogramm, sondern vermutlich eine Fehlerseite."
gefunden_firma = angaben.get("CompanyName", "")
if firma and firma.lower() not in gefunden_firma.lower():
return False, ("Die Datei stammt laut ihren eigenen Angaben von "
"%r, erwartet war %r." % (gefunden_firma or "niemandem", firma))
text = " ".join(angaben.get(f, "") for f in
("ProductName", "FileDescription")).lower()
if produkt and produkt.lower() not in text:
return False, ("Die Datei bezeichnet sich als %r, erwartet war %r."
% (text.strip() or "nichts", produkt))
if version:
gefunden = (angaben.get("FileVersion") or
angaben.get("ProductVersion") or "").lstrip("vV")
if gefunden and not gefunden.startswith(version):
return False, ("Die Datei ist Fassung %s, erwartet war %s."
% (gefunden, version))
return True, "%s %s" % (angaben.get("ProductName", "?"),
angaben.get("FileVersion", "?"))
+98
View File
@@ -0,0 +1,98 @@
"""Was eine Windows-Datei ueber sich selbst sagt.
## Warum das geprueft wird
Rippy laedt den MakeMKV-Installer notfalls aus einer Ausweichquelle, weil
makemkv.com tagelang mit HTTP 525 antwortet. Eine Datei von woanders zu holen
und ungeprueft auszufuehren waere leichtsinnig.
Der naheliegende Schutz -- die digitale Signatur -- geht NICHT: Am 28.08.2026
an der echten Datei gemessen, `Get-AuthenticodeSignature` sagt `NotSigned`.
MakeMKV signiert seinen Installer nicht. Eine Signaturpruefung waere also eine
Pruefung, die immer fehlschlaegt.
Geprueft wird deshalb die Versions-Ressource. An derselben Datei gemessen:
CompanyName GuinpinSoft inc
FileDescription MakeMKV installer
FileVersion v1.18.4
Alle Pruefungen hier sind rein -- keine Datei noetig, laeuft auf jeder
Plattform.
"""
import os
from rippy.platform import dateiangaben as d
ECHT = {
"CompanyName": "GuinpinSoft inc",
"FileDescription": "MakeMKV installer",
"FileVersion": "v1.18.4",
"ProductName": "MakeMKV",
}
def test_die_echte_datei_wird_erkannt():
gut, grund = d.passt(ECHT, firma="GuinpinSoft", produkt="MakeMKV",
version="1.18.4")
assert gut is True, grund
assert "1.18.4" in grund
def test_ohne_versionsangaben_ist_es_keine_programmdatei():
"""Das ist der haeufigste Fall: eine Fehlerseite mit .exe-Namen."""
gut, grund = d.passt({}, firma="GuinpinSoft")
assert gut is False
assert "Fehlerseite" in grund
def test_fremde_firma_wird_abgelehnt():
fremd = dict(ECHT, CompanyName="Irgendwer GmbH")
gut, grund = d.passt(fremd, firma="GuinpinSoft")
assert gut is False
assert "Irgendwer" in grund
def test_falsche_version_wird_abgelehnt():
"""Ein Archiv-Schnappschuss koennte eine aeltere Fassung liefern."""
gut, grund = d.passt(ECHT, firma="GuinpinSoft", version="1.19.0")
assert gut is False
assert "1.18.4" in grund and "1.19.0" in grund
def test_fremdes_produkt_wird_abgelehnt():
fremd = dict(ECHT, ProductName="Notepad", FileDescription="Editor")
gut, _ = d.passt(fremd, firma="GuinpinSoft", produkt="MakeMKV")
assert gut is False
def test_ohne_erwartung_wird_nichts_geurteilt():
"""Wer nichts fordert, bekommt kein Urteil vorgesetzt."""
gut, _ = d.passt(ECHT)
assert gut is True
def test_das_v_vor_der_version_stoert_nicht():
"""Die Datei meldet 'v1.18.4', erwartet wird '1.18.4'."""
gut, _ = d.passt(ECHT, version="1.18.4")
assert gut is True
def test_mz_pruefung_an_einer_echten_datei(tmp_path):
programm = tmp_path / "p.exe"
programm.write_bytes(b"MZ" + b"\x00" * 100)
assert d.ist_programm(str(programm)) is True
seite = tmp_path / "fehler.exe"
seite.write_bytes(b"<html>525</html>")
assert d.ist_programm(str(seite)) is False
assert d.ist_programm(str(tmp_path / "gibt-es-nicht.exe")) is False
def test_versionsangaben_ohne_windows_sind_leer():
if os.name == "nt":
import pytest
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert d.versionsangaben("/bin/sh") == {}
+209
View File
@@ -0,0 +1,209 @@
"""Kind-Prozesse ohne Fenster — und die Ausgabe ohne Konsole.
Ein Konsolenfenster laesst sich in einem Test nicht ansehen. Geprueft wird
deshalb das, woran es beim ersten Anlauf gescheitert ist: die ENTSCHEIDUNG,
wohin die Ausgabe geht, wenn es keine Konsole gibt.
## Warum das nicht Nebensache ist
Die EXE wird seit dem 28.08.2026 als Fenster-Programm gebaut, damit beim
Doppelklick keine schwarze Box aufgeht. Damit ist `sys.stdout` aber `None`.
Ohne Umleitung wuerde jede Bibliothek, die auf `stderr` schreibt, mit
`AttributeError: 'NoneType' object has no attribute 'write'` sterben mitten
im Start, und nirgends stuende etwas. Genau der stille Fehlschlag, vor dem
AGENTS.md warnt.
"""
import os
import subprocess
import sys
import pytest
from rippy.platform import winlauf
nur_windows = pytest.mark.skipif(os.name != "nt", reason="nur unter Windows")
# ── Kind-Prozesse ───────────────────────────────────────────────────────
def test_flag_ist_auf_windows_gesetzt_und_sonst_null():
"""Auf Linux ist CREATE_NO_WINDOW nicht vorhanden; `creationflags=0` wird
dort akzeptiert und ignoriert. So laeuft derselbe Code auf beiden Seiten."""
if os.name == "nt":
assert winlauf.OHNE_FENSTER == subprocess.CREATE_NO_WINDOW
else:
assert winlauf.OHNE_FENSTER == 0
# ── Gibt es ueberhaupt eine Ausgabe? ────────────────────────────────────
def test_fehlende_standardausgabe_wird_erkannt(monkeypatch):
"""Der Fall in der fertigen EXE: PyInstaller setzt `sys.stdout` auf None."""
monkeypatch.setattr(sys, "stdout", None)
assert winlauf.ohne_konsole() is True
def test_ein_strom_ohne_dateinummer_zaehlt_auch_als_ohne_konsole(monkeypatch):
"""pytest ersetzt stdout durch einen Auffang-Puffer. Der hat kein
`fileno` und ein Meldungsfenster waere in einem Testlauf das Letzte,
was jemand gebrauchen kann."""
class Puffer:
write = staticmethod(lambda _: None)
monkeypatch.setattr(sys, "stdout", Puffer())
assert winlauf.ohne_konsole() is True
def test_mit_echter_ausgabe_ist_alles_in_ordnung(monkeypatch, tmp_path):
datei = tmp_path / "echt.txt"
with open(datei, "w", encoding="utf-8") as f:
monkeypatch.setattr(sys, "stdout", f)
assert winlauf.ohne_konsole() is False
# ── Die Protokolldatei ──────────────────────────────────────────────────
def test_protokoll_liegt_bei_rippy():
"""Nicht im TEMP: Was Rippy ueber sich aufschreibt, gehoert zu Rippy und
verschwindet beim Deinstallieren mit."""
pfad = winlauf.protokolldatei(os.path.join("C:" + os.sep, "Basis"))
assert pfad.endswith(os.path.join("Rippy", "rippy.log"))
assert pfad.startswith(os.path.join("C:" + os.sep, "Basis"))
def test_umleiten_schreibt_wirklich(tmp_path, monkeypatch):
ziel = str(tmp_path / "unterordner" / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
assert winlauf.ausgabe_umleiten(ziel) == ziel
print("eine Zeile mit Umlaut: ä")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
assert "eine Zeile mit Umlaut" in open(ziel, encoding="utf-8").read()
def test_umleiten_haengt_an_statt_zu_ueberschreiben(tmp_path):
"""Sonst waere nach jedem Neustart das Protokoll des letzten Fehlers weg
also genau das, was man dann sucht."""
ziel = str(tmp_path / "rippy.log")
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten(ziel)
print("erster Start")
sys.stdout.flush()
winlauf.ausgabe_umleiten(ziel)
print("zweiter Start")
sys.stdout.flush()
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
inhalt = open(ziel, encoding="utf-8").read()
assert "erster Start" in inhalt and "zweiter Start" in inhalt
def test_unbeschreibbarer_ort_laesst_den_start_nicht_platzen(monkeypatch):
"""Lieber ins Nichts schreiben als beim Start sterben. Ein Programm, das
wegen seiner Protokolldatei nicht hochkommt, ist schlimmer als eines
ohne Protokoll."""
def geht_nicht(*a, **k):
raise OSError("kein Platz")
monkeypatch.setattr(winlauf.os, "makedirs", geht_nicht)
vorher_out, vorher_err = sys.stdout, sys.stderr
try:
winlauf.ausgabe_umleiten("/gibt/es/nicht/rippy.log")
print("das darf nicht werfen")
finally:
sys.stdout, sys.stderr = vorher_out, vorher_err
# ── Das Meldungsfenster ─────────────────────────────────────────────────
def test_ohne_windows_kein_meldungsfenster():
if os.name == "nt":
pytest.skip("prueft das Verhalten auf Nicht-Windows")
assert winlauf.meldung_zeigen("egal") is False
assert winlauf.an_elternkonsole_haengen() is False
# ── Der Auspack-Ordner (Befund 28.08.2026) ──────────────────────────────
#
# Commander, mit Bildschirmfoto:
#
# Failed to remove temporary directory:
# C:\Users\TobisPC\AppData\Local\Temp\_MEI0000b0882
#
# „Und manchmal kommt dieser fehler."
#
# GEMESSEN in seinem Temp-Ordner: 20 zurueckgelassene _MEI-Ordner, zusammen
# 1,1 GB. Der aus der Meldung liess sich hinterher anstandslos loeschen — die
# Sperre war also voruebergehend, es ist ein Wettlauf.
#
# Ursache: `starte_hintergrund` und `starte_fensterprozess` starten Rippy.exe
# erneut. `Popen` ohne `env=` reicht PyInstallers Auspack-Zeiger weiter, also
# laufen beide Kinder im Ordner des Elternprozesses. Der beendet sich zuerst,
# will loeschen — und die Kinder halten die DLLs offen.
def test_der_auspack_zeiger_wird_dem_kind_NICHT_mitgegeben():
"""Der Test, der 1,1 GB Reste verhindert haette."""
eltern = {"PATH": "/usr/bin", "_MEIPASS2": "/tmp/_MEI123",
"_PYI_APPLICATION_HOME_DIR": "/tmp/_MEI123",
"_PYI_ARCHIVE_FILE": "/x/Rippy.exe",
"_PYI_PARENT_PROCESS_LEVEL": "1"}
kind = winlauf.umgebung_ohne_bundle(eltern)
for name in winlauf.PYI_ZEIGER:
assert name not in kind, "%s wuerde das Kind in den Ordner der Eltern schicken" % name
assert kind["PATH"] == "/usr/bin", "der Rest der Umgebung muss bleiben"
def test_die_elternumgebung_wird_nicht_veraendert():
"""Ein `del os.environ[...]` haette den eigenen Prozess beschaedigt."""
eltern = {"_MEIPASS2": "/tmp/_MEI123"}
winlauf.umgebung_ohne_bundle(eltern)
assert eltern == {"_MEIPASS2": "/tmp/_MEI123"}
def test_aufraeumen_laesst_einen_BENUTZTEN_ordner_unangetastet(tmp_path):
"""Die wichtigste Eigenschaft: Ein laufender Rippy darf nichts verlieren.
Ein blindes `rmtree(ignore_errors=True)` haette ihm die halbe Bibliothek
weggeraeumt, bevor es an der gesperrten DLL scheitert.
"""
lebt = tmp_path / "_MEI111111"
lebt.mkdir()
(lebt / "python312.dll").write_bytes(b"MZ")
(lebt / "wichtig.pyd").write_bytes(b"x")
def gesperrt(pfad):
raise OSError(32, "Datei wird von einem anderen Prozess verwendet")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="",
jetzt_loeschen=gesperrt) == 0
assert (lebt / "wichtig.pyd").exists(), "an einem benutzten Ordner wird NICHTS angefasst"
def test_aufraeumen_entfernt_die_reste(tmp_path):
for name in ("_MEI000035002", "_MEI0000b0882"):
ordner = tmp_path / name
ordner.mkdir()
(ordner / "python312.dll").write_bytes(b"MZ")
(ordner / "base_library.zip").write_bytes(b"PK")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 2
assert not list(tmp_path.glob("_MEI*"))
def test_aufraeumen_raeumt_den_EIGENEN_ordner_nicht_weg(tmp_path):
"""Sonst saegte Rippy waehrend des Startens an seinem eigenen Ast."""
eigen = tmp_path / "_MEI999999"
eigen.mkdir()
(eigen / "python312.dll").write_bytes(b"MZ")
assert winlauf.reste_aufraeumen(str(tmp_path), eigener=str(eigen)) == 0
assert eigen.exists()
def test_aufraeumen_ignoriert_fremde_ordner(tmp_path):
"""Was nicht `_MEI` heisst, geht uns nichts an."""
(tmp_path / "wichtige-daten").mkdir()
assert winlauf.reste_aufraeumen(str(tmp_path), eigener="") == 0
assert (tmp_path / "wichtige-daten").exists()
+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"
+571
View File
@@ -0,0 +1,571 @@
"""Kind-Prozesse starten, ohne dass ein Konsolenfenster aufblitzt.
## Der Befund, der das nötig gemacht hat (26.07.2026)
Commander: *Es geht übrigens immer alle paar Sekunden ne CMD auf."* Und er hatte
recht es war kein Geist, sondern der Herzschlag des Workers.
Der Windows-Worker läuft als `pythonw.exe`, also ohne eigene Konsole (das Tray
startet ihn mit CREATE_NO_WINDOW). Startet ein Prozess OHNE Konsole ein
Konsolenprogramm, legt Windows dafür eine NEUE Konsole an und die ist sichtbar.
`capture_output=True` hilft nicht: Es leitet die Datenströme um, unterdrückt aber
kein Fenster.
Und der Worker startet solche Programme oft: `caps.py` fragt jede Minute
`HandBrakeCLI --help`, `--preset-list` und `--version` ab, dazu kommt die
Schlüssel-Automatik mit `makemkvcon`. Vier bis fünf Fenster pro Minute, in
Schüben genau das alle paar Sekunden".
## Warum eine eigene Datei
Damit es an EINER Stelle richtig ist. Die betroffenen Aufrufe stehen in caps.py,
ripping.py und schluessel.py; jeder hätte das Flag einzeln vergessen können, und
genau so ist es passiert. Auf Linux ist `CREATE_NO_WINDOW` nicht vorhanden und
`creationflags=0` eine Nulloperation im Worker-Container gegengeprüft, damit
derselbe Code auf beiden Seiten läuft.
## Die eigene Konsole (28.08.2026)
Commander: *Unterbinde das CMD Fenster was sich beim Öffnen mitöffnet."*
Der erste Anlauf wollte das Fenster VERSTECKEN: `GetConsoleProcessList`
fragen, und bei genau einem angehängten Prozess `ShowWindow(SW_HIDE)`. Zwei
Gründe, warum das nicht reicht:
1. **Eine Onefile-EXE ist ZWEI Prozesse** ein Starter und die eigentliche
Anwendung. An der Konsole hängen also immer zwei. Die Prüfung war damit
nie wahr, und das Fenster blieb einfach stehen. (Gemessen: PID 33172 mit
8 MB, PID 33580 mit 103 MB beide `Rippy.exe`.)
2. Selbst wenn sie ginge, wäre das Fenster erst nach ein bis zwei Sekunden
weg so lange braucht Python bis zur ersten eigenen Zeile. Ein
Aufblitzen ist kein unterbunden".
**Die Konsole wird deshalb gar nicht erst erzeugt.** Die EXE ist als
Fenster-Programm gebaut (`--windowed`), nicht als Konsolen-Programm. Windows
legt dann für keinen Start eine Konsole an.
Der Preis: Es gibt keine Standardausgabe mehr `sys.stdout` ist `None`.
Deshalb die drei Funktionen hier unten:
an_elternkonsole_haengen() in einem Terminal gestartet? Dann DORT
hineinschreiben (fuer --status, --hilfe)
ausgabe_umleiten() sonst: in eine Protokolldatei
meldung_zeigen() und was der Nutzer sehen MUSS, kommt in
ein Fenster statt in ein schwarzes Nichts
Ein Programm ohne Konsole, das seine Meldungen ins Leere schreibt, wäre
genau der stille Fehlschlag, vor dem AGENTS.md warnt.
"""
import os
import subprocess
import sys
# Auf Windows das Flag, auf Linux 0 (dort wird creationflags=0 akzeptiert und
# ignoriert — am 26.07.2026 im Worker-Container gemessen).
OHNE_FENSTER = getattr(subprocess, "CREATE_NO_WINDOW", 0)
ATTACH_PARENT_PROCESS = -1
MB_OK = 0x0
MB_ICONINFORMATION = 0x40
MB_ICONERROR = 0x10
#: PyInstaller schreibt den Auspack-Ordner in die eigene Umgebung. Namen je
#: nach Fassung: `_MEIPASS2` bis 5.x, ab 6.x die drei `_PYI_*`-Variablen
#: (pyinstaller/PyInstaller/loader/pyiboot01_bootstrap.py). Hier stehen alle,
#: damit ein Fassungswechsel den Fehler nicht stillschweigend zurückholt.
PYI_ZEIGER = ("_MEIPASS2", "_PYI_ARCHIVE_FILE", "_PYI_APPLICATION_HOME_DIR",
"_PYI_PARENT_PROCESS_LEVEL")
def umgebung_ohne_bundle(basis: dict = None) -> dict:
"""Die eigene Umgebung, aber ohne PyInstallers Auspack-Zeiger.
## Der Befund des Commanders (28.08.2026)
Failed to remove temporary directory:
C:\\Users\\TobisPC\\AppData\\Local\\Temp\\_MEI0000b0882
Und manchmal kommt dieser fehler." — Manchmal stimmt: Es ist ein
Wettlauf.
## Was gemessen wurde
In seinem Temp-Ordner lagen **20 zurückgelassene `_MEI`-Ordner mit
zusammen 1,1 GB**. Einer davon war der aus der Meldung, und er ließ sich
hinterher anstandslos löschen die Sperre war also vorübergehend.
## Die Ursache
Eine Onefile-EXE packt sich beim Start nach `%TEMP%\\_MEIxxxxxx` aus und
räumt beim Beenden auf. Den Ordner findet der Prozess über eine
Umgebungsvariable, die PyInstaller sich selbst setzt.
`starte_hintergrund` und `starte_fensterprozess` starten `Rippy.exe`
erneut mit `--dienst` und mit `--oeffnen`, denn Tray und Fenster
brauchen je einen eigenen Haupt-Thread. `subprocess.Popen` ohne `env=`
reicht die ganze Umgebung weiter, also **auch diesen Zeiger**. Die beiden
Kinder packen daraufhin gar nichts mehr aus: Sie laufen im Ordner des
Elternprozesses.
Der Eltern-Prozess beendet sich als Erster und will seinen Ordner
löschen. Die Kinder haben die DLLs darin noch offen Windows verweigert.
Meldung. Und weil danach niemand mehr zuständig ist, bleibt der Ordner
für immer liegen; beim nächsten Start derselbe Ablauf.
Das war nicht nur unschön: Wäre das Löschen TEILWEISE geglückt, hätten
Dienst und Fenster mitten im Betrieb ihre eigenen Dateien verloren.
Ohne den Zeiger packt sich jedes Kind seinen eigenen Ordner aus und räumt
ihn selbst wieder weg. Das kostet je Start etwa eine Sekunde und ein paar
Dutzend MB kurzzeitig deutlich billiger als 1,1 GB Reste.
"""
umgebung = dict(os.environ if basis is None else basis)
for name in PYI_ZEIGER:
umgebung.pop(name, None)
return umgebung
def reste_aufraeumen(ordner: str = None, eigener: str = None,
jetzt_loeschen=None) -> int:
"""Räumt zurückgelassene `_MEI`-Ordner weg. Gibt die Anzahl zurück.
Der Netzfang für alles, was der Zeiger-Fix nicht mehr erzeugt, aber schon
liegen ließ und für abgestürzte Läufe, die es immer geben wird.
## Warum das gefahrlos ist
Ein LAUFENDER Rippy hat seine `python312.dll` offen, und Windows lässt
eine offene Datei nicht löschen. Deshalb wird genau die zuerst versucht:
Geht sie nicht weg, gehört der Ordner einem lebenden Prozess, und es wird
**nichts weiter angefasst**. Ein blindes `rmtree(ignore_errors=True)`
hätte einem laufenden Rippy die halbe Bibliothek unter den Füßen
weggeräumt, bevor es an der gesperrten DLL scheitert.
"""
import glob
import shutil
import tempfile
ordner = ordner or tempfile.gettempdir()
eigener = eigener if eigener is not None else getattr(sys, "_MEIPASS", "")
weg = 0
for pfad in glob.glob(os.path.join(ordner, "_MEI*")):
if not os.path.isdir(pfad) or os.path.normcase(pfad) == os.path.normcase(eigener or "\0"):
continue
wache = [d for d in glob.glob(os.path.join(pfad, "python3*.dll"))]
try:
for datei in wache:
(jetzt_loeschen or os.remove)(datei)
except OSError:
continue # In Benutzung — Finger weg vom ganzen Ordner.
shutil.rmtree(pfad, ignore_errors=True)
weg += 1
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:
"""Läuft dieser Prozess ohne Standardausgabe?
Das ist der Normalfall in einer `--windowed` gebauten EXE. `print()` würde
dann werfen oder ins Leere gehen beides schlecht, deshalb wird es
abgefragt statt angenommen.
"""
return sys.stdout is None or getattr(sys.stdout, "fileno", None) is None
def an_elternkonsole_haengen() -> bool:
"""An die Konsole des Aufrufers andocken, falls es eine gibt.
Wer `Rippy.exe --status` in PowerShell eintippt, soll die Antwort DORT
sehen. Ein Fenster-Programm hat zwar keine eigene Konsole, darf sich aber
an die des Aufrufers hängen genau dafür gibt es
`ATTACH_PARENT_PROCESS`. Beim Doppelklick gibt es keine, dann ist das
Ergebnis schlicht False und der Aufrufer leitet um.
"""
if os.name != "nt":
return False
import ctypes
try:
return bool(ctypes.WinDLL("kernel32", use_last_error=True)
.AttachConsole(ATTACH_PARENT_PROCESS))
except (AttributeError, OSError):
return False
def protokolldatei(basis: str = None) -> str:
"""Wohin die Ausgabe geht, wenn niemand zusieht. (reine Funktion)
Neben die Datenbank, nicht ins TEMP: Was Rippy über sich selbst
aufschreibt, gehört zu Rippy und wird beim Deinstallieren mit entfernt.
"""
wurzel = basis or os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
return os.path.join(wurzel, "Rippy", "rippy.log")
def ausgabe_umleiten(pfad: str = None) -> str:
"""Standardausgabe in die Protokolldatei. Gibt den Pfad zurück, sonst "".
OHNE das würde jede Bibliothek, die auf `sys.stderr` schreibt, in einer
`--windowed` EXE mit `AttributeError: 'NoneType' object has no attribute
'write'` sterben mitten im Start, ohne dass irgendwo etwas stünde.
"""
pfad = pfad or protokolldatei()
try:
os.makedirs(os.path.dirname(pfad), exist_ok=True)
strom = open(pfad, "a", encoding="utf-8", errors="replace", buffering=1)
except OSError:
# Selbst das ging nicht. Dann lieber ins Nichts als beim Start sterben.
try:
strom = open(os.devnull, "w")
except OSError:
return ""
sys.stdout = strom
sys.stderr = strom
return pfad
def mit_rechteabfrage_starten(programm: str, argumente: str = "") -> bool:
r"""Startet ein Programm über die Shell — mit UAC-Abfrage, falls nötig.
Der Unterschied zu `subprocess.Popen` ist der ganze Punkt: `Popen` benutzt
`CreateProcess`, und das **zeigt keine UAC-Abfrage**. Verlangt das Ziel im
Manifest Administratorrechte, bricht es mit `ERROR_ELEVATION_REQUIRED`
(740) ab. Genau daran ist am 28.08.2026 der MakeMKV-Installer gescheitert.
Ohne Verb: Windows entscheidet selbst, ob eine Abfrage nötig ist. Ein
festes runas" würde auch dort erhöhen, wo es nicht gebraucht wird.
"""
if os.name != "nt":
return False
import ctypes
SW_SHOWNORMAL = 1
try:
ergebnis = ctypes.WinDLL("shell32").ShellExecuteW(
None, None, programm, argumente or None, None, SW_SHOWNORMAL)
except (AttributeError, OSError):
return False
# Über 32 heißt Erfolg (dokumentierter Rückgabewert von ShellExecute).
# 5 = ACCESS_DENIED: Der Nutzer hat die UAC-Abfrage weggeklickt.
return int(ergebnis) > 32
def als_admin_neu_starten(programm: str, argumente: str = "") -> bool:
r"""Startet ein Programm mit Administratorrechten neu (löst UAC aus).
True heißt: Windows hat den Start angenommen. False heißt: Der Nutzer hat
die Abfrage abgebrochen oder es ging nicht dann läuft der aufrufende
Prozess einfach weiter, statt sich zu beenden.
## Warum das NICHT der Normalfall ist (gemessen 28.08.2026)
Ein erhöhter Prozess bekommt ein anderes Zugriffstoken, und eingebundene
Netzlaufwerke hängen am Token der SITZUNG. Auf diesem Rechner gemessen:
ohne Erhöhung X:, Y:, Z: sichtbar
EnableLinkedConnections nicht gesetzt (Windows-Standard)
Erhöht wären die drei also weg genau der Befund, den der Commander
gemeldet hat (als Admin kann man kein netzlaufwerk wählen"). Deshalb ist
das hier ein Knopf und keine Voreinstellung: Wer nach `Programme`
installieren will, braucht ihn; wer eine Freigabe als Ablage will, darf
ihn NICHT drücken.
"""
if os.name != "nt":
return False
import ctypes
SW_SHOWNORMAL = 1
try:
# ShellExecuteW mit dem Verb „runas" ist der dokumentierte Weg. Ein
# Rückgabewert über 32 heißt Erfolg; 5 (ACCESS_DENIED) heißt: Der
# Nutzer hat die UAC-Abfrage weggeklickt.
ergebnis = ctypes.WinDLL("shell32").ShellExecuteW(
None, "runas", programm, argumente or None, None, SW_SHOWNORMAL)
except (AttributeError, OSError):
return False
return int(ergebnis) > 32
def meldung_zeigen(text: str, titel: str = "Rippy", fehler: bool = False) -> bool:
"""Ein Meldungsfenster. True, wenn es wirklich gezeigt wurde.
Für alles, was der Nutzer sehen MUSS und ohne Konsole sonst nie sähe
vor allem das Ergebnis der Installation.
"""
if os.name != "nt":
return False
import ctypes
symbol = MB_ICONERROR if fehler else MB_ICONINFORMATION
try:
ctypes.WinDLL("user32", use_last_error=True).MessageBoxW(
None, str(text), str(titel), MB_OK | symbol)
return True
except (AttributeError, OSError):
return False
+153
View File
@@ -0,0 +1,153 @@
"""Die vier Ports — die Nahtstellen, an denen Rippy austauschbar wird.
## Wozu das gut ist (KONZEPT-V2.md § 1 und § 2.3)
Rippy v2 soll in drei Betriebsarten laufen: Docker, native Windows-App,
Headless-Linux-Dienst. Der Trick dabei ist, dass es NICHT drei Programme sind,
sondern eines und ein Betriebsmodus nur die Auswahl der Treiber hinter diesen
vier Nahtstellen:
Port Standalone Verteilt
Store SQLite (WAL) PostgreSQL
Queue LocalQueue (Tabelle + Pool) Celery über Redis
Bus In-Process (asyncio) Redis Pub/Sub
Drives LinuxDrives / WindowsDrives LinuxDrives je Knoten
## Warum Protocol und nicht Basisklasse
Ein `typing.Protocol` prüft die FORM, nicht die Abstammung. Ein Treiber muss
nichts erben und nichts importieren er muss nur die Methoden haben. Damit
lässt sich in Tests ein handgeschriebenes Fake-Objekt einsetzen, ohne
Mocking-Bibliothek. Genau so arbeitet `zombies.raeume_zombies_auf(celery, db)`
heute schon: Die Tests dort geben ein `FakeDb` hinein, und es funktioniert,
weil nur die Form zählt. Diese Datei schreibt die Form auf, die bisher nur
stillschweigend galt.
## Stand (Etappe V2-1, 28.08.2026)
Beschrieben sind alle vier Ports. Treiber gibt es bisher für:
Store -> rippy.store (SQLAlchemy Core; heute Postgres,
ab V2-2 auch SQLite derselbe Code)
Drives -> rippy.drives.linux (ioctl; Windows folgt in V2-4)
Queue -> Celery, noch ohne Adapter (V2-2)
Bus -> noch keiner (V2-3)
Die Protokolle stehen trotzdem schon hier: Sie sind der Vertrag, gegen den die
nächsten Etappen gebaut werden, und sie machen sichtbar, wo heute noch etwas
fehlt. Ein Port ohne zweiten Treiber ist keine Abstraktion auf Vorrat er ist
die Stelle, an der der zweite Treiber hinkommt.
"""
from typing import AsyncIterator, Iterable, Optional, Protocol, runtime_checkable
# ───────────────────────────────────────────────────────────── Store ────
@runtime_checkable
class Store(Protocol):
"""Zustand: Jobs, Logs, Einstellungen, Worker, Speicherziele.
WICHTIG (KONZEPT-V2.md § 3.1): Der Store ist die WAHRHEIT über den
Job-Zustand nicht der Broker. Daraus folgt alles Weitere: Ein Auftrag
gehört dem, der ihn per bedingtem UPDATE übernommen hat, und er ist wieder
frei, wenn dessen Lease abläuft. Ein abgestürzter Knoten hinterlässt damit
keine Leiche, die jemand einsammeln muss.
"""
def init_db(self) -> None: ...
def get_job(self, job_id: str) -> Optional[dict]: ...
def list_jobs(self, limit: int = 100) -> list: ...
def insert_job(self, job_id: str, device: str, **felder) -> None: ...
def update_job(self, job_id: str, **felder) -> None: ...
def delete_job(self, job_id: str) -> None: ...
def get_job_status(self, job_id: str) -> str: ...
def list_jobs_mit_status(self, stati: Iterable[str]) -> list: ...
def add_log(self, level: str, source: str, message: str) -> None: ...
def list_logs(self, limit: int = 200) -> list: ...
def get_settings(self, key: str = "ui") -> dict: ...
def save_settings(self, werte: dict, key: str = "ui") -> None: ...
def save_worker(self, name: str, encoder_liste: list, info: dict = None) -> None: ...
def list_workers(self) -> list: ...
def zaehle_online_worker(self, sekunden: int = 120) -> int: ...
# ───────────────────────────────────────────────────────────── Queue ────
@runtime_checkable
class Queue(Protocol):
"""Aufträge: einreihen, übernehmen, am Leben halten, abschließen.
`uebernehmen` gibt einem Knoten einen Auftrag NUR, wenn er die verlangten
Fähigkeiten hat (`{"drive": "sr0"}` bzw. `{"encoder": "nvenc"}`) das ist
dieselbe Auswahl, die v1 über getrennte Celery-Queues und `worker_direct`
gelöst hat, nur als Datum statt als Verdrahtung.
`lebenszeichen` ist der Kern: Wer einen Auftrag hält, verlängert alle paar
Sekunden seine Lease. Läuft sie ab, ist der Auftrag frei egal ob der
Knoten abgestürzt ist, das Netz weg war oder jemand den Stecker gezogen
hat. Das ersetzt die nachträgliche Zombie-Erkennung aus v1 durch einen
Mechanismus, der den Fall gar nicht erst entstehen lässt.
"""
def einreihen(self, auftrag: dict) -> str: ...
def uebernehmen(self, faehigkeiten: set, knoten: str) -> Optional[dict]: ...
def lebenszeichen(self, auftrag_id: str) -> None: ...
def abschliessen(self, auftrag_id: str, ergebnis: dict) -> None: ...
def fehlgeschlagen(self, auftrag_id: str, fehler: str) -> None: ...
def abbrechen(self, auftrag_id: str) -> None: ...
# ─────────────────────────────────────────────────────────────── Bus ────
@runtime_checkable
class Bus(Protocol):
"""Ereignisse — Feuer und vergiss.
Der Bus überträgt nur NACHRICHTEN ÜBER ÄNDERUNGEN, nie den Zustand
selbst. Wer den Zustand will, fragt den Store.
Der Grund steht in AGENTS.md: Ein verpasster Abruf ist keine Nachricht
über die Welt." Im UI stand fünfmal `catch(() => [])` — jeder
fehlgeschlagene Abruf hieß damit es gibt keine Jobs". Wenn der Bus keinen
Zustand trägt, kann ein verpasstes Ereignis auch keinen Zustand löschen.
`abonnieren(ab_seq=)` liefert alles ab einer Folgenummer nach. Ist die
Lücke zu groß, schickt der Treiber ausdrücklich ein `snapshot`-Ereignis
statt stillschweigend Deltas.
"""
async def senden(self, ereignis: dict) -> None: ...
def abonnieren(self, ab_seq: Optional[int] = None) -> AsyncIterator[dict]: ...
# ──────────────────────────────────────────────────────────── Drives ────
@runtime_checkable
class Drives(Protocol):
"""Hardware: Laufwerke finden, Zustand lesen, verriegeln, auswerfen.
Der EINZIGE Ort im Projekt mit ioctl- bzw. Win32-Aufrufen.
Zwei Regeln gelten für jeden Treiber (KONZEPT-V2.md § 5):
1. **Auswerfen heißt: entriegeln, auswerfen, NACHSEHEN.** In dieser
Reihenfolge, auf jeder Plattform. Am 26.07.2026 am laufenden System
gemessen: Ein nacktes CDROMEJECT wird von einem verriegelten Laufwerk
mit ERFOLG quittiert und tut nichts MakeMKV verriegelt die Tür
während des Rips und entriegelt sie nicht wieder.
2. **Jede Operation mit Zeitgrenze, nie im Ereignis-Loop.** Ein hängendes
ioctl auf einem defekten Laufwerk darf die API nicht blockieren
dieselbe Fehlerklasse wie die CIFS-Blockade aus v1.
"""
def laufwerke(self) -> list: ...
def zustand(self, geraet: str) -> str: ...
def info(self, geraet: str) -> dict: ...
def verriegeln(self, geraet: str, an: bool) -> None: ...
def auswerfen(self, geraet: str) -> None: ...
async def ereignisse(self) -> AsyncIterator[dict]: ...
__all__ = ["Store", "Queue", "Bus", "Drives"]

Some files were not shown because too many files have changed in this diff Show More