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
+184
View File
@@ -0,0 +1,184 @@
// Tests der HandBrake-Schicht — die Testfälle aus test_ripping/caps
// wandern sinngemäß mit. Der wichtigste ist eine Anti-Regression: Der
// alte Test FORDERTE den falschen Schalter ein (assert "--audio-codec"
// in cmd) und schrieb den Fehler damit fest, statt ihn zu finden.
import { describe, expect, it } from 'vitest'
import {
buildHandbrakeArgs,
dateiDaneben,
HbAuswertung,
hbSchluss,
HbZeilenLeser,
progressAusZeile,
unbekannterSchalter,
unbekanntesPreset,
} from '../src/kern/komprimieren/handbrake'
import {
komprimierenFuer,
presetFuer,
PRESET_KEINE,
sprachliste,
DEFAULT_HB_PRESET,
} from '../src/kern/komprimieren/presets'
describe('buildHandbrakeArgs', () => {
const args = buildHandbrakeArgs({
eingabe: 'E:\\roh\\film_t00.mkv',
ausgabe: 'E:\\fertig\\Film (2009).mkv',
preset: 'H.265 MKV 1080p30',
audioSprachen: ['deu', 'eng'],
untertitelSprachen: ['deu'],
})
it('nutzt --aencoder und NIEMALS --audio-codec (der 16,5-GB-Fund)', () => {
// HandBrake meldet einen unbekannten Schalter mit Rückgabewert 0 —
// dieser Wächter passt auf, dass der Fehler nie zurückkommt.
expect(args).toContain('--aencoder')
expect(args.join(' ')).not.toContain('--audio-codec')
const stelle = args.indexOf('--aencoder')
expect(args[stelle + 1]).toBe('copy')
expect(args).toContain('--audio-fallback')
})
it('erzwingt den Container aus der Endung (--format av_mkv)', () => {
const stelle = args.indexOf('--format')
expect(stelle).toBeGreaterThan(-1)
expect(args[stelle + 1]).toBe('av_mkv')
})
it('Sprachlisten und --all-subtitles/--first-audio', () => {
expect(args[args.indexOf('--audio-lang-list') + 1]).toBe('deu,eng')
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu')
expect(args).toContain('--first-audio')
expect(args).toContain('--all-subtitles')
})
it('ohne Sprachwahl keine Listen-Schalter', () => {
const schlicht = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X' })
expect(schlicht).not.toContain('--audio-lang-list')
expect(schlicht).not.toContain('--subtitle-lang-list')
})
})
describe('progressAusZeile — nur die Encoding-Zeile zählt', () => {
it('liest den Encode-Fortschritt', () => {
expect(progressAusZeile('Encoding: task 1 of 1, 42.57 % (89.31 fps, avg 90.05 fps, ETA 00h12m34s)')).toBe(42)
})
it('der Scan-Durchlauf zählt NICHT (die 99-%-Falle)', () => {
expect(progressAusZeile('Scanning title 1 of 1, preview 7, 70.00 %')).toBe(-1)
expect(progressAusZeile('Encoding: task 1 of 1, Searching for start time, 12.00')).toBe(-1)
})
it('zwei Durchläufe werden zusammengerechnet', () => {
expect(progressAusZeile('Encoding: task 2 of 2, 50.00 %')).toBe(75)
})
it('ein echtes 0 % ist eine Angabe', () => {
expect(progressAusZeile('Encoding: task 1 of 1, 0.87 %')).toBe(0)
})
})
describe('Fehler-Erkennung', () => {
it('unbekanntes Preset (Wortlaut aus dem echten Lauf)', () => {
expect(unbekanntesPreset('Invalid preset Gibt Es Nicht 999p')).toBe('Gibt Es Nicht 999p')
expect(unbekanntesPreset('Valid presets are:')).toBe('')
})
it('unbekannter Schalter (kommt mit Rückgabewert 0!)', () => {
expect(unbekannterSchalter('unknown option (--audio-codec)')).toBe('--audio-codec')
expect(unbekannterSchalter('Encoding: task 1 of 1, 1.00 %')).toBe('')
})
it('hbSchluss hebt die letzten Worte auf', () => {
expect(hbSchluss(['a', '', 'b', 'c', 'd', 'e'])).toBe(' — HandBrake sagte zuletzt: b | c | d | e')
expect(hbSchluss([])).toBe('')
})
})
describe('HbAuswertung.urteil — die bewiesene Reihenfolge', () => {
it('falscher Schalter schlägt ALLES, auch Code 0', () => {
const a = new HbAuswertung()
a.verarbeite('unknown option (--audio-codec)')
a.verarbeite('HandBrake has exited.')
const urteil = a.urteil(0, 'x.mkv', () => true)
expect(urteil.status).toBe('error')
if (urteil.status === 'error') {
expect(urteil.fehler).toContain('--audio-codec')
expect(urteil.fehler).toContain('Fehler in Rippy')
}
})
it('Code 0 + Datei da = Erfolg', () => {
const urteil = new HbAuswertung().urteil(0, 'x.mkv', (p) => p === 'x.mkv')
expect(urteil).toEqual({ status: 'success', ausgabe: 'x.mkv' })
})
it('Code 0, Datei fehlt, liegt daneben → Erfolg MIT Hinweis (Container aus Preset)', () => {
const urteil = new HbAuswertung().urteil(0, 'E:\\ziel\\film.mkv', (p) => p === 'E:\\ziel\\film.mp4')
expect(urteil.status).toBe('success')
if (urteil.status === 'success') {
expect(urteil.ausgabe).toBe('E:\\ziel\\film.mp4')
expect(urteil.hinweis).toContain('Container')
}
})
it('Code 0 ohne Datei: Ordner wird GENANNT, letzte Worte zitiert', () => {
const a = new HbAuswertung()
a.verarbeite('No title found.')
const urteil = a.urteil(0, 'E:\\ziel\\film.mkv', () => false)
expect(urteil.status).toBe('error')
if (urteil.status === 'error') {
expect(urteil.fehler).toContain('E:\\ziel')
expect(urteil.fehler).toContain('No title found.')
}
})
it('unbekanntes Preset wird erklärt', () => {
const a = new HbAuswertung()
a.verarbeite('Invalid preset Uralt 480p')
const urteil = a.urteil(3, 'x.mkv', () => false)
expect(urteil.status).toBe('error')
if (urteil.status === 'error') expect(urteil.fehler).toContain('Uralt 480p')
})
})
describe('dateiDaneben', () => {
it('findet die Datei mit anderer Endung im selben Ordner', () => {
expect(dateiDaneben('E:\\z\\film.mkv', (p) => p === 'E:\\z\\film.mp4')).toBe('E:\\z\\film.mp4')
expect(dateiDaneben('E:\\z\\film.mkv', () => false)).toBe('')
})
})
describe('HbZeilenLeser — CR trennt den Fortschritt', () => {
it('CR, LF und CRLF trennen; halbe Zeilen bleiben liegen', () => {
const leser = new HbZeilenLeser()
expect(leser.hinzu(Buffer.from('Encoding: task 1 of 1, 1.00 %\rEncoding: task 1 of 1, 2.00'))).toEqual([
'Encoding: task 1 of 1, 1.00 %',
])
expect(leser.hinzu(Buffer.from(' %\r\nfertig\n'))).toEqual(['Encoding: task 1 of 1, 2.00 %', 'fertig'])
})
it('das ü aus zwei Kodierungen tötet den Leser NICHT (0x81-Falle)', () => {
const leser = new HbZeilenLeser()
const zeilen = leser.hinzu(
Buffer.concat([Buffer.from('pfad Gl'), Buffer.from([0x81]), Buffer.from('ck.mkv\n')]),
)
expect(zeilen).toHaveLength(1)
expect(zeilen[0]).toContain('pfad Gl')
})
})
describe('presets', () => {
it('komprimierenFuer: globaler Schalter schlägt alles; PRESET_KEINE je Typ', () => {
expect(komprimierenFuer('uhd', { transcodeEnabled: false })).toBe(false)
expect(komprimierenFuer('uhd', { transcodePresetUhd: PRESET_KEINE })).toBe(false)
expect(komprimierenFuer('dvd', { transcodePresetUhd: PRESET_KEINE })).toBe(true)
expect(komprimierenFuer('bluray', {})).toBe(true)
})
it('presetFuer: Typ-Preset → allgemein → Default, nie „keine"', () => {
expect(presetFuer('dvd', { transcodePresetDvd: 'Fast 480p' })).toBe('Fast 480p')
expect(presetFuer('dvd', { transcodePreset: 'Allgemein' })).toBe('Allgemein')
expect(presetFuer('dvd', {})).toBe(DEFAULT_HB_PRESET)
expect(presetFuer('uhd', { transcodePresetUhd: PRESET_KEINE, transcodePreset: 'A' })).toBe('A')
})
it('sprachliste säubert, dedupliziert, erzwingt Kleinschreibung', () => {
expect(sprachliste('deu, eng , ,DEU')).toEqual(['deu', 'eng'])
expect(sprachliste(['ENG', 'eng'])).toEqual(['eng'])
expect(sprachliste(null)).toEqual([])
})
})