diff --git a/server/api/review.ts b/server/api/review.ts index 72b38e4..d03f250 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -734,55 +734,46 @@ app.post("/:id/rescan", async (c) => { return c.json(detail); }); -// ─── Pipeline: approve up to here ──────────────────────────────────────────── +// ─── Pipeline: approve a batch of plan IDs ────────────────────────────────── +// +// The pipeline UI groups episodes into series cards and interleaves them +// with movies in a frontend-specific order, so we can't reconstruct +// "up to here" by re-running an ORDER BY on the server. The client knows +// exactly which plans are visually before (and including) the clicked card +// and sends them as an explicit list. -app.post("/approve-up-to/:id", (c) => { - const targetId = parseId(c.req.param("id")); - if (targetId == null) return c.json({ error: "invalid id" }, 400); - const db = getDb(); - - const target = db.prepare("SELECT id FROM review_plans WHERE id = ?").get(targetId) as { id: number } | undefined; - if (!target) return c.json({ error: "Plan not found" }, 404); - - // Get all pending plans sorted by confidence (high first), then name - const pendingPlans = db - .prepare(` - SELECT rp.id - FROM review_plans rp - JOIN media_items mi ON mi.id = rp.item_id - WHERE rp.status = 'pending' AND rp.is_noop = 0 - ORDER BY - CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END, - COALESCE(mi.series_name, mi.name), - mi.season_number, - mi.episode_number, - mi.name - `) - .all() as { id: number }[]; - - // Find the target and approve everything up to and including it - const toApprove: number[] = []; - for (const plan of pendingPlans) { - toApprove.push(plan.id); - if (plan.id === targetId) break; +app.post("/approve-batch", async (c) => { + const body = await c.req.json<{ planIds: unknown }>().catch(() => ({ planIds: null })); + if (!Array.isArray(body.planIds) || !body.planIds.every((id) => typeof id === "number" && id > 0)) { + return c.json({ error: "planIds must be an array of positive integers" }, 400); } + const planIds = body.planIds as number[]; + if (planIds.length === 0) return c.json({ approved: 0 }); + const db = getDb(); + const toApprove = planIds; - // Batch approve and create jobs + // Only approve plans that are still pending and not noop. Skip silently + // if a plan was already approved/skipped or doesn't exist — keeps batch + // idempotent under concurrent edits. + let approved = 0; for (const planId of toApprove) { + const planRow = db + .prepare( + "SELECT id, item_id, status, is_noop, job_type FROM review_plans WHERE id = ? AND status = 'pending' AND is_noop = 0", + ) + .get(planId) as { id: number; item_id: number; job_type: string } | undefined; + if (!planRow) continue; db.prepare("UPDATE review_plans SET status = 'approved', reviewed_at = datetime('now') WHERE id = ?").run(planId); - const planRow = db.prepare("SELECT item_id, job_type FROM review_plans WHERE id = ?").get(planId) as { - item_id: number; - job_type: string; - }; const detail = loadItemDetail(db, planRow.item_id); if (detail.item && detail.command) { db .prepare("INSERT INTO jobs (item_id, command, job_type, status) VALUES (?, ?, ?, 'pending')") .run(planRow.item_id, detail.command, planRow.job_type); + approved++; } } - return c.json({ approved: toApprove.length }); + return c.json({ approved }); }); // ─── Pipeline: series language ─────────────────────────────────────────────── diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index b951e87..beb1eed 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -30,11 +30,6 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu seriesMap.get(key)!.episodes.push(item); } - const approveUpTo = async (planId: number) => { - await api.post(`/api/review/approve-up-to/${planId}`); - onMutate(); - }; - // Interleave movies and series, sorted by confidence (high first) const allItems = [ ...movies.map((m: any) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })), @@ -45,10 +40,23 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu })), ].sort((a, b) => a.sortKey - b.sortKey); - // For "approve up to here" on series, use the last episode's plan ID - const lastPlanId = (series: { episodes: any[] }) => { - const eps = series.episodes; - return eps[eps.length - 1]?.id; + /** All plan IDs the user can see, in visible order — the source of truth for "up to here". */ + const visiblePlanIds: number[] = allItems.flatMap((entry) => + entry.type === "movie" ? [entry.item.id] : entry.item.episodes.map((e: any) => e.id), + ); + + /** Approve every visible plan from the top through (and including) the given index. */ + const approveUpToIndex = async (visibleIndex: number) => { + const planIds = visiblePlanIds.slice(0, visibleIndex + 1); + if (planIds.length === 0) return; + await api.post("/api/review/approve-batch", { planIds }); + onMutate(); + }; + + /** Index of the last plan in this entry within the visible list — used as the "up to" boundary. */ + const lastVisibleIndex = (entry: (typeof allItems)[number]): number => { + const lastId = entry.type === "movie" ? entry.item.id : entry.item.episodes[entry.item.episodes.length - 1]?.id; + return visiblePlanIds.lastIndexOf(lastId); }; return ( @@ -69,7 +77,7 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu await api.patch(`/api/review/${entry.item.item_id}/language`, { language: lang }); onMutate(); }} - onApproveUpTo={() => approveUpTo(entry.item.id)} + onApproveUpTo={() => approveUpToIndex(lastVisibleIndex(entry))} /> ); } else { @@ -82,7 +90,7 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu seriesJellyfinId={entry.item.jellyfinId} episodes={entry.item.episodes} onMutate={onMutate} - onApproveUpTo={() => approveUpTo(lastPlanId(entry.item))} + onApproveUpTo={() => approveUpToIndex(lastVisibleIndex(entry))} /> ); }