feat(core): add pagination to explore, search, person, and library endpoints

Convert trending, search, people.detail, and dashboard.library to page-based responses (`page` / `totalPages`) so clients can load results incrementally.

Update `@sofa/core` discovery and person services to accept a `page` / `limit` input and slice results accordingly. Add a new DB migration to persist the data needed to back paginated filmography queries.

On the web, introduce a `useInfiniteScroll` hook and wire up `useInfiniteQuery` in the explore, person detail, and filterable title row components with an intersection-observer sentinel. On native, switch the same screens from `useQuery` to `useInfiniteQuery` with `onEndReached` / `ListFooterComponent` loading indicators. Also replace remaining `FlatList` usages with `FlashList` in the home and title detail screens.
This commit is contained in:
jake committed 2026-03-15 17:37:39 -04:00
1 parent d0eca7cf32
commit 6c193a9271
34 files changed
+4135 -313

No files matched your search

+47
View File
@@ -395,6 +395,53 @@ export function getNewAvailableFeed(userId: string, days = 14) {
return results;
}
export function getLibraryFeed(userId: string, page = 1, limit = 20) {
const offset = (page - 1) * limit;
const availabilityFilter = sql`EXISTS (SELECT 1 FROM ${availabilityOffers} WHERE ${availabilityOffers.titleId} = ${titles.id})`;
const joinCondition = and(
eq(userTitleStatus.titleId, titles.id),
eq(userTitleStatus.userId, userId),
);
const [{ count }] = db
.select({ count: sql<number>`count(*)` })
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.all();
const items = db
.select({
titleId: titles.id,
title: titles.title,
type: titles.type,
tmdbId: titles.tmdbId,
posterPath: titles.posterPath,
posterThumbHash: titles.posterThumbHash,
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
popularity: titles.popularity,
userStatus: userTitleStatus.status,
})
.from(titles)
.innerJoin(userTitleStatus, joinCondition)
.where(availabilityFilter)
.orderBy(desc(titles.popularity))
.limit(limit)
.offset(offset)
.all();
const totalResults = count ?? 0;
return {
items,
page,
totalPages: Math.max(1, Math.ceil(totalResults / limit)),
totalResults,
};
}
export function getRecommendationsFeed(userId: string) {
// Get recommendations from user's highly-rated or completed titles
const userCompletedOrRated = db
+121 -83
View File
@@ -1,13 +1,14 @@
import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import { db } from "@sofa/db/client";
import { eq, inArray } from "@sofa/db/helpers";
import { persons, titleCast, titles } from "@sofa/db/schema";
import { personFilmography, persons, titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { generatePersonThumbHash } from "./thumbhash";
const log = createLogger("person");
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
async function ensureProfileThumbHash(
person: Pick<
@@ -180,13 +181,14 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
releaseDate: titles.releaseDate,
firstAirDate: titles.firstAirDate,
voteAverage: titles.voteAverage,
character: titleCast.character,
department: titleCast.department,
job: titleCast.job,
character: personFilmography.character,
department: personFilmography.department,
job: personFilmography.job,
})
.from(titleCast)
.innerJoin(titles, eq(titleCast.titleId, titles.id))
.where(eq(titleCast.personId, personId))
.from(personFilmography)
.innerJoin(titles, eq(personFilmography.titleId, titles.id))
.where(eq(personFilmography.personId, personId))
.orderBy(personFilmography.displayOrder)
.all();
return rows.map((r) => ({
@@ -205,16 +207,9 @@ export function getLocalFilmography(personId: string): PersonCredit[] {
}));
}
export async function fetchFullFilmography(
personId: string,
): Promise<PersonCredit[]> {
const person = db
.select()
.from(persons)
.where(eq(persons.id, personId))
.get();
if (!person) return [];
async function syncPersonFilmography(
person: Pick<typeof persons.$inferSelect, "id" | "tmdbId">,
) {
const credits = await getPersonCombinedCredits(person.tmdbId);
// Schema types combined credits as movie-only; TV entries also carry
@@ -233,56 +228,53 @@ export async function fetchFullFilmography(
(c) => c.media_type === "movie" || c.media_type === "tv",
);
if (validCast.length === 0 && validCrew.length === 0) return [];
// Collect all unique TMDB IDs from both cast and crew
const allEntries = [...validCast.map((c) => c), ...validCrew.map((c) => c)];
const allEntries = [...validCast, ...validCrew];
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
// Batch prefetch existing titles (1 query)
const existingTitles = db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all();
const existingTitles =
tmdbIds.length > 0
? db
.select({ id: titles.id, tmdbId: titles.tmdbId })
.from(titles)
.where(inArray(titles.tmdbId, tmdbIds))
.all()
: [];
const titleIdMap = new Map<number, string>(
existingTitles.map((t) => [t.tmdbId, t.id]),
existingTitles.map((title) => [title.tmdbId, title.id]),
);
// Batch insert missing titles in a transaction
const newEntries = allEntries.filter((c) => !titleIdMap.has(c.id));
const newEntries = allEntries.filter((entry) => !titleIdMap.has(entry.id));
if (newEntries.length > 0) {
const insertedTmdbIds = new Set<number>();
db.transaction((tx) => {
for (const c of newEntries) {
if (insertedTmdbIds.has(c.id)) continue;
insertedTmdbIds.add(c.id);
for (const entry of newEntries) {
if (insertedTmdbIds.has(entry.id)) continue;
insertedTmdbIds.add(entry.id);
const row = tx
.insert(titles)
.values({
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
overview: c.overview,
releaseDate: c.release_date,
firstAirDate: c.first_air_date,
posterPath: c.poster_path,
backdropPath: c.backdrop_path,
popularity: c.popularity,
voteAverage: c.vote_average,
voteCount: c.vote_count,
tmdbId: entry.id,
type: entry.media_type as "movie" | "tv",
title: entry.title ?? entry.name ?? "Unknown",
overview: entry.overview,
releaseDate: entry.release_date,
firstAirDate: entry.first_air_date,
posterPath: entry.poster_path,
backdropPath: entry.backdrop_path,
popularity: entry.popularity,
voteAverage: entry.vote_average,
voteCount: entry.vote_count,
lastFetchedAt: null,
})
.onConflictDoNothing()
.returning()
.get();
if (row) titleIdMap.set(c.id, row.id);
if (row) titleIdMap.set(entry.id, row.id);
}
});
// One fallback query for any that conflicted
const stillMissing = [...insertedTmdbIds].filter(
(id) => !titleIdMap.has(id),
(tmdbId) => !titleIdMap.has(tmdbId),
);
if (stillMissing.length > 0) {
const fallbacks = db
@@ -290,48 +282,94 @@ export async function fetchFullFilmography(
.from(titles)
.where(inArray(titles.tmdbId, stillMissing))
.all();
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.id);
for (const fallback of fallbacks) {
titleIdMap.set(fallback.tmdbId, fallback.id);
}
}
}
// Build results from map (0 queries)
const results: PersonCredit[] = [];
for (const c of validCast) {
const tid = titleIdMap.get(c.id);
if (!tid) continue;
results.push({
titleId: tid,
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
posterThumbHash: null,
releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average,
character: c.character ?? null,
const nextRows: (typeof personFilmography.$inferInsert)[] = [];
let displayOrder = 0;
for (const credit of validCast) {
const titleId = titleIdMap.get(credit.id);
if (!titleId) continue;
nextRows.push({
personId: person.id,
titleId,
character: credit.character ?? null,
department: "Acting",
job: null,
displayOrder,
});
}
for (const c of validCrew) {
const tid = titleIdMap.get(c.id);
if (!tid) continue;
results.push({
titleId: tid,
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
posterThumbHash: null,
releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average,
character: null,
department: c.department ?? "Crew",
job: c.job ?? null,
});
displayOrder++;
}
return results;
for (const credit of validCrew) {
const titleId = titleIdMap.get(credit.id);
if (!titleId) continue;
nextRows.push({
personId: person.id,
titleId,
character: null,
department: credit.department ?? "Crew",
job: credit.job ?? null,
displayOrder,
});
displayOrder++;
}
const now = new Date();
db.transaction((tx) => {
tx.delete(personFilmography)
.where(eq(personFilmography.personId, person.id))
.run();
for (const row of nextRows) {
tx.insert(personFilmography).values(row).run();
}
tx.update(persons)
.set({ filmographyLastFetchedAt: now })
.where(eq(persons.id, person.id))
.run();
});
}
export async function fetchFullFilmography(
personId: string,
): Promise<PersonCredit[]> {
const person = db
.select()
.from(persons)
.where(eq(persons.id, personId))
.get();
if (!person) return [];
const localFilmography = getLocalFilmography(personId);
const isFresh =
person.filmographyLastFetchedAt &&
person.filmographyLastFetchedAt.getTime() >
Date.now() - FILMOGRAPHY_STALE_MS;
if (isFresh) {
return localFilmography;
}
try {
await syncPersonFilmography(person);
return getLocalFilmography(personId);
} catch (err) {
if (person.filmographyLastFetchedAt) {
log.warn(
`Failed to refresh filmography for person ${personId}, falling back to cached DB rows:`,
err,
);
return localFilmography;
}
throw err;
}
}
+84
View File
@@ -13,6 +13,7 @@ import {
} from "@sofa/db/test-utils";
import {
getContinueWatchingFeed,
getLibraryFeed,
getNewAvailableFeed,
getRecommendationsFeed,
getRecommendationsForTitle,
@@ -359,3 +360,86 @@ describe("getRecommendationsForTitle", () => {
expect(recs.map((rec) => rec.id)).toEqual(["rec1", "rec2"]);
});
});
// ── getLibraryFeed ────────────────────────────────────────────────────
describe("getLibraryFeed", () => {
test("returns first page with correct pagination metadata", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 20);
expect(result.items).toHaveLength(20);
expect(result.page).toBe(1);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("returns second page with remaining items", () => {
insertUser();
for (let i = 1; i <= 25; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(5);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(25);
expect(result.totalPages).toBe(2);
});
test("custom limit respected", () => {
insertUser();
for (let i = 1; i <= 10; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 1, 5);
expect(result.items).toHaveLength(5);
expect(result.totalPages).toBe(2);
expect(result.totalResults).toBe(10);
});
test("empty page beyond total returns empty items", () => {
insertUser();
for (let i = 1; i <= 5; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const result = getLibraryFeed("user-1", 2, 20);
expect(result.items).toHaveLength(0);
expect(result.page).toBe(2);
expect(result.totalResults).toBe(5);
});
test("pages are disjoint and cover all items", () => {
insertUser();
for (let i = 1; i <= 4; i++) {
insertTitle({ id: `m${i}`, tmdbId: i });
insertStatus("user-1", `m${i}`, "watchlist");
insertAvailabilityOffer(`m${i}`);
}
const page1 = getLibraryFeed("user-1", 1, 2);
const page2 = getLibraryFeed("user-1", 2, 2);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
const allIds = [
...page1.items.map((i) => i.tmdbId),
...page2.items.map((i) => i.tmdbId),
].sort();
expect(allIds).toEqual([1, 2, 3, 4]);
});
});
+81 -3
View File
@@ -8,7 +8,7 @@ import {
test,
} from "bun:test";
import { eq } from "@sofa/db/helpers";
import { persons } from "@sofa/db/schema";
import { personFilmography, persons } from "@sofa/db/schema";
import { clearAllTables, testDb } from "@sofa/db/test-utils";
const TINY_PNG = Buffer.from(
@@ -29,13 +29,37 @@ let nextPersonDetails = {
popularity: 10,
imdb_id: null,
};
let combinedCreditsCalls = 0;
let nextCombinedCredits = {
cast: [
{
id: 501,
media_type: "movie",
title: "Cached Movie",
name: undefined,
overview: "Overview",
release_date: "2024-01-01",
first_air_date: undefined,
poster_path: "/poster.png",
backdrop_path: "/backdrop.png",
popularity: 5,
vote_average: 7.5,
vote_count: 42,
character: "Lead",
},
],
crew: [],
};
mock.module("@sofa/tmdb/client", () => ({
getPersonDetails: async () => nextPersonDetails,
getPersonCombinedCredits: async () => ({ cast: [] }),
getPersonCombinedCredits: async () => {
combinedCreditsCalls++;
return nextCombinedCredits;
},
}));
import { getOrFetchPerson } from "../src/person";
import { fetchFullFilmography, getOrFetchPerson } from "../src/person";
beforeEach(() => {
clearAllTables();
@@ -53,6 +77,27 @@ beforeEach(() => {
popularity: 10,
imdb_id: null,
};
combinedCreditsCalls = 0;
nextCombinedCredits = {
cast: [
{
id: 501,
media_type: "movie",
title: "Cached Movie",
name: undefined,
overview: "Overview",
release_date: "2024-01-01",
first_air_date: undefined,
poster_path: "/poster.png",
backdrop_path: "/backdrop.png",
popularity: 5,
vote_average: 7.5,
vote_count: 42,
character: "Lead",
},
],
crew: [],
};
spyOn(globalThis, "fetch").mockImplementation((async (
_input: string | URL | Request,
_init?: RequestInit,
@@ -124,3 +169,36 @@ describe("getOrFetchPerson", () => {
expect(stored?.profileThumbHash).toBe(person?.profileThumbHash);
});
});
describe("fetchFullFilmography", () => {
test("stores and reuses synced filmography rows from the database", async () => {
testDb
.insert(persons)
.values({
id: "p1",
tmdbId: 100,
name: "Cached Person",
lastFetchedAt: new Date(),
})
.run();
const first = await fetchFullFilmography("p1");
const second = await fetchFullFilmography("p1");
const storedPerson = testDb
.select()
.from(persons)
.where(eq(persons.id, "p1"))
.get();
const storedCredits = testDb
.select()
.from(personFilmography)
.where(eq(personFilmography.personId, "p1"))
.all();
expect(first).toHaveLength(1);
expect(second).toEqual(first);
expect(combinedCreditsCalls).toBe(1);
expect(storedPerson?.filmographyLastFetchedAt).toBeInstanceOf(Date);
expect(storedCredits).toHaveLength(1);
});
});
+8 -9
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { persons, titleCast } from "@sofa/db/schema";
import { personFilmography, persons } from "@sofa/db/schema";
import { clearAllTables, insertTitle, testDb } from "@sofa/db/test-utils";
import { getLocalFilmography } from "../src/person";
@@ -12,7 +12,7 @@ function insertPerson(id: string, tmdbId: number, name: string) {
return id;
}
function insertCastEntry(
function insertFilmographyEntry(
titleId: string,
personId: string,
overrides: {
@@ -23,7 +23,7 @@ function insertCastEntry(
} = {},
) {
testDb
.insert(titleCast)
.insert(personFilmography)
.values({
titleId,
personId,
@@ -31,7 +31,6 @@ function insertCastEntry(
department: overrides.department ?? "Acting",
job: overrides.job ?? null,
displayOrder: overrides.displayOrder ?? 0,
lastFetchedAt: new Date(),
})
.run();
}
@@ -41,8 +40,8 @@ describe("getLocalFilmography", () => {
insertTitle({ id: "m1", tmdbId: 1, title: "Movie One" });
insertTitle({ id: "m2", tmdbId: 2, title: "Movie Two" });
insertPerson("p1", 100, "Test Actor");
insertCastEntry("m1", "p1", { character: "Hero" });
insertCastEntry("m2", "p1", { character: "Sidekick" });
insertFilmographyEntry("m1", "p1", { character: "Hero" });
insertFilmographyEntry("m2", "p1", { character: "Sidekick" });
const filmography = getLocalFilmography("p1");
expect(filmography).toHaveLength(2);
@@ -55,7 +54,7 @@ describe("getLocalFilmography", () => {
test("includes crew roles", () => {
insertTitle({ id: "m1", tmdbId: 1, title: "Directed Movie" });
insertPerson("p1", 100, "Director Person");
insertCastEntry("m1", "p1", {
insertFilmographyEntry("m1", "p1", {
character: null,
department: "Directing",
job: "Director",
@@ -77,8 +76,8 @@ describe("getLocalFilmography", () => {
insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie" });
insertTitle({ id: "tv1", tmdbId: 2, type: "tv", title: "A Show" });
insertPerson("p1", 100, "Versatile Actor");
insertCastEntry("m1", "p1");
insertCastEntry("tv1", "p1");
insertFilmographyEntry("m1", "p1");
insertFilmographyEntry("tv1", "p1");
const filmography = getLocalFilmography("p1");
const types = filmography.map((f) => f.type).sort();