Files
netfelix-audio-fix/server/api/__tests__/review-sort-inbox.test.ts
T
felixfoertsch 9d65dd12be
Build and Push Docker Image / build (push) Successful in 2m10s
pipeline ux: actionable errors, inbox sorting, danger stop buttons, overlapping checkmarks
- clickable error count in header shows file names + error messages
- inbox sort dropdown (scan time / name, asc / desc)
- inbox movies no longer minimal (show available badges)
- stop buttons use solid danger style, descriptive labels (Stop Scan, Stop Job, Stop Sorting)
- double checkmarks overlap like WhatsApp read receipts
- processInbox logs start/completion to stdout for Docker visibility
- fix byTitle in language-resolver test, bump to 2026.04.21.1

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 08:58:08 +02:00

242 lines
7.7 KiB
TypeScript

import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { SCHEMA } from "../../db/schema";
import { processInbox } 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: "probe" | "radarr" | "sonarr" | "manual" | null;
needsReview?: number;
audio: AudioSeed[];
}
function seedItem(db: Database, opts: SeedOpts): void {
db
.prepare(
"INSERT INTO media_items (id, type, name, file_path, container, original_language, orig_lang_source, needs_review) VALUES (?, 'Movie', ?, ?, 'mkv', ?, ?, ?)",
)
.run(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. processInbox 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("processInbox", () => {
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 processInbox(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 processInbox(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 processInbox(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 processInbox(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. processInbox 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 processInbox(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 processInbox 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 processInbox(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 processInbox(db, [], undefined, {
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 },
]);
});
});