8112bfeb65
Build and Push Docker Image / build (push) Successful in 3m3s
adds stream_decisions.custom_language (ISO 639-2 code or null) so the user can correct a mislabeled audio track — e.g. a Spanish dub tagged "und" in the container — without going through Jellyfin. the override wins over stream.language everywhere it matters: the analyzer reads it for keep/remove decisions and track ordering, the ffmpeg command builder writes it as both the language metadata tag and the harmonized track title, and reanalyze preserves it across reruns and rescans. on the audio detail page, each pending audio row swaps its language cell for an inline <select> populated from LANG_NAMES. picking the raw file language clears the override; anything else sets it and triggers a server-side reanalyze so keep/remove + target_index update immediately. a small ✎ hint marks overridden tracks. rebuilt commands tag the output accordingly so Jellyfin reads the corrected language. PATCH /api/review/:id/stream/:streamId/language validates the code against LANG_NAMES (accepts ISO 639-1/2/2B aliases, rejects garbage) and runs reanalyze inside. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
524 lines
20 KiB
TypeScript
524 lines
20 KiB
TypeScript
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<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
|
|
|
|
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");
|
|
});
|
|
|
|
test("custom_language override wins over stream.language for keep/remove", () => {
|
|
// File says UND, user corrects it to Spanish. With OG=eng and no extra
|
|
// keep languages, the track should be removed — the override flowed
|
|
// into the decision just like a real "spa" tag would have.
|
|
const streams = [
|
|
stream({ id: 1, type: "Video", stream_index: 0 }),
|
|
stream({ id: 2, type: "Audio", stream_index: 1, language: null }),
|
|
];
|
|
const overrides = new Map<number, string>([[2, "spa"]]);
|
|
const removing = analyzeItem(
|
|
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
|
streams,
|
|
{ audioLanguages: [] },
|
|
overrides,
|
|
);
|
|
expect(removing.decisions.find((d) => d.stream_id === 2)?.action).toBe("remove");
|
|
|
|
// Same file, but Spanish is now in the keep list → kept.
|
|
const keeping = analyzeItem(
|
|
{ ...ITEM_DEFAULTS, original_language: "eng" },
|
|
streams,
|
|
{ audioLanguages: ["spa"] },
|
|
overrides,
|
|
);
|
|
expect(keeping.decisions.find((d) => d.stream_id === 2)?.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");
|
|
});
|
|
});
|