Files
netfelix-audio-fix/docs/superpowers/plans/2026-04-15-review-lazy-load.md
2026-04-15 12:06:57 +02:00

28 KiB
Raw Blame History

Review column lazy-load + season grouping — Implementation Plan

For agentic workers: Use superpowers:subagent-driven-development. Checkbox (- [ ]) syntax tracks progress.

Goal: Replace the 500-item review cap with group-paginated infinite scroll; nest season sub-groups inside series when they have pending work across >1 season; wire the existing /season/:key/:season/approve-all endpoint into the UI.

Architecture: Move the grouping logic from the client to the server so groups are always returned complete. New GET /api/review/groups?offset=N&limit=25 endpoint. Client's ReviewColumn becomes a stateful list that extends itself via IntersectionObserver on a sentinel.

Tech Stack: Bun + Hono (server), React 19 + TanStack Router (client), bun:sqlite.


Task 1: Server — build grouped data structure + new endpoint

Files:

  • Modify: server/api/review.ts

  • Step 1: Add shared types + builder

At the top of server/api/review.ts (near the other type definitions), add exported types:

export 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[] }>;
	  };

export interface ReviewGroupsResponse {
	groups: ReviewGroup[];
	totalGroups: number;
	totalItems: number;
	hasMore: boolean;
}

Add a helper after the existing enrichWithStreamsAndReasons helper:

function buildReviewGroups(db: ReturnType<typeof getDb>): {
	groups: ReviewGroup[];
	totalItems: number;
} {
	// Fetch ALL pending non-noop items. Grouping + pagination happen in memory.
	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 PipelineReviewItem[];

	const movies: PipelineReviewItem[] = [];
	const seriesMap = new Map<
		string,
		{
			seriesName: string;
			seriesJellyfinId: string | null;
			seasons: Map<number | null, PipelineReviewItem[]>;
			originalLanguage: string | null;
			minConfidence: "high" | "low";
			firstName: string;
		}
	>();

	for (const row of rows) {
		if (row.type === "Movie") {
			movies.push(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,
				minConfidence: row.confidence,
				firstName: row.series_name ?? "",
			};
			seriesMap.set(key, entry);
		}
		const season = row.season_number;
		let bucket = entry.seasons.get(season);
		if (!bucket) {
			bucket = [];
			entry.seasons.set(season, bucket);
		}
		bucket.push(row);
		if (row.confidence === "high" && entry.minConfidence === "low") {
			// Keep minConfidence as the "best" confidence across episodes — if any
			// episode is high, that's the group's dominant confidence for sort.
			// Actually we want the LOWEST (low wins) so user sees low-confidence
			// groups sorted after high-confidence ones. Revisit: keep low if present.
		}
		if (row.confidence === "low") entry.minConfidence = "low";
	}

	// Sort season keys within each series (nulls last), episodes by episode_number.
	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.minConfidence,
			originalLanguage: entry.originalLanguage,
			seasons,
		});
	}

	// Interleave movies + series, sort by (minConfidence, name).
	const movieGroups: ReviewGroup[] = movies.map((m) => ({ kind: "movie" as const, item: m }));
	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 as { episodeCount: number }).episodeCount, 0);
	return { groups: allGroups, totalItems };
}

(Delete the stray comment block inside the loop about "keep minConfidence as the best" — the actual logic below it is correct. I left a TODO-style note while drafting; clean it up when editing.)

  • Step 2: Add the /groups endpoint

Add before app.get("/pipeline", …):

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
	// (same shape the existing UI expects — reuse the helper already in this file).
	const flatItemsForEnrichment: Array<{ id: number; plan_id?: number; item_id: number; transcode_reasons?: string[]; audio_streams?: PipelineAudioStream[] }> = [];
	for (const g of page) {
		if (g.kind === "movie") flatItemsForEnrichment.push(g.item as never);
		else for (const s of g.seasons) for (const ep of s.episodes) flatItemsForEnrichment.push(ep as never);
	}
	enrichWithStreamsAndReasons(flatItemsForEnrichment);

	return c.json<ReviewGroupsResponse>({
		groups: page,
		totalGroups: groups.length,
		totalItems,
		hasMore: offset + limit < groups.length,
	});
});

PipelineAudioStream already imported; if not, add to existing import block.

  • Step 3: Modify /pipeline to drop review/reviewTotal

In the existing app.get("/pipeline", …) handler (around line 270):

  • Delete the review SELECT (lines ~278293) and the enrichment of review rows.

  • Delete the reviewTotal count query (lines ~294296).

  • Add in its place: const reviewItemsTotal = (db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }).n;

  • In the final return c.json({...}) (line ~430), replace review, reviewTotal with reviewItemsTotal.

  • Step 4: Run tests + lint + tsc

mise exec bun -- bun test
mise exec bun -- bun run lint
mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json

All must pass. If tests that hit /pipeline fail because they expect review[], update them in the same commit (they need to migrate anyway).

  • Step 5: Commit
git add server/api/review.ts
git commit -m "review: add /groups endpoint with server-side grouping + pagination"

Task 2: Server — test /groups endpoint

Files:

  • Create: server/api/__tests__/review-groups.test.ts

  • Step 1: Write the test file

import { describe, expect, test } from "bun:test";
import { Hono } from "hono";
import reviewRoutes from "../review";
import { setupTestDb, seedItem, seedPlan } from "./test-helpers"; // adjust to the project's test helpers; see existing webhook.test.ts for how tests wire up a DB

const app = new Hono();
app.route("/api/review", reviewRoutes);

describe("GET /api/review/groups", () => {
	test("returns complete series even when total items exceed limit", async () => {
		const db = setupTestDb();
		// Seed 1 series with 30 episodes, all pending non-noop
		for (let i = 1; i <= 30; i++) seedItem(db, { type: "Episode", seriesName: "Breaking Bad", seasonNumber: 1, episodeNumber: i });
		for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });

		const res = await app.request("/api/review/groups?offset=0&limit=25");
		const body = await res.json();

		expect(body.groups).toHaveLength(1);
		expect(body.groups[0].kind).toBe("series");
		expect(body.groups[0].episodeCount).toBe(30);
		expect(body.groups[0].seasons[0].episodes).toHaveLength(30);
		expect(body.totalItems).toBe(30);
		expect(body.hasMore).toBe(false);
	});

	test("paginates groups with hasMore=true", async () => {
		const db = setupTestDb();
		for (let i = 1; i <= 50; i++) seedItem(db, { type: "Movie", name: `Movie ${String(i).padStart(2, "0")}` });
		for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });

		const page1 = await (await app.request("/api/review/groups?offset=0&limit=25")).json();
		const page2 = await (await app.request("/api/review/groups?offset=25&limit=25")).json();

		expect(page1.groups).toHaveLength(25);
		expect(page1.hasMore).toBe(true);
		expect(page2.groups).toHaveLength(25);
		expect(page2.hasMore).toBe(false);
		const ids1 = page1.groups.map((g: { item: { item_id: number } }) => g.item.item_id);
		const ids2 = page2.groups.map((g: { item: { item_id: number } }) => g.item.item_id);
		expect(ids1.filter((id: number) => ids2.includes(id))).toHaveLength(0);
	});

	test("buckets episodes by season, nulls last", async () => {
		const db = setupTestDb();
		for (let ep = 1; ep <= 3; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 1, episodeNumber: ep });
		for (let ep = 1; ep <= 2; ep++) seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: 2, episodeNumber: ep });
		seedItem(db, { type: "Episode", seriesName: "Lost", seasonNumber: null, episodeNumber: null });
		for (const row of db.prepare("SELECT id FROM media_items").all() as { id: number }[]) seedPlan(db, row.id, { pending: true, isNoop: false });

		const body = await (await app.request("/api/review/groups?offset=0&limit=25")).json();
		const lost = body.groups[0];
		expect(lost.kind).toBe("series");
		expect(lost.seasons.map((s: { season: number | null }) => s.season)).toEqual([1, 2, null]);
	});
});

Important: this test file needs the project's actual test-helpers pattern. Before writing, look at server/services/__tests__/webhook.test.ts (the 60-line one that's still in the repo after the verified-flag block was removed) and copy its setup style — including how it creates a test DB, how it seeds media_items and review_plans, and how it invokes the Hono app. Replace the placeholder setupTestDb, seedItem, seedPlan calls with whatever the real helpers are.

  • Step 2: Run the tests
mise exec bun -- bun test server/api/__tests__/review-groups.test.ts

Expected: 3 passes.

  • Step 3: Commit
git add server/api/__tests__/review-groups.test.ts
git commit -m "test: /groups endpoint — series completeness, pagination, season buckets"

Task 3: Client types + PipelinePage

Files:

  • Modify: src/shared/lib/types.ts

  • Modify: src/features/pipeline/PipelinePage.tsx

  • Step 1: Update shared types

In src/shared/lib/types.ts, replace the PipelineData interface's review and reviewTotal fields with reviewItemsTotal: number. Add types for the new groups response:

export 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[] }>;
	  };

export interface ReviewGroupsResponse {
	groups: ReviewGroup[];
	totalGroups: number;
	totalItems: number;
	hasMore: boolean;
}

The PipelineData interface becomes:

export interface PipelineData {
	reviewItemsTotal: number;
	queued: PipelineJobItem[];
	processing: PipelineJobItem[];
	done: PipelineJobItem[];
	doneCount: number;
	jellyfinUrl: string;
}
  • Step 2: Update PipelinePage

Change PipelinePage.tsx:

  • Add state for the initial groups page: const [initialGroups, setInitialGroups] = useState<ReviewGroupsResponse | null>(null);

  • In load(), fetch both in parallel:

    const [pipelineRes, groupsRes] = await Promise.all([
        api.get<PipelineData>("/api/review/pipeline"),
        api.get<ReviewGroupsResponse>("/api/review/groups?offset=0&limit=25"),
    ]);
    setData(pipelineRes);
    setInitialGroups(groupsRes);
    
  • Wait for both before rendering (loading gate: if (loading || !data || !initialGroups) return <Loading />).

  • Pass to ReviewColumn: <ReviewColumn initialResponse={initialGroups} totalItems={data.reviewItemsTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} /> — drop items and total props.

  • Step 3: Tsc + lint

mise exec bun -- bunx tsc --noEmit
mise exec bun -- bun run lint

Expected: errors in ReviewColumn.tsx because its props type hasn't been updated yet — that's fine, Task 4 fixes it. For this step, only verify that types.ts and PipelinePage.tsx themselves compile internally. If the build breaks because of ReviewColumn, commit these two files anyway and proceed to Task 4 immediately.

  • Step 4: Commit
git add src/shared/lib/types.ts src/features/pipeline/PipelinePage.tsx
git commit -m "pipeline: fetch review groups endpoint in parallel with pipeline"

Task 4: Client — ReviewColumn with infinite scroll

Files:

  • Modify: src/features/pipeline/ReviewColumn.tsx

  • Step 1: Rewrite ReviewColumn

Replace the file contents with:

import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "~/shared/lib/api";
import type { ReviewGroup, ReviewGroupsResponse } from "~/shared/lib/types";
import { ColumnShell } from "./ColumnShell";
import { PipelineCard } from "./PipelineCard";
import { SeriesCard } from "./SeriesCard";

const PAGE_SIZE = 25;

interface ReviewColumnProps {
	initialResponse: ReviewGroupsResponse;
	totalItems: number;
	jellyfinUrl: string;
	onMutate: () => void;
}

export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) {
	const [groups, setGroups] = useState<ReviewGroup[]>(initialResponse.groups);
	const [hasMore, setHasMore] = useState(initialResponse.hasMore);
	const [loadingMore, setLoadingMore] = useState(false);
	const sentinelRef = useRef<HTMLDivElement | null>(null);

	// Reset when parent passes a new initial page (onMutate refetch)
	useEffect(() => {
		setGroups(initialResponse.groups);
		setHasMore(initialResponse.hasMore);
	}, [initialResponse]);

	const loadMore = useCallback(async () => {
		if (loadingMore || !hasMore) return;
		setLoadingMore(true);
		try {
			const res = await api.get<ReviewGroupsResponse>(`/api/review/groups?offset=${groups.length}&limit=${PAGE_SIZE}`);
			setGroups((prev) => [...prev, ...res.groups]);
			setHasMore(res.hasMore);
		} finally {
			setLoadingMore(false);
		}
	}, [groups.length, hasMore, loadingMore]);

	useEffect(() => {
		if (!hasMore || !sentinelRef.current) return;
		const observer = new IntersectionObserver(
			(entries) => {
				if (entries[0]?.isIntersecting) loadMore();
			},
			{ rootMargin: "200px" },
		);
		observer.observe(sentinelRef.current);
		return () => observer.disconnect();
	}, [hasMore, loadMore]);

	const skipAll = async () => {
		if (!confirm(`Skip all ${totalItems} pending items? They won't be processed unless you unskip them.`)) return;
		await api.post("/api/review/skip-all");
		onMutate();
	};

	const autoApprove = async () => {
		const res = await api.post<{ ok: boolean; count: number }>("/api/review/auto-approve");
		onMutate();
		if (res.count === 0) alert("No high-confidence items to auto-approve.");
	};

	const approveItem = async (itemId: number) => {
		await api.post(`/api/review/${itemId}/approve`);
		onMutate();
	};
	const skipItem = async (itemId: number) => {
		await api.post(`/api/review/${itemId}/skip`);
		onMutate();
	};
	const approveBatch = async (itemIds: number[]) => {
		if (itemIds.length === 0) return;
		await api.post<{ ok: boolean; count: number }>("/api/review/approve-batch", { itemIds });
		onMutate();
	};

	// Compute ids per visible group for "Approve above"
	const idsByGroup: number[][] = groups.map((g) =>
		g.kind === "movie" ? [g.item.item_id] : g.seasons.flatMap((s) => s.episodes.map((ep) => ep.item_id)),
	);
	const priorIds = (index: number): number[] => idsByGroup.slice(0, index).flat();

	const actions =
		totalItems > 0
			? [
					{ label: "Auto Review", onClick: autoApprove, primary: true },
					{ label: "Skip all", onClick: skipAll },
				]
			: undefined;

	return (
		<ColumnShell title="Review" count={totalItems} actions={actions}>
			<div className="space-y-2">
				{groups.map((group, index) => {
					const prior = index > 0 ? priorIds(index) : null;
					const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined;
					if (group.kind === "movie") {
						return (
							<PipelineCard
								key={group.item.id}
								item={group.item}
								jellyfinUrl={jellyfinUrl}
								onToggleStream={async (streamId, action) => {
									await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action });
									onMutate();
								}}
								onApprove={() => approveItem(group.item.item_id)}
								onSkip={() => skipItem(group.item.item_id)}
								onApproveUpToHere={onApproveUpToHere}
							/>
						);
					}
					return (
						<SeriesCard
							key={group.seriesKey}
							seriesKey={group.seriesKey}
							seriesName={group.seriesName}
							jellyfinUrl={jellyfinUrl}
							seriesJellyfinId={group.seriesJellyfinId}
							seasons={group.seasons}
							episodeCount={group.episodeCount}
							originalLanguage={group.originalLanguage}
							onMutate={onMutate}
							onApproveUpToHere={onApproveUpToHere}
						/>
					);
				})}
				{groups.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
				{hasMore && (
					<div ref={sentinelRef} className="py-4 text-center text-xs text-gray-400">
						{loadingMore ? "Loading more…" : ""}
					</div>
				)}
			</div>
		</ColumnShell>
	);
}
  • Step 2: Tsc + lint
mise exec bun -- bunx tsc --noEmit
mise exec bun -- bun run lint

Expected: the call site in ReviewColumn passes seasons, episodeCount, originalLanguage props to SeriesCard — this will fail until Task 5 updates SeriesCard. Same handling as Task 3 step 3: commit and proceed.

  • Step 3: Commit
git add src/features/pipeline/ReviewColumn.tsx
git commit -m "review column: infinite scroll with IntersectionObserver sentinel"

Task 5: Client — SeriesCard season nesting

Files:

  • Modify: src/features/pipeline/SeriesCard.tsx

  • Step 1: Rewrite SeriesCard

Replace the file contents with:

import { useState } from "react";
import { api } from "~/shared/lib/api";
import { LANG_NAMES } from "~/shared/lib/lang";
import type { PipelineReviewItem } from "~/shared/lib/types";
import { PipelineCard } from "./PipelineCard";

interface SeriesCardProps {
	seriesKey: string;
	seriesName: string;
	jellyfinUrl: string;
	seriesJellyfinId: string | null;
	seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>;
	episodeCount: number;
	originalLanguage: string | null;
	onMutate: () => void;
	onApproveUpToHere?: () => void;
}

export function SeriesCard({
	seriesKey,
	seriesName,
	jellyfinUrl,
	seriesJellyfinId,
	seasons,
	episodeCount,
	originalLanguage,
	onMutate,
	onApproveUpToHere,
}: SeriesCardProps) {
	const [expanded, setExpanded] = useState(false);

	const flatEpisodes = seasons.flatMap((s) => s.episodes);
	const highCount = flatEpisodes.filter((e) => e.confidence === "high").length;
	const lowCount = flatEpisodes.filter((e) => e.confidence === "low").length;
	const multipleSeasons = seasons.length > 1;

	const setSeriesLanguage = async (lang: string) => {
		await api.patch(`/api/review/series/${encodeURIComponent(seriesKey)}/language`, { language: lang });
		onMutate();
	};

	const approveSeries = async () => {
		await api.post(`/api/review/series/${encodeURIComponent(seriesKey)}/approve-all`);
		onMutate();
	};

	const approveSeason = async (season: number | null) => {
		if (season == null) return;
		await api.post(`/api/review/season/${encodeURIComponent(seriesKey)}/${season}/approve-all`);
		onMutate();
	};

	const jellyfinLink =
		jellyfinUrl && seriesJellyfinId ? `${jellyfinUrl}/web/index.html#!/details?id=${seriesJellyfinId}` : null;

	return (
		<div className="group/series rounded-lg border bg-white overflow-hidden">
			{/* Title row */}
			<div
				className="flex items-center gap-2 px-3 pt-3 pb-1 cursor-pointer hover:bg-gray-50 rounded-t-lg"
				onClick={() => setExpanded(!expanded)}
			>
				<span className="text-xs text-gray-400 shrink-0">{expanded ? "▼" : "▶"}</span>
				{jellyfinLink ? (
					<a
						href={jellyfinLink}
						target="_blank"
						rel="noopener noreferrer"
						className="text-sm font-medium truncate hover:text-blue-600 hover:underline"
						onClick={(e) => e.stopPropagation()}
					>
						{seriesName}
					</a>
				) : (
					<p className="text-sm font-medium truncate">{seriesName}</p>
				)}
			</div>

			{/* Controls row */}
			<div className="flex items-center gap-2 px-3 pb-3 pt-1">
				<span className="text-xs text-gray-500 shrink-0">{episodeCount} eps</span>
				{multipleSeasons && <span className="text-xs text-gray-500 shrink-0">· {seasons.length} seasons</span>}
				{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
				{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
				<div className="flex-1" />
				<select
					className="h-6 text-xs border border-gray-300 rounded px-1 bg-white shrink-0"
					value={originalLanguage ?? ""}
					onChange={(e) => {
						e.stopPropagation();
						setSeriesLanguage(e.target.value);
					}}
				>
					<option value="">unknown</option>
					{Object.entries(LANG_NAMES).map(([code, name]) => (
						<option key={code} value={code}>
							{name}
						</option>
					))}
				</select>
				{onApproveUpToHere && (
					<button
						onClick={(e) => {
							e.stopPropagation();
							onApproveUpToHere();
						}}
						title="Approve every card listed above this one"
						className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0 opacity-0 group-hover/series:opacity-100 transition-opacity"
					>
						 Approve above
					</button>
				)}
				<button
					onClick={(e) => {
						e.stopPropagation();
						approveSeries();
					}}
					className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 cursor-pointer whitespace-nowrap shrink-0"
				>
					Approve series
				</button>
			</div>

			{expanded && (
				<div className="border-t">
					{multipleSeasons
						? seasons.map((s) => (
								<SeasonGroup
									key={s.season ?? "unknown"}
									season={s.season}
									episodes={s.episodes}
									jellyfinUrl={jellyfinUrl}
									onApproveSeason={() => approveSeason(s.season)}
									onMutate={onMutate}
								/>
							))
						: flatEpisodes.map((ep) => (
								<EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />
							))}
				</div>
			)}
		</div>
	);
}

function SeasonGroup({
	season,
	episodes,
	jellyfinUrl,
	onApproveSeason,
	onMutate,
}: {
	season: number | null;
	episodes: PipelineReviewItem[];
	jellyfinUrl: string;
	onApproveSeason: () => void;
	onMutate: () => void;
}) {
	const [open, setOpen] = useState(false);
	const highCount = episodes.filter((e) => e.confidence === "high").length;
	const lowCount = episodes.filter((e) => e.confidence === "low").length;
	const label = season == null ? "No season" : `Season ${String(season).padStart(2, "0")}`;

	return (
		<div className="border-t first:border-t-0">
			<div
				className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-50"
				onClick={() => setOpen(!open)}
			>
				<span className="text-xs text-gray-400 shrink-0">{open ? "▼" : "▶"}</span>
				<span className="text-xs font-medium shrink-0">{label}</span>
				<span className="text-xs text-gray-500 shrink-0">· {episodes.length} eps</span>
				{highCount > 0 && <span className="text-xs text-green-600 shrink-0">{highCount} ready</span>}
				{lowCount > 0 && <span className="text-xs text-amber-600 shrink-0">{lowCount} review</span>}
				<div className="flex-1" />
				{season != null && (
					<button
						onClick={(e) => {
							e.stopPropagation();
							onApproveSeason();
						}}
						className="text-xs px-2 py-1 rounded border border-blue-600 text-blue-700 bg-white hover:bg-blue-50 cursor-pointer whitespace-nowrap shrink-0"
					>
						Approve season
					</button>
				)}
			</div>
			{open && (
				<div className="px-3 pb-3 space-y-2 pt-2">
					{episodes.map((ep) => (
						<EpisodeRow key={ep.id} ep={ep} jellyfinUrl={jellyfinUrl} onMutate={onMutate} />
					))}
				</div>
			)}
		</div>
	);
}

function EpisodeRow({ ep, jellyfinUrl, onMutate }: { ep: PipelineReviewItem; jellyfinUrl: string; onMutate: () => void }) {
	return (
		<div className="px-3 py-1">
			<PipelineCard
				item={ep}
				jellyfinUrl={jellyfinUrl}
				onToggleStream={async (streamId, action) => {
					await api.patch(`/api/review/${ep.item_id}/stream/${streamId}`, { action });
					onMutate();
				}}
				onApprove={async () => {
					await api.post(`/api/review/${ep.item_id}/approve`);
					onMutate();
				}}
				onSkip={async () => {
					await api.post(`/api/review/${ep.item_id}/skip`);
					onMutate();
				}}
			/>
		</div>
	);
}

(The EpisodeRow wrapper keeps the padding consistent whether episodes render directly under the series or under a season group.)

  • Step 2: Lint + tsc + test + build
mise exec bun -- bun run lint
mise exec bun -- bunx tsc --noEmit
mise exec bun -- bun test
mise exec bun -- bun run build

All must pass now that the whole pipeline (server → types → PipelinePage → ReviewColumn → SeriesCard) is consistent.

  • Step 3: Manual smoke test
mise exec bun -- bun run dev

Navigate to the Pipeline page:

  • Confirm no "Showing first 500 of N" banner.
  • Scroll the Review column to the bottom; new groups auto-load.
  • Find a series with pending work in >1 season; expand it; confirm nested seasons with working Approve season button.
  • Find a series with pending work in a single season; expand it; confirm flat episode list (no season nesting).
  • Click Approve series on a series with many pending episodes; confirm the whole series vanishes from the column.

Kill the dev server.

  • Step 4: Commit
git add src/features/pipeline/SeriesCard.tsx
git commit -m "series card: nest seasons when >1 pending, add Approve season button"

Task 6: Version bump + final push

  • Step 1: Bump CalVer

In package.json, set version to today's next free dot-suffix (today is 2026-04-15; prior releases are .1 and .2, so use .3 unless already taken).

  • Step 2: Final checks
mise exec bun -- bun run lint
mise exec bun -- bunx tsc --noEmit
mise exec bun -- bunx tsc --noEmit --project tsconfig.server.json
mise exec bun -- bun test
mise exec bun -- bun run build
  • Step 3: Commit + push
git add package.json
git commit -m "v2026.04.15.3 — review column lazy-load + season grouping"
git push gitea main

Guided Gates (user-verified)

  • GG-1: No "Showing first 500 of N" banner.
  • GG-2: A series with episodes previously split across the cap now shows the correct episode count.
  • GG-3: A series with >1 pending season expands into nested season groups, each with a working Approve season button.
  • GG-4: A series with 1 pending season expands flat (no extra nesting).
  • GG-5: Scrolling to the bottom of Review auto-loads the next page; no scroll = no extra fetch.