- execute: actually call isInScheduleWindow/waitForWindow/sleepBetweenJobs in runSequential (they were dead code); emit queue_status SSE events (running/paused/sleeping/idle) so the pipeline's existing QueueStatus listener lights up - review: POST /:id/retry resets an errored plan to approved, wipes old done/error jobs, rebuilds command from current decisions, queues fresh job - scan: dev-mode DELETE now also wipes jobs + subtitle_files (previously orphaned after every dev reset) - biome: migrate config to 2.4 schema, autoformat 68 files (strings + indentation), relax opinionated a11y/hooks-deps/index-key rules that don't fit this codebase - routeTree.gen.ts regenerated after /nodes removal
186 lines
7.0 KiB
TypeScript
186 lines
7.0 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import type { MediaStream } from "../../types";
|
|
import { analyzeItem } from "../analyzer";
|
|
|
|
type StreamOverride = Partial<MediaStream> & Pick<MediaStream, "id" | "type" | "stream_index">;
|
|
|
|
function stream(o: StreamOverride): MediaStream {
|
|
return {
|
|
item_id: 1,
|
|
codec: 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,
|
|
...o,
|
|
};
|
|
}
|
|
|
|
const ITEM_DEFAULTS = { needs_review: 0 as number, container: "mkv" as string | null };
|
|
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
audioLanguages: ["deu"],
|
|
});
|
|
expect(result.is_noop).toBe(false);
|
|
});
|
|
|
|
test("audioOrderChanged is_noop=true when OG audio is already first", () => {
|
|
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" }),
|
|
];
|
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: [],
|
|
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, {
|
|
subtitleLanguages: ["eng"],
|
|
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 → 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" }),
|
|
];
|
|
const result = analyzeItem({ ...ITEM_DEFAULTS, original_language: "eng" }, streams, {
|
|
subtitleLanguages: [],
|
|
audioLanguages: [],
|
|
});
|
|
expect(result.is_noop).toBe(true);
|
|
});
|
|
});
|
|
|
|
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({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
|
subtitleLanguages: [],
|
|
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({ original_language: "eng", needs_review: 0, container: "mp4" }, streams, {
|
|
subtitleLanguages: [],
|
|
audioLanguages: [],
|
|
});
|
|
expect(result.decisions[0].transcode_codec).toBe(null);
|
|
expect(result.job_type).toBe("copy");
|
|
});
|
|
});
|