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
+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'])
})
})