24 Commits
Author SHA1 Message Date
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
36 changed files with 6517 additions and 2730 deletions
+964 -1
View File
@@ -1,6 +1,969 @@
# SAVEPOINT — Rippy
## Aktueller Stand: Einstellungen neu + Version 5.1.0 (31.08.2026, nachts)
## ÜBERGABE an die nächste Session (01.09.2026, Sessionende)
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy`, Branch
`worktree-windows-electron``git pull` genügt. v5-Code in
`rippy-windows/`; `main` ist reines Docker-Rippy.
* **Bau-Vorbereitung:** `npm install` in `rippy-windows/`, danach
`node node_modules/electron/install.js` (npm 11 blockt Electrons
Install-Skript). Fürs Paketieren `vendor/` aus
`%LOCALAPPDATA%\Rippy\tools` befüllen.
* **Release veröffentlichen:** die VIER Dateien im Gitea-Tag `aktuell`
ERSETZEN. **Token: `F:\Coding Stuff\Gittea Token\rippy.txt`** (40 Hex).
`git credential fill` liefert NUR ein Passwort und taugt NICHT.
* **Vorgehen, das sich bewährt hat:** erst lokal installieren, den
Commander testen lassen, DANN veröffentlichen. 5.3.1 wäre sonst mit
einem Fehler in den Kanal gegangen.
### Stand
**5.3.2 ist im Kanal und läuft beim Commander.** Ein vollständiger Rip
ist erfolgreich durchgelaufen (seine Bestätigung). Titelauswahl,
Serien-Ablage, Defekt-Wächter, Selbst-Update: alles am Gerät bewiesen.
**Committet, aber NICHT veröffentlicht:** `7c489f8` — der Dialog-Fix
(Markieren schloss den Dialog). Kann mit der nächsten Fassung mitgehen.
### Was die nächste Session bauen soll (Reihenfolge = Empfehlung)
Der Commander hat alle Punkte gutgeheißen und zwei ergänzt.
1. **Roh-Dateien aufräumen** — jede Blu-ray lässt 30+ GB liegen, niemand
räumt auf. Einstellung *behalten / nach Kompression löschen / nach X
Tagen*, plus Anzeige der Belegung. Im Code steht seit W-3: „Löschen
bekommt erst mit der Einstellung im UI eine Stimme."
2. **Staffel und Folge von Hand setzen** — Rippy rät bewusst nicht; der
Nutzer weiß aber, dass auf Disc 2 die Folgen 34 sind. Feld im
Auswahl-Dialog: „Staffel 1, ab Folge 3".
3. **Wiederholen ab Kompression** — die Roh-Dateien liegen genau dafür
da. Heute kostet ein Preset-Wechsel 40 Minuten Neulesen.
4. **Sprachwahl vor dem Rip**`titelInfoLesen()` liefert Tonspuren und
Untertitel je Titel bereits MIT, niemand liest sie aus (der vierte
Fall von „gebaut, nicht verbunden"). Siehe auch Punkt 12.
5. **Vorschau: welche Datei landet wo** — Rolle, Serie, Staffel und
Extras entscheiden inzwischen einiges; zwei Zeilen Zielpfad im Dialog
ersparen Überraschungen.
6. **Warteschlange über mehrere Discs.**
7. **Ein Smoke-Beweis, der KLICKT** — beide Fehler vom 01.09. waren
Verkabelung; von 261 Unit-Tests sah sie keiner.
8. **Laufwerks-Gesundheit aus dem Windows-Protokoll** — bei einem
Ausfall die `cdrom`-Ereignisse lesen und sagen, ob Disc oder
Hardware. (Am 01.09. von Hand gemacht, war entscheidend.)
9. **Durchgehender Fortschrittsbalken** — seit Titel-für-Titel springt
er je Titel zurück auf null. Gewichtet nach Dateigröße durchlaufen.
10. **Aufräumen nach Abbruch** — halbe Dateien bleiben im Roh-Ordner.
**11. Metadaten v2 + Hero-Banner v2 (Commander-Wunsch, hoher Wert).**
*Erkennung:* Rippy rät heute allein am TITELTEXT. Seit 5.2.0 liegt
bessere Evidenz ungenutzt herum:
* **Laufzeit-Abgleich** — TMDb kennt die Laufzeit, der Info-Lauf misst
sie. Dieser Vergleich findet NICHT statt. Stärkster Bestätiger, der
zu haben ist; widerlegt auch falsche Treffer (95 vs. 158 Minuten).
* **Struktur als Fingerabdruck** — steht wörtlich im KONZEPT: „1 langer
+ viele kurze = Film; 613 gleich lange = Serienstaffel". Erkennt
Serien auch ohne bdmt.
* **Zeitpunkt** — erkannt wird beim EINLEGEN, also vor jeder Messung.
Nach dem Info-Lauf noch einmal rechnen: aus „wahrscheinlich" wird
„bestätigt", oder Rippy merkt selbst, dass es danebenlag.
* `bdmt_deu.xml` als zweite Titelvariante (heute nur `bdmt_eng`).
*Banner:* TMDb liefert in DERSELBEN Anfrage (`append_to_response`) noch
**Logo** (freigestellter Schriftzug — der größte optische Sprung),
**Tagline**, **Bewertung**, **FSK** (aus den deutschen Freigaben) und
**Besetzung** mit Portraits. Daraus ein Kino-Mix-Banner: Backdrop über
die volle Breite, Verlauf ins Bühnen-Schwarz, Logo, Zeile
`2011 · FSK 18 · 52 Min · Drama · ★ 7,9`, Beschreibung, Darsteller-Streifen.
Bilder von TMDb lädt Rippy längst (Poster), eine CSP ist nicht gesetzt.
**Vor dem Bauen den TMDb-Schlüssel besorgen** und die Antworten
MESSEN, statt Feldnamen zu raten (Regel D). Der Commander wurde gebeten,
ihn nach `F:\Coding Stuff\Gittea Token\tmdb.txt` zu legen — beim
Sessionstart nachsehen, ob er da ist.
**12. Sprachen je Titel im Auswahl-Dialog (Commander-Wunsch).**
Wörtlich: *„Du hast Feature 1 auf der disc. das hat aber z. B.
japanischen Ton und braucht dringend die deutschen untertitel. In der
auswahl soll das direkt auswählbar sein."*
Heute gibt es nur GLOBALE Einstellungen (`audioSprachen`,
`untertitelSprachen`) — dieselbe Vorgabe für jede Disc und jeden Titel.
Nötig: je gewähltem Titel die gemessenen Tonspuren und Untertitel zum
Anhaken. Die Daten sind schon da (`DiscInfo.sprachen` aus dem
Info-Lauf, `parseStreamInfo`/`sprachenZusammenfassen`); es fehlt die
Anzeige und der Weg in den Auftrag (`RipAuftragTitel` erweitern) bis in
`komprimieren()`. Hängt eng mit Punkt 4 zusammen — zusammen bauen.
### Der Bug, der noch offen sein KÖNNTE
`7c489f8` behebt die wahrscheinliche Ursache (Markieren über den
Dialogrand). **Nicht durch Klicken bestätigt** — ich kann das UI nicht
bedienen. Falls es weiter auftritt, ist der zweite Verdacht die
Kachelliste: sie hängt an `verbindung.laufwerke.length === 0`, ein
einziger leerer Takt der Wache räumt Kachel UND Dialog ab. **Gemessen
und vorerst widerlegt** (20 von 20 Abfragen lieferten das Laufwerk),
aber die Fragilität bleibt. Sauber wäre, den Dialog-Zustand aus der
Kachel herauszuheben.
### Hardware-Lage
* Das **BU40N hängt am USB** (`USBSTOR\CDROM&VEN_HL-DT-ST&PROD_BD-RE_BU40N`).
Bei Aussetzern zuerst einen Port direkt am Mainboard probieren.
* **Spartacus Disc 2 hat einen echten Defekt** (fester Offset 56881152 in
Folge 1). Disc 1 liest sauber.
* Der **MakeMKV-Beta-Key läuft Ende September ab** — Rippy holt den neuen
selbst, nur beobachten.
### Auch noch offen
* `extras/`-Ordnername ist aus Wissen gesetzt, NICHT gemessen — Jellyfin
muss bestätigen, dass es sie als Zugaben liest.
* Auswurf-Messung, DVD- und Audio-CD-Durchlauf, Jellyfin-Sichtbarkeit.
* main-Merge des Aufräum-Branches.
---
## Zwei eigene Fehler gefunden und behoben — Version 5.3.2 (01.09.2026, nachts)
> **Die Titelauswahl LÄUFT und ist am Gerät bestätigt.** Der Commander,
> Screenshot: *„Serie: 2 Folgen erwartet, 2 gefunden — passt."*
>
> ```
> [x] Titel 0 52:47 min 16,28 GB 7 Kap. [Folge]
> [x] Titel 1 54:09 min 16,67 GB 7 Kap. [Folge]
> 2 von 2 gewählt · zusammen 32,95 GB
> ```
>
> Die Längen liegen 2,5 % auseinander — weit innerhalb von `AEHNLICH`
> (15 %), deshalb „passt" ohne Unsicherheits-Warnung. Die Auslegung
> stimmt also an echten Zahlen, nicht nur im Test.
### Fehler 1 (5.3.1): Der Kern verwarf `titel-lesen` STILL
Der Dialog blieb ewig bei „Rippy liest die Titel …". Gemessen am
lebenden Objekt: `makemkvcon -r --noscan info dev:G:` antwortet in
**0,8 s** — der Info-Lauf hing nie.
Der Fehler lag in MEINEM Code von 5.2.0: `istFensterNachricht()` kannte
`'titel-lesen'` und `'rip-ueberspringen'` nicht. Beide standen in der
Union, aber nicht im Prüfer, und der Kern warf sie mit
`if (!istFensterNachricht(frage)) return` weg. **Ohne ein Wort.**
Auf drei Ebenen behoben:
1. Der Prüfer kennt beide.
2. Eine verworfene Nachricht ist LAUT (Protokoll + `kern-fehler` mit der
verworfenen Art).
3. **Neuer Wächter-Test:** liest das Schema, vergleicht jede `art:` der
Unions mit dem jeweiligen Prüfer, und prüft, dass der Kern nicht mehr
still verwirft. **GEGENPROBE gemacht** — mit zurückgenommener
Reparatur schlägt er fehl (`expected [ 'titel-lesen' ]`), mit
Reparatur grün. Kein Deko-Test.
Dazu: `DiscInfo.ursachen` reicht die kritischen MSG-Nummern durch. Beim
5042-Zustand steht jetzt die Abhilfe im Dialog statt „keine Titel
gefunden" — das zeigte auf die DISC, dabei fehlte das LAUFWERK.
### Fehler 2 (5.3.2): Ein alter Titel-Lauf legte sich über die Liste
*„Er liest es korrekt ein, ich bekomme auch die auswahl aber kurz danach
kommt dieser fehler"* — „keine Antwort nach 300 s", MINUTEN nach der
fertigen Liste. Mit EINEM Lauf unmöglich (der Timeout wird bei `close`
gelöscht), also waren zwei unterwegs.
Ich hatte nie bedacht, was passiert, wenn jemand zweimal auf „Rippen"
drückt — bei etwas, das zwei Minuten dauert und dabei nur „liest …"
anzeigt, ist das der Normalfall, keine exotische Bedienung.
Behoben je Laufwerk: Ein neuer Lauf **löst den alten ab**
(AbortController → `titelInfoLesen` killt das Kind, lehnt mit der Marke
`ABGELOEST` ab). Nur die neueste Laufnummer darf antworten; ein
abgelöster Lauf **schweigt**. Nebeneffekt: nie zwei `makemkvcon` auf
derselben Disc.
### Setup 5.3.2
* **131 526 975 Bytes**, SHA-256
`74BBB686BD7EF95C1EFF8076FCA564BF83E8836301C939C38596EA03C2D33DEE`
* Paket-Smoke GRÜN, **261 Tests**, Ampel GRÜN (`6d14921`), im Release
`aktuell` anonym gegengeprüft.
* **Vorgehen war: erst lokal installieren, testen lassen, dann
veröffentlichen** — auf Wunsch des Commanders. Hat sich bewährt: 5.3.1
wäre sonst mit Fehler 2 in den Kanal gegangen.
### ⚠ Offen
* **Der komplette Durchlauf steht noch aus.** Auswahl-Dialog ist am Gerät
bestätigt; ob die Folgen wirklich unter `Serien/…/Season 01/` landen
und was Rippy zu den Episoden-Nummern sagt, ist ungeprüft.
* Der `extras/`-Ordnername ist weiterhin aus Wissen gesetzt, nicht
gemessen — Jellyfin muss das bestätigen.
* Spartacus **Disc 2** hat einen echten Defekt (fester Offset 56881152 in
Folge 1). Disc 1 liest sauber.
---
## Serien-Staffelablage + Film-Extras — Version 5.3.0 (01.09.2026, nachts)
> Commander: *„Mach das gerne noch mit den Serien."* und *„Für FILME
> sollte so eine auswahl auch gelten z. B. Nur hauptfeature oder
> hauptfeature + extras usw. Rippy muss das auch selbst zusammenbasteln."*
### DAS MUSTER DIESER SITZUNG — zum dritten Mal
Auch hier lagen die Teile **fertig und getestet** herum und waren nur
nicht verbunden: `serienOrdner()`, `matcheEpisoden()`,
`episodenUmbenennen()` (`ablage/struktur.ts`) und `tvStaffel()`
(`metadaten/tmdb.ts`). Deshalb landeten Folgen unter `Filme/`, und
Jellyfin sah lauter Einzelfilme statt einer Staffel.
Vorher schon so gefunden: `titelInfoLesen()` (rief niemand auf) und die
Preset-Empfehlung (lief, war aber unsichtbar). **Lehre für die nächste
Sitzung: Bevor etwas neu gebaut wird, erst nachsehen, ob es schon da ist
und nur der Draht fehlt.** Der Docker-Zweig hat viel vorgelegt, was beim
v5-Neubau mitkam, aber nie angeschlossen wurde.
### Die Rolle bestimmt das Ziel
Jeder Titel im Auftrag trägt eine `TitelRolle` (EINE Definition in
`gemeinsam/nachrichten.ts`):
```
hauptfilm -> Filme/<Titel> (Jahr)/<Titel> (Jahr).mkv
extra -> Filme/<Titel> (Jahr)/extras/…
folge -> Serien/<Titel>/Season NN/… S01E02.mkv
```
* Rippy schlägt vor (Serie → Folge, Film → Hauptfilm, Rest → Extra), der
Dialog lässt es je Titel ändern. Neuer Schnellwahl-Knopf
„Hauptinhalt + Extras".
* Die Zuordnung Datei→Rolle ist **exakt, nicht aus Dateinamen geraten**:
Je makemkvcon-Lauf kommt genau ein Titel dazu, und genau der bekommt
die Rolle seines Auftragseintrags (`rolleJeDatei`).
* Der `extras/`-Ordner entsteht nur, wenn es wirklich Extras gibt.
### Episoden-Nummern: bewusst zurückhaltend
`RipAuftragTitel.dauerS` reist mit, die Staffel-Laufzeiten kommen über
einen **injizierten** Rückruf (`episodenLaufzeiten`, im Kern aus
`tvStaffel`) — so bleibt die Pipeline ohne Netz prüfbar.
`matcheEpisoden()` benennt **nur bei EINDEUTIGER Zuordnung** um. Sonst
behalten die Dateien ihre MakeMKV-Namen, und Rippy nennt den Grund
(„keine Staffel-Laufzeiten von TMDb" bzw. „nicht eindeutig"). Lieber gar
nicht als falsch — ARMs offene Wunde #395 war genau dieses Ratespiel.
Die Staffel kommt aus dem Disc-Titel (`discZusatzAbtrennen`, seit 5.1.1),
sonst 1.
### Setup 5.3.0
* **131 526 532 Bytes**, SHA-256
`A4613214F18481B03D6D11DDAE4344C8537A12825C086E89039380C62767043F`
* Paket-Smoke GRÜN (`version=5.3.0`), **257 Tests**, Ampel GRÜN
(`7ce0688`), im Release `aktuell` anonym gegengeprüft.
### ⚠ Offen — beides braucht Hand am Gerät
* **Weder Auswahl-Dialog noch Serien-Ablage sind je an echter Hardware
gelaufen.** Logik getestet, Verkabelung typgeprüft; der erste echte
Durchlauf steht aus. Das Laufwerk war im 5042-Zustand und sollte nach
dem halbstündigen Hämmern nicht weiter belastet werden.
* **Der `extras/`-Ordnername ist aus Wissen gesetzt, NICHT gemessen.**
Ob Jellyfin ihn als Zugaben liest, kann nur die Bibliothek des
Commanders beantworten. Falls nicht: `featurettes` oder
`behind the scenes` sind die Alternativen.
* Bei zwei ähnlich langen Spartacus-Folgen wird die Episoden-Zuordnung
vermutlich NICHT eindeutig — dann liegen sie richtig einsortiert im
Staffelordner, heißen aber noch wie MakeMKV sie nannte. Das ist das
gewollte Verhalten, kein Fehler.
---
## Titelauswahl + Defekt-Wächter — Version 5.2.0 (01.09.2026, nachts)
> 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**, samt Trailer, Werbung und Alterskennzeichen.
### Der Anlass: eine defekte Blu-ray
Beim Rip der Spartacus-Disc hing MakeMKV eine halbe Stunde an EINER
Stelle. Gemessen (01.09.2026):
```
Offset '56881152' — immer derselbe, 39× „MEDIUM ERROR:L-EC UNCORRECTABLE"
makemkvcon64 — 1,7 s CPU in 30 Minuten (es WARTETE)
Windows-Systemlog — 41 cdrom-Ereignisse in 10 Minuten (ID 7 + ID 11)
```
**Wichtige Korrektur einer eigenen Fehldiagnose:** Erst hielt ich die
USB-Stromversorgung für die Ursache (das Laufwerk hängt am USB, ID 11 =
Controllerfehler). Der FESTE Offset widerlegt das — Stromprobleme werfen
Fehler an wechselnden Stellen. Es ist ein physischer Defekt auf der Disc;
die Controllerfehler sind die FOLGE des halbstündigen Hämmerns. Der Code
wusste es sogar schon: `makemkv.ts` notiert seit 30.08. „MakeMKV kann 1
von 2 Titeln sichern … Spartacus Disc 2".
### Der Umbau: Titel für Titel statt `all`
Beide Wünsche brauchen denselben Unterbau, deshalb in einem Zug. Zwei
Bausteine lagen fertig herum und waren nur **nicht verbunden**:
`titelInfoLesen()` (Titel mit Dauer/Größe/Kapiteln) rief NIEMAND auf, und
`buildRipArgs(…, titel)` konnte längst einen einzelnen Titel.
### Neu `kern/rip/titelwahl.ts` (pur, ohne Laufwerk)
* **Serie** (von der Disc BEZEUGT, bdmt nennt Folgen): so viele Titel wie
Folgen erwartet; passen die Längen nicht zusammen, sagt Rippy das.
* **Film**: der längste Titel.
* **Sammeltitel-Falle** (`SAMMEL_TOLERANZ`): Ein Titel, der so lang ist
wie die anderen ZUSAMMEN („alle Folgen am Stück"), wäre der längste und
würde die Film-Auswahl gewinnen — Rippy hätte alles doppelt gesichert.
* **Angel-Falle** (`FILM_GLEICHAUF`): Mehrere fast gleich lange Fassungen
(Seamless Branching). Rippy nimmt die mit den meisten Kapiteln UND sagt,
dass es unsicher ist.
* **Unklar heißt: nichts vorausgewählt.** Lieber fragen als raten.
**Bewusst NICHT geraten:** Die bdmt-`titleNumber` (1, 2, 51, 101 …) sind
Blu-ray-Playlist-Nummern, MakeMKVs Titelnummern sind eigene. Sie
gleichzusetzen wäre Raterei — benutzt wird nur die ANZAHL als Prüfsumme.
### Der Defekt-Wächter
* `offsetAusMeldung()` liest die Stelle als **letzte gequotete Zahl**
damit sprachunabhängig (deutsch „bei Offset", englisch „at offset").
* `RipAuswertung` zählt Wiederholungen derselben Stelle; ab `HAENGT_AB`
(12 Meldungen ≈ 6 Versuche) gilt der Lauf als hängend. **Fortschritt
räumt den Verdacht wieder ab.**
* Das Fenster zeigt Klartext + Knopf **„Titel überspringen"** →
`Pipeline.ueberspringen()` beendet nur den laufenden makemkvcon
(eigener AbortController je Titel), der nächste Titel läuft weiter.
* Ein gescheiterter Titel beendet den Auftrag NICHT mehr — die Gründe
werden gesammelt und am Ende genannt.
### Ablauf im Fenster
„Rippen" → Info-Lauf (20120 s, mit Ansage) → Auswahl-Dialog mit Dauer,
Größe, Kapiteln und Begründung je Titel → Schnellwahl „Rippys Vorschlag /
Alles / Nichts" → rippen. Der Info-Lauf startet **erst beim Klick**, nie
beim Einlegen (der 30.08.-Fund „das Erkennen dauert sehr sehr lange").
### Setup 5.2.0
* **131 526 561 Bytes**, SHA-256
`C426A8460DDBE43C899933956F2A7A97A96083638D1C910F482802D9FAD8946A`
* Paket-Smoke GRÜN (`version=5.2.0`), **254 Tests** (vorher 237; 17 neue
in `titelwahl.test.ts`, alle beim ersten Lauf grün), Ampel GRÜN
(`a8e8b2b`), im Release `aktuell` anonym gegengeprüft.
### ⚠ Offen
* **Der Auswahl-Dialog ist noch nie an echter Hardware gelaufen** — das
Laufwerk war im 5042-Zustand und sollte nicht weiter belastet werden.
Logik getestet, Verkabelung typgeprüft, erster echter Durchlauf steht aus.
* **Serien-Ablage fehlt weiterhin:** Folgen landen unter `Filme/`. Für
Jellyfin & Co. bräuchte es `Serien/<Titel>/Staffel X/` mit `SxxEyy`.
Bewusst als eigener Brocken zurückgestellt.
* **Das BU40N hängt am USB** (`USBSTOR\CDROM&VEN_HL-DT-ST&PROD_BD-RE_BU40N`)
— bei künftigen Aussetzern zuerst Port direkt am Mainboard probieren.
* Die Spartacus-Disc hat einen echten Defekt in Folge 1. Zum Prüfen der
neuen Auswahl: NUR Folge 2 anhaken, dann wird der Defekt gar nicht erst
angefahren.
---
## Windows-Rand, Hardware-Empfehlung, Wizard — Version 5.1.5 (01.09.2026, nachts)
> **Zuerst der Beweis, dass 5.1.1 wirkt.** Der Commander legte die
> Spartacus-Disc mit dem neuen UI ein, im Protokoll steht:
>
> ```
> Laufwerk G: erkannt als „Spartacus: Gods of the Arena" · 90 %
> ```
>
> Nicht der Film von 1960, sondern die Serie — die vorhergesagten 90 %,
> an der echten Disc. Der Fund ist damit am lebenden Objekt erledigt.
Vier Punkte auf Zuruf:
### 1. Der Windows-11-Rand — er kam wirklich von Windows
Ein rahmenloses Fenster behält unter Windows 11 den **DWM-Rand**: eine
feine Linie, die der Fenstermanager zeichnet, nicht die Seite. Mit CSS
nicht erreichbar. Neu `haupt/fensterrand.ts`:
`DwmSetWindowAttribute(DWMWA_BORDER_COLOR = DWMWA_COLOR_NONE)`.
**Gemessen: HRESULT 0**, auch im gepackten Programm (keine
`Fensterrand bleibt`-Warnung im Paket-Smoke).
Runde Ecken bleiben (Windows-11-üblich); `eckenKantig = true` schaltet
sie ab, wenn gewünscht. Ältere Windows antworten mit Fehler-HRESULT —
wird als Wert zurückgegeben und protokolliert (R4), dort gibt es den
Rand ohnehin nicht.
**Regeländerung, bewusst und gemeldet:** Das ist der DRITTE erlaubte
koffi-Ort. Wächter R1 kennt jetzt `leine.ts`, `fensterrand.ts` und
`kern/laufwerk/win32.ts`. Die Regel („Win32 nur an benannten Orten")
bleibt und hält alles andere weiter dicht. Grund: `DwmSetWindowAttribute`
braucht das Fensterhandle und kann nur im Haupt liegen.
### 2. Die Preset-Empfehlung gab es SCHON
Commander: *„Die alte Rippy version konnte automatisch anhand der
gescannten hardware encoder das beste preset wählen."* — Diese auch.
`presetEmpfehlung()` wählt seit dem Kino-Mix nach den gemessenen
Backends (VCN → NVENC → QSV → CPU); in den Einstellungen stand
„automatisch: H.265 VCN 1080p", also seine AMD-Karte.
**Das Problem war Sichtbarkeit, nicht Logik.** Die Erst-Einrichtung hat
jetzt einen Schritt „Qualität": gemessene Encoder als Badges, die
Empfehlung für DVD / Blu-ray / 4K daneben, und eine ehrliche Warnung,
wenn nur CPU-Encoder da sind. Die Logik selbst blieb unangetastet.
### 3. Feld „Update-Adresse" ist raus
Der Schlüssel `updateUrl` gilt weiter (Datenbank), § 6.8 prüft ihn
unverändert — nur das Eingabefeld ist weg.
### 4. Erst-Einrichtung geprüft — fünf Befunde
* **„Dieses Setup stellt vier Dinge ein"** — es stellte ZWEI ein.
* Der Ablage-Schritt zeigte eine **leere Zeile**, wenn nichts gewählt
war. Es gibt aber eine Vorgabe (`kern/index.ts`: `Videos\Rippy` im
Benutzerprofil) — die steht jetzt da.
* **Kein Wort zum TMDb-Schlüssel**, obwohl Schritt 1 „Rippy erkennt, was
drauf ist" verspricht. Ohne Schlüssel heißen Filme wie das Disc-Label.
Jetzt ein eigener Schritt mit Eingabefeld.
* Rohe HTML-Checkbox statt des Kino-Mix-Schalters.
* 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`): Der
Wizard war nach dem ersten Start überhaupt nicht mehr erreichbar und
damit nicht mehr prüfbar. Beweis: `beweise/ui-515-wizard.png`.
### Setup 5.1.5
* `dist-setup-515/RippySetup-5.1.5.exe`, **131 529 635 Bytes**, SHA-256
`A7E8B51350D2C78AC6BDA2DF6881E640D3B4D84C935B4B395529433849B18D15`
* Paket-Smoke GRÜN (`version=5.1.5`), 237 Tests, Ampel GRÜN (`663127f`)
* Im Release `aktuell`, anonym gegengeprüft: Content-Length =
`latest.yml`; Changelog vollständig mit Umlauten durchgereicht.
### ✅ DER SELBST-UPDATE-BEWEIS IST ERBRACHT
Der Commander, 01.09.2026: *„Also das upate funktioniert!"* — 5.1.4 fand
5.1.5 selbst, lud sie, installierte sie und kam in der neuen Fassung
zurück. **Ohne Handanlegen.**
Damit ist der offene Punkt 1 aus der Übergabe vom 31.08. erledigt, und
die Kette ist zum ERSTEN Mal komplett bewiesen — nicht nur der Kanal
(Download, Prüfsummen, Changelog), sondern auch der letzte Meter, das
Installieren. Genau der Meter, an dem es von 5.1.2 bis 5.1.3 still
gescheitert war.
**Ab jetzt gilt wieder die einfache Regel:** neues Release = die vier
Dateien im Gitea-Tag `aktuell` ersetzen, fertig. Jede installierte Rippy
ab 5.1.4 zieht selbst nach.
### Weitergabe an andere Rechner
Die Setup-EXE genügt — **außer MakeMKV**, das ist Pflicht und liegt aus
Lizenzgründen nicht bei (makemkv.com/download; Rippy findet es danach
selbst und holt den Beta-Schlüssel automatisch). HandBrakeCLI und FLAC
sind im Paket. Nur **5.1.4 oder neuer** weitergeben: Ältere können sich
nicht selbst aktualisieren.
---
## Die eigene Leine erschlug den Update-Installer — Version 5.1.4 (01.09.2026, nachts)
> Commander: *„Das Update auf Version 5.1.3 geht nicht. Rippy wird zwar
> sauber beendet aber startet NICHT neu in der neuen version."*
>
> **Die Update-Kette hat ab 5.1.2 nie funktioniert.** Bewiesen war immer
> nur der KANAL (Download, Prüfsummen, Changelog) — der letzte Meter, das
> INSTALLIEREN, war nie gemessen. Genau die Sorte Annahme, vor der
> AGENTS.md warnt.
### Die 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 Installer starb also in genau der Sekunde, in der Rippy sich
beendete, um ihm Platz zu machen.
### Der Fix — die Leine bleibt, der Installer klinkt sich aus
* `leine.ts` setzt zusätzlich `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. Das ändert
für sich genommen NICHTS (siehe Messung, Fall 2).
* Neu `ausserhalbDerLeineStarten()``CreateProcessW` mit
`CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW`. Liegt in `leine.ts`,
weil dort die Arbeitsgruppe verwaltet wird und Wächter R1 koffi nur
dort und in `kern/laufwerk/win32.ts` erlaubt.
* `update.ts` installiert jetzt SELBST: `autoInstallOnAppQuit` ist AUS,
der Pfad kommt aus `downloadUpdate()` (AppUpdater.d.ts: „Paths to
downloaded files"), die Argumente sind die gemessenen
(`--updated /S --force-run`). Gestartet bei `will-quit` und per 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.
* **Warum nicht `shell.openPath` wie bei MakeMKV (§ 9)?** Der entkommt der
Leine nur, WEIL er Adminrechte verlangt und über den AppInfo-Dienst
startet. Rippys Installer ist bewusst benutzerlokal
(`isAdminRightsRequired: false`) — er liefe wieder als Kind.
### Die Messung: `beweise/leine-breakaway.js`
```
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
```
**Fall 2 ist der wichtige:** Das bloße Erlauben weicht § 3.3 NICHT auf —
Kern, makemkvcon und HandBrakeCLI bleiben angeleint, die rc10-Waisen-
Falle ist unverändert zu.
**Der erste Messlauf zeigte ein falsches Negativ** („Ausklinken wirkt
nicht"). Grund: Der Enkel teilte sich die KONSOLE des Elternprozesses und
starb mit ihr, nicht mit der Arbeitsgruppe. Erst `CREATE_NO_WINDOW` misst,
was gemessen werden soll. **Lehre: Auch der Versuchsaufbau kann die
falsche Sache messen** — der Flag steht deshalb auch im echten Aufruf.
### Setup 5.1.4 und Release
* `dist-setup-514/RippySetup-5.1.4.exe`, **131 528 605 Bytes**, SHA-256
`2933CA098421A3C5C5807E32BB718B18C4A7FB9F1AB6D70C9B1D164A15F21B84`
* Paket-Smoke GRÜN: `leine=gesetzt … version=5.1.4` — die Leine steht
auch mit dem neuen Recht.
* Im Release `aktuell`, anonym von außen gegengeprüft: Content-Length =
`latest.yml` = lokale Datei; `releaseNotes` durchgereicht.
* 237 Tests, Ampel GRÜN (`051cab3`).
### ⚠ Der Sprung AUF 5.1.4 ist einmalig Handarbeit
Eine installierte 5.1.05.1.3 kann sich nicht selbst aktualisieren — der
Fehler steckt in IHR. `RippySetup-5.1.4.exe` einmal von Hand ausführen.
**Ab 5.1.4 läuft die Kette von allein**; das ist dann der erste echte
Selbst-Update-Beweis und steht noch aus (z. B. mit einer kleinen 5.1.5).
---
## Eigenes Fenster + Layout, das mitwächst — Version 5.1.3 (01.09.2026, abends)
### Zuerst: „hier zieht garnichts hoch" — doch, es zog
Der Commander meldete, das Update komme nicht an. **Es kam an.** Gemessen
in `%LOCALAPPDATA%\rippy-updater\pending\`:
```
RippySetup-5.1.2.exe 131 526 566 B geladen 17:57:10
SHA-256 27D1BA58…6E3C03 = exakt die hochgeladene Datei
update-info.json {"fileName":"RippySetup-5.1.2.exe", …}
```
Der Kanal arbeitete also einwandfrei. Zwei Gründe, warum davon nichts zu
sehen war:
1. Die installierte **5.1.0 kennt die Update-Karte nicht** — der Code
dafür kommt erst mit 5.1.2 mit.
2. **Installiert wird beim Beenden — und Rippy lebt im Tray.** Das
Fenster zu machen beendet Rippy NICHT (§ 4.1 Punkt 3), also kam
`autoInstallOnAppQuit` nie zum Zug. Nötig ist ein echtes Beenden
über das Tray-Symbol.
**Lehre (passt zu AGENTS.md „nicht aus einem Zustandswert auf einen
Mechanismus schließen"):** „Es passiert nichts" hieß hier nicht „der
Kanal ist kaputt", sondern „die Wirkung ist an eine Bedingung geknüpft,
die niemand kennt". Der Ordner `rippy-updater\pending` beantwortet die
Frage in zehn Sekunden.
### Der UI-Umbau (`827910f`, Ampel GRÜN, 230 Tests)
Commander: *„Momentan ist alles nach links gerückt, nichts skaliert mit
der fenstergröße und es ist alles gequetscht."*
**Ursache:** `<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 links, rechts stand nichts.
* `<main>` und Kopfzeile teilen sich `mx-auto max-w-[1600px]` mit
mitwachsendem Innenabstand.
* 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
über den Karten (`lg:flex-row`), statt 176 px vom knappen Platz zu
nehmen.
* Info-Kacheln: zwei Spalten schmal, vier breit (vorher immer vier).
* Startgröße 1000×700 → **1280×820**, Mindestmaß 720×480 → 860×560.
### Eigene Titelleiste (§ 4.2)
Commander: *„wäre sogar richtig cool wenn du den rahmen wegbekommst"*.
`frame: false`, und die Kopfzeile IST die Titelleiste: Minimieren /
Maximieren / Schließen oben rechts in Windows-Maßen (46 × 32), gezogen
wird an der Kopfzeile, Doppelklick maximiert.
* **Größe ändern bleibt möglich** — belegt aus Electrons eigener
Typdefinition (`electron.d.ts`): `thickFrame` ist standardmäßig `true`,
erst ein `false` würde „disable window resizing via dragging the window
edges". Wir lassen den Standard.
* **Schließen nimmt GENAU den Weg des alten Fensterkreuzes**
(`fenster.close()` → der bestehende close-Horcher versteckt nur):
Rippy lebt im Tray weiter, ein laufender Rip merkt nichts.
* Ziehfläche ist `.ziehbar` in `stil.css`; alles Bedienbare darin trägt
`.nicht-ziehbar`, sonst verschluckt der Griff den Klick.
* `fensterMaximiert` im `HauptStatus` steuert das Knopf-Symbol und wird
auch bei Doppelklick/Tastenkürzel nachgeführt (`maximize`/`unmaximize`).
Beweis-Bilder: `beweise/ui-513-uebersicht.png` (kein Rahmen, eigene
Knöpfe, volle Breite), `beweise/ui-513-einstellungen.png`.
### Setup 5.1.3 und Release
* Gebaut nach `dist-setup-513/` (EBUSY-Sperre, BAUEN.md), Paket-Smoke
GRÜN mit `version=5.1.3`
* **131 527 863 Bytes**, SHA-256
`53CC7A5A505D44149E833ED261ACAB5E8DB1ED47F27BB144D1A89B6F076E3C03`
* Im Release `aktuell`, anonym von außen gegengeprüft: Content-Length =
Zahl in `latest.yml` = lokale Datei; `releaseNotes` durchgereicht,
Umlaute intakt (1561 B).
### ⚠ Offen für die nächste Sitzung
* **Der Selbst-Update-Beweis am lebenden Objekt.** Beim Beenden über das
Tray installiert die wartende 5.1.2, beim nächsten Start findet sie
5.1.3 — und DANN ist die goldene Karte zum ersten Mal zu sehen. Genau
der Ablauf, den der Commander sehen wollte.
* **Das Laufwerk ist wieder im 5042-Zustand** (`MediaLoaded: False`,
`G:\` antwortet nicht) — für den Disc-Test Disc per Taste am Laufwerk
auswerfen und neu einlegen.
* Der Spartacus-Durchlauf mit dem neuen UI steht noch aus.
---
## Update sichtbar gemacht — Version 5.1.2 (01.09.2026, abends)
> Der Commander: *„irgendwie fehlt in rippy selbst der Button Auf
> updates prüfen' oder auch direkt beim start ne meldung ey jo update
> verfügbar' — wäre noch cool wenn das kommt inkl. changelog"*.
>
> Zu Recht: Der Kanal arbeitete korrekt, war aber **unsichtbar**. Der
> Update-Stand lag im Haupt und ging nur beim Laden des Fensters EINMAL
> mit. Fand die Prüfung etwas, änderte 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 (`c0a7618`, Ampel GRÜN, 230 Tests)
* **Goldene Karte oben in der Übersicht**, sobald etwas gefunden wird:
neue Versionsnummer, laufende Version, Ladebalken, Änderungsnotizen.
Dazu der Windows-Toast beim Fund.
* **Knopf „Jetzt auf Updates prüfen"** in den Einstellungen. Während
eines Rips AUS, mit Begründung im Tooltip — Rippy sucht dann bewusst
nicht (§ 6.8), das ist keine Panne.
* **Knopf „Jetzt neu starten und installieren"** auf der Karte, sobald
das Paket geladen ist. Sagt NEIN mit Grund statt still nichts zu tun.
* `setzen()` ruft IMMER `beiAenderung` → jede Änderung am Stand meldet
sich sofort ans Fenster. Kein Zustand mehr, den niemand erfährt.
* `UpdateStand` ist jetzt EINE Definition in `gemeinsam/nachrichten.ts`.
### Der Changelog — an der Bibliothek geprüft, nicht geraten (Regel D)
`app-builder-lib/out/publish/updateInfoBuilder.js`, `getReleaseInfo()`
liest genau den Dateinamen `release-notes.md` und legt den Inhalt als
`releaseNotes` in `latest.yml`; `builder-util-runtime` deklariert
`UpdateInfo.releaseNotes` als `string | Array<ReleaseNoteInfo> | null`.
`neuerungenText()` verträgt beide Formen, wirft HTML-Marken raus und
deckelt auf 4000 Zeichen. Quelle des Textes: **`bau/release-notes.md`**
— die ist bei jedem Release zu pflegen, sie IST die Meldung im Fenster.
Nachgemessen am fertigen Bau UND anonym vom Server: Der Block
`releaseNotes: |` steht in `latest.yml` (1191 B statt 337 B), Umlaute
unversehrt.
### Das Setup 5.1.2 und das Release
* `rippy-windows/dist-setup-neu/RippySetup-5.1.2.exe` — **wegen der
EBUSY-Sperre in ein frisches Verzeichnis gebaut** (BAUEN.md), der
Paket-Smoke auf `dist-setup/` hatte `app.asar` verriegelt.
* Größe **131 526 566 Bytes**, SHA-256
`27D1BA58BF613C43BBCBF8CB819401A6D3CD6F6E59F09AE76BDC350F0CC9DA8E`
* Paket-Smoke GRÜN, meldet `version=5.1.2`
* Im Release `aktuell`: EXE + blockmap neu, `latest.yml` und `w0.yml`
ersetzt. Anonym von außen gegengeprüft — Server-Content-Length =
Zahl in `latest.yml` = lokale Datei.
* **Die EXEn von 5.1.0 und 5.1.1 liegen bewusst weiter im Release**
(Rückfallebene). Wenn der Platz knapp wird: 5.1.0 kann weg, der
Commander hat sie auf dem Desktop.
### ⚠ Zu wissen: Die Karte ist erst AB 5.1.2 sichtbar
Eine installierte 5.1.0/5.1.1 zeichnet die Karte noch nicht — der Code
dafür kommt ja erst mit diesem Update mit. Sie aktualisiert sich still
auf 5.1.2, wie bisher. **Erst ein Update VON 5.1.2 AUF etwas Neueres
zeigt die Meldung.** Angeboten, noch nicht entschieden: eine winzige
5.1.3 nachlegen, damit der Commander den Ablauf einmal komplett sieht.
---
## Disc-Erkennung repariert — Version 5.1.1 (01.09.2026)
> Der Commander legte **Disc 2 von „Spartacus: Gods of the Arena"** ein,
> Rippy meldete den **Film „Spartacus" von 1960**. Er schlug vor, Discs
> künftig über ihre EAN/Barcode-Nummer zu erkennen. Die Messung zeigte:
> Das braucht es nicht — **die Disc trug die richtige Antwort die ganze
> Zeit selbst mit sich, Rippy las nur eine Zeile davon.**
### Warum der Barcode nicht die Antwort war
Der Barcode steht auf der HÜLLE, nicht auf der Scheibe. Ein Laufwerk
liest die Scheibe. Für DVD und Blu-ray gibt es keinen Weg an die EAN.
(Ausnahme, notiert und nicht gebaut: Audio-CDs tragen im Subchannel eine
Media Catalog Number — das IST der UPC/EAN. Als Auffangnetz für
unbekannte MusicBrainz-Kennungen später denkbar; TMDb und OMDb können
ohnehin keine Barcode-Suche, dafür bräuchte es eine Produktdatenbank als
Zwischenstufe mit fraglicher Abdeckung deutscher Editionen.)
### Was tatsächlich kaputt war (Laufwerk G:, `SPARTACUS_GOTA_D2`, UDF, 33 759 100 928 B)
Rippy las korrekt `Spartacus: Gods Of The Arena - Disc 2` und baute
daraus Suchvarianten — der Doppelpunkt-Vorspann stand auf **Platz 2**:
```
[0] Spartacus: Gods Of The Arena - Disc 2
[1] Spartacus <- hier war Schluss
...
[5] Spartacus: Gods Of The Arena <- der richtige, nie gefragt
```
TMDb hat einen Film, der wörtlich „Spartacus" heißt → wörtlicher
Treffer → **0,95** → fertig. Der Fix vom 30.08. (`Reihenfolge JE
KANDIDAT`) griff nicht, weil er die Kandidaten-LISTE nicht anfasste; der
damalige Test benutzte einen geschönten Titel (ohne Doppelpunkt, ohne
`- Disc 2`) und lief deshalb grün.
### Die vier Ursachen (`38a3a87`, Ampel GRÜN, 225 Tests)
1. **Die Sicherheit maß die ART des Treffers, nie die QUALITÄT der
Frage.** Neu: `abdeckung()` + `MINDEST_ABDECKUNG` (0,5) — wer mehr
als die Hälfte des Titels wegwerfen musste, bekommt höchstens einen
Vorschlag (0,60) und das UI fragt nach. Maßstab ist der Titel OHNE
Verpackungs-Zusatz.
2. **Verpackungs-Zusätze galten als Wortmüll.** Neu:
`discZusatzAbtrennen()` trennt `- Disc 2` / `_D2` / `S1` ab UND merkt
sie. Vier Fallen sind mit Tests abgesichert: `Rocky 2` (nackte Zahl),
`Kill Bill Teil 2` (Teil/Part sind Titel-Wörter), `Ocean's 11`
(Apostroph-Wortgrenze — die Ein-Buchstaben-Formen `d`/`s` verlangen
deshalb einen Trenner davor), `Pulp Fiction`.
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()`. Vergleicht
die Wort-FOLGE, nicht die Wort-MENGE (sonst wäre `New York, New York`
gleich `New York`).
4. **Rippy öffnete das Inhaltsverzeichnis der Disc und las nur die erste
Zeile.** Neu: `inhaltAusBdmt()` liest auch `<di:titleName>`.
`EP 1`/`EP 2` beweisen die Serie, `DUB GER 1`/`101 LOGO`/`104 GER FSK`
nicht (`istFolgenName` ist bewusst eng). Ab zwei Folgen ist „Serie"
BEWIESEN — dann sind die Film-Zweige der Kaskade AUS.
Disc-Nummer, Staffel und Folgenzahl stehen jetzt auch im Fenster
(Kachel neben der Erkennung) und überleben eine eigene Titelwahl.
### Der Beweis an der echten Disc
Neu: `test/messung.disc-erkennung.test.ts` (nur Windows, nur auf
Verlangen — `RIPPY_MESSUNG_DISC=G npx vitest run …`):
```
Titel "Spartacus: Gods Of The Arena - Disc 2" (Quelle: bdmt)
Disc 2 | Staffel — | Folgen 2 | Serie bewiesen: true
Inhalt: EP 1 | EP 2 | MM ITA | ILSM | DUB GER 1 | DUB GER 2 | DUB FRE 1
| DUB FRE 2 | DUB ITA | 101 LOGO | 102 DUTCH BREIN
| 103 DUTCH AGE | 104 GER FSK | 105 WARNINGS
Varianten: "…- Disc 2" -> "…Arena" -> "Spartacus Gods…" -> … -> "Spartacus"
```
Deckt sich mit der Disc-Struktur: genau zwei große Ströme (`00047.m2ts`
15,76 GB, `00048.m2ts` 14,89 GB), alles andere unter 200 MB.
**Geänderter Bestandstest:** Der alte Spartacus-Test erwartete 0,80. Er
erwartet jetzt 0,90 — durch den satzzeichen-toleranten Vergleich ist es
ein WÖRTLICHER Serientreffer. Die Absicht bleibt, die Prüfung wird
strenger.
### Das Setup 5.1.1
* `rippy-windows/dist-setup/RippySetup-5.1.1.exe`
* Größe **131 526 527 Bytes**
* SHA-256 `04CB1D302616FB67DE217AE26D50B567350177CAE9801C3FDC6FE9EA04C26F88`
* sha512 (in `latest.yml`) `GiUmrSDr/c/fTai0pJhZEr5dZuy1pUyApcFnGO1x9Kv1aKBLFXTZch8VU7rSp1wvOhZh687DhOklE4v5MIo/Pg==`
* Paket-Smoke GRÜN: `fenster=geladen kern=pid,node:24.18.1 datenbank=ok
leine=gesetzt pong=ok version=5.1.1`
* `latest.yml` und `w0.yml` liegen daneben, beide auf 5.1.1
### Das Release ist DRAUSSEN und nachgemessen
Hochgeladen ins Release `aktuell` (ID 1, Ziel
`worktree-windows-electron`), alle vier HTTP 201/204:
`RippySetup-5.1.1.exe` + `.blockmap` neu, `latest.yml` und `w0.yml`
gelöscht und ersetzt (Namenskollision zwingt zum Löschen zuerst).
**Die 5.1.0-EXE liegt bewusst weiter im Release — sie ist die
Rückfallebene**, und `latest.yml` verweist ohnehin namentlich.
Nachgemessen **anonym und von außen**, also genau so, wie eine
installierte Rippy fragt:
```
…/releases/download/aktuell/latest.yml HTTP 200, 337 B, version: 5.1.1
…/releases/download/aktuell/RippySetup-5.1.1.exe
HTTP 200, Content-Length 131 526 527
(= die Zahl in latest.yml = die lokale Datei)
…/releases/download/aktuell/…exe.blockmap HTTP 200, 137 490 B
…/releases/download/aktuell/w0.yml HTTP 200, 337 B
```
**§ 3.6 ⚠ ist beantwortet — mit NEIN:**
`…/releases/latest/download/latest.yml` liefert **HTTP 404**. Gitea 1.27
bedient das GitHub-Muster NICHT. Das feste Tag `aktuell` war also nicht
die Rückfallebene, sondern der einzige Weg — und ist genau der, den die
App über `electron-builder publish` eingebacken hat. Der Punkt kann aus
den offenen Fragen gestrichen werden.
**Zugang:** Der Upload-Token liegt in `F:\Coding Stuff\Gittea Token\rippy.txt`
(40 Hex, Recht `repository: Lesen und Schreiben`). Der über
`git credential fill` erreichbare Zugang taugt NICHT für die API — das
ist ein 13-stelliges Passwort, Gitea antwortet damit HTTP 401.
### ⚠ OFFEN: der Selbst-Update-Beweis am lebenden Objekt
Alles ist gemessen — außer dem einen Schritt, den nur die installierte
App gehen kann: **Findet die installierte 5.1.0 das Update von selbst?**
Sie prüft beim Start und einmal täglich, lädt im Hintergrund, NIE
während eines Rips, und installiert beim nächsten Beenden
(`autoInstallOnAppQuit`). Zu tun: Rippy einmal neu starten, kein Rip
dabei, dann beenden und wieder starten — danach muss in den
Einstellungen `5.1.1` stehen.
---
## ÜBERGABE an die nächste Session (31.08.2026, Sessionende)
> Diese Session ist abgeschlossen und ihr Arbeitsordner
> (`.claude/worktrees/rippy-v5-windows-4e23db`) wird abgeräumt — ALLES
> Wichtige liegt in Git, im Gitea-Release oder auf dem Desktop.
### Wo die nächste Session startet
* **Arbeitsort:** `F:\Coding Stuff\rippy` (Haupt-Checkout, Branch
`worktree-windows-electron` MIT Upstream — `git pull` genügt).
v5-Code in `rippy-windows/`; main ist reines Docker-Rippy.
* **Bau-Vorbereitung einmalig** (der Session-Ordner samt node_modules,
vendor/ und Bau-Caches ist weg): `npm install` in `rippy-windows/`
(npm 11 blockt Electrons install.js — Ausweg in BAUEN.md), fürs
PAKETIEREN `vendor/` aus `%LOCALAPPDATA%\Rippy\tools` befüllen
(BAUEN.md § „Das Setup bauen").
* **Neues Release veröffentlichen** = die VIER Dateien im Gitea-Tag
`aktuell` ERSETZEN (exe, .blockmap, latest.yml, w0.yml) — die
installierten Apps ziehen selbst nach. Schreiben an die Gitea-API:
`git credential fill` (host git.tobisniceshomelab.ddnsfree.com)
liefert Nutzer+Token.
### Was fertig und wo es ist
* **Rippy v5.1.0** komplett: W-0…W-7, drei Commander-Funde behoben,
MakeMKV-Selbst-Update (§ 9), Kino-Mix-Design durchgängig,
Einstellungen als Karten-Seite. 209 Tests, Ampel grün je Schritt.
* **Setup:** `RippySetup-5.1.0.exe` liegt auf dem DESKTOP des
Commanders UND im Gitea-Release `aktuell` (SHA-256 147D6C7E…C94F).
* **Update-Kanal live und gemessen** (latest.yml anonym, sha512/size
passen; w0.yml als Brücke für 5.0.0-w0-Altinstallationen).
* **main bereinigt** (2b77ab0, 10.039 Zeilen Windows-Standalone) und
auf die VM DEPLOYT (Container healthy, UI/API 200). Gitea hat nur
noch `main` + `worktree-windows-electron`; fünf gemergte
Alt-Branches gelöscht; lokaler Checkout synchron, ~545 MB Müll weg.
### Offene Punkte (Reihenfolge = Vorschlag)
1. **Erster echter Selbst-Update-Beweis:** 5.1.1 bauen (irgendeine
kleine Änderung), die vier Dateien im Tag `aktuell` ersetzen — die
installierte 5.1.0 muss es selbst finden, laden und beim nächsten
Start installieren (§ 6.8-Livetest; misst auch, ob Gitea 1.27 das
releases/latest/download-Muster bedient — § 3.6 ⚠).
2. **Hand am Gerät:** voller Blu-ray-Durchlauf mit neuem UI (die Disc
STECKT schon im Laufwerk, es ist wach), Auswurf-Messung
(`messung.auswurf`), DVD- und Audio-CD-Durchlauf,
Jellyfin-Sichtbarkeit.
3. **MakeMKV-Beta-Key läuft Ende September ab** — Rippy prüft täglich
und holt den neuen selbst; nur beobachten. Der Update-Knopf wird
scharf, sobald der Hersteller eine Fassung > 1.18.4 ankündigt.
4. UI-Feinschliff nach echter Nutzung (z. B. Bibliothek-Detailseite,
Rip-Nachbearbeitung „Titel nachträglich ändern — Ablage wandert mit").
### Session-Ordner abräumen (nach dem Schließen dieser Session)
```
git -C "F:/Coding Stuff/rippy" worktree remove --force ".claude/worktrees/rippy-v5-windows-4e23db"
```
---
## DEPLOYT — Update-Kanal live, altes Windows-Rippy gelöscht (31.08.2026, nachts)
> Der Abschluss des Tages, alles gemessen:
>
> **1. Gitea-Release `aktuell` ist live** (Release-ID 1, Ziel
> `worktree-windows-electron`): `RippySetup-5.1.0.exe` + `.blockmap` +
> `latest.yml` + `w0.yml`, alle HTTP 201. Nachgemessen aus App-Sicht
> (anonym, extern): `…/releases/download/aktuell/latest.yml` → 200 mit
> `version: 5.1.0`, sha512 und `size: 131370618` — die EXE antwortet
> per HEAD mit exakt dieser Content-Length. **`w0.yml` ist die Brücke
> für die alten 5.0.0-w0-Installationen** (Vorab-Versionen suchen auf
> dem w0-Kanal): gleiche 5.1.0-Daten, einmal mitziehen, danach läuft
> alles über latest.yml.
>
> **2. Kein „unsichtbarer Nutzer", kein Token — mit Absicht:** Das
> Repo ist ÖFFENTLICH lesbar (anonym HTTP 200, intern wie extern
> gemessen). Die App fragt anonym nach Updates; ein Token im Code wäre
> aus jeder installierten Rippy.exe auslesbar und damit ein Risiko
> ohne Nutzen. Sollte das Repo je privat werden, ist der saubere Weg
> ein eigener Lese-Nutzer + Token in der `updateUrl`-EINSTELLUNG (DB,
> nie im Code/Repo) — notiert, nicht gebaut.
>
> **3. So updatet sich Rippy selbst** (§ 6.8, alles schon im Code):
> Prüfung beim Start und täglich, NIE während eines Rips (nicht mal
> der Download), Installation beim nächsten Beenden/Start
> (`autoInstallOnAppQuit`). Die Standard-Adresse steckt via
> electron-builder `publish` in der App; nur https:// wird akzeptiert.
> Ab jetzt gilt: **neues Release = die vier Dateien im Tag `aktuell`
> ERSETZEN**, fertig — jede installierte App zieht nach.
>
> **4. Altes Windows-Rippy GELÖSCHT:** `main` per Fast-Forward auf den
> geprüften Aufräum-Stand `2b77ab0` (Ampel war grün; 10.039 Zeilen,
> Teil A raus, Docker-Teil B unangetastet). Anschließend VM-Deploy
> (`git pull --ff-only && docker compose up -d --build` auf Arcane) —
> die VM hing zuvor auf `05ab655`.
>
> v5 selbst lebt weiter auf `worktree-windows-electron` (Spitze diese
> Doku) — main bleibt reines Docker-Rippy (Entscheid 7/§ 11).
---
## Einstellungen neu + Version 5.1.0 (31.08.2026, nachts)
> Der Commander fand den Einstellungs-Tab „sehr mager" — jetzt ist er
> eine erklärende Karten-Seite im Kino-Mix (`b64ddf5`, 209 Tests, Dev-
+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

+20
View File
@@ -0,0 +1,20 @@
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.
+68 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rippy",
"version": "5.0.0-w0",
"version": "5.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rippy",
"version": "5.0.0-w0",
"version": "5.3.2",
"license": "UNLICENSED",
"dependencies": {
"electron-updater": "^6.8.9",
@@ -2229,6 +2229,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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rippy",
"productName": "Rippy",
"version": "5.1.0",
"version": "5.3.2",
"description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)",
"main": "out/haupt/index.js",
"author": "KrBrZ",
+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 }>
},
})
+588 -42
View File
@@ -7,8 +7,16 @@
// Erst-Einrichtung, Dialoge). Die Regeln bleiben: EINE Einstellungs-Quelle
// über den Kern (§ 6.7), R2 (nichts verschwindet ohne Auskunft), R4
// (Fehler stehen im Fenster — bei Systemfehlern als rote Karte).
import { useEffect, useState } from 'react'
import type { DiscInfoStand, GeraeteInfo, HauptStatus, RipStatus } from '../gemeinsam/nachrichten'
import { useEffect, useRef, useState } from 'react'
import type {
DiscInfoStand,
DiscTitelVorschlag,
GeraeteInfo,
HauptStatus,
RipStatus,
TitelRolle,
TitelStand,
} from '../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import {
aufVerbindung,
@@ -18,7 +26,9 @@ import {
metadatenSuchen,
ripAbbrechen,
ripStarten,
ripUeberspringen,
schluesselPruefen,
titelLesen,
verbindungStarten,
zuordnungSetzen,
type Verbindung,
@@ -83,6 +93,60 @@ const PHASEN_MONO: Record<RipStatus['phase'], string> = {
const LAUFENDE_PHASEN = ['liest-info', 'rippt', 'komprimiert', 'legt-ab']
// ── Eigene Fensterknöpfe (§ 4.2, seit 5.1.3) ────────────────────────────
// Bewusst schlicht und in Windows-Maßen (46 × 32) — wer sie sucht, findet
// sie dort, wo er sie erwartet. Sie sitzen in der Ziehfläche und müssen
// deshalb `nicht-ziehbar` sein, sonst verschluckt der Fenstergriff den Klick.
const IconMinimieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0 5h10" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
const IconMaximieren = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="0.6" width="8.8" height="8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
const IconWiederherstellen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
<rect x="0.6" y="2.6" width="6.8" height="6.8" stroke="currentColor" strokeWidth="1.1" />
<path d="M2.7 2.6V0.6h6.7v6.7h-2" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
const IconSchliessen = (): React.JSX.Element => (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<path d="M0.6 0.6l8.8 8.8M9.4 0.6l-8.8 8.8" stroke="currentColor" strokeWidth="1.1" />
</svg>
)
function FensterKnopf({
titel,
aufKlick,
rot = false,
children,
}: {
titel: string
aufKlick: () => void
rot?: boolean
children: React.ReactNode
}) {
return (
<button
onClick={aufKlick}
title={titel}
aria-label={titel}
className={`flex h-8 w-[46px] items-center justify-center text-dunst transition-colors ${
rot ? 'hover:bg-rot hover:text-white' : 'hover:bg-white/10 hover:text-schnee'
}`}
>
{children}
</button>
)
}
function sicherheitsWort(confidence: number): string {
if (confidence >= 0.9) return 'sehr wahrscheinlich'
if (confidence >= 0.8) return 'wahrscheinlich'
@@ -90,6 +154,18 @@ function sicherheitsWort(confidence: number): string {
return 'unsicher'
}
/** 'Staffel 1 · Disc 2 · 2 Folgen' — was die Disc über ihre Lage in der
* Ausgabe sagt. Die Folgenzahl stammt aus ihrem eigenen
* Inhaltsverzeichnis, ist also gemessen und nicht geraten. '' wenn die
* Disc nichts davon verrät. */
function discHerkunft(info: DiscInfoStand): string {
const teile: string[] = []
if (info.staffel !== null) teile.push(`Staffel ${info.staffel}`)
if (info.discNr !== null) teile.push(`Disc ${info.discNr}`)
if (info.folgen > 0) teile.push(info.folgen === 1 ? '1 Folge' : `${info.folgen} Folgen`)
return teile.join(' · ')
}
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
* (eigene Wahl → allgemeines Preset → Empfehlung → Default). */
function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
@@ -196,6 +272,35 @@ function SegmentBalken({ prozent }: { prozent: number }) {
}
// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten.
/** Nur ein Klick, der WIRKLICH auf der Hintergrundfläche begann, schließt
* einen Dialog.
*
* Commander-Fund (01.09.2026): „es gibt einen kleinen bug im UI wenn man
* selbst den titel sucht das suchfenster schließt sich dann einfach."
* Der Grund ist eine bekannte Falle: Wer im Eingabefeld Text markiert und
* dabei über den Dialogrand hinauszieht, drückt INNEN und lässt AUSSEN
* los. Der Browser meldet als Ziel des Klicks dann den gemeinsamen
* Vorfahren — die Hintergrundfläche. Ein reiner onClick-Horcher darauf
* schließt den Dialog also mitten im Tippen.
*
* Deshalb wird der Anfang gemerkt: Nur wenn Drücken UND Loslassen auf der
* Fläche selbst passierten, ist es ein Klick daneben. */
function useHintergrundKlick(schliessen: () => void): {
onMouseDown: (e: React.MouseEvent) => void
onClick: (e: React.MouseEvent) => void
} {
const begannDraussen = useRef(false)
return {
onMouseDown: (e) => {
begannDraussen.current = e.target === e.currentTarget
},
onClick: (e) => {
if (begannDraussen.current && e.target === e.currentTarget) schliessen()
},
}
}
function AendernDialog({
id,
startText,
@@ -208,6 +313,7 @@ function AendernDialog({
schliessen: () => void
}) {
const [text, setText] = useState(startText)
const hintergrund = useHintergrundKlick(schliessen)
useEffect(() => {
metadatenSuchen(id, startText)
// Nur beim Öffnen — weitere Suchen löst der Knopf aus.
@@ -216,7 +322,7 @@ function AendernDialog({
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
onClick={schliessen}
{...hintergrund}
>
<div
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
@@ -286,6 +392,186 @@ function AendernDialog({
// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
// ── Auswahl: WAS soll gerippt werden? (§ 6.2) ───────────────────────────
// Bis zum 01.09.2026 rippte Rippy IMMER alles — Trailer, Werbung und
// Alterskennzeichen inklusive. Der Info-Lauf dauert 20120 s, deshalb
// beginnt er erst hier und nicht schon beim Einlegen.
function dauerText(s: number): string {
const m = Math.floor(s / 60)
return m >= 60
? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')} h`
: `${m}:${String(Math.round(s % 60)).padStart(2, '0')} min`
}
function TitelWahlDialog({
id,
stand,
schliessen,
}: {
id: string
stand: TitelStand | undefined
schliessen: () => void
}) {
const hintergrund = useHintergrundKlick(schliessen)
const [gewaehlt, setGewaehlt] = useState<Set<number> | null>(null)
// Eigene Rollen-Wahl je Titel; was hier nicht drinsteht, behaelt Rippys
// Vorschlag ('hauptfilm' beim Film, 'folge' bei der Serie, sonst 'extra').
const [rollen, setRollen] = useState<Record<number, TitelRolle>>({})
// Rippys Vorauswahl gilt, bis der Nutzer sie anfasst.
const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr))
const rolleVon = (t: DiscTitelVorschlag): TitelRolle => rollen[t.nr] ?? t.rolle
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
else neu.add(nr)
setGewaehlt(neu)
}
const alle = stand?.titel ?? []
const gesamtBytes = alle.filter((t) => aktiv.has(t.nr)).reduce((s, t) => s + t.groesseBytes, 0)
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm"
{...hintergrund}
>
<div
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-2xl border border-white/12 bg-buehne p-5 shadow-2xl shadow-black/60"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-display text-xl font-bold text-schnee">Was soll gerippt werden?</h3>
{stand === undefined || stand.laeuft ? (
<p className="mt-4 font-mono text-xs text-nebel">
Rippy liest die Titel der Disc das dauert bis zu zwei Minuten. Solange dreht das Laufwerk.
</p>
) : stand.fehler.length > 0 ? (
<p className="mt-4 font-mono text-xs text-rot"> {stand.fehler}</p>
) : (
<>
<p className={`mt-1 text-xs ${stand.unsicher ? 'text-warn' : 'text-dunst'}`}>
{stand.unsicher ? '⚠ ' : ''}
{stand.einordnung}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<button
onClick={() => setGewaehlt(new Set(alle.filter((t) => t.gewaehlt).map((t) => t.nr)))}
className="rounded-lg border border-gold/40 bg-gold/12 px-2.5 py-1 text-[11px] text-gold-hell"
>
Rippys Vorschlag
</button>
<button
onClick={() =>
setGewaehlt(
// Alles Sehenswerte: Hauptinhalt und Zugaben, aber weder
// Sammeltitel noch Logos/Warnungen.
new Set(
alle
.filter((t) => !t.grund.startsWith('Sammeltitel') && !t.grund.startsWith('zu kurz'))
.map((t) => t.nr),
),
)
}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Hauptinhalt + Extras
</button>
<button
onClick={() => setGewaehlt(new Set(alle.map((t) => t.nr)))}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Alles
</button>
<button
onClick={() => setGewaehlt(new Set())}
className="rounded-lg border border-white/15 px-2.5 py-1 text-[11px] text-dunst hover:text-schnee"
>
Nichts
</button>
</div>
<div className="mt-3 space-y-1">
{alle.map((t) => (
<label
key={t.nr}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-2 ${
aktiv.has(t.nr) ? 'border-gold/40 bg-gold/8' : 'border-white/8 bg-samt/40'
}`}
>
<input
type="checkbox"
checked={aktiv.has(t.nr)}
onChange={() => umschalten(t.nr)}
className="accent-[oklch(0.83_0.11_75)]"
/>
<span className="w-16 flex-none font-mono text-[11px] text-nebel">Titel {t.nr}</span>
<span className="w-24 flex-none font-mono text-xs text-schnee">{dauerText(t.dauerS)}</span>
<span className="w-20 flex-none font-mono text-[11px] text-dunst">{gb(t.groesseBytes)}</span>
<span className="hidden w-20 flex-none font-mono text-[11px] text-nebel sm:inline">
{t.kapitel} Kap.
</span>
{aktiv.has(t.nr) ? (
<select
value={rolleVon(t)}
onChange={(e) => setRollen({ ...rollen, [t.nr]: e.target.value as TitelRolle })}
onClick={(e) => e.stopPropagation()}
className="flex-none rounded-lg border border-white/15 bg-samt/70 px-2 py-0.5 text-[11px] text-dunst"
title="Bestimmt, WOHIN der Titel abgelegt wird"
>
<option value="hauptfilm">Hauptfilm</option>
<option value="extra">Extra</option>
<option value="folge">Folge</option>
</select>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
))}
{alle.length === 0 && (
<p className="font-mono text-xs text-warn">Die Disc gab keine Titel her.</p>
)}
</div>
<p className="mt-3 font-mono text-[11px] text-nebel">
{aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)}
</p>
<p className="mt-1 text-[11px] text-nebel">
{alle.some((t) => aktiv.has(t.nr) && rolleVon(t) === 'folge')
? 'Folgen landen unter Serien/… /Season NN. Die Episoden-Nummern setzt Rippy nur, wenn die Laufzeiten eindeutig passen — sonst behalten die Dateien ihre Namen.'
: 'Der Hauptfilm landet in seinem eigenen Ordner, Extras daneben im Unterordner „extras" — so lesen Jellyfin, Emby und Kodi sie als Zugaben.'}
</p>
</>
)}
<div className="mt-5 flex justify-between gap-2">
<button
onClick={schliessen}
className="rounded-lg border border-white/15 px-4 py-1.5 text-xs text-dunst hover:text-schnee"
>
Abbrechen
</button>
<button
onClick={() => {
ripStarten(
id,
alle
.filter((t) => aktiv.has(t.nr))
.map((t) => ({ nr: t.nr, rolle: rolleVon(t), dauerS: t.dauerS })),
)
schliessen()
}}
disabled={aktiv.size === 0 || stand === undefined || stand.laeuft}
className="rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4)] hover:brightness-105 disabled:opacity-35"
>
{aktiv.size === 1 ? 'Diesen Titel rippen' : `${aktiv.size} Titel rippen`}
</button>
</div>
</div>
</div>
)
}
function LaufwerkKachel({
geraet,
rip,
@@ -294,6 +580,7 @@ function LaufwerkKachel({
ripStartMs,
protokoll,
verbindung,
titelStand,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
@@ -302,8 +589,10 @@ function LaufwerkKachel({
ripStartMs: number | undefined
protokoll: string[]
verbindung: Verbindung
titelStand: TitelStand | undefined
}) {
const [aendernOffen, setAendernOffen] = useState(false)
const [titelOffen, setTitelOffen] = useState(false)
const [protokollOffen, setProtokollOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const discDa = geraet.status === 'ready'
@@ -350,10 +639,14 @@ function LaufwerkKachel({
</button>
) : (
<button
onClick={() => ripStarten(geraet.id)}
onClick={() => {
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
setTitelOffen(true)
titelLesen(geraet.id)
}}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Alle Titel, verlustfrei (MakeMKV) — danach Kompression laut Einstellungen"
title="Titel der Disc lesen und auswaehlen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
@@ -415,7 +708,9 @@ function LaufwerkKachel({
</p>
)}
<div className="mt-3 grid max-w-2xl grid-cols-4 gap-2.5">
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
breiten — vorher immer vier, also im schmalen gequetscht. */}
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
<InfoKachel
@@ -446,6 +741,9 @@ function LaufwerkKachel({
ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'}
/>
)}
{info !== undefined && discHerkunft(info).length > 0 && (
<Badge text={discHerkunft(info)} ton="blau" />
)}
{info !== undefined && info.schonGerippt !== null && (
<Badge text={`schon gerippt am ${info.schonGerippt.slice(0, 10)}`} ton="gelb" />
)}
@@ -479,6 +777,41 @@ function LaufwerkKachel({
)}
</div>
{ripLaeuft && <SegmentBalken prozent={rip.prozent} />}
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
</p>
)}
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
wieder ist kein Fortschritt, sondern ein physischer
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
waehrend identische Zeilen durchs Protokoll rauschten. */}
{rip.haengt != null && (
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
<p className="font-mono text-[11px] font-semibold text-warn">
Die Disc hat an einer Stelle einen Defekt.
</p>
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an
derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht besser der Rest der
Disc ist davon nicht betroffen.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem naechsten weitermachen"
>
Titel ueberspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">
oder weiter versuchen abbrechen geht oben.
</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
@@ -516,6 +849,10 @@ function LaufwerkKachel({
)}
</div>
{titelOffen && (
<TitelWahlDialog id={geraet.id} stand={titelStand} schliessen={() => setTitelOffen(false)} />
)}
{aendernOffen && (
<AendernDialog
id={geraet.id}
@@ -767,6 +1104,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
const s = verbindung.schluessel
const b = verbindung.beschaffung
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
const [aktiv, setAktiv] = useState('ablage')
const SEKTIONEN: Array<[string, string]> = [
@@ -789,9 +1129,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
if (ordner !== null) setzen('ablage', ordner)
}
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
// 01.09.2026: „alles gequetscht").
return (
<div className="flex gap-8">
<nav className="sticky top-4 flex h-fit w-44 flex-none flex-col gap-1">
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
{SEKTIONEN.map(([id, name]) => (
<button
key={id}
@@ -807,7 +1150,9 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
))}
</nav>
<div className="min-w-0 max-w-2xl flex-1 space-y-5">
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
breiten Fenster alles links klebte und rechts nichts stand. */}
<div className="min-w-0 flex-1 space-y-5">
<SektionsKarte
id="ablage"
titel="ABLAGE"
@@ -1000,12 +1345,12 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
aufWechsel={(an) => void window.rippy.autostartSetzen(an)}
label="Rippy mit Windows starten (Autostart im Benutzerkonto kein Dienst, keine Adminrechte)"
/>
<Feld
name="Update-Adresse"
hinweis="leer = Standard; NUR https:// wird angenommen"
wert={werte['updateUrl'] ?? ''}
aufAendern={(w) => setzen('updateUrl', w)}
/>
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
Commanders verschwunden: Updates kommen immer vom Gitea, ein
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
`updateUrl` gilt weiterhin wer ihn wirklich braucht, setzt
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
https://). */}
{haupt !== null && (
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
<p>Selbst-Update: {haupt.update.text}</p>
@@ -1017,6 +1362,25 @@ function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: H
{verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`}
</p>
<p className="text-nebel/70">Updates warten, solange ein Rip läuft nie mitten im Vorgang.</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => void window.rippy.updatePruefen()}
disabled={ripLaeuftGerade || haupt.update.laeuft}
className="rounded-lg border border-white/15 bg-samt/60 px-3 py-1 text-[11px] text-dunst hover:text-schnee disabled:opacity-35"
title={
ripLaeuftGerade
? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).'
: 'Fragt sofort beim Update-Server nach.'
}
>
{haupt.update.laeuft ? 'sucht …' : 'Jetzt auf Updates prüfen'}
</button>
{haupt.update.neueVersion.length > 0 && (
<span className="text-gold-hell">
Version {haupt.update.neueVersion} gefunden siehe Übersicht.
</span>
)}
</div>
</div>
)}
</SektionsKarte>
@@ -1163,10 +1527,24 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
},
]
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Automatik', 'Fertig']
// Was Rippy an DIESER Maschine gemessen hat — die Grundlage der
// Preset-Empfehlung. Stand bisher nur in den Einstellungen und war
// deshalb unsichtbar (Commander-Fund 01.09.2026: „die alte Version konnte
// automatisch anhand der gescannten hardware encoder das beste preset
// wählen"). Sie konnte es, diese auch — man sah es nur nirgends.
const backends = werkzeuge?.backends ?? []
const presets = werkzeuge?.presets ?? []
const nurCpu = backends.length > 0 && backends.every((b) => b.startsWith('cpu'))
const EMPFEHLUNGEN: ReadonlyArray<readonly [string, string]> = [
['DVD', 'dvd'],
['Blu-ray', 'bluray'],
['4K-UHD', 'uhd'],
]
const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig']
return (
<div className="mx-auto max-w-xl space-y-7 pt-6">
<div className="mx-auto max-w-2xl space-y-7 pt-6">
<div className="flex gap-2">
{schritte.map((name, i) => (
<span
@@ -1184,7 +1562,10 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
<p>Rippy sichert deine DVDs, Blu-rays und Audio-CDs erst verlustfrei, dann komprimiert.</p>
<p>Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.</p>
<p>Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.</p>
<p>Dieses Setup stellt vier Dinge ein und dauert eine Minute.</p>
<p>
Rippy prüft gleich seine Werkzeuge und fragt dich danach drei Dinge: wohin, wie schnell, und
woher die Titel kommen. Dauert eine Minute ändern kannst du alles später.
</p>
</div>
)}
@@ -1204,7 +1585,16 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
{schritt === 2 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Wohin sollen fertige Filme, Musik und Abbilder?</p>
<p className="break-all font-mono text-xs text-nebel">{verbindung.einstellungen['ablage'] ?? ''}</p>
{(verbindung.einstellungen['ablage'] ?? '').length > 0 ? (
<p className="break-all font-mono text-xs text-gruen">{verbindung.einstellungen['ablage']}</p>
) : (
// Vorher stand hier eine LEERE Zeile — man sah nicht, wohin
// Rippy legt, wenn man nichts wählt. Es gibt aber eine Vorgabe
// (kern/index.ts: Videos\Rippy im Benutzerprofil).
<p className="font-mono text-xs text-nebel">
noch nichts gewählt dann nimmt Rippy Videos\Rippy" in deinem Benutzerordner
</p>
)}
<button
onClick={() =>
void window.rippy.ordnerWaehlen('Ablage-Ordner wählen').then((ordner) => {
@@ -1215,31 +1605,98 @@ function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) {
>
Ordner wählen … (auch Netzlaufwerke)
</button>
<p className="text-xs text-nebel">
Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch.
</p>
</div>
)}
{schritt === 3 && (
<div className="space-y-3">
<p className="text-sm text-dunst">Wie viel soll Rippy allein entscheiden?</p>
<label className="flex items-center gap-2 text-xs text-dunst">
<input
type="checkbox"
checked={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
onChange={(e) => einstellungSetzen('transcodeEnabled', e.target.checked ? 'true' : 'false')}
className="accent-[oklch(0.83_0.11_75)]"
/>
Nach dem Rip automatisch komprimieren (je Disc-Typ später fein einstellbar)
</label>
<div className="space-y-4">
<p className="text-sm text-dunst">
Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung
berechnen — die kostet Zeit, spart aber viel Platz.
</p>
<Schalter
an={verbindung.einstellungen['transcodeEnabled'] !== 'false'}
aufWechsel={(an) => einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')}
label="Nach dem Rip automatisch komprimieren"
/>
{/* Die Hardware-Empfehlung — gemessen, nicht geraten. */}
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
{werkzeuge === null ? (
<p className="font-mono text-xs text-nebel">Encoder werden gemessen …</p>
) : backends.length === 0 ? (
<p className="font-mono text-xs text-warn">
Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip.
</p>
) : (
<>
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] tracking-wider text-nebel">
AUF DIESER MASCHINE GEMESSEN:
</span>
{backends.map((backend) => (
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
))}
</div>
<p className="mt-2 text-xs text-dunst">
Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt:
</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{EMPFEHLUNGEN.map(([name, typ]) => (
<div key={typ} className="rounded-lg border border-white/8 bg-buehne/60 p-2">
<p className="font-mono text-[10px] tracking-wider text-nebel">{name}</p>
<p className="mt-0.5 font-mono text-xs text-gold-hell">
{presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET}
</p>
</div>
))}
</div>
{nurCpu && (
<p className="mt-2 text-xs text-warn">
Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der
verlustfreie Rip ist davon nicht betroffen.
</p>
)}
</>
)}
</div>
<p className="text-xs text-nebel">
Alles Weitere Presets je Disc-Typ, Sprachen, Medienserver, API-Schlüssel steht unter
Einstellungen".
Du musst hier nichts wählen. Unter „Einstellungen → Kompression" kannst du je Disc-Typ ein
eigenes Preset setzen deine Wahl schlägt die Empfehlung immer.
</p>
</div>
)}
{schritt === 4 && (
<div className="space-y-3">
<p className="text-sm text-dunst">
Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen
kostenlosen TMDb-Schlüssel.
</p>
<Feld
name="TMDb-API-Key"
hinweis="kostenlos auf themoviedb.org → Einstellungen → API; in zwei Minuten geholt"
wert={verbindung.einstellungen['tmdbApiKey'] ?? ''}
aufAendern={(w) => einstellungSetzen('tmdbApiKey', w)}
/>
<p className="text-xs text-nebel">
Ohne Schlüssel rippt Rippy trotzdem die Filme heißen dann so, wie die Disc sich selbst
nennt, und es gibt keine Poster. Nachtragen geht jederzeit unter Einstellungen
Metadaten".
</p>
</div>
)}
{schritt === 5 && (
<div className="space-y-3 text-sm text-dunst">
<p>Das war's. Disc einlegen — Rippy meldet sich.</p>
<p className="text-xs text-nebel">
Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über
das Tray-Menü.
</p>
{makemkvFehlt && (
<p className="text-xs text-rot">
Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt
@@ -1281,6 +1738,9 @@ type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek'
export default function App() {
const [haupt, setHaupt] = useState<HauptStatus | null>(null)
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
const [updateHinweis, setUpdateHinweis] = useState('')
// #einstellungen / #bibliothek als Startanker — der Smoke-Beweis
// fotografiert damit auch die anderen Bereiche.
const [bereich, setBereich] = useState<Bereich>(() => {
@@ -1294,6 +1754,7 @@ export default function App() {
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
@@ -1325,8 +1786,13 @@ export default function App() {
// § 6.9: Die Erst-Einrichtung läuft NACH der Installation, im Programm —
// solange sie nicht abgeschlossen ist, ist sie die ganze Ansicht.
//
// `#einrichtung` erzwingt sie (RIPPY_START_BEREICH) — wie #einstellungen
// und #bibliothek. Ohne diesen Anker wäre der Wizard nach dem ersten
// Start nicht mehr zu fotografieren und damit nicht mehr zu prüfen.
const setupOffen =
Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true'
window.location.hash === '#einrichtung' ||
(Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true')
const knopf = (ziel: Bereich, text: string): React.JSX.Element => (
<button
@@ -1355,30 +1821,53 @@ export default function App() {
<div className="buehnenlicht pointer-events-none fixed inset-0" />
<div className="filmkorn pointer-events-none fixed inset-0" />
<header className="relative border-b border-white/8 px-7 py-3.5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3.5">
<IconDisc groesse={30} farbe="oklch(0.83 0.11 75)" />
<h1 className="font-display text-[25px] font-bold tracking-tight">Rippy</h1>
<span className="rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel">
{/* Die Kopfzeile IST die Titelleiste: Sie ist die Ziehfläche des
rahmenlosen Fensters (§ 4.2). Alles Bedienbare darin trägt
`nicht-ziehbar`, sonst schluckt der Griff den Klick. */}
<header className="ziehbar relative flex-none border-b border-white/8">
<div className="mx-auto flex w-full max-w-[1600px] items-center gap-3 py-2 pl-5 pr-0 sm:pl-7">
<div className="flex min-w-0 items-center gap-3">
<IconDisc groesse={28} farbe="oklch(0.83 0.11 75)" />
<h1 className="font-display text-[22px] font-bold tracking-tight sm:text-[25px]">Rippy</h1>
<span className="hidden rounded-md border border-white/12 px-2 py-0.5 font-mono text-[10px] text-nebel md:inline">
v{haupt?.version ?? '…'}
</span>
{einRipLaeuft && (
<span className="inline-flex items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
<span className="inline-flex flex-none items-center gap-2 rounded-md border border-rec/45 bg-rec/12 px-2.5 py-0.5 font-mono text-[10px] tracking-[0.15em] text-[oklch(0.83_0.12_50)]">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]" />
REC
</span>
)}
</div>
<nav className="flex gap-1.5">
<nav className="nicht-ziehbar ml-auto flex flex-wrap justify-end gap-1.5">
{knopf('uebersicht', 'Übersicht')}
{knopf('einstellungen', 'Einstellungen')}
{knopf('bibliothek', `Bibliothek${verbindung.bibliothek.length > 0 ? ` (${verbindung.bibliothek.length})` : ''}`)}
</nav>
<div className="nicht-ziehbar ml-1 flex flex-none items-center self-start">
<FensterKnopf titel="Minimieren" aufKlick={() => void window.rippy.fensterSteuern('minimieren')}>
<IconMinimieren />
</FensterKnopf>
<FensterKnopf
titel={haupt?.fensterMaximiert === true ? 'Wiederherstellen' : 'Maximieren'}
aufKlick={() => void window.rippy.fensterSteuern('maximieren')}
>
{haupt?.fensterMaximiert === true ? <IconWiederherstellen /> : <IconMaximieren />}
</FensterKnopf>
{/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein
laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */}
<FensterKnopf
titel="In den Infobereich (Rippy läuft weiter)"
rot
aufKlick={() => void window.rippy.fensterSteuern('schliessen')}
>
<IconSchliessen />
</FensterKnopf>
</div>
</div>
</header>
<main className="relative flex-1 space-y-7 p-7">
<main className="relative mx-auto w-full max-w-[1600px] flex-1 space-y-6 px-5 py-6 sm:px-7 sm:py-7">
{setupOffen && <ErstEinrichtung verbindung={verbindung} />}
{!setupOffen && bereich === 'uebersicht' && (
<>
@@ -1392,6 +1881,62 @@ export default function App() {
</div>
)}
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
Satz in den Einstellungen. Der Commander musste es am
01.09.2026 suchen — gefunden hat er es nicht. */}
{haupt !== null && haupt.update.neueVersion.length > 0 && (
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-lg text-gold-hell">
Update verfügbar — Version {haupt.update.neueVersion}
</span>
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gold transition-all"
style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }}
/>
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">
{haupt.update.neuerungen}
</p>
</div>
)}
{haupt.update.bereit && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setUpdateHinweis('')
void window.rippy.updateInstallieren().then((urteil) => {
if (!urteil.ok) setUpdateHinweis(urteil.grund)
})
}}
disabled={einRipLaeuft}
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
title={
einRipLaeuft
? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.'
: 'Rippy beendet sich, installiert und startet wieder.'
}
>
Jetzt neu starten und installieren
</button>
<span className="font-mono text-[11px] text-nebel">
… oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.
</span>
</div>
)}
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
</div>
)}
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
@@ -1412,6 +1957,7 @@ export default function App() {
ripStartMs={verbindung.ripStart[geraet.id]}
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
verbindung={verbindung}
titelStand={verbindung.discTitel[geraet.id]}
/>
))}
</div>
+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>
}
}
}
+23 -3
View File
@@ -14,6 +14,8 @@ import {
type GeraeteInfo,
type KernNachricht,
type MetaVorschlag,
type TitelStand,
type RipAuftragTitel,
type RipStatus,
type WerkzeugAuskunft,
} from '../gemeinsam/nachrichten'
@@ -30,6 +32,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
@@ -65,6 +69,7 @@ const stand: Verbindung = {
laufwerke: [],
rips: {},
discInfos: {},
discTitel: {},
vorschlaege: {},
ripStart: {},
ripProtokoll: {},
@@ -140,6 +145,10 @@ function kernNachricht(nachricht: KernNachricht): void {
}
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
@@ -245,7 +254,7 @@ export function aufVerbindung(h: Horcher): () => void {
return () => horcher.delete(h)
}
function anKern(nachricht: { art: string; id: string }, wasWar: string): void {
function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void {
if (port === null) {
meldung(`${wasWar}: keine Verbindung zum Kern`)
melden()
@@ -258,8 +267,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[] = []): void {
anKern({ art: 'rip-start', id, auftrag }, `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 {
+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; }
+109 -4
View File
@@ -57,6 +57,59 @@ 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
}
/** 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'
/** 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
}
/** 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
}
/** 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
}
/** Was Rippy über die eingelegte Disc herausgefunden hat (§ 5 Schritt 3). */
@@ -75,6 +128,13 @@ 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
}
/** Ein Eintrag der Bibliothek (§ 6.6). */
@@ -129,6 +189,7 @@ export type KernNachricht =
| { 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 }
@@ -156,8 +217,16 @@ export type KernNachricht =
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[] }
| { 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 }
@@ -182,8 +251,31 @@ 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> {
@@ -220,6 +312,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':
@@ -277,7 +376,13 @@ export function istDbStatus(wert: unknown): wert is DbStatus {
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') {
if (
wert.art === 'auswerfen' ||
wert.art === 'titel-lesen' ||
wert.art === 'rip-start' ||
wert.art === 'rip-abbruch' ||
wert.art === 'rip-ueberspringen'
) {
return typeof wert.id === 'string' && wert.id.length > 0
}
if (wert.art === 'einstellung-setzen') {
+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)}` }
}
}
+51 -1
View File
@@ -18,7 +18,7 @@ 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')
@@ -83,6 +83,13 @@ function start(): void {
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') {
@@ -172,6 +179,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 +195,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 +234,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)
+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: '' }
}
+215 -40
View File
@@ -10,11 +10,17 @@
import { copyFileSync, mkdirSync, statSync, 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, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
import type {
BibliothekEintrag,
GeraeteInfo,
RipAuftragTitel,
RipStatus,
TitelRolle,
} from '../../gemeinsam/nachrichten'
import { baueNfo } from '../ablage/nfo'
import { bibliothekRefresh } from '../ablage/medienserver'
import { posterSpeichern } from '../ablage/poster'
import { zielordner } from '../ablage/struktur'
import { episodenUmbenennen, matcheEpisoden, serienOrdner, zielordner } from '../ablage/struktur'
import { komprimieren } from '../komprimieren/handbrake'
import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets'
import type { Zuordnung } from '../metadaten/zuordnung'
@@ -27,7 +33,12 @@ import { mkdtempSync, rmSync, createWriteStream } from 'node:fs'
import { tmpdir } from 'node:os'
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
}
@@ -61,6 +72,14 @@ export interface PipelineUmgebung {
zuordnung?: (id: string) => Zuordnung | null
/** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */
fingerprint?: (id: string) => string | null
/** Was die Disc über sich selbst sagt — für die Staffel-Nummer (§ 4.2). */
discAngaben?: (id: string) => { staffel: number | null; discNr: number | null } | null
/** Laufzeiten einer Staffel von TMDb: [Episodennummer, Sekunden].
* Injiziert, damit die Pipeline ohne Netz prüfbar bleibt. */
episodenLaufzeiten?: (
tmdbId: number,
staffel: number,
) => Promise<ReadonlyArray<readonly [number, number]>>
/** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */
bibliothekEintragen?: (eintrag: BibliothekEintrag) => void
/** Zeitstempel für Ordnernamen — injizierbar für Tests. */
@@ -86,8 +105,19 @@ 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[] = []): Promise<void> {
if (this.laufend.has(id)) {
this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`))
return
@@ -143,48 +173,144 @@ export class Pipeline {
}
const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName)
mkdirSync(rohOrdner, { recursive: true })
const start: RipStatus = {
id,
phase: 'rippt',
prozent: -1,
text: `Rippe verlustfrei nach ${rohOrdner}`,
dateien: [],
lesefehler: false,
}
this.laufend.set(id, { abbruch, status: start })
this.melden(start)
// 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']
// Welche Rolle bekam welche Datei? Die Zuordnung ist EXAKT, weil je
// Lauf genau ein Titel dazukommt — nicht aus Dateinamen geraten.
const rolleJeDatei = new Map<string, TitelRolle>()
const dauerJeDatei = new Map<string, number>()
const alleDateien = new Set<string>()
let lesefehlerGesamt = false
const gescheitert: string[] = []
const uebersprungen: string[] = []
const rip = await rippen({
programm,
geraetepfad: geraet.pfad,
zielOrdner: rohOrdner,
titel: 'all',
abbruch: abbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent })
},
aufMeldung: (_code, text) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, text })
},
this.laufend.set(id, {
abbruch,
status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false },
})
if (rip.status === 'cancelled') {
this.laufend.delete(id)
this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false })
return
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 ${beschriftung} verlustfrei nach ${rohOrdner}`,
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
titelNr: i + 1,
titelGesamt: laeufe.length,
haengt: null,
})
const rip = await rippen({
programm,
geraetepfad: geraet.pfad,
zielOrdner: rohOrdner,
titel: welcher,
abbruch: titelAbbruch.signal,
aufFortschritt: (prozent) => {
const alt = this.laufend.get(id)?.status
if (alt !== undefined) this.melden({ ...alt, prozent })
},
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') {
// Nur dieser Titel — oder der ganze Auftrag?
if (this.laufend.get(id)?.uebersprungen !== true) {
this.laufend.delete(id)
this.melden({
id,
phase: 'abgebrochen',
prozent: -1,
text: 'Abgebrochen.',
dateien: [...alleDateien],
lesefehler: lesefehlerGesamt,
})
return
}
uebersprungen.push(beschriftung)
continue
}
lesefehlerGesamt = lesefehlerGesamt || rip.lesefehler
if (rip.status === 'error') {
// EIN kaputter Titel beendet den Auftrag nicht — die anderen sind
// vielleicht heil. Der Grund wird gesammelt und am Ende genannt.
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)
// Ohne Auswahl ('all') gilt für alles die Film-Regel.
const rolle: TitelRolle = auftrag[i]?.rolle ?? 'hauptfilm'
for (const d of neu) {
rolleJeDatei.set(d, rolle)
dauerJeDatei.set(d, auftrag[i]?.dauerS ?? 0)
}
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`)
}
}
if (rip.status === 'error') {
const nachLauf = this.laufend.get(id)
if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined
if (alleDateien.size === 0) {
this.laufend.delete(id)
this.melden({ id, phase: 'fehler', prozent: -1, text: rip.fehler, dateien: [], lesefehler: rip.lesefehler })
this.melden({
id,
phase: 'fehler',
prozent: -1,
text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.',
dateien: [],
lesefehler: lesefehlerGesamt,
})
return
}
const lesefehlerHinweis = rip.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: ''
const rip = { dateien: [...alleDateien].sort(), lesefehler: lesefehlerGesamt }
const lesefehlerHinweis =
(rip.lesefehler
? ' ⚠ Die Disc ließ sich stellenweise nicht lesen — mindestens ein Titel könnte fehlen (makemkv.com/errors/read).'
: '') +
(uebersprungen.length > 0 ? ` ⚠ Übersprungen: ${uebersprungen.join(', ')}.` : '') +
(gescheitert.length > 0 ? ` ⚠ Nicht gesichert: ${gescheitert.join(' · ')}` : '')
// ── Kompression (W-3) ────────────────────────────────────────────────
const einstellungen = this.umgebung.einstellungen()
@@ -211,16 +337,40 @@ export class Pipeline {
// 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
// ── 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 = auftrag.some((a) => a.rolle === 'folge')
const staffel = this.umgebung.discAngaben?.(id)?.staffel ?? 1
const serienTitel = zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName
const fertigOrdner = alsSerie
? serienOrdner(join(this.umgebung.ablage(), 'Serien'), serienTitel, staffel)
: 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 })
// Extras liegen im Unterordner „extras" NEBEN dem Hauptfilm — so
// lesen Jellyfin, Emby und Kodi sie als Zugaben und nicht als
// zweiten Film. Der Ordner entsteht nur, wenn es wirklich Extras gibt.
const extrasOrdner = join(fertigOrdner, 'extras')
const zielFuer = (roh: string): string => {
const rolle = rolleJeDatei.get(roh) ?? 'hauptfilm'
return rolle === 'extra' ? extrasOrdner : fertigOrdner
}
if (rip.dateien.some((d) => zielFuer(d) === extrasOrdner)) {
mkdirSync(extrasOrdner, { recursive: true })
}
const fertigeDateien: string[] = []
// Laufzeiten der FOLGEN in Ablage-Reihenfolge — Grundlage der
// Episoden-Zuordnung weiter unten.
const folgenSekunden: number[] = []
for (let i = 0; i < rip.dateien.length; i++) {
const eingabe = rip.dateien[i]
const ausgabe = join(fertigOrdner, basename(eingabe))
const ausgabe = join(zielFuer(eingabe), basename(eingabe))
const alt = this.laufend.get(id)?.status
if (alt !== undefined) {
this.melden({
@@ -270,6 +420,31 @@ export class Pipeline {
return
}
fertigeDateien.push(encode.ausgabe)
if ((rolleJeDatei.get(eingabe) ?? 'hauptfilm') === 'folge') {
folgenSekunden.push(dauerJeDatei.get(eingabe) ?? 0)
}
}
// ── Serien-Staffelablage (§ 4.2) ────────────────────────────────────
// Umbenannt wird NUR bei EINDEUTIGER Zuordnung — lieber gar nicht als
// falsch (die offene Wunde #395 von ARM war genau dieses Ratespiel).
// Die Laufzeiten der Staffel kommen von TMDb; ohne sie bleibt es beim
// MakeMKV-Namen, und Rippy SAGT das.
const ablageMeldungen: string[] = []
if (alsSerie && folgenSekunden.length > 0) {
const tmdbId = typeof zuordnung?.meta.id === 'number' ? zuordnung.meta.id : null
const laufzeiten =
tmdbId !== null ? ((await this.umgebung.episodenLaufzeiten?.(tmdbId, staffel)) ?? []) : []
const episoden = laufzeiten.length > 0 ? matcheEpisoden(folgenSekunden, laufzeiten) : null
if (episoden !== null) {
ablageMeldungen.push(...episodenUmbenennen(fertigOrdner, serienTitel, staffel, episoden))
} else {
ablageMeldungen.push(
laufzeiten.length === 0
? 'Episoden-Nummern unbekannt (keine Staffel-Laufzeiten von TMDb) — die Dateien behalten ihre Namen.'
: 'Episoden ließen sich nicht EINDEUTIG zuordnen — die Dateien behalten ihre Namen, lieber gar nicht als falsch.',
)
}
}
// ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ───────────
+143 -4
View File
@@ -26,12 +26,14 @@ import {
listOptischeLaufwerke,
} from './laufwerk/disc'
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 { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
import { Datenbank } from './speicher/db'
import { titelInfoLesen, ABGELOEST } from './rip/makemkv'
import { titelVorauswahl } from './rip/titelwahl'
import { finden } from './werkzeuge/katalog'
// --datenbank=<pfad> kommt vom Haupt (der Kern kennt Electrons app.getPath
@@ -94,15 +96,21 @@ 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>()
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)
// Der Hinweis kommt von der Disc selbst (bdmt-Inhaltsverzeichnis) —
// trägt sie Folgen, kann kein Filmtreffer mehr gewinnen.
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb, { istSerie: gefunden.istSerie })
discZuordnungen.set(id, zuordnung)
anHauptUndFenster({
art: 'disc-info',
@@ -118,6 +126,9 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
// § 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,
discNr: gefunden.discNr,
staffel: gefunden.staffel,
folgen: gefunden.folgen,
},
})
} catch (fehler) {
@@ -203,6 +214,10 @@ async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<vo
backdropPfad: zuordnung.meta.backdropPfad,
beschreibung: zuordnung.meta.beschreibung,
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
// Eigene Titelwahl ändert nichts daran, welche Disc der Ausgabe hier liegt.
discNr: discMerkmale.get(id)?.discNr ?? null,
staffel: discMerkmale.get(id)?.staffel ?? null,
folgen: discMerkmale.get(id)?.folgen ?? 0,
},
})
} catch (fehler) {
@@ -227,6 +242,7 @@ const laufwerkeBereit: Promise<void> = (async () => {
} else if (ereignis.art === 'disc-entnommen') {
discZuordnungen.delete(ereignis.geraet.id)
discFingerprints.delete(ereignis.geraet.id)
discMerkmale.delete(ereignis.geraet.id)
anHauptUndFenster({
art: 'disc-info',
info: {
@@ -239,6 +255,9 @@ const laufwerkeBereit: Promise<void> = (async () => {
backdropPfad: '',
beschreibung: '',
schonGerippt: null,
discNr: null,
staffel: null,
folgen: 0,
},
})
}
@@ -261,6 +280,95 @@ 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.
// ── 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 = geraetVon(id)
if (geraet === null || laufwerkApi === null) {
anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: `Laufwerk ${id}: gibt es nicht.` } })
return
}
const geraetepfad = geraeteInfo(laufwerkApi, 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,
titel: wahl.titel,
einordnung: wahl.einordnung,
unsicher: wahl.unsicher,
fehler: ohneErgebnis ? info.ursachen.join(' · ') : '',
},
})
} 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)
}
}
}
function ablageOrdner(): string {
const eingestellt = db?.einstellungLesen('ablage')
if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt
@@ -320,6 +428,21 @@ const ripVerwaltung = new Pipeline({
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
zuordnung: (id) => discZuordnungen.get(id) ?? null,
discAngaben: (id) => {
const m = discMerkmale.get(id)
return m === undefined ? null : { staffel: m.staffel, discNr: m.discNr }
},
// 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
},
fingerprint: (id) => discFingerprints.get(id) ?? null,
bibliothekEintragen: (eintrag) => {
try {
@@ -559,15 +682,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 ?? []))
} 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') {
+159 -25
View File
@@ -105,29 +105,118 @@ 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[]
}
/** Liest die bdmt-Datei EINMAL bdmt_eng bevorzugt. Regex statt
* XML-Parser (Namespaces variieren je Authoring-Werkzeug). */
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: [] }
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 ''
if (bdmt === undefined) return leer
let inhalt: string
try {
inhalt = readFileSync(join(metaOrdner, bdmt)).subarray(0, 65536).toString('utf8')
} catch {
return ''
return leer
}
const treffer = /<di:name>([^<]{2,120})<\/di:name>/.exec(inhalt)
return treffer === null ? '' : treffer[1].trim()
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 }
}
/** 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 +227,50 @@ 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
}
/** 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): DiscTitel {
const zusatz = discZusatzAbtrennen(titel)
return {
titel,
quelle,
discNr: zusatz.discNr,
staffel: zusatz.staffel,
folgen,
istSerie: folgen >= FOLGEN_FUER_SERIE,
}
}
/** 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)
}
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 }
}
/** Billiger, stabiler Disc-Fingerabdruck (Label|Größe) Basis der
@@ -163,25 +280,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
}
+122 -33
View File
@@ -12,7 +12,7 @@
//
// 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 { discZusatzAbtrennen, titelKandidaten } from './discmerkmale'
import type { FilmDetails, FilmTreffer, TmdbClient } from './tmdb'
import type { OmdbClient, OmdbTreffer } from './omdb'
@@ -94,17 +94,21 @@ const UNBEKANNT = (titel: string): Zuordnung => ({
},
})
/** 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 worte = (t: string): Set<string> =>
new Set(
t
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter((w) => w.length > 0),
)
const wa = worte(a)
const wb = worte(b)
if (wa.size === 0 || wb.size === 0) return 0
@@ -113,59 +117,130 @@ 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
}
/** 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.
*/
export async function zuordnen(
discTitel: string,
tmdb: Pick<TmdbClient, 'searchMovie' | 'searchTv' | 'movieDetails' | 'tvDetails' | 'findByImdb' | 'verfuegbar'>,
omdb: Pick<OmdbClient, 'lookup' | 'verfuegbar'>,
hinweis: DiscHinweis = {},
): Promise<Zuordnung> {
const titel = discTitel.trim()
if (titel.length === 0) return UNBEKANNT('')
const nurSerie = hinweis.istSerie === true
// Maßstab für die Abdeckung ist der Titel OHNE Verpackungs-Zusatz:
// '- Disc 2' ist Rauschen und darf einer guten Frage nicht angelastet werden.
const massstab = discZusatzAbtrennen(titel).titel || titel
const kandidaten = titelKandidaten(titel)
if (kandidaten.length === 0) kandidaten.push(titel)
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(massstab, kandidat)
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)) ?? [])
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)) {
const details = await tmdb.movieDetails(film.id)
if (details !== null) {
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)) ?? []
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)) {
const details = await tmdb.tvDetails(serie.id)
if (details !== null) {
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).
if (vollstaendig) {
if (filme.length > 0 && filme.length <= WENIGE_TREFFER) {
const details = await tmdb.movieDetails(filme[0].id)
if (details !== null) {
@@ -189,6 +264,9 @@ export async function zuordnen(
for (const kandidat of kandidaten) {
const treffer = await omdb.lookup(kandidat)
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.
@@ -208,17 +286,28 @@ 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(massstab, kandidat) >= 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)
// 5. Vorschlag: bester Rohtreffer (0,60) — das UI fragt nach. Bei einer
// Serien-Disc ist die Filmliste leer, dort zählen nur die Serien.
if (ersteFilme.length > 0) {
const details = await tmdb.movieDetails(ersteFilme[0].id)
if (details !== null) {
const meta = ausDetails('movie', ersteTreffer[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: 0.6, meta }
const meta = ausDetails('movie', ersteFilme[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
}
}
if (ersteSerien.length > 0) {
const details = await tmdb.tvDetails(ersteSerien[0].id)
if (details !== null) {
const meta = ausDetails('tv', ersteSerien[0].id, details)
return { titel: meta.titel, jahr: meta.jahr, confidence: VORSCHLAG, meta }
}
}
+79 -1
View File
@@ -18,6 +18,7 @@ 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,6 +157,7 @@ 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), {
@@ -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
}
@@ -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))
+20
View File
@@ -249,6 +249,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).
+158
View File
@@ -0,0 +1,158 @@
// 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
}
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 }
}
@@ -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',
]
@@ -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)
})
})
+190 -3
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[] = []
@@ -169,10 +180,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 +195,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 +238,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: [] })
})
})
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')
+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