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

WAS: Der Auswahl-Dialog zeigt je Titel jede Ton- und Untertitelspur, die
der Info-Lauf gemessen hat („German · DTS-HD MA · Surround 5.1", „German ·
DD · Stereo (Kommentar)", „German · PGS (nur erzwungene)"), vorbelegt wie
bisher (je Wunschsprache die erste Tonspur, alle Untertitel der Wunsch-
sprachen) — angehakt wird einzeln. Die Untertitel-Vorwahl je Extra ist
jetzt eine Spur. Der Parser liest dazu SINFO-Attribute 2 (Anordnung),
22 (Flags) und markiert abgeleitete Spuren (Bit 0x800, an der Kizuna-
Disc gemessen): DTS-Kerne werden nicht angeboten, „nur erzwungene"-
Untertitel schon. Die Ablage scannt die Roh-Datei und findet die
gewählten MakeMKV-Spuren gestuft wieder (Sprache + Codec-Familie +
Kanäle → … → Sprache), HandBrake bekommt `--audio 1,3 --subtitle 2,1
--subtitle-default=1`; Fehlendes wird gemeldet, scheitert der Scan,
gelten die Sprachen der gewählten Spuren als Liste. MakeMKVs Auswahl-
regel lässt Stereo-Doppel jetzt stehen (nur -sel:havecore); Rippys alte
Regel wird in der Registry ersetzt, eine fremde bleibt.
Dazu F31 (Durchsicht): `--aencoder copy` ohne `--audio-copy-mask`
verengt HandBrakeCLI 1.11.2 auf „copy:aac" — AC3/DTS/TrueHD wurden seit
5.0 in AAC umgerechnet. Beide Wege setzen die Maske jetzt ausdrücklich
(gemessen 12.09.2026: AC3 bleibt AC3, Passthru-Zeile nennt alle Codecs).
WARUM: Commander-Entscheid 12.09.2026 („Jede Spur einzeln anhaken") —
bisher ließ sich je Sprache nur „alles oder nichts" wählen, und der
Kommentar-Ton fehlte in der Roh-Datei (havemulti). Gemessen am
mitgelieferten HandBrakeCLI mit ffmpeg-gebauter Quelle
(test/messung.spuren.test.ts, RIPPY_MESSUNG=1): --audio 1,3 → jpn und
deu-Kommentar, --subtitle 2,1 --subtitle-default=1 → eng (default) vor
deu, --subtitle none → keine Spur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-12 22:11:27 +02:00
co-authored by Claude Opus 5
parent fc2d16ca38
commit e7e33e9021
18 changed files with 1098 additions and 60 deletions
@@ -9,6 +9,10 @@
// vorgewählt ist (weiche Spur, nie eingebrannt, immer optional) — und je
// Titel „anderer Film …": Double Feature, Trilogie-Disc, Bonus-Film bekommen
// eigenen Roh-Ordner, Vorgang und Zielordner.
// 5.7.0 (Commander, 12.09.2026): JEDE Spur einzeln anhaken — „Deutsch ·
// DTS-HD MA · 5.1" und „Deutsch · DD · 2.0 (Kommentar)" getrennt, Untertitel
// genauso. Die Sprach-Chips bleiben nur als Rückfall, wenn ein Titel keine
// einzelnen Spuren meldet.
import { useState } from 'react'
import { sichererName, zielVorschau } from '../../gemeinsam/ablagepfad'
import { platzUrteil } from '../../gemeinsam/platz'
@@ -19,9 +23,11 @@ import type {
MetaVorschlag,
SpeicherStand,
SprachEintrag,
SpurAngabe,
TitelRolle,
TitelStand,
} from '../../gemeinsam/nachrichten'
import { angeboten, sprachenDerSpuren, spurenVorbelegt, spurLabel } from '../../gemeinsam/spuren'
import { DialogRahmen, IconDisc, KNOPF_GOLD, datumKurz, dauerText, gb, gbKurz, posterUrlKlein, sprachenVorbelegt } from '../bausteine'
import { metadatenSuchen, ripStarten, titelAbbrechen } from '../kernverbindung'
@@ -64,6 +70,53 @@ function SprachChips({
)
}
/** Spur-Zeilen eines Titels (5.7.0): jede Ton- bzw. Untertitelspur einzeln
* anhaken, mit Codec und Kanälen, wie MakeMKV sie gemessen hat. Außerhalb
* des Auswahl-Labels (ein Haken IM Label würde den Titel mit umschalten). */
function SpurZeilen({
name,
art,
vorhanden,
gewaehlt,
aufWechsel,
}: {
name: string
art: 'audio' | 'untertitel'
vorhanden: readonly SpurAngabe[]
gewaehlt: readonly number[]
aufWechsel: (nr: number) => void
}) {
const zeilen = vorhanden.filter((s) => angeboten(s, art))
if (zeilen.length === 0) return null
return (
<div className="flex min-w-0 flex-col gap-0.5" data-spuren={art}>
<span className="font-mono text-[10px] tracking-wider text-nebel">{name}</span>
{zeilen.map((s) => {
const an = gewaehlt.includes(s.nr)
return (
<label
key={s.nr}
className={`inline-flex cursor-pointer items-center gap-1.5 font-mono text-[10.5px] ${an ? 'text-gold-hell' : 'text-nebel hover:text-dunst'}`}
title={s.beschreibung.length > 0 ? s.beschreibung : spurLabel(s)}
>
<input type="checkbox" checked={an} onChange={() => aufWechsel(s.nr)} className="accent-[oklch(0.83_0.11_75)]" />
{spurLabel(s)}
</label>
)
})}
</div>
)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts spurwahl, 5.7.0): Spielt die erste gewählte
* Tonspur nicht in der Wunschsprache, die erste gewählte Untertitelspur
* der Wunschsprache — sonst nichts. */
function regelVorwahlSpur(audio: readonly SpurAngabe[], untertitel: readonly SpurAngabe[], wunsch: string): SpurAngabe | undefined {
if (wunsch.length === 0 || audio.length === 0 || audio[0].lang.toLowerCase() === wunsch) return undefined
return untertitel.find((s) => s.lang.toLowerCase() === wunsch)
}
/** Was Rippys Regel für diesen Titel vorwählen würde (dieselbe Regel wie
* kern/ablauf/auftrag.ts sprachwahl) — '' wenn nichts. */
function regelVorwahl(audio: readonly string[], untertitel: readonly string[], wunsch: string): string {
@@ -105,6 +158,12 @@ export function TitelWahlDialog({
// 5.6.0: Untertitel-Vorwahl je Extra — fehlt: Rippys Regel; null: keine;
// sonst die Sprache. Immer optional, nie eingebrannt.
const [vorwahl, setVorwahl] = useState<Record<number, string | null>>({})
// 5.7.0: Spuren je Titel EINZELN (MakeMKV-Stream-Nummern). Was hier nicht
// drinsteht, ist vorbelegt: je Wunschsprache die erste Tonspur, alle
// Untertitel der Wunschsprachen (gemeinsam/spuren.ts). Dazu die
// Untertitel-Vorwahl je Extra als SPUR — fehlt: Regel; null: keine.
const [spuren, setSpuren] = useState<Record<number, { audio: number[]; untertitel: number[] }>>({})
const [vorwahlSpur, setVorwahlSpur] = useState<Record<number, number | null>>({})
// 5.6.0: Titel, die zu einem anderen Film gehören.
const [filme, setFilme] = useState<Record<number, MetaVorschlag>>({})
const [filmSuche, setFilmSuche] = useState<{ nr: number; text: string } | null>(null)
@@ -130,6 +189,31 @@ export function TitelWahlDialog({
const liste = jetzt[art].includes(lang) ? jetzt[art].filter((l) => l !== lang) : [...jetzt[art], lang]
setSprachen({ ...sprachen, [t.nr]: { ...jetzt, [art]: liste } })
}
const wunschListe = (schluessel: string): string[] =>
(einstellungen[schluessel] ?? '')
.split(',')
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0)
/** Meldet dieser Titel einzelne Spuren? Sonst gelten die Sprach-Chips. */
const spurenDa = (t: DiscTitelVorschlag): boolean => t.spuren !== undefined && (t.spuren.audio.length > 0 || t.spuren.untertitel.length > 0)
const spurenVon = (t: DiscTitelVorschlag): { audio: number[]; untertitel: number[] } =>
spuren[t.nr] ?? {
audio: spurenVorbelegt(t.spuren?.audio ?? [], wunschListe('audioSprachen'), 'audio'),
untertitel: spurenVorbelegt(t.spuren?.untertitel ?? [], wunschListe('untertitelSprachen'), 'untertitel'),
}
const spurWechsel = (t: DiscTitelVorschlag, art: 'audio' | 'untertitel', nr: number): void => {
const jetzt = spurenVon(t)
const liste = jetzt[art].includes(nr) ? jetzt[art].filter((n) => n !== nr) : [...jetzt[art], nr].sort((a, b) => a - b)
setSpuren({ ...spuren, [t.nr]: { ...jetzt, [art]: liste } })
}
/** Die gewählten Spuren als Angaben, in Disc-Reihenfolge. */
const gewaehlteSpuren = (t: DiscTitelVorschlag): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } => {
const nrn = spurenVon(t)
return {
audio: (t.spuren?.audio ?? []).filter((s) => nrn.audio.includes(s.nr)),
untertitel: (t.spuren?.untertitel ?? []).filter((s) => nrn.untertitel.includes(s.nr)),
}
}
const umschalten = (nr: number): void => {
const neu = new Set(aktiv)
if (neu.has(nr)) neu.delete(nr)
@@ -231,6 +315,10 @@ export function TitelWahlDialog({
const film = filme[t.nr]
const regelWahl = regelVorwahl(wahl.audio.length > 0 ? wahl.audio : t.sprachen.audio.map((s) => s.lang), wahl.untertitel, wunschSprache)
const vorwahlWert = t.nr in vorwahl ? (vorwahl[t.nr] === null ? 'keine' : vorwahl[t.nr]!) : 'regel'
const spurNrn = spurenVon(t)
const spurWahl = gewaehlteSpuren(t)
const regelSpur = regelVorwahlSpur(spurWahl.audio, spurWahl.untertitel, wunschSprache)
const vorwahlSpurWert = t.nr in vorwahlSpur ? (vorwahlSpur[t.nr] === null ? 'keine' : String(vorwahlSpur[t.nr])) : 'regel'
return (
<div key={t.nr} className={`rounded-lg border ${an ? 'border-gold/40 bg-gold/8' : 'border-tinte/8 bg-samt/40'}`}>
<label className="flex cursor-pointer items-center gap-3 p-2">
@@ -255,10 +343,56 @@ export function TitelWahlDialog({
<span className="min-w-0 flex-1 truncate text-[11px] text-nebel">{t.grund}</span>
)}
</label>
{/* Spuren je Titel (5.7.0): jede Ton- und Untertitelspur, die
der Info-Lauf an DIESEM Titel gemessen hat — einzeln
anhaken, was in die Datei soll. */}
{an && spurenDa(t) && (
<div className="flex flex-wrap items-start gap-x-6 gap-y-1.5 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SpurZeilen name="TON" art="audio" vorhanden={t.spuren.audio} gewaehlt={spurNrn.audio} aufWechsel={(nr) => spurWechsel(t, 'audio', nr)} />
<SpurZeilen
name="UNTERTITEL"
art="untertitel"
vorhanden={t.spuren.untertitel}
gewaehlt={spurNrn.untertitel}
aufWechsel={(nr) => spurWechsel(t, 'untertitel', nr)}
/>
{spurNrn.audio.length === 0 && t.spuren.audio.some((s) => angeboten(s, 'audio')) && (
<span className="font-mono text-[10.5px] text-warn">keine Tonspur gewählt HandBrake nimmt die erste Spur</span>
)}
{/* 5.6.0/5.7.0: Vorwahl NUR bei Extras, IMMER optional — eine
weiche Spur, die der Spieler von selbst einblendet;
eingebrannt wird nie. Hier als SPUR. */}
{rolle === 'extra' && spurWahl.untertitel.length > 0 && (
<label className="inline-flex items-center gap-1.5" title="Weiche Untertitel-Spur, die beim Abspielen schon an ist — nichts wird eingebrannt. Leer lassen ist immer erlaubt.">
<span className="font-mono text-[10px] tracking-wider text-nebel">BEIM ABSPIELEN AN</span>
<select
value={vorwahlSpurWert}
onChange={(e) => {
const w = e.target.value
const neu = { ...vorwahlSpur }
if (w === 'regel') delete neu[t.nr]
else neu[t.nr] = w === 'keine' ? null : Number(w)
setVorwahlSpur(neu)
}}
className="rounded-md border border-tinte/15 bg-samt/70 px-1.5 py-0.5 font-mono text-[10.5px] text-dunst"
>
<option value="regel">{regelSpur !== undefined ? `Rippys Regel (${spurLabel(regelSpur)})` : 'Rippys Regel (keine)'}</option>
<option value="keine">keine</option>
{spurWahl.untertitel.map((u) => (
<option key={u.nr} value={String(u.nr)}>
{spurLabel(u)}
</option>
))}
</select>
</label>
)}
</div>
)}
{/* Sprachen je Titel (5.4.0): Was der Info-Lauf an DIESEM
Titel gemessen hat — anhaken, was in die Datei soll.
Der Commander-Fall: japanischer Ton, deutsche Untertitel. */}
{an && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
Der Commander-Fall: japanischer Ton, deutsche Untertitel.
Seit 5.7.0 nur noch der Rückfall ohne einzelne Spuren. */}
{an && !spurenDa(t) && (t.sprachen.audio.length > 0 || t.sprachen.untertitel.length > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t border-tinte/8 px-2 py-1.5 pl-9">
<SprachChips name="TON" vorhanden={t.sprachen.audio} gewaehlt={wahl.audio} aufWechsel={(lang) => sprachWechsel(t, 'audio', lang)} />
<SprachChips name="UNTERTITEL" vorhanden={t.sprachen.untertitel} gewaehlt={wahl.untertitel} aufWechsel={(lang) => sprachWechsel(t, 'untertitel', lang)} />
@@ -484,6 +618,24 @@ export function TitelWahlDialog({
gewaehlteTitel.map((t) => {
const wahl = sprachenVon(t)
const rolle = rolleVon(t)
// 5.7.0: Spuren einzeln — die Sprachlisten reisen abgeleitet
// mit (Rückfall des Kerns, wenn der Scan der Roh-Datei
// scheitert; und die alten Hinweise bleiben stimmig).
if (spurenDa(t)) {
const spurWahl = gewaehlteSpuren(t)
return {
nr: t.nr,
rolle,
dauerS: t.dauerS,
groesseBytes: t.groesseBytes,
audioSprachen: sprachenDerSpuren(spurWahl.audio),
untertitelSprachen: sprachenDerSpuren(spurWahl.untertitel),
audioSpuren: spurWahl.audio,
untertitelSpuren: spurWahl.untertitel,
...(rolle === 'extra' && t.nr in vorwahlSpur ? { untertitelVorwahlSpur: vorwahlSpur[t.nr] } : {}),
...(filme[t.nr] !== undefined ? { film: filme[t.nr] } : {}),
}
}
return {
nr: t.nr,
rolle,
@@ -98,6 +98,30 @@ export interface SprachEintrag {
spuren: number
}
/** EINE Spur eines Titels, wie der Info-Lauf sie gemessen hat (5.7.0,
* Commander-Entscheid 12.09.2026: „jede Spur einzeln anhaken"). Die
* Attribute stammen aus SINFO (parser.ts): 3 Sprachcode, 4 Sprachname,
* 6 Codec, 14 Kanäle, 2 Anordnung, 30 Beschreibung, 22 Flags. */
export interface SpurAngabe {
/** MakeMKVs Stream-Nummer innerhalb des Titels. */
nr: number
lang: string
sprache: string
/** „DTS-HD MA", „TrueHD", „DD", „PGS", „VobSub" … */
codec: string
/** Kanalzahl als Text („6", „2"); leer bei Untertiteln. */
kanaele: string
/** „Surround 5.1", „Stereo" — Attribut 2. */
layout: string
/** Attribut 30, z. B. „DTS-HD MA Surround 5.1 German" oder „PGS German (nur erzwungene)". */
beschreibung: string
/** Bit 0x800 in Attribut 22 (an der Kizuna-Disc gemessen): eine von
* MakeMKV ABGELEITETE Spur — der DTS-Kern einer HD-Spur (nicht in der
* Roh-Datei) oder die „nur erzwungene"-Fassung eines Untertitels (in der
* Roh-Datei, wählbar). */
abgeleitet: boolean
}
/** Ein Titel im Rip-Auftrag: welcher, als was — und wie lang.
* Die Dauer reist mit, weil die Episoden-Zuordnung sie braucht: Rippy
* gleicht die Laufzeiten gegen die Staffel bei TMDb ab und benennt NUR
@@ -117,6 +141,12 @@ export interface RipAuftragTitel {
* vorgewählt ist (weiche Spur, nie eingebrannt). null = keine Vorwahl;
* fehlend = Rippys Regel (fremder Ton → Wunschsprache). */
untertitelVorwahl?: string | null
/** 5.7.0: die GEWÄHLTEN Spuren mit allem, was die Ablage braucht, um sie
* in der Roh-Datei wiederzufinden. Fehlend: die Sprachlisten gelten. */
audioSpuren?: SpurAngabe[]
untertitelSpuren?: SpurAngabe[]
/** 5.7.0: die vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
untertitelVorwahlSpur?: number | null
/** 5.6.0: Dieser Titel gehört zu einem ANDEREN Film als die Disc-Zuordnung
* (Double Feature) — er bekommt eigenen Roh-Ordner, Vorgang und Zielordner. */
film?: MetaVorschlag
@@ -147,6 +177,8 @@ export interface DiscTitelVorschlag {
grund: string
/** Die gemessenen Tonspuren und Untertitel DIESES Titels (5.4.0). */
sprachen: { audio: SprachEintrag[]; untertitel: SprachEintrag[] }
/** 5.7.0: jede Spur einzeln — der Dialog zeigt sie zum Anhaken. */
spuren: { audio: SpurAngabe[]; untertitel: SpurAngabe[] }
}
/** Was der Info-Lauf über die Titel der Disc ergab. Er dauert 20120 s,
@@ -685,9 +717,20 @@ export function istRipAuftragTitel(wert: unknown): wert is RipAuftragTitel {
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istText)) return false
}
for (const feld of ['audioSpuren', 'untertitelSpuren']) {
const liste = wert[feld]
if (liste === undefined) continue
if (!Array.isArray(liste) || !liste.every(istSpurAngabe)) return false
}
if (wert.untertitelVorwahlSpur !== undefined && wert.untertitelVorwahlSpur !== null && typeof wert.untertitelVorwahlSpur !== 'number') return false
return true
}
export function istSpurAngabe(wert: unknown): wert is SpurAngabe {
if (!istObjekt(wert)) return false
return typeof wert.nr === 'number' && typeof wert.lang === 'string' && typeof wert.codec === 'string' && typeof wert.abgeleitet === 'boolean'
}
export function istFensterNachricht(wert: unknown): wert is FensterNachricht {
if (!istObjekt(wert)) return false
if (wert.art === 'ping') return typeof wert.zeit === 'number'
+154
View File
@@ -0,0 +1,154 @@
// Spuren einzeln (5.7.0, Commander-Entscheid 12.09.2026: „Jede Spur einzeln
// anhaken — Deutsch · DTS-HD MA 5.1, Deutsch · Kommentar 2.0 getrennt").
// PUR und gemeinsam: Der Dialog zeigt die Zeilen und belegt sie vor, der
// Kern findet die gewählten Spuren nach dem Rip in der Roh-Datei wieder.
//
// Zwei Welten, ein Vergleich:
// * MakeMKV (SINFO, kern/rip/parser.ts): Sprachcode, Codec-Wort („DTS-HD
// MA"), Kanalzahl („6"), Disc-Reihenfolge.
// * HandBrake (--scan --json, kern/komprimieren/scan.ts; am 12.09.2026 am
// mitgelieferten HandBrake 1.11.2 gemessen): LanguageCode, CodecName
// („dtshd", „dca", „truehd", „ac3", „eac3", „aac", „flac", „pcm_…"),
// ChannelCount, TrackNumber — in Datei-Reihenfolge.
// MakeMKV schreibt die Spuren in Disc-Reihenfolge; Rippys Auswahlregel
// (werkzeuge/spurauswahl.ts) lässt nur die abgeleiteten DTS-Kerne weg.
// Deshalb reicht: je gewählter Spur die erste noch freie HandBrake-Spur
// gleicher Sprache, gleicher Codec-Familie und gleicher Kanalzahl — sonst
// gestuft weniger streng; findet sich nichts, sagt die Ablage es.
import type { SpurAngabe } from './nachrichten'
/** Codec-Familie aus MakeMKVs Codec-Wort (Attribut 6) oder HandBrakes
* CodecName — beide auf denselben Schlüssel. '' wenn unbekannt. */
export function codecFamilie(text: string): string {
const t = (text ?? '').toLowerCase()
if (t.length === 0) return ''
if (t.includes('dts-hd') || t.includes('dtshd') || t.includes('dts:x') || t.includes('dts hd')) return 'dtshd'
if (t === 'dca' || t.startsWith('dts')) return 'dts'
if (t.includes('truehd') || t.includes('atmos') || t.includes('mlp')) return 'truehd'
if (t.includes('eac3') || t.includes('e-ac3') || t.includes('dd+') || t.includes('digital plus')) return 'eac3'
if (t === 'ac3' || t.startsWith('dd') || t.includes('dolby digital') || t === 'ac-3') return 'ac3'
if (t.includes('aac')) return 'aac'
if (t.includes('flac')) return 'flac'
if (t.includes('pcm') || t.includes('lpcm')) return 'pcm'
if (t.includes('mp3') || t.includes('mp2') || t.includes('mpeg')) return 'mp3'
if (t.includes('opus')) return 'opus'
if (t.includes('vorbis')) return 'vorbis'
if (t.includes('pgs') || t.includes('hdmv')) return 'pgs'
if (t.includes('vobsub') || t.startsWith('dvd')) return 'vobsub'
if (t.includes('srt') || t.includes('text') || t.includes('utf-8') || t.includes('ssa') || t.includes('ass')) return 'text'
return t
}
/** Die Zeile im Dialog: „German · DTS-HD MA · Surround 5.1", „German · DD ·
* Stereo (Kommentar)". MakeMKV nennt die Sprache englisch (Attribut 4,
* „German"), auch bei deutscher Oberfläche — gemessen an der Kizuna-Disc. */
export function spurLabel(s: SpurAngabe): string {
const teile: string[] = [s.sprache.length > 0 ? s.sprache : s.lang.length > 0 ? s.lang : 'ohne Sprache']
if (s.codec.length > 0) teile.push(s.codec)
if (s.kanaele.length > 0) {
const n = Number(s.kanaele)
const kurz = n === 6 ? '5.1' : n === 8 ? '7.1' : n === 2 ? '2.0' : n === 1 ? '1.0' : s.kanaele
// MakeMKVs Anordnung („Surround 5.1", „Stereo") sagt genug — die Zahl
// nur, wenn sie fehlt.
teile.push(s.layout.length > 0 ? s.layout : kurz)
}
let label = teile.join(' · ')
// Was MakeMKV in der Beschreibung dazusagt — „(nur erzwungene)",
// Kommentar-Hinweise — hängt in Klammern an.
const zusatz = /\(([^()]*)\)\s*$/.exec(s.beschreibung)
if (zusatz !== null && !label.toLowerCase().includes(zusatz[1].toLowerCase())) label += ` (${zusatz[1]})`
return label
}
/** Eine Zeile, die der Dialog ANBIETET: Abgeleitete TON-Spuren (DTS-Kerne)
* sind nicht in der Roh-Datei — sie werden nicht gezeigt. Abgeleitete
* UNTERTITEL („nur erzwungene") sind drin und wählbar. */
export function angeboten(s: SpurAngabe, art: 'audio' | 'untertitel'): boolean {
return art === 'untertitel' || !s.abgeleitet
}
/** Rippys Vorbelegung (pur):
* * Ton: je Wunschsprache die ERSTE angebotene Spur — das ist die Regel
* von 5.4.0 („eine Spur je Sprache", meist die beste); trifft keine
* Wunschsprache, die erste angebotene Spur überhaupt (japanischer Ton
* statt gar keiner).
* * Untertitel: ALLE Spuren der Wunschsprachen (auch „nur erzwungene") —
* wie bisher mit --all-subtitles; ohne Treffer nichts.
* Liefert MakeMKV-Stream-Nummern. */
export function spurenVorbelegt(spuren: readonly SpurAngabe[], wunsch: readonly string[], art: 'audio' | 'untertitel'): number[] {
const kandidaten = spuren.filter((s) => angeboten(s, art))
const wunschKlein = wunsch.map((w) => w.trim().toLowerCase()).filter((w) => w.length > 0)
if (art === 'audio') {
const gewaehlt: number[] = []
for (const w of wunschKlein) {
const erste = kandidaten.find((s) => s.lang.toLowerCase() === w)
if (erste !== undefined && !gewaehlt.includes(erste.nr)) gewaehlt.push(erste.nr)
}
if (gewaehlt.length === 0 && kandidaten.length > 0) gewaehlt.push(kandidaten[0].nr)
return gewaehlt
}
return kandidaten.filter((s) => wunschKlein.includes(s.lang.toLowerCase())).map((s) => s.nr)
}
/** Eine Spur, wie HandBrakes Scan sie in der Roh-Datei sieht (scan.ts). */
export interface HbSpurAngabe {
nr: number
lang: string
/** CodecName („dtshd") bzw. bei Untertiteln Source („PGS"). */
codec: string
kanaele: number
beschreibung: string
}
export interface SpurZuordnung {
/** HandBrake-Spurnummern in der Reihenfolge der gewählten Spuren. */
nummern: number[]
/** Gewählte Spuren, die in der Roh-Datei nicht zu finden waren. */
fehlend: SpurAngabe[]
}
/** Gewählte MakeMKV-Spuren → HandBrake-Spurnummern (pur). Gestuft: Sprache
* + Familie + Kanäle → Sprache + Familie → Sprache + Kanäle → Sprache →
* (nur wenn die Datei GAR keine Sprachen kennt) Reihenfolge. Jede
* HandBrake-Spur nur einmal. */
export function spurenZuordnen(gewaehlt: readonly SpurAngabe[], vorhanden: readonly HbSpurAngabe[]): SpurZuordnung {
const frei = new Set(vorhanden.map((v) => v.nr))
const nummern: number[] = []
const fehlend: SpurAngabe[] = []
const ohneSprachen = vorhanden.every((v) => v.lang.length === 0 || v.lang === 'und')
for (const s of gewaehlt) {
const lang = s.lang.toLowerCase()
const familie = codecFamilie(s.codec)
const kanaele = Number(s.kanaele)
const stufen: Array<(v: HbSpurAngabe) => boolean> = [
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie && v.kanaele === kanaele,
(v) => v.lang.toLowerCase() === lang && codecFamilie(v.codec) === familie,
(v) => v.lang.toLowerCase() === lang && v.kanaele === kanaele,
(v) => v.lang.toLowerCase() === lang,
]
if (ohneSprachen) stufen.push((v) => codecFamilie(v.codec) === familie && v.kanaele === kanaele, () => true)
let treffer: HbSpurAngabe | undefined
for (const passt of stufen) {
treffer = vorhanden.find((v) => frei.has(v.nr) && passt(v))
if (treffer !== undefined) break
}
if (treffer === undefined) {
fehlend.push(s)
continue
}
frei.delete(treffer.nr)
nummern.push(treffer.nr)
}
return { nummern, fehlend }
}
/** Sprachen der gewählten Spuren, ohne Doppel, in Wahl-Reihenfolge —
* der Rückfall, wenn die Spuren in der Roh-Datei nicht zu finden sind. */
export function sprachenDerSpuren(spuren: readonly SpurAngabe[]): string[] {
const liste: string[] = []
for (const s of spuren) {
const l = s.lang.toLowerCase()
if (l.length > 0 && !liste.includes(l)) liste.push(l)
}
return liste
}
+50 -6
View File
@@ -46,8 +46,12 @@ import {
folgenNummern,
folgenPlausibel,
gewichteterFortschritt,
sprachlistenAusSpuren,
sprachwahl,
spurwahl,
type KompressionsAuftrag,
type RohDatei,
type SpurWahl,
} from './auftrag'
export interface AblageUmgebung {
@@ -103,6 +107,24 @@ export function dateiVerschieben(von: string, nach: string): void {
}
}
/** Die Sprachlisten aus den gewählten Spuren, als Felder für sprachwahl. */
function sprachlistenAusSpurenFuerWahl(datei: RohDatei): { audioSprachen: string[]; untertitelSprachen: string[] } {
const listen = sprachlistenAusSpuren(datei)
return { audioSprachen: listen.audio, untertitelSprachen: listen.untertitel }
}
/** Der Zusatz in der Fortschrittszeile: „ · Ton 1,3 · Untertitel 2 (vorgewählt)". */
function spurText(spurNr: SpurWahl | null, wahl: { audio: string[]; untertitel: string[]; untertitelStandard: boolean }): string {
if (spurNr !== null) {
return `${spurNr.audioNr.length > 0 ? ` · Ton Spur ${spurNr.audioNr.join(',')}` : ' · Ton: erste Spur'}${
spurNr.untertitelNr.length > 0 ? ` · Untertitel Spur ${spurNr.untertitelNr.join(',')}${spurNr.untertitelStandard > 0 ? ' (vorgewählt)' : ''}` : ''
}`
}
return `${wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''}${
wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''
}`
}
function groesseVon(pfad: string): number {
try {
return statSync(pfad).size
@@ -239,13 +261,34 @@ export async function komprimierenUndAblegen(
continue
}
const wahl = sprachwahl(datei, global)
let wahl = sprachwahl(datei, global)
// 5.7.0: Spuren EINZELN gewählt — die Roh-Datei wird gescannt und jede
// gewählte MakeMKV-Spur als HandBrake-Spurnummer wiedergefunden
// (auftrag.ts spurwahl). Scheitert der Scan, gelten die Sprachen der
// gewählten Spuren als Liste (wie 5.4.05.6.x).
const einzeln = datei.audioSpuren !== undefined || datei.untertitelSpuren !== undefined
let spurNr: SpurWahl | null = null
// Die Roh-Datei fragen, ob die gewünschten Sprachen wirklich drin sind
// (scan.ts). Nur wenn der Nutzer etwas JE TITEL gewählt hat — die
// globale Vorgabe war schon immer „was da ist".
if (datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
if (einzeln || datei.audioSprachen.length > 0 || datei.untertitelSprachen.length > 0) {
const spuren = await scannen(handbrake, datei.pfad)
if (spuren !== null) {
if (spuren !== null && einzeln) {
spurNr = spurwahl(datei, spuren, global.audio[0] ?? '')
for (const f of spurNr?.fehlend ?? []) {
hinweise.push(`${basename(datei.pfad)}: ${f} ist NICHT in der Roh-Datei — MakeMKV hat sie nicht mitgenommen (Auswahlregel prüfen).`)
}
if (spurNr !== null && spurNr.audioNr.length === 0 && (datei.audioSpuren?.length ?? 0) > 0) {
// Keine gewählte Tonspur gefunden → lieber Sprachlisten als eine
// stumme Datei.
spurNr = null
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
hinweise.push(`${basename(datei.pfad)}: keine der gewählten Tonspuren gefunden — Sprachen ${wahl.audio.join(',') || 'nach Einstellung'} statt Spuren.`)
}
} else if (spuren === null && einzeln) {
wahl = sprachwahl({ ...datei, ...sprachlistenAusSpurenFuerWahl(datei) }, global)
hinweise.push(`${basename(datei.pfad)}: Spuren der Roh-Datei ließen sich nicht messen — Sprachen ${[...wahl.audio, ...wahl.untertitel].join(',') || 'nach Einstellung'} statt einzelner Spuren.`)
} else if (spuren !== null) {
const fehlt = fehlendeSprachen(spuren, wahl)
if (fehlt.audio.length > 0) {
hinweise.push(
@@ -265,9 +308,7 @@ export async function komprimierenUndAblegen(
phase: 'komprimiert',
prozent: gewichteterFortschritt(gewichte, i, 0),
restS: -1,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${
wahl.audio.length > 0 ? ` · Ton ${wahl.audio.join(',')}` : ''
}${wahl.untertitel.length > 0 ? ` · Untertitel ${wahl.untertitel.join(',')}${wahl.untertitelStandard ? ' (vorgewählt)' : ''}` : ''}`,
text: `Komprimiere ${i + 1}/${dateien.length}: ${basename(datei.pfad)} („${preset}")${spurText(spurNr, wahl)}`,
})
const encode = await komprimieren({
programm: handbrake,
@@ -277,6 +318,9 @@ export async function komprimierenUndAblegen(
audioSprachen: wahl.audio,
untertitelSprachen: wahl.untertitel,
untertitelStandard: wahl.untertitelStandard,
...(spurNr !== null
? { audioNr: spurNr.audioNr, untertitelNr: spurNr.untertitelNr, untertitelStandardNr: spurNr.untertitelStandard }
: {}),
// Beim Wiederholen darf die neue Fassung die alte gleichen Namens
// ersetzen (ohne Arbeitsordner schreibt HandBrake direkt ins Ziel).
ueberschreiben: zwischen.length === 0 && ersetzt.has(zielDatei),
+74 -1
View File
@@ -6,7 +6,9 @@
// nur noch diese Beschreibung. Deshalb wird sie als JSON in der Datenbank
// gemerkt — so kann „Wiederholen ab Kompression" sie Tage später wieder
// aufnehmen, ohne die Disc noch einmal zu lesen (40 Minuten je Blu-ray).
import type { MetaVorschlag, TitelRolle } from '../../gemeinsam/nachrichten'
import type { MetaVorschlag, SpurAngabe, TitelRolle } from '../../gemeinsam/nachrichten'
import { istSpurAngabe } from '../../gemeinsam/nachrichten'
import { spurenZuordnen, sprachenDerSpuren, type HbSpurAngabe } from '../../gemeinsam/spuren'
import type { Zuordnung } from '../metadaten/zuordnung'
export interface RohDatei {
@@ -21,6 +23,11 @@ export interface RohDatei {
untertitelSprachen: string[]
/** 5.6.0 (Extras): vorgewählte Untertitel-Sprache; null = keine; fehlend = Regel. */
untertitelVorwahl?: string | null
/** 5.7.0: die GEWÄHLTEN Spuren (einzeln); fehlend = Sprachlisten gelten. */
audioSpuren?: SpurAngabe[]
untertitelSpuren?: SpurAngabe[]
/** 5.7.0: vorgewählte Untertitel-SPUR (MakeMKV-Nummer); null = keine. */
untertitelVorwahlSpur?: number | null
/** 5.6.0: der Film, zu dem diese Datei gehört, wenn nicht die Disc-Zuordnung. */
film?: MetaVorschlag
}
@@ -86,6 +93,9 @@ export function auftragAusJson(text: string): KompressionsAuftrag | null {
audioSprachen: Array.isArray(e.audioSprachen) ? e.audioSprachen.map(String) : [],
untertitelSprachen: Array.isArray(e.untertitelSprachen) ? e.untertitelSprachen.map(String) : [],
...(e.untertitelVorwahl === null || typeof e.untertitelVorwahl === 'string' ? { untertitelVorwahl: e.untertitelVorwahl } : {}),
...(Array.isArray(e.audioSpuren) && e.audioSpuren.every(istSpurAngabe) ? { audioSpuren: e.audioSpuren } : {}),
...(Array.isArray(e.untertitelSpuren) && e.untertitelSpuren.every(istSpurAngabe) ? { untertitelSpuren: e.untertitelSpuren } : {}),
...(e.untertitelVorwahlSpur === null || typeof e.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: e.untertitelVorwahlSpur } : {}),
...(filmAus(e.film) !== null ? { film: filmAus(e.film)! } : {}),
})
}
@@ -162,6 +172,69 @@ export function sprachwahl(
return { audio, untertitel, untertitelStandard }
}
/** Was HandBrake bekommt, wenn Spuren EINZELN gewählt wurden (5.7.0). */
export interface SpurWahl {
/** HandBrake-Spurnummern (1-basiert, Scan-Reihenfolge). */
audioNr: number[]
untertitelNr: number[]
/** Index (1-basiert) der vorgewählten Untertitelspur in `untertitelNr`, 0 = keine. */
untertitelStandard: number
/** Klartext für die Hinweise: gewählte Spuren, die nicht in der Datei sind. */
fehlend: string[]
}
/**
* Spurwahl je Datei (pur, 5.7.0): Die gewählten MakeMKV-Spuren werden in
* HandBrakes Scan der Roh-Datei wiedergefunden (spurenZuordnen). Die Regel
* von 5.4.0 gilt weiter: Spielt die erste gewählte Tonspur nicht in der
* Wunschsprache, wird eine gewählte Untertitelspur der Wunschsprache
* vorgewählt — außer der Nutzer hat eine Vorwahl gesetzt (Spur) oder
* ausdrücklich keine (null). Liefert null, wenn keine Spuren gewählt sind
* (dann gilt die Sprachwahl). */
export function spurwahl(
datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren' | 'untertitelVorwahlSpur'>,
scan: { audio: readonly HbSpurAngabe[]; untertitel: readonly HbSpurAngabe[] },
wunschSprache: string,
): SpurWahl | null {
if (datei.audioSpuren === undefined && datei.untertitelSpuren === undefined) return null
const audio = spurenZuordnen(datei.audioSpuren ?? [], scan.audio)
const untertitel = spurenZuordnen(datei.untertitelSpuren ?? [], scan.untertitel)
const gefundeneUntertitel = (datei.untertitelSpuren ?? []).filter((s) => !untertitel.fehlend.includes(s))
// Wer wird vorgewählt?
let standardSpur: SpurAngabe | undefined
if (datei.untertitelVorwahlSpur === null) {
standardSpur = undefined
} else if (typeof datei.untertitelVorwahlSpur === 'number') {
standardSpur = gefundeneUntertitel.find((s) => s.nr === datei.untertitelVorwahlSpur)
} else {
const ersteTon = (datei.audioSpuren ?? []).find((s) => !audio.fehlend.includes(s))
const tonFremd = wunschSprache.length > 0 && ersteTon !== undefined && ersteTon.lang.toLowerCase() !== wunschSprache
if (tonFremd) standardSpur = gefundeneUntertitel.find((s) => s.lang.toLowerCase() === wunschSprache)
}
// Die vorgewählte Spur nach vorn — HandBrakes --subtitle-default zählt
// die AUSGABE-Reihenfolge, und die folgt der Liste.
let untertitelNr = untertitel.nummern
let untertitelStandard = 0
if (standardSpur !== undefined) {
const i = gefundeneUntertitel.indexOf(standardSpur)
if (i >= 0) {
untertitelNr = [untertitel.nummern[i], ...untertitel.nummern.filter((_, k) => k !== i)]
untertitelStandard = 1
}
}
const fehlend = [
...audio.fehlend.map((s) => `Tonspur ${s.sprache || s.lang}${s.codec.length > 0 ? ` ${s.codec}` : ''}`),
...untertitel.fehlend.map((s) => `Untertitel ${s.sprache || s.lang}${s.beschreibung.includes('erzwungen') ? ' (nur erzwungene)' : ''}`),
]
return { audioNr: audio.nummern, untertitelNr, untertitelStandard, fehlend }
}
/** Die Sprachlisten aus gewählten Spuren — der Rückfall, wenn der Scan der
* Roh-Datei scheitert (pur). */
export function sprachlistenAusSpuren(datei: Pick<RohDatei, 'audioSpuren' | 'untertitelSpuren'>): { audio: string[]; untertitel: string[] } {
return { audio: sprachenDerSpuren(datei.audioSpuren ?? []), untertitel: sprachenDerSpuren(datei.untertitelSpuren ?? []) }
}
/** Folgen-Nummern von Hand: fortlaufend ab der ersten, in Disc-Reihenfolge. */
export function folgenNummern(anzahl: number, ersteFolge: number): number[] {
const start = Math.max(1, Math.trunc(ersteFolge))
@@ -575,6 +575,10 @@ export class Pipeline {
audioSprachen: a?.audioSprachen ?? [],
untertitelSprachen: a?.untertitelSprachen ?? [],
...(a?.untertitelVorwahl === null || typeof a?.untertitelVorwahl === 'string' ? { untertitelVorwahl: a.untertitelVorwahl } : {}),
// 5.7.0: die einzeln gewählten Spuren reisen mit in die Ablage.
...(a?.audioSpuren !== undefined ? { audioSpuren: a.audioSpuren } : {}),
...(a?.untertitelSpuren !== undefined ? { untertitelSpuren: a.untertitelSpuren } : {}),
...(a?.untertitelVorwahlSpur === null || typeof a?.untertitelVorwahlSpur === 'number' ? { untertitelVorwahlSpur: a.untertitelVorwahlSpur } : {}),
...(a?.film !== undefined ? { film: a.film } : {}),
}
})
+3 -1
View File
@@ -17,7 +17,7 @@ import { OmdbClient } from '../metadaten/omdb'
import { TmdbClient } from '../metadaten/tmdb'
import { LEERER_TREFFER, ausDetails, nachpruefen, zuordnen, type Zuordnung } from '../metadaten/zuordnung'
import { ABGELOEST, titelInfoLesen } from '../rip/makemkv'
import { sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { spurenFuerTitel, sprachenFuerTitel, type TitelInfo } from '../rip/parser'
import { strukturHinweis, titelVorauswahl } from '../rip/titelwahl'
export interface ErkennungAbhaengigkeiten {
@@ -348,6 +348,8 @@ export class ErkennungDienst {
...t,
// Die Sprachen JE TITEL reisen mit (5.4.0) — der Dialog bietet sie zum Anhaken an.
sprachen: sprachenFuerTitel(info.streams.get(t.nr)),
// 5.7.0: und jede Spur einzeln.
spuren: spurenFuerTitel(info.streams.get(t.nr)),
}))
const stand = senden({
id,
@@ -15,6 +15,13 @@
// im Fehlerfall die einzige Auskunft.
// - Abbruch bei JEDER Zeile prüfen, nicht nur bei Prozent-Änderung —
// sonst sieht „Abbrechen" bei einem 4K-Encode minutenlang tot aus.
// - `--aencoder copy` OHNE `--audio-copy-mask` kopiert NUR AAC: HandBrake-
// CLI 1.11.2 setzt die Kopiermaske dann auf „copy:aac" — obwohl das
// Preset alle Codecs erlaubt — und rechnet AC3/DTS/TrueHD in AAC um
// („Auto Passthru: allowed codecs are AAC … passthru not possible for
// track 1, using fallback"). Gemessen 12.09.2026 (Durchsicht, F31):
// mit ausdrücklicher Maske bleibt AC3 AC3. Seit 5.0 bis 5.6.1 wurde
// also jede Tonspur verlustbehaftet umgerechnet.
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { existsSync, renameSync, unlinkSync } from 'node:fs'
// win32-Pfadregeln ausdrücklich — die CI prüft diese Logik auf Linux.
@@ -33,6 +40,13 @@ export interface HandbrakeArgsOptionen {
/** Die ERSTE gewählte Untertitelspur beim Abspielen vorwählen (5.4.0):
* japanischer Ton, deutsche Untertitel sollen von selbst an sein. */
untertitelStandard?: boolean
/** 5.7.0: Spuren EINZELN — HandBrake-Spurnummern (`-a`/`-s`, 1-basiert,
* Scan-Reihenfolge). Gesetzt, gelten die Sprachlisten nicht. Eine leere
* Liste heißt: keine Spur dieser Art. `untertitelStandardNr` ist der
* 1-basierte Index in `untertitelNr` (0 = keine Vorwahl). */
audioNr?: readonly number[]
untertitelNr?: readonly number[]
untertitelStandardNr?: number
}
// Sprach-Schalter am mitgelieferten HandBrake 1.11 GEMESSEN (01.09.2026,
@@ -47,16 +61,47 @@ export interface HandbrakeArgsOptionen {
// zusätzlich forced=1 (so schreibt es HandBrakes Muxer; Jellyfin zeigt
// sie damit von selbst)
// -N deu allein fügt KEINE Untertitelspur hinzu (nur die Vorwahl-Logik)
// Spur-Schalter GEMESSEN (12.09.2026, Testdatei jpn 5.1/deu 2.0/deu 2.0
// „Kommentar" AC3 + deu/eng SRT, Preset „H.265 MKV 1080p30"):
// --audio 1,3 → jpn und deu-Kommentar, in dieser Reihenfolge
// --subtitle 2,1 --subtitle-default=1 → eng (default=1) vor deu
// --subtitle none → keine Untertitelspur (ohne --audio: die erste Tonspur)
// --audio-copy-mask aac,ac3,eac3,dtshd,dts,mp3,truehd,flac → AC3 bleibt AC3
/** Was HandBrake verlustfrei durchreichen darf — genau die Maske der
* mitgelieferten Presets („copy:aac" … „copy:flac"), ausdrücklich, weil
* die CLI sie sonst auf AAC verengt (siehe Kopf). */
export const KOPIERMASKE = 'aac,ac3,eac3,dtshd,dts,mp3,truehd,flac'
export function buildHandbrakeArgs(optionen: HandbrakeArgsOptionen): string[] {
const args = ['--input', optionen.eingabe, '--output', optionen.ausgabe, '--preset', optionen.preset]
const format = FORMATE[extname(optionen.ausgabe).toLowerCase()]
if (format !== undefined) args.push('--format', format)
// 5.7.0: Spuren einzeln — `--audio 1,3` und `--subtitle 2` (HandBrakeCLI
// --help: „-a, --audio <string> Select audio track(s), separated by
// commas", „-s, --subtitle <string> Select subtitle track(s)"; am
// mitgelieferten 1.11.2 gemessen, siehe test/messung.spuren.test.ts).
if (optionen.audioNr !== undefined || optionen.untertitelNr !== undefined) {
const audioNr = optionen.audioNr ?? []
// Ohne gewählte Tonspur bleibt HandBrakes Vorgabe: die erste Spur —
// ein stummer Film wäre nie gewollt (der Dialog sagt das dazu).
if (audioNr.length > 0) args.push('--audio', audioNr.join(','))
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
const untertitelNr = optionen.untertitelNr ?? []
if (untertitelNr.length > 0) {
args.push('--subtitle', untertitelNr.join(','))
if ((optionen.untertitelStandardNr ?? 0) > 0) args.push(`--subtitle-default=${optionen.untertitelStandardNr}`)
} else {
args.push('--subtitle', 'none')
}
return args
}
const audio = (optionen.audioSprachen ?? []).filter((s) => s.length > 0)
if (audio.length > 0) args.push('--audio-lang-list', audio.join(','))
args.push('--first-audio')
// `copy` reicht die gewählten Tonspuren verlustfrei durch; `av_aac` ist
// der dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
args.push('--aencoder', 'copy', '--audio-fallback', 'av_aac')
// `copy` reicht die gewählten Tonspuren verlustfrei durch — aber nur mit
// ausdrücklicher Kopiermaske (F31, siehe Kopf); `av_aac` ist der
// dokumentierte Rückfall, wenn ein Codec nicht kopierbar ist.
args.push('--aencoder', 'copy', '--audio-copy-mask', KOPIERMASKE, '--audio-fallback', 'av_aac')
const untertitel = (optionen.untertitelSprachen ?? []).filter((s) => s.length > 0)
if (untertitel.length > 0) args.push('--subtitle-lang-list', untertitel.join(','))
args.push('--all-subtitles')
+18 -3
View File
@@ -18,9 +18,21 @@
import { werkzeugSpawn } from '../werkzeuge/aufruf'
import { textVon } from '../rip/parser'
/** Eine Spur laut HandBrake-Scan. 5.7.0: dazu CodecName („dtshd", „dca",
* „truehd", „ac3" …), ChannelCount und bei Untertiteln Source/SourceName
* („PGS") — am 12.09.2026 am mitgelieferten HandBrake 1.11.2 gemessen
* (AudioList[].CodecName/ChannelCount/ChannelLayout/TrackNumber). */
export interface HbSpur {
nr: number
lang: string
beschreibung: string
codec: string
kanaele: number
}
export interface HbSpuren {
audio: Array<{ nr: number; lang: string; beschreibung: string }>
untertitel: Array<{ nr: number; lang: string; beschreibung: string }>
audio: HbSpur[]
untertitel: HbSpur[]
dauerS: number
}
@@ -60,14 +72,17 @@ export function spurenAusScan(ausgabe: string): HbSpuren | null {
const liste = (daten as { TitleList?: unknown }).TitleList
if (!Array.isArray(liste) || liste.length === 0) return null
const titel = liste[0] as Record<string, unknown>
const spuren = (feld: unknown): Array<{ nr: number; lang: string; beschreibung: string }> => {
const spuren = (feld: unknown): HbSpur[] => {
if (!Array.isArray(feld)) return []
return feld.map((s, i) => {
const eintrag = (typeof s === 'object' && s !== null ? s : {}) as Record<string, unknown>
const kanaele = eintrag.ChannelCount
return {
nr: i + 1,
lang: String(eintrag.LanguageCode ?? '').toLowerCase(),
beschreibung: String(eintrag.Description ?? eintrag.Language ?? ''),
codec: String(eintrag.CodecName ?? eintrag.SourceName ?? eintrag.Source ?? eintrag.Format ?? ''),
kanaele: typeof kanaele === 'number' && Number.isFinite(kanaele) ? kanaele : 0,
}
})
}
+33 -3
View File
@@ -134,8 +134,18 @@ export interface StreamInfo {
codec: string
kanaele: string
beschreibung: string
/** Attribut 2: „Surround 5.1", „Stereo" (5.7.0). */
layout: string
/** Attribut 22: Flags. Bit 0x800 heißt „abgeleitet" — an der Kizuna-Disc
* gemessen (03.09.2026): 2304 (0x900) am DTS-Kern der DTS-HD-Spur, 6144
* (0x1800) an der „nur erzwungene"-Fassung eines Untertitels, 1024 und 0
* an den vollen Spuren (5.7.0). */
flags: number
}
/** Bit 0x800 in Attribut 22: eine von MakeMKV abgeleitete Spur. */
export const STREAM_FLAG_ABGELEITET = 0x800
export interface TitelStreams {
audio: StreamInfo[]
untertitel: StreamInfo[]
@@ -160,17 +170,19 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
}
let eintrag = streams.get(sNr)
if (eintrag === undefined) {
eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '' }
eintrag = { nr: sNr, typ: '', typCode: 0, lang: '', sprache: '', codec: '', kanaele: '', beschreibung: '', layout: '', flags: 0 }
streams.set(sNr, eintrag)
}
if (attr === 1) {
eintrag.typ = wert
eintrag.typCode = code
}
else if (attr === 2) eintrag.layout = wert
else if (attr === 3) eintrag.lang = wert
else if (attr === 4) eintrag.sprache = wert
else if (attr === 6) eintrag.codec = wert
else if (attr === 14) eintrag.kanaele = wert
else if (attr === 22 && /^\d+$/.test(wert)) eintrag.flags = Number(wert)
else if (attr === 30) eintrag.beschreibung = wert
}
@@ -192,8 +204,8 @@ export function parseStreamInfo(ausgabe: string): Map<number, TitelStreams> {
// EINE Definition für Kern und Fenster (5.4.0): Der Auswahl-Dialog zeigt
// die Sprachen je Titel — deshalb lebt der Typ in gemeinsam/.
import type { SprachEintrag } from '../../gemeinsam/nachrichten'
export type { SprachEintrag }
import type { SprachEintrag, SpurAngabe } from '../../gemeinsam/nachrichten'
export type { SprachEintrag, SpurAngabe }
/** Welche Sprachen hat die DISC insgesamt? Über alle Titel vereinigt,
* Reihenfolge stabil (wie auf der Disc). */
@@ -219,6 +231,24 @@ export function sprachenZusammenfassen(streamsJeTitel: Map<number, TitelStreams>
return { audio: sammeln('audio'), untertitel: sammeln('untertitel') }
}
/** Jede Spur EINES Titels als SpurAngabe (5.7.0, für den Auswahl-Dialog):
* Ton und Untertitel getrennt, in Disc-Reihenfolge, abgeleitete Spuren
* markiert. Ohne Streams leer. */
export function spurenFuerTitel(streams: TitelStreams | undefined): { audio: SpurAngabe[]; untertitel: SpurAngabe[] } {
const angabe = (s: StreamInfo): SpurAngabe => ({
nr: s.nr,
lang: s.lang,
sprache: s.sprache.length > 0 ? s.sprache : s.lang,
codec: s.codec,
kanaele: s.kanaele,
layout: s.layout,
beschreibung: s.beschreibung,
abgeleitet: (s.flags & STREAM_FLAG_ABGELEITET) !== 0,
})
if (streams === undefined) return { audio: [], untertitel: [] }
return { audio: streams.audio.map(angabe), untertitel: streams.untertitel.map(angabe) }
}
/** Die Sprachen EINES Titels (5.4.0, für den Auswahl-Dialog) — dieselbe
* Zusammenfassung wie oben, nur auf einen Titel beschränkt. Ohne Streams
* (Titel unbekannt) leer, nicht erfunden. */
@@ -22,12 +22,20 @@
// eigene hat, behält sie — wie bei app_UpdateEnable in schluessel.ts).
// Abgelegt wird wie der Schlüssel in HKCU\Software\MakeMKV (Windows liest
// dort, die settings.conf ist der Linux-Weg).
//
// 5.7.0: Seit der Dialog jede Spur einzeln anbietet, dürfen auch die
// Stereo-Doppel bleiben (havemulti — „Deutsch · DD · 2.0 (Kommentar)"
// neben „Deutsch · DTS-HD MA · 5.1"): Nur die abgeleiteten DTS-Kerne
// (havecore) fallen weg, die zeigt der Dialog gar nicht erst. Stand
// Rippys ALTE Regel in der Registry, wird sie ersetzt; eine fremde bleibt.
import { spawnSync } from 'node:child_process'
export const REGISTRY_PFAD = 'HKCU\\Software\\MakeMKV'
export const REGEL_NAME = 'app_DefaultSelectionString'
/** Alles behalten, Doppel und Kerne weg — sprachunabhängig. */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all'
/** Alles behalten, nur die Kerne weg — sprachunabhängig (5.7.0). */
export const RIPPY_REGEL = '-sel:all,+sel:all,-sel:havecore,=100:all'
/** Rippys frühere Regeln (5.4.05.6.1) — werden beim Start ersetzt. */
export const RIPPY_REGELN_ALT: readonly string[] = ['-sel:all,+sel:all,-sel:(havemulti|havecore),=100:all']
export interface SpurauswahlStand {
/** 'gesetzt' (von Rippy, jetzt) | 'eigen' (der Nutzer hat eine eigene) |
@@ -37,6 +45,16 @@ export interface SpurauswahlStand {
text: string
}
/** Was mit einer vorgefundenen Regel geschieht (pur, testbar):
* 'rippy' = steht schon · 'ersetzen' = Rippys alte Regel · 'eigen' =
* fremd, bleibt · 'setzen' = keine da. */
export function regelUrteil(bisher: string | null): 'rippy' | 'ersetzen' | 'eigen' | 'setzen' {
if (bisher === null || bisher.length === 0) return 'setzen'
if (bisher === RIPPY_REGEL) return 'rippy'
if (RIPPY_REGELN_ALT.includes(bisher)) return 'ersetzen'
return 'eigen'
}
function reg(args: string[]): { ok: boolean; ausgabe: string } {
const ergebnis = spawnSync('reg', args, { windowsHide: true, timeout: 15_000, encoding: 'utf8' })
return { ok: ergebnis.status === 0, ausgabe: (ergebnis.stdout ?? '') + (ergebnis.stderr ?? '') }
@@ -56,14 +74,15 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
}
const vorhanden = reg(['query', REGISTRY_PFAD, '/v', REGEL_NAME])
const bisher = vorhanden.ok ? regelAusQuery(vorhanden.ausgabe) : null
if (bisher !== null && bisher.length > 0) {
if (bisher === RIPPY_REGEL) {
return { stand: 'rippy', regel: bisher, text: 'MakeMKV behält alle Sprachen (Rippys Auswahlregel steht).' }
}
const urteil = regelUrteil(bisher)
if (urteil === 'rippy') {
return { stand: 'rippy', regel: bisher ?? '', text: 'MakeMKV behält alle Spuren (Rippys Auswahlregel steht).' }
}
if (urteil === 'eigen') {
return {
stand: 'eigen',
regel: bisher,
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Sprachen in der Roh-Datei, liegt es daran.`,
regel: bisher ?? '',
text: `MakeMKV hat eine eigene Auswahlregel („${bisher}") — Rippy lässt sie stehen. Fehlen Spuren in der Roh-Datei, liegt es daran.`,
}
}
const gesetzt = reg(['add', REGISTRY_PFAD, '/v', REGEL_NAME, '/t', 'REG_SZ', '/d', RIPPY_REGEL, '/f'])
@@ -73,6 +92,9 @@ export function spurauswahlSicherstellen(): SpurauswahlStand {
return {
stand: 'gesetzt',
regel: RIPPY_REGEL,
text: 'MakeMKV-Auswahlregel gesetzt: alle Sprachen kommen in die Roh-Datei (Stereo-Doppel und DTS-Kerne nicht).',
text:
urteil === 'ersetzen'
? 'MakeMKV-Auswahlregel erneuert: jede Spur kommt in die Roh-Datei, auch Stereo-Fassungen und Kommentare (nur DTS-Kerne nicht).'
: 'MakeMKV-Auswahlregel gesetzt: jede Spur kommt in die Roh-Datei (nur DTS-Kerne nicht).',
}
}