// Tests von Kompression + Ablage (kern/ablauf/ablegen.ts, 5.4.0) — mit // nachgebildetem HandBrake, das die Ausgabedatei wirklich SCHREIBT (die // Serien-Umbenennung braucht sie). Die Regeln: Kompression nur wenn // gewollt UND HandBrake da, sonst VERSCHIEBEN; ein Kompressions-Fehler // lässt den verlustfreien Rip STEHEN und sagt wo er liegt; Sprachen je // Datei erreichen HandBrake; Folgen von Hand schlagen die Laufzeit-Raterei. import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { KompressionsAuftrag, RohDatei } from '../src/kern/ablauf/auftrag' import type { Zuordnung } from '../src/kern/metadaten/zuordnung' import { LEERER_TREFFER } from '../src/kern/metadaten/zuordnung' const hb = vi.hoisted(() => ({ aufrufe: [] as Array>, ergebnis: 'success' as 'success' | 'error' | 'cancelled', })) vi.mock('../src/kern/komprimieren/handbrake', async () => { const fs = await import('node:fs') return { komprimieren: vi.fn(async (o: { ausgabe: string; aufFortschritt?: (p: number) => void }) => { hb.aufrufe.push({ ...o }) o.aufFortschritt?.(50) if (hb.ergebnis === 'cancelled') return { status: 'cancelled' } if (hb.ergebnis === 'error') return { status: 'error', fehler: 'HandBrake endete mit Code 3' } fs.writeFileSync(o.ausgabe, 'encoded') return { status: 'success', ausgabe: o.ausgabe } }), } }) import { komprimierenUndAblegen, type AblageUmgebung } from '../src/kern/ablauf/ablegen' let wegwerf: string[] = [] afterEach(() => { hb.aufrufe = [] hb.ergebnis = 'success' for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true }) }) function zuordnung(typ: 'movie' | 'tv', titel: string, jahr: number | null): Zuordnung { return { titel, jahr, confidence: 0.95, meta: { ...LEERER_TREFFER, typ, id: 149, titel, jahr, quelle: 'tmdb', laufzeitMinuten: 124 }, } } function aufbau(dateinamen: string[], rollen: Array = []): { ablage: string roh: string dateien: RohDatei[] } { const ablage = mkdtempSync(join(tmpdir(), 'rippy-ablegen-')) wegwerf.push(ablage) const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00') mkdirSync(roh, { recursive: true }) const dateien = dateinamen.map((name, i) => { const pfad = join(roh, name) writeFileSync(pfad, 'roh'.repeat(10)) return { pfad, titelNr: i, rolle: rollen[i] ?? 'hauptfilm', dauerS: 3167, groesseBytes: 30, audioSprachen: [], untertitelSprachen: [], } }) return { ablage, roh, dateien } } function auftrag(teil: Partial & { rohOrdner: string; dateien: RohDatei[] }): KompressionsAuftrag { return { vorgangId: 'bluray-2026-08-30T12-00-00', laufwerk: 'G', discTyp: 'bluray', zuordnung: null, staffel: null, ersteFolge: null, discNr: null, fingerprint: 'LABEL|33', lesefehler: false, hinweise: [], begonnen: '2026-08-30T12:00:00Z', preset: '', zielOrdner: '', ...teil, } } function umgebung(ablage: string, überschreiben: Partial = {}): AblageUmgebung { return { handbrake: () => 'C:\\hb\\HandBrakeCLI.exe', ablage: () => ablage, einstellungen: () => ({ audioSprachen: 'deu,eng', untertitelSprachen: 'deu' }), jetzt: () => new Date('2026-08-30T13:00:00Z'), spurenScannen: async () => null, ...überschreiben, } } const KEIN_ABBRUCH = new AbortController().signal describe('komprimierenUndAblegen', () => { it('komprimiert in Filme/ mit NFO und meldet die Phasen', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const bibliothek: string[] = [] const phasen: string[] = [] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }), umgebung(ablage, { bibliothekEintragen: (e) => bibliothek.push(e.titel) }), (s) => phasen.push(s.phase), KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(ergebnis.komprimiert).toBe(true) expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'Akira (1988)')) expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) expect(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')]) expect(phasen).toContain('komprimiert') expect(phasen.at(-1)).toBe('legt-ab') expect(bibliothek).toEqual(['Akira']) expect(ergebnis.text).toContain('komprimiert nach') }) it('Kompression abgewählt: Roh-Dateien werden VERSCHOBEN, mit NFO — der Roh-Ordner bleibt leer zurück', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, discTyp: 'uhd', zuordnung: zuordnung('movie', 'Akira', 1988) }), umgebung(ablage, { einstellungen: () => ({ transcodePresetUhd: 'keine' }) }), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(ergebnis.komprimiert).toBe(false) expect(ergebnis.text).toContain('ohne Kompression') expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true) expect(readdirSync(roh)).toEqual([]) expect(hb.aufrufe).toHaveLength(0) }) it('ohne HandBrake: ebenfalls verschieben, nicht liegen lassen', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { handbrake: () => '' }), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.komprimiert).toBe(false) expect(readdirSync(roh)).toEqual([]) expect(ergebnis.zielOrdner).toBe(join(ablage, 'Filme', 'bluray-2026-08-30T12-00-00')) }) it('Kompressions-Fehler: Roh bleibt stehen und wird GENANNT', async () => { hb.ergebnis = 'error' const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) expect(ergebnis.status).toBe('fehler') expect(ergebnis.text).toContain('bleibt stehen') expect(ergebnis.text).toContain(roh) expect(existsSync(dateien[0].pfad)).toBe(true) }) it('Abbruch mittendrin: abgebrochen, Roh bleibt', async () => { hb.ergebnis = 'cancelled' const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) expect(ergebnis.status).toBe('abgebrochen') expect(existsSync(dateien[0].pfad)).toBe(true) }) it('Sprachen je Datei erreichen HandBrake — fremder Ton schaltet die deutschen Untertitel vor', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv']) dateien[0].audioSprachen = ['jpn'] dateien[0].untertitelSprachen = ['eng', 'deu'] const ergebnis = await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), () => {}, KEIN_ABBRUCH) expect(ergebnis.status).toBe('fertig') expect(hb.aufrufe).toHaveLength(2) // Datei 0: eigene Wahl — deu nach vorn, Vorwahl an. expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn']) expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu', 'eng']) expect(hb.aufrufe[0].untertitelStandard).toBe(true) // Datei 1: nichts gewählt — die globale Einstellung, keine Vorwahl. expect(hb.aufrufe[1].audioSprachen).toEqual(['deu', 'eng']) expect(hb.aufrufe[1].untertitelSprachen).toEqual(['deu']) expect(hb.aufrufe[1].untertitelStandard).toBe(false) }) it('der Scan der Roh-Datei meldet fehlende Sprachen als Hinweis', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) dateien[0].audioSprachen = ['jpn'] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch', codec: 'dtshd', kanaele: 6 }], untertitel: [], dauerS: 3167 }), }), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.hinweise.join(' ')).toContain('Tonspur jpn ist NICHT in der Roh-Datei') }) it('Serie von Hand: Staffel 1, ab Folge 3 → S01E03 und S01E04, Warnung bei TMDb-Abweichung', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) dateien[1].dauerS = 3249 const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3, }), umgebung(ablage, { // Folge 4 ist bei TMDb 20 min kürzer — das gibt eine Warnung, aber // die Hand des Nutzers gilt. episodenLaufzeiten: async () => [ [3, 3200], [4, 2000], ], }), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(ergebnis.zielOrdner).toBe(join(ablage, 'Serien', 'Spartacus', 'Season 01')) expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ 'Spartacus S01E03.mkv', 'Spartacus S01E04.mkv', ]) expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv']) expect(ergebnis.hinweise.join(' ')).toContain('Folge 4: Datei 54 min, TMDb 33 min') expect(existsSync(join(ergebnis.zielOrdner, 'tvshow.nfo'))).toBe(true) }) it('Serie ohne Vorgabe: Laufzeit-Zuordnung wie bisher — nur bei eindeutigem Treffer', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) dateien[0].dauerS = 3167 dateien[1].dauerS = 3249 const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 2 }), umgebung(ablage, { episodenLaufzeiten: async () => [ [1, 3180], [2, 3240], ], }), () => {}, KEIN_ABBRUCH, ) expect(readdirSync(ergebnis.zielOrdner).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ 'Spartacus S02E01.mkv', 'Spartacus S02E02.mkv', ]) }) it('Extras liegen im Unterordner extras\\ neben dem Hauptfilm', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t05.mkv'], ['hauptfilm', 'extra']) const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988) }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(existsSync(join(ergebnis.zielOrdner, 'Akira (1988).mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.mkv'))).toBe(true) }) it('Wiederholen: der vorgegebene Zielordner wird benutzt, kein zweiter angelegt', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ziel = join(ablage, 'Filme', 'Akira (1988)') mkdirSync(ziel, { recursive: true }) const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, preset: 'Fast 1080p30' }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.zielOrdner).toBe(ziel) expect(ergebnis.preset).toBe('Fast 1080p30') expect(readdirSync(join(ablage, 'Filme'))).toEqual(['Akira (1988)']) }) it('der Fortschritt ist über beide Dateien gewichtet', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv']) dateien[0].groesseBytes = 3 dateien[1].groesseBytes = 1 const prozente: number[] = [] await komprimierenUndAblegen(auftrag({ rohOrdner: roh, dateien }), umgebung(ablage), (s) => prozente.push(s.prozent), KEIN_ABBRUCH) // 50 % der ersten (3/4) = 37, 50 % der zweiten = 87. expect(prozente).toContain(37) expect(prozente).toContain(87) }) }) describe('Wiederholen ersetzt die alte Fassung', () => { it('Serie: die alten S01E03/E04 fallen erst nach dem Encode, dann greift die Umbenennung', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) const ziel = join(ablage, 'Serien', 'Spartacus', 'Season 01') mkdirSync(ziel, { recursive: true }) const alt3 = join(ziel, 'Spartacus S01E03.mkv') const alt4 = join(ziel, 'Spartacus S01E04.mkv') writeFileSync(alt3, 'alt') writeFileSync(alt4, 'alt') const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3, zielOrdner: ziel, ersetzt: [alt3, alt4], }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(readdirSync(ziel).filter((n) => n.endsWith('.mkv')).sort()).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv']) expect(readFileSync(alt3, 'utf8')).toBe('encoded') expect(ergebnis.hinweise.join(' ')).toContain('Alte Fassung ersetzt') expect(ergebnis.hinweise.join(' ')).not.toContain('existiert schon') }) it('scheitert der zweite Lauf, bleibt die alte Fassung stehen', async () => { hb.ergebnis = 'error' const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const ziel = join(ablage, 'Filme', 'Akira (1988)') mkdirSync(ziel, { recursive: true }) const alt = join(ziel, 'title_t00.mkv') writeFileSync(alt, 'alt') const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, ersetzt: [alt] }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fehler') expect(readFileSync(alt, 'utf8')).toBe('alt') }) }) // ── 5.7.0: Funde der Durchsicht vom 12.09.2026 ───────────────────────── describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => { // F3: Disc 2 derselben Staffel landet im selben Season-Ordner wie Disc 1. // Vorher zählte die Umbenennung ALLE MKVs des Ordners („4 Dateien, 2 // Zuordnungen") und tat nichts — der Serien-Merker lief ins Leere. it('F3: zweite Disc in denselben Season-Ordner — nur ihre Folgen werden benannt', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge']) const season = join(ablage, 'Serien', 'Spartacus', 'Season 01') mkdirSync(season, { recursive: true }) writeFileSync(join(season, 'Spartacus S01E01.mkv'), 'disc1') writeFileSync(join(season, 'Spartacus S01E02.mkv'), 'disc1') const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1, ersteFolge: 3 }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(readdirSync(season).filter((n) => n.endsWith('.mkv')).sort()).toEqual([ 'Spartacus S01E01.mkv', 'Spartacus S01E02.mkv', 'Spartacus S01E03.mkv', 'Spartacus S01E04.mkv', ]) expect(readFileSync(join(season, 'Spartacus S01E01.mkv'), 'utf8')).toBe('disc1') expect(ergebnis.folgenVergeben).toEqual([3, 4]) }) // F3: Liegt im Season-Ordner noch ein title_t00.mkv der ersten Disc // (deren Umbenennung ausfiel), darf die zweite es NICHT überschreiben. it('F3: gleichnamige Datei im Ziel wird nie überschrieben — „(2)" statt Verlust', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv'], ['folge']) const season = join(ablage, 'Serien', 'Spartacus', 'Season 01') mkdirSync(season, { recursive: true }) writeFileSync(join(season, 'title_t00.mkv'), 'disc1-unbenannt') const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('tv', 'Spartacus', 2010), staffel: 1 }), umgebung(ablage), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(readFileSync(join(season, 'title_t00.mkv'), 'utf8')).toBe('disc1-unbenannt') expect(existsSync(join(season, 'title_t00 (2).mkv'))).toBe(true) expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv']) }) }) // ── 5.7.0: Spuren einzeln ────────────────────────────────────────────────── describe('Spuren einzeln (5.7.0)', () => { const jpnHd = { nr: 1, lang: 'jpn', sprache: 'Japanese', codec: 'DTS-HD MA', kanaele: '6', layout: 'Surround 5.1', beschreibung: '', abgeleitet: false } const deuKommentar = { nr: 4, lang: 'deu', sprache: 'German', codec: 'DD', kanaele: '2', layout: 'Stereo', beschreibung: 'DD Stereo German (Kommentar)', abgeleitet: false } const deuPgs = { nr: 5, lang: 'deu', sprache: 'German', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS German', abgeleitet: false } const engPgs = { nr: 7, lang: 'eng', sprache: 'English', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS English', abgeleitet: false } const scan = { audio: [ { nr: 1, lang: 'jpn', beschreibung: '', codec: 'dtshd', kanaele: 6 }, { nr: 2, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 6 }, { nr: 3, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 2 }, ], untertitel: [ { nr: 1, lang: 'deu', beschreibung: '', codec: 'PGS', kanaele: 0 }, { nr: 2, lang: 'eng', beschreibung: '', codec: 'PGS', kanaele: 0 }, ], dauerS: 3167, } it('gewählte Spuren erreichen HandBrake als Nummern — japanischer Ton stellt die deutschen Untertitel vor und wählt sie vor', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) dateien[0].audioSprachen = ['jpn', 'deu'] dateien[0].untertitelSprachen = ['eng', 'deu'] dateien[0].audioSpuren = [jpnHd, deuKommentar] dateien[0].untertitelSpuren = [engPgs, deuPgs] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { spurenScannen: async () => scan }), () => {}, KEIN_ABBRUCH, ) expect(ergebnis.status).toBe('fertig') expect(hb.aufrufe[0].audioNr).toEqual([1, 3]) expect(hb.aufrufe[0].untertitelNr).toEqual([1, 2]) expect(hb.aufrufe[0].untertitelStandardNr).toBe(1) expect(ergebnis.hinweise.join(' ')).not.toContain('NICHT in der Roh-Datei') }) it('eine gewählte Spur fehlt in der Roh-Datei → Hinweis, der Rest läuft', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' } dateien[0].audioSpuren = [fra, deuKommentar] dateien[0].untertitelSpuren = [] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { spurenScannen: async () => scan }), () => {}, KEIN_ABBRUCH, ) expect(hb.aufrufe[0].audioNr).toEqual([3]) expect(hb.aufrufe[0].untertitelNr).toEqual([]) expect(ergebnis.hinweise.join(' ')).toContain('Tonspur French DTS-HD MA ist NICHT in der Roh-Datei') }) it('Scan der Roh-Datei scheitert → Sprachen der gewählten Spuren als Liste (wie bisher)', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) dateien[0].audioSpuren = [jpnHd] dateien[0].untertitelSpuren = [deuPgs] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { spurenScannen: async () => null }), () => {}, KEIN_ABBRUCH, ) expect(hb.aufrufe[0].audioNr).toBeUndefined() expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn']) expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu']) expect(hb.aufrufe[0].untertitelStandard).toBe(true) expect(ergebnis.hinweise.join(' ')).toContain('ließen sich nicht messen') }) it('keine gewählte Tonspur findbar → Sprachlisten statt stummer Datei', async () => { const { ablage, roh, dateien } = aufbau(['title_t00.mkv']) const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' } dateien[0].audioSpuren = [fra] dateien[0].untertitelSpuren = [deuPgs] const ergebnis = await komprimierenUndAblegen( auftrag({ rohOrdner: roh, dateien }), umgebung(ablage, { spurenScannen: async () => scan }), () => {}, KEIN_ABBRUCH, ) expect(hb.aufrufe[0].audioNr).toBeUndefined() expect(hb.aufrufe[0].audioSprachen).toEqual(['fra']) expect(ergebnis.hinweise.join(' ')).toContain('keine der gewählten Tonspuren gefunden') }) })