WAS: Der Rip endet, sobald die Roh-Dateien liegen; die Kompression laeuft danach in einer Warteschlange (eine zur Zeit), das Laufwerk ist frei fuer die naechste Disc. Jeder Rip ist jetzt ein Datensatz (Tabelle vorgaenge, Schema 4): Roh-Ordner, Zielordner, Phase, Auftrag als JSON. Darauf bauen: Roh-Aufraeumen nach Regel (Vorgabe 7 Tage, nur bei gelungener Kompression und nur wenn jede fertige Datei da und nicht leer ist), Wiederholen ab Kompression, Aufraeumen halber Dateien nach Abbruch/Fehler, Wiederaufnahme nach Neustart. Unkomprimierte Rips (4K, kein HandBrake) werden in den Zielordner VERSCHOBEN statt unter roh\ liegen zu bleiben. Sprachen je Titel reisen vom Info-Lauf (SINFO) bis in HandBrake; fremder Ton schaltet den deutschen Untertitel vor (--subtitle-default=1). Vor der Kompression prueft ein HandBrake-Scan die Roh-Datei. MakeMKVs Auswahlregel wird gesetzt, wenn keine steht, damit alle Sprachen in die Roh-Datei kommen. Staffel und erste Folge von Hand schlagen die Laufzeit-Raterei. Durchgehender Fortschritt, nach Groesse gewichtet. Erkennung v2: leere TMDb-Eintraege (Stubs) zaehlen nicht, Laufzeit wird nach dem Info-Lauf gegen TMDb gehalten, Titel-Struktur als Fingerabdruck, bdmt_deu als zweite Titel-Variante. TMDb liefert Logo, Tagline, Bewertung, FSK und Besetzung in derselben Anfrage. Laufwerks-Gesundheit aus dem Windows-Ereignisprotokoll (wevtutil): Disc oder Hardware. WARUM: Uebergabe-Punkte 1-5, 8-12 des SAVEPOINT vom 01.09.2026 — vom Commander gutgeheissen (7 Tage / Untertitel vorgewaehlt / entkoppelte Kompression waren seine Wahl). GEMESSEN (Regel D): HandBrake 1.11 Sprach-Schalter an einer ffmpeg- Testdatei (jpn/deu/deu/eng + deu/eng), HandBrake --scan --json Format, TMDb movie/149 + tv/46296 mit append_to_response, wevtutil /f:xml an diesem PC (338x cdrom 7, 8x cdrom 11 in 30 Tagen), makemkvcon --minlength, MakeMKV-Auswahlregel laut forum.makemkv.com t=4386. 348 Tests gruen (vorher 261), Typpruefung und Bau gruen. Oberflaeche folgt im naechsten Schritt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
302 lines
12 KiB
TypeScript
302 lines
12 KiB
TypeScript
// 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<Record<string, unknown>>,
|
|
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<RohDatei['rolle']> = []): {
|
|
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<KompressionsAuftrag> & { 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> = {}): 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/<Titel (Jahr)> 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)
|
|
})
|
|
})
|