// 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, 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, 'title_t00.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, 'title_t00.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' }], 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, 'title_t00.mkv'))).toBe(true) expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.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) }) })