analyzer: keep only one audio track per language, drop commentary/AD
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s

a release with 2× english (main + director's commentary, or a surround
track plus an audio-description track) was keeping both. the user only
wants one per language. rules, in priority order:

- always drop commentary / audio-description / visually-impaired /
  karaoke / sign-language tracks (matched by title regex + the
  is_hearing_impaired flag)
- within each kept-language group, pick one winner by:
  1. default disposition (main track the muxer chose)
  2. highest channel count
  3. apple-compatible codec (skip a transcode pass)
  4. lowest stream_index for stability

tests cover: commentary dropped even when it matches OG, AD flag
dropped, default beats non-default, higher channels beat default-less
candidates of equal type, Apple-compat tiebreak, per-language dedupe
runs independently, and single-stream files stay noop.
This commit is contained in:
2026-04-14 10:07:02 +02:00
parent e3686f2b76
commit 6698af020d
4 changed files with 220 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "netfelix-audio-fix", "name": "netfelix-audio-fix",
"version": "2026.04.14.9", "version": "2026.04.14.10",
"scripts": { "scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite", "dev:client": "vite",

View File

@@ -195,3 +195,116 @@ describe("analyzeItem — transcode targets", () => {
expect(result.job_type).toBe("copy"); expect(result.job_type).toBe("copy");
}); });
}); });
describe("analyzeItem — one audio track per language", () => {
test("drops commentary track even when it matches OG language", () => {
const streams = [
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({
id: 2,
type: "Audio",
stream_index: 1,
codec: "ac3",
language: "eng",
is_default: 1,
channels: 6,
title: "Surround 5.1",
}),
stream({
id: 3,
type: "Audio",
stream_index: 2,
codec: "ac3",
language: "eng",
channels: 2,
title: "Director's Commentary",
}),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "keep", 2: "keep", 3: "remove" });
});
test("drops audio-description (hearing-impaired) track", () => {
const streams = [
stream({ id: 1, type: "Audio", stream_index: 0, codec: "ac3", language: "eng", is_default: 1, channels: 6 }),
stream({
id: 2,
type: "Audio",
stream_index: 1,
codec: "ac3",
language: "eng",
channels: 6,
is_hearing_impaired: 1,
title: "Audio Description",
}),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "keep", 2: "remove" });
});
test("keeps the default track when two same-language tracks exist", () => {
const streams = [
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng", channels: 2 }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "ac3", language: "eng", channels: 6, is_default: 1 }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "remove", 2: "keep" });
});
test("when neither track is default, prefers more channels", () => {
const streams = [
stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng", channels: 2 }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6 }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "remove", 2: "keep" });
});
test("with equal channels + no default, prefers Apple-compatible over DTS", () => {
const streams = [
stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng", channels: 6 }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", channels: 6 }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
expect(actions).toEqual({ 1: "remove", 2: "keep" });
});
test("dedupes within each language independently (OG English + extra German)", () => {
const streams = [
stream({ id: 1, type: "Audio", stream_index: 0, codec: "ac3", language: "eng", is_default: 1, channels: 6 }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", channels: 2 }),
stream({ id: 3, type: "Audio", stream_index: 2, codec: "ac3", language: "deu", channels: 6, is_default: 1 }),
stream({
id: 4,
type: "Audio",
stream_index: 3,
codec: "aac",
language: "deu",
channels: 2,
title: "Kommentar",
}),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: ["deu"] });
const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action]));
// One English (default surround wins), one German (default wins, commentary
// is still a valid alternate because the title is in a language the regex
// doesn't know — but the default wins by disposition anyway).
expect(actions).toEqual({ 1: "keep", 2: "remove", 3: "keep", 4: "remove" });
});
test("single-stream file stays a noop", () => {
const streams = [
stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }),
stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng", is_default: 1 }),
];
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mp4" }, streams, {
audioLanguages: [],
});
expect(result.is_noop).toBe(true);
});
});

View File

@@ -1,5 +1,5 @@
import type { MediaItem, MediaStream, PlanResult } from "../types"; import type { MediaItem, MediaStream, PlanResult } from "../types";
import { computeAppleCompat, transcodeTarget } from "./apple-compat"; import { computeAppleCompat, isAppleCompatible, transcodeTarget } from "./apple-compat";
import { normalizeLanguage } from "./jellyfin"; import { normalizeLanguage } from "./jellyfin";
export interface AnalyzerConfig { export interface AnalyzerConfig {
@@ -28,6 +28,12 @@ export function analyzeItem(
return { stream_id: s.id, action, target_index: null, transcode_codec: null }; return { stream_id: s.id, action, target_index: null, transcode_codec: null };
}); });
// Second pass: within each kept-language group, drop commentary/AD tracks
// 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);
const anyAudioRemoved = streams.some((s, i) => s.type === "Audio" && decisions[i].action === "remove"); 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);
@@ -89,6 +95,102 @@ export function analyzeItem(
return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes }; return { is_noop, has_subs: hasSubs, confidence: "low", apple_compat, job_type, decisions, notes };
} }
/**
* Titles that scream "not the main track": commentary, director's track,
* visually-impaired/audio-description, karaoke. Case-insensitive.
*/
const NON_PRIMARY_AUDIO_TITLE =
/\b(commentary|director'?s?\b.*\b(track|comment|feature)|audio description|descriptive|visually? impaired|\bad\b|karaoke|sign language)/i;
function isCommentaryOrAuxiliary(stream: MediaStream): boolean {
if (stream.is_hearing_impaired) return true;
const title = stream.title ?? "";
return NON_PRIMARY_AUDIO_TITLE.test(title);
}
/**
* Sort comparator for picking the "primary" audio track within a
* single language group. Higher score wins.
*
* Priority (most → least important):
* 1. default disposition (the main track the muxer picked)
* 2. highest channel count (5.1 beats stereo)
* 3. Apple-compatible codec (avoids a transcode pass entirely)
* 4. lowest stream_index (original source order, stable tiebreak)
*/
function betterAudio(a: MediaStream, b: MediaStream): number {
const byDefault = (b.is_default ?? 0) - (a.is_default ?? 0);
if (byDefault !== 0) return byDefault;
const byChannels = (b.channels ?? 0) - (a.channels ?? 0);
if (byChannels !== 0) return byChannels;
const aApple = isAppleCompatible(a.codec ?? "") ? 1 : 0;
const bApple = isAppleCompatible(b.codec ?? "") ? 1 : 0;
const byApple = bApple - aApple;
if (byApple !== 0) return byApple;
return a.stream_index - b.stream_index;
}
function deduplicateAudioByLanguage(
streams: MediaStream[],
decisions: PlanResult["decisions"],
origLang: string | null,
): 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");
// 1. Flag commentary/AD tracks as remove regardless of language match.
for (const s of keptAudio) {
if (isCommentaryOrAuxiliary(s)) {
const d = decisionById.get(s.id);
if (d) d.action = "remove";
}
}
// 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.
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) {
noLang.push(s);
continue;
}
const key = normalizeLanguage(s.language);
if (!byLang.has(key)) byLang.set(key, []);
byLang.get(key)!.push(s);
}
for (const [, group] of byLang) {
if (group.length <= 1) continue;
const sorted = [...group].sort(betterAudio);
const winner = sorted[0];
for (const s of sorted.slice(1)) {
const d = decisionById.get(s.id);
if (d) d.action = "remove";
}
// Touch winner (no-op) to make intent clear.
void winner;
}
// Null-language audio: only dedupe when OG is known (so we already have
// a primary pick). If OG is null we leave ambiguity alone.
if (origLang && noLang.length > 1) {
const sorted = [...noLang].sort(betterAudio);
for (const s of sorted.slice(1)) {
const d = decisionById.get(s.id);
if (d) d.action = "remove";
}
}
}
function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" { function decideAction(stream: MediaStream, origLang: string | null, audioLanguages: string[]): "keep" | "remove" {
switch (stream.type) { switch (stream.type) {
case "Video": case "Video":

View File

@@ -212,10 +212,9 @@ export function MqttSection({ cfg, locked }: { cfg: Record<string, string>; lock
{enabled && <span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>} {enabled && <span className={`text-xs px-2 py-0.5 rounded border ${statusColor}`}>MQTT: {status.status}</span>}
</div> </div>
<p className="text-gray-500 text-sm mb-3 mt-0"> <p className="text-gray-500 text-sm mb-3 mt-0">
Two jobs over one channel: when Jellyfin's library picks up a brand-new or modified file, we analyze it Two jobs over one channel: when Jellyfin's library picks up a brand-new or modified file, we analyze it immediately
immediately and drop it into the Review column — no manual Scan needed. And when we finish an ffmpeg job, and drop it into the Review column — no manual Scan needed. And when we finish an ffmpeg job, Jellyfin's post-rescan
Jellyfin's post-rescan event confirms the plan as done (or flips it back to pending if the on-disk streams event confirms the plan as done (or flips it back to pending if the on-disk streams don't actually match).
don't actually match).
</p> </p>
<label className="flex items-center gap-2 text-sm text-gray-700 mb-3"> <label className="flex items-center gap-2 text-sm text-gray-700 mb-3">