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
@@ -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).',
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user