// 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 { werkzeugSpawn } from '../werkzeuge/aufruf' 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 /** 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 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, abbruch?: AbortSignal, ): Promise { return new Promise((fertig, scheitert) => { const kind = werkzeugSpawn(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) // 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) // 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 = werkzeugSpawn( 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, }) }) }) }