From 07c98f36f0a03eaf5eb3efa08eb359eed4b2c175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Wed, 15 Apr 2026 12:13:28 +0200 Subject: [PATCH] review: lazy-load groups with infinite scroll, nest seasons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client changes paired with the earlier /groups endpoint: - Types: drop review[]/reviewTotal from PipelineData, add ReviewGroup and ReviewGroupsResponse. - PipelinePage: parallel-fetch /pipeline and /groups?offset=0&limit=25. - ReviewColumn: IntersectionObserver on a sentinel div fetches the next page when it scrolls into view. No more "Showing first N of M" banner — the column loads lazily until hasMore is false. - SeriesCard: when a series has pending work in >1 season, render collapsible season sub-groups each with an "Approve season" button wired to POST /season/:key/:season/approve-all. Rename the series button from "Approve all" to "Approve series" for clarity. v2026.04.15.3 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/features/pipeline/PipelinePage.tsx | 18 +++- src/features/pipeline/ReviewColumn.tsx | 144 +++++++++++++------------ src/features/pipeline/SeriesCard.tsx | 140 +++++++++++++++++++----- src/shared/lib/types.ts | 25 ++++- 5 files changed, 225 insertions(+), 104 deletions(-) diff --git a/package.json b/package.json index 334e190..de96fea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "netfelix-audio-fix", - "version": "2026.04.15.2", + "version": "2026.04.15.3", "scripts": { "dev:server": "NODE_ENV=development bun --hot server/index.tsx", "dev:client": "vite", diff --git a/src/features/pipeline/PipelinePage.tsx b/src/features/pipeline/PipelinePage.tsx index 385c229..fb905f4 100644 --- a/src/features/pipeline/PipelinePage.tsx +++ b/src/features/pipeline/PipelinePage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; -import type { PipelineData } from "~/shared/lib/types"; +import type { PipelineData, ReviewGroupsResponse } from "~/shared/lib/types"; import { DoneColumn } from "./DoneColumn"; import { ProcessingColumn } from "./ProcessingColumn"; import { QueueColumn } from "./QueueColumn"; @@ -20,13 +20,18 @@ interface QueueStatus { export function PipelinePage() { const [data, setData] = useState(null); + const [initialGroups, setInitialGroups] = useState(null); const [progress, setProgress] = useState(null); const [queueStatus, setQueueStatus] = useState(null); const [loading, setLoading] = useState(true); const load = useCallback(async () => { - const pipelineRes = await api.get("/api/review/pipeline"); + const [pipelineRes, groupsRes] = await Promise.all([ + api.get("/api/review/pipeline"), + api.get("/api/review/groups?offset=0&limit=25"), + ]); setData(pipelineRes); + setInitialGroups(groupsRes); setLoading(false); }, []); @@ -70,7 +75,7 @@ export function PipelinePage() { }; }, [load]); - if (loading || !data) return
Loading pipeline...
; + if (loading || !data || !initialGroups) return
Loading pipeline...
; return (
@@ -79,7 +84,12 @@ export function PipelinePage() { {data.doneCount} files in desired state
- + diff --git a/src/features/pipeline/ReviewColumn.tsx b/src/features/pipeline/ReviewColumn.tsx index ce4f862..ef40048 100644 --- a/src/features/pipeline/ReviewColumn.tsx +++ b/src/features/pipeline/ReviewColumn.tsx @@ -1,28 +1,57 @@ +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "~/shared/lib/api"; -import type { PipelineReviewItem } from "~/shared/lib/types"; +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 { - items: PipelineReviewItem[]; - total: number; + initialResponse: ReviewGroupsResponse; + totalItems: number; jellyfinUrl: string; onMutate: () => void; } -interface SeriesGroup { - name: string; - key: string; - jellyfinId: string | null; - episodes: PipelineReviewItem[]; -} +export function ReviewColumn({ initialResponse, totalItems, jellyfinUrl, onMutate }: ReviewColumnProps) { + const [groups, setGroups] = useState(initialResponse.groups); + const [hasMore, setHasMore] = useState(initialResponse.hasMore); + const [loadingMore, setLoadingMore] = useState(false); + const sentinelRef = useRef(null); -export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) { - const truncated = total > items.length; + // Reset when the parent refetches page 0 (after approve/skip actions). + useEffect(() => { + setGroups(initialResponse.groups); + setHasMore(initialResponse.hasMore); + }, [initialResponse]); + + const loadMore = useCallback(async () => { + if (loadingMore || !hasMore) return; + setLoadingMore(true); + try { + const res = await api.get(`/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 ${total} pending items? They won't be processed unless you unskip them.`)) return; + 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(); }; @@ -47,89 +76,62 @@ export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColu onMutate(); }; - // Group by series (movies are standalone) - const movies = items.filter((i) => i.type === "Movie"); - const seriesMap = new Map(); - - for (const item of items.filter((i) => i.type === "Episode")) { - const key = item.series_jellyfin_id ?? item.series_name ?? String(item.item_id); - if (!seriesMap.has(key)) { - seriesMap.set(key, { name: item.series_name ?? "", key, jellyfinId: item.series_jellyfin_id, episodes: [] }); - } - seriesMap.get(key)!.episodes.push(item); - } - - // Interleave movies and series, sorted by confidence (high first) - const allItems = [ - ...movies.map((m) => ({ type: "movie" as const, item: m, sortKey: m.confidence === "high" ? 0 : 1 })), - ...[...seriesMap.values()].map((s) => ({ - type: "series" as const, - item: s, - sortKey: s.episodes.every((e) => e.confidence === "high") ? 0 : 1, - })), - ].sort((a, b) => a.sortKey - b.sortKey); - - // Flatten each visible entry to its list of item_ids. "Approve up to here" - // on index i approves everything in the union of idsByEntry[0..i-1] — one - // id for a movie, N ids for a series (one per episode). - const idsByEntry: number[][] = allItems.map((entry) => - entry.type === "movie" ? [entry.item.item_id] : entry.item.episodes.map((e) => e.item_id), + // 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[] => idsByEntry.slice(0, index).flat(); + 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 ( - 0 - ? [ - { label: "Auto Review", onClick: autoApprove, primary: true }, - { label: "Skip all", onClick: skipAll }, - ] - : undefined - } - > +
- {allItems.map((entry, index) => { - // The button approves everything visually above this card. First - // card has nothing before it → undefined suppresses the affordance. + {groups.map((group, index) => { const prior = index > 0 ? priorIds(index) : null; const onApproveUpToHere = prior && prior.length > 0 ? () => approveBatch(prior) : undefined; - if (entry.type === "movie") { + if (group.kind === "movie") { return ( { - await api.patch(`/api/review/${entry.item.item_id}/stream/${streamId}`, { action }); + await api.patch(`/api/review/${group.item.item_id}/stream/${streamId}`, { action }); onMutate(); }} - onApprove={() => approveItem(entry.item.item_id)} - onSkip={() => skipItem(entry.item.item_id)} + onApprove={() => approveItem(group.item.item_id)} + onSkip={() => skipItem(group.item.item_id)} onApproveUpToHere={onApproveUpToHere} /> ); } return ( ); })} - {allItems.length === 0 &&

No items to review

} - {truncated && ( -

- Showing first {items.length} of {total}. Approve some to see the rest. -

+ {groups.length === 0 &&

No items to review

} + {hasMore && ( +
+ {loadingMore ? "Loading more…" : ""} +
)}
diff --git a/src/features/pipeline/SeriesCard.tsx b/src/features/pipeline/SeriesCard.tsx index bf2222e..bdf2153 100644 --- a/src/features/pipeline/SeriesCard.tsx +++ b/src/features/pipeline/SeriesCard.tsx @@ -9,7 +9,9 @@ interface SeriesCardProps { seriesName: string; jellyfinUrl: string; seriesJellyfinId: string | null; - episodes: PipelineReviewItem[]; + seasons: Array<{ season: number | null; episodes: PipelineReviewItem[] }>; + episodeCount: number; + originalLanguage: string | null; onMutate: () => void; // Review-column affordance: approve every card visually above this // series in one round-trip. See ReviewColumn for the id computation. @@ -21,13 +23,18 @@ export function SeriesCard({ seriesName, jellyfinUrl, seriesJellyfinId, - episodes, + seasons, + episodeCount, + originalLanguage, onMutate, onApproveUpToHere, }: SeriesCardProps) { const [expanded, setExpanded] = useState(false); - const seriesLang = episodes[0]?.original_language ?? ""; + 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 }); @@ -39,8 +46,11 @@ export function SeriesCard({ onMutate(); }; - const highCount = episodes.filter((e) => e.confidence === "high").length; - const lowCount = episodes.filter((e) => e.confidence === "low").length; + 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; @@ -70,13 +80,14 @@ export function SeriesCard({ {/* Controls row */}
- {episodes.length} eps + {episodeCount} eps + {multipleSeasons && · {seasons.length} seasons} {highCount > 0 && {highCount} ready} {lowCount > 0 && {lowCount} review}
{onApproveUpToHere && ( )}
{expanded && ( -
+
+ {multipleSeasons + ? seasons.map((s) => ( + approveSeason(s.season)} + onMutate={onMutate} + /> + )) + : flatEpisodes.map((ep) => )} +
+ )} +
+ ); +} + +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 ( +
+
setOpen(!open)}> + {open ? "▼" : "▶"} + {label} + · {episodes.length} eps + {highCount > 0 && {highCount} ready} + {lowCount > 0 && {lowCount} review} +
+ {season != null && ( + + )} +
+ {open && ( +
{episodes.map((ep) => ( - { - 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(); - }} - /> + ))}
)}
); } + +function EpisodeRow({ + ep, + jellyfinUrl, + onMutate, +}: { + ep: PipelineReviewItem; + jellyfinUrl: string; + onMutate: () => void; +}) { + return ( +
+ { + 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(); + }} + /> +
+ ); +} diff --git a/src/shared/lib/types.ts b/src/shared/lib/types.ts index 9347ab7..771a0b1 100644 --- a/src/shared/lib/types.ts +++ b/src/shared/lib/types.ts @@ -160,11 +160,32 @@ export interface PipelineJobItem { } export interface PipelineData { - review: PipelineReviewItem[]; - reviewTotal: number; + reviewItemsTotal: number; queued: PipelineJobItem[]; processing: PipelineJobItem[]; done: PipelineJobItem[]; doneCount: number; jellyfinUrl: string; } + +// ─── Review groups (GET /api/review/groups) ────────────────────────────────── + +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; +}