diff --git a/package.json b/package.json index bdd82aa..15e93c1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/services/__tests__/analyzer.test.ts b/server/services/__tests__/analyzer.test.ts index 7992267..c9e2e31 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -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); + }); +}); diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index e9bf027..f7f4427 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -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(); + 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": diff --git a/src/features/settings/MqttSection.tsx b/src/features/settings/MqttSection.tsx index c84b4c5..a48345e 100644 --- a/src/features/settings/MqttSection.tsx +++ b/src/features/settings/MqttSection.tsx @@ -212,10 +212,9 @@ export function MqttSection({ cfg, locked }: { cfg: Record; lock {enabled && MQTT: {status.status}}

- 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).