diff --git a/rippy-windows/bau/release-notes.md b/rippy-windows/bau/release-notes.md index 68677d9..e203649 100644 --- a/rippy-windows/bau/release-notes.md +++ b/rippy-windows/bau/release-notes.md @@ -1,24 +1,22 @@ -Version 5.3.0 +Version 5.3.1 -Serien landen jetzt dort, wo Jellyfin sie sucht. -Bisher kamen auch Folgen unter "Filme" — Jellyfin sah lauter Einzelfilme -statt einer Staffel. Jetzt legt Rippy sie unter Serien//Season 01 -ab. Die Episoden-Nummern (S01E02) setzt es NUR, wenn die Laufzeiten -eindeutig zur Staffel passen; sonst behalten die Dateien ihre Namen und -Rippy sagt das. Lieber gar nicht als falsch. +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. -Bei Filmen wählst du jetzt auch, was mit soll. -Nur das Hauptfeature, oder Hauptfeature plus Extras. In der Auswahl legst -du je Titel fest, was er ist: Hauptfilm, Extra oder Folge. Extras landen -im Unterordner "extras" neben dem Film — so lesen Jellyfin, Emby und Kodi -sie als Zugaben und nicht als zweiten Film. +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. -Neuer Knopf in der Auswahl: "Hauptinhalt + Extras". -Hakt alles Sehenswerte an, aber weder Sammeltitel noch Logos und -Alterskennzeichen. - -Aus 5.2.0: Titel-Auswahl vor dem Rip und der Defekt-Waechter. -Aus 5.1.5: Windows-Rand weg, Erst-Einrichtung ueberarbeitet. -Aus 5.1.4: Das Update installiert sich wirklich. +Aus 5.3.0: Serien landen unter Serien//Season NN, Filme bekommen +ihre Extras im Unterordner "extras". +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 61fa4de..91b6602 100644 --- a/rippy-windows/package-lock.json +++ b/rippy-windows/package-lock.json @@ -1,12 +1,12 @@ { "name": "rippy", - "version": "5.3.0", + "version": "5.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rippy", - "version": "5.3.0", + "version": "5.3.1", "license": "UNLICENSED", "dependencies": { "electron-updater": "^6.8.9", diff --git a/rippy-windows/package.json b/rippy-windows/package.json index 7a98783..f4cffba 100644 --- a/rippy-windows/package.json +++ b/rippy-windows/package.json @@ -1,7 +1,7 @@ { "name": "rippy", "productName": "Rippy", - "version": "5.3.0", + "version": "5.3.1", "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/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts index 0131118..0f42bc8 100644 --- a/rippy-windows/src/gemeinsam/nachrichten.ts +++ b/rippy-windows/src/gemeinsam/nachrichten.ts @@ -376,7 +376,13 @@ export function istDbStatus(wert: unknown): wert is DbStatus { export function istFensterNachricht(wert: unknown): wert is FensterNachricht { if (!istObjekt(wert)) return false if (wert.art === 'ping') return typeof wert.zeit === 'number' - if (wert.art === 'auswerfen' || wert.art === 'rip-start' || wert.art === 'rip-abbruch') { + if ( + wert.art === 'auswerfen' || + wert.art === 'titel-lesen' || + wert.art === 'rip-start' || + wert.art === 'rip-abbruch' || + wert.art === 'rip-ueberspringen' + ) { return typeof wert.id === 'string' && wert.id.length > 0 } if (wert.art === 'einstellung-setzen') { diff --git a/rippy-windows/src/kern/index.ts b/rippy-windows/src/kern/index.ts index b3d5402..ded78dc 100644 --- a/rippy-windows/src/kern/index.ts +++ b/rippy-windows/src/kern/index.ts @@ -313,9 +313,22 @@ async function titelLesen(id: string): Promise { 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: '' }, + 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 @@ -640,7 +653,19 @@ process.parentPort.on('message', (ereignis) => { if (fensterPort === null) return fensterPort.on('message', (portEreignis) => { const frage: unknown = portEreignis.data - if (!istFensterNachricht(frage)) return + 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') { diff --git a/rippy-windows/src/kern/rip/makemkv.ts b/rippy-windows/src/kern/rip/makemkv.ts index 73bf1f2..0a8634f 100644 --- a/rippy-windows/src/kern/rip/makemkv.ts +++ b/rippy-windows/src/kern/rip/makemkv.ts @@ -1,268 +1,286 @@ -// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2). -// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar); -// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen. -// -// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen): -// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan -// hing bzw. stürzte — Befund 23.07.) -// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt -// Kodierungen; „Das Öffnen" war die Folge von text=True. -// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und -// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2). -// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer -// nur „Failed to open disc", die Ursache steht Zeilen davor. -import { spawn } from 'node:child_process' -import { mkdirSync, readdirSync } from 'node:fs' -import { join } from 'node:path' -import { - KRITISCHE_CODES, - KRITISCHE_TEXTE, - LESEFEHLER_MARKE, - offsetAusMeldung, - parseMsg, - parseStreamInfo, - parseTitelInfo, - progressAusPrgv, - quelle, - sprachenZusammenfassen, - textVon, - type SprachEintrag, - type TitelInfo, - type TitelStreams, -} from './parser' - -export function buildInfoArgs(geraetepfad: string): string[] { - return ['-r', '--noscan', 'info', quelle(geraetepfad)] -} - -export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] { - return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner] -} - -/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend. - * Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler), - * zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer - * von einem echten Defekt zu unterscheiden. */ -export const HAENGT_AB = 12 - -/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */ -export class RipAuswertung { - prozent = -1 - letzteMeldung = '' - lesefehler = false - kritischeMeldungen: string[] = [] - /** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */ - haengtBei = '' - /** Wie oft dieselbe Stelle schon gemeldet wurde. */ - haengtWiederholungen = 0 - - /** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf - * der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */ - haengt(): boolean { - return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB - } - - /** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */ - verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } { - const p = progressAusPrgv(zeile) - if (p >= 0) { - if (p === this.prozent) return {} - // Es geht wieder voran — was vorher klemmte, klemmt nicht mehr. - this.haengtBei = '' - this.haengtWiederholungen = 0 - this.prozent = p - return { prozent: p } - } - const meldung = parseMsg(zeile) - if (meldung === null) return {} - this.letzteMeldung = meldung.text - if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true - // Dieselbe Stelle wieder und wieder heißt: physischer Defekt. - const stelle = offsetAusMeldung(meldung.text) - if (stelle.length > 0) { - if (stelle === this.haengtBei) { - this.haengtWiederholungen += 1 - } else { - this.haengtBei = stelle - this.haengtWiederholungen = 1 - } - } - if (meldung.code in KRITISCHE_CODES) { - this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`) - } else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) { - this.kritischeMeldungen.push(meldung.text) - } - return { meldung } - } - - fehlerText(returncode: number | null, dateienDa: boolean): string { - return ( - `makemkvcon endete mit Code ${returncode ?? '?'}` + - (this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') + - (this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') + - (dateienDa ? '' : ' — keine MKV-Datei entstanden') - ) - } -} - -/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt - * liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */ -export class ZeilenLeser { - private rest: Buffer = Buffer.alloc(0) - - hinzu(stueck: Buffer): string[] { - this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck]) - const zeilen: string[] = [] - for (;;) { - const umbruch = this.rest.indexOf(0x0a) - if (umbruch < 0) break - let ende = umbruch - if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1 - zeilen.push(textVon(this.rest.subarray(0, ende))) - this.rest = this.rest.subarray(umbruch + 1) - } - return zeilen - } - - schluss(): string[] { - if (this.rest.length === 0) return [] - const letzte = textVon(this.rest) - this.rest = Buffer.alloc(0) - return letzte.trim().length > 0 ? [letzte] : [] - } -} - -export interface DiscInfo { - titel: TitelInfo[] - streams: Map - sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } -} - -/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s, - * zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */ -export function titelInfoLesen( - programm: string, - geraetepfad: string, - timeoutMs = 300_000, -): Promise { - return new Promise((fertig, scheitert) => { - const kind = spawn(programm, buildInfoArgs(geraetepfad), { - windowsHide: true, - stdio: ['ignore', 'pipe', 'pipe'], - }) - const stuecke: Buffer[] = [] - kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck)) - kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck)) - const zeiger = setTimeout(() => { - kind.kill() - scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) - }, timeoutMs) - kind.on('error', (fehler) => { - clearTimeout(zeiger) - scheitert(fehler) - }) - kind.on('close', () => { - clearTimeout(zeiger) - const ausgabe = textVon(Buffer.concat(stuecke)) - const titel = parseTitelInfo(ausgabe) - const streams = parseStreamInfo(ausgabe) - fertig({ titel, streams, sprachen: sprachenZusammenfassen(streams) }) - }) - }) -} - -export type RipErgebnis = - | { status: 'success'; dateien: string[]; lesefehler: boolean } - | { status: 'cancelled' } - | { status: 'error'; fehler: string; lesefehler: boolean } - -export interface RipLaufOptionen { - programm: string - geraetepfad: string - zielOrdner: string - /** 'all' oder eine Titel-Nummer als Text. */ - titel: string - aufFortschritt?: (prozent: number) => void - aufMeldung?: (code: number, text: string) => void - /** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) — - * das ist ein Disc-Defekt, kein Fortschrittsproblem. */ - aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void - abbruch?: AbortSignal -} - -/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt - * den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */ -export function rippen(optionen: RipLaufOptionen): Promise { - return new Promise((fertig) => { - mkdirSync(optionen.zielOrdner, { recursive: true }) - - const kind = spawn( - optionen.programm, - buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel), - { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, - ) - - const auswertung = new RipAuswertung() - const leser = new ZeilenLeser() - let abgebrochen = false - - const abbrechen = (): void => { - abgebrochen = true - kind.kill() - } - optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) - - // Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder - // Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster. - let klemmteVorher = false - const zeile = (z: string): void => { - const ergebnis = auswertung.verarbeite(z) - if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) - if (ergebnis.meldung !== undefined) { - optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text) - } - const klemmt = auswertung.haengt() - if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) { - klemmteVorher = klemmt - optionen.aufHaengt?.( - klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null, - ) - } - } - kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) - kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) - - kind.on('error', (fehler) => { - optionen.abbruch?.removeEventListener('abort', abbrechen) - fertig({ status: 'error', fehler: String(fehler), lesefehler: false }) - }) - - kind.on('close', (code) => { - optionen.abbruch?.removeEventListener('abort', abbrechen) - leser.schluss().forEach(zeile) - if (abgebrochen) { - fertig({ status: 'cancelled' }) - return - } - let dateien: string[] = [] - try { - dateien = readdirSync(optionen.zielOrdner) - .filter((name) => name.endsWith('.mkv')) - .sort() - .map((name) => join(optionen.zielOrdner, name)) - } catch (fehler) { - fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler }) - return - } - if (code === 0 && dateien.length > 0) { - fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler }) - return - } - fertig({ - status: 'error', - fehler: auswertung.fehlerText(code, dateien.length > 0), - lesefehler: auswertung.lesefehler, - }) - }) - }) -} +// makemkvcon ansprechen — Kommandobau und Lauf (KONZEPT § 6.2). +// Die Auswertung der Ausgabe steckt in RipAuswertung (pur, testbar); +// hier bleiben nur spawn, Zeilenzerlegung und Aufräumen. +// +// Was aus dem Docker-Zweig zwingend mitkommt (ripping.py, gemessen): +// - Robot-Mode: -r --noscan --progress=-same (--noscan: der Geräte-Scan +// hing bzw. stürzte — Befund 23.07.) +// - BINÄR lesen, je Zeile dekodieren (textVon): makemkvcon mischt +// Kodierungen; „Das Öffnen" war die Folge von text=True. +// - Lesefehler heißen Lesefehler: MakeMKV kann 1 von 2 Titeln sichern und +// sich trotzdem mit 0 beenden (30.08.2026, Spartacus Disc 2). +// - Kritische MSG-NUMMERN einsammeln — die letzte Zeile ist fast immer +// nur „Failed to open disc", die Ursache steht Zeilen davor. +import { spawn } from 'node:child_process' +import { mkdirSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { + KRITISCHE_CODES, + KRITISCHE_TEXTE, + LESEFEHLER_MARKE, + offsetAusMeldung, + parseMsg, + parseStreamInfo, + parseTitelInfo, + progressAusPrgv, + quelle, + sprachenZusammenfassen, + textVon, + type SprachEintrag, + type TitelInfo, + type TitelStreams, +} from './parser' + +export function buildInfoArgs(geraetepfad: string): string[] { + return ['-r', '--noscan', 'info', quelle(geraetepfad)] +} + +export function buildRipArgs(geraetepfad: string, zielOrdner: string, titel: string): string[] { + return ['-r', '--noscan', '--progress=-same', 'mkv', quelle(geraetepfad), titel, zielOrdner] +} + +/** Ab so vielen Meldungen an DERSELBEN Stelle gilt der Lauf als hängend. + * Jeder Leseversuch wirft zwei Zeilen (SCSI-Fehler + Betriebssystemfehler), + * zwölf sind also rund sechs Versuche — genug, um einen kurzen Aussetzer + * von einem echten Defekt zu unterscheiden. */ +export const HAENGT_AB = 12 + +/** Auswertung der Rip-Ausgabe — pur, eine Zeile nach der anderen. */ +export class RipAuswertung { + prozent = -1 + letzteMeldung = '' + lesefehler = false + kritischeMeldungen: string[] = [] + /** Die Stelle, an der es zuletzt klemmte — '' wenn keine. */ + haengtBei = '' + /** Wie oft dieselbe Stelle schon gemeldet wurde. */ + haengtWiederholungen = 0 + + /** Klemmt der Lauf an einer festen Stelle? Dann ist das ein Defekt auf + * der Disc und kein Fortschrittsproblem — Weiterversuchen hilft nicht. */ + haengt(): boolean { + return this.haengtBei.length > 0 && this.haengtWiederholungen >= HAENGT_AB + } + + /** Verarbeitet eine Zeile; gibt einen neuen Prozentwert zurück oder null. */ + verarbeite(zeile: string): { prozent?: number; meldung?: { code: number; text: string } } { + const p = progressAusPrgv(zeile) + if (p >= 0) { + if (p === this.prozent) return {} + // Es geht wieder voran — was vorher klemmte, klemmt nicht mehr. + this.haengtBei = '' + this.haengtWiederholungen = 0 + this.prozent = p + return { prozent: p } + } + const meldung = parseMsg(zeile) + if (meldung === null) return {} + this.letzteMeldung = meldung.text + if (meldung.text.includes(LESEFEHLER_MARKE)) this.lesefehler = true + // Dieselbe Stelle wieder und wieder heißt: physischer Defekt. + const stelle = offsetAusMeldung(meldung.text) + if (stelle.length > 0) { + if (stelle === this.haengtBei) { + this.haengtWiederholungen += 1 + } else { + this.haengtBei = stelle + this.haengtWiederholungen = 1 + } + } + if (meldung.code in KRITISCHE_CODES) { + this.kritischeMeldungen.push(`${KRITISCHE_CODES[meldung.code]} (${meldung.text.trim()})`) + } else if (KRITISCHE_TEXTE.some((muster) => meldung.text.includes(muster))) { + this.kritischeMeldungen.push(meldung.text) + } + return { meldung } + } + + fehlerText(returncode: number | null, dateienDa: boolean): string { + return ( + `makemkvcon endete mit Code ${returncode ?? '?'}` + + (this.kritischeMeldungen.length > 0 ? ` — Ursache: ${this.kritischeMeldungen.join('; ')}` : '') + + (this.letzteMeldung.length > 0 ? ` — letzte Meldung: ${this.letzteMeldung}` : '') + + (dateienDa ? '' : ' — keine MKV-Datei entstanden') + ) + } +} + +/** Buffer-Ströme in Zeilen zerlegen — binär, die letzte halbe Zeile bleibt + * liegen, bis ihr Rest kommt. Je Zeile wird einzeln dekodiert (textVon). */ +export class ZeilenLeser { + private rest: Buffer = Buffer.alloc(0) + + hinzu(stueck: Buffer): string[] { + this.rest = this.rest.length === 0 ? stueck : Buffer.concat([this.rest, stueck]) + const zeilen: string[] = [] + for (;;) { + const umbruch = this.rest.indexOf(0x0a) + if (umbruch < 0) break + let ende = umbruch + if (ende > 0 && this.rest[ende - 1] === 0x0d) ende -= 1 + zeilen.push(textVon(this.rest.subarray(0, ende))) + this.rest = this.rest.subarray(umbruch + 1) + } + return zeilen + } + + schluss(): string[] { + if (this.rest.length === 0) return [] + const letzte = textVon(this.rest) + this.rest = Buffer.alloc(0) + return letzte.trim().length > 0 ? [letzte] : [] + } +} + +export interface DiscInfo { + /** Was MakeMKV als URSACHE genannt hat, wenn nichts herauskam — die + * kritischen MSG-Nummern im Klartext. Leer, wenn alles glatt lief. + * + * Warum (01.09.2026 am Laufwerk gemessen): Beim Zustand 5042 („Das + * Programm konnte keine verwendbaren optischen Laufwerke finden") + * antwortet der Info-Lauf nach 0,7 s mit TCOUNT:0. Ohne diese Zeile + * stünde im Fenster nur „keine Titel gefunden" — und niemand wüsste, + * dass das Laufwerk gemeint ist und nicht die Disc. */ + ursachen: string[] + titel: TitelInfo[] + streams: Map + sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] } +} + +/** EIN Info-Lauf liefert Titel UND Sprachen — er dauert je Disc 20–120 s, + * zweimal holen wäre eine Minute Wartezeit für nichts (ripping.py). */ +export function titelInfoLesen( + programm: string, + geraetepfad: string, + timeoutMs = 300_000, +): Promise { + return new Promise((fertig, scheitert) => { + const kind = spawn(programm, buildInfoArgs(geraetepfad), { + windowsHide: true, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stuecke: Buffer[] = [] + kind.stdout.on('data', (stueck: Buffer) => stuecke.push(stueck)) + kind.stderr.on('data', (stueck: Buffer) => stuecke.push(stueck)) + const zeiger = setTimeout(() => { + kind.kill() + scheitert(new Error(`makemkvcon info: keine Antwort nach ${Math.round(timeoutMs / 1000)} s`)) + }, timeoutMs) + kind.on('error', (fehler) => { + clearTimeout(zeiger) + scheitert(fehler) + }) + kind.on('close', () => { + clearTimeout(zeiger) + const ausgabe = textVon(Buffer.concat(stuecke)) + const titel = parseTitelInfo(ausgabe) + const streams = parseStreamInfo(ausgabe) + // Die Ursache steht in den MSG-Nummern, nicht in der letzten Zeile. + const ursachen: string[] = [] + for (const zeile of ausgabe.split('\n')) { + const meldung = parseMsg(zeile) + if (meldung === null) continue + if (meldung.code in KRITISCHE_CODES && !ursachen.includes(KRITISCHE_CODES[meldung.code])) { + ursachen.push(KRITISCHE_CODES[meldung.code]) + } + } + fertig({ ursachen, titel, streams, sprachen: sprachenZusammenfassen(streams) }) + }) + }) +} + +export type RipErgebnis = + | { status: 'success'; dateien: string[]; lesefehler: boolean } + | { status: 'cancelled' } + | { status: 'error'; fehler: string; lesefehler: boolean } + +export interface RipLaufOptionen { + programm: string + geraetepfad: string + zielOrdner: string + /** 'all' oder eine Titel-Nummer als Text. */ + titel: string + aufFortschritt?: (prozent: number) => void + aufMeldung?: (code: number, text: string) => void + /** Der Lauf klemmt an einer festen Stelle (oder klemmt nicht mehr) — + * das ist ein Disc-Defekt, kein Fortschrittsproblem. */ + aufHaengt?: (klemmt: { offset: string; wiederholungen: number } | null) => void + abbruch?: AbortSignal +} + +/** Rippt verlustfrei. Löst NIE mit einer Ausnahme auf — das Ergebnis trägt + * den Fehler als Wert (der Aufrufer meldet ihn weiter, R4). */ +export function rippen(optionen: RipLaufOptionen): Promise { + return new Promise((fertig) => { + mkdirSync(optionen.zielOrdner, { recursive: true }) + + const kind = spawn( + optionen.programm, + buildRipArgs(optionen.geraetepfad, optionen.zielOrdner, optionen.titel), + { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }, + ) + + const auswertung = new RipAuswertung() + const leser = new ZeilenLeser() + let abgebrochen = false + + const abbrechen = (): void => { + abgebrochen = true + kind.kill() + } + optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) + + // Ob es klemmt, wird gemeldet, sobald es sich ÄNDERT — nicht bei jeder + // Zeile, sonst rauscht dieselbe Nachricht hundertfach durchs Fenster. + let klemmteVorher = false + const zeile = (z: string): void => { + const ergebnis = auswertung.verarbeite(z) + if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) + if (ergebnis.meldung !== undefined) { + optionen.aufMeldung?.(ergebnis.meldung.code, ergebnis.meldung.text) + } + const klemmt = auswertung.haengt() + if (klemmt !== klemmteVorher || (klemmt && auswertung.haengtWiederholungen % 10 === 0)) { + klemmteVorher = klemmt + optionen.aufHaengt?.( + klemmt ? { offset: auswertung.haengtBei, wiederholungen: auswertung.haengtWiederholungen } : null, + ) + } + } + kind.stdout.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) + kind.stderr.on('data', (stueck: Buffer) => leser.hinzu(stueck).forEach(zeile)) + + kind.on('error', (fehler) => { + optionen.abbruch?.removeEventListener('abort', abbrechen) + fertig({ status: 'error', fehler: String(fehler), lesefehler: false }) + }) + + kind.on('close', (code) => { + optionen.abbruch?.removeEventListener('abort', abbrechen) + leser.schluss().forEach(zeile) + if (abgebrochen) { + fertig({ status: 'cancelled' }) + return + } + let dateien: string[] = [] + try { + dateien = readdirSync(optionen.zielOrdner) + .filter((name) => name.endsWith('.mkv')) + .sort() + .map((name) => join(optionen.zielOrdner, name)) + } catch (fehler) { + fertig({ status: 'error', fehler: `Zielordner nicht lesbar: ${String(fehler)}`, lesefehler: auswertung.lesefehler }) + return + } + if (code === 0 && dateien.length > 0) { + fertig({ status: 'success', dateien, lesefehler: auswertung.lesefehler }) + return + } + fertig({ + status: 'error', + fehler: auswertung.fehlerText(code, dateien.length > 0), + lesefehler: auswertung.lesefehler, + }) + }) + }) +} diff --git a/rippy-windows/test/messung.titelwahl.test.ts b/rippy-windows/test/messung.titelwahl.test.ts new file mode 100644 index 0000000..e684106 --- /dev/null +++ b/rippy-windows/test/messung.titelwahl.test.ts @@ -0,0 +1,66 @@ +// MESSUNG am ECHTEN Laufwerk: Was bekäme der Auswahl-Dialog zu sehen? +// +// RIPPY_MESSUNG_DISC=G npx vitest run test/messung.titelwahl.test.ts --reporter=verbose +// +// Der Anlass (01.09.2026): Der Dialog blieb ewig bei „Rippy liest die +// Titel der Disc …". Ursache war NICHT der Info-Lauf, sondern der +// Nachrichten-Prüfer: `istFensterNachricht` kannte 'titel-lesen' nicht, +// der Kern verwarf die Anfrage — still. Gemessen wurde danach: +// +// makemkvcon -r --noscan info dev:G: -> 0,7 s, TCOUNT:0 +// MSG:5042 „konnte keine verwendbaren optischen Laufwerke finden" +// MSG:2024 „Unknown device - 'G:'" · MSG:5010 „Öffnen der Disk schlug fehl" +// +// Der Info-Lauf HING also nie. Diese Messung fährt genau den Weg des +// Kerns nach — Info-Lauf, Vorauswahl, und was als Fehlertext im Dialog +// stünde. Sie läuft nur auf Verlangen und nur unter Windows. +import { describe, expect, it } from 'vitest' + +const buchstabe = (process.env.RIPPY_MESSUNG_DISC ?? '').replace(/:$/, '').toUpperCase() +const messen = /^[A-Z]$/.test(buchstabe) && process.platform === 'win32' + +describe.skipIf(!messen)('Titelwahl am echten Laufwerk', () => { + it('liest die Titel und baut die Vorauswahl', { timeout: 320_000 }, async () => { + const { finden } = await import('../src/kern/werkzeuge/katalog') + const { titelInfoLesen } = await import('../src/kern/rip/makemkv') + const { titelVorauswahl } = await import('../src/kern/rip/titelwahl') + const { discTitel } = await import('../src/kern/metadaten/discmerkmale') + + const programm = finden('makemkv', '') + expect(programm.length, 'makemkvcon nicht gefunden').toBeGreaterThan(0) + + const geraetepfad = `\\\\.\\${buchstabe}:` + const merkmale = discTitel(geraetepfad) + console.log(`MESSUNG Disc sagt: "${merkmale.titel}" · Folgen ${merkmale.folgen} · Serie ${merkmale.istSerie}`) + + const start = Date.now() + const info = await titelInfoLesen(programm, geraetepfad) + console.log(`MESSUNG Info-Lauf: ${((Date.now() - start) / 1000).toFixed(1)} s, ${info.titel.length} Titel`) + if (info.ursachen.length > 0) console.log(`MESSUNG Ursachen: ${info.ursachen.join(' · ')}`) + + const wahl = titelVorauswahl(info.titel, { + istSerie: merkmale.istSerie, + folgen: merkmale.folgen, + }) + console.log(`MESSUNG Einordnung: ${wahl.einordnung} (unsicher: ${wahl.unsicher})`) + for (const t of wahl.titel) { + const min = Math.floor(t.dauerS / 60) + console.log( + `MESSUNG ${t.gewaehlt ? '[x]' : '[ ]'} Titel ${t.nr} | ${min}:${String(t.dauerS % 60).padStart(2, '0')} min` + + ` | ${(t.groesseBytes / 1e9).toFixed(2)} GB | ${t.kapitel} Kap. | ${t.rolle} | ${t.grund}`, + ) + } + // Was im Dialog als Fehler stünde — genau die Regel aus kern/index.ts. + const dialogFehler = info.titel.length === 0 && info.ursachen.length > 0 ? info.ursachen.join(' · ') : '' + console.log(`MESSUNG Dialog-Fehlertext: ${dialogFehler.length > 0 ? dialogFehler : '(keiner)'}`) + + // Der Lauf muss ANTWORTEN — leer sein darf er, hängen nicht. + expect(Date.now() - start).toBeLessThan(300_000) + }) +}) + +describe.skipIf(messen)('Titelwahl-Messung übersprungen', () => { + it('läuft nur mit RIPPY_MESSUNG_DISC= unter Windows', () => { + expect(true).toBe(true) + }) +}) diff --git a/rippy-windows/test/waechter.test.ts b/rippy-windows/test/waechter.test.ts index cc27826..41576d0 100644 --- a/rippy-windows/test/waechter.test.ts +++ b/rippy-windows/test/waechter.test.ts @@ -1,118 +1,158 @@ -// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3. -// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem -// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei. -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' - -const WURZEL = join(__dirname, '..') -const SRC = join(WURZEL, 'src') - -function quelldateien(ordner: string): string[] { - const ergebnis: string[] = [] - for (const name of readdirSync(ordner)) { - const pfad = join(ordner, name) - if (statSync(pfad).isDirectory()) { - ergebnis.push(...quelldateien(pfad)) - } else if (/\.(ts|tsx)$/.test(name)) { - ergebnis.push(pfad) - } - } - return ergebnis -} - -function relativPosix(pfad: string): string { - return relative(WURZEL, pfad).split(sep).join('/') -} - -const dateien = quelldateien(SRC).map((pfad) => ({ - pfad: relativPosix(pfad), - inhalt: readFileSync(pfad, 'utf8'), -})) - -describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => { - // § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die - // Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts: - // Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt. - // Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute - // braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die - // Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt - // jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht. - const erlaubt = new Set([ - 'src/haupt/leine.ts', - 'src/haupt/fensterrand.ts', - 'src/kern/laufwerk/win32.ts', - ]) - - it('koffi wird nirgendwo sonst angefasst', () => { - const verstoesse = dateien - .filter((d) => !erlaubt.has(d.pfad)) - .filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt)) - .map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) -}) - -describe('R2/R4 — kein Fehlschlag verschwindet still', () => { - it('kein leerer catch-Block', () => { - // R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde - // gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum. - const verstoesse = dateien - .filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt)) - .map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) - - it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => { - // R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß - // „es gibt keine Jobs", die Liste leerte sich im Sekundentakt. - const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/ - const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/ - const verstoesse = dateien - .filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt)) - .map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) -}) - -describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => { - it('niemand öffnet einen Server oder lauscht auf einem Port', () => { - // Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene - // Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von - // electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den - // reinen Variablennamen ist erlaubt. - const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/ - const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) -}) - -describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => { - it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => { - // Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten - // als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs - // leiden, und ein Test sah dadurch anders aus, als sein Quelltext las. - const testDateien = readdirSync(join(WURZEL, 'test')) - .filter((name) => /\.(ts|tsx)$/.test(name)) - .map((name) => ({ - pfad: `test/${name}`, - inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'), - })) - const verstoesse = [...dateien, ...testDateien] - .filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt)) - .map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) -}) - -describe('§ 6.7 — EINE Quelle für Einstellungen', () => { - it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => { - // Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las - // aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul. - const verstoesse = dateien - .filter((d) => d.pfad !== 'src/kern/speicher/db.ts') - .filter((d) => /['"]node:sqlite['"]/.test(d.inhalt)) - .map((d) => d.pfad) - expect(verstoesse).toEqual([]) - }) -}) +// Die Wächter — mechanische Prüfung der Regeln aus KONZEPT-WINDOWS.md § 4.3. +// Sie LESEN Quelltext, sie führen nichts aus. Jede Regel ist aus einem +// bezahlten Fehler abgeleitet; die Fundstelle steht jeweils dabei. +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' + +const WURZEL = join(__dirname, '..') +const SRC = join(WURZEL, 'src') + +function quelldateien(ordner: string): string[] { + const ergebnis: string[] = [] + for (const name of readdirSync(ordner)) { + const pfad = join(ordner, name) + if (statSync(pfad).isDirectory()) { + ergebnis.push(...quelldateien(pfad)) + } else if (/\.(ts|tsx)$/.test(name)) { + ergebnis.push(pfad) + } + } + return ergebnis +} + +function relativPosix(pfad: string): string { + return relative(WURZEL, pfad).split(sep).join('/') +} + +const dateien = quelldateien(SRC).map((pfad) => ({ + pfad: relativPosix(pfad), + inhalt: readFileSync(pfad, 'utf8'), +})) + +describe('R1 — Win32 lebt nur an den zwei benannten Orten', () => { + // § 4.3 R1: kein koffi-Import irgendwo sonst. Der zweite Ort ist die + // Prozess-Leine im Haupt — Begründung im Kopf von src/haupt/leine.ts: + // Das Job-Handle muss bei dem Prozess liegen, dessen Tod alles mitreißt. + // Der DRITTE (seit 01.09.2026) ist der Fensterrand: DwmSetWindowAttribute + // braucht das Fensterhandle und kann deshalb nur im Haupt liegen. Die + // Regel bleibt, was sie war — „Win32 nur an benannten Orten"; sie zählt + // jetzt drei statt zwei, und der Test hält alles andere weiterhin dicht. + const erlaubt = new Set([ + 'src/haupt/leine.ts', + 'src/haupt/fensterrand.ts', + 'src/kern/laufwerk/win32.ts', + ]) + + it('koffi wird nirgendwo sonst angefasst', () => { + const verstoesse = dateien + .filter((d) => !erlaubt.has(d.pfad)) + .filter((d) => /from\s+['"]koffi['"]|require\(\s*['"]koffi['"]\s*\)/.test(d.inhalt)) + .map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) +}) + +describe('R2/R4 — kein Fehlschlag verschwindet still', () => { + it('kein leerer catch-Block', () => { + // R4: „Kein stilles catch {}" — ein except-pass hat einmal eine Stunde + // gekostet (AGENTS.md). Wer einen Fehler bewusst trägt, schreibt hin warum. + const verstoesse = dateien + .filter((d) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(d.inhalt)) + .map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) + + it('kein catch, das leere Listen/Objekte als Antwort erfindet', () => { + // R2: Fünfmal catch(() => []) im alten UI — jeder verpasste Abruf hieß + // „es gibt keine Jobs", die Liste leerte sich im Sekundentakt. + const muster = /catch\s*(\([^)]*\))?\s*\{\s*return\s+(\[\s*\]|\{\s*\})/ + const pfeilMuster = /catch\s*\(\s*\(\s*\)\s*=>\s*(\[\s*\]|\{\s*\})\s*\)/ + const verstoesse = dateien + .filter((d) => muster.test(d.inhalt) || pfeilMuster.test(d.inhalt)) + .map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) +}) + +describe('§ 4.1 — kein HTTP-Server, kein Port, kein localhost', () => { + it('niemand öffnet einen Server oder lauscht auf einem Port', () => { + // Ein lokaler Webserver in einer Einzelplatz-Anwendung ist eine offene + // Tür ohne Gegenwert (§ 4.1/§ 14). ELECTRON_RENDERER_URL (dev-Server von + // electron-vite) ist Werkzeug, kein Programmteil — der Treffer auf den + // reinen Variablennamen ist erlaubt. + const muster = /createServer\s*\(|\.listen\s*\(\s*\d|from\s+['"]express['"]|localhost:\d/ + const verstoesse = dateien.filter((d) => muster.test(d.inhalt)).map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) +}) + +describe('Quelltext-Hygiene — keine rohen Steuerbytes', () => { + it('keine Datei enthält Steuerzeichen außer Tab/LF/CR', () => { + // Zweimal passiert (30.08.2026): Escape-Sequenzen wie \x00 landeten + // als ECHTE Bytes in der Datei — grep meldet „Binary file", Diffs + // leiden, und ein Test sah dadurch anders aus, als sein Quelltext las. + const testDateien = readdirSync(join(WURZEL, 'test')) + .filter((name) => /\.(ts|tsx)$/.test(name)) + .map((name) => ({ + pfad: `test/${name}`, + inhalt: readFileSync(join(WURZEL, 'test', name), 'utf8'), + })) + const verstoesse = [...dateien, ...testDateien] + .filter((d) => /[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(d.inhalt)) + .map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) +}) + +describe('Nachrichten-Schema — der Prüfer kennt JEDE Nachrichtenart', () => { + // Der teure Fund vom 01.09.2026: 'titel-lesen' und 'rip-ueberspringen' + // standen in der FensterNachricht-Union, aber NICHT in + // istFensterNachricht(). Der Kern verwarf sie — still. Im Fenster stand + // ewig „Rippy liest die Titel …", und nirgends stand ein Grund. + // + // Dieser Test liest den Quelltext: Jede `art: '…'` der Union muss im + // Prüfer wörtlich vorkommen. Eine neue Nachricht ohne Prüfung fällt + // damit sofort auf, statt erst am lebenden Objekt. + const schema = readFileSync(join(WURZEL, 'src', 'gemeinsam', 'nachrichten.ts'), 'utf8') + + function artenAus(unionName: string): string[] { + const start = schema.indexOf(`export type ${unionName} =`) + expect(start, `${unionName} nicht gefunden`).toBeGreaterThan(-1) + // Der Union-Block reicht bis zur nächsten Deklaration auf oberster Ebene. + const rest = schema.slice(start + `export type ${unionName} =`.length) + const ende = rest.indexOf('\nexport ') + const block = ende > 0 ? rest.slice(0, ende) : rest + return [...new Set([...block.matchAll(/art:\s*'([a-z0-9-]+)'/g)].map((m) => m[1]))] + } + + it('istFensterNachricht kennt jede FensterNachricht', () => { + const pruefer = schema.slice(schema.indexOf('export function istFensterNachricht')) + const fehlend = artenAus('FensterNachricht').filter((art) => !pruefer.includes(`'${art}'`)) + expect(fehlend).toEqual([]) + }) + + it('istKernNachricht kennt jede KernNachricht', () => { + const pruefer = schema.slice(schema.indexOf('export function istKernNachricht')) + const fehlend = artenAus('KernNachricht').filter((art) => !pruefer.includes(`'${art}'`)) + expect(fehlend).toEqual([]) + }) + + it('der Kern verwirft nichts still', () => { + // Wer eine Nachricht wegwirft, sagt WARUM (R4). + const kern = readFileSync(join(WURZEL, 'src', 'kern', 'index.ts'), 'utf8') + expect(kern).not.toMatch(/if\s*\(!istFensterNachricht\([^)]*\)\)\s*return/) + }) +}) + +describe('§ 6.7 — EINE Quelle für Einstellungen', () => { + it('node:sqlite wird nur in kern/speicher/db.ts angefasst', () => { + // Der rc11-Fund: Oberfläche schrieb in die Datenbank, der Betrieb las + // aus einer Datei, die niemand schrieb. Hier ist die Datenbank EIN Modul. + const verstoesse = dateien + .filter((d) => d.pfad !== 'src/kern/speicher/db.ts') + .filter((d) => /['"]node:sqlite['"]/.test(d.inhalt)) + .map((d) => d.pfad) + expect(verstoesse).toEqual([]) + }) +})