Die sechs Punkte des Commanders vom 02.09.2026 abends und seine Entscheide:
* Restzeit der Kompression aus HandBrakes eigener ETA-Klammer (Formatstring
aus dem Binary 1.11.2 gelesen), KompressionStand.restS.
* Kachel zeigt nach dem Rip nur noch eine Zeile — Balken, Prozent, Ziel in
der Kompressions-Karte und im Verlauf ("nur in der Kompressions-Karte").
* Kopfzeile bleibt beim Scrollen stehen (sticky), Sektionswahl darunter.
* Untertitel-Vorwahl je Extra ("NUR für Extras, IMMER OPTIONAL"): welche
Untertitel-Spur beim Abspielen an ist — weiche Spur, nie eingebrannt;
RipAuftragTitel/RohDatei.untertitelVorwahl, sprachwahl.
* Mehrere Filme je Disc: "anderer Film …" je Titel mit TMDb-Suche;
filme.ts filmGruppen trennt am Rip-Ende in eigene Roh-Ordner, Vorgänge
und Kompressions-Aufträge mit voller Zuordnung (erkennung.zuordnungAus).
* Roh-Ordner und Dateien tragen den Filmnamen (filme.ts rohOrdnerName,
ablage/dateinamen.ts: Akira (1988).mkv, - Fassung n, - Extra nn).
* Hell-Modus: Einstellung design (dunkel/hell/system), data-design auf
<html>, Token --color-tinte statt white/-Alpha-Klassen.
* Vorgangs-Leiste (Commander: "Phase 1–4 plus Komprimierung — zu einem
machen"): gemeinsam/vorgangsleiste.ts + uebersicht/VorgangsLeiste.tsx,
vier Stationen (Rettung als Zwischenstation), Segment-Balken in der
aktiven Station; Kachel, Kompressions-Karte, Verlauf. PHASE-x/4-Texte weg.
* Automatik-Kasten leert sich, sobald ein Rip startet.
Beweise: 446 Tests (neu: filme, dateinamen, vorgangsleiste; erweitert:
auftrag, handbrake, automatik, ablegen), drei Typprüfungen, Klick-Beweis
33 Schritte inkl. Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit,
Hell-Modus (Bilder beweise/klick/01…11).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
350 lines
14 KiB
TypeScript
350 lines
14 KiB
TypeScript
// 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, readFileSync, 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, 'Akira (1988).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, 'Akira (1988).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, 'Akira (1988).mkv'))).toBe(true)
|
|
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'Akira (1988) - Extra 01.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)
|
|
})
|
|
})
|
|
|
|
describe('Wiederholen ersetzt die alte Fassung', () => {
|
|
it('Serie: die alten S01E03/E04 fallen erst nach dem Encode, dann greift die Umbenennung', async () => {
|
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv', 'title_t01.mkv'], ['folge', 'folge'])
|
|
const ziel = join(ablage, 'Serien', 'Spartacus', 'Season 01')
|
|
mkdirSync(ziel, { recursive: true })
|
|
const alt3 = join(ziel, 'Spartacus S01E03.mkv')
|
|
const alt4 = join(ziel, 'Spartacus S01E04.mkv')
|
|
writeFileSync(alt3, 'alt')
|
|
writeFileSync(alt4, 'alt')
|
|
const ergebnis = await komprimierenUndAblegen(
|
|
auftrag({
|
|
rohOrdner: roh,
|
|
dateien,
|
|
zuordnung: zuordnung('tv', 'Spartacus', 2010),
|
|
staffel: 1,
|
|
ersteFolge: 3,
|
|
zielOrdner: ziel,
|
|
ersetzt: [alt3, alt4],
|
|
}),
|
|
umgebung(ablage),
|
|
() => {},
|
|
KEIN_ABBRUCH,
|
|
)
|
|
expect(ergebnis.status).toBe('fertig')
|
|
expect(readdirSync(ziel).filter((n) => n.endsWith('.mkv')).sort()).toEqual(['Spartacus S01E03.mkv', 'Spartacus S01E04.mkv'])
|
|
expect(readFileSync(alt3, 'utf8')).toBe('encoded')
|
|
expect(ergebnis.hinweise.join(' ')).toContain('Alte Fassung ersetzt')
|
|
expect(ergebnis.hinweise.join(' ')).not.toContain('existiert schon')
|
|
})
|
|
|
|
it('scheitert der zweite Lauf, bleibt die alte Fassung stehen', async () => {
|
|
hb.ergebnis = 'error'
|
|
const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
|
|
const ziel = join(ablage, 'Filme', 'Akira (1988)')
|
|
mkdirSync(ziel, { recursive: true })
|
|
const alt = join(ziel, 'title_t00.mkv')
|
|
writeFileSync(alt, 'alt')
|
|
const ergebnis = await komprimierenUndAblegen(
|
|
auftrag({ rohOrdner: roh, dateien, zuordnung: zuordnung('movie', 'Akira', 1988), zielOrdner: ziel, ersetzt: [alt] }),
|
|
umgebung(ablage),
|
|
() => {},
|
|
KEIN_ABBRUCH,
|
|
)
|
|
expect(ergebnis.status).toBe('fehler')
|
|
expect(readFileSync(alt, 'utf8')).toBe('alt')
|
|
})
|
|
})
|