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>
262 lines
13 KiB
TypeScript
262 lines
13 KiB
TypeScript
// 5.7.0 — Spuren einzeln (Commander-Entscheid 12.09.2026: „Jede Spur einzeln
|
|
// anhaken"). Pur und ohne Laufwerk: der Parser liefert je Titel jede Spur mit
|
|
// Codec, Kanälen und dem Abgeleitet-Flag; der Dialog belegt vor und
|
|
// beschriftet; die Ablage findet die gewählten MakeMKV-Spuren in HandBrakes
|
|
// Scan der Roh-Datei wieder und baut daraus `--audio 1,3 --subtitle 2`.
|
|
import { describe, expect, it } from 'vitest'
|
|
import { istRipAuftragTitel, istSpurAngabe, type SpurAngabe } from '../src/gemeinsam/nachrichten'
|
|
import { angeboten, codecFamilie, spurLabel, spurenVorbelegt, spurenZuordnen, sprachenDerSpuren } from '../src/gemeinsam/spuren'
|
|
import { auftragAusJson, spurwahl, sprachlistenAusSpuren } from '../src/kern/ablauf/auftrag'
|
|
import { buildHandbrakeArgs, KOPIERMASKE } from '../src/kern/komprimieren/handbrake'
|
|
import { spurenAusScan } from '../src/kern/komprimieren/scan'
|
|
import { parseStreamInfo, spurenFuerTitel, STREAM_FLAG_ABGELEITET } from '../src/kern/rip/parser'
|
|
import { regelUrteil, RIPPY_REGEL, RIPPY_REGELN_ALT } from '../src/kern/werkzeuge/spurauswahl'
|
|
|
|
// Wie fake-makemkvcon (bau/klick) und die Kizuna-Messung: DTS-HD MA mit
|
|
// abgeleitetem Kern (Flag 2304 = 0x900), DD 5.1, DD Stereo Kommentar,
|
|
// PGS deu, PGS deu „nur erzwungene" (6144 = 0x1800), PGS eng.
|
|
const SINFO = [
|
|
'SINFO:0,0,1,6201,"Video"',
|
|
'SINFO:0,1,1,6202,"Audio"',
|
|
'SINFO:0,1,2,0,"Surround 5.1"',
|
|
'SINFO:0,1,3,0,"jpn"',
|
|
'SINFO:0,1,4,0,"Japanese"',
|
|
'SINFO:0,1,6,0,"DTS-HD MA"',
|
|
'SINFO:0,1,14,0,"6"',
|
|
'SINFO:0,1,22,0,"1024"',
|
|
'SINFO:0,1,30,0,"DTS-HD MA Surround 5.1 Japanese"',
|
|
'SINFO:0,2,1,6202,"Audio"',
|
|
'SINFO:0,2,2,0,"Surround 5.1"',
|
|
'SINFO:0,2,3,0,"jpn"',
|
|
'SINFO:0,2,4,0,"Japanese"',
|
|
'SINFO:0,2,6,0,"DTS"',
|
|
'SINFO:0,2,14,0,"6"',
|
|
'SINFO:0,2,22,0,"2304"',
|
|
'SINFO:0,3,1,6202,"Audio"',
|
|
'SINFO:0,3,2,0,"Surround 5.1"',
|
|
'SINFO:0,3,3,0,"deu"',
|
|
'SINFO:0,3,4,0,"German"',
|
|
'SINFO:0,3,6,0,"DD"',
|
|
'SINFO:0,3,14,0,"6"',
|
|
'SINFO:0,3,22,0,"0"',
|
|
'SINFO:0,4,1,6202,"Audio"',
|
|
'SINFO:0,4,2,0,"Stereo"',
|
|
'SINFO:0,4,3,0,"deu"',
|
|
'SINFO:0,4,4,0,"German"',
|
|
'SINFO:0,4,6,0,"DD"',
|
|
'SINFO:0,4,14,0,"2"',
|
|
'SINFO:0,4,30,0,"DD Stereo German (Kommentar)"',
|
|
'SINFO:0,5,1,6203,"Untertitel"',
|
|
'SINFO:0,5,3,0,"deu"',
|
|
'SINFO:0,5,4,0,"German"',
|
|
'SINFO:0,5,6,0,"PGS"',
|
|
'SINFO:0,6,1,6203,"Untertitel"',
|
|
'SINFO:0,6,3,0,"deu"',
|
|
'SINFO:0,6,4,0,"German"',
|
|
'SINFO:0,6,6,0,"PGS"',
|
|
'SINFO:0,6,22,0,"6144"',
|
|
'SINFO:0,6,30,0,"PGS German (nur erzwungene)"',
|
|
'SINFO:0,7,1,6203,"Untertitel"',
|
|
'SINFO:0,7,3,0,"eng"',
|
|
'SINFO:0,7,4,0,"English"',
|
|
'SINFO:0,7,6,0,"PGS"',
|
|
].join('\n')
|
|
|
|
const spuren = spurenFuerTitel(parseStreamInfo(SINFO).get(0))
|
|
|
|
describe('Parser: jede Spur einzeln, abgeleitete markiert', () => {
|
|
it('liest Anordnung (2), Kanäle (14), Flags (22) und erkennt den DTS-Kern am Bit 0x800', () => {
|
|
expect(STREAM_FLAG_ABGELEITET).toBe(0x800)
|
|
expect(spuren.audio.map((s) => [s.nr, s.lang, s.codec, s.kanaele, s.layout, s.abgeleitet])).toEqual([
|
|
[1, 'jpn', 'DTS-HD MA', '6', 'Surround 5.1', false],
|
|
[2, 'jpn', 'DTS', '6', 'Surround 5.1', true],
|
|
[3, 'deu', 'DD', '6', 'Surround 5.1', false],
|
|
[4, 'deu', 'DD', '2', 'Stereo', false],
|
|
])
|
|
expect(spuren.untertitel.map((s) => [s.nr, s.lang, s.abgeleitet])).toEqual([
|
|
[5, 'deu', false],
|
|
[6, 'deu', true],
|
|
[7, 'eng', false],
|
|
])
|
|
expect(spuren.audio.every(istSpurAngabe)).toBe(true)
|
|
expect(spurenFuerTitel(undefined)).toEqual({ audio: [], untertitel: [] })
|
|
})
|
|
})
|
|
|
|
describe('Dialog: Beschriftung, Angebot, Vorbelegung', () => {
|
|
it('beschriftet mit Sprache, Codec, Anordnung und dem Klammer-Zusatz der Beschreibung', () => {
|
|
expect(spurLabel(spuren.audio[0])).toBe('Japanese · DTS-HD MA · Surround 5.1')
|
|
expect(spurLabel(spuren.audio[3])).toBe('German · DD · Stereo (Kommentar)')
|
|
expect(spurLabel(spuren.untertitel[0])).toBe('German · PGS')
|
|
expect(spurLabel(spuren.untertitel[1])).toBe('German · PGS (nur erzwungene)')
|
|
expect(spurLabel({ ...spuren.audio[0], layout: '', sprache: '' })).toBe('jpn · DTS-HD MA · 5.1')
|
|
expect(spurLabel({ ...spuren.audio[0], lang: '', sprache: '', codec: '', kanaele: '', layout: '', beschreibung: '' })).toBe('ohne Sprache')
|
|
})
|
|
it('bietet abgeleitete TONspuren nicht an, abgeleitete Untertitel schon', () => {
|
|
expect(spuren.audio.filter((s) => angeboten(s, 'audio')).map((s) => s.nr)).toEqual([1, 3, 4])
|
|
expect(spuren.untertitel.filter((s) => angeboten(s, 'untertitel')).map((s) => s.nr)).toEqual([5, 6, 7])
|
|
})
|
|
it('belegt vor: je Wunschsprache die erste Tonspur, alle Untertitel der Wunschsprachen', () => {
|
|
expect(spurenVorbelegt(spuren.audio, ['deu', 'eng'], 'audio')).toEqual([3])
|
|
expect(spurenVorbelegt(spuren.audio, ['jpn', 'deu'], 'audio')).toEqual([1, 3])
|
|
// Keine Wunschsprache da → die erste angebotene Spur (nie der Kern).
|
|
expect(spurenVorbelegt(spuren.audio, ['fra'], 'audio')).toEqual([1])
|
|
expect(spurenVorbelegt(spuren.audio, [], 'audio')).toEqual([1])
|
|
expect(spurenVorbelegt(spuren.untertitel, ['deu'], 'untertitel')).toEqual([5, 6])
|
|
expect(spurenVorbelegt(spuren.untertitel, ['fra'], 'untertitel')).toEqual([])
|
|
})
|
|
it('Sprachen der gewählten Spuren ohne Doppel', () => {
|
|
expect(sprachenDerSpuren([spuren.audio[2], spuren.audio[3], spuren.audio[0]])).toEqual(['deu', 'jpn'])
|
|
})
|
|
})
|
|
|
|
describe('Codec-Familien: MakeMKV-Wort und HandBrake-CodecName auf einen Schlüssel', () => {
|
|
it('DTS-HD MA ↔ dtshd, DTS ↔ dca, DD ↔ ac3, TrueHD, DVD-Untertitel ↔ VOBSUB', () => {
|
|
expect(codecFamilie('DTS-HD MA')).toBe('dtshd')
|
|
expect(codecFamilie('dtshd')).toBe('dtshd')
|
|
expect(codecFamilie('DTS')).toBe('dts')
|
|
expect(codecFamilie('dca')).toBe('dts')
|
|
expect(codecFamilie('DD')).toBe('ac3')
|
|
expect(codecFamilie('ac3')).toBe('ac3')
|
|
expect(codecFamilie('DD+')).toBe('eac3')
|
|
expect(codecFamilie('eac3')).toBe('eac3')
|
|
expect(codecFamilie('TrueHD Atmos')).toBe('truehd')
|
|
expect(codecFamilie('PGS')).toBe('pgs')
|
|
expect(codecFamilie('DVD Subtitles')).toBe('vobsub')
|
|
expect(codecFamilie('VOBSUB')).toBe('vobsub')
|
|
expect(codecFamilie('UTF-8')).toBe('text')
|
|
expect(codecFamilie('')).toBe('')
|
|
})
|
|
})
|
|
|
|
// HandBrakes Scan der Roh-Datei (Felder wie am 1.11.2 gemessen).
|
|
const SCAN = `JSON Title Set: {
|
|
"TitleList": [ {
|
|
"AudioList": [
|
|
{ "ChannelCount": 6, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "LanguageCode": "jpn", "TrackNumber": 1 },
|
|
{ "ChannelCount": 6, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "LanguageCode": "deu", "TrackNumber": 2 },
|
|
{ "ChannelCount": 2, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "LanguageCode": "deu", "TrackNumber": 3 }
|
|
],
|
|
"Duration": { "Hours": 1, "Minutes": 30, "Seconds": 0 },
|
|
"SubtitleList": [
|
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },
|
|
{ "Format": "bitmap", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },
|
|
{ "Format": "bitmap", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }
|
|
]
|
|
} ] }
|
|
HandBrake has exited.`
|
|
|
|
describe('Ablage: gewählte MakeMKV-Spuren in HandBrakes Scan wiederfinden', () => {
|
|
const scan = spurenAusScan(SCAN)!
|
|
it('der Scan liefert Codec und Kanäle', () => {
|
|
expect(scan.audio.map((a) => [a.nr, a.lang, a.codec, a.kanaele])).toEqual([
|
|
[1, 'jpn', 'dtshd', 6],
|
|
[2, 'deu', 'ac3', 6],
|
|
[3, 'deu', 'ac3', 2],
|
|
])
|
|
expect(scan.untertitel[0].codec).toBe('PGS')
|
|
})
|
|
it('ordnet gestuft zu — Sprache + Familie + Kanäle zuerst, jede Spur nur einmal', () => {
|
|
// Kommentar (deu DD 2.0) vor dem 5.1 gewählt: trotzdem landet er auf Spur 3.
|
|
const z = spurenZuordnen([spuren.audio[3], spuren.audio[2], spuren.audio[0]], scan.audio)
|
|
expect(z.nummern).toEqual([3, 2, 1])
|
|
expect(z.fehlend).toEqual([])
|
|
})
|
|
it('meldet, was nicht in der Datei ist — und fällt nur bei sprachlosen Dateien auf die Reihenfolge zurück', () => {
|
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
|
expect(spurenZuordnen([fra], scan.audio).fehlend).toEqual([fra])
|
|
const stumm = scan.audio.map((a) => ({ ...a, lang: '' }))
|
|
expect(spurenZuordnen([spuren.audio[3]], stumm).nummern).toEqual([3])
|
|
expect(spurenZuordnen([fra], stumm).nummern).toEqual([1])
|
|
})
|
|
it('spurwahl: HandBrake-Nummern, Vorwahl per Regel (fremder Ton → Wunschsprache nach vorn)', () => {
|
|
const wahl = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[2], spuren.untertitel[0]] }, scan, 'deu')
|
|
expect(wahl).toEqual({ audioNr: [1], untertitelNr: [1, 3], untertitelStandard: 1, fehlend: [] })
|
|
})
|
|
it('spurwahl: Vorwahl von Hand (Spur) oder ausdrücklich keine; deutscher Ton → keine Regel-Vorwahl', () => {
|
|
const deutsch = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]] }, scan, 'deu')
|
|
expect(deutsch).toEqual({ audioNr: [2], untertitelNr: [1, 3], untertitelStandard: 0, fehlend: [] })
|
|
const hand = spurwahl({ audioSpuren: [spuren.audio[2]], untertitelSpuren: [spuren.untertitel[0], spuren.untertitel[2]], untertitelVorwahlSpur: 7 }, scan, 'deu')
|
|
expect(hand).toEqual({ audioNr: [2], untertitelNr: [3, 1], untertitelStandard: 1, fehlend: [] })
|
|
const keine = spurwahl({ audioSpuren: [spuren.audio[0]], untertitelSpuren: [spuren.untertitel[0]], untertitelVorwahlSpur: null }, scan, 'deu')
|
|
expect(keine!.untertitelStandard).toBe(0)
|
|
})
|
|
it('spurwahl: ohne gewählte Spuren null (die Sprachwahl gilt); Fehlendes im Klartext', () => {
|
|
expect(spurwahl({}, scan, 'deu')).toBeNull()
|
|
const fra: SpurAngabe = { ...spuren.audio[0], nr: 9, lang: 'fra', sprache: 'French' }
|
|
const wahl = spurwahl({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [] }, scan, 'deu')
|
|
expect(wahl!.audioNr).toEqual([2])
|
|
expect(wahl!.fehlend).toEqual(['Tonspur French DTS-HD MA'])
|
|
expect(sprachlistenAusSpuren({ audioSpuren: [fra, spuren.audio[2]], untertitelSpuren: [spuren.untertitel[2]] })).toEqual({
|
|
audio: ['fra', 'deu'],
|
|
untertitel: ['eng'],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('HandBrake-Schalter für einzelne Spuren (am 1.11.2 gemessen, 12.09.2026)', () => {
|
|
it('--audio 1,3 --subtitle 2,1 --subtitle-default=1, mit Kopiermaske — keine Sprachlisten', () => {
|
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
|
|
expect(args[args.indexOf('--audio') + 1]).toBe('1,3')
|
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('2,1')
|
|
expect(args).toContain('--subtitle-default=1')
|
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe(KOPIERMASKE)
|
|
expect(args).not.toContain('--audio-lang-list')
|
|
expect(args).not.toContain('--all-subtitles')
|
|
expect(args).not.toContain('--first-audio')
|
|
})
|
|
it('keine Untertitel gewählt → --subtitle none; keine Tonspur → HandBrakes erste (kein --audio)', () => {
|
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioNr: [], untertitelNr: [] })
|
|
expect(args[args.indexOf('--subtitle') + 1]).toBe('none')
|
|
expect(args).not.toContain('--audio')
|
|
expect(args).not.toContain('--subtitle-default=1')
|
|
})
|
|
it('F31: auch die Sprachwelt bekommt die Kopiermaske — sonst rechnet HandBrake AC3/DTS zu AAC um', () => {
|
|
const args = buildHandbrakeArgs({ eingabe: 'a.mkv', ausgabe: 'b.mkv', preset: 'P', audioSprachen: ['deu'] })
|
|
expect(args[args.indexOf('--audio-copy-mask') + 1]).toBe('aac,ac3,eac3,dtshd,dts,mp3,truehd,flac')
|
|
})
|
|
})
|
|
|
|
describe('Auftrag: Spuren reisen durch Schema und JSON', () => {
|
|
it('istRipAuftragTitel nimmt Spuren und Vorwahl-Spur an, weist Unsinn ab', () => {
|
|
const titel = { nr: 0, rolle: 'hauptfilm', dauerS: 1, groesseBytes: 1, audioSprachen: [], untertitelSprachen: [] }
|
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [spuren.audio[0]], untertitelVorwahlSpur: null })).toBe(true)
|
|
expect(istRipAuftragTitel({ ...titel, audioSpuren: [{ nr: 'x' }] })).toBe(false)
|
|
expect(istRipAuftragTitel({ ...titel, untertitelVorwahlSpur: 'deu' })).toBe(false)
|
|
})
|
|
it('auftragAusJson liest die Spuren zurück, lässt Kaputtes weg', () => {
|
|
const json = JSON.stringify({
|
|
vorgangId: 'v1',
|
|
discTyp: 'bluray',
|
|
rohOrdner: 'E:\\roh\\x',
|
|
dateien: [
|
|
{
|
|
pfad: 'E:\\roh\\x\\title_t00.mkv',
|
|
rolle: 'hauptfilm',
|
|
audioSpuren: [spuren.audio[0]],
|
|
untertitelSpuren: [spuren.untertitel[1]],
|
|
untertitelVorwahlSpur: 6,
|
|
},
|
|
{ pfad: 'E:\\roh\\x\\title_t01.mkv', rolle: 'extra', audioSpuren: ['kaputt'], untertitelVorwahlSpur: 'deu' },
|
|
],
|
|
})
|
|
const a = auftragAusJson(json)!
|
|
expect(a.dateien[0].audioSpuren).toEqual([spuren.audio[0]])
|
|
expect(a.dateien[0].untertitelSpuren).toEqual([spuren.untertitel[1]])
|
|
expect(a.dateien[0].untertitelVorwahlSpur).toBe(6)
|
|
expect(a.dateien[1].audioSpuren).toBeUndefined()
|
|
expect(a.dateien[1].untertitelVorwahlSpur).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('MakeMKV-Auswahlregel 5.7.0: Stereo-Doppel bleiben, nur Kerne weg', () => {
|
|
it('die neue Regel lässt havemulti stehen; die alte wird ersetzt, eine fremde bleibt', () => {
|
|
expect(RIPPY_REGEL).toBe('-sel:all,+sel:all,-sel:havecore,=100:all')
|
|
expect(regelUrteil(null)).toBe('setzen')
|
|
expect(regelUrteil('')).toBe('setzen')
|
|
expect(regelUrteil(RIPPY_REGEL)).toBe('rippy')
|
|
for (const alt of RIPPY_REGELN_ALT) expect(regelUrteil(alt)).toBe('ersetzen')
|
|
expect(regelUrteil('-sel:all,+sel:(favlang|nolang)')).toBe('eigen')
|
|
})
|
|
})
|