Files
sofa/lib/services/person.ts
T
jake 0838e5fb74 Replace hand-written TMDB types with openapi-fetch + generated schema
- Add `openapi-fetch` dependency; generate `lib/tmdb/schema.d.ts` from
  TMDB's OpenAPI spec and delete `lib/tmdb/types.ts`
- Rewrite `lib/tmdb/client.ts` to use the typed fetch client against
  the generated schema, exporting `TmdbMovieDetails`, `TmdbTvDetails`,
  `TmdbVideo`, and `TmdbGenre` directly
- Fix null-safety across services (`credits`, `availability`,
  `metadata`, `person`, `webhooks`) and call sites (explore page,
  search route, explore actions) to handle optional fields produced
  by the stricter generated types
- Update `metadata.ts` and `metadata.test.ts` to import shared types
  from `@/lib/tmdb/client` instead of the removed `types.ts`
- Exclude `lib/tmdb/schema.d.ts` from Biome linting
2026-03-08 14:07:32 -04:00

265 lines
7.9 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";
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 || person.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 || person.name,
biography: details.biography || null,
birthday: details.birthday ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
knownForDepartment: details.known_for_department ?? null,
imdbId: details.imdb_id ?? null,
};
} 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, "profiles"),
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 ?? null,
deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth ?? null,
profilePath: details.profile_path ?? null,
knownForDepartment: details.known_for_department ?? null,
popularity: details.popularity,
imdbId: details.imdb_id ?? null,
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, "profiles"),
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, "posters"),
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
// Schema types combined credits cast 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;
const validCast = ((credits.cast ?? []) as CastEntry[]).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 ?? null, "posters"),
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;
}