mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-16 06:25:42 -04:00
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:
34 files changed
+4135
-313
No files matched your search
@@ -21,6 +21,8 @@ import {
|
||||
IntegrationsListOutput,
|
||||
LibraryOutput,
|
||||
MediaTypeParam,
|
||||
PageParam,
|
||||
PaginatedInput,
|
||||
PersonDetailOutput,
|
||||
PersonResolveOutput,
|
||||
PopularOutput,
|
||||
@@ -256,11 +258,11 @@ export const contract = {
|
||||
tags: ["People"],
|
||||
summary: "Get person details",
|
||||
description:
|
||||
"Fetch a person's profile and filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||
"Fetch a person's profile and paginated filmography. Imports from TMDB on first access if not yet cached locally.",
|
||||
successDescription:
|
||||
"Person profile, filmography credits, and user statuses for their titles",
|
||||
"Person profile, paginated filmography credits, and user statuses for their titles",
|
||||
})
|
||||
.input(IdParam)
|
||||
.input(IdParam.merge(PaginatedInput))
|
||||
.output(PersonDetailOutput)
|
||||
.errors({
|
||||
NOT_FOUND: { message: "Person not found" },
|
||||
@@ -312,9 +314,10 @@ export const contract = {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Get user library",
|
||||
description:
|
||||
"Fetch all titles in the user's library with their tracking statuses.",
|
||||
successDescription: "Library items with user statuses",
|
||||
"Fetch paginated titles in the user's library with their tracking statuses.",
|
||||
successDescription: "Paginated library items with user statuses",
|
||||
})
|
||||
.input(PaginatedInput)
|
||||
.output(LibraryOutput),
|
||||
recommendations: oc
|
||||
.route({
|
||||
@@ -351,7 +354,7 @@ export const contract = {
|
||||
"Fetch today's trending movies and/or TV shows from TMDB, including a featured hero title and the user's statuses.",
|
||||
successDescription: "Trending items, hero spotlight, and user statuses",
|
||||
})
|
||||
.input(TrendingTypeParam)
|
||||
.input(TrendingTypeParam.merge(PageParam))
|
||||
.output(TrendingOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
||||
@@ -366,7 +369,7 @@ export const contract = {
|
||||
"Fetch currently popular movies or TV shows from TMDB with the user's tracking statuses.",
|
||||
successDescription: "Popular items with user statuses",
|
||||
})
|
||||
.input(MediaTypeParam)
|
||||
.input(MediaTypeParam.merge(PageParam))
|
||||
.output(PopularOutput)
|
||||
.errors({
|
||||
PRECONDITION_FAILED: { message: "TMDB API key is not configured" },
|
||||
|
||||
@@ -31,6 +31,43 @@ export const TmdbIdTypeParam = z
|
||||
})
|
||||
.meta({ description: "TMDB ID and media type pair for resolving titles" });
|
||||
|
||||
// ─── Pagination ──────────────────────────────────────────────
|
||||
|
||||
/** Page param for TMDB-backed endpoints (fixed ~20 items/page from TMDB) */
|
||||
export const PageParam = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(1)
|
||||
.describe("Page number (1-indexed)"),
|
||||
});
|
||||
|
||||
/** Page + limit for locally-paginated endpoints */
|
||||
export const PaginatedInput = z.object({
|
||||
page: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.default(1)
|
||||
.describe("Page number (1-indexed)"),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(20)
|
||||
.describe("Results per page (1-100, default 20)"),
|
||||
});
|
||||
|
||||
export const PaginationMeta = z.object({
|
||||
page: z.number().describe("Current page number"),
|
||||
totalPages: z.number().describe("Total number of pages available"),
|
||||
totalResults: z.number().describe("Total number of results across all pages"),
|
||||
});
|
||||
|
||||
// ─── Title inputs ──────────────────────────────────────────────
|
||||
|
||||
export const UpdateStatusInput = z
|
||||
@@ -83,6 +120,7 @@ export const SearchInput = z
|
||||
.optional()
|
||||
.describe("Optional type filter to narrow results"),
|
||||
})
|
||||
.merge(PageParam)
|
||||
.meta({ description: "Search query with optional type filter" });
|
||||
|
||||
export const DiscoverInput = z
|
||||
@@ -90,6 +128,7 @@ export const DiscoverInput = z
|
||||
type: z.enum(["movie", "tv"]).describe("Media type to discover"),
|
||||
genreId: z.number().int().describe("TMDB genre ID to filter by"),
|
||||
})
|
||||
.merge(PageParam)
|
||||
.meta({ description: "Genre-based discovery filters" });
|
||||
|
||||
// ─── Watch history input ──────────────────────────────────────
|
||||
@@ -460,6 +499,7 @@ const BrowseOutput = z
|
||||
userStatuses: userStatusMap,
|
||||
episodeProgress: episodeProgressMap,
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({
|
||||
description:
|
||||
"Browse results with user tracking statuses and episode progress",
|
||||
@@ -528,12 +568,13 @@ export const PersonDetailOutput = z
|
||||
person: PersonSchema,
|
||||
filmography: z
|
||||
.array(PersonCreditSchema)
|
||||
.describe("All credits for this person"),
|
||||
.describe("Credits for this person (paginated)"),
|
||||
userStatuses: userStatusMap,
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({
|
||||
description:
|
||||
"Person profile with filmography and user's statuses for their titles",
|
||||
"Person profile with paginated filmography and user's statuses for their titles",
|
||||
});
|
||||
|
||||
export const PersonResolveOutput = z
|
||||
@@ -631,7 +672,8 @@ export const LibraryOutput = z
|
||||
.meta({ description: "A library item with user status" }),
|
||||
),
|
||||
})
|
||||
.meta({ description: "All titles in the user's library" });
|
||||
.merge(PaginationMeta)
|
||||
.meta({ description: "Paginated titles in the user's library" });
|
||||
|
||||
export const DashboardRecommendationsOutput = z
|
||||
.object({
|
||||
@@ -660,6 +702,7 @@ export const TrendingOutput = z
|
||||
userStatuses: userStatusMap,
|
||||
episodeProgress: episodeProgressMap,
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({
|
||||
description: "Trending titles with hero spotlight and user statuses",
|
||||
});
|
||||
@@ -717,6 +760,7 @@ export const SearchOutput = z
|
||||
.meta({ description: "A search result (movie, TV show, or person)" }),
|
||||
),
|
||||
})
|
||||
.merge(PaginationMeta)
|
||||
.meta({ description: "Search results from TMDB" });
|
||||
|
||||
// ─── Discover output ───────────────────────────────────────────
|
||||
@@ -1098,5 +1142,6 @@ export type ResolvedPerson = z.infer<typeof PersonSchema>;
|
||||
export type ResolvedTitle = z.infer<typeof ResolvedTitleSchema>;
|
||||
export type Season = z.infer<typeof SeasonSchema>;
|
||||
export type SystemHealthData = z.infer<typeof SystemHealthSchema>;
|
||||
export type PaginationInfo = z.infer<typeof PaginationMeta>;
|
||||
export type TimePeriod = z.infer<typeof WatchHistoryInput>["period"];
|
||||
export type UpdateCheckResult = z.infer<typeof UpdateCheckResultSchema>;
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `personFilmography` (
|
||||
`id` text PRIMARY KEY,
|
||||
`personId` text NOT NULL,
|
||||
`titleId` text NOT NULL,
|
||||
`character` text,
|
||||
`department` text DEFAULT 'Acting' NOT NULL,
|
||||
`job` text,
|
||||
`displayOrder` integer DEFAULT 0 NOT NULL,
|
||||
CONSTRAINT `fk_personFilmography_personId_persons_id_fk` FOREIGN KEY (`personId`) REFERENCES `persons`(`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_personFilmography_titleId_titles_id_fk` FOREIGN KEY (`titleId`) REFERENCES `titles`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `persons` ADD `filmographyLastFetchedAt` integer;--> statement-breakpoint
|
||||
CREATE INDEX `personFilmography_personId_displayOrder` ON `personFilmography` (`personId`,`displayOrder`);--> statement-breakpoint
|
||||
CREATE INDEX `personFilmography_titleId` ON `personFilmography` (`titleId`);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -348,6 +348,9 @@ export const persons = sqliteTable(
|
||||
popularity: real("popularity"),
|
||||
imdbId: text("imdbId"),
|
||||
lastFetchedAt: int("lastFetchedAt", { mode: "timestamp" }),
|
||||
filmographyLastFetchedAt: int("filmographyLastFetchedAt", {
|
||||
mode: "timestamp",
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("persons_tmdbId_unique").on(table.tmdbId),
|
||||
@@ -387,6 +390,30 @@ export const titleCast = sqliteTable(
|
||||
],
|
||||
);
|
||||
|
||||
export const personFilmography = sqliteTable(
|
||||
"personFilmography",
|
||||
{
|
||||
id: uuidPk(),
|
||||
personId: text("personId")
|
||||
.notNull()
|
||||
.references(() => persons.id, { onDelete: "cascade" }),
|
||||
titleId: text("titleId")
|
||||
.notNull()
|
||||
.references(() => titles.id, { onDelete: "cascade" }),
|
||||
character: text("character"),
|
||||
department: text("department").notNull().default("Acting"),
|
||||
job: text("job"),
|
||||
displayOrder: int("displayOrder").notNull().default(0),
|
||||
},
|
||||
(table) => [
|
||||
index("personFilmography_personId_displayOrder").on(
|
||||
table.personId,
|
||||
table.displayOrder,
|
||||
),
|
||||
index("personFilmography_titleId").on(table.titleId),
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Integrations ───────────────────────────────────────────────────
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
|
||||
+10
-12
@@ -303,31 +303,29 @@ export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
|
||||
export async function getTrending(
|
||||
mediaType: "all" | "movie" | "tv",
|
||||
timeWindow: "day" | "week" = "day",
|
||||
page = 1,
|
||||
) {
|
||||
const opts = {
|
||||
params: { path: { time_window: timeWindow } },
|
||||
} as const;
|
||||
// TMDB trending supports `page` but the generated schema omits it, so widen the query type.
|
||||
const query = { page } as Record<string, unknown>;
|
||||
|
||||
if (mediaType === "movie") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/movie/{time_window}",
|
||||
opts,
|
||||
{ params: { path: { time_window: timeWindow }, query } },
|
||||
);
|
||||
if (error) throw new Error("TMDB API error: trending/movie");
|
||||
return data;
|
||||
}
|
||||
if (mediaType === "tv") {
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/tv/{time_window}",
|
||||
opts,
|
||||
);
|
||||
const { data, error } = await client.GET("/3/trending/tv/{time_window}", {
|
||||
params: { path: { time_window: timeWindow }, query },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: trending/tv");
|
||||
return data;
|
||||
}
|
||||
const { data, error } = await client.GET(
|
||||
"/3/trending/all/{time_window}",
|
||||
opts,
|
||||
);
|
||||
const { data, error } = await client.GET("/3/trending/all/{time_window}", {
|
||||
params: { path: { time_window: timeWindow }, query },
|
||||
});
|
||||
if (error) throw new Error("TMDB API error: trending/all");
|
||||
return data;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user