diff --git a/package.json b/package.json index 12fa3de..4e92986 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.19.7", + "version": "2026.04.19.8", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/__tests__/review-sort-inbox.test.ts b/server/api/__tests__/review-sort-inbox.test.ts index 5e4a273..78907ce 100644 --- a/server/api/__tests__/review-sort-inbox.test.ts +++ b/server/api/__tests__/review-sort-inbox.test.ts @@ -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 }, + ]); + }); }); diff --git a/server/api/execute.ts b/server/api/execute.ts index d45cd85..4038168 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -144,6 +144,16 @@ export function emitInboxSorted(result: { moved_to_queue: number; moved_to_revie for (const l of jobListeners) l(line); } +export function emitInboxSortStart(total: number): void { + const line = `event: inbox_sort_start\ndata: ${JSON.stringify({ total })}\n\n`; + for (const l of jobListeners) l(line); +} + +export function emitInboxSortProgress(processed: number, total: number): void { + const line = `event: inbox_sort_progress\ndata: ${JSON.stringify({ processed, total })}\n\n`; + for (const l of jobListeners) l(line); +} + /** Parse "Duration: HH:MM:SS.MS" from ffmpeg startup output. */ function parseFFmpegDuration(line: string): number | null { const match = line.match(/Duration:\s*(\d+):(\d+):(\d+)\.(\d+)/); diff --git a/server/api/review.ts b/server/api/review.ts index b3ec4e5..768c336 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -5,7 +5,7 @@ import { analyzeItem, assignTargetOrder } from "../services/analyzer"; import { buildCommand } from "../services/ffmpeg"; import { getItem, mapStream, normalizeLanguage, refreshItem } from "../services/jellyfin"; import type { Job, MediaItem, MediaStream, ReviewPlan, StreamDecision } from "../types"; -import { emitInboxSorted } from "./execute"; +import { emitInboxSorted, emitInboxSortProgress, emitInboxSortStart } from "./execute"; const app = new Hono(); @@ -48,13 +48,31 @@ export interface SortInboxResult { moved_to_review: number; } +export interface SortInboxHooks { + onStart?: (total: number) => void; + onProgress?: (processed: number, total: number) => void; +} + +// Yield to the event loop every N items so SSE writes flush and other +// requests get a turn. 10 lands around 20 yields/second at typical +// reanalyze speed — smooth progress without thrashing. +const SORT_INBOX_YIELD_EVERY = 10; + /** * Distribute every unsorted (sorted=0) pending plan to its final bucket: * auto → sorted=1, status='approved', job enqueued (→ Queue) * auto_heuristic → sorted=1 (→ Review, badge ⚡) * manual → sorted=1 (→ Review, badge ✋) + * + * Emits progress via the optional hooks so the UI can render a live + * counter during long sorts; ticks after every item (including noops + * that get skipped) so the progress bar tracks real work done. */ -export function sortInbox(db: ReturnType, audioLanguages: string[]): SortInboxResult { +export async function sortInbox( + db: ReturnType, + audioLanguages: string[], + hooks?: SortInboxHooks, +): Promise { // Snapshot the ids first — reanalyze() below rewrites stream_decisions and // the plan's auto_class/is_noop so we must re-read the plan after each // reanalysis rather than trusting this initial select. @@ -66,8 +84,12 @@ export function sortInbox(db: ReturnType, audioLanguages: string[] `) .all() as { item_id: number }[]; + const total = unsortedIds.length; + hooks?.onStart?.(total); + let movedToQueue = 0; let movedToReview = 0; + let processed = 0; for (const { item_id } of unsortedIds) { // Re-run the analyzer so settings changes made after the scan (e.g. @@ -79,21 +101,28 @@ export function sortInbox(db: ReturnType, audioLanguages: string[] const plan = db.prepare("SELECT id, auto_class, is_noop FROM review_plans WHERE item_id = ?").get(item_id) as | { id: number; auto_class: string | null; is_noop: number } | undefined; - if (!plan) continue; - // Item became a noop after reanalysis — drop out of the sort loop; the - // is_noop filter naturally removes it from both Inbox and Review. - if (plan.is_noop) continue; - if (plan.auto_class === "auto") { - db - .prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now'), sorted = 1 WHERE id = ?") - .run(plan.id); - const { item, streams, decisions } = loadItemDetail(db, item_id); - if (item) enqueueAudioJob(db, item_id, buildCommand(item, streams, decisions)); - movedToQueue += 1; - } else { - db.prepare("UPDATE review_plans SET sorted = 1 WHERE id = ?").run(plan.id); - movedToReview += 1; + if (plan && !plan.is_noop) { + if (plan.auto_class === "auto") { + db + .prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now'), sorted = 1 WHERE id = ?") + .run(plan.id); + const { item, streams, decisions } = loadItemDetail(db, item_id); + if (item) enqueueAudioJob(db, item_id, buildCommand(item, streams, decisions)); + movedToQueue += 1; + } else { + db.prepare("UPDATE review_plans SET sorted = 1 WHERE id = ?").run(plan.id); + movedToReview += 1; + } + } + // plans that vanished (!plan) or became noops fall through — the + // is_noop filter already excludes them from both Inbox and Review. + + processed += 1; + hooks?.onProgress?.(processed, total); + + if (processed % SORT_INBOX_YIELD_EVERY === 0 && processed < total) { + await Bun.sleep(0); } } @@ -901,11 +930,28 @@ app.post("/approve-batch", async (c) => { // Distributor: walks every unsorted plan and moves it to Queue (auto) or Review // (auto_heuristic / manual). Called by the user's "Auto Review" button and by // the rescan hook when auto_processing is enabled. -app.post("/sort-inbox", (c) => { - const db = getDb(); - const result = sortInbox(db, getAudioLanguages()); - emitInboxSorted(result); - return c.json({ ok: true, ...result }); +// Module-level guard so a second "Auto Review" click (or an auto-scan hook +// racing with the user's click) can't start a parallel sort against the same +// inbox. The second caller gets a 409 and the UI keeps the first sort's +// progress visible. +let sortInboxRunning = false; + +app.post("/sort-inbox", async (c) => { + if (sortInboxRunning) { + return c.json({ ok: false, error: "sort already running" }, 409); + } + sortInboxRunning = true; + try { + const db = getDb(); + const result = await sortInbox(db, getAudioLanguages(), { + onStart: emitInboxSortStart, + onProgress: emitInboxSortProgress, + }); + emitInboxSorted(result); + return c.json({ ok: true, ...result }); + } finally { + sortInboxRunning = false; + } }); // ─── Approve all ready ─────────────────────────────────────────────────────── diff --git a/server/api/scan.ts b/server/api/scan.ts index f9dfbda..cbcd560 100644 --- a/server/api/scan.ts +++ b/server/api/scan.ts @@ -419,8 +419,11 @@ async function runScan(limit: number | null = null): Promise { if (getConfig("auto_processing") === "1") { const { sortInbox, getAudioLanguages } = await import("./review"); - const { emitInboxSorted } = await import("./execute"); - const result = sortInbox(db, getAudioLanguages()); + const { emitInboxSorted, emitInboxSortStart, emitInboxSortProgress } = await import("./execute"); + const result = await sortInbox(db, getAudioLanguages(), { + onStart: emitInboxSortStart, + onProgress: emitInboxSortProgress, + }); emitInboxSorted(result); } } diff --git a/server/api/settings.ts b/server/api/settings.ts index 47ca9a9..b63a664 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -126,8 +126,11 @@ app.post("/auto-processing", async (c) => { if (body.enabled) { const { sortInbox, getAudioLanguages } = await import("./review"); - const { emitInboxSorted } = await import("./execute"); - const result = sortInbox(getDb(), getAudioLanguages()); + const { emitInboxSorted, emitInboxSortStart, emitInboxSortProgress } = await import("./execute"); + const result = await sortInbox(getDb(), getAudioLanguages(), { + onStart: emitInboxSortStart, + onProgress: emitInboxSortProgress, + }); emitInboxSorted(result); return c.json({ ok: true, enabled: true, ...result }); } diff --git a/src/features/pipeline/InboxColumn.tsx b/src/features/pipeline/InboxColumn.tsx index d18283e..aacfb2e 100644 --- a/src/features/pipeline/InboxColumn.tsx +++ b/src/features/pipeline/InboxColumn.tsx @@ -14,6 +14,7 @@ interface InboxColumnProps { onToggleAutoProcessing: (enabled: boolean) => void; jellyfinUrl: string; onMutate: () => void; + sortProgress: { processed: number; total: number } | null; } export function InboxColumn({ @@ -23,6 +24,7 @@ export function InboxColumn({ onToggleAutoProcessing, jellyfinUrl, onMutate, + sortProgress, }: InboxColumnProps) { const [groups, setGroups] = useState(initialResponse.groups); const [hasMore, setHasMore] = useState(initialResponse.hasMore); @@ -71,7 +73,23 @@ export function InboxColumn({ onMutate(); }; - const subtitle = ( + // Progress bar fills the subtitle slot during an active sort so the user + // sees real work happening instead of a frozen button. The auto-process + // toggle hides while a sort runs — it can't be flipped meaningfully + // mid-pass and the progress deserves the full line of visual real estate. + const sorting = sortProgress !== null; + const pct = + sortProgress && sortProgress.total > 0 ? Math.round((sortProgress.processed / sortProgress.total) * 100) : 0; + const subtitle = sorting ? ( +
+ + Sorting {sortProgress.processed}/{sortProgress.total} + +
+
+
+
+ ) : (