stream auto review progress over SSE so large inboxes don't feel frozen
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>
This commit is contained in:
2026-04-19 20:56:51 +02:00
parent 76a16ba84c
commit a21bcefb54
8 changed files with 176 additions and 41 deletions
+37 -11
View File
@@ -62,7 +62,7 @@ function seedItem(db: Database, opts: SeedOpts): void {
}
describe("sortInbox", () => {
test("authoritative OG with only OG-language audio → auto → queue", () => {
test("authoritative OG with only OG-language audio → auto → queue", async () => {
const db = makeDb();
seedItem(db, {
id: 1,
@@ -71,7 +71,7 @@ describe("sortInbox", () => {
audio: [{ stream_index: 1, language: "eng" }],
});
const result = sortInbox(db, []);
const result = await sortInbox(db, []);
expect(result.moved_to_queue).toBe(1);
expect(result.moved_to_review).toBe(0);
@@ -87,7 +87,7 @@ describe("sortInbox", () => {
expect(job.status).toBe("pending");
});
test("commentary track triggers auto_heuristic → review, no job", () => {
test("commentary track triggers auto_heuristic → review, no job", async () => {
const db = makeDb();
seedItem(db, {
id: 1,
@@ -99,7 +99,7 @@ describe("sortInbox", () => {
],
});
const result = sortInbox(db, []);
const result = await sortInbox(db, []);
expect(result.moved_to_queue).toBe(0);
expect(result.moved_to_review).toBe(1);
@@ -115,7 +115,7 @@ describe("sortInbox", () => {
expect(jobCount).toBe(0);
});
test("missing authoritative OG → manual → review, no job", () => {
test("missing authoritative OG → manual → review, no job", async () => {
const db = makeDb();
seedItem(db, {
id: 1,
@@ -125,7 +125,7 @@ describe("sortInbox", () => {
audio: [{ stream_index: 1, language: "eng" }],
});
const result = sortInbox(db, []);
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 {
@@ -138,7 +138,7 @@ describe("sortInbox", () => {
expect(plan.auto_class).toBe("manual");
});
test("already sorted plans are untouched", () => {
test("already sorted plans are untouched", async () => {
const db = makeDb();
seedItem(db, {
id: 1,
@@ -148,7 +148,7 @@ describe("sortInbox", () => {
});
db.prepare("UPDATE review_plans SET sorted = 1 WHERE item_id = 1").run();
const result = sortInbox(db, []);
const result = await sortInbox(db, []);
expect(result.moved_to_queue).toBe(0);
expect(result.moved_to_review).toBe(0);
@@ -158,7 +158,7 @@ describe("sortInbox", () => {
// 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", () => {
test("reanalyzes on each run → honors current audio_languages", async () => {
const db = makeDb();
seedItem(db, {
id: 1,
@@ -172,7 +172,7 @@ describe("sortInbox", () => {
// First pass: user had "keep German" on, so German is kept and the
// plan auto-queues with both tracks preserved.
const firstPass = sortInbox(db, ["deu"]);
const firstPass = await sortInbox(db, ["deu"]);
expect(firstPass.moved_to_queue).toBe(1);
const firstActions = db
.prepare(`
@@ -194,7 +194,7 @@ describe("sortInbox", () => {
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 = sortInbox(db, []);
const secondPass = await sortInbox(db, []);
expect(secondPass.moved_to_queue).toBe(1);
const secondActions = db
.prepare(`
@@ -220,4 +220,30 @@ describe("sortInbox", () => {
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 },
]);
});
});