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
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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netfelix-audio-fix",
|
||||
"version": "2026.04.14.9",
|
||||
"version": "2026.04.14.10",
|
||||
"scripts": {
|
||||
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
|
||||
"dev:client": "vite",
|
||||
|
||||
@@ -195,3 +195,116 @@ describe("analyzeItem — transcode targets", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MediaItem, MediaStream, PlanResult } from "../types";
|
||||
import { computeAppleCompat, transcodeTarget } from "./apple-compat";
|
||||
import { computeAppleCompat, isAppleCompatible, transcodeTarget } from "./apple-compat";
|
||||
import { normalizeLanguage } from "./jellyfin";
|
||||
|
||||
export interface AnalyzerConfig {
|
||||
@@ -28,6 +28,12 @@ export function analyzeItem(
|
||||
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");
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" {
|
||||
switch (stream.type) {
|
||||
case "Video":
|
||||
|
||||
@@ -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>}
|
||||
</div>
|
||||
<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
|
||||
immediately and drop it into the Review column — no manual Scan needed. And when we finish an ffmpeg job,
|
||||
Jellyfin's post-rescan event confirms the plan as done (or flips it back to pending if the on-disk streams
|
||||
don't actually match).
|
||||
Two jobs over one channel: when Jellyfin's library picks up a brand-new or modified file, we analyze it immediately
|
||||
and drop it into the Review column — no manual Scan needed. And when we finish an ffmpeg job, Jellyfin's post-rescan
|
||||
event confirms the plan as done (or flips it back to pending if the on-disk streams don't actually match).
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 mb-3">
|
||||
|
||||
Reference in New Issue
Block a user