audio: single EAC3 transcode target, prefer direct-play over lossless default
All checks were successful
Build and Push Docker Image / build (push) Successful in 47s

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.
This commit is contained in:
2026-04-14 10:23:49 +02:00
parent aca627930f
commit 4057b692ba
4 changed files with 69 additions and 38 deletions

View File

@@ -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 }),

View File

@@ -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;
}

View File

@@ -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";
}