From 4057b692ba1cef942334d4d2106b61a0cfaa69af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 14 Apr 2026 10:23:49 +0200 Subject: [PATCH] audio: single EAC3 transcode target, prefer direct-play over lossless default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two simplifications to how we pick and transcode the one-per-language audio track, motivated by seeing inconsistent DTS → FLAC vs DTS → EAC3 outputs in the wild: transcode target: - drop the FLAC path entirely. every incompatible source now targets EAC3 regardless of container or lossless/lossy status - FLAC for movie audio is bad value: ~2-3× the file size vs EAC3, no Atmos spatial metadata (TrueHD Atmos → FLAC silently loses Atmos), no AVR passthrough on Apple TV - one target = no more container-conditional surprises winner within a language group (betterAudio): - new priority: highest channels → Apple-compatible → default → index - old order put 'default' on top which forced a DTS-HD MA transcode even when an AC3 track at equal channels was right next to it. flipping means AC3 beats DTS-HD MA at the same channel count — pure copy instead of a lossless-then-re-encode round trip - channel count still dominates, so 7.1 TrueHD still beats 5.1 AC3 (and gets transcoded, which is the right call for real surround) tests: new case for DTS-HD MA default + AC3 non-default at 5.1 → AC3 wins, job_type=copy. new case for 7.1 TrueHD beats 5.1 AC3 default. every other existing test still holds. --- package.json | 2 +- server/services/__tests__/analyzer.test.ts | 38 +++++++++++++++++ server/services/analyzer.ts | 18 ++++---- server/services/apple-compat.ts | 49 +++++++++------------- 4 files changed, 69 insertions(+), 38 deletions(-) diff --git a/package.json b/package.json index d36c5f5..2e084c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.14.11", + "version": "2026.04.14.12", "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 c9e2e31..e6de039 100644 --- a/server/services/__tests__/analyzer.test.ts +++ b/server/services/__tests__/analyzer.test.ts @@ -264,6 +264,44 @@ describe("analyzeItem — one audio track per language", () => { expect(actions).toEqual({ 1: "remove", 2: "keep" }); }); + test("Apple-compatible AC3 wins over DTS-HD MA default at same channel count", () => { + // The lossless DTS track being 'default' used to force a transcode; + // with Apple-compat above default in the priority order, we pick the + // AC3 track and the job becomes a pure copy. + const streams = [ + stream({ + id: 1, + type: "Audio", + stream_index: 0, + codec: "dts", + profile: "DTS-HD MA", + language: "eng", + is_default: 1, + channels: 6, + }), + stream({ id: 2, type: "Audio", stream_index: 1, codec: "ac3", language: "eng", channels: 6 }), + ]; + const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng", container: "mkv" }, streams, { + audioLanguages: [], + }); + const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); + expect(actions).toEqual({ 1: "remove", 2: "keep" }); + expect(result.job_type).toBe("copy"); // no transcode needed + }); + + test("higher channels wins over Apple-compat (7.1 TrueHD beats 5.1 AC3)", () => { + const streams = [ + stream({ id: 1, type: "Audio", stream_index: 0, codec: "truehd", language: "eng", channels: 8 }), + 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", container: "mkv" }, streams, { + audioLanguages: [], + }); + const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); + expect(actions).toEqual({ 1: "keep", 2: "remove" }); + expect(result.decisions[0].transcode_codec).toBe("eac3"); + }); + 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 }), diff --git a/server/services/analyzer.ts b/server/services/analyzer.ts index f7f4427..539864a 100644 --- a/server/services/analyzer.ts +++ b/server/services/analyzer.ts @@ -110,18 +110,17 @@ function isCommentaryOrAuxiliary(stream: MediaStream): boolean { /** * Sort comparator for picking the "primary" audio track within a - * single language group. Higher score wins. + * single language group. Lower return → a 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) + * 1. highest channel count (quality; 7.1 beats 5.1 beats stereo) + * 2. Apple-compatible codec (skips a transcode pass; AC3 wins over + * DTS-HD MA at equal channels — direct play > lossless that + * has to be re-encoded anyway) + * 3. default disposition (muxer's pick, tiebreak) + * 4. lowest stream_index (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; @@ -130,6 +129,9 @@ function betterAudio(a: MediaStream, b: MediaStream): number { const byApple = bApple - aApple; if (byApple !== 0) return byApple; + const byDefault = (b.is_default ?? 0) - (a.is_default ?? 0); + if (byDefault !== 0) return byDefault; + return a.stream_index - b.stream_index; } diff --git a/server/services/apple-compat.ts b/server/services/apple-compat.ts index 7952136..5396c24 100644 --- a/server/services/apple-compat.ts +++ b/server/services/apple-compat.ts @@ -1,6 +1,15 @@ // Codec sets and transcode target mapping for Apple device compatibility. -// Apple natively decodes: AAC, AC3, EAC3, ALAC, FLAC, MP3, PCM, Opus -// Everything else (DTS family, TrueHD family) needs transcoding. +// Apple natively decodes: AAC, AC3, EAC3, ALAC, FLAC, MP3, PCM, Opus. +// Everything else (DTS family, TrueHD family, etc.) transcodes to EAC3. +// +// Why EAC3 as the single target and not FLAC for lossless sources: +// - FLAC movie audio is ~2-3× the size of EAC3 at transparent quality +// - FLAC doesn't preserve Atmos spatial metadata (TrueHD Atmos → FLAC +// silently drops Atmos); EAC3 JOC can carry it +// - EAC3 passes through to AVRs over ARC/eARC; FLAC-to-AVR isn't a +// thing on Apple TV +// - One target per incompatible source = no container-conditional +// mapping surprises const APPLE_COMPATIBLE_AUDIO = new Set([ "aac", @@ -20,37 +29,19 @@ const APPLE_COMPATIBLE_AUDIO = new Set([ "opus", ]); -// Codec strings Jellyfin may report for DTS variants -const DTS_CODECS = new Set(["dts", "dca"]); - -const TRUEHD_CODECS = new Set(["truehd"]); - export function isAppleCompatible(codec: string): boolean { return APPLE_COMPATIBLE_AUDIO.has(codec.toLowerCase()); } -/** Maps (codec, profile, container) → target codec for transcoding. */ -export function transcodeTarget(codec: string, profile: string | null, container: string | null): string | null { - const c = codec.toLowerCase(); - const isMkv = !container || container.toLowerCase() === "mkv" || container.toLowerCase() === "matroska"; - - if (isAppleCompatible(c)) return null; // no transcode needed - - // DTS-HD MA and DTS:X are lossless → FLAC in MKV, EAC3 in MP4 - if (DTS_CODECS.has(c)) { - const p = (profile ?? "").toLowerCase(); - const isLossless = p.includes("ma") || p.includes("hd ma") || p.includes("x"); - if (isLossless) return isMkv ? "flac" : "eac3"; - // Lossy DTS variants → EAC3 - return "eac3"; - } - - // TrueHD (including Atmos) → FLAC in MKV, EAC3 in MP4 - if (TRUEHD_CODECS.has(c)) { - return isMkv ? "flac" : "eac3"; - } - - // Any other incompatible codec → EAC3 as safe fallback +/** + * Maps (codec, profile, container) → target codec for transcoding. + * Returns null when the source is already Apple-compatible. + * + * profile and container are no longer consulted (kept in the signature + * for call-site stability and potential future per-source tuning). + */ +export function transcodeTarget(codec: string, _profile: string | null, _container: string | null): string | null { + if (isAppleCompatible(codec.toLowerCase())) return null; return "eac3"; }