// 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') }) })