a21bcefb54
Build and Push Docker Image / build (push) Successful in 1m18s
sortInbox is now async, yields every 10 items, and emits inbox_sort_start + inbox_sort_progress via optional hooks. the pipeline route handler wires those hooks to the existing job events stream and guards against a second concurrent sort with a 409. the inbox column swaps its Auto Review button for a live "Sorting N/T" counter and progress bar while the sort is in flight; the auto-process toggle hides to give the progress the full subtitle line. the previous behaviour was a frozen button for the entire duration of the sort. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
7.7 KiB
TypeScript
250 lines
7.7 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { describe, expect, test } from "bun:test";
|
|
import { SCHEMA } from "../../db/schema";
|
|
import { sortInbox } from "../review";
|
|
|
|
function makeDb(): Database {
|
|
const db = new Database(":memory:");
|
|
for (const stmt of SCHEMA.split(";")) {
|
|
const trimmed = stmt.trim();
|
|
if (trimmed) db.run(trimmed);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
interface AudioSeed {
|
|
stream_index: number;
|
|
language: string | null;
|
|
title?: string | null;
|
|
}
|
|
|
|
interface SeedOpts {
|
|
id: number;
|
|
origLang: string | null;
|
|
origLangSource: "radarr" | "sonarr" | "manual" | "jellyfin" | null;
|
|
needsReview?: number;
|
|
audio: AudioSeed[];
|
|
}
|
|
|
|
function seedItem(db: Database, opts: SeedOpts): void {
|
|
db
|
|
.prepare(
|
|
"INSERT INTO media_items (id, jellyfin_id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, ?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
|
|
)
|
|
.run(
|
|
opts.id,
|
|
`jf-${opts.id}`,
|
|
`Item ${opts.id}`,
|
|
`/x/${opts.id}.mkv`,
|
|
opts.origLang,
|
|
opts.origLangSource,
|
|
opts.needsReview ?? 0,
|
|
);
|
|
db
|
|
.prepare(
|
|
"INSERT INTO media_streams (item_id, stream_index, type, codec, language) VALUES (?, 0, 'Video', 'h264', NULL)",
|
|
)
|
|
.run(opts.id);
|
|
for (const a of opts.audio) {
|
|
db
|
|
.prepare(
|
|
"INSERT INTO media_streams (item_id, stream_index, type, codec, language, title, channels) VALUES (?, ?, 'Audio', 'eac3', ?, ?, 2)",
|
|
)
|
|
.run(opts.id, a.stream_index, a.language, a.title ?? null);
|
|
}
|
|
// Placeholder plan. sortInbox reanalyzes and overwrites auto_class /
|
|
// is_noop / decisions, so the seeded values don't need to be correct.
|
|
db
|
|
.prepare(
|
|
"INSERT INTO review_plans (item_id, status, is_noop, auto_class, sorted, apple_compat, job_type) VALUES (?, 'pending', 0, 'manual', 0, 'direct_play', 'copy')",
|
|
)
|
|
.run(opts.id);
|
|
}
|
|
|
|
describe("sortInbox", () => {
|
|
test("authoritative OG with only OG-language audio → auto → queue", async () => {
|
|
const db = makeDb();
|
|
seedItem(db, {
|
|
id: 1,
|
|
origLang: "eng",
|
|
origLangSource: "radarr",
|
|
audio: [{ stream_index: 1, language: "eng" }],
|
|
});
|
|
|
|
const result = await sortInbox(db, []);
|
|
|
|
expect(result.moved_to_queue).toBe(1);
|
|
expect(result.moved_to_review).toBe(0);
|
|
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
|
status: string;
|
|
sorted: number;
|
|
auto_class: string;
|
|
};
|
|
expect(plan.status).toBe("approved");
|
|
expect(plan.sorted).toBe(1);
|
|
expect(plan.auto_class).toBe("auto");
|
|
const job = db.prepare("SELECT status FROM jobs WHERE item_id = 1").get() as { status: string };
|
|
expect(job.status).toBe("pending");
|
|
});
|
|
|
|
test("commentary track triggers auto_heuristic → review, no job", async () => {
|
|
const db = makeDb();
|
|
seedItem(db, {
|
|
id: 1,
|
|
origLang: "eng",
|
|
origLangSource: "radarr",
|
|
audio: [
|
|
{ stream_index: 1, language: "eng" },
|
|
{ stream_index: 2, language: "eng", title: "Director's Commentary" },
|
|
],
|
|
});
|
|
|
|
const result = await sortInbox(db, []);
|
|
|
|
expect(result.moved_to_queue).toBe(0);
|
|
expect(result.moved_to_review).toBe(1);
|
|
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
|
status: string;
|
|
sorted: number;
|
|
auto_class: string;
|
|
};
|
|
expect(plan.status).toBe("pending");
|
|
expect(plan.sorted).toBe(1);
|
|
expect(plan.auto_class).toBe("auto_heuristic");
|
|
const jobCount = (db.prepare("SELECT COUNT(*) as n FROM jobs WHERE item_id = 1").get() as { n: number }).n;
|
|
expect(jobCount).toBe(0);
|
|
});
|
|
|
|
test("missing authoritative OG → manual → review, no job", async () => {
|
|
const db = makeDb();
|
|
seedItem(db, {
|
|
id: 1,
|
|
origLang: null,
|
|
origLangSource: null,
|
|
needsReview: 1,
|
|
audio: [{ stream_index: 1, language: "eng" }],
|
|
});
|
|
|
|
const result = await sortInbox(db, []);
|
|
|
|
expect(result.moved_to_review).toBe(1);
|
|
const plan = db.prepare("SELECT status, sorted, auto_class FROM review_plans WHERE item_id = 1").get() as {
|
|
status: string;
|
|
sorted: number;
|
|
auto_class: string;
|
|
};
|
|
expect(plan.sorted).toBe(1);
|
|
expect(plan.status).toBe("pending");
|
|
expect(plan.auto_class).toBe("manual");
|
|
});
|
|
|
|
test("already sorted plans are untouched", async () => {
|
|
const db = makeDb();
|
|
seedItem(db, {
|
|
id: 1,
|
|
origLang: "eng",
|
|
origLangSource: "radarr",
|
|
audio: [{ stream_index: 1, language: "eng" }],
|
|
});
|
|
db.prepare("UPDATE review_plans SET sorted = 1 WHERE item_id = 1").run();
|
|
|
|
const result = await sortInbox(db, []);
|
|
|
|
expect(result.moved_to_queue).toBe(0);
|
|
expect(result.moved_to_review).toBe(0);
|
|
});
|
|
|
|
// Regression for: settings change (drop a language from audio_languages)
|
|
// followed by "back to inbox" + "auto review" left the old decisions in
|
|
// place. sortInbox must re-run the analyzer against the current config
|
|
// so the dropped language is actually removed this time around.
|
|
test("reanalyzes on each run → honors current audio_languages", async () => {
|
|
const db = makeDb();
|
|
seedItem(db, {
|
|
id: 1,
|
|
origLang: "eng",
|
|
origLangSource: "radarr",
|
|
audio: [
|
|
{ stream_index: 1, language: "eng" },
|
|
{ stream_index: 2, language: "deu" },
|
|
],
|
|
});
|
|
|
|
// First pass: user had "keep German" on, so German is kept and the
|
|
// plan auto-queues with both tracks preserved.
|
|
const firstPass = await sortInbox(db, ["deu"]);
|
|
expect(firstPass.moved_to_queue).toBe(1);
|
|
const firstActions = db
|
|
.prepare(`
|
|
SELECT ms.language, sd.action
|
|
FROM stream_decisions sd
|
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
|
JOIN review_plans rp ON rp.id = sd.plan_id
|
|
WHERE rp.item_id = 1 AND ms.type = 'Audio'
|
|
`)
|
|
.all() as { language: string; action: string }[];
|
|
expect(Object.fromEntries(firstActions.map((r) => [r.language, r.action]))).toEqual({
|
|
eng: "keep",
|
|
deu: "keep",
|
|
});
|
|
|
|
// User changes their mind, batches the plan back to Inbox, and
|
|
// toggles German off. The stored decisions still say "keep" for
|
|
// German — but the next sortInbox must re-derive them.
|
|
db.prepare("UPDATE review_plans SET status = 'pending', sorted = 0, reviewed_at = NULL WHERE item_id = 1").run();
|
|
db.prepare("DELETE FROM jobs WHERE item_id = 1").run();
|
|
|
|
const secondPass = await sortInbox(db, []);
|
|
expect(secondPass.moved_to_queue).toBe(1);
|
|
const secondActions = db
|
|
.prepare(`
|
|
SELECT ms.language, sd.action
|
|
FROM stream_decisions sd
|
|
JOIN media_streams ms ON ms.id = sd.stream_id
|
|
JOIN review_plans rp ON rp.id = sd.plan_id
|
|
WHERE rp.item_id = 1 AND ms.type = 'Audio'
|
|
`)
|
|
.all() as { language: string; action: string }[];
|
|
expect(Object.fromEntries(secondActions.map((r) => [r.language, r.action]))).toEqual({
|
|
eng: "keep",
|
|
deu: "remove",
|
|
});
|
|
|
|
const job = db.prepare("SELECT command FROM jobs WHERE item_id = 1 AND status = 'pending'").get() as
|
|
| { command: string }
|
|
| undefined;
|
|
expect(job).toBeDefined();
|
|
// The rebuilt ffmpeg command maps only the English audio. In
|
|
// type-relative specifiers (0:a:N) English is a:0 and German
|
|
// would have been a:1 — the latter must be absent.
|
|
expect(job?.command).toContain("-map 0:a:0");
|
|
expect(job?.command).not.toContain("-map 0:a:1");
|
|
});
|
|
|
|
test("emits start + progress hooks once per item", async () => {
|
|
const db = makeDb();
|
|
for (let i = 1; i <= 3; i++) {
|
|
seedItem(db, {
|
|
id: i,
|
|
origLang: "eng",
|
|
origLangSource: "radarr",
|
|
audio: [{ stream_index: 1, language: "eng" }],
|
|
});
|
|
}
|
|
|
|
const startCalls: number[] = [];
|
|
const progressCalls: Array<{ processed: number; total: number }> = [];
|
|
await sortInbox(db, [], {
|
|
onStart: (total) => startCalls.push(total),
|
|
onProgress: (processed, total) => progressCalls.push({ processed, total }),
|
|
});
|
|
|
|
expect(startCalls).toEqual([3]);
|
|
expect(progressCalls).toEqual([
|
|
{ processed: 1, total: 3 },
|
|
{ processed: 2, total: 3 },
|
|
{ processed: 3, total: 3 },
|
|
]);
|
|
});
|
|
});
|