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)