diff --git a/package.json b/package.json index 4e92986..4da7570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.19.8", + "version": "2026.04.19.9", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/__tests__/review-unsort-reopen.test.ts b/server/api/__tests__/review-unsort-reopen.test.ts index 1881628..6747c5e 100644 --- a/server/api/__tests__/review-unsort-reopen.test.ts +++ b/server/api/__tests__/review-unsort-reopen.test.ts @@ -59,7 +59,7 @@ describe("unsortAll", () => { }); describe("reopenAllDone", () => { - test("flips done/error plans back to pending and drops their jobs", () => { + test("flips done/error plans back to pending + inbox (sorted=0) and drops their jobs", () => { const db = makeDb(); seedPlan(db, 1, { status: "done" }); seedPlan(db, 2, { status: "error" }); @@ -71,15 +71,18 @@ describe("reopenAllDone", () => { const count = reopenAllDone(db); expect(count).toBe(2); - const statuses = db.prepare("SELECT item_id, status, reviewed_at FROM review_plans ORDER BY item_id").all() as { + const statuses = db + .prepare("SELECT item_id, status, sorted, reviewed_at FROM review_plans ORDER BY item_id") + .all() as { item_id: number; status: string; + sorted: number; reviewed_at: string | null; }[]; - expect(statuses[0]?.status).toBe("pending"); - expect(statuses[0]?.reviewed_at).toBeNull(); - expect(statuses[1]?.status).toBe("pending"); - expect(statuses[2]?.status).toBe("approved"); + expect(statuses[0]).toMatchObject({ status: "pending", sorted: 0, reviewed_at: null }); + expect(statuses[1]).toMatchObject({ status: "pending", sorted: 0, reviewed_at: null }); + // Untouched plan keeps its pre-existing sorted=1 (default for the seed). + expect(statuses[2]).toMatchObject({ status: "approved", sorted: 1 }); const jobs = db.prepare("SELECT item_id, status FROM jobs ORDER BY item_id").all(); expect(jobs).toEqual([{ item_id: 3, status: "pending" }]); diff --git a/server/api/execute.ts b/server/api/execute.ts index 4038168..c24c3ec 100644 --- a/server/api/execute.ts +++ b/server/api/execute.ts @@ -1,7 +1,7 @@ import { accessSync, constants } from "node:fs"; import { Hono } from "hono"; import { stream } from "hono/streaming"; -import { getDb } from "../db/index"; +import { getConfig, getDb } from "../db/index"; import { log, error as logError, warn } from "../lib/log"; import { parseId } from "../lib/validate"; import { predictExtractedFiles } from "../services/ffmpeg"; @@ -154,6 +154,22 @@ export function emitInboxSortProgress(processed: number, total: number): void { for (const l of jobListeners) l(line); } +/** + * If the queue runner is idle and the auto_process_queue config is on, kick + * off a sequential pass over whatever's currently pending. Used by the + * enqueue path (so a fresh approval immediately starts draining) and by the + * settings toggle (so flipping the checkbox drains existing queue items). + * Returns true when a new run was started. + */ +export function maybeStartQueueProcessor(): boolean { + if (queueRunning) return false; + if (getConfig("auto_process_queue") !== "1") return false; + const pending = getDb().prepare("SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at").all() as Job[]; + if (pending.length === 0) return false; + runSequential(pending).catch((err) => logError("Queue failed:", err)); + return true; +} + /** 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 768c336..9df5109 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, emitInboxSortProgress, emitInboxSortStart } from "./execute"; +import { emitInboxSorted, emitInboxSortProgress, emitInboxSortStart, maybeStartQueueProcessor } from "./execute"; const app = new Hono(); @@ -40,6 +40,12 @@ export function enqueueAudioJob(db: ReturnType, itemId: number, co WHERE NOT EXISTS (SELECT 1 FROM jobs WHERE item_id = ? AND status = 'pending') `) .run(itemId, command, itemId); + if (result.changes > 0) { + // Kick the processor if the user has opted into hands-off queue + // draining. No-ops when the runner is already active or the toggle + // is off, so every enqueue path gets the behaviour for free. + maybeStartQueueProcessor(); + } return result.changes > 0; } @@ -666,6 +672,7 @@ app.get("/pipeline", (c) => { ).n; const reviewManualCount = reviewItemsTotal - reviewReadyCount; const autoProcessing = getConfig("auto_processing") === "1"; + const autoProcessQueue = getConfig("auto_process_queue") === "1"; // Queued carries stream + transcode-reason enrichment so the card renders // read-only with a "Back to review" button. @@ -727,6 +734,7 @@ app.get("/pipeline", (c) => { reviewReadyCount, reviewManualCount, autoProcessing, + autoProcessQueue, queued, processing, done, @@ -978,14 +986,19 @@ app.post("/unsort-all", (c) => { return c.json({ ok: true, count }); }); -// ─── Reopen all done/errored (Done → Review) ───────────────────────────────── -// Backward counterpart of the per-item reopen: flips every finished plan -// back to pending and drops the finished job rows so the Done column clears. +// ─── Reopen all done/errored (Done → Inbox) ────────────────────────────────── +// Backward counterpart of the per-item reopen: flips every finished plan back +// to pending, sends it to the Inbox (sorted=0) so the next Auto Review pass +// can reclassify it, and drops the finished job rows so the Done column +// clears. We target Inbox rather than Review because settings may have +// changed since the original pass; Inbox forces reanalysis. export function reopenAllDone(db: ReturnType): number { let count = 0; db.transaction(() => { const result = db - .prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE status IN ('done', 'error')") + .prepare( + "UPDATE review_plans SET status = 'pending', reviewed_at = NULL, sorted = 0 WHERE status IN ('done', 'error')", + ) .run(); count = result.changes; db.prepare("DELETE FROM jobs WHERE status IN ('done', 'error')").run(); @@ -1119,12 +1132,13 @@ app.post("/:id/retry", (c) => { return c.json({ ok: true }); }); -// Reopen a completed or errored plan: flip it back to pending so the user -// can adjust decisions and re-approve. Used by the Done column's hover -// "Back to review" affordance. Unlike /unapprove (which rolls back an -// approved-but-not-yet-running plan), this handles the post-job states -// and drops the lingering job row so the pipeline doesn't show leftover -// history for an item that's about to be re-queued. +// Reopen a completed or errored plan: flip it back to pending and send it to +// the Inbox (sorted=0) so the user can adjust settings and have Auto Review +// redo the classification. Used by the Done column's hover "Back to inbox" +// affordance. Unlike /unapprove (which rolls back an approved-but-not-yet- +// running plan), this handles the post-job states and drops the lingering +// job row so the pipeline doesn't show leftover history for an item that's +// about to be re-sorted. app.post("/:id/reopen", (c) => { const db = getDb(); const id = parseId(c.req.param("id")); @@ -1137,7 +1151,7 @@ app.post("/:id/reopen", (c) => { db.transaction(() => { // Leave plan.notes alone so the user keeps any ffmpeg error summary // from the prior run — useful context when redeciding decisions. - db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL WHERE id = ?").run(plan.id); + db.prepare("UPDATE review_plans SET status = 'pending', reviewed_at = NULL, sorted = 0 WHERE id = ?").run(plan.id); db.prepare("DELETE FROM jobs WHERE item_id = ? AND status IN ('done', 'error')").run(id); })(); return c.json({ ok: true }); diff --git a/server/api/settings.ts b/server/api/settings.ts index b63a664..39d69ed 100644 --- a/server/api/settings.ts +++ b/server/api/settings.ts @@ -137,6 +137,24 @@ app.post("/auto-processing", async (c) => { return c.json({ ok: true, enabled: false }); }); +// Toggle the auto-process-queue flag. When flipped on, kick the queue +// processor so any already-pending jobs start draining immediately without +// waiting for the next approval to trigger it. +app.post("/auto-process-queue", async (c) => { + const body = await c.req.json<{ enabled?: unknown }>().catch(() => ({ enabled: null })); + if (typeof body.enabled !== "boolean") { + return c.json({ ok: false, error: "enabled must be a boolean" }, 400); + } + setConfig("auto_process_queue", body.enabled ? "1" : "0"); + + if (body.enabled) { + const { maybeStartQueueProcessor } = await import("./execute"); + const started = maybeStartQueueProcessor(); + return c.json({ ok: true, enabled: true, started }); + } + return c.json({ ok: true, enabled: false }); +}); + app.get("/schedule", (c) => { return c.json(getScheduleConfig()); }); diff --git a/server/db/schema.ts b/server/db/schema.ts index 5c130af..7aebab2 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -137,6 +137,7 @@ export const DEFAULT_CONFIG: Record = { sonarr_enabled: "0", audio_languages: "[]", auto_processing: "0", + auto_process_queue: "0", scan_running: "0", job_sleep_seconds: "0", diff --git a/src/features/pipeline/ColumnShell.tsx b/src/features/pipeline/ColumnShell.tsx index 13f9d46..800fcfc 100644 --- a/src/features/pipeline/ColumnShell.tsx +++ b/src/features/pipeline/ColumnShell.tsx @@ -41,31 +41,34 @@ function ActionButton({ action }: { action: ColumnAction }) { } /** - * Equal-width pipeline column with a three-row header (title + count, then a - * subtitle/help row, then a three-slot button row: backward left · skip middle - * · forward right) and a scrolling body. All five pipeline columns share this - * shell so widths, spacing, and the left/middle/right button layout stay + * Equal-width pipeline column with a fixed three-row header (title + count, + * subtitle, button row) and a scrolling body. All five pipeline columns share + * this shell so widths, spacing, and the left/middle/right button layout stay * consistent — which in turn makes the pipeline direction readable at a glance. + * + * The subtitle and button rows are always reserved — when a column has no + * subtitle we render an invisible spacer to keep every column's header the + * same height; buttons passed as disabled still occupy their slot so the + * header never jumps between states. */ export function ColumnShell({ title, count, subtitle, backward, skip, forward, children }: ColumnShellProps) { - const hasButtons = !!(backward || skip || forward); return (
{title} ({count}) - {subtitle &&
{subtitle}
} - {hasButtons && ( - // auto|1fr|auto: left/right buttons take their natural width (no wrapping - // on "← Back to inbox" / "Approve auto →"), the middle column flexes and - // centers the skip button if present. -
-
{backward && }
-
{skip && }
-
{forward && }
-
- )} +
{subtitle}
+ {/* + auto|1fr|auto: left/right buttons take their natural width (no + wrapping on "← Back to inbox" / "Approve auto →"), the middle + column flexes and centers the skip button if present. + */} +
+
{backward && }
+
{skip && }
+
{forward && }
+
{children}
diff --git a/src/features/pipeline/DoneColumn.tsx b/src/features/pipeline/DoneColumn.tsx index 1e0647e..2a852c7 100644 --- a/src/features/pipeline/DoneColumn.tsx +++ b/src/features/pipeline/DoneColumn.tsx @@ -17,7 +17,7 @@ export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) { }; const reopenAll = async () => { - if (!confirm(`Send all ${items.length} completed items back to Review for re-decisioning?`)) return; + if (!confirm(`Send all ${items.length} completed items back to the Inbox for re-sorting?`)) return; await api.post("/api/review/reopen-all"); onMutate(); }; @@ -27,16 +27,18 @@ export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) { onMutate(); }; - const backward = - items.length > 0 - ? { - label: "← Back to review", - onClick: reopenAll, - title: "Reopen every completed item so you can re-decide and re-queue", - } - : undefined; - const skip = - items.length > 0 ? { label: "Clear", onClick: clear, title: "Dismiss completed items from this column" } : undefined; + const backward = { + label: "← Back to inbox", + onClick: reopenAll, + disabled: items.length === 0, + title: "Reopen every completed item so Auto Review can reclassify and re-queue them", + }; + const forward = { + label: "Clear", + onClick: clear, + disabled: items.length === 0, + title: "Dismiss completed items from this column", + }; return ( {items.map((item) => (
@@ -61,10 +63,10 @@ export function DoneColumn({ items, doneCount, onMutate }: DoneColumnProps) {
diff --git a/src/features/pipeline/InboxColumn.tsx b/src/features/pipeline/InboxColumn.tsx index aacfb2e..db97802 100644 --- a/src/features/pipeline/InboxColumn.tsx +++ b/src/features/pipeline/InboxColumn.tsx @@ -97,11 +97,11 @@ export function InboxColumn({ checked={autoProcessing} onChange={(e) => onToggleAutoProcessing(e.target.checked)} /> - Auto-process new items + Auto-process Inbox ); - const skip = totalItems > 0 && !sorting ? { label: "Skip all", onClick: skipAll } : undefined; + const skip = { label: "Skip all", onClick: skipAll, disabled: totalItems === 0 || sorting }; const forward = sorting ? { label: `Sorting ${sortProgress.processed}/${sortProgress.total}…`, @@ -109,9 +109,13 @@ export function InboxColumn({ disabled: true, primary: true, } - : totalItems > 0 - ? { label: "Auto Review →", onClick: runSort, primary: true, title: "Sort inbox to Queue / Review" } - : undefined; + : { + label: "Auto Review →", + onClick: runSort, + primary: true, + disabled: totalItems === 0, + title: "Sort inbox to Queue / Review", + }; return ( diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index edea01b..608e332 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -111,6 +111,11 @@ export function PipelinePage() { loadAll(); }; + const toggleAutoProcessQueue = async (enabled: boolean) => { + await api.post<{ ok: boolean }>("/api/settings/auto-process-queue", { enabled }); + loadAll(); + }; + if (loading || !data || !inboxInitial || !reviewInitial) return
Loading pipeline...
; @@ -138,7 +143,14 @@ export function PipelinePage() { onMutate={loadAll} /> - + diff --git a/src/features/pipeline/ProcessingColumn.tsx b/src/features/pipeline/ProcessingColumn.tsx index 7b9e54c..dab298d 100644 --- a/src/features/pipeline/ProcessingColumn.tsx +++ b/src/features/pipeline/ProcessingColumn.tsx @@ -9,10 +9,19 @@ interface ProcessingColumnProps { items: PipelineJobItem[]; progress?: { id: number; seconds: number; total: number } | null; queueStatus?: { status: string; until?: string; seconds?: number } | null; + autoProcessQueue: boolean; + onToggleAutoProcessQueue: (enabled: boolean) => void; onMutate: () => void; } -export function ProcessingColumn({ items, progress, queueStatus, onMutate }: ProcessingColumnProps) { +export function ProcessingColumn({ + items, + progress, + queueStatus, + autoProcessQueue, + onToggleAutoProcessQueue, + onMutate, +}: ProcessingColumnProps) { const job = items[0]; // at most one running job // Wall-clock elapsed since the job started — re-renders every second. @@ -67,11 +76,30 @@ export function ProcessingColumn({ items, progress, queueStatus, onMutate }: Pro onMutate(); }; + const subtitle = ( + + ); + return ( {queueStatus && queueStatus.status !== "running" && (
diff --git a/src/features/pipeline/QueueColumn.tsx b/src/features/pipeline/QueueColumn.tsx index f54ab83..fba0b89 100644 --- a/src/features/pipeline/QueueColumn.tsx +++ b/src/features/pipeline/QueueColumn.tsx @@ -24,15 +24,18 @@ export function QueueColumn({ items, jellyfinUrl, onMutate }: QueueColumnProps) onMutate(); }; - const backward = - items.length > 0 - ? { - label: "← Back to inbox", - onClick: backToInbox, - title: "Cancel every pending job and send its plan back to the Inbox", - } - : undefined; - const forward = items.length > 0 ? { label: "Run all →", onClick: runAll, primary: true } : undefined; + const backward = { + label: "← Back to inbox", + onClick: backToInbox, + disabled: items.length === 0, + title: "Cancel every pending job and send its plan back to the Inbox", + }; + const forward = { + label: "Run all →", + onClick: runAll, + primary: true, + disabled: items.length === 0, + }; return ( diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index 444b9fb..68fafb5 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -100,20 +100,20 @@ export function ReviewColumn({ ); const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat(); - const backward = - totalItems > 0 - ? { label: "← Back to inbox", onClick: backToInbox, title: "Move everything back to the Inbox" } - : undefined; - const skip = totalItems > 0 ? { label: "Skip all", onClick: skipAll } : undefined; - const forward = - readyCount > 0 - ? { - label: "Approve auto →", - onClick: approveAllReady, - primary: true, - title: "Approve every auto-approvable item (no manual decision needed)", - } - : undefined; + const backward = { + label: "← Back to inbox", + onClick: backToInbox, + disabled: totalItems === 0, + title: "Move everything back to the Inbox", + }; + const skip = { label: "Skip all", onClick: skipAll, disabled: totalItems === 0 }; + const forward = { + label: "Approve auto →", + onClick: approveAllReady, + primary: true, + disabled: readyCount === 0, + title: "Approve every auto-approvable item (no manual decision needed)", + }; const subtitle = totalItems === 0 ? undefined : `${readyCount} auto · ${manualCount} need decisions`; diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index c0233c1..c09437e 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -166,6 +166,7 @@ export interface PipelineData { reviewReadyCount: number; reviewManualCount: number; autoProcessing: boolean; + autoProcessQueue: boolean; queued: PipelineJobItem[]; processing: PipelineJobItem[]; done: PipelineJobItem[];