From ca338ad4672e0d19cb0655c951e5d72b59a57983 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Sun, 30 Aug 2026 18:16:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(v5):=20Windows-Pfadregeln=20ausdruecklich?= =?UTF-8?q?=20=E2=80=94=20path.win32=20statt=20join?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Ampel (Linux) brach an den Katalog-Tests: das plattformabhaengige path.join mischte dort Schraegstriche in Windows-Pfade. Rippy v5 baut NUR Windows-Pfade — katalog.ts, handbrake.ts und ablauf/rippen.ts rechnen jetzt ausdruecklich mit path.win32 und damit auf jeder Plattform gleich. Im node:24-Container nachgestellt: vorher 2 failed, mit dem Fix lokal 96 gruen. Co-Authored-By: Claude Fable 5 --- rippy-windows/src/kern/ablauf/rippen.ts | 4 +- .../src/kern/komprimieren/handbrake.ts | 271 ++++++++++++++++++ .../src/kern/komprimieren/presets.ts | 69 +++++ rippy-windows/src/kern/werkzeuge/katalog.ts | 7 +- 4 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 rippy-windows/src/kern/komprimieren/handbrake.ts create mode 100644 rippy-windows/src/kern/komprimieren/presets.ts diff --git a/rippy-windows/src/kern/ablauf/rippen.ts b/rippy-windows/src/kern/ablauf/rippen.ts index 542e13e..9dc95b5 100644 --- a/rippy-windows/src/kern/ablauf/rippen.ts +++ b/rippy-windows/src/kern/ablauf/rippen.ts @@ -4,8 +4,10 @@ // automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die // Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die // Mitgliedschaft. -import { join } from 'node:path' +import { win32 as pfad } from 'node:path' import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' + +const { join } = pfad import { rippen } from '../rip/makemkv' interface LaufenderRip { diff --git a/rippy-windows/src/kern/komprimieren/handbrake.ts b/rippy-windows/src/kern/komprimieren/handbrake.ts new file mode 100644 index 0000000..52bf1d0 --- /dev/null +++ b/rippy-windows/src/kern/komprimieren/handbrake.ts @@ -0,0 +1,271 @@ +// 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)) + }) + }) +} diff --git a/rippy-windows/src/kern/komprimieren/presets.ts b/rippy-windows/src/kern/komprimieren/presets.ts new file mode 100644 index 0000000..099fa53 --- /dev/null +++ b/rippy-windows/src/kern/komprimieren/presets.ts @@ -0,0 +1,69 @@ +// Preset-Wahl je Disc-Typ (KONZEPT § 6.10) — Regeln aus ripping.py, dort +// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete +// 4K auf 1080p herunter und DVDs hinauf. + +export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30' + +/** Einstellungs-Schlüssel je Disc-Typ. */ +export const PRESET_SCHLUESSEL: Record = { + dvd: 'transcodePresetDvd', + bluray: 'transcodePresetBluray', + uhd: 'transcodePresetUhd', +} + +// Reservierter Wert: „diesen Typ NICHT komprimieren". Kein echtes +// HandBrake-Preset heißt so (gegen alle 90 aus --preset-list geprüft). +// Grund: 4K verlustfrei durchreichen, DVDs trotzdem schrumpfen. +export const PRESET_KEINE = 'keine' + +export type Einstellungen = Record + +/** Soll dieser Disc-Typ nach dem Rip komprimiert werden? (pur) */ +export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen): boolean { + if (einstellungen['transcodeEnabled'] === false || einstellungen['transcodeEnabled'] === 'false') { + return false + } + const schluessel = PRESET_SCHLUESSEL[discTyp] ?? '' + if (schluessel.length > 0) { + const wert = String(einstellungen[schluessel] ?? '').trim() + if (wert === PRESET_KEINE) return false + } + return true +} + +/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur) + * Kette: Typ-Preset → allgemeines transcodePreset → DEFAULT. PRESET_KEINE + * wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als Preset-Namen + * liefern, sonst bekäme HandBrake `--preset keine` und scheiterte. */ +export function presetFuer(discTyp: string, einstellungen: Einstellungen): string { + const schluessel = PRESET_SCHLUESSEL[discTyp] ?? '' + if (schluessel.length > 0) { + const eigen = String(einstellungen[schluessel] ?? '').trim() + if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen + } + const allgemein = String(einstellungen['transcodePreset'] ?? '').trim() + return allgemein.length > 0 ? allgemein : DEFAULT_HB_PRESET +} + +/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein; + * ein „DEU" aus einem Eingabefeld soll die Auswahl nicht still lahmlegen. */ +export function sprachliste(wert: string | readonly string[] | null | undefined): string[] { + if (wert === null || wert === undefined) return [] + const teile = typeof wert === 'string' ? wert.split(',') : [...wert] + const sauber: string[] = [] + for (const teil of teile) { + const code = String(teil).trim().toLowerCase() + if (code.length > 0 && !sauber.includes(code)) sauber.push(code) + } + return sauber +} + +/** Endung → HandBrake-Container. Am mitgelieferten HandBrake 1.11.2 + * gegengeprüft (`--help`, Abschnitt `-f, --format`). */ +export const FORMATE: Record = { + '.mkv': 'av_mkv', + '.mp4': 'av_mp4', + '.m4v': 'av_mp4', + '.mov': 'av_mov', + '.webm': 'av_webm', +} diff --git a/rippy-windows/src/kern/werkzeuge/katalog.ts b/rippy-windows/src/kern/werkzeuge/katalog.ts index 0ea101a..1730314 100644 --- a/rippy-windows/src/kern/werkzeuge/katalog.ts +++ b/rippy-windows/src/kern/werkzeuge/katalog.ts @@ -18,7 +18,12 @@ // (katalog.py, 28.08.2026). import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { delimiter, join } from 'node:path' +// Ausdrücklich die WINDOWS-Pfadregeln: Rippy v5 baut nur Windows-Pfade, +// und die CI-Tests laufen auf Linux — dort würde das plattformabhängige +// join() Schrägstriche hineinmischen (genau daran brach Ampel-Lauf 226). +import { win32 as pfad } from 'node:path' + +const { delimiter, join } = pfad export interface Werkzeug { titel: string