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:
co-authored by
Claude Opus 5
parent
fc2d16ca38
commit
e7e33e9021
@@ -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)
|
||||
|
||||
@@ -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(']')
|
||||
|
||||
@@ -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 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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.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).',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user