mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
- Add composite indexes on userEpisodeWatches and userMovieWatches for hot queries - Batch episode tracking: wrap season/batch watches in single transaction (~8 queries vs 8*N) - Fix N+1 patterns in credits, recommendations, and filmography with batch prefetch+insert - Stream TV season hydration via Suspense instead of blocking page render - Optimize webhook logs (per-connection LIMIT 10) and system health queries - Merge genre filter waterfalls into single Promise.all fetch - Migrate deprecated Jotai loadable() to unwrap() - Replace isolated createStore()+Provider with useHydrateAtoms on root store - Remove unnecessary atomWithStorage SSR guards (handled by Jotai internally) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
338 lines
9.8 KiB
TypeScript
338 lines
9.8 KiB
TypeScript
import { eq, inArray } from "drizzle-orm";
|
|
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 {
|
|
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,
|
|
})),
|
|
];
|
|
const personIds = batchUpsertPersons(allPeople);
|
|
|
|
// Batch insert titleCast rows
|
|
db.transaction((tx) => {
|
|
const now = new Date();
|
|
for (let i = 0; i < castSlice.length; i++) {
|
|
const c = castSlice[i];
|
|
const personId = personIds.get(c.id);
|
|
if (!personId) continue;
|
|
tx.insert(titleCast)
|
|
.values({
|
|
titleId,
|
|
personId,
|
|
character: c.character,
|
|
department: "Acting",
|
|
job: null,
|
|
displayOrder: i,
|
|
episodeCount: null,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleCast.titleId,
|
|
titleCast.personId,
|
|
titleCast.department,
|
|
titleCast.character,
|
|
],
|
|
set: {
|
|
job: null,
|
|
displayOrder: i,
|
|
episodeCount: null,
|
|
lastFetchedAt: now,
|
|
},
|
|
})
|
|
.run();
|
|
}
|
|
let crewOrder = 100;
|
|
for (const c of notableCrew) {
|
|
const personId = personIds.get(c.id);
|
|
if (!personId) continue;
|
|
tx.insert(titleCast)
|
|
.values({
|
|
titleId,
|
|
personId,
|
|
character: null,
|
|
department: c.department,
|
|
job: c.job,
|
|
displayOrder: crewOrder,
|
|
episodeCount: null,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleCast.titleId,
|
|
titleCast.personId,
|
|
titleCast.department,
|
|
titleCast.character,
|
|
],
|
|
set: {
|
|
job: c.job,
|
|
displayOrder: crewOrder,
|
|
episodeCount: null,
|
|
lastFetchedAt: now,
|
|
},
|
|
})
|
|
.run();
|
|
crewOrder++;
|
|
}
|
|
});
|
|
} 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,
|
|
})),
|
|
];
|
|
const personIds = batchUpsertPersons(allPeople);
|
|
|
|
// Batch insert titleCast rows
|
|
db.transaction((tx) => {
|
|
const now = new Date();
|
|
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;
|
|
tx.insert(titleCast)
|
|
.values({
|
|
titleId,
|
|
personId,
|
|
character,
|
|
department: "Acting",
|
|
job: null,
|
|
displayOrder: i,
|
|
episodeCount: c.total_episode_count,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleCast.titleId,
|
|
titleCast.personId,
|
|
titleCast.department,
|
|
titleCast.character,
|
|
],
|
|
set: {
|
|
job: null,
|
|
displayOrder: i,
|
|
episodeCount: c.total_episode_count,
|
|
lastFetchedAt: now,
|
|
},
|
|
})
|
|
.run();
|
|
}
|
|
let crewOrder = 100;
|
|
for (const c of notableCrew) {
|
|
const personId = personIds.get(c.person.id);
|
|
if (!personId) continue;
|
|
tx.insert(titleCast)
|
|
.values({
|
|
titleId,
|
|
personId,
|
|
character: null,
|
|
department: c.person.department,
|
|
job: c.job,
|
|
displayOrder: crewOrder,
|
|
episodeCount: c.episodeCount,
|
|
lastFetchedAt: now,
|
|
})
|
|
.onConflictDoUpdate({
|
|
target: [
|
|
titleCast.titleId,
|
|
titleCast.personId,
|
|
titleCast.department,
|
|
titleCast.character,
|
|
],
|
|
set: {
|
|
job: c.job,
|
|
displayOrder: crewOrder,
|
|
episodeCount: c.episodeCount,
|
|
lastFetchedAt: now,
|
|
},
|
|
})
|
|
.run();
|
|
crewOrder++;
|
|
}
|
|
});
|
|
}
|
|
|
|
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"),
|
|
}));
|
|
}
|