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