feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s

komprimieren/: handbrake.ts traegt ALLE bezahlten Fallen aus dem
Docker-Zweig — --aencoder statt --audio-codec (mit Anti-Regressions-
Wächter im Test: der alte Test hatte den Fehler FESTGESCHRIEBEN),
--format aus der Endung (Container kommt sonst aus dem Preset),
Fortschritt NUR aus der Encoding-Zeile (Scan-99%-Falle), letzte 12
Zeilen fuer den Fehlerfall, unknown-option-Erkennung VOR allem anderen
(kommt mit Rueckgabewert 0!), Datei-daneben-Suche, CR-Zeilentrennung
mit 0x81-Kodierungsfalle. encoder.ts misst statt behauptet (--help,
--preset-list, Familien statt Sammelbegriff). presets.ts: je Disc-Typ,
PRESET_KEINE, Sprachlisten.

ablage/: struktur.ts (sichere Namen, '<Titel> (Jahr)', Season NN,
Episoden-Zuordnung NUR bei Eindeutigkeit), nfo.ts (Kodi-Schema),
medienserver.ts (Jellyfin/Emby/Kodi-Refresh, wirft nie).

ablauf/pipeline.ts ERSETZT rippen.ts: Rip → Kompression → Ablage als
EINE Kette; Kompressions-Fehler laesst den verlustfreien Rip STEHEN und
nennt den Ort; Lesefehler ueberleben bis in die Fertig-Meldung. Roh wird
in dieser Etappe grundsaetzlich behalten (Loeschen erst mit W-5-UI).

GEMESSEN am echten System (30.08.2026):
- Encoder: 22 Stueck, Backends cpu-x264/x265/av1 + vce + vce-av1
  (RX 9070 XT als VCE-Familie — nicht unter Intel-Namen), 107 Presets.
- Probe-Encode 5 s Material: success in 45,5 s.
- VOLLBEWEIS: Spartacus-Rohschnitt (17,71 GB — die Datei, die in rc11
  am falschen Schalter starb) mit Preset 'H.265 VCN 1080p' in 2,1 min
  auf 1,17 GB komprimiert und nach Jellyfin-Schema abgelegt:
  E:\Rippy-v5-Beweis\Filme\Spartacus - Gods of the Arena - Disc 2
  (2011)\ mit movie.nfo.
- Nebenbefund: Der Evangelion-Roh-Rip vom 29.08. traegt 'This file was
  not properly finalized' im Kopf — 23,5 GB, die wie fertig aussehen
  und es nicht sind (der abgebrochene rc11-Rip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-30 18:26:00 +02:00
co-authored by Claude Fable 5
parent ca338ad467
commit 3dc296f071
14 changed files with 1300 additions and 155 deletions
@@ -0,0 +1,101 @@
// Encoder-Fähigkeiten — GEMESSEN, nicht behauptet (KONZEPT § 4.2,
// Regeln aus caps.py, dort am echten HandBrake abgenommen):
//
// - `--help`, Abschnitt `-e, --encoder`: NUR was DIESE Maschine wirklich
// benutzen kann (HandBrake probiert Hardware beim Start selbst an).
// - `--preset-list`: ALLE mitgelieferten Preset-NAMEN (Struktur ist die
// Einrückung — Namen stehen mit genau vier Leerzeichen).
// - Hardware nach FAMILIE unterscheiden (nvenc/qsv/vce/vaapi), sonst
// läuft AMD unter einem Intel-Namen (Commander-PC: RX 9070 XT).
import { spawn } from 'node:child_process'
import { textVon } from '../rip/parser'
const HB_ENCODER_KOPF = /^-e,\s*--encoder\b/
/** Video-Encoder aus `--help` — der Block endet an der nächsten Option. */
export function parseEncoderListe(hilfeText: string): string[] {
const encoder: string[] = []
let imBlock = false
for (const zeile of (hilfeText ?? '').split(/\r?\n/)) {
const inhalt = zeile.trim()
if (!imBlock) {
if (HB_ENCODER_KOPF.test(inhalt)) imBlock = true
continue
}
if (inhalt.length === 0) continue
if (inhalt.startsWith('-')) break
encoder.push(inhalt.split(/\s+/)[0])
}
return encoder
}
// Reihenfolge = Anzeige-Reihenfolge; AV1 in Hardware sichtbar machen.
const HW_FAMILIEN: ReadonlyArray<readonly [string, string]> = [
['nvenc', 'nvenc'],
['qsv', 'qsv'],
['vce', 'vce'],
['vaapi', 'vaapi'],
]
/** HandBrake-Encoder-Namen → Backend-Kennungen fürs UI. */
export function leiteBackendsAb(hbEncoder: readonly string[]): string[] {
const namen = new Set(hbEncoder.map((e) => e.toLowerCase()))
const gefunden: string[] = []
if ([...namen].some((n) => n.startsWith('x264'))) gefunden.push('cpu-x264')
if ([...namen].some((n) => n.startsWith('x265'))) gefunden.push('cpu-x265')
if ([...namen].some((n) => n.startsWith('svt_av1'))) gefunden.push('cpu-av1')
for (const [kennzeichen, backend] of HW_FAMILIEN) {
const treffer = [...namen].filter((n) => n.includes(kennzeichen))
if (treffer.length === 0) continue
gefunden.push(backend)
if (treffer.some((n) => n.includes('av1'))) gefunden.push(`${backend}-av1`)
}
return gefunden
}
/** Preset-Namen aus `--preset-list`: genau vier Leerzeichen Einrückung.
* Kategorien (0), Beschreibungen (8) und Logzeilen fallen so weg. */
export function parsePresetListe(text: string): string[] {
const presets: string[] = []
for (const zeile of (text ?? '').split(/\r?\n/)) {
if (zeile.trim().length === 0) continue
const einrueckung = zeile.length - zeile.replace(/^ +/, '').length
if (einrueckung === 4) presets.push(zeile.trim())
}
return presets
}
function lauf(programm: string, args: string[], timeoutMs: number): Promise<string> {
return new Promise((fertig) => {
const kind = spawn(programm, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
const stuecke: Buffer[] = []
kind.stdout.on('data', (s: Buffer) => stuecke.push(s))
kind.stderr.on('data', (s: Buffer) => stuecke.push(s))
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
kind.on('error', () => {
clearTimeout(zeiger)
fertig('')
})
kind.on('close', () => {
clearTimeout(zeiger)
fertig(textVon(Buffer.concat(stuecke)))
})
})
}
export interface EncoderAuskunft {
encoder: string[]
backends: string[]
presets: string[]
}
/** Fragt HandBrake selbst — leer, wenn es nicht da ist (kein Raten). */
export async function encoderMessen(programm: string): Promise<EncoderAuskunft> {
if (programm.length === 0) return { encoder: [], backends: [], presets: [] }
const [hilfe, presetListe] = await Promise.all([
lauf(programm, ['--help'], 30_000),
lauf(programm, ['--preset-list'], 30_000),
])
const encoder = parseEncoderListe(hilfe)
return { encoder, backends: leiteBackendsAb(encoder), presets: parsePresetListe(presetListe) }
}