Compare commits

10 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5 0464933ada docs: SAVEPOINT — Rippy v5, Etappe W-0
Ampel / ampel (push) Failing after 2m14s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:54:32 +02:00
HitonabiandClaude Fable 5 c539e65cd2 feat(v5): Etappe W-0 — das Geruest steht
Electron 44 + TypeScript, drei Prozesse (haupt / kern als utilityProcess /
fenster mit React), Nachrichten-Schema mit Pruef-Funktionen, node:sqlite
als einzige Datenbank-Stelle (kern/speicher/db.ts, § 6.7), Prozess-Leine
im Haupt (Job Object aus beweise/leine.js — Begruendung fuer den Ort im
Dateikopf), MessagePort direkt Fenster<->Kern, Einzelinstanz-Sperre,
Kern-Neustart-Wache, strikte CSP im gebauten Fenster.

Wächter-Tests nach § 4.3: koffi nur an zwei benannten Orten (R1), kein
leerer catch und kein catch-mit-Leerwert (R2/R4), kein HTTP-Server
(§ 4.1), node:sqlite nur in db.ts (§ 6.7). Dazu Datenbank- und
Schema-Tests: 18/18 gruen, Typpruefung in drei Kontexten.

Der W-0-Beweis (npm run smoke, echtes Programm):
SMOKE: OK — fenster=geladen kern=pid:13676,node:24.18.1 datenbank=ok
leine=gesetzt pong=ok version=5.0.0-w0

Nachgemessen ausserdem: taskkill /F auf den Haupt-Prozess (ohne /T,
der Absturz-Fall aus rc10) — der Kern stirbt mit.

Bau-Fallen dokumentiert (BAUEN.md): npm 11 blockt Electrons
Install-Skript (§ 3.5), plugin-react 6 verlangt Vite 8 (deshalb 5.2),
TypeScript bewusst auf 5.9.3 gepinnt statt tsgo 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:53:30 +02:00
HitonabiandClaude Fable 5 564bd17f84 ci: Ampel auf Node 24 verankert
Rippy v5 (rippy-windows/) braucht Node 24: Vite 7 und node:sqlite laufen
nicht auf dem Zufalls-Node des Runners. docker/ui unter Node 24 lokal
nachgemessen (npm ci + build gruen, 30.08.2026). Die Ampel prueft
unveraendert — nur das Werkzeug ist jetzt festgelegt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:53:16 +02:00
HitonabiandClaude Fable 5 a0cff205ab docs(konzept): Entscheid 4 — Worktree aufgeloest, Branch normal ausgecheckt
Der doppelte Checkout (Worktree + Hauptordner) blockierte den Start neuer
Sitzungen auf dem Branch. Der Branch selbst und main bleiben unveraendert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:25:58 +02:00
HitonabiandClaude Fable 5 c44bfa6daa docs(konzept): Zweite Fassung — geprueft, nachgemessen, drei Entscheide
- Faktenfehler korrigiert: Gitea ist oeffentlich per HTTPS erreichbar
  (gemessen, § 3.6) — Updates kommen direkt daraus, Drei-Wege-Tabelle weg
- node:sqlite in Electron 44 im utilityProcess bewiesen (§ 3.4,
  beweise/sqlite-main.js + sqlite-kern.js) — better-sqlite3 nicht noetig
- Disc-Wache umgedreht: 3-Sekunden-Takt ist Plan A, WM_DEVICECHANGE
  spaetere Verfeinerung (§ 5)
- Entscheid 7 verschaerft: main-Aufraeumen sofort, nicht erst nach W-6
- Entscheid 9 neu: Zielgruppe Bekannte mit Link, Lizenztexte liegen bei
- MakeMKV-Lage tagesaktuell bestaetigt (Key bis Ende September 2026,
  Kaufseite defekt = Dauerzustand seit Sommer 2025)
- Electron-Pflege-Regel (§ 6.8), npm-11-Sperre trifft auch Electron
  selbst (§ 3.5), Release = drei Dateien, 4K-Schluesselkette in § 10,
  Risikotabelle aktualisiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:20:51 +02:00
HitonabiandClaude Opus 5 e514e4070b docs(konzept): Entscheid 8 — der Remote-Worker bleibt bei Docker
Commander: „Das soll NICHT Teil des neuen Standalone-Produktes sein, da
dieser NUR fuer die Docker-Variante verwendet wird."

Damit ist bestaetigt, was in Paragraph 11.1 zuvor nur als Auslegung stand.
Der Remote-Encode-Worker wird WEDER abgeraeumt (er ist Docker, und Docker
bleibt unangetastet — Entscheid 2) NOCH nach v5 uebernommen (v5 ist
Einzelplatz, Entscheid 3 — es gibt dort keinen zweiten Knoten, dem man
etwas schicken koennte).

Dazu die eigentliche Reparatur an diesem Abschnitt: Er war in
Entwickler-Begriffen geschrieben (Modulnamen, PyInstaller, os.name-Zweige)
und deshalb fuer den Adressaten nicht lesbar — zweimal nachgefragt, zweimal
zu Recht. Paragraph 11.1 fuehrt die drei Teile jetzt erst in Klartext ein:

  A ist Rippy. B ist ein Handlanger fuer die VM. C sind Notizzettel,
  die wegen A eingeklebt wurden.

Und macht den Unterschied A/C an dem fest, worauf es beim Aufraeumen
ankommt — nicht am Inhalt, sondern am Ort: A sind ganze Ordner, die Docker
nie aufschlaegt (wegwerfen). C sind einzelne Seiten in Ordnern, die Docker
taeglich benutzt (einzeln durchgehen). Erst danach kommt die Tabelle mit
den Dateinamen.

Das ist kein Beiwerk: Genau diese Verwechslung wuerde beim Aufraeumen die
laufende VM treffen. Und manche Windows-Zeile braucht sogar B weiter — etwa
die Regel, dass eine Laufwerkswurzel ihren abschliessenden Trenner behaelt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:59:11 +02:00
HitonabiandClaude Opus 5 fde3a78e97 docs(konzept): Entscheide 5-7 — kein Zertifikat, Gitea-Updates, Altlast weg
Die drei offenen Punkte aus KONZEPT-WINDOWS sind entschieden (30.08.2026).

Entscheid 5 — kein Code-Signing-Zertifikat. Die SmartScreen-Warnung wird
hingenommen und in der Anleitung erklaert. Die Folge gehoert dazu und steht
jetzt in 6.8: Ohne Signatur kann electron-updater ein Update nicht auf
Echtheit pruefen, also ist der Transportweg die einzige Absicherung —
Updates nur ueber HTTPS, eine http-Adresse wird abgelehnt.

Entscheid 6 — Updates aus Gitea, auch fuer Externe erreichbar. Rippys
Seite ist einfach: die Adresse steht in der Konfiguration, electron-updater
braucht nur einen statischen HTTPS-Ort. Die Netz-Seite ist es nicht —
Gitea liegt auf 192.168.178.153 im Heimnetz und ist von aussen nicht
erreichbar. Drei Wege sind aufgeschrieben und bewertet (Gitea
veroeffentlichen / oeffentlicher Spiegel / eigene Quelle je Nutzer),
Empfehlung ist der Spiegel. Rippy wird fuer alle drei gebaut, die
Entscheidung kann spaeter fallen ohne Programmaenderung.

Entscheid 7 — der alte Windows-Weg wird entfernt, auch aus main. Dafuer
gibt es einen neuen Abschnitt 11 mit der Arbeitsliste, und die zerfaellt
in DREI Teile statt einem:

  A  die Standalone-App (PyInstaller, Tray, Fenster, Win32-Treiber,
     lokale Queue, Werkzeug-Beschaffung)          -> weg
  B  der Remote-Encode-Worker fuer die Docker-Installation -> BLEIBT
  C  die verstreuten os.name=="nt"-Zweige im Docker-Code -> einzeln pruefen

Dass B bleibt, ist ausdruecklich als Auslegung markiert, nicht als
Anweisung: Der Remote-Worker ist eine Funktion der Docker-Installation,
und Entscheid 2 haelt die unangetastet. Ihn mit abzuraeumen wuerde der VM
eine Faehigkeit nehmen, die niemand gekuendigt hat.

Teil C ist der heikle: pfade.py bleibt (der Remote-Worker braucht
Laufwerkswurzeln und UNC), nativ_nachsehen() ist zu pruefen, und die
Faehigkeiten-Auskunft /betrieb bleibt sinnvoll — nur ihre Windows-Werte
fallen weg.

Zeitpunkt: erst NACH v5 W-6. Solange v5 nicht installierbar ist, ist die
alte Fassung das einzige Windows-Rippy — sie vorher zu loeschen waere eine
Luecke ohne Gegenwert. Der Entscheid nennt die Richtung, kein Datum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:52:37 +02:00
HitonabiandClaude Opus 5 cb811bec28 docs: KONZEPT-WINDOWS — Rippy v5 als eigenstaendiges Electron-Programm
Die heutige Windows-Fassung ist der Docker-Code in einer EXE: PyInstaller
buendelt docker/api und docker/worker, uvicorn laeuft auf 127.0.0.1:7788,
pywebview stellt ein Fenster davor. Elf Release-Kandidaten in drei Tagen,
und die Funde sind fast alle derselbe Typ — Linux-Annahmen, die unter
Windows etwas anderes bedeuten (timeout ls -d, shutil.which, /app,
/dev/{name}, F: ohne Trenner).

Das Konzept beschreibt den Neubau als Windows-Programm: Electron 44,
TypeScript durchgehend, Einzelplatz, kein Python, kein HTTP-Server.

Vier Entscheide des Commanders (30.08.2026) sind eingearbeitet:
alles TypeScript/Node · Docker bleibt unangetastet · reiner Einzelplatz ·
eigener Branch.

Vor der ersten Zeile Konzept gemessen (Regel D), Belege in beweise/:

  * Win32 aus Node an G: (HL-DT-ST BD-RE BU40N) — Laufwerkssuche,
    CreateFileW, QUERY_PROPERTY (Modell + Serial), CHECK_VERIFY2,
    GET_LENGTH_INFO (33,76 GB -> Blu-ray). IOCTL_CDROM_DISK_TYPE
    antwortet mit Fehler 50 — derselbe Befund wie im Python-Treiber.
  * Prozess-Leine (Job Object): Kind stirbt mit dem per taskkill /F
    ohne /T abgeschossenen Elternprozess. Die Messung aus rc10, in
    Node nachgestellt.
  * node:sqlite ist in Node 24 eingebaut — in Electron noch ungeprueft,
    ausdruecklich als offener Punkt markiert.

Akutestes Risiko im Dokument: MakeMKVs freier Beta-Key laeuft Ende
September 2026 ab, die Kaufseite fuer die Dauerlizenz ist seit Mai defekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:38:39 +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
58 changed files with 8072 additions and 85 deletions
+9
View File
@@ -12,6 +12,15 @@ jobs:
steps:
- uses: actions/checkout@v4
# Node fest auf 24 (LTS) — dieselbe Linie wie Electrons eingebautes
# Node in rippy-windows/ (Rippy v5). Vite 7 und node:sqlite brauchen
# es; docker/ui baut unter Node 24 nachgemessen sauber (30.08.2026).
# Das ist eine Verankerung, keine Abschwächung: Vorher prüfte die
# Ampel mit dem Zufalls-Node des Runners.
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
shell: bash
run: |
+1131
View File
File diff suppressed because it is too large Load Diff
+301 -1
View File
@@ -1,6 +1,306 @@
# SAVEPOINT — Rippy
## Aktueller Stand: v4.0-rc10 — makemkvcon ueberlebt Rippy nicht mehr (29.08.2026)
## Aktueller Stand: Rippy v5 — Etappe W-0, das Gerüst steht (30.08.2026)
> **Rippy v5 hat sein Fundament.** Auf dem Branch `worktree-windows-electron`
> steht jetzt neben dem Konzept das erste lauffähige Programm: Electron 44,
> durchgehend TypeScript, drei Prozesse, eine Datenbank, die Prozess-Leine.
> Kein Python, kein Docker-Code, kein HTTP-Server — genau wie
> `KONZEPT-WINDOWS.md` es festlegt. 18/18 Tests grün, Smoke-Beweis grün.
### Was gebaut wurde (`rippy-windows/`)
* **Drei Prozesse, wie im Konzept § 4.1:** Das Haupt (`src/haupt/`) verwaltet
Fenster und Kern; der Kern (`src/kern/`, ein Electron-utilityProcess) macht
die Arbeit; das Fenster (`src/fenster/`, React) zeigt nur an. Fenster und
Kern reden über einen **direkten MessagePort** — kein localhost, kein Port,
kein Polling.
* **Die Datenbank läuft** — `node:sqlite`, wie in `beweise/` gemessen, und
sie hat genau **einen** Zugriffsort (`kern/speicher/db.ts`, § 6.7). Der
Kern schreibt beim Start eine Einstellung und liest sie zurück; „geöffnet"
allein gilt nicht als Beweis.
* **Die Prozess-Leine ist scharf:** Das Haupt legt die Windows-Arbeitsgruppe
aus `beweise/leine.js` an und hängt sich selbst hinein — alles, was Rippy
je startet, stirbt mit ihm. Eine bewusste, im Code begründete Abweichung
von der Ordnerliste § 4.2: Die Leine liegt in `haupt/leine.ts`, nicht im
Kern — das Job-Handle muss bei dem Prozess liegen, dessen Tod alles
mitreißen soll (§ 4.1: „ALLES stirbt mit ihm").
* **Wächter-Tests nach § 4.3** — mechanisch, wie versprochen: koffi nur an
den zwei benannten Orten (R1) · kein leerer catch, kein catch, das leere
Listen erfindet (R2/R4) · kein HTTP-Server (§ 4.1) · `node:sqlite` nur in
`db.ts` (§ 6.7). Dazu Tests für Datenbank und Nachrichten-Schema.
* **Sicherheitsrahmen:** contextIsolation, Sandbox, kein Node im Fenster,
strikte CSP im gebauten HTML, Einzelinstanz-Sperre, Kern-Neustart-Wache
(stirbt der Kern, startet ihn das Haupt neu — höchstens dreimal je Minute,
danach steht der Fehler sichtbar im Fenster).
### Der Beweis
`npm run smoke` startet das ECHTE Programm und prüft alle fünf Punkte
maschinell — Ausgabe vom 30.08.2026 auf dem Commander-PC:
```
SMOKE: OK — fenster=geladen kern=pid:13676,node:24.18.1 datenbank=ok leine=gesetzt pong=ok version=5.0.0-w0
```
Dazu nachgemessen, nicht geglaubt: `taskkill /F` auf den Haupt-Prozess
(ohne `/T` — der Absturz-Fall, der in rc10 verwaiste makemkvcon
hinterließ) → **der Kern ist danach tot.** Der Wirkungs-Test mit einem
echten Werkzeug-Kind folgt in W-2, wenn es erstmals eines gibt.
Das W-0-Fertigkriterium aus § 12 — „Ein Fenster geht auf, der Kern läuft,
ein Testlauf ist grün" — ist damit erfüllt.
### Die Ampel prüft v5 mit
`ci.yml` läuft jetzt fest auf **Node 24** (vorher: Zufalls-Node des
Runners; `docker/ui` unter Node 24 vorher lokal nachgemessen). Die Ampel
baut und testet `rippy-windows/` auf Linux mit — nur der Smoke bleibt ein
Windows-Beweis und steht deshalb hier mit Ausgabe.
### Stolperdrähte, dokumentiert in `rippy-windows/BAUEN.md`
npm 11 blockt Electrons Install-Skript — nach `npm install` fehlt die
`electron.exe`, `node node_modules/electron/install.js` holt sie nach
(§ 3.5, beim Bau erneut bestätigt) · `@vitejs/plugin-react` 6 verlangt
Vite 8, deshalb die 5er-Linie · TypeScript fest auf 5.9.3 gepinnt (die
neue Go-Fassung 7.x ist eine bewusste Entscheidung für später).
### Was W-0 absichtlich NICHT kann
Kein Tray, kein Autostart, kein Update, keine Laufwerks-Erkennung — das
sind W-1 bis W-6. Bis das Tray kommt (W-5), gilt: Fenster zu = Rippy zu.
### Nächste Schritte
1. **W-1 — Laufwerk:** `kern/laufwerk/win32.ts` aus dem § 3.2-Beweis,
Wache im 3-Sekunden-Takt, Disc-Typ, Auswerfen mit Nachsehen.
2. **Parallel, eigener Branch von `main`:** das Aufräumen nach § 11
(A → C, Remote-Worker bleibt) — noch nicht begonnen.
3. **Termin im Blick (§ 9.1):** MakeMKVs Beta-Key läuft **Ende September
2026** ab.
---
## v4.0-rc11 — der Schalter, den es nicht gibt (30.08.2026)
> **Ein falscher Kommandozeilen-Schalter kostete einen fertigen 16,5-GB-Rip —
> und HandBrake meldete es als Erfolg.** Dazu vierzehn weitere Funde aus
> demselben Rundgang. 977 Tests gruen.
### Der Befund
Spartacus Disc 2, eine Blu-ray mit Kratzern. MakeMKV sichert 1 von 2 Titeln,
die Kompression startet — und ist in derselben Sekunde vorbei:
```
13:18:57 Kompression gestartet (1 Datei, Preset 'H.265 VCN 1080p')
13:18:57 Kompression fehlgeschlagen: HandBrake endete mit Code 0
```
Nachgestellt mit genau der Befehlszeile, die Rippy baute:
```
unknown option (--audio-codec)
HandBrake has exited. $? = 0
```
**Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht** — er heisst
`-E` / `--aencoder`. Und ein unbekannter Schalter ist fuer HandBrake kein
Fehler: Rueckgabewert **0**. Rippy sah nur „Code 0" und keine Datei, riet auf
„Zielordner nicht beschreibbar" — und schickte die Suche in die falsche
Richtung.
Ein Test hat den Fehler festgeschrieben statt ihn zu finden:
`assert "--audio-codec" in cmd`. Ein Kommandozeilen-Schalter ist eine externe
Schnittstelle (Regel D) — er gehoert am echten Programm gemessen.
### Die Kompression
* **`--aencoder` statt `--audio-codec`.** Am mitgelieferten HandBrakeCLI
1.11.2 gemessen, danach mit einem 5-Sekunden-Encode auf der echten
Roh-Datei gegengeprueft.
* **HandBrakes letzte Worte werden aufgehoben** (12 Zeilen gepuffert, 4 in der
Meldung). Vorher wurde jede Zeile weggeworfen, die kein Fortschritt war —
bei Rueckgabewert 0 blieb damit gar keine Auskunft uebrig. Dazu eine eigene
Erkennung fuer `unknown option (...)`, die VOR allen anderen greift.
* **Ein `ü` im Pfad toetete die Kompression.** HandBrake schreibt zwei
Kodierungen in denselben Strom — denselben Pfad einmal als UTF-8, einmal als
CP850. In CP850 ist `ü` das Byte 0x81, und das ist in cp1252 (was
`text=True` auf einem deutschen Windows waehlt) **undefiniert**:
```
UnicodeDecodeError: charmap codec can't decode byte 0x81 in position 785
```
Betroffen war jeder Film mit „Glück", „Tür", „München", „Über", „Grün" —
dazu `ì`, `Å`, `É`, `Ø`. Die Entscheidung liegt jetzt als `HB_LESEN` im
gemeinsamen `rip/handbrake_aufruf.py`, weil `caps.py` HandBrake ebenfalls
aufruft. Bewusst NICHT binaer wie bei makemkvcon: HandBrake trennt seine
Fortschrittszeilen mit CR.
### Die Rohdaten
* **`F:\` wurde zu `F:`.** `rohdaten.kandidaten` strich den Schluss-Trenner ab
und verband mit dem Rest weiter. `F:` ohne Trenner ist unter Windows der
AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel:
```
verbinden("F:", job_id) -> F:1aa41fef-... isdir: False
verbinden("F:\", job_id) -> F:\1aa41fef-... isdir: True
```
16,5 GB Rohschnitt unsichtbar, und der Wiederholen-Dialog bot nur „Neu
rippen" an: Stunden am beschaedigten Datentraeger fuer etwas, das dalag. Die
Falle steht woertlich im Kopf von `pfade.verbinden`.
* **Gesucht wurde unter der heutigen Einstellung, nicht unter der Wahl des
Jobs.** Der Rip-Dialog laesst pro Rip waehlen (seit v3.15); die Wahl steht in
`meta["work_dir"]`. Genau dafuer wurde `rohdaten.py` am 26.07. gebaut —
repariert wurde damals die Kandidatenliste, nicht der Aufrufer.
* **Zwei Speicher fuer dieselben Ordner.** Die Oberflaeche schreibt
`outputDir`/`workDir` in die Datenbank, `betrieb` liest
`storage.medien`/`storage.temp` aus der Konfigurationsdatei — und die
schreibt niemand. An der laufenden Instanz gemessen:
```
eingestellt E:\Rippy (beides)
angezeigt C:\Users\TobisPC\Videos\Rippy und ...\_arbeit
```
Neue Bruecke `betrieb.mit_einstellungen` — dieselbe Reihenfolge, die der
Worker seit jeher benutzt.
### Das Laufwerk
* **Rippy kannte den Grund und behielt ihn fuer sich.** Nach dem Rip mit 29
Lesefehlern und einem gescheiterten Auswurf beantwortete das Laufwerk nichts
mehr, was mit dem MEDIUM zu tun hat — die Geraete-Auskunft aber schon:
```
CreateFileW mit GENERIC_READ -> Win32-Fehler 1
IOCTL_STORAGE_CHECK_VERIFY2 -> Win32-Fehler 1
IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
CreateFileW mit Zugriff 0 -> geht
IOCTL_STORAGE_QUERY_PROPERTY -> geht
```
Im UI stand eine vollstaendige Laufwerkskarte mit Modell und Seriennummer,
daneben „unknown" — und kein Rip startbar. Commander: *„jetzt erkennt rippy
die disk garnicht mehr (im log steht zwar erkannt, aber ein start des rips
ist nicht möglich)"*. Jetzt gibt es `ZUGRIFFS_GRUENDE` mit gemessenem
Klartext je Fehlernummer, der Grund reist im Laufwerks-Eintrag mit, und die
Wache schreibt ihn einmal je Wechsel ins Protokoll — samt „antwortet
wieder". Auch eine fehlgeschlagene Disc-Erkennung landet dort, statt nur auf
einer Konsole, die niemand sieht.
* **Der Linux-Treiber nannte ein unzugaengliches Laufwerk „empty"**, waehrend
Windows richtig „unknown" sagt. Dieselbe Lage, zwei Antworten. Angeglichen;
der Feld-Paritaetstest deckt das neue Feld mit ab.
* **ctypes ohne Typangaben.** `CreateFileW`, `DeviceIoControl` und
`CloseHandle` hatten weder `restype` noch `argtypes` — ctypes nimmt dann
32-Bit-`c_int` fuer einen 64-Bit-HANDLE, hin wie zurueck. Die Tuecke: Mit
`restype` aendert sich der Fehlerwert von -1 auf 0xFFFFFFFFFFFFFFFF, die
alte Pruefung haette stillschweigend aufgehoert zu greifen. Am echten
Laufwerk gegengeprueft, Fehlerpfad eingeschlossen.
* **Der teure Vor-Scan, den niemand las.** Bei jeder eingelegten Disc lief ein
`makemkvcon info` mit 120 s Zeitgrenze — auf einer Blu-ray 20 bis 120
Sekunden „Disc wird gelesen". Commander: *„das erkennen der disk dauert sehr
sehr lange. Das ging mal viel schneller."* Es ging schneller, weil der Zweig
unter Windows NIE lief (`shutil.which`, repariert am 28.08.). Und das
Ergebnis landete allein in `toc["tracks"]`, das niemand liest — der
Rip-Dialog holt seine Liste ueber `/devices/{id}/scan-tracks`, wenn sie
gebraucht wird.
### Drei Notbremsen
* **Endlosschleife vor jedem Rip.** `_frei_bytes` suchte den naechsten
vorhandenen Ordner selbst. `os.path.dirname("Q:\\")` gibt sich SELBST
zurueck — an einem freien Laufwerksbuchstaben gemessen, nach drei Runden
festgefahren. Ein Arbeitsverzeichnis auf einer abgezogenen Platte haette den
Job stumm haengen lassen, in `_platz_pruefen`, also VOR dem Rip.
`pfade.naechster_vorhandener` macht es seit V2-1 richtig; es war die ganze
Zeit da.
* **Laufwerks- und UNC-Wurzeln** bleiben in `naechster_vorhandener` jetzt
absolut — dieselbe Falle wie bei den Rohdaten, zweiter Fundort.
* **`rmdir /s /q` aus der Registry.** Das Aufraeum-Skript der Deinstallation
baut seinen Loeschbefehl aus `InstallLocation`, also aus dem `--ziel` beim
Installieren. Waere das eine Laufwerks-Wurzel, loeschte die Deinstallation
das Laufwerk; der noetige rstrip macht den Fall erst scharf. Nicht
beobachtet — aber nicht wiedergutzumachen.
### Lesefehler heissen jetzt Lesefehler
MakeMKV sicherte 1 von 2 Titeln, beendete sich mit 0, und Rippy schrieb „Rip
fertig". Dass ein Titel fehlt, stand nur in Zeilen, die niemand liest. Jetzt
gibt es eine Warnung nach dem Rip — auch und gerade dann, wenn er als Erfolg
endet — mit Abhilfe: reinigen, anderes Laufwerk. Und **die MSG-Nummer steht im
Protokoll**: MakeMKVs Texte sind uebersetzt, die Nummern nicht.
### Zwei Funde aus der Gegenprobe am laufenden Rippy
Die erste Fassung von rc11 war installiert, als der Commander meldete: *„nun
öffnen sich diverse fenster im hintergrund, gehen ganz kurz auf und dann
wieder zu. Das laufwerk hört auch einfach auf zu lesen."* Beides waren
Altlasten, die erst jetzt sichtbar wurden.
**Die aufblitzenden Fenster.** Prozesserzeugung 20 Sekunden lang mitgeschnitten:
```
14:54:40 timeout.exe timeout 4 ls -d C:\Users\...\d7ee6c06-...
14:54:40 WindowsTerminal.exe
```
`rohdaten.pruefen` fragt „gibt es dieses Verzeichnis?" mit `timeout N ls -d`.
Unter Linux ist das genau richtig: `os.path.isdir` kann an einem toten
CIFS-Mount im Kernel haengen (Zustand D, 26.07.2026), ein Kind-PROZESS laesst
sich abbrechen. Unter Windows ist es dreifach falsch — `timeout.exe` gibt es
dort, sie wartet aber nur Sekunden ab und kennt weder `ls` noch `-d`; sie
braucht eine Konsole, und die reisst Windows auf; und ihr Rueckgabewert ist
nie 0, also lautete die Antwort **„weg" fuer jedes Verzeichnis**. Rohdaten
waren unter Windows grundsaetzlich unsichtbar — der zweite, tiefere Grund fuer
„Auf der Platte liegt zu diesem Job nichts (mehr)". Neu: `nativ_nachsehen()`,
und dieselbe Absicherung fuer die zwei gleichartigen Aufrufe in `mounts.py`.
**Das Laufwerk hoert auf zu lesen.** Aus dem Protokoll:
```
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen mehr
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
12:50:12 makemkvcon endete mit Code 11
```
Der Waechter fragt alle drei Sekunden `device_info` ab — drei `CreateFileW`
plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon gehoert.
`_auto_prescan` haelt sich seit dem 29.08.2026 an die Regel *„Es gibt keinen
Grund, waehrend eines Rips zu scannen"*; die Laufwerksabfrage tat es nicht.
Jetzt gilt waehrend eines Rips der letzte bekannte Stand — das ist keine
Notluege, denn am Laufwerk aendert sich in der Zeit nichts.
Sichtbar wurden beide erst durch die neue Protokollzeile aus demselben
Rundgang. Ein Fehler, der sich meldet, sieht aus wie ein neuer.
### Geprueft, nichts gefunden
FLAC samt MusicBrainz-Tags mit Umlauten (landen korrekt als UTF-8) · die
makemkvcon-Aufrufe in `schluessel.py` (haben `errors="replace"` bereits) · die
Laufwerksliste in `main.py` (rstrip nur fuer den Anzeigenamen) · die uebrigen
`dirname`-Schleifen (es gab nur die eine) · `unbekanntes_preset` (Wortlaut und
Rueckgabewert 2 gemessen — korrekt).
### Zwei Tests, die gelogen haben
* `assert "--audio-codec" in cmd` — schrieb den Fehler fest, statt ihn zu
finden.
* `lambda: {}` als Doppelgaenger fuer
`get_settings(key="ui", bei_fehler_leer=False)` — brach, sobald ein Aufrufer
einen Parameter benutzte, und zeigte dann auf den Code statt auf sich
selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er ersetzt,
nicht nur den einen Aufruf, den es gerade gibt.
---
## v4.0-rc10 — makemkvcon ueberlebt Rippy nicht mehr (29.08.2026)
> **Ein liegengebliebener `makemkvcon` haelt das Laufwerk fest — jeder spaetere
> Rip scheitert dann.** 954 Tests gruen.
+135
View File
@@ -0,0 +1,135 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
überhaupt vorhanden?
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
```
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
Kern beendet mit 0
```
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
Messung zuvor ebenfalls grün.)
Nachstellen:
```
npm install electron@44 --no-save
node node_modules/electron/install.js
./node_modules/electron/dist/electron.exe sqlite-main.js
```
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
---
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
Tages jemand an der falschen Stelle.
+109
View File
@@ -0,0 +1,109 @@
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
// NUR LESEND — kein Auswerfen, kein Verriegeln.
const koffi = require('koffi');
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
const ctl = (typ, fn, methode, zugriff) =>
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
const kernel32 = koffi.load('kernel32.dll');
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
const DRIVE_CDROM = 5;
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
const maske = GetLogicalDrives();
const optische = [];
for (let i = 0; i < 26; i++) {
if (!(maske & (1 << i))) continue;
const buchstabe = String.fromCharCode(65 + i);
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
}
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
const lw = optische[0];
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
function oeffnen(zugriff) {
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
const adr = koffi.address(h);
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
return { h, fehler: 0 };
}
const ohneMedium = oeffnen(0);
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
const mitLesen = oeffnen(GENERIC_READ);
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
const h = ohneMedium.h;
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
const rueck = Buffer.alloc(4);
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
const anfrage = Buffer.alloc(12);
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
const antwort = Buffer.alloc(1024);
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
const text = (off) => {
const start = antwort.readUInt32LE(off);
if (!start || start >= antwort.length) return '';
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
return antwort.toString('latin1', start, ende).trim();
};
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
} else {
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
}
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
// ── 5. Audio- oder Datenspur?
const typBuf = Buffer.alloc(1);
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
const t = typBuf[0];
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
} else {
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
}
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
const laenge = Buffer.alloc(8);
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
const bytes = laenge.readBigUInt64LE(0);
const gb = Number(bytes) / 1e9;
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
} else {
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
}
CloseHandle(h);
if (mitLesen.h) CloseHandle(mitLesen.h);
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
+50
View File
@@ -0,0 +1,50 @@
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
// makemkvcon Rippy und haelt das Laufwerk fest.
const koffi = require('koffi');
const { spawn } = require('child_process');
const kernel32 = koffi.load('kernel32.dll');
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
// JobObjectExtendedLimitInformation = 9 (winnt.h)
const JobObjectExtendedLimitInformation = 9;
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
const job = CreateJobObjectW(null, null);
if (koffi.address(job) === 0n) {
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
const info = Buffer.alloc(144);
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
console.log('ELTERN-PID=' + process.pid);
console.log('KIND-PID=' + kind.pid);
// Offen halten, bis uns jemand hart abschiesst.
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
+12
View File
@@ -0,0 +1,12 @@
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
try {
const s = require('node:sqlite');
const db = new s.DatabaseSync(':memory:');
db.exec('CREATE TABLE t (a TEXT)');
db.prepare('INSERT INTO t VALUES (?)').run('geht');
const wert = db.prepare('SELECT a FROM t').get().a;
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
} catch (e) {
process.parentPort.postMessage({ ok: false, fehler: e.message });
}
+20
View File
@@ -0,0 +1,20 @@
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
//
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
const { app, utilityProcess } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
kind.on('message', (antwort) => {
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
app.exit(antwort.ok ? 0 : 1);
});
kind.on('exit', (code) => {
console.log('Kern beendet mit ' + code);
if (code !== 0) app.exit(1);
});
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
});
+138 -6
View File
@@ -233,6 +233,19 @@ async def _auto_prescan(pfad: str):
except Exception as e:
DISC_CACHE.pop(pfad, None)
print(f"Auto-Pre-Scan {pfad}: {e}")
# ... und ins PROTOKOLL, nicht nur auf die Konsole
# (Befund 30.08.2026). Der Commander: „im log steht zwar
# erkannt, aber ein start des rips ist nicht moeglich."
# Genau so war es: Die letzte Zeile war das erfolgreiche
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
# danach standen nur auf einer Konsole, die niemand sieht.
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
try:
db.add_log("warning", "watcher",
"Disc-Erkennung auf %s fehlgeschlagen: %s"
% (pfad, e))
except Exception: # noqa: BLE001
pass # Protokollieren darf die Wache nie anhalten
async def _auto_rip_wenn_aktiviert(pfad: str):
@@ -468,6 +481,9 @@ class Device(BaseModel):
status: str
model: Optional[str] = None
serial: Optional[str] = None
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
grund: str = ""
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
# erkennung noch läuft muss sichtbar sein")
@@ -789,6 +805,39 @@ def _rohdaten_suchen(job_id: str, work_dir: str) -> list:
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
HEUTIGEN Wert der Einstellung `workDir`.
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
wurde), statt nachzusehen. Der Job weiß es selbst.
"""
holen = getattr(job, "get", None)
eigen = ""
if holen:
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
if not eigen:
werte = einstellungen if einstellungen is not None else db.get_settings()
eigen = ((werte or {}).get("workDir") or "").strip()
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
# gemeint. "/" fällt in `kandidaten()` sauber durch.
return os.path.normpath(eigen or "/")
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
@@ -915,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
"""
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
einstellungen = db.get_settings()
alt = _ROHDATEN["treffer"]
treffer = {}
for zeile in db.list_jobs():
if zeile.get("status") != "failed":
continue
job_id = zeile["id"]
# Je Job SEINE Wahl — nicht die heutige Einstellung.
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
@@ -1050,8 +1101,7 @@ async def job_rohdaten(job_id: str):
raise HTTPException(status_code=404, detail="Job nicht gefunden")
def sammle():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
pfade = _rohdaten_suchen(job_id, work_dir)
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
return {
"pfade": pfade,
@@ -1080,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
geloescht_gb = 0.0
if rohdaten:
def raeume():
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
pfade = _rohdaten_suchen(job_id, work_dir)
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
bytes_gesamt, _ = _rohdaten_groesse(pfade)
for pfad in pfade:
shutil.rmtree(pfad, ignore_errors=True)
@@ -1564,7 +1613,7 @@ async def retry_transcode(job_id: str):
raise HTTPException(status_code=409, detail="Job rippt noch")
einstellungen = await asyncio.to_thread(db.get_settings)
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
if not gefunden:
raise HTTPException(
@@ -2528,6 +2577,13 @@ async def system_info():
werte = rippy_config.laden()
except Exception: # noqa: BLE001
werte = {}
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
# was eingestellt war (Befund 30.08.2026, siehe
# `betrieb.mit_einstellungen`).
werte = betriebs_auskunft.mit_einstellungen(
werte, db.get_settings(bei_fehler_leer=True))
for ort in betriebs_auskunft.platz_orte(werte):
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
@@ -2911,6 +2967,75 @@ async def get_devices():
await asyncio.to_thread(laufwerke_mit_disc)]
#: Zuletzt gemeldeter Grund je Laufwerk. Ohne dieses Gedaechtnis stuende die
#: Zeile alle drei Sekunden im Protokoll und verdraengte alles andere.
_LETZTER_GRUND: Dict[str, str] = {}
def _grund_melden(pfad: str, grund: str) -> None:
"""Warum ein Laufwerk „unknown" meldet — einmal ins Protokoll, beim
Wechsel.
## Der Befund des Commanders (30.08.2026)
> „jetzt erkennt rippy die disk garnicht mehr (im log steht zwar
> erkannt, aber ein start des rips ist nicht moeglich)"
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
Medien-Abfragen mehr (Win32-Fehler 1), die Geraete-Auskunft aber schon.
Im UI stand deshalb eine vollstaendige Laufwerkskarte mit Modell und
Seriennummer — nur „unknown" bei Typ und Status, und kein Rip startbar.
Die letzte Protokollzeile war das laengst veraltete „Disc erkannt".
Der Treiber kennt den Grund (siehe `drives.windows.ZUGRIFFS_GRUENDE`).
Hier wird er gesagt — samt Abhilfe, denn die Zeile soll nicht nur
beschreiben, sondern weiterhelfen.
"""
if _LETZTER_GRUND.get(pfad, "") == grund:
return
vorher = _LETZTER_GRUND.get(pfad, "")
_LETZTER_GRUND[pfad] = grund
try:
if grund:
db.add_log("warning", "watcher", "Laufwerk %s: %s" % (pfad, grund))
elif vorher:
db.add_log("info", "watcher",
"Laufwerk %s antwortet wieder." % pfad)
except Exception: # noqa: BLE001
pass # Protokollieren darf die Laufwerksliste nie aufhalten
def _job_haelt_das_laufwerk(pfad: str) -> bool:
"""Laeuft auf diesem Laufwerk gerade ein Rip?
## Warum das Laufwerk dann in Ruhe bleiben muss (Befund 30.08.2026)
Der Waechter fragt alle drei Sekunden `device_info` ab — das sind drei
`CreateFileW` plus IOCTLs auf ein Geraet, das waehrenddessen makemkvcon
gehoert. Am Protokoll des Commanders abgelesen:
12:49:52 bluray-Rip gestartet
12:50:09 [watcher] Laufwerk G: beantwortet keine Medien-Abfragen
12:50:12 MSG 2003 SCSI-Fehler ILLEGAL REQUEST:INVALID FIELD IN CDB
12:50:12 MSG 5010 Das Oeffnen der Disk schlug fehl
12:50:12 makemkvcon endete mit Code 11
Sein Befund dazu: „Das laufwerk hoert auch einfach auf zu lesen."
`_auto_prescan` haelt sich seit dem 29.08.2026 an genau diese Regel
(„Es gibt keinen Grund, waehrend eines Rips zu scannen") — die
Laufwerksabfrage tat es nicht. Sie hat dieselbe Begruendung: Wir wissen
bereits, was drinliegt, der Job laeuft ja darauf.
Faellt die Auskunft aus, gilt der letzte bekannte Stand weiter. Das ist
keine Notluege: Waehrend eines Rips aendert sich am Laufwerk nichts.
"""
try:
return bool(db.has_active_job(pfad))
except Exception: # noqa: BLE001
return False # im Zweifel nachsehen, wie bisher
def laufwerke_mit_disc() -> list:
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
@@ -2932,8 +3057,15 @@ def laufwerke_mit_disc() -> list:
nur diese hier.
"""
geraete = []
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
for pfad in device_discovery.list_optical_devices():
if _job_haelt_das_laufwerk(pfad) and pfad in letzte:
# Nicht anfassen — der letzte bekannte Stand gilt weiter.
info = {k: v for k, v in letzte[pfad].items()
if k not in ("disc", "disc_wird_erkannt")}
else:
info = device_discovery.device_info(pfad)
_grund_melden(pfad, info.get("grund") or "")
disc = DISC_CACHE.get(pfad)
if disc and disc.get("_laeuft"):
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
+6 -2
View File
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
import os
import posixpath
from rippy.platform.winlauf import OHNE_FENSTER
import re
import subprocess
import tempfile
@@ -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:
+30 -33
View File
@@ -11,9 +11,6 @@ from typing import Dict, List, Optional
from rippy import drives as _laufwerks_schicht
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
from rippy.rip.makemkv_aufruf import text_von
from rippy.tools import katalog as werkzeug_katalog
from clients.tmdb import TMDBClient
from clients.jikan import JikanClient
from clients.musicbrainz import MusicBrainzClient
@@ -359,37 +356,37 @@ class PreScan:
if label:
toc["title"] = normalize_disc_label(label)
# Titel-Quelle 3: die Titelliste von makemkvcon (optional).
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
#
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
# Windows liegen Programme in „Programme", nicht im PATH.
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
# installiertem MakeMKV None zurück, und dieser Zweig lief
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
# gibt.
makemkv = werkzeug_katalog.finden("makemkv")
if makemkv:
result = subprocess.run(
[makemkv, "-r", "--noscan", "--minlength=300", "info", makemkv_quelle(device_path)],
capture_output=True,
timeout=120, # binaer lesen — Begruendung in text_von
# OHNE_FENSTER: Sonst blitzt bei JEDER Disc-Erkennung
# eine Konsole auf. Rippy laeuft als Fenster-Programm
# ohne eigene Konsole — Windows legt fuer ein
# Konsolenprogramm dann eine NEUE an, und die ist
# sichtbar (Commander 29.08.2026: „nun oeffnen sich
# immer irgendwelche fenster ganz kurz").
creationflags=OHNE_FENSTER,
)
for line in text_von(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
})
# ## 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
+81 -2
View File
@@ -38,6 +38,7 @@ 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 os
import subprocess
from rippy import pfade as _pfade
@@ -48,6 +49,23 @@ 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.
@@ -70,6 +88,11 @@ def wurzeln(werte=None) -> tuple:
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))
@@ -99,10 +122,29 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
from rippy import pfade
orte = [pfade.verbinden(roh, job_id)]
wahl = (work_dir or "").strip().rstrip("/\\")
# 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 wahl and (frei or wahl == medien or wahl.startswith(medien + "/")):
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:
@@ -115,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
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.
@@ -131,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(
+53 -1
View File
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
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.
monkeypatch.setattr(main.db, "get_settings", lambda: {})
#
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
# einen der Parameter benutzte — `system_info` fiel damit aus dem
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
zustand = asyncio.run(main._snapshot())
@@ -880,3 +888,47 @@ def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
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"]
+67
View File
@@ -58,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"]) == []
@@ -237,3 +267,40 @@ def test_suche_mit_status_findet_trotz_unklarem_anderen_ort():
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"
+47 -4
View File
@@ -256,12 +256,33 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
def _frei_bytes(pfad: str) -> int:
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
kandidat = pfad
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
An dieser Stelle stand sie als eigene Schleife:
while kandidat and not os.path.exists(kandidat):
kandidat = os.path.dirname(kandidat)
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck an
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
Meldung stehen, und im Protokoll stuende nichts, womit man das
aufklären koennte.
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig mit
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
"""
from rippy import pfade
try:
return shutil.disk_usage(kandidat or "/").free
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
except OSError:
return -1
@@ -612,7 +633,14 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
return
gesehen.add(text)
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
# sind uebersetzt, die Nummern nicht — sie sind die einzige
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
# ersatzweise an einer URL im Text festhalten.
db.add_log("info", "makemkv",
f"Job {job_id}: MSG {code}{text[:300]}")
einstellungen = db.get_settings(bei_fehler_leer=True)
ist_video = disc_type in ("dvd", "bluray", "uhd")
@@ -740,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
"Normale BD/DVD gehen weiterhin."
)
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
# eine halbe Disc für eine ganze.
if ergebnis.get("lesefehler"):
db.add_log(
"warning", "worker",
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
"von innen nach außen, nicht kreisend) oder ein anderes "
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
)
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
#
+5 -4
View File
@@ -11,6 +11,7 @@ import re
import subprocess
from rippy.platform.winlauf import OHNE_FENSTER
from rippy.rip.handbrake_aufruf import HB_LESEN
from rippy.tools import katalog as werkzeuge
@@ -191,7 +192,7 @@ def hole_handbrake_presets() -> list:
try:
aus = subprocess.run(
[_hb(), "--preset-list"],
capture_output=True, text=True, timeout=30,
capture_output=True, timeout=30, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
@@ -304,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
return ""
try:
aus = subprocess.run(
[_hb(), "--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):
@@ -403,7 +404,7 @@ def werkzeug_versionen() -> dict:
try:
aus = subprocess.run(
[_hb(), "--version"],
capture_output=True, text=True, timeout=15,
capture_output=True, timeout=15, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
+148 -8
View File
@@ -37,6 +37,7 @@ from rippy.drives.linux import ( # noqa: F401
)
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
from rippy.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
@@ -88,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).
@@ -273,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
Titel oder 'all' also ein Aufruf je Titel, Fortschritt anteilig)."""
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:
@@ -280,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
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):
@@ -362,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
try:
ergebnis = subprocess.run(
[werkzeug("handbrake"), "--scan", "-i", pfad],
capture_output=True, text=True, timeout=timeout,
capture_output=True, timeout=timeout, **HB_LESEN,
creationflags=OHNE_FENSTER,
)
except (OSError, subprocess.TimeoutExpired):
@@ -507,7 +532,7 @@ 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 = [
@@ -539,7 +564,23 @@ def build_handbrake_cmd(input_path: str, output_path: str,
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)]
@@ -644,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:
@@ -673,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
@@ -684,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
@@ -702,6 +806,24 @@ 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:
@@ -721,11 +843,17 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
"(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. Meist ist der Zielordner nicht "
"beschreibbar: %s" % os.path.dirname(output_path)),
"entstanden (Zielordner: %s)."
% os.path.dirname(output_path)
+ hb_schluss(letzte_zeilen)),
"return_code": 0,
}
if falsches_preset:
@@ -742,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,
}
@@ -862,6 +991,13 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
)
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
@@ -891,6 +1027,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
if meldung is None:
continue
code, letzte_meldung = meldung
if LESEFEHLER_MARKE in letzte_meldung:
lesefehler = True
if code in KRITISCHE_CODES:
kritische_meldungen.append(
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
@@ -922,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 "")
+93 -3
View File
@@ -13,7 +13,9 @@ from ripping import (
build_makemkv_cmd,
get_progress_from_line,
get_progress_from_prgv,
hb_schluss,
parse_msg,
unbekannter_schalter,
write_abcde_config,
)
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
assert "--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
@@ -375,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:
@@ -390,7 +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
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
Ohne diese Erkennung landete er unten bei HandBrake meldet Erfolg,
aber es ist keine Datei entstanden" samt der falschen Vermutung
Zielordner nicht beschreibbar" — und die schickte die Suche in die
vollkommen falsche Richtung.
"""
import ripping
class FakeProcess:
def __init__(self):
self.stdout = iter([
"[13:30:54] hb_init: starting libhb thread\n",
"unknown option (--audio-codec)\n",
"HandBrake has exited.\n",
])
self.returncode = 0
def kill(self):
pass
def wait(self):
return 0
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
assert ergebnis["status"] == "error"
assert "--audio-codec" in ergebnis["error"]
assert "Fehler in Rippy" in ergebnis["error"]
# ... und NICHT die alte Vermutung über den Zielordner
assert "beschreibbar" not in ergebnis["error"]
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
+4
View File
@@ -0,0 +1,4 @@
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
node_modules/
out/
*.tsbuildinfo
+65
View File
@@ -0,0 +1,65 @@
# Rippy v5 bauen — Anleitung
> Gilt für den Ordner `rippy-windows/`. Grundlage: `KONZEPT-WINDOWS.md`,
> die Messungen dazu liegen in `beweise/`.
## Voraussetzungen
| Werkzeug | Version | Anmerkung |
|----------|---------|-----------|
| Node | 24 (LTS) | dieselbe Linie wie Electrons eingebautes Node (24.18) |
| npm | 11 | kommt mit Node 24 |
| Windows | 10 (ab 1809) / 11, x64 | zum Entwickeln und für den Smoke-Beweis; Bau/Tests laufen auch auf Linux (CI) |
## Einrichten — zwei Schritte, nicht einer
```
npm install
node node_modules/electron/install.js
```
**Die zweite Zeile ist kein Versehen** (gemessen 30.08.2026, siehe
`beweise/README.md` und KONZEPT § 3.5): npm 11 blockiert Install-Skripte
fremder Pakete. `koffi` übersteht das, weil es vorgebaute Binärdateien
mitbringt — **Electron nicht**: Sein Install-Skript lädt die eigentliche
Programmdatei herunter. Wird es geblockt, liegt nach `npm install` ein Paket
**ohne `dist/electron.exe`** da, und nichts startet. Der Nachholer ist auch
als npm-Skript hinterlegt: `npm run werkzeug:electron`.
## Die Läufe
| Befehl | Was er tut |
|--------|------------|
| `npm run dev` | Entwicklung: baut, startet Electron, lädt das Fenster vom Vite-Server |
| `npm run build` | Typprüfung (beide tsconfig) + Bau nach `out/` |
| `npm test` | Vitest: Wächter-Tests (§ 4.3), Datenbank, Nachrichten-Schema |
| `npm run smoke` | **Der W-0-Beweis:** baut, startet das echte Programm mit `--smoke`, prüft Fenster + Kern + Datenbank + Leine + Ping/Pong über den MessagePort und endet mit Code 0 (grün) oder 2. `RIPPY_SMOKE_BILD=<pfad.png>` legt zusätzlich einen Screenshot ab. Läuft nur unter Windows (Electron-Binary + koffi). |
Der Smoke-Lauf benutzt ein Wegwerf-Profil unter `%TEMP%\rippy-smoke` — er
fasst die echten Einstellungen nicht an.
## Was die CI-Ampel prüft (`.gitea/workflows/ci.yml`)
Die Ampel läuft auf Linux und führt hier `npm ci`, `npm run build` und
`npm test` aus. Deshalb gilt für Tests: **nichts importieren, was koffi,
Win32 oder das Electron-Binary braucht.** Die Wächter-Tests LESEN Quelltext
nur; die Datenbank-Tests laufen, weil `node:sqlite` im System-Node genauso
vorhanden ist wie in Electrons Node (§ 3.4). Was zwingend Windows braucht
(Smoke, später die Laufwerks-Messungen), wird lokal ausgeführt und im
`SAVEPOINT.md` mit Ausgabe belegt.
## Festgelegte Versionen und warum
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
Release prüfen, ob eine neue Linie ansteht.
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 57) und
Vitest 4 (verträgt 68), am 30.08.2026 per `npm view` ermittelt.
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
+65
View File
@@ -0,0 +1,65 @@
// Bau-Konfiguration für die drei Prozesse (KONZEPT-WINDOWS.md § 4.1):
// haupt → out/haupt/index.js (Electron Main)
// kern → out/haupt/kern.js (utilityProcess — als zweiter Eintrag im
// selben Node-Bau, weil er dieselbe Umgebung hat wie haupt)
// bruecke → out/bruecke/index.js (Preload des Fensters)
// fenster → out/fenster/ (React)
// externalizeDepsPlugin hält "dependencies" (koffi — natives Modul) aus dem
// Bündel heraus; sie werden zur Laufzeit aus node_modules geladen.
import { resolve } from 'node:path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import type { Plugin } from 'vite'
// Strikte CSP fürs GEBAUTE Fenster (lädt nur eigene Dateien). Nur im Bau:
// Der Dev-Server braucht Inline-Skripte (react-refresh) und WebSocket (HMR),
// eine Dev-CSP bräche also genau das Werkzeug, das sie schützen soll.
// Ob die Seite unter der CSP vollständig läuft, misst der Smoke-Lauf —
// blockte sie das Skript, käme kein Pong und der Lauf würde rot.
function cspNurImBau(): Plugin {
return {
name: 'csp-nur-im-bau',
apply: 'build',
transformIndexHtml(html) {
return html.replace(
'<head>',
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'" />`,
)
},
}
}
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/haupt',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/haupt/index.ts'),
kern: resolve(__dirname, 'src/kern/index.ts'),
},
},
},
},
preload: {
plugins: [externalizeDepsPlugin()],
build: {
outDir: 'out/bruecke',
rollupOptions: {
input: { index: resolve(__dirname, 'src/bruecke/index.ts') },
},
},
},
renderer: {
root: 'src/fenster',
plugins: [react(), tailwindcss(), cspNurImBau()],
build: {
outDir: 'out/fenster',
rollupOptions: {
input: { index: resolve(__dirname, 'src/fenster/index.html') },
},
},
},
})
+3818
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.0.0-w0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
"license": "UNLICENSED",
"private": true,
"scripts": {
"dev": "electron-vite dev",
"typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.bruecke.json && tsc --noEmit -p tsconfig.web.json",
"build": "npm run typecheck && electron-vite build",
"test": "vitest run",
"smoke": "electron-vite build && electron . --smoke",
"werkzeug:electron": "node node_modules/electron/install.js"
},
"dependencies": {
"koffi": "^3.1.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.0.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^5.2.0",
"electron": "44.0.0",
"electron-vite": "^5.0.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"tailwindcss": "^4.3.3",
"typescript": "5.9.3",
"vite": "^7.3.6",
"vitest": "^4.1.11"
}
}
+51
View File
@@ -0,0 +1,51 @@
// Die Brücke (Preload) — läuft im Fenster-Prozess mit contextIsolation.
// Sie reicht GENAU DREI Dinge in die Seite, mehr nicht:
// 1. den MessagePort zum Kern (Muster aus der Electron-Doku
// »MessagePorts in Electron«: window.postMessage mit Transfer über die
// Kontextgrenze — contextBridge kann keine Ports übertragen)
// 2. den Haupt-Status (Kanal 'haupt-status')
// 3. rippy.melden(art) für kurze Rückmeldungen ans Haupt
//
// Beides wird GEPUFFERT: Das Haupt schickt Port und Status direkt nach
// did-finish-load — ob die React-Seite ihre Horcher da schon registriert
// hat, ist Timing. Ein Port, der vor dem Horcher eintrifft, wäre sonst
// stumm verloren (R2: nichts verlieren, nur weil eine Nachricht früh kam).
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
let seiteBereit = false
const wartendePorts: IpcRendererEvent[] = []
ipcRenderer.on('kern-port', (ereignis) => {
if (seiteBereit) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
} else {
wartendePorts.push(ereignis)
}
})
let letzterStatus: unknown = null
const statusHorcher = new Set<(status: unknown) => void>()
ipcRenderer.on('haupt-status', (_ereignis, status: unknown) => {
letzterStatus = status
for (const horcher of statusHorcher) horcher(status)
})
contextBridge.exposeInMainWorld('rippy', {
/** Die Seite meldet: Mein 'message'-Horcher steht — gepufferte Ports jetzt. */
bereit(): void {
seiteBereit = true
for (const ereignis of wartendePorts.splice(0)) {
window.postMessage({ art: 'kern-port' }, '*', ereignis.ports)
}
},
/** Haupt-Status abonnieren; der letzte bekannte Stand kommt sofort. */
aufStatus(horcher: (status: unknown) => void): void {
statusHorcher.add(horcher)
if (letzterStatus !== null) horcher(letzterStatus)
},
/** Kurze Rückmeldung ans Haupt (z. B. 'kern-pong' im Smoke-Beweis). */
melden(art: string): void {
ipcRenderer.send('fenster-meldung', String(art))
},
})
+119
View File
@@ -0,0 +1,119 @@
// W-0-Statusbild: zeigt, dass die drei Prozesse stehen und miteinander
// reden. Die echte Oberfläche (Dashboard, Bibliothek, Einstellungen) ist
// Etappe W-5 — hier geht es nur darum, das Gerüst SICHTBAR zu beweisen.
import { useEffect, useState } from 'react'
import type { HauptStatus } from '../gemeinsam/nachrichten'
import { aufVerbindung, verbindungStarten, type Verbindung } from './kernverbindung'
function istHauptStatus(wert: unknown): wert is HauptStatus {
return (
typeof wert === 'object' &&
wert !== null &&
typeof (wert as Record<string, unknown>).version === 'string'
)
}
interface KachelProps {
titel: string
zustand: 'ok' | 'fehler' | 'wartet'
zeilen: string[]
}
function Kachel({ titel, zustand, zeilen }: KachelProps) {
const punktFarbe =
zustand === 'ok' ? 'bg-emerald-400' : zustand === 'fehler' ? 'bg-red-400' : 'bg-amber-400'
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-4">
<div className="flex items-center gap-2">
<span className={`inline-block h-2.5 w-2.5 rounded-full ${punktFarbe}`} />
<h2 className="text-sm font-semibold tracking-wide text-slate-200">{titel}</h2>
</div>
<div className="mt-2 space-y-0.5">
{zeilen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-slate-400">
{zeile}
</p>
))}
</div>
</div>
)
}
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
const [verbindung, setVerbindung] = useState<Verbindung>({
verbunden: false,
kern: null,
pongLatenzMs: null,
})
useEffect(() => {
window.rippy.aufStatus((status) => {
if (istHauptStatus(status)) setHaupt(status)
})
verbindungStarten()
return aufVerbindung(setVerbindung)
}, [])
const db = verbindung.kern?.datenbank ?? null
return (
<div className="flex min-h-screen flex-col bg-slate-900 text-slate-100">
<header className="border-b border-slate-700/60 px-6 py-4">
<div className="flex items-baseline gap-3">
<h1 className="text-2xl font-bold tracking-tight">Rippy</h1>
<span className="font-mono text-xs text-slate-400">
v{haupt?.version ?? '…'} · Etappe W-0 Gerüst
</span>
</div>
<p className="mt-1 text-sm text-slate-400">
Drei Prozesse, eine Datenbank, eine Leine das Fundament für Rippy v5.
</p>
</header>
<main className="grid flex-1 content-start gap-4 p-6 sm:grid-cols-2">
<Kachel
titel="Kern (utilityProcess)"
zustand={verbindung.kern !== null ? 'ok' : haupt?.kern.laeuft ? 'wartet' : 'fehler'}
zeilen={
verbindung.kern !== null
? [
`PID ${verbindung.kern.pid} · Node ${verbindung.kern.nodeVersion}`,
`Neustarts: ${haupt?.kern.neustarts ?? 0}`,
]
: ['wartet auf Meldung …']
}
/>
<Kachel
titel="Datenbank (node:sqlite)"
zustand={db === null ? 'wartet' : db.ok ? 'ok' : 'fehler'}
zeilen={db === null ? ['wartet auf Kern …'] : db.ok ? [db.pfad] : [db.fehler]}
/>
<Kachel
titel="Prozess-Leine (Job Object)"
zustand={haupt === null ? 'wartet' : haupt.leine.ok ? 'ok' : 'fehler'}
zeilen={
haupt === null
? ['wartet auf Haupt …']
: haupt.leine.ok
? ['gesetzt — Kern und Werkzeuge sterben mit Rippy']
: [haupt.leine.fehler ?? 'nicht gesetzt']
}
/>
<Kachel
titel="Fenster ↔ Kern (MessagePort)"
zustand={verbindung.pongLatenzMs !== null ? 'ok' : 'wartet'}
zeilen={
verbindung.pongLatenzMs !== null
? [`Ping → Pong in ${verbindung.pongLatenzMs} ms`]
: ['Ping unterwegs …']
}
/>
</main>
<footer className="border-t border-slate-700/60 px-6 py-3 text-center text-xs text-slate-500">
Rippy v5 · LucyAI · Claude · KrBrZ
</footer>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
// Was die Brücke (src/bruecke) der Seite unter window.rippy bereitstellt.
export {}
declare global {
interface Window {
rippy: {
bereit(): void
aufStatus(horcher: (status: unknown) => void): void
melden(art: string): void
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './stil.css'
createRoot(document.getElementById('wurzel')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="./haupt.tsx"></script>
</body>
</html>
@@ -0,0 +1,68 @@
// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React:
// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung;
// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren.
//
// R2 gilt auch hier: Der letzte bekannte Kern-Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam.
import { istKernNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
}
type Horcher = (stand: Verbindung) => void
const stand: Verbindung = { verbunden: false, kern: null, pongLatenzMs: null }
const horcher = new Set<Horcher>()
let gestartet = false
function melden(): void {
for (const h of horcher) h({ ...stand })
}
function portAnnehmen(port: MessagePort): void {
stand.verbunden = true
port.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (!istKernNachricht(nachricht)) return
if (nachricht.art === 'kern-bereit') {
stand.kern = nachricht
} else if (nachricht.art === 'pong') {
stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit)
// Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück.
window.rippy.melden('kern-pong')
}
melden()
}
port.start()
port.postMessage({ art: 'ping', zeit: Date.now() })
melden()
}
/** Idempotent der erste Aufruf registriert den Horcher und meldet der
* Brücke, dass gepufferte Ports jetzt zugestellt werden können. */
export function verbindungStarten(): void {
if (gestartet) return
gestartet = true
window.addEventListener('message', (ereignis: MessageEvent) => {
const daten: unknown = ereignis.data
if (
typeof daten === 'object' &&
daten !== null &&
(daten as Record<string, unknown>).art === 'kern-port' &&
ereignis.ports.length > 0
) {
portAnnehmen(ereignis.ports[0])
}
})
window.rippy.bereit()
}
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({ ...stand })
return () => horcher.delete(h)
}
+7
View File
@@ -0,0 +1,7 @@
@import 'tailwindcss';
/* Grundstimmung wie das heutige Rippy: dunkel, ruhig. Mehr Gestaltung
kommt mit der echten Oberfläche in Etappe W-5. */
:root {
color-scheme: dark;
}
@@ -0,0 +1,69 @@
// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1).
// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese
// Datei läuft im Fenster UND im Kern.
//
// Wege:
// Kern → Haupt über process.parentPort (KernNachricht)
// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht)
// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus)
// Haupt → Kern über utilityProcess.postMessage (HauptNachricht)
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
/** Nachrichten, die der Kern verschickt. */
export type KernNachricht =
| { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus }
| { art: 'pong'; zeit: number }
| { art: 'kern-fehler'; text: string }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht = { art: 'ping'; zeit: number }
/** Nachrichten, die das Haupt an den Kern schickt (Port-Übergabe). */
export type HauptNachricht = { art: 'fenster-port' }
/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */
export interface HauptStatus {
version: string
/** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
export function istKernNachricht(wert: unknown): wert is KernNachricht {
if (!istObjekt(wert)) return false
switch (wert.art) {
case 'kern-bereit':
return (
typeof wert.pid === 'number' &&
typeof wert.nodeVersion === 'string' &&
istDbStatus(wert.datenbank)
)
case 'pong':
return typeof wert.zeit === 'number'
case 'kern-fehler':
return typeof wert.text === 'string'
default:
return false
}
}
export function istDbStatus(wert: unknown): wert is DbStatus {
if (!istObjekt(wert)) return false
if (wert.ok === true) return typeof wert.pfad === 'string'
if (wert.ok === false) return typeof wert.fehler === 'string'
return false
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
return istObjekt(wert) && wert.art === 'ping' && typeof wert.zeit === 'number'
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
return istObjekt(wert) && wert.art === 'fenster-port'
}
+37
View File
@@ -0,0 +1,37 @@
// Fenster anlegen (KONZEPT § 4.2). Das Fenster zeigt an, sonst nichts —
// kein Node-Zugriff, contextIsolation an, alles läuft über die Brücke
// (src/bruecke) und den MessagePort zum Kern.
// „Zustand merken" (Position/Größe) kommt mit den Einstellungen im UI (W-5).
import { BrowserWindow } from 'electron'
import { join } from 'node:path'
export function fensterErstellen(): BrowserWindow {
const fenster = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 720,
minHeight: 480,
show: true,
autoHideMenuBar: true,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt.
backgroundColor: '#0b1220',
title: 'Rippy',
webPreferences: {
preload: join(__dirname, '../bruecke/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
// electron-vite: Im dev-Modus bedient ein Vite-Server das Fenster
// (ELECTRON_RENDERER_URL), gebaut wird aus out/fenster geladen.
const devUrl = process.env['ELECTRON_RENDERER_URL']
if (devUrl !== undefined && devUrl.length > 0) {
void fenster.loadURL(devUrl)
} else {
void fenster.loadFile(join(__dirname, '../fenster/index.html'))
}
return fenster
}
+171
View File
@@ -0,0 +1,171 @@
// Rippy v5 — Haupt-Prozess (KONZEPT-WINDOWS.md § 4.1).
// Ablauf: Einzelinstanz-Sperre → Prozess-Leine → Kern starten → Fenster
// öffnen → Fenster und Kern über einen MessagePort verdrahten.
//
// Smoke-Modus (`electron . --smoke`): beweist das W-0-Fertigkriterium
// („Ein Fenster geht auf, der Kern läuft") maschinell — alle drei Prozesse,
// beide IPC-Wege und die Datenbank — und endet mit Code 0 (grün) oder 2.
// RIPPY_SMOKE_BILD=<pfad.png> legt zusätzlich einen Screenshot ab.
import { app, ipcMain, type BrowserWindow } from 'electron'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine'
const smokeModus = process.argv.includes('--smoke')
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn.
if (!app.requestSingleInstanceLock()) {
app.exit(0)
} else {
start()
}
function start(): void {
// Die Leine kommt VOR dem ersten Kindprozess — Mitgliedschaft in der
// Arbeitsgruppe wird nur beim Start eines Kindes vererbt (§ 3.3).
const leine: LeineStatus = leineSetzen()
if (!leine.ok) {
// R4: laut, nicht still — zusätzlich steht es im Fenster-Status.
console.error(`[haupt] Prozess-Leine NICHT gesetzt: ${leine.fehler}`)
}
let fenster: BrowserWindow | null = null
let letzteKernNachricht: KernNachricht | null = null
const smoke = {
fenster: false,
kern: false,
datenbank: false,
leine: leine.ok,
pong: false,
}
const kernVerwaltung = new KernVerwaltung(
join(app.getPath('userData'), 'rippy.db'),
(nachricht) => {
letzteKernNachricht = nachricht
if (nachricht.art === 'kern-bereit') {
smoke.kern = true
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
}
},
() => statusSenden(),
)
function statusSenden(): void {
if (fenster === null || fenster.isDestroyed()) return
const status: HauptStatus = {
version: app.getVersion(),
leine,
kern: kernVerwaltung.zustand(),
}
fenster.webContents.send('haupt-status', status)
}
// Kurze Rückmeldungen aus dem Fenster (Brücke: rippy.melden). Im
// Smoke-Lauf ist 'kern-pong' der Beweis, dass der MessagePort
// Fenster ↔ Kern in BEIDE Richtungen trägt.
ipcMain.on('fenster-meldung', (_ereignis, art: unknown) => {
if (art === 'kern-pong') {
smoke.pong = true
smokePruefen()
}
})
app.on('second-instance', () => {
if (fenster !== null && !fenster.isDestroyed()) {
if (fenster.isMinimized()) fenster.restore()
fenster.focus()
}
})
// W-0: Fenster zu = Rippy zu. Erst W-5 bringt das Tray-Symbol — dann lebt
// Rippy nach dem Schließen im Hintergrund weiter (§ 4.1, Punkt 3).
app.on('window-all-closed', () => {
app.quit()
})
app.on('before-quit', () => {
kernVerwaltung.beenden()
})
void app.whenReady().then(() => {
kernVerwaltung.starten()
fenster = fensterErstellen()
fenster.webContents.on('did-finish-load', () => {
smoke.fenster = true
// Verdrahtung NACH dem Laden: Die Seite lauscht erst dann auf den Port.
kernVerwaltung.fensterVerbinden(fenster!.webContents)
statusSenden()
smokePruefen()
})
})
let smokeAbgeschlossen = false
function smokePruefen(): void {
if (!smokeModus || smokeAbgeschlossen) return
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
if (fehlt.length > 0) return
smokeAbgeschlossen = true
void smokeAbschliessen()
}
async function smokeAbschliessen(): Promise<void> {
const bildPfad = process.env['RIPPY_SMOKE_BILD']
if (bildPfad !== undefined && bildPfad.length > 0 && fenster !== null) {
try {
// Der Pong kommt schneller, als der Compositor das erste Bild malt —
// ein sofortiges capturePage lieferte hier »UnknownVizError«
// (gemessen 30.08.2026). Kurz warten, bis wirklich gemalt ist.
await new Promise((fertig) => setTimeout(fertig, 750))
const bild = await fenster.webContents.capturePage()
await writeFile(bildPfad, bild.toPNG())
console.log(`SMOKE: Bild abgelegt unter ${bildPfad}`)
} catch (fehler) {
console.error(`SMOKE: Bild fehlgeschlagen: ${String(fehler)}`)
}
}
const kern = letzteKernNachricht
const kernInfo =
kern !== null && kern.art === 'kern-bereit'
? `kern=pid:${kern.pid},node:${kern.nodeVersion}`
: 'kern=?'
console.log(
`SMOKE: OK — fenster=geladen ${kernInfo} datenbank=ok leine=gesetzt pong=ok version=${app.getVersion()}`,
)
// app.exit() überspringt before-quit — den Kern deshalb hier gewollt
// beenden, sonst deutet der exit-Wächter das als Absturz und startet neu.
kernVerwaltung.beenden()
setTimeout(() => app.exit(0), 100)
}
if (smokeModus) {
setTimeout(() => {
if (smokeAbgeschlossen) return
smokeAbgeschlossen = true
const fehlt = Object.entries(smoke)
.filter(([, wert]) => !wert)
.map(([name]) => name)
console.error(`SMOKE: FEHLGESCHLAGEN — es fehlt: ${fehlt.join(', ')}`)
kernVerwaltung.beenden()
setTimeout(() => app.exit(2), 100)
}, 25_000)
}
}
+91
View File
@@ -0,0 +1,91 @@
// Startet und überwacht den Kern (utilityProcess) — KONZEPT § 4.1.
// utilityProcess statt child_process.fork ist Electrons eigene Empfehlung;
// nur er kann einen MessagePort DIREKT zwischen Kern und Fenster aufspannen
// (Electron-Doku »utilityProcess« / »MessagePorts in Electron«).
import { MessageChannelMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron'
import { join } from 'node:path'
import { istKernNachricht, type HauptNachricht, type KernNachricht } from '../gemeinsam/nachrichten'
export interface KernZustand {
laeuft: boolean
pid?: number
neustarts: number
}
// Stirbt der Kern öfter als so, stimmt etwas Grundsätzliches — dann wird
// nicht weiter neu gestartet, sondern der Zustand im Fenster gezeigt.
const MAX_NEUSTARTS_JE_MINUTE = 3
export class KernVerwaltung {
private kern: UtilityProcess | null = null
private beendenGewollt = false
private neustarts = 0
private neustartZeiten: number[] = []
constructor(
private readonly datenbankPfad: string,
private readonly beiNachricht: (nachricht: KernNachricht) => void,
private readonly beiZustand: (zustand: KernZustand) => void,
) {}
starten(): void {
const kernDatei = join(__dirname, 'kern.js')
const kern = utilityProcess.fork(kernDatei, [`--datenbank=${this.datenbankPfad}`], {
serviceName: 'Rippy-Kern',
stdio: 'inherit',
})
this.kern = kern
kern.on('message', (nachricht: unknown) => {
if (istKernNachricht(nachricht)) this.beiNachricht(nachricht)
})
kern.on('spawn', () => {
this.beiZustand(this.zustand())
})
kern.on('exit', (code) => {
this.kern = null
if (this.beendenGewollt) return
// R4: Ein gestorbener Kern ist ein lautes Ereignis, kein stilles.
console.error(`[haupt] Kern beendet mit Code ${code} — Neustart wird geprüft`)
const jetzt = Date.now()
this.neustartZeiten = this.neustartZeiten.filter((t) => jetzt - t < 60_000)
if (this.neustartZeiten.length >= MAX_NEUSTARTS_JE_MINUTE) {
console.error('[haupt] Kern stirbt wiederholt — kein weiterer Neustart')
this.beiZustand(this.zustand())
return
}
this.neustartZeiten.push(jetzt)
this.neustarts += 1
this.starten()
})
}
/**
* Spannt einen frischen MessagePort direkt zwischen Kern und Fenster auf.
* Der Fortschritt eines Rips geht damit später ohne Umweg über das Haupt
* ins Fenster (§ 4.1).
*/
fensterVerbinden(webContents: WebContents): void {
if (this.kern === null) return
const { port1, port2 } = new MessageChannelMain()
const nachricht: HauptNachricht = { art: 'fenster-port' }
this.kern.postMessage(nachricht, [port1])
webContents.postMessage('kern-port', null, [port2])
}
zustand(): KernZustand {
return {
laeuft: this.kern !== null,
pid: this.kern?.pid,
neustarts: this.neustarts,
}
}
beenden(): void {
this.beendenGewollt = true
this.kern?.kill()
this.kern = null
}
}
+92
View File
@@ -0,0 +1,92 @@
// Die Prozess-Leine (Windows-Arbeitsgruppe / Job Object) — KONZEPT § 3.3,
// gemessen in beweise/leine.js am 30.08.2026. Der Befund dahinter
// (SAVEPOINT v4.0-rc10): Windows räumt Kindprozesse NICHT auf. Ein hart
// beendeter Rippy hinterließe ein makemkvcon, das das Laufwerk festhält.
//
// Mechanik: Dieser Prozess legt die Arbeitsgruppe an, setzt
// KILL_ON_JOB_CLOSE und hängt SICH SELBST hinein. Kinder (der Kern, später
// makemkvcon/HandBrakeCLI im Kern) erben die Mitgliedschaft. Stirbt der
// Haupt-Prozess — Absturz, taskkill /F, Drüber-Installieren —, schließt
// Windows sein einziges Handle auf die Gruppe, und ALLE Mitglieder sterben
// mit (§ 4.1: „hält die Prozess-Leine — ALLES stirbt mit ihm").
//
// WARUM die Leine HIER liegt und nicht in kern/werkzeuge/leine.ts (wie die
// Ordnerliste § 4.2 sie einsortiert): Das Handle muss bei dem Prozess liegen,
// dessen Tod alles mitreißen soll — und das ist laut § 4.1 der Haupt-Prozess.
// Hielte der Kern das Handle, wäre ein hart beendetes Haupt wieder die
// rc10-Waisen-Falle. Damit ist diese Datei neben kern/laufwerk/win32.ts der
// ZWEITE erlaubte koffi-Ort; der Wächter-Test R1 kennt genau diese zwei.
import koffi from 'koffi'
// Konstanten aus winnt.h — dieselbe Herleitung wie beweise/leine.js:
const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE = 9 // JobObjectExtendedLimitInformation
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64 (Rippy v5 ist
// x64-only, § 8); LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION
// bei Offset 16.
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
export type LeineStatus = { ok: true } | { ok: false; fehler: string }
let gesetzt: LeineStatus | null = null
/**
* Setzt die Prozess-Leine für DIESEN Prozess. Einmal pro Programmlauf,
* VOR dem Start des Kerns Kinder erben die Mitgliedschaft nur, wenn sie
* nach dem Anlegen gestartet werden.
*
* Ein Fehlschlag wird als Wert zurückgegeben, nie verschluckt (R4): Der
* Aufrufer protokolliert ihn und zeigt ihn im Fenster an.
*/
export function leineSetzen(): LeineStatus {
if (gesetzt !== null) return gesetzt
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func(
'void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)',
)
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)',
)
const AssignProcessToJobObject = kernel32.func(
'bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)',
)
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const job = CreateJobObjectW(null, null)
if (koffi.address(job) === 0n) {
gesetzt = { ok: false, fehler: `CreateJobObjectW: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
if (
!SetInformationJobObject(
job,
JOB_OBJECT_EXTENDED_LIMIT_INFORMATION_KLASSE,
info,
STRUKTUR_GROESSE,
)
) {
gesetzt = { ok: false, fehler: `SetInformationJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
gesetzt = { ok: false, fehler: `AssignProcessToJobObject: Win32-Fehler ${GetLastError()}` }
return gesetzt
}
// Das Job-Handle wird ABSICHTLICH nie geschlossen — sein Zuschnappen beim
// Prozess-Tod IST der Mechanismus.
gesetzt = { ok: true }
return gesetzt
} catch (fehler) {
gesetzt = { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
return gesetzt
}
}
+89
View File
@@ -0,0 +1,89 @@
// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1).
// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. In W-0 kann der
// Kern: die Datenbank öffnen (node:sqlite, § 3.4), sich beim Haupt melden und
// über einen direkten MessagePort mit dem Fenster reden (Ping → Pong).
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type KernNachricht,
} from '../gemeinsam/nachrichten'
import { Datenbank } from './speicher/db'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
// nicht — er ist ein reiner Node-Prozess).
function datenbankPfadAusArgv(argv: readonly string[]): string | null {
for (const arg of argv) {
if (arg.startsWith('--datenbank=')) {
const pfad = arg.slice('--datenbank='.length)
if (pfad.length > 0) return pfad
}
}
return null
}
let db: Datenbank | null = null
let dbStatus: DbStatus
const pfad = datenbankPfadAusArgv(process.argv)
if (pfad === null) {
dbStatus = { ok: false, fehler: 'Kein --datenbank=<pfad> übergeben' }
} else {
try {
db = new Datenbank(pfad)
// Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis.
db.einstellungSchreiben('letzter_start', new Date().toISOString())
if (db.einstellungLesen('letzter_start') === null) {
dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' }
} else {
dbStatus = { ok: true, pfad }
}
} catch (fehler) {
// R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt.
// Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu
// »es geht nicht« werden (§ 6.2, sinngemäß).
dbStatus = { ok: false, fehler: String(fehler) }
}
}
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
fensterPort?.postMessage(nachricht)
}
process.parentPort.on('message', (ereignis) => {
const nachricht: unknown = ereignis.data
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
// Fenster bringt einen neuen Port — der alte wird geschlossen.
fensterPort?.close()
fensterPort = ereignis.ports[0] ?? null
if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data
if (istFensterNachricht(frage) && frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
}
})
fensterPort.start()
// Damit das Fenster nach dem Verbinden sofort den Stand kennt:
anFenster(bereitNachricht())
}
})
function bereitNachricht(): KernNachricht {
return {
art: 'kern-bereit',
pid: process.pid,
nodeVersion: process.versions.node,
datenbank: dbStatus,
}
}
process.parentPort.postMessage(bereitNachricht())
+70
View File
@@ -0,0 +1,70 @@
// Der EINZIGE Ort, der die Datenbank anfasst — § 6.7: EINE Quelle für
// Einstellungen. Ein Wächter-Test (test/waechter.test.ts) prüft mechanisch,
// dass 'node:sqlite' nirgendwo sonst importiert wird. Der Fund hinter der
// Regel: In rc11 schrieb die Oberfläche in die Datenbank, der Betrieb las aus
// einer Konfigurationsdatei, die niemand schrieb.
//
// node:sqlite statt better-sqlite3: am 30.08.2026 in Electron 44 im
// utilityProcess gemessen (KONZEPT-WINDOWS.md § 3.4, beweise/sqlite-*.js) —
// keine native Kompilierung, kein node-gyp in der Bau-Kette.
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
const SCHEMA_VERSION = 1
export class Datenbank {
readonly pfad: string
private db: DatabaseSync
/**
* Öffnet (oder erzeugt) die Datenbank und legt das Schema an.
* Wirft bei nicht anlegbarem Pfad der Aufrufer meldet den Fehler
* LAUT weiter (R4), statt mit einer stillen Ersatz-Datenbank zu starten.
*/
constructor(pfad: string) {
this.pfad = pfad
if (pfad !== ':memory:') {
mkdirSync(dirname(pfad), { recursive: true })
}
this.db = new DatabaseSync(pfad)
this.db.exec(
`CREATE TABLE IF NOT EXISTS meta (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS einstellungen (
schluessel TEXT PRIMARY KEY,
wert TEXT NOT NULL
);`,
)
this.db
.prepare(`INSERT OR REPLACE INTO meta (schluessel, wert) VALUES ('schema_version', ?)`)
.run(String(SCHEMA_VERSION))
}
/** Liest eine Einstellung; unbekannter Schlüssel ist `null`, kein Fehler. */
einstellungLesen(schluessel: string): string | null {
const zeile = this.db
.prepare(`SELECT wert FROM einstellungen WHERE schluessel = ?`)
.get(schluessel) as { wert: string } | undefined
return zeile === undefined ? null : zeile.wert
}
einstellungSchreiben(schluessel: string, wert: string): void {
this.db
.prepare(`INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)`)
.run(schluessel, wert)
}
schemaVersion(): number {
const zeile = this.db
.prepare(`SELECT wert FROM meta WHERE schluessel = 'schema_version'`)
.get() as { wert: string } | undefined
return zeile === undefined ? 0 : Number(zeile.wert)
}
schliessen(): void {
this.db.close()
}
}
+73
View File
@@ -0,0 +1,73 @@
// Tests für kern/speicher/db.ts — laufen mit dem System-Node (node:sqlite
// gibt es dort wie in Electrons Node, KONZEPT-WINDOWS.md § 3.4).
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Datenbank } from '../src/kern/speicher/db'
let aufraeumen: Array<() => void> = []
afterEach(() => {
// LIFO — zuletzt Registriertes zuerst: die Datenbank muss ZU sein, bevor
// ihr Ordner gelöscht wird (Windows verweigert das Löschen offener Dateien).
for (const weg of aufraeumen.splice(0).reverse()) weg()
})
function frischerPfad(): string {
const ordner = mkdtempSync(join(tmpdir(), 'rippy-db-test-'))
aufraeumen.push(() => rmSync(ordner, { recursive: true, force: true }))
return join(ordner, 'unterordner', 'rippy.db')
}
describe('Datenbank', () => {
it('legt Datei samt fehlender Ordner an und meldet Schema-Version', () => {
const pfad = frischerPfad()
const db = new Datenbank(pfad)
aufraeumen.push(() => db.schliessen())
expect(existsSync(pfad)).toBe(true)
expect(db.schemaVersion()).toBe(1)
})
it('schreibt und liest eine Einstellung', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.einstellungSchreiben('ablage', 'E:\\Rippy')
expect(db.einstellungLesen('ablage')).toBe('E:\\Rippy')
})
it('überschreibt eine vorhandene Einstellung', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.einstellungSchreiben('ablage', 'C:\\alt')
db.einstellungSchreiben('ablage', 'E:\\neu')
expect(db.einstellungLesen('ablage')).toBe('E:\\neu')
})
it('unbekannter Schlüssel ist null, kein Fehler und kein Leerwert', () => {
// R2: „nicht gefunden" ist eine Aussage (null) — kein erfundener
// Leerstring, der als Wert gelesen würde.
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
expect(db.einstellungLesen('gibt-es-nicht')).toBeNull()
})
it('bleibt über Schließen und Neuöffnen erhalten', () => {
const pfad = frischerPfad()
const erste = new Datenbank(pfad)
erste.einstellungSchreiben('letzter_start', '2026-08-30T12:00:00Z')
erste.schliessen()
const zweite = new Datenbank(pfad)
aufraeumen.push(() => zweite.schliessen())
expect(zweite.einstellungLesen('letzter_start')).toBe('2026-08-30T12:00:00Z')
})
it('wirft bei nicht anlegbarem Pfad, statt still weiterzumachen', () => {
// R4: Der Aufrufer (kern/index.ts) fängt das und MELDET es — aber die
// Datenbank selbst darf so einen Zustand nie verschlucken.
// NUL ist unter Windows als Ordnername unzulässig; unter Linux (CI)
// scheitert das Anlegen unterhalb einer Datei genauso zuverlässig.
const kaputt = join(process.platform === 'win32' ? 'C:\\NUL<>:' : '/dev/null/x', 'rippy.db')
expect(() => new Datenbank(kaputt)).toThrow()
})
})
+73
View File
@@ -0,0 +1,73 @@
// Tests für das Nachrichten-Schema (gemeinsam/nachrichten.ts). Die
// Prüf-Funktionen sind die Eingangstür beider Prozesse — was hier
// durchkommt, wird drüben ungeprüft verwendet.
import { describe, expect, it } from 'vitest'
import {
istDbStatus,
istFensterNachricht,
istHauptNachricht,
istKernNachricht,
} from '../src/gemeinsam/nachrichten'
describe('istKernNachricht', () => {
it('akzeptiert kern-bereit mit vollständigen Feldern', () => {
expect(
istKernNachricht({
art: 'kern-bereit',
pid: 1234,
nodeVersion: '24.18.1',
datenbank: { ok: true, pfad: 'C:\\x\\rippy.db' },
}),
).toBe(true)
})
it('akzeptiert kern-bereit auch mit Datenbank-Fehler', () => {
expect(
istKernNachricht({
art: 'kern-bereit',
pid: 1,
nodeVersion: '24.18.1',
datenbank: { ok: false, fehler: 'Platte voll' },
}),
).toBe(true)
})
it('akzeptiert pong und kern-fehler', () => {
expect(istKernNachricht({ art: 'pong', zeit: 17 })).toBe(true)
expect(istKernNachricht({ art: 'kern-fehler', text: 'kaputt' })).toBe(true)
})
it('lehnt Unvollständiges und Fremdes ab', () => {
expect(istKernNachricht(null)).toBe(false)
expect(istKernNachricht('kern-bereit')).toBe(false)
expect(istKernNachricht({ art: 'kern-bereit', pid: 1 })).toBe(false)
expect(istKernNachricht({ art: 'pong', zeit: 'gleich' })).toBe(false)
expect(istKernNachricht({ art: 'unbekannt' })).toBe(false)
})
})
describe('istDbStatus', () => {
it('verlangt zum ok-Wert den Pfad, zum Fehler den Text', () => {
expect(istDbStatus({ ok: true, pfad: 'x' })).toBe(true)
expect(istDbStatus({ ok: false, fehler: 'y' })).toBe(true)
expect(istDbStatus({ ok: true })).toBe(false)
expect(istDbStatus({ ok: false })).toBe(false)
expect(istDbStatus({ ok: 'ja' })).toBe(false)
})
})
describe('istFensterNachricht', () => {
it('akzeptiert nur ping mit Zeit', () => {
expect(istFensterNachricht({ art: 'ping', zeit: 1 })).toBe(true)
expect(istFensterNachricht({ art: 'ping' })).toBe(false)
expect(istFensterNachricht({ art: 'pong', zeit: 1 })).toBe(false)
})
})
describe('istHauptNachricht', () => {
it('akzeptiert nur fenster-port', () => {
expect(istHauptNachricht({ art: 'fenster-port' })).toBe(true)
expect(istHauptNachricht({ art: 'port' })).toBe(false)
expect(istHauptNachricht(undefined)).toBe(false)
})
})
+92
View File
@@ -0,0 +1,92 @@
// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3.
// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem
// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei.
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative, sep } from 'node:path'
import { describe, expect, it } from 'vitest'
const WURZEL = join(__dirname, '..')
const SRC = join(WURZEL, 'src')
function quelldateien(ordner: string): string[] {
const ergebnis: string[] = []
for (const name of readdirSync(ordner)) {
const pfad = join(ordner, name)
if (statSync(pfad).isDirectory()) {
ergebnis.push(...quelldateien(pfad))
} else if (/\.(ts|tsx)$/.test(name)) {
ergebnis.push(pfad)
}
}
return ergebnis
}
function relativPosix(pfad: string): string {
return relative(WURZEL, pfad).split(sep).join('/')
}
const dateien = quelldateien(SRC).map((pfad) => ({
pfad: relativPosix(pfad),
inhalt: readFileSync(pfad, 'utf8'),
}))
describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
const erlaubt = new Set(['src/haupt/leine.ts', 'src/kern/laufwerk/win32.ts'])
it('koffi wird nirgendwo sonst angefasst', () => {
const verstoesse = dateien
.filter((d) => !erlaubt.has(d.pfad))
.filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})
describe('R2/R4 — kein Fehlschlag verschwindet still', () => {
it('kein leerer catch-Block', () => {
// R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde
// gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum.
const verstoesse = dateien
.filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => {
// R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß
// „es gibt keine Jobs", die Liste leerte sich im Sekundentakt.
const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/
const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/
const verstoesse = dateien
.filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})
describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => {
it('niemand öffnet einen Server oder lauscht auf einem Port', () => {
// Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene
// Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von
// electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den
// reinen Variablennamen ist erlaubt.
const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/
const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
// aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul.
const verstoesse = dateien
.filter((d) => d.pfad !== 'src/kern/speicher/db.ts')
.filter((d) => /['"]node:sqlite['"]/.test(d.inhalt))
.map((d) => d.pfad)
expect(verstoesse).toEqual([])
})
})
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM"],
"types": [],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["src/bruecke/**/*.ts", "src/gemeinsam/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.bruecke.json" },
{ "path": "./tsconfig.web.json" }
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["node", "electron"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": [
"src/haupt/**/*.ts",
"src/kern/**/*.ts",
"src/gemeinsam/**/*.ts",
"electron.vite.config.ts",
"vitest.config.ts",
"test/**/*.ts"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["src/fenster/**/*.ts", "src/fenster/**/*.tsx", "src/gemeinsam/**/*.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
// Testlauf (Vitest) — läuft mit dem System-Node, NICHT in Electron.
// Deshalb dürfen Tests nur Module anfassen, die ohne Electron und ohne
// Windows laufen: gemeinsam/, kern/speicher/ (node:sqlite gibt es in
// Node 24 wie in Electrons Node — KONZEPT-WINDOWS.md § 3.4) und die
// Wächter, die Quelltext nur LESEN. Alles mit koffi/Win32 wird hier
// weder importiert noch ausgeführt — das beweist der Smoke-Lauf (BAUEN.md).
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
})
+38
View File
@@ -292,6 +292,44 @@ def windows_laufwerke(art=None, buchstaben=None) -> list:
return gefunden
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", "")
+14 -3
View File
@@ -269,19 +269,27 @@ def device_info(device_path: str) -> dict:
vendor = read_sys_attr(name, "vendor")
model = read_sys_attr(name, "model")
grund = ""
try:
status_code = drive_status(device_path)
except OSError:
except OSError as e:
status_code = -1
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
disc_type = "unknown"
status = "empty"
# Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER —
# man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1
# daran („DIE Regel des Ports", siehe dort); hier stand weiterhin
# „empty", und damit behauptete derselbe Ereignisstrom je nach
# Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026).
status = "unknown" if status_code < 0 else "empty"
if status_code == CDS_DISC_OK:
status = "ready"
try:
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
except OSError:
except OSError as e:
disc_type = "unknown"
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
return {
"id": name,
@@ -291,4 +299,7 @@ def device_info(device_path: str) -> dict:
"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,
}
+33 -1
View File
@@ -186,6 +186,37 @@ def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer():
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
def test_unzugaengliches_laufwerk_sagt_auch_WARUM():
"""Befund 30.08.2026: „jetzt erkennt rippy die disk garnicht mehr (im
log steht zwar erkannt, aber ein start des rips ist nicht moeglich)".
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
Medien-Abfragen mehr (Win32-Fehler 1); die Geraete-Auskunft kam weiter
durch. Im UI stand eine vollstaendige Laufwerkskarte mit Modell und
Seriennummer und unknown" ohne ein Wort dazu. Rippy kannte den
Grund und behielt ihn fuer sich.
"""
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler(
"CreateFileW", w.ERROR_INVALID_FUNCTION))
info = windows.device_info(r"\\.\D:", api=api)
assert info["status"] == "unknown"
assert "auswerfen" in info["grund"]
def test_grund_bleibt_leer_solange_alles_geht():
"""Ein Feld, das immer gefuellt ist, sagt nichts mehr."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK,
groesse=25 * 1024**3)
assert windows.device_info(r"\\.\D:", api=api)["grund"] == ""
def test_unbekannte_fehlernummer_wird_GENANNT():
"""Eine Nummer, nach der man suchen kann, ist mehr als ein leerer Satz."""
text = windows.zugriffs_grund(windows.Win32Fehler("CreateFileW", 4711))
assert "4711" in text
assert windows.zugriffs_grund(OSError("ohne Nummer"))
@pytest.mark.parametrize("flags,groesse,erwartet", [
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
(w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"),
@@ -215,7 +246,8 @@ def test_device_info_hat_dieselben_felder_wie_unter_linux():
Karte im Dashboard leer."""
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
eintrag = windows.device_info(r"\\.\D:", api=api)
assert set(eintrag) == {"id", "name", "type", "path", "status", "model", "serial"}
assert set(eintrag) == {"id", "name", "type", "path", "status", "model",
"serial", "grund"}
assert eintrag["id"] == "D"
assert eintrag["status"] == "ready"
assert eintrag["type"] == "bluray"
+1
View File
@@ -117,6 +117,7 @@ ERROR_NOT_READY = 21 # kein Medium eingelegt
ERROR_ACCESS_DENIED = 5
ERROR_FILE_NOT_FOUND = 2
ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht
ERROR_NOT_SUPPORTED = 50 # Geraet lehnt den Steuercode gerade ab
ERROR_MEDIA_CHANGED = 1110
ERROR_NO_MEDIA_IN_DRIVE = 1112
+114 -3
View File
@@ -154,6 +154,7 @@ class Win32:
self._ctypes = ctypes
self._wintypes = wintypes
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_typen_erklaeren(ctypes, wintypes, self._k32)
# ── Laufwerke finden ────────────────────────────────────────────────
def laufwerksbuchstaben(self) -> list:
@@ -174,7 +175,12 @@ class Win32:
0,
None,
)
if handle == w.INVALID_HANDLE_VALUE or handle in (0, None):
# Mit erklaertem `restype` ist der Fehlerwert nicht mehr -1, sondern
# 0xFFFFFFFFFFFFFFFF — siehe `_typen_erklaeren`. Beides pruefen: die
# nackte -1 bliebe sonst als tote Bedingung stehen und taeuschte
# Absicherung vor.
ungueltig = self._ctypes.c_void_p(-1).value
if handle in (None, 0, ungueltig, w.INVALID_HANDLE_VALUE):
raise Win32Fehler("CreateFileW", self._ctypes.get_last_error())
return handle
@@ -200,6 +206,48 @@ class Win32:
return aus_puffer.raw[:zurueck.value] if aus_puffer else b""
def _typen_erklaeren(ctypes, wintypes, k) -> None:
r"""Den kernel32-Funktionen ihre echten Typen beibringen.
## Warum das keine Formsache ist (Befund 30.08.2026)
Ohne `restype` nimmt ctypes `c_int` an 32 Bit, mit Vorzeichen. Ein
Windows-HANDLE ist auf einem 64-Bit-System aber ein Zeiger, und dasselbe
gilt fuer den Weg HINEIN: Ein Handle, das ohne `argtypes` uebergeben wird,
geht als `c_int` durch. Solange Windows kleine Handle-Werte vergibt das
tut es meistens faellt nichts auf. Oberhalb von 2^31 wird still
abgeschnitten, und das folgende `DeviceIoControl` arbeitet auf einem
Handle, das es nie gab.
Dieselbe Lehre steht seit dem 29.08.2026 im Kopf von
`platform/winlauf.py` (dort die Job-Objekte). Hier war sie noch nicht
angekommen und das ist der Treiber, durch den JEDE Disc-Erkennung und
jeder Auswurf laeuft.
Mit `restype` aendert sich der FEHLERWERT von `CreateFileW`: Es gibt
`(HANDLE)-1` zurueck, und als Zeiger gelesen ist das
0xFFFFFFFFFFFFFFFF, nicht -1. Wer nur die Typen erklaert und die alte
Pruefung stehen laesst, legt damit die Fehlerbehandlung still ein
fehlgeschlagenes Oeffnen saehe aus wie ein Erfolg. Siehe `Win32.oeffnen`.
"""
k.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD,
wintypes.HANDLE]
k.CreateFileW.restype = wintypes.HANDLE
k.CloseHandle.argtypes = [wintypes.HANDLE]
k.CloseHandle.restype = wintypes.BOOL
k.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.c_void_p, wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p]
k.DeviceIoControl.restype = wintypes.BOOL
k.GetLogicalDrives.argtypes = []
k.GetLogicalDrives.restype = wintypes.DWORD
k.GetDriveTypeW.argtypes = [wintypes.LPCWSTR]
k.GetDriveTypeW.restype = wintypes.UINT
def _api(api):
return api if api is not None else Win32()
@@ -464,6 +512,63 @@ def pfad_zu_kennung(name: str, geraete=None) -> str:
return ""
#: Klartext zu den Win32-Fehlern, an denen ein Laufwerks-Zugriff scheitert.
#:
#: ## Warum der Grund nicht im „unknown" verschwinden darf (30.08.2026)
#:
#: Nach einem Rip mit 29 Lesefehlern und einem gescheiterten Auswurf
#: antwortete das Laufwerk des Commanders auf nichts mehr, was mit dem
#: MEDIUM zu tun hat — an seinem Geraet gemessen:
#:
#: CreateFileW mit GENERIC_READ -> Win32-Fehler 1
#: IOCTL_STORAGE_CHECK_VERIFY2 -> Win32-Fehler 1
#: IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
#: CreateFileW mit Zugriff 0 -> geht
#: IOCTL_STORAGE_QUERY_PROPERTY -> geht
#:
#: Die GERAETE-Auskunft kam also weiter durch: Modell und Seriennummer
#: standen im UI, waehrend Typ und Status auf „unknown" fielen und sich
#: kein Rip mehr starten liess. Im Protokoll stand als letztes die laengst
#: veraltete Zeile „Disc erkannt". Sein Befund dazu: „jetzt erkennt rippy
#: die disk garnicht mehr (im log steht zwar erkannt, aber ein start des
#: rips ist nicht moeglich)".
#:
#: Rippy KANNTE die Ursache und behielt sie fuer sich. Das ist dieselbe
#: Sorte Nichtauskunft wie „Platz fuer Rippy: unbekannt" (28.08.2026) —
#: sie sieht aus wie eine Auskunft.
ZUGRIFFS_GRUENDE = {
w.ERROR_INVALID_FUNCTION:
"Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert "
"nach abgebrochenen Lesevorgaengen. Abhilfe: Disc ueber die Taste am "
"Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner "
"neu starten.",
w.ERROR_NOT_SUPPORTED:
"Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen "
"und neu einlegen.",
w.ERROR_ACCESS_DENIED:
"Ein anderes Programm haelt das Laufwerk fest.",
w.ERROR_FILE_NOT_FOUND:
"Dieses Laufwerk gibt es nicht mehr.",
w.ERROR_NOT_READY:
"Es liegt keine Disc im Laufwerk.",
}
def zugriffs_grund(fehler) -> str:
"""Klartext zu einem fehlgeschlagenen Laufwerks-Zugriff (pure Funktion).
Unbekannte Nummern werden GENANNT, nicht verschwiegen eine Nummer,
nach der man suchen kann, ist mehr als ein leerer Satz.
"""
code = getattr(fehler, "winerror", None) or getattr(fehler, "code", None)
if code in ZUGRIFFS_GRUENDE:
return ZUGRIFFS_GRUENDE[code]
if code:
return ("Das Laufwerk antwortet nicht (Win32-Fehler %s). Abhilfe: "
"Disc auswerfen und neu einlegen." % code)
return "Das Laufwerk antwortet nicht."
def device_info(geraet: str, api=None) -> dict:
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
@@ -474,18 +579,21 @@ def device_info(geraet: str, api=None) -> dict:
api = _api(api)
status = "unknown"
disc_typ = "unknown"
grund = ""
try:
if drive_status(geraet, api) == CDS_DISC_OK:
status = "ready"
try:
disc_typ = classify(disc_status(geraet, api),
disc_size_bytes(geraet, api))
except OSError:
except OSError as e:
disc_typ = "unknown"
grund = zugriffs_grund(e)
else:
status = "empty"
except OSError:
except OSError as e:
status = "unknown"
grund = zugriffs_grund(e)
buchstabe = kennung(geraet)
angaben = _angaben_gemerkt(geraet, api)
@@ -499,6 +607,9 @@ def device_info(geraet: str, api=None) -> dict:
"status": status,
"model": modell,
"serial": angaben.get("seriennummer", ""),
# Leer, solange alles geht. Sonst steht hier, WARUM „unknown"
# dasteht — siehe ZUGRIFFS_GRUENDE.
"grund": grund,
}
+10 -1
View File
@@ -81,7 +81,16 @@ def naechster_vorhandener(pfad: str, existiert=None) -> str:
existiert = existiert or os.path.isdir
modul = _modul(pfad)
pfad = (pfad or "").rstrip("\\/")
pfad = (pfad or "").strip()
# Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als
# der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist
# unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel
# (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in
# `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe:
# aus "\\server\freigabe\" darf kein Ort ohne Trenner werden.
gekuerzt = pfad.rstrip("\\/")
if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]:
pfad = gekuerzt
gesehen = set()
while pfad and pfad not in gesehen:
if existiert(pfad):
+44
View File
@@ -0,0 +1,44 @@
"""Wie HandBrakeCLI angesprochen und wie seine Ausgabe gelesen wird.
## Warum das im GEMEINSAMEN Paket liegt
Zwei Module rufen HandBrake auf: `docker/worker/ripping.py` (der Encode) und
`docker/worker/caps.py` (Preset-Liste, Hilfe, Version). `caps` wird auch vom
Standalone-Betrieb geladen und darf `ripping` nicht importieren, nur um an
eine Konstante zu kommen.
Dasselbe Muster wie bei `makemkv_aufruf.py`: Eine Entscheidung, zwei Orte
einer altert. Dort kostete es die Formatzeile, hier waere es die Kodierung.
"""
#: Wie HandBrakes Ausgabe gelesen wird — und warum mit errors="replace".
#:
#: ## Der Befund (30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen)
#:
#: HandBrake schreibt **zwei Kodierungen in denselben Strom**. Eine Datei
#: namens „Glück über München.mkv" gescannt, die Bytes des Pfades in
#: derselben Ausgabe:
#:
#: Zeile „Opening ..." C3 BC = UTF-8
#: Zeile „..., title 1 ..." 81 = CP850 (OEM)
#:
#: Es gibt hier also keine richtige Kodierung, nur eine, die nicht
#: abstuerzt. Und ohne diese Zeile stuerzte es ab: In CP850 ist ü das Byte
#: **0x81**, und 0x81 ist in cp1252 — der Gebietsschema-Kodierung eines
#: deutschen Windows, die text=True von sich aus waehlt — **undefiniert**:
#:
#: UnicodeDecodeError: 'charmap' codec can't decode byte 0x81
#: in position 785: character maps to <undefined>
#:
#: In `run_handbrake` faellt das mitten in der Leseschleife an, wird von
#: `except Exception` eingefangen und landet als Fehlertext im Job. **Jeder
#: Film, dessen Pfad ein ü enthaelt, liess sich damit nicht komprimieren**
#: — „Glück", „Tür", „München", „Über", „Grün". Dasselbe gilt fuer
#: ì, Å, É, Ø (0x8D, 0x8F, 0x90, 0x9D).
#:
#: ⚠️ NICHT `text_von()` wie bei makemkvcon: Das liest binaer, und im
#: Binaermodus gibt es keine Universal-Newlines. HandBrake trennt seine
#: Fortschrittszeilen aber mit CR (0x0D) — der Balken waere weg. Alles, was
#: Rippy aus der Ausgabe liest, ist ASCII (Prozente, „Invalid preset",
#: „unknown option"); ersetzt wird also nur, was ohnehin nur Anzeige ist.
HB_LESEN = {"text": True, "errors": "replace"}
+32
View File
@@ -0,0 +1,32 @@
"""Warum HandBrakes Ausgabe mit errors="replace" gelesen wird.
Der Test prueft die VORAUSSETZUNG, nicht die Zuweisung: dass HandBrakes
OEM-Bytes in cp1252 wirklich nicht dekodierbar sind. Gemessen am 30.08.2026
am mitgelieferten HandBrakeCLI 1.11.2 (AGENTS Regel D).
"""
from rippy.rip import handbrake_aufruf
def test_oem_umlaute_sprengen_cp1252():
"""u-Umlaut ist in CP850 das Byte 0x81 — und in cp1252 undefiniert."""
oem = "Glück über München".encode("cp850")
assert b"\x81" in oem
try:
oem.decode("cp1252")
raise AssertionError("cp1252 haette 0x81 ablehnen muessen")
except UnicodeDecodeError:
pass
def test_mit_der_eingestellten_fehlerbehandlung_bricht_nichts_mehr():
oem = "Glück über München".encode("cp850")
text = oem.decode("cp1252", handbrake_aufruf.HB_LESEN["errors"])
assert text.startswith("Gl") # ASCII bleibt heil
assert "ber" in text and "nchen" in text
def test_universal_newlines_bleiben_an():
"""HandBrake trennt Fortschrittszeilen mit CR — ohne text=True waere der
Fortschrittsbalken weg (deshalb NICHT binaer wie bei makemkvcon)."""
assert handbrake_aufruf.HB_LESEN["text"] is True
+36
View File
@@ -20,6 +20,8 @@ nie auf.
from rippy import betrieb
B = chr(92) # Backslash, nie woertlich (siehe test_pfade)
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
@@ -217,3 +219,37 @@ def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
existiert=lambda p: False) == ""
def test_eingestellte_orte_schlagen_die_vorgabe():
"""Der Befund vom 30.08.2026: „die verzeichnise sind andere als dort
steht."
Die Oberflaeche schreibt `outputDir`/`workDir` in die Datenbank,
`betrieb` las `storage.*` aus der Konfigurationsdatei und die ist
leer. In Einstellungen -> System" stand deshalb dauerhaft die Vorgabe,
waehrend der Rip woanders hin lief.
"""
werte = betrieb.mit_einstellungen(
{"storage": {"medien": "", "temp": ""}},
{"outputDir": "F:" + B + "Filme", "workDir": "F:" + B})
assert werte["storage"]["medien"] == "F:" + B + "Filme"
assert werte["storage"]["temp"] == "F:" + B
assert betrieb.ablage_vorgabe(werte, False, "windows") == "F:" + B + "Filme"
assert betrieb.arbeits_vorgabe(werte, False, "windows") == "F:" + B
def test_ohne_einstellung_bleibt_die_vorgabe():
"""Leere Felder duerfen NICHTS ueberschreiben — sonst waere ein
ungesetztes Feld schlimmer als gar keine Bruecke."""
vorher = {"storage": {"medien": "/app/media", "temp": "/app/temp"}}
werte = betrieb.mit_einstellungen(vorher, {"outputDir": "", "workDir": " "})
assert werte["storage"]["medien"] == "/app/media"
assert werte["storage"]["temp"] == "/app/temp"
# ... und das Original bleibt unangetastet (pure Funktion)
assert vorher["storage"]["medien"] == "/app/media"
def test_bruecke_vertraegt_leere_eingaben():
assert betrieb.mit_einstellungen({}, {})["storage"] == {}
assert betrieb.mit_einstellungen(None, None)["storage"] == {}
+33
View File
@@ -52,3 +52,36 @@ def test_naechster_vorhandener_auf_posix():
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
assert pfade.naechster_vorhandener("Z:" + B + "nix",
existiert=lambda p: False) == ""
def test_laufwerks_wurzel_bleibt_absolut():
"""Befund 30.08.2026: Der Schluss-Trenner wurde immer abgestreift.
Aus "F:\" wurde "F:" — unter Windows der AKTUELLE Ordner auf
Laufwerk F, nicht dessen Wurzel. Wer das Ergebnis weiterverbindet,
landet woanders; genau so verschwanden in `rohdaten.kandidaten` 16,5 GB.
"""
assert pfade.naechster_vorhandener(
"F:" + B, existiert=lambda p: True) == "F:" + B
# UNC-Wurzel genauso
unc = B + B + "server" + B + "freigabe" + B
assert pfade.naechster_vorhandener(unc, existiert=lambda p: True) == unc
# Und POSIX bleibt POSIX
assert pfade.naechster_vorhandener("/", existiert=lambda p: True) == "/"
def test_unterordner_verliert_seinen_schluss_trenner_weiterhin():
"""Das Abstreifen war ja richtig — nur nicht bis auf den Laufwerksnamen."""
da = "F:" + B + "Roh"
assert pfade.naechster_vorhandener(da + B, existiert=lambda p: p == da) == da
def test_fehlendes_laufwerk_terminiert():
"""Die Endlosschleife, an der `ablauf._frei_bytes` haengen blieb.
`ntpath.dirname("Q:\\")` gibt sich selbst zurueck (30.08.2026 an
einem freien Laufwerksbuchstaben gemessen). Ohne den Abbruch bei
`eltern == pfad` dreht die Suche fuer immer vor dem Rip, ohne Meldung.
"""
assert pfade.naechster_vorhandener("Q:" + B + "Rippy" + B + "_arbeit",
existiert=lambda p: False) == ""
+24
View File
@@ -67,6 +67,30 @@ def test_skript_raeumt_den_ordner_MIT_inhalt():
assert "rmdir /s /q" in s, "ohne /s /q bleibt jeder nicht leere Ordner stehen"
def test_laufwerks_wurzel_wird_nicht_geloescht():
"""Notbremse (30.08.2026): `ordner` stammt aus `InstallLocation` in der
Registry, gesetzt aus dem `--ziel` beim Installieren also aus fremdem
Text. Waere er ein Laufwerks-Stammverzeichnis, loeschte die
Deinstallation das Laufwerk. Der noetige rstrip macht den Fall erst
scharf: aus "F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was
Windows gerade fuer den aktuellen Ordner auf F haelt.
"""
for wurzel in ("F:" + BACKSLASH, "F:", BACKSLASH + BACKSLASH + "srv" +
BACKSLASH + "freigabe" + BACKSLASH, "", "/"):
s = windows_app.aufraeum_skript("F:" + BACKSLASH + "Rippy.exe", wurzel)
assert "rmdir" not in s, "Wurzel %r haette geloescht werden koennen" % wurzel
assert "NICHT geloescht" in s
# Die EXE geht trotzdem weg, und das Skript raeumt sich selbst auf
assert "%~f0" in s
def test_echter_programmordner_wird_weiterhin_geloescht():
"""Die Bremse darf den Normalfall nicht treffen."""
s = windows_app.aufraeum_skript("C:" + BACKSLASH + "R" + BACKSLASH +
"Rippy.exe", "C:" + BACKSLASH + "R")
assert ('rmdir /s /q "C:' + BACKSLASH + 'R"') in s
# ── Installation (echt, in einem Testordner) ────────────────────────────
@nur_windows
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
+41 -7
View File
@@ -41,6 +41,7 @@ import threading
import time
import webbrowser
from rippy import pfade
from rippy.platform import verknuepfungen
from rippy.platform import win_registry as reg
@@ -501,19 +502,52 @@ for /l %%n in (1,1,15) do (
)
goto ende
:weg
rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
rmdir /s /q "{ordner}" >nul 2>&1
{ordner_zeile}
:ende
del /q "%~f0" >nul 2>&1
"""
#: Der Loeschbefehl fuer den Programmordner — samt Begruendung, die im
#: erzeugten Skript stehen bleiben soll.
_ORDNER_LOESCHEN = """rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
rmdir /s /q "%s" >nul 2>&1"""
def aufraeum_skript(exe: str, ordner: str) -> str:
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
r"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar).
## Warum hier eine Notbremse sitzt (30.08.2026)
`ordner` kommt aus `InstallLocation` in der Registry also aus dem, was
beim Installieren als `--ziel` angegeben wurde. Das ist nicht Rippys
eigenes Wort, sondern fremder Text, und daraus entsteht hier ein
`rmdir /s /q`.
Waere er ein Laufwerks-Stammverzeichnis, loeschte die Deinstallation das
Laufwerk. Der noetige `rstrip` macht den Fall sogar erst scharf: Aus
"F:\" wird "F:", und `rmdir /s /q "F:"` trifft, was Windows gerade fuer
den aktuellen Ordner auf F haelt.
Beobachtet wurde das nicht aber die Notbremse kostet eine Zeile, und
der Fall waere nicht wiedergutzumachen. Bleibt der Ordner stehen, sieht
man das; verschwindet das Falsche, ist es weg.
Der `rstrip` selbst muss bleiben: `rmdir "F:\Rippy\"` scheitert, weil
cmd.exe den Backslash vor dem Anfuehrungszeichen als Maskierung liest.
"""
ziel = (ordner or "").rstrip(chr(92) + "/")
# `pfade.laufwerk_von` urteilt nach der FORM des Pfades, nicht nach dem
# laufenden Rechner — damit greift die Bremse auch in der Linux-Ampel.
if ziel and ziel != pfade.laufwerk_von(ziel):
zeile = _ORDNER_LOESCHEN % ziel
else:
zeile = ("rem Programmordner NICHT geloescht: kein gueltiger Unterordner (%s)"
% (ordner or "leer"))
return AUFRAEUM_SKRIPT.format(exe=exe, ordner_zeile=zeile)
def selbst_loeschen(exe: str, ordner: str) -> str: