Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0464933ada | ||
|
|
c539e65cd2 | ||
|
|
564bd17f84 | ||
|
|
a0cff205ab | ||
|
|
c44bfa6daa | ||
|
|
e514e4070b | ||
|
|
fde3a78e97 | ||
|
|
cb811bec28 | ||
|
|
9050fea3f0 | ||
|
|
d3e86d2641 |
@@ -12,6 +12,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: Ampel — erkennt Projekt-Typ selbst und prüft entsprechend
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+1131
File diff suppressed because it is too large
Load Diff
+301
-1
@@ -1,6 +1,306 @@
|
|||||||
# SAVEPOINT — Rippy
|
# 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
|
> **Ein liegengebliebener `makemkvcon` haelt das Laufwerk fest — jeder spaetere
|
||||||
> Rip scheitert dann.** 954 Tests gruen.
|
> Rip scheitert dann.** 954 Tests gruen.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.');
|
||||||
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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
@@ -233,6 +233,19 @@ async def _auto_prescan(pfad: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
DISC_CACHE.pop(pfad, None)
|
DISC_CACHE.pop(pfad, None)
|
||||||
print(f"Auto-Pre-Scan {pfad}: {e}")
|
print(f"Auto-Pre-Scan {pfad}: {e}")
|
||||||
|
# ... und ins PROTOKOLL, nicht nur auf die Konsole
|
||||||
|
# (Befund 30.08.2026). Der Commander: „im log steht zwar
|
||||||
|
# erkannt, aber ein start des rips ist nicht moeglich."
|
||||||
|
# Genau so war es: Die letzte Zeile war das erfolgreiche
|
||||||
|
# „Disc erkannt" von vorhin, die fehlgeschlagenen Versuche
|
||||||
|
# danach standen nur auf einer Konsole, die niemand sieht.
|
||||||
|
# Ein Protokoll, in dem nur die Erfolge stehen, luegt.
|
||||||
|
try:
|
||||||
|
db.add_log("warning", "watcher",
|
||||||
|
"Disc-Erkennung auf %s fehlgeschlagen: %s"
|
||||||
|
% (pfad, e))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass # Protokollieren darf die Wache nie anhalten
|
||||||
|
|
||||||
|
|
||||||
async def _auto_rip_wenn_aktiviert(pfad: str):
|
async def _auto_rip_wenn_aktiviert(pfad: str):
|
||||||
@@ -468,6 +481,9 @@ class Device(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
serial: Optional[str] = None
|
serial: Optional[str] = None
|
||||||
|
# Warum steht bei `type`/`status` „unknown"? Leer, solange alles geht.
|
||||||
|
# Der Linux-Treiber setzt das Feld nicht — dann bleibt es leer.
|
||||||
|
grund: str = ""
|
||||||
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
disc: Optional[Dict] = None # Auto-Pre-Scan-Ergebnis (Titel/Jahr/Poster)
|
||||||
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
# Läuft die Erkennung gerade noch? (Commander 29.08.2026: „das die disc
|
||||||
# erkennung noch läuft muss sichtbar sein")
|
# 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)
|
return rohdaten.suche(job_id, work_dir, os.listdir, rohdaten.verzeichnis_da)
|
||||||
|
|
||||||
|
|
||||||
|
def _arbeitsverzeichnis_des_jobs(job, einstellungen=None) -> str:
|
||||||
|
"""Wohin ging der Roh-Rip DIESES Jobs? Seine Wahl schlägt die Einstellung.
|
||||||
|
|
||||||
|
## Warum die Einstellung allein nicht reicht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
Der Rip-Dialog lässt für JEDEN Rip einzeln wählen, wohin die Rohdaten
|
||||||
|
gehen (seit v3.15). Die Wahl landet in den Job-Metadaten als
|
||||||
|
`work_dir` — geschrieben in `start_rip`, gelesen im Worker von
|
||||||
|
`_arbeitsverzeichnis()`. Gesucht wurde danach aber immer unter dem
|
||||||
|
HEUTIGEN Wert der Einstellung `workDir`.
|
||||||
|
|
||||||
|
Genau daran scheiterte am 26.07.2026 schon einmal ein Job mit 79,6 GB
|
||||||
|
Rohschnitt — der Fall steht im Kopf von `rohdaten.py`. Repariert wurde
|
||||||
|
damals die Kandidatenliste, nicht der Aufrufer: Er reichte weiterhin
|
||||||
|
die Einstellung hinein. Am 30.08.2026 stand deshalb erneut „Auf der
|
||||||
|
Platte liegt zu diesem Job nichts (mehr)" — vor 16,5 GB, die dalagen.
|
||||||
|
|
||||||
|
Das ist das Muster aus AGENTS: aus einem Zustandswert (der heutigen
|
||||||
|
Einstellung) auf einen Vorgang geschlossen (wohin DAMALS gerippt
|
||||||
|
wurde), statt nachzusehen. Der Job weiß es selbst.
|
||||||
|
"""
|
||||||
|
holen = getattr(job, "get", None)
|
||||||
|
eigen = ""
|
||||||
|
if holen:
|
||||||
|
eigen = (phasen.meta_von(holen("meta")).get("work_dir") or "").strip()
|
||||||
|
if not eigen:
|
||||||
|
werte = einstellungen if einstellungen is not None else db.get_settings()
|
||||||
|
eigen = ((werte or {}).get("workDir") or "").strip()
|
||||||
|
# normpath("") wäre "." — der aktuelle Ordner, und der ist hier nie
|
||||||
|
# gemeint. "/" fällt in `kandidaten()` sauber durch.
|
||||||
|
return os.path.normpath(eigen or "/")
|
||||||
|
|
||||||
|
|
||||||
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
# Vorrat für die Job-Liste. Dasselbe Muster wie beim Celery-Ping in
|
||||||
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
# /capabilities (v3.15): Der Endpunkt wird alle 4 Sekunden vom Dashboard
|
||||||
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
# abgefragt und darf NIE am Dateisystem hängen. Ein schlafendes NAS hätte das
|
||||||
@@ -915,13 +964,15 @@ def _rohdaten_vorrat_auffrischen() -> None:
|
|||||||
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
Sekunden. Ohne diese Regel verschwände in dem Fenster der Knopf
|
||||||
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
„Neu komprimieren", und der Nutzer schlösse daraus, seine 74 GB seien weg.
|
||||||
"""
|
"""
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
einstellungen = db.get_settings()
|
||||||
alt = _ROHDATEN["treffer"]
|
alt = _ROHDATEN["treffer"]
|
||||||
treffer = {}
|
treffer = {}
|
||||||
for zeile in db.list_jobs():
|
for zeile in db.list_jobs():
|
||||||
if zeile.get("status") != "failed":
|
if zeile.get("status") != "failed":
|
||||||
continue
|
continue
|
||||||
job_id = zeile["id"]
|
job_id = zeile["id"]
|
||||||
|
# Je Job SEINE Wahl — nicht die heutige Einstellung.
|
||||||
|
work_dir = _arbeitsverzeichnis_des_jobs(zeile, einstellungen)
|
||||||
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
ergebnis = rohdaten.suche_mit_status(job_id, work_dir, os.listdir)
|
||||||
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
if not ergebnis["pfade"] and ergebnis["unklar"] and alt.get(job_id):
|
||||||
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
treffer[job_id] = alt[job_id] # letzte bekannte Antwort halten
|
||||||
@@ -1050,8 +1101,7 @@ async def job_rohdaten(job_id: str):
|
|||||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||||
|
|
||||||
def sammle():
|
def sammle():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
bytes_gesamt, dateien = _rohdaten_groesse(pfade)
|
||||||
return {
|
return {
|
||||||
"pfade": pfade,
|
"pfade": pfade,
|
||||||
@@ -1080,8 +1130,7 @@ async def delete_job(job_id: str, rohdaten: bool = False):
|
|||||||
geloescht_gb = 0.0
|
geloescht_gb = 0.0
|
||||||
if rohdaten:
|
if rohdaten:
|
||||||
def raeume():
|
def raeume():
|
||||||
work_dir = os.path.normpath((db.get_settings().get("workDir") or "").strip() or "/")
|
pfade = _rohdaten_suchen(job_id, _arbeitsverzeichnis_des_jobs(job))
|
||||||
pfade = _rohdaten_suchen(job_id, work_dir)
|
|
||||||
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
bytes_gesamt, _ = _rohdaten_groesse(pfade)
|
||||||
for pfad in pfade:
|
for pfad in pfade:
|
||||||
shutil.rmtree(pfad, ignore_errors=True)
|
shutil.rmtree(pfad, ignore_errors=True)
|
||||||
@@ -1564,7 +1613,7 @@ async def retry_transcode(job_id: str):
|
|||||||
raise HTTPException(status_code=409, detail="Job rippt noch")
|
raise HTTPException(status_code=409, detail="Job rippt noch")
|
||||||
|
|
||||||
einstellungen = await asyncio.to_thread(db.get_settings)
|
einstellungen = await asyncio.to_thread(db.get_settings)
|
||||||
work_dir = os.path.normpath((einstellungen.get("workDir") or "").strip() or "/")
|
work_dir = _arbeitsverzeichnis_des_jobs(job, einstellungen)
|
||||||
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
gefunden = await asyncio.to_thread(_rohdaten_suchen, job_id, work_dir)
|
||||||
if not gefunden:
|
if not gefunden:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -2528,6 +2577,13 @@ async def system_info():
|
|||||||
werte = rippy_config.laden()
|
werte = rippy_config.laden()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
werte = {}
|
werte = {}
|
||||||
|
# Die Oberflaeche schreibt nach `outputDir`/`workDir` in die
|
||||||
|
# DATENBANK, `betrieb` liest `storage.*` aus der DATEI. Ohne
|
||||||
|
# diese Bruecke zeigte die Uebersicht immer die Vorgabe, egal
|
||||||
|
# was eingestellt war (Befund 30.08.2026, siehe
|
||||||
|
# `betrieb.mit_einstellungen`).
|
||||||
|
werte = betriebs_auskunft.mit_einstellungen(
|
||||||
|
werte, db.get_settings(bei_fehler_leer=True))
|
||||||
for ort in betriebs_auskunft.platz_orte(werte):
|
for ort in betriebs_auskunft.platz_orte(werte):
|
||||||
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
# Frisch installiert gibt es den Ablage-Ordner noch nicht. Dann
|
||||||
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
# das naechste vorhandene Elternverzeichnis messen: Der Nutzer
|
||||||
@@ -2911,6 +2967,75 @@ async def get_devices():
|
|||||||
await asyncio.to_thread(laufwerke_mit_disc)]
|
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:
|
def laufwerke_mit_disc() -> list:
|
||||||
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
"""Laufwerke SAMT erkannter Disc — der eine Weg für beide Abnehmer.
|
||||||
|
|
||||||
@@ -2932,8 +3057,15 @@ def laufwerke_mit_disc() -> list:
|
|||||||
nur diese hier.
|
nur diese hier.
|
||||||
"""
|
"""
|
||||||
geraete = []
|
geraete = []
|
||||||
|
letzte = {g.get("path"): g for g in LETZTE_LAUFWERKE}
|
||||||
for pfad in device_discovery.list_optical_devices():
|
for pfad in device_discovery.list_optical_devices():
|
||||||
|
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)
|
info = device_discovery.device_info(pfad)
|
||||||
|
_grund_melden(pfad, info.get("grund") or "")
|
||||||
disc = DISC_CACHE.get(pfad)
|
disc = DISC_CACHE.get(pfad)
|
||||||
if disc and disc.get("_laeuft"):
|
if disc and disc.get("_laeuft"):
|
||||||
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
# NICHT als Disc ausgeben — es gibt noch keinen Titel. Aber
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ sondern über eine temporäre credentials-Datei).
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
|
|
||||||
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -60,7 +62,8 @@ def ist_erreichbar(name: str) -> bool:
|
|||||||
ziel = _mountpoint(name)
|
ziel = _mountpoint(name)
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "3", "ls", ziel], capture_output=True, timeout=5
|
["timeout", "3", "ls", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=5
|
||||||
)
|
)
|
||||||
return ergebnis.returncode == 0
|
return ergebnis.returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -89,7 +92,8 @@ def pfad_lage(ziel: str) -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
["timeout", "4", "ls", "-d", ziel], capture_output=True, timeout=6
|
["timeout", "4", "ls", "-d", ziel], capture_output=True,
|
||||||
|
creationflags=OHNE_FENSTER, timeout=6
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return "unklar"
|
return "unklar"
|
||||||
|
|||||||
@@ -44,8 +44,12 @@ UNKLAR = "unklar"
|
|||||||
NICHTS = ""
|
NICHTS = ""
|
||||||
|
|
||||||
|
|
||||||
def _meta(meta_json) -> dict:
|
def meta_von(meta_json) -> dict:
|
||||||
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern."""
|
"""Metadaten lesen, ohne an kaputtem JSON zu scheitern.
|
||||||
|
|
||||||
|
Oeffentlich, seit auch `main.py` sie braucht: Dort muss die Wahl des
|
||||||
|
Rip-Dialogs (`work_dir`) aus denselben Metadaten gelesen werden.
|
||||||
|
"""
|
||||||
if isinstance(meta_json, dict):
|
if isinstance(meta_json, dict):
|
||||||
return meta_json
|
return meta_json
|
||||||
if not meta_json:
|
if not meta_json:
|
||||||
@@ -68,7 +72,7 @@ def retry_art(job) -> str:
|
|||||||
"""
|
"""
|
||||||
if (job.get("status") or "") != "failed":
|
if (job.get("status") or "") != "failed":
|
||||||
return NICHTS
|
return NICHTS
|
||||||
marke = _meta(job.get("meta")).get(RIP_FERTIG)
|
marke = meta_von(job.get("meta")).get(RIP_FERTIG)
|
||||||
if marke is True:
|
if marke is True:
|
||||||
return NEU_KOMPRIMIEREN
|
return NEU_KOMPRIMIEREN
|
||||||
if marke is False:
|
if marke is False:
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from rippy import drives as _laufwerks_schicht
|
from rippy import drives as _laufwerks_schicht
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
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.tmdb import TMDBClient
|
||||||
from clients.jikan import JikanClient
|
from clients.jikan import JikanClient
|
||||||
from clients.musicbrainz import MusicBrainzClient
|
from clients.musicbrainz import MusicBrainzClient
|
||||||
@@ -359,37 +356,37 @@ class PreScan:
|
|||||||
if label:
|
if label:
|
||||||
toc["title"] = normalize_disc_label(label)
|
toc["title"] = normalize_disc_label(label)
|
||||||
|
|
||||||
# Titel-Quelle 3: die Titelliste von makemkvcon (optional).
|
# Titel-Quelle 3 (makemkvcon) gibt es hier NICHT mehr.
|
||||||
#
|
#
|
||||||
# ⚠️ Über den Werkzeug-Katalog, NICHT über `shutil.which`
|
# ## Warum sie weg ist (Befund 30.08.2026)
|
||||||
# (Befund 28.08.2026). `which` sucht nur im PATH — unter
|
#
|
||||||
# Windows liegen Programme in „Programme", nicht im PATH.
|
# Der Commander: „das erkennen der disk dauert sehr sehr
|
||||||
# Gemessen: `shutil.which("makemkvcon")` gibt dort auch bei
|
# lange. Das ging mal viel schneller."
|
||||||
# installiertem MakeMKV None zurück, und dieser Zweig lief
|
#
|
||||||
# NIE. Genau derselbe Fehler, für den es `tools/katalog.py`
|
# Hier stand ein `makemkvcon -r --noscan --minlength=300
|
||||||
# gibt.
|
# info` mit 120 s Zeitgrenze — bei jeder eingelegten Disc,
|
||||||
makemkv = werkzeug_katalog.finden("makemkv")
|
# vor jeder Anzeige. Auf einer Blu-ray dauert dieser Aufruf
|
||||||
if makemkv:
|
# 20 bis 120 Sekunden; solange steht „Disc wird gelesen".
|
||||||
result = subprocess.run(
|
#
|
||||||
[makemkv, "-r", "--noscan", "--minlength=300", "info", makemkv_quelle(device_path)],
|
# Dass es frueher schnell war, hat einen unschoenen Grund:
|
||||||
capture_output=True,
|
# Der Zweig lief unter Windows NIE. Er suchte makemkvcon mit
|
||||||
timeout=120, # binaer lesen — Begruendung in text_von
|
# `shutil.which`, und das findet unter Windows nichts
|
||||||
# OHNE_FENSTER: Sonst blitzt bei JEDER Disc-Erkennung
|
# (Programme liegen nicht im PATH). `be3fac5` hat das am
|
||||||
# eine Konsole auf. Rippy laeuft als Fenster-Programm
|
# 28.08.2026 richtig repariert — und damit erst die Kosten
|
||||||
# ohne eigene Konsole — Windows legt fuer ein
|
# sichtbar gemacht, die hier immer schon standen.
|
||||||
# Konsolenprogramm dann eine NEUE an, und die ist
|
#
|
||||||
# sichtbar (Commander 29.08.2026: „nun oeffnen sich
|
# Der Aufwand war umsonst: Das Ergebnis landete allein in
|
||||||
# immer irgendwelche fenster ganz kurz").
|
# `toc["tracks"]`, und **die liest niemand**. Der Rip-Dialog
|
||||||
creationflags=OHNE_FENSTER,
|
# holt seine Titelliste ueber `POST /devices/{id}/scan-tracks`
|
||||||
)
|
# und `GET /devices/{id}/tracks` — also dann, wenn sie
|
||||||
for line in text_von(result.stdout).split('\n'):
|
# gebraucht wird, statt bei jedem Einlegen auf Verdacht.
|
||||||
if line.startswith('TINFO:'):
|
# Der Weg fuer eine Disc, die man gar nicht rippen will,
|
||||||
parts = line.split(',')
|
# sind so zwei Minuten Warten fuer nichts.
|
||||||
if len(parts) >= 5:
|
#
|
||||||
toc["tracks"].append({
|
# Der Titel kommt aus Quelle 1 und 2 darueber; die sind
|
||||||
"title": parts[4].strip().strip('"') if len(parts) > 4 else "Title",
|
# billig. Wer den Aufruf je wieder braucht, braucht dazu
|
||||||
"duration": int(parts[2]) if len(parts) > 2 else 0
|
# `makemkv_aufruf.quelle`, `makemkv_aufruf.text_von` und
|
||||||
})
|
# `tools.katalog` — die Importe sind mit ihm gegangen.
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Pre-Scan TOC Error: {e}")
|
print(f"Pre-Scan TOC Error: {e}")
|
||||||
return toc
|
return toc
|
||||||
|
|||||||
+81
-2
@@ -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]`.
|
Job-ID (vollständige UUID), fertige Ablagen heißen `Titel (Jahr) [kurz-id]`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from rippy import pfade as _pfade
|
from rippy import pfade as _pfade
|
||||||
@@ -48,6 +49,23 @@ RAW_STANDARD = "/app/temp/raw"
|
|||||||
MEDIA_ROOT = "/app/media"
|
MEDIA_ROOT = "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def _ui_einstellungen() -> dict:
|
||||||
|
"""Was in der Oberflaeche eingestellt ist — leer, wenn die Datenbank
|
||||||
|
gerade nicht antwortet.
|
||||||
|
|
||||||
|
Bewusst gekapselt und abgesichert: `wurzeln()` wird auch aus der
|
||||||
|
Jobliste heraus aufgerufen, die alle vier Sekunden laeuft. Sie darf an
|
||||||
|
einer klemmenden Datenbank nicht scheitern — dann gilt eben die
|
||||||
|
Vorgabe, wie bisher.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rippy import store as db
|
||||||
|
|
||||||
|
return db.get_settings(bei_fehler_leer=True) or {}
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def wurzeln(werte=None) -> tuple:
|
def wurzeln(werte=None) -> tuple:
|
||||||
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
"""`(roh_standard, medien_wurzel, frei)` fuer DIESEN Betrieb.
|
||||||
|
|
||||||
@@ -70,6 +88,11 @@ def wurzeln(werte=None) -> tuple:
|
|||||||
werte = config.laden()
|
werte = config.laden()
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
werte = {}
|
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,
|
return (betrieb.arbeits_vorgabe(werte) or RAW_STANDARD,
|
||||||
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
betrieb.medien_wurzel(werte) or MEDIA_ROOT,
|
||||||
betrieb.frei_blaettern(werte))
|
betrieb.frei_blaettern(werte))
|
||||||
@@ -99,10 +122,29 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
|||||||
from rippy import pfade
|
from rippy import pfade
|
||||||
|
|
||||||
orte = [pfade.verbinden(roh, job_id)]
|
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
|
# Nativ zaehlt jede Wahl — dort liegt der Arbeitsordner oft auf einem
|
||||||
# ganz anderen Laufwerk und damit unter gar keiner Wurzel.
|
# 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))
|
orte.append(pfade.verbinden(wahl, job_id))
|
||||||
for name in media_unterordner or []:
|
for name in media_unterordner or []:
|
||||||
if name:
|
if name:
|
||||||
@@ -115,6 +157,16 @@ def kandidaten(job_id: str, work_dir: str, media_unterordner,
|
|||||||
return eindeutig
|
return eindeutig
|
||||||
|
|
||||||
|
|
||||||
|
def nativ_nachsehen() -> bool:
|
||||||
|
"""Darf direkt nachgesehen werden, statt einen Prozess dafuer zu starten?
|
||||||
|
|
||||||
|
Eigene Funktion, damit beide Zweige ueberall pruefbar sind — dieselbe
|
||||||
|
Regel wie bei `betrieb.im_container`. Die Begruendung steht in
|
||||||
|
`pruefen`.
|
||||||
|
"""
|
||||||
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def pruefen(pfad: str, laufen=None) -> str:
|
def pruefen(pfad: str, laufen=None) -> str:
|
||||||
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
"""Gibt es dieses Verzeichnis? „da" | „weg" | „unklar" — mit HARTER Zeitgrenze.
|
||||||
|
|
||||||
@@ -131,6 +183,33 @@ def pruefen(pfad: str, laufen=None) -> str:
|
|||||||
"""
|
"""
|
||||||
if not pfad:
|
if not pfad:
|
||||||
return "weg"
|
return "weg"
|
||||||
|
if laufen is None and nativ_nachsehen():
|
||||||
|
# ## Warum Windows hier NICHT den Umweg ueber einen Prozess geht
|
||||||
|
#
|
||||||
|
# ⚠️ Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
#
|
||||||
|
# 14:54:40 timeout.exe timeout 4 ls -d C:\\...\\d7ee6c06-...
|
||||||
|
# 14:54:40 WindowsTerminal.exe
|
||||||
|
#
|
||||||
|
# Der Commander: „nun oeffnen sich diverse fenster im hintergrund,
|
||||||
|
# gehen ganz kurz auf und dann wieder zu."
|
||||||
|
#
|
||||||
|
# `timeout` und `ls` sind Linux-Befehle. Unter Windows GIBT es eine
|
||||||
|
# `timeout.exe` — sie wartet nur Sekunden ab und kennt weder `ls`
|
||||||
|
# noch `-d`. Sie braucht aber eine Konsole, und die reisst Windows
|
||||||
|
# dann auf. Dreifach falsch also: ein Fenster bei jedem Durchlauf,
|
||||||
|
# ein Prozess fuer nichts, und ein Rueckgabewert ungleich 0 — also
|
||||||
|
# die Antwort „weg" fuer JEDES Verzeichnis. Rohdaten waren damit
|
||||||
|
# unter Windows grundsaetzlich unsichtbar.
|
||||||
|
#
|
||||||
|
# Der Grund fuer den Umweg gilt hier nicht: Der Kernel-Hang im
|
||||||
|
# Zustand D (siehe `verzeichnis_da`) ist eine Linux-Eigenheit. Ein
|
||||||
|
# totes Netzlaufwerk laesst `os.path.isdir` unter Windows mit einem
|
||||||
|
# Fehler zurueckkommen, nicht unabbrechbar haengen.
|
||||||
|
try:
|
||||||
|
return "da" if os.path.isdir(pfad) else "weg"
|
||||||
|
except OSError:
|
||||||
|
return "unklar"
|
||||||
starten = laufen or subprocess.run
|
starten = laufen or subprocess.run
|
||||||
try:
|
try:
|
||||||
ergebnis = starten(
|
ergebnis = starten(
|
||||||
|
|||||||
@@ -346,7 +346,15 @@ def test_snapshot_liefert_das_ganze_bild(monkeypatch):
|
|||||||
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
monkeypatch.setattr(main.device_discovery, "list_optical_devices", lambda: [])
|
||||||
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
# Ohne DB liefe system_info() in eine Ausnahme, und die Ampel hat keine
|
||||||
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
# Datenbank (Lauf 170). Geprueft wird hier die FORM des Schnappschusses.
|
||||||
monkeypatch.setattr(main.db, "get_settings", lambda: {})
|
#
|
||||||
|
# `*a, **k`, nicht `lambda: {}` (Befund 30.08.2026): Die echte
|
||||||
|
# Funktion heisst `get_settings(key="ui", bei_fehler_leer=False)`.
|
||||||
|
# Der zu enge Doppelgaenger warf `TypeError`, sobald ein Aufrufer
|
||||||
|
# einen der Parameter benutzte — `system_info` fiel damit aus dem
|
||||||
|
# Schnappschuss, und dieser Test zeigte auf den Code statt auf sich
|
||||||
|
# selbst. Ein Doppelgaenger muss die Schnittstelle abbilden, die er
|
||||||
|
# ersetzt, nicht nur den einen Aufruf, den es gerade gibt.
|
||||||
|
monkeypatch.setattr(main.db, "get_settings", lambda *a, **k: {})
|
||||||
|
|
||||||
zustand = asyncio.run(main._snapshot())
|
zustand = asyncio.run(main._snapshot())
|
||||||
|
|
||||||
@@ -880,3 +888,47 @@ def test_auswurf_bleibt_waehrend_der_kompression_erlaubt():
|
|||||||
from rippy import store
|
from rippy import store
|
||||||
|
|
||||||
assert "transcoding" not in inspect.getsource(store.has_active_job)
|
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"]
|
||||||
|
|||||||
@@ -58,6 +58,36 @@ def test_media_root_selbst_ist_erlaubt():
|
|||||||
assert f"/app/media/{JOB}" in orte
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
|
#: Die Wurzeln eines NATIVEN Betriebs (Windows, freies Blättern) —
|
||||||
|
#: Gegenstück zu CONTAINER weiter oben.
|
||||||
|
NATIV = ("C:\\Rippy\\_arbeit", "C:\\Rippy", True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Der Fall des Commanders (30.08.2026): Arbeitsordner F: — die Wurzel.
|
||||||
|
|
||||||
|
Hier wurde der Schluss-Trenner abgestreift und mit dem Rest dann auch
|
||||||
|
VERBUNDEN. Ein blosses "F:" ist unter Windows aber der AKTUELLE Ordner
|
||||||
|
auf Laufwerk F, nicht dessen Wurzel — die Suche sah damit an einer
|
||||||
|
ganz anderen Stelle nach. Ergebnis: 16,5 GB Rohschnitt unsichtbar, und
|
||||||
|
der Wiederholen-Dialog bot nur "Neu rippen" an: Stunden am
|
||||||
|
beschädigten Datenträger für etwas, das schon dalag.
|
||||||
|
"""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "F:\\", [], NATIV)
|
||||||
|
assert "F:" + chr(92) + JOB in orte
|
||||||
|
assert "F:" + JOB not in orte
|
||||||
|
# Ohne Schluss-Trenner muss dasselbe herauskommen
|
||||||
|
assert rohdaten.kandidaten(JOB, "F:\\Roh\\", [], NATIV)[-1] == (
|
||||||
|
"F:" + chr(92) + "Roh" + chr(92) + JOB)
|
||||||
|
|
||||||
|
|
||||||
|
def test_media_root_mit_schluss_trenner_zaehlt_auch():
|
||||||
|
"""/app/media/ und /app/media sind derselbe Ort — der Vergleich darf
|
||||||
|
nicht am Trenner scheitern."""
|
||||||
|
orte = rohdaten.kandidaten(JOB, "/app/media/", [], CONTAINER)
|
||||||
|
assert f"/app/media/{JOB}" in orte
|
||||||
|
|
||||||
|
|
||||||
def test_ohne_job_id_nichts():
|
def test_ohne_job_id_nichts():
|
||||||
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
assert rohdaten.kandidaten("", "/app/media", ["x"]) == []
|
||||||
|
|
||||||
@@ -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)
|
JOB, "", listdir=lambda p: ["rippy", "totes-nas"], pruefer=pruefe, orte_wurzeln=CONTAINER)
|
||||||
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
assert e["pfade"] == [f"/app/media/rippy/{JOB}"]
|
||||||
assert e["unklar"] is True
|
assert e["unklar"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_nativ_wird_kein_prozess_gestartet(monkeypatch, tmp_path):
|
||||||
|
"""Befund 30.08.2026, an der laufenden Instanz beobachtet:
|
||||||
|
|
||||||
|
14:54:40 timeout.exe timeout 4 ls -d C:...d7ee6c06-...
|
||||||
|
14:54:40 WindowsTerminal.exe
|
||||||
|
|
||||||
|
Der Commander sah Fenster aufblitzen. `timeout` und `ls` sind
|
||||||
|
Linux-Befehle; die Windows-eigene timeout.exe kennt weder `ls` noch
|
||||||
|
`-d`, braucht aber eine Konsole. Dreifach falsch: ein Fenster je
|
||||||
|
Durchlauf, ein Prozess fuer nichts, und Rueckgabewert ungleich 0 — also
|
||||||
|
die Antwort weg fuer JEDES Verzeichnis.
|
||||||
|
"""
|
||||||
|
gestartet = []
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: True)
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: gestartet.append(a))
|
||||||
|
assert rohdaten.pruefen(str(tmp_path)) == "da"
|
||||||
|
assert rohdaten.pruefen(str(tmp_path / "gibt-es-nicht")) == "weg"
|
||||||
|
assert gestartet == [], "es darf kein Prozess gestartet werden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_im_container_bleibt_der_kindprozess(monkeypatch):
|
||||||
|
"""Dort ist der Umweg richtig: os.path.isdir kann an einem toten
|
||||||
|
CIFS-Mount im Kernel haengen (Begruendung in verzeichnis_da)."""
|
||||||
|
monkeypatch.setattr(rohdaten, "nativ_nachsehen", lambda: False)
|
||||||
|
aufrufe = []
|
||||||
|
|
||||||
|
class Antwort:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(rohdaten.subprocess, "run",
|
||||||
|
lambda *a, **k: aufrufe.append(a[0]) or Antwort())
|
||||||
|
assert rohdaten.pruefen("/app/media/x") == "da"
|
||||||
|
assert aufrufe[0][0] == "timeout"
|
||||||
|
assert aufrufe[0][-1] == "/app/media/x"
|
||||||
|
|||||||
+47
-4
@@ -256,12 +256,33 @@ def _arbeitsverzeichnis(einstellungen: dict, job_wahl: str = "",
|
|||||||
|
|
||||||
|
|
||||||
def _frei_bytes(pfad: str) -> int:
|
def _frei_bytes(pfad: str) -> int:
|
||||||
"""Freier Platz am Pfad (nächster existierender Elternordner zählt)."""
|
"""Freier Platz am Pfad (nächster existierender Elternordner zählt).
|
||||||
kandidat = pfad
|
|
||||||
|
## Warum die Suche nach oben NICHT hier steht (Befund 30.08.2026)
|
||||||
|
|
||||||
|
An dieser Stelle stand sie als eigene Schleife:
|
||||||
|
|
||||||
while kandidat and not os.path.exists(kandidat):
|
while kandidat and not os.path.exists(kandidat):
|
||||||
kandidat = os.path.dirname(kandidat)
|
kandidat = os.path.dirname(kandidat)
|
||||||
|
|
||||||
|
Unter Windows gibt `os.path.dirname("Q:\\")` **sich selbst** zurueck — an
|
||||||
|
einem freien Laufwerksbuchstaben nachgemessen. Zeigt das
|
||||||
|
Arbeitsverzeichnis oder ein Ablageziel auf ein Laufwerk, das gerade
|
||||||
|
nicht da ist (abgezogene USB-Platte, getrennte Netzlaufwerks-
|
||||||
|
Zuordnung), dreht diese Schleife **fuer immer**. Und zwar in
|
||||||
|
`_platz_pruefen`, also VOR dem Rip: Der Job bliebe ohne eine einzige
|
||||||
|
Meldung stehen, und im Protokoll stuende nichts, womit man das
|
||||||
|
aufklären koennte.
|
||||||
|
|
||||||
|
`pfade.naechster_vorhandener` macht dasselbe seit V2-1 richtig — mit
|
||||||
|
Abbruch bei `eltern == pfad` und einer `gesehen`-Menge gegen Zyklen.
|
||||||
|
Es war die ganze Zeit da. Dieselbe Entscheidung an zwei Orten, einer
|
||||||
|
davon veraltet: genau das Muster aus dem Kopf von `makemkv_aufruf.py`.
|
||||||
|
"""
|
||||||
|
from rippy import pfade
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return shutil.disk_usage(kandidat or "/").free
|
return shutil.disk_usage(pfade.naechster_vorhandener(pfad) or "/").free
|
||||||
except OSError:
|
except OSError:
|
||||||
return -1
|
return -1
|
||||||
|
|
||||||
@@ -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:
|
if code == 1003 or len(gesehen) >= MAX_MELDUNGEN or text in gesehen:
|
||||||
return
|
return
|
||||||
gesehen.add(text)
|
gesehen.add(text)
|
||||||
db.add_log("info", "makemkv", f"Job {job_id}: {text[:300]}")
|
# Die MSG-NUMMER gehoert dazu (Befund 30.08.2026). MakeMKVs Texte
|
||||||
|
# sind uebersetzt, die Nummern nicht — sie sind die einzige
|
||||||
|
# verlaessliche Kennung (so arbeitet KRITISCHE_CODES). Ohne sie
|
||||||
|
# war an einem Lesefehler-Protokoll nicht abzulesen, WELCHE
|
||||||
|
# Meldung MakeMKV geschickt hatte; die Erkennung musste sich
|
||||||
|
# ersatzweise an einer URL im Text festhalten.
|
||||||
|
db.add_log("info", "makemkv",
|
||||||
|
f"Job {job_id}: MSG {code} — {text[:300]}")
|
||||||
|
|
||||||
einstellungen = db.get_settings(bei_fehler_leer=True)
|
einstellungen = db.get_settings(bei_fehler_leer=True)
|
||||||
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
ist_video = disc_type in ("dvd", "bluray", "uhd")
|
||||||
@@ -740,6 +768,21 @@ def rippen(device_path: str, job_id: str, target_dir: str = None,
|
|||||||
"Normale BD/DVD gehen weiterhin."
|
"Normale BD/DVD gehen weiterhin."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Lesefehler laut sagen — auch (und gerade) wenn der Rip als Erfolg
|
||||||
|
# endet. Befund 30.08.2026: MakeMKV sicherte 1 von 2 Titeln, meldete
|
||||||
|
# sich mit Code 0, und Rippy schrieb „Rip fertig". Dass ein Titel
|
||||||
|
# fehlt, stand nur in den MakeMKV-Zeilen. Wer die nicht liest, haelt
|
||||||
|
# eine halbe Disc für eine ganze.
|
||||||
|
if ergebnis.get("lesefehler"):
|
||||||
|
db.add_log(
|
||||||
|
"warning", "worker",
|
||||||
|
f"Job {job_id}: Die Disc hat Lesefehler — MakeMKV kam an "
|
||||||
|
"mehreren Stellen nicht durch. Es kann sein, dass ein Titel "
|
||||||
|
"fehlt oder unvollständig ist. Abhilfe: Disc reinigen (radial "
|
||||||
|
"von innen nach außen, nicht kreisend) oder ein anderes "
|
||||||
|
"Laufwerk probieren — Laufwerke unterscheiden sich hier stark.",
|
||||||
|
)
|
||||||
|
|
||||||
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
# Automatischer Auswurf. Die Disc ist nach dem Rip nicht mehr nötig — die
|
||||||
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
# Kompression arbeitet auf der Datei, nicht am Laufwerk.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
|
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ def hole_handbrake_presets() -> list:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--preset-list"],
|
[_hb(), "--preset-list"],
|
||||||
capture_output=True, text=True, timeout=30,
|
capture_output=True, timeout=30, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
return parse_preset_liste((aus.stdout or "") + (aus.stderr or ""))
|
||||||
@@ -304,8 +305,8 @@ def hole_handbrake_hilfe() -> str:
|
|||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--help"], capture_output=True, text=True, timeout=30,
|
[_hb(), "--help"], capture_output=True, timeout=30,
|
||||||
creationflags=OHNE_FENSTER
|
creationflags=OHNE_FENSTER, **HB_LESEN
|
||||||
)
|
)
|
||||||
return (aus.stdout or "") + (aus.stderr or "")
|
return (aus.stdout or "") + (aus.stderr or "")
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -403,7 +404,7 @@ def werkzeug_versionen() -> dict:
|
|||||||
try:
|
try:
|
||||||
aus = subprocess.run(
|
aus = subprocess.run(
|
||||||
[_hb(), "--version"],
|
[_hb(), "--version"],
|
||||||
capture_output=True, text=True, timeout=15,
|
capture_output=True, timeout=15, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
treffer = re.search(r"HandBrake\s+([\w.]+)", (aus.stdout or "") + (aus.stderr or ""))
|
||||||
|
|||||||
+148
-8
@@ -37,6 +37,7 @@ from rippy.drives.linux import ( # noqa: F401
|
|||||||
)
|
)
|
||||||
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
from rippy.drives.linux import auswerfen_versuchen as wirf_disc_aus # noqa: F401
|
||||||
from rippy.platform.winlauf import OHNE_FENSTER
|
from rippy.platform.winlauf import OHNE_FENSTER
|
||||||
|
from rippy.rip.handbrake_aufruf import HB_LESEN
|
||||||
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
from rippy.rip.makemkv_aufruf import KRITISCHE_CODES, text_von
|
||||||
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
from rippy.rip.makemkv_aufruf import quelle as makemkv_quelle
|
||||||
from rippy.tools import katalog as werkzeuge
|
from rippy.tools import katalog as werkzeuge
|
||||||
@@ -88,6 +89,24 @@ def check_cdparanoia_installed() -> bool:
|
|||||||
return shutil.which("cdparanoia") is not None
|
return shutil.which("cdparanoia") is not None
|
||||||
|
|
||||||
|
|
||||||
|
#: Sprachunabhaengige Marke fuer „die Disc liess sich stellenweise nicht lesen".
|
||||||
|
#:
|
||||||
|
#: Am 30.08.2026 im Protokoll des Commanders abgelesen (Spartacus Disc 2,
|
||||||
|
#: deutschsprachiges MakeMKV):
|
||||||
|
#:
|
||||||
|
#: Encountered 29 errors of type 'Read Error' - see
|
||||||
|
#: http://www.makemkv.com/errors/read/
|
||||||
|
#: Das Kopieren wurde abgeschlossen. 1 Titel wurden gesichert, 1 schlugen fehl.
|
||||||
|
#:
|
||||||
|
#: Die URL steht auch in der deutschen Fassung englisch da — sie ist damit die
|
||||||
|
#: einzige Stelle dieser Meldung, auf die Verlass ist. Die MSG-NUMMER waere
|
||||||
|
#: besser (so macht es KRITISCHE_CODES), aber sie war im Protokoll nicht zu
|
||||||
|
#: sehen: `melde_makemkv` schrieb sie bis heute nicht mit. Das ist behoben —
|
||||||
|
#: beim naechsten Lesefehler steht die Nummer im Log, und dann gehoert sie
|
||||||
|
#: hierher statt dieser Textsuche.
|
||||||
|
LESEFEHLER_MARKE = "makemkv.com/errors/read"
|
||||||
|
|
||||||
|
|
||||||
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
def build_makemkv_cmd(device_path: str, output_dir: str, titel: str = "all") -> list:
|
||||||
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
"""Baut das MakeMKV-Kommando (pure Funktion, testbar).
|
||||||
|
|
||||||
@@ -273,6 +292,9 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
Titel oder 'all' — also ein Aufruf je Titel, Fortschritt anteilig)."""
|
||||||
gesamt = len(titel_liste)
|
gesamt = len(titel_liste)
|
||||||
alle_dateien = []
|
alle_dateien = []
|
||||||
|
# Ein Lesefehler in Titel 1 darf nicht verschwinden, nur weil Titel 2
|
||||||
|
# sauber durchlief — je Titel laeuft ein eigener makemkvcon.
|
||||||
|
lesefehler = False
|
||||||
for index, nr in enumerate(titel_liste):
|
for index, nr in enumerate(titel_liste):
|
||||||
def anteilig(p, _i=index):
|
def anteilig(p, _i=index):
|
||||||
if progress_cb:
|
if progress_cb:
|
||||||
@@ -280,13 +302,16 @@ def rip_titel_auswahl(device_path: str, output_dir: str, titel_liste: list,
|
|||||||
|
|
||||||
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
ergebnis = run_makemkv(device_path, output_dir, progress_cb=anteilig, titel=str(nr),
|
||||||
log_cb=log_cb)
|
log_cb=log_cb)
|
||||||
|
lesefehler = lesefehler or bool(ergebnis.get("lesefehler"))
|
||||||
if ergebnis.get("status") == "cancelled":
|
if ergebnis.get("status") == "cancelled":
|
||||||
return ergebnis
|
return ergebnis
|
||||||
if ergebnis.get("status") != "success":
|
if ergebnis.get("status") != "success":
|
||||||
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
ergebnis["error"] = f"Titel {nr}: {ergebnis.get('error')}"
|
||||||
|
ergebnis["lesefehler"] = lesefehler
|
||||||
return ergebnis
|
return ergebnis
|
||||||
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
alle_dateien = ergebnis.get("files", []) # kumulativ: run_makemkv listet den Ordner
|
||||||
return {"status": "success", "output_dir": output_dir, "files": alle_dateien}
|
return {"status": "success", "output_dir": output_dir, "files": alle_dateien,
|
||||||
|
"lesefehler": lesefehler}
|
||||||
|
|
||||||
|
|
||||||
def laengster_titel(dauern: dict, meta: dict = None):
|
def laengster_titel(dauern: dict, meta: dict = None):
|
||||||
@@ -362,7 +387,7 @@ def lies_datei_dauer(pfad: str, timeout: int = 120) -> int:
|
|||||||
try:
|
try:
|
||||||
ergebnis = subprocess.run(
|
ergebnis = subprocess.run(
|
||||||
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
[werkzeug("handbrake"), "--scan", "-i", pfad],
|
||||||
capture_output=True, text=True, timeout=timeout,
|
capture_output=True, timeout=timeout, **HB_LESEN,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
@@ -507,7 +532,7 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
Sprachen drin — und das ist bei einer verlustfreien Ablage richtig.
|
||||||
|
|
||||||
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
`--audio-lang-list` zusammen mit `--first-audio` heißt: HandBrake pickt pro
|
||||||
Sprache genau die erste (beste) Tonspur heraus. `--audio-codec copy` reicht
|
Sprache genau die erste (beste) Tonspur heraus. `--aencoder copy` reicht
|
||||||
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
diese dann verlustfrei durch, statt sie auf Stereo herunterzurechnen.
|
||||||
"""
|
"""
|
||||||
befehl = [
|
befehl = [
|
||||||
@@ -539,7 +564,23 @@ def build_handbrake_cmd(input_path: str, output_path: str,
|
|||||||
if audio:
|
if audio:
|
||||||
befehl += ["--audio-lang-list", ",".join(audio)]
|
befehl += ["--audio-lang-list", ",".join(audio)]
|
||||||
befehl.append("--first-audio")
|
befehl.append("--first-audio")
|
||||||
befehl += ["--audio-codec", "copy", "--audio-fallback", "av_aac"]
|
# `--aencoder`, NICHT `--audio-codec` (Befund 30.08.2026).
|
||||||
|
#
|
||||||
|
# Den Schalter `--audio-codec` gibt es bei HandBrakeCLI nicht und hat es
|
||||||
|
# nie gegeben — er heisst `-E` / `--aencoder`. Am mitgelieferten
|
||||||
|
# HandBrakeCLI 1.11.2 nachgestellt, mit Rippys eigener Befehlszeile:
|
||||||
|
#
|
||||||
|
# unknown option (--audio-codec)
|
||||||
|
# HandBrake has exited. $? = 0
|
||||||
|
#
|
||||||
|
# Ein ganzer Blu-ray-Rip (16,5 GB) lief damit ins Leere: HandBrake war
|
||||||
|
# in derselben Sekunde wieder weg, in der es startete, und meldete das
|
||||||
|
# als ERFOLG. Der Test darunter forderte den falschen Namen sogar ein
|
||||||
|
# (`assert "--audio-codec" in cmd`) — ein Test, der einen Fehler
|
||||||
|
# festschreibt, statt ihn zu finden.
|
||||||
|
#
|
||||||
|
# `copy` ist als Wert gueltig (in `--help` gelistet, ebenda geprueft).
|
||||||
|
befehl += ["--aencoder", "copy", "--audio-fallback", "av_aac"]
|
||||||
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
untertitel = [s for s in (untertitel_sprachen or []) if s]
|
||||||
if untertitel:
|
if untertitel:
|
||||||
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
befehl += ["--subtitle-lang-list", ",".join(untertitel)]
|
||||||
@@ -644,9 +685,9 @@ def run_handbrake(input_path: str, output_path: str, preset: str = DEFAULT_HB_PR
|
|||||||
audio_sprachen, untertitel_sprachen),
|
audio_sprachen, untertitel_sprachen),
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.STDOUT,
|
stderr=subprocess.STDOUT,
|
||||||
text=True,
|
|
||||||
bufsize=1,
|
bufsize=1,
|
||||||
creationflags=OHNE_FENSTER,
|
creationflags=OHNE_FENSTER,
|
||||||
|
**HB_LESEN,
|
||||||
)
|
)
|
||||||
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
return _handbrake_schleife(process, output_path, abbruch_cb, progress_cb)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -673,10 +714,67 @@ def unbekanntes_preset(zeile: str) -> str:
|
|||||||
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
return text[len(kopf):].strip() if text.startswith(kopf) else ""
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie viele Ausgabezeilen von HandBrake fuer den Fehlerfall aufgehoben werden.
|
||||||
|
HB_ZEILEN_PUFFER = 12
|
||||||
|
#: ... und wie viele davon in die Fehlermeldung wandern. Sie steht in der
|
||||||
|
#: Jobzeile und im UI; zwoelf Zeilen Muxer-Statistik waeren dort unlesbar.
|
||||||
|
HB_ZEILEN_MELDUNG = 4
|
||||||
|
|
||||||
|
_UNBEKANNTER_SCHALTER = re.compile(r"unknown option \(([^)]*)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def unbekannter_schalter(zeile: str) -> str:
|
||||||
|
"""Meldet diese Zeile einen Schalter, den DIESES HandBrake nicht kennt?
|
||||||
|
|
||||||
|
## Der Befund des Commanders (30.08.2026)
|
||||||
|
|
||||||
|
> „Kompression fehlgeschlagen bei Spartacus … _t01.mkv: HandBrake endete
|
||||||
|
> mit Code 0 — Roh-Datei bleibt erhalten"
|
||||||
|
|
||||||
|
16,5 GB Rohschnitt, und die Kompression war in derselben Sekunde vorbei,
|
||||||
|
in der sie begann — fuer einen Scan-Durchlauf haette das nicht gereicht.
|
||||||
|
Am mitgelieferten HandBrakeCLI 1.11.2 nachgestellt, mit genau der
|
||||||
|
Befehlszeile, die Rippy baute:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited.
|
||||||
|
$? = 0
|
||||||
|
|
||||||
|
**Der Rueckgabewert ist 0.** HandBrake meldet einen Tippfehler in seiner
|
||||||
|
eigenen Befehlszeile als ERFOLG. Rippy sah nur „Code 0" und keine Datei —
|
||||||
|
und riet daraufhin auf „Zielordner nicht beschreibbar". Das war falsch,
|
||||||
|
und es schickte die Suche in die vollkommen falsche Richtung.
|
||||||
|
|
||||||
|
Der Schalter ist repariert (siehe `build_handbrake_cmd`). Diese Pruefung
|
||||||
|
bleibt trotzdem: Der naechste falsche Schalter soll sich SELBST melden,
|
||||||
|
statt wieder einen ganzen Rip zu kosten.
|
||||||
|
"""
|
||||||
|
treffer = _UNBEKANNTER_SCHALTER.search(zeile or "")
|
||||||
|
return treffer.group(1).strip() if treffer else ""
|
||||||
|
|
||||||
|
|
||||||
|
def hb_schluss(zeilen) -> str:
|
||||||
|
"""HandBrakes letzte Worte als Anhang fuer eine Fehlermeldung (pure).
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 warf `_handbrake_schleife` jede Zeile weg, die kein
|
||||||
|
Fortschritt war. Im Fehlerfall blieb damit nur der Rueckgabewert uebrig —
|
||||||
|
und wenn der 0 ist, sagt er nichts. Der Grund stand die ganze Zeit in der
|
||||||
|
Ausgabe, nur hoerte niemand zu.
|
||||||
|
"""
|
||||||
|
sauber = [z.strip() for z in (zeilen or []) if z and z.strip()]
|
||||||
|
if not sauber:
|
||||||
|
return ""
|
||||||
|
return " — HandBrake sagte zuletzt: " + " | ".join(sauber[-HB_ZEILEN_MELDUNG:])
|
||||||
|
|
||||||
|
|
||||||
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=None) -> dict:
|
||||||
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
"""Liest HandBrakes Ausgabe und wertet sie aus. Eigene Funktion, damit die
|
||||||
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
Reihenfolge (Abbruch VOR Fortschritt) ohne echtes HandBrake testbar ist."""
|
||||||
falsches_preset = ""
|
falsches_preset = ""
|
||||||
|
falscher_schalter = ""
|
||||||
|
# Die letzten Zeilen aufheben — im Fehlerfall sind sie die einzige
|
||||||
|
# Auskunft, die es ueberhaupt gibt (siehe `hb_schluss`).
|
||||||
|
letzte_zeilen = []
|
||||||
try:
|
try:
|
||||||
for line in process.stdout:
|
for line in process.stdout:
|
||||||
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
# Zuerst der Abbruch — unabhängig davon, ob die Zeile überhaupt
|
||||||
@@ -684,8 +782,14 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
# sich die Prozentzahl bewegt (Befund 25.07.2026).
|
||||||
if abbruch_cb:
|
if abbruch_cb:
|
||||||
abbruch_cb()
|
abbruch_cb()
|
||||||
|
if line.strip():
|
||||||
|
letzte_zeilen.append(line)
|
||||||
|
if len(letzte_zeilen) > HB_ZEILEN_PUFFER:
|
||||||
|
del letzte_zeilen[0]
|
||||||
if not falsches_preset:
|
if not falsches_preset:
|
||||||
falsches_preset = unbekanntes_preset(line)
|
falsches_preset = unbekanntes_preset(line)
|
||||||
|
if not falscher_schalter:
|
||||||
|
falscher_schalter = unbekannter_schalter(line)
|
||||||
progress = get_progress_from_line(line)
|
progress = get_progress_from_line(line)
|
||||||
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
# >= 0: ein echtes 0 % ist eine Angabe und muss durch. Der alte
|
||||||
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
# Filter `> 0` verwarf den gesamten ersten Prozentpunkt — bei
|
||||||
@@ -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):
|
if process.returncode == 0 and os.path.exists(output_path):
|
||||||
return {"status": "success", "output_path": output_path}
|
return {"status": "success", "output_path": output_path}
|
||||||
|
|
||||||
|
# Ein Schalter, den DIESES HandBrake nicht kennt, ist ein Fehler in
|
||||||
|
# RIPPY — und er kommt mit Rueckgabewert 0 daher (siehe
|
||||||
|
# `unbekannter_schalter`). Deshalb steht die Pruefung VOR allen
|
||||||
|
# anderen: sonst landet der Fall unten bei „HandBrake endete mit Code
|
||||||
|
# 0", und dort ist er nicht zu erraten. Genau das kostete am
|
||||||
|
# 30.08.2026 einen fertigen 16,5-GB-Rip.
|
||||||
|
if falscher_schalter:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": (
|
||||||
|
'Rippy hat HandBrake den Schalter „%s" übergeben, den '
|
||||||
|
'diese HandBrake-Fassung nicht kennt. Das ist ein Fehler '
|
||||||
|
'in Rippy, keine Einstellung — bitte melden.' % falscher_schalter
|
||||||
|
+ hb_schluss(letzte_zeilen)
|
||||||
|
),
|
||||||
|
"return_code": process.returncode,
|
||||||
|
}
|
||||||
|
|
||||||
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
# Code 0, aber die Datei fehlt: HandBrake hat sie woanders hingeschrieben.
|
||||||
#
|
#
|
||||||
# Commander 29.08.2026: „Kompression fehlgeschlagen bei title_t00.mkv:
|
# 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)."
|
"(der Container kommt aus dem Preset)."
|
||||||
% (os.path.basename(daneben),
|
% (os.path.basename(daneben),
|
||||||
os.path.basename(output_path))}
|
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 {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
"error": ("HandBrake meldet Erfolg, aber es ist keine Datei "
|
||||||
"entstanden. Meist ist der Zielordner nicht "
|
"entstanden (Zielordner: %s)."
|
||||||
"beschreibbar: %s" % os.path.dirname(output_path)),
|
% os.path.dirname(output_path)
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": 0,
|
"return_code": 0,
|
||||||
}
|
}
|
||||||
if falsches_preset:
|
if falsches_preset:
|
||||||
@@ -742,7 +870,8 @@ def _handbrake_schleife(process, output_path: str, abbruch_cb=None, progress_cb=
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error": f"HandBrake endete mit Code {process.returncode}",
|
"error": (f"HandBrake endete mit Code {process.returncode}"
|
||||||
|
+ hb_schluss(letzte_zeilen)),
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -862,6 +991,13 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
)
|
)
|
||||||
|
|
||||||
letzte_meldung = ""
|
letzte_meldung = ""
|
||||||
|
# Lesefehler sind KEIN Abbruchgrund — MakeMKV ueberspringt den
|
||||||
|
# kaputten Titel und macht mit dem naechsten weiter. Genau deshalb
|
||||||
|
# muessen sie gesagt werden: Am 30.08.2026 endete ein Rip als
|
||||||
|
# „erfolgreich", obwohl von zwei Titeln nur einer ankam. Im
|
||||||
|
# Jobprotokoll stand „Rip fertig" — dass ein Titel fehlt, war nur
|
||||||
|
# den MakeMKV-Zeilen zu entnehmen, die niemand liest.
|
||||||
|
lesefehler = False
|
||||||
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
# Kritische Meldungen einsammeln: die LETZTE Zeile ist fast immer nur
|
||||||
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
# "Failed to open disc" — die URSACHE ("volume key is unknown", Key
|
||||||
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
# abgelaufen) steht Zeilen davor und ging im Fehlertext verloren
|
||||||
@@ -891,6 +1027,8 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
if meldung is None:
|
if meldung is None:
|
||||||
continue
|
continue
|
||||||
code, letzte_meldung = meldung
|
code, letzte_meldung = meldung
|
||||||
|
if LESEFEHLER_MARKE in letzte_meldung:
|
||||||
|
lesefehler = True
|
||||||
if code in KRITISCHE_CODES:
|
if code in KRITISCHE_CODES:
|
||||||
kritische_meldungen.append(
|
kritische_meldungen.append(
|
||||||
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
"%s (%s)" % (KRITISCHE_CODES[code], letzte_meldung.strip()))
|
||||||
@@ -922,9 +1060,11 @@ def run_makemkv(device_path: str, output_dir: str, progress_cb=None, titel: str
|
|||||||
"output_dir": output_dir,
|
"output_dir": output_dir,
|
||||||
"files": mkv_dateien,
|
"files": mkv_dateien,
|
||||||
"return_code": process.returncode,
|
"return_code": process.returncode,
|
||||||
|
"lesefehler": lesefehler,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
|
"lesefehler": lesefehler,
|
||||||
"error": (
|
"error": (
|
||||||
f"makemkvcon endete mit Code {process.returncode}"
|
f"makemkvcon endete mit Code {process.returncode}"
|
||||||
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
+ (f" — Ursache: {'; '.join(kritische_meldungen)}" if kritische_meldungen else "")
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from ripping import (
|
|||||||
build_makemkv_cmd,
|
build_makemkv_cmd,
|
||||||
get_progress_from_line,
|
get_progress_from_line,
|
||||||
get_progress_from_prgv,
|
get_progress_from_prgv,
|
||||||
|
hb_schluss,
|
||||||
parse_msg,
|
parse_msg,
|
||||||
|
unbekannter_schalter,
|
||||||
write_abcde_config,
|
write_abcde_config,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,10 +51,56 @@ def test_handbrake_cmd_arbeitet_auf_datei_nicht_geraet():
|
|||||||
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
assert cmd[cmd.index("--output") + 1] == "/app/media/bluray/x/t00.mkv"
|
||||||
assert "--preset" in cmd
|
assert "--preset" in cmd
|
||||||
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
assert "--first-audio" in cmd # beste Spur pro Sprache behalten
|
||||||
assert "--audio-codec" in cmd
|
assert "--aencoder" in cmd
|
||||||
assert "--all-subtitles" in cmd
|
assert "--all-subtitles" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_handbrake_kennt_keinen_schalter_audio_codec():
|
||||||
|
"""Der Schalter heißt `--aencoder`. `--audio-codec` gibt es nicht.
|
||||||
|
|
||||||
|
Befund 30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen:
|
||||||
|
|
||||||
|
unknown option (--audio-codec)
|
||||||
|
HandBrake has exited. $? = 0
|
||||||
|
|
||||||
|
Rippy baute genau diesen Befehl. HandBrake stieg sofort aus und meldete
|
||||||
|
das mit Rückgabewert 0 als ERFOLG — ein fertiger 16,5-GB-Rip lief damit
|
||||||
|
ins Leere, ohne dass irgendwo ein Grund stand.
|
||||||
|
|
||||||
|
⚠️ Bis dahin stand hier `assert "--audio-codec" in cmd`: ein Test, der
|
||||||
|
den Fehler festschrieb, statt ihn zu finden. Ein Kommandozeilen-Schalter
|
||||||
|
ist eine externe Schnittstelle (AGENTS Regel D) — er gehört am echten
|
||||||
|
Programm gemessen, nicht aus dem Gedächtnis behauptet.
|
||||||
|
"""
|
||||||
|
cmd = build_handbrake_cmd("/tmp/a.mkv", "/tmp/b.mkv")
|
||||||
|
assert "--audio-codec" not in cmd
|
||||||
|
assert cmd[cmd.index("--aencoder") + 1] == "copy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_wird_erkannt():
|
||||||
|
"""Wortlaut aus dem echten Lauf (30.08.2026, HandBrakeCLI 1.11.2)."""
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"unknown option (--audio-codec)") == "--audio-codec"
|
||||||
|
# Mit Zeitstempel davor — HandBrake stellt vielen Zeilen einen voran.
|
||||||
|
assert unbekannter_schalter(
|
||||||
|
"[13:30:54] unknown option (--gibt-es-nicht)") == "--gibt-es-nicht"
|
||||||
|
assert unbekannter_schalter("Encoding: task 1 of 1, 5.00 %") == ""
|
||||||
|
assert unbekannter_schalter("") == ""
|
||||||
|
assert unbekannter_schalter(None) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_hb_schluss_haengt_handbrakes_letzte_worte_an():
|
||||||
|
"""Ohne sie stand im Fehlerfall nur der Rückgabewert da — und wenn der
|
||||||
|
0 ist, sagt er nichts (Befund 30.08.2026)."""
|
||||||
|
assert hb_schluss([]) == ""
|
||||||
|
assert hb_schluss(None) == ""
|
||||||
|
assert hb_schluss([" ", " "]) == ""
|
||||||
|
text = hb_schluss(["eins", "zwei", "drei", "vier", "fünf"])
|
||||||
|
assert "fünf" in text and "vier" in text
|
||||||
|
# Nur die letzten HB_ZEILEN_MELDUNG — sonst steht Muxer-Statistik im UI.
|
||||||
|
assert "eins" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_handbrake_progress_parsing():
|
def test_handbrake_progress_parsing():
|
||||||
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
# Testfund 22.07.: echtes HandBrake schreibt 45.50 % MIT Leerzeichen
|
||||||
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
assert get_progress_from_line("Encoding: task 1 of 1, 45.50 %") == 45
|
||||||
@@ -375,7 +423,13 @@ def test_falsches_preset_erklaert_den_fehlschlag_statt_nur_den_code():
|
|||||||
assert ergebnis["return_code"] == 3
|
assert ergebnis["return_code"] == 3
|
||||||
|
|
||||||
|
|
||||||
def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
def test_fehler_ohne_preset_problem_nennt_handbrakes_letzte_worte():
|
||||||
|
"""Der Code allein reicht nicht — HandBrake selbst muss zu Wort kommen.
|
||||||
|
|
||||||
|
Bis zum 30.08.2026 stand hier nur „HandBrake endete mit Code N", und
|
||||||
|
jede Ausgabezeile wurde weggeworfen. Bei Code 0 (den HandBrake auch
|
||||||
|
für Fehler vergibt) blieb damit gar keine Auskunft übrig.
|
||||||
|
"""
|
||||||
import ripping
|
import ripping
|
||||||
|
|
||||||
class FakeProcess:
|
class FakeProcess:
|
||||||
@@ -390,7 +444,43 @@ def test_fehler_ohne_preset_problem_bleibt_der_alte():
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
assert ergebnis["error"] == "HandBrake endete mit Code 1"
|
assert ergebnis["error"].startswith("HandBrake endete mit Code 1")
|
||||||
|
assert "irgendwas ganz anderes" in ergebnis["error"]
|
||||||
|
assert ergebnis["return_code"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannter_schalter_schlaegt_den_nichtssagenden_code_null():
|
||||||
|
"""Der Fall vom 30.08.2026, nachgestellt: HandBrake steigt an einem
|
||||||
|
Schalter aus, den es nicht kennt, und meldet das mit 0 als Erfolg.
|
||||||
|
|
||||||
|
Ohne diese Erkennung landete er unten bei „HandBrake meldet Erfolg,
|
||||||
|
aber es ist keine Datei entstanden" samt der falschen Vermutung
|
||||||
|
„Zielordner nicht beschreibbar" — und die schickte die Suche in die
|
||||||
|
vollkommen falsche Richtung.
|
||||||
|
"""
|
||||||
|
import ripping
|
||||||
|
|
||||||
|
class FakeProcess:
|
||||||
|
def __init__(self):
|
||||||
|
self.stdout = iter([
|
||||||
|
"[13:30:54] hb_init: starting libhb thread\n",
|
||||||
|
"unknown option (--audio-codec)\n",
|
||||||
|
"HandBrake has exited.\n",
|
||||||
|
])
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
ergebnis = ripping._handbrake_schleife(FakeProcess(), "/gibt-es-nicht.mkv")
|
||||||
|
assert ergebnis["status"] == "error"
|
||||||
|
assert "--audio-codec" in ergebnis["error"]
|
||||||
|
assert "Fehler in Rippy" in ergebnis["error"]
|
||||||
|
# ... und NICHT die alte Vermutung über den Zielordner
|
||||||
|
assert "beschreibbar" not in ergebnis["error"]
|
||||||
|
|
||||||
|
|
||||||
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
# --- Sprachen der Disc: gemessen an der Akira-Blu-ray (26.07.2026) -----------
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
|
||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -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 5–7) und
|
||||||
|
Vitest 4 (verträgt 6–8), 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`).
|
||||||
@@ -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') },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+3818
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.bruecke.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -292,6 +292,44 @@ def windows_laufwerke(art=None, buchstaben=None) -> list:
|
|||||||
return gefunden
|
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:
|
def ablage_vorgabe(werte: dict, container: bool, system: str) -> str:
|
||||||
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
"""Wohin Rippy standardmäßig ablegt — je Betrieb ein anderer Ort."""
|
||||||
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
eigen = ((werte or {}).get("storage", {}) or {}).get("medien", "")
|
||||||
|
|||||||
@@ -269,19 +269,27 @@ def device_info(device_path: str) -> dict:
|
|||||||
vendor = read_sys_attr(name, "vendor")
|
vendor = read_sys_attr(name, "vendor")
|
||||||
model = read_sys_attr(name, "model")
|
model = read_sys_attr(name, "model")
|
||||||
|
|
||||||
|
grund = ""
|
||||||
try:
|
try:
|
||||||
status_code = drive_status(device_path)
|
status_code = drive_status(device_path)
|
||||||
except OSError:
|
except OSError as e:
|
||||||
status_code = -1
|
status_code = -1
|
||||||
|
grund = "Das Laufwerk antwortet nicht (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
disc_type = "unknown"
|
disc_type = "unknown"
|
||||||
status = "empty"
|
# Ein Laufwerk, das sich nicht ansprechen laesst, ist nicht LEER —
|
||||||
|
# man weiss es nur nicht. Der Windows-Treiber haelt sich seit V2-1
|
||||||
|
# daran („DIE Regel des Ports", siehe dort); hier stand weiterhin
|
||||||
|
# „empty", und damit behauptete derselbe Ereignisstrom je nach
|
||||||
|
# Plattform etwas anderes ueber dieselbe Lage (Befund 30.08.2026).
|
||||||
|
status = "unknown" if status_code < 0 else "empty"
|
||||||
if status_code == CDS_DISC_OK:
|
if status_code == CDS_DISC_OK:
|
||||||
status = "ready"
|
status = "ready"
|
||||||
try:
|
try:
|
||||||
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
disc_type = classify(disc_status(device_path), disc_size_bytes(device_path))
|
||||||
except OSError:
|
except OSError as e:
|
||||||
disc_type = "unknown"
|
disc_type = "unknown"
|
||||||
|
grund = "Die Disc liess sich nicht einordnen (%s)." % (e.strerror or e)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": name,
|
"id": name,
|
||||||
@@ -291,4 +299,7 @@ def device_info(device_path: str) -> dict:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"model": model,
|
"model": model,
|
||||||
"serial": read_sys_attr(name, "wwid"),
|
"serial": read_sys_attr(name, "wwid"),
|
||||||
|
# Gleiche Felder wie im Windows-Treiber — das UI unterscheidet
|
||||||
|
# nicht nach Plattform (siehe test_windows: Feld-Parität).
|
||||||
|
"grund": grund,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,37 @@ def test_unzugaengliches_laufwerk_ist_UNBEKANNT_und_nicht_leer():
|
|||||||
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
|
assert windows.device_info(r"\\.\D:", api=api)["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unzugaengliches_laufwerk_sagt_auch_WARUM():
|
||||||
|
"""Befund 30.08.2026: „jetzt erkennt rippy die disk garnicht mehr (im
|
||||||
|
log steht zwar erkannt, aber ein start des rips ist nicht moeglich)".
|
||||||
|
|
||||||
|
Sein Laufwerk beantwortete nach einem Rip mit Lesefehlern keine
|
||||||
|
Medien-Abfragen mehr (Win32-Fehler 1); die Geraete-Auskunft kam weiter
|
||||||
|
durch. Im UI stand eine vollstaendige Laufwerkskarte mit Modell und
|
||||||
|
Seriennummer — und „unknown" ohne ein Wort dazu. Rippy kannte den
|
||||||
|
Grund und behielt ihn fuer sich.
|
||||||
|
"""
|
||||||
|
api = FakeLaufwerk(oeffnen_fehler=windows.Win32Fehler(
|
||||||
|
"CreateFileW", w.ERROR_INVALID_FUNCTION))
|
||||||
|
info = windows.device_info(r"\\.\D:", api=api)
|
||||||
|
assert info["status"] == "unknown"
|
||||||
|
assert "auswerfen" in info["grund"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grund_bleibt_leer_solange_alles_geht():
|
||||||
|
"""Ein Feld, das immer gefuellt ist, sagt nichts mehr."""
|
||||||
|
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK,
|
||||||
|
groesse=25 * 1024**3)
|
||||||
|
assert windows.device_info(r"\\.\D:", api=api)["grund"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_fehlernummer_wird_GENANNT():
|
||||||
|
"""Eine Nummer, nach der man suchen kann, ist mehr als ein leerer Satz."""
|
||||||
|
text = windows.zugriffs_grund(windows.Win32Fehler("CreateFileW", 4711))
|
||||||
|
assert "4711" in text
|
||||||
|
assert windows.zugriffs_grund(OSError("ohne Nummer"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("flags,groesse,erwartet", [
|
@pytest.mark.parametrize("flags,groesse,erwartet", [
|
||||||
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
|
(w.CDROM_DISK_AUDIO_TRACK, 700 * 1024**2, "cd"),
|
||||||
(w.CDROM_DISK_DATA_TRACK, 8 * 1024**3, "dvd"),
|
(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."""
|
Karte im Dashboard leer."""
|
||||||
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
|
api = FakeLaufwerk(medium=True, disk_flags=w.CDROM_DISK_DATA_TRACK, groesse=25 * 1024**3)
|
||||||
eintrag = windows.device_info(r"\\.\D:", api=api)
|
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["id"] == "D"
|
||||||
assert eintrag["status"] == "ready"
|
assert eintrag["status"] == "ready"
|
||||||
assert eintrag["type"] == "bluray"
|
assert eintrag["type"] == "bluray"
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ ERROR_NOT_READY = 21 # kein Medium eingelegt
|
|||||||
ERROR_ACCESS_DENIED = 5
|
ERROR_ACCESS_DENIED = 5
|
||||||
ERROR_FILE_NOT_FOUND = 2
|
ERROR_FILE_NOT_FOUND = 2
|
||||||
ERROR_INVALID_FUNCTION = 1 # Gerät kennt diesen Steuercode nicht
|
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_MEDIA_CHANGED = 1110
|
||||||
ERROR_NO_MEDIA_IN_DRIVE = 1112
|
ERROR_NO_MEDIA_IN_DRIVE = 1112
|
||||||
|
|
||||||
|
|||||||
+114
-3
@@ -154,6 +154,7 @@ class Win32:
|
|||||||
self._ctypes = ctypes
|
self._ctypes = ctypes
|
||||||
self._wintypes = wintypes
|
self._wintypes = wintypes
|
||||||
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
self._k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||||
|
_typen_erklaeren(ctypes, wintypes, self._k32)
|
||||||
|
|
||||||
# ── Laufwerke finden ────────────────────────────────────────────────
|
# ── Laufwerke finden ────────────────────────────────────────────────
|
||||||
def laufwerksbuchstaben(self) -> list:
|
def laufwerksbuchstaben(self) -> list:
|
||||||
@@ -174,7 +175,12 @@ class Win32:
|
|||||||
0,
|
0,
|
||||||
None,
|
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())
|
raise Win32Fehler("CreateFileW", self._ctypes.get_last_error())
|
||||||
return handle
|
return handle
|
||||||
|
|
||||||
@@ -200,6 +206,48 @@ class Win32:
|
|||||||
return aus_puffer.raw[:zurueck.value] if aus_puffer else b""
|
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):
|
def _api(api):
|
||||||
return api if api is not None else Win32()
|
return api if api is not None else Win32()
|
||||||
|
|
||||||
@@ -464,6 +512,63 @@ def pfad_zu_kennung(name: str, geraete=None) -> str:
|
|||||||
return ""
|
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:
|
def device_info(geraet: str, api=None) -> dict:
|
||||||
"""Der Geräte-Eintrag fürs UI — gleiche Felder wie beim Linux-Treiber.
|
"""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)
|
api = _api(api)
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
disc_typ = "unknown"
|
disc_typ = "unknown"
|
||||||
|
grund = ""
|
||||||
try:
|
try:
|
||||||
if drive_status(geraet, api) == CDS_DISC_OK:
|
if drive_status(geraet, api) == CDS_DISC_OK:
|
||||||
status = "ready"
|
status = "ready"
|
||||||
try:
|
try:
|
||||||
disc_typ = classify(disc_status(geraet, api),
|
disc_typ = classify(disc_status(geraet, api),
|
||||||
disc_size_bytes(geraet, api))
|
disc_size_bytes(geraet, api))
|
||||||
except OSError:
|
except OSError as e:
|
||||||
disc_typ = "unknown"
|
disc_typ = "unknown"
|
||||||
|
grund = zugriffs_grund(e)
|
||||||
else:
|
else:
|
||||||
status = "empty"
|
status = "empty"
|
||||||
except OSError:
|
except OSError as e:
|
||||||
status = "unknown"
|
status = "unknown"
|
||||||
|
grund = zugriffs_grund(e)
|
||||||
|
|
||||||
buchstabe = kennung(geraet)
|
buchstabe = kennung(geraet)
|
||||||
angaben = _angaben_gemerkt(geraet, api)
|
angaben = _angaben_gemerkt(geraet, api)
|
||||||
@@ -499,6 +607,9 @@ def device_info(geraet: str, api=None) -> dict:
|
|||||||
"status": status,
|
"status": status,
|
||||||
"model": modell,
|
"model": modell,
|
||||||
"serial": angaben.get("seriennummer", ""),
|
"serial": angaben.get("seriennummer", ""),
|
||||||
|
# Leer, solange alles geht. Sonst steht hier, WARUM „unknown"
|
||||||
|
# dasteht — siehe ZUGRIFFS_GRUENDE.
|
||||||
|
"grund": grund,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -81,7 +81,16 @@ def naechster_vorhandener(pfad: str, existiert=None) -> str:
|
|||||||
|
|
||||||
existiert = existiert or os.path.isdir
|
existiert = existiert or os.path.isdir
|
||||||
modul = _modul(pfad)
|
modul = _modul(pfad)
|
||||||
pfad = (pfad or "").rstrip("\\/")
|
pfad = (pfad or "").strip()
|
||||||
|
# Den Schluss-Trenner nur abstreifen, wenn danach mehr uebrig bleibt als
|
||||||
|
# der blosse Laufwerksname. Aus "F:\" wurde sonst "F:", und das ist
|
||||||
|
# unter Windows der AKTUELLE Ordner auf Laufwerk F, nicht dessen Wurzel
|
||||||
|
# (siehe `verbinden`). Dieselbe Falle kostete am 30.08.2026 in
|
||||||
|
# `rohdaten.kandidaten` 16,5 GB Sichtbarkeit. Fuer UNC gilt dasselbe:
|
||||||
|
# aus "\\server\freigabe\" darf kein Ort ohne Trenner werden.
|
||||||
|
gekuerzt = pfad.rstrip("\\/")
|
||||||
|
if gekuerzt and gekuerzt != modul.splitdrive(pfad)[0]:
|
||||||
|
pfad = gekuerzt
|
||||||
gesehen = set()
|
gesehen = set()
|
||||||
while pfad and pfad not in gesehen:
|
while pfad and pfad not in gesehen:
|
||||||
if existiert(pfad):
|
if existiert(pfad):
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Wie HandBrakeCLI angesprochen und wie seine Ausgabe gelesen wird.
|
||||||
|
|
||||||
|
## Warum das im GEMEINSAMEN Paket liegt
|
||||||
|
|
||||||
|
Zwei Module rufen HandBrake auf: `docker/worker/ripping.py` (der Encode) und
|
||||||
|
`docker/worker/caps.py` (Preset-Liste, Hilfe, Version). `caps` wird auch vom
|
||||||
|
Standalone-Betrieb geladen und darf `ripping` nicht importieren, nur um an
|
||||||
|
eine Konstante zu kommen.
|
||||||
|
|
||||||
|
Dasselbe Muster wie bei `makemkv_aufruf.py`: Eine Entscheidung, zwei Orte —
|
||||||
|
einer altert. Dort kostete es die Formatzeile, hier waere es die Kodierung.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Wie HandBrakes Ausgabe gelesen wird — und warum mit errors="replace".
|
||||||
|
#:
|
||||||
|
#: ## Der Befund (30.08.2026, am mitgelieferten HandBrakeCLI 1.11.2 gemessen)
|
||||||
|
#:
|
||||||
|
#: HandBrake schreibt **zwei Kodierungen in denselben Strom**. Eine Datei
|
||||||
|
#: namens „Glück über München.mkv" gescannt, die Bytes des Pfades in
|
||||||
|
#: derselben Ausgabe:
|
||||||
|
#:
|
||||||
|
#: Zeile „Opening ..." C3 BC = UTF-8
|
||||||
|
#: Zeile „..., title 1 ..." 81 = CP850 (OEM)
|
||||||
|
#:
|
||||||
|
#: Es gibt hier also keine richtige Kodierung, nur eine, die nicht
|
||||||
|
#: abstuerzt. Und ohne diese Zeile stuerzte es ab: In CP850 ist ü das Byte
|
||||||
|
#: **0x81**, und 0x81 ist in cp1252 — der Gebietsschema-Kodierung eines
|
||||||
|
#: deutschen Windows, die text=True von sich aus waehlt — **undefiniert**:
|
||||||
|
#:
|
||||||
|
#: UnicodeDecodeError: 'charmap' codec can't decode byte 0x81
|
||||||
|
#: in position 785: character maps to <undefined>
|
||||||
|
#:
|
||||||
|
#: In `run_handbrake` faellt das mitten in der Leseschleife an, wird von
|
||||||
|
#: `except Exception` eingefangen und landet als Fehlertext im Job. **Jeder
|
||||||
|
#: Film, dessen Pfad ein ü enthaelt, liess sich damit nicht komprimieren**
|
||||||
|
#: — „Glück", „Tür", „München", „Über", „Grün". Dasselbe gilt fuer
|
||||||
|
#: ì, Å, É, Ø (0x8D, 0x8F, 0x90, 0x9D).
|
||||||
|
#:
|
||||||
|
#: ⚠️ NICHT `text_von()` wie bei makemkvcon: Das liest binaer, und im
|
||||||
|
#: Binaermodus gibt es keine Universal-Newlines. HandBrake trennt seine
|
||||||
|
#: Fortschrittszeilen aber mit CR (0x0D) — der Balken waere weg. Alles, was
|
||||||
|
#: Rippy aus der Ausgabe liest, ist ASCII (Prozente, „Invalid preset",
|
||||||
|
#: „unknown option"); ersetzt wird also nur, was ohnehin nur Anzeige ist.
|
||||||
|
HB_LESEN = {"text": True, "errors": "replace"}
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -20,6 +20,8 @@ nie auf.
|
|||||||
|
|
||||||
from rippy import betrieb
|
from rippy import betrieb
|
||||||
|
|
||||||
|
B = chr(92) # Backslash, nie woertlich (siehe test_pfade)
|
||||||
|
|
||||||
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
DOCKER = {"profil": "api", "queue": {"treiber": "celery", "broker": "redis://x"}}
|
||||||
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
WINDOWS = {"profil": "standalone", "queue": {"treiber": "lokal"}}
|
||||||
|
|
||||||
@@ -217,3 +219,37 @@ def test_noch_nicht_angelegter_ordner_faellt_auf_das_laufwerk_zurueck():
|
|||||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||||
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
assert betrieb.naechster_vorhandener(r"Z:\gibt\es\nicht",
|
||||||
existiert=lambda p: False) == ""
|
existiert=lambda p: False) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_eingestellte_orte_schlagen_die_vorgabe():
|
||||||
|
"""Der Befund vom 30.08.2026: „die verzeichnise sind andere als dort
|
||||||
|
steht."
|
||||||
|
|
||||||
|
Die Oberflaeche schreibt `outputDir`/`workDir` in die Datenbank,
|
||||||
|
`betrieb` las `storage.*` aus der Konfigurationsdatei — und die ist
|
||||||
|
leer. In „Einstellungen -> System" stand deshalb dauerhaft die Vorgabe,
|
||||||
|
waehrend der Rip woanders hin lief.
|
||||||
|
"""
|
||||||
|
werte = betrieb.mit_einstellungen(
|
||||||
|
{"storage": {"medien": "", "temp": ""}},
|
||||||
|
{"outputDir": "F:" + B + "Filme", "workDir": "F:" + B})
|
||||||
|
assert werte["storage"]["medien"] == "F:" + B + "Filme"
|
||||||
|
assert werte["storage"]["temp"] == "F:" + B
|
||||||
|
assert betrieb.ablage_vorgabe(werte, False, "windows") == "F:" + B + "Filme"
|
||||||
|
assert betrieb.arbeits_vorgabe(werte, False, "windows") == "F:" + B
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_einstellung_bleibt_die_vorgabe():
|
||||||
|
"""Leere Felder duerfen NICHTS ueberschreiben — sonst waere ein
|
||||||
|
ungesetztes Feld schlimmer als gar keine Bruecke."""
|
||||||
|
vorher = {"storage": {"medien": "/app/media", "temp": "/app/temp"}}
|
||||||
|
werte = betrieb.mit_einstellungen(vorher, {"outputDir": "", "workDir": " "})
|
||||||
|
assert werte["storage"]["medien"] == "/app/media"
|
||||||
|
assert werte["storage"]["temp"] == "/app/temp"
|
||||||
|
# ... und das Original bleibt unangetastet (pure Funktion)
|
||||||
|
assert vorher["storage"]["medien"] == "/app/media"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bruecke_vertraegt_leere_eingaben():
|
||||||
|
assert betrieb.mit_einstellungen({}, {})["storage"] == {}
|
||||||
|
assert betrieb.mit_einstellungen(None, None)["storage"] == {}
|
||||||
|
|||||||
@@ -52,3 +52,36 @@ def test_naechster_vorhandener_auf_posix():
|
|||||||
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
def test_wenn_gar_nichts_existiert_wird_nichts_behauptet():
|
||||||
assert pfade.naechster_vorhandener("Z:" + B + "nix",
|
assert pfade.naechster_vorhandener("Z:" + B + "nix",
|
||||||
existiert=lambda p: False) == ""
|
existiert=lambda p: False) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_laufwerks_wurzel_bleibt_absolut():
|
||||||
|
"""Befund 30.08.2026: Der Schluss-Trenner wurde immer abgestreift.
|
||||||
|
|
||||||
|
Aus "F:\" wurde "F:" — unter Windows der AKTUELLE Ordner auf
|
||||||
|
Laufwerk F, nicht dessen Wurzel. Wer das Ergebnis weiterverbindet,
|
||||||
|
landet woanders; genau so verschwanden in `rohdaten.kandidaten` 16,5 GB.
|
||||||
|
"""
|
||||||
|
assert pfade.naechster_vorhandener(
|
||||||
|
"F:" + B, existiert=lambda p: True) == "F:" + B
|
||||||
|
# UNC-Wurzel genauso
|
||||||
|
unc = B + B + "server" + B + "freigabe" + B
|
||||||
|
assert pfade.naechster_vorhandener(unc, existiert=lambda p: True) == unc
|
||||||
|
# Und POSIX bleibt POSIX
|
||||||
|
assert pfade.naechster_vorhandener("/", existiert=lambda p: True) == "/"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unterordner_verliert_seinen_schluss_trenner_weiterhin():
|
||||||
|
"""Das Abstreifen war ja richtig — nur nicht bis auf den Laufwerksnamen."""
|
||||||
|
da = "F:" + B + "Roh"
|
||||||
|
assert pfade.naechster_vorhandener(da + B, existiert=lambda p: p == da) == da
|
||||||
|
|
||||||
|
|
||||||
|
def test_fehlendes_laufwerk_terminiert():
|
||||||
|
"""Die Endlosschleife, an der `ablauf._frei_bytes` haengen blieb.
|
||||||
|
|
||||||
|
`ntpath.dirname("Q:\\")` gibt sich selbst zurueck (30.08.2026 an
|
||||||
|
einem freien Laufwerksbuchstaben gemessen). Ohne den Abbruch bei
|
||||||
|
`eltern == pfad` dreht die Suche fuer immer — vor dem Rip, ohne Meldung.
|
||||||
|
"""
|
||||||
|
assert pfade.naechster_vorhandener("Q:" + B + "Rippy" + B + "_arbeit",
|
||||||
|
existiert=lambda p: False) == ""
|
||||||
|
|||||||
@@ -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"
|
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) ────────────────────────────
|
# ── Installation (echt, in einem Testordner) ────────────────────────────
|
||||||
@nur_windows
|
@nur_windows
|
||||||
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
|
def test_installation_legt_die_dateien_an_und_meldet_sich_bei_windows(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
|
from rippy import pfade
|
||||||
from rippy.platform import verknuepfungen
|
from rippy.platform import verknuepfungen
|
||||||
from rippy.platform import win_registry as reg
|
from rippy.platform import win_registry as reg
|
||||||
|
|
||||||
@@ -501,19 +502,52 @@ for /l %%n in (1,1,15) do (
|
|||||||
)
|
)
|
||||||
goto ende
|
goto ende
|
||||||
:weg
|
:weg
|
||||||
rem /s /q, nicht nur rmdir: Ein blosses rmdir scheitert an JEDER
|
{ordner_zeile}
|
||||||
rem verbliebenen Datei. Am 28.08.2026 blieben 148 Dateien im
|
|
||||||
rem WebView2-Zwischenspeicher liegen, weil deren Prozesse Rippy ueberlebt
|
|
||||||
rem hatten -- der Ordner blieb dann samt Inhalt stehen.
|
|
||||||
rmdir /s /q "{ordner}" >nul 2>&1
|
|
||||||
:ende
|
:ende
|
||||||
del /q "%~f0" >nul 2>&1
|
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:
|
def aufraeum_skript(exe: str, ordner: str) -> str:
|
||||||
"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar)."""
|
r"""Der Inhalt des Aufraeum-Skripts (reine Funktion, damit pruefbar).
|
||||||
return AUFRAEUM_SKRIPT.format(exe=exe, ordner=ordner.rstrip("\\/"))
|
|
||||||
|
## 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:
|
def selbst_loeschen(exe: str, ordner: str) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user