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