feat(v5): 5.6.0 — Vorgangs-Leiste, mehrere Filme je Disc, Filmnamen für Ordner und Dateien, Untertitel-Vorwahl je Extra, Restzeit, Hell-Modus

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>
This commit is contained in:
Hitonabi
2026-09-03 19:17:37 +02:00
co-authored by Claude Fable 5.1
parent 76ed3b337a
commit f5dab5d1c1
59 changed files with 1284 additions and 182 deletions
+4 -4
View File
@@ -121,7 +121,7 @@ describe('komprimierenUndAblegen', () => {
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(ergebnis.dateien).toEqual([join(ergebnis.zielOrdner, 'Akira (1988).mkv')])
expect(phasen).toContain('komprimiert')
expect(phasen.at(-1)).toBe('legt-ab')
expect(bibliothek).toEqual(['Akira'])
@@ -139,7 +139,7 @@ describe('komprimierenUndAblegen', () => {
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, 'Akira (1988).mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'movie.nfo'))).toBe(true)
expect(readdirSync(roh)).toEqual([])
expect(hb.aufrufe).toHaveLength(0)
@@ -269,8 +269,8 @@ describe('komprimierenUndAblegen', () => {
() => {},
KEIN_ABBRUCH,
)
expect(existsSync(join(ergebnis.zielOrdner, 'title_t00.mkv'))).toBe(true)
expect(existsSync(join(ergebnis.zielOrdner, 'extras', 'title_t05.mkv'))).toBe(true)
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 () => {
+20
View File
@@ -117,3 +117,23 @@ describe('Auftrag als JSON — und wieder zurück', () => {
expect(b.preset).toBe('')
})
})
describe('sprachwahl — Untertitel-Vorwahl je Extra (5.6.0, immer optional)', () => {
const global = { audio: ['deu', 'eng'], untertitel: ['deu'] }
it('eine gesetzte Vorwahl schlägt die Regel: nach vorn und vorgewählt', () => {
expect(sprachwahl({ audioSprachen: ['deu'], untertitelSprachen: ['eng', 'deu'], untertitelVorwahl: 'deu' }, global)).toEqual({
audio: ['deu'],
untertitel: ['deu', 'eng'],
untertitelStandard: true,
})
})
it('null heißt ausdrücklich keine Vorwahl — auch bei fremdem Ton', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'], untertitelVorwahl: null }, global).untertitelStandard).toBe(false)
})
it('fehlend heißt Regel wie bisher', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['deu'] }, global).untertitelStandard).toBe(true)
})
it('eine Vorwahl, die nicht in der Liste steht, wird ergänzt', () => {
expect(sprachwahl({ audioSprachen: ['jpn'], untertitelSprachen: ['eng'], untertitelVorwahl: 'deu' }, global).untertitel).toEqual(['deu', 'eng'])
})
})
+12
View File
@@ -187,6 +187,18 @@ describe('AutomatikDienst', () => {
a.tick()
expect(a.gestartet[0].optionen).toEqual({ staffel: 1, ersteFolge: 3 })
})
it('leeren: Countdown weg und ein leerer Stand ans Fenster — der Kasten verschwindet (5.6.0)', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
a.dienst.leeren('G')
expect(a.gestopptZahl()).toBe(1)
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, sekunden: 0, text: '' })
// Ohne bekannten Stand wird nichts gesendet — kein leerer Kasten aus dem Nichts.
const b = aufbau(5)
b.dienst.leeren('G')
expect(b.nachrichten).toHaveLength(0)
})
it('Disc raus: Countdown weg, Stand weg', async () => {
const a = aufbau(5)
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
+42
View File
@@ -0,0 +1,42 @@
// Tests der Dateinamen in der Ablage (kern/ablage/dateinamen.ts, 5.6.0).
import { describe, expect, it } from 'vitest'
import { rollenNummern, zielDateiname } from '../src/kern/ablage/dateinamen'
describe('zielDateiname', () => {
const film = { titel: 'Akira', jahr: 1988, alsSerie: false }
it('ein Hauptfilm heißt wie der Film', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('Akira (1988).mkv')
})
it('zwei Hauptfilme werden Fassungen', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 2, original: 'title_t00.mkv' })).toBe('Akira (1988) - Fassung 1.mkv')
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 2, anzahl: 2, original: 'title_t01.mkv' })).toBe('Akira (1988) - Fassung 2.mkv')
})
it('Extras werden nummeriert', () => {
expect(zielDateiname({ ...film, rolle: 'extra', nummer: 3, anzahl: 5, original: 'title_t07.mkv' })).toBe('Akira (1988) - Extra 03.mkv')
})
it('die Endung bleibt, was MakeMKV schrieb', () => {
expect(zielDateiname({ ...film, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'x.mp4' })).toBe('Akira (1988).mp4')
})
it('ohne Jahr ohne Klammer', () => {
expect(zielDateiname({ ...film, jahr: null, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'a.mkv' })).toBe('Akira.mkv')
})
it('Serie: Folgen behalten den Namen (die Episoden-Zuordnung benennt später), Extras heißen nach der Serie', () => {
const serie = { titel: 'Spartacus', jahr: 2010, alsSerie: true }
expect(zielDateiname({ ...serie, rolle: 'folge', nummer: 1, anzahl: 4, original: 'title_t02.mkv' })).toBe('title_t02.mkv')
expect(zielDateiname({ ...serie, rolle: 'extra', nummer: 1, anzahl: 1, original: 'title_t09.mkv' })).toBe('Spartacus - Extra 01.mkv')
})
it('ohne erkannten Titel bleibt der MakeMKV-Name', () => {
expect(zielDateiname({ titel: '', jahr: null, alsSerie: false, rolle: 'hauptfilm', nummer: 1, anzahl: 1, original: 'title_t00.mkv' })).toBe('title_t00.mkv')
})
})
describe('rollenNummern', () => {
it('zählt je Rolle in Reihenfolge und kennt die Gesamtzahl', () => {
expect(rollenNummern(['hauptfilm', 'extra', 'extra', 'hauptfilm'])).toEqual([
[1, 2],
[1, 2],
[2, 2],
[2, 2],
])
})
})
+73
View File
@@ -0,0 +1,73 @@
// Tests zu mehreren Filmen je Disc und Ordnern mit Filmnamen
// (kern/ablauf/filme.ts, 5.6.0).
import { describe, expect, it } from 'vitest'
import type { MetaVorschlag } from '../src/gemeinsam/nachrichten'
import type { RohDatei } from '../src/kern/ablauf/auftrag'
import { filmGruppen, rohOrdnerName, zuordnungAusVorschlag } from '../src/kern/ablauf/filme'
import { LEERER_TREFFER, type Zuordnung } from '../src/kern/metadaten/zuordnung'
function zuordnung(titel: string, jahr: number | null, typ: 'movie' | 'tv' = 'movie', id = 1): Zuordnung {
return { titel, jahr, confidence: 0.95, meta: { ...LEERER_TREFFER, typ, id, titel, jahr, quelle: 'tmdb' } }
}
function datei(pfad: string, film?: MetaVorschlag): RohDatei {
return { pfad, titelNr: 0, rolle: 'hauptfilm', dauerS: 6000, groesseBytes: 1e9, audioSprachen: [], untertitelSprachen: [], ...(film !== undefined ? { film } : {}) }
}
const akira: MetaVorschlag = { typ: 'movie', tmdbId: 149, titel: 'Akira', jahr: 1988, posterPfad: '/a.jpg' }
const ghost: MetaVorschlag = { typ: 'movie', tmdbId: 9323, titel: 'Ghost in the Shell', jahr: 1995, posterPfad: '' }
describe('rohOrdnerName', () => {
it('Film: „Titel (Jahr)"', () => {
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', () => false)).toBe('Akira (1988)')
})
it('Serie: Titel, Staffel und Disc', () => {
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: 1, discNr: 2 }, 'bluray-2026', () => false)).toBe('Spartacus - Staffel 1 Disc 2')
expect(rohOrdnerName(zuordnung('Spartacus', 2010, 'tv'), { staffel: null, discNr: null }, 'bluray-2026', () => false)).toBe('Spartacus')
})
it('bei Wiederholung mit Zusatz (2), (3) …', () => {
const da = new Set(['Akira (1988)', 'Akira (1988) (2)'])
expect(rohOrdnerName(zuordnung('Akira', 1988), null, 'bluray-2026', (n) => da.has(n))).toBe('Akira (1988) (3)')
})
it('ohne Zuordnung oder ohne brauchbaren Namen bleibt der Vorgangsname', () => {
expect(rohOrdnerName(null, null, 'bluray-2026', () => false)).toBe('bluray-2026')
expect(rohOrdnerName(zuordnung(' ', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
expect(rohOrdnerName(zuordnung('???', null), null, 'bluray-2026', () => false)).toBe('bluray-2026')
})
it('Sonderzeichen werden entschärft', () => {
expect(rohOrdnerName(zuordnung('Alien: Covenant', 2017), null, 'x', () => false)).not.toContain(':')
})
})
describe('filmGruppen', () => {
it('ohne Film-Zuordnung je Titel bleibt alles beim Hauptvorgang', () => {
const g = filmGruppen([datei('a'), datei('b')], 149)
expect(g.primaer).toHaveLength(2)
expect(g.primaerFilm).toBeNull()
expect(g.weitere).toHaveLength(0)
})
it('ein Titel, der zur Disc-Zuordnung passt, zählt als Hauptvorgang; ein anderer Film wird eigene Gruppe', () => {
const g = filmGruppen([datei('a', akira), datei('b', ghost), datei('c', ghost)], 149)
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
expect(g.weitere).toHaveLength(1)
expect(g.weitere[0].film.titel).toBe('Ghost in the Shell')
expect(g.weitere[0].dateien.map((d) => d.pfad)).toEqual(['b', 'c'])
})
it('hat kein Titel die Disc-Zuordnung behalten, wird die erste Gruppe zum Hauptvorgang', () => {
const g = filmGruppen([datei('a', ghost), datei('b', akira)], 42)
expect(g.primaerFilm?.titel).toBe('Ghost in the Shell')
expect(g.primaer.map((d) => d.pfad)).toEqual(['a'])
expect(g.weitere.map((w) => w.film.titel)).toEqual(['Akira'])
})
})
describe('zuordnungAusVorschlag', () => {
it('ist sicher (vom Nutzer) und trägt Titel, Jahr, Poster', () => {
const z = zuordnungAusVorschlag(akira)
expect(z.confidence).toBe(1)
expect(z.titel).toBe('Akira')
expect(z.meta.id).toBe(149)
expect(z.meta.posterPfad).toBe('/a.jpg')
expect(z.meta.typ).toBe('movie')
})
})
+19
View File
@@ -10,6 +10,7 @@ import {
hbSchluss,
HbZeilenLeser,
progressAusZeile,
etaAusZeile,
unbekannterSchalter,
unbekanntesPreset,
} from '../src/kern/komprimieren/handbrake'
@@ -201,3 +202,21 @@ describe('buildHandbrakeArgs — Untertitel vorwählen (5.4.0, am HandBrake 1.11
expect(args).not.toContain('--subtitle-default=1')
})
})
describe('etaAusZeile — Restzeit aus der Encoding-Zeile (5.6.0, Format aus dem Binary)', () => {
it('liest Stunden, Minuten, Sekunden', () => {
expect(etaAusZeile('Encoding: task 1 of 1, 42.57 % (89.31 fps, avg 90.05 fps, ETA 00h12m34s)')).toBe(12 * 60 + 34)
expect(etaAusZeile('Encoding: task 2 of 2, 10.00 % (5.00 fps, avg 5.00 fps, ETA 01h00m05s)')).toBe(3605)
})
it('ohne Klammer (erste Sekunden) oder außerhalb der Encoding-Zeile: -1', () => {
expect(etaAusZeile('Encoding: task 1 of 1, 0.87 %')).toBe(-1)
expect(etaAusZeile('Scanning title 1 of 1, preview 7, 70.00 % ETA 00h00m01s')).toBe(-1)
})
it('HbAuswertung meldet die Restzeit mit, auch wenn der Prozentwert gleich bleibt', () => {
const a = new HbAuswertung()
expect(a.verarbeite('Encoding: task 1 of 1, 10.00 % (50.00 fps, avg 50.00 fps, ETA 00h10m00s)')).toEqual({ prozent: 10, restS: 600 })
expect(a.verarbeite('Encoding: task 1 of 1, 10.40 % (50.00 fps, avg 50.00 fps, ETA 00h09m59s)')).toEqual({ prozent: 10, restS: 599 })
expect(a.verarbeite('Encoding: task 1 of 1, 10.40 % (50.00 fps, avg 50.00 fps, ETA 00h09m59s)')).toEqual({})
expect(a.verarbeite('Encoding: task 1 of 1, 11.00 %')).toEqual({ prozent: 11 })
})
})
+59
View File
@@ -0,0 +1,59 @@
// Tests der Vorgangs-Leiste (gemeinsam/vorgangsleiste.ts, 5.6.0): welche
// Station fertig, aktiv, offen oder gescheitert ist — aus Rip-Stand,
// Vorgang und Kompressions-Stand.
import { describe, expect, it } from 'vitest'
import { leisteAusKompression, leisteAusRip, leisteAusVorgang, stationen, stationsZustand } from '../src/gemeinsam/vorgangsleiste'
describe('stationen / stationsZustand', () => {
it('vier Stationen, mit Rettung fünf', () => {
expect(stationen(false)).toEqual(['titel', 'rip', 'kompression', 'ablage'])
expect(stationen(true)).toEqual(['titel', 'rettung', 'rip', 'kompression', 'ablage'])
})
it('vor der aktiven fertig, die aktive läuft, danach offen', () => {
expect(stationsZustand(stationen(false), 'rip', 'laeuft')).toEqual(['fertig', 'aktiv', 'offen', 'offen'])
expect(stationsZustand(stationen(false), 'kompression', 'wartet')).toEqual(['fertig', 'fertig', 'aktiv', 'offen'])
})
it('fertig heißt alle gold; Fehler markiert die aktive', () => {
expect(stationsZustand(stationen(false), 'ablage', 'fertig')).toEqual(['fertig', 'fertig', 'fertig', 'fertig'])
expect(stationsZustand(stationen(false), 'kompression', 'fehler')).toEqual(['fertig', 'fertig', 'fehler', 'offen'])
expect(stationsZustand(stationen(true), 'rettung', 'abgebrochen')).toEqual(['fertig', 'fehler', 'offen', 'offen', 'offen'])
})
})
describe('leisteAusRip — die Kachel', () => {
it('Titel-Info, Rip mit Prozent, Rettung als Zwischenstation', () => {
expect(leisteAusRip({ phase: 'liest-info', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'titel', zustand: 'laeuft', rettung: false })
expect(leisteAusRip({ phase: 'rippt', prozent: 62 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'laeuft', prozent: 62 })
expect(leisteAusRip({ phase: 'rettet', prozent: 10 }, undefined, 0)).toMatchObject({ station: 'rettung', rettung: true, prozent: 10 })
expect(leisteAusRip({ phase: 'rippt', prozent: 5, rettung: { unlesbareSektoren: 2, stellen: 1 } }, undefined, 0).rettung).toBe(true)
})
it('nach dem Rip zeigt der Vorgang, wo es steht — ohne Prozent (Details in der Karte)', () => {
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'wartet' }, 2)).toMatchObject({ station: 'kompression', zustand: 'wartet', hinweis: 'Platz 2' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'komprimiert' }, 0)).toMatchObject({ station: 'kompression', zustand: 'laeuft', prozent: -1 })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'legt-ab' }, 0)).toMatchObject({ station: 'ablage', zustand: 'laeuft' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'fertig' }, 0)).toMatchObject({ station: 'ablage', zustand: 'fertig' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, { phase: 'fehler' }, 0)).toMatchObject({ station: 'kompression', zustand: 'fehler' })
expect(leisteAusRip({ phase: 'fertig', prozent: 100 }, undefined, 0)).toMatchObject({ station: 'kompression', zustand: 'wartet' })
})
it('Rip-Fehler und Abbruch sitzen an der Rip-Station', () => {
expect(leisteAusRip({ phase: 'fehler', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'fehler' })
expect(leisteAusRip({ phase: 'abgebrochen', prozent: -1 }, undefined, 0)).toMatchObject({ station: 'rip', zustand: 'abgebrochen' })
})
})
describe('leisteAusVorgang — der Verlauf', () => {
it('bildet die Vorgangs-Phase ab; ein Fehler ohne Roh-Bytes war der Rip', () => {
expect(leisteAusVorgang({ phase: 'fertig', rohBytes: 5 })).toMatchObject({ station: 'ablage', zustand: 'fertig' })
expect(leisteAusVorgang({ phase: 'wartet', rohBytes: 5 })).toMatchObject({ station: 'kompression', zustand: 'wartet' })
expect(leisteAusVorgang({ phase: 'fehler', rohBytes: 0 })).toMatchObject({ station: 'rip', zustand: 'fehler' })
expect(leisteAusVorgang({ phase: 'fehler', rohBytes: 9 })).toMatchObject({ station: 'kompression', zustand: 'fehler' })
})
})
describe('leisteAusKompression — die Karte', () => {
it('Komprimieren mit Prozent, Ablegen, wartend', () => {
expect(leisteAusKompression({ phase: 'komprimiert', prozent: 43 })).toMatchObject({ station: 'kompression', zustand: 'laeuft', prozent: 43 })
expect(leisteAusKompression({ phase: 'legt-ab', prozent: 100 })).toMatchObject({ station: 'ablage', zustand: 'laeuft' })
expect(leisteAusKompression({ phase: 'wartet', prozent: -1 })).toMatchObject({ station: 'kompression', zustand: 'wartet' })
})
})