+ )
+}
+
+/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
+ * kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
+ * Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
+ * der Wunschsprache — sonst nichts. */
+function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
+ if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
+ return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
+}
+
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
@@ -105,6 +158,12 @@ export function TitelWahlDialog({
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
// sonst die Sprache. Immer optional, nie eingebrannt.
const [vorwahl, setVorwahl] = useState>({})
+ // 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
+ // drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
+ // Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
+ // Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
+ const [spuren, setSpuren] = useState>({})
+ const [vorwahlSpur, setVorwahlSpur] = useState>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState>({})
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
@@ -130,6 +189,31 @@ export function TitelWahlDialog({
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
}
+ const wunschListe = (schluessel: string): string[] =>
+ (einstellungen[schluessel] ?? '')
+ .split(',')
+ .map((s) => s.trim().toLowerCase())
+ .filter((s) => s.length > 0)
+ /** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
+ const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
+ const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
+ spuren[t.nr] ?? {
+ audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
+ untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
+ }
+ const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
+ const jetzt = spurenVon(t)
+ const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
+ setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
+ }
+ /** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
+ const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
+ const nrn = spurenVon(t)
+ return {
+ audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
+ untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
+ }
+ }
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
@@ -231,6 +315,10 @@ export function TitelWahlDialog({
const film = filme[t.nr]
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
+ const spurNrn = spurenVon(t)
+ const spurWahl = gewaehlteSpuren(t)
+ const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
+ const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
return (
+ {/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
+ der Info-Lauf an DIESEM Titel gemessen hat — einzeln
+ anhaken, was in die Datei soll. */}
+ {an && spurenDa(t) && (
+
+ spurWechsel(t, 'audio', nr)} />
+ spurWechsel(t, 'untertitel', nr)}
+ />
+ {spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
+ keine Tonspur gewählt — HandBrake nimmt die erste Spur
+ )}
+ {/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
+ weiche Spur, die der Spieler von selbst einblendet;
+ eingebrannt wird nie. Hier als SPUR. */}
+ {rolle === 'extra' && spurWahl.untertitel.length > 0 && (
+
+ )}
+
+ )}
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
Titel gemessen hat — anhaken, was in die Datei soll.
- Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
- {an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
+ Der Commander-Fall: japanischer Ton, deutsche Untertitel.
+ Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
+ {an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
sprachWechsel(t, 'audio', lang)} />
sprachWechsel(t, 'untertitel', lang)} />
@@ -484,6 +618,24 @@ export function TitelWahlDialog({
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
+ // 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
+ // mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
+ // scheitert; und die alten Hinweise bleiben stimmig).
+ if (spurenDa(t)) {
+ const spurWahl = gewaehlteSpuren(t)
+ return {
+ nr: t.nr,
+ rolle,
+ dauerS: t.dauerS,
+ groesseBytes: t.groesseBytes,
+ audioSprachen: sprachenDerSpuren(spurWahl.audio),
+ untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
+ audioSpuren: spurWahl.audio,
+ untertitelSpuren: spurWahl.untertitel,
+ ...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
+ ...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
+ }
+ }
return {
nr: t.nr,
rolle,
diff --git a/rippy-windows/src/gemeinsam/nachrichten.ts b/rippy-windows/src/gemeinsam/nachrichten.ts
index 16096af..7e20ad1 100644
--- a/rippy-windows/src/gemeinsam/nachrichten.ts
+++ b/rippy-windows/src/gemeinsam/nachrichten.ts
@@ -98,6 +98,30 @@ export interface SprachEintrag {
spuren: number
}
+/** EINE Spur eines Titels, wie der Info-Lauf sie gemessen hat (5.7.0,
+ * Commander-Entscheid 12.09.2026: „jede Spur einzeln anhaken"). Die
+ * Attribute stammen aus SINFO (parser.ts): 3 Sprachcode, 4 Sprachname,
+ * 6 Codec, 14 Kanäle, 2 Anordnung, 30 Beschreibung, 22 Flags. */
+export interface SpurAngabe {
+ /** MakeMKVs Stream-Nummer innerhalb des Titels. */
+ nr: number
+ lang: string
+ sprache: string
+ /** „DTS-HD MA", „TrueHD", „DD", „PGS", „VobSub" … */
+ codec: string
+ /** Kanalzahl als Text („6", „2"); leer bei Untertiteln. */
+ kanaele: string
+ /** „Surround 5.1", „Stereo" — Attribut 2. */
+ layout: string
+ /** Attribut 30, z. B. „DTS-HD MA Surround 5.1 German" oder „PGS German (nur erzwungene)". */
+ beschreibung: string
+ /** Bit 0x800 in Attribut 22 (an der Kizuna-Disc gemessen): eine von
+ * MakeMKV ABGELEITETE Spur — der DTS-Kern einer HD-Spur (nicht in der
+ * Roh-Datei) oder die „nur erzwungene"-Fassung eines Untertitels (in der
+ * Roh-Datei, wählbar). */
+ abgeleitet: boolean
+}
+
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
@@ -117,6 +141,12 @@ export interface RipAuftragTitel {
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
untertitelVorwahl?: string | null
+ /** 5.7.0: die GEWÄHLTEN Spuren mit allem, was die Ablage braucht, um sie
+ * in der Roh-Datei wiederzufinden. Fehlend: die Sprachlisten gelten. */
+ audioSpuren?: SpurAngabe[]
+ untertitelSpuren?: SpurAngabe[]
+ /** 5.7.0: die vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
+ untertitelVorwahlSpur?: number | null
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
film?: MetaVorschlag
@@ -147,6 +177,8 @@ export interface DiscTitelVorschlag {
grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
+ /** 5.7.0: jede Spur einzeln — der Dialog zeigt sie zum Anhaken. */
+ spuren: { audio: SpurAngabe[]; untertitel: SpurAngabe[] }
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20–120 s,
@@ -685,9 +717,20 @@ export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false
}
+ for (const feld of ['audioSpuren', 'untertitelSpuren']) {
+ const liste = wert[feld]
+ if (liste === undefined) continue
+ if (!Array.isArray(liste) || !liste.every(istSpurAngabe)) return false
+ }
+ if (wert.untertitelVorwahlSpur !== undefined && wert.untertitelVorwahlSpur !== null && typeof wert.untertitelVorwahlSpur !== 'number') return false
return true
}
+export function istSpurAngabe(wert: unknown): wert is SpurAngabe {
+ if (!istObjekt(wert)) return false
+ return typeof wert.nr === 'number' && typeof wert.lang === 'string' && typeof wert.codec === 'string' && typeof wert.abgeleitet === 'boolean'
+}
+
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
diff --git a/rippy-windows/src/gemeinsam/spuren.ts b/rippy-windows/src/gemeinsam/spuren.ts
new file mode 100644
index 0000000..d454f9c
--- /dev/null
+++ b/rippy-windows/src/gemeinsam/spuren.ts
@@ -0,0 +1,154 @@
+// Spuren einzeln (5.7.0, Commander-Entscheid 12.09.2026: „Jede Spur einzeln
+// anhaken — Deutsch · DTS-HD MA 5.1, Deutsch · Kommentar 2.0 getrennt").
+// PUR und gemeinsam: Der Dialog zeigt die Zeilen und belegt sie vor, der
+// Kern findet die gewählten Spuren nach dem Rip in der Roh-Datei wieder.
+//
+// Zwei Welten, ein Vergleich:
+// * MakeMKV (SINFO, kern/rip/parser.ts): Sprachcode, Codec-Wort („DTS-HD
+// MA"), Kanalzahl („6"), Disc-Reihenfolge.
+// * HandBrake (--scan --json, kern/komprimieren/scan.ts; am 12.09.2026 am
+// mitgelieferten HandBrake 1.11.2 gemessen): LanguageCode, CodecName
+// („dtshd", „dca", „truehd", „ac3", „eac3", „aac", „flac", „pcm_…"),
+// ChannelCount, TrackNumber — in Datei-Reihenfolge.
+// MakeMKV schreibt die Spuren in Disc-Reihenfolge; Rippys Auswahlregel
+// (werkzeuge/spurauswahl.ts) lässt nur die abgeleiteten DTS-Kerne weg.
+// Deshalb reicht: je gewählter Spur die erste noch freie HandBrake-Spur
+// gleicher Sprache, gleicher Codec-Familie und gleicher Kanalzahl — sonst
+// gestuft weniger streng; findet sich nichts, sagt die Ablage es.
+import type { SpurAngabe } from './nachrichten'
+
+/** Codec-Familie aus MakeMKVs Codec-Wort (Attribut 6) oder HandBrakes
+ * CodecName — beide auf denselben Schlüssel. '' wenn unbekannt. */
+export function codecFamilie(text: string): string {
+ const t = (text ?? '').toLowerCase()
+ if (t.length === 0) return ''
+ if (t.includes('dts-hd') || t.includes('dtshd') || t.includes('dts:x') || t.includes('dts hd')) return 'dtshd'
+ if (t === 'dca' || t.startsWith('dts')) return 'dts'
+ if (t.includes('truehd') || t.includes('atmos') || t.includes('mlp')) return 'truehd'
+ if (t.includes('eac3') || t.includes('e-ac3') || t.includes('dd+') || t.includes('digital plus')) return 'eac3'
+ if (t === 'ac3' || t.startsWith('dd') || t.includes('dolby digital') || t === 'ac-3') return 'ac3'
+ if (t.includes('aac')) return 'aac'
+ if (t.includes('flac')) return 'flac'
+ if (t.includes('pcm') || t.includes('lpcm')) return 'pcm'
+ if (t.includes('mp3') || t.includes('mp2') || t.includes('mpeg')) return 'mp3'
+ if (t.includes('opus')) return 'opus'
+ if (t.includes('vorbis')) return 'vorbis'
+ if (t.includes('pgs') || t.includes('hdmv')) return 'pgs'
+ if (t.includes('vobsub') || t.startsWith('dvd')) return 'vobsub'
+ if (t.includes('srt') || t.includes('text') || t.includes('utf-8') || t.includes('ssa') || t.includes('ass')) return 'text'
+ return t
+}
+
+/** Die Zeile im Dialog: „German · DTS-HD MA · Surround 5.1", „German · DD ·
+ * Stereo (Kommentar)". MakeMKV nennt die Sprache englisch (Attribut 4,
+ * „German"), auch bei deutscher Oberfläche — gemessen an der Kizuna-Disc. */
+export function spurLabel(s: SpurAngabe): string {
+ const teile: string[] = [s.sprache.length > 0 ? s.sprache : s.lang.length > 0 ? s.lang : 'ohne Sprache']
+ if (s.codec.length > 0) teile.push(s.codec)
+ if (s.kanaele.length > 0) {
+ const n = Number(s.kanaele)
+ const kurz = n === 6 ? '5.1' : n === 8 ? '7.1' : n === 2 ? '2.0' : n === 1 ? '1.0' : s.kanaele
+ // MakeMKVs Anordnung („Surround 5.1", „Stereo") sagt genug — die Zahl
+ // nur, wenn sie fehlt.
+ teile.push(s.layout.length > 0 ? s.layout : kurz)
+ }
+ let label = teile.join(' · ')
+ // Was MakeMKV in der Beschreibung dazusagt — „(nur erzwungene)",
+ // Kommentar-Hinweise — hängt in Klammern an.
+ const zusatz = /\(([^()]*)\)\s*$/.exec(s.beschreibung)
+ if (zusatz !== null && !label.toLowerCase().includes(zusatz[1].toLowerCase())) label += ` (${zusatz[1]})`
+ return label
+}
+
+/** Eine Zeile, die der Dialog ANBIETET: Abgeleitete TON-Spuren (DTS-Kerne)
+ * sind nicht in der Roh-Datei — sie werden nicht gezeigt. Abgeleitete
+ * UNTERTITEL („nur erzwungene") sind drin und wählbar. */
+export function angeboten(s: SpurAngabe, art: 'audio' | 'untertitel'): boolean {
+ return art === 'untertitel' || !s.abgeleitet
+}
+
+/** Rippys Vorbelegung (pur):
+ * * Ton: je Wunschsprache die ERSTE angebotene Spur — das ist die Regel
+ * von 5.4.0 („eine Spur je Sprache", meist die beste); trifft keine
+ * Wunschsprache, die erste angebotene Spur überhaupt (japanischer Ton
+ * statt gar keiner).
+ * * Untertitel: ALLE Spuren der Wunschsprachen (auch „nur erzwungene") —
+ * wie bisher mit --all-subtitles; ohne Treffer nichts.
+ * Liefert MakeMKV-Stream-Nummern. */
+export function spurenVorbelegt(spuren: readonly SpurAngabe[], wunsch: readonly string[], art: 'audio' | 'untertitel'): number[] {
+ const kandidaten = spuren.filter((s) => angeboten(s, art))
+ const wunschKlein = wunsch.map((w) => w.trim().toLowerCase()).filter((w) => w.length > 0)
+ if (art === 'audio') {
+ const gewaehlt: number[] = []
+ for (const w of wunschKlein) {
+ const erste = kandidaten.find((s) => s.lang.toLowerCase() === w)
+ if (erste !== undefined && !gewaehlt.includes(erste.nr)) gewaehlt.push(erste.nr)
+ }
+ if (gewaehlt.length === 0 && kandidaten.length > 0) gewaehlt.push(kandidaten[0].nr)
+ return gewaehlt
+ }
+ return kandidaten.filter((s) => wunschKlein.includes(s.lang.toLowerCase())).map((s) => s.nr)
+}
+
+/** Eine Spur, wie HandBrakes Scan sie in der Roh-Datei sieht (scan.ts). */
+export interface HbSpurAngabe {
+ nr: number
+ lang: string
+ /** CodecName („dtshd") bzw. bei Untertiteln Source („PGS"). */
+ codec: string
+ kanaele: number
+ beschreibung: string
+}
+
+export interface SpurZuordnung {
+ /** HandBrake-Spurnummern in der Reihenfolge der gewählten Spuren. */
+ nummern: number[]
+ /** Gewählte Spuren, die in der Roh-Datei nicht zu finden waren. */
+ fehlend: SpurAngabe[]
+}
+
+/** Gewählte MakeMKV-Spuren → HandBrake-Spurnummern (pur). Gestuft: Sprache
+ * + Familie + Kanäle → Sprache + Familie → Sprache + Kanäle → Sprache →
+ * (nur wenn die Datei GAR keine Sprachen kennt) Reihenfolge. Jede
+ * HandBrake-Spur nur einmal. */
+export function spurenZuordnen(gewaehlt: readonly SpurAngabe[], vorhanden: readonly HbSpurAngabe[]): SpurZuordnung {
+ const frei = new Set(vorhanden.map((v) => v.nr))
+ const nummern: number[] = []
+ const fehlend: SpurAngabe[] = []
+ const ohneSprachen = vorhanden.every((v) => v.lang.length === 0 || v.lang === 'und')
+ for (const s of gewaehlt) {
+ const lang = s.lang.toLowerCase()
+ const familie = codecFamilie(s.codec)
+ const kanaele = Number(s.kanaele)
+ const stufen: Array<(v: HbSpurAngabe) => boolean> = [
+ (v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie && v.kanaele === kanaele,
+ (v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie,
+ (v) => v.lang.toLowerCase() === lang && v.kanaele === kanaele,
+ (v) => v.lang.toLowerCase() === lang,
+ ]
+ if (ohneSprachen) stufen.push((v) => codecFamilie(v.codec) === familie && v.kanaele === kanaele, () => true)
+ let treffer: HbSpurAngabe | undefined
+ for (const passt of stufen) {
+ treffer = vorhanden.find((v) => frei.has(v.nr) && passt(v))
+ if (treffer !== undefined) break
+ }
+ if (treffer === undefined) {
+ fehlend.push(s)
+ continue
+ }
+ frei.delete(treffer.nr)
+ nummern.push(treffer.nr)
+ }
+ return { nummern, fehlend }
+}
+
+/** Sprachen der gewählten Spuren, ohne Doppel, in Wahl-Reihenfolge —
+ * der Rückfall, wenn die Spuren in der Roh-Datei nicht zu finden sind. */
+export function sprachenDerSpuren(spuren: readonly SpurAngabe[]): string[] {
+ const liste: string[] = []
+ for (const s of spuren) {
+ const l = s.lang.toLowerCase()
+ if (l.length > 0 && !liste.includes(l)) liste.push(l)
+ }
+ return liste
+}
diff --git a/rippy-windows/src/kern/ablauf/ablegen.ts b/rippy-windows/src/kern/ablauf/ablegen.ts
index fd828c7..c3c753b 100644
--- a/rippy-windows/src/kern/ablauf/ablegen.ts
+++ b/rippy-windows/src/kern/ablauf/ablegen.ts
@@ -46,8 +46,12 @@ import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
+ sprachlistenAusSpuren,
sprachwahl,
+ spurwahl,
type KompressionsAuftrag,
+ type RohDatei,
+ type SpurWahl,
} from './auftrag'
export interface AblageUmgebung {
@@ -103,6 +107,24 @@ export function dateiVerschieben(von: string, nach: string): void {
}
}
+/** Die Sprachlisten aus den gewählten Spuren, als Felder für sprachwahl. */
+function sprachlistenAusSpurenFuerWahl(datei: RohDatei): { audioSprachen: string[]; untertitelSprachen: string[] } {
+ const listen = sprachlistenAusSpuren(datei)
+ return { audioSprachen: listen.audio, untertitelSprachen: listen.untertitel }
+}
+
+/** Der Zusatz in der Fortschrittszeile: „ · Ton 1,3 · Untertitel 2 (vorgewählt)". */
+function spurText(spurNr: SpurWahl | null, wahl: { audio: string[]; untertitel: string[]; untertitelStandard: boolean }): string {
+ if (spurNr !== null) {
+ return `${spurNr.audioNr.length > 0 ? ` · Ton Spur ${spurNr.audioNr.join(',')}` : ' · Ton: erste Spur'}${
+ spurNr.untertitelNr.length > 0 ? ` · Untertitel Spur ${spurNr.untertitelNr.join(',')}${spurNr.untertitelStandard > 0 ? ' (vorgewählt)' : ''}` : ''
+ }`
+ }
+ return `${wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''}${
+ wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''
+ }`
+}
+
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
@@ -239,13 +261,34 @@ export async function komprimierenUndAblegen(
continue
}
- const wahl = sprachwahl(datei, global)
+ let wahl = sprachwahl(datei, global)
+ // 5.7.0: Spuren EINZELN gewählt — die Roh-Datei wird gescannt und jede
+ // gewählte MakeMKV-Spur als HandBrake-Spurnummer wiedergefunden
+ // (auftrag.ts spurwahl). Scheitert der Scan, gelten die Sprachen der
+ // gewählten Spuren als Liste (wie 5.4.0–5.6.x).
+ const einzeln = datei.audioSpuren !== undefined || datei.untertitelSpuren !== undefined
+ let spurNr: SpurWahl | null = null
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
- if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
+ if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
- if (spuren !== null) {
+ if (spuren !== null && einzeln) {
+ spurNr = spurwahl(datei, spuren, global.audio[0] ?? '')
+ for (const f of spurNr?.fehlend ?? []) {
+ hinweise.push(`${basename(datei.pfad)}: ${f} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`)
+ }
+ if (spurNr !== null && spurNr.audioNr.length === 0 && (datei.audioSpuren?.length ?? 0) > 0) {
+ // Keine gewählte Tonspur gefunden → lieber Sprachlisten als eine
+ // stumme Datei.
+ spurNr = null
+ wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
+ hinweise.push(`${basename(datei.pfad)}: keine der gewählten Tonspuren gefunden — Sprachen ${wahl.audio.join(',') || 'nach Einstellung'} statt Spuren.`)
+ }
+ } else if (spuren === null && einzeln) {
+ wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
+ hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — Sprachen ${[...wahl.audio, ...wahl.untertitel].join(',') || 'nach Einstellung'} statt einzelner Spuren.`)
+ } else if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
@@ -265,9 +308,7 @@ export async function komprimierenUndAblegen(
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
- text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
- wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
- }${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''} …`,
+ text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)} …`,
})
const encode = await komprimieren({
programm: handbrake,
@@ -277,6 +318,9 @@ export async function komprimierenUndAblegen(
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
+ ...(spurNr !== null
+ ? { audioNr: spurNr.audioNr, untertitelNr: spurNr.untertitelNr, untertitelStandardNr: spurNr.untertitelStandard }
+ : {}),
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
diff --git a/rippy-windows/src/kern/ablauf/auftrag.ts b/rippy-windows/src/kern/ablauf/auftrag.ts
index e46e7ef..9fe8614 100644
--- a/rippy-windows/src/kern/ablauf/auftrag.ts
+++ b/rippy-windows/src/kern/ablauf/auftrag.ts
@@ -6,7 +6,9 @@
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
-import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
+import type { MetaVorschlag, SpurAngabe, TitelRolle } from '../../gemeinsam/nachrichten'
+import { istSpurAngabe } from '../../gemeinsam/nachrichten'
+import { spurenZuordnen, sprachenDerSpuren, type HbSpurAngabe } from '../../gemeinsam/spuren'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
@@ -21,6 +23,11 @@ export interface RohDatei {
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
+ /** 5.7.0: die GEWÄHLTEN Spuren (einzeln); fehlend = Sprachlisten gelten. */
+ audioSpuren?: SpurAngabe[]
+ untertitelSpuren?: SpurAngabe[]
+ /** 5.7.0: vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
+ untertitelVorwahlSpur?: number | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
@@ -86,6 +93,9 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
+ ...(Array.isArray(e.audioSpuren) && e.audioSpuren.every(istSpurAngabe) ? { audioSpuren: e.audioSpuren } : {}),
+ ...(Array.isArray(e.untertitelSpuren) && e.untertitelSpuren.every(istSpurAngabe) ? { untertitelSpuren: e.untertitelSpuren } : {}),
+ ...(e.untertitelVorwahlSpur === null || typeof e.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: e.untertitelVorwahlSpur } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
@@ -162,6 +172,69 @@ export function sprachwahl(
return { audio, untertitel, untertitelStandard }
}
+/** Was HandBrake bekommt, wenn Spuren EINZELN gewählt wurden (5.7.0). */
+export interface SpurWahl {
+ /** HandBrake-Spurnummern (1-basiert, Scan-Reihenfolge). */
+ audioNr: number[]
+ untertitelNr: number[]
+ /** Index (1-basiert) der vorgewählten Untertitelspur in `untertitelNr`, 0 = keine. */
+ untertitelStandard: number
+ /** Klartext für die Hinweise: gewählte Spuren, die nicht in der Datei sind. */
+ fehlend: string[]
+}
+
+/**
+ * Spurwahl je Datei (pur, 5.7.0): Die gewählten MakeMKV-Spuren werden in
+ * HandBrakes Scan der Roh-Datei wiedergefunden (spurenZuordnen). Die Regel
+ * von 5.4.0 gilt weiter: Spielt die erste gewählte Tonspur nicht in der
+ * Wunschsprache, wird eine gewählte Untertitelspur der Wunschsprache
+ * vorgewählt — außer der Nutzer hat eine Vorwahl gesetzt (Spur) oder
+ * ausdrücklich keine (null). Liefert null, wenn keine Spuren gewählt sind
+ * (dann gilt die Sprachwahl). */
+export function spurwahl(
+ datei: Pick,
+ scan: { audio: readonly HbSpurAngabe[]; untertitel: readonly HbSpurAngabe[] },
+ wunschSprache: string,
+): SpurWahl | null {
+ if (datei.audioSpuren === undefined && datei.untertitelSpuren === undefined) return null
+ const audio = spurenZuordnen(datei.audioSpuren ?? [], scan.audio)
+ const untertitel = spurenZuordnen(datei.untertitelSpuren ?? [], scan.untertitel)
+ const gefundeneUntertitel = (datei.untertitelSpuren ?? []).filter((s) => !untertitel.fehlend.includes(s))
+ // Wer wird vorgewählt?
+ let standardSpur: SpurAngabe | undefined
+ if (datei.untertitelVorwahlSpur === null) {
+ standardSpur = undefined
+ } else if (typeof datei.untertitelVorwahlSpur === 'number') {
+ standardSpur = gefundeneUntertitel.find((s) => s.nr === datei.untertitelVorwahlSpur)
+ } else {
+ const ersteTon = (datei.audioSpuren ?? []).find((s) => !audio.fehlend.includes(s))
+ const tonFremd = wunschSprache.length > 0 && ersteTon !== undefined && ersteTon.lang.toLowerCase() !== wunschSprache
+ if (tonFremd) standardSpur = gefundeneUntertitel.find((s) => s.lang.toLowerCase() === wunschSprache)
+ }
+ // Die vorgewählte Spur nach vorn — HandBrakes --subtitle-default zählt
+ // die AUSGABE-Reihenfolge, und die folgt der Liste.
+ let untertitelNr = untertitel.nummern
+ let untertitelStandard = 0
+ if (standardSpur !== undefined) {
+ const i = gefundeneUntertitel.indexOf(standardSpur)
+ if (i >= 0) {
+ untertitelNr = [untertitel.nummern[i], ...untertitel.nummern.filter((_, k) => k !== i)]
+ untertitelStandard = 1
+ }
+ }
+ const fehlend = [
+ ...audio.fehlend.map((s) => `Tonspur ${s.sprache || s.lang}${s.codec.length > 0 ? ` ${s.codec}` : ''}`),
+ ...untertitel.fehlend.map((s) => `Untertitel ${s.sprache || s.lang}${s.beschreibung.includes('erzwungen') ? ' (nur erzwungene)' : ''}`),
+ ]
+ return { audioNr: audio.nummern, untertitelNr, untertitelStandard, fehlend }
+}
+
+/** Die Sprachlisten aus gewählten Spuren — der Rückfall, wenn der Scan der
+ * Roh-Datei scheitert (pur). */
+export function sprachlistenAusSpuren(datei: Pick): { audio: string[]; untertitel: string[] } {
+ return { audio: sprachenDerSpuren(datei.audioSpuren ?? []), untertitel: sprachenDerSpuren(datei.untertitelSpuren ?? []) }
+}
+
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
diff --git a/rippy-windows/src/kern/ablauf/pipeline.ts b/rippy-windows/src/kern/ablauf/pipeline.ts
index c77e16e..e60fc49 100644
--- a/rippy-windows/src/kern/ablauf/pipeline.ts
+++ b/rippy-windows/src/kern/ablauf/pipeline.ts
@@ -575,6 +575,10 @@ export class Pipeline {
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
+ // 5.7.0: die einzeln gewählten Spuren reisen mit in die Ablage.
+ ...(a?.audioSpuren !== undefined ? { audioSpuren: a.audioSpuren } : {}),
+ ...(a?.untertitelSpuren !== undefined ? { untertitelSpuren: a.untertitelSpuren } : {}),
+ ...(a?.untertitelVorwahlSpur === null || typeof a?.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: a.untertitelVorwahlSpur } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
diff --git a/rippy-windows/src/kern/dienste/erkennung.ts b/rippy-windows/src/kern/dienste/erkennung.ts
index 52ee922..f1504ff 100644
--- a/rippy-windows/src/kern/dienste/erkennung.ts
+++ b/rippy-windows/src/kern/dienste/erkennung.ts
@@ -17,7 +17,7 @@ import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
-import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
+import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
@@ -348,6 +348,8 @@ export class ErkennungDienst {
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
+ // 5.7.0: und jede Spur einzeln.
+ spuren: spurenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
diff --git a/rippy-windows/src/kern/komprimieren/handbrake.ts b/rippy-windows/src/kern/komprimieren/handbrake.ts
index 270b0d4..6b24fe7 100644
--- a/rippy-windows/src/kern/komprimieren/handbrake.ts
+++ b/rippy-windows/src/kern/komprimieren/handbrake.ts
@@ -15,6 +15,13 @@
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
+// - `--aencoder copy` OHNE `--audio-copy-mask` kopiert NUR AAC: HandBrake-
+// CLI 1.11.2 setzt die Kopiermaske dann auf „copy:aac" — obwohl das
+// Preset alle Codecs erlaubt — und rechnet AC3/DTS/TrueHD in AAC um
+// („Auto Passthru: allowed codecs are AAC … passthru not possible for
+// track 1, using fallback"). Gemessen 12.09.2026 (Durchsicht, F31):
+// mit ausdrücklicher Maske bleibt AC3 AC3. Seit 5.0 bis 5.6.1 wurde
+// also jede Tonspur verlustbehaftet umgerechnet.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync, renameSync, unlinkSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
@@ -33,6 +40,13 @@ export interface HandbrakeArgsOptionen {
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
+ /** 5.7.0: Spuren EINZELN — HandBrake-Spurnummern (`-a`/`-s`, 1-basiert,
+ * Scan-Reihenfolge). Gesetzt, gelten die Sprachlisten nicht. Eine leere
+ * Liste heißt: keine Spur dieser Art. `untertitelStandardNr` ist der
+ * 1-basierte Index in `untertitelNr` (0 = keine Vorwahl). */
+ audioNr?: readonly number[]
+ untertitelNr?: readonly number[]
+ untertitelStandardNr?: number
}
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
@@ -47,16 +61,47 @@ export interface HandbrakeArgsOptionen {
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
+// Spur-Schalter GEMESSEN (12.09.2026, Testdatei jpn 5.1/deu 2.0/deu 2.0
+// „Kommentar" AC3 + deu/eng SRT, Preset „H.265 MKV 1080p30"):
+// --audio 1,3 → jpn und deu-Kommentar, in dieser Reihenfolge
+// --subtitle 2,1 --subtitle-default=1 → eng (default=1) vor deu
+// --subtitle none → keine Untertitelspur (ohne --audio: die erste Tonspur)
+// --audio-copy-mask aac,ac3,eac3,dtshd,dts,mp3,truehd,flac → AC3 bleibt AC3
+
+/** Was HandBrake verlustfrei durchreichen darf — genau die Maske der
+ * mitgelieferten Presets („copy:aac" … „copy:flac"), ausdrücklich, weil
+ * die CLI sie sonst auf AAC verengt (siehe Kopf). */
+export const KOPIERMASKE = 'aac,ac3,eac3,dtshd,dts,mp3,truehd,flac'
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
if (format !== undefined) args.push('--format', format)
+ // 5.7.0: Spuren einzeln — `--audio 1,3` und `--subtitle 2` (HandBrakeCLI
+ // --help: „-a, --audio Select audio track(s), separated by
+ // commas", „-s, --subtitle Select subtitle track(s)"; am
+ // mitgelieferten 1.11.2 gemessen, siehe test/messung.spuren.test.ts).
+ if (optionen.audioNr !== undefined || optionen.untertitelNr !== undefined) {
+ const audioNr = optionen.audioNr ?? []
+ // Ohne gewählte Tonspur bleibt HandBrakes Vorgabe: die erste Spur —
+ // ein stummer Film wäre nie gewollt (der Dialog sagt das dazu).
+ if (audioNr.length > 0) args.push('--audio', audioNr.join(','))
+ args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
+ const untertitelNr = optionen.untertitelNr ?? []
+ if (untertitelNr.length > 0) {
+ args.push('--subtitle', untertitelNr.join(','))
+ if ((optionen.untertitelStandardNr ?? 0) > 0) args.push(`--subtitle-default=${optionen.untertitelStandardNr}`)
+ } else {
+ args.push('--subtitle', 'none')
+ }
+ return args
+ }
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
args.push('--first-audio')
- // `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
- // der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
- args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
+ // `copy` reicht die gewählten Tonspuren verlustfrei durch — aber nur mit
+ // ausdrücklicher Kopiermaske (F31, siehe Kopf); `av_aac` ist der
+ // dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
+ args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles')
diff --git a/rippy-windows/src/kern/komprimieren/scan.ts b/rippy-windows/src/kern/komprimieren/scan.ts
index fa1d804..2526ff8 100644
--- a/rippy-windows/src/kern/komprimieren/scan.ts
+++ b/rippy-windows/src/kern/komprimieren/scan.ts
@@ -18,9 +18,21 @@
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
+/** Eine Spur laut HandBrake-Scan. 5.7.0: dazu CodecName („dtshd", „dca",
+ * „truehd", „ac3" …), ChannelCount und bei Untertiteln Source/SourceName
+ * („PGS") — am 12.09.2026 am mitgelieferten HandBrake 1.11.2 gemessen
+ * (AudioList[].CodecName/ChannelCount/ChannelLayout/TrackNumber). */
+export interface HbSpur {
+ nr: number
+ lang: string
+ beschreibung: string
+ codec: string
+ kanaele: number
+}
+
export interface HbSpuren {
- audio: Array<{ nr: number; lang: string; beschreibung: string }>
- untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
+ audio: HbSpur[]
+ untertitel: HbSpur[]
dauerS: number
}
@@ -60,14 +72,17 @@ export function spurenAusScan(ausgabe: string): HbSpuren | null {
const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record
- const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
+ const spuren = (feld: unknown): HbSpur[] => {
if (!Array.isArray(feld)) return []
return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record
+ const kanaele = eintrag.ChannelCount
return {
nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
+ codec: String(eintrag.CodecName ?? eintrag.SourceName ?? eintrag.Source ?? eintrag.Format ?? ''),
+ kanaele: typeof kanaele === 'number' && Number.isFinite(kanaele) ? kanaele : 0,
}
})
}
diff --git a/rippy-windows/src/kern/rip/parser.ts b/rippy-windows/src/kern/rip/parser.ts
index a5f432f..ccb4479 100644
--- a/rippy-windows/src/kern/rip/parser.ts
+++ b/rippy-windows/src/kern/rip/parser.ts
@@ -134,8 +134,18 @@ export interface StreamInfo {
codec: string
kanaele: string
beschreibung: string
+ /** Attribut 2: „Surround 5.1", „Stereo" (5.7.0). */
+ layout: string
+ /** Attribut 22: Flags. Bit 0x800 heißt „abgeleitet" — an der Kizuna-Disc
+ * gemessen (03.09.2026): 2304 (0x900) am DTS-Kern der DTS-HD-Spur, 6144
+ * (0x1800) an der „nur erzwungene"-Fassung eines Untertitels, 1024 und 0
+ * an den vollen Spuren (5.7.0). */
+ flags: number
}
+/** Bit 0x800 in Attribut 22: eine von MakeMKV abgeleitete Spur. */
+export const STREAM_FLAG_ABGELEITET = 0x800
+
export interface TitelStreams {
audio: StreamInfo[]
untertitel: StreamInfo[]
@@ -160,17 +170,19 @@ export function parseStreamInfo(ausgabe: string): Map {
}
let eintrag = streams.get(sNr)
if (eintrag === undefined) {
- eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
+ eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '', layout: '', flags: 0 }
streams.set(sNr, eintrag)
}
if (attr === 1) {
eintrag.typ = wert
eintrag.typCode = code
}
+ else if (attr === 2) eintrag.layout = wert
else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert
+ else if (attr === 22 && /^\d+$/.test(wert)) eintrag.flags = Number(wert)
else if (attr === 30) eintrag.beschreibung = wert
}
@@ -192,8 +204,8 @@ export function parseStreamInfo(ausgabe: string): Map {
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
-import type { SprachEintrag } from '../../gemeinsam/nachrichten'
-export type { SprachEintrag }
+import type { SprachEintrag, SpurAngabe } from '../../gemeinsam/nachrichten'
+export type { SprachEintrag, SpurAngabe }
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
@@ -219,6 +231,24 @@ export function sprachenZusammenfassen(streamsJeTitel: Map
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
+/** Jede Spur EINES Titels als SpurAngabe (5.7.0, für den Auswahl-Dialog):
+ * Ton und Untertitel getrennt, in Disc-Reihenfolge, abgeleitete Spuren
+ * markiert. Ohne Streams leer. */
+export function spurenFuerTitel(streams: TitelStreams | undefined): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } {
+ const angabe = (s: StreamInfo): SpurAngabe => ({
+ nr: s.nr,
+ lang: s.lang,
+ sprache: s.sprache.length > 0 ? s.sprache : s.lang,
+ codec: s.codec,
+ kanaele: s.kanaele,
+ layout: s.layout,
+ beschreibung: s.beschreibung,
+ abgeleitet: (s.flags & STREAM_FLAG_ABGELEITET) !== 0,
+ })
+ if (streams === undefined) return { audio: [], untertitel: [] }
+ return { audio: streams.audio.map(angabe), untertitel: streams.untertitel.map(angabe) }
+}
+
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */
diff --git a/rippy-windows/src/kern/werkzeuge/spurauswahl.ts b/rippy-windows/src/kern/werkzeuge/spurauswahl.ts
index edd0932..531cfc6 100644
--- a/rippy-windows/src/kern/werkzeuge/spurauswahl.ts
+++ b/rippy-windows/src/kern/werkzeuge/spurauswahl.ts
@@ -22,12 +22,20 @@
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg).
+//
+// 5.7.0: Seit der Dialog jede Spur einzeln anbietet, dürfen auch die
+// Stereo-Doppel bleiben (havemulti — „Deutsch · DD · 2.0 (Kommentar)"
+// neben „Deutsch · DTS-HD MA · 5.1"): Nur die abgeleiteten DTS-Kerne
+// (havecore) fallen weg, die zeigt der Dialog gar nicht erst. Stand
+// Rippys ALTE Regel in der Registry, wird sie ersetzt; eine fremde bleibt.
import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString'
-/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
-export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
+/** Alles behalten, nur die Kerne weg — sprachunabhängig (5.7.0). */
+export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:havecore,=100:all'
+/** Rippys frühere Regeln (5.4.0–5.6.1) — werden beim Start ersetzt. */
+export const RIPPY_REGELN_ALT: readonly string[] = ['-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all']
export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
@@ -37,6 +45,16 @@ export interface SpurauswahlStand {
text: string
}
+/** Was mit einer vorgefundenen Regel geschieht (pur, testbar):
+ * 'rippy' = steht schon · 'ersetzen' = Rippys alte Regel · 'eigen' =
+ * fremd, bleibt · 'setzen' = keine da. */
+export function regelUrteil(bisher: string | null): 'rippy' | 'ersetzen' | 'eigen' | 'setzen' {
+ if (bisher === null || bisher.length === 0) return 'setzen'
+ if (bisher === RIPPY_REGEL) return 'rippy'
+ if (RIPPY_REGELN_ALT.includes(bisher)) return 'ersetzen'
+ return 'eigen'
+}
+
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
@@ -56,14 +74,15 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
}
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
- if (bisher !== null && bisher.length > 0) {
- if (bisher === RIPPY_REGEL) {
- return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
- }
+ const urteil = regelUrteil(bisher)
+ if (urteil === 'rippy') {
+ return { stand: 'rippy', regel: bisher ?? '', text: 'MakeMKV behält alle Spuren (Rippys Auswahlregel steht).' }
+ }
+ if (urteil === 'eigen') {
return {
stand: 'eigen',
- regel: bisher,
- text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
+ regel: bisher ?? '',
+ text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Spuren in der Roh-Datei, liegt es daran.`,
}
}
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
@@ -73,6 +92,9 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
return {
stand: 'gesetzt',
regel: RIPPY_REGEL,
- text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
+ text:
+ urteil === 'ersetzen'
+ ? 'MakeMKV-Auswahlregel erneuert: jede Spur kommt in die Roh-Datei, auch Stereo-Fassungen und Kommentare (nur DTS-Kerne nicht).'
+ : 'MakeMKV-Auswahlregel gesetzt: jede Spur kommt in die Roh-Datei (nur DTS-Kerne nicht).',
}
}
diff --git a/rippy-windows/test/ablegen.test.ts b/rippy-windows/test/ablegen.test.ts
index 2651e03..618f18c 100644
--- a/rippy-windows/test/ablegen.test.ts
+++ b/rippy-windows/test/ablegen.test.ts
@@ -199,7 +199,7 @@ describe('komprimierenUndAblegen', () => {
const ergebnis = await komprimierenUndAblegen(
auftrag({ rohOrdner: roh, dateien }),
umgebung(ablage, {
- spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch' }], untertitel: [], dauerS: 3167 }),
+ spurenScannen: async () => ({ audio: [{ nr: 1, lang: 'deu', beschreibung: 'Deutsch', codec: 'dtshd', kanaele: 6 }], untertitel: [], dauerS: 3167 }),
}),
() => {},
KEIN_ABBRUCH,
@@ -395,3 +395,91 @@ describe('komprimierenUndAblegen — Durchsicht 5.7.0', () => {
expect(ergebnis.dateien.map((d) => d.split(/[\\/]/).pop())).toEqual(['title_t00 (2).mkv'])
})
})
+
+// ── 5.7.0: Spuren einzeln ──────────────────────────────────────────────────
+describe('Spuren einzeln (5.7.0)', () => {
+ const jpnHd = { nr: 1, lang: 'jpn', sprache: 'Japanese', codec: 'DTS-HD MA', kanaele: '6', layout: 'Surround 5.1', beschreibung: '', abgeleitet: false }
+ const deuKommentar = { nr: 4, lang: 'deu', sprache: 'German', codec: 'DD', kanaele: '2', layout: 'Stereo', beschreibung: 'DD Stereo German (Kommentar)', abgeleitet: false }
+ const deuPgs = { nr: 5, lang: 'deu', sprache: 'German', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS German', abgeleitet: false }
+ const engPgs = { nr: 7, lang: 'eng', sprache: 'English', codec: 'PGS', kanaele: '', layout: '', beschreibung: 'PGS English', abgeleitet: false }
+ const scan = {
+ audio: [
+ { nr: 1, lang: 'jpn', beschreibung: '', codec: 'dtshd', kanaele: 6 },
+ { nr: 2, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 6 },
+ { nr: 3, lang: 'deu', beschreibung: '', codec: 'ac3', kanaele: 2 },
+ ],
+ untertitel: [
+ { nr: 1, lang: 'deu', beschreibung: '', codec: 'PGS', kanaele: 0 },
+ { nr: 2, lang: 'eng', beschreibung: '', codec: 'PGS', kanaele: 0 },
+ ],
+ dauerS: 3167,
+ }
+
+ it('gewählte Spuren erreichen HandBrake als Nummern — japanischer Ton stellt die deutschen Untertitel vor und wählt sie vor', async () => {
+ const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
+ dateien[0].audioSprachen = ['jpn', 'deu']
+ dateien[0].untertitelSprachen = ['eng', 'deu']
+ dateien[0].audioSpuren = [jpnHd, deuKommentar]
+ dateien[0].untertitelSpuren = [engPgs, deuPgs]
+ const ergebnis = await komprimierenUndAblegen(
+ auftrag({ rohOrdner: roh, dateien }),
+ umgebung(ablage, { spurenScannen: async () => scan }),
+ () => {},
+ KEIN_ABBRUCH,
+ )
+ expect(ergebnis.status).toBe('fertig')
+ expect(hb.aufrufe[0].audioNr).toEqual([1, 3])
+ expect(hb.aufrufe[0].untertitelNr).toEqual([1, 2])
+ expect(hb.aufrufe[0].untertitelStandardNr).toBe(1)
+ expect(ergebnis.hinweise.join(' ')).not.toContain('NICHT in der Roh-Datei')
+ })
+
+ it('eine gewählte Spur fehlt in der Roh-Datei → Hinweis, der Rest läuft', async () => {
+ const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
+ const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
+ dateien[0].audioSpuren = [fra, deuKommentar]
+ dateien[0].untertitelSpuren = []
+ const ergebnis = await komprimierenUndAblegen(
+ auftrag({ rohOrdner: roh, dateien }),
+ umgebung(ablage, { spurenScannen: async () => scan }),
+ () => {},
+ KEIN_ABBRUCH,
+ )
+ expect(hb.aufrufe[0].audioNr).toEqual([3])
+ expect(hb.aufrufe[0].untertitelNr).toEqual([])
+ expect(ergebnis.hinweise.join(' ')).toContain('Tonspur French DTS-HD MA ist NICHT in der Roh-Datei')
+ })
+
+ it('Scan der Roh-Datei scheitert → Sprachen der gewählten Spuren als Liste (wie bisher)', async () => {
+ const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
+ dateien[0].audioSpuren = [jpnHd]
+ dateien[0].untertitelSpuren = [deuPgs]
+ const ergebnis = await komprimierenUndAblegen(
+ auftrag({ rohOrdner: roh, dateien }),
+ umgebung(ablage, { spurenScannen: async () => null }),
+ () => {},
+ KEIN_ABBRUCH,
+ )
+ expect(hb.aufrufe[0].audioNr).toBeUndefined()
+ expect(hb.aufrufe[0].audioSprachen).toEqual(['jpn'])
+ expect(hb.aufrufe[0].untertitelSprachen).toEqual(['deu'])
+ expect(hb.aufrufe[0].untertitelStandard).toBe(true)
+ expect(ergebnis.hinweise.join(' ')).toContain('ließen sich nicht messen')
+ })
+
+ it('keine gewählte Tonspur findbar → Sprachlisten statt stummer Datei', async () => {
+ const { ablage, roh, dateien } = aufbau(['title_t00.mkv'])
+ const fra = { ...jpnHd, nr: 9, lang: 'fra', sprache: 'French' }
+ dateien[0].audioSpuren = [fra]
+ dateien[0].untertitelSpuren = [deuPgs]
+ const ergebnis = await komprimierenUndAblegen(
+ auftrag({ rohOrdner: roh, dateien }),
+ umgebung(ablage, { spurenScannen: async () => scan }),
+ () => {},
+ KEIN_ABBRUCH,
+ )
+ expect(hb.aufrufe[0].audioNr).toBeUndefined()
+ expect(hb.aufrufe[0].audioSprachen).toEqual(['fra'])
+ expect(ergebnis.hinweise.join(' ')).toContain('keine der gewählten Tonspuren gefunden')
+ })
+})
diff --git a/rippy-windows/test/automatik.test.ts b/rippy-windows/test/automatik.test.ts
index fce7c5f..7a3d0a1 100644
--- a/rippy-windows/test/automatik.test.ts
+++ b/rippy-windows/test/automatik.test.ts
@@ -10,7 +10,7 @@ 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: [] } }
+ 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 {
diff --git a/rippy-windows/test/messung.spuren.test.ts b/rippy-windows/test/messung.spuren.test.ts
index 18b1459..d98bf91 100644
--- a/rippy-windows/test/messung.spuren.test.ts
+++ b/rippy-windows/test/messung.spuren.test.ts
@@ -29,3 +29,72 @@ describe.skipIf(!messen)('Spuren einer echten Roh-Datei', () => {
expect(spuren!.audio.length).toBeGreaterThan(0)
})
})
+
+// ── 5.7.0: Spur-Schalter am mitgelieferten HandBrakeCLI ────────────────────
+// Baut sich die Quelle selbst (ffmpeg muss im PATH sein): jpn 5.1 / deu 2.0 /
+// deu 2.0 „Kommentar" als AC3, deu/eng SRT — und prüft die Ausgabe wieder
+// über HandBrakes eigenen Scan. Anlass: `--audio 1,3`, `--subtitle 2,1`,
+// `--subtitle-default=1` und die Kopiermaske (F31: ohne `--audio-copy-mask`
+// wurde AC3 zu AAC). Läuft nur mit RIPPY_MESSUNG=1 unter Windows.
+//
+// RIPPY_MESSUNG=1 npx vitest run test/messung.spuren.test.ts --reporter=verbose
+import { spawnSync } from 'node:child_process'
+import { mkdtempSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+const spurMessung = process.env.RIPPY_MESSUNG === '1' && process.platform === 'win32'
+
+describe.skipIf(!spurMessung)('Spur-Schalter am echten HandBrakeCLI (5.7.0)', () => {
+ it('--audio 1,3 --subtitle 2,1 --subtitle-default=1 mit Kopiermaske: AC3 bleibt AC3, eng vor deu', { timeout: 10 * 60_000 }, async () => {
+ const ffmpeg = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8', windowsHide: true })
+ if (ffmpeg.status !== 0) {
+ console.log('MESSUNG übersprungen: kein ffmpeg im PATH')
+ return
+ }
+ const { finden } = await import('../src/kern/werkzeuge/katalog')
+ const { buildHandbrakeArgs } = await import('../src/kern/komprimieren/handbrake')
+ const { spurenScannen } = await import('../src/kern/komprimieren/scan')
+ const handbrake = finden('handbrake', '')
+ expect(handbrake.length, 'HandBrakeCLI nicht gefunden').toBeGreaterThan(0)
+ const ordner = mkdtempSync(join(tmpdir(), 'rippy-spuren-'))
+ const deu = join(ordner, 'deu.srt')
+ const eng = join(ordner, 'eng.srt')
+ writeFileSync(deu, '1\n00:00:00,500 --> 00:00:02,000\nHallo\n\n')
+ writeFileSync(eng, '1\n00:00:00,500 --> 00:00:02,000\nHello\n\n')
+ const quelle = join(ordner, 'quelle.mkv')
+ const bau = spawnSync(
+ 'ffmpeg',
+ [
+ '-y', '-loglevel', 'error',
+ '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=25',
+ '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
+ '-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
+ '-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=48000',
+ '-i', deu, '-i', eng, '-t', '3',
+ '-map', '0:v', '-map', '1:a', '-map', '2:a', '-map', '3:a', '-map', '4:s', '-map', '5:s',
+ '-c:v', 'libx264', '-preset', 'ultrafast', '-c:a', 'ac3', '-ac:a:0', '6', '-ac:a:1', '2', '-ac:a:2', '2', '-c:s', 'srt',
+ '-metadata:s:a:0', 'language=jpn', '-metadata:s:a:1', 'language=deu', '-metadata:s:a:2', 'language=deu', '-metadata:s:a:2', 'title=Kommentar',
+ '-metadata:s:s:0', 'language=deu', '-metadata:s:s:1', 'language=eng',
+ quelle,
+ ],
+ { encoding: 'utf8', windowsHide: true, timeout: 120_000 },
+ )
+ expect(bau.status, `ffmpeg: ${bau.stderr}`).toBe(0)
+ const ausgabe = join(ordner, 'ausgabe.mkv')
+ const args = buildHandbrakeArgs({ eingabe: quelle, ausgabe, preset: 'H.265 MKV 1080p30', audioNr: [1, 3], untertitelNr: [2, 1], untertitelStandardNr: 1 })
+ const lauf = spawnSync(handbrake, args, { encoding: 'utf8', windowsHide: true, timeout: 8 * 60_000 })
+ expect(lauf.status, `HandBrake: ${(lauf.stderr ?? '').slice(-600)}`).toBe(0)
+ const passthru = /Auto Passthru: allowed codecs are ([^\n]*)/.exec(lauf.stderr ?? '')
+ console.log(`MESSUNG Passthru: ${passthru?.[1] ?? '(Zeile fehlt)'}`)
+ const spuren = await spurenScannen(handbrake, ausgabe)
+ expect(spuren).not.toBeNull()
+ for (const a of spuren!.audio) console.log(`MESSUNG Ton ${a.nr}: ${a.lang} ${a.codec} ${a.kanaele}ch — ${a.beschreibung}`)
+ for (const u of spuren!.untertitel) console.log(`MESSUNG Untertitel ${u.nr}: ${u.lang} ${u.codec} — ${u.beschreibung}`)
+ expect(spuren!.audio.map((a) => [a.lang, a.codec, a.kanaele])).toEqual([
+ ['jpn', 'ac3', 6],
+ ['deu', 'ac3', 2],
+ ])
+ expect(spuren!.untertitel.map((u) => u.lang)).toEqual(['eng', 'deu'])
+ })
+})
diff --git a/rippy-windows/test/spuren-570.test.ts b/rippy-windows/test/spuren-570.test.ts
new file mode 100644
index 0000000..de6c459
--- /dev/null
+++ b/rippy-windows/test/spuren-570.test.ts
@@ -0,0 +1,261 @@
+// 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')
+ })
+})