6.3 KiB
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:
{
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_numberASC (nulllast) - Within a season, episodes ordered by
episode_numberASC
Implementation outline:
- Query all pending non-noop plans joined to media_items (existing
reviewquery minus the LIMIT). - Walk once in sort order, producing groups: a Movie becomes a one-shot
{ kind: "movie" }; consecutive Episodes sharingseries_jellyfin_id(orseries_namefallback) accumulate into a{ kind: "series" }withseasonsbucketed byseason_number. - Apply
.slice(offset, offset + limit)over the full group list, enrich per-episode audio streams + transcode reasons for episodes that survive (reuse existingenrichWithStreamsAndReasons). totalGroups= full group count before slicing.totalItems= sum of episode counts + movie count (unchanged from today'sreviewTotal).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
<div>at the bottom whenhasMore. AnIntersectionObserverattached to it triggers a fetch of the next page when it enters the scroll viewport. - Pagination state (
offset,groups,hasMore,loading) lives locally in ReviewColumn — parent passesinitialGroupson mount and whenever the filter changes (onMutate→ parent refetches page 0). - Remove the "Showing first N of M" banner and the
truncatedlogic.
SeriesCard
When seasons.length > 1:
- Render seasons as collapsible sub-groups inside the expanded series body.
- Each season header:
S{NN} — {episodeCount} eps · {high} high / {low} low+ anApprove seasonbutton.
When seasons.length === 1:
- Render the current flat episode list (no extra nesting).
Rename the existing header button Approve all → Approve series.
"Approve above"
Keeps its current "approve every group currently visible above this card" semantic. With lazy loading, that means "everything the user has scrolled past". Compute item ids client-side across the loaded groups as today. No endpoint change.
Data flow
- PipelinePage mounts → parallel fetch
/pipeline+/groups?offset=0&limit=25. - User scrolls; sentinel becomes visible → fetch
/groups?offset=25&limit=25; appended to the list. - User clicks
Approve serieson a card →POST /series/:key/approve-all→onMutate→ parent refetches/pipeline+/groups?offset=0&limit=25. Series gone from list. - User clicks
Approve season S02on a nested season →POST /season/:key/2/approve-all→onMutate→ same refetch.
Testing
- Server unit test:
/groupsendpoint returns a series with all pending episodes even when the total item count exceedslimit * offset_pages. - Server unit test: offset/limit/hasMore correctness across the group boundary.
- Server unit test: seasons array is populated, sorted, with
nullseason_number ordered last. - Manual: scroll through the Review column on a library with >1000 pending items and confirm episode counts match
SELECT COUNT(*) ... WHERE pending AND is_noop=0scoped per series.
Guided Gates
- GG-1: No "Showing first 500 of N" banner ever appears.
- GG-2: A series whose episodes previously split across the cap now shows the correct episode count immediately on first page load (if the series is in the first page) or after scroll (if not).
- GG-3: A series with pending episodes in 2+ seasons expands into nested season sub-groups, each with an
Approve seasonbutton that approves only that season. - GG-4: A series with pending episodes in exactly one season expands into the flat episode list as before.
- GG-5: Scrolling to the bottom of the Review column auto-fetches the next page without a click; scrolling stops fetching when
hasMoreis false.