// Der Kern — läuft als Electron utilityProcess (KONZEPT-WINDOWS.md § 4.1). // Hier passiert die ganze Arbeit; das Fenster zeigt nur an. // // Seit 5.5.0 ist diese Datei nur noch der EINSTIEG (Separation of Concerns, // Commander-Wunsch): Datenbank öffnen, Protokoll anlegen, die Dienste // verdrahten und Nachrichten verteilen. Die Arbeit liegt in kern/dienste/: // einstellungen — die EINE Quelle (§ 6.7), Ablage/Arbeitsordner // laufwerke — Wache, Auswurf, Gesundheit, nachgebautes Laufwerk // erkennung — Disc-Merkmale, TMDb/OMDb, Titel-Lauf, Serien-Merker // vorgaenge — Datensätze, Warteschlange, Roh-Aufräumen, Statistik // werkzeuge — makemkvcon/HandBrake/flac, Schlüssel, MakeMKV-Update // speicher — freier Platz // benachrichtigung— Discord/Telegram // automatik — Zero-Click mit Countdown // und die Kette selbst in kern/ablauf/ (Pipeline → Warteschlange → Ablage). // // 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 { dirname, join } from 'node:path' import { istFensterNachricht, istHauptNachricht, type DbStatus, type KernNachricht, type RipAuftragOptionen, type RipAuftragTitel } from '../gemeinsam/nachrichten' import { Protokoll } from '../gemeinsam/protokoll' import { Pipeline } from './ablauf/pipeline' import { AUFBEWAHRUNG_SCHLUESSEL } from './ablage/rohaufraeumen' import { AutomatikDienst } from './dienste/automatik' import { BenachrichtigungsDienst } from './dienste/benachrichtigung' import { EinstellungsDienst } from './dienste/einstellungen' import { ErkennungDienst } from './dienste/erkennung' import { LaufwerkDienst } from './dienste/laufwerke' import { SpeicherDienst, platzUrteil } from './dienste/speicher' import { VorgangDienst } from './dienste/vorgaenge' import { WerkzeugDienst } from './dienste/werkzeuge' import { datenbankPfadAusArgv, type KernKontext } from './kontext' import { Datenbank } from './speicher/db' // ── Datenbank und Protokoll ───────────────────────────────────────────── let db: Datenbank | null = null let dbStatus: DbStatus const pfad = datenbankPfadAusArgv(process.argv) const protokoll = new Protokoll(join(pfad !== null ? dirname(pfad) : join(process.env['TEMP'] ?? '.', 'rippy'), 'logs'), 'kern') protokoll.aufraeumen() protokoll.schreiben(`Kern startet (PID ${process.pid}, Node ${process.versions.node}) — Datenbank ${pfad ?? '?'}`) 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) } } } protokoll.schreiben(dbStatus.ok ? `Datenbank geöffnet: ${dbStatus.pfad} (Schema ${db?.schemaVersion() ?? '?'})` : `DATENBANK-FEHLER: ${dbStatus.fehler}`) const smokeKern = process.argv.includes('--smoke-kern') let fensterPort: MessagePortMain | null = null const kontext: KernKontext = { db, dbStatus, smoke: smokeKern, protokoll, anFenster: (nachricht) => fensterPort?.postMessage(nachricht), anHauptUndFenster: (nachricht) => { process.parentPort.postMessage(nachricht) fensterPort?.postMessage(nachricht) }, hinweis: (text) => { protokoll.schreiben(text) fensterPort?.postMessage({ art: 'kern-hinweis', text }) }, fehler: (text) => { protokoll.schreiben(`FEHLER: ${text}`) console.error(`[kern] ${text}`) fensterPort?.postMessage({ art: 'kern-fehler', text }) }, } // 5.7.0 (Durchsicht 12.09.2026, F2): Ein Fehler, den niemand fängt, darf // nicht still verschwinden. Gemessen in Electron 44: Ein utilityProcess // STIRBT NICHT an einer unbehandelten Promise-Ablehnung — er schweigt nur, // und die Konsole eines Tray-Programms sieht niemand. Deshalb landet jede // solche Ablehnung im Protokoll und im Fenster (R4). Eine echte Ausnahme // wird protokolliert und beendet den Kern kontrolliert — der Haupt startet // ihn neu und verdrahtet das Fenster wieder (kernstart.ts). process.on('unhandledRejection', (grund: unknown) => { kontext.fehler(`Unbehandelte Ablehnung im Kern: ${String(grund instanceof Error ? (grund.stack ?? grund.message) : grund).slice(0, 600)}`) }) process.on('uncaughtException', (fehler: Error) => { kontext.fehler(`Absturz im Kern: ${String(fehler.stack ?? fehler.message).slice(0, 600)} — der Kern beendet sich, der Haupt startet ihn neu.`) setTimeout(() => process.exit(1), 200) }) // ── Dienste verdrahten ────────────────────────────────────────────────── const einstellungen = new EinstellungsDienst(kontext) const werkzeuge = new WerkzeugDienst(kontext, einstellungen) const speicher = new SpeicherDienst(kontext, einstellungen) const benachrichtigung = new BenachrichtigungsDienst(kontext, einstellungen) // Die Pipeline und die Laufwerke brauchen einander: Auswurf nur, wenn kein // Rip läuft; Rip nur mit Gerätestand. Deshalb erst die Funktionen, dann // beide Objekte. let ripVerwaltung: Pipeline const laufwerke = new LaufwerkDienst( kontext, { discEingelegt: (geraet) => { void erkennung .discErmitteln(geraet) .then((zuordnung) => { automatik.zuordnungMerken(geraet.id, zuordnung) return automatik.pruefen(geraet, zuordnung, erkennung.schonGerippt(geraet.id)) }) .catch((fehler: unknown) => kontext.fehler(`Nach dem Einlegen in ${geraet.id}: ${String(fehler)}`)) }, discEntnommen: (geraet) => { erkennung.discVergessen(geraet.id) automatik.discEntnommen(geraet.id) ripVerwaltung.discEntnommen(geraet.id) }, }, (id) => ripVerwaltung.laeuft(id), ) const erkennung = new ErkennungDienst(kontext, { ripLaeuft: (id) => ripVerwaltung.laeuft(id), geraetInfoVon: (id) => laufwerke.geraetInfoVon(id), makemkvcon: () => werkzeuge.makemkvcon(), }) const vorgaenge = new VorgangDienst(kontext, einstellungen, werkzeuge, erkennung, benachrichtigung, () => speicher.senden()) ripVerwaltung = new Pipeline({ makemkvcon: () => werkzeuge.makemkvcon(), handbrake: () => werkzeuge.handbrake(), flac: () => werkzeuge.flac(), audioToc: (id) => laufwerke.audioToc(id), audioSpurLesen: (id, start, sektoren, schreiben, fortschritt, abbruch) => laufwerke.audioSpurLesen(id, start, sektoren, schreiben, fortschritt, abbruch), geraet: (id) => laufwerke.geraetInfoVon(id), ablage: () => einstellungen.ablageOrdner(), rohWurzel: () => einstellungen.rohWurzel(), einstellungen: () => einstellungen.fuerPipeline(), melden: (status) => { kontext.anHauptUndFenster({ art: 'rip-status', status }) if (status.phase === 'fertig' || status.phase === 'fehler' || status.phase === 'abgebrochen') { protokoll.schreiben(`Rip ${status.id} ${status.phase}: ${status.text.slice(0, 300)}`) } // Nach einem gescheiterten Rip (oder Lesefehlern) gleich das Windows- // Protokoll fragen: Disc oder Hardware? (5.4.0, Punkt 8) if (status.phase === 'fehler' || (status.phase === 'fertig' && status.lesefehler)) void laufwerke.pruefen(status.id) if (status.phase === 'fehler') void benachrichtigung.senden('Rippy: Rip fehlgeschlagen', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`) if (status.phase === 'fertig') void benachrichtigung.senden('Rippy: Rip fertig', `Laufwerk ${status.id}: ${status.text.slice(0, 300)}`) }, zuordnung: (id) => erkennung.zuordnung(id), discAngaben: (id) => erkennung.discAngaben(id), fingerprint: (id) => erkennung.fingerprint(id), zuordnungFuer: (vorschlag) => erkennung.zuordnungAus(vorschlag), bibliothekEintragen: (eintrag) => vorgaenge.bibliothekEintragen(eintrag), vorgangBeginnt: (v) => vorgaenge.vorgangBeginnt(v), vorgangEinreihen: (auftrag, titel) => vorgaenge.vorgangEinreihen(auftrag, titel), vorgangEndet: (id, phase, text) => vorgaenge.vorgangEndet(id, phase, text), nachRipAuswerfen: (id) => { // Vorgabe: auswerfen — die Warteschlange will ein freies Laufwerk. if (einstellungen.lesen('autoAuswurf') === 'false') return void laufwerke.auswerfen(id) }, }) const automatik = new AutomatikDienst(kontext, { an: () => einstellungen.automatikAn(), sekunden: () => einstellungen.automatikSekunden(), ripLaeuft: (id) => ripVerwaltung.laeuft(id), titelLesen: async (id) => { const stand = await erkennung.titelLesen(id) automatik.titelMerken(id, stand) return stand }, ripStarten: (id, auftrag, optionen) => ripMitPlatz(id, auftrag, optionen), discAngaben: (id) => erkennung.discAngaben(id), serienMerker: (zuordnung) => { const m = zuordnung.meta.typ === 'tv' ? erkennung.serienMerkerFuer(zuordnung.meta.id) : null return m === null ? null : { staffel: m.staffel, naechsteFolge: m.naechsteFolge } }, benachrichtigen: (titel, text) => void benachrichtigung.senden(titel, text), }) /** Der Rip-Start mit Platz-Prüfung (5.5.0): Der Dialog zeigt das Urteil * vorher, der Kern urteilt beim Start — auch für die Automatik. Zu wenig * für den verlustfreien Rip: kein Start, der Grund steht in den * Ereignissen. Knapp: Start mit Hinweis. Nicht messbar: Start ohne Urteil * (die Messung ist Komfort, nicht Pflicht). */ function ripMitPlatz(id: string, auftrag: RipAuftragTitel[], optionen: RipAuftragOptionen): void { const rohBytes = auftrag.reduce((s, t) => s + (t.groesseBytes ?? 0), 0) const arbeit = speicher.stand().arbeit if (arbeit.ok && rohBytes > 0) { const urteil = platzUrteil(arbeit.freiBytes, rohBytes, einstellungen.lesen('transcodeEnabled') !== 'false') if (urteil.hart) { kontext.fehler(`Rip auf ${id} nicht gestartet — ${urteil.text}`) return } if (urteil.text.length > 0) kontext.hinweis(`Platz im Arbeitsordner: ${urteil.text}`) } // Der Rip beginnt — der Automatik-Kasten in der Kachel hat ausgedient. automatik.leeren(id) void laufwerke.bereit.then(() => ripVerwaltung.starten(id, auftrag, optionen)) } // ── Nachrichten vom Fenster ───────────────────────────────────────────── function fensterNachricht(frage: unknown): void { 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 kontext.fehler(`Nachricht „${art}" wurde verworfen — sie ist dem Kern unbekannt.`) return } switch (frage.art) { case 'ping': kontext.anFenster({ art: 'pong', zeit: frage.zeit }) return case 'auswerfen': void laufwerke.auswerfen(frage.id) return case 'titel-lesen': // Der Nutzer will selbst wählen — ein laufender Countdown endet. automatik.stopp(frage.id, '') void erkennung.titelLesen(frage.id) return case 'rip-start': automatik.stopp(frage.id, '') ripMitPlatz(frage.id, frage.auftrag ?? [], frage.optionen ?? {}) return case 'rip-abbruch': ripVerwaltung.abbrechen(frage.id) return case 'rip-ueberspringen': ripVerwaltung.ueberspringen(frage.id) return case 'rettung-start': automatik.leeren(frage.id) void laufwerke.bereit.then(() => ripVerwaltung.rettungStarten(frage.id)) return case 'metadaten-suchen': void erkennung.metadatenSuchen(frage.id, frage.text) return case 'zuordnung-setzen': automatik.stopp(frage.id, '') void erkennung.zuordnungSetzen(frage.id, frage.vorschlag) return case 'einstellung-setzen': if (einstellungen.setzen(frage.schluessel, frage.wert)) { // Neue Aufbewahrungs-Regel, neuer Arbeitsordner: sofort sichtbar. if (frage.schluessel === AUFBEWAHRUNG_SCHLUESSEL) vorgaenge.senden() if (frage.schluessel === 'ablage' || frage.schluessel === 'arbeitsordner') { vorgaenge.senden() speicher.senden() } // 5.7.0 (Durchsicht F13): Ein neuer Werkzeug-Pfad wird sofort neu // gesucht und gemessen — vorher stand „NICHT GEFUNDEN" so lange, bis // das Fenster neu geladen wurde (in der Erst-Einrichtung besonders // irreführend). Ein neuer eigener Schlüssel wird sofort geprüft. if (frage.schluessel.startsWith('werkzeug.')) void werkzeuge.senden() if (frage.schluessel === 'makemkvAppKey') void werkzeuge.schluesselLauf().then(() => werkzeuge.senden()) } return case 'einstellungen-laden': einstellungen.senden() return case 'werkzeuge-laden': void werkzeuge.senden() return case 'makemkv-update': void werkzeuge.makemkvUpdateLauf() return case 'schluessel-pruefen': void werkzeuge.schluesselLauf().then(() => werkzeuge.senden()) return case 'beschaffung-antwort': werkzeuge.beschaffungAntwort(frage.ja) return case 'vorgaenge-laden': vorgaenge.neuMessen() return case 'vorgang-roh-loeschen': vorgaenge.rohLoeschen(frage.vorgangId) return case 'vorgang-neu-komprimieren': vorgaenge.neuKomprimieren(frage.vorgangId, frage.preset ?? '') return case 'kompression-abbrechen': if (!vorgaenge.warteschlange.abbrechen(frage.vorgangId)) { kontext.fehler(`Vorgang ${frage.vorgangId} ist nicht in der Warteschlange.`) } return case 'kompression-pause': vorgaenge.pause(frage.an) return case 'roh-aufraeumen': vorgaenge.rohAufraeumenLauf() return case 'fremden-ordner-loeschen': vorgaenge.fremdenOrdnerLoeschen(frage.ordner) return case 'laufwerk-pruefen': void laufwerke.pruefen(frage.id) return case 'automatik-stopp': automatik.stopp(frage.id, 'Automatik gestoppt — du entscheidest.') return case 'automatik-jetzt': automatik.jetzt(frage.id) return case 'benachrichtigung-testen': void benachrichtigung.testen() return case 'poster-laden': void erkennung.posterLaden() return case 'speicher-laden': speicher.senden() return } } process.parentPort.on('message', (ereignis) => { const nachricht: unknown = ereignis.data if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') { werkzeuge.installerErgebnis(nachricht.fehler) 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) => fensterNachricht(portEreignis.data)) fensterPort.start() // Damit das Fenster nach dem Verbinden sofort den Stand kennt: kontext.anFenster(bereitNachricht()) void laufwerke.bereit.then(() => { if (laufwerke.fehler !== null) kontext.fehler(`Laufwerke: ${laufwerke.fehler}`) kontext.anFenster({ art: 'laufwerke', liste: laufwerke.aktuellerStand(), ereignisse: [] }) if (werkzeuge.schluesselStand !== null) { kontext.anFenster({ art: 'schluessel-status', stand: werkzeuge.schluesselStand }) } }) vorgaenge.senden() speicher.senden() } }) 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. einstellungen.senden() // Was nach dem letzten Beenden noch offen war (5.4.0). vorgaenge.wiederaufnehmen() // 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 (!smokeKern && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') { void werkzeuge.schluesselLauf() void werkzeuge.spurauswahlLauf() // Werkzeuge einmal beim Start messen — die Preset-Empfehlung der // Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster. void werkzeuge.senden() // Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten. setInterval(() => void werkzeuge.schluesselLauf(), 24 * 60 * 60 * 1000).unref() } // Roh-Aufräumen (5.4.0): eine Minute nach dem Start, dann täglich — und // nach jeder gelungenen Kompression (siehe Warteschlange). Speicher (5.5.0): // jede Minute. if (!smokeKern) { setTimeout(() => vorgaenge.rohAufraeumenLauf(), 60_000).unref() setInterval(() => vorgaenge.rohAufraeumenLauf(), 24 * 60 * 60 * 1000).unref() speicher.taktStarten() }