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
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:
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user