Files
netfelix-audio-fix/server/api/__tests__/review-sort-inbox.test.ts
T
felixfoertsch a21bcefb54
Build and Push Docker Image / build (push) Successful in 1m18s
stream auto review progress over SSE so large inboxes don't feel frozen
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>
2026-04-19 20:56:51 +02:00

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 },
]);
});
});