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'])
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// DER W-3-BEWEIS: kompletter Encode einer echten Roh-MKV plus Ablage in
|
||||
// echter Jellyfin-Struktur (Ordner „<Titel> (Jahr)" + movie.nfo). Läuft
|
||||
// nur auf Verlangen und dauert je nach Material 20 min bis Stunden:
|
||||
//
|
||||
// RIPPY_MESSUNG_ENCODE=1 RIPPY_MESSUNG_MKV=<roh.mkv> \
|
||||
// RIPPY_MESSUNG_ABLAGE=<basis> RIPPY_MESSUNG_TITEL=<titel> \
|
||||
// RIPPY_MESSUNG_JAHR=<jahr> [RIPPY_MESSUNG_PRESET=<preset>] \
|
||||
// npx vitest run test/messung.encode-voll.test.ts --reporter=verbose
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { win32 as pfad } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG_ENCODE === '1' && process.platform === 'win32'
|
||||
const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? ''
|
||||
const ablageBasis = process.env.RIPPY_MESSUNG_ABLAGE ?? ''
|
||||
const titel = process.env.RIPPY_MESSUNG_TITEL ?? 'Unbenannt'
|
||||
const jahr = process.env.RIPPY_MESSUNG_JAHR ?? ''
|
||||
const preset = process.env.RIPPY_MESSUNG_PRESET ?? 'H.265 MKV 1080p30'
|
||||
|
||||
describe.skipIf(!messen)('Voller Encode + Ablage am echten System', () => {
|
||||
it('komprimiert und legt nach Jellyfin-Schema ab', { timeout: 8 * 60 * 60 * 1000 }, async () => {
|
||||
expect(rohMkv.length, 'RIPPY_MESSUNG_MKV fehlt').toBeGreaterThan(0)
|
||||
expect(existsSync(rohMkv), `${rohMkv} fehlt`).toBe(true)
|
||||
expect(ablageBasis.length, 'RIPPY_MESSUNG_ABLAGE fehlt').toBeGreaterThan(0)
|
||||
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { komprimieren } = await import('../src/kern/komprimieren/handbrake')
|
||||
const { zielordner } = await import('../src/kern/ablage/struktur')
|
||||
const { baueNfo } = await import('../src/kern/ablage/nfo')
|
||||
|
||||
const programm = finden('handbrake')
|
||||
expect(programm.length).toBeGreaterThan(0)
|
||||
|
||||
const ordner = zielordner(ablageBasis, titel, jahr.length > 0 ? jahr : null, 'v5-beweis')
|
||||
mkdirSync(ordner, { recursive: true })
|
||||
const ausgabe = pfad.join(ordner, pfad.basename(ordner) + '.mkv')
|
||||
|
||||
console.log(`ENCODE-START ${rohMkv} → ${ausgabe} · Preset „${preset}"`)
|
||||
const start = Date.now()
|
||||
let letzteStufe = -5
|
||||
const ergebnis = await komprimieren({
|
||||
programm,
|
||||
eingabe: rohMkv,
|
||||
ausgabe,
|
||||
preset,
|
||||
audioSprachen: ['deu', 'eng'],
|
||||
aufFortschritt: (prozent) => {
|
||||
if (prozent >= letzteStufe + 5) {
|
||||
letzteStufe = prozent
|
||||
console.log(`ENCODE-FORTSCHRITT ${prozent}% nach ${((Date.now() - start) / 60000).toFixed(1)}min`)
|
||||
}
|
||||
},
|
||||
})
|
||||
const minuten = ((Date.now() - start) / 60000).toFixed(1)
|
||||
|
||||
if (ergebnis.status !== 'success') {
|
||||
console.log(`ENCODE-ERGEBNIS ${ergebnis.status} nach ${minuten}min: ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||
}
|
||||
expect(ergebnis.status).toBe('success')
|
||||
if (ergebnis.status !== 'success') return
|
||||
|
||||
const rohGb = statSync(rohMkv).size / 1e9
|
||||
const fertigGb = statSync(ergebnis.ausgabe).size / 1e9
|
||||
console.log(
|
||||
`ENCODE-ERGEBNIS success nach ${minuten}min · ${rohGb.toFixed(2)} GB → ${fertigGb.toFixed(2)} GB` +
|
||||
(ergebnis.hinweis !== undefined ? ` · ${ergebnis.hinweis}` : ''),
|
||||
)
|
||||
|
||||
const nfoPfad = pfad.join(ordner, 'movie.nfo')
|
||||
writeFileSync(nfoPfad, baueNfo({ quelle: 'Rippy v5 W-3-Beweis' }, titel, jahr.length > 0 ? jahr : null))
|
||||
console.log(`ABLAGE-FERTIG ${ordner} (movie.nfo + ${pfad.basename(ergebnis.ausgabe)})`)
|
||||
expect(existsSync(nfoPfad)).toBe(true)
|
||||
expect(fertigGb).toBeGreaterThan(0.05)
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Encode-Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG_ENCODE=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// MESSUNG am echten HandBrakeCLI: Encoder-Auskunft und ein kurzer
|
||||
// Probe-Encode an einer echten Roh-MKV. Läuft nur auf Verlangen:
|
||||
//
|
||||
// RIPPY_MESSUNG=1 [RIPPY_MESSUNG_MKV=<roh.mkv>] \
|
||||
// npx vitest run test/messung.handbrake.test.ts --reporter=verbose
|
||||
import { existsSync, mkdtempSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const messen = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
|
||||
const rohMkv = process.env.RIPPY_MESSUNG_MKV ?? ''
|
||||
|
||||
describe.skipIf(!messen)('HandBrake am echten System', () => {
|
||||
it('meldet Encoder, Backends und Presets', { timeout: 120_000 }, async () => {
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { encoderMessen } = await import('../src/kern/komprimieren/encoder')
|
||||
const programm = finden('handbrake')
|
||||
expect(programm.length).toBeGreaterThan(0)
|
||||
const auskunft = await encoderMessen(programm)
|
||||
console.log(`MESSUNG Encoder (${auskunft.encoder.length}): ${auskunft.encoder.join(', ')}`)
|
||||
console.log(`MESSUNG Backends: ${auskunft.backends.join(', ')}`)
|
||||
console.log(`MESSUNG Presets: ${auskunft.presets.length} Stück, z. B. ${auskunft.presets.slice(0, 4).join(' | ')}`)
|
||||
expect(auskunft.encoder.length).toBeGreaterThan(0)
|
||||
expect(auskunft.presets.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.skipIf(rohMkv.length === 0 || !existsSync(rohMkv))(
|
||||
'kurzer Probe-Encode an der echten Roh-MKV (5 s Material)',
|
||||
{ timeout: 15 * 60_000 },
|
||||
async () => {
|
||||
const { finden } = await import('../src/kern/werkzeuge/katalog')
|
||||
const { komprimieren } = await import('../src/kern/komprimieren/handbrake')
|
||||
const programm = finden('handbrake')
|
||||
const ziel = join(mkdtempSync(join(tmpdir(), 'rippy-hb-')), 'probe.mkv')
|
||||
const start = Date.now()
|
||||
const ergebnis = await komprimieren({
|
||||
programm,
|
||||
eingabe: rohMkv,
|
||||
ausgabe: ziel,
|
||||
preset: 'H.265 MKV 1080p30',
|
||||
audioSprachen: ['deu', 'jpn', 'eng'],
|
||||
extraArgs: ['--start-at', 'duration:60', '--stop-at', 'duration:5'],
|
||||
})
|
||||
const sekunden = ((Date.now() - start) / 1000).toFixed(1)
|
||||
if (ergebnis.status === 'success') {
|
||||
console.log(
|
||||
`MESSUNG Probe-Encode: success in ${sekunden}s → ${ergebnis.ausgabe} (${(statSync(ergebnis.ausgabe).size / 1e6).toFixed(1)} MB)` +
|
||||
(ergebnis.hinweis !== undefined ? ` · Hinweis: ${ergebnis.hinweis}` : ''),
|
||||
)
|
||||
} else {
|
||||
console.log(`MESSUNG Probe-Encode: ${ergebnis.status} in ${sekunden}s ${'fehler' in ergebnis ? ergebnis.fehler : ''}`)
|
||||
}
|
||||
expect(ergebnis.status).toBe('success')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(messen)('Messung übersprungen', () => {
|
||||
it('läuft nur mit RIPPY_MESSUNG=1 unter Windows', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
// Tests der Verkettung Rip → Kompression → Ablage — mit nachgebildeten
|
||||
// Werkzeug-Läufen. Die Regeln: Kompression nur wenn gewollt UND HandBrake
|
||||
// da; ein Kompressions-Fehler lässt den verlustfreien Rip STEHEN und sagt
|
||||
// wo er liegt; Statusmeldungen tragen die Phase.
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RipStatus } from '../src/gemeinsam/nachrichten'
|
||||
|
||||
const ripErgebnis = vi.hoisted(() => ({
|
||||
wert: { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false } as unknown,
|
||||
}))
|
||||
const encodeErgebnis = vi.hoisted(() => ({
|
||||
wert: { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' } as unknown,
|
||||
}))
|
||||
|
||||
vi.mock('../src/kern/rip/makemkv', () => ({
|
||||
rippen: vi.fn(async () => ripErgebnis.wert),
|
||||
}))
|
||||
vi.mock('../src/kern/komprimieren/handbrake', () => ({
|
||||
komprimieren: vi.fn(async () => encodeErgebnis.wert),
|
||||
}))
|
||||
|
||||
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
|
||||
|
||||
let wegwerf: string[] = []
|
||||
afterEach(() => {
|
||||
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
|
||||
pipeline: Pipeline
|
||||
meldungen: RipStatus[]
|
||||
} {
|
||||
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
||||
wegwerf.push(ablage)
|
||||
const meldungen: RipStatus[] = []
|
||||
const pipeline = new Pipeline({
|
||||
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
|
||||
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
|
||||
geraet: () => ({
|
||||
id: 'G',
|
||||
name: 'Laufwerk G:',
|
||||
typ: 'bluray',
|
||||
pfad: '\\\\.\\G:',
|
||||
status: 'ready',
|
||||
modell: '',
|
||||
seriennummer: '',
|
||||
groesseBytes: 33e9,
|
||||
grund: '',
|
||||
}),
|
||||
ablage: () => ablage,
|
||||
einstellungen: () => ({}),
|
||||
melden: (s) => meldungen.push(s),
|
||||
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
||||
...überschreiben,
|
||||
})
|
||||
return { pipeline, meldungen }
|
||||
}
|
||||
|
||||
describe('Pipeline', () => {
|
||||
it('kettet Rip → Kompression → Ablage und meldet die Phasen', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||
const { pipeline, meldungen } = umgebung()
|
||||
await pipeline.starten('G')
|
||||
const phasen = meldungen.map((m) => m.phase)
|
||||
expect(phasen[0]).toBe('rippt')
|
||||
expect(phasen).toContain('komprimiert')
|
||||
expect(phasen).toContain('legt-ab')
|
||||
expect(phasen.at(-1)).toBe('fertig')
|
||||
expect(meldungen.at(-1)?.dateien).toEqual(['R:\\fertig\\a.mkv'])
|
||||
})
|
||||
|
||||
it('Kompression abgewählt: Roh-Rip ist das Ergebnis, mit Begründung', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||
const { pipeline, meldungen } = umgebung({
|
||||
einstellungen: () => ({ transcodePresetBluray: 'keine' }),
|
||||
})
|
||||
await pipeline.starten('G')
|
||||
const letzte = meldungen.at(-1)
|
||||
expect(letzte?.phase).toBe('fertig')
|
||||
expect(letzte?.text).toContain('abgewählt')
|
||||
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
||||
})
|
||||
|
||||
it('ohne HandBrake bleibt es beim Roh-Rip — mit klarem Satz', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||
const { pipeline, meldungen } = umgebung({ handbrake: () => '' })
|
||||
await pipeline.starten('G')
|
||||
expect(meldungen.at(-1)?.text).toContain('HandBrakeCLI nicht gefunden')
|
||||
})
|
||||
|
||||
it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||
encodeErgebnis.wert = { status: 'error', fehler: 'HandBrake endete mit Code 3' }
|
||||
const { pipeline, meldungen } = umgebung()
|
||||
await pipeline.starten('G')
|
||||
const letzte = meldungen.at(-1)
|
||||
expect(letzte?.phase).toBe('fehler')
|
||||
expect(letzte?.text).toContain('bleibt stehen')
|
||||
expect(letzte?.dateien).toEqual(['R:\\roh\\a.mkv'])
|
||||
})
|
||||
|
||||
it('Lesefehler überleben bis in die Fertig-Meldung (Rückgabewert-0-Falle)', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: true }
|
||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||
const { pipeline, meldungen } = umgebung()
|
||||
await pipeline.starten('G')
|
||||
const letzte = meldungen.at(-1)
|
||||
expect(letzte?.lesefehler).toBe(true)
|
||||
expect(letzte?.text).toContain('nicht lesen')
|
||||
})
|
||||
|
||||
it('leeres Laufwerk: ein Satz statt makemkvcon (aber unknown rippt!)', async () => {
|
||||
const { pipeline, meldungen } = umgebung({
|
||||
geraet: () => ({
|
||||
id: 'G',
|
||||
name: 'Laufwerk G:',
|
||||
typ: 'unknown',
|
||||
pfad: '\\\\.\\G:',
|
||||
status: 'empty',
|
||||
modell: '',
|
||||
seriennummer: '',
|
||||
groesseBytes: 0,
|
||||
grund: '',
|
||||
}),
|
||||
})
|
||||
await pipeline.starten('G')
|
||||
expect(meldungen[0].phase).toBe('fehler')
|
||||
expect(meldungen[0].text).toContain('Kein Datenträger')
|
||||
})
|
||||
|
||||
it('NFO landet im fertigen Ordner', async () => {
|
||||
ripErgebnis.wert = { status: 'success', dateien: ['R:\\roh\\a.mkv'], lesefehler: false }
|
||||
encodeErgebnis.wert = { status: 'success', ausgabe: 'R:\\fertig\\a.mkv' }
|
||||
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
|
||||
wegwerf.push(ablage)
|
||||
const meldungen: RipStatus[] = []
|
||||
const pipeline = new Pipeline({
|
||||
makemkvcon: () => 'x',
|
||||
handbrake: () => 'y',
|
||||
geraet: () => ({
|
||||
id: 'G',
|
||||
name: 'Laufwerk G:',
|
||||
typ: 'dvd',
|
||||
pfad: '\\\\.\\G:',
|
||||
status: 'ready',
|
||||
modell: '',
|
||||
seriennummer: '',
|
||||
groesseBytes: 4e9,
|
||||
grund: '',
|
||||
}),
|
||||
ablage: () => ablage,
|
||||
einstellungen: () => ({}),
|
||||
melden: (s) => meldungen.push(s),
|
||||
jetzt: () => new Date('2026-08-30T12:00:00Z'),
|
||||
})
|
||||
await pipeline.starten('G')
|
||||
expect(meldungen.at(-1)?.phase).toBe('fertig')
|
||||
const { existsSync, readdirSync } = await import('node:fs')
|
||||
const filme = join(ablage, 'Filme')
|
||||
const ordner = readdirSync(filme)
|
||||
expect(ordner).toHaveLength(1)
|
||||
expect(existsSync(join(filme, ordner[0], 'movie.nfo'))).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user