Files
netfelix-audio-fix/docs/superpowers/specs/2026-04-15-review-lazy-load-design.md
2026-04-15 12:04:24 +02:00

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_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 <div> at the bottom when hasMore. An IntersectionObserver attached 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 passes initialGroups on mount and whenever the filter changes (onMutate → parent refetches page 0).
  • Remove the "Showing first N of M" banner and the truncated logic.

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 + an Approve season button.

When seasons.length === 1:

  • Render the current flat episode list (no extra nesting).

Rename the existing header button Approve allApprove 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

  1. PipelinePage mounts → parallel fetch /pipeline + /groups?offset=0&limit=25.
  2. User scrolls; sentinel becomes visible → fetch /groups?offset=25&limit=25; appended to the list.
  3. User clicks Approve series on a card → POST /series/:key/approve-allonMutate → parent refetches /pipeline + /groups?offset=0&limit=25. Series gone from list.
  4. User clicks Approve season S02 on a nested season → POST /season/:key/2/approve-allonMutate → same refetch.

Testing

  • Server unit test: /groups endpoint returns a series with all pending episodes even when the total item count exceeds limit * offset_pages.
  • Server unit test: offset/limit/hasMore correctness across the group boundary.
  • Server unit test: seasons array is populated, sorted, with null season_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=0 scoped 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 season button 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 hasMore is false.