fix(discover): map TV date sorts, clamp TMDB page counts, and pause infinite scroll after a failed page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 03:31:06 -04:00
1 parent 7269209d43
commit d6ac2324e7
12 files changed
+162 -53

No files matched your search

@@ -0,0 +1,15 @@
/** TMDB rejects page > 500 (and our PageParam caps at 500). */
export const TMDB_MAX_PAGE = 500;
export function clampTotalPages(totalPages: number | undefined): number {
return Math.min(totalPages ?? 1, TMDB_MAX_PAGE);
}
/** TMDB's /discover/tv sorts by first_air_date where /discover/movie uses primary_release_date. */
export function tmdbSortBy(type: "movie" | "tv", sortBy: string | undefined): string {
const value = sortBy ?? "popularity.desc";
if (type === "tv" && value.startsWith("primary_release_date.")) {
return value.replace("primary_release_date.", "first_air_date.");
}
return value;
}
+7 -6
View File
@@ -22,6 +22,7 @@ import { tmdbImageUrl } from "@sofa/tmdb/image";
import { os } from "../context";
import { authed } from "../middleware";
import { clampTotalPages, tmdbSortBy } from "./discover-utils";
function requireTmdb() {
if (!isTmdbConfigured()) {
@@ -115,7 +116,7 @@ export const trending = os.discover.trending.use(authed).handler(async ({ input,
userStatuses,
episodeProgress,
page: (data as { page?: number }).page ?? input.page,
totalPages: (data as { total_pages?: number }).total_pages ?? 1,
totalPages: clampTotalPages((data as { total_pages?: number }).total_pages),
totalResults: (data as { total_results?: number }).total_results ?? 0,
};
});
@@ -162,7 +163,7 @@ export const popular = os.discover.popular.use(authed).handler(async ({ input, c
userStatuses,
episodeProgress,
page: data.page ?? input.page,
totalPages: data.total_pages ?? 1,
totalPages: clampTotalPages(data.total_pages),
totalResults: data.total_results ?? 0,
};
});
@@ -214,7 +215,7 @@ export const search = os.discover.search.use(authed).handler(async ({ input }) =
}),
),
page: personResults.page ?? input.page,
totalPages: personResults.total_pages ?? 1,
totalPages: clampTotalPages(personResults.total_pages),
totalResults: personResults.total_results ?? 0,
};
}
@@ -319,7 +320,7 @@ export const search = os.discover.search.use(authed).handler(async ({ input }) =
return {
results,
page: raw.page ?? input.page,
totalPages: raw.total_pages ?? 1,
totalPages: clampTotalPages(raw.total_pages),
totalResults: raw.total_results ?? 0,
};
});
@@ -330,7 +331,7 @@ export const browse = os.discover.browse.use(authed).handler(async ({ input, con
requireTmdb();
const params: Record<string, string> = {
sort_by: input.sortBy ?? "popularity.desc",
sort_by: tmdbSortBy(input.type, input.sortBy),
"vote_count.gte": "50",
};
if (input.genreId) params.with_genres = String(input.genreId);
@@ -397,7 +398,7 @@ export const browse = os.discover.browse.use(authed).handler(async ({ input, con
userStatuses,
episodeProgress,
page: results.page ?? input.page,
totalPages: results.total_pages ?? 1,
totalPages: clampTotalPages(results.total_pages),
totalResults: results.total_results ?? 0,
};
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { clampTotalPages, tmdbSortBy } from "../src/orpc/procedures/discover-utils";
describe("clampTotalPages", () => {
test("defaults to 1 when undefined", () => {
expect(clampTotalPages(undefined)).toBe(1);
});
test("passes through values under the cap", () => {
expect(clampTotalPages(42)).toBe(42);
});
test("caps at 500", () => {
expect(clampTotalPages(1234)).toBe(500);
});
});
describe("tmdbSortBy", () => {
test("maps TV date sorts to first_air_date", () => {
expect(tmdbSortBy("tv", "primary_release_date.desc")).toBe("first_air_date.desc");
expect(tmdbSortBy("tv", "primary_release_date.asc")).toBe("first_air_date.asc");
});
test("leaves movie date sorts unchanged", () => {
expect(tmdbSortBy("movie", "primary_release_date.desc")).toBe("primary_release_date.desc");
});
test("defaults to popularity.desc", () => {
expect(tmdbSortBy("tv", undefined)).toBe("popularity.desc");
});
test("leaves other TV sorts unchanged", () => {
expect(tmdbSortBy("tv", "vote_average.desc")).toBe("vote_average.desc");
});
});
@@ -72,29 +72,31 @@ export function DiscoverSection() {
const { data: genreData } = useQuery(orpc.discover.genres.queryOptions({ input: { type } }));
const { data: providerData } = useQuery(orpc.discover.platforms.queryOptions());
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery(
orpc.discover.browse.infiniteOptions({
input: (pageParam: number) => ({
type,
genreId,
yearMin,
yearMax,
ratingMin,
sortBy,
language,
platformId,
page: pageParam,
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError, isPending } =
useInfiniteQuery(
orpc.discover.browse.infiniteOptions({
input: (pageParam: number) => ({
type,
genreId,
yearMin,
yearMax,
ratingMin,
sortBy,
language,
platformId,
page: pageParam,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
});
const items = useMemo(() => {
@@ -55,6 +55,7 @@ export function FilterableTitleRow({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
} = useInfiniteQuery(
orpc.discover.browse.infiniteOptions({
input:
@@ -96,6 +97,7 @@ export function FilterableTitleRow({
fetchNextPage,
hasNextPage: selectedGenre !== null && hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px",
});
@@ -26,6 +26,7 @@ interface TitleRowProps {
onEndReached?: () => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
}
export function TitleRow({
@@ -37,12 +38,14 @@ export function TitleRow({
onEndReached,
hasNextPage = false,
isFetchingNextPage = false,
isFetchNextPageError = false,
}: TitleRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useInfiniteScroll({
fetchNextPage: onEndReached ?? noop,
hasNextPage: !!onEndReached && hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px",
});
@@ -32,19 +32,21 @@ export function PersonDetailSkeleton() {
}
export function PersonDetailClient({ id }: { id: string }) {
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.people.get.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
useInfiniteQuery(
orpc.people.get.infiniteOptions({
input: (pageParam: number) => ({ id, page: pageParam, limit: 20 }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
});
const person = data?.pages[0]?.person;
@@ -10,11 +10,13 @@ function HorizontalRow({
fetchNextPage,
hasNextPage = true,
isFetchingNextPage = false,
isFetchNextPageError = false,
rootMargin = "0px",
}: {
fetchNextPage: () => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
rootMargin?: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
@@ -22,6 +24,7 @@ function HorizontalRow({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
rootRef: scrollRef,
rootMargin,
});
@@ -123,4 +126,34 @@ describe("useInfiniteScroll with rootRef", () => {
expect(fetchNextPage).not.toHaveBeenCalled();
});
test("does not refetch after a failed page until the sentinel leaves and re-enters view", async () => {
const fetchNextPage = vi.fn<() => void>();
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
expect(scroller).not.toBeNull();
await nextFrames();
scroller!.scrollLeft = scroller!.scrollWidth;
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
// The fetch starts, then fails; the sentinel is still on screen.
act(() => {
root?.render(<HorizontalRow fetchNextPage={fetchNextPage} isFetchingNextPage />);
});
await nextFrames();
act(() => {
root?.render(<HorizontalRow fetchNextPage={fetchNextPage} isFetchNextPageError />);
});
await nextFrames(5);
expect(fetchNextPage).toHaveBeenCalledTimes(1);
// Scroll away, then back to the end: now it may fetch again.
scroller!.scrollLeft = 0;
await nextFrames(5);
expect(fetchNextPage).toHaveBeenCalledTimes(1);
scroller!.scrollLeft = scroller!.scrollWidth;
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(2));
});
});
+10 -1
View File
@@ -4,12 +4,15 @@ export function useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError = false,
rootRef,
rootMargin = "200px",
}: {
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
/** When the last next-page fetch failed, don't auto-refetch until the sentinel re-enters view. */
isFetchNextPageError?: boolean;
/** Scroll container to observe within. Defaults to the document viewport. */
rootRef?: React.RefObject<HTMLElement | null>;
rootMargin?: string;
@@ -20,8 +23,14 @@ export function useInfiniteScroll({
const el = sentinelRef.current;
if (!el || !hasNextPage) return;
let initialCallback = true;
const observer = new IntersectionObserver(
([entry]) => {
// After a failed page, don't refetch just because the observer was re-created while the
// sentinel is still on screen — wait for the user to scroll it out of and back into view.
const skip = initialCallback && isFetchNextPageError;
initialCallback = false;
if (skip) return;
if (entry.isIntersecting && !isFetchingNextPage) {
fetchNextPage();
}
@@ -31,7 +40,7 @@ export function useInfiniteScroll({
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootRef, rootMargin]);
}, [hasNextPage, isFetchingNextPage, isFetchNextPageError, fetchNextPage, rootRef, rootMargin]);
return sentinelRef;
}
+2
View File
@@ -79,6 +79,7 @@ function ExplorePage() {
fetchNextPage: fetchNextTrending,
hasNextPage: hasNextTrending,
isFetchingNextPage: isFetchingNextTrending,
isFetchNextPageError: isTrendingNextPageError,
} = useInfiniteQuery(
orpc.discover.trending.infiniteOptions({
input: (pageParam: number) => ({ type: "all" as const, page: pageParam }),
@@ -147,6 +148,7 @@ function ExplorePage() {
onEndReached={fetchNextTrending}
hasNextPage={hasNextTrending}
isFetchingNextPage={isFetchingNextTrending}
isFetchNextPageError={isTrendingNextPageError}
/>
</div>
+10 -8
View File
@@ -147,19 +147,21 @@ function LibraryPage() {
return input;
}, [search]);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
useInfiniteQuery(
orpc.library.list.infiniteOptions({
input: (pageParam: number) => ({ ...queryInput, page: pageParam }),
initialPageParam: 1,
getNextPageParam: (lastPage) =>
lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined,
}),
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
});
const items = data?.pages.flatMap((p) => p.items) ?? [];
+15 -13
View File
@@ -68,25 +68,27 @@ function UpcomingPage() {
const typeFilter = search.type ?? "all";
const statusFilter = search.status ?? "all";
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(
orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({
days: 90,
limit: 20,
cursor: pageParam,
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
statusFilter:
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isFetchNextPageError } =
useInfiniteQuery(
orpc.library.upcoming.infiniteOptions({
input: (pageParam: string | undefined) => ({
days: 90,
limit: 20,
cursor: pageParam,
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
statusFilter:
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
}),
);
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetchNextPageError,
});
function setTypeFilter(value: string) {