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>
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
// 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 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[]
|
||
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))
|
||
})
|
||
})
|
||
}
|