feat(sprachen): Rippy fragt vor dem Rip, welche Sprachen du willst
Ampel / ampel (push) Successful in 29s

Commander-Anforderung: "Die Disc hat Material in X Sprachen und X Untertiteln -
Rippy muss VOR dem Rip fragen: Was genau willst du haben? In der Automatik muss
das ebenfalls einstellbar sein."

Die Auskunft lag laengst vor und wurde weggeworfen: Derselbe Titel-Scan, der die
Titel-Tabelle fuellt, liefert in derselben makemkvcon-Ausgabe die Streams mit.
Ein zweiter Info-Lauf haette eine Minute Wartezeit gekostet - jetzt kommt beides
aus einem Aufruf.

Format an der Akira-Blu-ray im Laufwerk gemessen (AGENTS Regel D):

  SINFO:<titel>,<stream>,<attribut>,<code>,"<wert>"
  1 = Typ ("Audio"/"Subtitles"), 3 = Sprachcode, 4 = Sprachname,
  6 = Codec, 14 = Kanaele, 30 = Beschreibung

DIE FALLE dabei: Die Sprache steht in 3/4, NICHT in 28/29. Die tragen auf JEDEM
Stream "eng"/"English" - auch auf einer deutschen Tonspur und auf dem
Videostream; das ist MakeMKVs eigene Anzeigesprache. Wer 28 nimmt, haelt jede
Disc fuer englisch. Ein Test haelt das fest.

Angewendet wird die Wahl bei der KOMPRESSION, nicht beim Rippen - drei Gruende:
der Rip bleibt vollstaendig und verlustfrei (Muss-Feature laut KONZEPT); HandBrake
hat dafuer dokumentierte Schalter (--audio-lang-list / --subtitle-lang-list, die
genau die ISO-639-2-Codes nehmen, die MakeMKV liefert - beides gegengeprueft);
und wer spaeter andere Sprachen will, komprimiert neu statt die Disc wieder
einzulegen. Genau das sagt der Dialog auch, sonst glaubt man, es werde
unvollstaendig gerippt.

Nichts angeklickt heisst "alles behalten" - das Verhalten von vorher. Auch die
Einstellung ist bewusst LEER vorbelegt: Ein stilles "deu" wuerde bei einem
japanischen Original die Originaltonspur wegwerfen, ohne dass jemand gefragt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 15:10:21 +02:00
parent 58f4991d92
commit 148ac494c2
7 changed files with 522 additions and 13 deletions
@@ -91,6 +91,10 @@ export default function DeviceDiscovery() {
...(optionen?.titles && optionen.titles.length ? { titles: optionen.titles } : {}),
...(optionen?.transcodeNode ? { transcode_node: optionen.transcodeNode } : {}),
...(optionen?.workDir ? { work_dir: optionen.workDir } : {}),
// Sprachauswahl dieses Rips (leer = alles behalten)
...(optionen?.audioSprachen?.length ? { audio_sprachen: optionen.audioSprachen } : {}),
...(optionen?.untertitelSprachen?.length
? { untertitel_sprachen: optionen.untertitelSprachen } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
+123
View File
@@ -21,6 +21,17 @@ export interface RipOptionen {
titles?: number[]
transcodeNode?: string // gewählter Encoder-Worker (Celery-Node) oder leer = auto
workDir?: string // Arbeitsverzeichnis für die Rohdaten; leer = Einstellung
// Sprachauswahl (ISO-639-2). Leer = alles behalten. Wirkt bei der
// KOMPRESSION — der Rip bleibt vollständig und verlustfrei.
audioSprachen?: string[]
untertitelSprachen?: string[]
}
// Eine Sprache, die die Disc anbietet (aus dem Titel-Scan, MakeMKV SINFO).
interface DiscSprache {
lang: string // ISO-639-2, z. B. "deu"
sprache: string // Klartext, z. B. "German"
spuren: number
}
// Ein Ablageziel aus GET /storage-targets.
@@ -85,6 +96,25 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [workers, setWorkers] = useState<WorkerWahl[]>([])
const [encoderNode, setEncoderNode] = useState('') // '' = automatisch
/*
* Sprachen der Disc (Commander-Anforderung 26.07.2026: „Die Disc hat Material
* in X Sprachen und X Untertiteln — Rippy muss VOR dem Rip fragen: Was genau
* willst du haben?").
*
* Die Auskunft kommt aus demselben Titel-Scan, der schon für die
* Titel-Auswahl läuft — MakeMKV liefert sie in derselben Ausgabe mit, sie
* wurde bisher nur weggeworfen.
*
* Leere Auswahl heißt bewusst „alles behalten": Wer nichts anklickt, bekommt
* das Verhalten von vorher, und niemand verliert versehentlich seine Tonspur.
*/
const [discSprachen, setDiscSprachen] = useState<{ audio: DiscSprache[], untertitel: DiscSprache[] } | null>(null)
const [audioWahl, setAudioWahl] = useState<Set<string>>(new Set())
const [untertitelWahl, setUntertitelWahl] = useState<Set<string>>(new Set())
// Wunschsprachen aus den Einstellungen — sie werden beim Scan vorausgewählt,
// soweit die Disc sie überhaupt hat.
const [standardAudio, setStandardAudio] = useState<string[]>([])
const [standardUntertitel, setStandardUntertitel] = useState<string[]>([])
const scanStarten = async () => {
if (!deviceId) return
@@ -107,6 +137,16 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const tracks: TitelInfo[] = r.data.tracks || []
setTitelListe(tracks)
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
const spr = r.data.sprachen || null
setDiscSprachen(spr)
// Wunschsprachen vorauswählen — aber nur, was die Disc wirklich hat.
// Sonst stünde da eine Auswahl, die nichts bewirkt.
if (spr) {
const vorhanden = (liste: DiscSprache[], wunsch: string[]) =>
new Set(liste.filter(s => wunsch.includes(s.lang)).map(s => s.lang))
setAudioWahl(vorhanden(spr.audio || [], standardAudio))
setUntertitelWahl(vorhanden(spr.untertitel || [], standardUntertitel))
}
setScanStatus('done')
} else if (r.data.status === 'error') {
setScanFehler(r.data.error || 'Scan fehlgeschlagen')
@@ -155,6 +195,9 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
setEncoderNode('')
setArbeitsDir('')
setBrowserOffen(false)
setDiscSprachen(null)
setAudioWahl(new Set())
setUntertitelWahl(new Set())
api.get('/storage-targets')
.then(r => setArbeitsZiele(Array.isArray(r.data) ? r.data : []))
.catch(() => setArbeitsZiele([]))
@@ -166,6 +209,10 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const s = r.data || {}
setNurHauptfilm(!!s.mainFeatureOnly)
setStandardArbeitsDir((s.workDir || '').trim())
const codes = (wert: any) => String(wert || '')
.split(',').map((t: string) => t.trim().toLowerCase()).filter(Boolean)
setStandardAudio(codes(s.audioSprachen))
setStandardUntertitel(codes(s.untertitelSprachen))
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
@@ -216,6 +263,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
}
if (encoderNode) optionen.transcodeNode = encoderNode
if (arbeitsDir) optionen.workDir = arbeitsDir
if (audioWahl.size > 0) optionen.audioSprachen = [...audioWahl]
if (untertitelWahl.size > 0) optionen.untertitelSprachen = [...untertitelWahl]
const target = targets.find(t => t.type === selectedType)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true }, optionen)
@@ -430,6 +479,80 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
</div>
)}
{/* Sprachen der Disc — steht direkt unter der Titel-Auswahl, weil beides
aus DEMSELBEN Scan kommt. Vorher warf Rippy diese Auskunft weg. */}
{scanStatus === 'done' && discSprachen
&& (discSprachen.audio.length > 0 || discSprachen.untertitel.length > 0) && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">
Sprachen auf dieser Disc
</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
{discSprachen.audio.length} Tonsprache{discSprachen.audio.length === 1 ? '' : 'n'}
{discSprachen.untertitel.length > 0
&& `, ${discSprachen.untertitel.length} Untertitelsprache${discSprachen.untertitel.length === 1 ? '' : 'n'}`}
{' — '}nichts angeklickt heißt <strong>alles behalten</strong>.
</p>
</div>
<div className="p-4 space-y-3">
{(['audio', 'untertitel'] as const).map(art => {
const liste = discSprachen[art]
if (liste.length === 0) return null
const wahl = art === 'audio' ? audioWahl : untertitelWahl
const setzen = art === 'audio' ? setAudioWahl : setUntertitelWahl
return (
<div key={art}>
<p className="text-xs font-medium mb-1.5 text-slate-700 dark:text-slate-300">
{art === 'audio' ? 'Tonspuren' : 'Untertitel'}
</p>
<div className="flex flex-wrap gap-2">
{liste.map(s => {
const an = wahl.has(s.lang)
return (
<button
key={s.lang}
onClick={() => {
const neu = new Set(wahl)
if (an) neu.delete(s.lang); else neu.add(s.lang)
setzen(neu)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
an
? 'bg-amber-500 border-amber-500 text-slate-950 font-bold'
: 'bg-slate-100 dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
{s.sprache}
<span className="opacity-70"> ({s.lang})</span>
{s.spuren > 1 && <span className="opacity-70"> · {s.spuren}×</span>}
</button>
)
})}
</div>
</div>
)
})}
{/* Der wichtigste Satz: WANN die Auswahl greift. Sonst glaubt man,
die Disc würde unvollständig gerippt. */}
<p className="text-xs text-slate-500 dark:text-slate-400">
{audioWahl.size > 0 || untertitelWahl.size > 0 ? (
<>
Der Rip selbst bleibt <strong>vollständig und verlustfrei</strong> die
Auswahl wirkt beim Komprimieren. Du kannst also später jederzeit
Neu komprimieren" mit anderen Sprachen wählen, ohne die Disc noch
einmal einzulegen.
</>
) : (
<>Ohne Auswahl behält Rippy alle Sprachen — wie bisher.</>
)}
</p>
</div>
</div>
)}
{/* Ordner-Browser — eingeklappt, siehe Begründung bei browserOffen */}
<div>
<button
+57
View File
@@ -38,6 +38,10 @@ interface SettingsState {
jellyfinApiKey: string
workDir: string
makemkvAppKey: string
// Wunschsprachen (ISO-639-2, Komma-Liste). Greifen bei der Kompression und
// sind im Rip-Dialog vorausgewählt. Leer = alle Sprachen behalten.
audioSprachen: string
untertitelSprachen: string
}
const defaultSettings: SettingsState = {
@@ -63,6 +67,11 @@ const defaultSettings: SettingsState = {
jellyfinApiKey: '',
workDir: '',
makemkvAppKey: '',
// Leer = alles behalten. Bewusst kein Vorgabewert: Eine stille Vorauswahl
// („deu") würde bei einem japanischen Original die Originaltonspur wegwerfen,
// ohne dass jemand danach gefragt hat.
audioSprachen: '',
untertitelSprachen: '',
}
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
@@ -826,6 +835,54 @@ export default function SettingsPage() {
</p>
</div>
{/*
Wunschsprachen (Commander-Anforderung 26.07.2026: die Auswahl
muss „in der Automatik ebenfalls einstellbar" sein).
Sie greifen bei der KOMPRESSION, nicht beim Rippen — der Rip
bleibt vollständig und verlustfrei (Muss-Feature). Damit lässt
sich später mit anderer Sprachwahl neu komprimieren, ohne die
Disc wieder einzulegen.
*/}
<div className="space-y-3 p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Wunschsprachen</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Gilt für Vollautomatik-Rips und ist im Rip-Dialog vorausgewählt.
<strong className="text-slate-700 dark:text-slate-300"> Leer = alle Sprachen behalten.</strong>
</p>
</div>
<Input
label="Tonspuren (ISO-639-2, mit Komma getrennt)"
value={settings.audioSprachen}
onChange={(e) => handleChange('audioSprachen', e.target.value)}
placeholder="z. B. deu,eng,jpn"
/>
<Input
label="Untertitel (ISO-639-2, mit Komma getrennt)"
value={settings.untertitelSprachen}
onChange={(e) => handleChange('untertitelSprachen', e.target.value)}
placeholder="z. B. deu"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Codes sind dreibuchstabig: <span className="font-mono">deu</span> Deutsch,
{' '}<span className="font-mono">eng</span> Englisch,
{' '}<span className="font-mono">jpn</span> Japanisch,
{' '}<span className="font-mono">fra</span> Französisch,
{' '}<span className="font-mono">spa</span> Spanisch,
{' '}<span className="font-mono">ita</span> Italienisch.
Welche eine Disc wirklich hat, zeigt Rippy beim Disc scannen" im
Rip-Dialog — dort ist die Auswahl auch je Disc änderbar.
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Wichtig: Diese Wahl wirkt beim <strong>Komprimieren</strong>. Der Rip
selbst sichert immer alles — sonst wäre eine spätere Änderung nur mit
neuem Rip möglich.
</p>
</div>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"