// HandBrakeCLI ansprechen — Kommandobau, Lauf und die Auswertung mit ALLEN // bezahlten Fallen aus dem Docker-Zweig (ripping.py, jeweils am echten // HandBrake gemessen): // // - `--aencoder`, NICHT `--audio-codec`: Den zweiten Schalter gibt es // nicht, und ein unbekannter Schalter ist für HandBrake KEIN Fehler — // Rückgabewert 0. Das kostete einen fertigen 16,5-GB-Rip (30.08.2026). // - `--format` aus der Endung erzwingen: Der Container kommt sonst aus // dem PRESET — ein MP4-Preset schreibt title.mp4 neben das verlangte // .mkv und meldet Erfolg (29.08.2026). // - Fortschritt NUR aus der `Encoding: task N of M, x %`-Zeile — der // Scan-Durchlauf läuft vorher bis 100 % und ließ die Anzeige bei 99 % // kleben, während der Encode bei 1 % war (25.07.2026). // - Die letzten 12 Ausgabezeilen aufheben: Bei Rückgabewert 0 sind sie // im Fehlerfall die einzige Auskunft. // - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung — // sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus. import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' // win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux. import { win32 as pfad } from 'node:path' import { textVon } from '../rip/parser' import { FORMATE } from './presets' const { basename, dirname, extname, join } = pfad export interface HandbrakeArgsOptionen { eingabe: string ausgabe: string preset: string audioSprachen?: readonly string[] untertitelSprachen?: readonly string[] } export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] { const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset] const format = FORMATE[extname(optionen.ausgabe).toLowerCase()] if (format !== undefined) args.push('--format', format) const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0) if (audio.length > 0) args.push('--audio-lang-list', audio.join(',')) args.push('--first-audio') // `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist // der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist. args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac') const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0) if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(',')) args.push('--all-subtitles') return args } // Formatstrings aus dem HandBrakeCLI-Binary gelesen (Regel D): // Scanning title %d of %d, preview %d, %.2f %% → Scan, NICHT Encode // Encoding: task %d of %d, %.2f %% → Encode // Encoding: … Searching for start time, %.2f → Vorlauf, NICHT Encode const HB_ENCODE = /^Encoding:\s*task\s+(\d+)\s+of\s+(\d+),\s*(\d+(?:\.\d+)?)\s*%/ /** Encode-Fortschritt 0–100 oder -1 („keine Angabe" — ein echtes 0 % muss * von „nichts" unterscheidbar bleiben). Mehrere Durchläufe (task N of M) * werden zusammengerechnet, sonst springt die Anzeige zurück auf 0. */ export function progressAusZeile(zeile: string): number { const treffer = HB_ENCODE.exec(zeile.trim()) if (treffer === null) return -1 const aktuell = Number(treffer[1]) const gesamt = Number(treffer[2]) const prozent = Number(treffer[3]) if (gesamt < 1 || aktuell < 1) return -1 return Math.floor(((aktuell - 1) * 100 + prozent) / gesamt) } /** „Invalid preset X" → X (Wortlaut aus dem echten Lauf, 26.07.2026). */ export function unbekanntesPreset(zeile: string): string { const text = (zeile ?? '').trim() const kopf = 'Invalid preset ' return text.startsWith(kopf) ? text.slice(kopf.length).trim() : '' } /** „unknown option (--x)" → --x. Rückgabewert bleibt dabei 0! */ export function unbekannterSchalter(zeile: string): string { const treffer = /unknown option \(([^)]*)\)/.exec(zeile ?? '') return treffer === null ? '' : treffer[1].trim() } export const HB_ZEILEN_PUFFER = 12 export const HB_ZEILEN_MELDUNG = 4 /** HandBrakes letzte Worte als Anhang für eine Fehlermeldung (pur). */ export function hbSchluss(zeilen: readonly string[]): string { const sauber = zeilen.map((z) => z.trim()).filter((z) => z.length > 0) if (sauber.length === 0) return '' return ' — HandBrake sagte zuletzt: ' + sauber.slice(-HB_ZEILEN_MELDUNG).join(' | ') } /** Dieselbe Datei mit anderer Endung im selben Ordner — oder ''. */ export function dateiDaneben(erwartet: string, existiert: (p: string) => boolean = existsSync): string { const ordner = dirname(erwartet) const stamm = basename(erwartet, extname(erwartet)) for (const endung of Object.keys(FORMATE)) { const kandidat = join(ordner, stamm + endung) if (kandidat !== erwartet && existiert(kandidat)) return kandidat } return '' } /** Auswertung des Laufs — pur, zeilenweise, testbar ohne HandBrake. */ export class HbAuswertung { prozent = -1 falschesPreset = '' falscherSchalter = '' letzteZeilen: string[] = [] verarbeite(zeile: string): { prozent?: number } { if (zeile.trim().length > 0) { this.letzteZeilen.push(zeile) if (this.letzteZeilen.length > HB_ZEILEN_PUFFER) this.letzteZeilen.shift() } if (this.falschesPreset.length === 0) this.falschesPreset = unbekanntesPreset(zeile) if (this.falscherSchalter.length === 0) this.falscherSchalter = unbekannterSchalter(zeile) const p = progressAusZeile(zeile) if (p >= 0 && p !== this.prozent) { this.prozent = p return { prozent: p } } return {} } /** Das Urteil NACH Prozessende — in der bewiesenen Reihenfolge: * falscher Schalter (kommt mit Code 0!) → Erfolg → Datei daneben → * Erfolg ohne Datei → falsches Preset → Code mit letzten Worten. */ urteil( returncode: number | null, ausgabe: string, existiert: (p: string) => boolean = existsSync, ): { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'error'; fehler: string } { if (this.falscherSchalter.length > 0) { return { status: 'error', fehler: `Rippy hat HandBrake den Schalter „${this.falscherSchalter}" übergeben, den diese ` + 'HandBrake-Fassung nicht kennt. Das ist ein Fehler in Rippy, keine Einstellung — bitte melden.' + hbSchluss(this.letzteZeilen), } } if (returncode === 0 && existiert(ausgabe)) { return { status: 'success', ausgabe } } if (returncode === 0) { const daneben = dateiDaneben(ausgabe, existiert) if (daneben.length > 0) { return { status: 'success', ausgabe: daneben, hinweis: `HandBrake hat ${basename(daneben)} geschrieben statt ${basename(ausgabe)} (der Container kommt aus dem Preset).`, } } return { status: 'error', fehler: `HandBrake meldet Erfolg, aber es ist keine Datei entstanden (Zielordner: ${dirname(ausgabe)}).` + hbSchluss(this.letzteZeilen), } } if (this.falschesPreset.length > 0) { return { status: 'error', fehler: `Dieses HandBrake kennt das Preset „${this.falschesPreset}" nicht. Preset-Namen unterscheiden ` + 'sich zwischen HandBrake-Versionen — in den Einstellungen eines aus der Liste wählen, die von ' + 'diesem HandBrake selbst kommt.', } } return { status: 'error', fehler: `HandBrake endete mit Code ${returncode ?? '?'}` + hbSchluss(this.letzteZeilen), } } } /** HandBrake-Ausgabe in Zeilen: CR UND LF trennen (HandBrake schreibt den * Fortschritt mit \r). Je Zeile einzeln dekodiert — HandBrake schreibt * denselben Pfad mal UTF-8, mal CP850 in einen Strom; ein `ü` (CP850 * 0x81) tötete so die Python-Fassung. textVon ersetzt statt zu sterben. */ export class HbZeilenLeser { 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 (;;) { let trenner = -1 for (let i = 0; i < this.rest.length; i++) { if (this.rest[i] === 0x0a || this.rest[i] === 0x0d) { trenner = i break } } if (trenner < 0) break if (trenner > 0) zeilen.push(textVon(this.rest.subarray(0, trenner))) // \r\n als EIN Trenner behandeln. const naechstes = trenner + 1 const doppelt = this.rest[trenner] === 0x0d && naechstes < this.rest.length && this.rest[naechstes] === 0x0a this.rest = this.rest.subarray(doppelt ? naechstes + 1 : naechstes) } 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 type HbErgebnis = | { status: 'success'; ausgabe: string; hinweis?: string } | { status: 'cancelled' } | { status: 'error'; fehler: string } export interface HbLaufOptionen { programm: string eingabe: string ausgabe: string preset: string audioSprachen?: readonly string[] untertitelSprachen?: readonly string[] /** Zusätzliche Schalter ANS ENDE (z. B. --stop-at duration:5 im Messlauf). */ extraArgs?: readonly string[] aufFortschritt?: (prozent: number) => void abbruch?: AbortSignal } export function komprimieren(optionen: HbLaufOptionen): Promise { return new Promise((fertig) => { const args = buildHandbrakeArgs(optionen) args.push(...(optionen.extraArgs ?? [])) const kind = spawn(optionen.programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) const auswertung = new HbAuswertung() const leser = new HbZeilenLeser() let abgebrochen = false const abbrechen = (): void => { abgebrochen = true kind.kill() } optionen.abbruch?.addEventListener('abort', abbrechen, { once: true }) const zeile = (z: string): void => { const ergebnis = auswertung.verarbeite(z) if (ergebnis.prozent !== undefined) optionen.aufFortschritt?.(ergebnis.prozent) } 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) }) }) kind.on('close', (code) => { optionen.abbruch?.removeEventListener('abort', abbrechen) leser.schluss().forEach(zeile) if (abgebrochen) { fertig({ status: 'cancelled' }) return } fertig(auswertung.urteil(code, optionen.ausgabe)) }) }) }