import { eq, inArray, sql } from "drizzle-orm"; import { updateTag } from "next/cache"; import { db } from "@/lib/db/client"; import { persons, titleCast, titles } from "@/lib/db/schema"; import { createLogger } from "@/lib/logger"; import { getMovieCredits, getTvAggregateCredits } from "@/lib/tmdb/client"; import { tmdbImageUrl } from "@/lib/tmdb/image"; import type { CastMember } from "@/lib/types/title"; import { cacheProfilePhotos, imageCacheEnabled } from "./image-cache"; const log = createLogger("credits"); const NOTABLE_DEPARTMENTS = new Set([ "Director", "Writer", "Screenplay", "Creator", "Executive Producer", ]); interface PersonData { tmdbId: number; name: string; profilePath: string | null; popularity?: number; } function batchUpsertPersons(people: PersonData[]): Map { if (people.length === 0) return new Map(); // Deduplicate by tmdbId const uniqueByTmdbId = new Map(); for (const p of people) { if (!uniqueByTmdbId.has(p.tmdbId)) uniqueByTmdbId.set(p.tmdbId, p); } const uniquePeople = [...uniqueByTmdbId.values()]; const tmdbIds = uniquePeople.map((p) => p.tmdbId); // Batch prefetch existing persons (1 query) const existing = db .select({ id: persons.id, tmdbId: persons.tmdbId }) .from(persons) .where(inArray(persons.tmdbId, tmdbIds)) .all(); const idMap = new Map(existing.map((p) => [p.tmdbId, p.id])); // Insert only new persons in a transaction const newPeople = uniquePeople.filter((p) => !idMap.has(p.tmdbId)); if (newPeople.length > 0) { db.transaction((tx) => { for (const p of newPeople) { const row = tx .insert(persons) .values({ tmdbId: p.tmdbId, name: p.name, profilePath: p.profilePath, popularity: p.popularity ?? null, }) .onConflictDoNothing() .returning() .get(); if (row) idMap.set(p.tmdbId, row.id); } }); // One fallback query for any that conflicted const stillMissing = newPeople .filter((p) => !idMap.has(p.tmdbId)) .map((p) => p.tmdbId); if (stillMissing.length > 0) { const fallbacks = db .select({ id: persons.id, tmdbId: persons.tmdbId }) .from(persons) .where(inArray(persons.tmdbId, stillMissing)) .all(); for (const f of fallbacks) idMap.set(f.tmdbId, f.id); } } return idMap; } export async function refreshCredits(titleId: string) { const title = db.select().from(titles).where(eq(titles.id, titleId)).get(); if (!title) return; log.debug(`Refreshing credits for "${title.title}" (${title.type})`); try { let personIds: Map; if (title.type === "movie") { const credits = await getMovieCredits(title.tmdbId); const castSlice = credits.cast.slice(0, 20); // Collect notable crew const seenCrew = new Set(); const notableCrew: typeof credits.crew = []; for (const c of credits.crew) { if (!NOTABLE_DEPARTMENTS.has(c.job)) continue; const key = `${c.id}-${c.job}`; if (seenCrew.has(key)) continue; seenCrew.add(key); notableCrew.push(c); } // Batch upsert all people at once const allPeople: PersonData[] = [ ...castSlice.map((c) => ({ tmdbId: c.id, name: c.name, profilePath: c.profile_path, popularity: c.popularity, })), ...notableCrew.map((c) => ({ tmdbId: c.id, name: c.name, profilePath: c.profile_path, popularity: c.popularity, })), ]; personIds = batchUpsertPersons(allPeople); // Collect all titleCast rows (cast + crew) and batch insert const now = new Date(); const allCastRows: (typeof titleCast.$inferInsert)[] = []; for (let i = 0; i < castSlice.length; i++) { const c = castSlice[i]; const personId = personIds.get(c.id); if (!personId) continue; allCastRows.push({ titleId, personId, character: c.character, department: "Acting", job: null, displayOrder: i, episodeCount: null, lastFetchedAt: now, }); } let crewOrder = 100; for (const c of notableCrew) { const personId = personIds.get(c.id); if (!personId) continue; allCastRows.push({ titleId, personId, character: null, department: c.department, job: c.job, displayOrder: crewOrder, episodeCount: null, lastFetchedAt: now, }); crewOrder++; } if (allCastRows.length > 0) { db.insert(titleCast) .values(allCastRows) .onConflictDoUpdate({ target: [ titleCast.titleId, titleCast.personId, titleCast.department, titleCast.character, ], set: { job: sql`excluded.job`, displayOrder: sql`excluded.displayOrder`, episodeCount: sql`excluded.episodeCount`, lastFetchedAt: sql`excluded.lastFetchedAt`, }, }) .run(); } } else { const credits = await getTvAggregateCredits(title.tmdbId); const castSlice = credits.cast.slice(0, 20); // Collect notable crew const seenCrew = new Set(); const notableCrew: Array<{ person: (typeof credits.crew)[0]; job: string; episodeCount: number; }> = []; for (const c of credits.crew) { for (const j of c.jobs) { if (!NOTABLE_DEPARTMENTS.has(j.job)) continue; const key = `${c.id}-${j.job}`; if (seenCrew.has(key)) continue; seenCrew.add(key); notableCrew.push({ person: c, job: j.job, episodeCount: j.episode_count, }); } } // Batch upsert all people at once const allPeople: PersonData[] = [ ...castSlice.map((c) => ({ tmdbId: c.id, name: c.name, profilePath: c.profile_path, popularity: c.popularity, })), ...notableCrew.map((c) => ({ tmdbId: c.person.id, name: c.person.name, profilePath: c.person.profile_path, popularity: c.person.popularity, })), ]; personIds = batchUpsertPersons(allPeople); // Collect all titleCast rows (cast + crew) and batch insert const now = new Date(); const allCastRows: (typeof titleCast.$inferInsert)[] = []; for (let i = 0; i < castSlice.length; i++) { const c = castSlice[i]; const personId = personIds.get(c.id); if (!personId) continue; const character = c.roles?.[0]?.character ?? null; allCastRows.push({ titleId, personId, character, department: "Acting", job: null, displayOrder: i, episodeCount: c.total_episode_count, lastFetchedAt: now, }); } let crewOrder = 100; for (const c of notableCrew) { const personId = personIds.get(c.person.id); if (!personId) continue; allCastRows.push({ titleId, personId, character: null, department: c.person.department, job: c.job, displayOrder: crewOrder, episodeCount: c.episodeCount, lastFetchedAt: now, }); crewOrder++; } if (allCastRows.length > 0) { db.insert(titleCast) .values(allCastRows) .onConflictDoUpdate({ target: [ titleCast.titleId, titleCast.personId, titleCast.department, titleCast.character, ], set: { job: sql`excluded.job`, displayOrder: sql`excluded.displayOrder`, episodeCount: sql`excluded.episodeCount`, lastFetchedAt: sql`excluded.lastFetchedAt`, }, }) .run(); } } for (const personId of personIds.values()) { updateTag(`person-${personId}`); } log.debug(`Credits refreshed for "${title.title}"`); if (imageCacheEnabled()) { cacheProfilePhotos(titleId).catch((err) => log.debug("Profile photo caching failed:", err), ); } } catch (err) { log.error(`Failed to refresh credits for title ${titleId}:`, err); } } export function getCastForTitle(titleId: string): CastMember[] { const rows = db .select({ id: titleCast.id, personId: titleCast.personId, name: persons.name, character: titleCast.character, department: titleCast.department, job: titleCast.job, displayOrder: titleCast.displayOrder, episodeCount: titleCast.episodeCount, profilePath: persons.profilePath, tmdbId: persons.tmdbId, }) .from(titleCast) .innerJoin(persons, eq(titleCast.personId, persons.id)) .where(eq(titleCast.titleId, titleId)) .orderBy(titleCast.displayOrder) .all(); return rows.map((r) => ({ ...r, profilePath: tmdbImageUrl(r.profilePath, "w185"), })); }