per-track language override on audio detail page
Build and Push Docker Image / build (push) Successful in 3m3s

adds stream_decisions.custom_language (ISO 639-2 code or null) so the
user can correct a mislabeled audio track — e.g. a Spanish dub tagged
"und" in the container — without going through Jellyfin. the override
wins over stream.language everywhere it matters: the analyzer reads it
for keep/remove decisions and track ordering, the ffmpeg command builder
writes it as both the language metadata tag and the harmonized track
title, and reanalyze preserves it across reruns and rescans.

on the audio detail page, each pending audio row swaps its language
cell for an inline <select> populated from LANG_NAMES. picking the raw
file language clears the override; anything else sets it and triggers a
server-side reanalyze so keep/remove + target_index update immediately.
a small ✎ hint marks overridden tracks. rebuilt commands tag the output
accordingly so Jellyfin reads the corrected language.

PATCH /api/review/:id/stream/:streamId/language validates the code
against LANG_NAMES (accepts ISO 639-1/2/2B aliases, rejects garbage)
and runs reanalyze inside.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 00:05:31 +02:00
parent fada511ecc
commit 8112bfeb65
11 changed files with 290 additions and 47 deletions
+12 -6
View File
@@ -150,7 +150,7 @@ function computeExtractionEntries(allStreams: MediaStream[], basePath: string):
// ─────────────────────────────────────────────────────────────────────────────
const LANG_NAMES: Record<string, string> = {
export const LANG_NAMES: Record<string, string> = {
eng: "English",
deu: "German",
spa: "Spanish",
@@ -207,7 +207,7 @@ function formatChannels(n: number | null): string | null {
return `${n}ch`;
}
function trackTitle(stream: MediaStream): string | null {
function trackTitle(stream: MediaStream, customLanguage: string | null = null): string | null {
if (stream.type === "Subtitle") {
// Subtitles always get a clean language-based title so Jellyfin displays
// "German", "English (Forced)", etc. regardless of the original file title.
@@ -225,8 +225,11 @@ function trackTitle(stream: MediaStream): string | null {
// the review UI to drop unwanted tracks before we get here, so by this
// point every kept audio track is a primary track that deserves a clean
// canonical label. If a user wants a different title, custom_title on
// the decision still wins (see buildStreamFlags).
const lang = stream.language ? normalizeLanguage(stream.language) : null;
// the decision still wins (see buildStreamFlags). A per-stream language
// override comes through as customLanguage so "UND → Spanish" renames
// flow through to the harmonized title too.
const rawLang = customLanguage ?? stream.language;
const lang = rawLang ? normalizeLanguage(rawLang) : null;
const langPart = lang ? lang.toUpperCase() : null;
const codecPart = stream.codec ? stream.codec.toUpperCase() : null;
const channelsPart = formatChannels(stream.channels);
@@ -278,10 +281,13 @@ function buildStreamFlags(kept: { stream: MediaStream; dec: StreamDecision }[]):
audioKept.forEach((k, i) => {
args.push(`-disposition:a:${i}`, i === 0 ? "default" : "0");
const title = k.dec.custom_title ?? trackTitle(k.stream);
const title = k.dec.custom_title ?? trackTitle(k.stream, k.dec.custom_language);
if (title) args.push(`-metadata:s:a:${i}`, `title=${shellQuote(title)}`);
const lang = k.stream.language ? normalizeLanguage(k.stream.language) : "und";
// Per-stream language override wins over the raw file tag so the
// ffmpeg output carries the corrected language (e.g. "und" → "spa").
const rawLang = k.dec.custom_language ?? k.stream.language;
const lang = rawLang ? normalizeLanguage(rawLang) : "und";
args.push(`-metadata:s:a:${i}`, `language=${lang}`);
});