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
+171
View File
@@ -0,0 +1,171 @@
// Tests der Ablage — sichere Namen, Zielstruktur, Episoden-Zuordnung, NFO.
// Testfälle sinngemäß aus test_medien.py (Docker-Zweig).
import { describe, expect, it } from 'vitest'
import { baueNfo } from '../src/kern/ablage/nfo'
import {
episodenUmbenennen,
matcheEpisoden,
serienOrdner,
sichererName,
zielordner,
} from '../src/kern/ablage/struktur'
import { parseEncoderListe, leiteBackendsAb, parsePresetListe } from '../src/kern/komprimieren/encoder'
describe('sichererName', () => {
it('entfernt Verbotenes, behält Leerzeichen, hängt das Jahr an', () => {
expect(sichererName('Der Herr der Ringe: Die Gefährten', 2001)).toBe(
'Der Herr der Ringe Die Gefährten (2001)',
)
})
it('kein doppeltes Jahr (der OMDb-Fund)', () => {
expect(sichererName('X (2009)', 2009)).toBe('X (2009)')
})
it('leerer Titel bleibt leer, Punkte am Ende fallen', () => {
expect(sichererName('...')).toBe('')
expect(sichererName('Film.', null)).toBe('Film')
})
})
describe('zielordner', () => {
it('sprechender Name; bei Kollision Job-Kennung statt Überschreiben', () => {
expect(zielordner('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => false)).toBe(
'E:\\Filme\\Akira (1988)',
)
expect(zielordner('E:\\Filme', 'Akira', 1988, 'job-1234abcd', () => true)).toBe(
'E:\\Filme\\Akira (1988) [job-1234]',
)
expect(zielordner('E:\\Filme', '', null, 'job-1', () => false)).toBe('E:\\Filme\\job-1')
})
})
describe('serienOrdner', () => {
it('Jellyfin-Schema Serie/Season NN', () => {
expect(serienOrdner('E:\\Serien', 'Dr. Who?', 2)).toBe('E:\\Serien\\Dr. Who\\Season 02')
})
})
describe('matcheEpisoden', () => {
it('Stufe 1: komplette Staffel in Reihenfolge — auch bei uniformen Zeiten', () => {
expect(
matcheEpisoden(
[1420, 1425, 1418],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toEqual([1, 2, 3])
})
it('Stufe 2: distinkte Laufzeiten, ordnungserhaltend', () => {
expect(
matcheEpisoden(
[1200, 2400],
[
[1, 1210],
[2, 2390],
[3, 3000],
],
),
).toEqual([1, 2])
})
it('mehrdeutig heißt null — lieber gar nicht als falsch', () => {
expect(
matcheEpisoden(
[1420, 1421],
[
[1, 1420],
[2, 1420],
[3, 1420],
],
),
).toBeNull()
})
it('keine Daten heißt null', () => {
expect(matcheEpisoden([], [[1, 100]])).toBeNull()
expect(matcheEpisoden([100], [])).toBeNull()
})
})
describe('episodenUmbenennen', () => {
it('benennt in Disc-Reihenfolge um, überschreibt nie', () => {
const umbenannt: Array<[string, string]> = []
const meldungen = episodenUmbenennen('E:\\S', 'Akte X', 1, [3, 4], {
listen: () => ['title_t01.mkv', 'title_t00.mkv'],
existiert: () => false,
umbenennen: (von, nach) => umbenannt.push([von, nach]),
})
expect(umbenannt).toEqual([
['E:\\S\\title_t00.mkv', 'E:\\S\\Akte X S01E03.mkv'],
['E:\\S\\title_t01.mkv', 'E:\\S\\Akte X S01E04.mkv'],
])
expect(meldungen).toHaveLength(2)
})
it('Zahl passt nicht → gar nichts anfassen', () => {
const meldungen = episodenUmbenennen('E:\\S', 'X', 1, [1], {
listen: () => ['a.mkv', 'b.mkv'],
existiert: () => false,
umbenennen: () => {
throw new Error('darf nicht')
},
})
expect(meldungen[0]).toContain('übersprungen')
})
})
describe('baueNfo', () => {
it('movie.nfo mit XML-Escaping', () => {
const nfo = baueNfo(
{ beschreibung: 'Roboter & Menschen', genres: ['Sci-Fi'], laufzeitMinuten: 124 },
'Akira <Director\'s Cut>',
1988,
)
expect(nfo).toContain('<movie>')
expect(nfo).toContain('<title>Akira &lt;Director\'s Cut&gt;</title>')
expect(nfo).toContain('<plot>Roboter &amp; Menschen</plot>')
expect(nfo).toContain('<year>1988</year>')
expect(nfo).toContain('<runtime>124</runtime>')
expect(nfo).toContain('Source: Rippy')
})
it('Serie bekommt tvshow als Wurzel', () => {
expect(baueNfo({ typ: 'serie' }, 'Akte X')).toContain('<tvshow>')
})
})
// caps.py-Fixtures: das --help- und --preset-list-Format, am echten
// HandBrake gemessen (25./26.07.2026).
describe('Encoder- und Preset-Listen', () => {
it('parseEncoderListe liest den -e-Block bis zur nächsten Option', () => {
const hilfe = [
' -e, --encoder <string> Select video encoder:',
' svt_av1',
' x264',
' x265',
' nvenc_h264',
' --encoder-preset <string>',
' ignored',
].join('\n')
expect(parseEncoderListe(hilfe)).toEqual(['svt_av1', 'x264', 'x265', 'nvenc_h264'])
})
it('leiteBackendsAb: Familien statt Sammelbegriff, AV1 sichtbar', () => {
expect(leiteBackendsAb(['x264', 'x265', 'svt_av1', 'vce_h265', 'vce_av1'])).toEqual([
'cpu-x264',
'cpu-x265',
'cpu-av1',
'vce',
'vce-av1',
])
expect(leiteBackendsAb([])).toEqual([])
})
it('parsePresetListe: genau vier Leerzeichen sind ein Name', () => {
const liste = [
'General/',
' Very Fast 2160p60 4K AV1',
' AV1 video (up to 2160p60) and AAC stereo audio.',
'Hardware/',
' H.265 VCN 2160p 4K',
'[10:26:05] qsv: not available on this system',
].join('\n')
expect(parsePresetListe(liste)).toEqual(['Very Fast 2160p60 4K AV1', 'H.265 VCN 2160p 4K'])
})
})