WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German · DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch- sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung), 22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna- Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"- Untertitel schon. Die Ablage scannt die Roh-Datei und findet die gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie + Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1 --subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan, gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl- regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte Regel wird in der Registry ersetzt, eine fremde bleibt. Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask` verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit 5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich (gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs). WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") — bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle (test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor deu, --subtitle none → keine Spur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
216 lines
9.8 KiB
TypeScript
216 lines
9.8 KiB
TypeScript
// Tests der Automatik (kern/dienste/automatik.ts, 5.5.0): Rippys Vorschlag
|
|
// als Auftrag, Staffel/Folge aus Disc und Serien-Merker, und der Ablauf
|
|
// mit Fake-Uhr — sicher → Titel lesen → Countdown → Rip; unsicher → nur
|
|
// sagen, warum; Stopp und Jetzt.
|
|
import { describe, expect, it } from 'vitest'
|
|
import type { AutomatikStand, GeraeteInfo, KernNachricht, RipAuftragOptionen, RipAuftragTitel, TitelStand } from '../src/gemeinsam/nachrichten'
|
|
import type { Protokoll } from '../src/gemeinsam/protokoll'
|
|
import { AUTOMATIK_SICHER, AutomatikDienst, auftragAusVorschlag, optionenAus, type AutomatikAbhaengigkeiten } from '../src/kern/dienste/automatik'
|
|
import type { KernKontext } from '../src/kern/kontext'
|
|
import type { Zuordnung } from '../src/kern/metadaten/zuordnung'
|
|
|
|
function titel(nr: number, gewaehlt: boolean, rolle: 'hauptfilm' | 'extra' | 'folge' = 'hauptfilm'): TitelStand['titel'][number] {
|
|
return { nr, dauerS: 3000 + nr, groesseBytes: 1e9 * (nr + 1), kapitel: 12, gewaehlt, rolle, grund: gewaehlt ? 'Hauptfilm' : 'zu kurz', sprachen: { audio: [], untertitel: [] }, spuren: { audio: [], untertitel: [] } }
|
|
}
|
|
|
|
function stand(teile: TitelStand['titel'], unsicher = false, fehler = ''): TitelStand {
|
|
return { id: 'G', laeuft: false, titel: teile, einordnung: unsicher ? 'zwei fast gleich lange Titel — bitte nachsehen' : 'Film: ein Hauptfilm', unsicher, fehler }
|
|
}
|
|
|
|
function geraet(typ: GeraeteInfo['typ'] = 'bluray'): GeraeteInfo {
|
|
return { id: 'G', name: 'G:', typ, pfad: 'G:\\', status: 'ready', modell: 'Fake', seriennummer: '', groesseBytes: 4e10, grund: '' }
|
|
}
|
|
|
|
function zuordnung(confidence: number): Zuordnung {
|
|
return { titel: 'Akira', jahr: 1988, confidence, meta: {} as never }
|
|
}
|
|
|
|
describe('auftragAusVorschlag', () => {
|
|
it('nimmt nur die vorausgewählten Titel, mit Rolle, Dauer und Größe — Sprachen leer (Einstellung gilt)', () => {
|
|
const auftrag = auftragAusVorschlag(stand([titel(0, true), titel(1, false), titel(2, true, 'extra')]))
|
|
expect(auftrag).toEqual<RipAuftragTitel[]>([
|
|
{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 },
|
|
{ nr: 2, rolle: 'extra', dauerS: 3002, groesseBytes: 3e9 },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('optionenAus', () => {
|
|
it('ohne alles: nichts vorgeben', () => {
|
|
expect(optionenAus(null, null)).toEqual<RipAuftragOptionen>({ staffel: null, ersteFolge: null })
|
|
})
|
|
it('die Staffel der Disc schlägt den Merker', () => {
|
|
expect(optionenAus({ staffel: 2, discNr: 2 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 2, ersteFolge: 5 })
|
|
})
|
|
it('Disc 1 fängt nie beim Merker an — sonst begänne die neue Ausgabe bei Folge 5', () => {
|
|
expect(optionenAus({ staffel: null, discNr: 1 }, { staffel: 1, naechsteFolge: 5 })).toEqual({ staffel: 1, ersteFolge: null })
|
|
})
|
|
it('Disc-Nummer unbekannt oder > 1: ab der gemerkten Folge weiter', () => {
|
|
expect(optionenAus({ staffel: null, discNr: null }, { staffel: 1, naechsteFolge: 3 })).toEqual({ staffel: 1, ersteFolge: 3 })
|
|
expect(optionenAus({ staffel: null, discNr: 3 }, { staffel: 1, naechsteFolge: 9 })).toEqual({ staffel: 1, ersteFolge: 9 })
|
|
})
|
|
})
|
|
|
|
function aufbau(sekunden = 3) {
|
|
const nachrichten: AutomatikStand[] = []
|
|
const hinweise: string[] = []
|
|
const kontext = {
|
|
db: null,
|
|
dbStatus: { ok: false, fehler: 'Test' },
|
|
anFenster: (n: KernNachricht) => {
|
|
if (n.art === 'automatik') nachrichten.push(n.stand)
|
|
},
|
|
anHauptUndFenster: () => {},
|
|
hinweis: (t: string) => hinweise.push(t),
|
|
fehler: () => {},
|
|
protokoll: { schreiben: () => {} } as unknown as Protokoll,
|
|
smoke: true,
|
|
} as unknown as KernKontext
|
|
const gestartet: Array<{ id: string; auftrag: RipAuftragTitel[]; optionen: RipAuftragOptionen }> = []
|
|
const benachrichtigt: string[] = []
|
|
let tick: (() => void) | null = null
|
|
let gestoppt = 0
|
|
let an = true
|
|
let ripLaeuft = false
|
|
let titelStand: TitelStand = stand([titel(0, true), titel(1, false)])
|
|
let angaben: { staffel: number | null; discNr: number | null } | null = null
|
|
let merker: { staffel: number; naechsteFolge: number } | null = null
|
|
const abh: AutomatikAbhaengigkeiten = {
|
|
an: () => an,
|
|
sekunden: () => sekunden,
|
|
ripLaeuft: () => ripLaeuft,
|
|
titelLesen: async () => titelStand,
|
|
ripStarten: (id, auftrag, optionen) => gestartet.push({ id, auftrag, optionen }),
|
|
discAngaben: () => angaben,
|
|
serienMerker: () => merker,
|
|
benachrichtigen: (t, x) => benachrichtigt.push(`${t}: ${x}`),
|
|
takt: (fn) => {
|
|
tick = fn
|
|
return 1 as unknown as NodeJS.Timeout
|
|
},
|
|
taktStoppen: () => {
|
|
gestoppt += 1
|
|
tick = null
|
|
},
|
|
}
|
|
const dienst = new AutomatikDienst(kontext, abh)
|
|
return {
|
|
dienst,
|
|
nachrichten,
|
|
hinweise,
|
|
gestartet,
|
|
benachrichtigt,
|
|
tick: () => {
|
|
if (tick === null) throw new Error('kein Takt')
|
|
tick()
|
|
},
|
|
gestopptZahl: () => gestoppt,
|
|
setAn: (w: boolean) => (an = w),
|
|
setRipLaeuft: (w: boolean) => (ripLaeuft = w),
|
|
setTitel: (s: TitelStand) => (titelStand = s),
|
|
setAngaben: (a: typeof angaben) => (angaben = a),
|
|
setMerker: (m: typeof merker) => (merker = m),
|
|
}
|
|
}
|
|
|
|
describe('AutomatikDienst', () => {
|
|
it('sicher erkannt, nicht gerippt: liest Titel, zählt herunter, startet mit Rippys Vorschlag', async () => {
|
|
const a = aufbau(3)
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.95), null)
|
|
expect(a.nachrichten[0].text).toContain('liest die Titel')
|
|
expect(a.nachrichten[1]).toMatchObject({ aktiv: true, sekunden: 3 })
|
|
a.tick()
|
|
a.tick()
|
|
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: true, sekunden: 1 })
|
|
expect(a.gestartet).toHaveLength(0)
|
|
a.tick()
|
|
expect(a.gestartet).toHaveLength(1)
|
|
expect(a.gestartet[0].auftrag).toEqual([{ nr: 0, rolle: 'hauptfilm', dauerS: 3000, groesseBytes: 1e9 }])
|
|
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik: Rip gestartet (1 Titel).' })
|
|
expect(a.benachrichtigt[0]).toContain('Automatik hat auf Laufwerk G gestartet')
|
|
expect(a.gestopptZahl()).toBe(1)
|
|
})
|
|
it('unsichere Erkennung: nichts passiert von selbst — nur der Grund', async () => {
|
|
const a = aufbau()
|
|
await a.dienst.pruefen(geraet(), zuordnung(AUTOMATIK_SICHER - 0.01), null)
|
|
expect(a.nachrichten).toHaveLength(1)
|
|
expect(a.nachrichten[0]).toMatchObject({ aktiv: false })
|
|
expect(a.nachrichten[0].text).toContain('nicht sicher genug')
|
|
expect(a.gestartet).toHaveLength(0)
|
|
})
|
|
it('schon gerippt: wartet und nennt das Datum', async () => {
|
|
const a = aufbau()
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), '2026-08-30T10:00:00Z')
|
|
expect(a.nachrichten[0].text).toContain('schon gerippt (2026-08-30)')
|
|
expect(a.gestartet).toHaveLength(0)
|
|
})
|
|
it('aus oder laufender Rip: gar nichts — CD und unbekannte Disc sagen, warum (5.7.0, F28)', async () => {
|
|
const a = aufbau()
|
|
a.setAn(false)
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
a.setRipLaeuft(true)
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
expect(a.nachrichten).toHaveLength(0)
|
|
a.setRipLaeuft(false)
|
|
a.setAn(true)
|
|
await a.dienst.pruefen(geraet('cd'), zuordnung(0.99), null)
|
|
await a.dienst.pruefen(geraet('unknown'), zuordnung(0.99), null)
|
|
expect(a.nachrichten).toHaveLength(2)
|
|
expect(a.nachrichten[0].text).toContain('Audio-CD')
|
|
expect(a.nachrichten[1].text).toContain('unbekannt')
|
|
expect(a.gestartet).toHaveLength(0)
|
|
})
|
|
it('unsicherer Titel-Lauf (zwei fast gleich lange Titel): kein Countdown, die Einordnung wird gesagt', async () => {
|
|
const a = aufbau()
|
|
a.setTitel(stand([titel(0, true), titel(1, false)], true))
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
expect(a.nachrichten[a.nachrichten.length - 1].text).toContain('bitte nachsehen')
|
|
expect(a.gestartet).toHaveLength(0)
|
|
})
|
|
it('Stopp beendet den Countdown mit Grund; Jetzt überspringt ihn', async () => {
|
|
const a = aufbau(30)
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
a.dienst.stopp('G', 'Automatik gestoppt — du entscheidest.')
|
|
expect(a.gestopptZahl()).toBe(1)
|
|
expect(a.nachrichten[a.nachrichten.length - 1]).toMatchObject({ aktiv: false, text: 'Automatik gestoppt — du entscheidest.' })
|
|
expect(() => a.tick()).toThrow('kein Takt')
|
|
// Jetzt braucht einen aktiven Countdown und den gemerkten Titel-Stand.
|
|
a.dienst.jetzt('G')
|
|
expect(a.gestartet).toHaveLength(0)
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
a.dienst.titelMerken('G', stand([titel(0, true), titel(1, false)]))
|
|
a.dienst.jetzt('G')
|
|
expect(a.gestartet).toHaveLength(1)
|
|
})
|
|
it('Serie: Staffel/Folge aus Disc und Merker, Merker nur mit gemerkter Zuordnung', async () => {
|
|
const a = aufbau(1)
|
|
a.setTitel(stand([titel(0, true, 'folge'), titel(1, true, 'folge')]))
|
|
a.setAngaben({ staffel: null, discNr: 2 })
|
|
a.setMerker({ staffel: 1, naechsteFolge: 3 })
|
|
a.dienst.zuordnungMerken('G', zuordnung(0.99))
|
|
await a.dienst.pruefen(geraet(), zuordnung(0.99), null)
|
|
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)
|
|
expect(a.dienst.stand('G')).not.toBeNull()
|
|
a.dienst.discEntnommen('G')
|
|
expect(a.dienst.stand('G')).toBeNull()
|
|
expect(a.gestopptZahl()).toBe(1)
|
|
})
|
|
})
|