feat(v5): Etappe W-3 — Komprimieren + Ablegen, mit Vollbeweis
Ampel / ampel (push) Failing after 1m18s
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:
co-authored by
Claude Fable 5
parent
ca338ad467
commit
3dc296f071
@@ -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 <Director\'s Cut></title>')
|
||||
expect(nfo).toContain('<plot>Roboter & 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user