diff --git a/package.json b/package.json index 0c147ae..4cafe1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.14.17", + "version": "2026.04.14.18", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/server/api/review.ts b/server/api/review.ts index c0f0e4e..9a37b02 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -575,6 +575,44 @@ app.post("/approve-all", (c) => { return c.json({ ok: true, count: pending.length }); }); +// ─── Batch approve (by item id list) ───────────────────────────────────────── +// Used by the "approve up to here" affordance in the review column. The +// client knows the visible order (movies + series sort-key) and passes in +// the prefix of item ids it wants approved in one round-trip. Items that +// aren't pending (already approved / skipped / done) are silently ignored +// so the endpoint is idempotent against stale client state. +app.post("/approve-batch", async (c) => { + const db = getDb(); + const body = await c.req.json<{ itemIds?: unknown }>().catch(() => ({ itemIds: undefined })); + if ( + !Array.isArray(body.itemIds) || + !body.itemIds.every((v) => typeof v === "number" && Number.isInteger(v) && v > 0) + ) { + return c.json({ ok: false, error: "itemIds must be an array of positive integers" }, 400); + } + const ids = body.itemIds as number[]; + if (ids.length === 0) return c.json({ ok: true, count: 0 }); + + const placeholders = ids.map(() => "?").join(","); + 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 mi.id IN (${placeholders})`, + ) + .all(...ids) as (ReviewPlan & { item_id: number })[]; + + let count = 0; + 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)); + count++; + } + } + return c.json({ ok: true, count }); +}); + // ─── 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. diff --git a/src/features/pipeline/PipelineCard.tsx b/src/features/pipeline/PipelineCard.tsx index b6d40bc..e00c29b 100644 --- a/src/features/pipeline/PipelineCard.tsx +++ b/src/features/pipeline/PipelineCard.tsx @@ -32,6 +32,11 @@ interface PipelineCardProps { // (no onToggleStream) and the primary button un-approves the plan, // sending the item back to the Review column. onUnapprove?: () => void; + // Review-column affordance: approve this card AND every card visually + // above it in one round-trip. Only set for the top-level review list; + // expanded series episodes don't get this (the series' "Approve all" + // covers the prior-episodes-in-series case). + onApproveUpToHere?: () => void; } function formatChannels(n: number | null | undefined): string | null { @@ -52,7 +57,15 @@ function describeStream(s: PipelineAudioStream): string { return parts.join(" · "); } -export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onSkip, onUnapprove }: PipelineCardProps) { +export function PipelineCard({ + item, + jellyfinUrl, + onToggleStream, + onApprove, + onSkip, + onUnapprove, + onApproveUpToHere, +}: PipelineCardProps) { const title = item.type === "Episode" ? `S${String(item.season_number).padStart(2, "0")}E${String(item.episode_number).padStart(2, "0")} — ${item.name}` @@ -68,7 +81,7 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS const mediaItemId: number = item.item_id ?? (item as { id: number }).id; return ( -
+
{jellyfinLink ? ( @@ -124,7 +137,7 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS {description && {description}} {s.is_default === 1 && default} {s.title && ( - + — “{s.title}” )} @@ -157,6 +170,16 @@ export function PipelineCard({ item, jellyfinUrl, onToggleStream, onApprove, onS )}
+ {onApproveUpToHere && ( + + )} {onApprove && ( + )}