review: lazy-load groups with infinite scroll, nest seasons
All checks were successful
Build and Push Docker Image / build (push) Successful in 29s

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 12:13:28 +02:00
parent 4e96382097
commit 07c98f36f0
5 changed files with 225 additions and 104 deletions

View File

@@ -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",

View File

@@ -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<PipelineData | null>(null);
const [initialGroups, setInitialGroups] = useState<ReviewGroupsResponse | null>(null);
const [progress, setProgress] = useState<Progress | null>(null);
const [queueStatus, setQueueStatus] = useState<QueueStatus | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
const pipelineRes = await api.get<PipelineData>("/api/review/pipeline");
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);
setLoading(false);
}, []);
@@ -70,7 +75,7 @@ export function PipelinePage() {
};
}, [load]);
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
if (loading || !data || !initialGroups) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
return (
<div className="flex flex-col -mx-3 sm:-mx-5 -mt-4 -mb-12 h-[calc(100vh-3rem)] overflow-hidden">
@@ -79,7 +84,12 @@ export function PipelinePage() {
<span className="text-sm text-gray-500">{data.doneCount} files in desired state</span>
</div>
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
<ReviewColumn
initialResponse={initialGroups}
totalItems={data.reviewItemsTotal}
jellyfinUrl={data.jellyfinUrl}
onMutate={load}
/>
<QueueColumn items={data.queued} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} onMutate={load} />
<DoneColumn items={data.done} onMutate={load} />

View File

@@ -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<ReviewGroup[]>(initialResponse.groups);
const [hasMore, setHasMore] = useState(initialResponse.hasMore);
const [loadingMore, setLoadingMore] = useState(false);
const sentinelRef = useRef<HTMLDivElement | null>(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<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 ${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<string, SeriesGroup>();
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();
return (
<ColumnShell
title="Review"
count={truncated ? `${items.length} of ${total}` : total}
actions={
total > 0
const actions =
totalItems > 0
? [
{ label: "Auto Review", onClick: autoApprove, primary: true },
{ label: "Skip all", onClick: skipAll },
]
: undefined
}
>
: undefined;
return (
<ColumnShell title="Review" count={totalItems} actions={actions}>
<div className="space-y-2">
{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 (
<PipelineCard
key={entry.item.id}
item={entry.item}
key={group.item.id}
item={group.item}
jellyfinUrl={jellyfinUrl}
onToggleStream={async (streamId, action) => {
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 (
<SeriesCard
key={entry.item.key}
seriesKey={entry.item.key}
seriesName={entry.item.name}
key={group.seriesKey}
seriesKey={group.seriesKey}
seriesName={group.seriesName}
jellyfinUrl={jellyfinUrl}
seriesJellyfinId={entry.item.jellyfinId}
episodes={entry.item.episodes}
seriesJellyfinId={group.seriesJellyfinId}
seasons={group.seasons}
episodeCount={group.episodeCount}
originalLanguage={group.originalLanguage}
onMutate={onMutate}
onApproveUpToHere={onApproveUpToHere}
/>
);
})}
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
{truncated && (
<p className="text-xs text-gray-400 text-center py-3 border-t mt-2">
Showing first {items.length} of {total}. Approve some to see the rest.
</p>
{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>

View File

@@ -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 */}
<div className="flex items-center gap-2 px-3 pb-3 pt-1">
<span className="text-xs text-gray-500 shrink-0">{episodes.length} eps</span>
<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={seriesLang}
value={originalLanguage ?? ""}
onChange={(e) => {
e.stopPropagation();
setSeriesLanguage(e.target.value);
@@ -91,6 +102,7 @@ export function SeriesCard({
</select>
{onApproveUpToHere && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onApproveUpToHere();
@@ -102,21 +114,100 @@ export function SeriesCard({
</button>
)}
<button
type="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 all
Approve series
</button>
</div>
{expanded && (
<div className="border-t px-3 pb-3 space-y-2 pt-2">
<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
type="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
key={ep.id}
item={ep}
jellyfinUrl={jellyfinUrl}
onToggleStream={async (streamId, action) => {
@@ -132,9 +223,6 @@ export function SeriesCard({
onMutate();
}}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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;
}