import { describe, expect, test } from "bun:test"; import type { MediaItem, MediaStream } from "../../types"; import { analyzeItem } from "../analyzer"; type OrigLangSource = MediaItem["orig_lang_source"]; type StreamOverride = Partial & Pick; function stream(o: StreamOverride): MediaStream { return { item_id: 1, codec: null, profile: null, language: null, language_display: null, title: null, is_default: 0, is_forced: 0, is_hearing_impaired: 0, channels: null, channel_layout: null, bit_rate: null, sample_rate: null, bit_depth: null, ...o, }; } const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null, orig_lang_source: null as OrigLangSource, }; describe("analyzeItem — audio keep rules", () => { test("keeps only OG + configured languages, drops others", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }), stream({ id: 4, type: "Audio", stream_index: 3, codec: "aac", language: "fra" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: ["deu"], }); const actions = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); expect(actions).toEqual({ 1: "keep", 2: "keep", 3: "keep", 4: "remove" }); }); test("keeps all audio when OG language unknown", () => { const streams = [ stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }), stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }), stream({ id: 3, type: "Audio", stream_index: 2, language: "fra" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: null, needs_review: 1 }, streams, { audioLanguages: ["deu"], }); expect(result.decisions.every((d) => d.action === "keep")).toBe(true); expect(result.notes.some((n) => n.includes("manual review"))).toBe(true); }); test("keeps audio tracks with undetermined language", () => { const streams = [ stream({ id: 1, type: "Audio", stream_index: 0, language: "eng" }), stream({ id: 2, type: "Audio", stream_index: 1, language: null }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [], }); const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.action])); expect(byId[2]).toBe("keep"); }); test("normalizes language codes (ger → deu)", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, language: "ger" })]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "deu" }, streams, { audioLanguages: [], }); expect(result.decisions[0].action).toBe("keep"); }); }); describe("analyzeItem — audio ordering", () => { test("OG first, then additional languages in configured order", () => { const streams = [ stream({ id: 10, type: "Audio", stream_index: 0, codec: "aac", language: "deu" }), stream({ id: 11, type: "Audio", stream_index: 1, codec: "aac", language: "eng" }), stream({ id: 12, type: "Audio", stream_index: 2, codec: "aac", language: "spa" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: ["deu", "spa"], }); const byId = Object.fromEntries(result.decisions.map((d) => [d.stream_id, d.target_index])); expect(byId[11]).toBe(0); // eng (OG) first expect(byId[10]).toBe(1); // deu second expect(byId[12]).toBe(2); // spa third }); test("audioOrderChanged is_noop=false when OG audio is not first in input", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, language: "deu" }), stream({ id: 3, type: "Audio", stream_index: 2, language: "eng" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: ["deu"], }); expect(result.is_noop).toBe(false); }); test("audioOrderChanged is_noop=true when OG audio is already first and default", () => { 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 }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "aac", language: "deu" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: ["deu"], }); expect(result.is_noop).toBe(true); }); test("removing an audio track triggers non-noop even if OG first", () => { const streams = [ stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "fra" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [], }); expect(result.is_noop).toBe(false); }); }); describe("analyzeItem — subtitles & is_noop", () => { test("subtitles are always marked remove (extracted to sidecar)", () => { const streams = [ stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" }), stream({ id: 2, type: "Subtitle", stream_index: 1, codec: "subrip", language: "eng" }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [], }); const subDec = result.decisions.find((d) => d.stream_id === 2); expect(subDec?.action).toBe("remove"); expect(result.is_noop).toBe(false); // subs present → not noop }); test("no audio change, no subs, OG already default+canonical → is_noop true", () => { 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" }, streams, { audioLanguages: [], }); expect(result.is_noop).toBe(true); }); test("OG audio present but not default → is_noop false (pipeline would set default)", () => { 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: 0 }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [], }); expect(result.is_noop).toBe(false); }); test("non-canonical language tag (en instead of eng) → is_noop false", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "aac", language: "en", is_default: 1 }), ]; const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, { audioLanguages: [], }); expect(result.is_noop).toBe(false); }); }); describe("analyzeItem — transcode targets", () => { test("DTS on mp4 → transcode to eac3", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "dts", language: "eng" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", needs_review: 0, container: "mp4" }, streams, { audioLanguages: [] }, ); expect(result.decisions[0].transcode_codec).toBe("eac3"); expect(result.job_type).toBe("transcode"); expect(result.is_noop).toBe(false); }); test("AAC passes through without transcode", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "aac", language: "eng" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", needs_review: 0, container: "mp4" }, streams, { audioLanguages: [] }, ); expect(result.decisions[0].transcode_codec).toBe(null); 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("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 }), 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); }); }); describe("analyzeItem — auto_class classification", () => { const AUTHORITATIVE = { ...ITEM_DEFAULTS, original_language: "eng" as string | null, orig_lang_source: "radarr" as OrigLangSource, needs_review: 0, }; test("one OG language track → auto", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }), ]; const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] }); expect(result.auto_class).toBe("auto"); }); test("OG + additional configured language, both kept → auto", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "eac3", language: "deu", channels: 6 }), ]; const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: ["deu"] }); expect(result.auto_class).toBe("auto"); }); test("two English tracks resolved by channel count → auto", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "dts", language: "eng", channels: 6, title: "English 5.1" }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "ac3", language: "eng", channels: 2, title: "English Stereo", }), ]; const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] }); expect(result.auto_class).toBe("auto"); }); test("commentary track dropped by title heuristic → auto_heuristic", () => { const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6, title: "English 5.1" }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "ac3", language: "eng", channels: 2, title: "Director's Commentary", }), ]; const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] }); expect(result.auto_class).toBe("auto_heuristic"); }); test("OG language unknown → manual", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: null, orig_lang_source: null, needs_review: 1 }, streams, { audioLanguages: [] }, ); expect(result.auto_class).toBe("manual"); }); test("OG known but not present in any audio track → manual", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "deu" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 0 }, streams, { audioLanguages: [] }, ); expect(result.auto_class).toBe("manual"); }); test("kept audio track with null language tag → manual", () => { const streams = [ stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: null }), ]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 0 }, streams, { audioLanguages: [] }, ); expect(result.auto_class).toBe("manual"); }); test("needs_review=1 → manual even with known OG", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "radarr", needs_review: 1 }, streams, { audioLanguages: [] }, ); expect(result.auto_class).toBe("manual"); }); test("non-OG track with coincidental commentary-ish title → auto (not auto_heuristic)", () => { // OG is English. German track is removed for LANGUAGE reasons, not title. // Its title coincidentally contains 'commentary', but that should not // upgrade the classification to auto_heuristic. const streams = [ stream({ id: 1, type: "Video", stream_index: 0, codec: "h264" }), stream({ id: 2, type: "Audio", stream_index: 1, codec: "eac3", language: "eng", channels: 6 }), stream({ id: 3, type: "Audio", stream_index: 2, codec: "ac3", language: "deu", channels: 2, title: "German Commentary Audio", }), ]; const result = analyzeItem(AUTHORITATIVE, streams, { audioLanguages: [] }); expect(result.auto_class).toBe("auto"); }); test("orig_lang_source=jellyfin is not authoritative → manual", () => { const streams = [stream({ id: 1, type: "Audio", stream_index: 0, codec: "eac3", language: "eng" })]; const result = analyzeItem( { ...ITEM_DEFAULTS, original_language: "eng", orig_lang_source: "jellyfin", needs_review: 0 }, streams, { audioLanguages: [] }, ); expect(result.auto_class).toBe("manual"); }); });