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>
262 lines
11 KiB
TypeScript
262 lines
11 KiB
TypeScript
// 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 { RipAuftragTitel, RipStatus } from '../src/gemeinsam/nachrichten'
|
|
import type { KompressionsAuftrag } from '../src/kern/ablauf/auftrag'
|
|
|
|
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[]
|
|
}
|
|
|
|
const plan = vi.hoisted(() => ({ schritte: [] as Schritt[] }))
|
|
|
|
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: () => 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, ablage, eingereiht, endet, ausgeworfen }
|
|
}
|
|
|
|
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', 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('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('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('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('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')
|
|
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: () => ({ ...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('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'])
|
|
})
|
|
})
|