per-track language override on audio detail page
Build and Push Docker Image / build (push) Successful in 3m3s
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:
@@ -79,6 +79,33 @@ describe("analyzeItem — audio keep rules", () => {
|
||||
});
|
||||
expect(result.decisions[0].action).toBe("keep");
|
||||
});
|
||||
|
||||
test("custom_language override wins over stream.language for keep/remove", () => {
|
||||
// File says UND, user corrects it to Spanish. With OG=eng and no extra
|
||||
// keep languages, the track should be removed — the override flowed
|
||||
// into the decision just like a real "spa" tag would have.
|
||||
const streams = [
|
||||
stream({ id: 1, type: "Video", stream_index: 0 }),
|
||||
stream({ id: 2, type: "Audio", stream_index: 1, language: null }),
|
||||
];
|
||||
const overrides = new Map<number, string>([[2, "spa"]]);
|
||||
const removing = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
||||
streams,
|
||||
{ audioLanguages: [] },
|
||||
overrides,
|
||||
);
|
||||
expect(removing.decisions.find((d) => d.stream_id === 2)?.action).toBe("remove");
|
||||
|
||||
// Same file, but Spanish is now in the keep list → kept.
|
||||
const keeping = analyzeItem(
|
||||
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
||||
streams,
|
||||
{ audioLanguages: ["spa"] },
|
||||
overrides,
|
||||
);
|
||||
expect(keeping.decisions.find((d) => d.stream_id === 2)?.action).toBe("keep");
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyzeItem — audio ordering", () => {
|
||||
|
||||
@@ -28,6 +28,7 @@ function decision(o: Partial<StreamDecision> & Pick<StreamDecision, "stream_id"
|
||||
plan_id: 1,
|
||||
target_index: null,
|
||||
custom_title: null,
|
||||
custom_language: null,
|
||||
transcode_codec: null,
|
||||
...o,
|
||||
};
|
||||
|
||||
+59
-20
@@ -9,6 +9,17 @@ export interface AnalyzerConfig {
|
||||
audioLanguages: string[]; // additional languages to keep (after OG)
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective-language lookup — prefer the user's per-stream override, fall
|
||||
* back to whatever the file reports. Returned as raw; callers still need
|
||||
* to normalizeLanguage() for comparison.
|
||||
*/
|
||||
function effectiveLanguage(stream: MediaStream, overrides: Map<number, string> | undefined): string | null {
|
||||
const override = overrides?.get(stream.id);
|
||||
if (override) return override;
|
||||
return stream.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an item and its streams, compute what action to take for each stream
|
||||
* and whether the file needs audio remuxing.
|
||||
@@ -17,17 +28,24 @@ export interface AnalyzerConfig {
|
||||
* sidecar files). is_noop considers audio removal/reorder, subtitle
|
||||
* extraction, and transcode — a "noop" is a file that needs no changes
|
||||
* at all.
|
||||
*
|
||||
* `languageOverrides` maps stream_id → ISO code and lets the user correct a
|
||||
* mislabeled track ("und" → "spa") before the analyzer groups and filters.
|
||||
* When present, the override wins over `MediaStream.language` for every
|
||||
* language-aware decision (keep/remove, dedup, ordering, is_noop).
|
||||
*/
|
||||
export function analyzeItem(
|
||||
item: Pick<MediaItem, "original_language" | "orig_lang_source" | "needs_review" | "container">,
|
||||
streams: MediaStream[],
|
||||
config: AnalyzerConfig,
|
||||
languageOverrides?: Map<number, string>,
|
||||
): PlanResult {
|
||||
const origLang = item.original_language ? normalizeLanguage(item.original_language) : null;
|
||||
const notes: string[] = [];
|
||||
|
||||
const decisions: PlanResult["decisions"] = streams.map((s) => {
|
||||
const action = decideAction(s, origLang, config.audioLanguages);
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
const action = decideAction(s, lang, origLang, config.audioLanguages);
|
||||
return { stream_id: s.id, action, target_index: null, transcode_codec: null };
|
||||
});
|
||||
|
||||
@@ -39,11 +57,11 @@ export function analyzeItem(
|
||||
// and alternate formats so we end up with exactly one audio stream per
|
||||
// language. The user doesn't need 2× English (main + director's
|
||||
// commentary) — one well-chosen track is enough.
|
||||
deduplicateAudioByLanguage(streams, decisions, origLang);
|
||||
deduplicateAudioByLanguage(streams, decisions, origLang, languageOverrides);
|
||||
|
||||
const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove");
|
||||
|
||||
assignTargetOrder(streams, decisions, origLang, config.audioLanguages);
|
||||
assignTargetOrder(streams, decisions, origLang, config.audioLanguages, languageOverrides);
|
||||
|
||||
const audioOrderChanged = checkAudioOrderChanged(streams, decisions);
|
||||
|
||||
@@ -82,9 +100,18 @@ export function analyzeItem(
|
||||
const firstKeptAudio = keptAudioSorted[0];
|
||||
const defaultMismatch = !!firstKeptAudio && firstKeptAudio.is_default !== 1;
|
||||
const nonDefaultHasDefault = keptAudioSorted.slice(1).some((s) => s.is_default === 1);
|
||||
const languageMismatch = keptAudioStreams.some(
|
||||
(s) => s.language != null && s.language !== normalizeLanguage(s.language),
|
||||
);
|
||||
// Non-canonical language tag in the file (e.g. "ger" instead of "deu", or
|
||||
// "en" instead of "eng") or a user-provided custom_language that differs
|
||||
// from the stream's raw tag — either one means ffmpeg would rewrite the
|
||||
// metadata, so the file isn't already in the desired state.
|
||||
const languageMismatch = keptAudioStreams.some((s) => {
|
||||
const override = languageOverrides?.get(s.id);
|
||||
if (override) {
|
||||
const canonical = normalizeLanguage(override);
|
||||
return s.language !== canonical;
|
||||
}
|
||||
return s.language != null && s.language !== normalizeLanguage(s.language);
|
||||
});
|
||||
|
||||
const is_noop =
|
||||
!anyAudioRemoved &&
|
||||
@@ -119,7 +146,10 @@ export function analyzeItem(
|
||||
const authoritativeOg =
|
||||
!!origLang && !!origLangSource && AUTHORITATIVE_ORIG_SOURCES.has(origLangSource) && item.needs_review === 0;
|
||||
|
||||
const keptAudioLanguages = keptAudioStreams.map((s) => (s.language ? normalizeLanguage(s.language) : null));
|
||||
const keptAudioLanguages = keptAudioStreams.map((s) => {
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
return lang ? normalizeLanguage(lang) : null;
|
||||
});
|
||||
const ogPresent = !!origLang && keptAudioLanguages.includes(origLang);
|
||||
const everyKeptHasLanguage = keptAudioStreams.length > 0 && keptAudioLanguages.every((l) => l != null);
|
||||
|
||||
@@ -191,6 +221,7 @@ function deduplicateAudioByLanguage(
|
||||
streams: MediaStream[],
|
||||
decisions: PlanResult["decisions"],
|
||||
origLang: string | null,
|
||||
languageOverrides: Map<number, string> | undefined,
|
||||
): void {
|
||||
const decisionById = new Map(decisions.map((d) => [d.stream_id, d]));
|
||||
const keptAudio = streams.filter((s) => s.type === "Audio" && decisionById.get(s.id)?.action === "keep");
|
||||
@@ -203,21 +234,23 @@ function deduplicateAudioByLanguage(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Group remaining kept-audio streams by normalized language and keep
|
||||
// one winner per group. Streams without a language tag are handled
|
||||
// specially: when OG language is unknown we keep them all (ambiguity
|
||||
// means we can't safely drop anything); when OG is known they've
|
||||
// already been kept by decideAction's "unknown language falls
|
||||
// through" clause, so still dedupe within them.
|
||||
// 2. Group remaining kept-audio streams by normalized effective language
|
||||
// and keep one winner per group. Streams without a language (no raw
|
||||
// tag and no override) are handled specially: when OG language is
|
||||
// unknown we keep them all (ambiguity means we can't safely drop
|
||||
// anything); when OG is known they've already been kept by
|
||||
// decideAction's "unknown language falls through" clause, so still
|
||||
// dedupe within them.
|
||||
const stillKept = keptAudio.filter((s) => decisionById.get(s.id)?.action === "keep");
|
||||
const byLang = new Map<string, MediaStream[]>();
|
||||
const noLang: MediaStream[] = [];
|
||||
for (const s of stillKept) {
|
||||
if (!s.language) {
|
||||
const lang = effectiveLanguage(s, languageOverrides);
|
||||
if (!lang) {
|
||||
noLang.push(s);
|
||||
continue;
|
||||
}
|
||||
const key = normalizeLanguage(s.language);
|
||||
const key = normalizeLanguage(lang);
|
||||
if (!byLang.has(key)) byLang.set(key, []);
|
||||
byLang.get(key)!.push(s);
|
||||
}
|
||||
@@ -245,7 +278,12 @@ function deduplicateAudioByLanguage(
|
||||
}
|
||||
}
|
||||
|
||||
function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
|
||||
function decideAction(
|
||||
stream: MediaStream,
|
||||
effectiveLang: string | null,
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
): "keep" | "remove" {
|
||||
switch (stream.type) {
|
||||
case "Video":
|
||||
case "Data":
|
||||
@@ -254,8 +292,8 @@ function decideAction(stream: MediaStream, origLang: string | null, audioLanguag
|
||||
|
||||
case "Audio": {
|
||||
if (!origLang) return "keep";
|
||||
if (!stream.language) return "keep";
|
||||
const normalized = normalizeLanguage(stream.language);
|
||||
if (!effectiveLang) return "keep";
|
||||
const normalized = normalizeLanguage(effectiveLang);
|
||||
if (normalized === origLang) return "keep";
|
||||
if (audioLanguages.includes(normalized)) return "keep";
|
||||
return "remove";
|
||||
@@ -280,6 +318,7 @@ export function assignTargetOrder(
|
||||
decisions: PlanResult["decisions"],
|
||||
origLang: string | null,
|
||||
audioLanguages: string[],
|
||||
languageOverrides?: Map<number, string>,
|
||||
): void {
|
||||
const keptByType = new Map<string, MediaStream[]>();
|
||||
for (const s of allStreams) {
|
||||
@@ -292,8 +331,8 @@ export function assignTargetOrder(
|
||||
const audio = keptByType.get("Audio");
|
||||
if (audio) {
|
||||
audio.sort((a, b) => {
|
||||
const aRank = langRank(a.language, origLang, audioLanguages);
|
||||
const bRank = langRank(b.language, origLang, audioLanguages);
|
||||
const aRank = langRank(effectiveLanguage(a, languageOverrides), origLang, audioLanguages);
|
||||
const bRank = langRank(effectiveLanguage(b, languageOverrides), origLang, audioLanguages);
|
||||
if (aRank !== bRank) return aRank - bRank;
|
||||
return a.stream_index - b.stream_index;
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user