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",
|
"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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user