fix(v5): Windows-Pfadregeln ausdruecklich — path.win32 statt join
Ampel / ampel (push) Successful in 1m21s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:16:17 +02:00
co-authored by Claude Fable 5
parent 844bca37ff
commit ca338ad467
4 changed files with 349 additions and 2 deletions
+3 -1
View File
@@ -4,8 +4,10 @@
// automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die // automatisch an der Prozess-Leine (R6): Der Main-Prozess hat die
// Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die // Arbeitsgruppe VOR dem Kern-Start angelegt, Kinder erben die
// Mitgliedschaft. // Mitgliedschaft.
import { join } from 'node:path' import { win32 as pfad } from 'node:path'
import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten' import type { GeraeteInfo, RipStatus } from '../../gemeinsam/nachrichten'
const { join } = pfad
import { rippen } from '../rip/makemkv' import { rippen } from '../rip/makemkv'
interface LaufenderRip { interface LaufenderRip {
@@ -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 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[]
/** 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 = 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))
})
})
}
@@ -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<string, string> = {
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<string, string | boolean | undefined>
/** 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<string, string> = {
'.mkv': 'av_mkv',
'.mp4': 'av_mp4',
'.m4v': 'av_mp4',
'.mov': 'av_mov',
'.webm': 'av_webm',
}
+6 -1
View File
@@ -18,7 +18,12 @@
// (katalog.py, 28.08.2026). // (katalog.py, 28.08.2026).
import { spawnSync } from 'node:child_process' import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs' 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 { export interface Werkzeug {
titel: string titel: string