diff --git a/server/api/review.ts b/server/api/review.ts index 1f5d36e..2f66f57 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -229,6 +229,10 @@ app.get("/pipeline", (c) => { const db = getDb(); const jellyfinUrl = getConfig("jellyfin_url") ?? ""; + // Cap the review column to keep the page snappy at scale; pipelines + // with thousands of pending items would otherwise ship 10k+ rows on + // every refresh and re-render every card. + const REVIEW_LIMIT = 500; const review = db .prepare(` SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id, @@ -242,8 +246,12 @@ app.get("/pipeline", (c) => { CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END, COALESCE(mi.series_name, mi.name), mi.season_number, mi.episode_number + LIMIT ${REVIEW_LIMIT} `) .all(); + const reviewTotal = ( + db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } + ).n; const queued = db .prepare(` @@ -281,17 +289,15 @@ app.get("/pipeline", (c) => { `) .all(); - // "Done" = files that are already in the desired end state. Two ways - // to get there: (a) the analyzer says nothing to do (is_noop=1), or - // (b) we ran a job that finished. Both count toward the same total. - const doneCount = ( - db - .prepare(` - SELECT COUNT(*) as count FROM review_plans - WHERE is_noop = 1 OR status = 'done' - `) - .get() as { count: number } - ).count; + // "Done" = files already in the desired end state. Either the analyzer + // says nothing to do (is_noop=1) or a job finished. Use two indexable + // counts and add — the OR form (is_noop=1 OR status='done') can't use + // our single-column indexes and gets slow on large libraries. + const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number }; + const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as { + n: number; + }; + const doneCount = noopRow.n + doneRow.n; // Batch transcode reasons for all review plans in one query (avoids N+1) const planIds = (review as { id: number }[]).map((r) => r.id); @@ -315,7 +321,7 @@ app.get("/pipeline", (c) => { item.transcode_reasons = reasonsByPlan.get(item.id) ?? []; } - return c.json({ review, queued, processing, done, doneCount, jellyfinUrl }); + return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl }); }); // ─── List ───────────────────────────────────────────────────────────────────── diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index bffe943..490f90d 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; import { DoneColumn } from "./DoneColumn"; import { ProcessingColumn } from "./ProcessingColumn"; @@ -8,6 +8,7 @@ import { ScheduleControls } from "./ScheduleControls"; interface PipelineData { review: any[]; + reviewTotal: number; queued: any[]; processing: any[]; done: any[]; @@ -55,17 +56,30 @@ export function PipelinePage() { load(); }, [load]); - // SSE for live updates + // SSE for live updates. job_update fires on every status change and per-line + // stdout flush of the running job — without coalescing, the pipeline endpoint + // (a 500-row review query + counts) would re-run several times per second. + const reloadTimer = useRef | null>(null); useEffect(() => { + const scheduleReload = () => { + if (reloadTimer.current) return; + reloadTimer.current = setTimeout(() => { + reloadTimer.current = null; + load(); + }, 1000); + }; const es = new EventSource("/api/execute/events"); - es.addEventListener("job_update", () => load()); + es.addEventListener("job_update", scheduleReload); es.addEventListener("job_progress", (e) => { setProgress(JSON.parse((e as MessageEvent).data)); }); es.addEventListener("queue_status", (e) => { setQueueStatus(JSON.parse((e as MessageEvent).data)); }); - return () => es.close(); + return () => { + es.close(); + if (reloadTimer.current) clearTimeout(reloadTimer.current); + }; }, [load]); if (loading || !data) return
Loading pipeline...
; @@ -80,7 +94,7 @@ export function PipelinePage() {
- + diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index b0492fe..e4170ac 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -4,11 +4,13 @@ import { SeriesCard } from "./SeriesCard"; interface ReviewColumnProps { items: any[]; + total: number; jellyfinUrl: string; onMutate: () => void; } -export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) { +export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) { + const truncated = total > items.length; // Group by series (movies are standalone) const movies = items.filter((i: any) => i.type === "Movie"); const seriesMap = new Map(); @@ -45,7 +47,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps return (
- Review ({items.length}) + Review ({truncated ? `${items.length} of ${total}` : total})
{allItems.map((entry) => { @@ -78,6 +80,11 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps } })} {allItems.length === 0 &&

No items to review

} + {truncated && ( +

+ Showing first {items.length} of {total}. Approve some to see the rest. +

+ )}
);