plan: review column lazy-load + season grouping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
857
docs/superpowers/plans/2026-04-15-review-lazy-load.md
Normal file
857
docs/superpowers/plans/2026-04-15-review-lazy-load.md
Normal file
@@ -0,0 +1,857 @@
|
|||||||
|
# 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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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", …)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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 ~278–293) and the enrichment of `review` rows.
|
||||||
|
- Delete the `reviewTotal` count query (lines ~294–296).
|
||||||
|
- 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
```ts
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user