feat(v5): jede Spur einzeln anhaken — Codec und Kanäle je Tonspur, Untertitel genauso

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>
This commit is contained in:
Hitonabi
2026-09-12 22:11:27 +02:00
co-authored by Claude Opus 5
parent fc2d16ca38
commit e7e33e9021
18 changed files with 1098 additions and 60 deletions
+36 -14
View File
@@ -124,16 +124,26 @@ await schritt('Titel-Lauf liefert drei Titel', async () => {
await dialog().getByText('Titel 2', { exact: true }).waitFor()
})
await schritt('Sprachen je Titel: jpn/deu Ton, deu/eng Untertitel als Chips', async () => {
await dialog().getByRole('button', { name: /jpn/ }).first().waitFor()
await dialog().getByRole('button', { name: /✓ deu/ }).first().waitFor()
await schritt('5.7.0: Spuren je Titel einzeln — Codec und Kanäle, der DTS-Kern bleibt unsichtbar', async () => {
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Surround 5.1').first().waitFor()
await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().waitFor()
await dialog().getByLabel('German · PGS (nur erzwungene)').first().waitFor()
if ((await dialog().getByLabel('Japanese · DTS · Surround 5.1').count()) !== 0) throw new Error('der abgeleitete DTS-Kern wird angeboten')
// Vorbelegt: je Wunschsprache (deu,eng) die erste Tonspur → nur German DD 5.1;
// Untertitel: alle deutschen (auch „nur erzwungene"), englisch nicht.
if (!(await dialog().getByLabel('German · DD · Surround 5.1').first().isChecked())) throw new Error('German 5.1 nicht vorbelegt')
if (await dialog().getByLabel('German · DD · Stereo (Kommentar)').first().isChecked()) throw new Error('Kommentar-Spur vorbelegt')
if (await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().isChecked()) throw new Error('Japanisch vorbelegt, obwohl Deutsch da ist')
if (!(await dialog().getByLabel('German · PGS (nur erzwungene)').first().isChecked())) throw new Error('erzwungene Untertitel nicht vorbelegt')
if (await dialog().getByLabel('English · PGS').first().isChecked()) throw new Error('englische Untertitel vorbelegt')
})
await schritt('Titel 0 bekommt japanischen Ton (deu abwählen, jpn anwählen)', async () => {
const chips = dialog().locator('button', { hasText: /^✓ deu$|^deu$/ })
await chips.first().click()
await dialog().getByRole('button', { name: /^jpn$/ }).first().click()
await dialog().getByRole('button', { name: /^✓ jpn$/ }).first().waitFor()
await schritt('Titel 0 bekommt japanischen Ton (German 5.1 abhaken, Japanese anhaken)', async () => {
await dialog().getByLabel('German · DD · Surround 5.1').first().uncheck()
await dialog().getByText('keine Tonspur gewählt — HandBrake nimmt die erste Spur').waitFor()
await dialog().getByLabel('Japanese · DTS-HD MA · Surround 5.1').first().check()
if ((await dialog().getByText('keine Tonspur gewählt').count()) !== 0) throw new Error('Warnung bleibt trotz japanischer Spur')
})
await schritt('„Hauptinhalt + Extras" wählt beide langen Titel, der Schnipsel bleibt draußen', async () => {
@@ -153,7 +163,9 @@ await schritt('5.6.0: Untertitel-Vorwahl erscheint NUR bei Extras, „anderer Fi
await rollen.nth(1).selectOption('extra')
await dialog().getByText('BEIM ABSPIELEN AN').waitFor()
const vorwahl = dialog().locator('select').nth(2)
await vorwahl.selectOption('deu')
// 5.7.0: Rippys Regel nennt die Spur (japanischer Ton → deutsche
// Untertitel), die Optionen sind Spuren — gewählt wird per Nummer (5).
await vorwahl.selectOption('5')
const knoepfe = await dialog().getByRole('button', { name: 'anderer Film …' }).count()
if (knoepfe < 2) throw new Error(`nur ${knoepfe} „anderer Film"-Knöpfe`)
await dialog().getByRole('button', { name: 'anderer Film …' }).first().click()
@@ -279,22 +291,32 @@ await schritt('Werkzeuge haben Durchsuchen, Allgemein und Update sind eigene Kar
})
await bild('einstellungen')
await schritt('Roh-Dateien löschen → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await schritt('Roh-Dateien löschen → Rückfrage → „Ja, löschen" → „gelöscht"', async () => {
await seite.getByRole('button', { name: 'Roh-Dateien löschen' }).first().click()
await seite.getByRole('button', { name: 'Ja, löschen' }).first().click()
await seite.getByText(/Roh: gelöscht/).first().waitFor()
})
await schritt('5.6.0: Hell-Modus über Einstellungen → Allgemein, data-design wechselt', async () => {
await seite.locator('#allgemein').locator('select').first().selectOption('hell')
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('hell')
await seite.locator('html[data-design="hell"]').waitFor()
})
await bild('hell-modus')
await schritt('zurück in den Kinosaal (dunkel)', async () => {
await seite.locator('#allgemein').locator('select').first().selectOption('dunkel')
await seite.locator('#allgemein').getByLabel('Erscheinungsbild').selectOption('dunkel')
await seite.locator('html[data-design="dunkel"]').waitFor()
})
await schritt('5.7.0: „Titel lesen — höchstens" steht auf 15 Minuten, 30 lässt sich wählen', async () => {
const regler = seite.locator('#allgemein').getByLabel('Titel lesen — höchstens')
if ((await regler.inputValue()) !== '15') throw new Error(`Vorgabe ist ${await regler.inputValue()}, nicht 15`)
await regler.selectOption('30')
// Der Wert geht zum Kern und kommt als Einstellung zurück — erst dann
// zeigt das (kontrollierte) Feld 30.
await regler.locator('option[value="30"]:checked').waitFor({ state: 'attached' })
})
await schritt('Ereignisse nennen die Kompression', async () => {
await seite.getByRole('button', { name: 'Übersicht' }).click()
await seite.getByText(/Kompression „/).first().waitFor()
@@ -357,6 +379,6 @@ await schritt('Fertig: der Knopf Los gehts', async () => {
})
await bild('einrichtung-fertig')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Sprachen, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
console.log('KLICK: OK — Speicher, Rippen, Dialog, Spuren einzeln, Folgen, Platz, Rip, Warteschlange, Kompression, Arbeitsordner, Ablage, Verlauf, Bilanz, Roh-Dateien, Einstellungen, Vorwahl, anderer Film, Vorgangs-Leiste, Restzeit, Hell-Modus, Erst-Einrichtung: alles geklickt.')
await app.close().catch(() => {})
process.exit(0)
+9 -4
View File
@@ -47,16 +47,21 @@ if (args.includes('--scan')) {
out('"MainFeature": 0,')
out('"TitleList": [')
out('{')
// 5.7.0: CodecName und ChannelCount wie das echte 1.11.2 (gemessen
// 12.09.2026) — dieselben Spuren, die fake-makemkvcon meldet, ohne den
// DTS-Kern (den nimmt MakeMKVs Regel nicht mit).
out('"AudioList": [')
out('{ "Description": "Japanese (AAC LC, 2.0 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "Description": "Deutsch (AAC LC, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 }')
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "dtshd", "Description": "Japanese (DTS-HD MA, 5.1 ch)", "Language": "Japanese", "LanguageCode": "jpn", "TrackNumber": 1 },')
out('{ "ChannelCount": 6, "ChannelLayout": 1551, "CodecName": "ac3", "Description": "Deutsch (AC3, 5.1 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 2 },')
out('{ "ChannelCount": 2, "ChannelLayout": 3, "CodecName": "ac3", "Description": "Deutsch (AC3, 2.0 ch)", "Language": "Deutsch", "LanguageCode": "deu", "TrackNumber": 3 }')
out('],')
out('"Duration": { "Hours": 0, "Minutes": 52, "Seconds": 47, "Ticks": 542070 },')
out('"Index": 1,')
out(`"Name": ${JSON.stringify(eingabe)},`)
out('"SubtitleList": [')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "SourceName": "PGS", "TrackNumber": 2 }')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 1 },')
out('{ "Format": "bitmap", "Language": "Deutsch", "LanguageCode": "deu", "Source": 5, "SourceName": "PGS", "TrackNumber": 2 },')
out('{ "Format": "bitmap", "Language": "English", "LanguageCode": "eng", "Source": 5, "SourceName": "PGS", "TrackNumber": 3 }')
out(']')
out('}')
out(']')
+21 -12
View File
@@ -31,18 +31,27 @@ if (befehl === 'info') {
out(`TINFO:${t.nr},11,0,"${t.bytes}"`)
out(`SINFO:${t.nr},0,1,6201,"Video"`)
if (t.nr < 2) {
out(`SINFO:${t.nr},1,1,6202,"Audio"`)
out(`SINFO:${t.nr},1,3,0,"jpn"`)
out(`SINFO:${t.nr},1,4,0,"Japanese"`)
out(`SINFO:${t.nr},2,1,6202,"Audio"`)
out(`SINFO:${t.nr},2,3,0,"deu"`)
out(`SINFO:${t.nr},2,4,0,"German"`)
out(`SINFO:${t.nr},3,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},3,3,0,"deu"`)
out(`SINFO:${t.nr},3,4,0,"German"`)
out(`SINFO:${t.nr},4,1,6203,"Subtitles"`)
out(`SINFO:${t.nr},4,3,0,"eng"`)
out(`SINFO:${t.nr},4,4,0,"English"`)
// 5.7.0: jede Spur mit Codec (6), Kanälen (14), Anordnung (2), Flags
// (22) und Beschreibung (30), wie an der Kizuna-Disc gemessen — samt
// dem abgeleiteten DTS-Kern (Flag 0x800), den der Dialog NICHT zeigt,
// und der „nur erzwungene"-Fassung eines Untertitels, die er zeigt.
const spur = (nr, code, typ, lang, name, codec, kanaele, layout, flags, text) => {
out(`SINFO:${t.nr},${nr},1,${code},"${typ}"`)
if (layout) out(`SINFO:${t.nr},${nr},2,0,"${layout}"`)
out(`SINFO:${t.nr},${nr},3,0,"${lang}"`)
out(`SINFO:${t.nr},${nr},4,0,"${name}"`)
out(`SINFO:${t.nr},${nr},6,0,"${codec}"`)
if (kanaele) out(`SINFO:${t.nr},${nr},14,0,"${kanaele}"`)
out(`SINFO:${t.nr},${nr},22,0,"${flags}"`)
out(`SINFO:${t.nr},${nr},30,0,"${text}"`)
}
spur(1, 6202, 'Audio', 'jpn', 'Japanese', 'DTS-HD MA', '6', 'Surround 5.1', 1024, 'DTS-HD MA Surround 5.1 Japanese')
spur(2, 6202, 'Audio', 'jpn', 'Japanese', 'DTS', '6', 'Surround 5.1', 2304, 'DTS Surround 5.1 Japanese')
spur(3, 6202, 'Audio', 'deu', 'German', 'DD', '6', 'Surround 5.1', 0, 'DD Surround 5.1 German')
spur(4, 6202, 'Audio', 'deu', 'German', 'DD', '2', 'Stereo', 0, 'DD Stereo German (Kommentar)')
spur(5, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 0, 'PGS German')
spur(6, 6203, 'Untertitel', 'deu', 'German', 'PGS', '', '', 6144, 'PGS German (nur erzwungene)')
spur(7, 6203, 'Untertitel', 'eng', 'English', 'PGS', '', '', 0, 'PGS English')
}
}
process.exit(0)
@@ -9,6 +9,10 @@
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
// eigenen Roh-Ordner, Vorgang und Zielordner.
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
// einzelnen Spuren meldet.
import { useState } from 'react'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
@@ -19,9 +23,11 @@ import type {
MetaVorschlag,
SpeicherStand,
SprachEintrag,
SpurAngabe,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
@@ -64,6 +70,53 @@ function SprachChips({
)
}
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
function SpurZeilen({
name,
art,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
art: 'audio' | 'untertitel'
vorhanden: readonly SpurAngabe[]
gewaehlt: readonly number[]
aufWechsel: (nr: number) => void
}) {
const zeilen = vorhanden.filter((s) => angeboten(s, art))
if (zeilen.length === 0) return null
return (
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{zeilen.map((s) => {
const an = gewaehlt.includes(s.nr)
return (
<label
key={s.nr}
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
>
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
{spurLabel(s)}
</label>
)
})}
</div>
)
}
/** 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<Record<number, string | null>>({})
// 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<Record<number, { audio: number[]; untertitel: number[] }>>({})
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
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 (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
@@ -255,10 +343,56 @@ export function TitelWahlDialog({
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
{/* 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) && (
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
<SpurZeilen
name="UNTERTITEL"
art="untertitel"
vorhanden={t.spuren.untertitel}
gewaehlt={spurNrn.untertitel}
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
/>
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 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 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlSpurWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahlSpur }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : Number(w)
setVorwahlSpur(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{spurWahl.untertitel.map((u) => (
<option key={u.nr} value={String(u.nr)}>
{spurLabel(u)}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* 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) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(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,
@@ -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 20120 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'
+154
View File
@@ -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
}
+50 -6
View File
@@ -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.05.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),
+74 -1
View File
@@ -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<RohDatei, 'audioSpuren' | 'untertitelSpuren' | 'untertitelVorwahlSpur'>,
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<RohDatei, 'audioSpuren' | 'untertitelSpuren'>): { 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))
@@ -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 } : {}),
}
})
+3 -1
View File
@@ -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,
@@ -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 <string> Select audio track(s), separated by
// commas", „-s, --subtitle <string> 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')
+18 -3
View File
@@ -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<string, unknown>
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<string, unknown>
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,
}
})
}
+33 -3
View File
@@ -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<number, TitelStreams> {
}
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<number, TitelStreams> {
// 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<number, TitelStreams>
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. */
@@ -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.05.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).',
}
}
+89 -1
View File
@@ -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')
})
})
+1 -1
View File
@@ -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 {
+69
View File
@@ -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'])
})
})
+261
View File
@@ -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')
})
})