add auto-review button that approves every high-confidence pending item
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m0s

one-click for the common case: anything whose language came from
radarr/sonarr (confidence='high') is trusted enough to skip manual
review. low-confidence items stay pending.

- POST /api/review/auto-approve filters on rp.confidence='high' and
  enqueues audio jobs through the same dedup-guarded helper
- ColumnShell now takes actions[] instead of a single action, so the
  Review header can show Auto Review + Skip all side by side
This commit is contained in:
2026-04-14 07:40:38 +02:00
parent 4f1433437b
commit 9b03a33e24
7 changed files with 67 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "netfelix-audio-fix",
"version": "2026.04.14",
"version": "2026.04.14.1",
"scripts": {
"dev:server": "NODE_ENV=development bun --hot server/index.tsx",
"dev:client": "vite",

View File

@@ -504,6 +504,24 @@ app.post("/approve-all", (c) => {
return c.json({ ok: true, count: pending.length });
});
// ─── Auto-approve high-confidence ────────────────────────────────────────────
// Approves every pending plan whose original language came from an authoritative
// source (radarr/sonarr). Anything with low confidence keeps needing a human.
app.post("/auto-approve", (c) => {
const db = getDb();
const pending = db
.prepare(
"SELECT rp.*, mi.id as item_id FROM review_plans rp JOIN media_items mi ON mi.id = rp.item_id WHERE rp.status = 'pending' AND rp.is_noop = 0 AND rp.confidence = 'high'",
)
.all() as (ReviewPlan & { item_id: number })[];
for (const plan of pending) {
db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(plan.id);
const { item, streams, decisions } = loadItemDetail(db, plan.item_id);
if (item) enqueueAudioJob(db, plan.item_id, buildCommand(item, streams, decisions));
}
return c.json({ ok: true, count: pending.length });
});
// ─── Detail ───────────────────────────────────────────────────────────────────
app.get("/:id", (c) => {

View File

@@ -1,37 +1,52 @@
import type { ReactNode } from "react";
export interface ColumnAction {
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
primary?: boolean;
}
interface ColumnShellProps {
title: string;
count: ReactNode;
action?: { label: string; onClick: () => void; disabled?: boolean; danger?: boolean };
actions?: ColumnAction[];
children: ReactNode;
}
/**
* Equal-width pipeline column with a header (title + count + optional action button)
* Equal-width pipeline column with a header (title + count + optional action buttons)
* and a scrolling body. All four pipeline columns share this shell so widths and
* header layout stay consistent.
*/
export function ColumnShell({ title, count, action, children }: ColumnShellProps) {
export function ColumnShell({ title, count, actions, children }: ColumnShellProps) {
return (
<div className="flex flex-col flex-1 basis-0 min-w-64 min-h-0 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b">
<span className="font-medium text-sm truncate">
{title} <span className="text-gray-400 font-normal">({count})</span>
</span>
{action && (
<button
type="button"
onClick={action.onClick}
disabled={action.disabled}
className={
action.danger
? "text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
: "text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
}
>
{action.label}
</button>
{actions && actions.length > 0 && (
<div className="flex items-center gap-1">
{actions.map((a) => (
<button
key={a.label}
type="button"
onClick={a.onClick}
disabled={a.disabled}
className={
a.danger
? "text-xs px-2 py-0.5 rounded border border-red-200 text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed"
: a.primary
? "text-xs px-2 py-0.5 rounded border border-blue-600 bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
: "text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
}
>
{a.label}
</button>
))}
</div>
)}
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">{children}</div>

View File

@@ -18,7 +18,7 @@ export function DoneColumn({ items, onMutate }: DoneColumnProps) {
<ColumnShell
title="Done"
count={items.length}
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
>
{items.map((item) => (
<div key={item.id} className="rounded border bg-white p-2">

View File

@@ -51,7 +51,7 @@ export function ProcessingColumn({ items, progress, queueStatus, onMutate }: Pro
<ColumnShell
title="Processing"
count={job ? 1 : 0}
action={job ? { label: "Stop", onClick: stop, danger: true } : undefined}
actions={job ? [{ label: "Stop", onClick: stop, danger: true }] : undefined}
>
{queueStatus && queueStatus.status !== "running" && (
<div className="mb-2 text-xs text-gray-500 bg-white rounded border p-2">

View File

@@ -19,7 +19,7 @@ export function QueueColumn({ items, onMutate }: QueueColumnProps) {
<ColumnShell
title="Queued"
count={items.length}
action={items.length > 0 ? { label: "Clear", onClick: clear } : undefined}
actions={items.length > 0 ? [{ label: "Clear", onClick: clear }] : undefined}
>
{items.map((item) => (
<div key={item.id} className="rounded border bg-white p-2">

View File

@@ -27,6 +27,12 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
onMutate();
};
const autoApprove = async () => {
const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve");
onMutate();
if (res.count === 0) alert("No high-confidence items to auto-approve.");
};
const approveItem = async (itemId: number) => {
await api.post(`/api/review/${itemId}/approve`);
onMutate();
@@ -62,7 +68,14 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu
<ColumnShell
title="Review"
count={truncated ? `${items.length} of ${total}` : total}
action={total > 0 ? { label: "Skip all", onClick: skipAll } : undefined}
actions={
total > 0
? [
{ label: "Auto Review", onClick: autoApprove, primary: true },
{ label: "Skip all", onClick: skipAll },
]
: undefined
}
>
<div className="space-y-2">
{allItems.map((entry) => {