feat(v5): Kern 5.4.0 — Vorgaenge, Kompressions-Warteschlange, Roh-Aufraeumen, Sprachen je Titel, Erkennung v2

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>
This commit is contained in:
Hitonabi
2026-09-01 22:02:03 +02:00
co-authored by Claude Fable 5.1
parent d70a81d194
commit 3f7f962dff
29 changed files with 5102 additions and 1418 deletions
+301
View File
@@ -0,0 +1,301 @@
// 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)
})
})
+118
View File
@@ -0,0 +1,118 @@
// Tests des Kompressions-Auftrags (kern/ablauf/auftrag.ts, 5.4.0) — pur:
// Sprachwahl je Datei, Folgen von Hand, gewichteter Fortschritt, JSON.
import { describe, expect, it } from 'vitest'
import {
auftragAusJson,
auftragZuJson,
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachwahl,
type KompressionsAuftrag,
} from '../src/kern/ablauf/auftrag'
describe('sprachwahl — die Regel des Commanders', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('nichts gewählt: die globale Einstellung, keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: [], untertitelSprachen: [] }, global)).toEqual({
audio: ['deu', 'eng'],
untertitel: ['deu'],
untertitelStandard: false,
})
})
it('japanischer Ton + deutsche Untertitel: Untertitel nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng', 'deu'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('deutscher Ton zuerst: keine Vorwahl, auch wenn Untertitel gewählt sind', () => {
expect(sprachwahl({ audioSprachen: ['deu', 'jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(false)
})
it('fremder Ton, aber kein deutscher Untertitel gewählt: nichts vorzuwählen', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'] }, global)).toEqual({
audio: ['jpn'],
untertitel: ['eng'],
untertitelStandard: false,
})
})
it('ohne globale Wunschsprache gibt es keine Vorwahl', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, { audio: [], untertitel: [] }).untertitelStandard).toBe(false)
})
})
describe('folgenNummern und folgenPlausibel', () => {
it('fortlaufend ab der ersten Folge', () => {
expect(folgenNummern(2, 3)).toEqual([3, 4])
expect(folgenNummern(0, 3)).toEqual([])
expect(folgenNummern(2, 0)).toEqual([1, 2])
})
it('warnt nur bei deutlicher Abweichung von TMDb', () => {
const laufzeiten: Array<[number, number]> = [
[3, 3200],
[4, 2000],
]
const w = folgenPlausibel([3167, 3249], [3, 4], laufzeiten)
expect(w).toHaveLength(1)
expect(w[0]).toContain('Folge 4')
expect(folgenPlausibel([3167], [9], laufzeiten)).toEqual([])
})
})
describe('gewichteterFortschritt — nie zurück auf null', () => {
it('gewichtet nach Größe', () => {
expect(gewichteterFortschritt([3, 1], 0, 50)).toBe(37)
expect(gewichteterFortschritt([3, 1], 0, 100)).toBe(75)
expect(gewichteterFortschritt([3, 1], 1, 50)).toBe(87)
expect(gewichteterFortschritt([3, 1], 1, 100)).toBe(100)
})
it('ohne Gewichte zählen alle gleich; -1 heißt „noch keine Angabe" vom laufenden Teil', () => {
expect(gewichteterFortschritt([0, 0], 1, 50)).toBe(75)
expect(gewichteterFortschritt([1, 1], 1, -1)).toBe(50)
expect(gewichteterFortschritt([], 0, 50)).toBe(-1)
})
})
describe('Auftrag als JSON — und wieder zurück', () => {
const a: KompressionsAuftrag = {
vorgangId: 'bluray-2026-08-30T12-00-00',
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: 'E:\\Rippy\\roh\\bluray-2026-08-30T12-00-00',
dateien: [
{ pfad: 'E:\\Rippy\\roh\\x\\title_t00.mkv', titelNr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
],
zuordnung: null,
staffel: 1,
ersteFolge: 3,
discNr: 2,
fingerprint: 'SPARTACUS_GOTA_D2|33759100928',
lesefehler: false,
hinweise: ['Übersprungen: Titel 5.'],
begonnen: '2026-08-30T12:00:00.000Z',
preset: '',
zielOrdner: '',
}
it('kommt unverändert zurück', () => {
expect(auftragAusJson(auftragZuJson(a))).toEqual(a)
})
it('Unsinn ist null, kein Absturz', () => {
expect(auftragAusJson('{')).toBeNull()
expect(auftragAusJson('{}')).toBeNull()
expect(auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{}]}')).toBeNull()
})
it('fehlende Felder bekommen Vorgaben', () => {
const b = auftragAusJson('{"vorgangId":"x","rohOrdner":"y","dateien":[{"pfad":"p"}]}')!
expect(b.dateien[0].rolle).toBe('hauptfilm')
expect(b.dateien[0].audioSprachen).toEqual([])
expect(b.staffel).toBeNull()
expect(b.preset).toBe('')
})
})
+56
View File
@@ -71,3 +71,59 @@ describe('Datenbank', () => {
expect(() => new Datenbank(kaputt)).toThrow()
})
})
// ── Vorgänge (5.4.0): welcher Roh-Ordner gehört zu welchem Rip ──────────
describe('Datenbank — Vorgänge', () => {
const vorgang = {
id: 'bluray-2026-09-01T20-00-00',
laufwerk: 'G',
typ: 'bluray',
titel: 'Spartacus',
jahr: 2010,
phase: 'rippt' as const,
begonnen: '2026-09-01T20:00:00.000Z',
beendet: null,
rohOrdner: 'E:\Rippy\roh\bluray-2026-09-01T20-00-00',
rohBytes: 0,
rohGeloeschtAm: null,
zielOrdner: '',
fertigBytes: 0,
komprimiert: false,
preset: '',
fehler: '',
auftrag: '{}',
lesefehler: false,
fertigeDateien: '[]',
}
it('anlegen, lesen, aktualisieren (nur die genannten Felder), listen, löschen', () => {
const db = new Datenbank(':memory:')
aufraeumen.push(() => db.schliessen())
db.vorgangAnlegen(vorgang)
expect(db.vorgangLesen(vorgang.id)).toEqual(vorgang)
db.vorgangAktualisieren(vorgang.id, { phase: 'fertig', komprimiert: true, fertigBytes: 4800, beendet: '2026-09-01T21:00:00.000Z' })
const neu = db.vorgangLesen(vorgang.id)!
expect(neu.phase).toBe('fertig')
expect(neu.komprimiert).toBe(true)
expect(neu.fertigBytes).toBe(4800)
expect(neu.titel).toBe('Spartacus')
expect(neu.rohGeloeschtAm).toBeNull()
db.vorgangAnlegen({ ...vorgang, id: 'dvd-2026-09-02T10-00-00', begonnen: '2026-09-02T10:00:00.000Z' })
expect(db.vorgangListe().map((v) => v.id)).toEqual(['dvd-2026-09-02T10-00-00', vorgang.id])
db.vorgangLoeschen(vorgang.id)
expect(db.vorgangLesen(vorgang.id)).toBeNull()
expect(db.vorgangAktualisieren('gibt-es-nicht', { phase: 'fertig' })).toBeUndefined()
})
it('Schema 3 → 4: eine Bestands-Datenbank bekommt die Tabelle ohne Datenverlust', () => {
const pfad = frischerPfad()
const alt = new Datenbank(pfad)
alt.einstellungSchreiben('ablage', 'E:\Rippy')
alt.schliessen()
const neu = new Datenbank(pfad)
aufraeumen.push(() => neu.schliessen())
expect(neu.einstellungLesen('ablage')).toBe('E:\Rippy')
expect(neu.vorgangListe()).toEqual([])
expect(neu.schemaVersion()).toBe(SCHEMA_VERSION)
})
})
+70
View File
@@ -0,0 +1,70 @@
// Tests der Laufwerks-Gesundheit (kern/laufwerk/ereignisse.ts, 5.4.0) —
// an der ECHTEN wevtutil-Ausgabe dieses PCs (01.09.2026): cdrom 7
// (fehlerhafter Block, der Spartacus-Defekt), cdrom 11 (Controllerfehler,
// der USB-Aussetzer).
import { describe, expect, it } from 'vitest'
import { beurteilen, buildWevtutilArgs, ereignisseAusXml, xpathFuer } from '../src/kern/laufwerk/ereignisse'
/** Drei Ereignisse, so wie wevtutil /f:xml sie schreibt — ohne Zeilenumbrüche. */
const XML =
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><Version>0</Version><Level>2</Level><TimeCreated SystemTime='2026-09-01T18:32:42.3868580Z'/><EventRecordID>135751</EventRecordID><Channel>System</Channel><Computer>TobisNicerPC</Computer></System><EventData><Data>\\Device\\CdRom0</Data><Binary>0300</Binary></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>7</EventID><TimeCreated SystemTime='2026-09-01T18:32:39.6629681Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>" +
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='cdrom'/><EventID Qualifiers='49156'>11</EventID><TimeCreated SystemTime='2026-09-01T18:17:39.6632548Z'/><Channel>System</Channel></System><EventData><Data>\\Device\\CdRom0</Data></EventData></Event>"
describe('ereignisseAusXml', () => {
it('liest Provider, ID, Zeit und Gerät je Ereignis', () => {
const e = ereignisseAusXml(XML)
expect(e).toHaveLength(3)
expect(e[0]).toEqual({ zeit: '2026-09-01T18:32:42.3868580Z', provider: 'cdrom', id: 7, geraet: '\\Device\\CdRom0' })
expect(e[2].id).toBe(11)
})
it('leere Ausgabe = keine Ereignisse', () => {
expect(ereignisseAusXml('')).toEqual([])
expect(ereignisseAusXml('\r\n')).toEqual([])
})
})
describe('beurteilen — Disc oder Hardware?', () => {
const alle = ereignisseAusXml(XML)
it('ein Controllerfehler dabei: Hardware — Port am Mainboard probieren', () => {
const b = beurteilen(alle, 24)
expect(b.urteil).toBe('hardware')
expect(b.controllerfehler).toBe(1)
expect(b.fehlerhafteBloecke).toBe(2)
expect(b.text).toContain('Mainboard')
expect(b.letzte).toHaveLength(3)
expect(b.letzte[0]).toContain('fehlerhafter Block')
})
it('nur fehlerhafte Blöcke: die Disc', () => {
const b = beurteilen(alle.filter((e) => e.id === 7), 24)
expect(b.urteil).toBe('disc')
expect(b.text).toContain('DISC')
})
it('nichts: ruhig', () => {
const b = beurteilen([], 24)
expect(b.urteil).toBe('ruhig')
expect(b.text).toContain('nichts')
})
it('seit einem Zeitpunkt: nur die jüngeren zählen', () => {
const b = beurteilen(alle, 24, new Date('2026-09-01T18:30:00Z'))
expect(b.controllerfehler).toBe(0)
expect(b.urteil).toBe('disc')
})
})
describe('die wevtutil-Argumente (Regel D: so gemessen)', () => {
it('XPath auf cdrom/disk mit Zeitfenster, XML, neueste zuerst', () => {
expect(xpathFuer(24)).toBe("*[System[Provider[@Name='cdrom' or @Name='disk'] and TimeCreated[timediff(@SystemTime) <= 86400000]]]")
const args = buildWevtutilArgs(24, 50)
expect(args[0]).toBe('qe')
expect(args[1]).toBe('System')
expect(args[2]).toBe(`/q:${xpathFuer(24)}`)
expect(args).toContain('/f:xml')
expect(args).toContain('/c:50')
expect(args).toContain('/rd:true')
})
})
+19
View File
@@ -182,3 +182,22 @@ describe('presets', () => {
expect(sprachliste(null)).toEqual([])
})
})
describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11 gemessen)', () => {
it('mit Sprachliste und untertitelStandard kommt --subtitle-default=1 dazu', () => {
const args = buildHandbrakeArgs({
eingabe: 'a.mkv',
ausgabe: 'b.mkv',
preset: 'X',
audioSprachen: ['jpn'],
untertitelSprachen: ['deu', 'eng'],
untertitelStandard: true,
})
expect(args).toContain('--subtitle-default=1')
expect(args[args.indexOf('--subtitle-lang-list') + 1]).toBe('deu,eng')
})
it('ohne Sprachliste KEINE Vorwahl — die Reihenfolge wäre nicht bestimmt', () => {
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'X', untertitelStandard: true })
expect(args).not.toContain('--subtitle-default=1')
})
})
+289
View File
@@ -0,0 +1,289 @@
// Erkennung v2 + Kino-Banner v2 (5.4.0) — alles an GEMESSENEN Antworten
// (01.09.2026, TMDb mit dem hinterlegten v4-Token):
// movie/149 (Akira): tagline, runtime 124, vote_average 7.94,
// images.logos mit iso_639_1 'en'/'de', credits.cast, release_dates DE
// mit certification '16' (type 3 Kino, 5 Heimvideo, 6 TV).
// tv/46296 (Spartacus): episode_run_time [53], content_ratings DE '18'.
// Die drei „Spartacus: Gods of the Arena"-Stubs: overview '', backdrop
// null, 1 Folge, Popularität 1.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { bdmtZerlegen, inhaltAusBdmt } from '../src/kern/metadaten/discmerkmale'
import { besetzungAus, fskAus, logoAus, type FilmDetails } from '../src/kern/metadaten/tmdb'
import {
LEERER_TREFFER,
VORSCHLAG,
ausDetails,
istStub,
kandidatenAus,
nachpruefen,
zuordnen,
type Zuordnung,
} from '../src/kern/metadaten/zuordnung'
import { strukturHinweis } from '../src/kern/rip/titelwahl'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
/** Die gemessene Akira-Antwort, auf die genutzten Felder gekürzt. */
const AKIRA: FilmDetails = {
id: 149,
title: 'Akira',
release_date: '1988-06-10',
overview: 'Tokio 1988. In einem Geheimlabor der Armee …',
poster_path: '/fDRZmPUPUHTxULsuSjPIqPHKjuE.jpg',
backdrop_path: '/fK40VGYIm7hmKrLJ26fgPQU0qRG.jpg',
runtime: 124,
genres: [{ name: 'Animation' }, { name: 'Science Fiction' }, { name: 'Action' }],
tagline: 'Neo-Tokyo ist am EXPLODIEREN',
vote_average: 7.94,
vote_count: 4944,
images: {
logos: [
{ iso_639_1: 'en', file_path: '/AbcUfIbGRwrlPqdmTSi2WzArjQh.png', width: 664, height: 387, vote_average: 1.75 },
{ iso_639_1: 'en', file_path: '/cHIsyx3xAW314SijTvOyPozWwDV.png', width: 472, height: 290, vote_average: 1.75 },
{ iso_639_1: 'de', file_path: '/lHBQKVTxGUUqCMVo7rzTkYICHjm.png', width: 867, height: 404, vote_average: 0 },
{ iso_639_1: null, file_path: '/sprachlos.png', width: 100, height: 50, vote_average: 5 },
],
},
credits: {
cast: [
{ name: 'Nozomu Sasaki', character: 'Tetsuo Shima (voice)', profile_path: '/7usoPlvc9Xa1rDA2vxmVi0OvLDb.jpg', order: 1 },
{ name: 'Mitsuo Iwata', character: 'Shotaro Kaneda (voice)', profile_path: '/xCAsuzc5OBQZWhG5WSZIyhBZ7xj.jpg', order: 0 },
{ name: 'Mami Koyama', character: 'Kei (voice)', profile_path: null, order: 2 },
],
},
release_dates: {
results: [
{ iso_3166_1: 'US', release_dates: [{ certification: 'R', type: 3 }] },
{
iso_3166_1: 'DE',
release_dates: [
{ certification: '16', type: 5 },
{ certification: '16', type: 3 },
{ certification: '', type: 6 },
],
},
],
},
}
const SPARTACUS_TV: FilmDetails = {
id: 46296,
name: 'Spartacus',
first_air_date: '2010-01-22',
overview: 'Der Thraker Spartacus …',
backdrop_path: '/x.jpg',
vote_average: 8,
vote_count: 1000,
episode_run_time: [53],
number_of_episodes: 39,
content_ratings: { results: [{ iso_3166_1: 'US', rating: 'TV-MA' }, { iso_3166_1: 'DE', rating: '18' }] },
}
/** Einer der drei gemessenen Stubs. */
const STUB: FilmDetails = {
id: 331850,
name: 'Spartacus: Gods of the Arena',
first_air_date: '',
overview: '',
backdrop_path: null,
vote_average: 8,
vote_count: 0,
number_of_episodes: 1,
episode_run_time: [],
}
describe('Kino-Banner v2 — die Felder aus der gemessenen Antwort', () => {
it('logoAus: deutsch vor englisch, darin das bestbewertete', () => {
expect(logoAus(AKIRA.images?.logos)).toBe('/lHBQKVTxGUUqCMVo7rzTkYICHjm.png')
expect(logoAus(AKIRA.images?.logos?.filter((l) => l.iso_639_1 !== 'de'))).toBe('/AbcUfIbGRwrlPqdmTSi2WzArjQh.png')
expect(logoAus([])).toBe('')
expect(logoAus(undefined)).toBe('')
})
it('fskAus: Film aus den deutschen Freigaben (Kino vor Heimvideo), Serie aus content_ratings', () => {
expect(fskAus(AKIRA, 'movie')).toBe('16')
expect(fskAus(SPARTACUS_TV, 'tv')).toBe('18')
expect(fskAus({ id: 1 }, 'movie')).toBe('')
expect(fskAus(STUB, 'tv')).toBe('')
})
it('besetzungAus: nach order sortiert, höchstens sechs, ohne Bild bleibt der Name', () => {
const b = besetzungAus(AKIRA.credits?.cast)
expect(b.map((d) => d.name)).toEqual(['Mitsuo Iwata', 'Nozomu Sasaki', 'Mami Koyama'])
expect(b[0].rolle).toBe('Shotaro Kaneda (voice)')
expect(b[2].bildPfad).toBe('')
expect(besetzungAus(undefined)).toEqual([])
})
it('ausDetails trägt alles in den Treffer — Film und Serie', () => {
const film = ausDetails('movie', 149, AKIRA)
expect(film.tagline).toBe('Neo-Tokyo ist am EXPLODIEREN')
expect(film.bewertung).toBe(7.9)
expect(film.fsk).toBe('16')
expect(film.logoPfad).toContain('.png')
expect(film.besetzung).toHaveLength(3)
expect(film.laufzeitMinuten).toBe(124)
expect(film.genres).toEqual(['Animation', 'Science Fiction', 'Action'])
const serie = ausDetails('tv', 46296, SPARTACUS_TV)
expect(serie.laufzeitMinuten).toBe(53)
expect(serie.folgenGesamt).toBe(39)
expect(serie.fsk).toBe('18')
})
it('istStub erkennt die leeren Einträge — und nichts anderes', () => {
expect(istStub(STUB)).toBe(true)
expect(istStub(AKIRA)).toBe(false)
expect(istStub(SPARTACUS_TV)).toBe(false)
// Nur die Beschreibung fehlt (deutsche Übersetzung nicht da) — kein Stub.
expect(istStub({ ...SPARTACUS_TV, overview: '' })).toBe(false)
})
})
describe('zuordnen — leere Einträge zählen nicht (der Stub-Fund)', () => {
const tmdb = {
verfuegbar: true,
searchMovie: async () => [],
searchTv: async (t: string) =>
t === 'Spartacus: Gods of the Arena'
? [
{ id: 331850, name: 'Spartacus: Gods of the Arena' },
{ id: 331915, name: 'Spartacus Gods of the Arena' },
]
: t === 'Spartacus'
? [{ id: 46296, name: 'Spartacus' }]
: [],
movieDetails: async () => null,
tvDetails: async (id: number) => (id === 46296 ? SPARTACUS_TV : { ...STUB, id }),
findByImdb: async () => null,
}
const keinOmdb = { verfuegbar: false, lookup: async () => null }
it('der wörtliche Stub-Treffer gewinnt nicht mehr — die echte Serie wird ein Vorschlag', async () => {
const z = await zuordnen('Spartacus: Gods of the Arena', tmdb, keinOmdb, { istSerie: true })
// Über den Doppelpunkt-Vorspann 'Spartacus' (Abdeckung 1/5 → gedeckelt).
expect(z.titel).toBe('Spartacus')
expect(z.confidence).toBe(VORSCHLAG)
expect(z.meta.laufzeitMinuten).toBe(53)
})
it('gibt es NUR Stubs, bleibt der erste ein Vorschlag — es wird ja gefragt', async () => {
const nurStubs = { ...tmdb, searchTv: async (t: string) => (t.startsWith('Spartacus: Gods') ? [{ id: 331850, name: 'Spartacus: Gods of the Arena' }] : []) }
const z = await zuordnen('Spartacus: Gods of the Arena', nurStubs, keinOmdb, { istSerie: true })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.titel).toBe('Spartacus: Gods of the Arena')
})
})
describe('kandidatenAus — jede Titel-Variante an ihrer eigenen Vorlage gemessen', () => {
it('volle Titel zuerst, dann die gestutzten, ohne Doppel', () => {
const k = kandidatenAus('Spartacus: Gods Of The Arena - Disc 2', ['Spartacus: Götter der Arena - Disc 2'])
expect(k[0].text).toBe('Spartacus: Gods Of The Arena - Disc 2')
expect(k[1].text).toBe('Spartacus: Götter der Arena - Disc 2')
expect(k[0].massstab).toBe('Spartacus: Gods Of The Arena')
expect(k[1].massstab).toBe('Spartacus: Götter der Arena')
expect(new Set(k.map((x) => x.text.toLowerCase())).size).toBe(k.length)
})
it('ohne Alternativen wie bisher', () => {
expect(kandidatenAus('Akira').map((x) => x.text)).toEqual(['Akira'])
})
})
describe('nachpruefen — die Laufzeit nach dem Info-Lauf', () => {
const akira: Zuordnung = { titel: 'Akira', jahr: 1988, confidence: 0.8, meta: ausDetails('movie', 149, AKIRA) }
it('passt die Laufzeit, wird aus 0,80 ein bestätigtes 0,95', () => {
const z = nachpruefen(akira, { hauptS: 124 * 60 + 30, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(0.95)
expect(z.hinweise?.[0]).toContain('Laufzeit bestätigt')
})
it('passt sie gar nicht (95 vs 158 min), fällt der Treffer auf den Vorschlag zurück', () => {
const falsch = { ...akira, meta: { ...akira.meta, laufzeitMinuten: 158 } }
const z = nachpruefen(falsch, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('passt NICHT')
})
it('die Wahl des Nutzers (1,0) wird nie herabgestuft, nur kommentiert', () => {
const eigene = { ...akira, confidence: 1 }
const z = nachpruefen(eigene, { hauptS: 95 * 60, folgenS: [], struktur: 'film' })
expect(z.confidence).toBe(1)
expect(z.hinweise).toHaveLength(1)
})
it('Serie: Folgenlänge gegen episode_run_time', () => {
const serie: Zuordnung = { titel: 'Spartacus', jahr: 2010, confidence: 0.6, meta: ausDetails('tv', 46296, SPARTACUS_TV) }
const z = nachpruefen(serie, { hauptS: 0, folgenS: [3167, 3249], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise?.[0]).toContain('Folgenlänge passt')
})
it('Struktur sagt Serie, Treffer sagt Film: Vorschlag mit Hinweis', () => {
const z = nachpruefen({ ...akira, meta: { ...akira.meta, laufzeitMinuten: 0 } }, { hauptS: 0, folgenS: [], struktur: 'serie' })
expect(z.confidence).toBe(VORSCHLAG)
expect(z.hinweise?.[0]).toContain('Serienstaffel')
})
it('ohne Laufzeit bei TMDb bleibt alles, wie es war', () => {
const ohne = { ...akira, meta: { ...LEERER_TREFFER, typ: 'movie' as const } }
const z = nachpruefen(ohne, { hauptS: 7000, folgenS: [], struktur: 'unklar' })
expect(z.confidence).toBe(0.8)
expect(z.hinweise).toEqual([])
})
})
describe('strukturHinweis — der Fingerabdruck der Titel-Struktur', () => {
const t = (nr: number, min: number) => ({ nr, dauerS: Math.round(min * 60), groesseBytes: 1e9, kapitel: 8 })
it('ein langer + viele kurze = Film', () => {
expect(strukturHinweis([t(0, 124), t(1, 3), t(2, 1.5), t(3, 20)])).toBe('film')
})
it('sechs gleich lange = Serie', () => {
expect(strukturHinweis([t(0, 44), t(1, 43), t(2, 45), t(3, 44), t(4, 42), t(5, 44), t(6, 2)])).toBe('serie')
})
it('zwei gleich lange Fassungen bleiben unklar (Seamless Branching)', () => {
expect(strukturHinweis([t(0, 120), t(1, 118)])).toBe('unklar')
})
it('zwei Folgen bleiben unklar — dafür ist das bdmt-Inhaltsverzeichnis da', () => {
expect(strukturHinweis([t(0, 52), t(1, 54), t(2, 1)])).toBe('unklar')
})
it('nichts Langes: unklar', () => {
expect(strukturHinweis([t(0, 1), t(1, 0.5)])).toBe('unklar')
expect(strukturHinweis([])).toBe('unklar')
})
})
describe('bdmt_deu als zweite Titel-Variante', () => {
it('bdmtZerlegen liest Titel und Inhaltsverzeichnis (pur)', () => {
const z = bdmtZerlegen('<x><di:name>Titel</di:name><di:titleName titleNumber="1">EP 1</di:titleName></x>')
expect(z).toEqual({ titel: 'Titel', eintraege: ['EP 1'] })
})
it('englisch bleibt der Haupttitel, deutsch wird Alternative — Doppel fallen weg', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt2-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Spartacus: Götter der Arena</di:name></x>')
writeFileSync(join(meta, 'bdmt_eng.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name><di:titleName titleNumber="1">EP 1</di:titleName><di:titleName titleNumber="2">EP 2</di:titleName></x>')
writeFileSync(join(meta, 'bdmt_fra.xml'), '<x><di:name>Spartacus: Gods of the Arena</di:name></x>')
const inhalt = inhaltAusBdmt(wurzel)
expect(inhalt.titel).toBe('Spartacus: Gods of the Arena')
expect(inhalt.eintraege).toEqual(['EP 1', 'EP 2'])
expect(inhalt.alternativen).toEqual(['Spartacus: Götter der Arena'])
})
it('nur bdmt_deu: dann ist der deutsche Titel der Haupttitel', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-bdmt3-'))
wegwerf.push(wurzel)
const meta = join(wurzel, 'BDMV', 'META', 'DL')
mkdirSync(meta, { recursive: true })
writeFileSync(join(meta, 'bdmt_deu.xml'), '<x><di:name>Nur Deutsch</di:name></x>')
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: 'Nur Deutsch', eintraege: [], alternativen: [] })
})
})
+4 -2
View File
@@ -109,7 +109,9 @@ function fakeTmdb(filme: Record<string, FilmEintrag[]>, serien: Record<string, F
tvDetails: async (id: number) => {
for (const liste of Object.values(serien)) {
const treffer = liste.find((f) => f.id === id)
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10' }
// Eine ECHTE Serie hat eine Beschreibung — ohne sie wäre es ein
// leerer Eintrag (istStub, 5.4.0), und der zählt nicht als Treffer.
if (treffer !== undefined) return { id, name: treffer.title, first_air_date: '1993-09-10', overview: 'Ü', ...treffer.details }
}
return null
},
@@ -292,7 +294,7 @@ describe('inhaltAusBdmt — die Disc sagt selbst, was sie ist', () => {
it('ohne bdmt-Datei bleibt alles leer, ohne Absturz', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-toc-'))
wegwerf.push(wurzel)
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [] })
expect(inhaltAusBdmt(wurzel)).toEqual({ titel: '', eintraege: [], alternativen: [] })
})
})
+44
View File
@@ -71,3 +71,47 @@ describe('istHauptNachricht', () => {
expect(istHauptNachricht(undefined)).toBe(false)
})
})
// ── 5.4.0: Auftrag mit Sprachen und Optionen, neue Nachrichten ──────────
describe('istFensterNachricht — rip-start mit Sprachen und Optionen (5.4.0)', () => {
it('nimmt Sprachlisten und Staffel/Folge an', () => {
expect(
istFensterNachricht({
art: 'rip-start',
id: 'G',
auftrag: [{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 16e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }],
optionen: { staffel: 1, ersteFolge: 3 },
}),
).toBe(true)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: null, ersteFolge: null } })).toBe(true)
})
it('lehnt kaputte Sprachlisten, Rollen und Optionen ab', () => {
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'folge', dauerS: 1, audioSprachen: 'jpn' }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', auftrag: [{ nr: 0, rolle: 'egal', dauerS: 1 }] })).toBe(false)
expect(istFensterNachricht({ art: 'rip-start', id: 'G', optionen: { staffel: 'eins' } })).toBe(false)
})
it('kennt die neuen Nachrichten', () => {
expect(istFensterNachricht({ art: 'vorgaenge-laden' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-roh-loeschen', vorgangId: '' })).toBe(false)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 'Fast' })).toBe(true)
expect(istFensterNachricht({ art: 'vorgang-neu-komprimieren', vorgangId: 'x', preset: 5 })).toBe(false)
expect(istFensterNachricht({ art: 'kompression-abbrechen', vorgangId: 'x' })).toBe(true)
expect(istFensterNachricht({ art: 'roh-aufraeumen' })).toBe(true)
expect(istFensterNachricht({ art: 'fremden-ordner-loeschen', ordner: 'E:\Rippy\roh\alt' })).toBe(true)
expect(istFensterNachricht({ art: 'laufwerk-pruefen', id: 'G' })).toBe(true)
})
})
describe('istKernNachricht — Vorgänge, Kompression, Gesundheit (5.4.0)', () => {
it('vorgaenge braucht Liste, Belegung und Warteschlange', () => {
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [], rohGesamtBytes: 0, fremde: [], regel: '', kompression: { laeuft: null, wartend: [] } } })).toBe(true)
expect(istKernNachricht({ art: 'vorgaenge', stand: { vorgaenge: [] } })).toBe(false)
})
it('kompression-ergebnis und laufwerk-gesundheit', () => {
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', titel: 'T', ok: true, text: 'fertig' })).toBe(true)
expect(istKernNachricht({ art: 'kompression-ergebnis', vorgangId: 'x', ok: true })).toBe(false)
expect(istKernNachricht({ art: 'laufwerk-gesundheit', id: 'G', bericht: { urteil: 'disc', text: 't', fehlerhafteBloecke: 1, controllerfehler: 0, sonstige: 0, letzte: [], stunden: 24 } })).toBe(true)
expect(istKernNachricht({ art: 'kern-hinweis', text: 'Roh-Ordner gelöscht' })).toBe(true)
})
})
+208 -117
View File
@@ -1,170 +1,261 @@
// 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'
// Tests der Rip-Phase (5.4.0): Die Pipeline führt den RIP und übergibt
// danach einen Kompressions-Auftrag an die Warteschlange — das Laufwerk
// ist frei, sobald die Roh-Dateien liegen. Die Kompression selbst prüft
// test/ablegen.test.ts.
//
// makemkvcon ist nachgebildet und SCHREIBT Dateien in den Roh-Ordner —
// nur so lässt sich prüfen, dass halbe Dateien nach Abbruch und Fehler
// wirklich verschwinden.
import { existsSync, 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 { RipStatus } from '../src/gemeinsam/nachrichten'
import type { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
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,
}))
interface Schritt {
status: 'success' | 'error' | 'cancelled'
/** Diese Datei entsteht im Roh-Ordner (auch bei Abbruch/Fehler: die halbe). */
datei?: string
bytes?: number
lesefehler?: boolean
fehler?: string
/** Fortschritts-Ticks, die der Lauf meldet. */
ticks?: number[]
}
vi.mock('../src/kern/rip/makemkv', () => ({
rippen: vi.fn(async () => ripErgebnis.wert),
}))
vi.mock('../src/kern/komprimieren/handbrake', () => ({
komprimieren: vi.fn(async () => encodeErgebnis.wert),
}))
const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] }))
import { Pipeline, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
vi.mock('../src/kern/rip/makemkv', async () => {
const fs = await import('node:fs')
const pfad = await import('node:path')
return {
rippen: vi.fn(
async (o: {
zielOrdner: string
aufFortschritt?: (p: number) => void
}) => {
const schritt = plan.schritte.shift() ?? { status: 'success' as const, datei: 'title_t00.mkv' }
for (const p of schritt.ticks ?? []) o.aufFortschritt?.(p)
if (schritt.datei !== undefined) {
fs.writeFileSync(pfad.join(o.zielOrdner, schritt.datei), 'x'.repeat(schritt.bytes ?? 10))
}
if (schritt.status === 'cancelled') return { status: 'cancelled' }
if (schritt.status === 'error') {
return { status: 'error', fehler: schritt.fehler ?? 'MakeMKV endete mit Code 1', lesefehler: schritt.lesefehler ?? false }
}
const dateien = fs
.readdirSync(o.zielOrdner)
.filter((n: string) => n.endsWith('.mkv'))
.sort()
.map((n: string) => pfad.join(o.zielOrdner, n))
return { status: 'success', dateien, lesefehler: schritt.lesefehler ?? false }
},
),
}
})
import { Pipeline, halbeDateienEntfernen, type PipelineUmgebung } from '../src/kern/ablauf/pipeline'
let wegwerf: string[] = []
afterEach(() => {
plan.schritte = []
for (const ordner of wegwerf.splice(0)) rmSync(ordner, { recursive: true, force: true })
})
const GERAET = {
id: 'G',
name: 'Laufwerk G:',
typ: 'bluray' as const,
pfad: '\\\\.\\G:',
status: 'ready' as const,
modell: '',
seriennummer: '',
groesseBytes: 33e9,
grund: '',
}
function umgebung(überschreiben: Partial<PipelineUmgebung> = {}): {
pipeline: Pipeline
meldungen: RipStatus[]
ablage: string
eingereiht: KompressionsAuftrag[]
endet: Array<{ id: string; phase: string; text: string }>
ausgeworfen: string[]
} {
const ablage = mkdtempSync(join(tmpdir(), 'rippy-pipe-'))
wegwerf.push(ablage)
const meldungen: RipStatus[] = []
const eingereiht: KompressionsAuftrag[] = []
const endet: Array<{ id: string; phase: string; text: string }> = []
const ausgeworfen: string[] = []
const pipeline = new Pipeline({
makemkvcon: () => 'C:\\mk\\makemkvcon64.exe',
handbrake: () => 'C:\\hb\\HandBrakeCLI.exe',
flac: () => '',
geraet: () => ({
id: 'G',
name: 'Laufwerk G:',
typ: 'bluray',
pfad: '\\\\.\\G:',
status: 'ready',
modell: '',
seriennummer: '',
groesseBytes: 33e9,
grund: '',
}),
geraet: () => GERAET,
ablage: () => ablage,
einstellungen: () => ({}),
melden: (s) => meldungen.push(s),
jetzt: () => new Date('2026-08-30T12:00:00Z'),
vorgangEinreihen: (auftrag) => {
eingereiht.push(auftrag)
return eingereiht.length
},
vorgangEndet: (id, phase, text) => endet.push({ id, phase, text }),
nachRipAuswerfen: (id) => ausgeworfen.push(id),
...überschreiben,
})
return { pipeline, meldungen }
return { pipeline, meldungen, ablage, eingereiht, endet, ausgeworfen }
}
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 AUFTRAG: RipAuftragTitel[] = [
{ nr: 0, rolle: 'folge', dauerS: 3167, groesseBytes: 3e9, audioSprachen: ['jpn'], untertitelSprachen: ['deu'] },
{ nr: 1, rolle: 'folge', dauerS: 3249, groesseBytes: 1e9 },
]
describe('Pipeline — die Rip-Phase (5.4.0)', () => {
it('rippt Titel für Titel, übergibt den Auftrag und gibt das Laufwerk frei', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', bytes: 300 },
{ status: 'success', datei: 'title_t01.mkv', bytes: 100 },
]
const { pipeline, meldungen, ablage, eingereiht, ausgeworfen } = umgebung()
const beginn: string[] = []
;(pipeline as unknown as { umgebung: PipelineUmgebung }).umgebung.vorgangBeginnt = (v) => beginn.push(v.rohOrdner)
await pipeline.starten('G', AUFTRAG, { staffel: 2, ersteFolge: 3 })
expect(meldungen[0].phase).toBe('rippt')
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(letzte.text).toContain('eingereiht')
expect(letzte.text).toContain('Laufwerk ist frei')
expect(letzte.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(pipeline.laeuft('G')).toBe(false)
expect(ausgeworfen).toEqual(['G'])
expect(beginn).toEqual([join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')])
expect(eingereiht).toHaveLength(1)
const auftrag = eingereiht[0]
expect(auftrag.vorgangId).toBe('bluray-2026-08-30T12-00-00')
expect(auftrag.dateien.map((d) => d.rolle)).toEqual(['folge', 'folge'])
expect(auftrag.dateien[0].audioSprachen).toEqual(['jpn'])
expect(auftrag.dateien[0].untertitelSprachen).toEqual(['deu'])
expect(auftrag.dateien[1].audioSprachen).toEqual([])
expect(auftrag.dateien.map((d) => d.groesseBytes)).toEqual([300, 100])
expect(auftrag.staffel).toBe(2)
expect(auftrag.ersteFolge).toBe(3)
expect(auftrag.preset).toBe('')
})
it('der Fortschritt läuft durch — nach Dateigröße gewichtet, ohne Rücksprung', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv', ticks: [50, 100] },
{ status: 'success', datei: 'title_t01.mkv', ticks: [50] },
]
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'])
await pipeline.starten('G', AUFTRAG)
const prozente = meldungen.filter((m) => m.phase === 'rippt').map((m) => m.prozent)
// Titel 0 wiegt 3 GB, Titel 1 wiegt 1 GB: 50 % vom ersten = 37 %,
// erster fertig = 75 %, 50 % vom zweiten = 87 %. Nie zurück auf 0.
expect(prozente).toContain(37)
expect(prozente).toContain(75)
expect(prozente).toContain(87)
for (let i = 1; i < prozente.length; i++) expect(prozente[i]).toBeGreaterThanOrEqual(prozente[i - 1])
})
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('Abbruch: die halbe Datei verschwindet, der leere Roh-Ordner auch', async () => {
plan.schritte = [{ status: 'cancelled', datei: 'title_t00.mkv' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('abgebrochen')
expect(letzte.text).toContain('Halbe Datei entfernt: title_t00.mkv')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
expect(eingereiht).toHaveLength(0)
expect(endet).toEqual([{ id: 'bluray-2026-08-30T12-00-00', phase: 'abgebrochen', text: letzte.text }])
})
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('Abbruch nach einem fertigen Titel: der fertige bleibt, die halbe geht', async () => {
plan.schritte = [
{ status: 'success', datei: 'title_t00.mkv' },
{ status: 'cancelled', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, ablage } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('abgebrochen')
const roh = join(ablage, 'roh', 'bluray-2026-08-30T12-00-00')
expect(readdirSync(roh)).toEqual(['title_t00.mkv'])
expect(meldungen.at(-1)!.text).toContain('1 fertige Datei(en) bleiben')
})
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('EIN kaputter Titel beendet den Auftrag nicht — seine halbe Datei wird entfernt', async () => {
plan.schritte = [
{ status: 'error', datei: 'title_t00.mkv', fehler: 'Lesefehler an Byte 56881152' },
{ status: 'success', datei: 'title_t01.mkv' },
]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', AUFTRAG)
const letzte = meldungen.at(-1)!
expect(letzte.phase).toBe('fertig')
expect(eingereiht[0].dateien.map((d) => d.pfad.split(/[\\/]/).pop())).toEqual(['title_t01.mkv'])
expect(eingereiht[0].hinweise.join(' ')).toContain('Nicht gesichert')
expect(eingereiht[0].hinweise.join(' ')).toContain('Halbe Dateien entfernt: title_t00.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()
it('kein Titel gesichert: Fehler, kein Auftrag, Roh-Ordner weg', async () => {
plan.schritte = [{ status: 'error', fehler: 'kaputt' }, { status: 'error', fehler: 'auch kaputt' }]
const { pipeline, meldungen, ablage, eingereiht, endet } = umgebung()
await pipeline.starten('G', AUFTRAG)
expect(meldungen.at(-1)!.phase).toBe('fehler')
expect(meldungen.at(-1)!.text).toContain('kaputt')
expect(eingereiht).toHaveLength(0)
expect(endet[0].phase).toBe('fehler')
expect(existsSync(join(ablage, 'roh', 'bluray-2026-08-30T12-00-00'))).toBe(false)
})
it('Lesefehler reisen bis in den Auftrag und die Fertig-Meldung', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv', lesefehler: true }]
const { pipeline, meldungen, eingereiht } = umgebung()
await pipeline.starten('G', [AUFTRAG[0]])
expect(meldungen.at(-1)!.lesefehler).toBe(true)
expect(meldungen.at(-1)!.text).toContain('nicht lesen')
expect(eingereiht[0].lesefehler).toBe(true)
})
it('ohne Auftrag (alles rippen): eine Datei, Rolle Hauptfilm, Staffel von der Disc', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, eingereiht } = umgebung({ discAngaben: () => ({ staffel: 3, discNr: 2 }) })
await pipeline.starten('G')
const letzte = meldungen.at(-1)
expect(letzte?.lesefehler).toBe(true)
expect(letzte?.text).toContain('nicht lesen')
expect(eingereiht[0].dateien).toHaveLength(1)
expect(eingereiht[0].dateien[0].rolle).toBe('hauptfilm')
expect(eingereiht[0].staffel).toBe(3)
expect(eingereiht[0].discNr).toBe(2)
})
it('ohne Empfänger für den Auftrag sagt die Meldung, wo die Roh-Dateien liegen', async () => {
plan.schritte = [{ status: 'success', datei: 'title_t00.mkv' }]
const { pipeline, meldungen } = umgebung({ vorgangEinreihen: undefined })
await pipeline.starten('G')
expect(meldungen.at(-1)!.text).toContain('nicht eingereiht')
})
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: '',
}),
geraet: () => ({ ...GERAET, typ: 'unknown', status: 'empty', groesseBytes: 0 }),
})
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',
flac: () => '',
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)
it('halbeDateienEntfernen: nur, was NEU dazukam', () => {
const ordner = mkdtempSync(join(tmpdir(), 'rippy-halb-'))
wegwerf.push(ordner)
writeFileSync(join(ordner, 'alt.mkv'), 'a')
const vorher = new Set(readdirSync(ordner))
writeFileSync(join(ordner, 'neu.mkv'), 'b')
expect(halbeDateienEntfernen(ordner, vorher)).toEqual(['neu.mkv'])
expect(readdirSync(ordner)).toEqual(['alt.mkv'])
})
})
+148
View File
@@ -0,0 +1,148 @@
// Tests des Roh-Aufräumens (kern/ablage/rohaufraeumen.ts, 5.4.0): die
// Regel, die Frist, die drei Sicherungen — und dass fremde Ordner nur
// gezeigt, nie von selbst gelöscht werden.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
aufraeumen,
faelligAm,
fremdeOrdner,
istUnterRohWurzel,
loeschHindernis,
ordnerGroesse,
regelLesen,
regelText,
type AufraeumKandidat,
type AufraeumZugriff,
} from '../src/kern/ablage/rohaufraeumen'
let wegwerf: string[] = []
afterEach(() => {
for (const o of wegwerf.splice(0)) rmSync(o, { recursive: true, force: true })
})
describe('regelLesen', () => {
it('kennt behalten, sofort und Tage — und die Vorgabe 7', () => {
expect(regelLesen('behalten')).toEqual({ art: 'behalten' })
expect(regelLesen('sofort')).toEqual({ art: 'sofort' })
expect(regelLesen('30')).toEqual({ art: 'tage', tage: 30 })
expect(regelLesen(null)).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('quatsch')).toEqual({ art: 'tage', tage: 7 })
expect(regelLesen('-3')).toEqual({ art: 'tage', tage: 7 })
expect(regelText(regelLesen('7'))).toContain('7 Tage')
})
})
describe('faelligAm', () => {
it('Tage nach dem Ende; sofort = am Ende; behalten = nie', () => {
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'tage', tage: 7 })).toBe('2026-09-08T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'sofort' })).toBe('2026-09-01T20:00:00.000Z')
expect(faelligAm('2026-09-01T20:00:00.000Z', { art: 'behalten' })).toBe('')
expect(faelligAm(null, { art: 'sofort' })).toBe('')
expect(faelligAm('kaputt', { art: 'sofort' })).toBe('')
})
})
const fertigeDateien = ['E:\\Rippy\\Filme\\Akira (1988)\\title_t00.mkv']
const kandidat = (teil: Partial<AufraeumKandidat> = {}): AufraeumKandidat => ({
id: 'bluray-1',
phase: 'fertig',
komprimiert: true,
rohOrdner: 'E:\\Rippy\\roh\\bluray-1',
rohGeloeschtAm: null,
beendet: '2026-09-01T20:00:00.000Z',
fertigeDateien,
...teil,
})
const zugriff = (überschreiben: Partial<AufraeumZugriff> = {}): AufraeumZugriff & { geloescht: string[] } => {
const geloescht: string[] = []
return {
existiert: () => true,
groesse: () => 1000,
ordnerLoeschen: (p) => {
geloescht.push(p)
},
geloescht,
...überschreiben,
}
}
const regel7 = { art: 'tage' as const, tage: 7 }
const spaeter = new Date('2026-09-10T00:00:00Z')
const frueher = new Date('2026-09-03T00:00:00Z')
describe('loeschHindernis — die drei Sicherungen', () => {
it('fertig, komprimiert, Ergebnis da, Frist um: löschbar', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff())).toBe('')
})
it('vor der Frist: nicht löschbar, mit Datum', () => {
expect(loeschHindernis(kandidat(), regel7, frueher, zugriff())).toContain('Frist läuft bis 2026-09-08')
})
it('nicht fertig / nicht komprimiert / schon gelöscht / Regel behalten', () => {
expect(loeschHindernis(kandidat({ phase: 'fehler' }), regel7, spaeter, zugriff())).toContain('nicht fertig')
expect(loeschHindernis(kandidat({ komprimiert: false }), regel7, spaeter, zugriff())).toContain('nicht komprimiert')
expect(loeschHindernis(kandidat({ rohGeloeschtAm: 'x' }), regel7, spaeter, zugriff())).toBe('schon gelöscht')
expect(loeschHindernis(kandidat(), { art: 'behalten' }, spaeter, zugriff())).toBe('Regel: behalten')
})
it('die fertige Datei fehlt oder ist leer: NIE löschen', () => {
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ existiert: (p) => p.endsWith('bluray-1') }))).toContain('fertige Datei fehlt')
expect(loeschHindernis(kandidat(), regel7, spaeter, zugriff({ groesse: () => 0 }))).toContain('ist leer')
expect(loeschHindernis(kandidat({ fertigeDateien: [] }), regel7, spaeter, zugriff())).toContain('keine fertige Datei')
})
it('sofort: gleich nach dem Ende löschbar', () => {
expect(loeschHindernis(kandidat(), { art: 'sofort' }, new Date('2026-09-01T20:00:01Z'), zugriff())).toBe('')
})
})
describe('aufraeumen', () => {
it('löscht nur die Fälligen und sagt, warum der Rest blieb', () => {
const z = zugriff()
const ergebnis = aufraeumen(
[kandidat(), kandidat({ id: 'jung', beendet: '2026-09-09T00:00:00.000Z' }), kandidat({ id: 'roh', komprimiert: false })],
regel7,
spaeter,
z,
)
expect(ergebnis.geloescht).toEqual(['bluray-1'])
expect(z.geloescht).toEqual(['E:\\Rippy\\roh\\bluray-1'])
expect(ergebnis.uebersprungen.map((u) => u.id)).toEqual(['jung', 'roh'])
expect(ergebnis.fehler).toEqual([])
})
it('ein Ordner, der sich nicht löschen lässt, ist ein lauter Fehler, kein stilles Nichts', () => {
const ergebnis = aufraeumen([kandidat()], regel7, spaeter, zugriff({ ordnerLoeschen: () => { throw new Error('EBUSY') } }))
expect(ergebnis.geloescht).toEqual([])
expect(ergebnis.fehler[0].grund).toContain('EBUSY')
})
})
describe('Belegung und fremde Ordner — am echten Dateisystem', () => {
it('ordnerGroesse zählt rekursiv; fremdeOrdner kennt die Vorgänge und lässt sie aus', () => {
const wurzel = mkdtempSync(join(tmpdir(), 'rippy-roh-'))
wegwerf.push(wurzel)
const roh = join(wurzel, 'roh')
mkdirSync(join(roh, 'bluray-1', 'tief'), { recursive: true })
mkdirSync(join(roh, 'alt-vor-540'), { recursive: true })
writeFileSync(join(roh, 'bluray-1', 'a.mkv'), 'x'.repeat(100))
writeFileSync(join(roh, 'bluray-1', 'tief', 'b.mkv'), 'x'.repeat(50))
writeFileSync(join(roh, 'alt-vor-540', 'c.mkv'), 'x'.repeat(7))
writeFileSync(join(roh, 'lose.txt'), 'x')
expect(ordnerGroesse(join(roh, 'bluray-1'))).toBe(150)
expect(ordnerGroesse(join(roh, 'gibt-es-nicht'))).toBe(0)
const fremde = fremdeOrdner(roh, new Set([join(roh, 'bluray-1')]))
expect(fremde).toEqual([{ ordner: join(roh, 'alt-vor-540'), bytes: 7 }])
expect(fremdeOrdner(join(wurzel, 'nix'), new Set())).toEqual([])
})
})
describe('istUnterRohWurzel — nur Ordner DIREKT unter roh\\', () => {
it('erlaubt genau eine Ebene', () => {
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\bluray-1')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh\\', 'e:\\rippy\\roh\\bluray-1\\')).toBe(true)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\roh\\a\\b')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\Filme\\Akira')).toBe(false)
expect(istUnterRohWurzel('E:\\Rippy\\roh', 'E:\\Rippy\\rohling\\x')).toBe(false)
})
})
+62
View File
@@ -0,0 +1,62 @@
// Tests des HandBrake-Scans (kern/komprimieren/scan.ts, 5.4.0) — am
// gemessenen Format: „JSON Title Set:" + Objekt + weiterer Text.
import { describe, expect, it } from 'vitest'
import { buildScanArgs, fehlendeSprachen, spurenAusScan } from '../src/kern/komprimieren/scan'
import { regelAusQuery, RIPPY_REGEL } from '../src/kern/werkzeuge/spurauswahl'
const AUSGABE = `[21:27:54] hb_init: starting libhb thread
Scanning title 1 of 1, preview 1, 25.00 %
JSON Title Set: {
"MainFeature": 0,
"TitleList": [
{
"AudioList": [
{ "Description": "\u65e5\u672c\u8a9e (AAC LC, 2.0 ch)", "Language": "\u65e5\u672c\u8a9e", "LanguageCode": "jpn", "TrackNumber": 1 },
{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },
{ "Description": "English (AAC LC, 2.0 ch)", "Language": "English", "LanguageCode": "eng", "TrackNumber": 3 }
],
"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },
"Index": 1,
"Name": "quelle",
"SubtitleList": [
{ "Format": "text", "Language": "Deutsch (UTF-8)", "LanguageCode": "deu", "SourceName": "UTF-8", "TrackNumber": 1 },
{ "Format": "text", "Language": "English (UTF-8)", "LanguageCode": "eng", "SourceName": "UTF-8", "TrackNumber": 2 }
]
}
]
}
HandBrake has exited.
`
describe('spurenAusScan', () => {
it('schneidet das Objekt aus dem Textprotokoll und liest Spuren + Dauer', () => {
const s = spurenAusScan(AUSGABE)!
expect(s.audio.map((a) => `${a.nr}:${a.lang}`)).toEqual(['1:jpn', '2:deu', '3:eng'])
expect(s.untertitel.map((u) => u.lang)).toEqual(['deu', 'eng'])
expect(s.dauerS).toBe(52 * 60 + 47)
})
it('ohne Marke oder mit kaputtem JSON: null, nie ein leerer Befund', () => {
expect(spurenAusScan('nichts')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [ ')).toBeNull()
expect(spurenAusScan('JSON Title Set: { "TitleList": [] }')).toBeNull()
})
it('buildScanArgs: --scan --json, Titel 1', () => {
expect(buildScanArgs('R:\\roh\\a.mkv')).toEqual(['--scan', '--json', '--input', 'R:\\roh\\a.mkv', '--title', '1'])
})
})
describe('fehlendeSprachen', () => {
it('nennt, was gewünscht ist und nicht drin', () => {
const s = spurenAusScan(AUSGABE)!
expect(fehlendeSprachen(s, { audio: ['jpn', 'fra'], untertitel: ['deu', 'jpn'] })).toEqual({ audio: ['fra'], untertitel: ['jpn'] })
expect(fehlendeSprachen(s, { audio: [], untertitel: [] })).toEqual({ audio: [], untertitel: [] })
})
})
describe('MakeMKV-Auswahlregel — reg query lesen', () => {
it('findet den Wert in der Ausgabe', () => {
const ausgabe = `\r\nHKEY_CURRENT_USER\\Software\\MakeMKV\r\n app_DefaultSelectionString REG_SZ ${RIPPY_REGEL}\r\n\r\n`
expect(regelAusQuery(ausgabe)).toBe(RIPPY_REGEL)
expect(regelAusQuery('FEHLER: Der angegebene Registrierungsschlüssel oder Wert wurde nicht gefunden.')).toBeNull()
})
})
+132
View File
@@ -0,0 +1,132 @@
// Tests der Kompressions-Warteschlange (kern/ablauf/warteschlange.ts,
// 5.4.0): eine zur Zeit, der Reihe nach; abbrechen wartend und laufend;
// eine Ausnahme im Encode beendet nicht die Schlange; kein Doppel.
import { describe, expect, it } from 'vitest'
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
import type { AblageErgebnis } from '../src/kern/ablauf/ablegen'
import { KompressionsWarteschlange, type WarteschlangenStand } from '../src/kern/ablauf/warteschlange'
function auftrag(id: string): KompressionsAuftrag {
return {
vorgangId: id,
laufwerk: 'G',
discTyp: 'bluray',
rohOrdner: `R:\\roh\\${id}`,
dateien: [],
zuordnung: null,
staffel: null,
ersteFolge: null,
discNr: null,
fingerprint: null,
lesefehler: false,
hinweise: [],
begonnen: '2026-08-30T12:00:00Z',
preset: '',
zielOrdner: '',
}
}
function fertig(text = 'ok'): AblageErgebnis {
return { status: 'fertig', zielOrdner: 'Z', dateien: [], text, komprimiert: true, preset: 'p', fertigBytes: 1, hinweise: [] }
}
/** Ein Encode, den der Test von außen beendet. */
function steuerbar() {
const freigaben: Array<() => void> = []
const gestartet: string[] = []
const ausfuehren = (a: KompressionsAuftrag, melden: (s: { phase: 'komprimiert'; prozent: number; text: string }) => void, abbruch: AbortSignal) =>
new Promise<AblageErgebnis>((loesen) => {
gestartet.push(a.vorgangId)
melden({ phase: 'komprimiert', prozent: 10, text: 'läuft' })
abbruch.addEventListener('abort', () => loesen({ ...fertig('abgebrochen'), status: 'abgebrochen' }), { once: true })
freigaben.push(() => loesen(fertig()))
})
return { ausfuehren, freigaben, gestartet }
}
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))
describe('KompressionsWarteschlange', () => {
it('arbeitet der Reihe nach, meldet Stand und Ergebnis, vergibt Plätze', async () => {
const { ausfuehren, freigaben, gestartet } = steuerbar()
const staende: WarteschlangenStand[] = []
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, (s) => staende.push(s), (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.einreihen(auftrag('c'), 'C')).toBe(3)
await tick()
expect(gestartet).toEqual(['a'])
expect(q.stand().laeuft?.vorgangId).toBe('a')
expect(q.stand().laeuft?.prozent).toBe(10)
expect(q.stand().wartend.map((w) => w.vorgangId)).toEqual(['b', 'c'])
expect(q.laeuftEtwas()).toBe(true)
expect(q.kennt('b')).toBe(true)
freigaben[0]()
await tick()
expect(gestartet).toEqual(['a', 'b'])
expect(ergebnisse).toEqual(['a:fertig'])
freigaben[1]()
await tick()
freigaben[2]()
await tick()
expect(ergebnisse).toEqual(['a:fertig', 'b:fertig', 'c:fertig'])
expect(q.laeuftEtwas()).toBe(false)
expect(q.stand().laeuft).toBeNull()
expect(staende.length).toBeGreaterThan(3)
})
it('kein Doppel: derselbe Vorgang bekommt seinen alten Platz zurück', async () => {
const { ausfuehren } = steuerbar()
const q = new KompressionsWarteschlange(ausfuehren, () => {}, () => {})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.einreihen(auftrag('a'), 'A')).toBe(1)
expect(q.einreihen(auftrag('b'), 'B')).toBe(2)
expect(q.stand().wartend).toHaveLength(1)
})
it('abbrechen: wartend wird entfernt (mit Ergebnis), laufend bekommt das Signal', async () => {
const { ausfuehren, freigaben } = steuerbar()
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(ausfuehren, () => {}, (a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}`)
})
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
expect(q.abbrechen('b')).toBe(true)
expect(ergebnisse).toEqual(['b:abgebrochen'])
expect(q.stand().wartend).toHaveLength(0)
expect(q.abbrechen('a')).toBe(true)
await tick()
expect(ergebnisse).toEqual(['b:abgebrochen', 'a:abgebrochen'])
expect(q.abbrechen('gibt-es-nicht')).toBe(false)
expect(freigaben).toHaveLength(1)
})
it('eine Ausnahme im Encode ist ein lautes Fehler-Ergebnis — die Schlange läuft weiter', async () => {
let n = 0
const ergebnisse: string[] = []
const q = new KompressionsWarteschlange(
async () => {
n += 1
if (n === 1) throw new Error('HandBrake explodiert')
return fertig()
},
() => {},
(a, e) => {
ergebnisse.push(`${a.vorgangId}:${e.status}:${e.text.includes('explodiert')}`)
},
)
q.einreihen(auftrag('a'), 'A')
q.einreihen(auftrag('b'), 'B')
await tick()
await tick()
expect(ergebnisse).toEqual(['a:fehler:true', 'b:fertig:false'])
})
})