Files
sofa/lib/services/credits.ts
T
jake 7ed172d675 Replace Jotai atoms with local state, add Suspense for PPR compatibility
- Delete filterable-row, backup-schedule, integrations, and system-health
  atom files; replace with useState/useTransition + server action calls
- Replace /api/explore/discover route with discoverByGenre server action;
  refactor FilterableTitleRow to call it directly via useTransition
- Wrap PagesLayout and AuthLayout children in Suspense to fix dynamic
  rendering errors during PPR static generation; remove StoreProvider
- Sequence ExplorePage session fetch before TMDB calls to prevent
  build-time requests during static generation
- Drop unnecessary "use client" directives from dashboard and person
  components that have no client-side hooks or browser API usage
- Improve Dockerfile: add bun install layer cache mount, copy
  .next/cache from builder, reorder ENV declarations
2026-03-07 16:00:19 -05:00

317 lines
9.2 KiB
TypeScript

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<number, string> {
if (people.length === 0) return new Map();
// Deduplicate by tmdbId
const uniqueByTmdbId = new Map<number, PersonData>();
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<number, string>(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<number, string>;
if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20);
// Collect notable crew
const seenCrew = new Set<string>();
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<string>();
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"),
}));
}