29 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
75 changed files with 11581 additions and 2493 deletions
+1150 -1
View File
File diff suppressed because it is too large Load Diff
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

+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()
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

+1
View File
@@ -34,6 +34,7 @@ als npm-Skript hinterlegt: `npm run werkzeug:electron`.
| `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.
+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)
}
+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.
+5 -2
View File
@@ -25,8 +25,11 @@ function cspNurImBau(): Plugin {
return html.replace(
'<head>',
// img-src erlaubt TMDb-Poster (Kachel + Ändern-Dialog) — sonst
// bleibt alles bei 'self'.
`<head>\n <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data: https://image.tmdb.org" />`,
// 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:" />`,
)
},
}
+116 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.0.0-w0",
"version": "5.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.0.0-w0",
"version": "5.4.0",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
@@ -24,6 +24,7 @@
"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",
@@ -2229,6 +2230,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -5546,6 +5613,53 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.1.0",
"version": "5.4.0",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
@@ -13,6 +13,7 @@
"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"
},
@@ -32,6 +33,7 @@
"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",
+13
View File
@@ -56,4 +56,17 @@ contextBridge.exposeInMainWorld('rippy', {
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
+3
View File
@@ -9,6 +9,9 @@ declare global {
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>
}
}
}
+114 -40
View File
@@ -11,10 +11,16 @@ import {
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'
@@ -30,6 +36,8 @@ export interface Verbindung {
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
@@ -50,13 +58,18 @@ export interface Verbindung {
} | 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 = 20
const MAX_MELDUNGEN = 30
const stand: Verbindung = {
verbunden: false,
@@ -65,6 +78,7 @@ const stand: Verbindung = {
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
@@ -73,15 +87,16 @@ const stand: Verbindung = {
bibliothek: [],
schluessel: null,
beschaffung: null,
vorgaenge: null,
gesundheit: {},
meldungen: [],
}
const horcher = new Set<Horcher>()
let gestartet = false
let port: MessagePort | null = null
function melden(): void {
for (const h of horcher) {
h({
function kopie(): Verbindung {
return {
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
@@ -89,11 +104,15 @@ function melden(): void {
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)
}
@@ -111,6 +130,9 @@ function kernNachricht(nachricht: KernNachricht): void {
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) {
@@ -132,14 +154,27 @@ function kernNachricht(nachricht: KernNachricht): void {
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
@@ -160,6 +195,18 @@ function kernNachricht(nachricht: KernNachricht): void {
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)
@@ -192,6 +239,9 @@ function kernNachricht(nachricht: KernNachricht): void {
}
break
}
case 'installer-starten':
// Nur fürs Haupt gedacht — im Fenster nichts zu tun.
break
}
melden()
}
@@ -204,10 +254,11 @@ function portAnnehmen(neuerPort: MessagePort): void {
if (istKernNachricht(nachricht)) kernNachricht(nachricht)
}
neuerPort.start()
neuerPort.postMessage({ art: 'ping', zeit: Date.now() })
neuerPort.postMessage({ art: 'einstellungen-laden' })
neuerPort.postMessage({ art: 'werkzeuge-laden' })
neuerPort.postMessage({ art: 'bibliothek-laden' })
senden({ art: 'ping', zeit: Date.now() })
senden({ art: 'einstellungen-laden' })
senden({ art: 'werkzeuge-laden' })
senden({ art: 'bibliothek-laden' })
senden({ art: 'vorgaenge-laden' })
melden()
}
@@ -232,20 +283,16 @@ export function verbindungStarten(): void {
export function aufVerbindung(h: Horcher): () => void {
horcher.add(h)
h({
...stand,
laufwerke: [...stand.laufwerke],
rips: { ...stand.rips },
discInfos: { ...stand.discInfos },
vorschlaege: { ...stand.vorschlaege },
einstellungen: { ...stand.einstellungen },
bibliothek: [...stand.bibliothek],
meldungen: [...stand.meldungen],
})
h(kopie())
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
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()
@@ -258,8 +305,19 @@ export function auswerfen(id: string): void {
anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`)
}
export function ripStarten(id: string): void {
anKern({ art: 'rip-start', id }, `Rip ${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 {
@@ -267,34 +325,50 @@ export function ripAbbrechen(id: string): void {
}
export function metadatenSuchen(id: string, text: string): void {
if (port === null) return
port.postMessage({ art: 'metadaten-suchen', id, text })
senden({ art: 'metadaten-suchen', id, text })
}
export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void {
if (port === null) return
port.postMessage({ art: 'zuordnung-setzen', id, vorschlag })
senden({ art: 'zuordnung-setzen', id, vorschlag })
}
export function makemkvUpdate(): void {
if (port === null) {
meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern')
melden()
return
}
port.postMessage({ art: 'makemkv-update' })
anKern({ art: 'makemkv-update' }, 'MakeMKV-Update')
}
export function schluesselPruefen(): void {
if (port === null) return
port.postMessage({ art: 'schluessel-pruefen' })
senden({ art: 'schluessel-pruefen' })
}
export function einstellungSetzen(schluessel: string, wert: string): void {
if (port === null) {
meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`)
melden()
return
}
port.postMessage({ art: 'einstellung-setzen', schluessel, wert })
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`)
}
+12
View File
@@ -59,6 +59,18 @@
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; }
+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']
}
+341 -9
View File
@@ -7,6 +7,10 @@
// 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 }
@@ -57,6 +61,99 @@ export interface RipStatus {
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). */
@@ -75,6 +172,29 @@ export interface DiscInfoStand {
/** 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). */
@@ -120,15 +240,92 @@ export interface BeschaffungStand {
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 }
@@ -151,13 +348,27 @@ export type KernNachricht =
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 }
| { art: 'rip-start'; 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 }
@@ -168,6 +379,21 @@ export type FensterNachricht =
| { 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 =
@@ -182,14 +408,45 @@ export interface HauptStatus {
leine: { ok: boolean; fehler?: string }
kern: { laeuft: boolean; pid?: number; neustarts: number }
autostart: boolean
/** Selbst-Update (§ 6.8): letzter Prüf-Erfolg + Klartext-Stand. */
update: { letzteErfolgreichePruefung: string; text: string }
/** 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) {
@@ -202,6 +459,7 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
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)
@@ -220,6 +478,13 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
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':
@@ -239,6 +504,28 @@ export function istKernNachricht(wert: unknown): wert is KernNachricht {
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
}
@@ -274,27 +561,72 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
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 === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') {
return typeof wert.id === 'string' && wert.id.length > 0
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 typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string'
return istKennung(wert.schluessel) && typeof wert.wert === 'string'
}
if (wert.art === 'metadaten-suchen') {
return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string'
return istKennung(wert.id) && typeof wert.text === 'string'
}
if (wert.art === 'zuordnung-setzen') {
return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag)
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 === 'schluessel-pruefen' ||
wert.art === 'vorgaenge-laden' ||
wert.art === 'roh-aufraeumen'
)
}
+20 -4
View File
@@ -4,15 +4,25 @@
// „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: 1000,
height: 700,
minWidth: 720,
minHeight: 480,
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).
@@ -30,6 +40,12 @@ export function fensterErstellen(): BrowserWindow {
// (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) {
+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)}` }
}
}
+113 -23
View File
@@ -18,17 +18,28 @@ import { fensterErstellen } from './fenster'
import { KernVerwaltung } from './kernstart'
import { leineSetzen, type LeineStatus } from './leine'
import { trayErstellen, trayHinweis } from './tray'
import { updateEinrichten, updateStand } from './update'
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.
// 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 {
@@ -77,17 +88,30 @@ function start(): void {
} 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 && !smokeModus) {
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 === 'disc-info' && nachricht.info.titel.length > 0 && !smokeModus) {
} 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',
@@ -102,6 +126,43 @@ function start(): void {
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 {
@@ -110,25 +171,11 @@ function start(): void {
} else {
aktiveVorgaenge.delete(status.id)
}
if (fenster !== null && !fenster.isDestroyed()) {
if ((status.phase === 'rippt' || status.phase === 'komprimiert') && status.prozent >= 0) {
fenster.setProgressBar(status.prozent / 100)
} else if (status.phase === 'fehler') {
fenster.setProgressBar(1, { mode: 'error' })
} else {
fenster.setProgressBar(-1)
}
}
if (status.phase === 'rippt') {
trayHinweis(`rippt Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else if (status.phase === 'komprimiert') {
trayHinweis(`komprimiert Laufwerk ${status.id}${status.prozent >= 0 ? ` (${status.prozent} %)` : ''}`)
} else {
trayHinweis('')
}
if (smokeModus) return
letzterRip = status
anzeigeAktualisieren()
if (ohneNebenwirkungen) return
if (status.phase === 'fertig') {
toast('Rippy ist fertig', status.text)
toast('Rippy: Rip fertig', status.text)
} else if (status.phase === 'fehler') {
toast('Rippy: Fehler', status.text)
}
@@ -172,6 +219,9 @@ function start(): void {
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)
}
@@ -185,6 +235,11 @@ function start(): void {
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
@@ -219,6 +274,41 @@ function start(): void {
}
})
// 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)
@@ -254,7 +344,7 @@ function start(): void {
void app.whenReady().then(() => {
kernVerwaltung.starten()
if (!smokeModus) {
if (!ohneNebenwirkungen) {
trayErstellen(fensterZeigen, beenden)
}
fenster = fensterErstellen()
+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']
+95 -1
View File
@@ -17,11 +17,44 @@
// 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.
@@ -63,7 +96,7 @@ export function leineSetzen(): LeineStatus {
}
const info = Buffer.alloc(STRUKTUR_GROESSE)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, LIMIT_FLAGS_OFFSET)
info.writeUInt32LE(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK, LIMIT_FLAGS_OFFSET)
if (
!SetInformationJobObject(
job,
@@ -90,3 +123,64 @@ export function leineSetzen(): LeineStatus {
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)}` }
}
}
+41
View File
@@ -18,3 +18,44 @@ export function updateUrlPruefen(url: string): { ok: boolean; grund: string } {
'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 ''
}
+189 -26
View File
@@ -2,42 +2,90 @@
// ö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 laden noch installieren.
// 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 { updateUrlPruefen } from './update-regeln'
import { INSTALLER_ARGUMENTE } from './kommandozeile'
import { ausserhalbDerLeineStarten } from './leine'
import { neuerungenText, updateUrlPruefen } from './update-regeln'
export { STANDARD_UPDATE_URL, updateUrlPruefen } from './update-regeln'
export {
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
neuerungenText,
updateUrlPruefen,
} from './update-regeln'
const { autoUpdater } = electronUpdater
export interface UpdateStand {
/** Wann die letzte Prüfung GELANG — '' wenn noch nie. */
letzteErfolgreichePruefung: string
text: string
// 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,
}
const stand: UpdateStand = { letzteErfolgreichePruefung: '', text: 'noch nicht geprüft' }
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) {
stand.text = 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.'
setzen({ text: 'Entwicklungslauf — Selbst-Update ist nur im installierten Rippy aktiv.' })
return
}
const pruefung = updateUrlPruefen(eigeneUrl)
if (!pruefung.ok) {
stand.text = pruefung.grund
setzen({ text: pruefung.grund })
melden(pruefung.grund)
return
}
@@ -45,39 +93,154 @@ export function updateEinrichten(
autoUpdater.setFeedURL({ provider: 'generic', url: eigeneUrl.trim() })
}
autoUpdater.autoDownload = false
// Installiert wird beim NÄCHSTEN Start — nie mitten in einem Rip.
autoUpdater.autoInstallOnAppQuit = true
// 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
autoUpdater.on('update-available', () => {
// 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.
stand.text = 'Update verfügbar — wird geladen, sobald kein Rip läuft.'
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
}
void autoUpdater.downloadUpdate().catch((fehler: unknown) => {
stand.text = `Update-Download fehlgeschlagen: ${String(fehler)}`
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) => {
stand.text = `Version ${info.version} liegt bereit — sie wird beim nächsten Start installiert.`
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', () => {
stand.letzteErfolgreichePruefung = new Date().toISOString()
stand.text = 'Rippy ist aktuell.'
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.
stand.text = `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}`
setzen({ laeuft: false, fortschritt: -1, text: `Letzte Prüfung scheiterte: ${String(fehler).slice(0, 160)}` })
})
const pruefen = (): void => {
if (ripLaeuft()) return
void autoUpdater.checkForUpdates().catch(() => {
// der error-Handler oben trägt den Grund ein
})
}
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,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)
}
+5 -17
View File
@@ -11,23 +11,11 @@ import { existsSync, readdirSync, renameSync } from 'node:fs'
// 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'
// 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 { sichererName }
/** Zielordner: sprechender Name; existiert er schon (Disc doppelt
* gerippt), wird die Job-Kennung angehängt statt zu überschreiben. */
@@ -48,7 +36,7 @@ export function zielordner(
/** 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, `Season ${String(Math.trunc(staffel)).padStart(2, '0')}`)
return join(basis, name, staffelOrdnerName(staffel))
}
/** Ordnet Dateien Episoden zu über Laufzeiten, ordnungserhaltend.
+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
}
+294 -205
View File
@@ -3,34 +3,58 @@
// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und
// hängen damit an der Prozess-Leine (R6).
//
// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein
// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in
// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der
// Einstellung im UI (W-5) eine Stimme.
import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
// 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 { basename, join } from 'node:path'
import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
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 { spawn } from 'node:child_process'
import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
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
@@ -53,18 +77,28 @@ export interface PipelineUmgebung {
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
ablage: () => string
einstellungen: () => Einstellungen
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ
* greift nur, wenn der Nutzer nichts eingestellt hat. */
presetEmpfehlung?: (discTyp: string) => string
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
/** Trägt einen fertigen Vorgang in die Bibliothek ein6.6). */
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer4.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 {
@@ -86,8 +120,23 @@ export class Pipeline {
return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false }
}
/** Startet die Kette für die ganze Disc ('all'). */
async starten(id: string): Promise<void> {
/** 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
@@ -125,8 +174,7 @@ export class Pipeline {
const abbruch = new AbortController()
const startZeit = this.umgebung.jetzt?.() ?? new Date()
const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)
const vorgangsName = `${geraet.typ}-${zeit}`
const vorgangsName = vorgangsNameFuer(geraet, startZeit)
// § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7).
if (geraet.typ === 'cd') {
@@ -143,207 +191,223 @@ export class Pipeline {
}
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(),
})
const start: RipStatus = {
// 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: -1,
text: `Rippe verlustfrei nach ${rohOrdner}`,
dateien: [],
lesefehler: false,
}
this.laufend.set(id, { abbruch, status: start })
this.melden(start)
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: 'all',
abbruch: abbruch.signal,
titel: welcher,
abbruch: titelAbbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent })
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') {
this.laufend.delete(id)
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
return
}
if (rip.status === 'error') {
this.laufend.delete(id)
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
return
}
const lesefehlerHinweis = rip.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: ''
// ── Kompression (W-3) ────────────────────────────────────────────────
const einstellungen = this.umgebung.einstellungen()
const handbrake = this.umgebung.handbrake()
const soll = komprimierenFuer(geraet.typ, einstellungen)
if (!soll || handbrake.length === 0) {
const grund = !soll
? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.`
: 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.'
this.laufend.delete(id)
this.melden({
id,
phase: 'fertig',
prozent: 100,
text: `Fertig. ${grund}${lesefehlerHinweis}`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
const fertigOrdner =
zuordnung !== null && zuordnung.titel.length > 0
? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName)
: zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName)
mkdirSync(fertigOrdner, { recursive: true })
const fertigeDateien: string[] = []
for (let i = 0; i < rip.dateien.length; i++) {
const eingabe = rip.dateien[i]
const ausgabe = join(fertigOrdner, basename(eingabe))
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
...alt,
phase: 'komprimiert',
prozent: -1,
text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`,
})
}
const encode = await komprimieren({
programm: handbrake,
eingabe,
ausgabe,
preset,
audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')),
untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')),
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const stand = this.laufend.get(id)?.status
if (stand !== undefined) {
this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) })
}
},
})
if (encode.status === 'cancelled') {
this.laufend.delete(id)
this.melden({
id,
phase: 'abgebrochen',
prozent: -1,
text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.',
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
if (encode.status === 'error') {
this.laufend.delete(id)
this.melden({
id,
phase: 'fehler',
prozent: -1,
text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`,
dateien: rip.dateien,
lesefehler: rip.lesefehler,
})
return
}
fertigeDateien.push(encode.ausgabe)
}
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien })
}
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',
},
zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
zuordnung?.jahr ?? null,
),
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}.` : ''),
)
} catch (fehler) {
// Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es
// wird GESAGT (R4).
const stand = this.laufend.get(id)?.status
if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` })
return
}
if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) {
await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg'))
uebersprungen.push(beschriftung)
continue
}
const server = String(einstellungen['mediaServer'] ?? '')
const serverUrl = String(einstellungen['mediaServerUrl'] ?? '')
const serverKey = String(einstellungen['mediaServerApiKey'] ?? '')
const refresh = await bibliothekRefresh(server, serverUrl, serverKey)
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`)
}
}
// § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt.
const fingerprint = this.umgebung.fingerprint?.(id) ?? null
if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) {
let groesse = 0
for (const datei of fertigeDateien) {
try {
groesse += statSync(datei).size
} catch {
// Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts
// am Eintrag selbst.
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 ?? [],
}
this.umgebung.bibliothekEintragen({
fingerprint,
titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName,
jahr: zuordnung?.jahr ?? null,
typ: geraet.typ,
groesseBytes: groesse,
ablageort: fertigOrdner,
datum: startZeit.toISOString(),
dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000),
posterPfad: zuordnung?.meta.posterPfad ?? '',
})
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:
`Fertig — komprimiert nach ${fertigOrdner}.` +
(refresh.length > 0 ? ` ${refresh}` : '') +
lesefehlerHinweis,
dateien: fertigeDateien,
lesefehler: rip.lesefehler,
`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,
})
}
@@ -435,13 +499,7 @@ export class Pipeline {
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) {
try {
groesse += statSync(datei).size
} catch {
// Anzeige-Komfort — siehe oben.
}
}
for (const datei of dateien) groesse += groesseVon(datei)
this.umgebung.bibliothekEintragen({
fingerprint,
titel: ordnerName,
@@ -504,22 +562,53 @@ export class Pipeline {
}
}
function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string {
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 = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] })
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') }))
})
}
// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon
// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken.
export function kopierenStattEncode(eingabe: string, ausgabe: string): void {
copyFileSync(eingabe, ausgabe)
}
@@ -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
}
}
}
+708 -97
View File
@@ -3,19 +3,44 @@
// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5),
// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort.
//
// Seit 5.4.0 dazu: Vorgänge als Datensätze (welcher Roh-Ordner gehört zu
// welchem Film), die Kompressions-Warteschlange (das Laufwerk ist frei,
// sobald die Roh-Dateien liegen), das Roh-Aufräumen im Takt, die
// Wiederaufnahme nach einem Neustart, die Laufwerks-Prüfung aus dem
// Windows-Protokoll und die Nachprüfung der Erkennung nach dem Info-Lauf.
//
// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess«
// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen
// Beweis beweise/sqlite-main.js.
import type { MessagePortMain } from 'electron'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import {
istFensterNachricht,
istHauptNachricht,
type DbStatus,
type DiscInfoStand,
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type VorgaengeStand,
type VorgangEintrag,
type WacheEreignis,
} from '../gemeinsam/nachrichten'
import { komprimierenUndAblegen, type AblageUmgebung } from './ablauf/ablegen'
import { auftragAusJson, auftragZuJson } from './ablauf/auftrag'
import { Pipeline } from './ablauf/pipeline'
import { KompressionsWarteschlange } from './ablauf/warteschlange'
import {
AUFBEWAHRUNG_SCHLUESSEL,
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
ordnerGroesse,
regelLesen,
regelText,
} from './ablage/rohaufraeumen'
import type { LaufwerkApi } from './laufwerk/codes'
import {
audioSpurLesen,
@@ -25,13 +50,17 @@ import {
kennung,
listOptischeLaufwerke,
} from './laufwerk/disc'
import { beurteilen, ereignisseLesen } from './laufwerk/ereignisse'
import { DiscWache } from './laufwerk/wache'
import { discFingerprint, discTitel } from './metadaten/discmerkmale'
import { discFingerprint, discTitel, type DiscTitel } from './metadaten/discmerkmale'
import { OmdbClient } from './metadaten/omdb'
import { TmdbClient } from './metadaten/tmdb'
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from './metadaten/zuordnung'
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { Datenbank, type Vorgang } from './speicher/db'
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
import { sprachenFuerTitel } from './rip/parser'
import { strukturHinweis, titelVorauswahl } from './rip/titelwahl'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
@@ -70,6 +99,8 @@ if (pfad === null) {
}
}
const smokeKern = process.argv.includes('--smoke-kern')
let fensterPort: MessagePortMain | null = null
function anFenster(nachricht: KernNachricht): void {
@@ -81,6 +112,11 @@ function anHauptUndFenster(nachricht: KernNachricht): void {
anFenster(nachricht)
}
function hinweis(text: string): void {
console.log(`[kern] ${text}`)
anFenster({ art: 'kern-hinweis', text })
}
// ── Laufwerke (W-1) ─────────────────────────────────────────────────────
// koffi wird erst HIER geladen, über einen echten dynamischen import():
// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein
@@ -94,32 +130,60 @@ let wache: DiscWache | null = null
// ── Metadaten (W-4): Was liegt da drin? ─────────────────────────────────
const discZuordnungen = new Map<string, Zuordnung>()
const discFingerprints = new Map<string, string>()
// Was die Disc über sich selbst sagt (Disc-Nummer, Staffel, Folgenzahl) —
// das bleibt wahr, auch wenn der Commander den Titel selbst neu wählt.
const discMerkmale = new Map<string, DiscTitel>()
/** EINE Stelle, die aus Zuordnung + Disc-Merkmalen den Stand fürs Fenster
* baut (5.4.0) vorher stand dieselbe Struktur dreimal im Code, und jedes
* neue Feld hätte dreimal nachgezogen werden müssen. */
function discInfoStand(id: string, zuordnung: Zuordnung | null, quelle?: string): DiscInfoStand {
const m = discMerkmale.get(id)
const meta = zuordnung?.meta
return {
id,
titel: zuordnung?.titel ?? '',
jahr: zuordnung?.jahr ?? null,
confidence: zuordnung?.confidence ?? 0,
quelle: quelle ?? meta?.quelle ?? '',
posterPfad: meta?.posterPfad ?? '',
backdropPfad: meta?.backdropPfad ?? '',
beschreibung: meta?.beschreibung ?? '',
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: zuordnung === null ? null : (db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null),
discNr: m?.discNr ?? null,
staffel: m?.staffel ?? null,
folgen: m?.folgen ?? 0,
typ: meta?.typ === 'movie' || meta?.typ === 'tv' ? meta.typ : '',
logoPfad: meta?.logoPfad ?? '',
tagline: meta?.tagline ?? '',
bewertung: meta?.bewertung ?? 0,
fsk: meta?.fsk ?? '',
laufzeitMinuten: meta?.laufzeitMinuten ?? 0,
genres: meta?.genres ?? [],
besetzung: meta?.besetzung ?? [],
hinweise: zuordnung?.hinweise ?? [],
}
}
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
try {
const gefunden = discTitel(geraetepfad)
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
discFingerprints.set(id, fingerprint)
discMerkmale.set(id, gefunden)
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: zuordnung.confidence,
quelle: zuordnung.meta.quelle,
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
// § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal
// durchgelaufen ist — und fragt nach, statt stumm zu doppeln.
schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null,
},
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen. Und jede
// Titel-Variante der Disc (bdmt_deu) wird mitgefragt (5.4.0).
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, {
istSerie: gefunden.istSerie,
alternativen: gefunden.alternativen,
})
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung) })
} catch (fehler) {
// R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt
// werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter).
@@ -172,54 +236,53 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const details =
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
const zuordnung: Zuordnung = {
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta: {
const meta =
details !== null
? ausDetails(vorschlag.typ, vorschlag.tmdbId, details)
: {
...LEERER_TREFFER,
typ: vorschlag.typ,
id: vorschlag.tmdbId,
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
beschreibung: details?.overview ?? '',
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
backdropPfad: details?.backdrop_path ?? '',
laufzeitMinuten: details?.runtime ?? 0,
genres: (details?.genres ?? []).map((g) => g.name),
quelle: 'tmdb',
},
titel: vorschlag.titel,
jahr: vorschlag.jahr,
posterPfad: vorschlag.posterPfad,
quelle: 'tmdb' as const,
}
const zuordnung: Zuordnung = {
titel: meta.titel.length > 0 ? meta.titel : vorschlag.titel,
jahr: meta.jahr ?? vorschlag.jahr,
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
confidence: 1,
meta,
}
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
info: {
id,
titel: zuordnung.titel,
jahr: zuordnung.jahr,
confidence: 1,
quelle: 'deine Wahl',
posterPfad: zuordnung.meta.posterPfad,
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
},
})
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, zuordnung, 'deine Wahl') })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
}
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
// Das nachgebaute Laufwerk des Klick-Beweises (bau/klick-smoke.mjs): Ein
// Buchstabe, den es nicht gibt (Q) — die Disc-Merkmale finden dann nichts,
// und makemkvcon/HandBrake sind Node-Skripte (werkzeuge/aufruf.ts). Leer im
// normalen Betrieb; die Win32-Schicht bleibt dann der einzige Weg.
const FAKE_LAUFWERK = (process.env['RIPPY_FAKE_LAUFWERK'] ?? '').replace(/:$/, '').toUpperCase()
function fakeGeraet(): GeraeteInfo {
return {
id: FAKE_LAUFWERK,
name: `Laufwerk ${FAKE_LAUFWERK}:`,
typ: 'bluray',
pfad: `\\\\.\\${FAKE_LAUFWERK}:`,
status: 'ready',
modell: 'NACHGEBAUT (KLICK-BEWEIS)',
seriennummer: 'KLICK',
groesseBytes: 33_759_100_928,
grund: '',
}
const { win32Api } = await import('./laufwerk/win32')
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
(meldung) => {
}
function wacheMeldung(meldung: { laufwerke: GeraeteInfo[]; ereignisse: WacheEreignis[] }): void {
anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse })
for (const ereignis of meldung.ereignisse) {
if (ereignis.art === 'disc-eingelegt') {
@@ -227,29 +290,35 @@ const laufwerkeBereit: Promise<void> = (async () => {
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
id: ereignis.geraet.id,
titel: '',
jahr: null,
confidence: 0,
quelle: '',
posterPfad: '',
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
},
})
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(ereignis.geraet.id, null) })
}
}
},
(fehler) => {
}
function wacheFehler(fehler: unknown): void {
// R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte
// Stand im Fenster bleibt stehen (R2), die Wache läuft weiter.
console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`)
anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` })
},
}
const laufwerkeBereit: Promise<void> = (async () => {
try {
if (FAKE_LAUFWERK.length > 0) {
wache = new DiscWache(() => [fakeGeraet()], wacheMeldung, wacheFehler)
wache.starten()
return
}
if (process.platform !== 'win32') {
throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).')
}
const { win32Api } = await import('./laufwerk/win32')
laufwerkApi = win32Api()
wache = new DiscWache(
() => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)),
wacheMeldung,
wacheFehler,
)
wache.starten()
} catch (fehler) {
@@ -258,17 +327,160 @@ const laufwerkeBereit: Promise<void> = (async () => {
}
})()
// ── Rippen (W-2) ────────────────────────────────────────────────────────
// Die Ablage kommt aus der EINEN Einstellungsquelle (§ 6.7); solange dort
// nichts steht, gilt Videos\Rippy im Benutzerprofil.
/** Der Geräte-Stand zu einer Kennung — echt oder nachgebaut. */
function geraetInfoVon(id: string): GeraeteInfo | null {
if (FAKE_LAUFWERK.length > 0) return id === FAKE_LAUFWERK ? fakeGeraet() : null
const geraet = geraetVon(id)
if (geraet === null || laufwerkApi === null) return null
return geraeteInfo(laufwerkApi, geraet)
}
// ── Titel der Disc lesen (§ 6.2) ────────────────────────────────────────
// Der Info-Lauf dauert 20120 s — deshalb geschieht er erst auf Zuruf und
// nicht schon beim Einlegen (der 30.08.-Fund: „das Erkennen dauert sehr
// sehr lange"). Das Fenster bekommt sofort `laeuft: true`, damit es in der
// Zeit nicht stumm dasteht (R2).
// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf
// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam
// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf
// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon
// auf derselben Disc bremsen sich außerdem gegenseitig aus.
const titelAbbrueche = new Map<string, AbortController>()
let titelLaufZaehler = 0
const titelLaufNummern = new Map<string, number>()
async function titelLesen(id: string): Promise<void> {
const leer = { id, titel: [], einordnung: '', unsicher: false }
if (ripVerwaltung.laeuft(id)) {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: 'Auf diesem Laufwerk läuft schon ein Vorgang.' } })
return
}
const geraet = geraetInfoVon(id)
if (geraet === null) {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
return
}
const geraetepfad = geraet.pfad
const programm = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
if (programm.length === 0) {
anFenster({
art: 'disc-titel',
stand: { ...leer, laeuft: false, fehler: 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert?' },
})
return
}
// Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei,
// und seine Antwort wird verworfen (nur die neueste Nummer zählt).
titelAbbrueche.get(id)?.abort()
const abbruch = new AbortController()
titelAbbrueche.set(id, abbruch)
const meineNummer = ++titelLaufZaehler
titelLaufNummern.set(id, meineNummer)
const nochAktuell = (): boolean => titelLaufNummern.get(id) === meineNummer
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } })
try {
const info = await titelInfoLesen(programm, geraetepfad, 300_000, abbruch.signal)
if (!nochAktuell()) return
const merkmale = discMerkmale.get(id)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale?.istSerie ?? false,
folgen: merkmale?.folgen ?? 0,
})
// Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der
// Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026
// gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit
// TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe
// nichts her — dabei sieht MakeMKV das LAUFWERK nicht.
const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0
anFenster({
art: 'disc-titel',
stand: {
id,
laeuft: false,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie
// zum Anhaken an.
titel: wahl.titel.map((t) => ({ ...t, sprachen: sprachenFuerTitel(info.streams.get(t.nr)) })),
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
},
})
// Erkennung v2 (5.4.0): Jetzt liegt die Laufzeit vor — nachprüfen.
if (info.titel.length > 0) await erkennungNachpruefen(id, wahl, info.titel)
} catch (fehler) {
// Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist
// Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund —
// der alte Lauf überschrieb sonst die längst gelieferte Titelliste.
if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return
// R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes
// als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn.
const grund = String(fehler).includes('keine Antwort nach')
? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.'
: `Titel lesen: ${String(fehler).slice(0, 200)}`
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: grund } })
} finally {
// Nur aufräumen, wenn ICH noch der aktuelle Lauf bin.
if (nochAktuell()) {
titelAbbrueche.delete(id)
titelLaufNummern.delete(id)
}
}
}
/** Erkennung v2 (5.4.0): Erkannt wird beim EINLEGEN, vor jeder Messung.
* Nach dem Info-Lauf wird noch einmal gerechnet Laufzeit gegen TMDb,
* Titel-Struktur gegen den Treffer-Typ. Aus wahrscheinlich" wird
* bestätigt", oder Rippy merkt selbst, dass es danebenlag. */
async function erkennungNachpruefen(
id: string,
wahl: ReturnType<typeof titelVorauswahl>,
titel: Parameters<typeof strukturHinweis>[0],
): Promise<void> {
const bisher = discZuordnungen.get(id)
if (bisher === undefined) return
const gewaehlt = wahl.titel.filter((t) => t.gewaehlt)
const folgen = gewaehlt.filter((t) => t.rolle === 'folge').map((t) => t.dauerS)
const laengster = [...titel].sort((a, b) => b.dauerS - a.dauerS)[0]?.dauerS ?? 0
const messung = {
hauptS: folgen.length > 0 ? 0 : (gewaehlt[0]?.dauerS ?? laengster),
folgenS: folgen,
struktur: strukturHinweis(titel),
}
let zuordnung = bisher
// Sagt die Struktur „Serie", der Treffer aber „Film" (und der Nutzer hat
// nicht selbst gewählt): einmal als Serie fragen — das ist der
// Fingerabdruck, der Serien auch ohne bdmt erkennt.
if (messung.struktur === 'serie' && bisher.meta.typ !== 'tv' && bisher.confidence < 1) {
const merkmale = discMerkmale.get(id)
if (merkmale !== undefined && merkmale.titel.length > 0) {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
const nochmal = await zuordnen(merkmale.titel, tmdb, omdb, { istSerie: true, alternativen: merkmale.alternativen })
if (nochmal.meta.typ === 'tv') {
zuordnung = { ...nochmal, hinweise: ['Serie erkannt an der Titel-Struktur (mehrere gleich lange Titel).'] }
}
}
}
const geprueft = nachpruefen(zuordnung, messung)
const hinweise = [...(zuordnung.hinweise ?? []), ...(geprueft.hinweise ?? [])]
const neu = { ...geprueft, hinweise }
discZuordnungen.set(id, neu)
anHauptUndFenster({ art: 'disc-info', info: discInfoStand(id, neu) })
}
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy')
}
function rohWurzel(): string {
return join(ablageOrdner(), 'roh')
}
// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN
// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults.
// Quelle (§ 6.7).
const PIPELINE_EINSTELLUNGEN = [
'transcodeEnabled',
'transcodePreset',
@@ -296,6 +508,274 @@ function geraetVon(id: string): string | null {
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
}
// ── Vorgänge (5.4.0): Datensatz je Rip, Warteschlange, Aufräumen ────────
function dateienAusJson(text: string): string[] {
const liste: string[] = []
try {
const roh: unknown = JSON.parse(text)
if (Array.isArray(roh)) liste.push(...roh.map(String))
} catch (fehler) {
// R4: Ein unlesbarer Eintrag wird gesagt — und zählt als „keine
// fertigen Dateien bekannt", womit das Aufräumen ihn nie anfasst.
console.error(`[kern] fertige Dateien nicht lesbar: ${String(fehler)}`)
}
return liste
}
const ablageUmgebung: AblageUmgebung = {
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
// Staffel-Laufzeiten von TMDb — Grundlage der Episoden-Zuordnung (§ 4.2).
episodenLaufzeiten: async (tmdbId, staffel) => {
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
if (!tmdb.verfuegbar) return []
const antwort = await tmdb.tvStaffel(tmdbId, staffel)
const paare: Array<[number, number]> = []
for (const ep of antwort?.episodes ?? []) {
if (typeof ep.runtime === 'number' && ep.runtime > 0) paare.push([ep.episode_number, ep.runtime * 60])
}
return paare
},
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
}
},
}
/** Nur bei einer PHASEN-Änderung in die Datenbank nicht bei jedem
* Prozent-Tick. */
const letztePhase = new Map<string, string>()
const warteschlange = new KompressionsWarteschlange(
(auftrag, melden, abbruch) => komprimierenUndAblegen(auftrag, ablageUmgebung, melden, abbruch),
(stand) => {
const l = stand.laeuft
if (l !== null && letztePhase.get(l.vorgangId) !== l.phase) {
letztePhase.set(l.vorgangId, l.phase)
db?.vorgangAktualisieren(l.vorgangId, { phase: l.phase === 'legt-ab' ? 'legt-ab' : 'komprimiert' })
}
vorgaengeSenden()
},
(auftrag, ergebnis) => {
letztePhase.delete(auftrag.vorgangId)
const jetzt = new Date().toISOString()
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: ergebnis.status,
beendet: jetzt,
zielOrdner: ergebnis.zielOrdner,
fertigBytes: ergebnis.fertigBytes,
komprimiert: ergebnis.komprimiert,
preset: ergebnis.preset,
fehler: ergebnis.status === 'fertig' ? '' : ergebnis.text,
fertigeDateien: JSON.stringify(ergebnis.dateien),
})
const vorgang = db?.vorgangLesen(auftrag.vorgangId)
const titel = vorgang?.titel ?? auftrag.vorgangId
const text = ergebnis.text + (ergebnis.hinweise.length > 0 ? ` · ${ergebnis.hinweise.join(' · ')}` : '')
anHauptUndFenster({
art: 'kompression-ergebnis',
vorgangId: auftrag.vorgangId,
titel,
ok: ergebnis.status === 'fertig',
text,
})
// Regel „sofort": direkt nach der gelungenen Kompression aufräumen.
if (ergebnis.status === 'fertig') rohAufraeumenLauf()
else vorgaengeSenden()
},
)
function vorgaengeStand(): VorgaengeStand {
const regel = regelLesen(db?.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const liste = db?.vorgangListe() ?? []
let rohGesamtBytes = 0
const vorgaenge: VorgangEintrag[] = liste.map((v) => {
const rohDa = v.rohGeloeschtAm === null && existsSync(v.rohOrdner)
const rohBytes = rohDa ? ordnerGroesse(v.rohOrdner) : 0
rohGesamtBytes += rohBytes
const auftrag = auftragAusJson(v.auftrag)
const ruhig = v.phase === 'fertig' || v.phase === 'fehler' || v.phase === 'abgebrochen'
return {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: v.phase,
begonnen: v.begonnen,
beendet: v.beendet,
rohOrdner: v.rohOrdner,
rohBytes,
rohGeloescht: !rohDa,
zielOrdner: v.zielOrdner,
fertigBytes: v.fertigBytes,
komprimiert: v.komprimiert,
preset: v.preset,
fehler: v.fehler,
titelZahl: auftrag?.dateien.length ?? 0,
neuKomprimierbar: rohDa && ruhig && auftrag !== null && auftrag.dateien.length > 0 && !warteschlange.kennt(v.id),
faelligAm: v.phase === 'fertig' && v.komprimiert && rohDa ? faelligAm(v.beendet, regel) : '',
}
})
const bekannte = new Set(liste.map((v) => v.rohOrdner))
const fremde = fremdeOrdner(rohWurzel(), bekannte)
for (const f of fremde) rohGesamtBytes += f.bytes
return { vorgaenge, rohGesamtBytes, fremde, regel: regelText(regel), kompression: warteschlange.stand() }
}
function vorgaengeSenden(): void {
try {
anHauptUndFenster({ art: 'vorgaenge', stand: vorgaengeStand() })
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Vorgänge: ${String(fehler)}` })
}
}
function rohAufraeumenLauf(): void {
if (db === null) return
try {
const regel = regelLesen(db.einstellungLesen(AUFBEWAHRUNG_SCHLUESSEL))
const kandidaten = db.vorgangListe().map((v) => ({
id: v.id,
phase: v.phase,
komprimiert: v.komprimiert,
rohOrdner: v.rohOrdner,
rohGeloeschtAm: v.rohGeloeschtAm,
beendet: v.beendet,
fertigeDateien: dateienAusJson(v.fertigeDateien),
}))
const ergebnis = aufraeumen(kandidaten, regel, new Date())
const jetzt = new Date().toISOString()
for (const id of ergebnis.geloescht) {
db.vorgangAktualisieren(id, { rohGeloeschtAm: jetzt, rohBytes: 0 })
hinweis(`Roh-Dateien von „${db.vorgangLesen(id)?.titel ?? id}" gelöscht (${regelText(regel)})`)
}
for (const f of ergebnis.fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von ${f.id} nicht gelöscht: ${f.grund}` })
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Aufräumen: ${String(fehler)}` })
}
vorgaengeSenden()
}
function vorgangRohLoeschen(vorgangId: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (v.phase === 'rippt' || v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab' || warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" läuft noch (${v.phase}) — erst abbrechen, dann löschen.` })
return
}
if (!istUnterRohWurzel(rohWurzel(), v.rohOrdner)) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" liegt nicht unter ${rohWurzel()} — nicht angefasst.` })
return
}
try {
rmSync(v.rohOrdner, { recursive: true, force: true })
db?.vorgangAktualisieren(vorgangId, { rohGeloeschtAm: new Date().toISOString(), rohBytes: 0 })
hinweis(`Roh-Dateien von „${v.titel}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `Roh-Ordner von „${v.titel}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
function fremdenOrdnerLoeschen(ordner: string): void {
if (!istUnterRohWurzel(rohWurzel(), ordner)) {
anFenster({ art: 'kern-fehler', text: `${ordner}" liegt nicht direkt unter ${rohWurzel()} — nicht angefasst.` })
return
}
const bekannt = (db?.vorgangListe() ?? []).some((v) => v.rohOrdner.toLowerCase() === ordner.toLowerCase())
if (bekannt) {
anFenster({ art: 'kern-fehler', text: `${ordner}" gehört zu einem Vorgang — dort löschen.` })
return
}
try {
rmSync(ordner, { recursive: true, force: true })
hinweis(`Ordner „${ordner}" gelöscht (von Hand).`)
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `${ordner}" nicht gelöscht: ${String(fehler)}` })
}
vorgaengeSenden()
}
/** „Wiederholen ab Kompression": Die Roh-Dateien liegen genau dafür da. */
function vorgangNeuKomprimieren(vorgangId: string, preset: string): void {
const v = db?.vorgangLesen(vorgangId)
if (v === null || v === undefined) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${vorgangId}: unbekannt.` })
return
}
if (warteschlange.kennt(vorgangId)) {
anFenster({ art: 'kern-fehler', text: `${v.titel}" ist schon in der Warteschlange.` })
return
}
const auftrag = auftragAusJson(v.auftrag)
if (auftrag === null) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Der Auftrag ist nicht mehr lesbar — nur ein neuer Rip hilft.` })
return
}
const vorhandene = auftrag.dateien.filter((d) => existsSync(d.pfad))
if (vorhandene.length === 0) {
anFenster({ art: 'kern-fehler', text: `${v.titel}": Die Roh-Dateien sind nicht mehr da (${v.rohOrdner}).` })
return
}
if (vorhandene.length < auftrag.dateien.length) {
hinweis(`${v.titel}": ${auftrag.dateien.length - vorhandene.length} Roh-Datei(en) fehlen — der Rest wird komprimiert.`)
}
const neu = {
...auftrag,
dateien: vorhandene,
preset,
// Die neue Fassung ersetzt die alte im selben Ordner.
zielOrdner: v.phase === 'fertig' && v.zielOrdner.length > 0 && existsSync(v.zielOrdner) ? v.zielOrdner : '',
}
db?.vorgangAktualisieren(vorgangId, { phase: 'wartet', fehler: '', auftrag: auftragZuJson(neu) })
const platz = warteschlange.einreihen(neu, v.titel)
hinweis(`${v.titel}": Kompression eingereiht${platz > 1 ? ` (Platz ${platz})` : ''}${preset.length > 0 ? ` mit Preset „${preset}"` : ''}.`)
vorgaengeSenden()
}
/** Nach einem Neustart: Was wartete noch? Was war mitten im Rip? */
function vorgaengeWiederaufnehmen(): void {
if (db === null) return
const jetzt = new Date().toISOString()
for (const v of db.vorgangListe()) {
if (v.phase === 'rippt') {
db.vorgangAktualisieren(v.id, {
phase: 'abgebrochen',
beendet: jetzt,
fehler: 'Rippy wurde beendet, bevor der Rip fertig war — die Roh-Dateien können unvollständig sein.',
})
continue
}
if (v.phase === 'wartet' || v.phase === 'komprimiert' || v.phase === 'legt-ab') {
const auftrag = auftragAusJson(v.auftrag)
if (auftrag !== null && existsSync(v.rohOrdner) && auftrag.dateien.some((d) => existsSync(d.pfad))) {
warteschlange.einreihen({ ...auftrag, dateien: auftrag.dateien.filter((d) => existsSync(d.pfad)) }, v.titel)
hinweis(`${v.titel}": Kompression nach dem Neustart wieder aufgenommen.`)
} else {
db.vorgangAktualisieren(v.id, {
phase: 'fehler',
beendet: jetzt,
fehler: 'Nach dem Neustart fehlen die Roh-Dateien — die Kompression kann nicht wieder aufgenommen werden.',
})
}
}
}
}
const ripVerwaltung = new Pipeline({
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
@@ -310,29 +790,96 @@ const ripVerwaltung = new Pipeline({
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
},
geraet: (id) => {
if (laufwerkApi === null) return null
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
},
geraet: geraetInfoVon,
ablage: ablageOrdner,
einstellungen: einstellungenLaden,
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
melden: (status) => {
anHauptUndFenster({ art: 'rip-status', status })
// Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows-
// Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8)
if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerkPruefen(status.id)
},
zuordnung: (id) => discZuordnungen.get(id) ?? null,
discAngaben: (id) => {
const m = discMerkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
},
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
db?.bibliothekEintragen(eintrag)
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
} catch (fehler) {
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
// gesagt.
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
bibliothekEintragen: ablageUmgebung.bibliothekEintragen,
vorgangBeginnt: (v) => {
const vorgang: Vorgang = {
id: v.id,
laufwerk: v.laufwerk,
typ: v.typ,
titel: v.titel,
jahr: v.jahr,
phase: 'rippt',
begonnen: v.begonnen,
beendet: null,
rohOrdner: v.rohOrdner,
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
db?.vorgangAnlegen(vorgang)
vorgaengeSenden()
},
vorgangEinreihen: (auftrag, titel) => {
db?.vorgangAktualisieren(auftrag.vorgangId, {
phase: 'wartet',
titel,
jahr: auftrag.zuordnung?.jahr ?? null,
rohBytes: auftrag.dateien.reduce((s, d) => s + d.groesseBytes, 0),
lesefehler: auftrag.lesefehler,
auftrag: auftragZuJson(auftrag),
})
const platz = warteschlange.einreihen(auftrag, titel)
vorgaengeSenden()
return platz
},
vorgangEndet: (id, phase, text) => {
db?.vorgangAktualisieren(id, { phase, beendet: new Date().toISOString(), fehler: text })
vorgaengeSenden()
},
nachRipAuswerfen: (id) => {
// Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk.
if (db?.einstellungLesen('autoAuswurf') === 'false') return
void auswerfen(id)
},
})
// ── Laufwerks-Gesundheit (5.4.0, Punkt 8) ───────────────────────────────
const GESUNDHEIT_STUNDEN = 24
async function laufwerkPruefen(id: string): Promise<void> {
if (process.platform !== 'win32') return
const ergebnis = await ereignisseLesen(GESUNDHEIT_STUNDEN)
if ('fehler' in ergebnis) {
anFenster({
art: 'laufwerk-gesundheit',
id,
bericht: {
urteil: 'nicht-lesbar',
text: `Das Windows-Protokoll ließ sich nicht lesen: ${ergebnis.fehler}`,
fehlerhafteBloecke: 0,
controllerfehler: 0,
sonstige: 0,
letzte: [],
stunden: GESUNDHEIT_STUNDEN,
},
})
return
}
anFenster({ art: 'laufwerk-gesundheit', id, bericht: beurteilen(ergebnis.ereignisse, GESUNDHEIT_STUNDEN) })
}
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
const ERLAUBTE_EINSTELLUNGEN = new Set([
@@ -356,6 +903,7 @@ const ERLAUBTE_EINSTELLUNGEN = new Set([
'werkzeug.makemkv',
'werkzeug.handbrake',
'werkzeug.flac',
AUFBEWAHRUNG_SCHLUESSEL,
])
function einstellungenSenden(): void {
@@ -392,6 +940,25 @@ async function schluesselLauf(): Promise<void> {
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
/** MakeMKVs Spur-Auswahlregel sicherstellen (5.4.0) sonst fehlen
* Sprachen in der Roh-Datei, die der Dialog anbot. */
async function spurauswahlLauf(): Promise<void> {
if (process.platform !== 'win32') return
try {
const { spurauswahlSicherstellen } = await import('./werkzeuge/spurauswahl')
const stand = spurauswahlSicherstellen()
if (stand.stand === 'eigen' || stand.stand === 'fehler') {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${stand.text}` })
} else if (stand.stand === 'gesetzt') {
hinweis(stand.text)
} else {
console.log(`[kern] ${stand.text}`)
}
} catch (fehler) {
anFenster({ art: 'kern-fehler', text: `MakeMKV-Auswahlregel: ${String(fehler)}` })
}
}
// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ────────────
// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt
// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der
@@ -559,15 +1126,31 @@ process.parentPort.on('message', (ereignis) => {
if (fensterPort === null) return
fensterPort.on('message', (portEreignis) => {
const frage: unknown = portEreignis.data
if (!istFensterNachricht(frage)) return
if (!istFensterNachricht(frage)) {
// R4: Eine verworfene Nachricht verschwindet NICHT still. Genau
// das kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im
// Prüfer, der Kern warf sie weg, und im Fenster stand ewig
// „Rippy liest die Titel …", ohne dass irgendwo etwas stand.
const art =
typeof frage === 'object' && frage !== null
? String((frage as Record<string, unknown>).art)
: typeof frage
console.error(`[kern] Fenster-Nachricht verworfen (unbekannt): ${art}`)
anFenster({ art: 'kern-fehler', text: `Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.` })
return
}
if (frage.art === 'ping') {
anFenster({ art: 'pong', zeit: frage.zeit })
} else if (frage.art === 'auswerfen') {
void auswerfen(frage.id)
} else if (frage.art === 'titel-lesen') {
void titelLesen(frage.id)
} else if (frage.art === 'rip-start') {
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, frage.auftrag ?? [], frage.optionen ?? {}))
} else if (frage.art === 'rip-abbruch') {
ripVerwaltung.abbrechen(frage.id)
} else if (frage.art === 'rip-ueberspringen') {
ripVerwaltung.ueberspringen(frage.id)
} else if (frage.art === 'metadaten-suchen') {
void metadatenSuchen(frage.id, frage.text)
} else if (frage.art === 'zuordnung-setzen') {
@@ -580,6 +1163,8 @@ process.parentPort.on('message', (ereignis) => {
} else {
db.einstellungSchreiben(frage.schluessel, frage.wert)
einstellungenSenden()
// Eine neue Aufbewahrungs-Regel wirkt sofort auf die Anzeige.
if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaengeSenden()
}
} else if (frage.art === 'einstellungen-laden') {
einstellungenSenden()
@@ -591,6 +1176,22 @@ process.parentPort.on('message', (ereignis) => {
void makemkvUpdateLauf()
} else if (frage.art === 'schluessel-pruefen') {
void schluesselLauf().then(() => werkzeugeSenden())
} else if (frage.art === 'vorgaenge-laden') {
vorgaengeSenden()
} else if (frage.art === 'vorgang-roh-loeschen') {
vorgangRohLoeschen(frage.vorgangId)
} else if (frage.art === 'vorgang-neu-komprimieren') {
vorgangNeuKomprimieren(frage.vorgangId, frage.preset ?? '')
} else if (frage.art === 'kompression-abbrechen') {
if (!warteschlange.abbrechen(frage.vorgangId)) {
anFenster({ art: 'kern-fehler', text: `Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.` })
}
} else if (frage.art === 'roh-aufraeumen') {
rohAufraeumenLauf()
} else if (frage.art === 'fremden-ordner-loeschen') {
fremdenOrdnerLoeschen(frage.ordner)
} else if (frage.art === 'laufwerk-pruefen') {
void laufwerkPruefen(frage.id)
}
})
fensterPort.start()
@@ -605,6 +1206,7 @@ process.parentPort.on('message', (ereignis) => {
anFenster({ art: 'schluessel-status', stand: schluesselStand })
}
})
vorgaengeSenden()
}
})
@@ -620,11 +1222,20 @@ function bereitNachricht(): KernNachricht {
process.parentPort.postMessage(bereitNachricht())
// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster.
einstellungenSenden()
// Was nach dem letzten Beenden noch offen war (5.4.0).
vorgaengeWiederaufnehmen()
// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an —
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
if (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
void schluesselLauf()
void spurauswahlLauf()
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
void werkzeugeSenden()
}
// Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und
// nach jeder gelungenen Kompression (siehe Warteschlange).
if (!smokeKern) {
setTimeout(() => rohAufraeumenLauf(), 60_000).unref()
setInterval(() => rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref()
}
@@ -7,7 +7,7 @@
// 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 { spawn } from 'node:child_process'
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
@@ -67,7 +67,7 @@ export function parsePresetListe(text: string): string[] {
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
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))
@@ -15,7 +15,7 @@
// 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 { spawn } from 'node:child_process'
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'
@@ -30,8 +30,23 @@ export interface HandbrakeArgsOptionen {
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()]
@@ -45,6 +60,9 @@ export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
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
}
@@ -224,6 +242,7 @@ export interface HbLaufOptionen {
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
@@ -234,7 +253,7 @@ export function komprimieren(optionen: HbLaufOptionen): Promise<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const kind = werkzeugSpawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const auswertung = new HbAuswertung()
const leser = new HbZeilenLeser()
+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)),
}
}
@@ -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) })
})
})
}
+196 -28
View File
@@ -105,29 +105,149 @@ export function udfVolumeLabel(geraetepfad: string): string {
}
}
/** Der Studio-Titel aus BDMV/META/DL/bdmt_*.xml bdmt_eng bevorzugt,
* Regex statt XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
export function titelAusBdmt(wurzel: string): string {
/** 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 ''
if (!existsSync(metaOrdner)) return leer
let kandidaten: string[]
try {
kandidaten = readdirSync(metaOrdner).sort()
} catch {
return ''
return leer
}
const bdmt =
kandidaten.find((k) => k === 'bdmt_eng.xml') ??
kandidaten.find((k) => k.startsWith('bdmt_') && k.endsWith('.xml'))
if (bdmt === undefined) return ''
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 {
return ''
continue
}
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
return treffer === null ? '' : treffer[1].trim()
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. */
@@ -138,22 +258,53 @@ export function discWurzel(geraetepfad: string): string {
}
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[]
}
/** Der beste verfügbare Disc-Titel: BDMT zuerst, sonst Label. Der
* BDMT-Titel wird NICHT normalisiert (2.22" würde zu „2 22"). */
/** 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 = titelAusBdmt(wurzel)
if (bdmt.length > 0) return { titel: bdmt, quelle: 'bdmt' }
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 { titel: normalizeDiscLabel(label), quelle: 'label' }
return { titel: '', quelle: '' }
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
@@ -163,25 +314,42 @@ export function discFingerprint(geraetepfad: string, groesseBytes: number): stri
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): voller Titel zuerst, dann ohne Doppelpunkt-Teil, ohne
* Klammern, dann hintere Worte strippen. */
* 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(/[ .]+$/, '').replace(/^[ .]+/, '')
const sauber = t.split(/\s+/).filter(Boolean).join(' ').replace(nachlaufend, '').replace(/^[ .]+/, '')
if (sauber.length > 0 && !kandidaten.includes(sauber)) kandidaten.push(sauber)
}
merke(titel)
if (titel.includes(':')) {
merke(titel.split(':', 1)[0])
merke(titel.replaceAll(':', ''))
}
merke(titel.replace(/\([^)]*\)/g, ''))
let worte = titel.split(/\s+/).filter(Boolean)
while (worte.length > 1 && kandidaten.length < 6) {
// 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
}
+99 -5
View File
@@ -2,14 +2,50 @@
// 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
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 {
@@ -19,10 +55,22 @@ export interface FilmDetails {
release_date?: string
first_air_date?: string
overview?: string
poster_path?: string
backdrop_path?: 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 {
@@ -31,6 +79,44 @@ export function istV4Token(key: string): boolean {
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,
@@ -84,11 +170,19 @@ export class TmdbClient {
}
async movieDetails(id: number): Promise<FilmDetails | null> {
return (await this.anfrage(`movie/${id}`, { language: 'de-DE' })) as 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' })) as 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. */
+309 -56
View File
@@ -12,9 +12,24 @@
//
// Rippy sagt nicht „das ist Akira", sondern „sehr wahrscheinlich Akira
// (1988)" — und fragt nur, wenn es das nicht sagen kann (§ 5).
import { titelKandidaten } from './discmerkmale'
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
//
// 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
@@ -30,6 +45,14 @@ export interface MetaTreffer {
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 {
@@ -37,6 +60,8 @@ export interface Zuordnung {
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 {
@@ -45,7 +70,11 @@ function jahrAus(datum: string | undefined): number | null {
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
}
function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): MetaTreffer {
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,
@@ -54,9 +83,15 @@ function ausDetails(typ: 'movie' | 'tv', id: number, details: FilmDetails): Meta
beschreibung: details.overview ?? '',
posterPfad: details.poster_path ?? '',
backdropPfad: details.backdrop_path ?? '',
laufzeitMinuten: details.runtime ?? 0,
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,
}
}
@@ -73,17 +108,19 @@ function ausOmdb(treffer: OmdbTreffer): MetaTreffer {
laufzeitMinuten: treffer.runtime,
genres: treffer.genres,
quelle: 'omdb',
logoPfad: '',
tagline: '',
bewertung: 0,
fsk: '',
besetzung: [],
folgenGesamt: 0,
}
}
const UNBEKANNT = (titel: string): Zuordnung => ({
titel,
jahr: null,
confidence: 0.3,
meta: {
export const LEERER_TREFFER: MetaTreffer = {
typ: 'unknown',
id: null,
titel,
titel: '',
jahr: null,
beschreibung: '',
posterPfad: '',
@@ -91,20 +128,46 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
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 },
})
/** 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 worte = (t: string): Set<string> =>
new Set(
/** 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
@@ -113,86 +176,191 @@ export function wortUeberlappung(a: string, b: string): number {
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 (nur beim
* vollen Titel) spezifisch Film spezifisch Serie. Erst dann der nächste,
* gekürzte Kandidat. Der Grund ist eine Live-Messung vom 30.08.2026:
* Beim Disc-Titel Spartacus Gods Of The Arena" erschlug der stark
* gekürzte Kandidat Spartacus" mit einem wörtlichen Treffer (Film von
* 1960!) die richtige Serie, die auf den VOLLEN Titel eindeutig war. */
* 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 kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
const nurSerie = hinweis.istSerie === true
const kandidaten = kandidatenAus(titel, hinweis.alternativen ?? [])
let ersteTreffer: FilmTreffer[] = []
let ersteFilme: FilmTreffer[] = []
let ersteSerien: FilmTreffer[] = []
for (let i = 0; i < kandidaten.length; i++) {
const kandidat = kandidaten[i]
const filme = (await tmdb.searchMovie(kandidat)) ?? []
if (filme.length > 0 && ersteTreffer.length === 0) ersteTreffer = filme
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 ((film.title ?? '').toLowerCase() === kandidat.toLowerCase()) {
if (gleichBedeutend(film.title ?? '', kandidat.text)) {
const details = await tmdb.movieDetails(film.id)
if (details !== null) {
if (details !== null && !istStub(details)) {
const meta = ausDetails('movie', film.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.95, meta }
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.95), meta }
}
}
}
const serien = (await tmdb.searchTv(kandidat)) ?? []
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 ((serie.name ?? '').toLowerCase() === kandidat.toLowerCase()) {
if (gleichBedeutend(serie.name ?? '', kandidat.text)) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null) {
if (details !== null && !istStub(details)) {
const meta = ausDetails('tv', serie.id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.9, meta }
return { titel: meta.titel, jahr: meta.jahr, confidence: deckel(0.9), meta }
}
}
}
// Spezifische Treffer NUR für den vollen, ungekürzten Titel (0,80):
// Wer auf die volle Anfrage eine Handvoll bekommt, hat gefragt wie
// jemand, der weiß, was er sucht (der Evangelion-Fund).
if (i === 0) {
// 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) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
const meta = ausDetails('movie', filme[0].id, details)
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) {
const details = await tmdb.tvDetails(serien[0].id)
if (details !== null) {
const meta = ausDetails('tv', serien[0].id, details)
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)
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, treffer.title) < 0.5) continue
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)
@@ -208,19 +376,36 @@ export async function zuordnen(
if (deutsch['type'] === 'tv') meta.typ = 'tv'
}
}
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.8, meta }
// 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.
if (ersteTreffer.length > 0) {
const details = await tmdb.movieDetails(ersteTreffer[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteTreffer[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, 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)
}
@@ -229,3 +414,71 @@ export async function zuordnen(
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 }
}
+82 -4
View File
@@ -11,13 +11,14 @@
// 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 { spawn } from 'node:child_process'
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,
@@ -38,18 +39,41 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str
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 }
}
@@ -57,6 +81,16 @@ export class RipAuswertung {
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))) {
@@ -103,6 +137,15 @@ export class ZeilenLeser {
}
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[] }
@@ -114,9 +157,10 @@ export function titelInfoLesen(
programm: string,
geraetepfad: string,
timeoutMs = 300_000,
abbruch?: AbortSignal,
): Promise<DiscInfo> {
return new Promise((fertig, scheitert) => {
const kind = spawn(programm, buildInfoArgs(geraetepfad), {
const kind = werkzeugSpawn(programm, buildInfoArgs(geraetepfad), {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
})
@@ -127,16 +171,37 @@ export function titelInfoLesen(
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)
fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) })
// 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) })
})
})
}
@@ -154,6 +219,9 @@ export interface RipLaufOptionen {
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
}
@@ -163,7 +231,7 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
return new Promise((fertig) => {
mkdirSync(optionen.zielOrdner, { recursive: true })
const kind = spawn(
const kind = werkzeugSpawn(
optionen.programm,
buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel),
{ windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] },
@@ -179,12 +247,22 @@ export function rippen(optionen: RipLaufOptionen): Promise<RipErgebnis> {
}
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))
+35 -5
View File
@@ -170,11 +170,10 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
return ergebnis
}
export interface SprachEintrag {
lang: string
sprache: string
spuren: number
}
// 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). */
@@ -200,6 +199,17 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
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
@@ -249,6 +259,26 @@ export function episodenTitel(dauern: Map<number, number>, episodenMinuten: numb
// (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).
+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 }
}
+144 -4
View File
@@ -1,18 +1,53 @@
// 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.
// 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 } from '../../gemeinsam/nachrichten'
import type { BibliothekEintrag, VorgangPhase } from '../../gemeinsam/nachrichten'
export const SCHEMA_VERSION = 3
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
@@ -48,6 +83,27 @@ export class Datenbank {
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
@@ -129,7 +185,91 @@ export class Datenbank {
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,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).',
}
}
+301
View File
@@ -0,0 +1,301 @@
// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit
// nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die
// Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn
// gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler
// lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je
// Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei.
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag'
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung'
const hb = vi.hoisted(() => ({
aufrufe: [] as Array<Record<string, unknown>>,
ergebnis: 'success' as 'success' | 'error' | 'cancelled',
}))
vi.mock('../src/kern/komprimieren/handbrake', async () => {
const fs = await import('node:fs')
return {
komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => {
hb.aufrufe.push({ ...o })
o.aufFortschritt?.(50)
if (hb.ergebnis === 'cancelled') return { status: 'cancelled' }
if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' }
fs.writeFileSync(o.ausgabe, 'encoded')
return { status: 'success', ausgabe: o.ausgabe }
}),
}
})
import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen'
let wegwerf: string[] = []
afterEach(() => {
hb.aufrufe = []
hb.ergebnis = 'success'
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung {
return {
titel,
jahr,
confidence: 0.95,
meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 },
}
}
function aufbau(dateinamen: string[], rollen: Array<RohDatei['rolle']> = []): {
ablage: string
roh: string
dateien: RohDatei[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-'))
wegwerf.push(ablage)
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
mkdirSync(roh, { recursive: true })
const dateien = dateinamen.map((name, i) => {
const pfad = join(roh, name)
writeFileSync(pfad, 'roh'.repeat(10))
return {
pfad,
titelNr: i,
rolle: rollen[i] ?? 'hauptfilm',
dauerS: 3167,
groesseBytes: 30,
audioSprachen: [],
untertitelSprachen: [],
}
})
return { ablage, roh, dateien }
}
function auftrag(teil: Partial<KompressionsAuftrag> & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag {
return {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: 'LABEL|33',
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
...teil,
}
}
function umgebung(ablage: string, überschreiben: Partial<AblageUmgebung> = {}): AblageUmgebung {
return {
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
ablage: () => ablage,
einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }),
jetzt: () => new Date('2026-08-30T13:00:00Z'),
spurenScannen: async () => null,
...überschreiben,
}
}
const KEIN_ABBRUCH = new AbortController().signal
describe('komprimierenUndAblegen', () => {
it('komprimiert in Filme/<Titel (Jahr)> mit NFO und meldet die Phasen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const bibliothek: string[] = []
const phasen: string[] = []
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }),
(s) => phasen.push(s.phase),
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(true)
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)'))
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'title_t00.mkv')])
expect(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira'])
expect(ergebnis.text).toContain('komprimiert nach')
})
it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.komprimiert).toBe(false)
expect(ergebnis.text).toContain('ohne Kompression')
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0)
})
it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, { handbrake: () => '' }),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.komprimiert).toBe(false)
expect(readdirSync(roh)).toEqual([])
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00'))
})
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
hb.ergebnis = 'error'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fehler')
expect(ergebnis.text).toContain('bleibt stehen')
expect(ergebnis.text).toContain(roh)
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => {
hb.ergebnis = 'cancelled'
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('abgebrochen')
expect(existsSync(dateien[0].pfad)).toBe(true)
})
it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].audioSprachen = ['jpn']
dateien[0].untertitelSprachen = ['eng', 'deu']
const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH)
expect(ergebnis.status).toBe('fertig')
expect(hb.aufrufe).toHaveLength(2)
// Datei 0: eigene Wahl — deu nach vorn, Vorwahl an.
expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[0].untertitelStandard).toBe(true)
// Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl.
expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng'])
expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu'])
expect(hb.aufrufe[1].untertitelStandard).toBe(false)
})
it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
dateien[0].audioSprachen = ['jpn']
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, {
spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei')
})
it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({
rohOrdner: roh,
dateien,
zuordnung: zuordnung('tv', 'Spartacus', 2010),
staffel: 1,
ersteFolge: 3,
}),
umgebung(ablage, {
// Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber
// die Hand des Nutzers gilt.
episodenLaufzeiten: async () => [
[3, 3200],
[4, 2000],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.status).toBe('fertig')
expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01'))
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S01E03.mkv',
'Spartacus S01E04.mkv',
])
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min')
expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true)
})
it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
dateien[0].dauerS = 3167
dateien[1].dauerS = 3249
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }),
umgebung(ablage, {
episodenLaufzeiten: async () => [
[1, 3180],
[2, 3240],
],
}),
() => {},
KEIN_ABBRUCH,
)
expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([
'Spartacus S02E01.mkv',
'Spartacus S02E02.mkv',
])
})
it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra'])
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true)
})
it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
const ziel = join(ablage, 'Filme', 'Akira (1988)')
mkdirSync(ziel, { recursive: true })
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }),
umgebung(ablage),
() => {},
KEIN_ABBRUCH,
)
expect(ergebnis.zielOrdner).toBe(ziel)
expect(ergebnis.preset).toBe('Fast 1080p30')
expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)'])
})
it('der Fortschritt ist über beide Dateien gewichtet', async () => {
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'])
dateien[0].groesseBytes = 3
dateien[1].groesseBytes = 1
const prozente: number[] = []
await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH)
// 50 % der ersten (3/4) = 37, 50 % der zweiten = 87.
expect(prozente).toContain(37)
expect(prozente).toContain(87)
})
})
+31
View File
@@ -0,0 +1,31 @@
// Die Werkzeug-Naht (kern/werkzeuge/aufruf.ts, 5.4.0): echte EXE-Pfade
// gehen unverändert durch; Node-Skripte (die Nachbauten des Klick-Beweises)
// laufen über die eigene Laufzeit mit ELECTRON_RUN_AS_NODE.
import { describe, expect, it } from 'vitest'
import { istSkript, werkzeugStart } from '../src/kern/werkzeuge/aufruf'
describe('werkzeugStart', () => {
it('eine EXE bleibt eine EXE — Argumente und Umgebung unverändert', () => {
const start = werkzeugStart('C:\\Programme\\MakeMKV\\makemkvcon.exe', ['-r', 'info', 'dev:G:'], { PATH: 'x' })
expect(start.programm).toBe('C:\\Programme\\MakeMKV\\makemkvcon.exe')
expect(start.args).toEqual(['-r', 'info', 'dev:G:'])
expect(start.env).toEqual({ PATH: 'x' })
})
it('ein Skript läuft über die eigene Laufzeit, das Skript wird erstes Argument', () => {
const start = werkzeugStart('F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', ['-r', 'info'], { PATH: 'x' })
expect(start.programm).toBe(process.execPath)
expect(start.args).toEqual(['F:\\rippy\\bau\\klick\\fake-makemkvcon.cjs', '-r', 'info'])
expect(start.env['ELECTRON_RUN_AS_NODE']).toBe('1')
expect(start.env['PATH']).toBe('x')
})
it('istSkript kennt .cjs/.mjs/.js — und sonst nichts', () => {
expect(istSkript('a.cjs')).toBe(true)
expect(istSkript('A.MJS')).toBe(true)
expect(istSkript('a.js')).toBe(true)
expect(istSkript('HandBrakeCLI.exe')).toBe(false)
expect(istSkript('a.json')).toBe(false)
expect(istSkript('')).toBe(false)
})
})
+118
View File
@@ -0,0 +1,118 @@
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
import { describe, expect, it } from 'vitest'
import {
auftragAusJson,
auftragZuJson,
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from '../src/kern/ablauf/auftrag'
describe('sprachwahl — die Regel des Commanders', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
audio: ['deu', 'eng'],
untertitel: ['deu'],
untertitelStandard: false,
})
})
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
})
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['eng'],
untertitelStandard: false,
})
})
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
})
})
describe('folgenNummern und folgenPlausibel', () => {
it('fortlaufend ab der ersten Folge', () => {
expect(folgenNummern(2, 3)).toEqual([3, 4])
expect(folgenNummern(0, 3)).toEqual([])
expect(folgenNummern(2, 0)).toEqual([1, 2])
})
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
const laufzeiten: Array<[number, number]> = [
[3, 3200],
[4, 2000],
]
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
expect(w).toHaveLength(1)
expect(w[0]).toContain('Folge 4')
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
})
})
describe('gewichteterFortschritt — nie zurück auf null', () => {
it('gewichtet nach Größe', () => {
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
})
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
})
})
describe('Auftrag als JSON — und wieder zurück', () => {
const a: KompressionsAuftrag = {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
dateien: [
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
],
zuordnung: null,
staffel: 1,
ersteFolge: 3,
discNr: 2,
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
lesefehler: false,
hinweise: ['Übersprungen: Titel 5.'],
begonnen: '2026-08-30T12:00:00.000Z',
preset: '',
zielOrdner: '',
}
it('kommt unverändert zurück', () => {
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
})
it('Unsinn ist null, kein Absturz', () => {
expect(auftragAusJson('{')).toBeNull()
expect(auftragAusJson('{}')).toBeNull()
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
})
it('fehlende Felder bekommen Vorgaben', () => {
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
expect(b.dateien[0].rolle).toBe('hauptfilm')
expect(b.dateien[0].audioSprachen).toEqual([])
expect(b.staffel).toBeNull()
expect(b.preset).toBe('')
})
})
+56
View File
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
expect(() => new Datenbank(kaputt)).toThrow()
})
})
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
describe('Datenbank — Vorgänge', () => {
const vorgang = {
id: 'bluray-2026-09-01T20-00-00',
laufwerk: 'G',
typ: 'bluray',
titel: 'Spartacus',
jahr: 2010,
phase: 'rippt' as const,
begonnen: '2026-09-01T20:00:00.000Z',
beendet: null,
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.vorgangAnlegen(vorgang)
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
const neu = db.vorgangLesen(vorgang.id)!
expect(neu.phase).toBe('fertig')
expect(neu.komprimiert).toBe(true)
expect(neu.fertigBytes).toBe(4800)
expect(neu.titel).toBe('Spartacus')
expect(neu.rohGeloeschtAm).toBeNull()
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
db.vorgangLoeschen(vorgang.id)
expect(db.vorgangLesen(vorgang.id)).toBeNull()
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
})
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
const pfad = frischerPfad()
const alt = new Datenbank(pfad)
alt.einstellungSchreiben('ablage', 'E:\Rippy')
alt.schliessen()
const neu = new Datenbank(pfad)
aufraeumen.push(() => neu.schliessen())
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
expect(neu.vorgangListe()).toEqual([])
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
// der USB-Aussetzer).
import { describe, expect, it } from 'vitest'
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
const XML =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
describe('ereignisseAusXml', () => {
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
const e = ereignisseAusXml(XML)
expect(e).toHaveLength(3)
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
expect(e[2].id).toBe(11)
})
it('leere Ausgabe = keine Ereignisse', () => {
expect(ereignisseAusXml('')).toEqual([])
expect(ereignisseAusXml('\r\n')).toEqual([])
})
})
describe('beurteilen — Disc oder Hardware?', () => {
const alle = ereignisseAusXml(XML)
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
const b = beurteilen(alle, 24)
expect(b.urteil).toBe('hardware')
expect(b.controllerfehler).toBe(1)
expect(b.fehlerhafteBloecke).toBe(2)
expect(b.text).toContain('Mainboard')
expect(b.letzte).toHaveLength(3)
expect(b.letzte[0]).toContain('fehlerhafter Block')
})
it('nur fehlerhafte Blöcke: die Disc', () => {
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
expect(b.urteil).toBe('disc')
expect(b.text).toContain('DISC')
})
it('nichts: ruhig', () => {
const b = beurteilen([], 24)
expect(b.urteil).toBe('ruhig')
expect(b.text).toContain('nichts')
})
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
expect(b.controllerfehler).toBe(0)
expect(b.urteil).toBe('disc')
})
})
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
const args = buildWevtutilArgs(24, 50)
expect(args[0]).toBe('qe')
expect(args[1]).toBe('System')
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
expect(args).toContain('/f:xml')
expect(args).toContain('/c:50')
expect(args).toContain('/rd:true')
})
})
+19
View File
@@ -182,3 +182,22 @@ describe('presets', () => {
expect(sprachliste(null)).toEqual([])
})
})
describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11 gemessen)', () => {
it('mit Sprachliste und untertitelStandard kommt --subtitle-default=1 dazu', () => {
const args = buildHandbrakeArgs({
eingabe: 'a.mkv',
ausgabe: 'b.mkv',
preset: 'X',
audioSprachen: ['jpn'],
untertitelSprachen: ['deu', 'eng'],
untertitelStandard: true,
})
expect(args).toContain('--subtitle-default=1')
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu,eng')
})
it('ohne Sprachliste KEINE Vorwahl — die Reihenfolge wäre nicht bestimmt', () => {
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X', untertitelStandard: true })
expect(args).not.toContain('--subtitle-default=1')
})
})
@@ -0,0 +1,47 @@
// MESSUNG an einer ECHTEN Disc im Laufwerk — läuft nur auf Verlangen und
// nur unter Windows (die CI-Ampel auf Linux überspringt sie):
//
// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.disc-erkennung.test.ts --reporter=verbose
//
// Der Anlass (01.09.2026): Der Commander legte Disc 2 der Serie
// „Spartacus: Gods of the Arena" ein, Rippy meldete den FILM „Spartacus"
// von 1960. Gemessen am Laufwerk G: — Volume-Label SPARTACUS_GOTA_D2,
// UDF, 33 759 100 928 Bytes. Die Disc trug die richtige Antwort die
// ganze Zeit selbst mit sich: In G:\BDMV\META\DL\bdmt_eng.xml steht der
// Studio-Titel MIT Disc-Nummer und ein Inhaltsverzeichnis, das `EP 1`
// und `EP 2` nennt. Rippy las davon nur die erste Zeile.
import { describe, expect, it } from 'vitest'
const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase()
const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32'
describe.skipIf(!messen)('Disc-Erkennung an der eingelegten Disc', () => {
it('liest Titel, Verpackungs-Zusatz und Inhaltsverzeichnis', async () => {
const { discTitel, discWurzel, inhaltAusBdmt, titelKandidaten } = await import(
'../src/kern/metadaten/discmerkmale'
)
const geraetepfad = `\\\\.\\${buchstabe}:`
const gefunden = discTitel(geraetepfad)
console.log(`MESSUNG Laufwerk ${buchstabe}: Titel "${gefunden.titel}" (Quelle: ${gefunden.quelle || 'keine'})`)
console.log(
`MESSUNG Disc ${gefunden.discNr ?? '—'} | Staffel ${gefunden.staffel ?? '—'} | ` +
`Folgen ${gefunden.folgen} | Serie bewiesen: ${gefunden.istSerie}`,
)
const wurzel = discWurzel(geraetepfad)
if (wurzel.length > 0) {
console.log(`MESSUNG Inhaltsverzeichnis: ${inhaltAusBdmt(wurzel).eintraege.join(' | ') || '(keines)'}`)
}
console.log(`MESSUNG Suchvarianten: ${titelKandidaten(gefunden.titel).join(' -> ')}`)
// Eine eingelegte Disc muss sich benennen lassen — sonst ist die
// Messung selbst kaputt und nicht die Erkennung.
expect(gefunden.titel.length).toBeGreaterThan(0)
})
})
describe.skipIf(messen)('Disc-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_DISC=<Laufwerksbuchstabe> unter Windows', () => {
expect(true).toBe(true)
})
})
@@ -16,6 +16,9 @@ const FAELLE = [
'Akira',
'Evangelion 2.22',
'Spartacus Gods Of The Arena',
// Der echte Studio-Titel der Disc vom 01.09.2026 — MIT Doppelpunkt und
// Verpackungs-Zusatz, genau so, wie er von der Disc kommt.
'Spartacus: Gods Of The Arena - Disc 2',
'Bd Evg D2',
]
+31
View File
@@ -0,0 +1,31 @@
// MESSUNG an einer ECHTEN Roh-Datei: Welche Ton- und Untertitelspuren hat
// MakeMKV wirklich in die MKV geschrieben? (5.4.0, Regel D)
//
// RIPPY_MESSUNG_MKV="E:\Rippy\roh\bluray-…\title_t00.mkv" npx vitest run test/messung.spuren.test.ts --reporter=verbose
//
// Der Anlass: Der Auswahl-Dialog zeigt die Sprachen, die der Info-Lauf an
// der DISC gemessen hat. Ob sie alle in der Datei landen, entscheidet
// MakeMKVs Auswahlregel (werkzeuge/spurauswahl.ts). Diese Messung fragt die
// Datei selbst — über denselben HandBrake-Scan, den die Kompression benutzt
// (komprimieren/scan.ts). Läuft nur auf Verlangen und nur unter Windows.
import { describe, expect, it } from 'vitest'
const datei = process.env.RIPPY_MESSUNG_MKV ?? ''
const messen = datei.length > 0 && process.platform === 'win32'
describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => {
it('HandBrake-Scan liefert Ton- und Untertitelspuren mit Sprachcode', { timeout: 180_000 }, async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { spurenScannen } = await import('../src/kern/komprimieren/scan')
const handbrake = finden('handbrake', '')
expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0)
const start = Date.now()
const spuren = await spurenScannen(handbrake, datei)
console.log(`MESSUNG Scan: ${((Date.now() - start) / 1000).toFixed(1)} s — ${datei}`)
expect(spuren, 'Scan lieferte nichts').not.toBeNull()
console.log(`MESSUNG Dauer: ${Math.round(spuren!.dauerS / 60)} min`)
for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang}${a.beschreibung}`)
for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang}${u.beschreibung}`)
expect(spuren!.audio.length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,66 @@
// MESSUNG am ECHTEN Laufwerk: Was bekäme der Auswahl-Dialog zu sehen?
//
// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.titelwahl.test.ts --reporter=verbose
//
// Der Anlass (01.09.2026): Der Dialog blieb ewig bei „Rippy liest die
// Titel der Disc …". Ursache war NICHT der Info-Lauf, sondern der
// Nachrichten-Prüfer: `istFensterNachricht` kannte 'titel-lesen' nicht,
// der Kern verwarf die Anfrage — still. Gemessen wurde danach:
//
// makemkvcon -r --noscan info dev:G: -> 0,7 s, TCOUNT:0
// MSG:5042 „konnte keine verwendbaren optischen Laufwerke finden"
// MSG:2024 „Unknown device - 'G:'" · MSG:5010 „Öffnen der Disk schlug fehl"
//
// Der Info-Lauf HING also nie. Diese Messung fährt genau den Weg des
// Kerns nach — Info-Lauf, Vorauswahl, und was als Fehlertext im Dialog
// stünde. Sie läuft nur auf Verlangen und nur unter Windows.
import { describe, expect, it } from 'vitest'
const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase()
const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32'
describe.skipIf(!messen)('Titelwahl am echten Laufwerk', () => {
it('liest die Titel und baut die Vorauswahl', { timeout: 320_000 }, async () => {
const { finden } = await import('../src/kern/werkzeuge/katalog')
const { titelInfoLesen } = await import('../src/kern/rip/makemkv')
const { titelVorauswahl } = await import('../src/kern/rip/titelwahl')
const { discTitel } = await import('../src/kern/metadaten/discmerkmale')
const programm = finden('makemkv', '')
expect(programm.length, 'makemkvcon nicht gefunden').toBeGreaterThan(0)
const geraetepfad = `\\\\.\\${buchstabe}:`
const merkmale = discTitel(geraetepfad)
console.log(`MESSUNG Disc sagt: "${merkmale.titel}" · Folgen ${merkmale.folgen} · Serie ${merkmale.istSerie}`)
const start = Date.now()
const info = await titelInfoLesen(programm, geraetepfad)
console.log(`MESSUNG Info-Lauf: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel`)
if (info.ursachen.length > 0) console.log(`MESSUNG Ursachen: ${info.ursachen.join(' · ')}`)
const wahl = titelVorauswahl(info.titel, {
istSerie: merkmale.istSerie,
folgen: merkmale.folgen,
})
console.log(`MESSUNG Einordnung: ${wahl.einordnung} (unsicher: ${wahl.unsicher})`)
for (const t of wahl.titel) {
const min = Math.floor(t.dauerS / 60)
console.log(
`MESSUNG ${t.gewaehlt ? '[x]' : '[ ]'} Titel ${t.nr} | ${min}:${String(t.dauerS % 60).padStart(2, '0')} min` +
` | ${(t.groesseBytes / 1e9).toFixed(2)} GB | ${t.kapitel} Kap. | ${t.rolle} | ${t.grund}`,
)
}
// Was im Dialog als Fehler stünde — genau die Regel aus kern/index.ts.
const dialogFehler = info.titel.length === 0 && info.ursachen.length > 0 ? info.ursachen.join(' · ') : ''
console.log(`MESSUNG Dialog-Fehlertext: ${dialogFehler.length > 0 ? dialogFehler : '(keiner)'}`)
// Der Lauf muss ANTWORTEN — leer sein darf er, hängen nicht.
expect(Date.now() - start).toBeLessThan(300_000)
})
})
describe.skipIf(messen)('Titelwahl-Messung übersprungen', () => {
it('läuft nur mit RIPPY_MESSUNG_DISC=<Laufwerksbuchstabe> unter Windows', () => {
expect(true).toBe(true)
})
})
+289
View File
@@ -0,0 +1,289 @@
// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten
// (01.09.2026, TMDb mit dem hinterlegten v4-Token):
// movie/149 (Akira): tagline, runtime 124, vote_average 7.94,
// images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE
// mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV).
// tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'.
// Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop
// null, 1 Folge, Popularität 1.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb'
import {
LEERER_TREFFER,
VORSCHLAG,
ausDetails,
istStub,
kandidatenAus,
nachpruefen,
zuordnen,
type Zuordnung,
} from '../src/kern/metadaten/zuordnung'
import { strukturHinweis } from '../src/kern/rip/titelwahl'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
/** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */
const AKIRA: FilmDetails = {
id: 149,
title: 'Akira',
release_date: '1988-06-10',
overview: 'Tokio 1988. In einem Geheimlabor der Armee …',
poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg',
backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg',
runtime: 124,
genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }],
tagline: 'Neo-Tokyo ist am EXPLODIEREN',
vote_average: 7.94,
vote_count: 4944,
images: {
logos: [
{ iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 },
{ iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 },
{ iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 },
{ iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 },
],
},
credits: {
cast: [
{ name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 },
{ name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 },
{ name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 },
],
},
release_dates: {
results: [
{ iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] },
{
iso_3166_1: 'DE',
release_dates: [
{ certification: '16', type: 5 },
{ certification: '16', type: 3 },
{ certification: '', type: 6 },
],
},
],
},
}
const SPARTACUS_TV: FilmDetails = {
id: 46296,
name: 'Spartacus',
first_air_date: '2010-01-22',
overview: 'Der Thraker Spartacus …',
backdrop_path: '/x.jpg',
vote_average: 8,
vote_count: 1000,
episode_run_time: [53],
number_of_episodes: 39,
content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] },
}
/** Einer der drei gemessenen Stubs. */
const STUB: FilmDetails = {
id: 331850,
name: 'Spartacus: Gods of the Arena',
first_air_date: '',
overview: '',
backdrop_path: null,
vote_average: 8,
vote_count: 0,
number_of_episodes: 1,
episode_run_time: [],
}
describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => {
it('logoAus: deutsch vor englisch, darin das bestbewertete', () => {
expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png')
expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png')
expect(logoAus([])).toBe('')
expect(logoAus(undefined)).toBe('')
})
it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => {
expect(fskAus(AKIRA, 'movie')).toBe('16')
expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18')
expect(fskAus({ id: 1 }, 'movie')).toBe('')
expect(fskAus(STUB, 'tv')).toBe('')
})
it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => {
const b = besetzungAus(AKIRA.credits?.cast)
expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama'])
expect(b[0].rolle).toBe('Shotaro Kaneda (voice)')
expect(b[2].bildPfad).toBe('')
expect(besetzungAus(undefined)).toEqual([])
})
it('ausDetails trägt alles in den Treffer — Film und Serie', () => {
const film = ausDetails('movie', 149, AKIRA)
expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN')
expect(film.bewertung).toBe(7.9)
expect(film.fsk).toBe('16')
expect(film.logoPfad).toContain('.png')
expect(film.besetzung).toHaveLength(3)
expect(film.laufzeitMinuten).toBe(124)
expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action'])
const serie = ausDetails('tv', 46296, SPARTACUS_TV)
expect(serie.laufzeitMinuten).toBe(53)
expect(serie.folgenGesamt).toBe(39)
expect(serie.fsk).toBe('18')
})
it('istStub erkennt die leeren Einträge — und nichts anderes', () => {
expect(istStub(STUB)).toBe(true)
expect(istStub(AKIRA)).toBe(false)
expect(istStub(SPARTACUS_TV)).toBe(false)
// Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub.
expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false)
})
})
describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => {
const tmdb = {
verfuegbar: true,
searchMovie: async () => [],
searchTv: async (t: string) =>
t === 'Spartacus: Gods of the Arena'
? [
{ id: 331850, name: 'Spartacus: Gods of the Arena' },
{ id: 331915, name: 'Spartacus Gods of the Arena' },
]
: t === 'Spartacus'
? [{ id: 46296, name: 'Spartacus' }]
: [],
movieDetails: async () => null,
tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }),
findByImdb: async () => null,
}
const keinOmdb = { verfuegbar: false, lookup: async () => null }
it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => {
const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true })
// Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt).
expect(z.titel).toBe('Spartacus')
expect(z.confidence).toBe(VORSCHLAG)
expect(z.meta.laufzeitMinuten).toBe(53)
})
it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => {
const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) }
const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
})
describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => {
it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => {
const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2'])
expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2')
expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena')
expect(k[1].massstab).toBe('Spartacus: Götter der Arena')
expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length)
})
it('ohne Alternativen wie bisher', () => {
expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira'])
})
})
describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => {
const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) }
it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => {
const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(0.95)
expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt')
})
it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => {
const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } }
const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('passt NICHT')
})
it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => {
const eigene = { ...akira, confidence: 1 }
const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(1)
expect(z.hinweise).toHaveLength(1)
})
it('Serie: Folgenlänge gegen episode_run_time', () => {
const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) }
const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise?.[0]).toContain('Folgenlänge passt')
})
it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => {
const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('Serienstaffel')
})
it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => {
const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } }
const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise).toEqual([])
})
})
describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => {
const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 })
it('ein langer + viele kurze = Film', () => {
expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film')
})
it('sechs gleich lange = Serie', () => {
expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie')
})
it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => {
expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar')
})
it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => {
expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar')
})
it('nichts Langes: unklar', () => {
expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar')
expect(strukturHinweis([])).toBe('unklar')
})
})
describe('bdmt_deu als zweite Titel-Variante', () => {
it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => {
const z = bdmtZerlegen('<x><di:name>Titel</di:name><di:titleName titleNumber="1">EP 1</di:titleName></x>')
expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] })
})
it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Spartacus: Götter der Arena</di:name></x>')
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name><di:titleName titleNumber="1">EP 1</di:titleName><di:titleName titleNumber="2">EP 2</di:titleName></x>')
writeFileSync(join(meta, 'bdmt_fra.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name></x>')
const inhalt = inhaltAusBdmt(wurzel)
expect(inhalt.titel).toBe('Spartacus: Gods of the Arena')
expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2'])
expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena'])
})
it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Nur Deutsch</di:name></x>')
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] })
})
})
+193 -4
View File
@@ -6,12 +6,23 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
discZusatzAbtrennen,
folgenZahl,
inhaltAusBdmt,
istFolgenName,
normalizeDiscLabel,
parseUdfDstring,
titelAusBdmt,
titelKandidaten,
} from '../src/kern/metadaten/discmerkmale'
import { istSicherGenug, wortUeberlappung, zuordnen, WENIGE_TREFFER } from '../src/kern/metadaten/zuordnung'
import {
abdeckung,
gleichBedeutend,
istSicherGenug,
wortUeberlappung,
zuordnen,
WENIGE_TREFFER,
} from '../src/kern/metadaten/zuordnung'
import { posterUrl } from '../src/kern/ablage/poster'
let wegwerf: string[] = []
@@ -98,7 +109,9 @@ function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, F
tvDetails: async (id: number) => {
for (const liste of Object.values(serien)) {
const treffer = liste.find((f) => f.id === id)
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' }
// Eine ECHTE Serie hat eine Beschreibung — ohne sie wäre es ein
// leerer Eintrag (istStub, 5.4.0), und der zählt nicht als Treffer.
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10', overview: 'Ü', ...treffer.details }
}
return null
},
@@ -169,10 +182,13 @@ describe('zuordnen — die Confidence-Kaskade', () => {
expect(z.meta.quelle).toBe('omdb')
})
it('der Spartacus-Fund: spezifische Serie auf den vollen Titel schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
it('der Spartacus-Fund: die Serie schlägt den wörtlichen Treffer eines STARK gekürzten Kandidaten', async () => {
// Live gemessen (30.08.2026): 'Spartacus Gods Of The Arena' wurde zum
// Film „Spartacus" (1960), weil der gekürzte Kandidat wörtlich traf,
// BEVOR die volle Anfrage als spezifische Serie gewertet wurde.
// Seit dem 01.09.2026 ist es sogar ein WÖRTLICHER Serientreffer (0,90
// statt 0,80): Der Vergleich stolpert nicht mehr über den Doppelpunkt,
// den TMDb schreibt und ein Volume-Label nicht tragen kann.
const z = await zuordnen(
'Spartacus Gods Of The Arena',
fakeTmdb(
@@ -181,7 +197,7 @@ describe('zuordnen — die Confidence-Kaskade', () => {
),
keinOmdb,
)
expect(z.confidence).toBe(0.8)
expect(z.confidence).toBe(0.9)
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
@@ -224,6 +240,179 @@ describe('zuordnen — die Confidence-Kaskade', () => {
})
})
// ── Der Spartacus-Fund des Commanders (01.09.2026) ──────────────────────
//
// Gemessen an der Disc, die im Laufwerk lag: Volume-Label
// SPARTACUS_GOTA_D2 (UDF, 33 759 100 928 Bytes), und in
// G:\BDMV\META\DL\bdmt_eng.xml stand alles, was Rippy brauchte — es las
// nur die erste Zeile davon. Rippy meldete „Spartacus" (Film, 1960).
/** Das echte bdmt_eng.xml der Disc, gekürzt auf das Wesentliche. */
const ECHTES_BDMT =
'<?xml version="1.0" encoding="UTF-8"?><disclib xmlns="urn:BDA:bdmv;disclib">' +
'<di:discinfo xmlns:di="urn:BDA:bdmv;discinfo"><di:title>' +
'<di:name>Spartacus: Gods Of The Arena - Disc 2</di:name>' +
'<di:numSets>1</di:numSets><di:setNumber>1</di:setNumber></di:title>' +
'<di:description><di:tableOfContents>' +
'<di:titleName titleNumber="1">EP 1</di:titleName>' +
'<di:titleName titleNumber="2">EP 2</di:titleName>' +
'<di:titleName titleNumber="20">MM ITA</di:titleName>' +
'<di:titleName titleNumber="50">ILSM</di:titleName>' +
'<di:titleName titleNumber="51">DUB GER 1</di:titleName>' +
'<di:titleName titleNumber="52">DUB GER 2</di:titleName>' +
'<di:titleName titleNumber="101">101 LOGO</di:titleName>' +
'<di:titleName titleNumber="104">104 GER FSK</di:titleName>' +
'<di:titleName titleNumber="105">105 WARNINGS</di:titleName>' +
'</di:tableOfContents></di:description></di:discinfo></disclib>'
describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
it('liest Titel UND Inhaltsverzeichnis der echten Disc', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_eng.xml'), ECHTES_BDMT)
const inhalt = inhaltAusBdmt(wurzel)
expect(inhalt.titel).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(inhalt.eintraege).toContain('EP 1')
expect(inhalt.eintraege).toContain('DUB GER 1')
// Zwei Folgen — das deckt sich mit der Disc-Struktur: genau zwei große
// Ströme (15,76 GB und 14,89 GB), alles andere unter 200 MB.
expect(folgenZahl(inhalt.eintraege)).toBe(2)
})
it('Tonspuren, Trailer und Warnhinweise sind KEINE Folgen', () => {
expect(istFolgenName('EP 1')).toBe(true)
expect(istFolgenName('Episode 12')).toBe(true)
expect(istFolgenName('Folge 3')).toBe(true)
expect(istFolgenName('DUB GER 1')).toBe(false)
expect(istFolgenName('101 LOGO')).toBe(false)
expect(istFolgenName('104 GER FSK')).toBe(false)
expect(istFolgenName('MM ITA')).toBe(false)
})
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [], alternativen: [] })
})
})
describe('discZusatzAbtrennen — abtrennen UND merken', () => {
it('trennt den Disc-Zusatz vom echten Titel', () => {
expect(discZusatzAbtrennen('Spartacus: Gods Of The Arena - Disc 2')).toEqual({
titel: 'Spartacus: Gods Of The Arena',
discNr: 2,
staffel: null,
})
})
it('auch aus dem Volume-Label (SPARTACUS_GOTA_D2)', () => {
expect(discZusatzAbtrennen(normalizeDiscLabel('SPARTACUS_GOTA_D2'))).toEqual({
titel: 'Spartacus Gota',
discNr: 2,
staffel: null,
})
})
it('Staffel und Disc hintereinander', () => {
expect(discZusatzAbtrennen('Akte X S1 D2')).toEqual({ titel: 'Akte X', discNr: 2, staffel: 1 })
})
it('lässt echte Titel in Ruhe — die vier Fallen', () => {
// Nackte Zahl am Ende: sonst würde aus 'Rocky 2' die 'Rocky', Disc 2.
expect(discZusatzAbtrennen('Rocky 2').titel).toBe('Rocky 2')
// 'Teil'/'Part' sind Titel-Wörter, keine Verpackung.
expect(discZusatzAbtrennen('Kill Bill Teil 2').titel).toBe('Kill Bill Teil 2')
// Die Apostroph-Wortgrenze: ohne den Trenner-Zwang würde hieraus
// "Ocean'" mit Staffel 11.
expect(discZusatzAbtrennen("Ocean's 11").titel).toBe("Ocean's 11")
expect(discZusatzAbtrennen("Ocean's 11").staffel).toBe(null)
expect(discZusatzAbtrennen('Pulp Fiction')).toEqual({ titel: 'Pulp Fiction', discNr: null, staffel: null })
})
})
describe('titelKandidaten — der Doppelpunkt-Vorspann steht GANZ hinten', () => {
it('die Reihenfolge der echten Disc', () => {
const k = titelKandidaten('Spartacus: Gods Of The Arena - Disc 2')
// Platz 1 war bis zum 01.09.2026 'Spartacus' — und erschlug damit alles.
expect(k[0]).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(k[1]).toBe('Spartacus: Gods Of The Arena')
expect(k).toContain('Spartacus Gods Of The Arena')
expect(k[k.length - 1]).toBe('Spartacus')
expect(k.indexOf('Spartacus')).toBeGreaterThan(k.indexOf('Spartacus: Gods Of The Arena'))
// Kein Müll-Zwischenschritt mehr, der auf einem Trennzeichen endet.
expect(k.some((t) => /[\s\-–—_:,.]$/.test(t))).toBe(false)
})
})
describe('gleichBedeutend und abdeckung', () => {
it('der Doppelpunkt kostet keinen Treffer mehr', () => {
expect(gleichBedeutend('Spartacus: Gods of the Arena', 'Spartacus Gods Of The Arena')).toBe(true)
})
it('aber Wortdopplungen bleiben unterscheidbar', () => {
expect(gleichBedeutend('New York, New York', 'New York')).toBe(false)
expect(gleichBedeutend('', 'x')).toBe(false)
})
it('abdeckung misst, wie viel von der Frage übrig ist', () => {
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus')).toBeCloseTo(0.2)
expect(abdeckung('Spartacus: Gods Of The Arena', 'Spartacus: Gods Of The Arena')).toBe(1)
expect(abdeckung('', 'x')).toBe(0)
})
})
describe('zuordnen — der Fund des Commanders, an der echten Disc', () => {
const echteWelt = () =>
fakeTmdb(
// TMDb hat einen Film, der WÖRTLICH 'Spartacus' heißt — das ist die Falle.
{ Spartacus: [{ id: 5, title: 'Spartacus' }] },
{ 'Spartacus: Gods Of The Arena': [{ id: 6, title: 'Spartacus: Gods of the Arena' }] },
)
it('der echte Disc-Titel führt zur Serie, nicht zum Film von 1960', async () => {
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb)
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
expect(z.confidence).toBe(0.9)
})
it('mit dem Serien-Hinweis der Disc kann ein Film gar nicht mehr gewinnen', async () => {
const z = await zuordnen('Spartacus: Gods Of The Arena - Disc 2', echteWelt(), keinOmdb, { istSerie: true })
expect(z.meta.typ).toBe('tv')
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
it('DER REGRESSIONSTEST: kennt TMDb nur den Film, wird daraus ein VORSCHLAG', async () => {
// Genau der Fehler, den der Commander sah: Die Restfrage 'Spartacus'
// trifft wörtlich — vorher 95 % Sicherheit auf den falschen Film.
// Jetzt deckelt die Abdeckung (1 von 5 Wörtern übrig) auf 0,60: Rippy
// zeigt es als Vorschlag und fragt nach, statt sich sicher zu irren.
const z = await zuordnen(
'Spartacus: Gods Of The Arena - Disc 2',
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
keinOmdb,
)
expect(z.titel).toBe('Spartacus')
expect(z.confidence).toBe(0.6)
expect(istSicherGenug(z)).toBe(false)
})
it('eine Serien-Disc nimmt auch aus dem Label keinen Filmtreffer an', async () => {
// Ohne bdmt bliebe nur 'Spartacus Gota D2' — die Folgen im
// Inhaltsverzeichnis bezeugen trotzdem, dass hier kein Film liegt.
const z = await zuordnen(
normalizeDiscLabel('SPARTACUS_GOTA_D2'),
fakeTmdb({ Spartacus: [{ id: 5, title: 'Spartacus' }] }),
keinOmdb,
{ istSerie: true },
)
expect(z.meta.typ).not.toBe('movie')
expect(z.confidence).toBe(0.3)
})
})
describe('posterUrl', () => {
it('relative TMDb-Pfade bekommen das w500-Präfix', () => {
expect(posterUrl('/abc.jpg')).toBe('https://image.tmdb.org/t/p/w500/abc.jpg')
+44
View File
@@ -71,3 +71,47 @@ describe('istHauptNachricht', () => {
expect(istHauptNachricht(undefined)).toBe(false)
})
})
// ── 5.4.0: Auftrag mit Sprachen und Optionen, neue Nachrichten ──────────
describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)', () => {
it('nimmt Sprachlisten und Staffel/Folge an', () => {
expect(
istFensterNachricht({
art: 'rip-start',
id: 'G',
auftrag: [{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }],
optionen: { staffel: 1, ersteFolge: 3 },
}),
).toBe(true)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: null, ersteFolge: null } })).toBe(true)
})
it('lehnt kaputte Sprachlisten, Rollen und Optionen ab', () => {
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'folge', dauerS: 1, audioSprachen: 'jpn' }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'egal', dauerS: 1 }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: 'eins' } })).toBe(false)
})
it('kennt die neuen Nachrichten', () => {
expect(istFensterNachricht({ art: 'vorgaenge-laden' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: '' })).toBe(false)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 'Fast' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 5 })).toBe(false)
expect(istFensterNachricht({ art: 'kompression-abbrechen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'roh-aufraeumen' })).toBe(true)
expect(istFensterNachricht({ art: 'fremden-ordner-loeschen', ordner: 'E:\Rippy\roh\alt' })).toBe(true)
expect(istFensterNachricht({ art: 'laufwerk-pruefen', id: 'G' })).toBe(true)
})
})
describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => {
it('vorgaenge braucht Liste, Belegung und Warteschlange', () => {
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true)
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false)
})
it('kompression-ergebnis und laufwerk-gesundheit', () => {
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', titel: 'T', ok: true, text: 'fertig' })).toBe(true)
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', ok: true })).toBe(false)
expect(istKernNachricht({ art: 'laufwerk-gesundheit', id: 'G', bericht: { urteil: 'disc', text: 't', fehlerhafteBloecke: 1, controllerfehler: 0, sonstige: 0, letzte: [], stunden: 24 } })).toBe(true)
expect(istKernNachricht({ art: 'kern-hinweis', text: 'Roh-Ordner gelöscht' })).toBe(true)
})
})
+212 -121
View File
@@ -1,170 +1,261 @@
// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten
// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake
// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt
// wo er liegt; Statusmeldungen tragen die Phase.
import { mkdtempSync, rmSync } from 'node:fs'
// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt
// danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk
// ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft
// test/ablegen.test.ts.
//
// makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner —
// nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler
// wirklich verschwinden.
import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RipStatus } from '../src/gemeinsam/nachrichten'
import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
const ripErgebnis = vi.hoisted(() => ({
wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown,
}))
const encodeErgebnis = vi.hoisted(() => ({
wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown,
}))
interface Schritt {
status: 'success' | 'error' | 'cancelled'
/** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */
datei?: string
bytes?: number
lesefehler?: boolean
fehler?: string
/** Fortschritts-Ticks, die der Lauf meldet. */
ticks?: number[]
}
vi.mock('../src/kern/rip/makemkv', () => ({
rippen: vi.fn(async () => ripErgebnis.wert),
}))
vi.mock('../src/kern/komprimieren/handbrake', () => ({
komprimieren: vi.fn(async () => encodeErgebnis.wert),
}))
const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] }))
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
vi.mock('../src/kern/rip/makemkv', async () => {
const fs = await import('node:fs')
const pfad = await import('node:path')
return {
rippen: vi.fn(
async (o: {
zielOrdner: string
aufFortschritt?: (p: number) => void
}) => {
const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' }
for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p)
if (schritt.datei !== undefined) {
fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10))
}
if (schritt.status === 'cancelled') return { status: 'cancelled' }
if (schritt.status === 'error') {
return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false }
}
const dateien = fs
.readdirSync(o.zielOrdner)
.filter((n: string) => n.endsWith('.mkv'))
.sort()
.map((n: string) => pfad.join(o.zielOrdner, n))
return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false }
},
),
}
})
import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
let wegwerf: string[] = []
afterEach(() => {
plan.schritte = []
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
pipeline: Pipeline
meldungen: RipStatus[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
flac: () => '',
geraet: () => ({
const GERAET = {
id: 'G',
name: 'Laufwerk G:',
typ: 'bluray',
typ: 'bluray' as const,
pfad: '\\\\.\\G:',
status: 'ready',
status: 'ready' as const,
modell: '',
seriennummer: '',
groesseBytes: 33e9,
grund: '',
}),
}
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
pipeline: Pipeline
meldungen: RipStatus[]
ablage: string
eingereiht: KompressionsAuftrag[]
endet: Array<{ id: string; phase: string; text: string }>
ausgeworfen: string[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const eingereiht: KompressionsAuftrag[] = []
const endet: Array<{ id: string; phase: string; text: string }> = []
const ausgeworfen: string[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
flac: () => '',
geraet: () => GERAET,
ablage: () => ablage,
einstellungen: () => ({}),
melden: (s) => meldungen.push(s),
jetzt: () => new Date('2026-08-30T12:00:00Z'),
vorgangEinreihen: (auftrag) => {
eingereiht.push(auftrag)
return eingereiht.length
},
vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }),
nachRipAuswerfen: (id) => ausgeworfen.push(id),
...überschreiben,
})
return { pipeline, meldungen }
return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen }
}
describe('Pipeline', () => {
it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const AUFTRAG: RipAuftragTitel[] = [
{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
{ nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 },
]
describe('Pipeline — die Rip-Phase (5.4.0)', () => {
it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', bytes: 300 },
{ status: 'success', datei: 'title_t01.mkv', bytes: 100 },
]
const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung()
const beginn: string[] = []
;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner)
await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 })
expect(meldungen[0].phase).toBe('rippt')
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(letzte.text).toContain('eingereiht')
expect(letzte.text).toContain('Laufwerk ist frei')
expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(pipeline.laeuft('G')).toBe(false)
expect(ausgeworfen).toEqual(['G'])
expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')])
expect(eingereiht).toHaveLength(1)
const auftrag = eingereiht[0]
expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge'])
expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn'])
expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu'])
expect(auftrag.dateien[1].audioSprachen).toEqual([])
expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100])
expect(auftrag.staffel).toBe(2)
expect(auftrag.ersteFolge).toBe(3)
expect(auftrag.preset).toBe('')
})
it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] },
{ status: 'success', datei: 'title_t01.mkv', ticks: [50] },
]
const { pipeline, meldungen } = umgebung()
await pipeline.starten('G')
const phasen = meldungen.map((m) => m.phase)
expect(phasen[0]).toBe('rippt')
expect(phasen).toContain('komprimiert')
expect(phasen).toContain('legt-ab')
expect(phasen.at(-1)).toBe('fertig')
expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv'])
await pipeline.starten('G', AUFTRAG)
const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent)
// Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %,
// erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0.
expect(prozente).toContain(37)
expect(prozente).toContain(75)
expect(prozente).toContain(87)
for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1])
})
it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
const { pipeline, meldungen } = umgebung({
einstellungen: () => ({ transcodePresetBluray: 'keine' }),
})
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.phase).toBe('fertig')
expect(letzte?.text).toContain('abgewählt')
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
it('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => {
plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('abgebrochen')
expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
expect(eingereiht).toHaveLength(0)
expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }])
})
it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
const { pipeline, meldungen } = umgebung({ handbrake: () => '' })
await pipeline.starten('G')
expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden')
it('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv' },
{ status: 'cancelled', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, ablage } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('abgebrochen')
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
expect(readdirSync(roh)).toEqual(['title_t00.mkv'])
expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben')
})
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' }
const { pipeline, meldungen } = umgebung()
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.phase).toBe('fehler')
expect(letzte?.text).toContain('bleibt stehen')
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
it('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => {
plan.schritte = [
{ status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' },
{ status: 'success', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv'])
expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert')
expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.mkv')
})
it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const { pipeline, meldungen } = umgebung()
it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => {
plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('fehler')
expect(meldungen.at(-1)!.text).toContain('kaputt')
expect(eingereiht).toHaveLength(0)
expect(endet[0].phase).toBe('fehler')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
})
it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', [AUFTRAG[0]])
expect(meldungen.at(-1)!.lesefehler).toBe(true)
expect(meldungen.at(-1)!.text).toContain('nicht lesen')
expect(eingereiht[0].lesefehler).toBe(true)
})
it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) })
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.lesefehler).toBe(true)
expect(letzte?.text).toContain('nicht lesen')
expect(eingereiht[0].dateien).toHaveLength(1)
expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm')
expect(eingereiht[0].staffel).toBe(3)
expect(eingereiht[0].discNr).toBe(2)
})
it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined })
await pipeline.starten('G')
expect(meldungen.at(-1)!.text).toContain('nicht eingereiht')
})
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
const { pipeline, meldungen } = umgebung({
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'unknown',
pfad: '\\\\.\\G:',
status: 'empty',
modell: '',
seriennummer: '',
groesseBytes: 0,
grund: '',
}),
geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }),
})
await pipeline.starten('G')
expect(meldungen[0].phase).toBe('fehler')
expect(meldungen[0].text).toContain('Kein Datenträger')
})
it('NFO landet im fertigen Ordner', async () => {
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'x',
handbrake: () => 'y',
flac: () => '',
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'dvd',
pfad: '\\\\.\\G:',
status: 'ready',
modell: '',
seriennummer: '',
groesseBytes: 4e9,
grund: '',
}),
ablage: () => ablage,
einstellungen: () => ({}),
melden: (s) => meldungen.push(s),
jetzt: () => new Date('2026-08-30T12:00:00Z'),
})
await pipeline.starten('G')
expect(meldungen.at(-1)?.phase).toBe('fertig')
const { existsSync, readdirSync } = await import('node:fs')
const filme = join(ablage, 'Filme')
const ordner = readdirSync(filme)
expect(ordner).toHaveLength(1)
expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true)
it('halbeDateienEntfernen: nur, was NEU dazukam', () => {
const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-'))
wegwerf.push(ordner)
writeFileSync(join(ordner, 'alt.mkv'), 'a')
const vorher = new Set(readdirSync(ordner))
writeFileSync(join(ordner, 'neu.mkv'), 'b')
expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv'])
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
})
})
+148
View File
@@ -0,0 +1,148 @@
// Tests des Roh-Aufräumens (kern/ablage/rohaufraeumen.ts, 5.4.0): die
// Regel, die Frist, die drei Sicherungen — und dass fremde Ordner nur
// gezeigt, nie von selbst gelöscht werden.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
loeschHindernis,
ordnerGroesse,
regelLesen,
regelText,
type AufraeumKandidat,
type AufraeumZugriff,
} from '../src/kern/ablage/rohaufraeumen'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
describe('regelLesen', () => {
it('kennt behalten, sofort und Tage — und die Vorgabe 7', () => {
expect(regelLesen('behalten')).toEqual({ art: 'behalten' })
expect(regelLesen('sofort')).toEqual({ art: 'sofort' })
expect(regelLesen('30')).toEqual({ art: 'tage', tage: 30 })
expect(regelLesen(null)).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('quatsch')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('-3')).toEqual({ art: 'tage', tage: 7 })
expect(regelText(regelLesen('7'))).toContain('7 Tage')
})
})
describe('faelligAm', () => {
it('Tage nach dem Ende; sofort = am Ende; behalten = nie', () => {
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'tage', tage: 7 })).toBe('2026-09-08T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'sofort' })).toBe('2026-09-01T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'behalten' })).toBe('')
expect(faelligAm(null, { art: 'sofort' })).toBe('')
expect(faelligAm('kaputt', { art: 'sofort' })).toBe('')
})
})
const fertigeDateien = ['E:\\Rippy\\Filme\\Akira (1988)\\title_t00.mkv']
const kandidat = (teil: Partial<AufraeumKandidat> = {}): AufraeumKandidat => ({
id: 'bluray-1',
phase: 'fertig',
komprimiert: true,
rohOrdner: 'E:\\Rippy\\roh\\bluray-1',
rohGeloeschtAm: null,
beendet: '2026-09-01T20:00:00.000Z',
fertigeDateien,
...teil,
})
const zugriff = (überschreiben: Partial<AufraeumZugriff> = {}): AufraeumZugriff & { geloescht: string[] } => {
const geloescht: string[] = []
return {
existiert: () => true,
groesse: () => 1000,
ordnerLoeschen: (p) => {
geloescht.push(p)
},
geloescht,
...überschreiben,
}
}
const regel7 = { art: 'tage' as const, tage: 7 }
const spaeter = new Date('2026-09-10T00:00:00Z')
const frueher = new Date('2026-09-03T00:00:00Z')
describe('loeschHindernis — die drei Sicherungen', () => {
it('fertig, komprimiert, Ergebnis da, Frist um: löschbar', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff())).toBe('')
})
it('vor der Frist: nicht löschbar, mit Datum', () => {
expect(loeschHindernis(kandidat(), regel7, frueher, zugriff())).toContain('Frist läuft bis 2026-09-08')
})
it('nicht fertig / nicht komprimiert / schon gelöscht / Regel behalten', () => {
expect(loeschHindernis(kandidat({ phase: 'fehler' }), regel7, spaeter, zugriff())).toContain('nicht fertig')
expect(loeschHindernis(kandidat({ komprimiert: false }), regel7, spaeter, zugriff())).toContain('nicht komprimiert')
expect(loeschHindernis(kandidat({ rohGeloeschtAm: 'x' }), regel7, spaeter, zugriff())).toBe('schon gelöscht')
expect(loeschHindernis(kandidat(), { art: 'behalten' }, spaeter, zugriff())).toBe('Regel: behalten')
})
it('die fertige Datei fehlt oder ist leer: NIE löschen', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ existiert: (p) => p.endsWith('bluray-1') }))).toContain('fertige Datei fehlt')
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ groesse: () => 0 }))).toContain('ist leer')
expect(loeschHindernis(kandidat({ fertigeDateien: [] }), regel7, spaeter, zugriff())).toContain('keine fertige Datei')
})
it('sofort: gleich nach dem Ende löschbar', () => {
expect(loeschHindernis(kandidat(), { art: 'sofort' }, new Date('2026-09-01T20:00:01Z'), zugriff())).toBe('')
})
})
describe('aufraeumen', () => {
it('löscht nur die Fälligen und sagt, warum der Rest blieb', () => {
const z = zugriff()
const ergebnis = aufraeumen(
[kandidat(), kandidat({ id: 'jung', beendet: '2026-09-09T00:00:00.000Z' }), kandidat({ id: 'roh', komprimiert: false })],
regel7,
spaeter,
z,
)
expect(ergebnis.geloescht).toEqual(['bluray-1'])
expect(z.geloescht).toEqual(['E:\\Rippy\\roh\\bluray-1'])
expect(ergebnis.uebersprungen.map((u) => u.id)).toEqual(['jung', 'roh'])
expect(ergebnis.fehler).toEqual([])
})
it('ein Ordner, der sich nicht löschen lässt, ist ein lauter Fehler, kein stilles Nichts', () => {
const ergebnis = aufraeumen([kandidat()], regel7, spaeter, zugriff({ ordnerLoeschen: () => { throw new Error('EBUSY') } }))
expect(ergebnis.geloescht).toEqual([])
expect(ergebnis.fehler[0].grund).toContain('EBUSY')
})
})
describe('Belegung und fremde Ordner — am echten Dateisystem', () => {
it('ordnerGroesse zählt rekursiv; fremdeOrdner kennt die Vorgänge und lässt sie aus', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-roh-'))
wegwerf.push(wurzel)
const roh = join(wurzel, 'roh')
mkdirSync(join(roh, 'bluray-1', 'tief'), { recursive: true })
mkdirSync(join(roh, 'alt-vor-540'), { recursive: true })
writeFileSync(join(roh, 'bluray-1', 'a.mkv'), 'x'.repeat(100))
writeFileSync(join(roh, 'bluray-1', 'tief', 'b.mkv'), 'x'.repeat(50))
writeFileSync(join(roh, 'alt-vor-540', 'c.mkv'), 'x'.repeat(7))
writeFileSync(join(roh, 'lose.txt'), 'x')
expect(ordnerGroesse(join(roh, 'bluray-1'))).toBe(150)
expect(ordnerGroesse(join(roh, 'gibt-es-nicht'))).toBe(0)
const fremde = fremdeOrdner(roh, new Set([join(roh, 'bluray-1')]))
expect(fremde).toEqual([{ ordner: join(roh, 'alt-vor-540'), bytes: 7 }])
expect(fremdeOrdner(join(wurzel, 'nix'), new Set())).toEqual([])
})
})
describe('istUnterRohWurzel — nur Ordner DIREKT unter roh\\', () => {
it('erlaubt genau eine Ebene', () => {
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\bluray-1')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh\\', 'e:\\rippy\\roh\\bluray-1\\')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\a\\b')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\Filme\\Akira')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\rohling\\x')).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
// Tests des HandBrake-Scans (kern/komprimieren/scan.ts, 5.4.0) — am
// gemessenen Format: „JSON Title Set:" + Objekt + weiterer Text.
import { describe, expect, it } from 'vitest'
import { buildScanArgs, fehlendeSprachen, spurenAusScan } from '../src/kern/komprimieren/scan'
import { regelAusQuery, RIPPY_REGEL } from '../src/kern/werkzeuge/spurauswahl'
const AUSGABE = `[21:27:54] hb_init: starting libhb thread
Scanning title 1 of 1, preview 1, 25.00 %
JSON Title Set: {
"MainFeature": 0,
"TitleList": [
{
"AudioList": [
{ "Description": "\u65e5\u672c\u8a9e (AAC LC, 2.0 ch)", "Language": "\u65e5\u672c\u8a9e", "LanguageCode": "jpn", "TrackNumber": 1 },
{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },
{ "Description": "English (AAC LC, 2.0 ch)", "Language": "English", "LanguageCode": "eng", "TrackNumber": 3 }
],
"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },
"Index": 1,
"Name": "quelle",
"SubtitleList": [
{ "Format": "text", "Language": "Deutsch (UTF-8)", "LanguageCode": "deu", "SourceName": "UTF-8", "TrackNumber": 1 },
{ "Format": "text", "Language": "English (UTF-8)", "LanguageCode": "eng", "SourceName": "UTF-8", "TrackNumber": 2 }
]
}
]
}
HandBrake has exited.
`
describe('spurenAusScan', () => {
it('schneidet das Objekt aus dem Textprotokoll und liest Spuren + Dauer', () => {
const s = spurenAusScan(AUSGABE)!
expect(s.audio.map((a) => `${a.nr}:${a.lang}`)).toEqual(['1:jpn', '2:deu', '3:eng'])
expect(s.untertitel.map((u) => u.lang)).toEqual(['deu', 'eng'])
expect(s.dauerS).toBe(52 * 60 + 47)
})
it('ohne Marke oder mit kaputtem JSON: null, nie ein leerer Befund', () => {
expect(spurenAusScan('nichts')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [ ')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [] }')).toBeNull()
})
it('buildScanArgs: --scan --json, Titel 1', () => {
expect(buildScanArgs('R:\\roh\\a.mkv')).toEqual(['--scan', '--json', '--input', 'R:\\roh\\a.mkv', '--title', '1'])
})
})
describe('fehlendeSprachen', () => {
it('nennt, was gewünscht ist und nicht drin', () => {
const s = spurenAusScan(AUSGABE)!
expect(fehlendeSprachen(s, { audio: ['jpn', 'fra'], untertitel: ['deu', 'jpn'] })).toEqual({ audio: ['fra'], untertitel: ['jpn'] })
expect(fehlendeSprachen(s, { audio: [], untertitel: [] })).toEqual({ audio: [], untertitel: [] })
})
})
describe('MakeMKV-Auswahlregel — reg query lesen', () => {
it('findet den Wert in der Ausgabe', () => {
const ausgabe = `\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_DefaultSelectionString REG_SZ ${RIPPY_REGEL}\r\n\r\n`
expect(regelAusQuery(ausgabe)).toBe(RIPPY_REGEL)
expect(regelAusQuery('FEHLER: Der angegebene Registrierungsschlüssel oder Wert wurde nicht gefunden.')).toBeNull()
})
})
+200
View File
@@ -0,0 +1,200 @@
// Welche Titel schlägt Rippy vor? — und wann sagt es „ich bin unsicher"?
//
// Anlass (01.09.2026): Bis dahin übergab die Pipeline fest `titel: 'all'`
// — Rippy rippte IMMER alles, inklusive Trailer, Werbung und
// Alterskennzeichen. Der Commander: „es fehlt generell noch komplett die
// auswahl WAS man rippen möchte."
import { describe, expect, it } from 'vitest'
import { offsetAusMeldung } from '../src/kern/rip/parser'
import { HAENGT_AB, RipAuswertung } from '../src/kern/rip/makemkv'
import { AEHNLICH, KURZ_S, titelVorauswahl } from '../src/kern/rip/titelwahl'
import type { TitelInfo } from '../src/kern/rip/parser'
function titel(nr: number, minuten: number, kapitel = 8, gb = 1): TitelInfo {
return { nr, dauerS: Math.round(minuten * 60), groesseBytes: Math.round(gb * 1e9), kapitel }
}
const KEIN_HINWEIS = { istSerie: false, folgen: 0 }
describe('titelVorauswahl — Serie', () => {
it('die echte Spartacus-Disc: zwei Folgen, sonst nichts', () => {
// Gemessen an der Disc: zwei große Ströme (15,76 GB / 14,89 GB) und
// Kleinkram. Das bdmt-Inhaltsverzeichnis nennt EP 1 und EP 2.
const wahl = titelVorauswahl(
[titel(0, 52.2, 8, 15.76), titel(1, 49.6, 8, 14.89), titel(2, 0.5, 1), titel(3, 0.2, 1)],
{ istSerie: true, folgen: 2 },
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(false)
expect(wahl.einordnung).toContain('2 Folgen erwartet, 2 gefunden')
expect(wahl.titel[0].grund).toBe('Folge')
expect(wahl.titel[3].grund).toContain('zu kurz')
})
it('weniger Folgen gefunden als erwartet — Rippy sagt es', () => {
const wahl = titelVorauswahl([titel(0, 50), titel(1, 0.4, 1)], { istSerie: true, folgen: 3 })
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('nur 1 gefunden')
})
it('gefundene Folgen sind unterschiedlich lang — auch das sagt Rippy', () => {
// 50 min und 20 min sind keine Staffel; die Auswahl bleibt, aber
// Rippy behauptet nicht, sie sei sicher.
const wahl = titelVorauswahl([titel(0, 50), titel(1, 20)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0, 1])
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('unterschiedlich lang')
})
it('ähnlich lange Folgen bleiben innerhalb der Toleranz sicher', () => {
// 45 und 50 Minuten liegen bei 10 % — unter AEHNLICH.
expect((50 - 45) / 50).toBeLessThan(AEHNLICH)
const wahl = titelVorauswahl([titel(0, 50), titel(1, 45)], { istSerie: true, folgen: 2 })
expect(wahl.unsicher).toBe(false)
})
})
describe('titelVorauswahl — Film', () => {
it('der längste Titel ist der Hauptinhalt', () => {
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4), titel(2, 2)], KEIN_HINWEIS)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([0])
expect(wahl.unsicher).toBe(false)
expect(wahl.titel[0].grund).toBe('Hauptinhalt')
expect(wahl.titel[1].grund).toBe('Zugabe')
})
it('DIE ANGEL-FALLE: mehrere fast gleich lange Fassungen', () => {
// Seamless Branching / Kamerawinkel — dieselbe Fassung in Varianten.
// Rippy nimmt die mit den meisten Kapiteln UND sagt, dass es unsicher ist.
const wahl = titelVorauswahl(
[titel(0, 120, 12), titel(1, 119, 24), titel(2, 118, 6)],
KEIN_HINWEIS,
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain('fast gleich lang')
})
})
describe('titelVorauswahl — die Sammeltitel-Falle', () => {
it('„alle Folgen am Stück" gewinnt NICHT die Film-Auswahl', () => {
// 150 min = 50 + 50 + 50. Ohne diese Regel wäre der Sammeltitel der
// längste — und Rippy würde alles doppelt sichern.
const wahl = titelVorauswahl(
[titel(0, 150), titel(1, 50), titel(2, 50), titel(3, 50)],
KEIN_HINWEIS,
)
expect(wahl.titel[0].gewaehlt).toBe(false)
expect(wahl.titel[0].grund).toContain('Sammeltitel')
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1])
})
it('auch bei einer Serie bleibt der Sammeltitel außen vor', () => {
const wahl = titelVorauswahl(
[titel(0, 100), titel(1, 50), titel(2, 50)],
{ istSerie: true, folgen: 2 },
)
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.nr)).toEqual([1, 2])
})
it('ein einzelner langer Titel ist KEIN Sammeltitel', () => {
// Ein Film mit einem Trailer daneben — 118 min sind nicht die Summe
// von irgendetwas, sonst würde jeder Film als Sammeltitel gelten.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].gewaehlt).toBe(true)
})
})
describe('titelVorauswahl — wenn nichts passt, wird nichts behauptet', () => {
it('nur Schnipsel: keine Vorauswahl, aber eine Ansage', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => !t.gewaehlt)).toBe(true)
expect(wahl.unsicher).toBe(true)
expect(wahl.einordnung).toContain(`${Math.round(KURZ_S / 60)} Minuten`)
})
it('gar keine Titel', () => {
const wahl = titelVorauswahl([], KEIN_HINWEIS)
expect(wahl.titel).toEqual([])
expect(wahl.unsicher).toBe(true)
})
})
describe('titelVorauswahl — die Rolle bestimmt, WOHIN abgelegt wird', () => {
it('Serie: gewählte Titel sind Folgen', () => {
const wahl = titelVorauswahl([titel(0, 52), titel(1, 50), titel(2, 3)], { istSerie: true, folgen: 2 })
expect(wahl.titel.filter((t) => t.gewaehlt).map((t) => t.rolle)).toEqual(['folge', 'folge'])
})
it('Film: der Hauptinhalt ist Hauptfilm, alles andere Extra', () => {
// Hakt der Nutzer eine Zugabe zusätzlich an, landet sie damit unter
// extras/ statt als zweiter Film daneben.
const wahl = titelVorauswahl([titel(0, 118), titel(1, 6), titel(2, 4)], KEIN_HINWEIS)
expect(wahl.titel[0].rolle).toBe('hauptfilm')
expect(wahl.titel[1].rolle).toBe('extra')
expect(wahl.titel[2].rolle).toBe('extra')
})
it('ohne Vorauswahl ist nichts Hauptfilm', () => {
const wahl = titelVorauswahl([titel(0, 1, 1), titel(1, 0.5, 1)], KEIN_HINWEIS)
expect(wahl.titel.every((t) => t.rolle === 'extra')).toBe(true)
})
})
// ── Der Defekt-Wächter ──────────────────────────────────────────────────
// Gemessen an der Spartacus-Disc (01.09.2026): MakeMKV meldete eine halbe
// Stunde lang denselben Offset, makemkvcon verbrauchte dabei 1,7 s CPU
// (es wartete also), und Windows protokollierte 41 fehlerhafte Blöcke in
// 10 Minuten. Dieselbe Stelle wieder und wieder ist kein Fortschritt.
const FEHLER_DE =
"Fehler 'SCSI-Fehler - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' trat auf während des Auslesens von '/BDMV/STREAM/00047.m2ts' bei Offset '56881152'"
const FEHLER_EN =
"Error 'SCSI error - MEDIUM ERROR:L-EC UNCORRECTABLE ERROR' occurred while reading '/BDMV/STREAM/00047.m2ts' at offset '56881152'"
describe('offsetAusMeldung', () => {
it('liest die Stelle — unabhängig von MakeMKVs Sprache', () => {
expect(offsetAusMeldung(FEHLER_DE)).toBe('56881152')
expect(offsetAusMeldung(FEHLER_EN)).toBe('56881152')
})
it('ohne Stelle bleibt es leer', () => {
expect(offsetAusMeldung('Kopiere 2 Titel')).toBe('')
expect(offsetAusMeldung('')).toBe('')
// Eine einzelne Ziffer ist eine Titelnummer, kein Offset.
expect(offsetAusMeldung("Titel '3'")).toBe('')
})
})
describe('RipAuswertung — hängt der Lauf an einer festen Stelle?', () => {
const zeile = (text: string): string => `MSG:2003,0,3,"${text}"`
it('dieselbe Stelle immer wieder = Defekt', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengtBei).toBe('56881152')
expect(a.haengt()).toBe(true)
})
it('ein kurzer Aussetzer ist noch kein Defekt', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB - 1; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(false)
})
it('Fortschritt räumt den Verdacht ab — es ging ja weiter', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB; i++) a.verarbeite(zeile(FEHLER_DE))
expect(a.haengt()).toBe(true)
a.verarbeite('PRGV:16384,16384,65536')
expect(a.haengt()).toBe(false)
expect(a.haengtBei).toBe('')
})
it('wandernde Stellen sind normale Lesefehler, kein Hängen', () => {
const a = new RipAuswertung()
for (let i = 0; i < HAENGT_AB * 2; i++) {
a.verarbeite(zeile(FEHLER_DE.replace('56881152', String(56881152 + i * 2048))))
}
expect(a.haengt()).toBe(false)
})
})
+91 -1
View File
@@ -2,7 +2,13 @@
// Ohne Code-Signing (Entscheid 5) ist der Transportweg die EINZIGE
// Absicherung des Selbst-Updates.
import { describe, expect, it } from 'vitest'
import { updateUrlPruefen, STANDARD_UPDATE_URL } from '../src/haupt/update-regeln'
import { argumentQuoten, kommandozeileBauen, INSTALLER_ARGUMENTE } from '../src/haupt/kommandozeile'
import {
neuerungenText,
updateUrlPruefen,
NEUERUNGEN_HOECHSTENS,
STANDARD_UPDATE_URL,
} from '../src/haupt/update-regeln'
describe('updateUrlPruefen', () => {
it('leer heißt Standard (der ist https)', () => {
@@ -22,3 +28,87 @@ describe('updateUrlPruefen', () => {
expect(updateUrlPruefen('file://x').ok).toBe(false)
})
})
// Die Änderungsnotizen kommen aus latest.yml und werden im Fenster
// angezeigt (Commander-Wunsch 01.09.2026: „inkl. changelog").
// builder-util-runtime deklariert sie als
// `string | Array<ReleaseNoteInfo> | null` — beide Formen müssen hier
// ankommen, sonst steht in der Karte „[object Object]".
describe('neuerungenText', () => {
it('nimmt den Text aus bau/release-notes.md, wie er kommt', () => {
expect(neuerungenText('Version 5.1.2\n\nNeu: ein Knopf.\n')).toBe('Version 5.1.2\n\nNeu: ein Knopf.')
})
it('nimmt auch die Listenform (mehrere übersprungene Versionen)', () => {
const roh = [
{ version: '5.1.1', note: 'Serien-Discs richtig erkannt.' },
{ version: '5.1.2', note: 'Update-Meldung im Fenster.' },
]
const text = neuerungenText(roh)
expect(text).toContain('5.1.1')
expect(text).toContain('Serien-Discs richtig erkannt.')
expect(text).toContain('Update-Meldung im Fenster.')
})
it('HTML-Marken fliegen raus — der Text kommt von einem Server', () => {
const text = neuerungenText('<b>fett</b> und <script>boese()</script> Rest')
expect(text).not.toContain('<')
expect(text).not.toContain('script>')
expect(text).toContain('fett')
})
it('nichts, Unsinn und Leeres ergeben einen leeren Text — nie [object Object]', () => {
expect(neuerungenText(null)).toBe('')
expect(neuerungenText(undefined)).toBe('')
expect(neuerungenText(42)).toBe('')
expect(neuerungenText([{ version: '1' }])).toBe('')
expect(neuerungenText(' \n ')).toBe('')
})
it('eine ausufernde Datei sprengt die Karte nicht', () => {
expect(neuerungenText('x'.repeat(NEUERUNGEN_HOECHSTENS * 2)).length).toBe(NEUERUNGEN_HOECHSTENS)
})
})
// Seit 5.1.4 startet Rippy den Update-Installer SELBST, außerhalb der
// Prozess-Leine (§ 3.3) — electron-updaters eigener Weg lief als Kind und
// wurde beim Beenden von der Leine erschlagen. CreateProcessW nimmt EINE
// Zeichenkette statt einer Argumentliste; wer sie naiv zusammenklebt,
// verliert jeden Pfad mit Leerzeichen.
describe('kommandozeileBauen — die Regeln von CommandLineToArgvW', () => {
it('Harmloses bleibt unangetastet', () => {
expect(argumentQuoten('--updated')).toBe('--updated')
expect(argumentQuoten('/S')).toBe('/S')
})
it('DER FALL: ein Pfad mit Leerzeichen bleibt EIN Argument', () => {
// Ohne Anführungszeichen zerfiele das in „C:\\Users\\Tobi" und „Neu\\…"
// — der Installer startete nie.
expect(argumentQuoten('C:\\Users\\Tobi Neu\\RippySetup.exe')).toBe('"C:\\Users\\Tobi Neu\\RippySetup.exe"')
})
it('Rückstriche vor dem Schluss-Anführungszeichen zählen doppelt', () => {
// Sonst würde das abschließende " selbst escaped und die Zeile bliebe offen.
expect(argumentQuoten('C:\\Pfad mit Lücke\\')).toBe('"C:\\Pfad mit Lücke\\\\"')
})
it('Anführungszeichen im Argument werden escaped', () => {
expect(argumentQuoten('sag "hallo"')).toBe('"sag \\"hallo\\""')
expect(argumentQuoten('a\\"b')).toBe('"a\\\\\\"b"')
})
it('das leere Argument verschwindet nicht', () => {
expect(argumentQuoten('')).toBe('""')
})
it('die ganze Zeile, wie sie an CreateProcessW geht', () => {
const zeile = kommandozeileBauen('C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe', INSTALLER_ARGUMENTE)
expect(zeile).toBe('"C:\\Users\\Tobi Neu\\RippySetup-5.1.4.exe" --updated /S --force-run')
})
it('die Argumente sind die von electron-updater gemessenen', () => {
// NsisUpdater.js doInstall: --updated immer, /S für still,
// --force-run für „danach wieder starten".
expect(INSTALLER_ARGUMENTE).toEqual(['--updated', '/S', '--force-run'])
})
})
+49 -1
View File
@@ -34,7 +34,15 @@ describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => {
// § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die
// Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts:
// Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt.
const erlaubt = new Set(['src/haupt/leine.ts', 'src/kern/laufwerk/win32.ts'])
// Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute
// braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die
// Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt
// jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht.
const erlaubt = new Set([
'src/haupt/leine.ts',
'src/haupt/fensterrand.ts',
'src/kern/laufwerk/win32.ts',
])
it('koffi wird nirgendwo sonst angefasst', () => {
const verstoesse = dateien
@@ -97,6 +105,46 @@ describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => {
})
})
describe('Nachrichten-Schema — der Prüfer kennt JEDE Nachrichtenart', () => {
// Der teure Fund vom 01.09.2026: 'titel-lesen' und 'rip-ueberspringen'
// standen in der FensterNachricht-Union, aber NICHT in
// istFensterNachricht(). Der Kern verwarf sie — still. Im Fenster stand
// ewig „Rippy liest die Titel …", und nirgends stand ein Grund.
//
// Dieser Test liest den Quelltext: Jede `art: '…'` der Union muss im
// Prüfer wörtlich vorkommen. Eine neue Nachricht ohne Prüfung fällt
// damit sofort auf, statt erst am lebenden Objekt.
const schema = readFileSync(join(WURZEL, 'src', 'gemeinsam', 'nachrichten.ts'), 'utf8')
function artenAus(unionName: string): string[] {
const start = schema.indexOf(`export type ${unionName} =`)
expect(start, `${unionName} nicht gefunden`).toBeGreaterThan(-1)
// Der Union-Block reicht bis zur nächsten Deklaration auf oberster Ebene.
const rest = schema.slice(start + `export type ${unionName} =`.length)
const ende = rest.indexOf('\nexport ')
const block = ende > 0 ? rest.slice(0, ende) : rest
return [...new Set([...block.matchAll(/art:\s*'([a-z0-9-]+)'/g)].map((m) => m[1]))]
}
it('istFensterNachricht kennt jede FensterNachricht', () => {
const pruefer = schema.slice(schema.indexOf('export function istFensterNachricht'))
const fehlend = artenAus('FensterNachricht').filter((art) => !pruefer.includes(`'${art}'`))
expect(fehlend).toEqual([])
})
it('istKernNachricht kennt jede KernNachricht', () => {
const pruefer = schema.slice(schema.indexOf('export function istKernNachricht'))
const fehlend = artenAus('KernNachricht').filter((art) => !pruefer.includes(`'${art}'`))
expect(fehlend).toEqual([])
})
it('der Kern verwirft nichts still', () => {
// Wer eine Nachricht wegwirft, sagt WARUM (R4).
const kern = readFileSync(join(WURZEL, 'src', 'kern', 'index.ts'), 'utf8')
expect(kern).not.toMatch(/if\s*\(!istFensterNachricht\([^)]*\)\)\s*return/)
})
})
describe('§ 6.7 — EINE Quelle für Einstellungen', () => {
it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => {
// Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las
+132
View File
@@ -0,0 +1,132 @@
// Tests der Kompressions-Warteschlange (kern/ablauf/warteschlange.ts,
// 5.4.0): eine zur Zeit, der Reihe nach; abbrechen wartend und laufend;
// eine Ausnahme im Encode beendet nicht die Schlange; kein Doppel.
import { describe, expect, it } from 'vitest'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
import type { AblageErgebnis } from '../src/kern/ablauf/ablegen'
import { KompressionsWarteschlange, type WarteschlangenStand } from '../src/kern/ablauf/warteschlange'
function auftrag(id: string): KompressionsAuftrag {
return {
vorgangId: id,
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: `R:\\roh\\${id}`,
dateien: [],
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: null,
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
}
}
function fertig(text = 'ok'): AblageErgebnis {
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] }
}
/** Ein Encode, den der Test von außen beendet. */
function steuerbar() {
const freigaben: Array<() => void> = []
const gestartet: string[] = []
const ausfuehren = (a: KompressionsAuftrag, melden: (s: { phase: 'komprimiert'; prozent: number; text: string }) => void, abbruch: AbortSignal) =>
new Promise<AblageErgebnis>((loesen) => {
gestartet.push(a.vorgangId)
melden({ phase: 'komprimiert', prozent: 10, text: 'läuft' })
abbruch.addEventListener('abort', () => loesen({ ...fertig('abgebrochen'), status: 'abgebrochen' }), { once: true })
freigaben.push(() => loesen(fertig()))
})
return { ausfuehren, freigaben, gestartet }
}
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
describe('KompressionsWarteschlange', () => {
it('arbeitet der Reihe nach, meldet Stand und Ergebnis, vergibt Plätze', async () => {
const { ausfuehren, freigaben, gestartet } = steuerbar()
const staende: WarteschlangenStand[] = []
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, (s) => staende.push(s), (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.einreihen(auftrag('c'), 'C')).toBe(3)
await tick()
expect(gestartet).toEqual(['a'])
expect(q.stand().laeuft?.vorgangId).toBe('a')
expect(q.stand().laeuft?.prozent).toBe(10)
expect(q.stand().wartend.map((w) => w.vorgangId)).toEqual(['b', 'c'])
expect(q.laeuftEtwas()).toBe(true)
expect(q.kennt('b')).toBe(true)
freigaben[0]()
await tick()
expect(gestartet).toEqual(['a', 'b'])
expect(ergebnisse).toEqual(['a:fertig'])
freigaben[1]()
await tick()
freigaben[2]()
await tick()
expect(ergebnisse).toEqual(['a:fertig', 'b:fertig', 'c:fertig'])
expect(q.laeuftEtwas()).toBe(false)
expect(q.stand().laeuft).toBeNull()
expect(staende.length).toBeGreaterThan(3)
})
it('kein Doppel: derselbe Vorgang bekommt seinen alten Platz zurück', async () => {
const { ausfuehren } = steuerbar()
const q = new KompressionsWarteschlange(ausfuehren, () => {}, () => {})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.stand().wartend).toHaveLength(1)
})
it('abbrechen: wartend wird entfernt (mit Ergebnis), laufend bekommt das Signal', async () => {
const { ausfuehren, freigaben } = steuerbar()
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, () => {}, (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.abbrechen('b')).toBe(true)
expect(ergebnisse).toEqual(['b:abgebrochen'])
expect(q.stand().wartend).toHaveLength(0)
expect(q.abbrechen('a')).toBe(true)
await tick()
expect(ergebnisse).toEqual(['b:abgebrochen', 'a:abgebrochen'])
expect(q.abbrechen('gibt-es-nicht')).toBe(false)
expect(freigaben).toHaveLength(1)
})
it('eine Ausnahme im Encode ist ein lautes Fehler-Ergebnis — die Schlange läuft weiter', async () => {
let n = 0
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(
async () => {
n += 1
if (n === 1) throw new Error('HandBrake explodiert')
return fertig()
},
() => {},
(a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}:${e.text.includes('explodiert')}`)
},
)
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
await tick()
expect(ergebnisse).toEqual(['a:fehler:true', 'b:fertig:false'])
})
})