// HandBrakes Scan einer Datei — welche Ton- und Untertitelspuren stecken // WIRKLICH in der Roh-Datei? (5.4.0) // // Warum das nötig ist: Der Auswahl-Dialog zeigt die Sprachen, die MakeMKV // im Info-Lauf an der DISC gemessen hat. Ob sie alle in der MKV landen, // entscheidet MakeMKVs Auswahlregel (siehe werkzeuge/spurauswahl.ts). Vor // der Kompression wird deshalb die Datei selbst gefragt — fehlt eine // gewünschte Sprache, steht das im Protokoll, statt dass ein leerer // Wunsch still durchrutscht. // // Ausgabeformat am mitgelieferten HandBrake 1.11 gemessen (01.09.2026, // Regel D): `HandBrakeCLI --scan --json -i -t 1` schreibt ein // Textprotokoll, darin EINMAL „JSON Title Set:" gefolgt von einem // JSON-Objekt, und danach weitere Zeilen („HandBrake has exited."). Das // Objekt endet also NICHT am Textende — es wird über die Klammern // ausgeschnitten. Darin: TitleList[0].AudioList[].LanguageCode (ISO-639-2, // z. B. „jpn"), .SubtitleList[].LanguageCode, .Duration.{Hours,Minutes,Seconds}. import { werkzeugSpawn } from '../werkzeuge/aufruf' import { textVon } from '../rip/parser' export interface HbSpuren { audio: Array<{ nr: number; lang: string; beschreibung: string }> untertitel: Array<{ nr: number; lang: string; beschreibung: string }> dauerS: number } export function buildScanArgs(datei: string): string[] { return ['--scan', '--json', '--input', datei, '--title', '1'] } /** Das JSON-Objekt hinter „JSON Title Set:" ausschneiden (pur, testbar). */ export function spurenAusScan(ausgabe: string): HbSpuren | null { const marke = ausgabe.indexOf('JSON Title Set:') if (marke < 0) return null const start = ausgabe.indexOf('{', marke) if (start < 0) return null let tiefe = 0 let ende = -1 for (let i = start; i < ausgabe.length; i++) { const zeichen = ausgabe[i] if (zeichen === '{') tiefe += 1 else if (zeichen === '}') { tiefe -= 1 if (tiefe === 0) { ende = i break } } } if (ende < 0) return null let daten: unknown try { daten = JSON.parse(ausgabe.slice(start, ende + 1)) } catch { // Kein gültiges JSON heißt „nicht auswertbar" — das ist ein null, kein // erfundener leerer Befund (R2). return null } if (typeof daten !== 'object' || daten === null) return null const liste = (daten as { TitleList?: unknown }).TitleList if (!Array.isArray(liste) || liste.length === 0) return null const titel = liste[0] as Record const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => { if (!Array.isArray(feld)) return [] return feld.map((s, i) => { const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record return { nr: i + 1, lang: String(eintrag.LanguageCode ?? '').toLowerCase(), beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''), } }) } const dauer = (titel.Duration ?? {}) as Record const zahl = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0) return { audio: spuren(titel.AudioList), untertitel: spuren(titel.SubtitleList), dauerS: zahl(dauer.Hours) * 3600 + zahl(dauer.Minutes) * 60 + zahl(dauer.Seconds), } } /** Fragt die Datei — null heißt „konnte nicht nachsehen", nie „keine Spuren". */ export function spurenScannen(programm: string, datei: string, timeoutMs = 120_000): Promise { return new Promise((fertig) => { if (programm.length === 0) { fertig(null) return } const kind = werkzeugSpawn(programm, buildScanArgs(datei), { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const stuecke: Buffer[] = [] kind.stdout.on('data', (s: Buffer) => stuecke.push(s)) kind.stderr.on('data', (s: Buffer) => stuecke.push(s)) const zeiger = setTimeout(() => kind.kill(), timeoutMs) kind.on('error', () => { clearTimeout(zeiger) fertig(null) }) kind.on('close', () => { clearTimeout(zeiger) fertig(spurenAusScan(textVon(Buffer.concat(stuecke)))) }) }) } /** Welche gewünschten Sprachen FEHLEN in der Datei? (pur) */ export function fehlendeSprachen( spuren: HbSpuren, gewuenscht: { audio: readonly string[]; untertitel: readonly string[] }, ): { audio: string[]; untertitel: string[] } { const da = (liste: Array<{ lang: string }>): Set => new Set(liste.map((s) => s.lang)) const audioDa = da(spuren.audio) const untertitelDa = da(spuren.untertitel) return { audio: gewuenscht.audio.filter((l) => !audioDa.has(l)), untertitel: gewuenscht.untertitel.filter((l) => !untertitelDa.has(l)), } }