From 3f910873eb3cf895ee6362791f2a7909a01b9ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 15 Apr 2026 12:09:27 +0200 Subject: [PATCH] review: add /groups endpoint with server-side grouping + pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/review/pipeline no longer ships the review array — it now only carries queue state + reviewItemsTotal. Review items live behind /api/review/groups?offset=N&limit=25 which returns complete series (every pending non-noop episode, bucketed by season) so the UI never sees a split group. Lifted enrichWithStreamsAndReasons + PipelineAudioStream to module scope so both /pipeline (queued column) and /groups (review page) can share the same enrichment. Co-Authored-By: Claude Opus 4.6 (1M context) --- server/api/review.ts | 330 ++++++++++++++++++++++++++++++------------- 1 file changed, 235 insertions(+), 95 deletions(-) diff --git a/server/api/review.ts b/server/api/review.ts index 0367473..b36cac4 100644 --- a/server/api/review.ts +++ b/server/api/review.ts @@ -275,36 +275,246 @@ interface PipelineAudioStream { action: "keep" | "remove"; } +type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & { + transcode_reasons?: string[]; + audio_streams?: PipelineAudioStream[]; +}; + +/** + * Enrich review/queued rows with transcode-reason badges and pre-checked audio + * streams. Works for both the Review column (where `id` is the plan id) and + * the Queued column (where `plan_id` is explicit and `id` is the job id). + */ +function enrichWithStreamsAndReasons(db: ReturnType, rows: EnrichableRow[]): void { + if (rows.length === 0) return; + const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number; + const planIds = rows.map(planIdFor); + const itemIds = rows.map((r) => r.item_id); + + const reasonPh = planIds.map(() => "?").join(","); + const allReasons = db + .prepare(` + SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec + FROM stream_decisions sd + JOIN media_streams ms ON ms.id = sd.stream_id + WHERE sd.plan_id IN (${reasonPh}) AND sd.transcode_codec IS NOT NULL + `) + .all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[]; + const reasonsByPlan = new Map(); + for (const r of allReasons) { + if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []); + reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()} → ${r.transcode_codec.toUpperCase()}`); + } + + const streamPh = itemIds.map(() => "?").join(","); + const streamRows = db + .prepare(` + SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title, + ms.is_default, sd.action + FROM media_streams ms + JOIN review_plans rp ON rp.item_id = ms.item_id + LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id + WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio' + ORDER BY ms.item_id, ms.stream_index + `) + .all(...itemIds) as { + id: number; + item_id: number; + language: string | null; + codec: string | null; + channels: number | null; + title: string | null; + is_default: number; + action: "keep" | "remove" | null; + }[]; + const streamsByItem = new Map(); + for (const r of streamRows) { + if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []); + streamsByItem.get(r.item_id)!.push({ + id: r.id, + language: r.language, + codec: r.codec, + channels: r.channels, + title: r.title, + is_default: r.is_default, + action: r.action ?? "keep", + }); + } + + for (const r of rows) { + r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? []; + r.audio_streams = streamsByItem.get(r.item_id) ?? []; + } +} + +// ─── Review groups (paginated, always returns complete series) ────────────── + +interface ReviewItemRow { + id: number; + item_id: number; + status: string; + is_noop: number; + confidence: "high" | "low"; + apple_compat: ReviewPlan["apple_compat"]; + job_type: "copy" | "transcode"; + name: string; + series_name: string | null; + series_jellyfin_id: string | null; + jellyfin_id: string; + season_number: number | null; + episode_number: number | null; + type: "Movie" | "Episode"; + container: string | null; + original_language: string | null; + orig_lang_source: string | null; + file_path: string; + transcode_reasons?: string[]; + audio_streams?: PipelineAudioStream[]; +} + +type ReviewGroup = + | { kind: "movie"; item: ReviewItemRow } + | { + kind: "series"; + seriesKey: string; + seriesName: string; + seriesJellyfinId: string | null; + episodeCount: number; + minConfidence: "high" | "low"; + originalLanguage: string | null; + seasons: Array<{ season: number | null; episodes: ReviewItemRow[] }>; + }; + +function buildReviewGroups(db: ReturnType): { groups: ReviewGroup[]; totalItems: number } { + const rows = db + .prepare(` + SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id, + mi.jellyfin_id, + mi.season_number, mi.episode_number, mi.type, mi.container, + mi.original_language, mi.orig_lang_source, mi.file_path + 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 + `) + .all() as ReviewItemRow[]; + + const movieGroups: ReviewGroup[] = []; + interface SeriesAccum { + seriesName: string; + seriesJellyfinId: string | null; + seasons: Map; + originalLanguage: string | null; + hasLow: boolean; + } + const seriesMap = new Map(); + + for (const row of rows) { + if (row.type === "Movie") { + movieGroups.push({ kind: "movie", item: row }); + continue; + } + const key = row.series_jellyfin_id ?? row.series_name ?? String(row.item_id); + let entry = seriesMap.get(key); + if (!entry) { + entry = { + seriesName: row.series_name ?? "", + seriesJellyfinId: row.series_jellyfin_id, + seasons: new Map(), + originalLanguage: row.original_language, + hasLow: false, + }; + seriesMap.set(key, entry); + } + let bucket = entry.seasons.get(row.season_number); + if (!bucket) { + bucket = []; + entry.seasons.set(row.season_number, bucket); + } + bucket.push(row); + if (row.confidence === "low") entry.hasLow = true; + } + + const seriesGroups: ReviewGroup[] = []; + for (const [seriesKey, entry] of seriesMap) { + const seasonKeys = [...entry.seasons.keys()].sort((a, b) => { + if (a === null) return 1; + if (b === null) return -1; + return a - b; + }); + const seasons = seasonKeys.map((season) => ({ + season, + episodes: (entry.seasons.get(season) ?? []).sort((a, b) => (a.episode_number ?? 0) - (b.episode_number ?? 0)), + })); + const episodeCount = seasons.reduce((sum, s) => sum + s.episodes.length, 0); + seriesGroups.push({ + kind: "series", + seriesKey, + seriesName: entry.seriesName, + seriesJellyfinId: entry.seriesJellyfinId, + episodeCount, + minConfidence: entry.hasLow ? "low" : "high", + originalLanguage: entry.originalLanguage, + seasons, + }); + } + + const allGroups = [...movieGroups, ...seriesGroups].sort((a, b) => { + const confA = a.kind === "movie" ? a.item.confidence : a.minConfidence; + const confB = b.kind === "movie" ? b.item.confidence : b.minConfidence; + const rankA = confA === "high" ? 0 : 1; + const rankB = confB === "high" ? 0 : 1; + if (rankA !== rankB) return rankA - rankB; + const nameA = a.kind === "movie" ? a.item.name : a.seriesName; + const nameB = b.kind === "movie" ? b.item.name : b.seriesName; + return nameA.localeCompare(nameB); + }); + + const totalItems = + movieGroups.length + seriesGroups.reduce((sum, g) => sum + (g.kind === "series" ? g.episodeCount : 0), 0); + return { groups: allGroups, totalItems }; +} + +app.get("/groups", (c) => { + const db = getDb(); + const offset = Math.max(0, Number.parseInt(c.req.query("offset") ?? "0", 10) || 0); + const limit = Math.max(1, Math.min(200, Number.parseInt(c.req.query("limit") ?? "25", 10) || 25)); + + const { groups, totalItems } = buildReviewGroups(db); + const page = groups.slice(offset, offset + limit); + + // Enrich each visible episode/movie with audio streams + transcode reasons. + const flat: EnrichableRow[] = []; + for (const g of page) { + if (g.kind === "movie") flat.push(g.item as EnrichableRow); + else for (const s of g.seasons) for (const ep of s.episodes) flat.push(ep as EnrichableRow); + } + enrichWithStreamsAndReasons(db, flat); + + return c.json({ + groups: page, + totalGroups: groups.length, + totalItems, + hasMore: offset + limit < groups.length, + }); +}); + 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, - mi.jellyfin_id, - mi.season_number, mi.episode_number, mi.type, mi.container, - mi.original_language, mi.orig_lang_source, mi.file_path - 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 - LIMIT ${REVIEW_LIMIT} - `) - .all(); - const reviewTotal = ( + // Review items ship via GET /groups (paginated, always returns complete + // series). The pipeline payload only carries the total count so the column + // header can render immediately. + const reviewItemsTotal = ( db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number } ).n; - // Queued gets the same enrichment as review so the card can render - // streams + transcode reasons read-only (with a "Back to review" button). + // Queued carries stream + transcode-reason enrichment so the card renders + // read-only with a "Back to review" button. const queued = db .prepare(` SELECT j.id, j.item_id, j.status, j.started_at, j.completed_at, @@ -355,79 +565,9 @@ app.get("/pipeline", (c) => { }; const doneCount = noopRow.n + doneRow.n; - // Enrich rows that have (plan_id, item_id) with the transcode-reason - // badges and pre-checked audio streams. Used for both review and queued - // columns so the queued card can render read-only with the same info. - type EnrichableRow = { id?: number; plan_id?: number; item_id: number } & { - transcode_reasons?: string[]; - audio_streams?: PipelineAudioStream[]; - }; - const enrichWithStreamsAndReasons = (rows: EnrichableRow[]) => { - if (rows.length === 0) return; - const planIdFor = (r: EnrichableRow): number => (r.plan_id ?? r.id) as number; - const planIds = rows.map(planIdFor); - const itemIds = rows.map((r) => r.item_id); + enrichWithStreamsAndReasons(db, queued as EnrichableRow[]); - const reasonPh = planIds.map(() => "?").join(","); - const allReasons = db - .prepare(` - SELECT DISTINCT sd.plan_id, ms.codec, sd.transcode_codec - FROM stream_decisions sd - JOIN media_streams ms ON ms.id = sd.stream_id - WHERE sd.plan_id IN (${reasonPh}) AND sd.transcode_codec IS NOT NULL - `) - .all(...planIds) as { plan_id: number; codec: string | null; transcode_codec: string }[]; - const reasonsByPlan = new Map(); - for (const r of allReasons) { - if (!reasonsByPlan.has(r.plan_id)) reasonsByPlan.set(r.plan_id, []); - reasonsByPlan.get(r.plan_id)!.push(`${(r.codec ?? "").toUpperCase()} → ${r.transcode_codec.toUpperCase()}`); - } - - const streamPh = itemIds.map(() => "?").join(","); - const streamRows = db - .prepare(` - SELECT ms.id, ms.item_id, ms.language, ms.codec, ms.channels, ms.title, - ms.is_default, sd.action - FROM media_streams ms - JOIN review_plans rp ON rp.item_id = ms.item_id - LEFT JOIN stream_decisions sd ON sd.plan_id = rp.id AND sd.stream_id = ms.id - WHERE ms.item_id IN (${streamPh}) AND ms.type = 'Audio' - ORDER BY ms.item_id, ms.stream_index - `) - .all(...itemIds) as { - id: number; - item_id: number; - language: string | null; - codec: string | null; - channels: number | null; - title: string | null; - is_default: number; - action: "keep" | "remove" | null; - }[]; - const streamsByItem = new Map(); - for (const r of streamRows) { - if (!streamsByItem.has(r.item_id)) streamsByItem.set(r.item_id, []); - streamsByItem.get(r.item_id)!.push({ - id: r.id, - language: r.language, - codec: r.codec, - channels: r.channels, - title: r.title, - is_default: r.is_default, - action: r.action ?? "keep", - }); - } - - for (const r of rows) { - r.transcode_reasons = reasonsByPlan.get(planIdFor(r)) ?? []; - r.audio_streams = streamsByItem.get(r.item_id) ?? []; - } - }; - - enrichWithStreamsAndReasons(review as EnrichableRow[]); - enrichWithStreamsAndReasons(queued as EnrichableRow[]); - - return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl }); + return c.json({ reviewItemsTotal, queued, processing, done, doneCount, jellyfinUrl }); }); // ─── List ─────────────────────────────────────────────────────────────────────