diff --git a/rippy-windows/bau/klick-smoke.mjs b/rippy-windows/bau/klick-smoke.mjs index 1f4db0b..2b748a7 100644 --- a/rippy-windows/bau/klick-smoke.mjs +++ b/rippy-windows/bau/klick-smoke.mjs @@ -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 geht’s', 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) diff --git a/rippy-windows/bau/klick/fake-handbrake.cjs b/rippy-windows/bau/klick/fake-handbrake.cjs index c7c58a0..b343ad7 100644 --- a/rippy-windows/bau/klick/fake-handbrake.cjs +++ b/rippy-windows/bau/klick/fake-handbrake.cjs @@ -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(']') diff --git a/rippy-windows/bau/klick/fake-makemkvcon.cjs b/rippy-windows/bau/klick/fake-makemkvcon.cjs index 6301f94..e93d2b6 100644 --- a/rippy-windows/bau/klick/fake-makemkvcon.cjs +++ b/rippy-windows/bau/klick/fake-makemkvcon.cjs @@ -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) diff --git a/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx index 76624fd..a5a97b7 100644 --- a/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx +++ b/rippy-windows/src/fenster/dialoge/TitelWahlDialog.tsx @@ -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 ( +
+ {name} + {zeilen.map((s) => { + const an = gewaehlt.includes(s.nr) + return ( + + ) + })} +
+ ) +} + +/** 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') + }) +})