diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 0a768e6..68677d9 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,29 +1,24 @@ -Version 5.2.0 +Version 5.3.0 -Du wählst jetzt aus, was gerippt wird. -Bisher hat Rippy immer ALLES von der Disc geholt — auch Trailer, Werbung -und Alterskennzeichen. Ein Klick auf "Rippen" liest jetzt erst die Titel -der Disc (das dauert bis zu zwei Minuten, solange dreht das Laufwerk) und -zeigt sie dir mit Dauer, Größe und Kapitelzahl. +Serien landen jetzt dort, wo Jellyfin sie sucht. +Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme +statt einer Staffel. Jetzt legt Rippy sie unter Serien//Season 01 +ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten +eindeutig zur Staffel passen; sonst behalten die Dateien ihre Namen und +Rippy sagt das. Lieber gar nicht als falsch. -Rippy macht dabei einen Vorschlag. -Bei einer Serie hakt es so viele Titel an, wie die Disc Folgen nennt — -und sagt dazu, ob die Zahl passt. Bei einem Film den längsten Titel. Zwei -Fallen kennt es: einen Sammeltitel, der alle Folgen am Stück enthält, -wählt es NICHT vor; und wenn mehrere Titel fast gleich lang sind, sagt es -offen, dass es unsicher ist, statt sich stumm zu entscheiden. +Bei Filmen wählst du jetzt auch, was mit soll. +Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst +du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen +im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi +sie als Zugaben und nicht als zweiten Film. -Ein Defekt auf der Disc kostet nicht mehr alles. -Rippy rippt jetzt Titel für Titel. Merkt es, dass MakeMKV immer wieder an -derselben Stelle hängt, sagt es das im Klartext und bietet an, diesen -einen Titel zu überspringen — der Rest der Disc läuft weiter. Vorher riss -ein kaputter Sektor den ganzen Durchlauf mit, und im Protokoll rauschte -stundenlang dieselbe Zeile durch. +Neuer Knopf in der Auswahl: "Hauptinhalt + Extras". +Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und +Alterskennzeichen. -Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung überarbeitet. +Aus 5.2.0: Titel-Auswahl vor dem Rip und der Defekt-Waechter. +Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung ueberarbeitet. Aus 5.1.4: Das Update installiert sich wirklich. -Aus 5.1.3: Eigenes Fenster, Layout wächst mit. -Aus 5.1.2: Rippy sagt Bescheid, wenn ein Update da ist. -Aus 5.1.1: Serien-Discs werden richtig erkannt. -Unverändert: Updates warten immer, bis kein Rip mehr läuft. +Unveraendert: Updates warten immer, bis kein Rip mehr laeuft. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index de0804d..61fa4de 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.2.0", + "version": "5.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.2.0", + "version": "5.3.0", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 1eb5217..7a98783 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.2.0", + "version": "5.3.0", "description": "Rippy v5 — Disc-Ripping als eigenständiges Windows-Programm (KONZEPT-WINDOWS.md)", "main": "out/haupt/index.js", "author": "KrBrZ", diff --git a/rippy-windows/src/fenster/App.tsx b/rippy-windows/src/fenster/App.tsx index b9ea656..71db938 100644 --- a/rippy-windows/src/fenster/App.tsx +++ b/rippy-windows/src/fenster/App.tsx @@ -1,1922 +1,1974 @@ -// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026, -// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz, -// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die -// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit -// großer Prozentzahl, Mono-Protokoll, LED-Systemleiste. Der Look zieht -// sich durch ALLE Bereiche (Übersicht, Einstellungen, Bibliothek, -// 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, TitelStand } from '../gemeinsam/nachrichten' -import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung' -import { - aufVerbindung, - auswerfen, - einstellungSetzen, - makemkvUpdate, - metadatenSuchen, - ripAbbrechen, - ripStarten, - ripUeberspringen, - schluesselPruefen, - titelLesen, - verbindungStarten, - zuordnungSetzen, - type Verbindung, -} from './kernverbindung' -import type { MetaVorschlag } from '../gemeinsam/nachrichten' - -function posterUrlKlein(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w154${pfad}` -} - -function posterUrlGross(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w342${pfad}` -} - -function backdropUrl(pfad: string): string { - if (pfad.length === 0) return '' - if (pfad.startsWith('http')) return pfad - return `https://image.tmdb.org/t/p/w780${pfad}` -} - -function restzeitText(prozent: number, startMs: number | undefined): string { - if (startMs === undefined || prozent <= 2 || prozent >= 100) return '' - const verstrichen = (Date.now() - startMs) / 1000 - const restS = Math.round((verstrichen * (100 - prozent)) / prozent) - if (restS < 90) return `REST ~${restS} S` - return `REST ~${Math.round(restS / 60)} MIN` -} - -function istHauptStatus(wert: unknown): wert is HauptStatus { - return ( - typeof wert === 'object' && - wert !== null && - typeof (wert as Record).version === 'string' - ) -} - -function gb(bytes: number): string { - return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB` -} - -const TYP_NAMEN: Record = { - cd: 'Audio-CD', - dvd: 'DVD', - bluray: 'Blu-ray', - uhd: '4K-UHD', - unknown: 'unbekannt', -} - -const PHASEN_MONO: Record = { - 'liest-info': 'PHASE 1/4 — LIEST TITEL-INFO', - rippt: 'PHASE 2/4 — RIPPT (VERLUSTFREI)', - komprimiert: 'PHASE 3/4 — KOMPRIMIERT', - 'legt-ab': 'PHASE 4/4 — LEGT AB', - fertig: 'FERTIG', - fehler: 'FEHLER', - abgebrochen: 'ABGEBROCHEN', -} - -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 => ( - -) - -const IconMaximieren = (): React.JSX.Element => ( - -) - -const IconWiederherstellen = (): React.JSX.Element => ( - -) - -const IconSchliessen = (): React.JSX.Element => ( - -) - -function FensterKnopf({ - titel, - aufKlick, - rot = false, - children, -}: { - titel: string - aufKlick: () => void - rot?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -function sicherheitsWort(confidence: number): string { - if (confidence >= 0.9) return 'sehr wahrscheinlich' - if (confidence >= 0.8) return 'wahrscheinlich' - if (confidence >= 0.6) return 'Vorschlag' - 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 { - if (typ === 'cd') return 'FLAC (verlustfrei)' - if (typ === 'unknown') return 'ISO-Abbild' - const werte = verbindung.einstellungen - if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression' - const schluessel = - typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray' - const eigene = werte[schluessel] ?? '' - const allgemein = werte['transcodePreset'] ?? '' - const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? []) - const gewaehlt = eigene.length > 0 ? eigene : allgemein.length > 0 ? allgemein : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET - return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt -} - -// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ── -function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) { - return ( - - - - - - ) -} - -function IconPlay() { - return ( - - - - ) -} - -function IconStop() { - return ( - - - - ) -} - -function IconEject() { - return ( - - - - ) -} - -function IconStift() { - return ( - - - - ) -} - -function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) { - const farben = { - gruen: 'border-gruen/40 bg-gruen/10 text-gruen', - gelb: 'border-warn/40 bg-warn/10 text-warn', - blau: 'border-gold/40 bg-gold/10 text-gold-hell', - grau: 'border-white/15 bg-white/5 text-dunst', - } - return ( - - {text} - - ) -} - -/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */ -function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) { - return ( -
-
{label}
-
- {wert} -
-
- ) -} - -/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */ -function SegmentBalken({ prozent }: { prozent: number }) { - const segmente = 20 - const anteil = Math.max(0, Math.min(100, prozent)) / 100 - const voll = Math.floor(anteil * segmente) - const halb = anteil * segmente - voll >= 0.3 - return ( -
- {Array.from({ length: segmente }, (_, i) => ( -
- ))} -
- ) -} - -// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten. -function AendernDialog({ - id, - startText, - vorschlaege, - schliessen, -}: { - id: string - startText: string - vorschlaege: MetaVorschlag[] - schliessen: () => void -}) { - const [text, setText] = useState(startText) - useEffect(() => { - metadatenSuchen(id, startText) - // Nur beim Öffnen — weitere Suchen löst der Knopf aus. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - return ( -
-
e.stopPropagation()} - > -

Was ist das wirklich?

-
- setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') metadatenSuchen(id, text) - }} - placeholder="Titel suchen …" - className="flex-1 rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none" - /> - -
- {vorschlaege.length === 0 ? ( -

Keine Treffer — anderen Suchtext probieren.

- ) : ( -
- {vorschlaege.map((v) => ( - - ))} -
- )} -
- -
-
-
- ) -} - -// 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 20–120 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 [gewaehlt, setGewaehlt] = useState | null>(null) - // Rippys Vorauswahl gilt, bis der Nutzer sie anfasst. - const aktiv = gewaehlt ?? new Set((stand?.titel ?? []).filter((t) => t.gewaehlt).map((t) => t.nr)) - 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 ( -
-
e.stopPropagation()} - > -

Was soll gerippt werden?

- - {stand === undefined || stand.laeuft ? ( -

- Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk. -

- ) : stand.fehler.length > 0 ? ( -

✗ {stand.fehler}

- ) : ( - <> -

- {stand.unsicher ? '⚠ ' : ''} - {stand.einordnung} -

- -
- - - -
- -
- {alle.map((t) => ( - - ))} - {alle.length === 0 && ( -

Die Disc gab keine Titel her.

- )} -
- -

- {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} -

- - )} - -
- - -
-
-
- ) -} - -function LaufwerkKachel({ - geraet, - rip, - info, - vorschlaege, - ripStartMs, - protokoll, - verbindung, - titelStand, -}: { - geraet: GeraeteInfo - rip: RipStatus | undefined - info: DiscInfoStand | undefined - vorschlaege: MetaVorschlag[] - 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' - const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : '' - const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : '' - const erkannt = discDa && info !== undefined && info.titel.length > 0 - - return ( -
- {backdrop.length > 0 && ( - <> - -
-
- - )} - -
-
-
- - LAUFWERK {geraet.id}: - · - {geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'} -
-
- {ripLaeuft ? ( - - ) : ( - - )} - -
-
- - {!discDa && ( -
- -

- {geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund} -

-
- )} - - {discDa && ( -
- {poster.length > 0 ? ( - - ) : ( -
- -
- )} -
- {erkannt ? ( -

- {info.titel} - {info.jahr !== null && ({info.jahr})} -

- ) : ( -

- {TYP_NAMEN[geraet.typ]} -

- )} - {erkannt && info.beschreibung.length > 0 && ( -

{info.beschreibung}

- )} - {!erkannt && ( -

- {geraet.typ === 'cd' - ? 'Titel kommen beim Rippen von MusicBrainz.' - : 'Noch keine Zuordnung — „Ändern …" hilft nach.'} -

- )} - - {/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im - breiten — vorher immer vier, also im schmalen gequetscht. */} -
- - 0 ? gb(geraet.groesseBytes) : '—'} /> - 0 ? ` · ${info.quelle}` : ''}` - : '—' - } - farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined} - /> - -
- -
- - {erkannt && ( - = 0.8 ? 'gruen' : 'gelb'} - /> - )} - {info !== undefined && discHerkunft(info).length > 0 && ( - - )} - {info !== undefined && info.schonGerippt !== null && ( - - )} -
- {geraet.grund.length > 0 &&

{geraet.grund}

} -
-
- )} - - {rip !== undefined && ( -
-
-
-
- - {PHASEN_MONO[rip.phase]} - {rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''} - - {ripLaeuft && ( - {restzeitText(rip.prozent, ripStartMs)} - )} -
- {ripLaeuft && } - {ripLaeuft && (rip.titelGesamt ?? 0) > 1 && ( -

- Titel {rip.titelNr} von {rip.titelGesamt} -

- )} - - {/* 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 && ( -
-

- Die Disc hat an einer Stelle einen Defekt. -

-

- 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. -

-
- - - oder weiter versuchen — abbrechen geht oben. - -
-
- )} - -

- > {rip.text} -

- {rip.dateien.map((datei) => ( -

- {datei} -

- ))} -
- {ripLaeuft && rip.prozent >= 0 && ( -
- {rip.prozent} - % -
- )} -
- {protokoll.length > 1 && ( - - )} - {protokollOffen && ( -
- {protokoll.map((zeile, i) => ( -

- > {zeile} -

- ))} -
- )} -
- )} -
- - {titelOffen && ( - setTitelOffen(false)} /> - )} - - {aendernOffen && ( - 0 ? info.titel : ''} - vorschlaege={vorschlaege} - schliessen={() => setAendernOffen(false)} - /> - )} -
- ) -} - -// Die MakeMKV-Karte (§ 9.1) — amber Glas mit dem Update-Weg aus § 9. -function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) { - const s = verbindung.schluessel - const b = verbindung.beschaffung - const punktFarbe = - s === null - ? 'bg-warn' - : s.angenommen === true - ? 'bg-gruen' - : s.weiterBetrieb - ? 'bg-warn' - : 'bg-rot' - return ( -
-
- - - MAKEMKV-SCHLÜSSEL - -
- {s !== null && s.version.length > 0 && ( - MakeMKV {s.version} - )} - {s !== null && s.angenommen === false && ( - - )} - -
-
- {s === null ? ( -

wird geprüft …

- ) : ( - <> -

{s.text}

-

- zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} - {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} -

- - )} - {b !== null && ( -
-

- {b.text} -

- {b.laeuft && b.anteil !== null && ( -
-
-
- )} -
- )} -
- ) -} - -// ── Einstellungen ─────────────────────────────────────────────────────── -const FELD_KLASSE = - 'mt-1 w-full rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none' - -function Kicker({ text }: { text: string }) { - return

{text}

-} - -/** Ein richtiger Schalter statt einer nackten Checkbox. */ -function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) { - return ( - - ) -} - -/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */ -function SektionsKarte({ - id, - titel, - intro, - children, -}: { - id: string - titel: string - intro: string - children: React.ReactNode -}) { - return ( -
- -

{intro}

-
{children}
-
- ) -} - -/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */ -function HinterlegtPunkt({ wert }: { wert: string }) { - return ( - 0 ? 'text-gruen' : 'text-nebel'}`}> - {wert.length > 0 ? '● hinterlegt' : '○ fehlt'} - - ) -} - -function Feld({ - name, - wert, - hinweis, - geheim, - aufAendern, -}: { - name: string - wert: string - hinweis?: string - geheim?: boolean - aufAendern: (wert: string) => void -}) { - const [entwurf, setEntwurf] = useState(wert) - useEffect(() => setEntwurf(wert), [wert]) - return ( - - ) -} - -function PresetWahl({ - name, - schluessel, - wert, - presets, - empfehlung, - aufAendern, -}: { - name: string - schluessel: string - wert: string - presets: string[] - empfehlung: string - aufAendern: (schluessel: string, wert: string) => void -}) { - return ( - - ) -} - -/** Eine Werkzeug-Zeile: Fundort in Klartext plus der eigene Pfad, der - * die automatische Suche übersteuert (Katalog-Schritt 1). */ -function WerkzeugZeile({ - name, - pfad, - fehlt, - pflicht, - schluessel, - wert, - aufAendern, -}: { - name: string - pfad: string | null - fehlt: string - pflicht: boolean - schluessel: string - wert: string - aufAendern: (schluessel: string, wert: string) => void -}) { - return ( -
-

0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}> - {name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt} -

- aufAendern(schluessel, w)} - /> -
- ) -} - -function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) { - const werte = verbindung.einstellungen - const presets = verbindung.werkzeuge?.presets ?? [] - const backends = verbindung.werkzeuge?.backends ?? [] - const setzen = einstellungSetzen - 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]> = [ - ['ablage', 'Ablage'], - ['kompression', 'Kompression'], - ['metadaten', 'Metadaten'], - ['makemkv', 'MakeMKV'], - ['medienserver', 'Medienserver'], - ['programm', 'Programm'], - ['werkzeuge', 'Werkzeuge'], - ] - - const springe = (id: string): void => { - setAktiv(id) - document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) - } - - const ordnerWaehlen = async (): Promise => { - const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen') - 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 ( -
- - - {/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem - breiten Fenster alles links klebte und rechts nichts stand. */} -
- -
-
- setzen('ablage', w)} /> -
- -
-
-

So legt Rippy ab:

-

├─ Filme\Akira (1988)\ — Film, Poster und Infodatei beieinander

-

├─ Musik\ — Audio-CDs als FLAC, benannt nach MusicBrainz

-

├─ ISO\ — unerkannte Discs als 1:1-Abbild

-

└─ roh\ — Arbeitsstand während des Rips, räumt sich selbst auf

-
-
- - - {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && ( -

⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).

- )} - setzen('transcodeEnabled', an ? 'true' : 'false')} - label="Nach dem Rip automatisch komprimieren" - /> - {backends.length > 0 && ( -
- GEMESSENE ENCODER: - {backends.map((backend) => ( - - ))} -
- )} -
- - - -
-

- „automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine - die Grafikkarte, wo es sie gibt. Eine eigene Wahl schlägt die Empfehlung immer. -

- setzen('transcodePreset', w)} - /> -
- setzen('audioSprachen', w)} - /> - setzen('untertitelSprachen', w)} - /> -
-
- - -
-
- setzen('tmdbApiKey', w)} /> -

- www.themoviedb.org → Einstellungen → API - -

-
-
- setzen('omdbApiKey', w)} /> -

- www.omdbapi.com/apikey.aspx - -

-
-
-

- Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" - an der Laufwerks-Kachel bleibt ohne Suche. -

-
- - - {s !== null && ( -
-

- - - {s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'} - - {s.version.length > 0 && MakeMKV {s.version}} -

-

{s.text}

-

- zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} - {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} -

-
- )} -
- {s !== null && s.angenommen === false && ( - - )} - -
- {b !== null && ( -

- {b.text} -

- )} - setzen('makemkvAppKey', w)} - /> -
- - -
- - setzen('mediaServerUrl', w)} /> - setzen('mediaServerApiKey', w)} /> -
-

- Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur - aus „Ablage" passt für alle vier. -

-
- - - void window.rippy.autostartSetzen(an)} - label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" - /> - {/* 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 && ( -
-

Selbst-Update: {haupt.update.text}

- {haupt.update.letzteErfolgreichePruefung.length > 0 && ( -

letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}

- )} -

- Rippy v{haupt.version} - {verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`} -

-

Updates warten, solange ein Rip läuft — nie mitten im Vorgang.

-
- - {haupt.update.neueVersion.length > 0 && ( - - Version {haupt.update.neueVersion} gefunden — siehe Übersicht. - - )} -
-
- )} -
- - - - - - {verbindung.werkzeuge !== null && ( -

- HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder:{' '} - {backends.length > 0 ? backends.join(', ') : '—'} -

- )} -
-
-
- ) -} - -// ── Bibliothek: das Poster-Regal (§ 6.6) ──────────────────────────────── -function Bibliothek({ verbindung }: { verbindung: Verbindung }) { - if (verbindung.bibliothek.length === 0) { - return ( -

- Noch nichts gerippt — die Bibliothek füllt sich mit jedem fertigen Vorgang. -

- ) - } - const gesamt = verbindung.bibliothek.reduce((summe, e) => summe + e.groesseBytes, 0) - return ( -
-

- BIBLIOTHEK — {verbindung.bibliothek.length} {verbindung.bibliothek.length === 1 ? 'EINTRAG' : 'EINTRÄGE'} ·{' '} - {gb(gesamt)} -

-
- {verbindung.bibliothek.map((eintrag) => ( -
- {eintrag.posterPfad.length > 0 ? ( - - ) : ( -
- -
- )} -

- {eintrag.titel} -

-

- {eintrag.jahr !== null ? `${eintrag.jahr} · ` : ''} - {(TYP_NAMEN[eintrag.typ as GeraeteInfo['typ']] ?? eintrag.typ).toUpperCase()} · {gb(eintrag.groesseBytes)} -

-
- ))} -
-
- ) -} - -// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ──────── -// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein -// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein -// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen. -function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) { - const [schritt, setSchritt] = useState(0) - const werkzeuge = verbindung.werkzeuge - const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0 - - const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [ - { - ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0, - pflicht: true, - text: - werkzeuge === null - ? 'MakeMKV wird gesucht …' - : werkzeuge.makemkvcon.length > 0 - ? `MakeMKV gefunden: ${werkzeuge.makemkvcon}` - : 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst.', - }, - { - ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0, - pflicht: false, - text: - werkzeuge === null - ? 'HandBrakeCLI wird gesucht …' - : werkzeuge.handbrake.length > 0 - ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` - : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.', - }, - { - ok: werkzeuge === null ? null : werkzeuge.flac.length > 0, - pflicht: false, - text: - werkzeuge === null - ? 'FLAC wird gesucht …' - : werkzeuge.flac.length > 0 - ? `FLAC dabei: ${werkzeuge.flac}` - : 'FLAC fehlt — nur für Audio-CDs nötig.', - }, - { - ok: verbindung.laufwerke.length > 0 ? true : null, - pflicht: false, - text: - verbindung.laufwerke.length > 0 - ? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})` - : 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.', - }, - ] - - // 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 = [ - ['DVD', 'dvd'], - ['Blu-ray', 'bluray'], - ['4K-UHD', 'uhd'], - ] - - const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig'] - - return ( -
-
- {schritte.map((name, i) => ( - - {i + 1} {name} - - ))} -
- - {schritt === 0 && ( -
-

Willkommen bei Rippy

-

Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.

-

Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.

-

Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.

-

- 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. -

-
- )} - - {schritt === 1 && ( -
- {pruefzeilen.map((zeile, i) => ( -

- {zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text} -

- ))} -
- )} - - {schritt === 2 && ( -
-

Wohin sollen fertige Filme, Musik und Abbilder?

- {(verbindung.einstellungen['ablage'] ?? '').length > 0 ? ( -

{verbindung.einstellungen['ablage']}

- ) : ( - // 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). -

- noch nichts gewählt — dann nimmt Rippy „Videos\Rippy" in deinem Benutzerordner -

- )} - -

- Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch. -

-
- )} - - {schritt === 3 && ( -
-

- Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung - berechnen — die kostet Zeit, spart aber viel Platz. -

- einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')} - label="Nach dem Rip automatisch komprimieren" - /> - - {/* Die Hardware-Empfehlung — gemessen, nicht geraten. */} -
- {werkzeuge === null ? ( -

Encoder werden gemessen …

- ) : backends.length === 0 ? ( -

- Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip. -

- ) : ( - <> -
- - AUF DIESER MASCHINE GEMESSEN: - - {backends.map((backend) => ( - - ))} -
-

- Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt: -

-
- {EMPFEHLUNGEN.map(([name, typ]) => ( -
-

{name}

-

- {presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET} -

-
- ))} -
- {nurCpu && ( -

- Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der - verlustfreie Rip ist davon nicht betroffen. -

- )} - - )} -
-

- 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. -

-
- )} - - {schritt === 4 && ( -
-

- Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen - kostenlosen TMDb-Schlüssel. -

- einstellungSetzen('tmdbApiKey', w)} - /> -

- 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". -

-
- )} - - {schritt === 5 && ( -
-

Das war's. Disc einlegen — Rippy meldet sich.

-

- Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über - das Tray-Menü. -

- {makemkvFehlt && ( -

- Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt - den Stand, sobald es installiert ist). -

- )} -
- )} - -
- - {schritt < schritte.length - 1 ? ( - - ) : ( - - )} -
-
- ) -} - -type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek' - -export default function App() { - const [haupt, setHaupt] = useState(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(() => { - const anker = window.location.hash.replace('#', '') - return anker === 'einstellungen' || anker === 'bibliothek' ? anker : 'uebersicht' - }) - const [verbindung, setVerbindung] = useState({ - verbunden: false, - kern: null, - pongLatenzMs: null, - laufwerke: [], - rips: {}, - discInfos: {}, - discTitel: {}, - vorschlaege: {}, - ripStart: {}, - ripProtokoll: {}, - einstellungen: {}, - werkzeuge: null, - bibliothek: [], - schluessel: null, - beschaffung: null, - meldungen: [], - }) - - useEffect(() => { - window.rippy.aufStatus((status) => { - if (istHauptStatus(status)) setHaupt(status) - }) - verbindungStarten() - return aufVerbindung(setVerbindung) - }, []) - - const db = verbindung.kern?.datenbank ?? null - const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase)) - - // R4: Systemfehler stehen als rote Karte in der Übersicht — die - // LED-Leiste unten zeigt nur den Daumenwert. - const systemFehler: string[] = [] - if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`) - if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`) - if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.') - - // § 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 = - window.location.hash === '#einrichtung' || - (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true') - - const knopf = (ziel: Bereich, text: string): React.JSX.Element => ( - - ) - - function LedPunkt({ ok, text }: { ok: boolean | null; text: string }) { - return ( - - - {text} - - ) - } - - return ( -
-
-
- - {/* 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. */} -
-
-
- -

Rippy

- - v{haupt?.version ?? '…'} - - {einRipLaeuft && ( - - - REC - - )} -
- -
- void window.rippy.fensterSteuern('minimieren')}> - - - void window.rippy.fensterSteuern('maximieren')} - > - {haupt?.fensterMaximiert === true ? : } - - {/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein - laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */} - void window.rippy.fensterSteuern('schliessen')} - > - - -
-
-
- -
- {setupOffen && } - {!setupOffen && bereich === 'uebersicht' && ( - <> - {systemFehler.length > 0 && ( -
- {systemFehler.map((zeile, i) => ( -

- ✗ {zeile} -

- ))} -
- )} - - {/* § 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 && ( -
-
- - Update verfügbar — Version {haupt.update.neueVersion} - - du hast {haupt.version} -
-

{haupt.update.text}

- {haupt.update.fortschritt >= 0 && !haupt.update.bereit && ( -
-
-
- )} - {haupt.update.neuerungen.length > 0 && ( -
-

Was neu ist

-

- {haupt.update.neuerungen} -

-
- )} - {haupt.update.bereit && ( -
- - - … oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst. - -
- )} - {updateHinweis.length > 0 &&

{updateHinweis}

} -
- )} - -
- {verbindung.laufwerke.length === 0 ? ( -
- -

- {verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'} -

-
- ) : ( -
- {verbindung.laufwerke.map((geraet) => ( - - ))} -
- )} -
- -
- -
- - {verbindung.meldungen.length > 0 && ( -
-
EREIGNISSE
-
- {verbindung.meldungen.map((zeile, i) => ( -

- {zeile} -

- ))} -
-
- )} - - )} - - {!setupOffen && bereich === 'einstellungen' && } - {!setupOffen && bereich === 'bibliothek' && } -
- -
- - - - - Rippy v5 · LucyAI · Claude · KrBrZ -
-
- ) -} +// Die Oberfläche im Kino-Mix (Design-Entscheid des Commanders, 31.08.2026, +// beweise/…-Canvas „Rippy Redesign"): die Kinosaal-Bühne — warmes Schwarz, +// Saallicht, Filmkorn, Gold, Poster mit Goldkante, Plakat-Titel — plus die +// Schaltpult-Präzision: REC-Glühen, Info-Kacheln, Segment-Anzeige mit +// großer Prozentzahl, Mono-Protokoll, LED-Systemleiste. Der Look zieht +// sich durch ALLE Bereiche (Übersicht, Einstellungen, Bibliothek, +// 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, + DiscTitelVorschlag, + GeraeteInfo, + HauptStatus, + RipStatus, + TitelRolle, + TitelStand, +} from '../gemeinsam/nachrichten' +import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../gemeinsam/preset-empfehlung' +import { + aufVerbindung, + auswerfen, + einstellungSetzen, + makemkvUpdate, + metadatenSuchen, + ripAbbrechen, + ripStarten, + ripUeberspringen, + schluesselPruefen, + titelLesen, + verbindungStarten, + zuordnungSetzen, + type Verbindung, +} from './kernverbindung' +import type { MetaVorschlag } from '../gemeinsam/nachrichten' + +function posterUrlKlein(pfad: string): string { + if (pfad.length === 0) return '' + if (pfad.startsWith('http')) return pfad + return `https://image.tmdb.org/t/p/w154${pfad}` +} + +function posterUrlGross(pfad: string): string { + if (pfad.length === 0) return '' + if (pfad.startsWith('http')) return pfad + return `https://image.tmdb.org/t/p/w342${pfad}` +} + +function backdropUrl(pfad: string): string { + if (pfad.length === 0) return '' + if (pfad.startsWith('http')) return pfad + return `https://image.tmdb.org/t/p/w780${pfad}` +} + +function restzeitText(prozent: number, startMs: number | undefined): string { + if (startMs === undefined || prozent <= 2 || prozent >= 100) return '' + const verstrichen = (Date.now() - startMs) / 1000 + const restS = Math.round((verstrichen * (100 - prozent)) / prozent) + if (restS < 90) return `REST ~${restS} S` + return `REST ~${Math.round(restS / 60)} MIN` +} + +function istHauptStatus(wert: unknown): wert is HauptStatus { + return ( + typeof wert === 'object' && + wert !== null && + typeof (wert as Record).version === 'string' + ) +} + +function gb(bytes: number): string { + return `${(bytes / 1e9).toFixed(2).replace('.', ',')} GB` +} + +const TYP_NAMEN: Record = { + cd: 'Audio-CD', + dvd: 'DVD', + bluray: 'Blu-ray', + uhd: '4K-UHD', + unknown: 'unbekannt', +} + +const PHASEN_MONO: Record = { + 'liest-info': 'PHASE 1/4 — LIEST TITEL-INFO', + rippt: 'PHASE 2/4 — RIPPT (VERLUSTFREI)', + komprimiert: 'PHASE 3/4 — KOMPRIMIERT', + 'legt-ab': 'PHASE 4/4 — LEGT AB', + fertig: 'FERTIG', + fehler: 'FEHLER', + abgebrochen: 'ABGEBROCHEN', +} + +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 => ( + +) + +const IconMaximieren = (): React.JSX.Element => ( + +) + +const IconWiederherstellen = (): React.JSX.Element => ( + +) + +const IconSchliessen = (): React.JSX.Element => ( + +) + +function FensterKnopf({ + titel, + aufKlick, + rot = false, + children, +}: { + titel: string + aufKlick: () => void + rot?: boolean + children: React.ReactNode +}) { + return ( + + ) +} + +function sicherheitsWort(confidence: number): string { + if (confidence >= 0.9) return 'sehr wahrscheinlich' + if (confidence >= 0.8) return 'wahrscheinlich' + if (confidence >= 0.6) return 'Vorschlag' + 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 { + if (typ === 'cd') return 'FLAC (verlustfrei)' + if (typ === 'unknown') return 'ISO-Abbild' + const werte = verbindung.einstellungen + if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression' + const schluessel = + typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray' + const eigene = werte[schluessel] ?? '' + const allgemein = werte['transcodePreset'] ?? '' + const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? []) + const gewaehlt = eigene.length > 0 ? eigene : allgemein.length > 0 ? allgemein : empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET + return gewaehlt === 'keine' ? 'ohne Kompression' : gewaehlt +} + +// ── Ikonografie: eine Handvoll Strich-Symbole, alle aus demselben Guss ── +function IconDisc({ groesse, farbe }: { groesse: number; farbe: string }) { + return ( + + + + + + ) +} + +function IconPlay() { + return ( + + + + ) +} + +function IconStop() { + return ( + + + + ) +} + +function IconEject() { + return ( + + + + ) +} + +function IconStift() { + return ( + + + + ) +} + +function Badge({ text, ton }: { text: string; ton: 'gruen' | 'gelb' | 'blau' | 'grau' }) { + const farben = { + gruen: 'border-gruen/40 bg-gruen/10 text-gruen', + gelb: 'border-warn/40 bg-warn/10 text-warn', + blau: 'border-gold/40 bg-gold/10 text-gold-hell', + grau: 'border-white/15 bg-white/5 text-dunst', + } + return ( + + {text} + + ) +} + +/** Die kleinen Mess-Kacheln aus dem Schaltpult (MEDIUM / GRÖSSE / …). */ +function InfoKachel({ label, wert, farbe }: { label: string; wert: string; farbe?: string }) { + return ( +
+
{label}
+
+ {wert} +
+
+ ) +} + +/** Die goldene Aussteuerungsanzeige — 20 Segmente, gefüllt nach Prozent. */ +function SegmentBalken({ prozent }: { prozent: number }) { + const segmente = 20 + const anteil = Math.max(0, Math.min(100, prozent)) / 100 + const voll = Math.floor(anteil * segmente) + const halb = anteil * segmente - voll >= 0.3 + return ( +
+ {Array.from({ length: segmente }, (_, i) => ( +
+ ))} +
+ ) +} + +// § 5 Schritt 4: „nein → fragen" — Suche + Auswahl der richtigen Metadaten. +function AendernDialog({ + id, + startText, + vorschlaege, + schliessen, +}: { + id: string + startText: string + vorschlaege: MetaVorschlag[] + schliessen: () => void +}) { + const [text, setText] = useState(startText) + useEffect(() => { + metadatenSuchen(id, startText) + // Nur beim Öffnen — weitere Suchen löst der Knopf aus. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + return ( +
+
e.stopPropagation()} + > +

Was ist das wirklich?

+
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') metadatenSuchen(id, text) + }} + placeholder="Titel suchen …" + className="flex-1 rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none" + /> + +
+ {vorschlaege.length === 0 ? ( +

Keine Treffer — anderen Suchtext probieren.

+ ) : ( +
+ {vorschlaege.map((v) => ( + + ))} +
+ )} +
+ +
+
+
+ ) +} + +// 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 20–120 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 [gewaehlt, setGewaehlt] = useState | 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>({}) + // 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 ( +
+
e.stopPropagation()} + > +

Was soll gerippt werden?

+ + {stand === undefined || stand.laeuft ? ( +

+ Rippy liest die Titel der Disc … das dauert bis zu zwei Minuten. Solange dreht das Laufwerk. +

+ ) : stand.fehler.length > 0 ? ( +

✗ {stand.fehler}

+ ) : ( + <> +

+ {stand.unsicher ? '⚠ ' : ''} + {stand.einordnung} +

+ +
+ + + + +
+ +
+ {alle.map((t) => ( + + ))} + {alle.length === 0 && ( +

Die Disc gab keine Titel her.

+ )} +
+ +

+ {aktiv.size} von {alle.length} gewählt · zusammen {gb(gesamtBytes)} +

+

+ {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.'} +

+ + )} + +
+ + +
+
+
+ ) +} + +function LaufwerkKachel({ + geraet, + rip, + info, + vorschlaege, + ripStartMs, + protokoll, + verbindung, + titelStand, +}: { + geraet: GeraeteInfo + rip: RipStatus | undefined + info: DiscInfoStand | undefined + vorschlaege: MetaVorschlag[] + 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' + const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : '' + const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : '' + const erkannt = discDa && info !== undefined && info.titel.length > 0 + + return ( +
+ {backdrop.length > 0 && ( + <> + +
+
+ + )} + +
+
+
+ + LAUFWERK {geraet.id}: + · + {geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'} +
+
+ {ripLaeuft ? ( + + ) : ( + + )} + +
+
+ + {!discDa && ( +
+ +

+ {geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund} +

+
+ )} + + {discDa && ( +
+ {poster.length > 0 ? ( + + ) : ( +
+ +
+ )} +
+ {erkannt ? ( +

+ {info.titel} + {info.jahr !== null && ({info.jahr})} +

+ ) : ( +

+ {TYP_NAMEN[geraet.typ]} +

+ )} + {erkannt && info.beschreibung.length > 0 && ( +

{info.beschreibung}

+ )} + {!erkannt && ( +

+ {geraet.typ === 'cd' + ? 'Titel kommen beim Rippen von MusicBrainz.' + : 'Noch keine Zuordnung — „Ändern …" hilft nach.'} +

+ )} + + {/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im + breiten — vorher immer vier, also im schmalen gequetscht. */} +
+ + 0 ? gb(geraet.groesseBytes) : '—'} /> + 0 ? ` · ${info.quelle}` : ''}` + : '—' + } + farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined} + /> + +
+ +
+ + {erkannt && ( + = 0.8 ? 'gruen' : 'gelb'} + /> + )} + {info !== undefined && discHerkunft(info).length > 0 && ( + + )} + {info !== undefined && info.schonGerippt !== null && ( + + )} +
+ {geraet.grund.length > 0 &&

{geraet.grund}

} +
+
+ )} + + {rip !== undefined && ( +
+
+
+
+ + {PHASEN_MONO[rip.phase]} + {rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''} + + {ripLaeuft && ( + {restzeitText(rip.prozent, ripStartMs)} + )} +
+ {ripLaeuft && } + {ripLaeuft && (rip.titelGesamt ?? 0) > 1 && ( +

+ Titel {rip.titelNr} von {rip.titelGesamt} +

+ )} + + {/* 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 && ( +
+

+ Die Disc hat an einer Stelle einen Defekt. +

+

+ 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. +

+
+ + + oder weiter versuchen — abbrechen geht oben. + +
+
+ )} + +

+ > {rip.text} +

+ {rip.dateien.map((datei) => ( +

+ {datei} +

+ ))} +
+ {ripLaeuft && rip.prozent >= 0 && ( +
+ {rip.prozent} + % +
+ )} +
+ {protokoll.length > 1 && ( + + )} + {protokollOffen && ( +
+ {protokoll.map((zeile, i) => ( +

+ > {zeile} +

+ ))} +
+ )} +
+ )} +
+ + {titelOffen && ( + setTitelOffen(false)} /> + )} + + {aendernOffen && ( + 0 ? info.titel : ''} + vorschlaege={vorschlaege} + schliessen={() => setAendernOffen(false)} + /> + )} +
+ ) +} + +// Die MakeMKV-Karte (§ 9.1) — amber Glas mit dem Update-Weg aus § 9. +function SchluesselKarte({ verbindung }: { verbindung: Verbindung }) { + const s = verbindung.schluessel + const b = verbindung.beschaffung + const punktFarbe = + s === null + ? 'bg-warn' + : s.angenommen === true + ? 'bg-gruen' + : s.weiterBetrieb + ? 'bg-warn' + : 'bg-rot' + return ( +
+
+ + + MAKEMKV-SCHLÜSSEL + +
+ {s !== null && s.version.length > 0 && ( + MakeMKV {s.version} + )} + {s !== null && s.angenommen === false && ( + + )} + +
+
+ {s === null ? ( +

wird geprüft …

+ ) : ( + <> +

{s.text}

+

+ zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} + {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} +

+ + )} + {b !== null && ( +
+

+ {b.text} +

+ {b.laeuft && b.anteil !== null && ( +
+
+
+ )} +
+ )} +
+ ) +} + +// ── Einstellungen ─────────────────────────────────────────────────────── +const FELD_KLASSE = + 'mt-1 w-full rounded-lg border border-white/12 bg-samt px-3 py-2 font-mono text-xs text-schnee placeholder:text-nebel focus:border-gold/60 focus:outline-none' + +function Kicker({ text }: { text: string }) { + return

{text}

+} + +/** Ein richtiger Schalter statt einer nackten Checkbox. */ +function Schalter({ an, aufWechsel, label }: { an: boolean; aufWechsel: (an: boolean) => void; label: string }) { + return ( + + ) +} + +/** Eine Einstellungs-Karte: Kicker, ein Satz Einordnung, dann die Felder. */ +function SektionsKarte({ + id, + titel, + intro, + children, +}: { + id: string + titel: string + intro: string + children: React.ReactNode +}) { + return ( +
+ +

{intro}

+
{children}
+
+ ) +} + +/** Kleiner Status-Punkt für Schlüssel-Felder: hinterlegt oder leer. */ +function HinterlegtPunkt({ wert }: { wert: string }) { + return ( + 0 ? 'text-gruen' : 'text-nebel'}`}> + {wert.length > 0 ? '● hinterlegt' : '○ fehlt'} + + ) +} + +function Feld({ + name, + wert, + hinweis, + geheim, + aufAendern, +}: { + name: string + wert: string + hinweis?: string + geheim?: boolean + aufAendern: (wert: string) => void +}) { + const [entwurf, setEntwurf] = useState(wert) + useEffect(() => setEntwurf(wert), [wert]) + return ( + + ) +} + +function PresetWahl({ + name, + schluessel, + wert, + presets, + empfehlung, + aufAendern, +}: { + name: string + schluessel: string + wert: string + presets: string[] + empfehlung: string + aufAendern: (schluessel: string, wert: string) => void +}) { + return ( + + ) +} + +/** Eine Werkzeug-Zeile: Fundort in Klartext plus der eigene Pfad, der + * die automatische Suche übersteuert (Katalog-Schritt 1). */ +function WerkzeugZeile({ + name, + pfad, + fehlt, + pflicht, + schluessel, + wert, + aufAendern, +}: { + name: string + pfad: string | null + fehlt: string + pflicht: boolean + schluessel: string + wert: string + aufAendern: (schluessel: string, wert: string) => void +}) { + return ( +
+

0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}> + {name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt} +

+ aufAendern(schluessel, w)} + /> +
+ ) +} + +function Einstellungen({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) { + const werte = verbindung.einstellungen + const presets = verbindung.werkzeuge?.presets ?? [] + const backends = verbindung.werkzeuge?.backends ?? [] + const setzen = einstellungSetzen + 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]> = [ + ['ablage', 'Ablage'], + ['kompression', 'Kompression'], + ['metadaten', 'Metadaten'], + ['makemkv', 'MakeMKV'], + ['medienserver', 'Medienserver'], + ['programm', 'Programm'], + ['werkzeuge', 'Werkzeuge'], + ] + + const springe = (id: string): void => { + setAktiv(id) + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + const ordnerWaehlen = async (): Promise => { + const ordner = await window.rippy.ordnerWaehlen('Ablage-Ordner wählen') + 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 ( +
+ + + {/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem + breiten Fenster alles links klebte und rechts nichts stand. */} +
+ +
+
+ setzen('ablage', w)} /> +
+ +
+
+

So legt Rippy ab:

+

├─ Filme\Akira (1988)\ — Film, Poster und Infodatei beieinander

+

├─ Musik\ — Audio-CDs als FLAC, benannt nach MusicBrainz

+

├─ ISO\ — unerkannte Discs als 1:1-Abbild

+

└─ roh\ — Arbeitsstand während des Rips, räumt sich selbst auf

+
+
+ + + {verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && ( +

⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).

+ )} + setzen('transcodeEnabled', an ? 'true' : 'false')} + label="Nach dem Rip automatisch komprimieren" + /> + {backends.length > 0 && ( +
+ GEMESSENE ENCODER: + {backends.map((backend) => ( + + ))} +
+ )} +
+ + + +
+

+ „automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine + die Grafikkarte, wo es sie gibt. Eine eigene Wahl schlägt die Empfehlung immer. +

+ setzen('transcodePreset', w)} + /> +
+ setzen('audioSprachen', w)} + /> + setzen('untertitelSprachen', w)} + /> +
+
+ + +
+
+ setzen('tmdbApiKey', w)} /> +

+ www.themoviedb.org → Einstellungen → API + +

+
+
+ setzen('omdbApiKey', w)} /> +

+ www.omdbapi.com/apikey.aspx + +

+
+
+

+ Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" + an der Laufwerks-Kachel bleibt ohne Suche. +

+
+ + + {s !== null && ( +
+

+ + + {s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'} + + {s.version.length > 0 && MakeMKV {s.version}} +

+

{s.text}

+

+ zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')} + {s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`} +

+
+ )} +
+ {s !== null && s.angenommen === false && ( + + )} + +
+ {b !== null && ( +

+ {b.text} +

+ )} + setzen('makemkvAppKey', w)} + /> +
+ + +
+ + setzen('mediaServerUrl', w)} /> + setzen('mediaServerApiKey', w)} /> +
+

+ Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur + aus „Ablage" passt für alle vier. +

+
+ + + void window.rippy.autostartSetzen(an)} + label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" + /> + {/* 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 && ( +
+

Selbst-Update: {haupt.update.text}

+ {haupt.update.letzteErfolgreichePruefung.length > 0 && ( +

letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}

+ )} +

+ Rippy v{haupt.version} + {verbindung.kern !== null && ` · Kern: Node ${verbindung.kern.nodeVersion} (PID ${verbindung.kern.pid})`} +

+

Updates warten, solange ein Rip läuft — nie mitten im Vorgang.

+
+ + {haupt.update.neueVersion.length > 0 && ( + + Version {haupt.update.neueVersion} gefunden — siehe Übersicht. + + )} +
+
+ )} +
+ + + + + + {verbindung.werkzeuge !== null && ( +

+ HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder:{' '} + {backends.length > 0 ? backends.join(', ') : '—'} +

+ )} +
+
+
+ ) +} + +// ── Bibliothek: das Poster-Regal (§ 6.6) ──────────────────────────────── +function Bibliothek({ verbindung }: { verbindung: Verbindung }) { + if (verbindung.bibliothek.length === 0) { + return ( +

+ Noch nichts gerippt — die Bibliothek füllt sich mit jedem fertigen Vorgang. +

+ ) + } + const gesamt = verbindung.bibliothek.reduce((summe, e) => summe + e.groesseBytes, 0) + return ( +
+

+ BIBLIOTHEK — {verbindung.bibliothek.length} {verbindung.bibliothek.length === 1 ? 'EINTRAG' : 'EINTRÄGE'} ·{' '} + {gb(gesamt)} +

+
+ {verbindung.bibliothek.map((eintrag) => ( +
+ {eintrag.posterPfad.length > 0 ? ( + + ) : ( +
+ +
+ )} +

+ {eintrag.titel} +

+

+ {eintrag.jahr !== null ? `${eintrag.jahr} · ` : ''} + {(TYP_NAMEN[eintrag.typ as GeraeteInfo['typ']] ?? eintrag.typ).toUpperCase()} · {gb(eintrag.groesseBytes)} +

+
+ ))} +
+
+ ) +} + +// ── Erst-Einrichtung (§ 6.9): nach der Installation, im Programm ──────── +// Zwei Regeln aus dem alten Setup: NUR ein Fehler blockiert (kein +// Laufwerk ist eine WARNUNG — eine reine Komprimier-Maschine ist ein +// vorgesehener Betriebsfall), und Erfolg wird am BESTAND gemessen. +function ErstEinrichtung({ verbindung }: { verbindung: Verbindung }) { + const [schritt, setSchritt] = useState(0) + const werkzeuge = verbindung.werkzeuge + const makemkvFehlt = werkzeuge !== null && werkzeuge.makemkvcon.length === 0 + + const pruefzeilen: Array<{ ok: boolean | null; pflicht: boolean; text: string }> = [ + { + ok: werkzeuge === null ? null : werkzeuge.makemkvcon.length > 0, + pflicht: true, + text: + werkzeuge === null + ? 'MakeMKV wird gesucht …' + : werkzeuge.makemkvcon.length > 0 + ? `MakeMKV gefunden: ${werkzeuge.makemkvcon}` + : 'MakeMKV FEHLT — ohne makemkvcon kann Rippy keine Video-Disc lesen. Von makemkv.com/download installieren, dann findet Rippy es von selbst.', + }, + { + ok: werkzeuge === null ? null : werkzeuge.handbrake.length > 0, + pflicht: false, + text: + werkzeuge === null + ? 'HandBrakeCLI wird gesucht …' + : werkzeuge.handbrake.length > 0 + ? `HandBrakeCLI dabei: ${werkzeuge.handbrake}` + : 'HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht.', + }, + { + ok: werkzeuge === null ? null : werkzeuge.flac.length > 0, + pflicht: false, + text: + werkzeuge === null + ? 'FLAC wird gesucht …' + : werkzeuge.flac.length > 0 + ? `FLAC dabei: ${werkzeuge.flac}` + : 'FLAC fehlt — nur für Audio-CDs nötig.', + }, + { + ok: verbindung.laufwerke.length > 0 ? true : null, + pflicht: false, + text: + verbindung.laufwerke.length > 0 + ? `${verbindung.laufwerke.length} optisches Laufwerk gefunden (${verbindung.laufwerke.map((l) => l.id + ':').join(', ')})` + : 'Kein optisches Laufwerk — nur eine Warnung: Eine reine Komprimier-Maschine ist ein vorgesehener Betriebsfall.', + }, + ] + + // 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 = [ + ['DVD', 'dvd'], + ['Blu-ray', 'bluray'], + ['4K-UHD', 'uhd'], + ] + + const schritte = ['Willkommen', 'Prüfung', 'Ordner', 'Qualität', 'Titel', 'Fertig'] + + return ( +
+
+ {schritte.map((name, i) => ( + + {i + 1} {name} + + ))} +
+ + {schritt === 0 && ( +
+

Willkommen bei Rippy

+

Rippy sichert deine DVDs, Blu-rays und Audio-CDs — erst verlustfrei, dann komprimiert.

+

Disc rein genügt: Rippy erkennt, was drauf ist, und sagt Bescheid.

+

Fertige Filme landen so abgelegt, dass Jellyfin, Emby, Kodi und Plex sie sofort erkennen.

+

+ 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. +

+
+ )} + + {schritt === 1 && ( +
+ {pruefzeilen.map((zeile, i) => ( +

+ {zeile.ok === true ? '✓' : zeile.ok === null ? '…' : zeile.pflicht ? '✗' : '⚠'} {zeile.text} +

+ ))} +
+ )} + + {schritt === 2 && ( +
+

Wohin sollen fertige Filme, Musik und Abbilder?

+ {(verbindung.einstellungen['ablage'] ?? '').length > 0 ? ( +

{verbindung.einstellungen['ablage']}

+ ) : ( + // 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). +

+ noch nichts gewählt — dann nimmt Rippy „Videos\Rippy" in deinem Benutzerordner +

+ )} + +

+ Rippy baut darin selbst die Struktur: Filme, Musik, ISO und roh. Ein Netzlaufwerk geht auch. +

+
+ )} + + {schritt === 3 && ( +
+

+ Rippy sichert eine Disc immer erst verlustfrei. Danach kann es eine kleinere Fassung + berechnen — die kostet Zeit, spart aber viel Platz. +

+ einstellungSetzen('transcodeEnabled', an ? 'true' : 'false')} + label="Nach dem Rip automatisch komprimieren" + /> + + {/* Die Hardware-Empfehlung — gemessen, nicht geraten. */} +
+ {werkzeuge === null ? ( +

Encoder werden gemessen …

+ ) : backends.length === 0 ? ( +

+ Keine Encoder gemessen — ohne HandBrakeCLI bleibt es beim verlustfreien Rip. +

+ ) : ( + <> +
+ + AUF DIESER MASCHINE GEMESSEN: + + {backends.map((backend) => ( + + ))} +
+

+ Danach wählt Rippy von selbst — Grafikkarte vor CPU, wo es eine gibt: +

+
+ {EMPFEHLUNGEN.map(([name, typ]) => ( +
+

{name}

+

+ {presetEmpfehlung(typ, backends, presets) || DEFAULT_HB_PRESET} +

+
+ ))} +
+ {nurCpu && ( +

+ Nur CPU-Encoder gefunden — das Komprimieren dauert dann deutlich länger. Der + verlustfreie Rip ist davon nicht betroffen. +

+ )} + + )} +
+

+ 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. +

+
+ )} + + {schritt === 4 && ( +
+

+ Woher sollen Titel, Jahr, Poster und Beschreibung kommen? Dafür braucht Rippy einen + kostenlosen TMDb-Schlüssel. +

+ einstellungSetzen('tmdbApiKey', w)} + /> +

+ 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". +

+
+ )} + + {schritt === 5 && ( +
+

Das war's. Disc einlegen — Rippy meldet sich.

+

+ Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über + das Tray-Menü. +

+ {makemkvFehlt && ( +

+ Denk an MakeMKV: Ohne makemkvcon bleibt der Video-Weg zu (Einstellungen → Werkzeuge zeigt + den Stand, sobald es installiert ist). +

+ )} +
+ )} + +
+ + {schritt < schritte.length - 1 ? ( + + ) : ( + + )} +
+
+ ) +} + +type Bereich = 'uebersicht' | 'einstellungen' | 'bibliothek' + +export default function App() { + const [haupt, setHaupt] = useState(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(() => { + const anker = window.location.hash.replace('#', '') + return anker === 'einstellungen' || anker === 'bibliothek' ? anker : 'uebersicht' + }) + const [verbindung, setVerbindung] = useState({ + verbunden: false, + kern: null, + pongLatenzMs: null, + laufwerke: [], + rips: {}, + discInfos: {}, + discTitel: {}, + vorschlaege: {}, + ripStart: {}, + ripProtokoll: {}, + einstellungen: {}, + werkzeuge: null, + bibliothek: [], + schluessel: null, + beschaffung: null, + meldungen: [], + }) + + useEffect(() => { + window.rippy.aufStatus((status) => { + if (istHauptStatus(status)) setHaupt(status) + }) + verbindungStarten() + return aufVerbindung(setVerbindung) + }, []) + + const db = verbindung.kern?.datenbank ?? null + const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase)) + + // R4: Systemfehler stehen als rote Karte in der Übersicht — die + // LED-Leiste unten zeigt nur den Daumenwert. + const systemFehler: string[] = [] + if (haupt !== null && !haupt.leine.ok) systemFehler.push(`Prozess-Leine nicht gesetzt: ${haupt.leine.fehler ?? '?'}`) + if (db !== null && !db.ok) systemFehler.push(`Datenbank: ${db.fehler}`) + if (haupt !== null && !haupt.kern.laeuft && verbindung.kern === null) systemFehler.push('Der Kern läuft nicht.') + + // § 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 = + window.location.hash === '#einrichtung' || + (Object.keys(verbindung.einstellungen).length > 0 && verbindung.einstellungen['setup.done'] !== 'true') + + const knopf = (ziel: Bereich, text: string): React.JSX.Element => ( + + ) + + function LedPunkt({ ok, text }: { ok: boolean | null; text: string }) { + return ( + + + {text} + + ) + } + + return ( +
+
+
+ + {/* 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. */} +
+
+
+ +

Rippy

+ + v{haupt?.version ?? '…'} + + {einRipLaeuft && ( + + + REC + + )} +
+ +
+ void window.rippy.fensterSteuern('minimieren')}> + + + void window.rippy.fensterSteuern('maximieren')} + > + {haupt?.fensterMaximiert === true ? : } + + {/* Schließen versteckt nur — Rippy lebt im Tray weiter, ein + laufender Rip merkt davon nichts (§ 4.1 Punkt 3). */} + void window.rippy.fensterSteuern('schliessen')} + > + + +
+
+
+ +
+ {setupOffen && } + {!setupOffen && bereich === 'uebersicht' && ( + <> + {systemFehler.length > 0 && ( +
+ {systemFehler.map((zeile, i) => ( +

+ ✗ {zeile} +

+ ))} +
+ )} + + {/* § 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 && ( +
+
+ + Update verfügbar — Version {haupt.update.neueVersion} + + du hast {haupt.version} +
+

{haupt.update.text}

+ {haupt.update.fortschritt >= 0 && !haupt.update.bereit && ( +
+
+
+ )} + {haupt.update.neuerungen.length > 0 && ( +
+

Was neu ist

+

+ {haupt.update.neuerungen} +

+
+ )} + {haupt.update.bereit && ( +
+ + + … oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst. + +
+ )} + {updateHinweis.length > 0 &&

{updateHinweis}

} +
+ )} + +
+ {verbindung.laufwerke.length === 0 ? ( +
+ +

+ {verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'} +

+
+ ) : ( +
+ {verbindung.laufwerke.map((geraet) => ( + + ))} +
+ )} +
+ +
+ +
+ + {verbindung.meldungen.length > 0 && ( +
+
EREIGNISSE
+
+ {verbindung.meldungen.map((zeile, i) => ( +

+ {zeile} +

+ ))} +
+
+ )} + + )} + + {!setupOffen && bereich === 'einstellungen' && } + {!setupOffen && bereich === 'bibliothek' && } +
+ +
+ + + + + Rippy v5 · LucyAI · Claude · KrBrZ +
+
+ ) +} diff --git a/rippy-windows/src/fenster/kernverbindung.ts b/rippy-windows/src/fenster/kernverbindung.ts index 017adec..5a25e18 100644 --- a/rippy-windows/src/fenster/kernverbindung.ts +++ b/rippy-windows/src/fenster/kernverbindung.ts @@ -1,319 +1,320 @@ -// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: -// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; -// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. -// -// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird -// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« -// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die -// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. -import { - istKernNachricht, - type BeschaffungStand, - type BibliothekEintrag, - type DiscInfoStand, - type GeraeteInfo, - type KernNachricht, - type MetaVorschlag, - type TitelStand, - type RipStatus, - type WerkzeugAuskunft, -} from '../gemeinsam/nachrichten' - -export interface Verbindung { - verbunden: boolean - kern: Extract | null - pongLatenzMs: number | null - laufwerke: GeraeteInfo[] - /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, - * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ - rips: Record - /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ - discInfos: Record - /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ - vorschlaege: Record - /** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */ - discTitel: Record - /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ - ripStart: Record - /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare - * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer - * Vorgang beginnt. */ - ripProtokoll: Record - einstellungen: Record - werkzeuge: WerkzeugAuskunft | null - bibliothek: BibliothekEintrag[] - schluessel: { - quelle: string - angenommen: boolean | null - weiterBetrieb: boolean - gueltigBis: string - geholtAm: string - text: string - version: string - } | null - /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ - beschaffung: BeschaffungStand | null - /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ - meldungen: string[] -} - -type Horcher = (stand: Verbindung) => void - -const MAX_MELDUNGEN = 20 - -const stand: Verbindung = { - verbunden: false, - kern: null, - pongLatenzMs: null, - laufwerke: [], - rips: {}, - discInfos: {}, - discTitel: {}, - vorschlaege: {}, - ripStart: {}, - ripProtokoll: {}, - einstellungen: {}, - werkzeuge: null, - bibliothek: [], - schluessel: null, - beschaffung: null, - meldungen: [], -} -const horcher = new Set() -let gestartet = false -let port: MessagePort | null = null - -function melden(): void { - for (const h of horcher) { - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) - } -} - -function meldung(text: string): void { - stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) -} - -function kernNachricht(nachricht: KernNachricht): void { - switch (nachricht.art) { - case 'kern-bereit': - stand.kern = nachricht - break - case 'pong': - stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) - // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. - window.rippy.melden('kern-pong') - break - case 'kern-fehler': - meldung(`⚠ ${nachricht.text}`) - break - case 'laufwerke': - stand.laufwerke = nachricht.liste - for (const ereignis of nachricht.ereignisse) { - if (ereignis.art === 'disc-eingelegt') { - meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) - } else if (ereignis.art === 'disc-entnommen') { - meldung(`Disc entnommen aus ${ereignis.geraet.name}`) - } else if (ereignis.art === 'laufwerk-weg') { - meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) - } - } - break - case 'auswurf-ergebnis': { - const text = - nachricht.grund === 'ok' - ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` - : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` - meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) - break - } - case 'disc-info': { - stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } - if (nachricht.info.titel.length > 0) { - meldung( - `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, - ) - } - break - } - case 'disc-titel': - stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand } - if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`) - break - case 'metadaten-vorschlaege': - stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } - break - case 'einstellungen': - stand.einstellungen = nachricht.werte - break - case 'werkzeuge': - stand.werkzeuge = nachricht.auskunft - break - case 'bibliothek': - stand.bibliothek = nachricht.eintraege - break - case 'schluessel-status': - stand.schluessel = nachricht.stand - if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) - break - case 'beschaffung-status': - stand.beschaffung = nachricht.stand - if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) - break - case 'rip-status': { - stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } - const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) - if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { - stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } - // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. - stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } - } else if (!laeuft) { - const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart - stand.ripStart = rest - } - // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks - // wiederholen den Text — nur echte Änderungen zählen). - const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] - if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { - stand.ripProtokoll = { - ...stand.ripProtokoll, - [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80), - } - } - if (nachricht.status.phase === 'fertig') { - meldung( - `Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + - (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), - ) - } else if (nachricht.status.phase === 'fehler') { - meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) - } else if (nachricht.status.phase === 'abgebrochen') { - meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) - } - break - } - } - melden() -} - -function portAnnehmen(neuerPort: MessagePort): void { - port = neuerPort - stand.verbunden = true - neuerPort.onmessage = (ereignis: MessageEvent) => { - const nachricht: unknown = ereignis.data - if (istKernNachricht(nachricht)) kernNachricht(nachricht) - } - neuerPort.start() - neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) - neuerPort.postMessage({ art: 'einstellungen-laden' }) - neuerPort.postMessage({ art: 'werkzeuge-laden' }) - neuerPort.postMessage({ art: 'bibliothek-laden' }) - melden() -} - -/** Idempotent — der erste Aufruf registriert den Horcher und meldet der - * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ -export function verbindungStarten(): void { - if (gestartet) return - gestartet = true - window.addEventListener('message', (ereignis: MessageEvent) => { - const daten: unknown = ereignis.data - if ( - typeof daten === 'object' && - daten !== null && - (daten as Record).art === 'kern-port' && - ereignis.ports.length > 0 - ) { - portAnnehmen(ereignis.ports[0]) - } - }) - window.rippy.bereit() -} - -export function aufVerbindung(h: Horcher): () => void { - horcher.add(h) - h({ - ...stand, - laufwerke: [...stand.laufwerke], - rips: { ...stand.rips }, - discInfos: { ...stand.discInfos }, - vorschlaege: { ...stand.vorschlaege }, - einstellungen: { ...stand.einstellungen }, - bibliothek: [...stand.bibliothek], - meldungen: [...stand.meldungen], - }) - return () => horcher.delete(h) -} - -function anKern(nachricht: { art: string; id: string; titel?: number[] }, wasWar: string): void { - if (port === null) { - meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) - melden() - return - } - port.postMessage(nachricht) -} - -export function auswerfen(id: string): void { - anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) -} - -/** Den Info-Lauf anstoßen (20–120 s) — erst auf Zuruf, nie beim Einlegen. */ -export function titelLesen(id: string): void { - anKern({ art: 'titel-lesen', id }, `Titel lesen ${id}`) -} - -/** `titel` leer heißt: alles rippen. */ -export function ripStarten(id: string, titel: number[] = []): void { - anKern({ art: 'rip-start', id, titel }, `Rip ${id}`) -} - -/** Den laufenden Titel aufgeben und mit dem nächsten weitermachen. */ -export function ripUeberspringen(id: string): void { - anKern({ art: 'rip-ueberspringen', id }, `Titel überspringen ${id}`) -} - -export function ripAbbrechen(id: string): void { - anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) -} - -export function metadatenSuchen(id: string, text: string): void { - if (port === null) return - port.postMessage({ art: 'metadaten-suchen', id, text }) -} - -export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { - if (port === null) return - port.postMessage({ art: 'zuordnung-setzen', id, vorschlag }) -} - -export function makemkvUpdate(): void { - if (port === null) { - meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') - melden() - return - } - port.postMessage({ art: 'makemkv-update' }) -} - -export function schluesselPruefen(): void { - if (port === null) return - port.postMessage({ art: 'schluessel-pruefen' }) -} - -export function einstellungSetzen(schluessel: string, wert: string): void { - if (port === null) { - meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) - melden() - return - } - port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) -} +// Hält den EINEN MessagePort zum Kern — bewusst außerhalb von React: +// Die Port-Übergabe vom Haupt passiert genau einmal je Fenster-Ladung; +// React-Re-Mounts (StrictMode im Dev, HMR) dürfen sie nicht verlieren. +// +// R2 gilt auch hier: Der letzte bekannte Stand bleibt stehen und wird +// jedem neuen Abonnenten sofort wiedergegeben — er wird nie auf »leer« +// zurückgesetzt, nur weil gerade keine Nachricht kam. Insbesondere die +// Laufwerksliste: Sie ändert sich NUR durch eine neue Liste vom Kern. +import { + istKernNachricht, + type BeschaffungStand, + type BibliothekEintrag, + type DiscInfoStand, + type GeraeteInfo, + type KernNachricht, + type MetaVorschlag, + type TitelStand, + type RipAuftragTitel, + type RipStatus, + type WerkzeugAuskunft, +} from '../gemeinsam/nachrichten' + +export interface Verbindung { + verbunden: boolean + kern: Extract | null + pongLatenzMs: number | null + laufwerke: GeraeteInfo[] + /** Letzter Rip-Stand je Laufwerks-Kennung. Bleibt nach dem Ende stehen, + * bis ein neuer Rip startet (R2: kein Verschwinden ohne neue Auskunft). */ + rips: Record + /** Was Rippy über die eingelegte Disc weiß (§ 5 Schritt 3). */ + discInfos: Record + /** Suchergebnisse des Ändern-Dialogs je Laufwerk (§ 5 Schritt 4). */ + vorschlaege: Record + /** Die Titel der Disc je Laufwerk (§ 6.2) — Ergebnis des Info-Laufs. */ + discTitel: Record + /** Wann der laufende Vorgang je Laufwerk begann (für die Restzeit). */ + ripStart: Record + /** Statuszeilen des (letzten) Vorgangs je Laufwerk — das aufklappbare + * Protokoll der Kachel. Bleibt nach dem Ende stehen (R2), bis ein neuer + * Vorgang beginnt. */ + ripProtokoll: Record + einstellungen: Record + werkzeuge: WerkzeugAuskunft | null + bibliothek: BibliothekEintrag[] + schluessel: { + quelle: string + angenommen: boolean | null + weiterBetrieb: boolean + gueltigBis: string + geholtAm: string + text: string + version: string + } | null + /** Stand der MakeMKV-Beschaffung (§ 9) — null, solange nie eine lief. */ + beschaffung: BeschaffungStand | null + /** Kurze Ereigniszeilen (neueste zuerst): Disc rein/raus, Auswurf, Fehler. */ + meldungen: string[] +} + +type Horcher = (stand: Verbindung) => void + +const MAX_MELDUNGEN = 20 + +const stand: Verbindung = { + verbunden: false, + kern: null, + pongLatenzMs: null, + laufwerke: [], + rips: {}, + discInfos: {}, + discTitel: {}, + vorschlaege: {}, + ripStart: {}, + ripProtokoll: {}, + einstellungen: {}, + werkzeuge: null, + bibliothek: [], + schluessel: null, + beschaffung: null, + meldungen: [], +} +const horcher = new Set() +let gestartet = false +let port: MessagePort | null = null + +function melden(): void { + for (const h of horcher) { + h({ + ...stand, + laufwerke: [...stand.laufwerke], + rips: { ...stand.rips }, + discInfos: { ...stand.discInfos }, + vorschlaege: { ...stand.vorschlaege }, + einstellungen: { ...stand.einstellungen }, + bibliothek: [...stand.bibliothek], + meldungen: [...stand.meldungen], + }) + } +} + +function meldung(text: string): void { + stand.meldungen = [text, ...stand.meldungen].slice(0, MAX_MELDUNGEN) +} + +function kernNachricht(nachricht: KernNachricht): void { + switch (nachricht.art) { + case 'kern-bereit': + stand.kern = nachricht + break + case 'pong': + stand.pongLatenzMs = Math.max(0, Date.now() - nachricht.zeit) + // Der Beweis für den Smoke-Lauf: Ping ging hin, Pong kam zurück. + window.rippy.melden('kern-pong') + break + case 'kern-fehler': + meldung(`⚠ ${nachricht.text}`) + break + case 'laufwerke': + stand.laufwerke = nachricht.liste + for (const ereignis of nachricht.ereignisse) { + if (ereignis.art === 'disc-eingelegt') { + meldung(`Disc erkannt in ${ereignis.geraet.name} — ${ereignis.geraet.typ}`) + } else if (ereignis.art === 'disc-entnommen') { + meldung(`Disc entnommen aus ${ereignis.geraet.name}`) + } else if (ereignis.art === 'laufwerk-weg') { + meldung(`Laufwerk ${ereignis.id}: nicht mehr da`) + } + } + break + case 'auswurf-ergebnis': { + const text = + nachricht.grund === 'ok' + ? `Laufwerk ${nachricht.id}: ausgeworfen (nachgesehen)` + : `Laufwerk ${nachricht.id}: Auswurf ${nachricht.grund}${nachricht.fehler !== null ? ` — ${nachricht.fehler}` : ''}` + meldung(nachricht.grund === 'ok' ? text : `⚠ ${text}`) + break + } + case 'disc-info': { + stand.discInfos = { ...stand.discInfos, [nachricht.info.id]: nachricht.info } + if (nachricht.info.titel.length > 0) { + meldung( + `Laufwerk ${nachricht.info.id}: erkannt als „${nachricht.info.titel}${nachricht.info.jahr !== null ? ` (${nachricht.info.jahr})` : ''}“ · ${Math.round(nachricht.info.confidence * 100)} %`, + ) + } + break + } + case 'disc-titel': + stand.discTitel = { ...stand.discTitel, [nachricht.stand.id]: nachricht.stand } + if (nachricht.stand.fehler.length > 0) meldung(`⚠ Titel lesen: ${nachricht.stand.fehler}`) + break + case 'metadaten-vorschlaege': + stand.vorschlaege = { ...stand.vorschlaege, [nachricht.id]: nachricht.vorschlaege } + break + case 'einstellungen': + stand.einstellungen = nachricht.werte + break + case 'werkzeuge': + stand.werkzeuge = nachricht.auskunft + break + case 'bibliothek': + stand.bibliothek = nachricht.eintraege + break + case 'schluessel-status': + stand.schluessel = nachricht.stand + if (nachricht.stand.angenommen === false) meldung(`⚠ MakeMKV-Schlüssel: ${nachricht.stand.text}`) + break + case 'beschaffung-status': + stand.beschaffung = nachricht.stand + if (nachricht.stand.fehler) meldung(`⚠ MakeMKV-Update: ${nachricht.stand.text}`) + break + case 'rip-status': { + stand.rips = { ...stand.rips, [nachricht.status.id]: nachricht.status } + const laeuft = ['liest-info', 'rippt', 'komprimiert', 'legt-ab'].includes(nachricht.status.phase) + if (laeuft && stand.ripStart[nachricht.status.id] === undefined) { + stand.ripStart = { ...stand.ripStart, [nachricht.status.id]: Date.now() } + // Ein NEUER Vorgang beginnt — das Protokoll des alten weicht. + stand.ripProtokoll = { ...stand.ripProtokoll, [nachricht.status.id]: [] } + } else if (!laeuft) { + const { [nachricht.status.id]: _weg, ...rest } = stand.ripStart + stand.ripStart = rest + } + // Jede NEUE Statuszeile wandert ins Kachel-Protokoll (Prozent-Ticks + // wiederholen den Text — nur echte Änderungen zählen). + const bisher = stand.ripProtokoll[nachricht.status.id] ?? [] + if (nachricht.status.text.length > 0 && bisher[bisher.length - 1] !== nachricht.status.text) { + stand.ripProtokoll = { + ...stand.ripProtokoll, + [nachricht.status.id]: [...bisher, nachricht.status.text].slice(-80), + } + } + if (nachricht.status.phase === 'fertig') { + meldung( + `Laufwerk ${nachricht.status.id}: Rip fertig — ${nachricht.status.dateien.length} Datei(en)` + + (nachricht.status.lesefehler ? ' ⚠ mit Lesefehlern' : ''), + ) + } else if (nachricht.status.phase === 'fehler') { + meldung(`⚠ Laufwerk ${nachricht.status.id}: ${nachricht.status.text}`) + } else if (nachricht.status.phase === 'abgebrochen') { + meldung(`Laufwerk ${nachricht.status.id}: Rip abgebrochen`) + } + break + } + } + melden() +} + +function portAnnehmen(neuerPort: MessagePort): void { + port = neuerPort + stand.verbunden = true + neuerPort.onmessage = (ereignis: MessageEvent) => { + const nachricht: unknown = ereignis.data + if (istKernNachricht(nachricht)) kernNachricht(nachricht) + } + neuerPort.start() + neuerPort.postMessage({ art: 'ping', zeit: Date.now() }) + neuerPort.postMessage({ art: 'einstellungen-laden' }) + neuerPort.postMessage({ art: 'werkzeuge-laden' }) + neuerPort.postMessage({ art: 'bibliothek-laden' }) + melden() +} + +/** Idempotent — der erste Aufruf registriert den Horcher und meldet der + * Brücke, dass gepufferte Ports jetzt zugestellt werden können. */ +export function verbindungStarten(): void { + if (gestartet) return + gestartet = true + window.addEventListener('message', (ereignis: MessageEvent) => { + const daten: unknown = ereignis.data + if ( + typeof daten === 'object' && + daten !== null && + (daten as Record).art === 'kern-port' && + ereignis.ports.length > 0 + ) { + portAnnehmen(ereignis.ports[0]) + } + }) + window.rippy.bereit() +} + +export function aufVerbindung(h: Horcher): () => void { + horcher.add(h) + h({ + ...stand, + laufwerke: [...stand.laufwerke], + rips: { ...stand.rips }, + discInfos: { ...stand.discInfos }, + vorschlaege: { ...stand.vorschlaege }, + einstellungen: { ...stand.einstellungen }, + bibliothek: [...stand.bibliothek], + meldungen: [...stand.meldungen], + }) + return () => horcher.delete(h) +} + +function anKern(nachricht: { art: string; id: string; auftrag?: RipAuftragTitel[] }, wasWar: string): void { + if (port === null) { + meldung(`⚠ ${wasWar}: keine Verbindung zum Kern`) + melden() + return + } + port.postMessage(nachricht) +} + +export function auswerfen(id: string): void { + anKern({ art: 'auswerfen', id }, `Auswerfen ${id}`) +} + +/** Den Info-Lauf anstoßen (20–120 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 { + anKern({ art: 'rip-abbruch', id }, `Rip-Abbruch ${id}`) +} + +export function metadatenSuchen(id: string, text: string): void { + if (port === null) return + port.postMessage({ art: 'metadaten-suchen', id, text }) +} + +export function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): void { + if (port === null) return + port.postMessage({ art: 'zuordnung-setzen', id, vorschlag }) +} + +export function makemkvUpdate(): void { + if (port === null) { + meldung('⚠ MakeMKV-Update: keine Verbindung zum Kern') + melden() + return + } + port.postMessage({ art: 'makemkv-update' }) +} + +export function schluesselPruefen(): void { + if (port === null) return + port.postMessage({ art: 'schluessel-pruefen' }) +} + +export function einstellungSetzen(schluessel: string, wert: string): void { + if (port === null) { + meldung(`⚠ Einstellung ${schluessel}: keine Verbindung zum Kern`) + melden() + return + } + port.postMessage({ art: 'einstellung-setzen', schluessel, wert }) +} diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 4ee50ab..0131118 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -1,386 +1,405 @@ -// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1). -// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese -// Datei läuft im Fenster UND im Kern. -// -// Wege: -// Kern → Haupt über process.parentPort (KernNachricht) -// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht) -// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus) -// Haupt → Kern über utilityProcess.postMessage (HauptNachricht) - -/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */ -export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string } - -// ── Laufwerke (Etappe W-1) ────────────────────────────────────────────── -export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown' -export type LaufwerkStatus = 'ready' | 'empty' | 'unknown' - -export interface GeraeteInfo { - /** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */ - id: string - name: string - typ: DiscTyp - pfad: string - status: LaufwerkStatus - modell: string - seriennummer: string - /** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */ - groesseBytes: number - /** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */ - grund: string -} - -export type WacheEreignis = - | { art: 'disc-eingelegt'; geraet: GeraeteInfo } - | { art: 'disc-entnommen'; geraet: GeraeteInfo } - | { art: 'laufwerk-neu'; geraet: GeraeteInfo } - | { art: 'laufwerk-weg'; id: string } - -export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin' - -// ── Rippen (Etappe W-2) ───────────────────────────────────────────────── -export type RipPhase = - | 'liest-info' - | 'rippt' - | 'komprimiert' - | 'legt-ab' - | 'fertig' - | 'fehler' - | 'abgebrochen' - -export interface RipStatus { - /** Laufwerks-Kennung, auf der der Rip läuft. */ - id: string - phase: RipPhase - /** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */ - prozent: number - text: string - dateien: string[] - lesefehler: boolean - /** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es - * insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO - * und in Fehler-Meldungen; die kennen keine Titel. */ - titelNr?: number - titelGesamt?: number - /** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer - * Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */ - haengt?: { offset: string; wiederholungen: number } | null -} - -/** 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 - /** Warum — Klartext („Folge", „Sammeltitel …", „zu kurz …"). */ - grund: string -} - -/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 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). */ -export interface DiscInfoStand { - id: string - titel: string - jahr: number | null - /** 0–1; die Schwelle für „sicher genug" ist einstellbar. */ - confidence: number - quelle: string - posterPfad: string - /** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */ - backdropPfad: string - /** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */ - beschreibung: string - /** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am - * Fingerabdruck wieder (§ 6.6); null wenn neu. */ - schonGerippt: string | null - /** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */ - discNr: number | null - /** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */ - staffel: number | null - /** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines - * gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */ - folgen: number -} - -/** Ein Eintrag der Bibliothek (§ 6.6). */ -export interface BibliothekEintrag { - fingerprint: string - titel: string - jahr: number | null - typ: string - groesseBytes: number - ablageort: string - datum: string - dauerS: number - /** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */ - posterPfad: string -} - -/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */ -export interface MetaVorschlag { - typ: 'movie' | 'tv' - tmdbId: number - titel: string - jahr: number | null - posterPfad: string -} - -/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */ -export interface WerkzeugAuskunft { - makemkvcon: string - handbrake: string - flac: string - presets: string[] - backends: string[] -} - -/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */ -export interface BeschaffungStand { - /** Welches Werkzeug — bisher nur 'makemkv'. */ - werkzeug: string - laeuft: boolean - text: string - /** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */ - anteil: number | null - fehler: boolean -} - -/** Nachrichten, die der Kern verschickt. */ -export type KernNachricht = - | { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus } - | { art: 'pong'; zeit: number } - | { art: 'kern-fehler'; text: string } - | { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] } - | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } - | { art: 'rip-status'; status: RipStatus } - | { art: 'disc-info'; info: DiscInfoStand } - | { art: 'disc-titel'; stand: TitelStand } - | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } - | { art: 'einstellungen'; werte: Record } - | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } - | { art: 'bibliothek'; eintraege: BibliothekEintrag[] } - | { art: 'beschaffung-status'; stand: BeschaffungStand } - /** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt — - * starten kann ihn nur das Haupt (shell/UAC, § 9). */ - | { art: 'installer-starten'; pfad: string } - | { - art: 'schluessel-status' - stand: { - quelle: string - angenommen: boolean | null - /** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */ - weiterBetrieb: boolean - gueltigBis: string - geholtAm: string - text: string - /** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */ - version: string - } - } - -/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */ -export type FensterNachricht = - | { art: 'ping'; zeit: number } - | { art: 'auswerfen'; id: string } - /** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 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 } - /** `titel` leer heißt: alles rippen — wie vor 5.2. */ - | { art: 'rip-start'; id: string; titel?: number[] } - | { art: 'rip-abbruch'; id: string } - /** Den gerade laufenden Titel aufgeben und mit dem nächsten weitermachen - * — der Ausweg, wenn die Disc an einer Stelle einen Defekt hat. */ - | { art: 'rip-ueberspringen'; id: string } - | { art: 'einstellung-setzen'; schluessel: string; wert: string } - | { art: 'metadaten-suchen'; id: string; text: string } - | { art: 'zuordnung-setzen'; id: string; vorschlag: MetaVorschlag } - | { art: 'einstellungen-laden' } - | { art: 'werkzeuge-laden' } - | { art: 'bibliothek-laden' } - /** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */ - | { art: 'makemkv-update' } - /** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */ - | { art: 'schluessel-pruefen' } - -/** Nachrichten, die das Haupt an den Kern schickt. */ -export type HauptNachricht = - | { art: 'fenster-port' } - /** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */ - | { art: 'installer-ergebnis'; fehler: string | null } - -/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */ -export interface HauptStatus { - version: string - /** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */ - leine: { ok: boolean; fehler?: string } - kern: { laeuft: boolean; pid?: number; neustarts: number } - autostart: boolean - /** Selbst-Update (§ 6.8). */ - update: UpdateStand - /** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster - * gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */ - fensterMaximiert: boolean -} - -/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt - * und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene - * Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal - * arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */ -export interface UpdateStand { - /** Wann die letzte Prüfung GELANG — '' wenn noch nie. */ - letzteErfolgreichePruefung: string - text: string - /** Die gefundene neue Version — '' wenn keine bereitsteht. */ - neueVersion: string - /** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */ - neuerungen: string - /** Geladen und wartet auf den Neustart. */ - bereit: boolean - /** Eine Prüfung oder ein Download läuft gerade. */ - laeuft: boolean - /** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */ - fortschritt: number -} - -function istObjekt(wert: unknown): wert is Record { - return typeof wert === 'object' && wert !== null -} - -export function istKernNachricht(wert: unknown): wert is KernNachricht { - if (!istObjekt(wert)) return false - switch (wert.art) { - case 'kern-bereit': - return ( - typeof wert.pid === 'number' && - typeof wert.nodeVersion === 'string' && - istDbStatus(wert.datenbank) - ) - case 'pong': - return typeof wert.zeit === 'number' - case 'kern-fehler': - return typeof wert.text === 'string' - case 'laufwerke': - return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse) - case 'auswurf-ergebnis': - return ( - typeof wert.id === 'string' && - typeof wert.grund === 'string' && - (wert.fehler === null || typeof wert.fehler === 'string') - ) - case 'rip-status': - return istRipStatus(wert.status) - case 'disc-info': - return ( - istObjekt(wert.info) && - typeof (wert.info as Record).id === 'string' && - typeof (wert.info as Record).titel === 'string' && - typeof (wert.info as Record).confidence === 'number' - ) - case 'disc-titel': - return ( - istObjekt(wert.stand) && - typeof (wert.stand as Record).id === 'string' && - typeof (wert.stand as Record).laeuft === 'boolean' && - Array.isArray((wert.stand as Record).titel) - ) - case 'einstellungen': - return istObjekt(wert.werte) - case 'werkzeuge': - return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record).presets) - case 'bibliothek': - return Array.isArray(wert.eintraege) - case 'schluessel-status': - return istObjekt(wert.stand) && typeof (wert.stand as Record).text === 'string' - case 'beschaffung-status': - return ( - istObjekt(wert.stand) && - typeof (wert.stand as Record).werkzeug === 'string' && - typeof (wert.stand as Record).text === 'string' && - typeof (wert.stand as Record).laeuft === 'boolean' - ) - case 'installer-starten': - return typeof wert.pfad === 'string' && wert.pfad.length > 0 - case 'metadaten-vorschlaege': - return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege) - default: - return false - } -} - -export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag { - if (!istObjekt(wert)) return false - return ( - (wert.typ === 'movie' || wert.typ === 'tv') && - typeof wert.tmdbId === 'number' && - typeof wert.titel === 'string' && - (wert.jahr === null || typeof wert.jahr === 'number') && - typeof wert.posterPfad === 'string' - ) -} - -export function istRipStatus(wert: unknown): wert is RipStatus { - if (!istObjekt(wert)) return false - return ( - typeof wert.id === 'string' && - typeof wert.phase === 'string' && - typeof wert.prozent === 'number' && - typeof wert.text === 'string' && - Array.isArray(wert.dateien) && - typeof wert.lesefehler === 'boolean' - ) -} - -export function istDbStatus(wert: unknown): wert is DbStatus { - if (!istObjekt(wert)) return false - if (wert.ok === true) return typeof wert.pfad === 'string' - if (wert.ok === false) return typeof wert.fehler === 'string' - return false -} - -export function istFensterNachricht(wert: unknown): wert is FensterNachricht { - if (!istObjekt(wert)) return false - if (wert.art === 'ping') return typeof wert.zeit === 'number' - if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { - return typeof wert.id === 'string' && wert.id.length > 0 - } - if (wert.art === 'einstellung-setzen') { - return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string' - } - if (wert.art === 'metadaten-suchen') { - return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string' - } - if (wert.art === 'zuordnung-setzen') { - return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag) - } - return ( - wert.art === 'einstellungen-laden' || - wert.art === 'werkzeuge-laden' || - wert.art === 'bibliothek-laden' || - wert.art === 'makemkv-update' || - wert.art === 'schluessel-pruefen' - ) -} - -export function istHauptNachricht(wert: unknown): wert is HauptNachricht { - if (!istObjekt(wert)) return false - if (wert.art === 'fenster-port') return true - if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string' - return false -} +// Das Nachrichten-Schema zwischen den drei Prozessen (KONZEPT-WINDOWS.md § 4.1). +// NUR Typen und Prüf-Funktionen — keine Logik, keine Abhängigkeiten, denn diese +// Datei läuft im Fenster UND im Kern. +// +// Wege: +// Kern → Haupt über process.parentPort (KernNachricht) +// Kern ↔ Fenster über einen direkten MessagePort (KernNachricht/FensterNachricht) +// Haupt → Fenster über IPC-Kanal 'haupt-status' (HauptStatus) +// Haupt → Kern über utilityProcess.postMessage (HauptNachricht) + +/** Zustand der Datenbank, wie der Kern ihn beim Start gemessen hat. */ +export type DbStatus = { ok: true; pfad: string } | { ok: false; fehler: string } + +// ── Laufwerke (Etappe W-1) ────────────────────────────────────────────── +export type DiscTyp = 'cd' | 'dvd' | 'bluray' | 'uhd' | 'unknown' +export type LaufwerkStatus = 'ready' | 'empty' | 'unknown' + +export interface GeraeteInfo { + /** Kurze Kennung fürs UI und für Befehle — der Laufwerksbuchstabe. */ + id: string + name: string + typ: DiscTyp + pfad: string + status: LaufwerkStatus + modell: string + seriennummer: string + /** Größe des eingelegten Mediums in Bytes; 0, wenn unbekannt/leer. */ + groesseBytes: number + /** Leer, solange alles geht — sonst steht hier, WARUM „unknown" dasteht. */ + grund: string +} + +export type WacheEreignis = + | { art: 'disc-eingelegt'; geraet: GeraeteInfo } + | { art: 'disc-entnommen'; geraet: GeraeteInfo } + | { art: 'laufwerk-neu'; geraet: GeraeteInfo } + | { art: 'laufwerk-weg'; id: string } + +export type AuswurfGrund = 'ok' | 'kein-zugriff' | 'abgelehnt' | 'bleibt-drin' + +// ── Rippen (Etappe W-2) ───────────────────────────────────────────────── +export type RipPhase = + | 'liest-info' + | 'rippt' + | 'komprimiert' + | 'legt-ab' + | 'fertig' + | 'fehler' + | 'abgebrochen' + +export interface RipStatus { + /** Laufwerks-Kennung, auf der der Rip läuft. */ + id: string + phase: RipPhase + /** 0–100; -1 heißt „noch keine Angabe" (ein echtes 0 % bleibt sichtbar). */ + prozent: number + text: string + dateien: string[] + lesefehler: boolean + /** Der wievielte gewählte Titel läuft gerade (1-basiert) und wie viele es + * insgesamt sind — Rippy rippt Titel für Titel. Fehlt bei Audio-CD, ISO + * und in Fehler-Meldungen; die kennen keine Titel. */ + titelNr?: number + titelGesamt?: number + /** Der Lauf klemmt an einer festen Stelle der Disc — ein physischer + * Defekt, kein Fortschrittsproblem. Fehlt/null heißt: alles in Ordnung. */ + haengt?: { offset: string; wiederholungen: number } | null +} + +/** Was ein Titel IST — davon hängt ab, WOHIN er abgelegt wird: + * hauptfilm → Filme/ (Jahr)/ (Jahr).mkv + * extra → Filme/ (Jahr)/extras/… (Jellyfin/Emby/Kodi) + * folge → Serien//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 20–120 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). */ +export interface DiscInfoStand { + id: string + titel: string + jahr: number | null + /** 0–1; die Schwelle für „sicher genug" ist einstellbar. */ + confidence: number + quelle: string + posterPfad: string + /** TMDb-Hintergrundbild (Querformat) fürs Kachel-Panorama; '' wenn keins. */ + backdropPfad: string + /** Kurzbeschreibung (TMDb-Overview) — '' wenn keine. */ + beschreibung: string + /** Datum des früheren Rips dieser Disc — die Bibliothek erkennt sie am + * Fingerabdruck wieder (§ 6.6); null wenn neu. */ + schonGerippt: string | null + /** Nummer der Disc in einer Mehr-Disc-Ausgabe — null, wenn keine dasteht. */ + discNr: number | null + /** Staffel-Nummer aus Titel oder Label — null, wenn keine dasteht. */ + staffel: number | null + /** Folgen laut dem Inhaltsverzeichnis der Disc selbst — 0, wenn es keines + * gibt. Ab zwei gilt „Serie" als bewiesen (FOLGEN_FUER_SERIE). */ + folgen: number +} + +/** Ein Eintrag der Bibliothek (§ 6.6). */ +export interface BibliothekEintrag { + fingerprint: string + titel: string + jahr: number | null + typ: string + groesseBytes: number + ablageort: string + datum: string + dauerS: number + /** TMDb-Posterpfad fürs Bibliotheks-Regal — '' wenn keiner bekannt. */ + posterPfad: string +} + +/** Ein wählbarer Metadaten-Vorschlag (§ 5 Schritt 4: „nein → fragen"). */ +export interface MetaVorschlag { + typ: 'movie' | 'tv' + tmdbId: number + titel: string + jahr: number | null + posterPfad: string +} + +/** Auskunft über die Werkzeuge (Katalog + gemessene HandBrake-Listen). */ +export interface WerkzeugAuskunft { + makemkvcon: string + handbrake: string + flac: string + presets: string[] + backends: string[] +} + +/** Stand einer laufenden Werkzeug-Beschaffung (§ 9: MakeMKV-Update). */ +export interface BeschaffungStand { + /** Welches Werkzeug — bisher nur 'makemkv'. */ + werkzeug: string + laeuft: boolean + text: string + /** 0–1 während des Downloads; null wenn gerade nicht bezifferbar. */ + anteil: number | null + fehler: boolean +} + +/** Nachrichten, die der Kern verschickt. */ +export type KernNachricht = + | { art: 'kern-bereit'; pid: number; nodeVersion: string; datenbank: DbStatus } + | { art: 'pong'; zeit: number } + | { art: 'kern-fehler'; text: string } + | { art: 'laufwerke'; liste: GeraeteInfo[]; ereignisse: WacheEreignis[] } + | { art: 'auswurf-ergebnis'; id: string; grund: AuswurfGrund; fehler: string | null } + | { art: 'rip-status'; status: RipStatus } + | { art: 'disc-info'; info: DiscInfoStand } + | { art: 'disc-titel'; stand: TitelStand } + | { art: 'metadaten-vorschlaege'; id: string; vorschlaege: MetaVorschlag[] } + | { art: 'einstellungen'; werte: Record } + | { art: 'werkzeuge'; auskunft: WerkzeugAuskunft } + | { art: 'bibliothek'; eintraege: BibliothekEintrag[] } + | { art: 'beschaffung-status'; stand: BeschaffungStand } + /** Nur fürs Haupt: Der Kern hat einen geprüften Installer abgelegt — + * starten kann ihn nur das Haupt (shell/UAC, § 9). */ + | { art: 'installer-starten'; pfad: string } + | { + art: 'schluessel-status' + stand: { + quelle: string + angenommen: boolean | null + /** Rips laufen trotzdem (Beta-Modus) — Warnung, kein Ausfall. */ + weiterBetrieb: boolean + gueltigBis: string + geholtAm: string + text: string + /** Die installierte MakeMKV-Fassung (aus MSG 1005) — '' wenn unbekannt. */ + version: string + } + } + +/** Nachrichten, die das Fenster über den MessagePort an den Kern schickt. */ +export type FensterNachricht = + | { art: 'ping'; zeit: number } + | { art: 'auswerfen'; id: string } + /** Titel-Lauf anstoßen (§ 6.2): dauert 20–120 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 } + | { art: 'einstellungen-laden' } + | { art: 'werkzeuge-laden' } + | { art: 'bibliothek-laden' } + /** § 9: MakeMKV über die Quellenkette holen und installieren lassen. */ + | { art: 'makemkv-update' } + /** Schlüsselkette sofort neu laufen lassen (z. B. nach einer Installation). */ + | { art: 'schluessel-pruefen' } + +/** Nachrichten, die das Haupt an den Kern schickt. */ +export type HauptNachricht = + | { art: 'fenster-port' } + /** Antwort auf 'installer-starten': '' bzw. null heißt gestartet. */ + | { art: 'installer-ergebnis'; fehler: string | null } + +/** Zustand des Haupt-Prozesses fürs Fenster (Kanal 'haupt-status'). */ +export interface HauptStatus { + version: string + /** Prozess-Leine (Job Object, § 3.3): gesetzt oder mit Fehlertext. */ + leine: { ok: boolean; fehler?: string } + kern: { laeuft: boolean; pid?: number; neustarts: number } + autostart: boolean + /** Selbst-Update (§ 6.8). */ + update: UpdateStand + /** Für die eigene Titelleiste (§ 4.2, seit 5.1.3): Ist das Fenster + * gerade maximiert? Entscheidet nur, welches Symbol der Knopf zeigt. */ + fensterMaximiert: boolean +} + +/** Der Stand des Selbst-Updates (§ 6.8) — EINE Definition, die sich Haupt + * und Fenster teilen. Seit 01.09.2026 trägt sie auch die gefundene + * Version, ihre Änderungsnotizen und den Ladefortschritt: Der Kanal + * arbeitete vorher korrekt, aber unsichtbar (Commander-Fund). */ +export interface UpdateStand { + /** Wann die letzte Prüfung GELANG — '' wenn noch nie. */ + letzteErfolgreichePruefung: string + text: string + /** Die gefundene neue Version — '' wenn keine bereitsteht. */ + neueVersion: string + /** Was in ihr neu ist (aus latest.yml) — '' wenn nichts hinterlegt ist. */ + neuerungen: string + /** Geladen und wartet auf den Neustart. */ + bereit: boolean + /** Eine Prüfung oder ein Download läuft gerade. */ + laeuft: boolean + /** Ladefortschritt in Prozent; -1 heißt: kein Download unterwegs. */ + fortschritt: number +} + +function istObjekt(wert: unknown): wert is Record { + return typeof wert === 'object' && wert !== null +} + +export function istKernNachricht(wert: unknown): wert is KernNachricht { + if (!istObjekt(wert)) return false + switch (wert.art) { + case 'kern-bereit': + return ( + typeof wert.pid === 'number' && + typeof wert.nodeVersion === 'string' && + istDbStatus(wert.datenbank) + ) + case 'pong': + return typeof wert.zeit === 'number' + case 'kern-fehler': + return typeof wert.text === 'string' + case 'laufwerke': + return Array.isArray(wert.liste) && Array.isArray(wert.ereignisse) + case 'auswurf-ergebnis': + return ( + typeof wert.id === 'string' && + typeof wert.grund === 'string' && + (wert.fehler === null || typeof wert.fehler === 'string') + ) + case 'rip-status': + return istRipStatus(wert.status) + case 'disc-info': + return ( + istObjekt(wert.info) && + typeof (wert.info as Record).id === 'string' && + typeof (wert.info as Record).titel === 'string' && + typeof (wert.info as Record).confidence === 'number' + ) + case 'disc-titel': + return ( + istObjekt(wert.stand) && + typeof (wert.stand as Record).id === 'string' && + typeof (wert.stand as Record).laeuft === 'boolean' && + Array.isArray((wert.stand as Record).titel) + ) + case 'einstellungen': + return istObjekt(wert.werte) + case 'werkzeuge': + return istObjekt(wert.auskunft) && Array.isArray((wert.auskunft as Record).presets) + case 'bibliothek': + return Array.isArray(wert.eintraege) + case 'schluessel-status': + return istObjekt(wert.stand) && typeof (wert.stand as Record).text === 'string' + case 'beschaffung-status': + return ( + istObjekt(wert.stand) && + typeof (wert.stand as Record).werkzeug === 'string' && + typeof (wert.stand as Record).text === 'string' && + typeof (wert.stand as Record).laeuft === 'boolean' + ) + case 'installer-starten': + return typeof wert.pfad === 'string' && wert.pfad.length > 0 + case 'metadaten-vorschlaege': + return typeof wert.id === 'string' && Array.isArray(wert.vorschlaege) + default: + return false + } +} + +export function istMetaVorschlag(wert: unknown): wert is MetaVorschlag { + if (!istObjekt(wert)) return false + return ( + (wert.typ === 'movie' || wert.typ === 'tv') && + typeof wert.tmdbId === 'number' && + typeof wert.titel === 'string' && + (wert.jahr === null || typeof wert.jahr === 'number') && + typeof wert.posterPfad === 'string' + ) +} + +export function istRipStatus(wert: unknown): wert is RipStatus { + if (!istObjekt(wert)) return false + return ( + typeof wert.id === 'string' && + typeof wert.phase === 'string' && + typeof wert.prozent === 'number' && + typeof wert.text === 'string' && + Array.isArray(wert.dateien) && + typeof wert.lesefehler === 'boolean' + ) +} + +export function istDbStatus(wert: unknown): wert is DbStatus { + if (!istObjekt(wert)) return false + if (wert.ok === true) return typeof wert.pfad === 'string' + if (wert.ok === false) return typeof wert.fehler === 'string' + return false +} + +export function istFensterNachricht(wert: unknown): wert is FensterNachricht { + if (!istObjekt(wert)) return false + if (wert.art === 'ping') return typeof wert.zeit === 'number' + if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { + return typeof wert.id === 'string' && wert.id.length > 0 + } + if (wert.art === 'einstellung-setzen') { + return typeof wert.schluessel === 'string' && wert.schluessel.length > 0 && typeof wert.wert === 'string' + } + if (wert.art === 'metadaten-suchen') { + return typeof wert.id === 'string' && wert.id.length > 0 && typeof wert.text === 'string' + } + if (wert.art === 'zuordnung-setzen') { + return typeof wert.id === 'string' && wert.id.length > 0 && istMetaVorschlag(wert.vorschlag) + } + return ( + wert.art === 'einstellungen-laden' || + wert.art === 'werkzeuge-laden' || + wert.art === 'bibliothek-laden' || + wert.art === 'makemkv-update' || + wert.art === 'schluessel-pruefen' + ) +} + +export function istHauptNachricht(wert: unknown): wert is HauptNachricht { + if (!istObjekt(wert)) return false + if (wert.art === 'fenster-port') return true + if (wert.art === 'installer-ergebnis') return wert.fehler === null || typeof wert.fehler === 'string' + return false +} diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts index 73178d5..7ea3f0a 100644 --- a/rippy-windows/src/kern/ablauf/pipeline.ts +++ b/rippy-windows/src/kern/ablauf/pipeline.ts @@ -1,627 +1,700 @@ -// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. -// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über -// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und -// hängen damit an der Prozess-Leine (R6). -// -// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein -// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in -// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der -// Einstellung im UI (W-5) eine Stimme. -import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' -// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. -import { basename, join } from 'node:path' -import type { BibliothekEintrag, GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' -import { baueNfo } from '../ablage/nfo' -import { bibliothekRefresh } from '../ablage/medienserver' -import { posterSpeichern } from '../ablage/poster' -import { zielordner } from '../ablage/struktur' -import { komprimieren } from '../komprimieren/handbrake' -import { komprimierenFuer, presetFuer, sprachliste, type Einstellungen } from '../komprimieren/presets' -import type { Zuordnung } from '../metadaten/zuordnung' -import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' -import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' -import { isoSichern } from '../rip/iso' -import { rippen } from '../rip/makemkv' -import { spawn } from 'node:child_process' -import { mkdtempSync, rmSync, createWriteStream } from 'node:fs' -import { 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 -} - -export interface PipelineUmgebung { - /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ - makemkvcon: () => string - /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ - handbrake: () => string - /** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */ - flac: () => string - /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ - audioToc?: (id: string) => Spur[] - /** Rohe Audio-Sektoren lesen (W-7). */ - audioSpurLesen?: ( - id: string, - start: number, - sektoren: number, - schreiben: (daten: Buffer) => void, - fortschritt?: (anteil: number) => void, - ) => number - /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ - geraet: (id: string) => GeraeteInfo | null - /** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ - ablage: () => string - einstellungen: () => Einstellungen - /** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ — - * greift nur, wenn der Nutzer nichts eingestellt hat. */ - presetEmpfehlung?: (discTyp: string) => string - melden: (status: RipStatus) => void - /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ - zuordnung?: (id: string) => Zuordnung | null - /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ - fingerprint?: (id: string) => string | null - /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */ - bibliothekEintragen?: (eintrag: BibliothekEintrag) => void - /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ - jetzt?: () => Date -} - -export class Pipeline { - private laufend = new Map() - - constructor(private readonly umgebung: PipelineUmgebung) {} - - laeuft(id: string): boolean { - return this.laufend.has(id) - } - - private melden(status: RipStatus): void { - const eintrag = this.laufend.get(status.id) - if (eintrag !== undefined) eintrag.status = status - this.umgebung.melden(status) - } - - private fehler(id: string, text: string): RipStatus { - return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } - } - - /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten - * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: - * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, - * während Folge 2 heil daneben lag). */ - ueberspringen(id: string): void { - const vorgang = this.laufend.get(id) - if (vorgang === undefined || vorgang.titelAbbruch === undefined) return - vorgang.uebersprungen = true - vorgang.titelAbbruch.abort() - } - - /** Startet die Kette. `titelWahl` leer heißt: alles rippen (wie früher). */ - async starten(id: string, titelWahl: readonly number[] = []): Promise { - if (this.laufend.has(id)) { - this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) - return - } - - const programm = this.umgebung.makemkvcon() - if (programm.length === 0) { - this.umgebung.melden( - this.fehler( - id, - 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)', - ), - ) - return - } - - const geraet = this.umgebung.geraet(id) - if (geraet === null) { - this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`)) - return - } - // Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt - // einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der - // Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf - // nie zu „es geht nicht" werden. - if (geraet.status === 'empty') { - this.umgebung.melden( - this.fehler( - id, - `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`, - ), - ) - return - } - - const abbruch = new AbortController() - const startZeit = this.umgebung.jetzt?.() ?? new Date() - const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) - const vorgangsName = `${geraet.typ}-${zeit}` - - // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). - if (geraet.typ === 'cd') { - this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) - this.melden(this.laufend.get(id)!.status) - await this.audioCdRippen(id, geraet, startZeit, abbruch) - return - } - if (geraet.typ === 'unknown') { - this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) - this.melden(this.laufend.get(id)!.status) - await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) - return - } - - const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) - mkdirSync(rohOrdner, { recursive: true }) - - // 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 auftrag = titelWahl.length > 0 ? titelWahl.map(String) : ['all'] - const alleDateien = new Set() - let lesefehlerGesamt = false - const gescheitert: string[] = [] - const uebersprungen: string[] = [] - - this.laufend.set(id, { - abbruch, - status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false }, - }) - - for (let i = 0; i < auftrag.length; i++) { - const welcher = auftrag[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 ${auftrag.length})` - this.melden({ - id, - phase: 'rippt', - prozent: -1, - text: `Rippe ${beschriftung} verlustfrei nach ${rohOrdner} …`, - dateien: [...alleDateien], - lesefehler: lesefehlerGesamt, - titelNr: i + 1, - titelGesamt: auftrag.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) - if (neu.length === 0) { - // MakeMKV kann mit Code 0 enden und trotzdem nichts abgelegt haben - // (30.08.2026 gemessen) — dann ist dieser Titel NICHT gesichert. - gescheitert.push(`${beschriftung}: keine Datei entstanden`) - } - } - - const nachLauf = this.laufend.get(id) - if (nachLauf !== undefined) nachLauf.titelAbbruch = undefined - - if (alleDateien.size === 0) { - this.laufend.delete(id) - this.melden({ - id, - phase: 'fehler', - prozent: -1, - text: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.', - dateien: [], - lesefehler: lesefehlerGesamt, - }) - return - } - - 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() - const handbrake = this.umgebung.handbrake() - const soll = komprimierenFuer(geraet.typ, einstellungen) - - if (!soll || handbrake.length === 0) { - const grund = !soll - ? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.` - : 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.' - this.laufend.delete(id) - this.melden({ - id, - phase: 'fertig', - prozent: 100, - text: `Fertig. ${grund}${lesefehlerHinweis}`, - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - - const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '') - // Mit Metadaten (W-4) heißt der Ordner „ (Jahr)" — daran - // erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname. - const zuordnung = this.umgebung.zuordnung?.(id) ?? null - const fertigOrdner = - zuordnung !== null && zuordnung.titel.length > 0 - ? zielordner(join(this.umgebung.ablage(), 'Filme'), zuordnung.titel, zuordnung.jahr, vorgangsName) - : zielordner(join(this.umgebung.ablage(), 'Filme'), vorgangsName, null, vorgangsName) - mkdirSync(fertigOrdner, { recursive: true }) - - const fertigeDateien: string[] = [] - for (let i = 0; i < rip.dateien.length; i++) { - const eingabe = rip.dateien[i] - const ausgabe = join(fertigOrdner, basename(eingabe)) - const alt = this.laufend.get(id)?.status - if (alt !== undefined) { - this.melden({ - ...alt, - phase: 'komprimiert', - prozent: -1, - text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`, - }) - } - const encode = await komprimieren({ - programm: handbrake, - eingabe, - ausgabe, - preset, - audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')), - untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')), - abbruch: abbruch.signal, - aufFortschritt: (prozent) => { - const stand = this.laufend.get(id)?.status - if (stand !== undefined) { - this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) }) - } - }, - }) - if (encode.status === 'cancelled') { - this.laufend.delete(id) - this.melden({ - id, - phase: 'abgebrochen', - prozent: -1, - text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.', - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - if (encode.status === 'error') { - this.laufend.delete(id) - this.melden({ - id, - phase: 'fehler', - prozent: -1, - text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`, - dateien: rip.dateien, - lesefehler: rip.lesefehler, - }) - return - } - fertigeDateien.push(encode.ausgabe) - } - - // ── Ablage (W-3): NFO daneben; echte Titel kommen mit W-4 ─────────── - const alt = this.laufend.get(id)?.status - if (alt !== undefined) { - this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien }) - } - try { - const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo' - writeFileSync( - join(fertigOrdner, nfoName), - baueNfo( - { - typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film', - beschreibung: zuordnung?.meta.beschreibung, - genres: zuordnung?.meta.genres, - laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, - quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', - }, - zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, - zuordnung?.jahr ?? null, - ), - ) - } catch (fehler) { - // Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es - // wird GESAGT (R4). - const stand = this.laufend.get(id)?.status - if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` }) - } - if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { - await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) - } - - const server = String(einstellungen['mediaServer'] ?? '') - const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') - const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') - const refresh = await bibliothekRefresh(server, serverUrl, serverKey) - - // § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt. - const fingerprint = this.umgebung.fingerprint?.(id) ?? null - if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { - let groesse = 0 - for (const datei of fertigeDateien) { - try { - groesse += statSync(datei).size - } catch { - // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts - // am Eintrag selbst. - } - } - this.umgebung.bibliothekEintragen({ - fingerprint, - titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, - jahr: zuordnung?.jahr ?? null, - typ: geraet.typ, - groesseBytes: groesse, - ablageort: fertigOrdner, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: zuordnung?.meta.posterPfad ?? '', - }) - } - - this.laufend.delete(id) - this.melden({ - id, - phase: 'fertig', - prozent: 100, - text: - `Fertig — komprimiert nach ${fertigOrdner}.` + - (refresh.length > 0 ? ` ${refresh}` : '') + - lesefehlerHinweis, - dateien: fertigeDateien, - lesefehler: rip.lesefehler, - }) - } - - abbrechen(id: string): void { - this.laufend.get(id)?.abbruch.abort() - } - - // ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ── - private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise { - const fertigMachen = (status: RipStatus): void => { - this.laufend.delete(id) - this.melden(status) - } - const flac = this.umgebung.flac() - if (flac.length === 0) { - fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).')) - return - } - if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) { - fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.')) - return - } - let spuren: Spur[] - try { - spuren = this.umgebung.audioToc(id).filter((s) => s.audio) - } catch (fehler) { - fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`)) - return - } - if (spuren.length === 0) { - fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?')) - return - } - - // MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne - // Treffer heißen die Dateien „Track 01" — das ist keine Ausrede, - // sondern die Wahrheit über eine Disc, die niemand kennt. - const kennung = kennungAusSpuren(spuren) - const antwort = await abfragen(kennung) - const tags = tagsJeSpur(antwort) - const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit) - const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName) - mkdirSync(ziel, { recursive: true }) - - const dateien: string[] = [] - for (let i = 0; i < spuren.length; i++) { - if (abbruch.signal.aborted) { - fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false }) - return - } - const spur = spuren[i] - const spurTags = tags.get(spur.nr) ?? {} - const name = dateiname(spur.nr, spurTags['TITLE'] ?? '') - const zielDatei = join(ziel, `${name}.flac`) - const stand = this.laufend.get(id)?.status - if (stand !== undefined) { - this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) }) - } - - // Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang - // hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht. - const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-')) - try { - const wav = join(arbeit, 'spur.wav') - const strom = createWriteStream(wav) - strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) - this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { - const s = this.laufend.get(id)?.status - if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) - }) - await new Promise((fertig, kaputt) => { - strom.end(() => fertig()) - strom.on('error', kaputt) - }) - const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags)) - if (flacErgebnis.code !== 0) { - fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`)) - return - } - } catch (fehler) { - fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`)) - return - } finally { - rmSync(arbeit, { recursive: true, force: true }) - } - dateien.push(zielDatei) - } - - const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) - if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { - let groesse = 0 - for (const datei of dateien) { - try { - groesse += statSync(datei).size - } catch { - // Anzeige-Komfort — siehe oben. - } - } - this.umgebung.bibliothekEintragen({ - fingerprint, - titel: ordnerName, - jahr: null, - typ: 'cd', - groesseBytes: groesse, - ablageort: ziel, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: '', - }) - } - fertigMachen({ - id, - phase: 'fertig', - prozent: 100, - text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''), - dateien, - lesefehler: false, - }) - } - - // ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ───────────────── - private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise { - const ziel = join(this.umgebung.ablage(), 'ISO') - mkdirSync(ziel, { recursive: true }) - const zielDatei = join(ziel, `${vorgangsName}.iso`) - const ergebnis = await isoSichern( - geraet.pfad, - zielDatei, - geraet.groesseBytes, - (prozent) => { - const stand = this.laufend.get(id)?.status - if (stand !== undefined) this.melden({ ...stand, prozent }) - }, - abbruch.signal, - ) - this.laufend.delete(id) - if (ergebnis.status === 'success') { - const fingerprint = this.umgebung.fingerprint?.(id) ?? null - if (fingerprint !== null) { - this.umgebung.bibliothekEintragen?.({ - fingerprint, - titel: vorgangsName, - jahr: null, - typ: 'iso', - groesseBytes: ergebnis.bytes, - ablageort: zielDatei, - datum: startZeit.toISOString(), - dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), - posterPfad: '', - }) - } - this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false }) - } else if (ergebnis.status === 'cancelled') { - this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) - } else { - this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false }) - } - } -} - -function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { - return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` -} - -function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { - return new Promise((fertig) => { - const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) - const stuecke: Buffer[] = [] - kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) - kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) - kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) - }) -} - -// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon -// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken. -export function kopierenStattEncode(eingabe: string, ausgabe: string): void { - copyFileSync(eingabe, ausgabe) -} +// Die Verkettung — NUR HIER (KONZEPT § 4.2): Rip → Kompression → Ablage. +// EIN Vorgang je Laufwerk, Status-Meldungen nach draußen, Abbruch über +// AbortController. Alle Werkzeuge laufen als Kinder dieses Kerns und +// hängen damit an der Prozess-Leine (R6). +// +// Bis W-4 (Metadaten-Automatik) heißt der fertige Film wie sein +// Roh-Ordner; die echten Titel ersetzen das dann. Roh-Dateien werden in +// dieser Etappe GRUNDSÄTZLICH BEHALTEN — Löschen bekommt erst mit der +// Einstellung im UI (W-5) eine Stimme. +import { copyFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +// Plattform-Pfade wie in struktur.ts: Laufzeit-Wurzeln, CI testet auf Linux. +import { basename, join } from 'node:path' +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 { 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' +import { abfragen, albumOrdner, kennungAusSpuren, tagsJeSpur } from '../metadaten/musicbrainz' +import { dateiname, flacBefehl, sauberName, wavKopf, SEKTOR_BYTES, type Spur } from '../rip/audiocd' +import { isoSichern } from '../rip/iso' +import { rippen } from '../rip/makemkv' +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, createWriteStream } from 'node:fs' +import { 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 +} + +export interface PipelineUmgebung { + /** Pfad zu makemkvcon — oder '' wenn nicht gefunden. */ + makemkvcon: () => string + /** Pfad zu HandBrakeCLI — oder '' (dann bleibt es beim Roh-Rip). */ + handbrake: () => string + /** Pfad zu flac — oder '' (dann scheitert nur der Audio-CD-Weg, laut). */ + flac: () => string + /** Audio-TOC des Laufwerks (W-7); wirft bei Gerätefehlern. */ + audioToc?: (id: string) => Spur[] + /** Rohe Audio-Sektoren lesen (W-7). */ + audioSpurLesen?: ( + id: string, + start: number, + sektoren: number, + schreiben: (daten: Buffer) => void, + fortschritt?: (anteil: number) => void, + ) => number + /** Aktueller Stand des Laufwerks (für Typ und Vorab-Blick). */ + geraet: (id: string) => GeraeteInfo | null + /** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */ + ablage: () => string + einstellungen: () => Einstellungen + /** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ — + * greift nur, wenn der Nutzer nichts eingestellt hat. */ + presetEmpfehlung?: (discTyp: string) => string + melden: (status: RipStatus) => void + /** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */ + zuordnung?: (id: string) => Zuordnung | null + /** Disc-Fingerabdruck fürs Wiedererkennen (§ 6.6) — oder null. */ + fingerprint?: (id: string) => string | null + /** 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> + /** Trägt einen fertigen Vorgang in die Bibliothek ein (§ 6.6). */ + bibliothekEintragen?: (eintrag: BibliothekEintrag) => void + /** Zeitstempel für Ordnernamen — injizierbar für Tests. */ + jetzt?: () => Date +} + +export class Pipeline { + private laufend = new Map() + + constructor(private readonly umgebung: PipelineUmgebung) {} + + laeuft(id: string): boolean { + return this.laufend.has(id) + } + + private melden(status: RipStatus): void { + const eintrag = this.laufend.get(status.id) + if (eintrag !== undefined) eintrag.status = status + this.umgebung.melden(status) + } + + private fehler(id: string, text: string): RipStatus { + return { id, phase: 'fehler', prozent: -1, text, dateien: [], lesefehler: false } + } + + /** Bricht nur den GERADE LAUFENDEN Titel ab und macht mit dem nächsten + * weiter — der Ausweg bei einem Disc-Defekt (Commander 01.09.2026: + * MakeMKV hing eine halbe Stunde an derselben Stelle in Folge 1, + * während Folge 2 heil daneben lag). */ + ueberspringen(id: string): void { + const vorgang = this.laufend.get(id) + if (vorgang === undefined || vorgang.titelAbbruch === undefined) return + vorgang.uebersprungen = true + vorgang.titelAbbruch.abort() + } + + /** Startet die Kette. `auftrag` leer heißt: alles rippen (wie früher). */ + async starten(id: string, auftrag: readonly RipAuftragTitel[] = []): Promise { + if (this.laufend.has(id)) { + this.umgebung.melden(this.fehler(id, `Auf Laufwerk ${id}: läuft schon ein Vorgang.`)) + return + } + + const programm = this.umgebung.makemkvcon() + if (programm.length === 0) { + this.umgebung.melden( + this.fehler( + id, + 'makemkvcon wurde nicht gefunden — ist MakeMKV installiert? (Suchwege: Einstellung, Rippy-Werkzeuge, PATH, Programme, Registry)', + ), + ) + return + } + + const geraet = this.umgebung.geraet(id) + if (geraet === null) { + this.umgebung.melden(this.fehler(id, `Laufwerk ${id}: gibt es nicht.`)) + return + } + // Erst nachsehen, dann rippen (§ 6.2): Ein leeres Laufwerk bekommt + // einen Satz statt zwei Minuten makemkvcon mit Code 11. Aber: Ist der + // Zustand UNBEKANNT, wird trotzdem gerippt — „ich weiß es nicht" darf + // nie zu „es geht nicht" werden. + if (geraet.status === 'empty') { + this.umgebung.melden( + this.fehler( + id, + `Kein Datenträger in Laufwerk ${id}. Nach einem fertigen Rip wirft Rippy die Disc aus — für einen neuen Rip muss sie wieder hinein.`, + ), + ) + return + } + + const abbruch = new AbortController() + const startZeit = this.umgebung.jetzt?.() ?? new Date() + const zeit = startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19) + const vorgangsName = `${geraet.typ}-${zeit}` + + // § 5 Schritt 2: Musik und Daten gehen eigene Wege (W-7). + if (geraet.typ === 'cd') { + this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Lese Audio-CD …', dateien: [], lesefehler: false } }) + this.melden(this.laufend.get(id)!.status) + await this.audioCdRippen(id, geraet, startZeit, abbruch) + return + } + if (geraet.typ === 'unknown') { + this.laufend.set(id, { abbruch, status: { id, phase: 'rippt', prozent: -1, text: 'Sichere 1:1-Abbild …', dateien: [], lesefehler: false } }) + this.melden(this.laufend.get(id)!.status) + await this.isoSichern(id, geraet, vorgangsName, startZeit, abbruch) + return + } + + const rohOrdner = join(this.umgebung.ablage(), 'roh', vorgangsName) + mkdirSync(rohOrdner, { recursive: true }) + + // 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() + const dauerJeDatei = new Map() + const alleDateien = new Set() + let lesefehlerGesamt = false + const gescheitert: string[] = [] + const uebersprungen: string[] = [] + + this.laufend.set(id, { + abbruch, + status: { id, phase: 'rippt', prozent: -1, text: 'Beginne …', dateien: [], lesefehler: false }, + }) + + 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`) + } + } + + 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: gescheitert.length > 0 ? gescheitert.join(' · ') : 'Kein Titel konnte gesichert werden.', + dateien: [], + lesefehler: lesefehlerGesamt, + }) + return + } + + 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() + const handbrake = this.umgebung.handbrake() + const soll = komprimierenFuer(geraet.typ, einstellungen) + + if (!soll || handbrake.length === 0) { + const grund = !soll + ? `Kompression für ${geraet.typ} ist abgewählt — verlustfreier Rip bleibt stehen.` + : 'HandBrakeCLI nicht gefunden — verlustfreier Rip bleibt stehen.' + this.laufend.delete(id) + this.melden({ + id, + phase: 'fertig', + prozent: 100, + text: `Fertig. ${grund}${lesefehlerHinweis}`, + dateien: rip.dateien, + lesefehler: rip.lesefehler, + }) + return + } + + const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '') + // Mit Metadaten (W-4) heißt der Ordner „ (Jahr)" — daran + // erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname. + const zuordnung = this.umgebung.zuordnung?.(id) ?? null + + // ── 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(zielFuer(eingabe), basename(eingabe)) + const alt = this.laufend.get(id)?.status + if (alt !== undefined) { + this.melden({ + ...alt, + phase: 'komprimiert', + prozent: -1, + text: `Komprimiere ${i + 1}/${rip.dateien.length}: ${basename(eingabe)} („${preset}") …`, + }) + } + const encode = await komprimieren({ + programm: handbrake, + eingabe, + ausgabe, + preset, + audioSprachen: sprachliste(String(einstellungen['audioSprachen'] ?? '')), + untertitelSprachen: sprachliste(String(einstellungen['untertitelSprachen'] ?? '')), + abbruch: abbruch.signal, + aufFortschritt: (prozent) => { + const stand = this.laufend.get(id)?.status + if (stand !== undefined) { + this.melden({ ...stand, prozent: Math.floor((i * 100 + prozent) / rip.dateien.length) }) + } + }, + }) + if (encode.status === 'cancelled') { + this.laufend.delete(id) + this.melden({ + id, + phase: 'abgebrochen', + prozent: -1, + text: 'Abgebrochen — der verlustfreie Rip bleibt stehen.', + dateien: rip.dateien, + lesefehler: rip.lesefehler, + }) + return + } + if (encode.status === 'error') { + this.laufend.delete(id) + this.melden({ + id, + phase: 'fehler', + prozent: -1, + text: `Kompression fehlgeschlagen: ${encode.fehler} — der verlustfreie Rip bleibt stehen (${rohOrdner}).`, + dateien: rip.dateien, + lesefehler: rip.lesefehler, + }) + return + } + fertigeDateien.push(encode.ausgabe) + 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 ─────────── + const alt = this.laufend.get(id)?.status + if (alt !== undefined) { + this.melden({ ...alt, phase: 'legt-ab', prozent: 100, text: 'Lege ab …', dateien: fertigeDateien }) + } + try { + const nfoName = zuordnung?.meta.typ === 'tv' ? 'tvshow.nfo' : 'movie.nfo' + writeFileSync( + join(fertigOrdner, nfoName), + baueNfo( + { + typ: zuordnung?.meta.typ === 'tv' ? 'serie' : 'film', + beschreibung: zuordnung?.meta.beschreibung, + genres: zuordnung?.meta.genres, + laufzeitMinuten: zuordnung?.meta.laufzeitMinuten, + quelle: zuordnung !== null && zuordnung.meta.quelle.length > 0 ? `Rippy v5 (${zuordnung.meta.quelle})` : 'Rippy v5', + }, + zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, + zuordnung?.jahr ?? null, + ), + ) + } catch (fehler) { + // Ein fehlendes NFO darf einen gelungenen Rip nicht kippen — aber es + // wird GESAGT (R4). + const stand = this.laufend.get(id)?.status + if (stand !== undefined) this.melden({ ...stand, text: `NFO fehlgeschlagen: ${String(fehler)}` }) + } + if (zuordnung !== null && zuordnung.meta.posterPfad.length > 0) { + await posterSpeichern(zuordnung.meta.posterPfad, join(fertigOrdner, 'poster.jpg')) + } + + const server = String(einstellungen['mediaServer'] ?? '') + const serverUrl = String(einstellungen['mediaServerUrl'] ?? '') + const serverKey = String(einstellungen['mediaServerApiKey'] ?? '') + const refresh = await bibliothekRefresh(server, serverUrl, serverKey) + + // § 6.6: Merken — beim nächsten Einlegen wird die Disc wiedererkannt. + const fingerprint = this.umgebung.fingerprint?.(id) ?? null + if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { + let groesse = 0 + for (const datei of fertigeDateien) { + try { + groesse += statSync(datei).size + } catch { + // Größe ist Anzeige-Komfort — eine unlesbare Datei ändert nichts + // am Eintrag selbst. + } + } + this.umgebung.bibliothekEintragen({ + fingerprint, + titel: zuordnung !== null && zuordnung.titel.length > 0 ? zuordnung.titel : vorgangsName, + jahr: zuordnung?.jahr ?? null, + typ: geraet.typ, + groesseBytes: groesse, + ablageort: fertigOrdner, + datum: startZeit.toISOString(), + dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), + posterPfad: zuordnung?.meta.posterPfad ?? '', + }) + } + + this.laufend.delete(id) + this.melden({ + id, + phase: 'fertig', + prozent: 100, + text: + `Fertig — komprimiert nach ${fertigOrdner}.` + + (refresh.length > 0 ? ` ${refresh}` : '') + + lesefehlerHinweis, + dateien: fertigeDateien, + lesefehler: rip.lesefehler, + }) + } + + abbrechen(id: string): void { + this.laufend.get(id)?.abbruch.abort() + } + + // ── Audio-CD (W-7, § 6.3): Sektoren → WAV → FLAC, Tags von MusicBrainz ── + private async audioCdRippen(id: string, geraet: GeraeteInfo, startZeit: Date, abbruch: AbortController): Promise { + const fertigMachen = (status: RipStatus): void => { + this.laufend.delete(id) + this.melden(status) + } + const flac = this.umgebung.flac() + if (flac.length === 0) { + fertigMachen(this.fehler(id, 'Der FLAC-Encoder fehlt — er gehört zu Rippy (Einstellungen → Werkzeuge zeigt den Stand).')) + return + } + if (this.umgebung.audioToc === undefined || this.umgebung.audioSpurLesen === undefined) { + fertigMachen(this.fehler(id, 'Audio-CD-Lesen ist auf diesem System nicht verfügbar.')) + return + } + let spuren: Spur[] + try { + spuren = this.umgebung.audioToc(id).filter((s) => s.audio) + } catch (fehler) { + fertigMachen(this.fehler(id, `Das Inhaltsverzeichnis der CD ließ sich nicht lesen: ${String(fehler)}`)) + return + } + if (spuren.length === 0) { + fertigMachen(this.fehler(id, 'Keine Audio-Spuren gefunden. Ist das wirklich eine Audio-CD?')) + return + } + + // MusicBrainz zuerst — daraus kommen Ordnername und Titel. Ohne + // Treffer heißen die Dateien „Track 01" — das ist keine Ausrede, + // sondern die Wahrheit über eine Disc, die niemand kennt. + const kennung = kennungAusSpuren(spuren) + const antwort = await abfragen(kennung) + const tags = tagsJeSpur(antwort) + const ordnerName = albumOrdner(antwort, sauberName) || vorgangsNameFuer(geraet, startZeit) + const ziel = join(this.umgebung.ablage(), 'Musik', ordnerName) + mkdirSync(ziel, { recursive: true }) + + const dateien: string[] = [] + for (let i = 0; i < spuren.length; i++) { + if (abbruch.signal.aborted) { + fertigMachen({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien, lesefehler: false }) + return + } + const spur = spuren[i] + const spurTags = tags.get(spur.nr) ?? {} + const name = dateiname(spur.nr, spurTags['TITLE'] ?? '') + const zielDatei = join(ziel, `${name}.flac`) + const stand = this.laufend.get(id)?.status + if (stand !== undefined) { + this.melden({ ...stand, text: `Spur ${i + 1} von ${spuren.length} (${spur.dauer}) …`, prozent: Math.floor((i * 100) / spuren.length) }) + } + + // Erst nach WAV, dann kodieren — ein abgebrochener Lesevorgang + // hinterließe sonst eine halbe FLAC-Datei, die wie eine ganze aussieht. + const arbeit = mkdtempSync(join(tmpdir(), 'rippy-cd-')) + try { + const wav = join(arbeit, 'spur.wav') + const strom = createWriteStream(wav) + strom.write(wavKopf(spur.sektoren * SEKTOR_BYTES)) + this.umgebung.audioSpurLesen(id, spur.start, spur.sektoren, (daten) => strom.write(daten), (anteil) => { + const s = this.laufend.get(id)?.status + if (s !== undefined) this.melden({ ...s, prozent: Math.floor(((i + anteil) * 100) / spuren.length) }) + }) + await new Promise((fertig, kaputt) => { + strom.end(() => fertig()) + strom.on('error', kaputt) + }) + const flacErgebnis = await lauf(flac, flacBefehl(wav, zielDatei, spurTags)) + if (flacErgebnis.code !== 0) { + fertigMachen(this.fehler(id, `FLAC brach bei Spur ${spur.nr} ab: ${flacErgebnis.fehler.slice(0, 200)}`)) + return + } + } catch (fehler) { + fertigMachen(this.fehler(id, `Spur ${spur.nr} ließ sich nicht lesen: ${String(fehler)}`)) + return + } finally { + rmSync(arbeit, { recursive: true, force: true }) + } + dateien.push(zielDatei) + } + + const fingerprint = kennung.length > 0 ? `mb:${kennung}` : (this.umgebung.fingerprint?.(id) ?? null) + if (fingerprint !== null && this.umgebung.bibliothekEintragen !== undefined) { + let groesse = 0 + for (const datei of dateien) { + try { + groesse += statSync(datei).size + } catch { + // Anzeige-Komfort — siehe oben. + } + } + this.umgebung.bibliothekEintragen({ + fingerprint, + titel: ordnerName, + jahr: null, + typ: 'cd', + groesseBytes: groesse, + ablageort: ziel, + datum: startZeit.toISOString(), + dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), + posterPfad: '', + }) + } + fertigMachen({ + id, + phase: 'fertig', + prozent: 100, + text: `Fertig — ${dateien.length} Spuren als FLAC in ${ziel}.` + (tags.size === 0 ? ' (MusicBrainz kannte die Disc nicht — Dateien heißen „Track NN".)' : ''), + dateien, + lesefehler: false, + }) + } + + // ── Datendisc (W-7, § 6.4): 1:1-Abbild statt Ablehnung ───────────────── + private async isoSichern(id: string, geraet: GeraeteInfo, vorgangsName: string, startZeit: Date, abbruch: AbortController): Promise { + const ziel = join(this.umgebung.ablage(), 'ISO') + mkdirSync(ziel, { recursive: true }) + const zielDatei = join(ziel, `${vorgangsName}.iso`) + const ergebnis = await isoSichern( + geraet.pfad, + zielDatei, + geraet.groesseBytes, + (prozent) => { + const stand = this.laufend.get(id)?.status + if (stand !== undefined) this.melden({ ...stand, prozent }) + }, + abbruch.signal, + ) + this.laufend.delete(id) + if (ergebnis.status === 'success') { + const fingerprint = this.umgebung.fingerprint?.(id) ?? null + if (fingerprint !== null) { + this.umgebung.bibliothekEintragen?.({ + fingerprint, + titel: vorgangsName, + jahr: null, + typ: 'iso', + groesseBytes: ergebnis.bytes, + ablageort: zielDatei, + datum: startZeit.toISOString(), + dauerS: Math.round(((this.umgebung.jetzt?.() ?? new Date()).getTime() - startZeit.getTime()) / 1000), + posterPfad: '', + }) + } + this.melden({ id, phase: 'fertig', prozent: 100, text: `Fertig — 1:1-Abbild unter ${zielDatei}.`, dateien: [zielDatei], lesefehler: false }) + } else if (ergebnis.status === 'cancelled') { + this.melden({ id, phase: 'abgebrochen', prozent: -1, text: 'Abgebrochen.', dateien: [], lesefehler: false }) + } else { + this.melden({ id, phase: 'fehler', prozent: -1, text: ergebnis.fehler, dateien: [], lesefehler: false }) + } + } +} + +function vorgangsNameFuer(geraet: GeraeteInfo, startZeit: Date): string { + return `${geraet.typ}-${startZeit.toISOString().replaceAll(/[:.]/g, '-').slice(0, 19)}` +} + +function lauf(programm: string, args: string[]): Promise<{ code: number | null; fehler: string }> { + return new Promise((fertig) => { + const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] }) + const stuecke: Buffer[] = [] + kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) + kind.on('error', (fehler) => fertig({ code: null, fehler: String(fehler) })) + kind.on('close', (code) => fertig({ code, fehler: Buffer.concat(stuecke).toString('utf8') })) + }) +} + +// Für einen späteren Wiederanlauf „ab Kompression" (W-5/W-7): eine schon +// gerippte Datei ohne Laufwerk durch Kompression + Ablage schicken. +export function kopierenStattEncode(eingabe: string, ausgabe: string): void { + copyFileSync(eingabe, ausgabe) +} diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index 8bc37d8..b3d5402 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -1,700 +1,715 @@ -// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1). -// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1: -// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5), -// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort. -// -// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess« -// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen -// Beweis beweise/sqlite-main.js. -import type { MessagePortMain } from 'electron' -import { join } from 'node:path' -import { - istFensterNachricht, - istHauptNachricht, - type DbStatus, - type KernNachricht, - type MetaVorschlag, -} from '../gemeinsam/nachrichten' -import { Pipeline } from './ablauf/pipeline' -import type { LaufwerkApi } from './laufwerk/codes' -import { - audioSpurLesen, - audioToc, - auswerfenMitGrund, - geraeteInfo, - kennung, - listOptischeLaufwerke, -} from './laufwerk/disc' -import { DiscWache } from './laufwerk/wache' -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 } from './rip/makemkv' -import { titelVorauswahl } from './rip/titelwahl' -import { finden } from './werkzeuge/katalog' - -// --datenbank= kommt vom Haupt (der Kern kennt Electrons app.getPath -// nicht — er ist ein reiner Node-Prozess). -function datenbankPfadAusArgv(argv: readonly string[]): string | null { - for (const arg of argv) { - if (arg.startsWith('--datenbank=')) { - const pfad = arg.slice('--datenbank='.length) - if (pfad.length > 0) return pfad - } - } - return null -} - -let db: Datenbank | null = null -let dbStatus: DbStatus - -const pfad = datenbankPfadAusArgv(process.argv) -if (pfad === null) { - dbStatus = { ok: false, fehler: 'Kein --datenbank= übergeben' } -} else { - try { - db = new Datenbank(pfad) - // Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis. - db.einstellungSchreiben('letzter_start', new Date().toISOString()) - if (db.einstellungLesen('letzter_start') === null) { - dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' } - } else { - dbStatus = { ok: true, pfad } - } - } catch (fehler) { - // R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt. - // Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu - // »es geht nicht« werden (§ 6.2, sinngemäß). - dbStatus = { ok: false, fehler: String(fehler) } - } -} - -let fensterPort: MessagePortMain | null = null - -function anFenster(nachricht: KernNachricht): void { - fensterPort?.postMessage(nachricht) -} - -function anHauptUndFenster(nachricht: KernNachricht): void { - process.parentPort.postMessage(nachricht) - anFenster(nachricht) -} - -// ── Laufwerke (W-1) ───────────────────────────────────────────────────── -// koffi wird erst HIER geladen, über einen echten dynamischen import(): -// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein -// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am -// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform, -// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4). -let laufwerkApi: LaufwerkApi | null = null -let laufwerkFehler: string | null = null -let wache: DiscWache | null = null - -// ── Metadaten (W-4): Was liegt da drin? ───────────────────────────────── -const discZuordnungen = new Map() -const discFingerprints = new Map() -// 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() - -async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise { - 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') ?? '') - // 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', - info: { - id, - titel: zuordnung.titel, - jahr: zuordnung.jahr, - confidence: zuordnung.confidence, - quelle: zuordnung.meta.quelle, - posterPfad: zuordnung.meta.posterPfad, - backdropPfad: zuordnung.meta.backdropPfad, - beschreibung: zuordnung.meta.beschreibung, - // § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal - // durchgelaufen ist — und fragt nach, statt stumm zu doppeln. - schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null, - discNr: gefunden.discNr, - staffel: gefunden.staffel, - folgen: gefunden.folgen, - }, - }) - } catch (fehler) { - // R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt - // werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter). - console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`) - anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` }) - } -} - -// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst. -function jahrAus(datum: string | undefined): number | null { - if (datum === undefined || datum.length < 4) return null - const jahr = Number(datum.slice(0, 4)) - return Number.isFinite(jahr) && jahr > 0 ? jahr : null -} - -async function metadatenSuchen(id: string, text: string): Promise { - try { - const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') - if (!tmdb.verfuegbar) { - anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' }) - anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] }) - return - } - const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)]) - const vorschlaege: MetaVorschlag[] = [ - ...(filme ?? []).slice(0, 6).map((f) => ({ - typ: 'movie' as const, - tmdbId: f.id, - titel: f.title ?? '', - jahr: jahrAus(f.release_date), - posterPfad: f.poster_path ?? '', - })), - ...(serien ?? []).slice(0, 6).map((s) => ({ - typ: 'tv' as const, - tmdbId: s.id, - titel: s.name ?? '', - jahr: jahrAus(s.first_air_date), - posterPfad: s.poster_path ?? '', - })), - ].filter((v) => v.titel.length > 0) - anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege }) - } catch (fehler) { - anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` }) - anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] }) - } -} - -async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise { - try { - const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') - const details = - vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId) - const zuordnung: Zuordnung = { - titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel, - jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr, - // Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5). - confidence: 1, - meta: { - typ: vorschlag.typ, - id: vorschlag.tmdbId, - titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel, - jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr, - beschreibung: details?.overview ?? '', - posterPfad: details?.poster_path ?? vorschlag.posterPfad, - backdropPfad: details?.backdrop_path ?? '', - laufzeitMinuten: details?.runtime ?? 0, - genres: (details?.genres ?? []).map((g) => g.name), - quelle: 'tmdb', - }, - } - discZuordnungen.set(id, zuordnung) - anHauptUndFenster({ - art: 'disc-info', - info: { - id, - titel: zuordnung.titel, - jahr: zuordnung.jahr, - confidence: 1, - quelle: 'deine Wahl', - posterPfad: zuordnung.meta.posterPfad, - backdropPfad: zuordnung.meta.backdropPfad, - beschreibung: zuordnung.meta.beschreibung, - schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null, - // 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) { - anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` }) - } -} - -const laufwerkeBereit: Promise = (async () => { - try { - if (process.platform !== 'win32') { - throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).') - } - const { win32Api } = await import('./laufwerk/win32') - laufwerkApi = win32Api() - wache = new DiscWache( - () => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)), - (meldung) => { - anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse }) - for (const ereignis of meldung.ereignisse) { - if (ereignis.art === 'disc-eingelegt') { - void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes) - } 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: { - id: ereignis.geraet.id, - titel: '', - jahr: null, - confidence: 0, - quelle: '', - posterPfad: '', - backdropPfad: '', - beschreibung: '', - schonGerippt: null, - discNr: null, - staffel: null, - folgen: 0, - }, - }) - } - } - }, - (fehler) => { - // R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte - // Stand im Fenster bleibt stehen (R2), die Wache läuft weiter. - console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`) - anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` }) - }, - ) - wache.starten() - } catch (fehler) { - laufwerkFehler = String(fehler) - console.error(`[kern] Laufwerke: ${laufwerkFehler}`) - } -})() - -// ── 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 20–120 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). -async function titelLesen(id: string): Promise { - 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 - } - anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } }) - try { - const info = await titelInfoLesen(programm, geraetepfad) - const merkmale = discMerkmale.get(id) - const wahl = titelVorauswahl(info.titel, { - istSerie: merkmale?.istSerie ?? false, - folgen: merkmale?.folgen ?? 0, - }) - anFenster({ - art: 'disc-titel', - stand: { id, laeuft: false, titel: wahl.titel, einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: '' }, - }) - } catch (fehler) { - // R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als - // „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn. - anFenster({ - art: 'disc-titel', - stand: { ...leer, laeuft: false, fehler: `Titel lesen: ${String(fehler).slice(0, 200)}` }, - }) - } -} - -function ablageOrdner(): string { - const eingestellt = db?.einstellungLesen('ablage') - if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt - return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy') -} - -// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN -// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults. -const PIPELINE_EINSTELLUNGEN = [ - 'transcodeEnabled', - 'transcodePreset', - 'transcodePresetDvd', - 'transcodePresetBluray', - 'transcodePresetUhd', - 'audioSprachen', - 'untertitelSprachen', - 'mediaServer', - 'mediaServerUrl', - 'mediaServerApiKey', -] as const - -function einstellungenLaden(): Record { - const werte: Record = {} - for (const schluessel of PIPELINE_EINSTELLUNGEN) { - const wert = db?.einstellungLesen(schluessel) - if (wert !== null && wert !== undefined) werte[schluessel] = wert - } - return werte -} - -function geraetVon(id: string): string | null { - if (laufwerkApi === null) return null - return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null -} - -const ripVerwaltung = new Pipeline({ - makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), - handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), - flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''), - audioToc: (id) => { - const geraet = geraetVon(id) - if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) - return audioToc(laufwerkApi, geraet) - }, - audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => { - const geraet = geraetVon(id) - if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) - return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt) - }, - geraet: (id) => { - if (laufwerkApi === null) return null - const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) - return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet) - }, - ablage: ablageOrdner, - einstellungen: einstellungenLaden, - presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets), - melden: (status) => anHauptUndFenster({ art: 'rip-status', status }), - zuordnung: (id) => discZuordnungen.get(id) ?? null, - fingerprint: (id) => discFingerprints.get(id) ?? null, - bibliothekEintragen: (eintrag) => { - try { - db?.bibliothekEintragen(eintrag) - anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) - } catch (fehler) { - // R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber - // gesagt. - anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` }) - } - }, -}) - -// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den -// Kern; erlaubt sind genau diese Schlüssel ─────────────────────────── -const ERLAUBTE_EINSTELLUNGEN = new Set([ - 'ablage', - 'transcodeEnabled', - 'transcodePreset', - 'transcodePresetDvd', - 'transcodePresetBluray', - 'transcodePresetUhd', - 'audioSprachen', - 'untertitelSprachen', - 'mediaServer', - 'mediaServerUrl', - 'mediaServerApiKey', - 'tmdbApiKey', - 'omdbApiKey', - 'makemkvAppKey', - 'updateUrl', - 'setup.done', - 'autoAuswurf', - 'werkzeug.makemkv', - 'werkzeug.handbrake', - 'werkzeug.flac', -]) - -function einstellungenSenden(): void { - const werte: Record = { ablage: ablageOrdner() } - for (const schluessel of ERLAUBTE_EINSTELLUNGEN) { - const wert = db?.einstellungLesen(schluessel) - if (wert !== null && wert !== undefined) werte[schluessel] = wert - } - // AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine - // Einstellungs-Quelle bleibt trotzdem der Kern. - anHauptUndFenster({ art: 'einstellungen', werte }) -} - -// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ────────────── -let schluesselStand: Extract['stand'] | null = null - -async function schluesselLauf(): Promise { - if (process.platform !== 'win32') return - try { - const { schluesselKette } = await import('./werkzeuge/schluessel') - const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') - schluesselStand = await schluesselKette( - makemkvcon, - db?.einstellungLesen('makemkvAppKey') ?? '', - new Date().toISOString(), - ) - anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand }) - } catch (fehler) { - // R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt. - console.error(`[kern] Schlüsselkette: ${String(fehler)}`) - anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` }) - } -} -// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten. -setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref() - -// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ──────────── -// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt -// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der -// Prozess-Leine und überlebt ein geschlossenes Rippy). -let beschaffungLaeuft = false -let installerAntwort: ((fehler: string | null) => void) | null = null - -function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void { - anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } }) -} - -async function makemkvUpdateLauf(): Promise { - if (beschaffungLaeuft) { - beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true) - return - } - beschaffungLaeuft = true - try { - const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import( - './werkzeuge/beschaffen' - ) - beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true) - const neueste = await makemkvNeuesteVersion() - if (neueste.length === 0) { - beschaffungMelden( - 'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' + - 'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.', - null, - false, - true, - ) - beschaffungLaeuft = false - return - } - const installiert = schluesselStand?.version ?? '' - if (installiert.length > 0 && !istNeuer(neueste, installiert)) { - // Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND - // die neueste angekündigte — die Beta ist nur abgelaufen): Ein - // Download derselben Fassung würde nichts lösen. - beschaffungMelden( - `MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` + - 'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' + - 'bis dahin rippt der Beta-Modus weiter.', - null, - false, - ) - beschaffungLaeuft = false - return - } - // Merken, woran eine gelungene Installation zu erkennen ist: Die - // Versions-Ressource der gefundenen EXE ändert sich (oder es gibt - // erstmals eine). - const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') - const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : '' - - const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => - beschaffungMelden(text, anteil, true), - ) - beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true) - - const startFehler = await new Promise((fertig) => { - installerAntwort = fertig - process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad }) - // Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen. - setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref() - }) - installerAntwort = null - if (startFehler !== null && startFehler.length > 0) { - beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true) - beschaffungLaeuft = false - return - } - beschaffungMelden( - `Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` + - 'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.', - null, - true, - ) - // Warten, bis die Installation durch ist: Die Versions-Ressource der - // EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten. - let runden = 0 - const zeiger = setInterval(() => { - runden += 1 - const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') - const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : '' - if (jetztVersion.length > 0 && jetztVersion !== alteVersion) { - clearInterval(zeiger) - beschaffungLaeuft = false - beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false) - void schluesselLauf().then(() => werkzeugeSenden()) - } else if (runden >= 80) { - clearInterval(zeiger) - beschaffungLaeuft = false - beschaffungMelden( - 'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".', - null, - false, - ) - } - }, 15_000) - zeiger.unref() - } catch (fehler) { - beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true) - beschaffungLaeuft = false - } -} - -// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die -// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. -let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] } - -async function werkzeugeSenden(): Promise { - const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') - const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '') - const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '') - const { encoderMessen } = await import('./komprimieren/encoder') - const gemessen = await encoderMessen(handbrake) - werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends } - anFenster({ - art: 'werkzeuge', - auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends }, - }) -} - -async function auswerfen(id: string): Promise { - await laufwerkeBereit - if (ripVerwaltung.laeuft(id)) { - anFenster({ - art: 'auswurf-ergebnis', - id, - grund: 'abgelehnt', - fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`, - }) - return - } - if (laufwerkApi === null) { - anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler }) - return - } - const geraete = listOptischeLaufwerke(laufwerkApi) - const geraet = geraete.find((g) => kennung(g) === id) - if (geraet === undefined) { - anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` }) - return - } - const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet) - anFenster({ art: 'auswurf-ergebnis', id, grund, fehler }) - // Die Wache merkt den neuen Zustand spätestens in der nächsten Runde; - // eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch. - wache?.runde() -} - -process.parentPort.on('message', (ereignis) => { - const nachricht: unknown = ereignis.data - if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') { - installerAntwort?.(nachricht.fehler ?? '') - installerAntwort = null - return - } - if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') { - // Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues - // Fenster bringt einen neuen Port — der alte wird geschlossen. - fensterPort?.close() - fensterPort = ereignis.ports[0] ?? null - if (fensterPort === null) return - fensterPort.on('message', (portEreignis) => { - const frage: unknown = portEreignis.data - if (!istFensterNachricht(frage)) 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, frage.titel ?? [])) - } 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') { - void zuordnungSetzen(frage.id, frage.vorschlag) - } else if (frage.art === 'einstellung-setzen') { - if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) { - anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` }) - } else if (db === null) { - anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' }) - } else { - db.einstellungSchreiben(frage.schluessel, frage.wert) - einstellungenSenden() - } - } else if (frage.art === 'einstellungen-laden') { - einstellungenSenden() - } else if (frage.art === 'werkzeuge-laden') { - void werkzeugeSenden() - } else if (frage.art === 'bibliothek-laden') { - anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) - } else if (frage.art === 'makemkv-update') { - void makemkvUpdateLauf() - } else if (frage.art === 'schluessel-pruefen') { - void schluesselLauf().then(() => werkzeugeSenden()) - } - }) - fensterPort.start() - // Damit das Fenster nach dem Verbinden sofort den Stand kennt: - anFenster(bereitNachricht()) - void laufwerkeBereit.then(() => { - if (laufwerkFehler !== null) { - anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` }) - } - anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] }) - if (schluesselStand !== null) { - anFenster({ art: 'schluessel-status', stand: schluesselStand }) - } - }) - } -}) - -function bereitNachricht(): KernNachricht { - return { - art: 'kern-bereit', - pid: process.pid, - nodeVersion: process.versions.node, - datenbank: dbStatus, - } -} - -process.parentPort.postMessage(bereitNachricht()) -// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster. -einstellungenSenden() -// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an — -// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein. -if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { - void schluesselLauf() - // Werkzeuge einmal beim Start messen — die Preset-Empfehlung der - // Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster. - void werkzeugeSenden() -} +// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1). +// Hier passiert die ganze Arbeit; das Fenster zeigt nur an. Stand W-1: +// Datenbank (node:sqlite, § 3.4), Disc-Wache im 3-Sekunden-Takt (§ 5), +// Auswerfen mit Nachsehen (R3), Ping/Pong über den direkten MessagePort. +// +// parentPort/MessagePortMain: Schnittstelle laut Electron-Doku »utilityProcess« +// und »MessagePorts in Electron« — dieselbe Verdrahtung wie im gemessenen +// Beweis beweise/sqlite-main.js. +import type { MessagePortMain } from 'electron' +import { join } from 'node:path' +import { + istFensterNachricht, + istHauptNachricht, + type DbStatus, + type KernNachricht, + type MetaVorschlag, +} from '../gemeinsam/nachrichten' +import { Pipeline } from './ablauf/pipeline' +import type { LaufwerkApi } from './laufwerk/codes' +import { + audioSpurLesen, + audioToc, + auswerfenMitGrund, + geraeteInfo, + kennung, + listOptischeLaufwerke, +} from './laufwerk/disc' +import { DiscWache } from './laufwerk/wache' +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 } from './rip/makemkv' +import { titelVorauswahl } from './rip/titelwahl' +import { finden } from './werkzeuge/katalog' + +// --datenbank= kommt vom Haupt (der Kern kennt Electrons app.getPath +// nicht — er ist ein reiner Node-Prozess). +function datenbankPfadAusArgv(argv: readonly string[]): string | null { + for (const arg of argv) { + if (arg.startsWith('--datenbank=')) { + const pfad = arg.slice('--datenbank='.length) + if (pfad.length > 0) return pfad + } + } + return null +} + +let db: Datenbank | null = null +let dbStatus: DbStatus + +const pfad = datenbankPfadAusArgv(process.argv) +if (pfad === null) { + dbStatus = { ok: false, fehler: 'Kein --datenbank= übergeben' } +} else { + try { + db = new Datenbank(pfad) + // Selbsttest: Schreiben und Zurücklesen — »geöffnet« allein ist kein Beweis. + db.einstellungSchreiben('letzter_start', new Date().toISOString()) + if (db.einstellungLesen('letzter_start') === null) { + dbStatus = { ok: false, fehler: 'Selbsttest: Geschriebenes kam nicht zurück' } + } else { + dbStatus = { ok: true, pfad } + } + } catch (fehler) { + // R4: Der Fehler wird GEMELDET (im Fenster sichtbar), nicht verschluckt. + // Der Kern läuft ohne Datenbank weiter — »ich weiß es nicht« darf nie zu + // »es geht nicht« werden (§ 6.2, sinngemäß). + dbStatus = { ok: false, fehler: String(fehler) } + } +} + +let fensterPort: MessagePortMain | null = null + +function anFenster(nachricht: KernNachricht): void { + fensterPort?.postMessage(nachricht) +} + +function anHauptUndFenster(nachricht: KernNachricht): void { + process.parentPort.postMessage(nachricht) + anFenster(nachricht) +} + +// ── Laufwerke (W-1) ───────────────────────────────────────────────────── +// koffi wird erst HIER geladen, über einen echten dynamischen import(): +// Rollup legt win32.ts damit als eigenen Chunk neben kern.js (ein +// wörtliches require überlebte das Bündeln nicht — im Smoke-Lauf am +// 30.08.2026 sichtbar geworden). Scheitert das Laden (falsche Plattform, +// kaputtes Paket), läuft der Kern weiter und der Grund steht im Fenster (R4). +let laufwerkApi: LaufwerkApi | null = null +let laufwerkFehler: string | null = null +let wache: DiscWache | null = null + +// ── Metadaten (W-4): Was liegt da drin? ───────────────────────────────── +const discZuordnungen = new Map() +const discFingerprints = new Map() +// 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() + +async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise { + 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') ?? '') + // 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', + info: { + id, + titel: zuordnung.titel, + jahr: zuordnung.jahr, + confidence: zuordnung.confidence, + quelle: zuordnung.meta.quelle, + posterPfad: zuordnung.meta.posterPfad, + backdropPfad: zuordnung.meta.backdropPfad, + beschreibung: zuordnung.meta.beschreibung, + // § 6.6: Rippy erkennt beim Einlegen, wenn eine Disc schon einmal + // durchgelaufen ist — und fragt nach, statt stumm zu doppeln. + schonGerippt: db?.bibliothekSchonGerippt(fingerprint) ?? null, + discNr: gefunden.discNr, + staffel: gefunden.staffel, + folgen: gefunden.folgen, + }, + }) + } catch (fehler) { + // R4: Eine gescheiterte Erkennung ist eine laute Zeile — gerippt + // werden kann trotzdem (die Zuordnung ist Komfort, kein Torwächter). + console.error(`[kern] Disc-Erkennung ${id}: ${String(fehler)}`) + anFenster({ art: 'kern-fehler', text: `Disc-Erkennung ${id}: ${String(fehler)}` }) + } +} + +// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst. +function jahrAus(datum: string | undefined): number | null { + if (datum === undefined || datum.length < 4) return null + const jahr = Number(datum.slice(0, 4)) + return Number.isFinite(jahr) && jahr > 0 ? jahr : null +} + +async function metadatenSuchen(id: string, text: string): Promise { + try { + const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') + if (!tmdb.verfuegbar) { + anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' }) + anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] }) + return + } + const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)]) + const vorschlaege: MetaVorschlag[] = [ + ...(filme ?? []).slice(0, 6).map((f) => ({ + typ: 'movie' as const, + tmdbId: f.id, + titel: f.title ?? '', + jahr: jahrAus(f.release_date), + posterPfad: f.poster_path ?? '', + })), + ...(serien ?? []).slice(0, 6).map((s) => ({ + typ: 'tv' as const, + tmdbId: s.id, + titel: s.name ?? '', + jahr: jahrAus(s.first_air_date), + posterPfad: s.poster_path ?? '', + })), + ].filter((v) => v.titel.length > 0) + anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege }) + } catch (fehler) { + anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` }) + anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] }) + } +} + +async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise { + try { + const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '') + const details = + vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId) + const zuordnung: Zuordnung = { + titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel, + jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr, + // Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5). + confidence: 1, + meta: { + typ: vorschlag.typ, + id: vorschlag.tmdbId, + titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel, + jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr, + beschreibung: details?.overview ?? '', + posterPfad: details?.poster_path ?? vorschlag.posterPfad, + backdropPfad: details?.backdrop_path ?? '', + laufzeitMinuten: details?.runtime ?? 0, + genres: (details?.genres ?? []).map((g) => g.name), + quelle: 'tmdb', + }, + } + discZuordnungen.set(id, zuordnung) + anHauptUndFenster({ + art: 'disc-info', + info: { + id, + titel: zuordnung.titel, + jahr: zuordnung.jahr, + confidence: 1, + quelle: 'deine Wahl', + posterPfad: zuordnung.meta.posterPfad, + backdropPfad: zuordnung.meta.backdropPfad, + beschreibung: zuordnung.meta.beschreibung, + schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null, + // 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) { + anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` }) + } +} + +const laufwerkeBereit: Promise = (async () => { + try { + if (process.platform !== 'win32') { + throw new Error('Die Laufwerks-Schicht läuft nur unter Windows (KONZEPT § 14).') + } + const { win32Api } = await import('./laufwerk/win32') + laufwerkApi = win32Api() + wache = new DiscWache( + () => listOptischeLaufwerke(laufwerkApi!).map((g) => geraeteInfo(laufwerkApi!, g)), + (meldung) => { + anHauptUndFenster({ art: 'laufwerke', liste: meldung.laufwerke, ereignisse: meldung.ereignisse }) + for (const ereignis of meldung.ereignisse) { + if (ereignis.art === 'disc-eingelegt') { + void discErmitteln(ereignis.geraet.id, ereignis.geraet.pfad, ereignis.geraet.groesseBytes) + } 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: { + id: ereignis.geraet.id, + titel: '', + jahr: null, + confidence: 0, + quelle: '', + posterPfad: '', + backdropPfad: '', + beschreibung: '', + schonGerippt: null, + discNr: null, + staffel: null, + folgen: 0, + }, + }) + } + } + }, + (fehler) => { + // R4: Eine gescheiterte Runde ist eine laute Zeile — der letzte + // Stand im Fenster bleibt stehen (R2), die Wache läuft weiter. + console.error(`[kern] Disc-Wache: Runde fehlgeschlagen: ${String(fehler)}`) + anFenster({ art: 'kern-fehler', text: `Disc-Wache: ${String(fehler)}` }) + }, + ) + wache.starten() + } catch (fehler) { + laufwerkFehler = String(fehler) + console.error(`[kern] Laufwerke: ${laufwerkFehler}`) + } +})() + +// ── 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 20–120 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). +async function titelLesen(id: string): Promise { + 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 + } + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } }) + try { + const info = await titelInfoLesen(programm, geraetepfad) + const merkmale = discMerkmale.get(id) + const wahl = titelVorauswahl(info.titel, { + istSerie: merkmale?.istSerie ?? false, + folgen: merkmale?.folgen ?? 0, + }) + anFenster({ + art: 'disc-titel', + stand: { id, laeuft: false, titel: wahl.titel, einordnung: wahl.einordnung, unsicher: wahl.unsicher, fehler: '' }, + }) + } catch (fehler) { + // R4: laut scheitern. „Konnte nicht nachsehen" ist etwas anderes als + // „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn. + anFenster({ + art: 'disc-titel', + stand: { ...leer, laeuft: false, fehler: `Titel lesen: ${String(fehler).slice(0, 200)}` }, + }) + } +} + +function ablageOrdner(): string { + const eingestellt = db?.einstellungLesen('ablage') + if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt + return join(process.env['USERPROFILE'] ?? '.', 'Videos', 'Rippy') +} + +// Die Einstellungs-Schlüssel, die die Pipeline liest — alle aus der EINEN +// Quelle (§ 6.7). Das UI dafür kommt mit W-5; bis dahin gelten Defaults. +const PIPELINE_EINSTELLUNGEN = [ + 'transcodeEnabled', + 'transcodePreset', + 'transcodePresetDvd', + 'transcodePresetBluray', + 'transcodePresetUhd', + 'audioSprachen', + 'untertitelSprachen', + 'mediaServer', + 'mediaServerUrl', + 'mediaServerApiKey', +] as const + +function einstellungenLaden(): Record { + const werte: Record = {} + for (const schluessel of PIPELINE_EINSTELLUNGEN) { + const wert = db?.einstellungLesen(schluessel) + if (wert !== null && wert !== undefined) werte[schluessel] = wert + } + return werte +} + +function geraetVon(id: string): string | null { + if (laufwerkApi === null) return null + return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null +} + +const ripVerwaltung = new Pipeline({ + makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''), + handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''), + flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''), + audioToc: (id) => { + const geraet = geraetVon(id) + if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) + return audioToc(laufwerkApi, geraet) + }, + audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => { + const geraet = geraetVon(id) + if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`) + return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt) + }, + geraet: (id) => { + if (laufwerkApi === null) return null + const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) + return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet) + }, + ablage: ablageOrdner, + einstellungen: einstellungenLaden, + 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 { + db?.bibliothekEintragen(eintrag) + anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) + } catch (fehler) { + // R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber + // gesagt. + anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` }) + } + }, +}) + +// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den +// Kern; erlaubt sind genau diese Schlüssel ─────────────────────────── +const ERLAUBTE_EINSTELLUNGEN = new Set([ + 'ablage', + 'transcodeEnabled', + 'transcodePreset', + 'transcodePresetDvd', + 'transcodePresetBluray', + 'transcodePresetUhd', + 'audioSprachen', + 'untertitelSprachen', + 'mediaServer', + 'mediaServerUrl', + 'mediaServerApiKey', + 'tmdbApiKey', + 'omdbApiKey', + 'makemkvAppKey', + 'updateUrl', + 'setup.done', + 'autoAuswurf', + 'werkzeug.makemkv', + 'werkzeug.handbrake', + 'werkzeug.flac', +]) + +function einstellungenSenden(): void { + const werte: Record = { ablage: ablageOrdner() } + for (const schluessel of ERLAUBTE_EINSTELLUNGEN) { + const wert = db?.einstellungLesen(schluessel) + if (wert !== null && wert !== undefined) werte[schluessel] = wert + } + // AUCH ans Haupt: Es braucht die Update-Adresse (§ 6.8) — die eine + // Einstellungs-Quelle bleibt trotzdem der Kern. + anHauptUndFenster({ art: 'einstellungen', werte }) +} + +// ── MakeMKV-Schlüsselkette (§ 9.1): beim Start und täglich ────────────── +let schluesselStand: Extract['stand'] | null = null + +async function schluesselLauf(): Promise { + if (process.platform !== 'win32') return + try { + const { schluesselKette } = await import('./werkzeuge/schluessel') + const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + schluesselStand = await schluesselKette( + makemkvcon, + db?.einstellungLesen('makemkvAppKey') ?? '', + new Date().toISOString(), + ) + anHauptUndFenster({ art: 'schluessel-status', stand: schluesselStand }) + } catch (fehler) { + // R4: Der Schlüssel-Lauf ist Komfort — sein Scheitern wird gesagt. + console.error(`[kern] Schlüsselkette: ${String(fehler)}`) + anFenster({ art: 'kern-fehler', text: `Schlüsselkette: ${String(fehler)}` }) + } +} +// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten. +setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref() + +// ── MakeMKV-Update (§ 9): holen, prüfen, installieren lassen ──────────── +// Der Kern lädt und PRÜFT die Datei; STARTEN kann sie nur das Haupt +// (shell → UAC-Abfrage; der erhöhte Installer hängt dadurch nicht an der +// Prozess-Leine und überlebt ein geschlossenes Rippy). +let beschaffungLaeuft = false +let installerAntwort: ((fehler: string | null) => void) | null = null + +function beschaffungMelden(text: string, anteil: number | null, laeuft: boolean, fehler = false): void { + anFenster({ art: 'beschaffung-status', stand: { werkzeug: 'makemkv', laeuft, text, anteil, fehler } }) +} + +async function makemkvUpdateLauf(): Promise { + if (beschaffungLaeuft) { + beschaffungMelden('Ein MakeMKV-Update läuft bereits.', null, true) + return + } + beschaffungLaeuft = true + try { + const { istNeuer, makemkvNeuesteVersion, makemkvInstallerHolen, versionsangaben } = await import( + './werkzeuge/beschaffen' + ) + beschaffungMelden('Nach der neuesten MakeMKV-Fassung wird gesucht …', null, true) + const neueste = await makemkvNeuesteVersion() + if (neueste.length === 0) { + beschaffungMelden( + 'Keine der Quellen konnte sagen, welche Fassung aktuell ist (Hersteller-Seite, Forum, Archiv). ' + + 'Ausweg: MakeMKV von Hand von makemkv.com laden und installieren; Rippy findet es danach von allein.', + null, + false, + true, + ) + beschaffungLaeuft = false + return + } + const installiert = schluesselStand?.version ?? '' + if (installiert.length > 0 && !istNeuer(neueste, installiert)) { + // Ehrlich bleiben (31.08.2026 gemessen: 1.18.4 ist installiert UND + // die neueste angekündigte — die Beta ist nur abgelaufen): Ein + // Download derselben Fassung würde nichts lösen. + beschaffungMelden( + `MakeMKV ${installiert} ist bereits die neueste angekündigte Fassung. ` + + 'Sobald der Hersteller eine neuere veröffentlicht, holt dieser Knopf sie — ' + + 'bis dahin rippt der Beta-Modus weiter.', + null, + false, + ) + beschaffungLaeuft = false + return + } + // Merken, woran eine gelungene Installation zu erkennen ist: Die + // Versions-Ressource der gefundenen EXE ändert sich (oder es gibt + // erstmals eine). + const alteExe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + const alteVersion = alteExe.length > 0 ? versionsangaben(alteExe).version : '' + + const ergebnis = await makemkvInstallerHolen(neueste, (text, anteil) => + beschaffungMelden(text, anteil, true), + ) + beschaffungMelden(`Geprüft (Quelle: ${ergebnis.quelle}) — der Installer wird gestartet …`, null, true) + + const startFehler = await new Promise((fertig) => { + installerAntwort = fertig + process.parentPort.postMessage({ art: 'installer-starten', pfad: ergebnis.pfad }) + // Falls das Haupt nie antwortet, bleibt der Lauf nicht ewig hängen. + setTimeout(() => fertig('Keine Antwort vom Haupt-Prozess'), 30_000).unref() + }) + installerAntwort = null + if (startFehler !== null && startFehler.length > 0) { + beschaffungMelden(`Der Installer wurde nicht gestartet: ${startFehler}`, null, false, true) + beschaffungLaeuft = false + return + } + beschaffungMelden( + `Der MakeMKV-Installer ${ergebnis.version} läuft — bitte die Windows-Nachfrage bestätigen und durchklicken. ` + + 'Rippy merkt die fertige Installation von allein und prüft den Schlüssel neu.', + null, + true, + ) + // Warten, bis die Installation durch ist: Die Versions-Ressource der + // EXE ändert sich. Alle 15 s nachsehen, höchstens 20 Minuten. + let runden = 0 + const zeiger = setInterval(() => { + runden += 1 + const exe = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + const jetztVersion = exe.length > 0 ? versionsangaben(exe).version : '' + if (jetztVersion.length > 0 && jetztVersion !== alteVersion) { + clearInterval(zeiger) + beschaffungLaeuft = false + beschaffungMelden(`MakeMKV ${jetztVersion} ist installiert — der Schlüssel wird neu geprüft.`, null, false) + void schluesselLauf().then(() => werkzeugeSenden()) + } else if (runden >= 80) { + clearInterval(zeiger) + beschaffungLaeuft = false + beschaffungMelden( + 'Die fertige Installation wurde nicht bemerkt — nach dem Durchklicken hilft „Schlüssel neu prüfen".', + null, + false, + ) + } + }, 15_000) + zeiger.unref() + } catch (fehler) { + beschaffungMelden(fehler instanceof Error ? fehler.message : String(fehler), null, false, true) + beschaffungLaeuft = false + } +} + +// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die +// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist. +let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] } + +async function werkzeugeSenden(): Promise { + const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '') + const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '') + const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '') + const { encoderMessen } = await import('./komprimieren/encoder') + const gemessen = await encoderMessen(handbrake) + werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends } + anFenster({ + art: 'werkzeuge', + auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends }, + }) +} + +async function auswerfen(id: string): Promise { + await laufwerkeBereit + if (ripVerwaltung.laeuft(id)) { + anFenster({ + art: 'auswurf-ergebnis', + id, + grund: 'abgelehnt', + fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`, + }) + return + } + if (laufwerkApi === null) { + anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler }) + return + } + const geraete = listOptischeLaufwerke(laufwerkApi) + const geraet = geraete.find((g) => kennung(g) === id) + if (geraet === undefined) { + anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` }) + return + } + const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet) + anFenster({ art: 'auswurf-ergebnis', id, grund, fehler }) + // Die Wache merkt den neuen Zustand spätestens in der nächsten Runde; + // eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch. + wache?.runde() +} + +process.parentPort.on('message', (ereignis) => { + const nachricht: unknown = ereignis.data + if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') { + installerAntwort?.(nachricht.fehler ?? '') + installerAntwort = null + return + } + if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') { + // Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues + // Fenster bringt einen neuen Port — der alte wird geschlossen. + fensterPort?.close() + fensterPort = ereignis.ports[0] ?? null + if (fensterPort === null) return + fensterPort.on('message', (portEreignis) => { + const frage: unknown = portEreignis.data + if (!istFensterNachricht(frage)) 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, 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') { + void zuordnungSetzen(frage.id, frage.vorschlag) + } else if (frage.art === 'einstellung-setzen') { + if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) { + anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` }) + } else if (db === null) { + anFenster({ art: 'kern-fehler', text: 'Einstellung nicht gespeichert — die Datenbank ist nicht da.' }) + } else { + db.einstellungSchreiben(frage.schluessel, frage.wert) + einstellungenSenden() + } + } else if (frage.art === 'einstellungen-laden') { + einstellungenSenden() + } else if (frage.art === 'werkzeuge-laden') { + void werkzeugeSenden() + } else if (frage.art === 'bibliothek-laden') { + anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] }) + } else if (frage.art === 'makemkv-update') { + void makemkvUpdateLauf() + } else if (frage.art === 'schluessel-pruefen') { + void schluesselLauf().then(() => werkzeugeSenden()) + } + }) + fensterPort.start() + // Damit das Fenster nach dem Verbinden sofort den Stand kennt: + anFenster(bereitNachricht()) + void laufwerkeBereit.then(() => { + if (laufwerkFehler !== null) { + anFenster({ art: 'kern-fehler', text: `Laufwerke: ${laufwerkFehler}` }) + } + anFenster({ art: 'laufwerke', liste: wache?.aktuellerStand() ?? [], ereignisse: [] }) + if (schluesselStand !== null) { + anFenster({ art: 'schluessel-status', stand: schluesselStand }) + } + }) + } +}) + +function bereitNachricht(): KernNachricht { + return { + art: 'kern-bereit', + pid: process.pid, + nodeVersion: process.versions.node, + datenbank: dbStatus, + } +} + +process.parentPort.postMessage(bereitNachricht()) +// Das Haupt braucht die Einstellungen (Update-Adresse) auch ohne Fenster. +einstellungenSenden() +// Nicht im Smoke: Der Schlüssel-Lauf spricht Forum + makemkvcon an — +// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein. +if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { + void schluesselLauf() + // Werkzeuge einmal beim Start messen — die Preset-Empfehlung der + // Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster. + void werkzeugeSenden() +} diff --git a/rippy-windows/src/kern/rip/titelwahl.ts b/rippy-windows/src/kern/rip/titelwahl.ts index 26a5428..06fde7d 100644 --- a/rippy-windows/src/kern/rip/titelwahl.ts +++ b/rippy-windows/src/kern/rip/titelwahl.ts @@ -1,147 +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. -import type { TitelInfo } from './parser' - -/** 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 - /** 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() - 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 titel: TitelVorschlag[] = alle.map((t) => ({ - ...t, - gewaehlt: gewaehlt.has(t.nr), - grund: gewaehlt.has(t.nr) - ? hinweis.istSerie && hinweis.folgen >= 2 - ? '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 } -} +// 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() + 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 } +} diff --git a/rippy-windows/test/titelwahl.test.ts b/rippy-windows/test/titelwahl.test.ts index 2e966b6..9ee1565 100644 --- a/rippy-windows/test/titelwahl.test.ts +++ b/rippy-windows/test/titelwahl.test.ts @@ -1,179 +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) - }) -}) - -// ── 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) - }) -}) +// 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) + }) +})