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