diff --git a/apps/server/src/orpc/procedures/people.ts b/apps/server/src/orpc/procedures/people.ts index 20e38fb..4658762 100644 --- a/apps/server/src/orpc/procedures/people.ts +++ b/apps/server/src/orpc/procedures/people.ts @@ -1,6 +1,6 @@ import { ORPCError } from "@orpc/server"; import { - getLocalFilmography, + fetchFullFilmography, getOrFetchPerson, getOrFetchPersonByTmdbId, } from "@sofa/core/person"; @@ -15,7 +15,7 @@ export const detail = os.people.detail if (!person) throw new ORPCError("NOT_FOUND", { message: "Person not found" }); - const filmography = getLocalFilmography(person.id); + const filmography = await fetchFullFilmography(person.id); const userStatuses = getUserStatusesByTitleIds( context.user.id, filmography.map((c) => c.titleId), diff --git a/packages/core/src/person.ts b/packages/core/src/person.ts index 069fc63..4554bc5 100644 --- a/packages/core/src/person.ts +++ b/packages/core/src/person.ts @@ -217,19 +217,29 @@ export async function fetchFullFilmography( const credits = await getPersonCombinedCredits(person.tmdbId); - // Filter to valid cast entries - // Schema types combined credits cast as movie-only; TV entries also carry + // Schema types combined credits as movie-only; TV entries also carry // `name` and `first_air_date` at runtime, so widen the type minimally. type CastEntry = (typeof credits)["cast"] extends (infer E)[] | undefined ? E & { name?: string; first_air_date?: string } : never; + type CrewEntry = (typeof credits)["crew"] extends (infer E)[] | undefined + ? E & { name?: string; first_air_date?: string } + : never; + const validCast = ((credits.cast ?? []) as CastEntry[]).filter( (c) => c.media_type === "movie" || c.media_type === "tv", ); - if (validCast.length === 0) return []; + const validCrew = ((credits.crew ?? []) as CrewEntry[]).filter( + (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 tmdbIds = [...new Set(allEntries.map((c) => c.id))]; // Batch prefetch existing titles (1 query) - const tmdbIds = [...new Set(validCast.map((c) => c.id))]; const existingTitles = db .select({ id: titles.id, tmdbId: titles.tmdbId }) .from(titles) @@ -240,11 +250,11 @@ export async function fetchFullFilmography( ); // Batch insert missing titles in a transaction - const newCast = validCast.filter((c) => !titleIdMap.has(c.id)); - if (newCast.length > 0) { + const newEntries = allEntries.filter((c) => !titleIdMap.has(c.id)); + if (newEntries.length > 0) { const insertedTmdbIds = new Set(); db.transaction((tx) => { - for (const c of newCast) { + for (const c of newEntries) { if (insertedTmdbIds.has(c.id)) continue; insertedTmdbIds.add(c.id); const row = tx @@ -304,6 +314,24 @@ export async function fetchFullFilmography( job: null, }); } + 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, + }); + } return results; }