Ampel / ampel (push) Successful in 1m34s
Update-Strecke: Quellenkette (Hersteller 525 → Forum → Archiv, höchste gewinnt, 31.08. nachgemessen), MZ- + Versions-Ressourcen-Prüfung (GuinpinSoft inc, gemessen), Download in .neu, Start übers Haupt (shell/UAC — Installer hängt bewusst NICHT an der Leine), Warte-Poll bis die EXE-Version wechselt, dann Schlüssel neu prüfen. Ehrlicher Zweig: installierte == neueste Fassung → kein sinnloser Download. Fenster: Laufwerks-Kachel als Bühne (TMDb-Backdrop, Poster, Beschreibung, Phasen-Label, Restzeit, aufklappbares Protokoll), Bibliothek als Poster-Regal (DB-Schema 3: poster_pfad, Migration), Schlüssel-Karte mit Update-Knopf und Beschaffungs-Balken, MakeMKV-Version als Badge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
631 lines
26 KiB
TypeScript
631 lines
26 KiB
TypeScript
// 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 } 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 { finden } from './werkzeuge/katalog'
|
|
|
|
// --datenbank=<pfad> 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=<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) }
|
|
}
|
|
}
|
|
|
|
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<string, Zuordnung>()
|
|
const discFingerprints = new Map<string, string>()
|
|
|
|
async function discErmitteln(id: string, geraetepfad: string, groesseBytes: number): Promise<void> {
|
|
try {
|
|
const gefunden = discTitel(geraetepfad)
|
|
const fingerprint = discFingerprint(geraetepfad, groesseBytes)
|
|
discFingerprints.set(id, fingerprint)
|
|
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
|
const omdb = new OmdbClient(db?.einstellungLesen('omdbApiKey') ?? '')
|
|
const zuordnung = await zuordnen(gefunden.titel, tmdb, omdb)
|
|
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,
|
|
},
|
|
})
|
|
} 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<void> {
|
|
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<void> {
|
|
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,
|
|
},
|
|
})
|
|
} catch (fehler) {
|
|
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
|
}
|
|
}
|
|
|
|
const laufwerkeBereit: Promise<void> = (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)
|
|
anHauptUndFenster({
|
|
art: 'disc-info',
|
|
info: {
|
|
id: ereignis.geraet.id,
|
|
titel: '',
|
|
jahr: null,
|
|
confidence: 0,
|
|
quelle: '',
|
|
posterPfad: '',
|
|
backdropPfad: '',
|
|
beschreibung: '',
|
|
schonGerippt: null,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
},
|
|
(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.
|
|
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<string, string> {
|
|
const werte: Record<string, string> = {}
|
|
for (const schluessel of PIPELINE_EINSTELLUNGEN) {
|
|
const wert = db?.einstellungLesen(schluessel)
|
|
if (wert !== null && wert !== undefined) werte[schluessel] = wert
|
|
}
|
|
return werte
|
|
}
|
|
|
|
function geraetVon(id: string): string | null {
|
|
if (laufwerkApi === null) return null
|
|
return listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id) ?? null
|
|
}
|
|
|
|
const ripVerwaltung = new Pipeline({
|
|
makemkvcon: () => finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? ''),
|
|
handbrake: () => finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? ''),
|
|
flac: () => finden('flac', db?.einstellungLesen('werkzeug.flac') ?? ''),
|
|
audioToc: (id) => {
|
|
const geraet = geraetVon(id)
|
|
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
|
return audioToc(laufwerkApi, geraet)
|
|
},
|
|
audioSpurLesen: (id, start, sektoren, schreiben, fortschritt) => {
|
|
const geraet = geraetVon(id)
|
|
if (laufwerkApi === null || geraet === null) throw new Error(`Laufwerk ${id}: gibt es nicht`)
|
|
return audioSpurLesen(laufwerkApi, geraet, start, sektoren, schreiben, fortschritt)
|
|
},
|
|
geraet: (id) => {
|
|
if (laufwerkApi === null) return null
|
|
const geraet = listOptischeLaufwerke(laufwerkApi).find((g) => kennung(g) === id)
|
|
return geraet === undefined ? null : geraeteInfo(laufwerkApi, geraet)
|
|
},
|
|
ablage: ablageOrdner,
|
|
einstellungen: einstellungenLaden,
|
|
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
|
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
|
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
|
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
|
bibliothekEintragen: (eintrag) => {
|
|
try {
|
|
db?.bibliothekEintragen(eintrag)
|
|
anFenster({ art: 'bibliothek', eintraege: db?.bibliothekListe() ?? [] })
|
|
} catch (fehler) {
|
|
// R4: Ein Bibliotheks-Fehler kippt keinen gelungenen Rip, wird aber
|
|
// gesagt.
|
|
anFenster({ art: 'kern-fehler', text: `Bibliothek: ${String(fehler)}` })
|
|
}
|
|
},
|
|
})
|
|
|
|
// ── Einstellungen (§ 6.7): das Fenster liest und schreibt NUR über den
|
|
// Kern; erlaubt sind genau diese Schlüssel ───────────────────────────
|
|
const ERLAUBTE_EINSTELLUNGEN = new Set([
|
|
'ablage',
|
|
'transcodeEnabled',
|
|
'transcodePreset',
|
|
'transcodePresetDvd',
|
|
'transcodePresetBluray',
|
|
'transcodePresetUhd',
|
|
'audioSprachen',
|
|
'untertitelSprachen',
|
|
'mediaServer',
|
|
'mediaServerUrl',
|
|
'mediaServerApiKey',
|
|
'tmdbApiKey',
|
|
'omdbApiKey',
|
|
'makemkvAppKey',
|
|
'updateUrl',
|
|
'setup.done',
|
|
'autoAuswurf',
|
|
'werkzeug.makemkv',
|
|
'werkzeug.handbrake',
|
|
'werkzeug.flac',
|
|
])
|
|
|
|
function einstellungenSenden(): void {
|
|
const werte: Record<string, string> = { 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<KernNachricht, { art: 'schluessel-status' }>['stand'] | null = null
|
|
|
|
async function schluesselLauf(): Promise<void> {
|
|
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<void> {
|
|
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<string | null>((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<void> {
|
|
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<void> {
|
|
await laufwerkeBereit
|
|
if (ripVerwaltung.laeuft(id)) {
|
|
anFenster({
|
|
art: 'auswurf-ergebnis',
|
|
id,
|
|
grund: 'abgelehnt',
|
|
fehler: `Auf Laufwerk ${id} läuft ein Rip — erst abbrechen oder abwarten.`,
|
|
})
|
|
return
|
|
}
|
|
if (laufwerkApi === null) {
|
|
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: laufwerkFehler })
|
|
return
|
|
}
|
|
const geraete = listOptischeLaufwerke(laufwerkApi)
|
|
const geraet = geraete.find((g) => kennung(g) === id)
|
|
if (geraet === undefined) {
|
|
anFenster({ art: 'auswurf-ergebnis', id, grund: 'kein-zugriff', fehler: `Laufwerk ${id}: gibt es nicht` })
|
|
return
|
|
}
|
|
const { grund, fehler } = await auswerfenMitGrund(laufwerkApi, geraet)
|
|
anFenster({ art: 'auswurf-ergebnis', id, grund, fehler })
|
|
// Die Wache merkt den neuen Zustand spätestens in der nächsten Runde;
|
|
// eine sofortige Runde macht das Fenster ohne 3-Sekunden-Wartezeit frisch.
|
|
wache?.runde()
|
|
}
|
|
|
|
process.parentPort.on('message', (ereignis) => {
|
|
const nachricht: unknown = ereignis.data
|
|
if (istHauptNachricht(nachricht) && nachricht.art === 'installer-ergebnis') {
|
|
installerAntwort?.(nachricht.fehler ?? '')
|
|
installerAntwort = null
|
|
return
|
|
}
|
|
if (istHauptNachricht(nachricht) && nachricht.art === 'fenster-port') {
|
|
// Das Haupt reicht einen frischen Port zum Fenster durch. Ein neues
|
|
// Fenster bringt einen neuen Port — der alte wird geschlossen.
|
|
fensterPort?.close()
|
|
fensterPort = ereignis.ports[0] ?? null
|
|
if (fensterPort === null) return
|
|
fensterPort.on('message', (portEreignis) => {
|
|
const frage: unknown = portEreignis.data
|
|
if (!istFensterNachricht(frage)) return
|
|
if (frage.art === 'ping') {
|
|
anFenster({ art: 'pong', zeit: frage.zeit })
|
|
} else if (frage.art === 'auswerfen') {
|
|
void auswerfen(frage.id)
|
|
} else if (frage.art === 'rip-start') {
|
|
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
|
} else if (frage.art === 'rip-abbruch') {
|
|
ripVerwaltung.abbrechen(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()
|
|
}
|