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:
co-authored by
Claude Fable 5.1
parent
d70a81d194
commit
3f7f962dff
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
@@ -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: [] })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user