# Review column lazy-load + season grouping Date: 2026-04-15 ## Summary Replace the Review column's 500-item hard cap with server-side group-paginated lazy loading. Series are always returned complete (every pending non-noop episode, grouped by season), eliminating the "2 eps" mirage caused by groups getting split across the cap. When a series has pending work in more than one season, the UI nests seasons as collapsible sub-groups, each with its own "Approve season" button. ## Motivation `server/api/review.ts:277` caps the pipeline's review list at 500 items. ReviewColumn groups client-side, so any series whose episodes spill beyond the cap shows a wrong episode count and partial episode list. The banner "Showing first 500 of N" is present but misleading — the *groups* don't survive the cut, not just the tail. The existing "Approve all" button on a series card already calls `/series/:seriesKey/approve-all`, which operates on the DB directly and does approve every pending episode — so functionality works, only the display is wrong. Still, partial groups are confusing and the 500 cap forces users to approve in waves. ## Server changes ### New endpoint `GET /api/review/groups?offset=0&limit=25` Response: ```ts { groups: ReviewGroup[]; totalGroups: number; totalItems: number; hasMore: boolean; } type ReviewGroup = | { kind: "movie"; item: PipelineReviewItem } | { kind: "series"; seriesKey: string; seriesName: string; seriesJellyfinId: string | null; episodeCount: number; minConfidence: "high" | "low"; originalLanguage: string | null; seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>; }; ``` Ordering: - Groups ordered by (min confidence across group ASC — `high` < `low`), then (series_name or movie name ASC) - Within a series, seasons ordered by `season_number` ASC (`null` last) - Within a season, episodes ordered by `episode_number` ASC Implementation outline: 1. Query all pending non-noop plans joined to media_items (existing `review` query minus the LIMIT). 2. Walk once in sort order, producing groups: a Movie becomes a one-shot `{ kind: "movie" }`; consecutive Episodes sharing `series_jellyfin_id` (or `series_name` fallback) accumulate into a `{ kind: "series" }` with `seasons` bucketed by `season_number`. 3. Apply `.slice(offset, offset + limit)` over the full group list, enrich per-episode audio streams + transcode reasons for episodes that survive (reuse existing `enrichWithStreamsAndReasons`). 4. `totalGroups` = full group count before slicing. `totalItems` = sum of episode counts + movie count (unchanged from today's `reviewTotal`). `hasMore` = `offset + limit < totalGroups`. ### `GET /api/review/pipeline` changes Drop `review` and `reviewTotal` from the response. Add `reviewItemsTotal: number` so the column header shows a count before the groups endpoint resolves. Queue / Processing / Done / doneCount stay unchanged. ### Kept as-is - `POST /api/review/series/:seriesKey/approve-all` (`review.ts:529`) - `POST /api/review/season/:seriesKey/:season/approve-all` (`review.ts:549`) — already implemented, just unused by the UI until now ## Client changes ### PipelinePage Fetches `/api/review/pipeline` for queue columns (existing) and separately `/api/review/groups?offset=0&limit=25` for the Review column's initial page. `onMutate` refetches both. Pass `reviewGroups`, `reviewGroupsTotalItems`, `reviewHasMore` into `ReviewColumn`. ### ReviewColumn Replace the hard-cap rendering with infinite scroll: - Render the current loaded groups. - Append a sentinel `