From 6d149212c90ebc903fbf9e24a03a54797bc3a175 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Tue, 1 Sep 2026 20:29:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(v5):=20Nur=20der=20NEUESTE=20Titel-Lauf=20a?= =?UTF-8?q?ntwortet=20=E2=80=94=20ein=20alter=20legte=20sich=20darueber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commander: "Er liest es korrekt ein, ich bekomme auch die auswahl aber kurz danach kommt dieser fehler" — "makemkvcon info: keine Antwort nach 300 s", MINUTEN nach der fertigen Titelliste. Mit nur einem Lauf ist das unmoeglich: Der Timeout wird bei 'close' geloescht. Es waren also zwei Laeufe unterwegs, und der aeltere meldete verspaetet seinen Abbruch — auf die laengst gelieferte Liste drauf. Behoben, je Laufwerk: * Ein neuer Lauf LOEST den alten AB: AbortController im Kern, titelInfoLesen nimmt jetzt ein AbortSignal, killt das Kind und lehnt mit der Marke ABGELOEST ab. Das Laufwerk ist sofort frei, und es laufen nie zwei makemkvcon auf derselben Disc. * Nur die NEUESTE Laufnummer darf antworten (titelLaufNummern) — eine verspaetete Antwort wird verworfen, nicht angezeigt. * Ein abgeloester Lauf SCHWEIGT: abgeloest ist Absicht, kein Fehlschlag. * Laeuft es wirklich in den Timeout, steht jetzt ein Satz statt einer Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf Minuten brauchen; Rippy beendet den Versuch und gibt das Laufwerk frei. Am lebenden Objekt bestaetigt (SPARTACUS_GOTA_D1, Commander-Screenshot): "Serie: 2 Folgen erwartet, 2 gefunden - passt." | Titel 0 52:47 min 16,28 GB 7 Kap. | Titel 1 54:09 min 16,67 GB 7 Kap. | beide Rolle Folge | 2 von 2 gewaehlt, zusammen 32,95 GB — und KEIN Timeout hinterher. Die Laengen liegen 2,5 Prozent auseinander, also weit innerhalb von AEHNLICH (15 Prozent) — deshalb "passt" ohne Unsicherheits-Warnung. 261 Tests gruen, Typpruefung sauber. Version 5.3.2. Co-Authored-By: Claude Opus 5 --- rippy-windows/bau/release-notes.md | 30 +- rippy-windows/package-lock.json | 4 +- rippy-windows/package.json | 2 +- rippy-windows/src/kern/index.ts | 1509 +++++++++++++------------ rippy-windows/src/kern/rip/makemkv.ts | 17 + 5 files changed, 803 insertions(+), 759 deletions(-) diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index e203649..c296267 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,22 +1,20 @@ -Version 5.3.1 +Version 5.3.2 -Die Titel-Auswahl funktioniert jetzt wirklich. -In 5.2.0 und 5.3.0 blieb der Dialog ewig bei "Rippy liest die Titel der -Disc …" stehen. Der Grund war ein Fehler in Rippy selbst: Die Anfrage -"lies die Titel" war dem Kern unbekannt und wurde weggeworfen — still. -Behoben, und zwar auf drei Ebenen: Die Anfrage ist jetzt bekannt, eine -verworfene Nachricht sagt künftig laut warum, und ein neuer Prüf-Test -vergleicht bei jedem Bau die Nachrichten-Liste mit dem Prüfer. Derselbe -Fehler kann so nicht wiederkommen. +Die Titelliste bleibt jetzt stehen. +In 5.3.1 kam die Auswahl korrekt — und Minuten spaeter legte sich ein +"keine Antwort nach 300 s" darueber. Der Grund: Ein aelterer, laengst +ueberholter Lese-Vorgang meldete verspaetet seinen Abbruch. Jetzt gilt je +Laufwerk nur der NEUESTE Lauf; ein abgeloester gibt das Laufwerk sofort +frei und schweigt. Nebenbei bremsen sich damit nicht mehr zwei Lesevorgaenge +auf derselben Disc gegenseitig aus. -Rippy sagt jetzt, WARUM keine Titel kamen. -Findet MakeMKV das Laufwerk nicht (das passiert nach abgebrochenen -Rips), stand dort bisher nur "keine Titel gefunden" — als läge es an der -Disc. Jetzt steht die echte Ursache da, samt Abhilfe: Disc auswerfen und -neu einlegen, Laufwerk ab- und anstecken, notfalls neu starten. +Und wenn es wirklich zu lange dauert, steht da jetzt ein Satz statt einer +Fehlermeldung: Bei einer beschaedigten Disc kann das Lesen ueber fuenf +Minuten brauchen — Rippy beendet den Versuch dann und gibt das Laufwerk frei. -Aus 5.3.0: Serien landen unter Serien//Season NN, Filme bekommen -ihre Extras im Unterordner "extras". +Aus 5.3.1: Die Titel-Auswahl funktioniert (der Kern verwarf die Anfrage +vorher still), und Rippy nennt die Ursache, wenn MakeMKV kein Laufwerk findet. +Aus 5.3.0: Serien unter Serien//Season NN, Film-Extras im Unterordner. Aus 5.2.0: Auswahl, was gerippt wird — plus der Defekt-Waechter. Unveraendert: Updates warten immer, bis kein Rip mehr laeuft. diff --git a/rippy-windows/package-lock.json b/rippy-windows/package-lock.json index 91b6602..85c5f0c 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.3.1", + "version": "5.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.3.1", + "version": "5.3.2", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index f4cffba..11220df 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.3.1", + "version": "5.3.2", "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/kern/index.ts b/rippy-windows/src/kern/index.ts index ded78dc..ebb410a 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -1,740 +1,769 @@ -// 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, - }) - // Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der - // Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026 - // gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit - // TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe - // nichts her — dabei sieht MakeMKV das LAUFWERK nicht. - const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0 - anFenster({ - art: 'disc-titel', - stand: { - id, - laeuft: false, - titel: wahl.titel, - einordnung: wahl.einordnung, - unsicher: wahl.unsicher, - fehler: ohneErgebnis ? info.ursachen.join(' · ') : '', - }, - }) - } catch (fehler) { - // 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)) { - // R4: Eine verworfene Nachricht verschwindet NICHT still. Genau - // das kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im - // Prüfer, der Kern warf sie weg, und im Fenster stand ewig - // „Rippy liest die Titel …", ohne dass irgendwo etwas stand. - const art = - typeof frage === 'object' && frage !== null - ? String((frage as Record).art) - : typeof frage - console.error(`[kern] Fenster-Nachricht verworfen (unbekannt): ${art}`) - anFenster({ art: 'kern-fehler', text: `Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.` }) - return - } - if (frage.art === 'ping') { - anFenster({ art: 'pong', zeit: frage.zeit }) - } else if (frage.art === 'auswerfen') { - void auswerfen(frage.id) - } else if (frage.art === 'titel-lesen') { - void titelLesen(frage.id) - } else if (frage.art === 'rip-start') { - void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, 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() -} +// 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, ABGELOEST } 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). +// Je Laufwerk höchstens EIN Titel-Lauf, und nur der NEUESTE darf +// antworten. Der Commander-Fund vom 01.09.2026: Die Titelliste kam +// korrekt an — und 300 s später legte ein ÄLTERER, längst überholter Lauf +// seinen Timeout darüber („keine Antwort nach 300 s"). Zwei makemkvcon +// auf derselben Disc bremsen sich außerdem gegenseitig aus. +const titelAbbrueche = new Map() +let titelLaufZaehler = 0 +const titelLaufNummern = new Map() + +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 + } + // Einen noch laufenden Lauf ablösen: Er gibt das Laufwerk sofort frei, + // und seine Antwort wird verworfen (nur die neueste Nummer zählt). + titelAbbrueche.get(id)?.abort() + const abbruch = new AbortController() + titelAbbrueche.set(id, abbruch) + const meineNummer = ++titelLaufZaehler + titelLaufNummern.set(id, meineNummer) + const nochAktuell = (): boolean => titelLaufNummern.get(id) === meineNummer + + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: true, fehler: '' } }) + try { + const info = await titelInfoLesen(programm, geraetepfad, 300_000, abbruch.signal) + if (!nochAktuell()) return + const merkmale = discMerkmale.get(id) + const wahl = titelVorauswahl(info.titel, { + istSerie: merkmale?.istSerie ?? false, + folgen: merkmale?.folgen ?? 0, + }) + // Kam nichts heraus UND MakeMKV hat einen Grund genannt, dann ist der + // Grund die Nachricht — nicht „keine Titel gefunden". Am 01.09.2026 + // gemessen: Beim 5042-Zustand antwortet der Info-Lauf nach 0,7 s mit + // TCOUNT:0, und ohne diesen Zweig stünde im Fenster, die DISC gäbe + // nichts her — dabei sieht MakeMKV das LAUFWERK nicht. + const ohneErgebnis = info.titel.length === 0 && info.ursachen.length > 0 + anFenster({ + art: 'disc-titel', + stand: { + id, + laeuft: false, + titel: wahl.titel, + einordnung: wahl.einordnung, + unsicher: wahl.unsicher, + fehler: ohneErgebnis ? info.ursachen.join(' · ') : '', + }, + }) + } catch (fehler) { + // Ein ABGELÖSTER Lauf schweigt: Ihn hat ein neuerer ersetzt, das ist + // Absicht und kein Fehlschlag. Genau hier lag der Commander-Fund — + // der alte Lauf überschrieb sonst die längst gelieferte Titelliste. + if (String(fehler).includes(ABGELOEST) || !nochAktuell()) return + // R4: sonst laut scheitern. „Konnte nicht nachsehen" ist etwas anderes + // als „die Disc hat keine Titel" — beides zu vermischen erzeugt Unsinn. + const grund = String(fehler).includes('keine Antwort nach') + ? 'MakeMKV hat die Titel nach fünf Minuten nicht gelesen. Bei einer beschädigten Disc kann das passieren — Rippy hat den Versuch beendet und das Laufwerk freigegeben.' + : `Titel lesen: ${String(fehler).slice(0, 200)}` + anFenster({ art: 'disc-titel', stand: { ...leer, laeuft: false, fehler: grund } }) + } finally { + // Nur aufräumen, wenn ICH noch der aktuelle Lauf bin. + if (nochAktuell()) { + titelAbbrueche.delete(id) + titelLaufNummern.delete(id) + } + } +} + +function ablageOrdner(): string { + const eingestellt = db?.einstellungLesen('ablage') + if (eingestellt !== null && eingestellt !== undefined && eingestellt.length > 0) return eingestellt + 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)) { + // R4: Eine verworfene Nachricht verschwindet NICHT still. Genau + // das kostete am 01.09.2026 eine Runde — 'titel-lesen' fehlte im + // Prüfer, der Kern warf sie weg, und im Fenster stand ewig + // „Rippy liest die Titel …", ohne dass irgendwo etwas stand. + const art = + typeof frage === 'object' && frage !== null + ? String((frage as Record).art) + : typeof frage + console.error(`[kern] Fenster-Nachricht verworfen (unbekannt): ${art}`) + anFenster({ art: 'kern-fehler', text: `Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.` }) + return + } + if (frage.art === 'ping') { + anFenster({ art: 'pong', zeit: frage.zeit }) + } else if (frage.art === 'auswerfen') { + void auswerfen(frage.id) + } else if (frage.art === 'titel-lesen') { + void titelLesen(frage.id) + } else if (frage.art === 'rip-start') { + void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id, 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/makemkv.ts b/rippy-windows/src/kern/rip/makemkv.ts index 0a8634f..7b48db5 100644 --- a/rippy-windows/src/kern/rip/makemkv.ts +++ b/rippy-windows/src/kern/rip/makemkv.ts @@ -45,6 +45,10 @@ export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: str * von einem echten Defekt zu unterscheiden. */ export const HAENGT_AB = 12 +/** Kennzeichnet einen Titel-Lauf, den ein NEUERER abgelöst hat. Der + * Aufrufer schweigt dazu — abgelöst ist kein Fehler, sondern Absicht. */ +export const ABGELOEST = 'titel-lauf-abgeloest' + /** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */ export class RipAuswertung { prozent = -1 @@ -153,6 +157,7 @@ export function titelInfoLesen( programm: string, geraetepfad: string, timeoutMs = 300_000, + abbruch?: AbortSignal, ): Promise { return new Promise((fertig, scheitert) => { const kind = spawn(programm, buildInfoArgs(geraetepfad), { @@ -166,12 +171,24 @@ export function titelInfoLesen( kind.kill() scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) }, timeoutMs) + // Ein abgelöster Lauf lässt das Laufwerk sofort los — zwei makemkvcon + // auf derselben Disc machen beide langsamer (Commander-Fund 01.09.2026: + // Ein alter Lauf meldete 300 s später seinen Timeout und legte ihn über + // die längst gelieferte Titelliste). + const aufgeben = (): void => { + clearTimeout(zeiger) + kind.kill() + scheitert(new Error(ABGELOEST)) + } + abbruch?.addEventListener('abort', aufgeben, { once: true }) kind.on('error', (fehler) => { clearTimeout(zeiger) + abbruch?.removeEventListener('abort', aufgeben) scheitert(fehler) }) kind.on('close', () => { clearTimeout(zeiger) + abbruch?.removeEventListener('abort', aufgeben) const ausgabe = textVon(Buffer.concat(stuecke)) const titel = parseTitelInfo(ausgabe) const streams = parseStreamInfo(ausgabe)