Files
rippy/rippy-windows/src/kern/komprimieren/handbrake.ts
T
HitonabiandClaude Fable 5.1 6dd1b0bc7a feat(v5): Ein Smoke-Beweis, der KLICKT — Playwright durch das echte Programm
WAS: `npm run klick` startet das ECHTE Rippy (Electron, Haupt, Kern,
Fenster, Datenbank) mit eigenem Profil (--profil, --klick-smoke: ohne
Tray, Updater, Toasts), einem nachgebauten Laufwerk (RIPPY_FAKE_LAUFWERK)
und nachgebauten Werkzeugen (bau/klick/fake-*.cjs) — und klickt sich
durch: Rippen → Dialog → Sprachen je Titel (jpn statt deu) → Rollen auf
Folge → Staffel 1, ab Folge 3 → Vorschau → Rip → Warteschlange →
Kompression → Ablage als S01E03/S01E04 unter Serien\…\Season 01 →
Einstellungen/Roh-Dateien → Roh loeschen → Ereignisse. Bilder liegen in
beweise/klick/. Die Naht dafuer (kern/werkzeuge/aufruf.ts): Zeigt ein
Werkzeug-Pfad auf ein Node-Skript, laeuft es ueber die eigene Laufzeit
(ELECTRON_RUN_AS_NODE) — fuer EXE-Pfade aendert sich nichts.

WARUM: Beide Fehler vom 01.09.2026 waren Verkabelung; 261 Unit-Tests
sahen keinen. Der Klick-Beweis haette beide gefunden.

NEBENBEFUND: Die CSP blockte eingebettete Schrift-Teile (data:-Fonts der
fontsource-Pakete) — Konsolenfehler im gebauten Fenster, jetzt font-src.
Dazu test/messung.spuren.test.ts: HandBrake-Scan einer echten Roh-Datei
auf Verlangen (RIPPY_MESSUNG_MKV).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-01 22:22:04 +02:00

291 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { werkzeugSpawn } from '../werkzeuge/aufruf'
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[]
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
}
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
// Testdatei mit jpn/deu/deu/eng-Ton und deu/eng-Untertiteln, Ausgabe per
// ffprobe geprüft — Regel D):
// --audio-lang-list deu,eng → je Sprache die ERSTE Spur (2 Spuren)
// --audio-lang-list deu,eng --first-audio → dasselbe (je Sprache eine)
// --audio-lang-list deu,eng --all-audio → ALLE passenden Spuren (3)
// --subtitle-lang-list eng,deu --all-subtitles → Ausgabe-Reihenfolge = LISTEN-
// Reihenfolge (eng zuerst), NICHT Quell-Reihenfolge
// --subtitle-default=1 → die erste Untertitelspur trägt default=1 — und
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
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')
// Nur mit einer Sprachliste ist klar, WELCHE Spur die erste ist — die
// Liste bestimmt die Reihenfolge (gemessen, siehe oben).
if (optionen.untertitelStandard === true && untertitel.length > 0) args.push('--subtitle-default=1')
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 0100 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[]
untertitelStandard?: boolean
/** 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<HbErgebnis> {
return new Promise((fertig) => {
const args = buildHandbrakeArgs(optionen)
args.push(...(optionen.extraArgs ?? []))
const kind = werkzeugSpawn(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))
})
})
}