58 Commits
Author SHA1 Message Date
HitonabiandClaude Fable 5.1 6555562a4c docs: SAVEPOINT — 5.4.0 gebaut und lokal installiert, NICHT veroeffentlicht; Uebergabe
Ampel / ampel (push) Successful in 1m54s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:27:55 +02:00
HitonabiandClaude Fable 5.1 c46a280b1f chore(v5): Version 5.4.0 — Aenderungsnotizen fuer latest.yml
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:24:19 +02:00
HitonabiandClaude Fable 5.1 6dd1b0bc7a feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:22:04 +02:00
HitonabiandClaude Fable 5.1 e3ce8220f1 feat(v5): Oberflaeche 5.4.0 — Sprachen je Titel, Staffel/Folge, Vorschau, Kino-Banner v2, Roh-Dateien, Warteschlange
WAS: Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und
Untertitelspuren als Chips (vorbelegt aus der Einstellung, sonst alles,
was der Titel hat), Felder fuer Staffel und erste Folge, und zwei Zeilen
Zielpfad-Vorschau (gemeinsam/ablagepfad.ts — dieselbe Namensregel wie der
Kern). Die Dialoge leben jetzt im App-Zustand statt in der Kachel: ein
leerer Takt der Wache raeumte sonst Kachel UND Dialog ab.
Die Laufwerks-Kachel ist das Kino-Banner v2: Logo statt Text, Tagline,
Zeile Jahr/FSK/Laufzeit/Genre/Bewertung, Darsteller-Streifen, die
Hinweise der Nachpruefung, ein Knopf „Laufwerk pruefen" mit Urteil aus
dem Windows-Protokoll, und nach dem Rip der Stand der Kompression.
Neu in der Uebersicht: die Kompressions-Karte (laeuft/wartet, Abbruch).
Neu in den Einstellungen: „Roh-Dateien" mit Regel, Belegung, jedem
Vorgang (Neu komprimieren mit Preset, Roh loeschen) und Ordnern ohne
Vorgang; der Auswurf-Schalter in „Ablage"; der Datenbank-Pfad unter
„Programm". Das Haupt zeigt die Kompression in Taskleiste und Tray und
laesst Updates auch auf sie warten. Playwright kommt als Bau-Werkzeug
dazu (Commander-Ja nach Regel B; MIT, wird nicht ausgeliefert).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:13:22 +02:00
HitonabiandClaude Fable 5.1 3f7f962dff feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2
WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft
danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer
die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge,
Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen:
Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression
und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab
Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme
nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den
Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben.
Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder
Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der
Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs
Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die
Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die
Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet.
Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird
nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck,
bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline,
Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit
aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware.

WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom
Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte
Kompression waren seine Wahl).

GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg-
Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format,
TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an
diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength,
MakeMKV-Auswahlregel laut forum.makemkv.com t=4386.

348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche
folgt im naechsten Schritt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:02:03 +02:00
HitonabiandClaude Opus 5 d70a81d194 docs: SAVEPOINT — UEBERGABE an die naechste Session
Ampel / ampel (push) Successful in 1m39s
Zwoelf Punkte in empfohlener Reihenfolge, davon zwei vom Commander:
Metadaten v2 mit Hero-Banner (Laufzeit-Abgleich, Struktur-Fingerabdruck,
TMDb-Logo/FSK/Besetzung) und Sprachwahl je Titel im Auswahl-Dialog.

Dazu Startanleitung, Token-Fundstelle, das bewaehrte Vorgehen
(erst lokal, dann veroeffentlichen), Hardware-Lage und der Bug, der
noch offen sein koennte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:49:22 +02:00
HitonabiandClaude Opus 5 7c489f8404 fix(ui): Dialoge schliessen nicht mehr beim Markieren von Text
Ampel / ampel (push) Successful in 2m18s
Commander: "es gibt einen kleinen bug im UI wenn man selbst den titel
sucht das suchfenster schliesst sich dann einfach."

Bekannte Falle bei Dialogen mit klickbarer Hintergrundflaeche: Wer im
Eingabefeld Text markiert und dabei ueber den Dialogrand hinauszieht,
drueckt INNEN und laesst AUSSEN los. Der Browser meldet als Ziel des
Klicks dann den gemeinsamen Vorfahren — die Hintergrundflaeche. Der
onClick-Horcher darauf schloss den Dialog also mitten im Tippen.

Neu useHintergrundKlick(): merkt sich, WO der Klick begann. Nur wenn
Druecken UND Loslassen auf der Flaeche selbst passierten, ist es ein
Klick daneben. Gilt fuer beide Dialoge (Titelwahl und "Aendern ...").

Gegengeprueft und VERWORFEN wurde eine andere Vermutung: Die Kachelliste
haengt an `laufwerke.length === 0`, ein einziger leerer Takt der
Laufwerks-Wache wuerde die Kachel samt Dialog abraeumen. Gemessen: 20 von
20 Abfragen liefern das Laufwerk, keine leere Antwort. Die Fragilitaet
bleibt als Risiko notiert (SAVEPOINT), ist aber nicht die Ursache.

261 Tests gruen. NICHT veroeffentlicht — der Kanal steht auf 5.3.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:48:19 +02:00
HitonabiandClaude Opus 5 1f678c036d docs: SAVEPOINT — 5.3.2 im Kanal, Titelauswahl am Geraet bestaetigt
Ampel / ampel (push) Successful in 1m32s
"Serie: 2 Folgen erwartet, 2 gefunden - passt." mit 52:47 und 54:09 min —
2,5 Prozent auseinander, weit innerhalb von AEHNLICH. Die Auslegung
stimmt an echten Zahlen.

Zwei eigene Fehler festgehalten: der still verworfene Nachrichtentyp und
der alte Titel-Lauf, der sich ueber die fertige Liste legte. Dazu das
Vorgehen, das sich bewaehrt hat: erst lokal installieren, testen lassen,
dann veroeffentlichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:32:48 +02:00
HitonabiandClaude Opus 5 6d149212c9 fix(v5): Nur der NEUESTE Titel-Lauf antwortet — ein alter legte sich darueber
Ampel / ampel (push) Successful in 1m49s
Commander: "Er liest es korrekt ein, ich bekomme auch die auswahl aber
kurz danach kommt dieser fehler" — "makemkvcon info: keine Antwort nach
300 s", MINUTEN nach der fertigen Titelliste.

Mit nur einem Lauf ist das unmoeglich: Der Timeout wird bei 'close'
geloescht. Es waren also zwei Laeufe unterwegs, und der aeltere meldete
verspaetet seinen Abbruch — auf die laengst gelieferte Liste drauf.

Behoben, je Laufwerk:
* Ein neuer Lauf LOEST den alten AB: AbortController im Kern,
  titelInfoLesen nimmt jetzt ein AbortSignal, killt das Kind und lehnt
  mit der Marke ABGELOEST ab. Das Laufwerk ist sofort frei, und es
  laufen nie zwei makemkvcon auf derselben Disc.
* Nur die NEUESTE Laufnummer darf antworten (titelLaufNummern) — eine
  verspaetete Antwort wird verworfen, nicht angezeigt.
* Ein abgeloester Lauf SCHWEIGT: abgeloest ist Absicht, kein Fehlschlag.
* Laeuft es wirklich in den Timeout, steht jetzt ein Satz statt einer
  Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
  Minuten brauchen; Rippy beendet den Versuch und gibt das Laufwerk frei.

Am lebenden Objekt bestaetigt (SPARTACUS_GOTA_D1, Commander-Screenshot):
"Serie: 2 Folgen erwartet, 2 gefunden - passt." | Titel 0 52:47 min
16,28 GB 7 Kap. | Titel 1 54:09 min 16,67 GB 7 Kap. | beide Rolle Folge |
2 von 2 gewaehlt, zusammen 32,95 GB — und KEIN Timeout hinterher.

Die Laengen liegen 2,5 Prozent auseinander, also weit innerhalb von
AEHNLICH (15 Prozent) — deshalb "passt" ohne Unsicherheits-Warnung.

261 Tests gruen, Typpruefung sauber. Version 5.3.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:29:25 +02:00
HitonabiandClaude Opus 5 9622f4e6ef fix(v5): Der Kern verwarf 'titel-lesen' STILL — Auswahl-Dialog hing ewig
Ampel / ampel (push) Successful in 1m48s
Commander: "das funktioniert nicht, du kannst gerne am lebenen objekt
pruefen". Der Dialog blieb bei "Rippy liest die Titel der Disc ..."
stehen, ohne Fehler, ohne Ende.

Ursache, am lebenden Objekt gemessen: NICHT der Info-Lauf.

  makemkvcon -r --noscan info dev:G:  ->  0,8 s, TCOUNT:0
  MSG:5042 "konnte keine verwendbaren optischen Laufwerke finden"

Der Lauf antwortet also sofort. Der Fehler lag in MEINEM Code von 5.2.0:
istFensterNachricht() kannte 'titel-lesen' und 'rip-ueberspringen' nicht
— beide standen in der FensterNachricht-Union, aber nicht im Pruefer.
Der Kern verwarf sie mit `if (!istFensterNachricht(frage)) return`.
STILL. Genau die R4-Wunde, vor der die Hausordnung warnt.

Auf drei Ebenen behoben:

1. Der Pruefer kennt beide Nachrichten.
2. Eine verworfene Nachricht ist jetzt LAUT: console.error plus
   kern-fehler ins Fenster mit der Art, die verworfen wurde.
3. Neuer Waechter-Test (waechter.test.ts): Er liest das Schema und
   vergleicht JEDE `art: '...'` der Unions mit dem jeweiligen Pruefer;
   dazu prueft er, dass der Kern nicht mehr still verwirft.
   GEGENPROBE gemacht: Mit zurueckgenommener Reparatur schlaegt er fehl
   ("expected [ 'titel-lesen' ] to deeply equal []"), mit Reparatur ist
   er gruen. Kein Deko-Test.

Dazu: Der Info-Lauf reicht die URSACHE durch (DiscInfo.ursachen aus den
kritischen MSG-Nummern). Kam nichts heraus und MakeMKV hat einen Grund
genannt, steht im Dialog der Grund statt "keine Titel gefunden" — beim
5042-Zustand also die Abhilfe (Disc neu einlegen, Laufwerk ab- und
anstecken, notfalls neu starten) statt eines Fingerzeigs auf die Disc.

Neu test/messung.titelwahl.test.ts — faehrt am echten Laufwerk genau den
Weg des Kerns nach (Info-Lauf, Vorauswahl, Dialog-Fehlertext).

Zweimal in dieselbe Heredoc-Falle getappt (AGENTS.md warnt davor):
Escape-Sequenzen wurden halbiert, ein rohes CR landete in makemkv.ts.
Beides korrigiert; der Hygiene-Waechter haette das CR auch gefangen.

260 Tests gruen (vorher 257), Typpruefung sauber. Version 5.3.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 20:12:44 +02:00
HitonabiandClaude Opus 5 306b090ccf docs: SAVEPOINT — 5.3.0 im Kanal, Serien-Ablage und Rollen
Ampel / ampel (push) Successful in 1m30s
Zum dritten Mal in dieser Sitzung dasselbe Muster: Die Bausteine lagen
fertig UND getestet da (serienOrdner, matcheEpisoden, episodenUmbenennen,
tvStaffel) und waren nur nicht verbunden. Als Lehre festgehalten.

Offen notiert: Weder Auswahl-Dialog noch Serien-Ablage sind je an echter
Hardware gelaufen, und der extras/-Ordnername ist aus Wissen gesetzt,
nicht gemessen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:40:36 +02:00
HitonabiandClaude Opus 5 7ce06889f8 feat(v5): Serien-Staffelablage und Film-Extras — die Rolle bestimmt das Ziel
Ampel / ampel (push) Successful in 1m56s
Commander (01.09.2026): "Mach das gerne noch mit den Serien." und "Fuer
FILME sollte so eine auswahl auch gelten z.B. Nur hauptfeature oder
hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."

Wieder lagen die Teile fertig herum und waren nur nicht verbunden:
serienOrdner(), matcheEpisoden(), episodenUmbenennen() (struktur.ts) und
tvStaffel() (tmdb.ts) sind gebaut UND getestet — aufgerufen hat sie
niemand. Folgen landeten deshalb unter Filme/, und Jellyfin sah lauter
Einzelfilme statt einer Staffel.

Neu: jeder Titel im Auftrag traegt eine ROLLE (gemeinsam/nachrichten.ts,
EINE Definition fuer Kern und Fenster):

  hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
  extra     -> Filme/<Titel> (Jahr)/extras/...
  folge     -> Serien/<Titel>/Season NN/... S01E02.mkv

* Rippy schlaegt die Rolle vor (Serie -> Folge, Film -> Hauptfilm, Rest
  -> Extra), im Dialog ist sie je Titel aenderbar.
* Die Zuordnung Datei->Rolle ist EXAKT, nicht aus Dateinamen geraten: Je
  makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt die
  Rolle des Auftragseintrags.
* Extras liegen im Unterordner "extras" neben dem Film — die Konvention,
  die Jellyfin, Emby und Kodi als Zugaben lesen. Der Ordner entsteht nur,
  wenn es wirklich Extras gibt.
* Neuer Schnellwahl-Knopf "Hauptinhalt + Extras": alles Sehenswerte, aber
  weder Sammeltitel noch Logos/Alterskennzeichen.

Episoden-Nummern: Die Laufzeit jedes gewaehlten Titels reist im Auftrag
mit (RipAuftragTitel.dauerS), die Staffel-Laufzeiten kommen ueber einen
injizierten Rueckruf von TMDb (tvStaffel). matcheEpisoden() benennt NUR
bei EINDEUTIGER Zuordnung um. Sonst behalten die Dateien ihre Namen, und
Rippy SAGT warum — entweder "keine Staffel-Laufzeiten von TMDb" oder
"nicht eindeutig zuzuordnen". Lieber gar nicht als falsch; das war ARMs
offene Wunde #395.

Die Staffel-Nummer kommt aus dem Disc-Titel (discZusatzAbtrennen, seit
5.1.1) — sonst 1.

257 Tests gruen (vorher 254), Typpruefung sauber. Version 5.3.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:37:10 +02:00
HitonabiandClaude Opus 5 043052b453 docs: SAVEPOINT — 5.2.0 im Kanal, Titelauswahl und Defekt-Waechter
Ampel / ampel (push) Successful in 1m34s
Inklusive der Korrektur einer eigenen Fehldiagnose: Der FESTE Offset
widerlegt die USB-Strom-Vermutung — es ist ein Disc-Defekt, die
Controllerfehler sind die Folge des halbstuendigen Haemmerns.

Offen notiert: Der Auswahl-Dialog lief noch nie an echter Hardware, und
die Serien-Ablage (Serien/<Titel>/Staffel X mit SxxEyy) fehlt weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:26:05 +02:00
HitonabiandClaude Opus 5 a8e8b2bef3 feat(v5): Titel auswaehlen statt immer alles — und ein Defekt kostet nicht mehr alles
Ampel / ampel (push) Successful in 1m34s
Commander (01.09.2026): "es fehlt generell noch komplett die auswahl WAS
man rippen moechte. Bei Serien eben genau das die folgen, bei filmen z.B.
nur das hauptfeature oder auch die extras."

Bis hierher uebergab die Pipeline fest `titel: 'all'` — Rippy rippte
IMMER alles, samt Trailer, Werbung und Alterskennzeichen. Beide Wuensche
brauchen denselben Umbau, deshalb in einem Zug:

TITEL FUER TITEL statt einem 'all'-Aufruf. Nur so laesst sich auswaehlen,
und nur so ueberlebt der Rest, wenn EIN Titel an einem Disc-Defekt haengt.

Was schon da war und nur nicht verbunden: titelInfoLesen() (Titel mit
Dauer/Groesse/Kapiteln) rief niemand auf, und buildRipArgs() konnte
laengst einen einzelnen Titel. Dazu kennt Rippy seit heute das
Inhaltsverzeichnis der Disc (EP 1, EP 2, DUB GER 1, 104 GER FSK).

Neu kern/rip/titelwahl.ts (pur, ohne Laufwerk):
* Serie (von der Disc BEZEUGT): so viele Titel wie das Inhaltsverzeichnis
  Folgen nennt; passen sie nicht zusammen, sagt Rippy das.
* Film: der laengste Titel.
* Zwei Fallen mit Tests abgesichert:
  - SAMMELTITEL: Ein Titel, der so lang ist wie die anderen zusammen
    ("alle Folgen am Stueck"), waere der laengste und wuerde die
    Film-Auswahl gewinnen — Rippy wuerde alles doppelt sichern.
  - ANGEL/Seamless Branching: Mehrere fast gleich lange Fassungen. Rippy
    nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
* Unklar heisst: nichts vorausgewaehlt. Lieber fragen als raten.

Der Defekt-Waechter:
* offsetAusMeldung() liest die Stelle aus MakeMKVs Meldung — als LETZTE
  gequotete Zahl, damit es unabhaengig von MakeMKVs Sprache bleibt
  (deutsch "bei Offset", englisch "at offset").
* RipAuswertung zaehlt Wiederholungen derselben Stelle; ab HAENGT_AB gilt
  der Lauf als haengend. Fortschritt raeumt den Verdacht wieder ab.
* Das Fenster zeigt dann Klartext plus "Titel ueberspringen" — der
  laufende makemkvcon wird beendet, der naechste Titel laeuft weiter.

Gemessen am lebenden Objekt (Spartacus Disc 2, 01.09.2026): MakeMKV
meldete eine halbe Stunde denselben Offset 56881152, makemkvcon
verbrauchte dabei 1,7 s CPU (es wartete), und Windows protokollierte 41
fehlerhafte Bloecke in 10 Minuten. Der Defekt lag in Folge 1 — Folge 2
lag heil daneben und ging trotzdem verloren.

17 neue Tests in test/titelwahl.test.ts, alle beim ersten Lauf gruen.
254 Tests gesamt (vorher 237), Typpruefung sauber. Version 5.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:22:55 +02:00
HitonabiandClaude Opus 5 1253148652 docs: SAVEPOINT — der Selbst-Update-Beweis ist erbracht
Ampel / ampel (push) Successful in 1m33s
Commander: "Also das upate funktioniert!" — 5.1.4 fand 5.1.5 selbst,
lud sie, installierte sie und kam in der neuen Fassung zurueck, ohne
Handanlegen.

Damit ist der offene Punkt 1 aus der Uebergabe vom 31.08. erledigt und
die Kette zum ERSTEN Mal komplett bewiesen: Kanal UND letzter Meter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:50:09 +02:00
HitonabiandClaude Opus 5 2549b9643b docs: SAVEPOINT — 5.1.5 im Kanal, Spartacus am lebenden Objekt bewiesen
Ampel / ampel (push) Successful in 1m36s
Der Commander legte die Disc mit dem neuen UI ein: "erkannt als
Spartacus: Gods of the Arena - 90 %". Der Fund von 5.1.1 ist damit
erledigt.

Dazu 5.1.5: DWM-Rand weg (HRESULT 0, auch im Paket), Preset-Empfehlung
sichtbar gemacht (die Logik gab es schon), Update-Adresse raus, Wizard
mit fuenf Befunden geradegezogen. Waechter R1 kennt jetzt drei koffi-Orte
— bewusst und gemeldet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:47:17 +02:00
HitonabiandClaude Opus 5 663127f854 feat(v5): Windows-Rand weg, Hardware-Empfehlung sichtbar, Wizard geradegezogen
Ampel / ampel (push) Successful in 1m35s
Vier Punkte des Commanders (01.09.2026):

1. "Ich hab noch den rand ich glaube der kommt aber von windows selbst"
   Stimmt. Ein rahmenloses Fenster behaelt unter Windows 11 den DWM-Rand
   — eine feine Linie, die der Fenstermanager zeichnet, nicht die Seite;
   mit CSS ist da nichts zu holen. Neu fensterrand.ts:
   DwmSetWindowAttribute mit DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE.
   Gemessen: HRESULT 0, Windows nimmt es an. Runde Ecken bleiben (sie
   sind unter Windows 11 das Uebliche); ein Schalter fuer kantig ist da.
   Aeltere Windows antworten mit Fehler — der wird als Wert
   zurueckgegeben und protokolliert, nicht verschluckt (R4); dort gibt es
   den Rand schlicht nicht.

   Das ist der DRITTE erlaubte koffi-Ort. Waechter R1 kennt jetzt drei
   statt zwei Dateien; die Regel selbst ("Win32 nur an benannten Orten")
   bleibt und haelt alles andere weiter dicht.

2. "Die alte Version konnte automatisch anhand der gescannten hardware
   encoder das beste preset waehlen."
   Das KANN diese auch — presetEmpfehlung() waehlt nach den gemessenen
   Backends (VCN -> NVENC -> QSV -> CPU) und laeuft seit dem Kino-Mix.
   Man sah es nur nirgends ausser als Wort "automatisch:" in einem
   Auswahlfeld. Die Erst-Einrichtung zeigt jetzt die gemessenen Encoder
   als Badges und was daraus fuer DVD, Blu-ray und 4K wird — plus eine
   ehrliche Warnung, wenn nur CPU-Encoder da sind.

3. Feld "Update-Adresse" ist raus. Der Schluessel updateUrl gilt
   weiterhin (Datenbank), Paragraph 6.8 prueft ihn wie zuvor.

4. Erst-Einrichtung geprueft. Gefunden und behoben:
   * "Dieses Setup stellt vier Dinge ein" — es stellte ZWEI ein.
   * Der Ablage-Schritt zeigte eine LEERE Zeile, wenn nichts gewaehlt
     war. Es gibt aber eine Vorgabe (Videos\Rippy im Benutzerprofil) —
     die steht jetzt da.
   * Kein Wort zum TMDb-Schluessel, obwohl Schritt 1 "Rippy erkennt, was
     drauf ist" verspricht. Ohne Schluessel heissen Filme wie das
     Disc-Label. Jetzt ein eigener Schritt mit Eingabefeld.
   * Rohe HTML-Checkbox statt des Schalters aus dem Kino-Mix.
   * Kein Hinweis, dass Rippy im Infobereich weiterlebt.
   Neu sechs Schritte, drei Fragen — und die Zahl im Text stimmt.

   Dazu: #einrichtung als Beweis-Anker (RIPPY_START_BEREICH), sonst ist
   der Wizard nach dem ersten Start nicht mehr zu fotografieren und
   damit nicht mehr pruefbar. Beweis: beweise/ui-515-wizard.png

237 Tests gruen, Typpruefung sauber. Version 5.1.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:43:58 +02:00
HitonabiandClaude Opus 5 45402e887e docs: SAVEPOINT — 5.1.4 im Kanal, Leine-Fix gemessen
Ampel / ampel (push) Successful in 1m31s
Die Update-Kette hat ab 5.1.2 nie installiert; bewiesen war immer nur der
Kanal, nie der letzte Meter. Ursache, Fix und die dreifache Messung
festgehalten — inklusive des ersten, falsch negativen Messlaufs.

Notiert: Der Sprung AUF 5.1.4 ist einmalig Handarbeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:30:43 +02:00
HitonabiandClaude Opus 5 7a4f92036d test(beweise): dritter Fall — erlaubt das Ausklinken die Leine auf?
Ampel / ampel (push) Successful in 1m33s
Die eigentliche Regressionsfrage fehlte noch: Ein normales Kind muss auch
dann sterben, wenn die Arbeitsgruppe das Ausklinken ERLAUBT. Sonst waere
Paragraph 3.3 aufgeweicht und makemkvcon koennte als Waise das Laufwerk
festhalten (der rc10-Fund).

  1) Leine wie bisher, Kind normal                  -> stirbt  WIE ERWARTET
  2) Leine erlaubt Ausklinken, Kind normal          -> stirbt  WIE ERWARTET
  3) Leine erlaubt Ausklinken, Kind klinkt sich aus -> lebt    WIE ERWARTET

Das blosse Erlauben aendert also nichts; nur wer CREATE_BREAKAWAY_FROM_JOB
verlangt, kommt raus. Genau das tut allein der Update-Installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:29:23 +02:00
HitonabiandClaude Opus 5 051cab3afa fix(v5): Das Update installierte nie — die eigene Leine erschlug den Installer
Ampel / ampel (push) Successful in 1m41s
Commander: "Rippy wird zwar sauber beendet aber startet NICHT neu in der
neuen version."

Ursache, auf drei Beinen belegt:

1. Bibliothek: electron-updater startet den Installer per
   spawn(..., { detached: true }) (BaseUpdater.js, an der installierten
   Fassung nachgelesen). `detached` setzt unter Windows KEIN Breakaway.
2. Leine: leine.ts setzt KILL_ON_JOB_CLOSE, und Kinder erben die
   Mitgliedschaft. Der Installer war damit Mitglied der Arbeitsgruppe.
3. Platte: pending\RippySetup-5.1.3.exe lag geladen da (18:11:47),
   installiert war weiter 5.1.2. Der Download klappte, nur die
   Installation nicht.

Der Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.

Fix — die Leine bleibt, der Installer klinkt sich aus:

* leine.ts setzt zusaetzlich JOB_OBJECT_LIMIT_BREAKAWAY_OK. Das aendert
  fuer sich genommen NICHTS: Kinder erben weiter, ausser sie verlangen
  das Ausklinken selbst. Kern, makemkvcon und HandBrakeCLI bleiben
  angeleint, die rc10-Waisen-Falle (Paragraph 3.3) ist unveraendert zu.
* Neu: ausserhalbDerLeineStarten() startet EIN Programm per CreateProcessW
  mit CREATE_BREAKAWAY_FROM_JOB. Liegt in leine.ts, weil dort die
  Arbeitsgruppe verwaltet wird — und weil Waechter R1 koffi nur dort und
  in kern/laufwerk/win32.ts erlaubt.
* update.ts installiert jetzt SELBST: autoInstallOnAppQuit ist aus, der
  Installerpfad kommt aus downloadUpdate() (laut AppUpdater.d.ts "Paths to
  downloaded files"), die Argumente sind die von electron-updater
  gemessenen (--updated /S --force-run). Gestartet wird beim Beenden
  (will-quit) und ueber den Knopf.
* Neu kommandozeile.ts (pur, ohne koffi/Electron): CreateProcessW nimmt
  EINE Zeichenkette. Ohne die Regeln von CommandLineToArgvW zerfiele ein
  Pfad wie "C:\Users\Tobi Neu\..." in zwei Argumente.

BEWIESEN, nicht behauptet — neu: beweise/leine-breakaway.js

  OHNE Ausklinken (so macht es electron-updater)
    lebt nach dem Tod des Elternprozesses: NEIN  -> WIE ERWARTET
  MIT Ausklinken (so macht es Rippy ab 5.1.4)
    lebt nach dem Tod des Elternprozesses: JA    -> WIE ERWARTET

Der erste Messlauf zeigte ein falsches Negativ: Der Enkel starb an der
sterbenden KONSOLE des Elternprozesses, nicht an der Arbeitsgruppe. Erst
mit CREATE_NO_WINDOW (eigene Konsole) misst der Versuch, was er messen
soll. Diese Lehre steht als Kommentar im Code und der Flag ist auch im
echten Aufruf gesetzt.

237 Tests gruen (vorher 230), Typpruefung sauber. Version 5.1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:27:36 +02:00
HitonabiandClaude Opus 5 1d53e8526f docs: SAVEPOINT — 5.1.3 im Kanal, "zieht nichts hoch" aufgeklaert
Ampel / ampel (push) Successful in 1m33s
Das Update ZOG: RippySetup-5.1.2.exe lag geladen und pruefsummengleich
in rippy-updater\pending. Es fehlte nur das echte Beenden ueber das Tray
— Fenster zumachen beendet Rippy nicht (Paragraph 4.1 Punkt 3).

5.1.3 anonym von aussen nachgemessen: Content-Length = latest.yml =
lokale Datei, releaseNotes durchgereicht, Umlaute intakt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:10:50 +02:00
HitonabiandClaude Opus 5 827910fd63 feat(v5): eigenes Fenster ohne Windows-Rahmen + Layout skaliert mit
Ampel / ampel (push) Successful in 1m34s
Zwei Befunde des Commanders (01.09.2026):

1. "Momentan ist alles nach links gerueckt, nichts skaliert mit der
   fenstergroesse und es ist alles gequetscht."

   Ursache gefunden: <main> hatte WEDER Zentrierung NOCH Maximalbreite,
   die Karten darin aber einen harten Deckel von max-w-2xl (672 px). In
   einem 1280er Fenster klebte damit alles am linken Rand und rechts
   stand nichts. Behoben:
   * <main> und Kopfzeile teilen sich mx-auto max-w-[1600px] mit
     mitwachsendem Innenabstand — der Inhalt ist mittig und nutzt die
     Breite.
   * Der 672-px-Deckel der Einstellungs-Spalte ist weg. Drei
     Encoder-Felder stehen jetzt nebeneinander statt untereinander.
   * Die Sektionswahl der Einstellungen wird im schmalen Fenster zur
     Zeile ueber den Karten (lg:flex-row), statt eine 176-px-Spalte vom
     ohnehin knappen Platz abzuziehen.
   * Info-Kacheln der Laufwerks-Karte: zwei Spalten schmal, vier breit.
   * Startgroesse 1000x700 -> 1280x820, Mindestmass 720x480 -> 860x560.

2. "waere sogar richtig cool wenn du den rahmen wegbekommst"

   frame:false, und die Kopfzeile IST jetzt die Titelleiste:
   Minimieren / Maximieren / Schliessen sitzen oben rechts in
   Windows-Massen (46x32), gezogen wird an der Kopfzeile, Doppelklick
   maximiert. Groesse aendern bleibt moeglich — Electron setzt fuer
   rahmenlose Fenster unter Windows weiter WS_THICKFRAME (thickFrame,
   Standard true), die Kanten ziehen also wie gewohnt.

   Schliessen nimmt GENAU den Weg des alten Fensterkreuzes
   (fenster.close() -> der bestehende close-Horcher versteckt nur):
   Rippy lebt im Tray weiter, ein laufender Rip merkt davon nichts
   (Paragraph 4.1 Punkt 3).

   Die Ziehflaeche ist .ziehbar in stil.css; alles Bedienbare darin
   traegt .nicht-ziehbar, sonst verschluckt der Griff den Klick.
   fensterMaximiert im HauptStatus entscheidet ueber das Knopf-Symbol
   und wird auch bei Doppelklick/Tastenkuerzel nachgefuehrt (maximize-
   und unmaximize-Horcher).

Nachgemessen mit dem Smoke-Beweis: beweise/ui-513-uebersicht.png (kein
Rahmen, eigene Knoepfe, Inhalt ueber die volle Breite) und
beweise/ui-513-einstellungen.png (drei Encoder nebeneinander).

230 Tests gruen, Typpruefung sauber. Version 5.1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 18:07:45 +02:00
HitonabiandClaude Opus 5 0c38754c09 docs: SAVEPOINT — 5.1.2 im Update-Kanal, Changelog nachgemessen
Ampel / ampel (push) Successful in 1m36s
releaseNotes stehen anonym abrufbar in latest.yml (1191 B statt 337 B,
Umlaute intakt), Content-Length der EXE = Zahl in latest.yml = lokale
Datei. Notiert: Die Update-Karte ist erst AB 5.1.2 sichtbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:54:48 +02:00
HitonabiandClaude Opus 5 c0a7618e33 feat(v5): Update sichtbar machen — Meldung, Knopf und Changelog
Ampel / ampel (push) Successful in 1m33s
Der Commander: "irgendwie fehlt in rippy selbst der Button 'Auf updates
pruefen' oder auch direkt beim start ne meldung 'ey jo update
verfuegbar' - waere noch cool wenn das kommt inkl. changelog".

Zu Recht. Der Kanal arbeitete korrekt, war aber UNSICHTBAR: Der
Update-Stand lag im Haupt und wurde nur beim Laden des Fensters EINMAL
mitgeschickt. Fand die Pruefung etwas, aenderte sich am Bildschirm
nichts; der einzige Hinweis war ein Satz unter "Update-Adresse", den man
kennen musste, um ihn zu finden.

Was jetzt da ist:

* Eine goldene Karte OBEN in der Uebersicht, sobald etwas gefunden wird
  — mit neuer Versionsnummer, der laufenden Version, Ladefortschritt und
  den Aenderungsnotizen. Dazu ein Windows-Toast beim Fund.
* Knopf "Jetzt auf Updates pruefen" in den Einstellungen. Waehrend eines
  Rips ist er AUS, mit Begruendung im Tooltip — Rippy sucht dann
  bewusst nicht (Paragraph 6.8).
* Knopf "Jetzt neu starten und installieren" auf der Karte, sobald das
  Paket geladen ist. Sagt NEIN mit Grund statt still nichts zu tun (R4).
* Der Stand meldet JEDE Aenderung sofort ans Fenster (setzen() ruft
  immer beiAenderung) — kein Zustand mehr, den niemand erfaehrt.

Der Changelog kommt aus bau/release-notes.md. An der installierten
Bibliothek geprueft statt aus dem Kopf (Regel D): app-builder-lib
out/publish/updateInfoBuilder.js getReleaseInfo() liest genau diesen
Dateinamen und legt den Inhalt als releaseNotes in latest.yml;
builder-util-runtime deklariert UpdateInfo.releaseNotes als
"string | Array<ReleaseNoteInfo> | null". neuerungenText() vertraegt
beide Formen, wirft HTML-Marken raus und deckelt die Laenge.

UpdateStand ist jetzt EINE Definition in gemeinsam/nachrichten.ts, die
sich Haupt und Fenster teilen.

230 Tests gruen (vorher 225), Typpruefung sauber. Version 5.1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:51:38 +02:00
HitonabiandClaude Opus 5 7d496cf654 docs: SAVEPOINT — 5.1.1 im Update-Kanal, Gitea-Muster geklaert
Ampel / ampel (push) Successful in 1m59s
Release aktuell traegt jetzt 5.1.1 (vier Dateien, anonym von aussen
nachgemessen: latest.yml 200/version 5.1.1, EXE Content-Length
131526527 = Zahl in latest.yml = lokale Datei).

KONZEPT 3.6 beantwortet: releases/latest/download liefert HTTP 404 —
Gitea 1.27 bedient das GitHub-Muster NICHT. Das feste Tag aktuell ist
damit der einzige Weg, nicht nur die Rueckfallebene.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:42:41 +02:00
HitonabiandClaude Opus 5 4f943e2c4f docs: SAVEPOINT — Disc-Erkennung repariert, 5.1.1 gebaut
Ampel / ampel (push) Successful in 1m37s
Ampel gruen fuer 38a3a87, Paket-Smoke gruen, Setup liegt in dist-setup.
Offen bleibt allein der Release-Upload: der gespeicherte Gitea-Zugang ist
ein Passwort, kein API-Token (HTTP 401).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:36:46 +02:00
HitonabiandClaude Opus 5 38a3a87f70 fix(v5): Disc-Erkennung — die Disc selbst befragen statt raten
Ampel / ampel (push) Successful in 1m38s
Der Commander legte Disc 2 von „Spartacus: Gods of the Arena" ein, Rippy
meldete den FILM „Spartacus" von 1960. An der echten Disc gemessen
(Laufwerk G:, Label SPARTACUS_GOTA_D2, UDF, 33.759.100.928 Bytes): Der
stark gestutzte Kandidat „Spartacus" stand auf Platz 2 der Suchvarianten
und holte dort einen wörtlichen Filmtreffer mit 95 Prozent — die richtige
Serie stand auf Platz 6 und wurde nie gefragt.

Vier Ursachen, alle behoben:

1. Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der Frage.
   Neu: abdeckung() + MINDEST_ABDECKUNG — wer mehr als die Hälfte des
   Titels wegwerfen musste, um zu treffen, bekommt höchstens einen
   Vorschlag (0,60), und das UI fragt nach, statt sich sicher zu irren.

2. Verpackungs-Zusätze galten als Wortmüll. Neu: discZusatzAbtrennen()
   trennt "- Disc 2" / "_D2" / "S1" ab UND merkt sie — die Nummer braucht
   die Ablage später sowieso. Vier Fallen sind abgesichert: "Rocky 2",
   "Kill Bill Teil 2", "Ocean s 11" (Apostroph-Wortgrenze) und nackte
   Zahlen am Ende.

3. Der Titelvergleich war zeichengenau. Ein Volume-Label KANN keinen
   Doppelpunkt tragen, TMDb schreibt ihn — der exakt richtige Treffer
   fiel an einem Satzzeichen durch. Neu: gleichBedeutend().

4. Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste
   Zeile daraus. Neu: inhaltAusBdmt() liest auch <di:titleName>. "EP 1"
   und "EP 2" beweisen die Serie, "DUB GER 1" und "104 GER FSK" nicht.
   Ist die Serie bewiesen, sind die Film-Zweige der Kaskade AUS.

Gemessen an der eingelegten Disc (neue messung.disc-erkennung.test.ts):
  Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt)
  Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true
  Varianten: "...Arena - Disc 2" -> "...Arena" -> ... -> "Spartacus"

225 Tests grün (vorher 209), Typprüfung sauber. Disc-Nummer, Staffel und
Folgenzahl stehen jetzt auch im Fenster (Kachel neben der Erkennung).

Version 5.1.1 — zugleich der erste echte Selbst-Update-Beweis (§ 6.8).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:31:56 +02:00
HitonabiandClaude Fable 5 673f7ff3e7 docs: SAVEPOINT — Übergabe an die nächste Session
Ampel / ampel (push) Successful in 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:24:35 +02:00
HitonabiandClaude Fable 5 5522a50425 docs: SAVEPOINT — deployt: Update-Kanal aktuell live, main bereinigt
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:15:57 +02:00
HitonabiandClaude Fable 5 d21332e3c7 docs: SAVEPOINT — Einstellungen-Karten und Version 5.1.0
Ampel / ampel (push) Successful in 1m44s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:10:10 +02:00
HitonabiandClaude Fable 5 b64ddf5f06 feat(v5): Einstellungen als erklärende Karten-Seite — Version 5.1.0
Ampel / ampel (push) Successful in 1m35s
Der Einstellungs-Tab im Kino-Mix, statt magerer Feldliste: links eine
Sektions-Navigation, rechts Karten mit je einem Satz Einordnung —
Ablage (mit Struktur-Vorschau Filme/Musik/ISO/roh), Kompression
(Schalter, gemessene Encoder als Badges, Empfehlungs-Erklärung),
Metadaten (Key-Quellen im Klartext, hinterlegt/fehlt-Punkte), MakeMKV
(eigene Karte: Live-Schlüsselstand, Update- und Prüf-Knopf,
Dauerlizenz), Medienserver, Programm (Schalter, Update-Stand,
Versionszeile) und Werkzeuge — NEU mit den drei „Eigener
Pfad'-Feldern (werkzeug.*, vom Katalog seit je unterstützt, bisher
ohne UI).

Dazu: #einstellungen/#bibliothek als Startanker (RIPPY_START_BEREICH),
RIPPY_SMOKE_WARTE_MS für Bild-Beweise, die auf späte Kern-Antworten
warten (Einstellungen+Encoder-Messung kommen nach den 750 ms).
Version 5.1.0 — erste Fassung ohne Vorab-Suffix, der Bau erzeugt
damit erstmals latest.yml (der Update-Kanal aus § 6.8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 22:06:38 +02:00
HitonabiandClaude Fable 5 c12b1be448 docs: SAVEPOINT — Kino-Mix-Design durchgängig eingebaut
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 21:56:39 +02:00
HitonabiandClaude Fable 5 b9de2ba729 feat(v5): Kino-Mix-Design durchs ganze Fenster (Commander-Entscheid)
Ampel / ampel (push) Successful in 1m54s
Der auf dem Design-Canvas gewählte Mix aus Kinosaal und Schaltpult,
durchgängig über Übersicht, Einstellungen, Bibliothek, Erst-Einrichtung
und Dialoge: warmes Bühnen-Schwarz mit Saallicht und Filmkorn, Gold als
Akzent, Poster mit Goldkante, Plakat-Titel (Bricolage Grotesque) — dazu
die Schaltpult-Präzision: REC-Glühen bei laufendem Rip, Info-Kacheln
(Medium/Größe/Erkennung/Preset inkl. der echten Preset-Kette),
20-Segment-Aussteuerungsanzeige mit großer Mono-Prozentzahl,
Mono-Protokoll, LED-Systemleiste statt Status-Kacheln (Systemfehler
werden zur roten Karte, R4). Schriften gebündelt via fontsource
(OFL, bau/lizenzen) — zur Laufzeit lädt Rippy nichts aus dem Netz.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 21:54:23 +02:00
HitonabiandClaude Fable 5 98ca2855e9 docs: SAVEPOINT — MakeMKV-Selbst-Update und Kino-Look
Ampel / ampel (push) Successful in 1m27s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:20:13 +02:00
HitonabiandClaude Fable 5 d5ea48cd1e feat(v5): MakeMKV-Selbst-Update (§ 9) und Kino-Look fürs Fenster
Ampel / ampel (push) Successful in 1m34s
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste
gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung
(GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt
(shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll
bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher
Zweig: installierte == neueste Fassung → kein sinnloser Download.

Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung,
Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als
Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit
Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:18:08 +02:00
HitonabiandClaude Fable 5 5622d8e47d docs: SAVEPOINT — erster Commander-Test, drei Funde behoben
Ampel / ampel (push) Successful in 1m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:23:55 +02:00
HitonabiandClaude Fable 5 c5dd90cc18 fix(v5): Die drei Funde aus dem ersten Commander-Test
Ampel / ampel (push) Successful in 1m25s
FUND 1 — 'Key korrekt, aber als ungueltig angezeigt': Stimmt beides.
Live gemessen: Der aktuelle Forum-Key ist in Ordnung, aber MakeMKV
1.18.4 ist ZU ALT fuer ihn — makemkvcon meldet 5020 UND 5021 zusammen,
und die alte Pruefung brach beim ersten Code ab und beschuldigte den
Schluessel. urteilAusAusgabe sammelt jetzt ALLE Codes (5021 schlaegt
5020, MakeMKV-Version aus MSG 1005 wird mitgelesen) und die Kachel
sagt den wahren Satz: 'Der Key ist in Ordnung — MakeMKV 1.18.4 ist zu
alt. Rippy hat ihn entfernt, damit der Beta-Modus weiterlaeuft (Rips
klappen trotzdem). Abhilfe: MakeMKV aktualisieren.' Neues Feld
weiterBetrieb: Beta-Modus ist GELB (Warnung), nicht rot. Test mit der
echten Ausgabe als Fixture.

FUND 2 — 'Komprimieren auf CPU statt GPU': Der Default 'H.265 MKV
1080p30' ist ein CPU-Preset. Neue GEMESSENE Empfehlung
(gemeinsam/preset-empfehlung.ts): aus den echten Backends und der
echten Preset-Liste des eigenen HandBrake — VCN vor NVENC vor QSV vor
CPU, nur Namen die WIRKLICH in der Liste stehen, HandBrake-Presets
deckeln die Aufloesung nur (DVD bleibt SD). Kette: eigene Wahl >
allgemeines Preset > Empfehlung > CPU-Default. Auf dem Commander-PC:
bluray/dvd -> 'H.265 VCN 1080p', uhd -> 'H.265 VCN 2160p 4K'. Die
Preset-Auswahl zeigt 'automatisch: <Empfehlung>' als Leer-Option.

FUND 3 — 'keine Alternative waehlbar': 'Aendern …'-Dialog an der
Laufwerks-Kachel (§ 5 Schritt 4): TMDb-Suche (Film + Serie, mit
Postern), Klick uebernimmt — Nutzer-Wahl gilt als 100 % und steuert
Ablage-Ordner, NFO und Poster. Neue Nachrichten metadaten-suchen /
zuordnung-setzen / metadaten-vorschlaege.

Dazu: Poster in der Laufwerks-Kachel (CSP um image.tmdb.org
erweitert), Restzeit-Schaetzung am Fortschritt.

192 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:20:10 +02:00
HitonabiandClaude Fable 5 0e1c927e7b docs: SAVEPOINT — Rippy v5 komplett (W-0 bis W-7), Beweise und offene Hand-am-Geraet-Punkte
Ampel / ampel (push) Successful in 1m21s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:20:40 +02:00
HitonabiandClaude Fable 5 9fa3df500d feat(v5): Etappe W-6 — Setup, Selbst-Update, Erst-Einrichtung, Lizenzen
Ampel / ampel (push) Successful in 1m22s
electron-builder → NSIS: EIN RippySetup-<version>.exe, pro Benutzer,
ohne Adminrechte, eigenes Icon (PNG-basiertes ICO selbst gebaut).
extraResources: die MITGELIEFERTEN Werkzeuge (HandBrakeCLI GPL-2,
flac.exe MIT libFLAC.dll — die Ohne-DLL-Falle aus § 9) samt
Lizenztexten und Quellverweisen (bau/lizenzen, Entscheid 9); vendor/
liegt nicht im Git (74 MB), Befuellung steht in BAUEN.md. Der Kern
findet die mitgelieferten Werkzeuge ueber RIPPY_VENDOR (resourcesvendor) VOR allen anderen Suchwegen.

haupt/update.ts (§ 6.8): electron-updater gegen das oeffentliche Gitea
(festes Tag 'aktuell' als Rueckfallebene, Entscheid 6) — NUR https
(http wird ABGELEHNT; Regel-Tests), NIE waehrend eines Rips (der Haupt
kennt die aktiven Vorgaenge), Fehlschlaege still aber ablesbar
(letzte erfolgreiche Pruefung im Status), installiert beim naechsten
Start. haupt/autostart.ts: HKCU-Run ueber app.setLoginItemSettings,
Toggle in den Einstellungen. Erst-Einrichtung (§ 6.9) im Programm:
5 Schritte, NUR ein Fehler blockiert (kein Laufwerk = Warnung,
MakeMKV-Pflicht mit Klartext-Weg).

GEBAUT UND GEMESSEN (30.08.2026):
- dist-setup/RippySetup-5.0.0-w0.exe — 130,8 MB, SHA-256
  07D39D6BAB74A47C21AA53D847FB5A5DB6990AE9FA1CBC7BE814B51050883115,
  plus .blockmap.
- MESSUNG: Die Update-Auskunft heisst bei Vorab-Versionen nach dem
  KANAL (w0.yml) — erst ein Release ohne Suffix erzeugt latest.yml
  (steht jetzt in BAUEN.md).
- Das GEPACKTE Programm (win-unpacked, asar + resources) besteht den
  Smoke: SMOKE OK, und der Screenshot zeigt die Erst-Einrichtung —
  das Bild eines frischen Rechners.

Bewusst offen (SAVEPOINT): die automatische MakeMKV-Beschaffung
(beschaffen.ts — Download+Installer-Start braucht einen Live-Test);
das Setup NENNT den Weg klar. 185 Tests gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:03:40 +02:00
HitonabiandClaude Fable 5 2d9cef22b3 feat(v5): Etappe W-7 — Audio-CD, ISO-Backup, MakeMKV-Schluesselkette
Ampel / ampel (push) Successful in 1m20s
rip/audiocd.ts: die Rechnungen PUR (MSF→LBA mit den 150 Vorlauf-Frames,
TOC-Zerlegung mit Lead-Out-Pflicht und Daten-Track-Bit, 27-Sektoren-
Bloecke unter der 64-KB-Treibergrenze, RAW_READ_INFO mit DiskOffset in
2048ern OBWOHL der Sektor 2352 hat, WAV-Kopf, AC/DC-sichere Namen).
metadaten/musicbrainz.ts: DiscID nach musicbrainz.org-Rechnung — gegen
die ECHTE AC/DC-Kennung getestet (3KVSIWn_…), Vorlauf wird beim Melden
wieder HINZUgerechnet; Tags je Spur mit Bindewort-Kuenstlern.
laufwerk/disc.ts: audioToc + audioSpurLesen ueber die LaufwerkApi.

rip/iso.ts (§ 6.4): 1:1-Abbild fuer unbekannte Discs — async (ein
33-GB-Abbild darf den Kern nicht blockieren), sektor-ausgerichtet,
unvollstaendig heisst FEHLER mit Byte-Zahlen, nie Schoenfaerberei.

werkzeuge/schluessel.ts (§ 9.1): Dauerlizenz schlaegt Forum-Beta-Key
(t=1053, T-Muster aus makemkv_key.py), Ablage in HKCU ueber reg.exe
(kein zweiter koffi-Ort), GEFRAGT statt gerechnet (makemkvcon info
disc:9999, MSG 5020/5021/5051), abgelehnter Key wird ZURUECKGENOMMEN
(schlimmer als keiner: 0 Titel, kein Laufwerk), app_UpdateEnable nur-
wenn-fehlt (der 4K-Schluesselkanal). Kern prueft beim Start und
taeglich; Dashboard-Kachel zeigt Stand + letzten Pruefzeitpunkt;
Dauerlizenz-Feld in den Einstellungen.

Pipeline verzweigt nach Disc-Typ: cd → Sektoren→WAV→FLAC mit
MusicBrainz-Tags (Ordner 'Kuenstler - Album', ohne Treffer ehrlich
'Track NN'), unknown → ISO. Bibliothek-Eintraege fuer beide.

181 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:56:07 +02:00
HitonabiandClaude Fable 5 c78ff22ab7 feat(v5): Etappe W-5 — Oberflaeche: Tray, Toasts, Taskleiste, Einstellungen, Bibliothek
Ampel / ampel (push) Successful in 1m23s
haupt/: tray.ts (Symbol mit Zustand, Kontextmenue, Fenster zu = weiter-
laufen, Beenden nur noch gewollt), Windows-Toasts bei Disc erkannt /
fertig / Fehler (Klick holt das Fenster), Taskleisten-Fortschritt
(setProgressBar, Fehlermodus), echter Windows-Ordnerdialog per IPC.
Eigenes Icon (bau/icon.png + tray.png, Disc-Optik).

Kern: Einstellungen lesen/schreiben NUR ueber die eine Quelle (§ 6.7,
Whitelist der Schluessel), Werkzeug-Auskunft (Katalog + gemessene
HandBrake-Presets/Backends), Bibliothek in db.ts (Schema v2 —
fingerprint, Titel, Groesse, Ort, Dauer), Duplikat-Erkennung beim
Einlegen (§ 6.6: 'schon einmal gerippt am …' als disc-info + Toast),
Pipeline traegt fertige Vorgaenge ein.

Fenster: drei Bereiche (Uebersicht / Einstellungen / Bibliothek).
Einstellungen: Ablage mit Durchsuchen-Dialog, Kompression je Disc-Typ
(Preset-Liste VOM eigenen HandBrake gemessen, 'NICHT komprimieren'
waehlbar), Sprachlisten, TMDb/OMDb-Keys, Medienserver, Werkzeug-Stand
mit Klartext (gefunden/fehlt + Folgen). Bibliothek als Tabelle.

Der Smoke-Screenshot zeigt nebenbei den Ernstfall in schoen: Das
Laufwerk ist nach dem rc11-Vorfall inzwischen auch am Storage-Stack in
'unknown' gekippt — und die Kachel zeigt den ZUGRIFFS_GRUENDE-Klartext
samt Abhilfe statt eines stummen Zustands.

165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:47:39 +02:00
HitonabiandClaude Fable 5 a9fa8fa2d8 fix(v5): discmerkmale auf Plattform-Pfade — dritte Lehre desselben Musters
Ampel / ampel (push) Successful in 1m17s
titelAusBdmt lief auf der Laufzeit-Wurzel (G:\ im Betrieb, tmp-Ordner im
Linux-CI-Test) mit win32-join — der BDMV-Pfad wurde auf POSIX zu EINEM
Namen mit Backslashes und der Test brach. Dieselbe Regel wie
struktur/pipeline: Laufzeit-Wurzeln nehmen plattform-path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:41:45 +02:00
HitonabiandClaude Fable 5 5f386e5406 feat(v5): Etappe W-4 — Metadaten-Automatik mit Sicherheitsgrad
Ampel / ampel (push) Failing after 1m20s
metadaten/: discmerkmale.ts (BDMT-Studio-Titel, ISO-9660- und
UDF-Volume-Label direkt vom Medium — OHNE koffi, ueber fs auf \.\G: —,
Label-Normalisierung, Fingerabdruck, progressive Titel-Kandidaten),
tmdb.ts (v3-Key UND v4-Token, de-DE, /find fuer deutsche Texte zu
OMDb-Treffern), omdb.ts, zuordnung.ts (die Confidence-Kaskade
0,95/0,90/0,80/0,60/0,30 aus prescan.py). ablage/poster.ts. Kern meldet
'disc-info' beim Einlegen; die Pipeline benennt den fertigen Ordner
'<Titel> (Jahr)', schreibt movie.nfo/tvshow.nfo mit echten Metadaten
und laedt das Poster. Fenster zeigt 'sehr wahrscheinlich: … · 95 %'.

ZWEI Kaskaden-Schwaechen LIVE GEFUNDEN und gehaertet (gegen die echten
APIs gemessen, Keys aus den Docker-Settings der VM, nicht persistiert):
1. 'Spartacus Gods Of The Arena' wurde zum FILM 'Spartacus' (1960) —
   der stark gekuerzte Kandidat traf woertlich, bevor die volle Anfrage
   als spezifische SERIE galt. Kaskade jetzt JE KANDIDAT
   woertlich→spezifisch (spezifisch nur fuer die volle Anfrage, auch
   fuer Serien). Nachgemessen: jetzt Serie, 90 %.
2. OMDbs t=-Suche machte aus dem Label 'Bd Evg D2' selbstbewusst
   'BD Scream 5' — jetzt Wort-Jaccard-Gate >= 0,5. Nachgemessen: der
   Fall ist jetzt ein ehrlicher 60-%-Vorschlag, den das UI nachfragt.

Messwerte: Akira→95% · Evangelion 2.22→80% (der dokumentierte Fund) ·
Spartacus GotA→Serie 90% · Bd Evg D2→Vorschlag 60%.

Dazu ein neuer Quelltext-Hygiene-Waechter (keine rohen Steuerbytes —
zweimal beim Schreiben passiert, einmal haette ein Test dadurch anders
gemessen als gelesen). 165 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:39:09 +02:00
HitonabiandClaude Fable 5 e840b7f327 fix(v5): Laufzeit-Pfade plattformneutral — pipeline/struktur auf node:path
Ampel / ampel (push) Successful in 1m21s
Der NFO-Ablage-Test brach in der Linux-CI: pipeline/struktur bauten mit
path.win32 auf POSIX-Testordnern (mkdir legte 'x\Filme' als EINEN Namen
an, readdir fand nichts). Regel jetzt dokumentiert: Module ueber
LAUFZEIT-Wurzeln (Ablage) nutzen plattform-path — zur echten Laufzeit
immer Windows, in der CI physisch korrekt auf POSIX; Module mit FESTER
Windows-Semantik (katalog, dateiDaneben) bleiben bei path.win32.
Testerwartungen entsprechend ueber join() statt Literale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:29:46 +02:00
HitonabiandClaude Fable 5 3dc296f071 feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem
Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions-
Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN),
--format aus der Endung (Container kommt sonst aus dem Preset),
Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12
Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen
(kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung
mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help,
--preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ,
PRESET_KEINE, Sprachlisten.

ablage/: struktur.ts (sichere Namen, '<Titel> (Jahr)', Season NN,
Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema),
medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie).

ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als
EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und
nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird
in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI).

GEMESSEN am echten System (30.08.2026):
- Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1
  (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets.
- Probe-Encode 5 s Material: success in 45,5 s.
- VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11
  am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min
  auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt:
  E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2
  (2011)\ mit movie.nfo.
- Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was
  not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen
  und es nicht sind (der abgebrochene rc11-Rip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:26:00 +02:00
HitonabiandClaude Fable 5 ca338ad467 fix(v5): Windows-Pfadregeln ausdruecklich — path.win32 statt join
Ampel / ampel (push) Successful in 1m21s
Die Ampel (Linux) brach an den Katalog-Tests: das plattformabhaengige
path.join mischte dort Schraegstriche in Windows-Pfade. Rippy v5 baut
NUR Windows-Pfade — katalog.ts, handbrake.ts und ablauf/rippen.ts
rechnen jetzt ausdruecklich mit path.win32 und damit auf jeder
Plattform gleich. Im node:24-Container nachgestellt: vorher 2 failed,
mit dem Fix lokal 96 gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:16:17 +02:00
HitonabiandClaude Fable 5 844bca37ff feat(v5): Etappe W-2 — Rippen: Parser, makemkvcon-Lauf, Werkzeug-Katalog
Ampel / ampel (push) Failing after 1m22s
rip/parser.ts: die reine Textverarbeitung mit allen bezahlten Fallen aus
dem Docker-Zweig — quelle() (dev:G: statt dev:\.\G:, der 404-Verwandte),
textVon() (UTF-8 streng, sonst cp1252 — nie wieder 'Öffnen'), parseMsg
(Regex statt split — Meldungen mit Komma), PRGV (-1 vs. echtes 0 %),
TINFO/SINFO (Sprache aus Attr 3/4, NICHT 28/29), laengsterTitel mit
Play-All-Schutz, episodenTitel, KRITISCHE_CODES (sprachfeste NUMMERN).

rip/makemkv.ts: Kommandobau + Lauf — ZeilenLeser (binaer, halbe Zeilen,
je Zeile dekodiert), RipAuswertung (pur), rippen() mit AbortSignal,
Lesefehler-Flag AUCH bei Rueckgabewert 0. werkzeuge/katalog.ts: die
Suchreihenfolge samt %VAR%-Gross/Klein-Falle und Registry-Rueckfall.
ablauf/rippen.ts: ein Rip je Laufwerk, Status-Push, 'erst nachsehen,
dann rippen' (unbekannt heisst trotzdem rippen). Fenster: Rippen-Knopf,
Fortschrittsbalken, Abbrechen; Auswurf waehrend des Rips abgelehnt.

Gemessen am echten System (30.08.2026): Katalog findet
makemkvcon64 (Program Files x86), HandBrakeCLI und flac (beide im
rc11-Werkzeugordner LOCALAPPDATA\Rippy\tools). NEUER BEFUND: Nach dem
abgebrochenen rc11-Rip vom Mittag sieht MakeMKV GAR KEIN Laufwerk mehr
(MSG 5042, leere DRV-Liste, mit und ohne --noscan), waehrend die
Storage-IOCTLs sauber antworten — MakeMKV spricht SCSI direkt. 5042 ist
jetzt ein KRITISCHER_CODE mit Klartext-Abhilfe. Der volle Rip-Beweis
(messung.rip.test.ts liegt bereit) braucht deshalb erst eine Hand am
Geraet: Disc neu einlegen oder USB ab-/anstecken.

96 Tests gruen, Smoke gruen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:12:44 +02:00
HitonabiandClaude Fable 5 a45069af5e feat(v5): Etappe W-1 — Laufwerk: Wache, Typ, Auswerfen mit Nachsehen
Ampel / ampel (push) Successful in 1m21s
kern/laufwerk in drei Schichten: codes.ts (Steuercodes HERGELEITET wie
win_ioctl.py, gegen die Doku-Zahlen getestet), win32.ts (der einzige
koffi-Ort im Kern, Muster aus beweise/laufwerk.js), disc.ts (Logik gegen
die LaufwerkApi-Schnittstelle — classify mit den cdrom.py-Schwellen,
Geraete-Info mit ready/empty/unknown + Klartext-Grund, Auswurf als
entriegeln→auswerfen→NACHSEHEN mit injizierbarem Warten). Dazu wache.ts
im 3-Sekunden-Takt (§ 5 Plan A): Uebergangslogik pur, gescheiterte Runde
behaelt den Stand (R2) und meldet laut (R4).

Fenster zeigt Laufwerke live (Modell, Typ, Groesse, Grund) mit
Auswerfen-Knopf; Ereigniszeilen fuer Disc rein/raus und Fehler.

Bündel-Falle gefunden und behoben: Ein woertliches require überlebt
Rollup nicht — win32 wird per dynamischem import() als eigener Chunk
gebaut; der Fehler war dank R4 im Fenster sichtbar statt still.

Gemessen am echten BU40N (30.08.2026, RIPPY_MESSUNG=1):
  Laufwerk G: status=ready typ=bluray groesse=33759690752
  modell=HL-DT-ST BD-RE BU40N 1.03 serial=0025114C0149
Der Auswurf-Beweis (messung.auswurf) folgt am Ende der Sitzung — der
BU40N kann die Schublade nicht selbst einziehen.

52 Tests gruen (Steuercodes, classify, Auswurf-Ablauf, Geraete-Info,
Wache, Smoke weiterhin gruen mit Laufwerks-Kachel im Bild).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:19:51 +02:00
HitonabiandClaude Fable 5 a172a3e2c0 fix(ci): setup-node raus — der Runner hat Node 24 nativ
Ampel / ampel (push) Failing after 1m20s
Lauf 221 brach nach 2:14 min ab; der einzige neue Schritt war
actions/setup-node@v4. Nachgemessen am 30.08.2026: Das Runner-Image
docker.gitea.com/runner-images:ubuntu-latest bringt Node v24.18.0 und
npm 11.16 von Haus aus mit, und die kompletten Node-Schritte der Ampel
(npm ci + build + test in rippy-windows) laufen in einem frischen
node:24-Container auf der VM gruen durch. Statt Beschaffungs-Magie
prueft die Ampel jetzt im Klartext, dass Node >= 22 da ist — zu alt
heisst ROT mit Ansage, nicht kryptisch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 15:38:39 +02:00
139 changed files with 29688 additions and 6 deletions
+11
View File
@@ -52,6 +52,17 @@ jobs:
if [ -n "$pkg_dateien" ]; then if [ -n "$pkg_dateien" ]; then
echo "== Node/TypeScript erkannt ==" echo "== Node/TypeScript erkannt =="
# Rippy v5 (rippy-windows/) braucht Node >= 22 (node:sqlite, Vite 7).
# Das Runner-Image (docker.gitea.com/runner-images:ubuntu-latest)
# bringt Node 24 nativ mit — am 30.08.2026 nachgemessen. Ein
# actions/setup-node-Schritt scheiterte hier dagegen (Lauf 221);
# deshalb die Prüfung als Klartext statt Beschaffungs-Magie.
node_major=$(node -p 'parseInt(process.versions.node)' 2>/dev/null || echo 0)
echo "-- Node im Runner: $(node -v 2>/dev/null || echo 'FEHLT')"
if [ "$node_major" -lt 22 ]; then
echo "❌ Node zu alt für rippy-windows (braucht >= 22) — Runner-Image prüfen."
rot=1
fi
while IFS= read -r pkg; do while IFS= read -r pkg; do
[ -n "$pkg" ] || continue [ -n "$pkg" ] || continue
d=$(dirname "$pkg") d=$(dirname "$pkg")
+3
View File
@@ -55,3 +55,6 @@ logs/
*.db *.db
*.sqlite *.sqlite
dist/ dist/
# Design-Canvas-Arbeitsdateien (leben im Claude-Artifact, nicht im Git)
design-entwuerfe/
+1131
View File
File diff suppressed because it is too large Load Diff
+1497 -1
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
# Beweise — die Messungen zu KONZEPT-WINDOWS.md § 3
Diese zwei Skripte sind die Grundlage für die Entscheidung, Rippy v5 in
TypeScript/Node zu bauen. Sie beantworten die einzige Frage, an der das ganze
Konzept hängt:
> **Kann Node überhaupt alles, was Rippy am Laufwerk braucht?**
Wäre die Antwort nein gewesen, wäre das Konzept wertlos gewesen. Deshalb sind
sie zuerst gelaufen — vor der ersten Zeile Konzept.
`AGENTS.md` Regel D: *„Externe Schnittstellen NIE aus dem Kopf."*
---
## Nachstellen
```
npm install koffi
node laufwerk.js
```
Gemessen am 30.08.2026 mit Node v24.19.0, koffi 3.1.6, an einem
HL-DT-ST BD-RE BU40N (USB) mit eingelegter Blu-ray.
---
## `laufwerk.js` — Win32-Laufwerkszugriff
Ruft dieselben Steuercodes auf wie `src/rippy/drives/win_ioctl.py` im
Docker-Zweig. **Nur lesend** — kein Auswerfen, kein Verriegeln.
```
1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke: [ 'G' ]
2. CreateFileW \\.\G: (Zugriff 0) -> offen
CreateFileW \\.\G: (GENERIC_READ) -> offen
3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller: HL-DT-ST | Modell: BD-RE BU40N
| Rev: 1.03 | Serial: 0025114C0149
4. IOCTL_STORAGE_CHECK_VERIFY2 -> Medium eingelegt
5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler 50
6. IOCTL_DISK_GET_LENGTH_INFO -> 33.759.690.752 Bytes (33,76 GB) => Blu-ray
```
**Punkt 5 ist kein Fehlschlag.** Fehler 50 ist `ERROR_NOT_SUPPORTED` — genau
das, was der Python-Treiber an diesem Laufwerk auch sieht (`SAVEPOINT.md`,
v4.0-rc11, Abschnitt „Das Laufwerk"). Node verhält sich identisch. Die Lehre
steht im alten Code und gilt weiter: **Die Disc-Einordnung darf sich nicht auf
`IOCTL_CDROM_DISK_TYPE` verlassen — die Größe ist der verlässliche Weg.**
Punkt 2 zeigt die Unterscheidung, die in rc11 teuer war: `Zugriff 0` fragt das
**Gerät**, `GENERIC_READ` fragt das **Medium**. Bei einer gestörten Disc
scheitert das zweite und das erste geht weiter — wer nur eins probiert, hält
ein antwortendes Laufwerk für tot.
---
## `leine.js` — die Prozess-Leine (Job Object)
Stellt den Befund aus `SAVEPOINT.md` v4.0-rc10 nach: Windows räumt
Kindprozesse **nicht** auf. Ein hart beendeter Rippy hinterlässt ein
`makemkvcon`, das das Laufwerk festhält, und jeder spätere Rip scheitert.
Das Skript setzt eine Arbeitsgruppe mit `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`,
startet ein langlebiges Kind und lässt sich dann hart abschießen —
`taskkill /F` **ohne** `/T`, also genau das, was bei einem Absturz und beim
Drüber-Installieren passiert.
```
1. CreateJobObjectW -> Arbeitsgruppe angelegt
2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt
3. AssignProcessToJobObject -> dieser Prozess hängt in der Gruppe
4. Kindprozess gestartet, PID 16040
vor dem Abschuss: Eltern lebt: True Kind lebt: True
taskkill /F /PID 29488 -> ERFOLGREICH
danach: Eltern lebt: False Kind lebt: False
```
Nachstellen (PowerShell):
```powershell
$p = Start-Process node -ArgumentList "leine.js" -PassThru -RedirectStandardOutput out.txt
Start-Sleep 3
$kind = (Select-String -Path out.txt -Pattern 'KIND-PID=(\d+)').Matches[0].Groups[1].Value
taskkill /F /PID $p.Id
Start-Sleep 2
Get-Process -Id $kind -ErrorAction SilentlyContinue # muss LEER sein
```
---
## `sqlite-main.js` + `sqlite-kern.js` — node:sqlite in Electron
Beantwortet die Frage, die in der ersten Fassung des Konzepts als ⚠️ offen
stand: Electron baut sein Node mit eigenen Flags — ist `node:sqlite` dort
überhaupt vorhanden?
Gemessen am 30.08.2026 mit **Electron 44.0.0**, und zwar im
**utilityProcess** — dem Kontext, in dem Rippy v5 seine Datenbank betreiben
wird (`kern/speicher/db.ts`), nicht bloß im Node-Modus der Binary:
```
KERN-ANTWORT: {"ok":true,"wert":"geht","node":"24.18.1"}
Kern beendet mit 0
```
**Antwort: ja.** Die im Konzept vorgesehene Rückfallebene `better-sqlite3`
wird nicht gebraucht. (Zur Einordnung: In Node 24.19.0 pur war dieselbe
Messung zuvor ebenfalls grün.)
Nachstellen:
```
npm install electron@44 --no-save
node node_modules/electron/install.js
./node_modules/electron/dist/electron.exe sqlite-main.js
```
Die zweite Zeile ist kein Versehen — siehe nächster Abschnitt.
---
## npm 11 blockiert Install-Skripte — und das trifft Electron selbst
```
npm warn allow-scripts koffi@3.1.6 (install: node ./cnoke.cjs -P . --prebuild --release)
```
koffi lädt trotzdem — es bringt vorgebaute Binärdateien mit. **Electron
trifft die Sperre härter:** Sein Install-Skript lädt die eigentliche
Programmdatei herunter; wird es geblockt, liegt nach `npm install` ein
Paket **ohne `dist/electron.exe`** da, und nichts startet. Am 30.08.2026
beim sqlite-Beweis selbst hineingelaufen; `node node_modules/electron/install.js`
holt die Binary nach. Beides gehört in die Bau-Anleitung, sonst sucht eines
Tages jemand an der falschen Stelle.
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+109
View File
@@ -0,0 +1,109 @@
// Beweis-Test: Kann Node/koffi dieselben Win32-Aufrufe wie Rippys Python-Treiber?
// NUR LESEND — kein Auswerfen, kein Verriegeln.
const koffi = require('koffi');
// ── CTL_CODE aus winioctl.h, eins zu eins wie in src/rippy/drives/win_ioctl.py
const ctl = (typ, fn, methode, zugriff) =>
((typ << 16) | (zugriff << 14) | (fn << 2) | methode) >>> 0;
const FILE_DEVICE_CD_ROM = 0x02, FILE_DEVICE_DISK = 0x07, FILE_DEVICE_MASS_STORAGE = 0x2d;
const IOCTL_STORAGE_CHECK_VERIFY2 = ctl(FILE_DEVICE_MASS_STORAGE, 0x0200, 0, 0);
const IOCTL_STORAGE_QUERY_PROPERTY = ctl(FILE_DEVICE_MASS_STORAGE, 0x0500, 0, 0);
const IOCTL_CDROM_DISK_TYPE = ctl(FILE_DEVICE_CD_ROM, 0x0010, 0, 0);
const IOCTL_DISK_GET_LENGTH_INFO = ctl(FILE_DEVICE_DISK, 0x0017, 0, 1);
const kernel32 = koffi.load('kernel32.dll');
const GetLogicalDrives = kernel32.func('uint32_t __stdcall GetLogicalDrives()');
const GetDriveTypeW = kernel32.func('uint32_t __stdcall GetDriveTypeW(str16 lpRootPathName)');
const CreateFileW = kernel32.func('void* __stdcall CreateFileW(str16 lpFileName, uint32_t dwDesiredAccess, uint32_t dwShareMode, void *lpSecurityAttributes, uint32_t dwCreationDisposition, uint32_t dwFlagsAndAttributes, void *hTemplateFile)');
const CloseHandle = kernel32.func('bool __stdcall CloseHandle(void *hObject)');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
const DeviceIoControl = kernel32.func('bool __stdcall DeviceIoControl(void *hDevice, uint32_t dwIoControlCode, void *lpInBuffer, uint32_t nInBufferSize, _Out_ void *lpOutBuffer, uint32_t nOutBufferSize, _Out_ uint32_t *lpBytesReturned, void *lpOverlapped)');
const GENERIC_READ = 0x80000000, FILE_SHARE_READ = 1, FILE_SHARE_WRITE = 2, OPEN_EXISTING = 3;
const DRIVE_CDROM = 5;
// ── 1. Laufwerke finden (die Windows-Entsprechung von glob /dev/sr*)
const maske = GetLogicalDrives();
const optische = [];
for (let i = 0; i < 26; i++) {
if (!(maske & (1 << i))) continue;
const buchstabe = String.fromCharCode(65 + i);
if (GetDriveTypeW(buchstabe + ':\\') === DRIVE_CDROM) optische.push(buchstabe);
}
console.log('1. GetLogicalDrives + GetDriveTypeW -> optische Laufwerke:', optische);
if (!optische.length) { console.log(' Kein optisches Laufwerk — Test endet hier.'); process.exit(0); }
const lw = optische[0];
// ── 2. Gerät öffnen. Zugriff 0 = nur das GERÄT fragen, nicht das Medium.
// Genau die Unterscheidung aus SAVEPOINT rc11: Bei gestörtem Medium
// scheitert GENERIC_READ, Zugriff 0 geht weiter.
function oeffnen(zugriff) {
const h = CreateFileW('\\\\.\\' + lw + ':', zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE, null, OPEN_EXISTING, 0, null);
const adr = koffi.address(h);
// INVALID_HANDLE_VALUE ist -1, als vorzeichenlose 64-Bit-Zahl 0xFFFF...
if (adr === 0n || adr === 0xffffffffffffffffn) return { h: null, fehler: GetLastError() };
return { h, fehler: 0 };
}
const ohneMedium = oeffnen(0);
console.log(`2. CreateFileW \\\\.\\${lw}: (Zugriff 0) -> ` +
(ohneMedium.h ? 'offen' : 'Win32-Fehler ' + ohneMedium.fehler));
const mitLesen = oeffnen(GENERIC_READ);
console.log(` CreateFileW \\\\.\\${lw}: (GENERIC_READ) -> ` +
(mitLesen.h ? 'offen' : 'Win32-Fehler ' + mitLesen.fehler));
const h = ohneMedium.h;
if (!h) { console.log(' Gerät nicht ansprechbar — Test endet hier.'); process.exit(0); }
const rueck = Buffer.alloc(4);
// ── 3. Hersteller/Modell/Serial (STORAGE_DEVICE_DESCRIPTOR)
const anfrage = Buffer.alloc(12);
anfrage.writeUInt32LE(0, 0); // PropertyId = StorageDeviceProperty
anfrage.writeUInt32LE(0, 4); // QueryType = PropertyStandardQuery
const antwort = Buffer.alloc(1024);
if (DeviceIoControl(h, IOCTL_STORAGE_QUERY_PROPERTY, anfrage, 12, antwort, 1024, rueck, null)) {
const text = (off) => {
const start = antwort.readUInt32LE(off);
if (!start || start >= antwort.length) return '';
let ende = start; while (ende < antwort.length && antwort[ende] !== 0) ende++;
return antwort.toString('latin1', start, ende).trim();
};
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Hersteller:', text(12),
'| Modell:', text(16), '| Rev:', text(20), '| Serial:', text(24));
} else {
console.log('3. IOCTL_STORAGE_QUERY_PROPERTY -> Win32-Fehler', GetLastError());
}
// ── 4. Liegt ein Medium drin? (ERROR_NOT_READY = 21 heißt: leer)
const okVerify = DeviceIoControl(h, IOCTL_STORAGE_CHECK_VERIFY2, null, 0, null, 0, rueck, null);
console.log('4. IOCTL_STORAGE_CHECK_VERIFY2 -> ' +
(okVerify ? 'Medium eingelegt' : 'kein Medium (Win32-Fehler ' + GetLastError() + ')'));
// ── 5. Audio- oder Datenspur?
const typBuf = Buffer.alloc(1);
if (DeviceIoControl(h, IOCTL_CDROM_DISK_TYPE, null, 0, typBuf, 1, rueck, null)) {
const t = typBuf[0];
console.log('5. IOCTL_CDROM_DISK_TYPE -> ' +
(t === 1 ? 'Audio-CD' : t === 2 ? 'Daten-Disc' : 'Wert ' + t));
} else {
console.log('5. IOCTL_CDROM_DISK_TYPE -> Win32-Fehler', GetLastError());
}
// ── 6. Größe des Mediums (für die Unterscheidung DVD / BD / UHD)
const laenge = Buffer.alloc(8);
if (mitLesen.h && DeviceIoControl(mitLesen.h, IOCTL_DISK_GET_LENGTH_INFO, null, 0, laenge, 8, rueck, null)) {
const bytes = laenge.readBigUInt64LE(0);
const gb = Number(bytes) / 1e9;
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> ' + bytes + ' Bytes (' + gb.toFixed(2) + ' GB)' +
' => Einordnung: ' + (gb > 60 ? 'UHD' : gb > 9.5 ? 'Blu-ray' : gb > 1 ? 'DVD' : 'CD'));
} else {
console.log('6. IOCTL_DISK_GET_LENGTH_INFO -> Win32-Fehler', GetLastError());
}
CloseHandle(h);
if (mitLesen.h) CloseHandle(mitLesen.h);
console.log('\nAlle Handles geschlossen. Nichts ausgeworfen, nichts verriegelt.');
+114
View File
@@ -0,0 +1,114 @@
// MESSUNG: Überlebt ein ausdrücklich ausgeklinktes Kind den Tod seines
// Elternprozesses — und stirbt ein normales Kind weiterhin mit?
//
// Anlass (01.09.2026): Das Update auf 5.1.3 lief nicht. Rippy beendete
// sauber, aber der Installer startete nie. Verdacht: Die Prozess-Leine
// (§ 3.3, KILL_ON_JOB_CLOSE) erschlägt den Installer, weil
// electron-updater ihn per spawn(…, { detached: true }) startet und
// `detached` unter Windows KEIN Breakaway setzt.
//
// Aufbau (drei Läufe, der äußere ruft die inneren):
// node beweise/leine-breakaway.js
// -> startet zweimal sich selbst als „innen":
// innen-ohne : Arbeitsgruppe NUR mit KILL_ON_JOB_CLOSE, Enkel normal
// innen-mit : Arbeitsgruppe zusätzlich mit BREAKAWAY_OK,
// Enkel mit CREATE_BREAKAWAY_FROM_JOB
// Beide beenden sich sofort. Danach schaut der äußere Lauf nach,
// welcher Enkel noch lebt.
//
// Erwartung: ohne Ausklinken stirbt der Enkel, mit Ausklinken lebt er.
//
// Ausführen: node beweise/leine-breakaway.js
const { execFileSync, spawnSync } = require('node:child_process')
const path = require('node:path')
const koffi = require(path.join(__dirname, '..', 'rippy-windows', 'node_modules', 'koffi'))
const JOB_KLASSE = 9 // JobObjectExtendedLimitInformation
const KILL_ON_JOB_CLOSE = 0x2000
const BREAKAWAY_OK = 0x0800
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
const CREATE_NO_WINDOW = 0x08000000
const STRUKTUR_GROESSE = 144
const LIMIT_FLAGS_OFFSET = 16
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
const kernel32 = koffi.load('kernel32.dll')
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *a, str16 n)')
const SetInformationJobObject = kernel32.func(
'bool __stdcall SetInformationJobObject(void *j, int k, void *i, uint32_t l)',
)
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *j, void *p)')
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()')
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 an, void *cl, void *pa, void *ta, bool ih, uint32_t cf, void *env, str16 cd, void *si, void *pi)',
)
/** Ein Enkel, der 40 Sekunden lebt — lange genug zum Nachsehen. */
const ENKEL = 'C:\\Windows\\System32\\cmd.exe'
const ENKEL_ZEILE = '"C:\\Windows\\System32\\cmd.exe" /c ping -n 40 127.0.0.1'
function innen(jobErlaubtAusklinken, kindKlinktSichAus) {
const job = CreateJobObjectW(null, null)
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(KILL_ON_JOB_CLOSE | (jobErlaubtAusklinken ? BREAKAWAY_OK : 0), LIMIT_FLAGS_OFFSET)
SetInformationJobObject(job, JOB_KLASSE, info, STRUKTUR_GROESSE)
AssignProcessToJobObject(job, GetCurrentProcess())
const si = Buffer.alloc(STARTUPINFO_GROESSE)
si.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const pi = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
const zeile = Buffer.from(ENKEL_ZEILE + '\0', 'utf16le')
// CREATE_NO_WINDOW gibt dem Enkel eine EIGENE (unsichtbare) Konsole.
// Ohne das teilt er sich die Konsole des Elternprozesses und stirbt mit
// IHR, nicht mit der Arbeitsgruppe — der erste Messlauf am 01.09.2026
// lief genau in diese Falle und zeigte fälschlich „Ausklinken wirkt
// nicht". Beide Fälle bekommen es, damit der Vergleich fair bleibt.
const flags = CREATE_NO_WINDOW | (kindKlinktSichAus ? CREATE_BREAKAWAY_FROM_JOB : 0)
const ok = CreateProcessW(ENKEL, zeile, null, null, false, flags, null, null, si, pi)
if (!ok) {
console.log(`PID=0 FEHLER=${GetLastError()}`)
return
}
// dwProcessId steht in PROCESS_INFORMATION an Offset 16.
console.log(`PID=${pi.readUInt32LE(16)} FEHLER=0`)
}
function lebt(pid) {
const ergebnis = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH'], { encoding: 'utf8' })
return (ergebnis.stdout || '').includes(String(pid))
}
function aussen() {
const faelle = [
['1) Leine wie bisher, Kind normal — die Ausgangslage', 'alt', false],
// DIE Regressionsfrage: Weicht das blosse ERLAUBEN die Leine auf?
// Wenn hier JA stuende, waere Paragraph 3.3 kaputt und makemkvcon
// koennte als Waise das Laufwerk festhalten (der rc10-Fund).
['2) Leine erlaubt Ausklinken, Kind normal — MUSS weiter sterben', 'erlaubt-normal', false],
['3) Leine erlaubt Ausklinken, Kind klinkt sich aus — der Installer', 'erlaubt-aus', true],
]
for (const [name, modus, erwartetLebend] of faelle) {
const ausgabe = execFileSync(process.execPath, [__filename, modus], { encoding: 'utf8' }).trim()
const pid = Number(/PID=(\d+)/.exec(ausgabe)?.[1] ?? 0)
const fehler = Number(/FEHLER=(\d+)/.exec(ausgabe)?.[1] ?? 0)
// Der innere Lauf ist hier schon beendet — die Arbeitsgruppe ist also
// zugeschnappt, falls sie zuschnappt.
const nochDa = pid > 0 ? lebt(pid) : false
const urteil = nochDa === erwartetLebend ? 'WIE ERWARTET' : 'ABWEICHUNG'
console.log(
`${name}\n Enkel-PID ${pid || '—'}${fehler ? ` (Win32-Fehler ${fehler})` : ''}` +
`\n lebt nach dem Tod des Elternprozesses: ${nochDa ? 'JA' : 'NEIN'} -> ${urteil}\n`,
)
if (nochDa) spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'])
}
}
const modus = process.argv[2] ?? ''
if (modus === 'alt') innen(false, false)
else if (modus === 'erlaubt-normal') innen(true, false)
else if (modus === 'erlaubt-aus') innen(true, true)
else aussen()
+50
View File
@@ -0,0 +1,50 @@
// Beweis-Test 2: Kann Node eine Windows-Arbeitsgruppe (Job Object) setzen,
// die Kindprozesse mitreisst, wenn der Elternprozess HART beendet wird?
// Das ist der Mechanismus aus SAVEPOINT v4.0-rc10 — ohne ihn ueberlebt
// makemkvcon Rippy und haelt das Laufwerk fest.
const koffi = require('koffi');
const { spawn } = require('child_process');
const kernel32 = koffi.load('kernel32.dll');
const CreateJobObjectW = kernel32.func('void* __stdcall CreateJobObjectW(void *lpJobAttributes, str16 lpName)');
const SetInformationJobObject = kernel32.func('bool __stdcall SetInformationJobObject(void *hJob, int JobObjectInformationClass, void *lpJobObjectInformation, uint32_t cbJobObjectInformationLength)');
const AssignProcessToJobObject = kernel32.func('bool __stdcall AssignProcessToJobObject(void *hJob, void *hProcess)');
const GetCurrentProcess = kernel32.func('void* __stdcall GetCurrentProcess()');
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()');
// JobObjectExtendedLimitInformation = 9 (winnt.h)
const JobObjectExtendedLimitInformation = 9;
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 (winnt.h)
const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
const job = CreateJobObjectW(null, null);
if (koffi.address(job) === 0n) {
console.log('CreateJobObjectW fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('1. CreateJobObjectW -> Arbeitsgruppe angelegt');
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION ist 144 Byte auf x64.
// LimitFlags liegt im eingebetteten BASIC_LIMIT_INFORMATION bei Offset 16.
const info = Buffer.alloc(144);
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, 16);
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, info, 144)) {
console.log('SetInformationJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('2. SetInformationJobObject -> KILL_ON_JOB_CLOSE gesetzt');
if (!AssignProcessToJobObject(job, GetCurrentProcess())) {
console.log('AssignProcessToJobObject fehlgeschlagen, Win32-Fehler', GetLastError());
process.exit(1);
}
console.log('3. AssignProcessToJobObject -> dieser Prozess haengt in der Gruppe');
// Ein langlebiges Kind starten — der Platzhalter fuer makemkvcon.
const kind = spawn('ping', ['-n', '300', '127.0.0.1'], { stdio: 'ignore' });
console.log('4. Kindprozess gestartet, PID ' + kind.pid);
console.log('ELTERN-PID=' + process.pid);
console.log('KIND-PID=' + kind.pid);
// Offen halten, bis uns jemand hart abschiesst.
setTimeout(() => { console.log('Zeit abgelaufen'); process.exit(0); }, 60000);
+12
View File
@@ -0,0 +1,12 @@
// Läuft als utilityProcess — der Kontext, in dem Rippy v5 seine Datenbank
// betreiben wird (kern/speicher/db.ts laut KONZEPT-WINDOWS.md § 4.2).
try {
const s = require('node:sqlite');
const db = new s.DatabaseSync(':memory:');
db.exec('CREATE TABLE t (a TEXT)');
db.prepare('INSERT INTO t VALUES (?)').run('geht');
const wert = db.prepare('SELECT a FROM t').get().a;
process.parentPort.postMessage({ ok: true, wert: wert, node: process.versions.node });
} catch (e) {
process.parentPort.postMessage({ ok: false, fehler: e.message });
}
+20
View File
@@ -0,0 +1,20 @@
// Beweis: node:sqlite funktioniert im utilityProcess von Electron 44.
// Gemessen 30.08.2026 — siehe README.md in diesem Ordner.
//
// Startet den Kern-Prozess (sqlite-kern.js) genau so, wie Rippy v5 seinen
// Kern starten wird (utilityProcess.fork), und wartet auf dessen Antwort.
const { app, utilityProcess } = require('electron');
const path = require('path');
app.whenReady().then(() => {
const kind = utilityProcess.fork(path.join(__dirname, 'sqlite-kern.js'));
kind.on('message', (antwort) => {
console.log('KERN-ANTWORT: ' + JSON.stringify(antwort));
app.exit(antwort.ok ? 0 : 1);
});
kind.on('exit', (code) => {
console.log('Kern beendet mit ' + code);
if (code !== 0) app.exit(1);
});
setTimeout(() => { console.log('TIMEOUT'); app.exit(2); }, 15000);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

+13 -5
View File
@@ -197,10 +197,16 @@ def test_englisch_wird_bevorzugt(tmp_path):
assert titel_aus_bdmt(str(tmp_path)) == "English Title" assert titel_aus_bdmt(str(tmp_path)) == "English Title"
def test_makemkvcon_wird_ueber_den_katalog_gesucht(): def test_prescan_ruft_makemkvcon_nicht_mehr_auf():
"""DER Fehler: `shutil.which("makemkvcon")` gibt unter Windows auch bei """Der makemkvcon-Zweig (Titel-Quelle 3) wurde mit d3e86d2 bewusst
installiertem MakeMKV None zurueck -- Windows-Programme liegen in ENTFERNT er lief unter Windows nie (`shutil.which` findet dort nichts,
Programme", nicht im PATH. Dieser Zweig lief dort also NIE.""" Programme liegen in Programme", nicht im PATH) und war unter Docker
doppelt zum eigentlichen Rip-Weg. Dieser Waechter passt auf, dass weder
der Aufruf noch die shutil.which-Suche zurueckkommen.
(Bis 30.08.2026 verlangte er stattdessen `werkzeug_katalog.finden`
das war die Welt VOR d3e86d2. Er laeuft nur in der CI (fcntl) und hat
die Entfernung deshalb erst dort gemeldet.)"""
import os import os
hier = os.path.dirname(os.path.abspath(__file__)) hier = os.path.dirname(os.path.abspath(__file__))
@@ -212,4 +218,6 @@ def test_makemkvcon_wird_ueber_den_katalog_gesucht():
quelle = "".join(code) quelle = "".join(code)
assert "shutil.which(" not in quelle, ( assert "shutil.which(" not in quelle, (
"shutil.which findet unter Windows keine Programme aus 'Programme'") "shutil.which findet unter Windows keine Programme aus 'Programme'")
assert 'werkzeug_katalog.finden("makemkv")' in quelle assert "makemkvcon" not in quelle, (
"Der makemkvcon-Zweig im Prescan wurde mit d3e86d2 entfernt und "
"soll nicht zurueckkommen — Titel kommen aus Label/BDMV/IFO.")
+11
View File
@@ -0,0 +1,11 @@
# Bau-Ausgaben von electron-vite (out/) — dist/ ignoriert schon die Root-.gitignore
node_modules/
out/
*.tsbuildinfo
# Setup-Bau (electron-builder) und die MITGELIEFERTEN Binaerdateien —
# vendor/ wird beim Paketieren aus LOCALAPPDATA\Rippy\tools befuellt
# (BAUEN.md), 74 MB gehoeren nicht ins Git.
# Auch die Ausweich-Ordner der win-unpacked-Sperre (BAUEN.md)
dist-setup*/
vendor/
.electron-cache/
+114
View File
@@ -0,0 +1,114 @@
# 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). |
| `npm run klick` | **Der Klick-Beweis (5.4.0):** baut, startet das echte Programm mit eigenem Profil (`--profil=…`, `--klick-smoke`: ohne Tray, Updater, Toasts), einem nachgebauten Laufwerk (`RIPPY_FAKE_LAUFWERK=Q`) und nachgebauten Werkzeugen (`bau/klick/fake-*.cjs`, laufen über `kern/werkzeuge/aufruf.ts`) — und klickt per Playwright durch Rippen → Dialog → Sprachen → Folgen → Rip → Warteschlange → Kompression → Ablage → Roh-Dateien. Bilder in `../beweise/klick/`, Code 0 oder 2. Grund: Beide Fehler vom 01.09.2026 waren Verkabelung, die kein Unit-Test sah. Playwright ist reines Bau-Werkzeug (MIT, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` beim Installieren — Electron braucht keinen Browser-Download). |
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.
## Das Setup bauen (W-6)
```
npm run paket
```
erzeugt `dist-setup/RippySetup-<version>.exe` (NSIS, pro Benutzer, ohne
Adminrechte) plus `latest.yml` und `.blockmap` — **alle drei Dateien
gehören zum Release** (§ 6.8: `latest.yml` ist die Update-Auskunft, die
`.blockmap` macht aus dem Voll-Download einen Differenz-Download).
**Die `win-unpacked`-Sperre (30./31.08.2026 gemessen):** Nachdem der
Paket-Smoke (`win-unpacked\Rippy.exe --smoke`) gelaufen ist, hält ein
Filter-Treiber (Virenscanner/Indexer — KEIN Prozess mit sichtbarem Pfad)
`resources\app.asar` mitunter STUNDENLANG fest. Der nächste Bau in
denselben Ordner scheitert dann mit `EBUSY … unlink app.asar`, und zwar
mit **Exit-Code 0** (electron-builder!) — nicht vom Log-Ende täuschen
lassen, die -Zeile zählt. Ausweg: in ein frisches Verzeichnis bauen
(`npx electron-builder --win --config electron-builder.yml
-c.directories.output=dist-setup-neu`), die fertige Setup-EXE nach
`dist-setup/` KOPIEREN (eine Kopie ist eine neue Datei ohne Sperre) und
den gesperrten Ordner löschen, sobald er wieder frei ist.
Vorher einmalig `vendor/` befüllen (die MITGELIEFERTEN Werkzeuge, § 9 —
liegen nicht im Git, 74 MB):
```
vendor/handbrake/HandBrakeCLI.exe (GPL-2 — Lizenztext liegt bei)
vendor/flac/flac.exe + libFLAC.dll (Xiph — OHNE die DLL endet jeder
Aufruf ohne eine Zeile Ausgabe)
```
Quelle auf dieser Maschine: `%LOCALAPPDATA%\Rippy\tools\` (die rc11-
Beschaffung hat sie dort abgelegt); Original-Quellen und Lizenzen stehen
in `bau/lizenzen/QUELLEN.md`.
**Release veröffentlichen (Entscheid 6):** Die drei Dateien in ein
Gitea-Release mit dem festen Tag `aktuell` hochladen (Dateien dort
ERSETZEN) — `electron-updater` prüft
`…/releases/download/aktuell/latest.yml`. Ob Gitea 1.27 zusätzlich das
GitHub-Muster `releases/latest/download/…` bedient, ist am ersten echten
Release zu messen (§ 3.6 ⚠); das feste Tag ist die Rückfallebene.
**SmartScreen (Entscheid 5):** Die EXE ist unsigniert. Beim ersten Start
eines Downloads: „Weitere Informationen → Trotzdem ausführen". Beim
Release Größe und SHA-256 mit angeben, damit Bekannte die echte Datei
erkennen.
## Festgelegte Versionen und warum
- **Electron 44.0.0** — exakt die am 30.08.2026 gemessene Version
(KONZEPT § 3.1/§ 3.4). Sprünge nach der Pflege-Regel § 6.8: bei jedem
Release prüfen, ob eine neue Linie ansteht.
- **TypeScript 5.9.3, fest gepinnt** — bewusst NICHT die neue
Go-Implementierung (7.x, „tsgo", seit 2026 `latest` auf npm): Für die
Ampel zählt ein Typprüfer, dessen Verhalten die restliche Werkzeugkette
(Vitest 4, electron-vite 5) breit getestet hat. Der Umstieg auf 7.x ist
eine bewusste Entscheidung für später, kein Nebeneffekt eines `npm update`.
- **Vite 7** — der gemeinsame Nenner von electron-vite 5 (verträgt 57) und
Vitest 4 (verträgt 68), am 30.08.2026 per `npm view` ermittelt.
- **koffi** ist die einzige Laufzeit-Abhängigkeit (`dependencies`) — sie
wird nicht gebündelt, sondern zur Laufzeit aus `node_modules` geladen
(natives Modul). Alles andere ist Bauzeit (`devDependencies`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+192
View File
@@ -0,0 +1,192 @@
// Der Klick-Beweis (5.4.0, Punkt 7 der Übergabe vom 01.09.2026): startet
// das ECHTE Rippy (Electron, Haupt, Kern, Fenster, Datenbank) mit einem
// frischen Profil, einem nachgebauten Laufwerk und nachgebauten Werkzeugen
// — und KLICKT sich durch: Rippen → Titel-Dialog → Sprachen je Titel →
// Rollen auf Folge → Staffel/Folge → Rip → Warteschlange → Kompression →
// Ablage → Roh-Dateien → löschen.
//
// Warum: Beide Fehler vom 01.09.2026 waren Verkabelung (Nachrichtentyp
// nicht im Prüfer, zwei gleichzeitige Titel-Läufe). 261 Unit-Tests sahen
// keinen davon; ein Klick hätte beide gefunden. Playwright treibt Electron
// (Doku »Electron« bei playwright.dev, _electron.launch).
//
// npm run klick (baut vorher)
// Bilder: ../beweise/klick/*.png — Ausgang 0 (grün) oder 2.
import { _electron as electron } from 'playwright'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const wurzel = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const profil = join(tmpdir(), 'rippy-klick')
const ablage = join(profil, 'ablage')
const bilder = resolve(wurzel, '..', 'beweise', 'klick')
rmSync(profil, { recursive: true, force: true })
mkdirSync(profil, { recursive: true })
mkdirSync(bilder, { recursive: true })
// Das Profil vorbereiten: Einrichtung erledigt, Ablage im Profil, die
// Werkzeuge sind die Nachbauten (kern/werkzeuge/aufruf.ts startet Skripte
// über die eigene Node-Laufzeit). Die Datenbank legt der Kern selbst an —
// hier nur die Einstellungen (Tabelle wie in kern/speicher/db.ts).
{
const db = new DatabaseSync(join(profil, 'rippy.db'))
db.exec('CREATE TABLE IF NOT EXISTS einstellungen (schluessel TEXT PRIMARY KEY, wert TEXT NOT NULL)')
const setzen = db.prepare('INSERT OR REPLACE INTO einstellungen (schluessel, wert) VALUES (?, ?)')
setzen.run('setup.done', 'true')
setzen.run('ablage', ablage)
setzen.run('werkzeug.makemkv', join(wurzel, 'bau', 'klick', 'fake-makemkvcon.cjs'))
setzen.run('werkzeug.handbrake', join(wurzel, 'bau', 'klick', 'fake-handbrake.cjs'))
setzen.run('audioSprachen', 'deu,eng')
setzen.run('untertitelSprachen', 'deu')
setzen.run('rohAufbewahrung', 'behalten')
setzen.run('autoAuswurf', 'false')
db.close()
}
const app = await electron.launch({
executablePath: join(wurzel, 'node_modules', 'electron', 'dist', 'electron.exe'),
args: [wurzel, `--profil=${profil}`, '--klick-smoke'],
env: { ...process.env, RIPPY_FAKE_LAUFWERK: 'Q', RIPPY_OHNE_SCHLUESSEL: '1' },
timeout: 60_000,
})
const seite = await app.firstWindow()
seite.setDefaultTimeout(30_000)
seite.on('console', (m) => {
if (m.type() === 'error') console.log(`KLICK fenster: ${m.text().slice(0, 200)}`)
})
let nr = 0
async function bild(name) {
nr += 1
const pfad = join(bilder, `${String(nr).padStart(2, '0')}-${name}.png`)
await seite.screenshot({ path: pfad, fullPage: true })
console.log(`KLICK bild: ${pfad}`)
}
async function schritt(name, tun) {
try {
await tun()
console.log(`KLICK ✓ ${name}`)
} catch (fehler) {
console.error(`KLICK ✗ ${name}: ${String(fehler.message ?? fehler).split('\n')[0]}`)
await seite.screenshot({ path: join(bilder, `fehler-${name.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.png`), fullPage: true }).catch(() => {})
await app.close().catch(() => {})
process.exit(2)
}
}
const dialog = () => seite.locator('div.fixed.inset-0').last()
await schritt('Fenster offen, nachgebautes Laufwerk Q: sichtbar', async () => {
await seite.getByText('LAUFWERK Q:').waitFor()
await seite.getByText('NACHGEBAUT (KLICK-BEWEIS)').waitFor()
})
await bild('uebersicht')
await schritt('Rippen → Dialog öffnet sich', async () => {
await seite.getByRole('button', { name: 'Rippen', exact: true }).click()
await seite.getByText('Was soll gerippt werden?').waitFor()
})
await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 0', { exact: true }).waitFor()
await dialog().getByText('Titel 1', { exact: true }).waitFor()
await dialog().getByText('Titel 2', { exact: true }).waitFor()
})
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
})
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
await chips.first().click()
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
})
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
// Rippy wählt bei zwei fast gleich langen Titeln nur EINEN vor („bitte
// nachsehen") — der Knopf holt den zweiten dazu, Titel 2 (35 s) nicht.
await dialog().getByRole('button', { name: 'Hauptinhalt + Extras' }).click()
await dialog().getByText(/2 von 3 gewählt/).waitFor()
})
await schritt('Rollen auf Folge → Staffel/Folge-Felder erscheinen', async () => {
const rollen = dialog().locator('select')
await rollen.nth(0).selectOption('folge')
await rollen.nth(1).selectOption('folge')
await dialog().getByText('AB FOLGE').waitFor()
await dialog().getByPlaceholder('automatisch').fill('3')
})
await schritt('Vorschau nennt Serien\\…\\Season 01 und S01E03', async () => {
await dialog().getByText(/Serien\\/).waitFor()
await dialog().getByText(/Season 01/).waitFor()
await dialog().getByText(/S01E03/).waitFor()
})
await bild('dialog')
await schritt('Rip starten → Phase RIPPT', async () => {
await dialog().getByRole('button', { name: /Titel rippen/ }).click()
await seite.getByText(/RIPPT/).first().waitFor()
})
await schritt('Rip fertig, Kompression eingereiht, Laufwerk frei', async () => {
await seite.getByText(/Kompression eingereiht/).waitFor({ timeout: 60_000 })
await seite.getByRole('button', { name: 'Rippen', exact: true }).waitFor()
})
await schritt('Kompressions-Karte läuft und die Ablage endet', async () => {
await seite.getByText('KOMPRESSION', { exact: true }).waitFor({ timeout: 30_000 })
await seite.getByText(/Komprimiert und abgelegt/).waitFor({ timeout: 60_000 })
})
await bild('fertig')
await schritt('Ergebnis liegt als S01E03/S01E04 unter Serien\\…\\Season 01', async () => {
const serien = join(ablage, 'Serien')
if (!existsSync(serien)) throw new Error(`kein Ordner ${serien}`)
const gefunden = []
const gehen = (o) => {
for (const e of readdirSync(o, { withFileTypes: true })) {
const p = join(o, e.name)
if (e.isDirectory()) gehen(p)
else if (e.name.endsWith('.mkv') && statSync(p).size > 0) gefunden.push(p)
}
}
gehen(serien)
const namen = gefunden.map((p) => p.split(/[\\/]/).pop())
if (!namen.some((n) => n.endsWith('S01E03.mkv')) || !namen.some((n) => n.endsWith('S01E04.mkv'))) {
throw new Error(`gefunden: ${namen.join(', ')}`)
}
if (!gefunden[0].includes('Season 01')) throw new Error(`kein Season-01-Ordner: ${gefunden[0]}`)
console.log(`KLICK dateien: ${gefunden.join(' | ')}`)
})
await schritt('Einstellungen → Roh-Dateien zeigt den Vorgang mit Belegung', async () => {
await seite.getByRole('button', { name: 'Einstellungen' }).click()
await seite.locator('#roh').waitFor()
await seite.getByText(/Belegung jetzt/).waitFor()
await seite.getByText(/Roh: \d/).first().waitFor()
})
await bild('roh-dateien')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
})
await bild('ereignisse')
console.log('KLICK: OK — Rippen, Dialog, Sprachen, Folgen, Rip, Warteschlange, Kompression, Ablage, Roh-Dateien: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
@@ -0,0 +1,90 @@
// Nachgebautes HandBrakeCLI für den Klick-Beweis (bau/klick-smoke.mjs).
// Antwortet so, wie encoder.ts, scan.ts und handbrake.ts es am echten
// HandBrake 1.11 gemessen haben:
// --help → Encoder-Block „-e, --encoder" bis zur nächsten Option
// --preset-list → Preset-Namen mit genau vier Leerzeichen Einrückung
// --scan --json → „JSON Title Set:" + Objekt + „HandBrake has exited."
// Encode → „Encoding: task 1 of 1, x %" in Stufen, Datei kopiert
const fs = require('node:fs')
const args = process.argv.slice(2)
const out = (zeile) => process.stdout.write(zeile + '\n')
const wert = (schalter) => {
const i = args.indexOf(schalter)
return i >= 0 ? args[i + 1] : undefined
}
if (args.includes('--help') || args.includes('-h')) {
out('Usage: HandBrakeCLI [options] -i <source> -o <destination>')
out('')
out('Video Options -----------------------------------------------------------------')
out('')
out(' -e, --encoder <string> Select video encoder:')
out(' x264')
out(' x265')
out(' nvenc_h264')
out(' -q, --quality <float> Set video quality (e.g. 22.0)')
out('')
process.exit(0)
}
if (args.includes('--preset-list')) {
out('General/')
out(' Very Fast 1080p30')
out(' Fast 1080p30')
out(' HQ 1080p30 Surround')
out('')
process.exit(0)
}
if (args.includes('--scan')) {
const eingabe = wert('--input') ?? wert('-i') ?? 'quelle'
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 1, 100.00 %')
out('JSON Title Set: {')
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
out('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
out(']')
out('}')
out(']')
out('}')
out('HandBrake has exited.')
process.exit(0)
}
const eingabe = wert('--input') ?? wert('-i')
const ausgabe = wert('--output') ?? wert('-o')
if (eingabe === undefined || ausgabe === undefined) {
out('Missing input or output file name. Run HandBrakeCLI --help for syntax.')
process.exit(1)
}
out('[12:00:00] hb_init: starting libhb thread')
out('Scanning title 1 of 1, preview 10, 100.00 %')
let schritt = 0
const stufen = [5, 30, 55, 80, 100]
const tick = () => {
out(`Encoding: task 1 of 1, ${stufen[schritt].toFixed(2)} %`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 300)
return
}
fs.copyFileSync(eingabe, ausgabe)
out('[12:00:05] libhb: work result = 0')
out('')
out('Encode done!')
out('HandBrake has exited.')
process.exit(0)
}
setTimeout(tick, 200)
@@ -0,0 +1,79 @@
// Nachgebautes makemkvcon für den Klick-Beweis (bau/klick-smoke.mjs).
// Spricht den Robot-Modus so, wie der Parser (kern/rip/parser.ts) ihn an
// echten Discs gemessen hat: TINFO (8 Kapitel, 9 Dauer, 11 Bytes), SINFO
// (1 Typ, 3 Sprachcode, 4 Sprachname), PRGV (aktuell,gesamt,max), MSG.
//
// info dev:X: → drei Titel: zwei Folgen (52:47 / 54:09, jpn+deu
// Ton, deu+eng Untertitel) und ein Logo-Schnipsel
// mkv dev:X: <nr> <ziel> → Fortschritt in Stufen, dann title_t0N.mkv
//
// Läuft über die eigene Node-Laufzeit (kern/werkzeuge/aufruf.ts) — kein EXE.
const fs = require('node:fs')
const path = require('node:path')
const args = process.argv.slice(2)
const befehl = args.find((a) => a === 'info' || a === 'mkv')
const out = (zeile) => process.stdout.write(zeile + '\n')
out('MSG:1005,0,1,"MakeMKV v1.18.4 win(x86-release) gestartet","%1 gestartet","MakeMKV v1.18.4 win(x86-release)"')
const TITEL = [
{ nr: 0, dauer: '0:52:47', bytes: 16283000000, kapitel: 7 },
{ nr: 1, dauer: '0:54:09', bytes: 16670000000, kapitel: 7 },
{ nr: 2, dauer: '0:00:35', bytes: 20000000, kapitel: 1 },
]
if (befehl === 'info') {
out(`TCOUNT:${TITEL.length}`)
for (const t of TITEL) {
out(`TINFO:${t.nr},8,0,"${t.kapitel}"`)
out(`TINFO:${t.nr},9,0,"${t.dauer}"`)
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
out(`SINFO:${t.nr},1,3,0,"jpn"`)
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
out(`SINFO:${t.nr},2,3,0,"deu"`)
out(`SINFO:${t.nr},2,4,0,"German"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},3,3,0,"deu"`)
out(`SINFO:${t.nr},3,4,0,"German"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},4,3,0,"eng"`)
out(`SINFO:${t.nr},4,4,0,"English"`)
}
}
process.exit(0)
}
if (befehl === 'mkv') {
const i = args.indexOf('mkv')
const nr = Number(args[i + 2])
const ziel = args[i + 3]
if (!Number.isInteger(nr) || typeof ziel !== 'string') {
out('MSG:5010,0,0,"Falscher Aufruf"')
process.exit(1)
}
let schritt = 0
const stufen = [10, 35, 60, 85, 100]
const tick = () => {
out(`PRGV:${stufen[schritt]},${stufen[schritt]},100`)
schritt += 1
if (schritt < stufen.length) {
setTimeout(tick, 250)
return
}
fs.mkdirSync(ziel, { recursive: true })
const datei = path.join(ziel, `title_t${String(nr).padStart(2, '0')}.mkv`)
// 1 MB „Roh-Material" — groß genug, dass Größen sichtbar sind.
fs.writeFileSync(datei, Buffer.alloc(1024 * 1024, nr + 1))
out(`MSG:5003,0,1,"1 Titel gespeichert","%1 Titel gespeichert","1"`)
process.exit(0)
}
setTimeout(tick, 200)
} else {
out('MSG:5010,0,0,"Unbekannter Befehl"')
process.exit(1)
}
@@ -0,0 +1,29 @@
Copyright (C) 2000-2009 Josh Coalson
Copyright (C) 2011-2025 Xiph.Org Foundation
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
- Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
- Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
- Neither the name of the Xiph.Org Foundation nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
+35
View File
@@ -0,0 +1,35 @@
# Mitgelieferte Werkzeuge — Lizenzen und Quellen
Rippy v5 liefert zwei Werkzeuge mit (KONZEPT-WINDOWS.md § 9). Weitergabe an
Bekannte ist Weitergabe (Entscheid 9) — deshalb liegen die Lizenztexte und
Quellverweise hier bei. MakeMKV wird NICHT mitgeliefert (proprietär); Rippy
holt es beim Einrichten vom Hersteller.
## HandBrakeCLI
- Lizenz: GNU General Public License v2 — Volltext in
`HANDBRAKE-GPL-2.0.txt` (bezogen von
https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt am 30.08.2026).
- Quelltext und Projekt: https://handbrake.fr bzw.
https://github.com/HandBrake/HandBrake — der Quelltext der
mitgelieferten Fassung ist dort erhältlich (GPL-2 § 3).
## FLAC (flac.exe + libFLAC.dll)
- Lizenz der Kommandozeilen-Werkzeuge/Bibliothek: BSD-artig — Volltext in
`FLAC-COPYING.Xiph` (bezogen von
https://raw.githubusercontent.com/xiph/flac/master/COPYING.Xiph am
30.08.2026).
- Projekt: https://xiph.org/flac/
- ⚠ `flac.exe` braucht `libFLAC.dll` DANEBEN — mit nur der EXE endet
jeder Aufruf ohne eine Zeile Ausgabe (§ 9, gemessen).
## Schriften (im Renderer-Bundle, seit dem Kino-Mix 31.08.2026)
- **Bricolage Grotesque** (Display), **Instrument Sans** (Fließtext),
**JetBrains Mono** (Technik) — alle drei unter der SIL Open Font
License 1.1; Volltexte in `SCHRIFT-*-OFL.txt`.
- Bezogen als npm-Pakete `@fontsource-variable/bricolage-grotesque`,
`@fontsource/instrument-sans`, `@fontsource/jetbrains-mono` (je 5.3.0);
Vite bündelt die woff2-Dateien ins Fenster — Rippy lädt zur Laufzeit
keine Schriften aus dem Netz.
@@ -0,0 +1,93 @@
Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) JetBrainsMono-Italic[wght].ttf: Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+65
View File
@@ -0,0 +1,65 @@
Version 5.4.0
Das Laufwerk ist frei, sobald die Roh-Dateien liegen.
Rip und Kompression sind getrennt: Der Rip belegt das Laufwerk, danach
laeuft die Kompression in einer Warteschlange im Hintergrund (Karte in der
Uebersicht). Die naechste Disc kann rein, waehrend die vorige noch
rechnet; nach dem Rip wirft Rippy aus (abschaltbar unter Ablage).
Roh-Dateien raeumen sich auf.
Jeder Rip ist jetzt ein Vorgang mit Roh-Ordner und Zielordner. Unter
Einstellungen -> Roh-Dateien: Belegung, die Regel (Vorgabe: 7 Tage nach
gelungener Kompression, nur wenn jede fertige Datei nachweislich da ist),
"Neu komprimieren" mit anderem Preset ohne Neulesen der Disc, "Roh-Dateien
loeschen" von Hand. Ordner aus der Zeit vor 5.4.0 werden nur gezeigt, nie
von selbst geloescht. Halbe Dateien nach Abbruch oder Fehler verschwinden.
Unkomprimierte Rips (4K verlustfrei) landen im Zielordner statt unter roh.
Sprachen je Titel.
Der Auswahl-Dialog zeigt je Titel die gemessenen Ton- und Untertitelspuren
zum Anhaken. Japanischer Ton plus deutsche Untertitel: Die Untertitel sind
beim Abspielen vorgewaehlt. Vor der Kompression prueft Rippy die Roh-Datei,
ob die Sprachen wirklich drin sind, und setzt MakeMKVs Auswahlregel so,
dass alle Sprachen mitkommen.
Staffel und Folge von Hand.
Bei Folgen: "Staffel 1, ab Folge 3" im Dialog, dann zaehlt Rippy
fortlaufend in Disc-Reihenfolge; TMDb gibt nur noch eine Warnung. Zwei
Zeilen Vorschau zeigen, wohin was landet. Der Fortschrittsbalken laeuft
durch, nach Groesse gewichtet, statt je Titel auf null zu springen.
Erkennung v2 und Kino-Banner.
Nach dem Titel-Lauf haelt Rippy die gemessene Laufzeit gegen TMDb: aus
"wahrscheinlich" wird "bestaetigt", oder der Treffer faellt auf einen
Vorschlag zurueck. Leere TMDb-Eintraege zaehlen nicht mehr als Treffer.
Die Kachel zeigt Logo, Tagline, Jahr, FSK, Laufzeit, Genre, Bewertung
und Besetzung. "Laufwerk pruefen" liest das Windows-Protokoll: Disc oder
Hardware?
Dazu: Dialoge schliessen nicht mehr beim Markieren von Text; nach einem
Neustart nimmt Rippy wartende Kompressionen wieder auf; der Datenbank-
Pfad steht unter Programm; ein Klick-Beweis (Playwright) prueft den
ganzen Weg durch das echte Programm.
Unveraendert: Updates warten, bis kein Rip und keine Kompression mehr laeuft.
Version 5.3.2
Die Titelliste bleibt jetzt stehen.
In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein
"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst
ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je
Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort
frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge
auf derselben Disc gegenseitig aus.
Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer
Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf
Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei.
Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage
vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet.
Aus 5.3.0: Serien unter Serien/<Titel>/Season NN, Film-Extras im Unterordner.
Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter.
Unveraendert: Updates warten immer, bis kein Rip mehr laeuft.
Binary file not shown.

After

Width:  |  Height:  |  Size: 486 B

+42
View File
@@ -0,0 +1,42 @@
# Der Bau zum Setup (KONZEPT § 8): electron-builder → NSIS, EIN
# RippySetup.exe. Pro Benutzer, ohne Administratorrechte, Ziel
# %LOCALAPPDATA%\Programs\Rippy. Unsigniert (Entscheid 5) — SmartScreen-Weg
# und Prüfsumme stehen in der Anleitung; Updates NUR über HTTPS (§ 6.8).
appId: de.krbrz.rippy
productName: Rippy
directories:
output: dist-setup
buildResources: bau
files:
- out/**
- package.json
extraResources:
# Mitgelieferte Werkzeuge (§ 9, Lizenzgründe in bau/lizenzen/QUELLEN.md)
- from: vendor
to: vendor
- from: bau/lizenzen
to: lizenzen
- from: bau/tray.png
to: bau/tray.png
win:
icon: bau/icon.ico
target:
- nsis
nsis:
oneClick: true
perMachine: false
# Drüber-Installieren funktioniert: Der Installer beendet die laufende
# Fassung, bevor er kopiert (§ 8, der Rippy.exe.neu-Fund).
allowToChangeInstallationDirectory: false
deleteAppDataOnUninstall: false
artifactName: RippySetup-${version}.${ext}
publish:
# Entscheid 6: das öffentliche Gitea, festes Update-Tag „aktuell" als
# Rückfallebene (ob Gitea 1.27 releases/latest/download bedient, wird am
# ersten echten Release gemessen — § 3.6 ⚠).
provider: generic
url: https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell
electronDownload:
# npm 11 blockt Electrons Install-Skript (§ 3.5) — electron-builder
# lädt die Binärdateien selbst über diesen Cache-Weg.
cache: .electron-cache
+70
View File
@@ -0,0 +1,70 @@
// 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>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'. font-src data: (5.4.0, im Klick-Beweis
// gesehen): Vite bettet kleine Schrift-Teile der fontsource-Pakete
// als data:-URI ein, und die blockte die CSP — als Fehler in der
// Konsole, unsichtbar im Fenster.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org; font-src 'self' data:" />`,
)
},
}
}
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') },
},
},
},
})
+7403
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.4.0",
"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",
"klick": "electron-vite build && node bau/klick-smoke.mjs",
"paket": "npm run build && electron-builder --win --config electron-builder.yml",
"werkzeug:electron": "node node_modules/electron/install.js"
},
"dependencies": {
"electron-updater": "^6.8.9",
"koffi": "^3.1.6"
},
"devDependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource/instrument-sans": "^5.3.0",
"@fontsource/jetbrains-mono": "^5.3.0",
"@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-builder": "^26.15.3",
"electron-vite": "^5.0.0",
"playwright": "^1.62.1",
"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"
}
}
+72
View File
@@ -0,0 +1,72 @@
// 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))
},
/** Echter Windows-Ordnerdialog (§ 7). null = abgebrochen. */
ordnerWaehlen(titel: string): Promise<string | null> {
return ipcRenderer.invoke('ordner-waehlen', String(titel)) as Promise<string | null>
},
/** Autostart an/aus (§ 8) — liefert den tatsächlichen Zustand zurück. */
autostartSetzen(an: boolean): Promise<boolean> {
return ipcRenderer.invoke('autostart-setzen', an === true) as Promise<boolean>
},
/** Eigene Titelleiste (§ 4.2): minimieren | maximieren | schliessen.
* schliessen" versteckt nur Rippy lebt im Tray weiter. */
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean> {
return ipcRenderer.invoke('fenster-steuern', String(was)) as Promise<boolean>
},
/** „Jetzt prüfen" (§ 6.8) — das Ergebnis kommt über den Haupt-Status. */
updatePruefen(): Promise<unknown> {
return ipcRenderer.invoke('update-pruefen') as Promise<unknown>
},
/** „Jetzt neu starten und installieren" — antwortet mit Grund, wenn nicht. */
updateInstallieren(): Promise<{ ok: boolean; grund: string }> {
return ipcRenderer.invoke('update-installieren') as Promise<{ ok: boolean; grund: string }>
},
})
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// 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
ordnerWaehlen(titel: string): Promise<string | null>
autostartSetzen(an: boolean): Promise<boolean>
updatePruefen(): Promise<unknown>
updateInstallieren(): Promise<{ ok: boolean; grund: string }>
fensterSteuern(was: 'minimieren' | 'maximieren' | 'schliessen'): Promise<boolean>
}
}
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
// Die drei Schriften des Kino-Mixes reisen im Paket mit (OFL-lizenziert,
// bau/lizenzen) — Rippy lädt zur Laufzeit NICHTS aus dem Netz.
import '@fontsource-variable/bricolage-grotesque'
import '@fontsource/instrument-sans/400.css'
import '@fontsource/instrument-sans/500.css'
import '@fontsource/instrument-sans/600.css'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/500.css'
import '@fontsource/jetbrains-mono/700.css'
import './stil.css'
createRoot(document.getElementById('wurzel')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rippy</title>
</head>
<body>
<div id="wurzel"></div>
<script type="module" src="./haupt.tsx"></script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
// 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 Stand bleibt stehen und wird
// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer«
// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die
// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern.
import {
istKernNachricht,
type BeschaffungStand,
type BibliothekEintrag,
type DiscInfoStand,
type FensterNachricht,
type GeraeteInfo,
type KernNachricht,
type LaufwerkBericht,
type MetaVorschlag,
type RipAuftragOptionen,
type RipAuftragTitel,
type RipStatus,
type TitelStand,
type VorgaengeStand,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
export interface Verbindung {
verbunden: boolean
kern: Extract<KernNachricht, { art: 'kern-bereit' }> | null
pongLatenzMs: number | null
laufwerke: GeraeteInfo[]
/** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen,
* bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */
rips: Record<string, RipStatus>
/** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */
discInfos: Record<string, DiscInfoStand>
/** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */
vorschlaege: Record<string, MetaVorschlag[]>
/** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */
discTitel: Record<string, TitelStand>
/** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */
ripStart: Record<string, number>
/** Statuszeilen des (letzten) Vorgangs je Laufwerk das aufklappbare
* Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer
* Vorgang beginnt. */
ripProtokoll: Record<string, string[]>
einstellungen: Record<string, string>
werkzeuge: WerkzeugAuskunft | null
bibliothek: BibliothekEintrag[]
schluessel: {
quelle: string
angenommen: boolean | null
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
version: string
} | null
/** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */
beschaffung: BeschaffungStand | null
/** Vorgänge, Roh-Belegung und Kompressions-Warteschlange (5.4.0) null,
* solange der Kern noch nichts geschickt hat. */
vorgaenge: VorgaengeStand | null
/** Was das Windows-Protokoll je Laufwerk sagt (5.4.0) — bleibt stehen. */
gesundheit: Record<string, LaufwerkBericht>
/** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */
meldungen: string[]
}
type Horcher = (stand: Verbindung) => void
const MAX_MELDUNGEN = 30
const stand: Verbindung = {
verbunden: false,
kern: null,
pongLatenzMs: null,
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
einstellungen: {},
werkzeuge: null,
bibliothek: [],
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
gesundheit: { ...stand.gesundheit },
meldungen: [...stand.meldungen],
}
}
function melden(): void {
for (const h of horcher) h(kopie())
}
function meldung(text: string): void {
stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN)
}
function kernNachricht(nachricht: KernNachricht): void {
switch (nachricht.art) {
case 'kern-bereit':
stand.kern = nachricht
break
case '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')
break
case 'kern-fehler':
meldung(`${nachricht.text}`)
break
case 'kern-hinweis':
meldung(nachricht.text)
break
case 'laufwerke':
stand.laufwerke = nachricht.liste
for (const ereignis of nachricht.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
meldung(`Disc erkannt in ${ereignis.geraet.name}${ereignis.geraet.typ}`)
} else if (ereignis.art === 'disc-entnommen') {
meldung(`Disc entnommen aus ${ereignis.geraet.name}`)
} else if (ereignis.art === 'laufwerk-weg') {
meldung(`Laufwerk ${ereignis.id}: nicht mehr da`)
}
}
break
case 'auswurf-ergebnis': {
const text =
nachricht.grund === 'ok'
? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)`
: `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? `${nachricht.fehler}` : ''}`
meldung(nachricht.grund === 'ok' ? text : `${text}`)
break
}
case 'disc-info': {
const vorher = stand.discInfos[nachricht.info.id]
stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info }
if (nachricht.info.titel.length > 0) {
// Dieselbe Erkennung nicht zweimal melden — die Nachprüfung nach
// dem Info-Lauf schickt den Stand erneut, mit Hinweisen.
const neuErkannt = vorher === undefined || vorher.titel !== nachricht.info.titel
if (neuErkannt) {
meldung(
`Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`,
)
}
for (const h of nachricht.info.hinweise) {
if (vorher === undefined || !vorher.hinweise.includes(h)) meldung(`Laufwerk ${nachricht.info.id}: ${h}`)
}
}
break
}
case 'disc-titel':
stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand }
if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`)
break
case 'metadaten-vorschlaege':
stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege }
break
case 'einstellungen':
stand.einstellungen = nachricht.werte
break
case 'werkzeuge':
stand.werkzeuge = nachricht.auskunft
break
case 'bibliothek':
stand.bibliothek = nachricht.eintraege
break
case 'schluessel-status':
stand.schluessel = nachricht.stand
if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`)
break
case 'beschaffung-status':
stand.beschaffung = nachricht.stand
if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`)
break
case 'vorgaenge':
stand.vorgaenge = nachricht.stand
break
case 'kompression-ergebnis':
meldung(`${nachricht.ok ? '' : '⚠ '}Kompression „${nachricht.titel}": ${nachricht.text}`)
break
case 'laufwerk-gesundheit':
stand.gesundheit = { ...stand.gesundheit, [nachricht.id]: nachricht.bericht }
if (nachricht.bericht.urteil === 'hardware' || nachricht.bericht.urteil === 'disc') {
meldung(`Laufwerk ${nachricht.id}: ${nachricht.bericht.text}`)
}
break
case 'rip-status': {
stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status }
const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase)
if (laeuft && stand.ripStart[nachricht.status.id] === undefined) {
stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() }
// Ein NEUER Vorgang beginnt — das Protokoll des alten weicht.
stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] }
} else if (!laeuft) {
const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart
stand.ripStart = rest
}
// Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks
// wiederholen den Text — nur echte Änderungen zählen).
const bisher = stand.ripProtokoll[nachricht.status.id] ?? []
if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) {
stand.ripProtokoll = {
...stand.ripProtokoll,
[nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80),
}
}
if (nachricht.status.phase === 'fertig') {
meldung(
`Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` +
(nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''),
)
} else if (nachricht.status.phase === 'fehler') {
meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`)
} else if (nachricht.status.phase === 'abgebrochen') {
meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`)
}
break
}
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
}
melden()
}
function portAnnehmen(neuerPort: MessagePort): void {
port = neuerPort
stand.verbunden = true
neuerPort.onmessage = (ereignis: MessageEvent) => {
const nachricht: unknown = ereignis.data
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'bibliothek-laden' })
senden({ art: 'vorgaenge-laden' })
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(kopie())
return () => horcher.delete(h)
}
function senden(nachricht: FensterNachricht): void {
port?.postMessage(nachricht)
}
/** Schickt eine Nachricht — und sagt es, wenn kein Kern da ist (R4). */
function anKern(nachricht: FensterNachricht, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage(nachricht)
}
export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
/** Den Info-Lauf anstoßen (20120 s) — erst auf Zuruf, nie beim Einlegen. */
export function titelLesen(id: string): void {
anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`)
}
/** `auftrag` leer heißt: alles rippen (wie vor 5.2). */
export function ripStarten(id: string, auftrag: RipAuftragTitel[] = [], optionen: RipAuftragOptionen = {}): void {
anKern({ art: 'rip-start', id, auftrag, optionen }, `Rip ${id}`)
}
/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */
export function ripUeberspringen(id: string): void {
anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`)
}
export function ripAbbrechen(id: string): void {
anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`)
}
export function metadatenSuchen(id: string, text: string): void {
senden({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
senden({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
}
export function schluesselPruefen(): void {
senden({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
anKern({ art: 'einstellung-setzen', schluessel, wert }, `Einstellung ${schluessel}`)
}
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
export function vorgaengeLaden(): void {
senden({ art: 'vorgaenge-laden' })
}
export function vorgangRohLoeschen(vorgangId: string): void {
anKern({ art: 'vorgang-roh-loeschen', vorgangId }, 'Roh-Dateien löschen')
}
export function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
anKern({ art: 'vorgang-neu-komprimieren', vorgangId, preset }, 'Neu komprimieren')
}
export function kompressionAbbrechen(vorgangId: string): void {
anKern({ art: 'kompression-abbrechen', vorgangId }, 'Kompression abbrechen')
}
export function rohAufraeumen(): void {
anKern({ art: 'roh-aufraeumen' }, 'Roh-Dateien aufräumen')
}
export function fremdenOrdnerLoeschen(ordner: string): void {
anKern({ art: 'fremden-ordner-loeschen', ordner }, 'Ordner löschen')
}
export function laufwerkPruefen(id: string): void {
anKern({ art: 'laufwerk-pruefen', id }, `Laufwerk ${id} prüfen`)
}
+78
View File
@@ -0,0 +1,78 @@
@import 'tailwindcss';
/* Der Kinosaal+Schaltpult-Mix (Design-Entscheid des Commanders, 31.08.2026):
warmes Schwarz mit Saallicht und Korn, Gold als Akzent, dazu die
Geräte-Präzision Segmente, Mono-Caps, LED-Punkte. Die Tokens hier sind
die EINE Quelle der Farbwelt; alles Weitere sind Tailwind-Klassen. */
@theme {
--font-display: 'Bricolage Grotesque Variable', Georgia, serif;
--font-sans: 'Instrument Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', Consolas, monospace;
/* Bühne und Panels — warm abgetönte Schwarztöne, nie kaltes Grau. */
--color-buehne: oklch(0.13 0.012 60);
--color-karte: oklch(0.17 0.015 60);
--color-samt: oklch(0.16 0.015 60);
/* Premierengold, in drei Stufen für Verläufe und Ränder. */
--color-gold-hell: oklch(0.88 0.09 80);
--color-gold: oklch(0.83 0.11 75);
--color-gold-tief: oklch(0.74 0.13 60);
/* Die Aufnahme-LED aus dem Schaltpult. */
--color-rec: oklch(0.72 0.17 45);
/* Schriftstufen — warmes Weiß bis Nebel. */
--color-schnee: oklch(0.93 0.01 80);
--color-dunst: oklch(0.68 0.015 80);
--color-nebel: oklch(0.55 0.015 80);
/* Zustandsfarben, alle in der warmen Familie. */
--color-gruen: oklch(0.78 0.13 150);
--color-warn: oklch(0.8 0.12 85);
--color-rot: oklch(0.68 0.19 25);
}
:root {
color-scheme: dark;
}
/* Das Saallicht: zwei warme Glühherde und ein Grundschimmer liegt als
festes Bühnenbild hinter allem. */
.buehnenlicht {
background:
radial-gradient(900px 520px at 78% 0%, oklch(0.28 0.07 45 / 0.5), transparent 70%),
radial-gradient(1100px 700px at 0% 100%, oklch(0.24 0.09 25 / 0.55), transparent 65%),
radial-gradient(700px 400px at 45% 45%, oklch(0.2 0.04 60 / 0.45), transparent 70%);
}
/* Filmkorn — ein feines Turbulenz-Muster, hauchdünn über die Bühne gelegt. */
.filmkorn {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
opacity: 0.05;
mix-blend-mode: overlay;
}
/* Volle Segmente der Aussteuerungsanzeige glühen golden. */
.segment-gluehen {
background: linear-gradient(180deg, var(--color-gold-hell), var(--color-gold-tief));
box-shadow: 0 0 8px oklch(0.8 0.12 70 / 0.45);
}
/* Eigene Titelleiste (§ 4.2): Das Fenster hat seit 5.1.3 keinen
Windows-Rahmen mehr (frame:false) gezogen wird an der Kopfzeile.
`drag` macht eine Fläche zum Fenstergriff; ALLES Bedienbare darin
braucht `nicht-ziehbar`, sonst verschluckt der Griff den Klick.
Doppelklick auf den Griff maximiert, das macht Windows von selbst. */
.ziehbar {
-webkit-app-region: drag;
}
.nicht-ziehbar {
-webkit-app-region: no-drag;
}
/* Dezente Scrollbalken — helle Standardbalken zerschneiden die Bühne. */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: oklch(1 0 0 / 0.12); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background-color: oklch(1 0 0 / 0.2); }
::-webkit-scrollbar-track { background: transparent; }
+74
View File
@@ -0,0 +1,74 @@
// Ablage-Namen und die Zielpfad-Vorschau — PUR, ohne Dateisystem, damit
// Kern UND Fenster dieselbe Regel benutzen (5.4.0: „Vorschau, welche Datei
// landet wo" im Auswahl-Dialog). Die Kern-Seite (kern/ablage/struktur.ts)
// baut auf diesen Namen ihre echten Ordner.
// Zeichen, die in Ordnernamen auf SMB/NTFS/ext4 Ärger machen.
// eslint-Hinweis unnötig: bewusst inklusive Steuerzeichen.
// eslint-disable-next-line no-control-regex
const VERBOTEN = /[<>:"/\\|?*\x00-\x1f]/g
/** Titel → dateisystem-tauglicher Ordnername (pur). */
export function sichererName(titel: string, jahr?: number | string | null): string {
let name = (titel ?? '').replace(VERBOTEN, '').trim().replace(/\.+$/, '')
name = name.replace(/\s+/g, ' ').slice(0, 150).trim()
if (name.length === 0) return ''
// Kein doppeltes Jahr: OMDb-Titel tragen es teils schon im Namen.
if (jahr !== undefined && jahr !== null && String(jahr).length > 0 && !name.endsWith(`(${jahr})`)) {
name = `${name} (${jahr})`
}
return name
}
export function staffelOrdnerName(staffel: number): string {
return `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`
}
export function folgenDateiname(serie: string, staffel: number, folge: number): string {
const s = String(Math.trunc(staffel)).padStart(2, '0')
const e = String(Math.trunc(folge)).padStart(2, '0')
return `${sichererName(serie) || 'Serie'} S${s}E${e}.mkv`
}
export interface VorschauEingabe {
ablage: string
alsSerie: boolean
titel: string
jahr: number | null
staffel: number | null
ersteFolge: number | null
/** Zahl der gewählten Folgen (Serie) bzw. Hauptfilme (Film). */
hauptTitel: number
extras: number
/** Der Vorgangsname — gilt, wenn kein Titel erkannt ist. */
vorgangsName: string
}
/** Zwei Zeilen: Zielordner und was darin landet (pur). Windows-Trenner,
* weil die Vorschau eine Windows-Ablage beschreibt. */
export function zielVorschau(e: VorschauEingabe): [string, string] {
const wurzel = e.ablage.replace(/[\\/]+$/, '')
const titel = e.titel.trim().length > 0 ? e.titel.trim() : e.vorgangsName
if (e.alsSerie) {
const staffel = e.staffel ?? 1
const ordner = `${wurzel}\\Serien\\${sichererName(titel) || 'Unbekannte Serie'}\\${staffelOrdnerName(staffel)}\\`
const n = Math.max(1, e.hauptTitel)
let inhalt: string
if (e.ersteFolge !== null && e.ersteFolge > 0) {
const erste = folgenDateiname(titel, staffel, e.ersteFolge)
inhalt =
n === 1
? erste
: `${erste}${folgenDateiname(titel, staffel, e.ersteFolge + n - 1)} (${n} Folgen, fortlaufend)`
} else {
inhalt = `${n} ${n === 1 ? 'Folge' : 'Folgen'} — Nummern aus den TMDb-Laufzeiten, sonst bleiben die MakeMKV-Namen`
}
if (e.extras > 0) inhalt += ` · extras\\ (${e.extras})`
return [ordner, inhalt]
}
const ordner = `${wurzel}\\Filme\\${sichererName(titel, e.jahr) || e.vorgangsName}\\`
const teile: string[] = []
if (e.hauptTitel > 0) teile.push(e.hauptTitel === 1 ? 'der Hauptfilm' : `${e.hauptTitel} Hauptfilme`)
if (e.extras > 0) teile.push(`extras\\ (${e.extras} ${e.extras === 1 ? 'Zugabe' : 'Zugaben'})`)
return [ordner, teile.length > 0 ? teile.join(' · ') : 'nichts gewählt']
}
+638
View File
@@ -0,0 +1,638 @@
// 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)
//
// Regel seit dem 01.09.2026 (test/waechter.test.ts): JEDE `art:` einer Union
// steht wörtlich im zugehörigen Prüfer — sonst verwirft der Kern die
// Nachricht, und niemand erfährt es.
/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */
export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string }
// ── Laufwerke (Etappe W-1) ──────────────────────────────────────────────
export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown'
export type LaufwerkStatus = 'ready' | 'empty' | 'unknown'
export interface GeraeteInfo {
/** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */
id: string
name: string
typ: DiscTyp
pfad: string
status: LaufwerkStatus
modell: string
seriennummer: string
/** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */
groesseBytes: number
/** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */
grund: string
}
export type WacheEreignis =
| { art: 'disc-eingelegt'; geraet: GeraeteInfo }
| { art: 'disc-entnommen'; geraet: GeraeteInfo }
| { art: 'laufwerk-neu'; geraet: GeraeteInfo }
| { art: 'laufwerk-weg'; id: string }
export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin'
// ── Rippen (Etappe W-2) ─────────────────────────────────────────────────
export type RipPhase =
| 'liest-info'
| 'rippt'
| 'komprimiert'
| 'legt-ab'
| 'fertig'
| 'fehler'
| 'abgebrochen'
export interface RipStatus {
/** Laufwerks-Kennung, auf der der Rip läuft. */
id: string
phase: RipPhase
/** 0100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */
prozent: number
text: string
dateien: string[]
lesefehler: boolean
/** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es
* insgesamt sind Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO
* und in Fehler-Meldungen; die kennen keine Titel. */
titelNr?: number
titelGesamt?: number
/** Der Lauf klemmt an einer festen Stelle der Disc ein physischer
* Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */
haengt?: { offset: string; wiederholungen: number } | null
/** Der Vorgang, zu dem dieser Rip gehört (5.4.0) die Kompression läuft
* danach unter dieser Kennung in der Warteschlange weiter. */
vorgangId?: string
}
/** Was ein Titel IST davon hängt ab, WOHIN er abgelegt wird:
* hauptfilm Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
* extra Filme/<Titel> (Jahr)/extras/ (Jellyfin/Emby/Kodi)
* folge Serien/<Titel>/Season NN/ S01E02.mkv
* EINE Definition für Kern und Fenster. */
export type TitelRolle = 'hauptfilm' | 'extra' | 'folge'
/** Eine Sprache, wie der Info-Lauf sie an einem Titel gemessen hat
* (SINFO-Attribute 3/4, parser.ts). `spuren` zählt, wie viele Spuren
* dieser Sprache es gibt (Stereo + 5.1 = 2). */
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
/** Ein Titel im Rip-Auftrag: welcher, als was und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
* bei eindeutiger Zuordnung um (§ 4.2). */
export interface RipAuftragTitel {
nr: number
rolle: TitelRolle
dauerS: number
/** Größe laut Info-Lauf gewichtet den durchgehenden Fortschrittsbalken
* (5.4.0). Fehlt: alle Titel zählen gleich. */
groesseBytes?: number
/** Sprachen JE TITEL (5.4.0, Commander-Wunsch: japanischer Ton, deutsche
* Untertitel). Leer/fehlend heißt: die globale Einstellung gilt. */
audioSprachen?: string[]
untertitelSprachen?: string[]
}
/** Was der Nutzer im Auswahl-Dialog über die GANZE Disc sagt (5.4.0):
* Staffel und erste Folge von Hand Rippy rät bewusst nicht, aber der
* Nutzer weiß, dass auf Disc 2 die Folgen 34 sind. */
export interface RipAuftragOptionen {
/** null/fehlend: was die Disc sagt (bdmt/Label), sonst 1. */
staffel?: number | null
/** Fortlaufend ab dieser Folge in Disc-Reihenfolge. null/fehlend:
* Zuordnung über die TMDb-Laufzeiten wie bisher. */
ersteFolge?: number | null
}
/** Ein Titel der Disc, wie ihn der Auswahl-Dialog zeigt (§ 6.2). */
export interface DiscTitelVorschlag {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
/** Von Rippy vorausgewählt. */
gewaehlt: boolean
/** Als was Rippy ihn einsortieren würde — im Dialog änderbar. */
rolle: TitelRolle
/** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */
grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
* deshalb trägt der Stand ein eigenes `laeuft` das Fenster steht in der
* Zeit nicht stumm da (R2: nichts ohne Auskunft). */
export interface TitelStand {
id: string
laeuft: boolean
titel: DiscTitelVorschlag[]
/** Einordnung („Serie: 2 Folgen erwartet, 2 gefunden — passt."). */
einordnung: string
/** Rippy ist unsicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
fehler: string
}
/** Ein Darsteller fürs Kino-Banner (TMDb credits.cast, 5.4.0 gemessen). */
export interface Darsteller {
name: string
rolle: string
/** TMDb-Profilbild (relativ, z. B. /xCAs….jpg) — '' wenn keins. */
bildPfad: string
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
export interface DiscInfoStand {
id: string
titel: string
jahr: number | null
/** 01; die Schwelle für „sicher genug" ist einstellbar. */
confidence: number
quelle: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
/** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */
beschreibung: string
/** Datum des früheren Rips dieser Disc die Bibliothek erkennt sie am
* Fingerabdruck wieder (§ 6.6); null wenn neu. */
schonGerippt: string | null
/** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */
staffel: number | null
/** Folgen laut dem Inhaltsverzeichnis der Disc selbst 0, wenn es keines
* gibt. Ab zwei gilt Serie" als bewiesen (FOLGEN_FUER_SERIE). */
folgen: number
/** 'movie' | 'tv' | '' — was die Zuordnung sagt. */
typ: string
// ── Kino-Banner v2 (5.4.0, alles aus DERSELBEN TMDb-Anfrage gemessen) ──
/** Freigestellter Schriftzug (TMDb images.logos) — '' wenn keiner. */
logoPfad: string
tagline: string
/** TMDb vote_average (0 = keine). */
bewertung: number
/** Deutsche Freigabe („16", „18") — '' wenn unbekannt. */
fsk: string
laufzeitMinuten: number
genres: string[]
besetzung: Darsteller[]
/** Was Rippy NACH dem Info-Lauf noch dazugelernt hat (Laufzeit
* bestätigt: 124 min"), Klartext-Zeilen. */
hinweise: string[]
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
export interface BibliothekEintrag {
fingerprint: string
titel: string
jahr: number | null
typ: string
groesseBytes: number
ablageort: string
datum: string
dauerS: number
/** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */
posterPfad: string
}
/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */
export interface MetaVorschlag {
typ: 'movie' | 'tv'
tmdbId: number
titel: string
jahr: number | null
posterPfad: string
}
/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */
export interface WerkzeugAuskunft {
makemkvcon: string
handbrake: string
flac: string
presets: string[]
backends: string[]
}
/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */
export interface BeschaffungStand {
/** Welches Werkzeug — bisher nur 'makemkv'. */
werkzeug: string
laeuft: boolean
text: string
/** 01 während des Downloads; null wenn gerade nicht bezifferbar. */
anteil: number | null
fehler: boolean
}
// ── Vorgänge und Kompressions-Warteschlange (5.4.0) ─────────────────────
/** Der Lebenslauf eines Vorgangs. `wartet` = Roh-Dateien liegen da, die
* Kompression ist eingereiht; `fertig` = abgelegt (komprimiert ODER
* verschoben). */
export type VorgangPhase = 'rippt' | 'wartet' | 'komprimiert' | 'legt-ab' | 'fertig' | 'fehler' | 'abgebrochen'
/** Ein Vorgang, wie das Fenster ihn zeigt (Einstellungen Roh-Dateien,
* Übersicht Kompression). */
export interface VorgangEintrag {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
/** Was der Roh-Ordner JETZT belegt (gemessen, nicht gemerkt). */
rohBytes: number
rohGeloescht: boolean
zielOrdner: string
fertigBytes: number
komprimiert: boolean
preset: string
fehler: string
titelZahl: number
/** Die Roh-Dateien liegen noch da UND der Auftrag ist bekannt. */
neuKomprimierbar: boolean
/** Wann die Frist abläuft — '' wenn nie (behalten / nicht fertig). */
faelligAm: string
}
export interface KompressionStand {
vorgangId: string
titel: string
/** 0100; -1 = keine Angabe. */
prozent: number
text: string
phase: VorgangPhase
}
/** Ein Ordner unter roh\, den kein Vorgang kennt (Rest aus der Zeit vor
* 5.4.0 oder von Hand hineingelegt) nur von Hand löschbar. */
export interface FremderOrdner {
ordner: string
bytes: number
}
export interface VorgaengeStand {
vorgaenge: VorgangEintrag[]
/** Summe aller Roh-Ordner, die noch da sind. */
rohGesamtBytes: number
fremde: FremderOrdner[]
/** Die Aufbewahrungs-Regel, die gerade gilt (Einstellung rohAufbewahrung). */
regel: string
kompression: { laeuft: KompressionStand | null; wartend: KompressionStand[] }
}
/** Was das Windows-Ereignisprotokoll über ein Laufwerk sagt (5.4.0). */
export interface LaufwerkBericht {
/** 'ruhig' | 'disc' | 'hardware' | 'unklar' | 'nicht-lesbar' */
urteil: string
text: string
fehlerhafteBloecke: number
controllerfehler: number
sonstige: number
/** Die jüngsten Zeilen, neueste zuerst („2026-09-01 20:32 · fehlerhafter Block"). */
letzte: string[]
/** Betrachteter Zeitraum in Stunden. */
stunden: number
}
/** 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 }
/** Eine Zeile fürs Ereignis-Protokoll OHNE Warnzeichen (5.4.0): Roh-
* Ordner von X gelöscht", „Kompression eingereiht" Auskunft, kein Alarm. */
| { art: 'kern-hinweis'; text: string }
| { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] }
| { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null }
| { art: 'rip-status'; status: RipStatus }
| { art: 'disc-info'; info: DiscInfoStand }
| { art: 'disc-titel'; stand: TitelStand }
| { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] }
| { art: 'einstellungen'; werte: Record<string, string> }
| { art: 'werkzeuge'; auskunft: WerkzeugAuskunft }
| { art: 'bibliothek'; eintraege: BibliothekEintrag[] }
| { art: 'beschaffung-status'; stand: BeschaffungStand }
/** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt
* starten kann ihn nur das Haupt (shell/UAC, § 9). */
| { art: 'installer-starten'; pfad: string }
| {
art: 'schluessel-status'
stand: {
quelle: string
angenommen: boolean | null
/** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */
version: string
}
}
/** Alle Vorgänge, die Roh-Belegung und die Kompressions-Warteschlange
* (5.4.0) geht auch ans Haupt (Taskleiste, Updates warten). */
| { art: 'vorgaenge'; stand: VorgaengeStand }
/** Eine Kompression ist zu Ende — fürs Toast und die Ereignis-Zeile. */
| { art: 'kompression-ergebnis'; vorgangId: string; titel: string; ok: boolean; text: string }
| { art: 'laufwerk-gesundheit'; id: string; bericht: LaufwerkBericht }
/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */
export type FensterNachricht =
| { art: 'ping'; zeit: number }
| { art: 'auswerfen'; id: string }
/** Titel-Lauf anstoßen (§ 6.2): dauert 20120 s, deshalb erst auf Zuruf
* und nicht schon beim Einlegen (der 30.08.-Fund das Erkennen dauert
* sehr sehr lange"). */
| { art: 'titel-lesen'; id: string }
/** `auftrag` leer heißt: alles rippen — wie vor 5.2. */
| { art: 'rip-start'; id: string; auftrag?: RipAuftragTitel[]; optionen?: RipAuftragOptionen }
| { art: 'rip-abbruch'; id: string }
/** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen
* der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */
| { art: 'rip-ueberspringen'; id: string }
| { art: 'einstellung-setzen'; schluessel: string; wert: string }
| { art: 'metadaten-suchen'; id: string; text: string }
| { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag }
| { art: 'einstellungen-laden' }
| { art: 'werkzeuge-laden' }
| { art: 'bibliothek-laden' }
/** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */
| { art: 'makemkv-update' }
/** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */
| { art: 'schluessel-pruefen' }
// ── 5.4.0: Vorgänge, Roh-Dateien, Warteschlange, Laufwerks-Gesundheit ──
| { art: 'vorgaenge-laden' }
/** Den Roh-Ordner EINES Vorgangs jetzt löschen (von Hand). */
| { art: 'vorgang-roh-loeschen'; vorgangId: string }
/** Wiederholen ab Kompression": Roh-Dateien liegen da, ein Preset-Wechsel
* kostet kein Neulesen mehr. `preset` leer = Einstellung gilt. */
| { art: 'vorgang-neu-komprimieren'; vorgangId: string; preset?: string }
/** Einen wartenden oder laufenden Kompressions-Auftrag abbrechen. */
| { art: 'kompression-abbrechen'; vorgangId: string }
/** Die Aufräum-Regel sofort anwenden (statt auf den Takt zu warten). */
| { art: 'roh-aufraeumen' }
/** Einen Ordner unter roh\, den kein Vorgang kennt, löschen. */
| { art: 'fremden-ordner-loeschen'; ordner: string }
/** Das Windows-Ereignisprotokoll zu diesem Laufwerk lesen. */
| { art: 'laufwerk-pruefen'; id: string }
/** Nachrichten, die das Haupt an den Kern schickt. */
export type HauptNachricht =
| { art: 'fenster-port' }
/** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */
| { art: 'installer-ergebnis'; fehler: string | null }
/** 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 }
autostart: boolean
/** Selbst-Update (§ 6.8). */
update: UpdateStand
/** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster
* gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */
fensterMaximiert: boolean
}
/** Der Stand des Selbst-Updates (§ 6.8) EINE Definition, die sich Haupt
* und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene
* Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal
* arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
/** Die gefundene neue Version — '' wenn keine bereitsteht. */
neueVersion: string
/** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */
neuerungen: string
/** Geladen und wartet auf den Neustart. */
bereit: boolean
/** Eine Prüfung oder ein Download läuft gerade. */
laeuft: boolean
/** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */
fortschritt: number
}
function istObjekt(wert: unknown): wert is Record<string, unknown> {
return typeof wert === 'object' && wert !== null
}
function istText(wert: unknown): wert is string {
return typeof wert === 'string'
}
function istKennung(wert: unknown): wert is string {
return typeof wert === 'string' && wert.length > 0
}
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':
case 'kern-hinweis':
return typeof wert.text === 'string'
case 'laufwerke':
return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse)
case 'auswurf-ergebnis':
return (
typeof wert.id === 'string' &&
typeof wert.grund === 'string' &&
(wert.fehler === null || typeof wert.fehler === 'string')
)
case 'rip-status':
return istRipStatus(wert.status)
case 'disc-info':
return (
istObjekt(wert.info) &&
typeof (wert.info as Record<string, unknown>).id === 'string' &&
typeof (wert.info as Record<string, unknown>).titel === 'string' &&
typeof (wert.info as Record<string, unknown>).confidence === 'number'
)
case 'disc-titel':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).id === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean' &&
Array.isArray((wert.stand as Record<string, unknown>).titel)
)
case 'einstellungen':
return istObjekt(wert.werte)
case 'werkzeuge':
return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record<string, unknown>).presets)
case 'bibliothek':
return Array.isArray(wert.eintraege)
case 'schluessel-status':
return istObjekt(wert.stand) && typeof (wert.stand as Record<string, unknown>).text === 'string'
case 'beschaffung-status':
return (
istObjekt(wert.stand) &&
typeof (wert.stand as Record<string, unknown>).werkzeug === 'string' &&
typeof (wert.stand as Record<string, unknown>).text === 'string' &&
typeof (wert.stand as Record<string, unknown>).laeuft === 'boolean'
)
case 'installer-starten':
return typeof wert.pfad === 'string' && wert.pfad.length > 0
case 'metadaten-vorschlaege':
return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege)
case 'vorgaenge':
return (
istObjekt(wert.stand) &&
Array.isArray((wert.stand as Record<string, unknown>).vorgaenge) &&
typeof (wert.stand as Record<string, unknown>).rohGesamtBytes === 'number' &&
istObjekt((wert.stand as Record<string, unknown>).kompression)
)
case 'kompression-ergebnis':
return (
istKennung(wert.vorgangId) &&
typeof wert.titel === 'string' &&
typeof wert.ok === 'boolean' &&
typeof wert.text === 'string'
)
case 'laufwerk-gesundheit':
return (
istKennung(wert.id) &&
istObjekt(wert.bericht) &&
typeof (wert.bericht as Record<string, unknown>).urteil === 'string' &&
typeof (wert.bericht as Record<string, unknown>).text === 'string' &&
Array.isArray((wert.bericht as Record<string, unknown>).letzte)
)
default:
return false
}
}
export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag {
if (!istObjekt(wert)) return false
return (
(wert.typ === 'movie' || wert.typ === 'tv') &&
typeof wert.tmdbId === 'number' &&
typeof wert.titel === 'string' &&
(wert.jahr === null || typeof wert.jahr === 'number') &&
typeof wert.posterPfad === 'string'
)
}
export function istRipStatus(wert: unknown): wert is RipStatus {
if (!istObjekt(wert)) return false
return (
typeof wert.id === 'string' &&
typeof wert.phase === 'string' &&
typeof wert.prozent === 'number' &&
typeof wert.text === 'string' &&
Array.isArray(wert.dateien) &&
typeof wert.lesefehler === 'boolean'
)
}
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
}
/** Ein Rip-Auftragstitel, wie das Fenster ihn schickt die Sprachlisten
* sind optional, aber wenn sie da sind, müssen es Textlisten sein. */
export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (!istObjekt(wert)) return false
if (typeof wert.nr !== 'number' || typeof wert.dauerS !== 'number') return false
if (wert.rolle !== 'hauptfilm' && wert.rolle !== 'extra' && wert.rolle !== 'folge') return false
if (wert.groesseBytes !== undefined && typeof wert.groesseBytes !== 'number') return false
for (const feld of ['audioSprachen', 'untertitelSprachen']) {
const liste = wert[feld]
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false
}
return true
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
if (wert.art === 'rip-start') {
if (!istKennung(wert.id)) return false
if (wert.auftrag !== undefined && (!Array.isArray(wert.auftrag) || !wert.auftrag.every(istRipAuftragTitel))) {
return false
}
if (wert.optionen !== undefined) {
if (!istObjekt(wert.optionen)) return false
const o = wert.optionen
const zahlOderNull = (v: unknown): boolean => v === undefined || v === null || typeof v === 'number'
if (!zahlOderNull(o.staffel) || !zahlOderNull(o.ersteFolge)) return false
}
return true
}
if (
wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen' ||
wert.art === 'laufwerk-pruefen'
) {
return istKennung(wert.id)
}
if (wert.art === 'einstellung-setzen') {
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return istKennung(wert.id) && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return istKennung(wert.id) && istMetaVorschlag(wert.vorschlag)
}
if (wert.art === 'vorgang-roh-loeschen' || wert.art === 'kompression-abbrechen') {
return istKennung(wert.vorgangId)
}
if (wert.art === 'vorgang-neu-komprimieren') {
return istKennung(wert.vorgangId) && (wert.preset === undefined || typeof wert.preset === 'string')
}
if (wert.art === 'fremden-ordner-loeschen') {
return istKennung(wert.ordner)
}
return (
wert.art === 'einstellungen-laden' ||
wert.art === 'werkzeuge-laden' ||
wert.art === 'bibliothek-laden' ||
wert.art === 'makemkv-update' ||
wert.art === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen'
)
}
export function istHauptNachricht(wert: unknown): wert is HauptNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'fenster-port') return true
if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string'
return false
}
@@ -0,0 +1,44 @@
// Die Preset-Empfehlung — GEMESSEN, nicht behauptet (Commander-Fund
// 30.08.2026: „Das Komprimieren passiert auf der CPU, nicht auf der
// deutlich schnelleren GPU"). Der alte Default „H.265 MKV 1080p30" ist
// ein CPU-x265-Preset; auf dem Commander-PC kodiert die RX 9070 XT
// (VCN) denselben Film in 2 statt 60+ Minuten.
//
// Regeln:
// * Empfohlen wird NUR ein Preset, das in der vom eigenen HandBrake
// gemessenen Liste WIRKLICH steht (encoder.ts) — nie ein geratener
// Name (die „Invalid preset"-Falle).
// * Familienreihenfolge nach den gemessenen Backends: VCN (AMD) →
// NVENC (NVIDIA) → QSV (Intel) → CPU-Default.
// * HandBrake-Presets DECKELN die Auflösung nur — eine DVD bleibt SD,
// auch unter „…1080p" (deshalb ist die 1080p-Empfehlung auch für
// DVDs richtig; hochskaliert wird nichts).
// Liegt hier in gemeinsam/, weil Kern (Pipeline-Fallback) UND Fenster
// (Anzeige „automatisch: …") dieselbe Rechnung brauchen.
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
const FAMILIEN: ReadonlyArray<readonly [backend: string, presetKennung: string]> = [
['vce', 'VCN'],
['nvenc', 'NVENC'],
['qsv', 'QSV'],
]
/** Das empfohlene Preset für diesen Disc-Typ aus den GEMESSENEN
* Backends und Preset-Namen. Fällt auf den CPU-Default zurück. */
export function presetEmpfehlung(
discTyp: string,
backends: readonly string[],
presets: readonly string[],
): string {
const gewuenscht = discTyp === 'uhd' ? '2160p 4K' : '1080p'
for (const [backend, kennung] of FAMILIEN) {
if (!backends.includes(backend)) continue
const treffer = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung) && p.includes(gewuenscht))
if (treffer !== undefined) return treffer
// 4K-Familie ohne 4K-Preset: das 1080p-Pendant ist besser als CPU.
const kleiner = presets.find((p) => p.startsWith('H.265 ') && p.includes(kennung))
if (kleiner !== undefined) return kleiner
}
return DEFAULT_HB_PRESET
}
+13
View File
@@ -0,0 +1,13 @@
// Autostart über HKCU\...\Run (KONZEPT § 8) — via Electrons eigener
// API (app.setLoginItemSettings nutzt unter Windows genau diesen
// Registry-Zweig). Kein Dienst: Ein Dienst kostete bei jeder Installation
// eine Administrator-Abfrage und könnte kein Tray zeigen.
import { app } from 'electron'
export function autostartSetzen(an: boolean): void {
app.setLoginItemSettings({ openAtLogin: an })
}
export function autostartAktiv(): boolean {
return app.getLoginItemSettings().openAtLogin
}
+60
View File
@@ -0,0 +1,60 @@
// 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'
import { fensterrandEntfernen } from './fensterrand'
export function fensterErstellen(): BrowserWindow {
const fenster = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 860,
minHeight: 560,
show: true,
autoHideMenuBar: true,
// Eigene Titelleiste (Commander-Wunsch 01.09.2026): Der Windows-Rahmen
// ist weg, Minimieren/Maximieren/Schließen sitzen im UI und gezogen
// wird an der Kopfzeile (.ziehbar in fenster/stil.css).
//
// Wichtig: GRÖSSE ÄNDERN bleibt möglich. Electron setzt für rahmenlose
// Fenster unter Windows weiterhin WS_THICKFRAME (Option `thickFrame`,
// Standard true) — die Kanten lassen sich also weiter ziehen, und
// Doppelklick auf die Ziehfläche maximiert wie gewohnt.
frame: false,
// Dunkler Grund SOFORT — sonst blitzt beim Start ein weißes Rechteck,
// bevor React lädt. Das warme Bühnen-Schwarz des Kino-Mixes
// (oklch(0.13 0.012 60), siehe fenster/stil.css).
backgroundColor: '#171412',
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.
// RIPPY_START_BEREICH öffnet einen bestimmten Bereich (#einstellungen /
// #bibliothek) — der Smoke-Beweis fotografiert damit auch diese Seiten.
// Der DWM-Rand von Windows 11 (die feine helle Linie ums Fenster) — er
// gehört nicht zum Fensterinhalt, mit CSS ist er nicht zu erreichen.
// Ein Fehlschlag ist kein Ausfall: Unter Windows 10 gibt es ihn nicht.
const rand = fensterrandEntfernen(fenster.getNativeWindowHandle())
if (!rand.ok) console.warn(`[haupt] Fensterrand bleibt: ${rand.fehler}`)
const devUrl = process.env['ELECTRON_RENDERER_URL']
const startBereich = process.env['RIPPY_START_BEREICH'] ?? ''
if (devUrl !== undefined && devUrl.length > 0) {
void fenster.loadURL(startBereich.length > 0 ? `${devUrl}#${startBereich}` : devUrl)
} else {
void fenster.loadFile(
join(__dirname, '../fenster/index.html'),
startBereich.length > 0 ? { hash: startBereich } : undefined,
)
}
return fenster
}
+78
View File
@@ -0,0 +1,78 @@
// Den Windows-11-Fensterrand loswerden.
//
// Commander-Fund (01.09.2026): „Ich hab noch den rand ich glaube der kommt
// aber von windows selbst oder?" — Genau so ist es. Ein rahmenloses Fenster
// (`frame: false`) behält unter Windows 11 trotzdem den DWM-Rand: eine
// hauchfeine Linie in der Systemfarbe, gezeichnet vom Fenstermanager, nicht
// von der Seite. Mit CSS ist da nichts zu holen, das liegt außerhalb des
// Fensterinhalts.
//
// DwmSetWindowAttribute kennt dafür zwei Schalter (dwmapi.h, beide erst ab
// Windows 11 Build 22000):
// DWMWA_BORDER_COLOR (34) = DWMWA_COLOR_NONE (0xFFFFFFFE)
// DWMWA_WINDOW_CORNER_PREFERENCE (33) = DWMWCP_DONOTROUND (1)
//
// Ältere Windows antworten mit einem Fehler-HRESULT. Das ist KEIN Problem
// und wird als Wert zurückgegeben, nicht verschluckt (R4): Unter Windows 10
// gibt es diesen Rand schlicht nicht.
//
// Dies ist neben `leine.ts` und `kern/laufwerk/win32.ts` der DRITTE erlaubte
// koffi-Ort; der Wächter-Test R1 kennt genau diese drei. Begründung wie
// dort: ein Win32-Aufruf, der nirgendwo sonst hingehört — und er MUSS im
// Haupt liegen, weil nur dort das Fensterhandle existiert.
import koffi from 'koffi'
const DWMWA_WINDOW_CORNER_PREFERENCE = 33
const DWMWA_BORDER_COLOR = 34
/** „Zeichne gar keinen Rand" — nicht zu verwechseln mit Schwarz. */
const DWMWA_COLOR_NONE = 0xfffffffe
const DWMWCP_DONOTROUND = 1
export interface RandErgebnis {
ok: boolean
/** '' wenn alles saß; sonst der HRESULT-Grund im Klartext. */
fehler: string
}
/**
* Rand entfernen. `eckenKantig` schaltet zusätzlich die runden Ecken ab
* standardmäßig AUS, weil runde Ecken unter Windows 11 das Übliche sind
* und Rippy sich nicht fremder anfühlen soll, als es muss.
*
* @param handle Das native Fensterhandle (`BrowserWindow.getNativeWindowHandle()`).
*/
export function fensterrandEntfernen(handle: Buffer, eckenKantig = false): RandErgebnis {
if (handle.length < 8) return { ok: false, fehler: 'Fensterhandle unbrauchbar' }
try {
const dwmapi = koffi.load('dwmapi.dll')
// Das Handle steckt als Zeiger IM Puffer. Auf x64 wandert ein Zeiger im
// selben Register wie ein uint64 — deshalb ist das hier gefahrlos.
const DwmSetWindowAttribute = dwmapi.func(
'int32_t __stdcall DwmSetWindowAttribute(uint64_t hwnd, uint32_t dwAttribute, void *pvAttribute, uint32_t cbAttribute)',
)
const hwnd = handle.readBigUInt64LE(0)
const farbe = Buffer.alloc(4)
farbe.writeUInt32LE(DWMWA_COLOR_NONE, 0)
const randErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, farbe, 4)
let eckenErgebnis = 0
if (eckenKantig) {
const ecken = Buffer.alloc(4)
ecken.writeUInt32LE(DWMWCP_DONOTROUND, 0)
eckenErgebnis = DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, ecken, 4)
}
if (randErgebnis !== 0 || eckenErgebnis !== 0) {
// Ein negatives HRESULT heißt hier fast immer: älteres Windows, das
// diese Schalter nicht kennt. Rippy läuft weiter, nur mit Rand.
return {
ok: false,
fehler: `DwmSetWindowAttribute: HRESULT 0x${(randErgebnis >>> 0).toString(16)} (Rand) / 0x${(eckenErgebnis >>> 0).toString(16)} (Ecken) — vermutlich Windows 10`,
}
}
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/dwmapi nicht verfügbar: ${String(fehler)}` }
}
}
+407
View File
@@ -0,0 +1,407 @@
// 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. Seit W-5
// mit Tray: Fenster zu heißt weiterlaufen (§ 4.1 Punkt 3), beendet wird
// über das Tray-Menü; dazu Windows-Toasts und Taskleisten-Fortschritt (§ 7).
//
// 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, dialog, ipcMain, Notification, shell, type BrowserWindow } from 'electron'
import { writeFile } from 'node:fs/promises'
import { join, win32 } from 'node:path'
import type { HauptStatus, KernNachricht } from '../gemeinsam/nachrichten'
import { werkzeugOrdner } from '../kern/werkzeuge/katalog'
import { autostartAktiv, autostartSetzen } from './autostart'
import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateInstallierenJetzt, updatePruefenJetzt, updateStand } from './update'
const smokeModus = process.argv.includes('--smoke')
// Der Klick-Beweis (bau/klick-smoke.mjs, 5.4.0): das ECHTE Programm mit
// eigenem Profil und nachgebauten Werkzeugen — ohne Tray, Updater und
// Toasts, aber mit allem, was klickbar ist. Beendet wird er von außen.
const klickSmoke = process.argv.includes('--klick-smoke')
const profilArg = process.argv.find((a) => a.startsWith('--profil='))
/** Smoke UND Klick-Beweis: keine Nebenwirkungen auf dem Rechner. */
const ohneNebenwirkungen = smokeModus || klickSmoke
// Der Smoke-Lauf fasst das echte Profil nicht an — er hinterlässt nichts.
if (smokeModus) {
app.setPath('userData', join(app.getPath('temp'), 'rippy-smoke'))
} else if (profilArg !== undefined && profilArg.length > '--profil='.length) {
app.setPath('userData', profilArg.slice('--profil='.length))
}
// Nur EIN Rippy je Benutzer (§ 4.2: Einzelinstanz-Sperre). Die zweite
// Instanz beendet sich; die erste holt ihr Fenster nach vorn. Die Sperre
// hängt am Profil — ein Beweislauf mit eigenem Profil stört das laufende
// Rippy nicht und wird von ihm nicht gestört.
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 kernBereit: Extract<KernNachricht, { art: 'kern-bereit' }> | null = null
let beendenGewollt = smokeModus
// § 6.8 Regel 2: Der Haupt weiß, ob irgendwo gerippt wird — Updates
// warten dann.
const aktiveVorgaenge = new Set<string>()
let updateGestartet = false
const smoke = {
fenster: false,
kern: false,
datenbank: false,
leine: leine.ok,
pong: false,
}
const kernVerwaltung = new KernVerwaltung(
join(app.getPath('userData'), 'rippy.db'),
(nachricht) => {
if (nachricht.art === 'kern-bereit') {
kernBereit = nachricht
smoke.kern = true
smoke.datenbank = nachricht.datenbank.ok
if (!nachricht.datenbank.ok) {
console.error(`[haupt] Kern meldet Datenbank-Fehler: ${nachricht.datenbank.fehler}`)
}
statusSenden()
smokePruefen()
} else if (nachricht.art === 'rip-status') {
ripStatusVerarbeiten(nachricht.status)
} else if (nachricht.art === 'einstellungen') {
// Einmalig das Selbst-Update einrichten — die Adresse kommt aus
// der EINEN Einstellungs-Quelle im Kern (§ 6.7/§ 6.8).
if (!updateGestartet && !ohneNebenwirkungen) {
updateGestartet = true
updateEinrichten(
nachricht.werte['updateUrl'] ?? '',
() => aktiveVorgaenge.size > 0,
(text) => toast('Rippy-Update', text),
// Jede Änderung am Update-Stand geht SOFORT ans Fenster —
// vorher lag sie still im Haupt und war erst beim nächsten
// Laden zu sehen (Commander-Fund 01.09.2026).
() => statusSenden(),
// Genau der Weg des Tray-Menüs: beendenGewollt setzen, damit
// der close-Horcher das Fenster nicht bloß versteckt.
() => beenden(),
)
}
} else if (nachricht.art === 'installer-starten') {
void installerStarten(nachricht.pfad)
} else if (nachricht.art === 'vorgaenge') {
// 5.4.0: Die Kompression läuft OHNE Laufwerk weiter — Updates
// warten auch auf sie, und die Taskleiste zeigt ihren Fortschritt.
kompressionVerarbeiten(nachricht.stand.kompression.laeuft)
} else if (nachricht.art === 'kompression-ergebnis' && !ohneNebenwirkungen) {
toast(nachricht.ok ? 'Rippy ist fertig' : 'Rippy: Kompression fehlgeschlagen', `${nachricht.titel}: ${nachricht.text}`)
} else if (nachricht.art === 'disc-info' && nachricht.info.titel.length > 0 && !ohneNebenwirkungen) {
// Windows-Toast beim Erkennen (§ 7) — Klick holt das Fenster hoch.
toast(
'Disc erkannt',
`${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''} · ${Math.round(nachricht.info.confidence * 100)} %` +
(nachricht.info.schonGerippt !== null
? ` — schon einmal gerippt (${nachricht.info.schonGerippt.slice(0, 10)})`
: ''),
)
}
},
() => statusSenden(),
smokeModus,
)
// Die laufende Kompression (5.4.0) — ein eigener Schlüssel in den
// aktiven Vorgängen, weil sie an keinem Laufwerk mehr hängt.
const KOMPRESSION = 'kompression'
let kompressionStand: { titel: string; prozent: number } | null = null
function kompressionVerarbeiten(laeuft: { titel: string; prozent: number } | null): void {
kompressionStand = laeuft === null ? null : { titel: laeuft.titel, prozent: laeuft.prozent }
if (laeuft === null) aktiveVorgaenge.delete(KOMPRESSION)
else aktiveVorgaenge.add(KOMPRESSION)
anzeigeAktualisieren()
}
/** Taskleiste und Tray aus dem, was gerade läuft: ein Rip zuerst, sonst
* die Kompression sonst nichts. */
let letzterRip: Extract<KernNachricht, { art: 'rip-status' }>['status'] | null = null
function anzeigeAktualisieren(): void {
const rip = letzterRip !== null && letzterRip.phase === 'rippt' ? letzterRip : null
if (fenster !== null && !fenster.isDestroyed()) {
if (rip !== null && rip.prozent >= 0) {
fenster.setProgressBar(rip.prozent / 100)
} else if (letzterRip !== null && letzterRip.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else if (kompressionStand !== null && kompressionStand.prozent >= 0) {
fenster.setProgressBar(kompressionStand.prozent / 100)
} else {
fenster.setProgressBar(-1)
}
}
if (rip !== null) {
trayHinweis(`rippt Laufwerk ${rip.id}${rip.prozent >= 0 ? ` (${rip.prozent} %)` : ''}`)
} else if (kompressionStand !== null) {
trayHinweis(`komprimiert „${kompressionStand.titel}"${kompressionStand.prozent >= 0 ? ` (${kompressionStand.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
}
// § 7: Toasts, Taskleisten-Fortschritt und Tray-Zustand aus dem
// Rip-Status — die Information, die man will, ohne das Fenster zu öffnen.
function ripStatusVerarbeiten(status: Extract<KernNachricht, { art: 'rip-status' }>['status']): void {
if (status.phase === 'rippt' || status.phase === 'komprimiert' || status.phase === 'legt-ab' || status.phase === 'liest-info') {
aktiveVorgaenge.add(status.id)
} else {
aktiveVorgaenge.delete(status.id)
}
letzterRip = status
anzeigeAktualisieren()
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
}
// § 9: Der Kern hat einen geprüften Installer abgelegt — gestartet wird
// er HIER über die Shell. Zwei Gründe: (1) MakeMKVs Installer fordert im
// Manifest Administratorrechte an — CreateProcess bricht dann mit
// ERROR_ELEVATION_REQUIRED ab, nur der Shell-Weg zeigt die UAC-Abfrage
// (beschaffen.py, am Commander-PC gemessen). (2) Die Erhöhung läuft über
// den AppInfo-Dienst, der Installer ist dadurch KEIN Kind von Rippy —
// er hängt nicht an der Prozess-Leine und überlebt ein Beenden.
async function installerStarten(pfad: string): Promise<void> {
// Nur, was der Kern selbst geladen und geprüft hat: eine .exe aus
// Rippys eigenem Downloads-Ordner.
const downloads = win32.join(werkzeugOrdner(), 'downloads') + '\\'
if (!pfad.startsWith(downloads) || !pfad.toLowerCase().endsWith('.exe')) {
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: `Pfad außerhalb des Downloads-Ordners: ${pfad}` })
return
}
const fehler = await shell.openPath(pfad)
kernVerwaltung.senden({ art: 'installer-ergebnis', fehler: fehler.length > 0 ? fehler : null })
}
function toast(titel: string, text: string): void {
try {
if (!Notification.isSupported()) return
const meldung = new Notification({ title: titel, body: text.slice(0, 200) })
meldung.on('click', () => fensterZeigen())
meldung.show()
} catch (fehler) {
console.error(`[haupt] Toast fehlgeschlagen: ${String(fehler)}`)
}
}
function statusSenden(): void {
if (fenster === null || fenster.isDestroyed()) return
const status: HauptStatus = {
version: app.getVersion(),
leine,
kern: kernVerwaltung.zustand(),
autostart: autostartAktiv(),
update: updateStand(),
// Für die eigene Titelleiste: Das Knopf-Symbol muss wissen, ob das
// Fenster gerade maximiert ist.
fensterMaximiert: fenster !== null && !fenster.isDestroyed() && fenster.isMaximized(),
}
fenster.webContents.send('haupt-status', status)
}
function fensterVerdrahten(): void {
if (fenster === null) return
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()
})
// Die eigene Titelleiste zeichnet ihr Maximieren-Symbol nach diesem
// Zustand — auch wenn per Doppelklick oder Windows-Tastenkürzel
// maximiert wird, nicht nur über unseren Knopf.
fenster.on('maximize', () => statusSenden())
fenster.on('unmaximize', () => statusSenden())
// § 4.1 Punkt 3: Fenster zu, Rip läuft weiter — Rippy lebt im Tray.
fenster.on('close', (ereignis) => {
if (beendenGewollt) return
ereignis.preventDefault()
fenster?.hide()
})
}
function fensterZeigen(): void {
if (fenster === null || fenster.isDestroyed()) {
fenster = fensterErstellen()
fensterVerdrahten()
return
}
if (fenster.isMinimized()) fenster.restore()
fenster.show()
fenster.focus()
}
function beenden(): void {
beendenGewollt = true
app.quit()
}
// 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()
}
})
// Eigene Titelleiste (§ 4.2): Das Fenster hat keinen Windows-Rahmen
// mehr, die drei Knöpfe sitzen im UI und melden sich hier.
ipcMain.handle('fenster-steuern', (_ereignis, was: unknown) => {
if (fenster === null || fenster.isDestroyed()) return false
if (was === 'minimieren') {
fenster.minimize()
} else if (was === 'maximieren') {
if (fenster.isMaximized()) fenster.unmaximize()
else fenster.maximize()
} else if (was === 'schliessen') {
// Genau der Weg des alten Fensterkreuzes: Der close-Horcher weiter
// unten versteckt nur — Rippy lebt im Tray weiter (§ 4.1 Punkt 3),
// ein laufender Rip darf davon nichts merken.
fenster.close()
} else {
return false
}
return fenster.isDestroyed() ? false : fenster.isMaximized()
})
// Der Knopf „Jetzt prüfen" (§ 6.8). Der Stand meldet sich über
// beiAenderung von selbst ans Fenster — hier gibt es nichts zurück.
ipcMain.handle('update-pruefen', () => {
updatePruefenJetzt()
return updateStand()
})
// Der Knopf „Jetzt neu starten und installieren". Sagt NEIN mit Grund,
// statt still nichts zu tun (R4).
ipcMain.handle('update-installieren', () => {
const urteil = updateInstallierenJetzt()
if (!urteil.ok) statusSenden()
return urteil
})
// Autostart (§ 8): HKCU\...\Run über Electrons eigene API.
ipcMain.handle('autostart-setzen', (_ereignis, an: unknown) => {
autostartSetzen(an === true)
statusSenden()
return autostartAktiv()
})
// Echter Windows-Ordnerdialog (§ 7) — der Nutzer sieht seine
// Netzlaufwerke und Schnellzugriffe statt eines nachgebauten Browsers.
ipcMain.handle('ordner-waehlen', async (_ereignis, titel: unknown) => {
if (fenster === null) return null
const ergebnis = await dialog.showOpenDialog(fenster, {
title: typeof titel === 'string' && titel.length > 0 ? titel : 'Ordner wählen',
properties: ['openDirectory', 'createDirectory'],
})
return ergebnis.canceled || ergebnis.filePaths.length === 0 ? null : ergebnis.filePaths[0]
})
app.on('second-instance', () => {
fensterZeigen()
})
// Mit Tray beendet ein geschlossenes Fenster Rippy NICHT mehr — es läuft
// im Hintergrund weiter. Beendet wird über das Tray-Menü.
app.on('window-all-closed', () => {
if (beendenGewollt) app.quit()
})
app.on('before-quit', () => {
beendenGewollt = true
kernVerwaltung.beenden()
})
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
fensterVerdrahten()
})
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;
// RIPPY_SMOKE_WARTE_MS verlängert das für Bild-Beweise, die auf
// späte Kern-Antworten warten.
const warteMs = Number(process.env['RIPPY_SMOKE_WARTE_MS'] ?? '750')
await new Promise((fertig) => setTimeout(fertig, Number.isFinite(warteMs) ? warteMs : 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 kernInfo =
kernBereit !== null ? `kern=pid:${kernBereit.pid},node:${kernBereit.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)
}
}
+108
View File
@@ -0,0 +1,108 @@
// 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 { app, 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,
private readonly smokeModus = false,
) {}
starten(): void {
const kernDatei = join(__dirname, 'kern.js')
const args = [`--datenbank=${this.datenbankPfad}`]
// Der Smoke-Beweis läuft ohne Netz und ohne MakeMKV — der Kern lässt
// die Schlüsselkette dann aus.
if (this.smokeModus) args.push('--smoke-kern')
// Im installierten Rippy liegen die mitgelieferten Werkzeuge unter
// resources\vendor — der Kern findet sie über RIPPY_VENDOR (§ 9).
const umgebung: NodeJS.ProcessEnv = { ...process.env }
if (app.isPackaged) {
umgebung['RIPPY_VENDOR'] = join(process.resourcesPath, 'vendor')
}
const kern = utilityProcess.fork(kernDatei, args, {
serviceName: 'Rippy-Kern',
stdio: 'inherit',
env: umgebung,
})
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])
}
/** Eine Nachricht an den Kern (z. B. die Antwort auf 'installer-starten'). */
senden(nachricht: HauptNachricht): void {
this.kern?.postMessage(nachricht)
}
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
}
}
+49
View File
@@ -0,0 +1,49 @@
// Windows-Kommandozeilen bauen — PUR, ohne koffi und ohne Electron, damit
// die Ampel das auf Linux prüfen kann (BAUEN.md).
//
// CreateProcessW bekommt EINE Zeichenkette, keine Argumentliste. Wer sie
// naiv mit Leerzeichen zusammenklebt, verliert jeden Pfad mit Leerzeichen:
// Aus `C:\Users\Tobi Neu\…\RippySetup.exe` würden zwei Argumente, und der
// Installer startet nie. Die Regeln sind die von CommandLineToArgvW —
// dieselben, nach denen die Gegenseite die Zeichenkette wieder zerlegt.
/** Ein Argument nach den Regeln von CommandLineToArgvW einpacken. */
export function argumentQuoten(arg: string): string {
// Ohne Leerzeichen, Tabulator und Anführungszeichen bleibt alles, wie es
// ist — auch der leere Fall braucht Anführungszeichen, sonst verschwindet er.
if (arg.length > 0 && !/["\s]/.test(arg)) return arg
let ergebnis = '"'
let rueckstriche = 0
for (const zeichen of arg) {
if (zeichen === '\\') {
rueckstriche += 1
continue
}
if (zeichen === '"') {
// Vor einem Anführungszeichen zählen Rückstriche doppelt, und das
// Anführungszeichen selbst bekommt einen eigenen davor.
ergebnis += '\\'.repeat(rueckstriche * 2 + 1) + '"'
rueckstriche = 0
continue
}
ergebnis += '\\'.repeat(rueckstriche) + zeichen
rueckstriche = 0
}
// Am Ende steht das schließende Anführungszeichen — die Rückstriche
// davor zählen deshalb ebenfalls doppelt.
return ergebnis + '\\'.repeat(rueckstriche * 2) + '"'
}
/** Programm + Argumente zu der einen Zeichenkette, die CreateProcessW will. */
export function kommandozeileBauen(programm: string, argumente: readonly string[]): string {
return [programm, ...argumente].map(argumentQuoten).join(' ')
}
/** Die Argumente, mit denen der NSIS-Installer still durchläuft und Rippy
* danach wieder startet. Gemessen an der installierten Bibliothek
* (electron-updater `NsisUpdater.js`, `doInstall`): `--updated` immer,
* `/S` für still", `--force-run` für „danach starten". Genau diese drei
* benutzt electron-updater selbst wir starten den Installer nur auf
* einem anderen Weg (außerhalb der Prozess-Leine). */
export const INSTALLER_ARGUMENTE: readonly string[] = ['--updated', '/S', '--force-run']
+186
View File
@@ -0,0 +1,186 @@
// 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'
import { kommandozeileBauen } from './kommandozeile'
// 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
// Erlaubt AUSDRÜCKLICHES Ausklinken. Für sich genommen ändert dieses Recht
// NICHTS an der Leine: Kinder erben die Mitgliedschaft weiterhin, es sei
// denn, sie verlangen das Ausklinken beim Anlegen selbst
// (CREATE_BREAKAWAY_FROM_JOB). Ohne das Recht scheitert genau das mit
// ERROR_ACCESS_DENIED. makemkvcon, HandBrakeCLI und der Kern bleiben also
// angeleint — die rc10-Waisen-Falle (§ 3.3) ist unverändert zu.
//
// WOFÜR (01.09.2026, am lebenden Objekt bezahlt): Der Update-Installer von
// electron-updater wird per `spawn(…, { detached: true })` gestartet
// (BaseUpdater.js, an der installierten Fassung nachgelesen). `detached`
// setzt unter Windows KEIN Breakaway — der Installer wurde damit Mitglied
// der Leine und starb in genau der Sekunde, in der Rippy sich beendete, um
// ihm Platz zu machen. Befund des Commanders: „Rippy wird zwar sauber
// beendet aber startet NICHT neu in der neuen version."
const JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x0800
/** Nur ein Kind, das das ausdrücklich verlangt, verlässt die Leine. */
const CREATE_BREAKAWAY_FROM_JOB = 0x01000000
// Eigene (unsichtbare) Konsole statt der des Elternprozesses. Für den
// NSIS-Installer (ein Fenster-Programm) wirkungslos, aber die Lehre aus
// dem ersten Messlauf zu `beweise/leine-breakaway.js` (01.09.2026): Ein
// Kind, das sich die Konsole des Elternprozesses teilt, stirbt MIT IHR —
// und sieht dann aus, als hätte das Ausklinken nicht gewirkt. Die Messung
// zeigte deshalb erst ein falsches Negativ.
const CREATE_NO_WINDOW = 0x08000000
// STARTUPINFOW ist auf x64 104 Byte, `cb` steht als DWORD an Offset 0;
// PROCESS_INFORMATION ist 24 Byte (zwei Handles + zwei DWORDs).
const STARTUPINFO_GROESSE = 104
const PROCESS_INFORMATION_GROESSE = 24
// 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 | JOB_OBJECT_LIMIT_BREAKAWAY_OK, 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
}
}
/**
* Ein Programm starten, das die Prozess-Leine AUSDRÜCKLICH verlässt.
*
* Gedacht für genau einen Fall: den Update-Installer. Er ist das einzige
* Kind, das Rippys eigenen Tod überleben MUSS er installiert ja gerade
* über Rippy drüber und startet es danach wieder. Alles andere (Kern,
* makemkvcon, HandBrakeCLI) bleibt angeleint, § 3.3 ist unverändert.
*
* Warum nicht `shell.openPath` wie beim MakeMKV-Installer (§ 9)? Der
* entkommt der Leine nur, WEIL er Adminrechte verlangt und deshalb über
* den AppInfo-Dienst startet. Rippys eigener Installer ist bewusst
* benutzerlokal und ohne Erhöhung (Entscheid 5 / § 8; in der
* `update-info.json` steht `isAdminRightsRequired: false`) er liefe also
* wieder als Kind und stürbe wieder mit.
*/
export function ausserhalbDerLeineStarten(
programm: string,
argumente: readonly string[],
): { ok: boolean; fehler: string } {
try {
const kernel32 = koffi.load('kernel32.dll')
const CreateProcessW = kernel32.func(
'bool __stdcall CreateProcessW(str16 lpApplicationName, void *lpCommandLine, void *lpProcessAttributes, void *lpThreadAttributes, bool bInheritHandles, uint32_t dwCreationFlags, void *lpEnvironment, str16 lpCurrentDirectory, void *lpStartupInfo, void *lpProcessInformation)',
)
const GetLastError = kernel32.func('uint32_t __stdcall GetLastError()')
const startupInfo = Buffer.alloc(STARTUPINFO_GROESSE)
startupInfo.writeUInt32LE(STARTUPINFO_GROESSE, 0)
const prozessInfo = Buffer.alloc(PROCESS_INFORMATION_GROESSE)
// CreateProcessW DARF diese Zeichenkette verändern — deshalb ein
// beschreibbarer Puffer und nicht die Zeichenkette selbst.
const zeile = Buffer.from(`${kommandozeileBauen(programm, argumente)}\0`, 'utf16le')
const gestartet = CreateProcessW(
programm,
zeile,
null,
null,
false,
CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW,
null,
null,
startupInfo,
prozessInfo,
)
if (!gestartet) {
// Win32-Fehler 5 (ERROR_ACCESS_DENIED) heißt hier: Das Ausklinken war
// nicht erlaubt — dann fehlt JOB_OBJECT_LIMIT_BREAKAWAY_OK, oder eine
// ÄUSSERE Arbeitsgruppe verbietet es (Sandbox, Fernwartung).
return { ok: false, fehler: `CreateProcessW: Win32-Fehler ${GetLastError()}` }
}
// Die beiden Handles in prozessInfo bleiben offen: Rippy beendet sich
// unmittelbar danach, Windows schließt sie dabei mit. Sie hier zu
// schließen hieße, sie erst aus dem Puffer zurückzudecodieren — Aufwand
// ohne Wirkung.
return { ok: true, fehler: '' }
} catch (fehler) {
return { ok: false, fehler: `koffi/kernel32 nicht verfügbar: ${String(fehler)}` }
}
}
+52
View File
@@ -0,0 +1,52 @@
// Tray-Symbol mit Zustand (KONZEPT § 7): auf einen Blick, ob ein Rip
// läuft und wie weit. Mit dem Tray ändert sich das Schließen-Verhalten:
// Fenster zu heißt „im Hintergrund weiterarbeiten" (§ 4.1 Punkt 3) —
// beendet wird über das Kontextmenü oder den Menüpunkt.
import { Menu, nativeImage, Tray } from 'electron'
import { join } from 'node:path'
let tray: Tray | null = null
export function trayErstellen(oeffnen: () => void, beenden: () => void): Tray | null {
try {
// Das Bau-Icon liegt neben dem Bundle: out/haupt → ../../bau/tray.png
// im Quell-Lauf; im gepackten Programm über resourcesPath (W-6 legt es
// per extraResources dorthin).
const kandidaten = [
join(__dirname, '..', '..', 'bau', 'tray.png'),
join(process.resourcesPath ?? '', 'bau', 'tray.png'),
]
let bild = nativeImage.createEmpty()
for (const kandidat of kandidaten) {
const versuch = nativeImage.createFromPath(kandidat)
if (!versuch.isEmpty()) {
bild = versuch
break
}
}
tray = new Tray(bild)
tray.setToolTip('Rippy — bereit')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Rippy öffnen', click: oeffnen },
{ type: 'separator' },
{ label: 'Beenden', click: beenden },
]),
)
tray.on('click', oeffnen)
return tray
} catch (fehler) {
// R4: Ohne Tray läuft Rippy trotzdem — aber der Grund steht im Log.
console.error(`[haupt] Tray nicht erstellt: ${String(fehler)}`)
return null
}
}
export function trayHinweis(text: string): void {
tray?.setToolTip(text.length > 0 ? `Rippy — ${text}` : 'Rippy — bereit')
}
export function trayWeg(): void {
tray?.destroy()
tray = null
}
+61
View File
@@ -0,0 +1,61 @@
// Die reinen Update-Regeln (§ 6.8) — getrennt von update.ts, damit die
// Tests sie ohne Electron laden können.
export const STANDARD_UPDATE_URL =
'https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy/releases/download/aktuell'
/** Prüft die Update-Adresse: NUR https:// ist zulässig (§ 6.8 Regel 1)
* ohne Code-Signing (Entscheid 5) ist der Transportweg die einzige
* Absicherung; eine http://-Adresse wird ABGELEHNT, nicht still
* akzeptiert. */
export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
const sauber = url.trim()
if (sauber.length === 0) return { ok: true, grund: '' } // Standard gilt
if (sauber.startsWith('https://')) return { ok: true, grund: '' }
return {
ok: false,
grund:
'Die Update-Adresse muss mit https:// beginnen — ohne Signatur ist der sichere Transportweg die einzige Absicherung (KONZEPT § 6.8).',
}
}
/** So viel Änderungstext zeigt das Fenster höchstens der Rest wird
* abgeschnitten, damit eine ausufernde Datei die Karte nicht sprengt. */
export const NEUERUNGEN_HOECHSTENS = 4000
/** Die Änderungsnotizen aus `latest.yml` in reinen Text.
*
* `UpdateInfo.releaseNotes` ist laut builder-util-runtime
* `string | Array<ReleaseNoteInfo> | null` bei uns ein String
* (electron-builder legt den Inhalt von `bau/release-notes.md` hinein),
* bei mehreren übersprungenen Versionen eine Liste. Beides muss hier
* ankommen können, sonst steht im Fenster [object Object]".
*
* HTML-Marken fliegen raus: Der Text kommt von einem Server, und auch
* wenn React ihn ohnehin nur als Text setzt, wird hier nichts
* weitergereicht, was wie Auszeichnung aussieht. */
export function neuerungenText(roh: unknown): string {
const saubern = (t: string): string =>
t
.replace(/<[^>]*>/g, ' ')
.split('\n')
.map((zeile) => zeile.replace(/[ \t]+$/, ''))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
if (typeof roh === 'string') return saubern(roh).slice(0, NEUERUNGEN_HOECHSTENS)
if (Array.isArray(roh)) {
const teile: string[] = []
for (const eintrag of roh) {
if (eintrag === null || typeof eintrag !== 'object') continue
const e = eintrag as { version?: unknown; note?: unknown }
const version = typeof e.version === 'string' ? e.version : ''
const notiz = typeof e.note === 'string' ? saubern(e.note) : ''
if (notiz.length === 0) continue
teile.push(version.length > 0 ? `${version}\n${notiz}` : notiz)
}
return teile.join('\n\n').slice(0, NEUERUNGEN_HOECHSTENS)
}
return ''
}
+246
View File
@@ -0,0 +1,246 @@
// Selbst-Update (KONZEPT § 6.8) — electron-updater gegen die Releases im
// öffentlichen Gitea (Entscheid 6). Die vier Regeln:
// 1. NUR HTTPS — ohne Code-Signing (Entscheid 5) ist der Transportweg
// die einzige Absicherung; eine http://-Adresse wird ABGELEHNT.
// 2. NIE während eines Rips — weder prüfen, laden noch installieren.
// 3. Ein Fehlschlag ist sichtbar, aber nicht laut: Der Zeitpunkt der
// letzten erfolgreichen Prüfung steht im Status.
// 4. Die laufende Version ist ablesbar (Fenster + Protokollkopf).
//
// Ergänzt am 01.09.2026 auf Zuruf des Commanders („irgendwie fehlt der
// Knopf Auf Updates prüfen' und die Meldung beim Start"): Der Kanal
// arbeitete korrekt, war aber UNSICHTBAR — der Stand lag im Haupt und
// wurde nur beim Laden des Fensters einmal mitgeschickt. Jetzt trägt er
// zusätzlich die gefundene Version, ihre Änderungsnotizen und den
// Ladefortschritt, und JEDE Änderung meldet sich sofort ans Fenster.
import { app } from 'electron'
import electronUpdater from 'electron-updater'
import { INSTALLER_ARGUMENTE } from './kommandozeile'
import { ausserhalbDerLeineStarten } from './leine'
import { neuerungenText, updateUrlPruefen } from './update-regeln'
export {
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
neuerungenText,
updateUrlPruefen,
} from './update-regeln'
const { autoUpdater } = electronUpdater
// EINE Definition für Haupt und Fenster (§ 6.7 sinngemäß: eine Quelle).
export type { UpdateStand } from '../gemeinsam/nachrichten'
import type { UpdateStand } from '../gemeinsam/nachrichten'
const ANFANG: UpdateStand = {
letzteErfolgreichePruefung: '',
text: 'noch nicht geprüft',
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
}
let stand: UpdateStand = { ...ANFANG }
let aenderungMelden: () => void = () => {
// Vor updateEinrichten() gibt es noch kein Fenster, das zuhören könnte.
}
let ripLaeuftJetzt: () => boolean = () => false
let scharf = false
/** Der geladene Installer — leer, solange nichts bereitliegt. */
let installerPfad = ''
/** Einmal gestartet reicht: Der Knopf und der Beenden-Weg dürfen sich
* nicht gegenseitig zwei Installer auf den Hals hetzen. */
let installerGestartet = false
let rippyBeenden: () => void = () => app.quit()
export function updateStand(): UpdateStand {
return { ...stand }
}
/** Stand ändern UND das Fenster benachrichtigen nie das eine ohne das
* andere: Ein Stand, den niemand erfährt, ist der Zustand von vorher
* (der 26.07.-Fund ein still scheiternder Hintergrund-Prozess"). */
function setzen(teil: Partial<UpdateStand>): void {
stand = { ...stand, ...teil }
aenderungMelden()
}
export function updateEinrichten(
eigeneUrl: string,
ripLaeuft: () => boolean,
melden: (text: string) => void,
beiAenderung: () => void = () => {
// ohne Fenster kein Empfänger — der Stand bleibt trotzdem abrufbar
},
beenden: () => void = () => app.quit(),
): void {
aenderungMelden = beiAenderung
ripLaeuftJetzt = ripLaeuft
rippyBeenden = beenden
if (!app.isPackaged) {
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
return
}
const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) {
setzen({ text: pruefung.grund })
melden(pruefung.grund)
return
}
if (eigeneUrl.trim().length > 0) {
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
}
autoUpdater.autoDownload = false
// WIR installieren, nicht electron-updater (01.09.2026, am lebenden
// Objekt bezahlt): Sein eigener Weg startet den Installer per
// `spawn(…, { detached: true })`, und `detached` setzt unter Windows KEIN
// Breakaway. Der Installer wurde damit Mitglied der Prozess-Leine (§ 3.3)
// und starb in genau der Sekunde, in der Rippy sich für ihn beendete.
// Befund: „Rippy wird zwar sauber beendet aber startet NICHT neu."
autoUpdater.autoInstallOnAppQuit = false
scharf = true
// Beim Beenden — egal ob über das Tray, den Update-Knopf oder das
// Herunterfahren von Windows — bekommt ein bereitliegendes Update noch
// seinen Start. CreateProcessW ist synchron: Der Installer LEBT, bevor
// diese Zeile zurückkommt.
app.on('will-quit', () => {
if (!stand.bereit || ripLaeuftJetzt()) return
installerAnwerfen()
})
autoUpdater.on('update-available', (info) => {
const neuerungen = neuerungenText(info.releaseNotes)
if (ripLaeuft()) {
// Regel 2: nicht einmal herunterladen, solange gerippt wird — der
// tägliche Takt versucht es wieder.
setzen({
neueVersion: info.version,
neuerungen,
laeuft: false,
fortschritt: -1,
text: `Version ${info.version} ist da — sie wird geladen, sobald kein Rip mehr läuft.`,
})
melden(stand.text)
return
}
setzen({
neueVersion: info.version,
neuerungen,
laeuft: true,
fortschritt: 0,
text: `Version ${info.version} wird geladen …`,
})
// Der Commander wollte die Meldung sehen, nicht suchen müssen.
melden(`Update verfügbar: Version ${info.version} wird geladen.`)
void autoUpdater
.downloadUpdate()
.then((pfade: string[]) => {
// downloadUpdate liefert die abgelegten Dateien zurück
// (AppUpdater.d.ts: „Paths to downloaded files") — die .exe darin
// ist der Installer, den wir gleich selbst starten. Liegt die Datei
// vom letzten Lauf schon im Zwischenspeicher, kommt sie hier
// ebenfalls heraus, ohne erneut zu laden.
const exe = (pfade ?? []).find((p) => p.toLowerCase().endsWith('.exe')) ?? ''
if (exe.length > 0) installerPfad = exe
})
.catch((fehler: unknown) => {
setzen({
laeuft: false,
fortschritt: -1,
text: `Update-Download fehlgeschlagen: ${String(fehler).slice(0, 160)}`,
})
})
})
autoUpdater.on('download-progress', (fortschritt) => {
setzen({ laeuft: true, fortschritt: Math.round(fortschritt.percent) })
})
autoUpdater.on('update-downloaded', (info) => {
setzen({
neueVersion: info.version,
neuerungen: neuerungenText(info.releaseNotes) || stand.neuerungen,
bereit: true,
laeuft: false,
fortschritt: 100,
text: `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`,
})
melden(stand.text)
})
autoUpdater.on('update-not-available', () => {
setzen({
letzteErfolgreichePruefung: new Date().toISOString(),
neueVersion: '',
neuerungen: '',
bereit: false,
laeuft: false,
fortschritt: -1,
text: 'Rippy ist aktuell.',
})
})
autoUpdater.on('error', (fehler) => {
// Regel 3: still scheitern (WLAN weg, Server aus) — aber ablesbar.
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
})
pruefen()
setInterval(pruefen, 24 * 60 * 60 * 1000).unref?.()
}
function pruefen(): void {
if (ripLaeuftJetzt()) return
setzen({ laeuft: true, text: 'Suche nach Updates …' })
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
/** Der Knopf Jetzt prüfen" im Fenster. Antwortet IMMER etwas ein
* Knopf, der nichts sagt, ist schlimmer als keiner. */
export function updatePruefenJetzt(): void {
if (!scharf) {
// Im Entwicklungslauf steht der Grund schon im Text; ihn erneut zu
// melden hält den Knopf ehrlich, statt ihn ins Leere laufen zu lassen.
aenderungMelden()
return
}
if (ripLaeuftJetzt()) {
setzen({ text: 'Während eines Rips wird nicht nach Updates gesucht — das ist Absicht (§ 6.8).' })
return
}
pruefen()
}
/** Den Installer AUSSERHALB der Prozess-Leine anwerfen. Gibt zurück, ob
* er wirklich läuft nichts passiert" darf nicht als Erfolg durchgehen. */
function installerAnwerfen(): { ok: boolean; grund: string } {
if (installerGestartet) return { ok: true, grund: '' }
if (installerPfad.length === 0) {
return { ok: false, grund: 'Es liegt kein geladener Installer bereit.' }
}
const urteil = ausserhalbDerLeineStarten(installerPfad, INSTALLER_ARGUMENTE)
if (!urteil.ok) {
setzen({ text: `Update konnte nicht gestartet werden: ${urteil.fehler}` })
return { ok: false, grund: urteil.fehler }
}
installerGestartet = true
return { ok: true, grund: '' }
}
/** Der Knopf Jetzt neu starten und installieren". Nur sinnvoll, wenn
* wirklich etwas bereitliegt und nie mitten in einem Rip. */
export function updateInstallierenJetzt(): { ok: boolean; grund: string } {
if (!scharf || !stand.bereit) {
return { ok: false, grund: 'Es liegt kein geladenes Update bereit.' }
}
if (ripLaeuftJetzt()) {
return { ok: false, grund: 'Erst wenn der Rip fertig ist — Rippy startet dafür neu (§ 6.8).' }
}
// Erst den Installer, DANN beenden: Er wartet von sich aus, bis Rippy
// weg ist, und startet es hinterher wieder (`--force-run`).
const urteil = installerAnwerfen()
if (!urteil.ok) return urteil
rippyBeenden()
return { ok: true, grund: '' }
}
@@ -0,0 +1,46 @@
// Bibliotheks-Refresh des Medienservers anstoßen (KONZEPT § 6.10) —
// Wege aus medien.py: Jellyfin/Emby per POST /Library/Refresh mit
// X-Emby-Token; Kodi per JSON-RPC VideoLibrary.Scan mit Basic-Auth
// (api_key = "user:passwort"). Wirft NIE — der Refresh ist Komfort,
// nicht Teil des Rips (R4: das Ergebnis wird trotzdem GEMELDET).
export type MedienServer = 'jellyfin' | 'emby' | 'kodi' | 'plex' | 'none'
export async function bibliothekRefresh(
server: string,
url: string,
apiKey: string,
holen: typeof fetch = fetch,
): Promise<string> {
if (url.length === 0 || !['jellyfin', 'emby', 'kodi'].includes(server)) return ''
try {
if ((server === 'jellyfin' || server === 'emby') && apiKey.length > 0) {
const antwort = await holen(url.replace(/\/+$/, '') + '/Library/Refresh', {
method: 'POST',
headers: { 'X-Emby-Token': apiKey },
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) {
return `${server[0].toUpperCase()}${server.slice(1)}-Bibliothek aktualisiert (${url})`
}
return `Bibliotheks-Refresh: HTTP ${antwort.status} von ${url}`
}
if (server === 'kodi') {
const kopf: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey.includes(':')) {
kopf['Authorization'] = 'Basic ' + Buffer.from(apiKey).toString('base64')
}
const antwort = await holen(url.replace(/\/+$/, '') + '/jsonrpc', {
method: 'POST',
headers: kopf,
body: JSON.stringify({ jsonrpc: '2.0', method: 'VideoLibrary.Scan', id: 1 }),
signal: AbortSignal.timeout(15_000),
})
if (antwort.status < 300) return `Kodi-Bibliothek aktualisiert (${url})`
return `Kodi-Refresh: HTTP ${antwort.status} von ${url}`
}
} catch (fehler) {
return `Bibliotheks-Refresh fehlgeschlagen: ${String(fehler)}`
}
return ''
}
+46
View File
@@ -0,0 +1,46 @@
// movie.nfo/tvshow.nfo im Kodi-Schema (kodi.wiki/view/NFO_files) —
// Jellyfin, Emby und Kodi lesen es nativ; Plex bekommt nur die Benennung
// (liest NFO ohne Zusatz-Agent nicht). Regeln aus medien.py.
export const NFO_SERVER = ['jellyfin', 'emby', 'kodi']
export interface NfoMeta {
typ?: 'film' | 'serie'
beschreibung?: string
genres?: readonly string[]
laufzeitMinuten?: number
quelle?: string
}
function xml(wert: string): string {
return wert
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
/** Minimales NFO (pur, testbar). */
export function baueNfo(meta: NfoMeta, titel: string, jahr?: number | string | null): string {
const wurzel = meta.typ === 'serie' ? 'tvshow' : 'movie'
const zeilen = [
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>',
`<${wurzel}>`,
` <title>${xml(titel ?? '')}</title>`,
]
if (jahr !== undefined && jahr !== null && String(jahr).length > 0) {
zeilen.push(` <year>${xml(String(jahr))}</year>`)
}
if (meta.beschreibung !== undefined && meta.beschreibung.length > 0) {
zeilen.push(` <plot>${xml(meta.beschreibung)}</plot>`)
}
for (const genre of meta.genres ?? []) {
zeilen.push(` <genre>${xml(String(genre))}</genre>`)
}
if (meta.laufzeitMinuten !== undefined && meta.laufzeitMinuten > 0) {
zeilen.push(` <runtime>${xml(String(meta.laufzeitMinuten))}</runtime>`)
}
zeilen.push(` <details>Source: ${xml(meta.quelle ?? 'Rippy')}</details>`)
zeilen.push(`</${wurzel}>`)
return zeilen.join('\n') + '\n'
}
+30
View File
@@ -0,0 +1,30 @@
// Poster/Fanart neben den Film legen (KONZEPT § 6.10) — Regeln aus
// medien.py: relative TMDb-Pfade bekommen das w500-Präfix; ein fehlendes
// Poster darf einen gelungenen Rip NIE kippen (best effort, aber die
// Meldung sagt was passiert ist).
import { writeFileSync } from 'node:fs'
export function posterUrl(posterPfad: string): string {
if (posterPfad.length === 0) return ''
if (posterPfad.startsWith('http')) return posterPfad
return `https://image.tmdb.org/t/p/w500${posterPfad}`
}
export async function posterSpeichern(
posterPfad: string,
zielDatei: string,
holen: typeof fetch = fetch,
): Promise<string> {
const url = posterUrl(posterPfad)
if (url.length === 0) return ''
try {
const antwort = await holen(url, { signal: AbortSignal.timeout(15_000) })
if (antwort.status !== 200) return `Poster: HTTP ${antwort.status}`
const daten = Buffer.from(await antwort.arrayBuffer())
if (daten.length === 0) return 'Poster: leere Antwort'
writeFileSync(zielDatei, daten)
return 'poster.jpg gespeichert'
} catch (fehler) {
return `Poster fehlgeschlagen: ${String(fehler)}`
}
}
@@ -0,0 +1,198 @@
// Roh-Dateien aufräumen (5.4.0) — PUR bis auf injizierte Dateizugriffe.
//
// Der Befund: Jede Blu-ray ließ 30+ GB unter roh\ liegen, niemand räumte
// auf. Seit W-3 stand im Code „Löschen bekommt erst mit der Einstellung im
// UI eine Stimme". Das ist diese Stimme. Die Regel des Commanders
// (01.09.2026): „Nach 7 Tagen löschen, nur bei gelungener Kompression".
//
// Drei Sicherungen, alle bewusst:
// 1. Gelöscht wird NUR ein Roh-Ordner, dessen Vorgang FERTIG und
// KOMPRIMIERT ist. Unkomprimierte Rips (4K verlustfrei) sind verschoben
// worden, ihr Roh-Ordner ist leer — es gibt dort nichts zu löschen.
// 2. Vorher wird nachgesehen, ob JEDE fertige Datei noch da ist und nicht
// leer — eine gelöschte Roh-Datei ohne heiles Ergebnis wäre der einzige
// Weg, wie Rippy Daten verlieren könnte.
// 3. Ordner unter roh\, die KEIN Vorgang kennt (aus der Zeit vor 5.4.0),
// werden nur gezeigt — löschen darf sie nur ein Klick des Nutzers.
import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
// Plattform-Pfade: Laufzeit-Wurzeln (Ablage-Einstellung), CI testet auf Linux.
import { join } from 'node:path'
import type { FremderOrdner } from '../../gemeinsam/nachrichten'
export type AufbewahrungsRegel = { art: 'behalten' } | { art: 'sofort' } | { art: 'tage'; tage: number }
/** Einstellungs-Schlüssel und Vorgabe (Commander-Wahl 01.09.2026). */
export const AUFBEWAHRUNG_SCHLUESSEL = 'rohAufbewahrung'
export const STANDARD_AUFBEWAHRUNG = '7'
/** 'behalten' | 'sofort' | '<Tage>' → Regel. Unbekanntes = Vorgabe. */
export function regelLesen(wert: string | null | undefined): AufbewahrungsRegel {
const text = (wert ?? '').trim().toLowerCase()
if (text === 'behalten' || text === 'nie') return { art: 'behalten' }
if (text === 'sofort' || text === '0') return { art: 'sofort' }
const tage = Number(text.length > 0 ? text : STANDARD_AUFBEWAHRUNG)
if (Number.isInteger(tage) && tage > 0 && tage <= 3650) return { art: 'tage', tage }
return { art: 'tage', tage: Number(STANDARD_AUFBEWAHRUNG) }
}
export function regelText(regel: AufbewahrungsRegel): string {
if (regel.art === 'behalten') return 'Roh-Dateien werden behalten — aufgeräumt wird nur von Hand.'
if (regel.art === 'sofort') return 'Roh-Dateien werden sofort nach gelungener Kompression gelöscht.'
return `Roh-Dateien werden ${regel.tage} Tage nach gelungener Kompression gelöscht.`
}
/** Wann die Frist eines fertigen, komprimierten Vorgangs abläuft — '' wenn nie. */
export function faelligAm(beendetIso: string | null, regel: AufbewahrungsRegel): string {
if (beendetIso === null || beendetIso.length === 0 || regel.art === 'behalten') return ''
const beendet = new Date(beendetIso)
if (Number.isNaN(beendet.getTime())) return ''
if (regel.art === 'sofort') return beendet.toISOString()
return new Date(beendet.getTime() + regel.tage * 24 * 60 * 60 * 1000).toISOString()
}
/** Das, was das Aufräumen über einen Vorgang wissen muss. */
export interface AufraeumKandidat {
id: string
phase: string
komprimiert: boolean
rohOrdner: string
rohGeloeschtAm: string | null
beendet: string | null
fertigeDateien: readonly string[]
}
export interface AufraeumZugriff {
existiert: (pfad: string) => boolean
/** Dateigröße in Bytes; 0 wenn nicht lesbar. */
groesse: (pfad: string) => number
ordnerLoeschen: (pfad: string) => void
}
export const ECHTER_ZUGRIFF: AufraeumZugriff = {
existiert: existsSync,
groesse: (pfad) => {
try {
return statSync(pfad).size
} catch {
// Nicht lesbar zählt wie „nicht da" — und dann wird NICHT gelöscht.
return 0
}
},
ordnerLoeschen: (pfad) => rmSync(pfad, { recursive: true, force: true }),
}
/** Warum ein Vorgang (noch) nicht gelöscht wird — '' heißt: löschbar. */
export function loeschHindernis(
v: AufraeumKandidat,
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): string {
if (v.rohGeloeschtAm !== null) return 'schon gelöscht'
if (v.phase !== 'fertig') return `nicht fertig (${v.phase})`
if (!v.komprimiert) return 'nicht komprimiert — die Roh-Dateien SIND das Ergebnis'
if (!zugriff.existiert(v.rohOrdner)) return 'Roh-Ordner ist schon weg'
if (v.fertigeDateien.length === 0) return 'keine fertige Datei bekannt'
for (const datei of v.fertigeDateien) {
if (!zugriff.existiert(datei)) return `fertige Datei fehlt: ${datei}`
if (zugriff.groesse(datei) <= 0) return `fertige Datei ist leer: ${datei}`
}
if (regel.art === 'behalten') return 'Regel: behalten'
const frist = faelligAm(v.beendet, regel)
if (frist.length === 0) return 'kein Ende-Zeitpunkt bekannt'
if (new Date(frist).getTime() > jetzt.getTime()) return `Frist läuft bis ${frist.slice(0, 16).replace('T', ' ')}`
return ''
}
export interface AufraeumErgebnis {
geloescht: string[]
uebersprungen: Array<{ id: string; grund: string }>
fehler: Array<{ id: string; grund: string }>
}
/** Wendet die Regel an. Liefert, was gelöscht wurde und warum der Rest blieb. */
export function aufraeumen(
vorgaenge: readonly AufraeumKandidat[],
regel: AufbewahrungsRegel,
jetzt: Date,
zugriff: AufraeumZugriff = ECHTER_ZUGRIFF,
): AufraeumErgebnis {
const ergebnis: AufraeumErgebnis = { geloescht: [], uebersprungen: [], fehler: [] }
for (const v of vorgaenge) {
const hindernis = loeschHindernis(v, regel, jetzt, zugriff)
if (hindernis.length > 0) {
ergebnis.uebersprungen.push({ id: v.id, grund: hindernis })
continue
}
try {
zugriff.ordnerLoeschen(v.rohOrdner)
ergebnis.geloescht.push(v.id)
} catch (fehler) {
// R4: Ein Ordner, der sich nicht löschen lässt (offene Datei?), wird
// gesagt — beim nächsten Takt kommt er wieder dran.
ergebnis.fehler.push({ id: v.id, grund: String(fehler) })
}
}
return ergebnis
}
/** Belegung eines Ordners in Bytes (rekursiv). 0, wenn es ihn nicht gibt. */
export function ordnerGroesse(pfad: string): number {
let summe = 0
const gehen = (ordner: string): void => {
let eintraege: import('node:fs').Dirent[]
try {
eintraege = readdirSync(ordner, { withFileTypes: true })
} catch {
// Nicht lesbar zählt nicht mit — die Anzeige ist dann zu klein, nie
// erfunden.
return
}
for (const e of eintraege) {
const voll = join(ordner, e.name)
if (e.isDirectory()) gehen(voll)
else {
try {
summe += statSync(voll).size
} catch {
// siehe oben
}
}
}
}
if (existsSync(pfad)) gehen(pfad)
return summe
}
/** Ordner unter roh\, die kein Vorgang kennt — nur zeigen, nie von selbst löschen. */
export function fremdeOrdner(rohWurzel: string, bekannte: ReadonlySet<string>): FremderOrdner[] {
if (!existsSync(rohWurzel)) return []
const liste: FremderOrdner[] = []
let namen: string[]
try {
namen = readdirSync(rohWurzel, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
} catch (fehler) {
// R4: Ein nicht lesbarer Roh-Ordner ist eine laute Zeile — die Liste
// bleibt dann leer, weil wirklich nichts gesehen wurde.
console.error(`[roh] ${rohWurzel} nicht lesbar: ${String(fehler)}`)
return liste
}
for (const name of namen) {
const voll = join(rohWurzel, name)
if (bekannte.has(voll) || bekannte.has(name)) continue
liste.push({ ordner: voll, bytes: ordnerGroesse(voll) })
}
return liste.sort((a, b) => b.bytes - a.bytes)
}
/** Ist dieser Pfad ein Ordner DIREKT unter der Roh-Wurzel? Nur solche
* darf fremden Ordner löschen" anfassen nie etwas anderes. */
export function istUnterRohWurzel(rohWurzel: string, pfad: string): boolean {
const wurzel = rohWurzel.replace(/[\\/]+$/, '')
const kandidat = pfad.replace(/[\\/]+$/, '')
if (!kandidat.toLowerCase().startsWith(wurzel.toLowerCase())) return false
const rest = kandidat.slice(wurzel.length)
return /^[\\/][^\\/]+$/.test(rest)
}
+125
View File
@@ -0,0 +1,125 @@
// Zielstruktur für Jellyfin/Emby/Kodi/Plex (KONZEPT § 4.2) — Regeln aus
// medien.py: Ordner heißt „<Titel> (Jahr)" (daran erkennen die Server den
// Film; jellyfin.org/docs …/movies, support.plex.tv „Naming"), Serien
// liegen als <Serie>/Season NN. Episoden werden NUR bei EINDEUTIGER
// Zuordnung umbenannt — lieber gar nicht als falsch (ARMs offene Wunde
// #395 war genau das Ratespiel).
import { existsSync, readdirSync, renameSync } from 'node:fs'
// Plattform-Pfadregeln MIT ABSICHT: Diese Funktionen bauen auf
// LAUFZEIT-Wurzeln auf (die Ablage-Einstellung) — zur echten Laufzeit ist
// das immer Windows, in der Linux-CI testen sie mit POSIX-Ordnern und
// müssen dort physisch funktionieren. Feste Windows-Muster (Katalog,
// %ProgramFiles%) bleiben dagegen bei path.win32.
import { join } from 'node:path'
// EINE Namensregel für Kern und Fenster (5.4.0: die Zielpfad-Vorschau im
// Auswahl-Dialog rechnet mit derselben Funktion).
import { sichererName, staffelOrdnerName } from '../../gemeinsam/ablagepfad'
export { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
export function zielordner(
basis: string,
titel: string,
jahr: number | string | null,
jobId: string,
existiert: (p: string) => boolean = existsSync,
): string {
const name = sichererName(titel, jahr)
if (name.length === 0) return join(basis, jobId)
const ziel = join(basis, name)
if (existiert(ziel)) return join(basis, `${name} [${jobId.slice(0, 8)}]`)
return ziel
}
/** Jellyfin-Konvention: <basis>/<Serie>/Season NN (pur). */
export function serienOrdner(basis: string, serie: string, staffel: number): string {
const name = sichererName(serie) || 'Unbekannte Serie'
return join(basis, name, staffelOrdnerName(staffel))
}
/** Ordnet Dateien Episoden zu über Laufzeiten, ordnungserhaltend.
* Rückgabe: Episoden-Nummern je Datei, oder null wenn NICHT eindeutig.
*
* Stufe 1: komplette Staffel (Dateizahl == Episodenzahl) 1:1 in
* Reihenfolge. Stufe 2: Einzel-Zuordnung über distinkte Laufzeiten;
* passt eine Datei auf mehrere Episoden ähnlich gut null. */
export function matcheEpisoden(
dateiSekunden: readonly number[],
episoden: ReadonlyArray<readonly [nummer: number, laufzeitS: number]>,
toleranzProzent = 8.0,
toleranzMinS = 180,
): number[] | null {
if (dateiSekunden.length === 0 || episoden.length === 0) return null
const toleranz = (dauer: number): number => Math.max(toleranzMinS, (dauer * toleranzProzent) / 100)
if (dateiSekunden.length === episoden.length) {
const passt = dateiSekunden.every((dauer, i) => {
const epDauer = episoden[i][1]
return epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer)
})
if (passt) return episoden.map(([nr]) => nr)
}
const zuordnung: number[] = []
let start = 0
for (const dauer of dateiSekunden) {
const kandidaten = episoden
.slice(start)
.filter(([, epDauer]) => epDauer > 0 && Math.abs(dauer - epDauer) <= toleranz(dauer))
if (kandidaten.length === 0) return null
let gewaehlt = kandidaten[0]
if (kandidaten.length > 1) {
const sortiert = [...kandidaten].sort((a, b) => Math.abs(dauer - a[1]) - Math.abs(dauer - b[1]))
// Nur eindeutig, wenn der beste KLAR besser passt (doppelt so nah).
if (!(Math.abs(dauer - sortiert[0][1]) * 2 < Math.abs(dauer - sortiert[1][1]))) return null
gewaehlt = sortiert[0]
}
zuordnung.push(gewaehlt[0])
start = episoden.findIndex(([nr]) => nr === gewaehlt[0]) + 1
}
return zuordnung
}
/** Benennt MKVs nach Jellyfin-Schema um: Serie S01E02.mkv". Reihenfolge =
* sortierte Dateinamen (MakeMKV nummeriert in Disc-Reihenfolge). */
export function episodenUmbenennen(
ordner: string,
serie: string,
staffel: number,
zuordnung: readonly number[],
fs: {
listen: (o: string) => string[]
existiert: (p: string) => boolean
umbenennen: (von: string, nach: string) => void
} = {
listen: (o) => readdirSync(o),
existiert: existsSync,
umbenennen: renameSync,
},
): string[] {
const meldungen: string[] = []
const dateien = fs
.listen(ordner)
.filter((f) => f.endsWith('.mkv'))
.sort()
if (dateien.length !== zuordnung.length) {
return [`Umbenennen übersprungen: ${dateien.length} Dateien, ${zuordnung.length} Zuordnungen`]
}
const serienName = sichererName(serie) || 'Serie'
for (let i = 0; i < dateien.length; i++) {
const alt = dateien[i]
const neu = `${serienName} S${String(Math.trunc(staffel)).padStart(2, '0')}E${String(Math.trunc(zuordnung[i])).padStart(2, '0')}.mkv`
if (alt === neu) continue
const ziel = join(ordner, neu)
if (fs.existiert(ziel)) {
meldungen.push(`Umbenennen übersprungen (${neu} existiert schon)`)
continue
}
fs.umbenennen(join(ordner, alt), ziel)
meldungen.push(`${alt}${neu}`)
}
return meldungen
}
+393
View File
@@ -0,0 +1,393 @@
// Kompression + Ablage EINES Vorgangs (5.4.0) — die zweite Hälfte der
// Kette aus pipeline.ts, herausgelöst, damit sie OHNE Laufwerk läuft:
// * in der Warteschlange, während das Laufwerk schon die nächste Disc
// liest (Commander-Wunsch: „Nächste Disc rippen, während die vorige
// noch komprimiert"),
// * und Tage später noch einmal — „Wiederholen ab Kompression": Die
// Roh-Dateien liegen genau dafür da, ein Preset-Wechsel kostet kein
// Neulesen mehr.
//
// Was hier NEU ist gegenüber 5.3.x:
// * Ohne Kompression (4K verlustfrei, kein HandBrake) werden die Roh-
// Dateien in den Zielordner VERSCHOBEN — mit NFO, Poster, Bibliothek.
// Vorher blieben sie unter roh\ liegen, und nichts wusste von ihnen.
// * Sprachen JE DATEI (auftrag.ts sprachwahl) statt einer globalen Vorgabe.
// * Vor der Kompression wird die Roh-Datei GEFRAGT (HandBrake-Scan), ob
// die gewünschten Sprachen drin sind — fehlt eine, steht es im Protokoll.
// * Folgen von Hand („Staffel 1, ab Folge 3") schlagen die Laufzeit-Raterei;
// TMDb gibt dann nur noch eine Warnung.
// * Durchgehender Fortschritt, nach Dateigröße gewichtet.
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
renameSync,
statSync,
unlinkSync,
writeFileSync,
} from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { basename, join } from 'node:path'
import type { BibliothekEintrag, KompressionStand } from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import { fehlendeSprachen, spurenScannen, type HbSpuren } from '../komprimieren/scan'
import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from './auftrag'
export interface AblageUmgebung {
/** Pfad zu HandBrakeCLI — oder '' (dann werden die Roh-Dateien verschoben). */
handbrake: () => string
/** Wurzel der Ablage. */
ablage: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ. */
presetEmpfehlung?: (discTyp: string) => string
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden]. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
jetzt?: () => Date
/** Roh-Datei ins Ziel verschieben — injizierbar für Tests. */
verschieben?: (von: string, nach: string) => void
/** Spuren der Roh-Datei messen — injizierbar (Standard: HandBrake-Scan). */
spurenScannen?: (programm: string, datei: string) => Promise<HbSpuren | null>
}
export interface AblageErgebnis {
status: 'fertig' | 'fehler' | 'abgebrochen'
zielOrdner: string
dateien: string[]
text: string
komprimiert: boolean
preset: string
fertigBytes: number
hinweise: string[]
}
export type AblageMeldung = (stand: Pick<KompressionStand, 'phase' | 'prozent' | 'text'>) => void
/** Verschieben auf demselben Datenträger ist ein Umbenennen; über
* Datenträger-Grenzen (EXDEV) hilft nur Kopieren + Löschen. */
export function dateiVerschieben(von: string, nach: string): void {
try {
renameSync(von, nach)
} catch (fehler) {
const code = (fehler as NodeJS.ErrnoException).code
if (code !== 'EXDEV') throw fehler
copyFileSync(von, nach)
unlinkSync(von)
}
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts am
// Ergebnis selbst.
return 0
}
}
export async function komprimierenUndAblegen(
auftrag: KompressionsAuftrag,
umgebung: AblageUmgebung,
melden: AblageMeldung,
abbruch: AbortSignal,
): Promise<AblageErgebnis> {
const einstellungen = umgebung.einstellungen()
const handbrake = umgebung.handbrake()
const soll = komprimierenFuer(auftrag.discTyp, einstellungen)
const komprimiert = soll && handbrake.length > 0
const preset =
auftrag.preset.length > 0
? auftrag.preset
: presetFuer(auftrag.discTyp, einstellungen, umgebung.presetEmpfehlung?.(auftrag.discTyp) ?? '')
const zuordnung = auftrag.zuordnung
const hinweise: string[] = [...auftrag.hinweise]
const dateien = [...auftrag.dateien].sort((a, b) => a.pfad.localeCompare(b.pfad))
// ── WOHIN? (§ 4.2) ──────────────────────────────────────────────────
// Serien gehören NICHT unter Filme/ — sonst sieht Jellyfin lauter
// Einzelfilme statt einer Staffel. Serie ist es, wenn der Auftrag
// Folgen enthält (der Nutzer hat es im Dialog so bestätigt).
const alsSerie = dateien.some((d) => d.rolle === 'folge')
const staffel = auftrag.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
const fertigOrdner =
auftrag.zielOrdner.length > 0
? auftrag.zielOrdner
: alsSerie
? serienOrdner(join(umgebung.ablage(), 'Serien'), serienTitel, staffel)
: zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, auftrag.vorgangId)
: zielordner(join(umgebung.ablage(), 'Filme'), auftrag.vorgangId, null, auftrag.vorgangId)
mkdirSync(fertigOrdner, { recursive: true })
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so lesen
// Jellyfin, Emby und Kodi sie als Zugaben und nicht als zweiten Film.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (rolle: string): string => (rolle === 'extra' ? extrasOrdner : fertigOrdner)
if (dateien.some((d) => d.rolle === 'extra')) mkdirSync(extrasOrdner, { recursive: true })
const global = {
audio: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitel: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
}
const gewichte = dateien.map((d) => d.groesseBytes)
const scannen = umgebung.spurenScannen ?? spurenScannen
const verschieben = umgebung.verschieben ?? dateiVerschieben
const fertigeDateien: string[] = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
for (let i = 0; i < dateien.length; i++) {
const datei = dateien[i]
const ausgabe = join(zielFuer(datei.rolle), basename(datei.pfad))
if (abbruch.aborted) {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (!komprimiert) {
melden({
phase: 'legt-ab',
prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Verschiebe ${i + 1}/${dateien.length}: ${basename(datei.pfad)} (ohne Kompression) …`,
})
try {
verschieben(datei.pfad, ausgabe)
} catch (fehler) {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Verschieben fehlgeschlagen: ${String(fehler)} — die Roh-Dateien bleiben stehen (${auftrag.rohOrdner}).`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise,
}
}
fertigeDateien.push(ausgabe)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
continue
}
const wahl = sprachwahl(datei, global)
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Tonspur ${fehlt.audio.join(', ')} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`,
)
}
if (fehlt.untertitel.length > 0) {
hinweise.push(
`${basename(datei.pfad)}: Untertitel ${fehlt.untertitel.join(', ')} sind NICHT in der Roh-Datei.`,
)
}
} else {
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — komprimiert wie gewünscht.`)
}
}
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} `,
})
const encode = await komprimieren({
programm: handbrake,
eingabe: datei.pfad,
ausgabe,
preset,
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
abbruch,
aufFortschritt: (prozent) => {
melden({
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, prozent),
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}") …`,
})
},
})
if (encode.status === 'cancelled') {
return abgebrochen(fertigOrdner, fertigeDateien, preset, komprimiert, hinweise)
}
if (encode.status === 'error') {
return {
status: 'fehler',
zielOrdner: fertigOrdner,
dateien: fertigeDateien,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${auftrag.rohOrdner}).`,
komprimiert: true,
preset,
fertigBytes: 0,
hinweise,
}
}
fertigeDateien.push(encode.ausgabe)
if (datei.rolle === 'folge') folgenSekunden.push(datei.dauerS)
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Von Hand gesetzte Folgen („ab Folge 3") schlagen die Laufzeit-
// Zuordnung; TMDb darf dann nur noch warnen. Ohne Vorgabe wird
// umbenannt NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als falsch
// (die offene Wunde #395 von ARM war genau dieses Ratespiel).
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten =
tmdbId !== null ? ((await umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
let episoden: number[] | null = null
if (auftrag.ersteFolge !== null && auftrag.ersteFolge > 0) {
episoden = folgenNummern(folgenSekunden.length, auftrag.ersteFolge)
hinweise.push(`Folgen von Hand: Staffel ${staffel}, ab Folge ${auftrag.ersteFolge}.`)
hinweise.push(...folgenPlausibel(folgenSekunden, episoden, laufzeiten))
} else {
episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
}
if (episoden !== null) {
hinweise.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
} else {
hinweise.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen. Tipp: „Staffel und Folge von Hand" im Auswahl-Dialog.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage: NFO und Poster daneben ───────────────────────────────────
melden({ phase: 'legt-ab', prozent: 100, text: 'Lege ab …' })
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : auftrag.vorgangId
try {
const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo'
writeFileSync(
join(fertigOrdner, nfoName),
baueNfo(
{
typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film',
beschreibung: zuordnung?.meta.beschreibung,
genres: zuordnung?.meta.genres,
laufzeitMinuten: zuordnung?.meta.laufzeitMinuten,
quelle:
zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5',
},
anzeigeTitel,
zuordnung?.jahr ?? null,
),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
hinweise.push(`NFO fehlgeschlagen: ${String(fehler)}`)
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
// Umbenannte Folgen: Die Liste der fertigen Dateien muss den NEUEN
// Namen tragen — sonst prüft das Aufräumen später auf Dateien, die es
// unter dem alten Namen nicht mehr gibt.
const fertigJetzt = fertigeDateien.map((d) => (existsSync(d) ? d : '')).filter((d) => d.length > 0)
const endgueltig = fertigJetzt.length === fertigeDateien.length ? fertigeDateien : vorhandeneMkv(fertigOrdner, extrasOrdner)
let fertigBytes = 0
for (const datei of endgueltig) fertigBytes += groesseVon(datei)
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const jetzt = umgebung.jetzt?.() ?? new Date()
if (auftrag.fingerprint !== null && umgebung.bibliothekEintragen !== undefined) {
const begonnen = auftrag.begonnen.length > 0 ? new Date(auftrag.begonnen) : jetzt
umgebung.bibliothekEintragen({
fingerprint: auftrag.fingerprint,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
typ: auftrag.discTyp,
groesseBytes: fertigBytes,
ablageort: fertigOrdner,
datum: begonnen.toISOString(),
dauerS: Math.max(0, Math.round((jetzt.getTime() - begonnen.getTime()) / 1000)),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
}
const text =
(komprimiert ? `Fertig — komprimiert nach ${fertigOrdner}.` : `Fertig — verlustfrei abgelegt unter ${fertigOrdner} (ohne Kompression).`) +
(refresh.length > 0 ? ` ${refresh}` : '') +
(auftrag.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '')
return {
status: 'fertig',
zielOrdner: fertigOrdner,
dateien: endgueltig,
text,
komprimiert,
preset: komprimiert ? preset : '',
fertigBytes,
hinweise,
}
}
function abgebrochen(
zielOrdner: string,
dateien: string[],
preset: string,
komprimiert: boolean,
hinweise: string[],
): AblageErgebnis {
return {
status: 'abgebrochen',
zielOrdner,
dateien,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
komprimiert,
preset,
fertigBytes: 0,
hinweise,
}
}
/** Alle MKVs im Zielordner (und in extras\) nach dem Umbenennen der Folgen
* ist das die einzige verlässliche Liste. */
function vorhandeneMkv(ordner: string, extras: string): string[] {
const liste: string[] = []
for (const o of [ordner, extras]) {
if (!existsSync(o)) continue
try {
for (const name of readdirSync(o)) if (name.toLowerCase().endsWith('.mkv')) liste.push(join(o, name))
} catch {
// Ein nicht lesbarer Ordner liefert keine Dateien — die Bibliothek
// zeigt dann 0 Bytes, das Ergebnis selbst bleibt ein Erfolg.
}
}
return liste.sort()
}
+177
View File
@@ -0,0 +1,177 @@
// Der Kompressions-Auftrag (5.4.0) — PUR, ohne Laufwerk und ohne Netz.
//
// Er ist das Bindeglied zwischen zwei Phasen, die seit 5.4.0 getrennt
// laufen: Der RIP belegt das Laufwerk und endet, sobald die Roh-Dateien
// liegen; die KOMPRESSION läuft danach in einer Warteschlange und braucht
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
pfad: string
titelNr: number
rolle: TitelRolle
dauerS: number
/** Größe der Roh-Datei — gewichtet den Fortschrittsbalken. */
groesseBytes: number
/** Sprachen je Datei (leer = die globale Einstellung gilt). */
audioSprachen: string[]
untertitelSprachen: string[]
}
export interface KompressionsAuftrag {
vorgangId: string
laufwerk: string
discTyp: string
rohOrdner: string
dateien: RohDatei[]
/** Schnappschuss der Metadaten-Zuordnung zum Zeitpunkt des Rips. */
zuordnung: Zuordnung | null
/** Staffel (Nutzer > Disc > 1) und erste Folge (nur vom Nutzer). */
staffel: number | null
ersteFolge: number | null
discNr: number | null
fingerprint: string | null
lesefehler: boolean
/** Klartext aus dem Rip („Übersprungen: …", „Nicht gesichert: …"). */
hinweise: string[]
/** Beginn des Rips (ISO) — die Bibliothek merkt sich diesen Zeitpunkt. */
begonnen: string
/** Preset-Vorgabe beim Wiederholen; '' = die Einstellung gilt. */
preset: string
/** Beim Wiederholen: der Zielordner des ERSTEN Durchlaufs die neue
* Fassung ersetzt die alte, statt einen zweiten Ordner anzulegen.
* '' = Zielordner wird frisch bestimmt. */
zielOrdner: string
}
export function auftragZuJson(auftrag: KompressionsAuftrag): string {
return JSON.stringify(auftrag)
}
/** Liest einen gemerkten Auftrag zurück null, wenn er nicht die Form hat
* (eine Datenbank aus einer späteren Fassung, ein Fremdeintrag). */
export function auftragAusJson(text: string): KompressionsAuftrag | null {
let roh: unknown
try {
roh = JSON.parse(text)
} catch {
return null
}
if (typeof roh !== 'object' || roh === null) return null
const a = roh as Record<string, unknown>
if (typeof a.vorgangId !== 'string' || typeof a.rohOrdner !== 'string' || !Array.isArray(a.dateien)) return null
const dateien: RohDatei[] = []
for (const d of a.dateien) {
if (typeof d !== 'object' || d === null) return null
const e = d as Record<string, unknown>
if (typeof e.pfad !== 'string') return null
dateien.push({
pfad: e.pfad,
titelNr: typeof e.titelNr === 'number' ? e.titelNr : 0,
rolle: e.rolle === 'extra' || e.rolle === 'folge' ? e.rolle : 'hauptfilm',
dauerS: typeof e.dauerS === 'number' ? e.dauerS : 0,
groesseBytes: typeof e.groesseBytes === 'number' ? e.groesseBytes : 0,
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
})
}
return {
vorgangId: a.vorgangId,
laufwerk: typeof a.laufwerk === 'string' ? a.laufwerk : '',
discTyp: typeof a.discTyp === 'string' ? a.discTyp : 'bluray',
rohOrdner: a.rohOrdner,
dateien,
zuordnung: typeof a.zuordnung === 'object' && a.zuordnung !== null ? (a.zuordnung as Zuordnung) : null,
staffel: typeof a.staffel === 'number' ? a.staffel : null,
ersteFolge: typeof a.ersteFolge === 'number' ? a.ersteFolge : null,
discNr: typeof a.discNr === 'number' ? a.discNr : null,
fingerprint: typeof a.fingerprint === 'string' ? a.fingerprint : null,
lesefehler: a.lesefehler === true,
hinweise: Array.isArray(a.hinweise) ? a.hinweise.map(String) : [],
begonnen: typeof a.begonnen === 'string' ? a.begonnen : '',
preset: typeof a.preset === 'string' ? a.preset : '',
zielOrdner: typeof a.zielOrdner === 'string' ? a.zielOrdner : '',
}
}
/** Was HandBrake für EINE Datei bekommt. */
export interface SprachWahl {
audio: string[]
untertitel: string[]
/** Die erste Untertitelspur vorwählen weil der Ton nicht in der
* Wunschsprache ist. */
untertitelStandard: boolean
}
/**
* Sprachwahl je Datei (pur): Was der Nutzer am Titel gewählt hat, schlägt
* die globale Einstellung. Und die Regel des Commanders (01.09.2026):
* japanischer Ton und braucht dringend die deutschen untertitel"
* spielt die ERSTE Tonspur nicht in der Wunschsprache (der ersten
* globalen Tonsprache), wird die Untertitelspur der Wunschsprache nach
* vorn gestellt und als Standard markiert. HandBrake ordnet die Spuren in
* Listen-Reihenfolge und `--subtitle-default=1` trifft die erste (gemessen,
* komprimieren/handbrake.ts).
*/
export function sprachwahl(
datei: { audioSprachen: readonly string[]; untertitelSprachen: readonly string[] },
global: { audio: readonly string[]; untertitel: readonly string[] },
): SprachWahl {
const audio = datei.audioSprachen.length > 0 ? [...datei.audioSprachen] : [...global.audio]
let untertitel = datei.untertitelSprachen.length > 0 ? [...datei.untertitelSprachen] : [...global.untertitel]
const wunsch = global.audio[0] ?? ''
const tonFremd = wunsch.length > 0 && audio.length > 0 && audio[0] !== wunsch
const untertitelStandard = tonFremd && untertitel.includes(wunsch)
if (untertitelStandard) untertitel = [wunsch, ...untertitel.filter((u) => u !== wunsch)]
return { audio, untertitel, untertitelStandard }
}
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
return Array.from({ length: Math.max(0, anzahl) }, (_, i) => start + i)
}
/**
* Durchgehender Fortschritt über mehrere Teile, nach Gewicht (pur).
* Vorher sprang der Balken je Titel auf null zurück bei 2 × 16 GB stand
* er nach der Hälfte wieder bei 0 %. Fehlt ein Gewicht (0), zählen alle
* Teile gleich. `prozent` < 0 heißt vom laufenden Teil noch keine Angabe".
*/
export function gewichteterFortschritt(gewichte: readonly number[], index: number, prozent: number): number {
if (gewichte.length === 0) return -1
const gleich = gewichte.some((g) => !(g > 0))
const g = gleich ? gewichte.map(() => 1) : [...gewichte]
const gesamt = g.reduce((s, w) => s + w, 0)
if (gesamt <= 0) return -1
const i = Math.min(Math.max(0, index), g.length - 1)
const erledigt = g.slice(0, i).reduce((s, w) => s + w, 0)
const laufend = prozent < 0 ? 0 : (g[i] * Math.min(100, prozent)) / 100
return Math.max(0, Math.min(100, Math.floor(((erledigt + laufend) / gesamt) * 100)))
}
/** Passen die von Hand gesetzten Folgen zu den TMDb-Laufzeiten? Nur eine
* Warnung der Nutzer weiß, was auf seiner Disc ist. Rückgabe: Klartext
* je Abweichung (leer = alles plausibel oder nichts zum Vergleichen). */
export function folgenPlausibel(
dateiSekunden: readonly number[],
nummern: readonly number[],
laufzeiten: ReadonlyArray<readonly [nummer: number, sekunden: number]>,
toleranz = 0.15,
): string[] {
const warnungen: string[] = []
for (let i = 0; i < nummern.length; i++) {
const soll = laufzeiten.find(([nr]) => nr === nummern[i])
const ist = dateiSekunden[i] ?? 0
if (soll === undefined || soll[1] <= 0 || ist <= 0) continue
if (Math.abs(ist - soll[1]) / soll[1] > toleranz) {
warnungen.push(
`Folge ${nummern[i]}: Datei ${Math.round(ist / 60)} min, TMDb ${Math.round(soll[1] / 60)} min — bitte nachsehen.`,
)
}
}
return warnungen
}
+614
View File
@@ -0,0 +1,614 @@
// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage.
// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
// hängen damit an der Prozess-Leine (R6).
//
// Seit 5.4.0 ist die Kette an EINER Stelle geteilt: Diese Datei führt den
// RIP (das Laufwerk ist belegt) und übergibt danach einen Kompressions-
// Auftrag (auftrag.ts) an die Warteschlange (warteschlange.ts →
// ablegen.ts). Das Laufwerk ist damit frei, sobald die Roh-Dateien
// liegen — die nächste Disc kann rein, während die vorige noch rechnet
// (Commander-Wahl 01.09.2026). Der Vorgang selbst ist ein Datensatz
// (speicher/db.ts, Tabelle vorgaenge): So weiß Rippy hinterher, welcher
// Roh-Ordner zu welchem Film gehört — und kann aufräumen, wiederholen und
// nach einem Abbruch nachsehen.
import { mkdirSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux.
import { join } from 'node:path'
import type {
BibliothekEintrag,
GeraeteInfo,
RipAuftragOptionen,
RipAuftragTitel,
RipStatus,
TitelRolle,
} from '../../gemeinsam/nachrichten'
import type { Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung'
import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz'
import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
import { isoSichern } from '../rip/iso'
import { rippen } from '../rip/makemkv'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdtempSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
import { gewichteterFortschritt, type KompressionsAuftrag, type RohDatei } from './auftrag'
interface LaufenderVorgang {
/** Beendet den GANZEN Auftrag. */
abbruch: AbortController
/** Beendet nur den gerade laufenden Titel (überspringen). */
titelAbbruch?: AbortController
/** Der laufende Titel wurde übersprungen, nicht der Auftrag abgebrochen. */
uebersprungen?: boolean
status: RipStatus
}
/** Was der Kern über einen beginnenden Vorgang wissen will (Datenbank). */
export interface VorgangBeginn {
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
rohOrdner: string
begonnen: string
}
export interface PipelineUmgebung {
/** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */
makemkvcon: () => string
/** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */
handbrake: () => string
/** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */
flac: () => string
/** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */
audioToc?: (id: string) => Spur[]
/** Rohe Audio-Sektoren lesen (W-7). */
audioSpurLesen?: (
id: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
) => number
/** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */
geraet: (id: string) => GeraeteInfo | null
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
ablage: () => string
einstellungen: () => Einstellungen
melden: (status: RipStatus) => void
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6) für
* Audio-CD und ISO, die keine Kompression durchlaufen. */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
jetzt?: () => Date
/** 5.4.0: Der Rip ist durch der Kern merkt sich den Vorgang und reiht
* die Kompression ein. Liefert den Platz in der Schlange (1 = jetzt). */
vorgangEinreihen?: (auftrag: KompressionsAuftrag, titel: string) => number
/** 5.4.0: Ein Vorgang beginnt — Datensatz anlegen. */
vorgangBeginnt?: (vorgang: VorgangBeginn) => void
/** 5.4.0: Der Rip endete ohne Auftrag — Fehler oder Abbruch. */
vorgangEndet?: (id: string, phase: 'fehler' | 'abgebrochen', text: string) => void
/** 5.4.0: Nach dem Rip auswerfen (Einstellung autoAuswurf) die nächste
* Disc soll rein, während die Kompression läuft. */
nachRipAuswerfen?: (id: string) => void
}
export class Pipeline {
private laufend = new Map<string, LaufenderVorgang>()
constructor(private readonly umgebung: PipelineUmgebung) {}
laeuft(id: string): boolean {
return this.laufend.has(id)
}
private melden(status: RipStatus): void {
const eintrag = this.laufend.get(status.id)
if (eintrag !== undefined) eintrag.status = status
this.umgebung.melden(status)
}
private fehler(id: string, text: string): RipStatus {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
}
/** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten
* weiter der Ausweg bei einem Disc-Defekt (Commander 01.09.2026:
* MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1,
* während Folge 2 heil daneben lag). */
ueberspringen(id: string): void {
const vorgang = this.laufend.get(id)
if (vorgang === undefined || vorgang.titelAbbruch === undefined) return
vorgang.uebersprungen = true
vorgang.titelAbbruch.abort()
}
/** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */
async starten(
id: string,
auftrag: readonly RipAuftragTitel[] = [],
optionen: RipAuftragOptionen = {},
): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
return
}
const programm = this.umgebung.makemkvcon()
if (programm.length === 0) {
this.umgebung.melden(
this.fehler(
id,
'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)',
),
)
return
}
const geraet = this.umgebung.geraet(id)
if (geraet === null) {
this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`))
return
}
// Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt
// einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der
// Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf
// nie zu „es geht nicht" werden.
if (geraet.status === 'empty') {
this.umgebung.melden(
this.fehler(
id,
`Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`,
),
)
return
}
const abbruch = new AbortController()
const startZeit = this.umgebung.jetzt?.() ?? new Date()
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
if (geraet.typ === 'cd') {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } })
this.melden(this.laufend.get(id)!.status)
await this.audioCdRippen(id, geraet, startZeit, abbruch)
return
}
if (geraet.typ === 'unknown') {
this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } })
this.melden(this.laufend.get(id)!.status)
await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch)
return
}
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
mkdirSync(rohOrdner, { recursive: true })
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const anzeigeTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
this.umgebung.vorgangBeginnt?.({
id: vorgangsName,
laufwerk: id,
typ: geraet.typ,
titel: anzeigeTitel,
jahr: zuordnung?.jahr ?? null,
rohOrdner,
begonnen: startZeit.toISOString(),
})
// TITEL FÜR TITEL statt einem 'all'-Aufruf (Commander-Wunsch
// 01.09.2026). Zwei Gründe, und beide brauchen denselben Umbau:
// * Auswählen geht nur so — vorher rippte Rippy IMMER alles, samt
// Trailer, Werbung und Alterskennzeichen.
// * Ein Titel, der an einem Disc-Defekt hängen bleibt, reißt nicht
// mehr den ganzen Auftrag mit. Bei der Spartacus-Disc lag der
// Defekt in Folge 1 — Folge 2 war heil und ging trotzdem verloren.
const laeufe = auftrag.length > 0 ? auftrag.map((a) => String(a.nr)) : ['all']
// Durchgehender Balken (5.4.0): nach der Größe der Titel gewichtet —
// vorher sprang er je Titel zurück auf null.
const gewichte = auftrag.length > 0 ? auftrag.map((a) => a.groesseBytes ?? 0) : [1]
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const auftragJeDatei = new Map<string, RipAuftragTitel | undefined>()
const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const gescheitert: string[] = []
const uebersprungen: string[] = []
const aufgeraeumt: string[] = []
this.laufend.set(id, {
abbruch,
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false, vorgangId: vorgangsName },
})
const beenden = (phase: 'fehler' | 'abgebrochen', text: string): void => {
this.laufend.delete(id)
// Ein Roh-Ordner ohne eine einzige brauchbare Datei ist Müll (5.4.0,
// Punkt „Aufräumen nach Abbruch").
if (alleDateien.size === 0) {
try {
rmSync(rohOrdner, { recursive: true, force: true })
} catch (fehler) {
text += ` (Roh-Ordner ließ sich nicht entfernen: ${String(fehler)})`
}
}
this.umgebung.vorgangEndet?.(vorgangsName, phase, text)
this.melden({
id,
phase,
prozent: -1,
text,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
vorgangId: vorgangsName,
})
}
for (let i = 0; i < laeufe.length; i++) {
const welcher = laeufe[i]
// Eigener Abbruch je Titel: „überspringen" beendet NUR diesen Lauf,
// „abbrechen" beendet den ganzen Auftrag — und reicht nach unten durch.
const titelAbbruch = new AbortController()
const weiterreichen = (): void => titelAbbruch.abort()
abbruch.signal.addEventListener('abort', weiterreichen, { once: true })
const vorgang = this.laufend.get(id)
if (vorgang !== undefined) {
vorgang.titelAbbruch = titelAbbruch
vorgang.uebersprungen = false
}
const beschriftung =
welcher === 'all' ? 'alle Titel' : `Titel ${welcher} (${i + 1} von ${laeufe.length})`
this.melden({
id,
phase: 'rippt',
prozent: gewichteterFortschritt(gewichte, i, -1),
text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner}`,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
titelGesamt: laeufe.length,
haengt: null,
vorgangId: vorgangsName,
})
// Was VOR diesem Titel schon da lag — alles Neue nach einem Abbruch
// oder Fehler ist eine halbe Datei und wird entfernt (5.4.0).
const vorher = dateienIn(rohOrdner)
const rip = await rippen({
programm,
geraetepfad: geraet.pfad,
zielOrdner: rohOrdner,
titel: welcher,
abbruch: titelAbbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent: gewichteterFortschritt(gewichte, i, prozent) })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, text })
},
aufHaengt: (klemmt) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, haengt: klemmt })
},
})
abbruch.signal.removeEventListener('abort', weiterreichen)
if (rip.status === 'cancelled') {
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
// Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) {
beenden(
'abgebrochen',
'Abgebrochen.' +
(aufgeraeumt.length > 0 ? ` Halbe Datei entfernt: ${aufgeraeumt.join(', ')}.` : '') +
(alleDateien.size > 0 ? ` ${alleDateien.size} fertige Datei(en) bleiben unter ${rohOrdner}.` : ''),
)
return
}
uebersprungen.push(beschriftung)
continue
}
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
// Was dieser Lauf hinterlassen hat, ist nicht vertrauenswürdig.
aufgeraeumt.push(...halbeDateienEntfernen(rohOrdner, vorher))
gescheitert.push(`${beschriftung}: ${rip.fehler}`)
continue
}
// rippen() liefert den ganzen Zielordner zurück, also auch die
// Dateien der vorigen Titel — neu ist, was noch nicht bekannt war.
const neu = rip.dateien.filter((d) => !alleDateien.has(d))
for (const d of rip.dateien) alleDateien.add(d)
for (const d of neu) auftragJeDatei.set(d, auftrag[i])
if (neu.length === 0) {
// MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben
// (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert.
gescheitert.push(`${beschriftung}: keine Datei entstanden`)
}
}
const nachLauf = this.laufend.get(id)
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
if (alleDateien.size === 0) {
beenden('fehler', gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.')
return
}
const hinweise: string[] = []
if (uebersprungen.length > 0) hinweise.push(`Übersprungen: ${uebersprungen.join(', ')}.`)
if (gescheitert.length > 0) hinweise.push(`Nicht gesichert: ${gescheitert.join(' · ')}`)
if (aufgeraeumt.length > 0) hinweise.push(`Halbe Dateien entfernt: ${aufgeraeumt.join(', ')}.`)
// ── Übergabe an die Warteschlange (5.4.0) ──────────────────────────
const dateien: RohDatei[] = [...alleDateien].sort().map((pfad) => {
const a = auftragJeDatei.get(pfad)
const rolle: TitelRolle = a?.rolle ?? 'hauptfilm'
return {
pfad,
titelNr: a?.nr ?? -1,
rolle,
dauerS: a?.dauerS ?? 0,
groesseBytes: groesseVon(pfad),
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
}
})
const angaben = this.umgebung.discAngaben?.(id) ?? null
const kompression: KompressionsAuftrag = {
vorgangId: vorgangsName,
laufwerk: id,
discTyp: geraet.typ,
rohOrdner,
dateien,
zuordnung,
staffel: optionen.staffel ?? angaben?.staffel ?? null,
ersteFolge: optionen.ersteFolge ?? null,
discNr: angaben?.discNr ?? null,
fingerprint: this.umgebung.fingerprint?.(id) ?? null,
lesefehler: lesefehlerGesamt,
hinweise,
begonnen: startZeit.toISOString(),
preset: '',
zielOrdner: '',
}
const platz = this.umgebung.vorgangEinreihen?.(kompression, anzeigeTitel) ?? 0
const rohBytes = dateien.reduce((s, d) => s + d.groesseBytes, 0)
// Das Laufwerk ist FREI — die Kompression läuft ohne es weiter.
this.laufend.delete(id)
this.umgebung.nachRipAuswerfen?.(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text:
`Verlustfrei gesichert: ${dateien.length} ${dateien.length === 1 ? 'Titel' : 'Titel'}, ${(rohBytes / 1e9).toFixed(2).replace('.', ',')} GB — ` +
(platz > 0
? `Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}, das Laufwerk ist frei.`
: 'Kompression nicht eingereiht (kein Empfänger) — die Roh-Dateien liegen unter ' + rohOrdner + '.') +
(lesefehlerGesamt
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
(hinweise.length > 0 ? `${hinweise.join(' ')}` : ''),
dateien: dateien.map((d) => d.pfad),
lesefehler: lesefehlerGesamt,
vorgangId: vorgangsName,
})
}
abbrechen(id: string): void {
this.laufend.get(id)?.abbruch.abort()
}
// ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ──
private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise<void> {
const fertigMachen = (status: RipStatus): void => {
this.laufend.delete(id)
this.melden(status)
}
const flac = this.umgebung.flac()
if (flac.length === 0) {
fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).'))
return
}
if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) {
fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.'))
return
}
let spuren: Spur[]
try {
spuren = this.umgebung.audioToc(id).filter((s) => s.audio)
} catch (fehler) {
fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`))
return
}
if (spuren.length === 0) {
fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?'))
return
}
// MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne
// Treffer heißen die Dateien „Track 01" — das ist keine Ausrede,
// sondern die Wahrheit über eine Disc, die niemand kennt.
const kennung = kennungAusSpuren(spuren)
const antwort = await abfragen(kennung)
const tags = tagsJeSpur(antwort)
const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit)
const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName)
mkdirSync(ziel, { recursive: true })
const dateien: string[] = []
for (let i = 0; i < spuren.length; i++) {
if (abbruch.signal.aborted) {
fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false })
return
}
const spur = spuren[i]
const spurTags = tags.get(spur.nr) ?? {}
const name = dateiname(spur.nr, spurTags['TITLE'] ?? '')
const zielDatei = join(ziel, `${name}.flac`)
const stand = this.laufend.get(id)?.status
if (stand !== undefined) {
this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) })
}
// Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang
// hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht.
const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-'))
try {
const wav = join(arbeit, 'spur.wav')
const strom = createWriteStream(wav)
strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES))
this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => {
const s = this.laufend.get(id)?.status
if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) })
})
await new Promise<void>((fertig, kaputt) => {
strom.end(() => fertig())
strom.on('error', kaputt)
})
const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags))
if (flacErgebnis.code !== 0) {
fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`))
return
}
} catch (fehler) {
fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`))
return
} finally {
rmSync(arbeit, { recursive: true, force: true })
}
dateien.push(zielDatei)
}
const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null)
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of dateien) groesse += groesseVon(datei)
this.umgebung.bibliothekEintragen({
fingerprint,
titel: ordnerName,
jahr: null,
typ: 'cd',
groesseBytes: groesse,
ablageort: ziel,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: '',
})
}
fertigMachen({
id,
phase: 'fertig',
prozent: 100,
text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''),
dateien,
lesefehler: false,
})
}
// ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ─────────────────
private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise<void> {
const ziel = join(this.umgebung.ablage(), 'ISO')
mkdirSync(ziel, { recursive: true })
const zielDatei = join(ziel, `${vorgangsName}.iso`)
const ergebnis = await isoSichern(
geraet.pfad,
zielDatei,
geraet.groesseBytes,
(prozent) => {
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, prozent })
},
abbruch.signal,
)
this.laufend.delete(id)
if (ergebnis.status === 'success') {
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null) {
this.umgebung.bibliothekEintragen?.({
fingerprint,
titel: vorgangsName,
jahr: null,
typ: 'iso',
groesseBytes: ergebnis.bytes,
ablageort: zielDatei,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: '',
})
}
this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false })
} else if (ergebnis.status === 'cancelled') {
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
} else {
this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false })
}
}
}
export function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}`
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts an
// der Liste selbst.
return 0
}
}
function dateienIn(ordner: string): Set<string> {
try {
return new Set(readdirSync(ordner))
} catch {
// Ein nicht lesbarer Ordner: dann gilt „nichts war vorher da" — und
// im Zweifel wird nichts gelöscht, weil auch nachher nichts lesbar ist.
return new Set()
}
}
/** Entfernt, was seit `vorher` im Ordner dazukam die halbe Datei eines
* abgebrochenen oder gescheiterten Titels. Liefert die Namen. */
export function halbeDateienEntfernen(ordner: string, vorher: ReadonlySet<string>): string[] {
const entfernt: string[] = []
for (const name of dateienIn(ordner)) {
if (vorher.has(name)) continue
try {
unlinkSync(join(ordner, name))
entfernt.push(name)
} catch (fehler) {
// Nicht löschbar (noch offen?) — das steht dann als Name mit Grund da.
entfernt.push(`${name} (nicht gelöscht: ${String(fehler).slice(0, 60)})`)
}
}
return entfernt
}
function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> {
return new Promise((fertig) => {
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
const stuecke: Buffer[] = []
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) }))
kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') }))
})
}
@@ -0,0 +1,159 @@
// Die Kompressions-Warteschlange (5.4.0) — KONZEPT § 4.2 nannte sie
// `auftraege.ts`: Warteschlange, Slots. Seit 5.4.0 sind Rip und Kompression
// entkoppelt (Commander-Wahl 01.09.2026: „Nächste Disc rippen, während
// die vorige noch komprimiert"). Das Laufwerk ist frei, sobald die
// Roh-Dateien liegen; die Kompressionen laufen hier der Reihe nach —
// EINE zur Zeit, denn zwei HandBrakes teilen sich dieselbe Grafikkarte und
// werden zusammen nicht schneller.
//
// PUR: Was ausgeführt wird, kommt injiziert (ablegen.ts) — die Ampel
// prüft das Verhalten der Schlange auf Linux ohne HandBrake.
import type { KompressionStand } from '../../gemeinsam/nachrichten'
import type { KompressionsAuftrag } from './auftrag'
import type { AblageErgebnis, AblageMeldung } from './ablegen'
export type Ausfuehren = (
auftrag: KompressionsAuftrag,
melden: AblageMeldung,
abbruch: AbortSignal,
) => Promise<AblageErgebnis>
interface Eintrag {
auftrag: KompressionsAuftrag
titel: string
abbruch: AbortController
}
export interface WarteschlangenStand {
laeuft: KompressionStand | null
wartend: KompressionStand[]
}
export class KompressionsWarteschlange {
private wartend: Eintrag[] = []
private laufend: Eintrag | null = null
private laufendStand: KompressionStand | null = null
private arbeitet = false
constructor(
private readonly ausfuehren: Ausfuehren,
/** Jede Änderung — Einreihen, Fortschritt, Ende — geht hier hinaus. */
private readonly beiStand: (stand: WarteschlangenStand) => void,
/** Ein Auftrag ist zu Ende (auch abgebrochen/gescheitert). */
private readonly beiErgebnis: (auftrag: KompressionsAuftrag, ergebnis: AblageErgebnis) => void | Promise<void>,
) {}
/** Reiht ein und liefert den Platz (1 = läuft jetzt oder als Nächstes).
* Ein Vorgang, der schon drin ist, wird nicht doppelt eingereiht. */
einreihen(auftrag: KompressionsAuftrag, titel: string): number {
if (this.laufend?.auftrag.vorgangId === auftrag.vorgangId) return 1
const schon = this.wartend.findIndex((e) => e.auftrag.vorgangId === auftrag.vorgangId)
if (schon >= 0) return schon + (this.laufend === null ? 1 : 2)
this.wartend.push({ auftrag, titel, abbruch: new AbortController() })
const platz = this.wartend.length + (this.laufend === null ? 0 : 1)
this.beiStand(this.stand())
void this.schleife()
return platz
}
/** Bricht einen wartenden (entfernt) oder laufenden (Signal) Auftrag ab. */
abbrechen(vorgangId: string): boolean {
const i = this.wartend.findIndex((e) => e.auftrag.vorgangId === vorgangId)
if (i >= 0) {
const [weg] = this.wartend.splice(i, 1)
this.beiStand(this.stand())
void this.beiErgebnis(weg.auftrag, {
status: 'abgebrochen',
zielOrdner: '',
dateien: [],
text: 'Aus der Warteschlange genommen — die Roh-Dateien bleiben stehen.',
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
})
return true
}
if (this.laufend?.auftrag.vorgangId === vorgangId) {
this.laufend.abbruch.abort()
return true
}
return false
}
stand(): WarteschlangenStand {
return {
laeuft: this.laufendStand === null ? null : { ...this.laufendStand },
wartend: this.wartend.map((e) => ({
vorgangId: e.auftrag.vorgangId,
titel: e.titel,
prozent: -1,
text: 'wartet',
phase: 'wartet',
})),
}
}
laeuftEtwas(): boolean {
return this.laufend !== null || this.wartend.length > 0
}
/** Liegt dieser Vorgang gerade in der Schlange (wartend oder laufend)? */
kennt(vorgangId: string): boolean {
return this.laufend?.auftrag.vorgangId === vorgangId || this.wartend.some((e) => e.auftrag.vorgangId === vorgangId)
}
private async schleife(): Promise<void> {
if (this.arbeitet) return
this.arbeitet = true
try {
while (this.wartend.length > 0) {
const eintrag = this.wartend.shift()!
this.laufend = eintrag
this.laufendStand = {
vorgangId: eintrag.auftrag.vorgangId,
titel: eintrag.titel,
prozent: -1,
text: 'Beginne …',
phase: 'komprimiert',
}
this.beiStand(this.stand())
let ergebnis: AblageErgebnis
try {
ergebnis = await this.ausfuehren(
eintrag.auftrag,
(stand) => {
if (this.laufendStand === null) return
this.laufendStand = { ...this.laufendStand, ...stand }
this.beiStand(this.stand())
},
eintrag.abbruch.signal,
)
} catch (fehler) {
// R4: Eine Ausnahme mitten im Encode ist ein lauter Fehlschlag,
// kein stilles Ende der Schlange.
ergebnis = {
status: 'fehler',
zielOrdner: '',
dateien: [],
text: `Kompression abgestürzt: ${String(fehler)} — die Roh-Dateien bleiben stehen.`,
komprimiert: false,
preset: '',
fertigBytes: 0,
hinweise: [],
}
}
this.laufend = null
this.laufendStand = null
try {
await this.beiErgebnis(eintrag.auftrag, ergebnis)
} catch (fehler) {
console.error(`[warteschlange] Ergebnis-Verarbeitung: ${String(fehler)}`)
}
this.beiStand(this.stand())
}
} finally {
this.arbeitet = false
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
//
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
// Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
export function parseEncoderListe(hilfeText: string): string[] {
const encoder: string[] = []
let imBlock = false
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
const inhalt = zeile.trim()
if (!imBlock) {
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
continue
}
if (inhalt.length === 0) continue
if (inhalt.startsWith('-')) break
encoder.push(inhalt.split(/\s+/)[0])
}
return encoder
}
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
['nvenc', 'nvenc'],
['qsv', 'qsv'],
['vce', 'vce'],
['vaapi', 'vaapi'],
]
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
const gefunden: string[] = []
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
for (const [kennzeichen, backend] of HW_FAMILIEN) {
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
if (treffer.length === 0) continue
gefunden.push(backend)
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
}
return gefunden
}
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
export function parsePresetListe(text: string): string[] {
const presets: string[] = []
for (const zeile of (text ?? '').split(/\r?\n/)) {
if (zeile.trim().length === 0) continue
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
if (einrueckung === 4) presets.push(zeile.trim())
}
return presets
}
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = werkzeugSpawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig('')
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(textVon(Buffer.concat(stuecke)))
})
})
}
export interface EncoderAuskunft {
encoder: string[]
backends: string[]
presets: string[]
}
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
const [hilfe, presetListe] = await Promise.all([
lauf(programm, ['--help'], 30_000),
lauf(programm, ['--preset-list'], 30_000),
])
const encoder = parseEncoderListe(hilfe)
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
}
@@ -0,0 +1,290 @@
// HandBrakeCLI ansprechen — Kommandobau, Lauf und die Auswertung mit ALLEN
// bezahlten Fallen aus dem Docker-Zweig (ripping.py, jeweils am echten
// HandBrake gemessen):
//
// - `--aencoder`, NICHT `--audio-codec`: Den zweiten Schalter gibt es
// nicht, und ein unbekannter Schalter ist für HandBrake KEIN Fehler —
// Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip (30.08.2026).
// - `--format` aus der Endung erzwingen: Der Container kommt sonst aus
// dem PRESET — ein MP4-Preset schreibt title.mp4 neben das verlangte
// .mkv und meldet Erfolg (29.08.2026).
// - Fortschritt NUR aus der `Encoding: task N of M, x %`-Zeile — der
// Scan-Durchlauf läuft vorher bis 100 % und ließ die Anzeige bei 99 %
// kleben, während der Encode bei 1 % war (25.07.2026).
// - Die letzten 12 Ausgabezeilen aufheben: Bei Rückgabewert 0 sind sie
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
import { win32 as pfad } from 'node:path'
import { textVon } from '../rip/parser'
import { FORMATE } from './presets'
const { basename, dirname, extname, join } = pfad
export interface HandbrakeArgsOptionen {
eingabe: string
ausgabe: string
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
}
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
// ffprobe geprüft — Regel D):
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
if (format !== undefined) args.push('--format', format)
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
args.push('--first-audio')
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles')
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
return args
}
// Formatstrings aus dem HandBrakeCLI-Binary gelesen (Regel D):
// Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode
// Encoding: task %d of %d, %.2f %% → Encode
// Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode
const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/
/** Encode-Fortschritt 0100 oder -1 (keine Angabe" ein echtes 0 % muss
* von nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M)
* werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */
export function progressAusZeile(zeile: string): number {
const treffer = HB_ENCODE.exec(zeile.trim())
if (treffer === null) return -1
const aktuell = Number(treffer[1])
const gesamt = Number(treffer[2])
const prozent = Number(treffer[3])
if (gesamt < 1 || aktuell < 1) return -1
return Math.floor(((aktuell - 1) * 100 + prozent) / gesamt)
}
/** „Invalid preset X" → X (Wortlaut aus dem echten Lauf, 26.07.2026). */
export function unbekanntesPreset(zeile: string): string {
const text = (zeile ?? '').trim()
const kopf = 'Invalid preset '
return text.startsWith(kopf) ? text.slice(kopf.length).trim() : ''
}
/** „unknown option (--x)" → --x. Rückgabewert bleibt dabei 0! */
export function unbekannterSchalter(zeile: string): string {
const treffer = /unknown option \(([^)]*)\)/.exec(zeile ?? '')
return treffer === null ? '' : treffer[1].trim()
}
export const HB_ZEILEN_PUFFER = 12
export const HB_ZEILEN_MELDUNG = 4
/** HandBrakes letzte Worte als Anhang für eine Fehlermeldung (pur). */
export function hbSchluss(zeilen: readonly string[]): string {
const sauber = zeilen.map((z) => z.trim()).filter((z) => z.length > 0)
if (sauber.length === 0) return ''
return ' — HandBrake sagte zuletzt: ' + sauber.slice(-HB_ZEILEN_MELDUNG).join(' | ')
}
/** Dieselbe Datei mit anderer Endung im selben Ordner — oder ''. */
export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean = existsSync): string {
const ordner = dirname(erwartet)
const stamm = basename(erwartet, extname(erwartet))
for (const endung of Object.keys(FORMATE)) {
const kandidat = join(ordner, stamm + endung)
if (kandidat !== erwartet && existiert(kandidat)) return kandidat
}
return ''
}
/** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */
export class HbAuswertung {
prozent = -1
falschesPreset = ''
falscherSchalter = ''
letzteZeilen: string[] = []
verarbeite(zeile: string): { prozent?: number } {
if (zeile.trim().length > 0) {
this.letzteZeilen.push(zeile)
if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift()
}
if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile)
if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile)
const p = progressAusZeile(zeile)
if (p >= 0 && p !== this.prozent) {
this.prozent = p
return { prozent: p }
}
return {}
}
/** Das Urteil NACH Prozessende in der bewiesenen Reihenfolge:
* falscher Schalter (kommt mit Code 0!) Erfolg Datei daneben
* Erfolg ohne Datei falsches Preset Code mit letzten Worten. */
urteil(
returncode: number | null,
ausgabe: string,
existiert: (p: string) => boolean = existsSync,
): { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'error'; fehler: string } {
if (this.falscherSchalter.length > 0) {
return {
status: 'error',
fehler:
`Rippy hat HandBrake den Schalter „${this.falscherSchalter}" übergeben, den diese ` +
'HandBrake-Fassung nicht kennt. Das ist ein Fehler in Rippy, keine Einstellung — bitte melden.' +
hbSchluss(this.letzteZeilen),
}
}
if (returncode === 0 && existiert(ausgabe)) {
return { status: 'success', ausgabe }
}
if (returncode === 0) {
const daneben = dateiDaneben(ausgabe, existiert)
if (daneben.length > 0) {
return {
status: 'success',
ausgabe: daneben,
hinweis: `HandBrake hat ${basename(daneben)} geschrieben statt ${basename(ausgabe)} (der Container kommt aus dem Preset).`,
}
}
return {
status: 'error',
fehler:
`HandBrake meldet Erfolg, aber es ist keine Datei entstanden (Zielordner: ${dirname(ausgabe)}).` +
hbSchluss(this.letzteZeilen),
}
}
if (this.falschesPreset.length > 0) {
return {
status: 'error',
fehler:
`Dieses HandBrake kennt das Preset „${this.falschesPreset}" nicht. Preset-Namen unterscheiden ` +
'sich zwischen HandBrake-Versionen — in den Einstellungen eines aus der Liste wählen, die von ' +
'diesem HandBrake selbst kommt.',
}
}
return {
status: 'error',
fehler: `HandBrake endete mit Code ${returncode ?? '?'}` + hbSchluss(this.letzteZeilen),
}
}
}
/** HandBrake-Ausgabe in Zeilen: CR UND LF trennen (HandBrake schreibt den
* Fortschritt mit \r). Je Zeile einzeln dekodiert HandBrake schreibt
* denselben Pfad mal UTF-8, mal CP850 in einen Strom; ein `ü` (CP850
* 0x81) tötete so die Python-Fassung. textVon ersetzt statt zu sterben. */
export class HbZeilenLeser {
private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = []
for (;;) {
let trenner = -1
for (let i = 0; i < this.rest.length; i++) {
if (this.rest[i] === 0x0a || this.rest[i] === 0x0d) {
trenner = i
break
}
}
if (trenner < 0) break
if (trenner > 0) zeilen.push(textVon(this.rest.subarray(0, trenner)))
// \r\n als EIN Trenner behandeln.
const naechstes = trenner + 1
const doppelt =
this.rest[trenner] === 0x0d && naechstes < this.rest.length && this.rest[naechstes] === 0x0a
this.rest = this.rest.subarray(doppelt ? naechstes + 1 : naechstes)
}
return zeilen
}
schluss(): string[] {
if (this.rest.length === 0) return []
const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : []
}
}
export type HbErgebnis =
| { status: 'success'; ausgabe: string; hinweis?: string }
| { status: 'cancelled' }
| { status: 'error'; fehler: string }
export interface HbLaufOptionen {
programm: string
eingabe: string
ausgabe: string
preset: string
audioSprachen?: readonly string[]
untertitelSprachen?: readonly string[]
untertitelStandard?: boolean
/** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */
extraArgs?: readonly string[]
aufFortschritt?: (prozent: number) => void
abbruch?: AbortSignal
}
export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler) })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
fertig(auswertung.urteil(code, optionen.ausgabe))
})
})
}
@@ -0,0 +1,73 @@
// Preset-Wahl je Disc-Typ (KONZEPT § 6.10) — Regeln aus ripping.py, dort
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
// 4K auf 1080p herunter und DVDs hinauf.
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
export { DEFAULT_HB_PRESET, presetEmpfehlung }
/** Einstellungs-Schlüssel je Disc-Typ. */
export const PRESET_SCHLUESSEL: Record<string, string> = {
dvd: 'transcodePresetDvd',
bluray: 'transcodePresetBluray',
uhd: 'transcodePresetUhd',
}
// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes
// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft).
// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen.
export const PRESET_KEINE = 'keine'
export type Einstellungen = Record<string, string | boolean | undefined>
/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */
export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean {
if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') {
return false
}
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const wert = String(einstellungen[schluessel] ?? '').trim()
if (wert === PRESET_KEINE) return false
}
return true
}
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
* Kette: Typ-Preset allgemeines transcodePreset EMPFEHLUNG (gemessen,
* Hardware zuerst) CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN diese
* Funktion darf nie keine" als Preset-Namen liefern, sonst bekäme
* HandBrake `--preset keine` und scheiterte. */
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
if (schluessel.length > 0) {
const eigen = String(einstellungen[schluessel] ?? '').trim()
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
}
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
if (allgemein.length > 0) return allgemein
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
}
/** deu, eng , ,DEU" ["deu","eng"] HandBrake will ISO-639-2 klein;
* ein DEU" aus einem Eingabefeld soll die Auswahl nicht still lahmlegen. */
export function sprachliste(wert: string | readonly string[] | null | undefined): string[] {
if (wert === null || wert === undefined) return []
const teile = typeof wert === 'string' ? wert.split(',') : [...wert]
const sauber: string[] = []
for (const teil of teile) {
const code = String(teil).trim().toLowerCase()
if (code.length > 0 && !sauber.includes(code)) sauber.push(code)
}
return sauber
}
/** Endung HandBrake-Container. Am mitgelieferten HandBrake 1.11.2
* gegengeprüft (`--help`, Abschnitt `-f, --format`). */
export const FORMATE: Record<string, string> = {
'.mkv': 'av_mkv',
'.mp4': 'av_mp4',
'.m4v': 'av_mp4',
'.mov': 'av_mov',
'.webm': 'av_webm',
}
+118
View File
@@ -0,0 +1,118 @@
// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken
// WIRKLICH in der Roh-Datei? (5.4.0)
//
// Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV
// im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen,
// entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor
// der Kompression wird deshalb die Datei selbst gefragt — fehlt eine
// gewünschte Sprache, steht das im Protokoll, statt dass ein leerer
// Wunsch still durchrutscht.
//
// Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026,
// Regel D): `HandBrakeCLI --scan --json -i <datei> -t 1` schreibt ein
// Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem
// JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das
// Objekt endet also NICHT am Textende — es wird über die Klammern
// ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2,
// z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
export interface HbSpuren {
audio: Array<{ nr: number; lang: string; beschreibung: string }>
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
dauerS: number
}
export function buildScanArgs(datei: string): string[] {
return ['--scan', '--json', '--input', datei, '--title', '1']
}
/** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */
export function spurenAusScan(ausgabe: string): HbSpuren | null {
const marke = ausgabe.indexOf('JSON Title Set:')
if (marke < 0) return null
const start = ausgabe.indexOf('{', marke)
if (start < 0) return null
let tiefe = 0
let ende = -1
for (let i = start; i < ausgabe.length; i++) {
const zeichen = ausgabe[i]
if (zeichen === '{') tiefe += 1
else if (zeichen === '}') {
tiefe -= 1
if (tiefe === 0) {
ende = i
break
}
}
}
if (ende < 0) return null
let daten: unknown
try {
daten = JSON.parse(ausgabe.slice(start, ende + 1))
} catch {
// Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein
// erfundener leerer Befund (R2).
return null
}
if (typeof daten !== 'object' || daten === null) return null
const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record<string, unknown>
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
if (!Array.isArray(feld)) return []
return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
return {
nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
}
})
}
const dauer = (titel.Duration ?? {}) as Record<string, unknown>
const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
return {
audio: spuren(titel.AudioList),
untertitel: spuren(titel.SubtitleList),
dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds),
}
}
/** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */
export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise<HbSpuren | null> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig(null)
return
}
const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig(null)
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(spurenAusScan(textVon(Buffer.concat(stuecke))))
})
})
}
/** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */
export function fehlendeSprachen(
spuren: HbSpuren,
gewuenscht: { audio: readonly string[]; untertitel: readonly string[] },
): { audio: string[]; untertitel: string[] } {
const da = (liste: Array<{ lang: string }>): Set<string> => new Set(liste.map((s) => s.lang))
const audioDa = da(spuren.audio)
const untertitelDa = da(spuren.untertitel)
return {
audio: gewuenscht.audio.filter((l) => !audioDa.has(l)),
untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)),
}
}
+94
View File
@@ -0,0 +1,94 @@
// Win32-Steuercodes und -Konstanten für optische Laufwerke — HERGELEITET,
// nicht abgeschrieben (Regel D). Bewusst OHNE koffi: disc.ts und die Tests
// brauchen die Zahlen, aber kein natives Modul. Die echten Aufrufe stehen
// allein in win32.ts (Wächter R1).
//
// Ein Zahlendreher liefert kein „unbekannter Befehl", sondern
// ERROR_INVALID_FUNCTION — das sieht aus wie „das Laufwerk kann das nicht";
// test/laufwerk.test.ts rechnet die Werte deshalb gegen die in der
// Microsoft-Dokumentation genannten Zahlen gegen.
//
// Fundstellen: CTL_CODE/FILE_DEVICE_*/METHOD_* — winioctl.h;
// IOCTL_STORAGE_* — learn.microsoft.com/windows/win32/api/winioctl;
// IOCTL_CDROM_* — ntddcdrm.h. Gemessen in beweise/laufwerk.js (30.08.2026),
// Herleitung identisch mit src/rippy/drives/win_ioctl.py (Docker-Zweig,
// dort an echter Hardware bewiesen).
// ── CTL_CODE aus winioctl.h, eins zu eins ───────────────────────────────
export function ctlCode(geraetetyp: number, funktion: number, methode: number, zugriff: number): number {
return ((geraetetyp << 16) | (zugriff << 14) | (funktion << 2) | methode) >>> 0
}
const FILE_DEVICE_CD_ROM = 0x02
const FILE_DEVICE_DISK = 0x07
const FILE_DEVICE_MASS_STORAGE = 0x2d
const METHOD_BUFFERED = 0
const METHOD_OUT_DIRECT = 2
const FILE_ANY_ACCESS = 0
const FILE_READ_ACCESS = 0x0001
export const IOCTL_STORAGE_CHECK_VERIFY2 = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0200, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_STORAGE_MEDIA_REMOVAL = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0201, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_STORAGE_EJECT_MEDIA = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0202, METHOD_BUFFERED, FILE_READ_ACCESS)
// LOAD_MEDIA (Schublade einziehen) fehlt MIT ABSICHT: Am BU40N des
// Commanders antwortet es mit ERROR_INVALID_FUNCTION — bei flachen und
// externen Laufwerken die Regel. Ein Knopf, der auf der Hälfte aller
// Laufwerke still nichts tut, wäre ein Placebo (windows.py, 28.08.2026).
export const IOCTL_STORAGE_QUERY_PROPERTY = ctlCode(FILE_DEVICE_MASS_STORAGE, 0x0500, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_CDROM_DISK_TYPE = ctlCode(FILE_DEVICE_CD_ROM, 0x0010, METHOD_BUFFERED, FILE_ANY_ACCESS)
export const IOCTL_DISK_GET_LENGTH_INFO = ctlCode(FILE_DEVICE_DISK, 0x0017, METHOD_BUFFERED, FILE_READ_ACCESS)
// Audio-CD (W-7): TOC + rohe Sektoren. Herleitung wie win_ioctl.py.
export const IOCTL_CDROM_READ_TOC = ctlCode(FILE_DEVICE_CD_ROM, 0x0000, METHOD_BUFFERED, FILE_READ_ACCESS)
export const IOCTL_CDROM_RAW_READ = ctlCode(FILE_DEVICE_CD_ROM, 0x000f, METHOD_OUT_DIRECT, FILE_READ_ACCESS)
export const CDROM_TOC_BYTES = 4 + 100 * 8
// STORAGE_DEVICE_DESCRIPTOR: Offsets der Zeichenketten-Verweise (winioctl.h).
export const SDD_VENDOR_ID_OFFSET = 12
export const SDD_PRODUCT_ID_OFFSET = 16
export const SDD_PRODUCT_REVISION_OFFSET = 20
export const SDD_SERIAL_NUMBER_OFFSET = 24
export const CDROM_DISK_AUDIO_TRACK = 0x01
export const CDROM_DISK_DATA_TRACK = 0x02
// GetLastError-Werte, an denen Zugriffe scheitern (winerror.h).
export const ERROR_INVALID_FUNCTION = 1
export const ERROR_FILE_NOT_FOUND = 2
export const ERROR_ACCESS_DENIED = 5
export const ERROR_NOT_READY = 21
export const ERROR_NOT_SUPPORTED = 50
export const ERROR_NO_MEDIA_IN_DRIVE = 1112
export const DRIVE_CDROM = 5
export const GENERIC_READ = 0x8000_0000
export const FILE_SHARE_READ = 0x01
export const FILE_SHARE_WRITE = 0x02
export const OPEN_EXISTING = 3
/** Ein fehlgeschlagener Win32-Aufruf, mit dem Code von GetLastError(). */
export class Win32Fehler extends Error {
constructor(
was: string,
readonly code: number,
) {
super(`${was} scheiterte (Win32-Fehler ${code})`)
this.name = 'Win32Fehler'
}
}
/** Die Schnittstelle, gegen die disc.ts arbeitet — in Tests nachgebildet. */
export interface LaufwerkApi {
laufwerksbuchstaben(): string[]
laufwerkstyp(buchstabe: string): number
/** Öffnet das Gerät. `zugriff` 0 fragt das GERÄT, GENERIC_READ das MEDIUM
* bei gestörter Disc scheitert das zweite und das erste geht weiter
* (rc11-Befund, in beweise/laufwerk.js nachgemessen). Wirft Win32Fehler. */
oeffnen(geraetepfad: string, zugriff: number): unknown
schliessen(handle: unknown): void
/** DeviceIoControl. Wirft Win32Fehler, sonst die Antwort (ggf. leer). */
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes?: number): Buffer
}
+344
View File
@@ -0,0 +1,344 @@
// Laufwerks-Logik: Typ, Zustand, Auswerfen — KONZEPT § 4.2/§ 5.
// Alles hier arbeitet gegen die LaufwerkApi-Schnittstelle (codes.ts) und
// ist damit ohne Windows und ohne Laufwerk prüfbar; die echten Aufrufe
// liegen allein in win32.ts. Die Regeln stammen aus dem Docker-Treiber
// (windows.py, an echter Hardware gemessen 28.08.2026):
//
// 1. Auswerfen heißt: ENTRIEGELN, AUSWERFEN, NACHSEHEN. MakeMKV verriegelt
// die Tür während des Rips; ein verriegeltes Laufwerk quittiert den
// Auswurf mit Erfolg und tut nichts (R3).
// 2. Ein unbekannter Zustand heißt UNBEKANNT, nicht leer. „Konnte nicht
// nachsehen" und „keine Disc" zu vermischen leerte in v1 die Listen (R2).
import {
CDROM_DISK_AUDIO_TRACK,
CDROM_DISK_DATA_TRACK,
ERROR_ACCESS_DENIED,
ERROR_FILE_NOT_FOUND,
ERROR_INVALID_FUNCTION,
ERROR_NOT_READY,
ERROR_NOT_SUPPORTED,
ERROR_NO_MEDIA_IN_DRIVE,
CDROM_TOC_BYTES,
DRIVE_CDROM,
GENERIC_READ,
IOCTL_CDROM_DISK_TYPE,
IOCTL_CDROM_RAW_READ,
IOCTL_CDROM_READ_TOC,
IOCTL_DISK_GET_LENGTH_INFO,
IOCTL_STORAGE_CHECK_VERIFY2,
IOCTL_STORAGE_EJECT_MEDIA,
IOCTL_STORAGE_MEDIA_REMOVAL,
IOCTL_STORAGE_QUERY_PROPERTY,
SDD_PRODUCT_ID_OFFSET,
SDD_PRODUCT_REVISION_OFFSET,
SDD_SERIAL_NUMBER_OFFSET,
SDD_VENDOR_ID_OFFSET,
Win32Fehler,
type LaufwerkApi,
} from './codes'
import type { AuswurfGrund, DiscTyp, GeraeteInfo, LaufwerkStatus } from '../../gemeinsam/nachrichten'
import { bloecke, rawReadInfo, tocZerlegen, SEKTOR_BYTES, type Spur } from '../rip/audiocd'
export type { DiscTyp, GeraeteInfo, LaufwerkStatus }
// ── Schwellen für die Typ-Zuordnung (aus cdrom.py, dort mit Begründung) ──
// DVD9 ~8,5 GB; Blu-ray beginnt bei 25 GB → ab 10 GiB sicher Blu-ray.
export const BLURAY_MIN_BYTES = 10 * 1024 ** 3
// UHD sind BD-66/BD-100; eine BD-50 bleibt unter ~47 GiB → ab 55 GiB UHD.
// (Am 28.08.2026 gegengeprüft: BD-50 mit 44,84 GiB wird „bluray".)
export const UHD_MIN_BYTES = 55 * 1024 ** 3
/** Pure Zuordnung: Spur-Flags + Größe → Disc-Typ. */
export function classify(spurFlags: number, groesseBytes: number): DiscTyp {
if (spurFlags & CDROM_DISK_AUDIO_TRACK) return 'cd'
if (spurFlags & CDROM_DISK_DATA_TRACK) {
if (groesseBytes >= UHD_MIN_BYTES) return 'uhd'
return groesseBytes >= BLURAY_MIN_BYTES ? 'bluray' : 'dvd'
}
return 'unknown'
}
/** D:" oder D" oder D:\" \\.\D: der Gerätepfad hat KEINEN
* abschließenden Backslash. Mit einem öffnet CreateFileW das
* Wurzelverzeichnis statt des Geräts, und die Steuercodes scheitern mit
* ERROR_INVALID_FUNCTION das sieht aus wie kann das Laufwerk nicht". */
export function geraetepfad(buchstabe: string): string {
const kern = (buchstabe ?? '').trim().replace(/[\\/]+$/, '').replace(/:$/, '')
if (kern.length === 0) throw new Error('Kein Laufwerksbuchstabe angegeben')
return '\\\\.\\' + kern[0].toUpperCase() + ':'
}
/** `\\.\G:` → `G`. Hin- und Rückweg gehören zusammen (der /dev/G-404-Fund). */
export function kennung(geraet: string): string {
return geraet.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
}
export function listOptischeLaufwerke(api: LaufwerkApi): string[] {
return api
.laufwerksbuchstaben()
.filter((b) => api.laufwerkstyp(b) === DRIVE_CDROM)
.map(geraetepfad)
.sort()
}
// ── Zustand ─────────────────────────────────────────────────────────────
function mediumDa(api: LaufwerkApi, handle: unknown): boolean {
try {
api.steuern(handle, IOCTL_STORAGE_CHECK_VERIFY2)
return true
} catch (fehler) {
if (fehler instanceof Win32Fehler && (fehler.code === ERROR_NOT_READY || fehler.code === ERROR_NO_MEDIA_IN_DRIVE)) {
return false
}
throw fehler
}
}
function mitHandle<T>(api: LaufwerkApi, geraet: string, zugriff: number, tun: (handle: unknown) => T): T {
const handle = api.oeffnen(geraet, zugriff)
try {
return tun(handle)
} finally {
api.schliessen(handle)
}
}
/** Spur-Flags (Audio/Daten) — 0 heißt unbekannt, classify sagt dann 'unknown'. */
export function spurFlags(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_CDROM_DISK_TYPE, null, 4)
return antwort.length >= 4 ? antwort.readUInt32LE(0) : 0
})
}
export function discGroesseBytes(api: LaufwerkApi, geraet: string): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const antwort = api.steuern(h, IOCTL_DISK_GET_LENGTH_INFO, null, 8)
return antwort.length >= 8 ? Number(antwort.readBigUInt64LE(0)) : 0
})
}
// ── Verriegeln ──────────────────────────────────────────────────────────
/** Tür verriegeln (true) / entriegeln (false). PREVENT_MEDIA_REMOVAL ist
* eine Struktur mit genau einem BOOLEAN. Wirft bei Fehlschlag. */
export function verriegeln(api: LaufwerkApi, geraet: string, an: boolean): void {
mitHandle(api, geraet, GENERIC_READ, (h) => {
api.steuern(h, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([an ? 1 : 0]))
})
}
// ── Auswerfen ───────────────────────────────────────────────────────────
export type { AuswurfGrund }
const AUSWURF_NACHSEHEN_RUNDEN = 5
/** Entriegeln auswerfen NACHSEHEN (R3). Wirft NIE; gibt den Grund
* zurück. `warten` ist injizierbar, damit der Ablauf ohne echte Sekunden
* prüfbar ist. */
export async function auswerfenMitGrund(
api: LaufwerkApi,
geraet: string,
warten: (ms: number) => Promise<void> = (ms) => new Promise((f) => setTimeout(f, ms)),
): Promise<{ grund: AuswurfGrund; fehler: string | null }> {
let handle: unknown
try {
handle = api.oeffnen(geraet, GENERIC_READ)
} catch (fehler) {
return { grund: 'kein-zugriff', fehler: String(fehler) }
}
try {
// Entriegeln zuerst. Scheitert es, wird der Auswurf trotzdem versucht —
// bei einem nicht verriegelten Laufwerk klappt er ohnehin. Der Fehlschlag
// wird aber MITGEFÜHRT, nicht verschluckt (R4).
let entriegelnFehler: string | null = null
try {
api.steuern(handle, IOCTL_STORAGE_MEDIA_REMOVAL, Buffer.from([0]))
} catch (fehler) {
entriegelnFehler = String(fehler)
}
try {
api.steuern(handle, IOCTL_STORAGE_EJECT_MEDIA)
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
// NACHSEHEN — der Befehl oben meldet nur, dass er angenommen wurde.
for (let runde = 0; runde < AUSWURF_NACHSEHEN_RUNDEN; runde++) {
try {
if (!mediumDa(api, handle)) return { grund: 'ok', fehler: null }
} catch (fehler) {
return { grund: 'abgelehnt', fehler: String(fehler) }
}
await warten(1000)
}
return { grund: 'bleibt-drin', fehler: entriegelnFehler }
} finally {
try {
api.schliessen(handle)
} catch {
// Ein Handle, das sich nicht schließen lässt, ändert am Ergebnis
// nichts mehr — der Auswurf-Grund steht fest und wird gemeldet.
}
}
}
// ── Audio-CD (§ 6.3): TOC und rohe Sektoren ─────────────────────────────
/** Das Inhaltsverzeichnis einer Audio-CD leere Liste, wenn keine drin.
* Die Zerlegung ist pur (audiocd.tocZerlegen); hier nur der Steuercode. */
export function audioToc(api: LaufwerkApi, geraet: string): Spur[] {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const roh = api.steuern(h, IOCTL_CDROM_READ_TOC, null, CDROM_TOC_BYTES)
return tocZerlegen(roh)
})
}
/** Eine Audio-Spur sektorweise lesen. `schreiben` bekommt jeden Block
* eine 47-MB-Spur wandert so nie komplett in den Speicher.
* DiskOffset zählt in 2048ern (rawReadInfo), obwohl ein Audio-Sektor
* 2352 Bytes hat dokumentiert, sieht falsch aus, ist richtig. */
export function audioSpurLesen(
api: LaufwerkApi,
geraet: string,
start: number,
sektoren: number,
schreiben: (daten: Buffer) => void,
fortschritt?: (anteil: number) => void,
): number {
return mitHandle(api, geraet, GENERIC_READ, (h) => {
const teile = bloecke(start, sektoren)
let gelesen = 0
for (let i = 0; i < teile.length; i++) {
const [von, anzahl] = teile[i]
const daten = api.steuern(h, IOCTL_CDROM_RAW_READ, rawReadInfo(von, anzahl), anzahl * SEKTOR_BYTES)
schreiben(daten)
gelesen += daten.length
fortschritt?.((i + 1) / teile.length)
}
return gelesen
})
}
// ── Klartext zu Win32-Fehlern (der 30.08.-Befund: Rippy KANNTE die
// Ursache und behielt sie für sich — nie wieder) ────────────────────────
const ZUGRIFFS_GRUENDE: Record<number, string> = {
[ERROR_INVALID_FUNCTION]:
'Das Laufwerk beantwortet keine Medien-Abfragen mehr. Das passiert nach abgebrochenen Lesevorgängen. Abhilfe: Disc über die Taste am Laufwerk auswerfen und neu einlegen; hilft das nicht, den Rechner neu starten.',
[ERROR_NOT_SUPPORTED]: 'Das Laufwerk lehnt die Abfrage gerade ab. Abhilfe: Disc auswerfen und neu einlegen.',
[ERROR_ACCESS_DENIED]: 'Ein anderes Programm hält das Laufwerk fest.',
[ERROR_FILE_NOT_FOUND]: 'Dieses Laufwerk gibt es nicht mehr.',
[ERROR_NOT_READY]: 'Es liegt keine Disc im Laufwerk.',
}
export function zugriffsGrund(fehler: unknown): string {
const code = fehler instanceof Win32Fehler ? fehler.code : null
if (code !== null && code in ZUGRIFFS_GRUENDE) return ZUGRIFFS_GRUENDE[code]
if (code !== null) {
return `Das Laufwerk antwortet nicht (Win32-Fehler ${code}). Abhilfe: Disc auswerfen und neu einlegen.`
}
return 'Das Laufwerk antwortet nicht.'
}
// ── Geräteangaben ───────────────────────────────────────────────────────
/** Eine nullterminierte Zeichenkette, auf die ein Offset im Puffer zeigt.
* Offset 0 heißt gibt es nicht" (dokumentiertes Verhalten des
* STORAGE_DEVICE_DESCRIPTOR). Reine Funktion, ohne Laufwerk prüfbar. */
export function textBei(puffer: Buffer, offsetPos: number): string {
if (puffer.length < offsetPos + 4) return ''
const offset = puffer.readUInt32LE(offsetPos)
if (offset === 0 || offset >= puffer.length) return ''
let ende = puffer.indexOf(0, offset)
if (ende < 0) ende = puffer.length
return puffer.toString('latin1', offset, ende).trim()
}
export interface Geraeteangaben {
hersteller: string
modell: string
seriennummer: string
}
/** Hersteller/Modell/Seriennummer des LAUFWERKS. Wirft nicht ein Laufwerk
* ohne Angaben ist ärgerlich, aber kein Grund, die Liste scheitern zu
* lassen. Öffnet mit Zugriff 0: Die GERÄTE-Auskunft geht auch dann noch,
* wenn das Medium gestört ist (rc11-Befund). */
export function geraeteangaben(api: LaufwerkApi, geraet: string): Geraeteangaben {
const leer: Geraeteangaben = { hersteller: '', modell: '', seriennummer: '' }
const abfrage = Buffer.alloc(12) // PropertyId=0, QueryType=0, Reserved
try {
return mitHandle(api, geraet, 0, (h) => {
const puffer = api.steuern(h, IOCTL_STORAGE_QUERY_PROPERTY, abfrage, 1024)
return {
hersteller: textBei(puffer, SDD_VENDOR_ID_OFFSET),
modell: [textBei(puffer, SDD_PRODUCT_ID_OFFSET), textBei(puffer, SDD_PRODUCT_REVISION_OFFSET)]
.filter((t) => t.length > 0)
.join(' '),
seriennummer: textBei(puffer, SDD_SERIAL_NUMBER_OFFSET),
}
})
} catch {
return leer
}
}
// Die Angaben eines Laufwerks ändern sich nicht, solange es dasselbe
// Laufwerk ist — die Wache fragt aber alle drei Sekunden. Ohne Gedächtnis
// wäre das alle drei Sekunden ein CreateFileW auf ein Gerät, das gerade
// rippt (Muster aus windows.py).
const angabenSpeicher = new Map<string, Geraeteangaben>()
function angabenGemerkt(api: LaufwerkApi, geraet: string): Geraeteangaben {
let angaben = angabenSpeicher.get(geraet)
if (angaben === undefined) {
angaben = geraeteangaben(api, geraet)
angabenSpeicher.set(geraet, angaben)
}
return angaben
}
/** Nur für Tests: das Angaben-Gedächtnis leeren. */
export function angabenSpeicherLeeren(): void {
angabenSpeicher.clear()
}
// ── Der Geräte-Eintrag fürs UI ──────────────────────────────────────────
export function geraeteInfo(api: LaufwerkApi, geraet: string): GeraeteInfo {
let status: LaufwerkStatus = 'unknown'
let typ: DiscTyp = 'unknown'
let groesse = 0
let grund = ''
try {
// Medium-Frage über Zugriff 0 — geht auch bei gestörter Disc (rc11).
const da = mitHandle(api, geraet, 0, (h) => mediumDa(api, h))
if (da) {
status = 'ready'
try {
groesse = discGroesseBytes(api, geraet)
typ = classify(spurFlags(api, geraet), groesse)
} catch (fehler) {
typ = 'unknown'
grund = zugriffsGrund(fehler)
}
} else {
status = 'empty'
}
} catch (fehler) {
status = 'unknown'
grund = zugriffsGrund(fehler)
}
const buchstabe = kennung(geraet)
const angaben = angabenGemerkt(api, geraet)
return {
id: buchstabe,
name: `Laufwerk ${buchstabe}:`,
typ,
pfad: geraet,
status,
modell: [angaben.hersteller, angaben.modell].filter((t) => t.length > 0).join(' '),
seriennummer: angaben.seriennummer,
groesseBytes: groesse,
grund,
}
}
@@ -0,0 +1,172 @@
// Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (5.4.0).
//
// Der Anlass (01.09.2026): Beim Spartacus-Defekt und beim 5042-Zustand
// des BU40N war die entscheidende Frage „Disc oder Hardware?" — und die
// Antwort stand im System-Protokoll, von Hand gelesen. Jetzt liest Rippy
// sie selbst.
//
// GEMESSEN (01.09.2026, Regel D, an diesem PC):
// wevtutil qe System "/q:<XPath>" /f:xml /c:200 /rd:true
// Provider 'cdrom', EventID 7 → „Fehlerhafter Block bei Gerät \Device\CdRom0."
// EventID 11 → „Der Treiber hat einen Controllerfehler auf \Device\CdRom0 gefunden."
// EventID 51 → Auslagerungs-/E/A-Fehler (bei 7 dabei)
// In 30 Tagen: 338 × 7, 8 × 11, 46 × 51 — die 7er häuften sich exakt um
// den Spartacus-Rip (20:32), die 11er beim USB-Aussetzer (20:17).
// TimeCreated SystemTime ist UTC. Die XML-Ereignisse stehen OHNE
// Zeilenumbruch hintereinander; ein Regex je <Event>…</Event> reicht.
// Die Argumente gehen ohne Shell an wevtutil — ein „/q:"-Argument mit
// Leerzeichen bleibt so EIN Argument (über Git Bash zerlegt es der
// Pfad-Konverter, über spawn nicht).
import { spawn } from 'node:child_process'
import type { LaufwerkBericht } from '../../gemeinsam/nachrichten'
import { textVon } from '../rip/parser'
export interface LaufwerkEreignis {
/** ISO-Zeit (UTC). */
zeit: string
provider: string
id: number
geraet: string
}
/** Die Ereignis-IDs des cdrom-/disk-Treibers, wie am PC gemessen. */
export const EREIGNIS_TEXTE: Record<number, string> = {
7: 'fehlerhafter Block',
11: 'Controllerfehler',
15: 'Gerät nicht bereit',
51: 'E/A-Fehler beim Lesen',
153: 'E/A-Vorgang wiederholt',
}
export function xpathFuer(stunden: number): string {
const ms = Math.max(1, Math.round(stunden)) * 60 * 60 * 1000
return `*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= ${ms}]]]`
}
export function buildWevtutilArgs(stunden: number, hoechstens = 200): string[] {
return ['qe', 'System', `/q:${xpathFuer(stunden)}`, '/f:xml', `/c:${hoechstens}`, '/rd:true']
}
/** Die XML-Ausgabe von wevtutil → Ereignisse (pur, testbar). */
export function ereignisseAusXml(xml: string): LaufwerkEreignis[] {
const liste: LaufwerkEreignis[] = []
for (const treffer of xml.matchAll(/<Event\b[\s\S]*?<\/Event>/g)) {
const block = treffer[0]
const provider = /<Provider Name='([^']*)'/.exec(block)?.[1] ?? ''
const id = Number(/<EventID[^>]*>(\d+)<\/EventID>/.exec(block)?.[1] ?? '')
const zeit = /<TimeCreated SystemTime='([^']*)'/.exec(block)?.[1] ?? ''
const geraet = /<Data>([^<]*)<\/Data>/.exec(block)?.[1] ?? ''
if (!Number.isInteger(id) || provider.length === 0) continue
liste.push({ zeit, provider, id, geraet })
}
return liste
}
/** Urteil aus den Ereignissen (pur). `seit` grenzt auf einen Zeitraum ein
* (z. B. den laufenden Rip); ohne `seit` zählt alles, was da ist. */
export function beurteilen(ereignisse: readonly LaufwerkEreignis[], stunden: number, seit?: Date): LaufwerkBericht {
const relevant = seit === undefined ? [...ereignisse] : ereignisse.filter((e) => new Date(e.zeit).getTime() >= seit.getTime())
let fehlerhafteBloecke = 0
let controllerfehler = 0
let sonstige = 0
for (const e of relevant) {
if (e.id === 7) fehlerhafteBloecke += 1
else if (e.id === 11 || e.id === 15) controllerfehler += 1
else sonstige += 1
}
const letzte = relevant.slice(0, 8).map((e) => {
const lokal = e.zeit.length > 0 ? new Date(e.zeit) : null
const zeit =
lokal !== null && !Number.isNaN(lokal.getTime())
? `${lokal.getFullYear()}-${String(lokal.getMonth() + 1).padStart(2, '0')}-${String(lokal.getDate()).padStart(2, '0')} ${String(lokal.getHours()).padStart(2, '0')}:${String(lokal.getMinutes()).padStart(2, '0')}`
: '?'
return `${zeit} · ${e.provider} ${e.id} · ${EREIGNIS_TEXTE[e.id] ?? 'Ereignis'} (${e.geraet})`
})
const zeitraum = seit === undefined ? `in den letzten ${stunden} Stunden` : `seit ${seit.toISOString().slice(11, 16)} UTC`
if (relevant.length === 0) {
return {
urteil: 'ruhig',
text: `Das Windows-Protokoll meldet ${zeitraum} nichts zum Laufwerk — weder Lesefehler noch Hardware-Fehler.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (controllerfehler > 0) {
return {
urteil: 'hardware',
text:
`${controllerfehler} Controllerfehler ${zeitraum}` +
(fehlerhafteBloecke > 0 ? ` und ${fehlerhafteBloecke} fehlerhafte Blöcke` : '') +
'. Ein Controllerfehler kommt vom Laufwerk oder seiner Verbindung, nicht von der Disc: ' +
'Bei einem USB-Laufwerk zuerst einen Port direkt am Mainboard probieren, Kabel und Netzteil prüfen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
if (fehlerhafteBloecke > 0) {
return {
urteil: 'disc',
text:
`${fehlerhafteBloecke} fehlerhafte Blöcke ${zeitraum}, keine Controllerfehler. ` +
'Das spricht für die DISC (Kratzer, Fertigungsfehler), nicht für das Laufwerk: ' +
'Disc reinigen, den betroffenen Titel überspringen oder eine andere Ausgabe versuchen.',
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
return {
urteil: 'unklar',
text: `${sonstige} Laufwerks-Ereignisse ${zeitraum}, aber weder fehlerhafte Blöcke noch Controllerfehler — kein klares Bild.`,
fehlerhafteBloecke,
controllerfehler,
sonstige,
letzte,
stunden,
}
}
/** Liest das Protokoll — null heißt „konnte nicht lesen", mit Grund. */
export function ereignisseLesen(
stunden: number,
timeoutMs = 30_000,
): Promise<{ ereignisse: LaufwerkEreignis[] } | { fehler: string }> {
return new Promise((fertig) => {
let kind: ReturnType<typeof spawn>
try {
kind = spawn('wevtutil', buildWevtutilArgs(stunden), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
} catch (fehler) {
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
return
}
const stuecke: Buffer[] = []
const fehlerStuecke: Buffer[] = []
kind.stdout?.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr?.on('data', (s: Buffer) => fehlerStuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ fehler: `wevtutil nicht startbar: ${String(fehler)}` })
})
kind.on('close', (code) => {
clearTimeout(zeiger)
const xml = textVon(Buffer.concat(stuecke))
// Ohne Treffer endet wevtutil mit 0 und leerer Ausgabe; ein Fehler
// steht auf stderr mit Code ≠ 0.
if (code !== 0 && xml.trim().length === 0) {
fertig({ fehler: `wevtutil endete mit Code ${code}: ${textVon(Buffer.concat(fehlerStuecke)).trim().slice(0, 200)}` })
return
}
fertig({ ereignisse: ereignisseAusXml(xml) })
})
})
}
+107
View File
@@ -0,0 +1,107 @@
// Die Disc-Wache — KONZEPT § 5, Schritt 1: ein 3-Sekunden-Takt über
// IOCTL_STORAGE_CHECK_VERIFY2 (praktisch kostenlos, in beweise/laufwerk.js
// gemessen). WM_DEVICECHANGE ist die spätere Verfeinerung; wenn sie kommt,
// bleibt dieser Takt als Rückfallebene bestehen.
//
// Regeln, die hier verdrahtet sind:
// R2 — Ein Fehlschlag löscht nie einen Zustand: Scheitert eine Runde,
// bleibt der letzte bekannte Stand stehen; gemeldet wird der Fehler.
// R4 — Jede Hintergrundschleife meldet ihren Fehler. Kein stilles catch.
import type { GeraeteInfo, WacheEreignis } from '../../gemeinsam/nachrichten'
export type { WacheEreignis }
/** Pure Funktion: Zustandsvergleich Ereignisse (Muster aus
* bus/waechter.py `laufwerks_unterschiede`). */
export function laufwerksUnterschiede(
vorher: ReadonlyMap<string, GeraeteInfo>,
jetzt: ReadonlyMap<string, GeraeteInfo>,
): WacheEreignis[] {
const ereignisse: WacheEreignis[] = []
for (const [id, stand] of jetzt) {
const alt = vorher.get(id)
if (alt === undefined) {
ereignisse.push({ art: 'laufwerk-neu', geraet: stand })
if (stand.status === 'ready') ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
continue
}
const vorherBereit = alt.status === 'ready'
const jetztBereit = stand.status === 'ready'
if (!vorherBereit && jetztBereit) {
ereignisse.push({ art: 'disc-eingelegt', geraet: stand })
} else if (vorherBereit && !jetztBereit && stand.status === 'empty') {
// NUR der Übergang ready → empty ist eine Entnahme. ready → unknown
// heißt „konnte nicht nachsehen" — das ist KEINE Aussage über die
// Disc (R2); der Grund steht im Geräte-Eintrag.
ereignisse.push({ art: 'disc-entnommen', geraet: stand })
}
}
for (const id of vorher.keys()) {
if (!jetzt.has(id)) ereignisse.push({ art: 'laufwerk-weg', id })
}
return ereignisse
}
export const WACHE_TAKT_MS = 3000
export interface WacheMeldung {
laufwerke: GeraeteInfo[]
ereignisse: WacheEreignis[]
}
export class DiscWache {
private stand = new Map<string, GeraeteInfo>()
private zeiger: NodeJS.Timeout | null = null
private laeuft = false
constructor(
/** Liest den aktuellen Stand aller Laufwerke. Darf werfen. */
private readonly lesen: () => GeraeteInfo[],
/** Bekommt jede Runde mit Änderungen — und jede mit Ereignissen. */
private readonly melden: (meldung: WacheMeldung) => void,
/** R4: Ein Rundenfehler wird GEMELDET, nicht verschluckt. */
private readonly fehlerMelden: (fehler: unknown) => void,
private readonly taktMs: number = WACHE_TAKT_MS,
) {}
starten(): void {
if (this.laeuft) return
this.laeuft = true
this.runde()
}
stoppen(): void {
this.laeuft = false
if (this.zeiger !== null) {
clearTimeout(this.zeiger)
this.zeiger = null
}
}
/** Eine Runde: lesen, vergleichen, melden, nächste Runde planen.
* Öffentlich, damit Tests sie ohne Uhr treiben können. */
runde(): void {
if (!this.laeuft) return
try {
const liste = this.lesen()
const jetzt = new Map(liste.map((g) => [g.id, g]))
const ereignisse = laufwerksUnterschiede(this.stand, jetzt)
const geaendert =
ereignisse.length > 0 || JSON.stringify([...jetzt.values()]) !== JSON.stringify([...this.stand.values()])
this.stand = jetzt
if (geaendert) this.melden({ laufwerke: liste, ereignisse })
} catch (fehler) {
// R2: Der letzte Stand bleibt stehen. R4: Der Fehler wird laut.
this.fehlerMelden(fehler)
}
if (this.laeuft) {
this.zeiger = setTimeout(() => this.runde(), this.taktMs)
// Der Takt darf einen Programm-Exit nicht aufhalten.
this.zeiger.unref?.()
}
}
aktuellerStand(): GeraeteInfo[] {
return [...this.stand.values()]
}
}
+88
View File
@@ -0,0 +1,88 @@
// DER EINZIGE ORT MIT koffi im Kern (Regel R1, KONZEPT § 4.3) — die rohe
// Win32-Anbindung für optische Laufwerke. Entscheidungen stehen NICHT hier,
// sondern in disc.ts; die Konstanten samt Herleitung in codes.ts. Dadurch
// ist alles außer diesen Aufrufen ohne Windows und ohne Laufwerk prüfbar
// (dasselbe Muster wie win_ioctl.py/windows.py im Docker-Zweig, dort an
// echter Hardware bewiesen — 28.08.2026, BU40N; hier gemessen in
// beweise/laufwerk.js, 30.08.2026).
import koffi from 'koffi'
import { FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, Win32Fehler, type LaufwerkApi } from './codes'
export type { LaufwerkApi }
let echteApi: LaufwerkApi | null = null
/** Die echten kernel32-Aufrufe — einmal geladen, dann wiederverwendet. */
export function win32Api(): LaufwerkApi {
if (echteApi !== null) return echteApi
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)',
)
echteApi = {
laufwerksbuchstaben(): string[] {
const maske = GetLogicalDrives() as number
const buchstaben: string[] = []
for (let i = 0; i < 26; i++) {
if (maske & (1 << i)) buchstaben.push(String.fromCharCode(65 + i))
}
return buchstaben
},
laufwerkstyp(buchstabe: string): number {
return GetDriveTypeW(`${buchstabe}:\\`) as number
},
oeffnen(geraetepfad: string, zugriff: number): unknown {
const handle = CreateFileW(
geraetepfad,
zugriff,
FILE_SHARE_READ | FILE_SHARE_WRITE,
null,
OPEN_EXISTING,
0,
null,
)
const adresse = koffi.address(handle as Parameters<typeof koffi.address>[0])
// INVALID_HANDLE_VALUE ist (HANDLE)-1 — als vorzeichenlose 64-Bit-Zahl
// 0xFFFF…FF, nicht -1 (dieselbe Falle wie in windows.py dokumentiert).
if (adresse === 0n || adresse === 0xffff_ffff_ffff_ffffn) {
throw new Win32Fehler('CreateFileW', GetLastError() as number)
}
return handle
},
schliessen(handle: unknown): void {
CloseHandle(handle)
},
steuern(handle: unknown, code: number, eingabe?: Buffer | null, ausgabeBytes = 0): Buffer {
const aus = ausgabeBytes > 0 ? Buffer.alloc(ausgabeBytes) : null
const zurueck = [0]
const erfolg = DeviceIoControl(
handle,
code,
eingabe ?? null,
eingabe?.length ?? 0,
aus,
ausgabeBytes,
zurueck,
null,
)
if (!erfolg) {
throw new Win32Fehler(`DeviceIoControl(0x${code.toString(16)})`, GetLastError() as number)
}
return aus === null ? Buffer.alloc(0) : aus.subarray(0, zurueck[0])
},
}
return echteApi
}
@@ -0,0 +1,355 @@
// Was die Disc über sich selbst verrät (KONZEPT § 5, Schritt 3) — Regeln
// aus prescan.py, dort an echten Discs gemessen:
//
// Quelle 1 (beste): BDMV/META/DL/bdmt_*.xml — der Studio-Titel im
// Klartext. Unter Windows hängt Windows die Disc selbst ein (G:\).
// Quelle 2: Volume-Label (ISO-9660 für DVDs, UDF für Blu-rays ohne
// ISO-Bridge) — Labels wie BD_EVG_D2 taugen erst normalisiert.
//
// KEIN makemkvcon hier: Der Zweig kostete 20120 s je eingelegter Disc
// und sein Ergebnis las niemand (der 30.08.-Fund „das erkennen dauert
// sehr sehr lange").
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
// Plattform-Pfade: titelAusBdmt läuft auf einer LAUFZEIT-Wurzel (G:\ im
// Betrieb, ein tmp-Ordner im Linux-CI-Test) — dieselbe Regel wie in
// struktur.ts, am 30.08.2026 zum dritten Mal gelernt.
import { join } from 'node:path'
/** 'PULP_FICTION_DE' → 'Pulp Fiction De' (pur). */
export function normalizeDiscLabel(label: string): string {
if (label.length === 0) return ''
const bereinigt = label.replaceAll('_', ' ').replaceAll('.', ' ').split(/\s+/).filter(Boolean).join(' ')
if (bereinigt === bereinigt.toUpperCase()) {
return bereinigt
.toLowerCase()
.split(' ')
.map((w) => (w.length > 0 ? w[0].toUpperCase() + w.slice(1) : w))
.join(' ')
}
return bereinigt
}
/** UDF d-string (ECMA-167 1/7.2.12): letztes Byte = Länge, erstes =
* Kompressions-ID (8 = Latin-1, 16 = UTF-16BE). Pur, testbar. */
export function parseUdfDstring(daten: Buffer): string {
if (daten.length === 0) return ''
const laenge = daten[daten.length - 1]
if (laenge < 2 || laenge > daten.length - 1) return ''
const compId = daten[0]
const text = daten.subarray(1, laenge)
if (compId === 8) return text.toString('latin1').trim()
if (compId === 16) {
// UTF-16BE: Bytes tauschen, dann als LE lesen (Node kennt nur utf16le).
const getauscht = Buffer.alloc(text.length - (text.length % 2))
for (let i = 0; i + 1 < text.length; i += 2) {
getauscht[i] = text[i + 1]
getauscht[i + 1] = text[i]
}
return getauscht.toString('utf16le').trim()
}
return ''
}
const SEKTOR = 2048
function lesenBei(fd: number, position: number, laenge: number): Buffer {
const puffer = Buffer.alloc(laenge)
const gelesen = readSync(fd, puffer, 0, laenge, position)
return puffer.subarray(0, gelesen)
}
/** ISO-9660-Volume-Label: Sektor 16, Bytes 4071 (DVDs). '' wenn keins. */
export function isoVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const pvd = lesenBei(fd, 16 * SEKTOR, SEKTOR)
if (pvd.length < 72 || !pvd.subarray(1, 6).equals(Buffer.from('CD001', 'ascii'))) return ''
return pvd.subarray(40, 72).toString('latin1').trim()
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** UDF-Volume-Label (Blu-rays ohne ISO-Bridge): AVDP bei Sektor 256
* Main VDS PVD (Tag 1), Volume Identifier ab Offset 24 (ECMA-167). */
export function udfVolumeLabel(geraetepfad: string): string {
let fd: number
try {
fd = openSync(geraetepfad, 'r')
} catch {
return ''
}
try {
const avdp = lesenBei(fd, 256 * SEKTOR, SEKTOR)
if (avdp.length < 24 || avdp.readUInt16LE(0) !== 2) return ''
const vdsOrt = avdp.readUInt32LE(20)
for (let i = 0; i < 32; i++) {
const block = lesenBei(fd, (vdsOrt + i) * SEKTOR, SEKTOR)
if (block.length < 56) return ''
const tagId = block.readUInt16LE(0)
if (tagId === 1) return parseUdfDstring(block.subarray(24, 56))
if (tagId === 8) return '' // Terminating Descriptor
}
return ''
} catch {
return ''
} finally {
closeSync(fd)
}
}
/** Was in BDMV/META/DL/bdmt_*.xml steht. Bis zum 01.09.2026 nahm Rippy von
* dieser Datei NUR den Titel das Inhaltsverzeichnis daneben ist der
* stärkere Hinweis und lag ungelesen da. An SPARTACUS_GOTA_D2 gemessen:
*
* <di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>
* <di:titleName titleNumber="1">EP 1</di:titleName>
* <di:titleName titleNumber="2">EP 2</di:titleName>
* <di:titleName titleNumber="51">DUB GER 1</di:titleName> usw.
*
* Die Disc sagt damit selbst, dass sie eine SERIE mit zwei Folgen trägt.
* Das ist ein Beweis, keine Vermutung und schlägt jede Titel-Raterei. */
export interface BdmtInhalt {
/** <di:name> — der Studio-Titel im Klartext; '' wenn keiner dasteht. */
titel: string
/** <di:titleName> — was die Disc trägt: Folgen, Tonspuren, Trailer. */
eintraege: string[]
/** Weitere Titel derselben Disc aus den anderen bdmt-Sprachdateien
* (bdmt_deu neben bdmt_eng, 5.4.0) ohne den Haupttitel, ohne Doppel. */
alternativen: string[]
}
/** Den Studio-Titel und das Inhaltsverzeichnis aus EINER bdmt-Datei ziehen
* (pur, testbar). Regex statt XML-Parser (Namespaces variieren je
* Authoring-Werkzeug). */
export function bdmtZerlegen(inhalt: string): { titel: string; eintraege: string[] } {
const name = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
const eintraege: string[] = []
for (const t of inhalt.matchAll(/<di:titleName[^>]*>([^<]{1,120})<\/di:titleName>/g)) {
const text = t[1].trim()
if (text.length > 0) eintraege.push(text)
}
return { titel: name === null ? '' : name[1].trim(), eintraege }
}
/** Liest die bdmt-Dateien bdmt_eng zuerst, die anderen Sprachen liefern
* Titel-Varianten (5.4.0). */
export function inhaltAusBdmt(wurzel: string): BdmtInhalt {
// Nicht lesbar heißt „nichts erfahren" — dasselbe Ergebnis wie „keine
// Datei da". Eine Erkennung scheitert daran nie, sie wird nur ärmer.
const leer: BdmtInhalt = { titel: '', eintraege: [], alternativen: [] }
const metaOrdner = join(wurzel, 'BDMV', 'META', 'DL')
if (!existsSync(metaOrdner)) return leer
let kandidaten: string[]
try {
kandidaten = readdirSync(metaOrdner).sort()
} catch {
return leer
}
const bdmts = kandidaten.filter((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmts.length === 0) return leer
// Englisch zuerst (das ist der Titel, den die Suche bisher kannte), dann
// Deutsch, dann der Rest in Dateireihenfolge.
const rang = (k: string): number => (k === 'bdmt_eng.xml' ? 0 : k === 'bdmt_deu.xml' ? 1 : 2)
bdmts.sort((a, b) => rang(a) - rang(b) || a.localeCompare(b))
let titel = ''
let eintraege: string[] = []
const alternativen: string[] = []
for (const bdmt of bdmts) {
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
continue
}
const zerlegt = bdmtZerlegen(inhalt)
if (titel.length === 0 && zerlegt.titel.length > 0) {
titel = zerlegt.titel
eintraege = zerlegt.eintraege
continue
}
if (eintraege.length === 0 && zerlegt.eintraege.length > 0) eintraege = zerlegt.eintraege
if (
zerlegt.titel.length > 0 &&
zerlegt.titel.toLowerCase() !== titel.toLowerCase() &&
!alternativen.some((a) => a.toLowerCase() === zerlegt.titel.toLowerCase())
) {
alternativen.push(zerlegt.titel)
}
}
return { titel, eintraege, alternativen }
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml — '' wenn keiner dasteht. */
export function titelAusBdmt(wurzel: string): string {
return inhaltAusBdmt(wurzel).titel
}
/** Sieht dieser Inhaltseintrag aus wie eine Folge? Bewusst ENG: An der
* echten Disc stehen neben `EP 1`/`EP 2` auch `DUB GER 1`, `MM ITA` und
* `101 LOGO` Tonspuren, Trailer und Warnhinweise sind keine Folgen. */
export function istFolgenName(name: string): boolean {
return /^(?:ep|episode|folge)\s*0*\d{1,3}$/i.test(name.trim())
}
/** Wie viele Einträge des Inhaltsverzeichnisses sind Folgen? */
export function folgenZahl(eintraege: readonly string[]): number {
return eintraege.filter(istFolgenName).length
}
// Verpackungs-Zusätze am ENDE eines Disc-Titels. Was hier NICHT steht,
// steht mit Absicht nicht hier:
// * `Teil`/`Part` — „Kill Bill Teil 2" ist ein Filmtitel, keine Disc-Nummer.
// * eine nackte Zahl — sonst würde aus „Rocky 2" die „Rocky", Disc 2.
// Die Ein-Buchstaben-Formen `d`/`s` verlangen einen TRENNER davor: ohne ihn
// würde „Ocean's 11" an der Apostroph-Wortgrenze zu „Ocean'", Staffel 11.
const TRENNER = String.raw`[\s\-–—_:,.]`
const DISC_MUSTER = new RegExp(`${TRENNER}+(?:disc|disk|dvd|scheibe|d)\\s*0*(\\d{1,2})\\s*$`, 'i')
const STAFFEL_MUSTER = new RegExp(`${TRENNER}+(?:season|staffel|series|s)\\s*0*(\\d{1,2})\\s*$`, 'i')
export interface DiscZusatz {
/** Der Titel ohne die erkannten Verpackungs-Zusätze. */
titel: string
/** Nummer der Disc innerhalb der Ausgabe — null, wenn keine dasteht. */
discNr: number | null
/** Staffel-Nummer — null, wenn keine dasteht. */
staffel: number | null
}
/** `Spartacus: Gods Of The Arena - Disc 2` Titel + Disc 2 (pur).
* Abtrennen UND merken: Der Suchtitel wird brauchbar, und die Nummer
* braucht die Ablage später sowieso, um Folgen richtig einzusortieren. */
export function discZusatzAbtrennen(titel: string): DiscZusatz {
let rest = titel.trim()
let discNr: number | null = null
let staffel: number | null = null
// Mehrfach, weil ein Label wie `SHOW_S1_D2` beide Zusätze hintereinander
// trägt; die Schleife endet, sobald hinten nichts Bekanntes mehr steht.
for (let runde = 0; runde < 4; runde++) {
const disc = DISC_MUSTER.exec(rest)
if (disc !== null && discNr === null && Number(disc[1]) > 0) {
discNr = Number(disc[1])
rest = rest.slice(0, disc.index)
continue
}
const staffelTreffer = STAFFEL_MUSTER.exec(rest)
if (staffelTreffer !== null && staffel === null && Number(staffelTreffer[1]) > 0) {
staffel = Number(staffelTreffer[1])
rest = rest.slice(0, staffelTreffer.index)
continue
}
break
}
return { titel: rest.replace(new RegExp(`${TRENNER}+$`), '').trim(), discNr, staffel }
}
/** \\.\G: → G:\ — unter Windows hängt Windows die Disc selbst ein. */
export function discWurzel(geraetepfad: string): string {
const buchstabe = geraetepfad.replace(/:$/, '').split('\\').pop()!.replace(/:$/, '')
const wurzel = buchstabe + ':\\'
return existsSync(wurzel) ? wurzel : ''
}
export interface DiscTitel {
/** Der Titel, wie die Disc ihn nennt — MIT Zusätzen, ungekürzt. */
titel: string
/** 'bdmt' (Studio-Klartext) | 'label' (normalisiert) | '' (nichts). */
quelle: 'bdmt' | 'label' | ''
/** Nummer der Disc in der Ausgabe (aus Titel/Label) — null wenn keine. */
discNr: number | null
/** Staffel-Nummer aus Titel/Label — null wenn keine. */
staffel: number | null
/** Folgen im bdmt-Inhaltsverzeichnis (0, wenn es keines gibt). */
folgen: number
/** Die Disc bezeugt selbst, dass sie Folgen trägt ab zwei ist es
* keine Zufalls-Benennung mehr, sondern eine Serien-Disc. */
istSerie: boolean
/** Weitere Titel-Varianten (bdmt_deu, 5.4.0) — die Suche fragt jede. */
alternativen: string[]
}
/** Mindestens so viele Folgen im Inhaltsverzeichnis, damit Serie" als
* bewiesen gilt eine einzelne könnte auch eine Zugabe sein. */
export const FOLGEN_FUER_SERIE = 2
function mitZusatz(titel: string, quelle: 'bdmt' | 'label', folgen: number, alternativen: string[] = []): DiscTitel {
const zusatz = discZusatzAbtrennen(titel)
return {
titel,
quelle,
discNr: zusatz.discNr,
staffel: zusatz.staffel,
folgen,
istSerie: folgen >= FOLGEN_FUER_SERIE,
alternativen,
}
}
/** Alles, was die Disc über sich selbst sagt: BDMT zuerst, sonst Label.
* Der BDMT-Titel wird NICHT normalisiert ('2.22' würde zu '2 22'). */
export function discTitel(geraetepfad: string): DiscTitel {
const wurzel = discWurzel(geraetepfad)
let folgen = 0
if (wurzel.length > 0) {
const bdmt = inhaltAusBdmt(wurzel)
folgen = folgenZahl(bdmt.eintraege)
if (bdmt.titel.length > 0) return mitZusatz(bdmt.titel, 'bdmt', folgen, bdmt.alternativen)
}
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad)
if (label.length > 0) return mitZusatz(normalizeDiscLabel(label), 'label', folgen)
return { titel: '', quelle: '', discNr: null, staffel: null, folgen, istSerie: folgen >= FOLGEN_FUER_SERIE, alternativen: [] }
}
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der
* Duplikat-Erkennung in der Bibliothek. */
export function discFingerprint(geraetepfad: string, groesseBytes: number): string {
const label = isoVolumeLabel(geraetepfad) || udfVolumeLabel(geraetepfad) || 'unbekannt'
return `${label}|${groesseBytes}`
}
/** Höchstens so viele Suchvarianten — jede kostet API-Anfragen. */
export const HOECHSTENS_KANDIDATEN = 7
/** Suchvarianten für die Metadaten-APIs (progressive Degradation,
* ARM-Lehre) von SPEZIFISCH nach vage:
*
* voller Titel ohne Disc-/Staffel-Zusatz ohne Doppelpunkt
* ohne Klammern hintere Worte strippen Doppelpunkt-Vorspann
*
* Die Reihenfolge ist teuer gelernt (01.09.2026, echte Disc): Der
* Doppelpunkt-Vorspann stand auf Platz 2 und wirft am meisten weg
* ('Spartacus: Gods Of The Arena' 'Spartacus'). TMDb hat einen Film,
* der wörtlich so heißt die richtige Serie wurde nie gefragt. Er
* steht jetzt GANZ hinten; die Sicherheit deckelt ihn zusätzlich
* (MINDEST_ABDECKUNG in zuordnung.ts). */
export function titelKandidaten(titel: string): string[] {
const kandidaten: string[] = []
const nachlaufend = new RegExp(`${TRENNER}+$`)
const merke = (t: string): void => {
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
}
merke(titel)
// Ohne den Verpackungs-Zusatz ist der Titel erst suchbar — und das ist
// der Kandidat, der die Serie findet, nicht der gestutzte Vorspann.
const ohneZusatz = discZusatzAbtrennen(titel).titel
merke(ohneZusatz)
// Volume-Labels können keinen Doppelpunkt tragen, TMDb schreibt ihn:
// beide Schreibweisen fragen.
if (ohneZusatz.includes(':')) merke(ohneZusatz.replaceAll(':', ''))
merke(ohneZusatz.replace(/\([^)]*\)/g, ''))
let worte = ohneZusatz.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < HOECHSTENS_KANDIDATEN - 1) {
worte = worte.slice(0, -1)
merke(worte.join(' '))
}
if (ohneZusatz.includes(':')) merke(ohneZusatz.split(':', 1)[0])
return kandidaten
}
@@ -0,0 +1,123 @@
// MusicBrainz erkennt eine CD an der LAGE ihrer Spuren (KONZEPT § 6.3,
// Vorlage musicbrainz_cd.py — Rechnung laut
// musicbrainz.org/doc/Disc_ID_Calculation, am 29.08.2026 gegen die echte
// AC/DC-„Back in Black"-Antwort bewiesen).
//
// DIE Falle: Die Versätze zählen MIT den 150 Frames Vorlauf (MSF), das
// Lesen zählt OHNE (LBA). Wer das verwechselt, bekommt eine Kennung, die
// MusicBrainz nicht kennt — ohne Fehlermeldung, die Antwort ist dann
// einfach „unbekannte Disc".
import { createHash } from 'node:crypto'
import { VORLAUF_FRAMES, type Spur } from '../rip/audiocd'
const DIENST = 'https://musicbrainz.org/ws/2/discid/'
// MusicBrainz verlangt eine sprechende Kennung mit Kontakt — ohne sie
// wird gedrosselt oder geblockt (Rate-Limiting-Richtlinie).
const KOPF = { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' }
/** Immer 99 Spur-Plätze im Hash-Text, auch bei 12 Spuren. */
export const PLAETZE = 99
/** Die MusicBrainz-Kennung (pur). Versätze EINSCHLIESSLICH Vorlauf. */
export function discId(spurVersaetze: readonly number[], leadOut: number, erste = 1, letzte?: number): string {
if (spurVersaetze.length === 0) return ''
const letzteSpur = letzte ?? erste + spurVersaetze.length - 1
const hex2 = (n: number): string => n.toString(16).toUpperCase().padStart(2, '0')
const hex8 = (n: number): string => n.toString(16).toUpperCase().padStart(8, '0')
const teile = [hex2(erste), hex2(letzteSpur), hex8(leadOut)]
for (let platz = 0; platz < PLAETZE; platz++) {
teile.push(hex8(platz < spurVersaetze.length ? spurVersaetze[platz] : 0))
}
const roh = createHash('sha1').update(teile.join(''), 'ascii').digest('base64')
// MusicBrainz' eigenes Alphabet: + / = werden zu . _ -
return roh.replaceAll('+', '.').replaceAll('/', '_').replaceAll('=', '-')
}
/** Kennung direkt aus tocZerlegen() — rechnet den Vorlauf wieder HINZU. */
export function kennungAusSpuren(spuren: readonly Spur[]): string {
if (spuren.length === 0) return ''
const geordnet = [...spuren].sort((a, b) => a.nr - b.nr)
const versaetze = geordnet.map((s) => s.start + VORLAUF_FRAMES)
const letzte = geordnet[geordnet.length - 1]
const leadOut = letzte.start + letzte.sektoren + VORLAUF_FRAMES
return discId(versaetze, leadOut, geordnet[0].nr, letzte.nr)
}
export interface MusicBrainzAntwort {
releases?: Array<{
title?: string
date?: string
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
media?: Array<{
tracks?: Array<{
position?: number | string
title?: string
'artist-credit'?: Array<{ name?: string; joinphrase?: string }>
}>
}>
}>
}
/** Nachschlagen null bei unbekannter Disc, keinem Netz oder Fremdem:
* alles dasselbe Ergebnis (keine Tags), ein Rip scheitert daran nie. */
export async function abfragen(
kennung: string,
holen: typeof fetch = fetch,
): Promise<MusicBrainzAntwort | null> {
if (kennung.length === 0) return null
try {
const antwort = await holen(
`${DIENST}${encodeURIComponent(kennung).replaceAll('%2D', '-')}?fmt=json&inc=recordings+artist-credits`,
{ headers: KOPF, signal: AbortSignal.timeout(15_000) },
)
if (antwort.status >= 300) return null
return (await antwort.json()) as MusicBrainzAntwort
} catch {
return null
}
}
function kuenstler(eintrag: Array<{ name?: string; joinphrase?: string }> | undefined): string {
// „Artist Credit" ist eine Liste mit Bindewörtern — „Simon" + „ & " +
// „Garfunkel". Nur den ersten Namen zu nehmen verlöre die Hälfte.
return (eintrag ?? [])
.map((s) => (s.name ?? '') + (s.joinphrase ?? ''))
.join('')
.trim()
}
/** Antwort {spurNr: Tags}. Die ERSTE Veröffentlichung mit dieser Disc
* bei mehreren ist jede gleich richtig, eine Auswahl wäre geraten. */
export function tagsJeSpur(antwort: MusicBrainzAntwort | null): Map<number, Record<string, string>> {
const tags = new Map<number, Record<string, string>>()
const release = antwort?.releases?.[0]
if (release === undefined) return tags
const album = release.title ?? ''
const jahr = (release.date ?? '').slice(0, 4)
const albumKuenstler = kuenstler(release['artist-credit'])
for (const medium of release.media ?? []) {
for (const spur of medium.tracks ?? []) {
const nr = Number(spur.position ?? 0)
if (!Number.isInteger(nr) || nr <= 0) continue
tags.set(nr, {
TITLE: spur.title ?? '',
ARTIST: kuenstler(spur['artist-credit']) || albumKuenstler,
ALBUM: album,
ALBUMARTIST: albumKuenstler,
DATE: jahr,
TRACKNUMBER: String(nr),
})
}
}
return tags
}
/** „Künstler - Album" für den Zielordner — '' wenn nichts bekannt. */
export function albumOrdner(antwort: MusicBrainzAntwort | null, saubern: (t: string) => string): string {
const tags = tagsJeSpur(antwort)
if (tags.size === 0) return ''
const erste = tags.get(Math.min(...tags.keys()))!
const wer = erste['ALBUMARTIST'] || erste['ARTIST'] || ''
const album = erste['ALBUM'] || ''
const roh = wer.length > 0 && album.length > 0 ? `${wer} - ${album}` : album || wer
return saubern(roh)
}
+59
View File
@@ -0,0 +1,59 @@
// OMDb-Client — eigene Datenbasis, findet oft, was TMDb nicht exakt
// trifft. Nur Englisch; der deutsche Datensatz wird danach über
// TMDb /find (IMDb-ID) nachgeladen (zuordnung.ts). Ohne Key überspringt
// er sich selbst — kein Fehler, keine Behauptung.
export interface OmdbTreffer {
type: 'movie' | 'tv'
id: string
title: string
year: number | null
overview: string
poster_path: string
runtime: number
genres: string[]
source: 'omdb'
}
export class OmdbClient {
constructor(
private readonly apiKey: string,
private readonly holen: typeof fetch = fetch,
) {}
get verfuegbar(): boolean {
return this.apiKey.length > 0
}
async lookup(titel: string): Promise<OmdbTreffer | null> {
if (!this.verfuegbar) return null
try {
const url = new URL('https://www.omdbapi.com/')
url.searchParams.set('apikey', this.apiKey)
url.searchParams.set('t', titel)
url.searchParams.set('r', 'json')
const antwort = await this.holen(url.toString(), { signal: AbortSignal.timeout(15_000) })
if (antwort.status >= 300) return null
const daten = (await antwort.json()) as Record<string, string>
if (daten['Response'] !== 'True') return null
const jahr = /(\d{4})/.exec(daten['Year'] ?? '')
const laufzeit = /(\d+)/.exec(daten['Runtime'] ?? '')
return {
type: daten['Type'] === 'series' ? 'tv' : 'movie',
id: daten['imdbID'] ?? '',
title: daten['Title'] ?? titel,
year: jahr === null ? null : Number(jahr[1]),
overview: daten['Plot'] ?? '',
poster_path: daten['Poster'] !== 'N/A' ? (daten['Poster'] ?? '') : '',
runtime: laufzeit === null ? 0 : Number(laufzeit[1]),
genres: (daten['Genre'] ?? '')
.split(',')
.map((g) => g.trim())
.filter((g) => g.length > 0),
source: 'omdb',
}
} catch {
return null
}
}
}
+224
View File
@@ -0,0 +1,224 @@
// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann
// ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…',
// Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur
// eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE.
//
// 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per
// `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen
// sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D):
// movie/149?append_to_response=images,credits,release_dates
// &include_image_language=de,en,null
// → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124,
// vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null,
// file_path:'/….png'|'.svg', width, height, vote_average}],
// credits.cast[{name, character, profile_path, order}],
// release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}]
// tv/46296?append_to_response=images,credits,content_ratings
// → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}],
// seasons[{season_number, name, episode_count}], number_of_episodes
// configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632
// Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei
// leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der
// echte Inhalt ist die Extras-Staffel von „Spartacus" (46296).
export interface FilmTreffer {
id: number
title?: string
name?: string
poster_path?: string | null
backdrop_path?: string | null
release_date?: string
first_air_date?: string
overview?: string
popularity?: number
}
export interface TmdbLogo {
iso_639_1: string | null
file_path: string
width?: number
height?: number
vote_average?: number
}
export interface TmdbCast {
name: string
character?: string
profile_path?: string | null
order?: number
}
export interface FilmDetails {
id: number
title?: string
name?: string
release_date?: string
first_air_date?: string
overview?: string
poster_path?: string | null
backdrop_path?: string | null
runtime?: number
genres?: Array<{ name: string }>
tagline?: string
vote_average?: number
vote_count?: number
popularity?: number
episode_run_time?: number[]
number_of_episodes?: number
number_of_seasons?: number
seasons?: Array<{ season_number: number; name?: string; episode_count?: number }>
images?: { logos?: TmdbLogo[] }
credits?: { cast?: TmdbCast[] }
release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> }
content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> }
}
export function istV4Token(key: string): boolean {
return key.startsWith('eyJ')
}
const BASIS = 'https://api.themoviedb.org/3'
/** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das
* bestbewertete. '' wenn keins. */
export function logoAus(logos: readonly TmdbLogo[] | undefined): string {
if (logos === undefined || logos.length === 0) return ''
const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3)
const sortiert = [...logos]
.filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0)
.sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0))
return sortiert[0]?.file_path ?? ''
}
/** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor
* Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */
export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string {
if (typ === 'tv') {
const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE')
return (de?.rating ?? '').trim()
}
const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE')
const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0)
if (eintraege.length === 0) return ''
const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4)
return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim()
}
/** Die ersten Darsteller mit Bild (pur). */
export function besetzungAus(
cast: readonly TmdbCast[] | undefined,
hoechstens = 6,
): Array<{ name: string; rolle: string; bildPfad: string }> {
if (cast === undefined) return []
return [...cast]
.filter((c) => typeof c.name === 'string' && c.name.length > 0)
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.slice(0, hoechstens)
.map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' }))
}
export class TmdbClient {
constructor(
private readonly apiKey: string,
private readonly holen: typeof fetch = fetch,
) {}
get verfuegbar(): boolean {
return this.apiKey.length > 0
}
private async anfrage(weg: string, params: Record<string, string>): Promise<unknown> {
if (!this.verfuegbar) return null
const url = new URL(`${BASIS}/${weg}`)
for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert)
const kopf: Record<string, string> = { Accept: 'application/json' }
if (istV4Token(this.apiKey)) {
kopf['Authorization'] = `Bearer ${this.apiKey}`
} else {
url.searchParams.set('api_key', this.apiKey)
}
try {
const antwort = await this.holen(url.toString(), {
headers: kopf,
signal: AbortSignal.timeout(15_000),
})
if (antwort.status >= 300) return null
return await antwort.json()
} catch {
// R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der
// Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung.
return null
}
}
async searchMovie(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/movie', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async searchTv(titel: string): Promise<FilmTreffer[] | null> {
const daten = (await this.anfrage('search/tv', {
query: titel,
language: 'de-DE',
include_adult: 'false',
})) as { results?: FilmTreffer[] } | null
return daten?.results ?? null
}
async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,release_dates',
include_image_language: 'de,en,null',
})) as FilmDetails | null
}
async tvDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`tv/${id}`, {
language: 'de-DE',
append_to_response: 'images,credits,content_ratings',
include_image_language: 'de,en,null',
})) as FilmDetails | null
}
/** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */
async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> {
return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as {
episodes?: Array<{ episode_number: number; runtime?: number }>
} | null
}
/** IMDb-ID deutscher Datensatz (für OMDb-Treffer, die nur Englisch
* können Commander-Wunsch 24.07.). */
async findByImdb(imdbId: string): Promise<Record<string, unknown> | null> {
const daten = (await this.anfrage(`find/${imdbId}`, {
external_source: 'imdb_id',
language: 'de-DE',
})) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null
const film = daten?.movie_results?.[0]
if (film !== undefined) {
return {
title: film.title,
overview: film.overview,
poster_path: film.poster_path,
year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null,
type: 'movie',
}
}
const serie = daten?.tv_results?.[0]
if (serie !== undefined) {
return {
title: serie.name,
overview: serie.overview,
poster_path: serie.poster_path,
year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null,
type: 'tv',
}
}
return null
}
}
@@ -0,0 +1,484 @@
// Treffer + Sicherheitsgrad (KONZEPT § 5, Schritt 3) — die Kaskade aus
// prescan.py mit ihren gemessenen Begründungen:
//
// 0,95 wörtlicher TMDb-Filmtreffer auf eine Suchvariante
// 0,90 wörtlicher TMDb-Serientreffer
// 0,80 SPEZIFISCHER Treffer: voller Disc-Titel, höchstens 3 Treffer
// (der Evangelion-Fund: 'Evangelion 2.22' → 1 Treffer = der
// richtige Film; 'Evangelion' → 20 beliebige. Die Spezifität der
// Anfrage sagt mehr als jede Titel-Ähnlichkeit.) — auch OMDb
// 0,60 Vorschlag (bester Rohtreffer) — das UI fragt nach
// 0,30 unbekannt
//
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
//
// 5.4.0 — Erkennung v2, drei Ergänzungen, alle an echten Daten bezahlt:
// * STUBS: TMDb hat „Spartacus: Gods of the Arena" dreimal als leeren
// Serien-Eintrag (kein Backdrop, keine Beschreibung, 1 Folge,
// Popularität 1). Ein wörtlicher Treffer darauf sah 5.1.1 als 90 %
// sicher an — es war ein Eintrag ohne Inhalt. Leere Einträge zählen
// nicht mehr als Treffer.
// * LAUFZEIT: Nach dem Info-Lauf wird die gemessene Länge des Hauptinhalts
// gegen TMDb gehalten (nachpruefen). Passt sie, wird aus „wahrscheinlich"
// „bestätigt"; passt sie gar nicht (95 vs. 158 min), fällt der Treffer
// auf einen Vorschlag zurück.
// * VARIANTEN: Die Disc trägt oft zwei Titel (bdmt_eng UND bdmt_deu) —
// jede Variante wird gefragt, und die Abdeckung wird an IHRER Vorlage
// gemessen, nicht an der englischen.
import { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails, type FilmTreffer, type TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
import type { Darsteller } from '../../gemeinsam/nachrichten'
export const WENIGE_TREFFER = 3
export interface MetaTreffer {
typ: 'movie' | 'tv' | 'unknown'
id: number | string | null
titel: string
jahr: number | null
beschreibung: string
posterPfad: string
/** TMDb-Hintergrundbild (Querformat) — fürs Kachel-Panorama; '' wenn keins. */
backdropPfad: string
laufzeitMinuten: number
genres: string[]
quelle: 'tmdb' | 'omdb' | ''
// ── Kino-Banner v2 (5.4.0) ──
logoPfad: string
tagline: string
bewertung: number
fsk: string
besetzung: Darsteller[]
/** Folgen laut TMDb (Serie) — 0 wenn unbekannt/Film. */
folgenGesamt: number
}
export interface Zuordnung {
titel: string
jahr: number | null
confidence: number
meta: MetaTreffer
/** Was Rippy nach dem Info-Lauf noch dazugelernt hat (nachpruefen). */
hinweise?: string[]
}
function jahrAus(datum: string | undefined): number | null {
if (datum === undefined || datum.length < 4) return null
const jahr = Number(datum.slice(0, 4))
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
export function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
const laufzeit =
typ === 'movie'
? (details.runtime ?? 0)
: ((details.episode_run_time ?? []).find((m) => typeof m === 'number' && m > 0) ?? 0)
return {
typ,
id,
titel: (typ === 'movie' ? details.title : details.name) ?? '',
jahr: jahrAus(typ === 'movie' ? details.release_date : details.first_air_date),
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: laufzeit,
genres: (details.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
logoPfad: logoAus(details.images?.logos),
tagline: (details.tagline ?? '').trim(),
bewertung: typeof details.vote_average === 'number' ? Math.round(details.vote_average * 10) / 10 : 0,
fsk: fskAus(details, typ),
besetzung: besetzungAus(details.credits?.cast),
folgenGesamt: typ === 'tv' ? (details.number_of_episodes ?? 0) : 0,
}
}
function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
return {
typ: treffer.type,
id: treffer.id,
titel: treffer.title,
jahr: treffer.year,
beschreibung: treffer.overview,
posterPfad: treffer.poster_path,
// OMDb kennt keine Querformat-Bilder — die Kachel fällt aufs Poster zurück.
backdropPfad: '',
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
}
export const LEERER_TREFFER: MetaTreffer = {
typ: 'unknown',
id: null,
titel: '',
jahr: null,
beschreibung: '',
posterPfad: '',
backdropPfad: '',
laufzeitMinuten: 0,
genres: [],
quelle: '',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: { ...LEERER_TREFFER, titel },
})
/** Ein Eintrag ohne Inhalt ist kein Treffer (pur). Gemessen an den drei
* Spartacus: Gods of the Arena"-Stubs: kein Backdrop, keine Beschreibung,
* keine Bewertung TMDb kennt dort nur den Namen. */
export function istStub(details: FilmDetails): boolean {
const beschreibung = (details.overview ?? '').trim().length > 0
const bild = (details.backdrop_path ?? '') !== '' && details.backdrop_path !== null
const bewertet = (details.vote_count ?? 0) > 0
return !beschreibung && !bild && !bewertet
}
/** Titel Wortmenge, Satzzeichen fallen weg. Grundlage von Überlappung,
* Abdeckung und Titelvergleich alle drei sollen dasselbe Wort" meinen. */
function worte(t: string): Set<string> {
return new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
}
/** Wort-Überlappung zweier Titel (Jaccard, 01) das Gate gegen
* selbstbewussten Unsinn: Bei der Live-Messung am 30.08.2026 machte OMDb
* aus dem Label Bd Evg D2" den Film „BD Scream 5" (gemeinsam nur BD"). */
export function wortUeberlappung(a: string, b: string): number {
const wa = worte(a)
const wb = worte(b)
if (wa.size === 0 || wb.size === 0) return 0
let gemeinsam = 0
for (const w of wa) if (wb.has(w)) gemeinsam += 1
return gemeinsam / (wa.size + wb.size - gemeinsam)
}
/** Gleicher Titel, wenn man Satzzeichen und Groß-/Kleinschreibung
* weglässt. Ein Volume-Label KANN keinen Doppelpunkt tragen, TMDb
* schreibt ihn: 'Spartacus Gods Of The Arena' gegen 'Spartacus: Gods of
* the Arena' war zeichengenau verglichen kein Treffer der exakt
* richtige Fund fiel an einem Satzzeichen durch (01.09.2026 gemessen). */
export function gleichBedeutend(a: string, b: string): boolean {
// Wortmenge wäre hier falsch: Sie machte 'New York, New York' gleich
// 'New York'. Verglichen wird die Wort-FOLGE, nur ohne Satzzeichen.
const nackt = (t: string): string =>
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0)
.join(' ')
const na = nackt(a)
return na.length > 0 && na === nackt(b)
}
/** Wie viel vom Disc-Titel steckt noch in dieser Suchvariante (01)? */
export function abdeckung(discTitel: string, kandidat: string): number {
const ganz = worte(discTitel)
if (ganz.size === 0) return 0
let drin = 0
for (const w of worte(kandidat)) if (ganz.has(w)) drin += 1
return drin / ganz.size
}
/** Sicherheit eines Treffers, den das UI als Vorschlag zeigt und nachfragt. */
export const VORSCHLAG = 0.6
/** Darunter ist ein Treffer GERATEN, nicht gefunden und wird zum
* Vorschlag herabgestuft, egal wie wörtlich er aussieht.
*
* Der Grund, an der echten Disc gemessen (01.09.2026): Aus
* 'Spartacus: Gods Of The Arena - Disc 2' blieb nach dem Stutzen die
* Frage 'Spartacus' übrig sechs von sieben Wörtern weggeworfen. TMDb
* antwortete mit einem wörtlichen Filmtreffer, und Rippy meldete mit
* 95 % Sicherheit den falschen Film. Die alte Kaskade maß nur die ART
* des Treffers, nie die QUALITÄT der Frage. */
export const MINDEST_ABDECKUNG = 0.5
export interface DiscHinweis {
/** Die Disc bezeugt über ihr EIGENES Inhaltsverzeichnis, dass sie Folgen
* trägt (discmerkmale.discTitel().istSerie). Dann sind die Film-Zweige
* aus: Was bewiesen ist, muss nicht mehr geraten werden. */
istSerie?: boolean
/** Weitere Titel derselben Disc (bdmt_deu neben bdmt_eng, 5.4.0). */
alternativen?: readonly string[]
}
interface Kandidat {
text: string
/** Die Vorlage, an der die Abdeckung gemessen wird (Titel ohne Zusatz). */
massstab: string
}
/** Suchvarianten aller Titel-Varianten: erst die vollen Titel, dann die
* gestutzten je Variante an ihrer eigenen Vorlage gemessen (pur). */
export function kandidatenAus(titel: string, alternativen: readonly string[] = []): Kandidat[] {
const quellen = [titel, ...alternativen].map((t) => t.trim()).filter((t) => t.length > 0)
const gesehen = new Set<string>()
const voll: Kandidat[] = []
const rest: Kandidat[] = []
for (const quelle of quellen) {
const massstab = discZusatzAbtrennen(quelle).titel || quelle
const varianten = titelKandidaten(quelle)
if (varianten.length === 0) varianten.push(quelle)
varianten.forEach((text, i) => {
const schluessel = text.toLowerCase()
if (gesehen.has(schluessel)) return
gesehen.add(schluessel)
;(i === 0 ? voll : rest).push({ text, massstab })
})
}
return [...voll, ...rest]
}
/** Die Kaskade Clients injiziert, damit sie ohne Netz prüfbar ist.
*
* Reihenfolge JE KANDIDAT: wörtlich Film wörtlich Serie spezifisch
* Film spezifisch Serie. Erst dann der nächste, ärmere Kandidat.
*
* Drei Sicherungen, alle an echten Discs bezahlt:
*
* * `hinweis.istSerie` schaltet die Film-Zweige ganz ab.
* * Jede Sicherheit wird an der ABDECKUNG gedeckelt (MINDEST_ABDECKUNG):
* Wer den halben Titel wegwerfen musste, um zu treffen, hat geraten
* das ist ein Vorschlag, kein Fund. Der Fall vom 01.09.2026:
* 'Spartacus: Gods Of The Arena - Disc 2' wurde über die Restfrage
* 'Spartacus' zum Film von 1960, mit 95 % Sicherheit gemeldet.
* * Die spezifischen Treffer (0,80) verlangen einen VOLLSTÄNDIGEN
* Kandidaten er muss noch jedes Wort des Titels tragen (Zusätze wie
* '- Disc 2' zählen nicht mit). Vorher hing das an Kandidat Nummer
* 0"; sobald der Zusatz abgetrennt wurde, war der richtige Kandidat
* Nummer 1 und fiel durch dieses Raster.
* * (5.4.0) Ein Eintrag ohne Inhalt (istStub) ist kein Treffer der
* nächste wörtliche Treffer bekommt die Chance.
*/
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
hinweis: DiscHinweis = {},
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const nurSerie = hinweis.istSerie === true
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
let ersteFilme: FilmTreffer[] = []
let ersteSerien: FilmTreffer[] = []
for (const kandidat of kandidaten) {
const deckung = abdeckung(kandidat.massstab, kandidat.text)
const vollstaendig = deckung >= 1
/** Ein Treffer auf eine stark gestutzte Frage bleibt ein Vorschlag. */
const deckel = (basis: number): number => (deckung >= MINDEST_ABDECKUNG ? basis : Math.min(basis, VORSCHLAG))
const filme = nurSerie ? [] : ((await tmdb.searchMovie(kandidat.text)) ?? [])
if (filme.length > 0 && ersteFilme.length === 0) ersteFilme = filme
// Wörtlicher Filmtreffer (0,95)
for (const film of filme) {
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat.text)) ?? []
if (serien.length > 0 && ersteSerien.length === 0) ersteSerien = serien
// Wörtlicher Serientreffer (0,90)
for (const serie of serien) {
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
}
}
}
// Spezifische Treffer (0,80): Wer auf eine VOLLSTÄNDIGE Anfrage eine
// Handvoll bekommt, hat gefragt wie jemand, der weiß, was er sucht
// (der Evangelion-Fund). Stubs zählen dabei nicht mit.
if (vollstaendig) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
for (const film of filme) {
const details = await tmdb.movieDetails(film.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
if (serien.length > 0 && serien.length <= WENIGE_TREFFER) {
for (const serie of serien) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
}
}
}
}
}
// 4. OMDb (0,80) — findet oft, was TMDb nicht exakt trifft; deutscher
// Datensatz wird über TMDb /find (IMDb-ID) nachgeladen.
if (omdb.verfuegbar) {
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat.text)
if (treffer !== null) {
// Eine Serien-Disc nimmt keinen Film entgegen — sie hat bewiesen,
// was sie ist.
if (nurSerie && treffer.type !== 'tv') continue
// Ähnlichkeits-Gate: OMDbs t=-Suche antwortet auch auf kryptische
// Labels mit irgendeinem Film — gemessen „Bd Evg D2" → „BD Scream
// 5". Ohne echte Wort-Überlappung ist das kein Treffer.
if (wortUeberlappung(kandidat.text, treffer.title) < 0.5) continue
const meta = ausOmdb(treffer)
if (typeof meta.id === 'string' && meta.id.startsWith('tt') && tmdb.verfuegbar) {
const deutsch = await tmdb.findByImdb(meta.id)
if (deutsch !== null) {
if (typeof deutsch['title'] === 'string' && deutsch['title'].length > 0) meta.titel = deutsch['title']
if (typeof deutsch['overview'] === 'string' && deutsch['overview'].length > 0) {
meta.beschreibung = deutsch['overview']
}
if (typeof deutsch['poster_path'] === 'string' && deutsch['poster_path'].length > 0) {
meta.posterPfad = deutsch['poster_path']
}
if (typeof deutsch['year'] === 'number') meta.jahr = deutsch['year']
if (deutsch['type'] === 'tv') meta.typ = 'tv'
}
}
// Auch OMDb wird gedeckelt: Ein Treffer auf eine ausgedünnte Frage
// ist ein Vorschlag, egal von welchem Dienst er kommt.
const sicher = abdeckung(kandidat.massstab, kandidat.text) >= MINDEST_ABDECKUNG ? 0.8 : VORSCHLAG
return { titel: meta.titel, jahr: meta.jahr, confidence: sicher, meta }
}
}
}
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
// Einträge MIT Inhalt zuerst; ist alles leer, bleibt der erste Treffer
// trotzdem ein Vorschlag — besser als gar keiner (es wird ja gefragt).
let notnagel: Zuordnung | null = null
for (const film of ersteFilme.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.movieDetails(film.id)
if (details === null) continue
const meta = ausDetails('movie', film.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
for (const serie of ersteSerien.slice(0, WENIGE_TREFFER)) {
const details = await tmdb.tvDetails(serie.id)
if (details === null) continue
const meta = ausDetails('tv', serie.id, details)
const vorschlag = { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
if (!istStub(details)) return vorschlag
notnagel ??= vorschlag
}
if (notnagel !== null) return notnagel
return UNBEKANNT(titel)
}
/** § 5 Schritt 4: „Sicher genug?" — die Schwelle ist einstellbar. */
export function istSicherGenug(zuordnung: Zuordnung, schwelle = 0.9): boolean {
return zuordnung.confidence >= schwelle
}
/** Laufzeit passt, wenn die gemessene Länge des Hauptinhalts höchstens so
* weit von TMDb abweicht (Kino- gegen Heimvideo-Fassung, Abspann). */
export const LAUFZEIT_PASST = 0.06
/** Darüber passt sie NICHT — der Treffer wird zum Vorschlag (95 vs. 158 min). */
export const LAUFZEIT_FALSCH = 0.25
/** Was der Info-Lauf über die Disc gemessen hat (rip/titelwahl.ts). */
export interface Messung {
/** Länge des Hauptinhalts in Sekunden (Film) — 0 wenn keiner erkennbar. */
hauptS: number
/** Längen der Folgen-Kandidaten in Sekunden (Serie). */
folgenS: readonly number[]
/** Was die Titel-Struktur sagt: 'film' | 'serie' | 'unklar'. */
struktur: 'film' | 'serie' | 'unklar'
}
/**
* Nachprüfen NACH dem Info-Lauf (pur, 5.4.0): Erkannt wird beim EINLEGEN,
* also vor jeder Messung. Jetzt liegt die stärkste Evidenz vor, die zu
* haben ist die Laufzeit. Aus wahrscheinlich" wird „bestätigt", oder
* Rippy merkt selbst, dass es danebenlag. Eine Wahl des Nutzers
* (confidence 1) wird nie herabgestuft, nur kommentiert.
*/
export function nachpruefen(zuordnung: Zuordnung, messung: Messung): Zuordnung {
const hinweise: string[] = []
let confidence = zuordnung.confidence
const meta = zuordnung.meta
const vomNutzer = zuordnung.confidence >= 1
const soll = meta.laufzeitMinuten * 60
if (meta.typ === 'movie' && soll > 0 && messung.hauptS > 0) {
const abweichung = Math.abs(messung.hauptS - soll) / soll
const ist = Math.round(messung.hauptS / 60)
if (abweichung <= LAUFZEIT_PASST) {
hinweise.push(`Laufzeit bestätigt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.95)
} else if (abweichung >= LAUFZEIT_FALSCH) {
hinweise.push(
`Laufzeit passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb — bitte über „Ändern …" nachsehen.`,
)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else {
hinweise.push(`Laufzeit ähnlich: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb (andere Fassung?).`)
}
} else if (meta.typ === 'tv' && soll > 0 && messung.folgenS.length > 0) {
const mittel = messung.folgenS.reduce((s, f) => s + f, 0) / messung.folgenS.length
const abweichung = Math.abs(mittel - soll) / soll
const ist = Math.round(mittel / 60)
if (abweichung <= 0.2) {
hinweise.push(`Folgenlänge passt: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.max(confidence, 0.8)
} else if (abweichung >= 0.5) {
hinweise.push(`Folgenlänge passt NICHT: ${ist} min auf der Disc, ${meta.laufzeitMinuten} min bei TMDb.`)
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
}
if (messung.struktur === 'serie' && meta.typ === 'movie') {
hinweise.push('Die Titel-Struktur sieht nach einer Serienstaffel aus (mehrere gleich lange Titel), TMDb-Treffer ist ein Film.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
} else if (messung.struktur === 'film' && meta.typ === 'tv') {
hinweise.push('Die Titel-Struktur sieht nach einem Film aus (ein langer Titel), TMDb-Treffer ist eine Serie.')
if (!vomNutzer) confidence = Math.min(confidence, VORSCHLAG)
}
return { ...zuordnung, confidence: Math.round(confidence * 100) / 100, hinweise }
}
+144
View File
@@ -0,0 +1,144 @@
// Audio-CDs lesen — die Rechnungen, PUR (KONZEPT § 6.3, Vorlage
// audio_cd.py, dort am echten Laufwerk bewiesen — rc8). Eine Audio-CD hat
// kein Dateisystem: `Track01.cda` sind 44-Byte-Platzhalter, die Musik
// liegt roh in 2352-Byte-Sektoren.
//
// Die zwei dokumentierten Fallen (learn.microsoft.com, RAW_READ_INFO):
// * DiskOffset zählt in 2048er-Einheiten, obwohl ein Audio-Sektor 2352
// Bytes hat — `sektor * 2048` ist richtig und sieht falsch aus.
// * Der Ausgabepuffer muss SectorCount * 2352 groß sein.
/** Ein Audio-Sektor: 588 Sample-Paare à 4 Byte. */
export const SEKTOR_BYTES = 2352
/** RAW_READ_INFO.DiskOffset zählt in DIESEN Einheiten — nicht in 2352. */
export const OFFSET_EINHEIT = 2048
export const FRAMES_JE_SEKUNDE = 75
/** Die ersten 2 Sekunden sind Vorlauf — MSF-Adressen zählen sie MIT. */
export const VORLAUF_FRAMES = 150
/** 27 * 2352 = 63504 Bytes — knapp unter der 64-KB-Treibergrenze. */
export const BLOCK_SEKTOREN = 27
/** TRACK_MODE_TYPE.CDDA aus ntddcdrm.h. */
export const CDDA = 2
export interface Spur {
nr: number
/** Startsektor als LBA (OHNE Vorlauf — fürs Lesen richtig). */
start: number
sektoren: number
audio: boolean
dauer: string
}
/** MSF-Adresse logische Sektornummer (ECMA-130). Wer die 150 vergisst,
* liest jeden Track zwei Sekunden zu früh. */
export function msfZuLba(minute: number, sekunde: number, frame: number): number {
return (minute * 60 + sekunde) * FRAMES_JE_SEKUNDE + frame - VORLAUF_FRAMES
}
export function dauerText(sektoren: number): string {
const gesamt = Math.floor(Math.max(0, sektoren) / FRAMES_JE_SEKUNDE)
return `${Math.floor(gesamt / 60)}:${String(gesamt % 60).padStart(2, '0')}`
}
/** Die Bytes von IOCTL_CDROM_READ_TOC Spurliste. CDROM_TOC (ntddcdrm.h):
* 4 Byte Kopf, dann je Eintrag 8 Byte (Adr/Control, TrackNumber, 0-M-S-F).
* Control-Bit 2 = Daten-Track; 0xAA ist der Lead-Out (nur Endmarke
* ohne ihn fehlt der letzten Spur die Länge). */
export function tocZerlegen(rohdaten: Buffer): Spur[] {
if (rohdaten.length < 12) return []
const eintraege: Array<{ nr: number; lba: number; audio: boolean }> = []
for (let stelle = 4; stelle + 8 <= rohdaten.length; stelle += 8) {
const adrControl = rohdaten[stelle + 1]
const nummer = rohdaten[stelle + 2]
const m = rohdaten[stelle + 5]
const s = rohdaten[stelle + 6]
const f = rohdaten[stelle + 7]
eintraege.push({ nr: nummer, lba: msfZuLba(m, s, f), audio: (adrControl & 0x04) === 0 })
}
const spuren: Spur[] = []
for (let i = 0; i < eintraege.length; i++) {
const eintrag = eintraege[i]
if (eintrag.nr === 0xaa) continue
if (i + 1 >= eintraege.length) continue // ohne Endmarke keine Länge
const laenge = eintraege[i + 1].lba - eintrag.lba
if (laenge <= 0) continue
spuren.push({
nr: eintrag.nr,
start: eintrag.lba,
sektoren: laenge,
audio: eintrag.audio,
dauer: dauerText(laenge),
})
}
return spuren
}
/** Einen Track in Lese-Blöcke zerlegen am Stück scheitert an der
* 64-KB-Grenze vieler Treiber. */
export function bloecke(start: number, sektoren: number, jeBlock = BLOCK_SEKTOREN): Array<[number, number]> {
if (sektoren <= 0 || jeBlock <= 0) return []
const teile: Array<[number, number]> = []
let gelesen = 0
while (gelesen < sektoren) {
const anzahl = Math.min(jeBlock, sektoren - gelesen)
teile.push([start + gelesen, anzahl])
gelesen += anzahl
}
return teile
}
/** Der 44-Byte-WAV-Kopf CD-Audio IST schon PCM im WAV-Format, es fehlt
* nur der Kopf; die Sektoren wandern unverändert dahinter. */
export function wavKopf(datenBytes: number, rate = 44100, kanaele = 2, bits = 16): Buffer {
const byteRate = (rate * kanaele * bits) / 8
const block = (kanaele * bits) / 8
const kopf = Buffer.alloc(44)
kopf.write('RIFF', 0, 'ascii')
kopf.writeUInt32LE(36 + datenBytes, 4)
kopf.write('WAVE', 8, 'ascii')
kopf.write('fmt ', 12, 'ascii')
kopf.writeUInt32LE(16, 16)
kopf.writeUInt16LE(1, 20)
kopf.writeUInt16LE(kanaele, 22)
kopf.writeUInt32LE(rate, 24)
kopf.writeUInt32LE(byteRate, 28)
kopf.writeUInt16LE(block, 32)
kopf.writeUInt16LE(bits, 34)
kopf.write('data', 36, 'ascii')
kopf.writeUInt32LE(datenBytes, 40)
return kopf
}
/** RAW_READ_INFO für IOCTL_CDROM_RAW_READ: LARGE_INTEGER DiskOffset (in
* 2048ern!), ULONG SectorCount, TRACK_MODE_TYPE (CDDA=2). */
export function rawReadInfo(startSektor: number, anzahl: number): Buffer {
const eingabe = Buffer.alloc(16)
eingabe.writeBigInt64LE(BigInt(startSektor) * BigInt(OFFSET_EINHEIT), 0)
eingabe.writeUInt32LE(anzahl, 8)
eingabe.writeUInt32LE(CDDA, 12)
return eingabe
}
/** Zeichen, die Windows in Namen verbietet. Ersetzt wird durch NICHTS
* AC/DC - Back in Black" hätte sonst einen Unterordner aufgemacht. */
export function sauberName(text: string): string {
const sauber = [...text].filter((z) => !'<>:"/\\|?*'.includes(z)).join('')
return sauber.split(/\s+/).filter(Boolean).join(' ').replace(/[. ]+$/, '')
}
export function dateiname(nr: number, titel = ''): string {
const sauber = sauberName(titel)
const nummer = String(nr).padStart(2, '0')
return sauber.length > 0 ? `${nummer} - ${sauber}` : `Track ${nummer}`
}
/** Der FLAC-Aufruf: -8 beste Kompression, --totally-silent (Rippy meldet
* den Fortschritt selbst), Tags einzeln. */
export function flacBefehl(wav: string, ziel: string, tags: Record<string, string> = {}): string[] {
const befehl = ['-8', '--totally-silent', '-o', ziel]
for (const [name, wert] of Object.entries(tags)) {
if (wert.length > 0) befehl.push('--tag', `${name}=${wert}`)
}
befehl.push(wav)
return befehl
}
+80
View File
@@ -0,0 +1,80 @@
// ISO-Backup für Datendiscs (KONZEPT § 6.4) — 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;
// die Länge kommt aus IOCTL_DISK_GET_LENGTH_INFO (§ 3.2 gemessen: exakte
// Bytes). Kein Fremdwerkzeug nötig.
//
// Async gelesen: Ein 33-GB-Abbild darf den Kern nicht blockieren — die
// Disc-Wache und der Ping laufen währenddessen weiter. Windows verlangt
// auf Geräte-Handles SEKTOR-AUSGERICHTETE Zugriffe — der Blockpuffer ist
// ein Vielfaches von 2048 (1 MiB = 512 Sektoren), gelesen wird strikt
// sequenziell ab 0.
import { open } from 'node:fs/promises'
const BLOCK_BYTES = 1024 * 1024
export type IsoErgebnis =
| { status: 'success'; bytes: number }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; bytes: number }
export async function isoSichern(
geraetepfad: string,
zielDatei: string,
erwarteteBytes: number,
aufFortschritt?: (prozent: number) => void,
abbruch?: AbortSignal,
): Promise<IsoErgebnis> {
if (erwarteteBytes <= 0) {
return { status: 'error', fehler: 'Die Größe der Disc ist unbekannt — ohne sie gibt es kein 1:1-Abbild.', bytes: 0 }
}
let quelle
try {
quelle = await open(geraetepfad, 'r')
} catch (fehler) {
return { status: 'error', fehler: `Gerät nicht lesbar: ${String(fehler)}`, bytes: 0 }
}
let ziel
try {
ziel = await open(zielDatei, 'w')
} catch (fehler) {
await quelle.close()
return { status: 'error', fehler: `Zieldatei nicht anlegbar: ${String(fehler)}`, bytes: 0 }
}
const puffer = Buffer.alloc(BLOCK_BYTES)
let gelesen = 0
let letzterProzent = -1
try {
while (gelesen < erwarteteBytes) {
if (abbruch?.aborted === true) return { status: 'cancelled' }
const rest = erwarteteBytes - gelesen
const anfordern = rest >= BLOCK_BYTES ? BLOCK_BYTES : rest
const { bytesRead } = await quelle.read(puffer, 0, anfordern, gelesen)
if (bytesRead <= 0) break
await ziel.write(puffer, 0, bytesRead)
gelesen += bytesRead
const prozent = Math.floor((gelesen * 100) / erwarteteBytes)
if (prozent !== letzterProzent) {
letzterProzent = prozent
aufFortschritt?.(prozent)
}
}
} catch (fehler) {
return { status: 'error', fehler: `Lesen brach bei ${gelesen} Bytes ab: ${String(fehler)}`, bytes: gelesen }
} finally {
await quelle.close()
await ziel.close()
}
if (gelesen !== erwarteteBytes) {
// R4/„Rückgabewert ist kein Beweis": Das Abbild ist NUR vollständig,
// wenn die Bytes stimmen — alles andere wird gesagt, nicht beschönigt.
return {
status: 'error',
fehler: `Abbild unvollständig: ${gelesen} von ${erwarteteBytes} Bytes gelesen.`,
bytes: gelesen,
}
}
return { status: 'success', bytes: gelesen }
}
+303
View File
@@ -0,0 +1,303 @@
// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2).
// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar);
// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen.
//
// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen):
// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan
// hing bzw. stürzte — Befund 23.07.)
// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt
// Kodierungen; „Das Öffnen" war die Folge von text=True.
// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und
// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2).
// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer
// nur „Failed to open disc", die Ursache steht Zeilen davor.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { mkdirSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import {
KRITISCHE_CODES,
KRITISCHE_TEXTE,
LESEFEHLER_MARKE,
offsetAusMeldung,
parseMsg,
parseStreamInfo,
parseTitelInfo,
progressAusPrgv,
quelle,
sprachenZusammenfassen,
textVon,
type SprachEintrag,
type TitelInfo,
type TitelStreams,
} from './parser'
export function buildInfoArgs(geraetepfad: string): string[] {
return ['-r', '--noscan', 'info', quelle(geraetepfad)]
}
export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] {
return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner]
}
/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend.
* Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler),
* zwölf sind also rund sechs Versuche genug, um einen kurzen Aussetzer
* von einem echten Defekt zu unterscheiden. */
export const HAENGT_AB = 12
/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der
* Aufrufer schweigt dazu abgelöst ist kein Fehler, sondern Absicht. */
export const ABGELOEST = 'titel-lauf-abgeloest'
/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */
export class RipAuswertung {
prozent = -1
letzteMeldung = ''
lesefehler = false
kritischeMeldungen: string[] = []
/** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */
haengtBei = ''
/** Wie oft dieselbe Stelle schon gemeldet wurde. */
haengtWiederholungen = 0
/** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf
* der Disc und kein Fortschrittsproblem Weiterversuchen hilft nicht. */
haengt(): boolean {
return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB
}
/** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */
verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } {
const p = progressAusPrgv(zeile)
if (p >= 0) {
if (p === this.prozent) return {}
// Es geht wieder voran — was vorher klemmte, klemmt nicht mehr.
this.haengtBei = ''
this.haengtWiederholungen = 0
this.prozent = p
return { prozent: p }
}
const meldung = parseMsg(zeile)
if (meldung === null) return {}
this.letzteMeldung = meldung.text
if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true
// Dieselbe Stelle wieder und wieder heißt: physischer Defekt.
const stelle = offsetAusMeldung(meldung.text)
if (stelle.length > 0) {
if (stelle === this.haengtBei) {
this.haengtWiederholungen += 1
} else {
this.haengtBei = stelle
this.haengtWiederholungen = 1
}
}
if (meldung.code in KRITISCHE_CODES) {
this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`)
} else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) {
this.kritischeMeldungen.push(meldung.text)
}
return { meldung }
}
fehlerText(returncode: number | null, dateienDa: boolean): string {
return (
`makemkvcon endete mit Code ${returncode ?? '?'}` +
(this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') +
(this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') +
(dateienDa ? '' : ' — keine MKV-Datei entstanden')
)
}
}
/** Buffer-Ströme in Zeilen zerlegen binär, die letzte halbe Zeile bleibt
* liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */
export class ZeilenLeser {
private rest: Buffer = Buffer.alloc(0)
hinzu(stueck: Buffer): string[] {
this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck])
const zeilen: string[] = []
for (;;) {
const umbruch = this.rest.indexOf(0x0a)
if (umbruch < 0) break
let ende = umbruch
if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1
zeilen.push(textVon(this.rest.subarray(0, ende)))
this.rest = this.rest.subarray(umbruch + 1)
}
return zeilen
}
schluss(): string[] {
if (this.rest.length === 0) return []
const letzte = textVon(this.rest)
this.rest = Buffer.alloc(0)
return letzte.trim().length > 0 ? [letzte] : []
}
}
export interface DiscInfo {
/** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam die
* kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief.
*
* Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 (Das
* Programm konnte keine verwendbaren optischen Laufwerke finden")
* antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile
* stünde im Fenster nur keine Titel gefunden" und niemand wüsste,
* dass das Laufwerk gemeint ist und nicht die Disc. */
ursachen: string[]
titel: TitelInfo[]
streams: Map<number, TitelStreams>
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
}
/** EIN Info-Lauf liefert Titel UND Sprachen er dauert je Disc 20120 s,
* zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */
export function titelInfoLesen(
programm: string,
geraetepfad: string,
timeoutMs = 300_000,
abbruch?: AbortSignal,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
const stuecke: Buffer[] = []
kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck))
kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck))
const zeiger = setTimeout(() => {
kind.kill()
scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`))
}, timeoutMs)
// Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon
// auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026:
// Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über
// die längst gelieferte Titelliste).
const aufgeben = (): void => {
clearTimeout(zeiger)
kind.kill()
scheitert(new Error(ABGELOEST))
}
abbruch?.addEventListener('abort', aufgeben, { once: true })
kind.on('error', (fehler) => {
clearTimeout(zeiger)
abbruch?.removeEventListener('abort', aufgeben)
scheitert(fehler)
})
kind.on('close', () => {
clearTimeout(zeiger)
abbruch?.removeEventListener('abort', aufgeben)
const ausgabe = textVon(Buffer.concat(stuecke))
const titel = parseTitelInfo(ausgabe)
const streams = parseStreamInfo(ausgabe)
// Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile.
const ursachen: string[] = []
for (const zeile of ausgabe.split('\n')) {
const meldung = parseMsg(zeile)
if (meldung === null) continue
if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) {
ursachen.push(KRITISCHE_CODES[meldung.code])
}
}
fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) })
})
})
}
export type RipErgebnis =
| { status: 'success'; dateien: string[]; lesefehler: boolean }
| { status: 'cancelled' }
| { status: 'error'; fehler: string; lesefehler: boolean }
export interface RipLaufOptionen {
programm: string
geraetepfad: string
zielOrdner: string
/** 'all' oder eine Titel-Nummer als Text. */
titel: string
aufFortschritt?: (prozent: number) => void
aufMeldung?: (code: number, text: string) => void
/** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr)
* das ist ein Disc-Defekt, kein Fortschrittsproblem. */
aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void
abbruch?: AbortSignal
}
/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf das Ergebnis trägt
* den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */
export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
)
const auswertung = new RipAuswertung()
const leser = new ZeilenLeser()
let abgebrochen = false
const abbrechen = (): void => {
abgebrochen = true
kind.kill()
}
optionen.abbruch?.addEventListener('abort', abbrechen, { once: true })
// Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder
// Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster.
let klemmteVorher = false
const zeile = (z: string): void => {
const ergebnis = auswertung.verarbeite(z)
if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent)
if (ergebnis.meldung !== undefined) {
optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text)
}
const klemmt = auswertung.haengt()
if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) {
klemmteVorher = klemmt
optionen.aufHaengt?.(
klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null,
)
}
}
kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile))
kind.on('error', (fehler) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
fertig({ status: 'error', fehler: String(fehler), lesefehler: false })
})
kind.on('close', (code) => {
optionen.abbruch?.removeEventListener('abort', abbrechen)
leser.schluss().forEach(zeile)
if (abgebrochen) {
fertig({ status: 'cancelled' })
return
}
let dateien: string[] = []
try {
dateien = readdirSync(optionen.zielOrdner)
.filter((name) => name.endsWith('.mkv'))
.sort()
.map((name) => join(optionen.zielOrdner, name))
} catch (fehler) {
fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler })
return
}
if (code === 0 && dateien.length > 0) {
fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler })
return
}
fertig({
status: 'error',
fehler: auswertung.fehlerText(code, dateien.length > 0),
lesefehler: auswertung.lesefehler,
})
})
})
}
+309
View File
@@ -0,0 +1,309 @@
// makemkvcon-Ausgabe lesen — reine Textverarbeitung, vollständig testbar
// ohne Laufwerk (KONZEPT § 6.2). Format laut
// https://www.makemkv.com/developers/usage.txt (Robot-Mode -r); jede
// Eigenheit hier ist im Docker-Zweig an echten Discs GEMESSEN worden
// (ripping.py / makemkv_aufruf.py) und wandert mit ihren Testfällen mit.
// ── Quelle: Wie MakeMKV DIESES Laufwerk nennt ───────────────────────────
// Der 29.08.-Befund: `dev:\\.\G:` → MSG 2024 „Unknown device";
// `dev:G:` → 68 TINFO-Zeilen. CreateFileW braucht den Geräte-Namensraum,
// MakeMKV will den Laufwerksbuchstaben. Entschieden wird nach der FORM des
// Pfades, nicht nach dem Rechner.
export function quelle(geraetepfad: string): string {
let pfad = (geraetepfad ?? '').trim()
if (pfad.length === 0) return ''
for (const vorsatz of ['\\\\.\\', '\\\\?\\']) {
if (pfad.startsWith(vorsatz)) {
pfad = pfad.slice(vorsatz.length)
break
}
}
// „G:\" → „G:" — ohne Trenner ist es das, was MakeMKV selbst ausgibt.
if (pfad.length === 3 && pfad[1] === ':' && (pfad[2] === '\\' || pfad[2] === '/')) {
pfad = pfad.slice(0, 2)
}
return `dev:${pfad}`
}
// ── Kodierung: BINÄR lesen, dann bestimmen statt behaupten ──────────────
// makemkvcon mischt Kodierungen je nach Konsole (UTF-8 oder cp1252): Beim
// Commander wurde aus „Öffnen" ein „Öffnen". UTF-8 STRENG versuchen,
// sonst cp1252 — mit errors=replace ginge jeder Umlaut still unter.
export function textVon(roh: Buffer | string): string {
if (typeof roh === 'string') return roh
if (roh.length === 0) return ''
try {
return new TextDecoder('utf-8', { fatal: true }).decode(roh)
} catch {
// cp1252 kann jedes Byte abbilden — hier kann nichts mehr scheitern.
return new TextDecoder('windows-1252').decode(roh)
}
}
// ── MSG-Zeilen ──────────────────────────────────────────────────────────
// MSG:code,flags,count,"message",… — Feld 4 ist der fertige Klartext.
// Ein naives split(",") schnitt jede Meldung mit Komma ab (25.07.2026) —
// und MakeMKV schreibt solche laufend.
const MSG_ZEILE = /^MSG:(\d+),\d+,\d+,"((?:[^"\\]|\\.)*)"/
export function parseMsg(zeile: string): { code: number; text: string } | null {
const treffer = MSG_ZEILE.exec(zeile.trim())
if (treffer === null) return null
return { code: Number(treffer[1]), text: treffer[2].replaceAll('\\"', '"') }
}
// ── PRGV: Gesamtfortschritt ─────────────────────────────────────────────
// PRGV:current,total,max — total/max ist der Gesamtfortschritt.
// -1 heißt „keine Angabe": Ein echtes 0 % muss von „nichts" unterscheidbar
// bleiben, sonst friert der Anfang jedes Rips optisch ein.
export function progressAusPrgv(zeile: string): number {
const treffer = /^PRGV:(\d+),(\d+),(\d+)/.exec(zeile.trim())
if (treffer === null) return -1
const gesamt = Number(treffer[2])
const maximum = Number(treffer[3])
if (maximum <= 0) return -1
return Math.min(100, Math.floor((gesamt * 100) / maximum))
}
// ── TINFO: Titel-Liste ──────────────────────────────────────────────────
// Attribut-IDs laut MakeMKV apdefs.h: 8 = ChapterCount, 9 = Duration
// ("H:MM:SS"), 11 = DiskSizeBytes.
export interface TitelInfo {
nr: number
dauerS: number
groesseBytes: number
kapitel: number
}
const TINFO_ZEILE = /^TINFO:(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseTitelInfo(ausgabe: string): TitelInfo[] {
const titel = new Map<number, TitelInfo>()
for (const roh of ausgabe.split(/\r?\n/)) {
const treffer = TINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const nr = Number(treffer[1])
const attr = Number(treffer[2])
const wert = treffer[3]
let eintrag = titel.get(nr)
if (eintrag === undefined) {
eintrag = { nr, dauerS: 0, groesseBytes: 0, kapitel: 0 }
titel.set(nr, eintrag)
}
if (attr === 9) {
const teile = wert.split(':')
if (teile.length === 3 && teile.every((t) => /^\d+$/.test(t))) {
eintrag.dauerS = Number(teile[0]) * 3600 + Number(teile[1]) * 60 + Number(teile[2])
}
} else if (attr === 11 && /^\d+$/.test(wert)) {
eintrag.groesseBytes = Number(wert)
} else if (attr === 8 && /^\d+$/.test(wert)) {
eintrag.kapitel = Number(wert)
}
}
return [...titel.keys()].sort((a, b) => a - b).map((nr) => titel.get(nr)!)
}
// ── SINFO: Tonspuren und Untertitel ─────────────────────────────────────
// Attribut-IDs am 26.07.2026 an der Akira-Blu-ray abgenommen:
// 1 = Typ („Audio"/„Subtitles"), 3 = Sprachcode, 4 = Sprachname,
// 6 = Codec, 14 = Kanäle, 30 = Beschreibung.
// ⚠️ Die Sprache steht in 3/4 — NICHT in 28/29: Die tragen auf JEDEM
// Stream „eng"/„English" (MakeMKVs Anzeigesprache). Wer 28 nimmt, hält
// jede Disc für englisch.
export interface StreamInfo {
nr: number
typ: string
lang: string
sprache: string
codec: string
kanaele: string
beschreibung: string
}
export interface TitelStreams {
audio: StreamInfo[]
untertitel: StreamInfo[]
}
const SINFO_ZEILE = /^SINFO:(\d+),(\d+),(\d+),\d+,"(.*)"\s*$/
export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
const titel = new Map<number, Map<number, StreamInfo>>()
for (const roh of (ausgabe ?? '').split(/\r?\n/)) {
const treffer = SINFO_ZEILE.exec(roh.trim())
if (treffer === null) continue
const tNr = Number(treffer[1])
const sNr = Number(treffer[2])
const attr = Number(treffer[3])
const wert = treffer[4]
let streams = titel.get(tNr)
if (streams === undefined) {
streams = new Map()
titel.set(tNr, streams)
}
let eintrag = streams.get(sNr)
if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
streams.set(sNr, eintrag)
}
if (attr === 1) eintrag.typ = wert
else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert
else if (attr === 30) eintrag.beschreibung = wert
}
const ergebnis = new Map<number, TitelStreams>()
for (const [tNr, streams] of titel) {
const audio: StreamInfo[] = []
const untertitel: StreamInfo[] = []
for (const sNr of [...streams.keys()].sort((a, b) => a - b)) {
const s = streams.get(sNr)!
// MakeMKV schreibt „Subtitles" (englisch, Plural) — gemessen.
if (s.typ === 'Audio') audio.push(s)
else if (s.typ.startsWith('Subtitle')) untertitel.push(s)
}
ergebnis.set(tNr, { audio, untertitel })
}
return ergebnis
}
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
export type { SprachEintrag }
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
const sammeln = (schluessel: 'audio' | 'untertitel'): SprachEintrag[] => {
const gefunden = new Map<string, SprachEintrag>()
for (const streams of streamsJeTitel.values()) {
for (const s of streams[schluessel]) {
if (s.lang.length === 0) continue
let eintrag = gefunden.get(s.lang)
if (eintrag === undefined) {
eintrag = { lang: s.lang, sprache: s.sprache.length > 0 ? s.sprache : s.lang, spuren: 0 }
gefunden.set(s.lang, eintrag)
}
eintrag.spuren += 1
}
}
return [...gefunden.values()]
}
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */
export function sprachenFuerTitel(streams: TitelStreams | undefined): {
audio: SprachEintrag[]
untertitel: SprachEintrag[]
} {
if (streams === undefined) return { audio: [], untertitel: [] }
return sprachenZusammenfassen(new Map([[0, streams]]))
}
// ── Titelwahl-Heuristiken ───────────────────────────────────────────────
/** Titel-Nummer für den Hauptfilm. Mit Soll-Laufzeit (Minuten, z. B. aus
* TMDb) gewinnt der Titel, der ihr am nächsten kommt (±20 min) das
* vermeidet Play All"-Titel, die länger als der Film sind. */
export function laengsterTitel(dauern: Map<number, number>, sollMinuten = 0): number | null {
if (dauern.size === 0) return null
const zielS = sollMinuten * 60
if (zielS > 0) {
let bester: number | null = null
let abstand = Number.POSITIVE_INFINITY
for (const [nr, dauer] of dauern) {
const d = Math.abs(dauer - zielS)
if (d < abstand) {
abstand = d
bester = nr
}
}
if (bester !== null && abstand <= 20 * 60) return bester
}
let laengster: number | null = null
let max = -1
for (const [nr, dauer] of dauern) {
if (dauer > max) {
max = dauer
laengster = nr
}
}
return laengster
}
/** Episoden-Titel: alle Titel, deren Laufzeit ±10 min am Durchschnitt der
* bekannten Episoden-Laufzeiten (Minuten) liegt. */
export function episodenTitel(dauern: Map<number, number>, episodenMinuten: number[]): number[] {
if (dauern.size === 0 || episodenMinuten.length === 0) return []
const laufzeitenS = episodenMinuten.map((m) => m * 60)
const schnitt = laufzeitenS.reduce((a, b) => a + b, 0) / laufzeitenS.length
const gefunden: number[] = []
for (const [nr, dauer] of dauern) {
if (Math.abs(dauer - schnitt) <= 10 * 60) gefunden.push(nr)
}
return gefunden.sort((a, b) => a - b)
}
// ── Fehler-Erkennung ────────────────────────────────────────────────────
// Sprachunabhängige Marke für „die Disc ließ sich stellenweise nicht
// lesen": Die URL steht auch in der deutschen MakeMKV-Fassung englisch da
// (30.08.2026, Spartacus Disc 2 — 1 von 2 Titeln kam an, Rückgabewert 0).
export const LESEFEHLER_MARKE = 'makemkv.com/errors/read'
/** Die STELLE, an der ein Lesefehler auftrat. MakeMKV hängt sie ans Ende
* der Meldung, in jeder Sprache als letzte Zahl in Anführungszeichen:
*
* während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'
* while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'
*
* Deshalb wird die letzte gequotete Zahl gelesen und nicht das Wort davor
* das wäre von der MakeMKV-Sprache abhängig. '' wenn keine dasteht.
*
* Wozu (01.09.2026, an der Spartacus-Disc gesehen): Wiederholt sich
* DIESELBE Stelle, ist das kein Fortschritt, sondern ein physischer
* Defekt MakeMKV versucht es dann stundenlang, das Laufwerk hämmert,
* und Windows protokolliert einen fehlerhaften Block nach dem anderen. */
export function offsetAusMeldung(text: string): string {
// Mindestens zwei Ziffern: Titel- und Kapitelnummern stehen ebenfalls in
// Anführungszeichen, ein Offset ist immer groß.
const treffer = /'(\d{2,})'\s*$/.exec(text.trim())
return treffer === null ? '' : treffer[1]
}
// Meldungs-Nummern, die die URSACHE eines Fehlschlags benennen — sie sind
// sprachunabhängig, anders als die Texte. Jede Nummer wurde am Laufwerk
// des Commanders GESEHEN (makemkv_aufruf.py, im Wortlaut dokumentiert).
export const KRITISCHE_CODES: Record<number, string> = {
2024: 'MakeMKV kennt dieses Laufwerk nicht',
5010: 'Das Laufwerk ließ sich nicht öffnen — liegt die Disc noch drin? Nach einem fertigen Rip wirft Rippy sie aus',
5020: 'Der hinterlegte MakeMKV-Schlüssel wird nicht angenommen',
5021: 'MakeMKV ist zu alt für den aktuellen Beta-Schlüssel — bitte MakeMKV aktualisieren',
// Am 30.08.2026 am BU40N gemessen: Nach einem abgebrochenen Rip samt
// gescheitertem Auswurf sah MakeMKV GAR KEIN Laufwerk mehr (leere
// DRV-Liste), während die Windows-Storage-Abfragen (CHECK_VERIFY2,
// GET_LENGTH) weiter sauber antworteten — MakeMKV spricht SCSI direkt,
// und diese Ebene blieb hängen.
5042: 'MakeMKV findet gerade kein Laufwerk (das passiert nach abgebrochenen Rips). Abhilfe: Disc auswerfen und neu einlegen oder das Laufwerk kurz ab- und anstecken; hilft das nicht, den Rechner neu starten',
5051: 'MakeMKV ist nicht freigeschaltet (Testzeitraum abgelaufen)',
}
/** Meldungen, die sagen: DIESER Schlüssel taugt für DIESE Fassung nicht. */
export const KEY_ABGELEHNT = [5020, 5021]
// Text-Rückfall für englische Installationen — die NUMMERN sind die
// verlässliche Quelle (beim Commander meldet MakeMKV deutsch).
export const KRITISCHE_TEXTE = [
'volume key is unknown',
'evaluation period has expired',
'registration key',
'Too old version',
]
+176
View File
@@ -0,0 +1,176 @@
// Welche Titel einer Disc will man wirklich? — PUR, ohne Laufwerk, damit
// die Ampel das auf Linux prüfen kann.
//
// Anlass (01.09.2026, Commander): „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte. Bei Serien eben genau das die folgen,
// bei filmen z. B. nur das hauptfeature oder auch die extras."
//
// Bis hierher übergab die Pipeline fest `titel: 'all'` — Rippy rippte
// IMMER alles, inklusive Trailer, Werbung und Alterskennzeichen.
//
// Diese Datei RÄT NICHT INS BLAUE. Sie rechnet mit dem, was messbar ist
// (Dauer, Größe, Kapitelzahl) und mit dem, was die Disc selbst bezeugt
// (Folgenzahl aus dem bdmt-Inhaltsverzeichnis, siehe discmerkmale.ts).
// Wo es nicht eindeutig ist, sagt sie das — statt sich zu entscheiden und
// zu schweigen.
// Die Rolle ist EINE Definition für Kern und Fenster (gemeinsam/).
import type { TitelRolle } from '../../gemeinsam/nachrichten'
import type { TitelInfo } from './parser'
export type { TitelRolle }
/** Kürzer als das ist kein Film und keine Folge, sondern ein Logo, eine
* Warnung oder ein Menü-Schnipsel. Dieselbe Grenze benutzt makemkvcon
* standardmäßig für seine eigene Mindestlänge. */
export const KURZ_S = 120
/** Folgen einer Staffel sind ähnlich lang 45 bis 55 Minuten sind eine
* Serie, 50 Minuten und 3 Minuten sind es nicht. */
export const AEHNLICH = 0.15
/** Mehrere Titel dicht am längsten heißen fast immer Seamless Branching"
* bzw. Kamerawinkel: dieselbe Fassung in Varianten. Dann ist die Wahl
* nicht eindeutig, und Rippy sagt das. */
export const FILM_GLEICHAUF = 0.05
/** Ein Titel, der ungefähr so lang ist wie die anderen ZUSAMMEN, ist ein
* Sammeltitel (Alle Folgen am Stück"). Der ist der längste und würde
* eine Film-Auswahl sonst gewinnen und dann rippt Rippy alles doppelt. */
export const SAMMEL_TOLERANZ = 0.1
export interface TitelVorschlag extends TitelInfo {
/** Von Rippy vorausgewählt? */
gewaehlt: boolean
/** Als was Rippy diesen Titel einsortieren würde. */
rolle: TitelRolle
/** Warum — im Klartext, das Fenster zeigt es an. */
grund: string
}
export interface TitelWahl {
titel: TitelVorschlag[]
/** Einordnung fürs Fenster, z. B. „Serie: 2 Folgen erwartet, 2 gefunden". */
einordnung: string
/** Rippy ist sich NICHT sicher — das Fenster fragt dann deutlicher nach. */
unsicher: boolean
}
/** Was die Disc über sich selbst bezeugt (discmerkmale.discTitel()). */
export interface DiscHinweis {
istSerie: boolean
/** Folgen laut bdmt-Inhaltsverzeichnis; 0 wenn es keines gibt. */
folgen: number
}
/** Die Titel-Struktur als Fingerabdruck (5.4.0, KONZEPT § 5: 1 langer +
* viele kurze = Film; 613 gleich lange = Serienstaffel"). Bewusst
* ZURÜCKHALTEND 'unklar' ist die häufigste und ehrlichste Antwort:
* * 'serie': mindestens drei ähnlich lange Titel zwischen 15 und 75 min
* * 'film': ein Titel ab 60 min, der den zweitlängsten deutlich überragt
* Zwei gleich lange Fassungen (Seamless Branching) bleiben 'unklar', eine
* Zwei-Folgen-Disc auch (dafür ist das bdmt-Inhaltsverzeichnis da). */
export function strukturHinweis(alle: readonly TitelInfo[]): 'film' | 'serie' | 'unklar' {
const lang = alle.filter((t) => t.dauerS >= KURZ_S).sort((a, b) => b.dauerS - a.dauerS)
if (lang.length === 0) return 'unklar'
const laengster = lang[0].dauerS
const aehnlich = lang.filter((t) => Math.abs(t.dauerS - laengster) / laengster <= AEHNLICH)
if (aehnlich.length >= 3 && laengster >= 15 * 60 && laengster <= 75 * 60) return 'serie'
const zweiter = lang[1]?.dauerS ?? 0
if (laengster >= 60 * 60 && zweiter < laengster * 0.6) return 'film'
return 'unklar'
}
function dauerText(s: number): string {
const m = Math.round(s / 60)
return m >= 60 ? `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min` : `${m} min`
}
/** Ist dieser Titel ungefähr so lang wie die kürzeren zusammen? */
function istSammeltitel(kandidat: TitelInfo, alle: readonly TitelInfo[]): boolean {
const kuerzere = alle.filter((t) => t.nr !== kandidat.nr && t.dauerS >= KURZ_S && t.dauerS < kandidat.dauerS)
// Mit weniger als zwei Teilen gibt es nichts zu sammeln.
if (kuerzere.length < 2) return false
const summe = kuerzere.reduce((s, t) => s + t.dauerS, 0)
if (summe <= 0) return false
return Math.abs(kandidat.dauerS - summe) / kandidat.dauerS <= SAMMEL_TOLERANZ
}
/**
* Die Vorauswahl. Sie wählt VOR entschieden wird im Fenster.
*
* Drei Fälle:
* * Serie (von der Disc bezeugt): die längsten Titel, so viele wie das
* Inhaltsverzeichnis Folgen nennt.
* * Film: der längste Titel, der kein Sammeltitel ist.
* * Unklar: nichts vorausgewählt lieber fragen als raten.
*/
export function titelVorauswahl(alle: readonly TitelInfo[], hinweis: DiscHinweis): TitelWahl {
if (alle.length === 0) {
return { titel: [], einordnung: 'Keine Titel gefunden — die Disc gab nichts her.', unsicher: true }
}
const sammel = new Set(alle.filter((t) => istSammeltitel(t, alle)).map((t) => t.nr))
const kandidaten = alle
.filter((t) => t.dauerS >= KURZ_S && !sammel.has(t.nr))
.sort((a, b) => b.dauerS - a.dauerS)
const gewaehlt = new Set<number>()
let einordnung: string
let unsicher = false
if (kandidaten.length === 0) {
einordnung = `Kein Titel über ${Math.round(KURZ_S / 60)} Minuten — hier ist nichts, was nach Hauptinhalt aussieht.`
unsicher = true
} else if (hinweis.istSerie && hinweis.folgen >= 2) {
const folgen = kandidaten.slice(0, hinweis.folgen)
for (const t of folgen) gewaehlt.add(t.nr)
const laengste = folgen[0].dauerS
const passen = folgen.every((t) => Math.abs(t.dauerS - laengste) / laengste <= AEHNLICH)
if (folgen.length < hinweis.folgen) {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, nur ${folgen.length} gefunden — bitte nachsehen.`
unsicher = true
} else if (!passen) {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, aber die gefundenen sind unterschiedlich lang — bitte nachsehen.`
unsicher = true
} else {
einordnung = `Serie: ${hinweis.folgen} Folgen erwartet, ${folgen.length} gefunden — passt.`
}
} else {
const laengster = kandidaten[0]
const gleichauf = kandidaten.filter(
(t) => Math.abs(t.dauerS - laengster.dauerS) / laengster.dauerS <= FILM_GLEICHAUF,
)
if (gleichauf.length > 1) {
// Mehrere Fassungen derselben Länge: die mit den meisten Kapiteln ist
// erfahrungsgemäß die Hauptfassung — sicher ist das nicht.
const beste = [...gleichauf].sort((a, b) => b.kapitel - a.kapitel || a.nr - b.nr)[0]
gewaehlt.add(beste.nr)
einordnung = `${gleichauf.length} Titel sind fast gleich lang (${dauerText(laengster.dauerS)}) — Rippy hat den mit den meisten Kapiteln gewählt. Bitte nachsehen.`
unsicher = true
} else {
gewaehlt.add(laengster.nr)
einordnung = `Film: ein Hauptinhalt von ${dauerText(laengster.dauerS)} gefunden.`
}
}
const alsSerie = hinweis.istSerie && hinweis.folgen >= 2
const titel: TitelVorschlag[] = alle.map((t) => ({
...t,
gewaehlt: gewaehlt.has(t.nr),
// Gewählt UND Serie heißt Folge; gewählt und Film heißt Hauptfilm.
// Alles andere ist eine Zugabe — wer sie mit anhakt, bekommt sie als
// Extra abgelegt, nicht als zweiten Hauptfilm.
rolle: gewaehlt.has(t.nr) ? (alsSerie ? 'folge' : 'hauptfilm') : 'extra',
grund: gewaehlt.has(t.nr)
? alsSerie
? 'Folge'
: 'Hauptinhalt'
: sammel.has(t.nr)
? 'Sammeltitel — enthält vermutlich alles am Stück'
: t.dauerS < KURZ_S
? 'zu kurz — Logo, Warnung oder Menü'
: 'Zugabe',
}))
return { titel, einordnung, unsicher }
}
+275
View File
@@ -0,0 +1,275 @@
// 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.
//
// Schema 4 (5.4.0): die Tabelle `vorgaenge`. Bis dahin wusste Rippy nach
// einem Rip NICHT mehr, welcher Roh-Ordner zu welchem Film gehört — und
// genau deshalb konnte niemand aufräumen (jede Blu-ray ließ 30+ GB liegen),
// niemand „ab Kompression wiederholen" und niemand nach einem Abbruch
// nachsehen, was halb fertig herumlag. Der Vorgang ist jetzt ein Datensatz.
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
export const SCHEMA_VERSION = 4
/** Ein Vorgang, wie die Datenbank ihn kennt (§ 6.7 / 5.4.0). */
export interface Vorgang {
/** Der Vorgangsname — zugleich der Name des Roh-Ordners. */
id: string
laufwerk: string
typ: string
titel: string
jahr: number | null
phase: VorgangPhase
begonnen: string
beendet: string | null
rohOrdner: string
rohBytes: number
rohGeloeschtAm: string | null
zielOrdner: string
fertigBytes: number
/** 0 = die Roh-Dateien SIND das Ergebnis (verschoben, nicht komprimiert). */
komprimiert: boolean
preset: string
fehler: string
/** Der Kompressions-Auftrag als JSON Grundlage für ab Kompression
* wiederholen" (kern/ablauf/auftrag.ts). */
auftrag: string
lesefehler: boolean
/** Die fertigen Dateien als JSON-Liste das Aufräumen prüft, dass sie
* wirklich da sind, BEVOR es den Roh-Ordner löscht. */
fertigeDateien: string
}
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
);
CREATE TABLE IF NOT EXISTS bibliothek (
fingerprint TEXT PRIMARY KEY,
titel TEXT NOT NULL,
jahr INTEGER,
typ TEXT NOT NULL,
groesse_bytes INTEGER NOT NULL,
ablageort TEXT NOT NULL,
datum TEXT NOT NULL,
dauer_s INTEGER NOT NULL,
poster_pfad TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS vorgaenge (
id TEXT PRIMARY KEY,
laufwerk TEXT NOT NULL,
typ TEXT NOT NULL,
titel TEXT NOT NULL,
jahr INTEGER,
phase TEXT NOT NULL,
begonnen TEXT NOT NULL,
beendet TEXT,
roh_ordner TEXT NOT NULL,
roh_bytes INTEGER NOT NULL DEFAULT 0,
roh_geloescht_am TEXT,
ziel_ordner TEXT NOT NULL DEFAULT '',
fertig_bytes INTEGER NOT NULL DEFAULT 0,
komprimiert INTEGER NOT NULL DEFAULT 0,
preset TEXT NOT NULL DEFAULT '',
fehler TEXT NOT NULL DEFAULT '',
auftrag TEXT NOT NULL DEFAULT '{}',
lesefehler INTEGER NOT NULL DEFAULT 0,
fertige_dateien TEXT NOT NULL DEFAULT '[]'
);`,
)
// Schema 2 → 3: Bestands-Datenbanken haben die Bibliothek noch ohne
// Poster-Spalte — nachrüsten, ohne Einträge anzufassen.
const spalten = this.db.prepare(`PRAGMA table_info(bibliothek)`).all() as Array<{ name: string }>
if (!spalten.some((s) => s.name === 'poster_pfad')) {
this.db.exec(`ALTER TABLE bibliothek ADD COLUMN poster_pfad TEXT NOT NULL DEFAULT ''`)
}
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)
}
// ── Bibliothek (§ 6.6): alles Gerippte, wiedererkennbar am
// Disc-Fingerabdruck ────────────────────────────────────────────────
bibliothekEintragen(eintrag: BibliothekEintrag): void {
this.db
.prepare(
`INSERT OR REPLACE INTO bibliothek
(fingerprint, titel, jahr, typ, groesse_bytes, ablageort, datum, dauer_s, poster_pfad)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
eintrag.fingerprint,
eintrag.titel,
eintrag.jahr,
eintrag.typ,
eintrag.groesseBytes,
eintrag.ablageort,
eintrag.datum,
eintrag.dauerS,
eintrag.posterPfad,
)
}
bibliothekListe(): BibliothekEintrag[] {
const zeilen = this.db
.prepare(`SELECT * FROM bibliothek ORDER BY datum DESC`)
.all() as Array<Record<string, unknown>>
return zeilen.map((z) => ({
fingerprint: String(z.fingerprint),
titel: String(z.titel),
jahr: z.jahr === null ? null : Number(z.jahr),
typ: String(z.typ),
groesseBytes: Number(z.groesse_bytes),
ablageort: String(z.ablageort),
datum: String(z.datum),
dauerS: Number(z.dauer_s),
posterPfad: String(z.poster_pfad ?? ''),
}))
}
/** Datum des früheren Rips dieser Disc — oder null (neu). */
bibliothekSchonGerippt(fingerprint: string): string | null {
const zeile = this.db
.prepare(`SELECT datum FROM bibliothek WHERE fingerprint = ?`)
.get(fingerprint) as { datum: string } | undefined
return zeile === undefined ? null : zeile.datum
}
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
vorgangAnlegen(v: Vorgang): void {
this.db
.prepare(
`INSERT OR REPLACE INTO vorgaenge
(id, laufwerk, typ, titel, jahr, phase, begonnen, beendet, roh_ordner, roh_bytes,
roh_geloescht_am, ziel_ordner, fertig_bytes, komprimiert, preset, fehler, auftrag,
lesefehler, fertige_dateien)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
v.id,
v.laufwerk,
v.typ,
v.titel,
v.jahr,
v.phase,
v.begonnen,
v.beendet,
v.rohOrdner,
v.rohBytes,
v.rohGeloeschtAm,
v.zielOrdner,
v.fertigBytes,
v.komprimiert ? 1 : 0,
v.preset,
v.fehler,
v.auftrag,
v.lesefehler ? 1 : 0,
v.fertigeDateien,
)
}
/** Ändert nur die genannten Felder — der Rest bleibt, wie er war. */
vorgangAktualisieren(id: string, teil: Partial<Omit<Vorgang, 'id'>>): void {
const alt = this.vorgangLesen(id)
if (alt === null) return
this.vorgangAnlegen({ ...alt, ...teil, id })
}
vorgangLesen(id: string): Vorgang | null {
const zeile = this.db.prepare(`SELECT * FROM vorgaenge WHERE id = ?`).get(id) as
| Record<string, unknown>
| undefined
return zeile === undefined ? null : vorgangAusZeile(zeile)
}
/** Alle Vorgänge, neueste zuerst. */
vorgangListe(): Vorgang[] {
const zeilen = this.db
.prepare(`SELECT * FROM vorgaenge ORDER BY begonnen DESC`)
.all() as Array<Record<string, unknown>>
return zeilen.map(vorgangAusZeile)
}
vorgangLoeschen(id: string): void {
this.db.prepare(`DELETE FROM vorgaenge WHERE id = ?`).run(id)
}
schliessen(): void {
this.db.close()
}
}
function vorgangAusZeile(z: Record<string, unknown>): Vorgang {
return {
id: String(z.id),
laufwerk: String(z.laufwerk),
typ: String(z.typ),
titel: String(z.titel),
jahr: z.jahr === null || z.jahr === undefined ? null : Number(z.jahr),
phase: String(z.phase) as VorgangPhase,
begonnen: String(z.begonnen),
beendet: z.beendet === null || z.beendet === undefined ? null : String(z.beendet),
rohOrdner: String(z.roh_ordner),
rohBytes: Number(z.roh_bytes ?? 0),
rohGeloeschtAm:
z.roh_geloescht_am === null || z.roh_geloescht_am === undefined ? null : String(z.roh_geloescht_am),
zielOrdner: String(z.ziel_ordner ?? ''),
fertigBytes: Number(z.fertig_bytes ?? 0),
komprimiert: Number(z.komprimiert ?? 0) === 1,
preset: String(z.preset ?? ''),
fehler: String(z.fehler ?? ''),
auftrag: String(z.auftrag ?? '{}'),
lesefehler: Number(z.lesefehler ?? 0) === 1,
fertigeDateien: String(z.fertige_dateien ?? '[]'),
}
}
@@ -0,0 +1,37 @@
// EINE Stelle, an der Werkzeuge gestartet werden (5.4.0) — mit einer Naht
// für den Klick-Beweis (bau/klick-smoke.mjs): Zeigt der eingestellte
// Werkzeug-Pfad auf ein Node-Skript (.cjs/.js), läuft es über die eigene
// Node-Laufzeit (ELECTRON_RUN_AS_NODE, Electron-Doku »Environment
// Variables«) statt als EXE. So klickt der Beweis durch das ECHTE Programm
// — Fenster, Kern, Datenbank, Warteschlange — nur makemkvcon und HandBrake
// sind nachgebaut. Für echte EXE-Pfade ändert sich nichts.
//
// Warum nicht einfach eine .cmd: Node weigert sich seit 18.20/20.12,
// .bat/.cmd ohne Shell zu starten (CVE-2024-27980) — und mit Shell wären
// Pfade mit Leerzeichen wieder eine Falle.
import { spawn } from 'node:child_process'
export function istSkript(programm: string): boolean {
const p = programm.toLowerCase()
return p.endsWith('.cjs') || p.endsWith('.mjs') || p.endsWith('.js')
}
/** Programm + Argumente, so wie spawn sie bekommen soll. */
export function werkzeugStart(
programm: string,
args: readonly string[],
umgebung: NodeJS.ProcessEnv = process.env,
): { programm: string; args: string[]; env: NodeJS.ProcessEnv } {
if (!istSkript(programm)) return { programm, args: [...args], env: umgebung }
return {
programm: process.execPath,
args: [programm, ...args],
env: { ...umgebung, ELECTRON_RUN_AS_NODE: '1' },
}
}
/** spawn mit der Naht — dieselbe Signatur, dieselben Typen. */
export const werkzeugSpawn: typeof spawn = ((programm: string, args: readonly string[], optionen: Record<string, unknown>) => {
const start = werkzeugStart(programm, args)
return spawn(start.programm, start.args, { ...optionen, env: start.env })
}) as typeof spawn
@@ -0,0 +1,330 @@
// MakeMKV beschaffen — die Quellenkette aus KONZEPT § 9, Vorlage
// tools/beschaffen.py (alle Regeln dort am 28.08.2026 gemessen, hier am
// 31.08.2026 nachgemessen):
//
// Hersteller-Seite HTTP 525 (Cloudflare erreicht den Ursprung nicht)
// Hersteller-Forum HTTP 200, 0,2 s → 1.18.4
// Internet Archive HTTP 200, 7,8 s → höchste dort: 1.18.2
//
// Daraus folgen die drei Regeln dieser Datei:
// 1. QUELLENKETTE: Der Hersteller zuerst, immer — er ist maßgeblich.
// Schweigt er (seit Tagen 525), zählen Forum und Archiv, und die
// HÖCHSTE gefundene Nummer gewinnt: Fällt das wacklige Forum aus,
// würde sonst der ältere Archiv-Schnappschuss (1.18.2) als „neueste
// Fassung" verkauft.
// 2. GEPRÜFT WIRD, WAS ANKOMMT: MakeMKV signiert seinen Installer nicht
// (Get-AuthenticodeSignature → NotSigned, an der echten Datei
// gemessen). Deshalb Versions-Ressource: CompanyName „GuinpinSoft
// inc", ProductName „MakeMKV", FileVersion „v1.18.4" (am 31.08.2026
// an makemkvcon64.exe des Commander-PCs gemessen — mit v-Präfix!).
// Das ersetzt keine Signatur, fängt aber ab, was wirklich droht:
// Fehlerseite, halber Download, falsche Version.
// 3. NIE eine gute Datei durch eine halbe ersetzen: Geladen wird in
// `.neu`, geprüft, erst dann getauscht.
import { spawnSync } from 'node:child_process'
import { createWriteStream, openSync, readSync, closeSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { win32 as pfad } from 'node:path'
import { werkzeugOrdner } from './katalog'
export const MAKEMKV_SEITE = 'https://www.makemkv.com/download/'
export const MAKEMKV_FORUM_NEWS = 'https://forum.makemkv.com/forum/viewforum.php?f=3'
// Ein Zeitstempel in der VERGANGENHEIT ist Pflicht: Ohne ihn holt das
// Archiv live nach — und die Quelle ist ja gerade tot (beschaffen.py).
const ARCHIV_STAND = '2025'
const MAKEMKV_DATEI = 'https://www.makemkv.com/download/Setup_MakeMKV_v%s.exe'
const VERSION_RE = /MakeMKV\s+v?(\d+\.\d+\.\d+)/g
// Eine Seite zu lesen dauert Millisekunden; 8 s sind reichlich und
// begrenzen den schlimmsten Fall (3 Quellen × 2 Versuche) auf unter eine
// Minute. Das Archiv brauchte bei der Messung 7,8 s — knapp, aber drin.
const VERSIONS_TIMEOUT_MS = 8_000
const DOWNLOAD_TIMEOUT_MS = 600_000
const VERSUCHE = 2
const WARTEN_ZWISCHEN_VERSUCHEN_MS = 2_000
// Der Installer hat 16,4 MB. Alles unter einem MB ist keiner — sondern
// eine Fehlerseite, die mit HTTP 200 ausgeliefert wurde.
const MINDESTGROESSE_BYTES = 1024 * 1024
const MAKEMKV_FIRMA = 'GuinpinSoft'
const MAKEMKV_PRODUKT = 'MakeMKV'
export class BeschaffungsFehler extends Error {}
/** Die höchste Fassung, die in einem Text vorkommt (pur). NUMERISCH
* verglichen als Zeichenkette wäre 1.9.2" größer als „1.18.4". */
export function hoechsteVersion(text: string): string {
const funde = [...(text ?? '').matchAll(VERSION_RE)].map((t) => t[1])
if (funde.length === 0) return ''
return funde.reduce((a, b) => (istNeuer(b, a) ? b : a))
}
/** Ist `neu` wirklich neuer als `alt`? (pur, numerisch je Stelle) */
export function istNeuer(neu: string, alt: string): boolean {
const teile = (v: string): number[] =>
(v ?? '')
.split('.')
.filter((t) => /^\d+$/.test(t))
.map(Number)
const a = teile(neu)
const b = teile(alt)
if (a.length === 0 || b.length === 0) return a.length > 0 && b.length === 0
for (let i = 0; i < Math.max(a.length, b.length); i++) {
const x = a[i] ?? 0
const y = b[i] ?? 0
if (x !== y) return x > y
}
return false
}
export interface VersionsQuelle {
name: string
url: string
/** Diese Quelle IST die Wahrheit wer sie erreicht, sucht nicht weiter.
* Gilt nur für den Hersteller selbst. */
massgeblich: boolean
}
export function versionsquellen(): VersionsQuelle[] {
return [
{ name: 'Hersteller-Seite', url: MAKEMKV_SEITE, massgeblich: true },
{ name: 'Hersteller-Forum', url: MAKEMKV_FORUM_NEWS, massgeblich: false },
{
name: 'Archiv der Hersteller-Seite',
url: `https://web.archive.org/web/${ARCHIV_STAND}/${MAKEMKV_SEITE}`,
massgeblich: false,
},
]
}
async function seiteLaden(url: string, holen: typeof fetch): Promise<string> {
let letzter: unknown = null
for (let versuch = 0; versuch < VERSUCHE; versuch++) {
try {
const antwort = await holen(url, {
signal: AbortSignal.timeout(VERSIONS_TIMEOUT_MS),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
})
if (antwort.status >= 300) throw new Error(`HTTP ${antwort.status}`)
return await antwort.text()
} catch (fehler) {
letzter = fehler
if (versuch + 1 < VERSUCHE) {
await new Promise((weiter) => setTimeout(weiter, WARTEN_ZWISCHEN_VERSUCHEN_MS))
}
}
}
throw letzter
}
/** Die neueste angekündigte Fassung — '' wenn KEINE Quelle antwortet. */
export async function makemkvNeuesteVersion(
holen: typeof fetch = fetch,
quellen: VersionsQuelle[] = versionsquellen(),
): Promise<string> {
const gefunden: string[] = []
for (const quelle of quellen) {
let text: string
try {
text = await seiteLaden(quelle.url, holen)
} catch {
continue
}
const version = hoechsteVersion(text)
if (version.length === 0) continue
if (quelle.massgeblich) return version
gefunden.push(version)
}
if (gefunden.length === 0) return ''
return gefunden.reduce((a, b) => (istNeuer(b, a) ? b : a))
}
/** Alle Bezugsquellen für den Installer, in Reihenfolge: eigene Basis
* (wer sie einträgt, hat sich etwas gedacht) Hersteller Archiv. */
export function makemkvQuellen(version: string, basis = ''): Array<[string, string]> {
const herstellerUrl = MAKEMKV_DATEI.replace('%s', version)
const quellen: Array<[string, string]> = []
if (basis.trim().length > 0) {
quellen.push(['eigene Quelle', `${basis.trim().replace(/\/+$/, '')}/Setup_MakeMKV_v${version}.exe`])
}
quellen.push(['Hersteller', herstellerUrl])
// `id_` liefert die ROHE Datei; ohne das Kürzel kommt HTML (gemessen).
quellen.push(['Internet Archive', `https://web.archive.org/web/${ARCHIV_STAND}id_/${herstellerUrl}`])
return quellen
}
export interface Versionsangaben {
firma: string
produkt: string
version: string
}
/** Die Versions-Ressource einer EXE über PowerShells FileVersionInfo,
* damit kein zweiter koffi-Ort entsteht (R1). Nur unter Windows lesbar. */
export function versionsangaben(dateiPfad: string): Versionsangaben {
const leer = { firma: '', produkt: '', version: '' }
if (process.platform !== 'win32') return leer
// ' im Pfad wird PowerShell-üblich verdoppelt — mehr Quoting braucht ein
// wörtliches '…'-Literal nicht.
const skript =
`$v = [System.Diagnostics.FileVersionInfo]::GetVersionInfo('${dateiPfad.replace(/'/g, "''")}'); ` +
'@{firma=$v.CompanyName; produkt=$v.ProductName; version=$v.FileVersion} | ConvertTo-Json -Compress'
const lauf = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', skript], {
windowsHide: true,
timeout: 20_000,
encoding: 'utf8',
})
if (lauf.status !== 0) return leer
try {
const roh: unknown = JSON.parse(lauf.stdout ?? '')
if (typeof roh !== 'object' || roh === null) return leer
const angaben = roh as Record<string, unknown>
return {
firma: typeof angaben['firma'] === 'string' ? angaben['firma'] : '',
produkt: typeof angaben['produkt'] === 'string' ? angaben['produkt'] : '',
version: typeof angaben['version'] === 'string' ? angaben['version'] : '',
}
} catch {
return leer
}
}
/** Ist das WIRKLICH der MakeMKV-Installer? (pur bis auf den Datei-Kopf) */
export function dateiPruefen(
dateiPfad: string,
version: string,
angaben: (p: string) => Versionsangaben = versionsangaben,
plattform: NodeJS.Platform = process.platform,
): { ok: boolean; grund: string } {
// Windows-Programme beginnen mit „MZ" — eine mit HTTP 200 ausgelieferte
// Fehlerseite tut das nicht.
const kopf = Buffer.alloc(2)
try {
const datei = openSync(dateiPfad, 'r')
try {
readSync(datei, kopf, 0, 2, 0)
} finally {
closeSync(datei)
}
} catch (fehler) {
return { ok: false, grund: `Datei nicht lesbar: ${String(fehler)}` }
}
if (kopf.toString('latin1') !== 'MZ') {
return { ok: false, grund: 'Die geladene Datei ist kein Windows-Programm (kein „MZ"-Kopf) — vermutlich eine Fehlerseite.' }
}
if (plattform !== 'win32') {
// Ohne Windows keine Versions-Ressourcen — der MZ-Test hat gegriffen,
// mehr ist dort nicht zu holen (CI läuft auf Linux).
return { ok: true, grund: 'Windows-Programm (Versionsangaben nur unter Windows lesbar)' }
}
const info = angaben(dateiPfad)
if (!info.firma.includes(MAKEMKV_FIRMA)) {
return { ok: false, grund: `Die Datei stammt laut Versions-Ressource nicht von ${MAKEMKV_FIRMA} (CompanyName: „${info.firma}").` }
}
if (!info.produkt.includes(MAKEMKV_PRODUKT)) {
return { ok: false, grund: `Die Datei ist laut Versions-Ressource kein ${MAKEMKV_PRODUKT} (ProductName: „${info.produkt}").` }
}
if (version.length > 0 && !info.version.includes(version)) {
return { ok: false, grund: `Falsche Fassung: erwartet ${version}, die Datei sagt „${info.version}".` }
}
return { ok: true, grund: `Versions-Ressource geprüft: ${info.firma}, ${info.produkt} ${info.version}` }
}
async function dateiLaden(
url: string,
ziel: string,
melden: (text: string, anteil: number | null) => void,
holen: typeof fetch,
): Promise<void> {
const antwort = await holen(url, {
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)', Accept: '*/*' },
})
if (antwort.status >= 300) throw new BeschaffungsFehler(`HTTP ${antwort.status}`)
if (antwort.body === null) throw new BeschaffungsFehler('Antwort ohne Inhalt')
const erwartet = Number(antwort.headers.get('content-length') ?? '0')
const schreiber = createWriteStream(ziel)
const leser = antwort.body.getReader()
let geladen = 0
let letzteStufe = -1
try {
for (;;) {
const { done, value } = await leser.read()
if (done) break
geladen += value.byteLength
await new Promise<void>((weiter, kaputt) => {
schreiber.write(Buffer.from(value), (fehler) => (fehler ? kaputt(fehler) : weiter()))
})
if (erwartet > 0) {
const anteil = Math.min(1, geladen / erwartet)
// Nur bei jedem zehnten Prozent melden — 16 MB in kleinen Häppchen
// wären sonst hundert Zeilen.
const stufe = Math.floor(anteil * 10)
if (stufe !== letzteStufe) {
letzteStufe = stufe
melden(
`${Math.floor(anteil * 100)} % geladen (${(geladen / 1024 ** 2).toFixed(1)} von ${(erwartet / 1024 ** 2).toFixed(1)} MB)`,
anteil * 0.85,
)
}
}
}
} finally {
await new Promise<void>((weiter) => schreiber.end(weiter))
}
const groesse = statSync(ziel).size
if (groesse < MINDESTGROESSE_BYTES) {
throw new BeschaffungsFehler(
`Die geladene Datei ist nur ${groesse} Bytes groß — das ist keine Programmdatei, sondern vermutlich eine Fehlerseite.`,
)
}
}
export interface BeschaffungErgebnis {
pfad: string
quelle: string
version: string
}
/** Lädt den MakeMKV-Installer über die Quellenkette in Rippys
* Downloads-Ordner. **Rippy liefert MakeMKV nicht mit** es lädt die
* offizielle Datei; gestartet wird sie vom Haupt (UAC, § 9). */
export async function makemkvInstallerHolen(
version: string,
melden: (text: string, anteil: number | null) => void,
basis = '',
holen: typeof fetch = fetch,
ordner: string = pfad.join(werkzeugOrdner(), 'downloads'),
pruefen: typeof dateiPruefen = dateiPruefen,
): Promise<BeschaffungErgebnis> {
mkdirSync(ordner, { recursive: true })
const ziel = pfad.join(ordner, `Setup_MakeMKV_v${version}.exe`)
const vorlaeufig = `${ziel}.neu`
const fehlschlaege: string[] = []
for (const [name, url] of makemkvQuellen(version, basis)) {
melden(`MakeMKV ${version} wird geladen (${name}) …`, 0)
try {
await dateiLaden(url, vorlaeufig, melden, holen)
const pruefung = pruefen(vorlaeufig, version)
if (!pruefung.ok) throw new BeschaffungsFehler(pruefung.grund)
// Erst tauschen, wenn geprüft — ein halber Download darf eine gute
// Datei nicht ersetzen.
renameSync(vorlaeufig, ziel)
return { pfad: ziel, quelle: name, version }
} catch (fehler) {
fehlschlaege.push(`${name}: ${fehler instanceof Error ? fehler.message : String(fehler)}`)
melden(`${name} hat nicht geliefert.`, null)
try {
rmSync(vorlaeufig, { force: true })
} catch {
// Aufräumen ist Kür — der nächste Versuch überschreibt ohnehin.
}
}
}
throw new BeschaffungsFehler(
`Keine Quelle hat den MakeMKV-Installer geliefert:\n ${fehlschlaege.join('\n ')}\n` +
'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.',
)
}
+203
View File
@@ -0,0 +1,203 @@
// Wo Rippys Werkzeuge liegen — und wie man sie findet (KONZEPT § 9).
// Die Suchreihenfolge und ihre Fallen stammen aus tools/katalog.py, dort am
// Commander-PC gemessen:
//
// 1. Ausdrücklich eingestellt — wer einen Pfad einträgt, will genau den.
// 2. Rippys eigener Werkzeug-Ordner (mitgeliefert bzw. selbst geholt) —
// VOR dem System, damit eine gepflegte Fassung eine alte Installation
// schlägt.
// 3. PATH — der „selbst eingetragen"-Fall.
// 4. Bekannte Installationsorte (%ProgramFiles%…) — der Normalfall.
// 5. Uninstall-Zweig der Registry: Bei MakeMKV ist `InstallLocation` LEER
// (28.08.2026 nachgesehen), aber `DisplayIcon` zeigt in den
// Programmordner.
//
// ⚠ %VAR%-Entfaltung MUSS Groß/Klein ignorieren: os-Umgebungen liefern
// PROGRAMFILES(X86), das Muster sagt %ProgramFiles(x86)% — der strikte
// Vergleich ließ die bekannten Orte auf einer echten Maschine NIE greifen
// (katalog.py, 28.08.2026).
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
// Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade,
// und die CI-Tests laufen auf Linux — dort würde das plattformabhängige
// join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226).
import { win32 as pfad } from 'node:path'
const { delimiter, join } = pfad
export interface Werkzeug {
titel: string
zweck: string
programme: readonly string[]
orte: readonly string[]
/** Teilstring des DisplayName im Uninstall-Zweig der Registry. */
anzeige: string
pflicht: boolean
}
export const WERKZEUGE: Record<string, Werkzeug> = {
makemkv: {
titel: 'MakeMKV',
zweck: 'Liest DVDs, Blu-rays und 4K-UHDs — der einzige Weg durch AACS.',
programme: ['makemkvcon64.exe', 'makemkvcon.exe'],
orte: ['%ProgramFiles(x86)%\\MakeMKV', '%ProgramFiles%\\MakeMKV'],
anzeige: 'MakeMKV',
pflicht: true,
},
handbrake: {
titel: 'HandBrakeCLI',
zweck: 'Komprimiert den verlustfreien Rip auf Arbeitsgröße.',
programme: ['HandBrakeCLI.exe'],
orte: ['%ProgramFiles%\\HandBrake', '%ProgramFiles(x86)%\\HandBrake'],
anzeige: 'HandBrake',
pflicht: false, // ohne HandBrake geht Rippen, nur ohne Kompression
},
flac: {
titel: 'FLAC',
zweck: 'Wandelt die von der Audio-CD gelesenen Rohdaten in FLAC.',
programme: ['flac.exe'],
orte: ['%ProgramFiles%\\FLAC', '%ProgramFiles(x86)%\\FLAC'],
anzeige: 'FLAC',
pflicht: false, // ohne FLAC läuft alles außer Audio-CDs
},
}
/** Rippys eigener Werkzeug-Ordner — dort landet Mitgeliefertes/Geholtes. */
export function werkzeugOrdner(umgebung: NodeJS.ProcessEnv = process.env): string {
const wurzel = umgebung['LOCALAPPDATA'] ?? umgebung['USERPROFILE'] ?? '.'
return join(wurzel, 'Rippy', 'tools')
}
/** %ProgramFiles%\X C:\Program Files\X Groß/Klein wird IGNORIERT.
* Bleibt ein % stehen, ist der Kandidat unbrauchbar ''. */
export function entfalten(pfad: string, umgebung: NodeJS.ProcessEnv): string {
let ergebnis = pfad
for (const [name, wert] of Object.entries(umgebung)) {
if (wert === undefined) continue
const marke = `%${name}%`.toUpperCase()
for (;;) {
const stelle = ergebnis.toUpperCase().indexOf(marke)
if (stelle < 0) break
ergebnis = ergebnis.slice(0, stelle) + wert + ergebnis.slice(stelle + marke.length)
}
}
return ergebnis.includes('%') ? '' : ergebnis
}
/** Alle Kandidaten-Pfade in Suchreihenfolge — reine Funktion, prüft nichts. */
export function kandidaten(
name: string,
eigenerOrdner: string,
umgebung: NodeJS.ProcessEnv,
): string[] {
const werkzeug = WERKZEUGE[name]
const pfade: string[] = []
// Das MITGELIEFERTE Werkzeug (§ 9) — im installierten Rippy zeigt
// RIPPY_VENDOR auf resources\vendor (der Haupt setzt es beim Kern-Start).
const vendor = umgebung['RIPPY_VENDOR'] ?? ''
if (vendor.length > 0) {
for (const programm of werkzeug.programme) {
pfade.push(join(vendor, name, programm))
}
}
for (const programm of werkzeug.programme) {
pfade.push(join(eigenerOrdner, name, programm))
pfade.push(join(eigenerOrdner, programm))
}
for (const ort of werkzeug.orte) {
const entfaltet = entfalten(ort, umgebung)
if (entfaltet.length === 0) continue
for (const programm of werkzeug.programme) {
pfade.push(join(entfaltet, programm))
}
}
return pfade
}
function imPfadSuchen(programme: readonly string[], umgebung: NodeJS.ProcessEnv): string {
const pfadVar = umgebung['PATH'] ?? umgebung['Path'] ?? ''
for (const ordner of pfadVar.split(delimiter)) {
if (ordner.length === 0) continue
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
return ''
}
/** Registry-Rückfall: DisplayIcon aus dem Uninstall-Zweig. Bei MakeMKV ist
* InstallLocation leer DisplayIcon zeigt auf eine Datei im
* Programmordner (28.08.2026 nachgesehen). Über reg.exe gelesen, damit
* kein zweiter koffi-Ort entsteht (R1). */
export function ausUninstallRegistry(
anzeige: string,
programme: readonly string[],
ausfuehren: (wurzel: string) => string = regQuery,
): string {
const wurzeln = [
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
]
for (const wurzel of wurzeln) {
const ausgabe = ausfuehren(wurzel)
// reg.exe gruppiert je Unterschlüssel: Kopfzeile, dann NAME TYP WERT.
let passt = false
for (const zeile of ausgabe.split(/\r?\n/)) {
if (zeile.startsWith('HK')) {
passt = false
continue
}
const wert = /^\s*(\S+)\s+REG_SZ\s+(.+)$/.exec(zeile)
if (wert === null) continue
if (wert[1] === 'DisplayName') {
passt = wert[2].includes(anzeige)
} else if (passt && (wert[1] === 'DisplayIcon' || wert[1] === 'UninstallString')) {
const ordner = wert[2].replace(/^"|"$/g, '').replace(/[^\\]*$/, '')
for (const programm of programme) {
const kandidat = join(ordner, programm)
if (existsSync(kandidat)) return kandidat
}
}
}
}
return ''
}
function regQuery(wurzel: string): string {
const ergebnis = spawnSync('reg', ['query', wurzel, '/s'], {
encoding: 'utf8',
windowsHide: true,
timeout: 15_000,
maxBuffer: 32 * 1024 * 1024,
})
return ergebnis.stdout ?? ''
}
/** Der Pfad zum Werkzeug oder '' wenn es nicht da ist. Prüf-Funktionen
* einspritzbar, damit die Suchreihenfolge ohne Installationen testbar ist. */
export function finden(
name: string,
eingestellt = '',
eigenerOrdner: string = werkzeugOrdner(),
umgebung: NodeJS.ProcessEnv = process.env,
existiert: (pfad: string) => boolean = existsSync,
registry: typeof ausUninstallRegistry = ausUninstallRegistry,
): string {
// 1. Ausdrücklich eingestellt — auch wenn der Pfad (noch) fehlt, wird
// NICHT still etwas anderes genommen: Wer einen Pfad einträgt, will
// genau den, und ein Tippfehler soll als Fehler sichtbar werden.
if (eingestellt.trim().length > 0) {
return existiert(eingestellt.trim()) ? eingestellt.trim() : ''
}
// 2 + 4. Eigener Ordner, dann bekannte Orte.
for (const kandidat of kandidaten(name, eigenerOrdner, umgebung)) {
if (existiert(kandidat)) return kandidat
}
// 3. PATH.
const imPfad = imPfadSuchen(WERKZEUGE[name].programme, umgebung)
if (imPfad.length > 0) return imPfad
// 5. Registry-Rückfall.
return registry(WERKZEUGE[name].anzeige, WERKZEUGE[name].programme)
}
@@ -0,0 +1,238 @@
// Die MakeMKV-Schlüsselkette (KONZEPT § 9.1) — Vorlagen makemkv_key.py
// (Forum-Abruf) und makemkv_aufruf.py (Registry-Ablage + Prüfung, beides
// am Commander-PC gemessen):
//
// 1. Ein eingetragener Dauerlizenz-Schlüssel schlägt alles.
// 2. Sonst der Beta-Key aus dem Forum-Thread des Herstellers (t=1053).
// 3. Abgelegt wird in HKCU\Software\MakeMKV\app_Key — die settings.conf
// ist der Linux-Weg; unter Windows liest MakeMKV die Registry.
// 4. GEFRAGT statt gerechnet: makemkvcon selbst sagt per MSG 5020/5021/
// 5051, ob der Key taugt. Ein ABGELEHNTER Key ist schlimmer als
// keiner (0 Titel, kein Laufwerk) — er wird dann ZURÜCKGENOMMEN.
// 5. app_UpdateEnable=1 nur-wenn-fehlt: Über diesen Kanal holt MakeMKV
// die 4K-UHD-Disc-Schlüssel nach (MSG 3338; unter Windows gemessen).
import { spawn, spawnSync } from 'node:child_process'
import { textVon } from '../rip/parser'
import { KEY_ABGELEHNT, KRITISCHE_CODES } from '../rip/parser'
export const FORUM_URL = 'https://forum.makemkv.com/forum/viewtopic.php?t=1053'
export const KEY_MUSTER = /T-[A-Za-z0-9_]{50,}/
const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
const KEY_NAME = 'app_Key'
/** Den Beta-Key aus dem Forum-HTML ziehen (pur). */
export function extractKey(html: string): string {
const treffer = KEY_MUSTER.exec(html ?? '')
return treffer === null ? '' : treffer[0]
}
/** valid until/through <Datum>" aus dem Thread best effort, Rohtext.
* § 9.1 will das Ablaufdatum sichtbar; die verlässliche Instanz bleibt
* aber makemkvcon selbst (MSG 5051 = abgelaufen). */
export function extractGueltigBis(html: string): string {
const treffer = /valid\s+(?:until|through)\s+([A-Za-zäöüÄÖÜ]+\s+\d{1,2}(?:st|nd|rd|th)?,?\s+\d{4})/i.exec(
html ?? '',
)
return treffer === null ? '' : treffer[1].replace(/(\d)(st|nd|rd|th)/, '$1')
}
export interface ForumKey {
key: string
gueltigBis: string
}
/** Der aktuelle Beta-Key aus dem Forum 8-s-Zeitgrenze je Versuch: Das
* Forum fiel bei der Messung in zwei von drei Abrufen aus (§ 9). */
export async function betaKeyHolen(holen: typeof fetch = fetch): Promise<ForumKey | null> {
for (let versuch = 0; versuch < 3; versuch++) {
try {
const antwort = await holen(FORUM_URL, {
signal: AbortSignal.timeout(8_000),
headers: { 'User-Agent': 'Rippy/5.0 (https://github.com/Hitonabi/rippy)' },
})
if (antwort.status >= 300) continue
const html = await antwort.text()
const key = extractKey(html)
if (key.length > 0) return { key, gueltigBis: extractGueltigBis(html) }
} catch {
// nächster Versuch — der Fehlschlag wird am Ende als null sichtbar
}
}
return null
}
// ── Registry-Ablage über reg.exe (kein zweiter koffi-Ort, R1) ───────────
// In dieser Registry steckt die Arbeit des Commanders (Laufwerksprofile,
// Fensterlage). Geschrieben wird GENAU app_Key und app_UpdateEnable —
// gelöscht wird nur app_Key, und nur nach Ablehnung.
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
}
export function keyAblegen(key: string): boolean {
if (key.trim().length === 0) return false
return reg(['add', REGISTRY_PFAD, '/v', KEY_NAME, '/t', 'REG_SZ', '/d', key.trim(), '/f']).ok
}
export function keyZuruecknehmen(): boolean {
return reg(['delete', REGISTRY_PFAD, '/v', KEY_NAME, '/f']).ok
}
/** app_UpdateEnable=1 setzen, NUR wenn es fehlt wer den Web-Kontakt
* bewusst abgeschaltet hat, behält seine Entscheidung. */
export function updateKanalSicherstellen(): boolean {
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', 'app_UpdateEnable'])
if (vorhanden.ok) return false
return reg(['add', REGISTRY_PFAD, '/v', 'app_UpdateEnable', '/t', 'REG_DWORD', '/d', '1', '/f']).ok
}
export interface KeyUrteil {
ok: boolean
code: number | null
grund: string
/** Die MakeMKV-Version aus MSG 1005 (MakeMKV v1.18.4 ") '' wenn
* nicht erkennbar. */
version: string
}
/** Nimmt MakeMKV den hinterlegten Schlüssel an? es selbst gefragt:
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
*
* ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
* 5021 zusammen (30.08.2026 live gemessen) 5020 allein klingt nach
* Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
* den falschen. */
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
return new Promise((fertig) => {
if (programm.length === 0) {
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
return
}
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', (fehler) => {
clearTimeout(zeiger)
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
})
})
}
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
const codes: number[] = []
let version = ''
for (const zeile of ausgabe.split(/\r?\n/)) {
if (!zeile.startsWith('MSG:')) continue
const code = Number(zeile.slice(4).split(',', 1)[0])
if (!Number.isInteger(code)) continue
if (code === 1005) {
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
if (treffer !== null) version = treffer[1]
}
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
}
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
const code = codes.includes(5021) ? 5021 : codes[0]
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
}
export interface SchluesselStand {
/** 'dauerlizenz' | 'forum' | 'keiner' */
quelle: string
angenommen: boolean | null
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja das ist eine
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
weiterBetrieb: boolean
gueltigBis: string
geholtAm: string
text: string
/** Die installierte MakeMKV-Fassung (aus MSG 1005) — fürs Update-Urteil. */
version: string
}
/** Die ganze Kette: Dauerlizenz Forum-Key ablegen PRÜFEN
* bei Ablehnung zurücknehmen. Gibt den Stand fürs Dashboard zurück. */
export async function schluesselKette(
makemkvcon: string,
dauerlizenz: string,
jetztIso: string,
holen: typeof fetch = fetch,
): Promise<SchluesselStand> {
updateKanalSicherstellen()
if (dauerlizenz.trim().length > 0) {
keyAblegen(dauerlizenz)
const urteil = await keyPruefen(makemkvcon)
return {
quelle: 'dauerlizenz',
angenommen: urteil.ok,
weiterBetrieb: urteil.ok,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
version: urteil.version,
}
}
const forum = await betaKeyHolen(holen)
if (forum === null) {
// Kein neuer Key erreichbar — der VORHANDENE bleibt stehen (R2) und
// wird geprüft, damit der Stand ehrlich ist.
const urteil = await keyPruefen(makemkvcon)
return {
quelle: 'keiner',
angenommen: urteil.ok,
weiterBetrieb: true,
gueltigBis: '',
geholtAm: jetztIso,
text: urteil.ok
? 'Forum nicht erreichbar — der hinterlegte Schlüssel funktioniert weiter.'
: `Forum nicht erreichbar und der hinterlegte Schlüssel taugt nicht: ${urteil.grund}`,
version: urteil.version,
}
}
keyAblegen(forum.key)
const urteil = await keyPruefen(makemkvcon)
if (!urteil.ok) {
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
keyZuruecknehmen()
}
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
return {
quelle: 'forum',
angenommen: urteil.ok,
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
weiterBetrieb: true,
gueltigBis: forum.gueltigBis,
geholtAm: jetztIso,
text: urteil.ok
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
: urteil.code === 5021
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
version: urteil.version,
}
}
@@ -0,0 +1,78 @@
// MakeMKVs Spur-Auswahlregel (5.4.0) — damit ALLE Sprachen in der
// Roh-Datei landen, die der Auswahl-Dialog anbietet.
//
// Der Befund: Der Info-Lauf zeigt jede Tonspur der Disc — aber ob sie in
// die MKV kommt, entscheidet MakeMKVs „default selection rule". Die Doku
// (forum.makemkv.com, Thema 4386, am 01.09.2026 nachgelesen — Regel D)
// nennt als Regel des Standard-Profils
//
// -sel:all,+sel:(favlang|nolang),-sel:(havemulti|havecore),=100:all,-10:favlang
//
// mit: favlang = „matches favorite languages, always matches if no
// favorite language is set" · havemulti = „track is mono/stereo and there
// is a multi-channel track in same language" · havecore = „this track is
// hd track with core audio" · +sel/-sel = wählen/abwählen · =N = Gewicht.
//
// Solange KEINE Vorzugssprache gesetzt ist, nimmt MakeMKV also alles
// (bis auf die Stereo-Doppel und die DTS-Kerne). Sobald jemand in MakeMKVs
// Einstellungen „Deutsch" wählt, fielen japanischer und englischer Ton
// STILL weg — und der Dialog böte Sprachen an, die nicht in der Datei
// sind. Rippy setzt deshalb seine eigene Regel, die sprachunabhängig
// alles behält, und zwar NUR, wenn noch keine gesetzt ist (wer eine
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg).
import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString'
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
* 'rippy' (Rippys Regel steht schon) | 'fehler' */
stand: 'gesetzt' | 'eigen' | 'rippy' | 'fehler'
regel: string
text: string
}
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
}
/** Den Wert aus `reg query`-Ausgabe lesen (pur, testbar):
* app_DefaultSelectionString REG_SZ -sel:all," */
export function regelAusQuery(ausgabe: string): string | null {
const treffer = new RegExp(`${REGEL_NAME}\\s+REG_SZ\\s+(.*)$`, 'm').exec(ausgabe)
return treffer === null ? null : treffer[1].trim()
}
/** Sicherstellen, dass eine Regel steht, die alle Sprachen behält. */
export function spurauswahlSicherstellen(): SpurauswahlStand {
if (process.platform !== 'win32') {
return { stand: 'fehler', regel: '', text: 'Nur unter Windows (Registry).' }
}
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
if (bisher !== null && bisher.length > 0) {
if (bisher === RIPPY_REGEL) {
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
}
return {
stand: 'eigen',
regel: bisher,
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
}
}
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
if (!gesetzt.ok) {
return { stand: 'fehler', regel: '', text: `Auswahlregel nicht setzbar: ${gesetzt.ausgabe.trim().slice(0, 160)}` }
}
return {
stand: 'gesetzt',
regel: RIPPY_REGEL,
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
}
}
+174
View File
@@ -0,0 +1,174 @@
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { baueNfo } from '../src/kern/ablage/nfo'
import {
episodenUmbenennen,
matcheEpisoden,
serienOrdner,
sichererName,
zielordner,
} from '../src/kern/ablage/struktur'
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
describe('sichererName', () => {
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
'Der Herr der Ringe Die Gefährten (2001)',
)
})
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
})
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
expect(sichererName('...')).toBe('')
expect(sichererName('Film.', null)).toBe('Film')
})
})
describe('zielordner', () => {
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
// Erwartungen über join() — die Funktionen arbeiten mit den
// Pfadregeln der LAUFZEIT-Plattform (Linux-CI testet mit POSIX).
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
join('Filme', 'Akira (1988)'),
)
expect(zielordner('Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
join('Filme', 'Akira (1988) [job-1234]'),
)
expect(zielordner('Filme', '', null, 'job-1', () => false)).toBe(join('Filme', 'job-1'))
})
})
describe('serienOrdner', () => {
it('Jellyfin-Schema Serie/Season NN', () => {
expect(serienOrdner('Serien', 'Dr. Who?', 2)).toBe(join('Serien', 'Dr. Who', 'Season 02'))
})
})
describe('matcheEpisoden', () => {
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
expect(
matcheEpisoden(
[1420, 1425, 1418],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toEqual([1, 2, 3])
})
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
expect(
matcheEpisoden(
[1200, 2400],
[
[1, 1210],
[2, 2390],
[3, 3000],
],
),
).toEqual([1, 2])
})
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
expect(
matcheEpisoden(
[1420, 1421],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toBeNull()
})
it('keine Daten heißt null', () => {
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
expect(matcheEpisoden([100], [])).toBeNull()
})
})
describe('episodenUmbenennen', () => {
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
const umbenannt: Array<[string, string]> = []
const meldungen = episodenUmbenennen('S', 'Akte X', 1, [3, 4], {
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
existiert: () => false,
umbenennen: (von, nach) => umbenannt.push([von, nach]),
})
expect(umbenannt).toEqual([
[join('S', 'title_t00.mkv'), join('S', 'Akte X S01E03.mkv')],
[join('S', 'title_t01.mkv'), join('S', 'Akte X S01E04.mkv')],
])
expect(meldungen).toHaveLength(2)
})
it('Zahl passt nicht → gar nichts anfassen', () => {
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
listen: () => ['a.mkv', 'b.mkv'],
existiert: () => false,
umbenennen: () => {
throw new Error('darf nicht')
},
})
expect(meldungen[0]).toContain('übersprungen')
})
})
describe('baueNfo', () => {
it('movie.nfo mit XML-Escaping', () => {
const nfo = baueNfo(
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
'Akira <Director\'s Cut>',
1988,
)
expect(nfo).toContain('<movie>')
expect(nfo).toContain('<title>Akira &lt;Director\'s Cut&gt;</title>')
expect(nfo).toContain('<plot>Roboter &amp; Menschen</plot>')
expect(nfo).toContain('<year>1988</year>')
expect(nfo).toContain('<runtime>124</runtime>')
expect(nfo).toContain('Source: Rippy')
})
it('Serie bekommt tvshow als Wurzel', () => {
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
})
})
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
// HandBrake gemessen (25./26.07.2026).
describe('Encoder- und Preset-Listen', () => {
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
const hilfe = [
' -e, --encoder <string> Select video encoder:',
' svt_av1',
' x264',
' x265',
' nvenc_h264',
' --encoder-preset <string>',
' ignored',
].join('\n')
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
})
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
'cpu-x264',
'cpu-x265',
'cpu-av1',
'vce',
'vce-av1',
])
expect(leiteBackendsAb([])).toEqual([])
})
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
const liste = [
'General/',
' Very Fast 2160p60 4K AV1',
' AV1 video (up to 2160p60) and AAC stereo audio.',
'Hardware/',
' H.265 VCN 2160p 4K',
'[10:26:05] qsv: not available on this system',
].join('\n')
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
})
})

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