From cb811bec283853d6f85e6478d230d7f6ad12f211 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 15:38:39 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20KONZEPT-WINDOWS=20=E2=80=94=20Rippy=20v?= =?UTF-8?q?5=20als=20eigenstaendiges=20Electron-Programm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die heutige Windows-Fassung ist der Docker-Code in einer EXE: PyInstaller buendelt docker/api und docker/worker, uvicorn laeuft auf 127.0.0.1:7788, pywebview stellt ein Fenster davor. Elf Release-Kandidaten in drei Tagen, und die Funde sind fast alle derselbe Typ — Linux-Annahmen, die unter Windows etwas anderes bedeuten (timeout ls -d, shutil.which, /app, /dev/{name}, F: ohne Trenner). Das Konzept beschreibt den Neubau als Windows-Programm: Electron 44, TypeScript durchgehend, Einzelplatz, kein Python, kein HTTP-Server. Vier Entscheide des Commanders (30.08.2026) sind eingearbeitet: alles TypeScript/Node · Docker bleibt unangetastet · reiner Einzelplatz · eigener Branch. Vor der ersten Zeile Konzept gemessen (Regel D), Belege in beweise/: * Win32 aus Node an G: (HL-DT-ST BD-RE BU40N) — Laufwerkssuche, CreateFileW, QUERY_PROPERTY (Modell + Serial), CHECK_VERIFY2, GET_LENGTH_INFO (33,76 GB -> Blu-ray). IOCTL_CDROM_DISK_TYPE antwortet mit Fehler 50 — derselbe Befund wie im Python-Treiber. * Prozess-Leine (Job Object): Kind stirbt mit dem per taskkill /F ohne /T abgeschossenen Elternprozess. Die Messung aus rc10, in Node nachgestellt. * node:sqlite ist in Node 24 eingebaut — in Electron noch ungeprueft, ausdruecklich als offener Punkt markiert. Akutestes Risiko im Dokument: MakeMKVs freier Beta-Key laeuft Ende September 2026 ab, die Kaufseite fuer die Dauerlizenz ist seit Mai defekt. Co-Authored-By: Claude Opus 5 --- KONZEPT-WINDOWS.md | 771 ++++++++++++++++++++++++++++++++++++++++++++ beweise/README.md | 113 +++++++ beweise/laufwerk.js | 109 +++++++ beweise/leine.js | 50 +++ 4 files changed, 1043 insertions(+) create mode 100644 KONZEPT-WINDOWS.md create mode 100644 beweise/README.md create mode 100644 beweise/laufwerk.js create mode 100644 beweise/leine.js diff --git a/KONZEPT-WINDOWS.md b/KONZEPT-WINDOWS.md new file mode 100644 index 0000000..0fd06a0 --- /dev/null +++ b/KONZEPT-WINDOWS.md @@ -0,0 +1,771 @@ +# KONZEPT — Rippy für Windows + +> **Rippy v5.** Ein eigenständiges Windows-Programm, gebaut mit Electron und +> TypeScript. Grüne Wiese: kein Docker-Code, kein Python, keine Container-Reste. +> +> Stand: 30.08.2026 · Branch `worktree-windows-electron` + +--- + +## 0. Leseanleitung + +| § | Frage | +|---|-------| +| 1 | Warum wird neu gebaut statt repariert? | +| 2 | Was hat der Commander entschieden? | +| 3 | Was wurde gemessen, bevor entschieden wurde? | +| 4–5 | Wie ist das Programm aufgebaut, und was passiert, wenn eine Disc reingeht? | +| 6–9 | Features, Oberfläche, Setup, Werkzeuge | +| 10 | Was kommt aus dem alten Rippy mit? | +| 11–13 | Etappen, Risiken, was NICHT gebaut wird | + +**Was dieses Dokument NICHT tut:** Es erfindet keine Messungen. Alles, was hier +als Zahl oder Verhalten steht, ist entweder in § 3 an dieser Maschine gemessen, +im alten Rippy belegt (`SAVEPOINT.md`, `AGENTS.md`) oder ausdrücklich als +**Annahme** markiert. Die Trennung ist wichtig, weil dieses Projekt schon +mehrfach teuer dafür bezahlt hat, dass jemand eine plausible Erklärung für eine +gemessene gehalten hat. + +--- + +## 1. Warum neu gebaut wird + +Die heutige Windows-Fassung ist **kein Windows-Programm**. Sie ist der +Docker-Code in einer EXE: + +``` +RippySetup.exe (PyInstaller) + ├─ docker/api/ → FastAPI, uvicorn auf 127.0.0.1:7788 + ├─ docker/worker/ → der Celery-Worker, nur ohne Celery + ├─ docker/ui/dist/ → dieselbe React-Oberfläche wie im Browser + └─ pywebview → ein Fenster davor +``` + +Das Ergebnis steht im `SAVEPOINT.md`: **elf Release-Kandidaten in drei Tagen.** +Und die Funde sind fast alle vom selben Typ — Linux-Annahmen, die unter Windows +etwas anderes bedeuten: + +| Fund | Was passierte | +|------|---------------| +| `timeout 4 ls -d ` als Verzeichnisprüfung | Unter Windows gibt es `timeout.exe`, aber sie kennt weder `ls` noch `-d`. Antwort war **„weg" für jedes Verzeichnis** — Rohdaten waren grundsätzlich unsichtbar. Dazu blitzten Konsolenfenster auf. | +| `shutil.which("makemkvcon")` | Findet unter Windows nie etwas — Programme liegen in `Program Files`, nicht im PATH. Die 4K-Schlüssel-Automatik lief deshalb **nie** an. | +| `os.path.isdir("/app")` | Rippy hielt sich für einen fremden Docker-Worker. Zweimal gefunden, an zwei Stellen. | +| Routen `/dev/{name}` | Das UI ruft mit `G` auf, gebaut war `/dev/G`. **Auswerfen und Disc-Scan antworteten immer mit 404.** | +| `F:\` wurde zu `F:` | Unter Windows ist `F:` ohne Trenner der *aktuelle Ordner* auf F, nicht dessen Wurzel. 16,5 GB Rohschnitt unsichtbar; der Dialog bot nur „Neu rippen" an. | +| `makemkvcon` überlebte den Elternprozess | Windows räumt Kindprozesse nicht auf. Zwei Waisen hielten das Laufwerk fest. | +| Zwei Speicher für dieselben Ordner | Die Oberfläche schrieb `outputDir` in die Datenbank, der Betrieb las `storage.medien` aus einer Konfigurationsdatei, die niemand schrieb. Eingestellt `E:\Rippy`, angezeigt `C:\Users\...\Videos\Rippy`. | + +Diese Liste ist nicht das Problem — sie ist das **Symptom**. Jeder einzelne +Fund war eine Stelle, an der Code, der für einen Linux-Container geschrieben +wurde, unter Windows etwas anderes bedeutet. Solange die Grundlage ein +umgebogener Container ist, ist der nächste Fund dieser Sorte nur eine Frage der +Zeit. + +**Die Entscheidung ist deshalb nicht „das ist schlecht gebaut", sondern: das +ist am falschen Ort gebaut.** Ein Windows-Programm fängt bei Windows an, nicht +bei einem Container, dem man Windows beibringt. + +--- + +## 2. Die Entscheidungen (Commander, 30.08.2026) + +Vier Fragen wurden gestellt, vier sind beantwortet. Sie stehen hier, damit +später nachvollziehbar ist, was Entscheidung war und was Vorschlag. + +### Entscheid 1 — Alles TypeScript/Node + +Es gibt **keinen Python-Anteil** in Rippy für Windows. Kein Sidecar, keine +mitgelieferte Laufzeit, keine IPC-Brücke zwischen zwei Sprachen. Eine Sprache, +eine Werkzeugkette, ein Setup. + +*Was das kostet:* Die rund 18.000 Zeilen Python werden nicht übernommen. Was +übernommen wird, ist ihr **Wissen** — die Parser-Regeln, die gemessenen +Fallen, die Kommandozeilen (§ 10). Das ist der teure Teil an ihnen, und der +wandert vollständig mit. + +*Was das bringt:* Der ganze Fehlertyp aus § 1 kann nicht mehr entstehen. Es +gibt keinen Container-Pfad, den jemand vergessen könnte, weil es nie einen gab. + +### Entscheid 2 — Die Docker-Version bleibt unangetastet + +Rippy auf der VM läuft weiter wie heute. Es wird nicht angefasst, nicht +migriert, nicht mitgepflegt. Zwei getrennte Produkte, keine geteilten Module. + +*Begründung:* Geteilte Module zwischen zwei Betriebssystemen sind genau die +Konstruktion, an der v1 gelitten hat — drei Dateien lagen byte-identisch +doppelt im Repo, und `db.py` schrieb es sogar in den Kopfkommentar: *„Wer die +Struktur ändert, ändert BEIDE Dateien."* + +### Entscheid 3 — Reiner Einzelplatz + +Ein Rechner macht alles: erkennen, rippen, komprimieren, ablegen. Kein Server, +keine Netz-Queue, keine Worker-Verwaltung, keine Pfad-Übersetzung zwischen +Maschinen. + +*Was damit ersatzlos wegfällt:* Celery, Redis, Postgres, die Worker-Tabelle, +`worker_direct`, die Lease-Mechanik über Netz, `RIPPY_PATH_MAP`, die +Zombie-Erkennung, die Mount-Wache, das Rate-Limit (es gibt keinen fremden +Client mehr), `/worker-setup/*`, die Encoder-Auswahl je Knoten. + +Grob geschätzt sind das **40 % der heutigen Komplexität** — und zwar der Teil, +der die meisten Fehlerbilder erzeugt hat. + +*Was bleibt:* Die Ablage darf trotzdem auf dem NAS liegen. Ein UNC-Pfad +(`\\nas\medien`) oder ein verbundenes Netzlaufwerk ist für Rippy nur ein Pfad. +Windows mountet, Rippy prüft — dieselbe Trennung wie in Entscheid 3 des +v2-Konzepts, nur ohne Container drumherum. + +### Entscheid 4 — Eigener Branch im selben Gitea-Repo + +Ein Git-Worktree unter `.claude/worktrees/windows-electron` auf dem Branch +`worktree-windows-electron`. `main` bleibt unberührt und jederzeit deploybar. + +--- + +## 3. Was gemessen wurde, bevor entschieden wurde + +Electron bedeutet: Die Arbeit macht Node, nicht Python. Damit hängt das ganze +Konzept an einer Frage, die vorher niemand geprüft hat: + +> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?** + +Wäre die Antwort nein, wäre das Konzept wertlos. Also gemessen — am +30.08.2026, auf diesem Rechner, am echten Laufwerk. + +### 3.1 Die Werkzeuglage + +``` +Node v24.19.0 +npm 11.17.0 +Electron 44.0.0 (Chromium 152.0.7977.54 · Node 24.18.1) +WebView2 151.0.4129.107 +Laufwerk G: HL-DT-ST BD-RE BU40N USB Device (Medium eingelegt) +``` + +### 3.2 Win32 aus Node — die Laufwerks-Steuerung + +`koffi` 3.1.6 installiert, dieselben Steuercodes wie in +`src/rippy/drives/win_ioctl.py` (die dort aus dem `CTL_CODE`-Makro hergeleitet +sind, nicht abgeschrieben). Nur lesende Aufrufe, nichts ausgeworfen: + +``` +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, sondern eine Bestätigung.** Fehler 50 ist +`ERROR_NOT_SUPPORTED` — genau das, was auch der Python-Treiber an diesem +Laufwerk sieht (`SAVEPOINT.md`, rc11). Node verhält sich identisch. Und die +Lehre daraus steht schon im alten Code: **Die Disc-Einordnung darf sich nicht +auf `IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.** + +### 3.3 Die Prozess-Leine — überlebt makemkvcon einen Absturz? + +Der Befund aus rc10: Windows räumt Kindprozesse nicht auf. Ein hart beendeter +Rippy hinterlässt ein `makemkvcon`, das das Laufwerk festhält. Die Lösung dort +war eine Windows-Arbeitsgruppe (Job Object) mit +`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. + +In Node nachgestellt — Job Object gesetzt, Kind gestartet, Eltern mit +`taskkill /F` **ohne** `/T` abgeschossen (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 +``` + +### 3.4 Datenbank ohne native Abhängigkeit + +``` +node:sqlite eingebaut -> geht +``` + +Node 24 bringt SQLite mit (`node:sqlite`, `DatabaseSync`). Keine +`better-sqlite3`-Kompilierung, kein `node-gyp`, kein Visual-Studio-Build in der +Bau-Kette. + +> ⚠️ **Ungeprüft:** Electron kompiliert sein Node mit eigenen Flags. Ob +> `node:sqlite` in Electron 44 vorhanden ist, ist an *dieser* Stelle **nicht** +> gemessen — nur in Node 24.19.0 selbst. Das ist der erste Handgriff in +> Etappe W-0. Rückfallebene: `better-sqlite3` (bewährt, aber nativ und damit +> eine Kompilierung in der Bau-Kette). + +### 3.5 Ein Nebenbefund für die Bau-Kette + +`npm` 11 blockiert Install-Skripte fremder Pakete standardmäßig: + +``` +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. Aber die +Warnung gehört in die Bau-Anleitung, sonst sucht jemand eines Tages an der +falschen Stelle. + +--- + +## 4. Aufbau + +### 4.1 Drei Teile, drei Prozesse + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Rippy.exe — Electron Main-Prozess │ +│ ├─ Tray-Symbol, Fensterverwaltung, Autostart, Selbst-Update │ +│ └─ hält die Prozess-Leine (§ 3.3) — ALLES stirbt mit ihm │ +└───────────┬─────────────────────────────┬────────────────────┘ + │ IPC │ MessagePort +┌───────────▼──────────────┐ ┌──────────▼───────────────────────┐ +│ Fenster (Renderer) │ │ Kern (utilityProcess) │ +│ React + Tailwind │◄──┤ die GANZE Arbeit │ +│ zeigt an, sonst nichts │ │ ├─ Laufwerkswache (koffi/Win32) │ +│ │ │ ├─ Ablauf-Steuerung │ +│ kein Node-Zugriff │ │ ├─ Auftragstabelle (SQLite) │ +│ (contextIsolation) │ │ └─ Werkzeuge als Kindprozesse: │ +│ │ │ makemkvcon · HandBrakeCLI │ +└──────────────────────────┘ └──────────────────────────────────┘ +``` + +**Warum drei und nicht einer:** + +1. **Ein Rip dauert 30–90 Minuten.** Läuft er im Main-Prozess, friert die + Oberfläche ein — Electrons eigene Dokumentation sagt das ausdrücklich. +2. **Der Kern darf abstürzen, ohne das Fenster mitzureißen.** Dann steht dort + „Der Kern ist abgestürzt, ich starte ihn neu" statt eines toten Fensters. +3. **Das Fenster darf zugehen, ohne den Rip zu beenden.** Rippy lebt dann im + Tray weiter — genau wie heute, nur ohne den Umweg über einen HTTP-Server. + +**Warum `utilityProcess` und nicht `child_process.fork`:** Electrons eigene +Empfehlung. `utilityProcess` kann einen `MessagePort` direkt zum Renderer +aufbauen — der Fortschritt eines Rips geht damit ohne Umweg über den +Main-Prozess ins Fenster. + +**Was es NICHT gibt:** keinen HTTP-Server, keinen Port, kein `localhost:7788`, +keine REST-API, kein SSE. Fenster und Kern reden über Electrons IPC. Ein +lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene Tür ohne +Gegenwert — und er war der Grund für das Rate-Limit, den Proxy-Kummer und die +Polling-Last im alten Rippy. + +### 4.2 Ordner + +``` +rippy-windows/ +├── package.json +├── electron.vite.config.ts +├── src/ +│ ├── haupt/ Electron Main +│ │ ├── index.ts Start, Einzelinstanz-Sperre, Leine setzen +│ │ ├── fenster.ts Fenster anlegen, Zustand merken +│ │ ├── tray.ts Symbol + Kontextmenü +│ │ ├── autostart.ts HKCU\...\Run +│ │ └── update.ts electron-updater +│ │ +│ ├── kern/ utilityProcess — die Arbeit +│ │ ├── laufwerk/ +│ │ │ ├── win32.ts DER EINZIGE ORT MIT koffi +│ │ │ ├── wache.ts Einwurf/Auswurf erkennen +│ │ │ └── disc.ts Typ, Label, Größe, TOC +│ │ ├── metadaten/ +│ │ │ ├── tmdb.ts tvdb.ts omdb.ts musicbrainz.ts +│ │ │ ├── discmerkmale.ts Label + Laufzeiten + BDMV-Titel +│ │ │ └── zuordnung.ts Treffer + Sicherheitsgrad +│ │ ├── rip/ +│ │ │ ├── makemkv.ts Kommandobau + Aufruf +│ │ │ ├── parser.ts TINFO/SINFO/PRGV/MSG (rein, testbar) +│ │ │ ├── audiocd.ts Roh-Sektoren + FLAC +│ │ │ └── iso.ts Datendiscs 1:1 sichern +│ │ ├── komprimieren/ +│ │ │ ├── handbrake.ts Kommandobau + Aufruf + Fortschritt +│ │ │ ├── encoder.ts GEMESSEN, nicht behauptet +│ │ │ └── presets.ts +│ │ ├── ablage/ +│ │ │ ├── struktur.ts Jellyfin/Emby/Kodi/Plex +│ │ │ ├── nfo.ts poster.ts medienserver.ts +│ │ ├── ablauf/ +│ │ │ ├── pipeline.ts die Verkettung — NUR HIER +│ │ │ ├── zustand.ts Zustandsmaschine +│ │ │ └── auftraege.ts Warteschlange, Slots +│ │ ├── werkzeuge/ +│ │ │ ├── katalog.ts wo liegt makemkvcon/HandBrakeCLI/flac +│ │ │ ├── beschaffen.ts holen, prüfen, einrichten +│ │ │ └── leine.ts Job Object (§ 3.3) +│ │ └── speicher/ +│ │ ├── db.ts SQLite +│ │ ├── einstellungen.ts EINE Quelle (§ 6.7) +│ │ └── bibliothek.ts +│ │ +│ ├── gemeinsam/ Typen + Ereignis-Schema — Fenster UND Kern +│ └── fenster/ React +│ +├── bau/ electron-builder, NSIS-Skript, Icon +└── test/ Vitest +``` + +### 4.3 Sechs Regeln, die den Aufbau tragen + +Jede ist aus einem bezahlten Fehler abgeleitet. Sie sind mechanisch prüfbar +und bekommen je einen Wächter-Test. + +**R1 — Win32 lebt nur in `laufwerk/win32.ts`.** +Kein `koffi`-Import irgendwo sonst. Ein Wächter-Test greift bei jedem anderen +Fundort. *(Grund: In v1 rief `main.py` `eject()` direkt auf — deshalb brauchte +der API-Container Geräte-Zugriff und `SYS_ADMIN`.)* + +**R2 — Ein Fehlschlag löscht nie einen Zustand.** +Kein `catch { return [] }`. Wer nichts Neues weiß, behält, was er wusste. +*(Grund: Fünfmal `catch(() => [])` im alten UI — jeder verpasste Abruf hieß +„es gibt keine Jobs", die Liste leerte sich im Sekundentakt. Der Commander +meldete es als „wird oft neu geladen".)* + +**R3 — Auswerfen heißt: entriegeln, auswerfen, nachsehen.** +In dieser Reihenfolge. Kein Rückgabewert gilt als Beweis. *(Grund: `CDROMEJECT` +quittiert auf einem verriegelten Laufwerk Erfolg und tut nichts. MakeMKV +verriegelt die Tür während des Rips.)* + +**R4 — Jede Hintergrundschleife meldet ihren Fehler.** +Kein stilles `catch {}`. *(Grund: Ein `except Exception: pass` in einer +Vorrats-Schleife hat einmal eine Stunde gekostet.)* + +**R5 — Externe Schnittstellen werden gemessen, nicht erinnert.** +Jeder Kommandozeilen-Schalter, jeder IOCTL-Code trägt im Kommentar, wo er +herkommt. *(Grund: `--audio-codec` gibt es bei HandBrakeCLI nicht — es heißt +`-E`/`--aencoder`. Ein unbekannter Schalter ist für HandBrake kein Fehler: +Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip, und ein Test hatte den +Fehler festgeschrieben statt ihn zu finden.)* + +**R6 — Alles läuft an der Leine.** +Jeder Werkzeugaufruf hängt in der Arbeitsgruppe aus § 3.3. Ausgenommen ist nur +das Aufräum-Skript der Deinstallation — es muss Rippy überleben. + +--- + +## 5. Der Ablauf — was passiert, wenn eine Disc reingeht + +Der rote Faden des Commanders: **so viel Arbeit abnehmen wie möglich.** + +``` + 1. Disc rein + │ WM_DEVICECHANGE / DBT_DEVICEARRIVAL (Ereignis, kein Poll) + │ Rückfallebene: 3-Sekunden-Takt, falls die Nachricht ausbleibt + ▼ + 2. Was ist das? + │ Größe (33,76 GB → Blu-ray) · Audio-TOC? · Dateisystem lesbar? + │ → Film / Serie / Musik / Daten + ▼ + 3. Was ist da drauf? ← DER SCHRITT, DER HEUTE ZU DÜNN IST + │ Disc-Label · Titel-Laufzeiten · BDMV-Titel · DVD-IFO · CD-DiscID + │ → TMDb / TVDb / OMDb / MusicBrainz + │ → Treffer mit SICHERHEITSGRAD + ▼ + 4. Sicher genug? + │ ja → sofort losrippen, nur Bescheid sagen (Zero-Click) + │ nein → fragen: Fenster hoch, Vorschläge zur Wahl + ▼ + 5. Rippen makemkvcon, verlustfrei, Fortschritt aus PRGV + ▼ + 6. Komprimieren HandBrakeCLI, Encoder GEMESSEN, Preset je Disc-Typ + ▼ + 7. Ablegen Zielstruktur · NFO · Poster/Fanart + ▼ + 8. Melden Medienserver anstoßen · Benachrichtigung · Auswerfen + ▼ + 9. Merken Bibliothek fortschreiben — beim nächsten Mal erkannt +``` + +**Was an Schritt 3 neu ist.** Heute nimmt Rippy im Wesentlichen das Disc-Label +und die längste Laufzeit. Das reicht bei „PIRATES_OF_THE_CARIBBEAN", aber nicht +bei „LOGICAL_VOLUME_ID" — und dann fragt Rippy, obwohl die Antwort auf der +Disc steht. Vier Quellen kommen dazu: + +| Quelle | Wo | Was sie liefert | +|--------|-----|-----------------| +| **BDMV-Metadaten** | `BDMV/META/DL/bdmt_*.xml` auf der Blu-ray | Der vom Studio hinterlegte Disc-Titel, oft mit Sprachvarianten. **Deutlich besser als das Volume-Label.** | +| **Titel-Struktur** | makemkvcon `info` | Die Anzahl und Länge der Titel ist ein Fingerabdruck: 1 langer + viele kurze = Film; 6–13 gleich lange = Serienstaffel. | +| **DVD-Struktur** | `VIDEO_TS`, IFO-Dateien | Kapitelzahl und Titelsatz-Aufbau | +| **CD-DiscID** | Audio-TOC | Bereits gebaut und bewiesen (rc8): Spurlage → MusicBrainz → Album, Interpret, Jahr, jeder Titel. | + +Dazu ein **Sicherheitsgrad**, der ehrlich ist. Rippy sagt nicht „das ist +Akira", sondern „das ist sehr wahrscheinlich Akira (1988)" — und fragt nur, +wenn es das nicht sagen kann. Die Schwelle ist einstellbar; wer will, lässt +Rippy immer fragen, und wer will, lässt es immer durchlaufen. + +**Und wenn Rippy sich irrt:** Der Titel lässt sich nachträglich ändern, und die +Ablage wandert mit. Das ist der Unterschied zwischen „falsch geraten" und +„falsch abgelegt". + +--- + +## 6. Die Features + +### 6.1 Automatische Erkennung (§ 5, Schritt 2–4) + +Siehe oben. Kernsatz: **Rippy fragt nur, wenn es wirklich nicht weiß.** + +### 6.2 Rippen + +`makemkvcon` im Robot-Modus (`--robot --messages=-stdout --progress=-same`), +verlustfrei. Der Parser (TINFO/SINFO/PRGV/MSG) wandert aus dem Python-Code +nach TypeScript — er ist reine Textverarbeitung und damit vollständig +testbar, ohne Laufwerk. + +Was aus dem alten Rippy zwingend mitkommt: + +- **Lesefehler heißen Lesefehler.** MakeMKV kann 1 von 2 Titeln sichern und + sich trotzdem mit 0 beenden. Rippy warnt nach dem Rip — auch und gerade dann, + wenn er als Erfolg endete — und nennt die **MSG-Nummer**: MakeMKVs Texte sind + übersetzt, die Nummern nicht. +- **Erst nachsehen, dann rippen.** Liegt keine Disc drin, sagt Rippy das in + einem Satz, statt zwei Minuten in makemkvcon zu laufen und mit Code 11 zu + enden. Aber: Wenn das Nachsehen selbst scheitert, wird trotzdem gerippt — + „ich weiß es nicht" darf nie zu „es geht nicht" werden. +- **Die Ausgabe binär lesen.** makemkvcon mischt Kodierungen; ein `ü` im Pfad + hat die Kompression einmal getötet (`UnicodeDecodeError` auf Byte 0x81). + +### 6.3 Audio-CDs + +Der Weg aus rc8, bewiesen: Roh-Sektoren über Win32 lesen (eine Audio-CD hat +kein Dateisystem — die `Track01.cda` sind 44-Byte-Platzhalter), dann durch +`flac.exe`. Dazu MusicBrainz über die DiscID. + +Zwei Fallen, beide gemessen und beide dokumentiert: Die DiscID rechnet **mit** +den 150 Frames Vorlauf, das Lesen **ohne**. Und die Leseadresse zählt in +2048er-Einheiten, obwohl ein Audio-Sektor 2352 Bytes hat. + +### 6.4 ISO-Backup für Datendiscs *(neu)* + +Die einzige echte Lücke gegenüber ARM. Wird eine Disc weder als Film noch als +Musik erkannt, wird sie 1:1 gesichert statt abgelehnt: sequenziell aus +`\\.\G:` lesen, Länge aus `IOCTL_DISK_GET_LENGTH_INFO` (in § 3.2 gemessen: +liefert exakte Bytes). Kein Fremdwerkzeug nötig. + +### 6.5 Mehrere Laufwerke gleichzeitig *(neu)* + +Laufwerke sind eigenständige Objekte mit stabiler Kennung (Seriennummer, nicht +Buchstabe — `0025114C0149` in § 3.2 gemessen). Ein Rip-Auftrag belegt genau +ein Laufwerk; die Zahl gleichzeitiger Rips und Kompressionen ist einstellbar. + +> ⚠️ **Annahme, zu messen bevor sie festgeschrieben wird:** Mehrere +> `makemkvcon`-Prozesse teilen sich ein Datenverzeichnis +> (`_private_data.tar`, `settings.conf`). Der Entwurf sieht deshalb vor: +> **Rip-Phase parallel, Schlüssel-Phase serialisiert.** Das ist aus der +> Aktenlage abgeleitet, nicht gemessen — und gehört an zwei Laufwerken geprüft, +> bevor die Grenze im Code steht. + +### 6.6 Bibliothek *(neu)* + +Eine Ansicht über alles Gerippte: Cover, Größe, Datum, Ablageort, Dauer des +Vorgangs. Und der praktische Teil: **Rippy erkennt beim Einlegen, wenn eine +Disc schon einmal durchgelaufen ist** (über die Disc-Merkmale aus § 5) und +fragt nach, statt stumm zu doppeln. + +### 6.7 Arbeitsverzeichnis und Ablage — eine Quelle + +Der Fund aus rc11: Die Oberfläche schrieb in die Datenbank, der Betrieb las +aus einer Konfigurationsdatei, die niemand schrieb. Eingestellt `E:\Rippy`, +angezeigt `C:\Users\...\Videos\Rippy`. + +In v5 gibt es **genau einen Speicher für Einstellungen** (`speicher/einstellungen.ts`, +SQLite). Ein Wächter-Test prüft mechanisch, dass kein zweiter Ort entsteht. + +Dazu die Pfad-Regeln aus rc11, als reine Funktionen mit Tests: +- Eine Laufwerkswurzel behält ihren Trenner (`F:\`, nicht `F:`) +- UNC-Wurzeln bleiben absolut +- Verzeichnisprüfungen sind **windows-nativ** — kein `timeout ls -d` +- Pro Rip darf ein anderes Arbeitsverzeichnis gewählt werden, und **gesucht + wird später unter der Wahl des Jobs**, nicht unter der heutigen Einstellung + +### 6.8 Selbst-Update *(neu)* + +`electron-updater` gegen die Releases im Gitea. Rippy prüft beim Start und +danach täglich, lädt im Hintergrund und installiert beim nächsten Start — +**nie mitten in einem Rip**. + +Die Quelle steht in der Konfiguration und ist vorbelegt mit dem Gitea des +Commanders. Ein nicht erreichbarer Update-Server ist kein Fehler, sondern eine +Zeile im Protokoll. + +### 6.9 Erst-Einrichtung *(neu gedacht)* + +Der Befund des Commanders zur heutigen Fassung: *„Dann hätte ich beim Setup +auch erwartet, dass ein echtes Setup passiert — wo will ich das hinspeichern, +ein Pre-Requirement-Check usw. Da kommt gar nichts, Rippy geht einfach auf."* + +Der Assistent läuft **nach** der Installation, im Programm selbst, in fünf +Schritten: + +| Schritt | Was passiert | +|---------|--------------| +| 1 Willkommen | Was Rippy tut, in vier Sätzen | +| 2 Prüfung | Laufwerke · Werkzeuge · Plattenplatz · Internet · WebView2 — **jeder Befund sagt, was zu tun ist** | +| 3 Werkzeuge | Was fehlt, wird geholt (§ 9) — mit Fortschritt, nicht mit einem eingefrorenen Fenster | +| 4 Ordner | Ablage und Arbeitsverzeichnis, mit echten Windows-Ordnerdialogen und Platzanzeige | +| 5 Automatik | Wie viel soll Rippy allein entscheiden? Je Disc-Typ einstellbar. | + +Zwei Regeln, die aus dem alten Setup übernommen werden: + +- **Nur ein Fehler blockiert.** Eine Warnung, die den Knopf sperrt, ist + Bevormundung; ein Fehler, der nur warnt, ist eine Falle. Kein optisches + Laufwerk ist eine **Warnung** — eine reine Komprimier-Maschine ist ein + vorgesehener Betriebsfall. +- **Ein Setup meldet nie Erfolg, während ein Pflichtwerkzeug fehlt.** Der + Bestand wird VOR und NACH dem Versuch gelesen; berichtet wird, was danach + wirklich da ist. + +### 6.10 Was unverändert erhalten bleibt + +Alles, was der Commander heute benutzt: + +Kompression je Disc-Typ abwählbar (4K verlustfrei durchreichen) · Preset-Wahl +je Typ · Sprachwahl für Ton und Untertitel vor dem Rip · Original behalten · +Vollautomatik · Nur-Hauptfeature · automatischer Auswurf · Medienserver-Refresh +(Jellyfin/Emby/Plex/Kodi) · NFO + Poster + Fanart · Serien-Staffelablage mit +Episoden-Zuordnung per Laufzeitabgleich (**nur bei eindeutiger Zuordnung wird +umbenannt**) · Webhook-Benachrichtigungen (Discord/Slack/ntfy/generisch) · +Live-Log · Job-Detailansicht · Wiederholen ab Rip oder ab Kompression · +4K-Schlüsselkette in drei Stufen · Restzeit-Schätzung. + +--- + +## 7. Die Oberfläche + +**Entscheid:** Neu bauen, Look behalten. + +Wiedererkennbar bleibt die Design-Sprache — dunkel/hell, die Kachel-Anordnung, +das Kino-Banner, die Farbwelt. Neu ist, dass die Oberfläche eine +**Einzelplatz-Anwendung** bedient statt eines Docker-Clusters. + +**Was verschwindet:** Worker-Verwaltung · Container-Platte · „Prüfen: +`docker compose ps`" · Speicherziele-Einhängen · Pfad-Map · Worker-Setup-Paket · +Encoder-Auswahl je Knoten · alles, was einen zweiten Rechner voraussetzt. + +**Was dazukommt:** + +- Echte **Windows-Ordnerdialoge** statt eines nachgebauten Dateibrowsers im + Web. Das ist ein spürbarer Unterschied im Alltag: Der Nutzer sieht seine + Netzlaufwerke, seine Schnellzugriffe, seine gewohnte Oberfläche. +- **Tray-Symbol mit Zustand** — auf einen Blick: läuft ein Rip, wie weit, + wie lange noch. Rechtsklick: Öffnen, Pause, Beenden. +- **Windows-Benachrichtigungen** (echte Toasts, nicht Browser-Popups), mit + Klick direkt in den Job. +- **Fortschritt in der Taskleiste** — Windows kann einen Fortschrittsbalken + über dem Symbol zeigen. Bei einem 90-Minuten-Rip ist das genau die + Information, die man will, ohne das Fenster zu öffnen. +- **Die Bibliothek** als eigene Ansicht (§ 6.6). + +**Was die Oberfläche NIE tut:** Eine Liste auf leer setzen, weil eine Nachricht +ausblieb (R2). Bei einem Aussetzer bleibt der letzte Stand stehen, und ein +Banner sagt, dass gerade nichts Neues kommt. + +--- + +## 8. Setup, Autostart, Deinstallation + +**Entscheid: pro Benutzer, ohne Administratorrechte, Windows 10 (ab 1809) und 11, x64.** + +| | | +|---|---| +| Installer | `electron-builder` → NSIS, ein `RippySetup.exe` | +| Ziel | `%LOCALAPPDATA%\Programs\Rippy` — kein UAC-Dialog | +| Autostart | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` | +| Update | `electron-updater`, NSIS-Ziel (§ 6.8) | +| Deinstallation | Über die Windows-Programmliste, räumt **wirklich** auf | + +**Warum kein Windows-Dienst:** Ein Dienst startet vor der Anmeldung — klingt +besser, kostet aber bei jeder Installation und jedem Update eine +Administrator-Abfrage, und er kann kein Tray-Symbol zeigen (keine +Benutzersitzung). Für einen Rechner, an dem jemand sitzt und Discs einlegt, ist +der Autostart-Eintrag der richtige Weg. + +**Drei Dinge, die aus den alten Fehlern übernommen werden:** + +1. **Drüber-Installieren funktioniert.** Rippy läuft fast immer (Autostart) — + der Installer beendet die laufende Fassung, bevor er kopiert. Sonst landet + die neue Version als `Rippy.exe.neu` daneben. *(Genau das ist passiert.)* +2. **Die Deinstallation räumt die WebView-Reste weg.** Beim letzten Mal blieben + **148 Dateien** liegen, weil die Renderer-Hilfsprozesse den Zwischenspeicher + offen hielten — und der Deinstallierer meldete trotzdem „Rippy wurde + entfernt." +3. **Das Aufräum-Skript darf keine Laufwerkswurzel löschen.** Wenn der + Installationsort aus der Registry eine Wurzel wäre, löschte die + Deinstallation das Laufwerk. Nicht beobachtet — aber nicht wiedergutzumachen. + +> ⚠️ **SmartScreen.** Eine unsignierte EXE bekommt beim ersten Start eine +> Warnung („Der Computer wurde geschützt"). Ein Code-Signing-Zertifikat kostet +> etwa 200–400 € im Jahr. Für den Eigengebrauch ist die Warnung hinnehmbar; +> das gehört in die Anleitung, nicht unter den Teppich. **Offener Punkt für den +> Commander.** + +--- + +## 9. Die Werkzeuge + +Der Entscheid vom 28.08.2026 gilt unverändert: *„handbrake und makemkv MÜSSEN +mitgeliefert werden oder während des Setups separat installiert werden."* Die +Wege unterscheiden sich, und der Grund ist die **Lizenz**, nicht die +Bequemlichkeit. + +| | Weg | Warum | +|---|---|---| +| **HandBrakeCLI** | **mitgeliefert** (~35 MB) | GPL-2 erlaubt die Weitergabe, solange Lizenztext und Quellverweis dabei sind. Damit komprimiert Rippy auch ohne Internet. | +| **FLAC** | **mitgeliefert** (~2 MB) | Xiph-Lizenz (BSD-artig), Weitergabe erlaubt. ⚠️ `flac.exe` braucht `libFLAC.dll` daneben — mit nur der EXE endet jeder Aufruf ohne eine Zeile Ausgabe. | +| **MakeMKV** | **beim Einrichten geholt** | Proprietär — die Lizenz erlaubt Dritten keine Weitergabe. Rippy lädt die offizielle Datei vom Hersteller und startet dessen Installer. | + +**Die Bezugskette für MakeMKV** (aus dem 28.08.-Entscheid, unverändert): +eigene Quelle → Hersteller-Seite → Hersteller-Forum → Internet Archive. Mit +zwei Feinheiten, beide gemessen: **Die höchste Versionsnummer gewinnt, nicht +die erste** (das Archiv meldet sonst 1.18.2 statt 1.18.4), und **jeder Versuch +bekommt eine knappe Zeitgrenze** (8 s), weil das Forum in zwei von drei +Abrufen ausfiel. + +MakeMKV signiert seinen Installer nicht (`NotSigned`, an der echten Datei +gemessen) — geprüft wird deshalb die Versions-Ressource +(`CompanyName: GuinpinSoft inc`). Das ersetzt keine Signatur, fängt aber ab, +was hier wirklich droht: eine Fehlerseite mit `.exe`-Namen, ein abgebrochener +Download, eine falsche Fassung. + +### 9.1 Der Beta-Key — ein Termin, kein Detail + +> **MakeMKVs freier Beta-Key ist bis Ende September 2026 gültig. Die Kaufseite +> für die Dauerlizenz ist seit Mai 2026 defekt.** + +Das trifft Rippy in etwa vier Wochen, und ohne gültigen Key geht **keine +Blu-ray und keine 4K-Disc** mehr auf (DVDs laufen weiter). + +Rippy v5 geht damit so um: + +1. **Der Key wird automatisch geholt** (wie heute) — aus dem Forum-Thread, in + dem der Hersteller ihn veröffentlicht. +2. **Rippy zeigt das Ablaufdatum im Dashboard** und warnt **sieben Tage + vorher** — statt dass ein Rip mitten in der Nacht scheitert und niemand + weiß, warum. +3. **Ein abgelaufener Key ist eine klare Meldung**, kein rätselhafter + MakeMKV-Fehlercode. +4. **Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.** Wer eine Lizenz + hat, trägt sie ein und ist raus aus dem Thema. + +--- + +## 10. Was aus dem alten Rippy mitkommt + +**Kein Code. Aber jede teuer bezahlte Erkenntnis.** + +Das ist der Kern des Grüne-Wiese-Ansatzes: Der Python-Code wird nicht portiert, +aber er ist die beste verfügbare Dokumentation darüber, wie sich MakeMKV, +HandBrake und Windows-Laufwerke wirklich verhalten. Diese Tabelle ist die +Arbeitsliste für den Neubau. + +| Herkunft | Was übernommen wird | Wohin | +|----------|--------------------|-------| +| `ripping.py` | TINFO/SINFO/PRGV/MSG-Parser, Kommandobau, `laengster_titel`, `episoden_titel`, Sprach-Zusammenfassung | `rip/parser.ts`, `rip/makemkv.ts` | +| `win_ioctl.py` | Die hergeleiteten Steuercodes samt Herleitung — **in § 3.2 aus Node bestätigt** | `laufwerk/win32.ts` | +| `windows.py`, `winlauf.py` | Zugriffsgründe je Fehlernummer, `CREATE_NO_WINDOW`, Standby verhindern, die Prozess-Leine | `laufwerk/`, `werkzeuge/leine.ts` | +| `caps.py` | Encoder **messen** statt behaupten (`HandBrakeCLI --help` auswerten) | `komprimieren/encoder.ts` | +| `handbrake_aufruf.py` | `--aencoder` (nicht `--audio-codec`), Container aus dem Preset erzwingen, letzte 12 Zeilen puffern, `unknown option`-Erkennung | `komprimieren/handbrake.ts` | +| `medien.py` | Zielstruktur, NFO-Schema, Episoden-Zuordnung per Laufzeit, Medienserver-Refresh | `ablage/` | +| `prescan.py`, `clients/*` | TMDb/TVDb/OMDb/MusicBrainz/Jikan-Abfragen, Zwischenspeicher, Confidence | `metadaten/` | +| `pfade.py` | Laufwerkswurzeln, UNC, `naechster_vorhandener` **ohne Endlosschleife** | `speicher/pfade.ts` | +| `musicbrainz_cd.py`, `audio_cd.py` | DiscID-Rechnung (mit/ohne 150 Frames), 2048-vs-2352-Falle | `rip/audiocd.ts` | +| `presets.py`, `eta.py`, `phasen.py` | Preset-Zuordnung je Disc-Typ, Restzeit, Phasenmodell | `komprimieren/`, `ablauf/` | +| `beschaffen.py`, `katalog.py` | Suchreihenfolge für Werkzeuge, Registry-Auswertung, Ausweichquellen | `werkzeuge/` | +| `AGENTS.md` | Die sechs Regeln aus § 4.3 | Wächter-Tests | + +**Die Tests wandern sinngemäß mit.** Von den 977 heutigen Tests ist der größte +Teil reine Textverarbeitung (Parser, Pfade, Kommandobau) — die Testfälle +gelten unverändert, nur die Sprache wechselt. Das ist kein Nebeneffekt, +sondern die Absicherung: Ein neu geschriebener Parser, der dieselben +Testfälle besteht wie der alte, hat dieselben Fallen abgedeckt. + +--- + +## 11. Etappen + +**Grundregel:** Jede Etappe endet mit grüner Ampel und einem Programm, das man +starten kann. Kein großer Umbau, nach dem monatelang nichts geht. + +| # | Titel | Inhalt | Fertig, wenn | +|---|-------|--------|--------------| +| **W-0** | **Gerüst** | Electron 44 + TypeScript + Vite + Vitest, drei Prozesse, IPC, `node:sqlite` in Electron **prüfen** (§ 3.4), Prozess-Leine, CI-Ampel | Ein Fenster geht auf, der Kern läuft, ein Testlauf ist grün | +| **W-1** | **Laufwerk** | `laufwerk/win32.ts` aus dem § 3.2-Beweis, Wache über `WM_DEVICECHANGE`, Disc-Typ, Verriegeln, Auswerfen mit Nachsehen | Disc rein → Rippy sagt korrekt, was es ist. Auswerfen wirkt (nachgemessen, nicht geglaubt) | +| **W-2** | **Rippen** | makemkvcon-Aufruf, Parser, Fortschritt, Abbruch, Lesefehler-Warnung, Werkzeug-Beschaffung | Eine DVD und eine Blu-ray laufen durch, verlustfrei | +| **W-3** | **Komprimieren + Ablegen** | HandBrake, Encoder gemessen, Presets, Zielstruktur, NFO, Poster, Medienserver | Ein Film liegt fertig in Jellyfin und ist dort sichtbar | +| **W-4** | **Metadaten-Automatik** | Die vier Quellen aus § 5, Sicherheitsgrad, Nachkorrektur | Fünf verschiedene Discs richtig erkannt, ohne Nachfrage | +| **W-5** | **Oberfläche** | Dashboard, Einstellungen, Logs, Bibliothek, Tray, Windows-Toasts, Taskleisten-Fortschritt | Der Commander bedient Rippy einen Abend lang ohne Rückfrage | +| **W-6** | **Setup + Update** | NSIS, Autostart, Erst-Einrichtung, Selbst-Update, Deinstallation | Frischer Rechner: Setup → Disc rein → MKV raus. Und: drüber-installieren geht | +| **W-7** | **Der Rest** | Audio-CD, ISO-Backup, mehrere Laufwerke, 4K-Schlüsselkette | Je Feature ein Nachweis | + +Reihenfolge-Begründung: W-1 bis W-3 sind die Kette, ohne die Rippy nichts tut. +W-4 und W-5 machen sie bequem. W-6 macht sie weitergebbar. W-7 sind die +Ergänzungen, die ohne die Kette keinen Sinn hätten. + +--- + +## 12. Risiken und offene Punkte + +| Risiko | Bewertung | Behandlung | +|--------|-----------|------------| +| **MakeMKV-Beta-Key läuft Ende September 2026 ab** | hoch × sicher | § 9.1 — Ablaufdatum sichtbar, Warnung 7 Tage vorher, klare Meldung, Dauerlizenz eintragbar. **Das ist der akuteste Punkt im ganzen Dokument.** | +| `node:sqlite` in Electron ungeprüft | mittel × unbekannt | Erster Handgriff in W-0. Rückfallebene `better-sqlite3` | +| Neubau dauert länger als eine Reparatur | sicher | Deshalb Entscheid 2: Die Docker-Version läuft weiter. Es entsteht keine Lücke | +| Electron-Paket ~150–200 MB | sicher, gering | Ein einmaliger Download. Der Gegenwert ist eine Werkzeugkette statt zweier | +| SmartScreen bei unsignierter EXE | mittel | § 8 — Anleitung, oder Zertifikat. **Entscheidung offen** | +| koffi + Electron + npm-Install-Sperre | niedrig | § 3.5 gemessen: koffi lädt mit vorgebauten Binärdateien. In W-0 im Electron-Kontext gegenprüfen | +| Parallel-Rip vs. MakeMKV-Datenverzeichnis | mittel | § 6.5 — **messen, bevor die Grenze festgeschrieben wird** | +| Ein neu geschriebener Parser bringt neue Fehler | mittel | Die alten Testfälle wandern mit (§ 10). Ein Parser, der sie besteht, kennt dieselben Fallen | +| 4K-UHD-Schlüssel | gelöst | Unter Windows holt makemkvcon sie selbst — der strukturelle Vorteil dieser Plattform, gemessen 25.07.2026 | + +### Offene Punkte für den Commander + +1. **Code-Signing-Zertifikat** — ja oder nein? (~200–400 €/Jahr, sonst + SmartScreen-Warnung beim ersten Start) +2. **Update-Quelle** — Gitea-Releases auf `192.168.178.153`, oder ein Ordner + im Netz? +3. **Der alte Windows-Weg** — bleibt `RippySetup.exe` (PyInstaller) parallel + installierbar, bis v5 W-6 erreicht hat? *(Empfehlung: ja, und v5 bekommt + einen eigenen Installationsordner, damit sich die beiden nicht überschreiben.)* + +--- + +## 13. Was NICHT gebaut wird + +- **Kein Python.** Auch nicht „nur für den einen Parser". +- **Kein HTTP-Server, kein Port, kein localhost.** Fenster und Kern reden über + IPC. +- **Keine Docker-Kompatibilität.** Kein geteiltes Modul, kein gemeinsames + Schema, keine Container-Pfade. +- **Kein Linux, kein macOS.** „100 % für Windows gebaut" heißt: Wo eine + Windows-Lösung besser ist als eine portable, gewinnt die Windows-Lösung. +- **Kein ARM-Fork.** Rippy bleibt ein Eigenbau. +- **Keine Auth.** Einzelplatz, kein Netzdienst — es gibt nichts zu schützen. + +--- + +## Anhang — Verhältnis zu den bestehenden Konzepten + +`KONZEPT.md` (v1) und `KONZEPT-V2.md` (v2) **bleiben gültig für die +Docker-Version**. Dieses Dokument ersetzt sie nicht, es steht daneben. + +Drei Punkte aus v2 werden für Windows bewusst anders entschieden — jeweils mit +Begründung: + +| v2 sagt | v5 macht | Warum | +|---------|----------|-------| +| Entscheid 4: *kein Electron, pywebview + WebView2* (8 MB statt 150 MB) | **Electron** | Die Rechnung war richtig und die Schlussfolgerung trotzdem falsch: Sie hat nur die Fenster-Frage betrachtet. Der Preis für pywebview war nicht 0 MB, sondern **eine zweite Werkzeugkette** — PyInstaller, hidden imports, `pythonnet`, und ein Kern, der aus Container-Code bestand. Electron kostet 150 MB und schenkt uns *eine* Sprache von der Oberfläche bis zum Laufwerk. | +| Eine Anwendung, drei Verdrahtungen (Ports/Treiber) | **Ein Windows-Programm** | Die Ports-Architektur ist richtig für ein Produkt, das drei Betriebsarten tragen muss. Bei Entscheid 2 (Docker bleibt getrennt) trägt sie nichts mehr — sie wäre Abstraktion auf Vorrat. | +| REST + SSE als Schnittstelle | **IPC** | Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene Tür ohne Gegenwert. | + +--- + +*Rippy v5 · Konzept · 30.08.2026 · Branch `worktree-windows-electron`* diff --git a/beweise/README.md b/beweise/README.md new file mode 100644 index 0000000..2b909a8 --- /dev/null +++ b/beweise/README.md @@ -0,0 +1,113 @@ +# 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 +``` + +--- + +## Zwei weitere Messungen aus § 3, ohne eigenes Skript + +**`node:sqlite` ist in Node 24 eingebaut:** + +``` +node -e "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'); console.log(db.prepare('SELECT a FROM t').get().a)" +geht +``` + +⚠️ Das ist in **Node 24.19.0** gemessen, nicht in Electron. Electron baut sein +Node mit eigenen Flags — ob `node:sqlite` dort vorhanden ist, ist der erste +Handgriff in Etappe W-0. + +**npm 11 blockiert Install-Skripte:** + +``` +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. Die Warnung +gehört in die Bau-Anleitung, sonst sucht eines Tages jemand an der falschen +Stelle. diff --git a/beweise/laufwerk.js b/beweise/laufwerk.js new file mode 100644 index 0000000..ac53c9b --- /dev/null +++ b/beweise/laufwerk.js @@ -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.'); diff --git a/beweise/leine.js b/beweise/leine.js new file mode 100644 index 0000000..6781d3f --- /dev/null +++ b/beweise/leine.js @@ -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);