mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45: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>
260 lines
7.4 KiB
TypeScript
260 lines
7.4 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 { getPersonCombinedCredits, getPersonDetails } from "@/lib/tmdb/client";
|
|
import { tmdbImageUrl } from "@/lib/tmdb/image";
|
|
import type { PersonCredit, ResolvedPerson } from "@/lib/types/title";
|
|
|
|
const log = createLogger("person");
|
|
|
|
export async function getOrFetchPerson(
|
|
personId: string,
|
|
): Promise<ResolvedPerson | null> {
|
|
const person = db
|
|
.select()
|
|
.from(persons)
|
|
.where(eq(persons.id, personId))
|
|
.get();
|
|
if (!person) return null;
|
|
|
|
// Shell record — lazily hydrate from TMDB
|
|
if (!person.lastFetchedAt) {
|
|
try {
|
|
const details = await getPersonDetails(person.tmdbId);
|
|
db.update(persons)
|
|
.set({
|
|
name: details.name,
|
|
biography: details.biography || null,
|
|
birthday: details.birthday,
|
|
deathday: details.deathday,
|
|
placeOfBirth: details.place_of_birth,
|
|
profilePath: details.profile_path,
|
|
knownForDepartment: details.known_for_department,
|
|
popularity: details.popularity,
|
|
imdbId: details.imdb_id,
|
|
lastFetchedAt: new Date(),
|
|
})
|
|
.where(eq(persons.id, personId))
|
|
.run();
|
|
|
|
return {
|
|
id: person.id,
|
|
tmdbId: person.tmdbId,
|
|
name: details.name,
|
|
biography: details.biography || null,
|
|
birthday: details.birthday,
|
|
deathday: details.deathday,
|
|
placeOfBirth: details.place_of_birth,
|
|
profilePath: tmdbImageUrl(details.profile_path, "w185"),
|
|
knownForDepartment: details.known_for_department,
|
|
imdbId: details.imdb_id,
|
|
};
|
|
} catch (err) {
|
|
log.error(`Failed to hydrate person ${personId}:`, err);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: person.id,
|
|
tmdbId: person.tmdbId,
|
|
name: person.name,
|
|
biography: person.biography,
|
|
birthday: person.birthday,
|
|
deathday: person.deathday,
|
|
placeOfBirth: person.placeOfBirth,
|
|
profilePath: tmdbImageUrl(person.profilePath, "w185"),
|
|
knownForDepartment: person.knownForDepartment,
|
|
imdbId: person.imdbId,
|
|
};
|
|
}
|
|
|
|
export async function getOrFetchPersonByTmdbId(
|
|
tmdbId: number,
|
|
): Promise<ResolvedPerson | null> {
|
|
const existing = db
|
|
.select()
|
|
.from(persons)
|
|
.where(eq(persons.tmdbId, tmdbId))
|
|
.get();
|
|
|
|
if (existing) {
|
|
return getOrFetchPerson(existing.id);
|
|
}
|
|
|
|
// Create from TMDB
|
|
try {
|
|
const details = await getPersonDetails(tmdbId);
|
|
const row = db
|
|
.insert(persons)
|
|
.values({
|
|
tmdbId,
|
|
name: details.name,
|
|
biography: details.biography || null,
|
|
birthday: details.birthday,
|
|
deathday: details.deathday,
|
|
placeOfBirth: details.place_of_birth,
|
|
profilePath: details.profile_path,
|
|
knownForDepartment: details.known_for_department,
|
|
popularity: details.popularity,
|
|
imdbId: details.imdb_id,
|
|
lastFetchedAt: new Date(),
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.get();
|
|
|
|
const person =
|
|
row ?? db.select().from(persons).where(eq(persons.tmdbId, tmdbId)).get();
|
|
if (!person) return null;
|
|
|
|
return {
|
|
id: person.id,
|
|
tmdbId: person.tmdbId,
|
|
name: person.name,
|
|
biography: person.biography,
|
|
birthday: person.birthday,
|
|
deathday: person.deathday,
|
|
placeOfBirth: person.placeOfBirth,
|
|
profilePath: tmdbImageUrl(person.profilePath, "w185"),
|
|
knownForDepartment: person.knownForDepartment,
|
|
imdbId: person.imdbId,
|
|
};
|
|
} catch (err) {
|
|
log.error(`Failed to fetch person TMDB ${tmdbId}:`, err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getLocalFilmography(personId: string): PersonCredit[] {
|
|
const rows = db
|
|
.select({
|
|
titleId: titles.id,
|
|
tmdbId: titles.tmdbId,
|
|
type: titles.type,
|
|
title: titles.title,
|
|
posterPath: titles.posterPath,
|
|
releaseDate: titles.releaseDate,
|
|
firstAirDate: titles.firstAirDate,
|
|
voteAverage: titles.voteAverage,
|
|
character: titleCast.character,
|
|
department: titleCast.department,
|
|
job: titleCast.job,
|
|
})
|
|
.from(titleCast)
|
|
.innerJoin(titles, eq(titleCast.titleId, titles.id))
|
|
.where(eq(titleCast.personId, personId))
|
|
.all();
|
|
|
|
return rows.map((r) => ({
|
|
titleId: r.titleId,
|
|
tmdbId: r.tmdbId,
|
|
type: r.type as "movie" | "tv",
|
|
title: r.title,
|
|
posterPath: tmdbImageUrl(r.posterPath, "w500"),
|
|
releaseDate: r.releaseDate,
|
|
firstAirDate: r.firstAirDate,
|
|
voteAverage: r.voteAverage,
|
|
character: r.character,
|
|
department: r.department,
|
|
job: r.job,
|
|
}));
|
|
}
|
|
|
|
export async function fetchFullFilmography(
|
|
personId: string,
|
|
): Promise<PersonCredit[]> {
|
|
const person = db
|
|
.select()
|
|
.from(persons)
|
|
.where(eq(persons.id, personId))
|
|
.get();
|
|
if (!person) return [];
|
|
|
|
const credits = await getPersonCombinedCredits(person.tmdbId);
|
|
|
|
// Filter to valid cast entries
|
|
const validCast = credits.cast.filter(
|
|
(c) => c.media_type === "movie" || c.media_type === "tv",
|
|
);
|
|
if (validCast.length === 0) return [];
|
|
|
|
// 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)
|
|
.where(inArray(titles.tmdbId, tmdbIds))
|
|
.all();
|
|
const titleIdMap = new Map<number, string>(
|
|
existingTitles.map((t) => [t.tmdbId, t.id]),
|
|
);
|
|
|
|
// Batch insert missing titles in a transaction
|
|
const newCast = validCast.filter((c) => !titleIdMap.has(c.id));
|
|
if (newCast.length > 0) {
|
|
const insertedTmdbIds = new Set<number>();
|
|
db.transaction((tx) => {
|
|
for (const c of newCast) {
|
|
if (insertedTmdbIds.has(c.id)) continue;
|
|
insertedTmdbIds.add(c.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,
|
|
lastFetchedAt: null,
|
|
})
|
|
.onConflictDoNothing()
|
|
.returning()
|
|
.get();
|
|
if (row) titleIdMap.set(c.id, row.id);
|
|
}
|
|
});
|
|
|
|
// One fallback query for any that conflicted
|
|
const stillMissing = [...insertedTmdbIds].filter(
|
|
(id) => !titleIdMap.has(id),
|
|
);
|
|
if (stillMissing.length > 0) {
|
|
const fallbacks = db
|
|
.select({ id: titles.id, tmdbId: titles.tmdbId })
|
|
.from(titles)
|
|
.where(inArray(titles.tmdbId, stillMissing))
|
|
.all();
|
|
for (const f of fallbacks) titleIdMap.set(f.tmdbId, f.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, "w500"),
|
|
releaseDate: c.release_date ?? null,
|
|
firstAirDate: c.first_air_date ?? null,
|
|
voteAverage: c.vote_average,
|
|
character: c.character ?? null,
|
|
department: "Acting",
|
|
job: null,
|
|
});
|
|
}
|
|
|
|
return results;
|
|
}
|