Files
rippy/rippy-windows/src/kern/index.ts
T
HitonabiandClaude Opus 5 ae82d8c0fd fix(v5): MakeMKV-Installer aus dem Internet Archive nur nach Rückfrage, mit Größe und SHA-256
WAS: makemkvInstallerHolen fragt vor der Archiv-Quelle über eine
Freigabe-Funktion; ohne Freigabe wird das Archiv übersprungen und im
Bericht genannt. Der Kern stellt die Frage ans Fenster (beschaffung-status
mit `frage`), die MakeMKV-Karte zeigt sie mit „Archiv-Kopie laden" /
„Lieber nicht" (beschaffung-antwort); ohne Antwort in zehn Minuten gilt
Nein. Die geladene Datei wird mit Größe und SHA-256 gemeldet.
WARUM: Durchsicht 12.09.2026, Fund F16 (Commander-Entscheid: Archiv nur
nach Rückfrage) — eine unsignierte EXE von fremder Hand, die mit
Adminrechten läuft, entscheidet der Nutzer, nicht Rippy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:43:13 +02:00

399 lines
17 KiB
TypeScript

// 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=<pfad> ü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<string, unknown>).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()
}