Files
sofa/packages/core/src/person.ts
T
jakeandClaude Opus 4.6 8d1cc46875 fix(db): use (tmdbId, type) composite keys for title lookups
The unique constraint on titles is (tmdbId, type), meaning the same
numeric TMDB ID can exist as both a movie and TV show. Several places
were keying maps by tmdbId alone, which caused UNIQUE constraint
violations when an UPDATE inadvertently changed a row's type, and
silently dropped titles when both types existed for the same ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:47:14 -04:00

323 lines
9.9 KiB
TypeScript

import type { PersonCredit, ResolvedPerson } from "@sofa/api/schemas";
import {
batchInsertShellTitlesTransaction,
ensureBrowsePersonsTransaction,
getPersonById,
getPersonByTmdbId,
getPersonFilmographyJoined,
insertPersonReturning,
replaceFilmographyTransaction,
updatePerson,
} from "@sofa/db/queries/person";
import { getExistingTitlesByTmdbIds } from "@sofa/db/queries/title";
import type { personFilmography } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { getPersonCombinedCredits, getPersonDetails } from "@sofa/tmdb/client";
import { tmdbImageUrl } from "@sofa/tmdb/image";
import { generatePersonThumbHash } from "./thumbhash";
const log = createLogger("person");
const FILMOGRAPHY_STALE_MS = 30 * 24 * 60 * 60 * 1000;
async function ensureProfileThumbHash(
person: Pick<
typeof import("@sofa/db/schema").persons.$inferSelect,
"id" | "profilePath" | "profileThumbHash"
>,
) {
if (!person.profilePath) {
if (person.profileThumbHash) {
await generatePersonThumbHash(person.id, null);
}
return null;
}
if (person.profileThumbHash) {
return person.profileThumbHash;
}
return (await generatePersonThumbHash(person.id, person.profilePath)) ?? null;
}
export async function getOrFetchPerson(personId: string): Promise<ResolvedPerson | null> {
const person = getPersonById(personId);
if (!person) return null;
// Shell record — lazily hydrate from TMDB
if (!person.lastFetchedAt) {
try {
const details = await getPersonDetails(person.tmdbId);
updatePerson(personId, {
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(),
});
const newProfilePath = details.profile_path ?? null;
const pathChanged = newProfilePath !== person.profilePath;
const profileThumbHash = pathChanged
? await ensureProfileThumbHash({
id: personId,
profilePath: newProfilePath,
profileThumbHash: null,
})
: await ensureProfileThumbHash({
id: personId,
profilePath: newProfilePath,
profileThumbHash: person.profileThumbHash,
});
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"),
profileThumbHash,
knownForDepartment: details.known_for_department ?? null,
imdbId: details.imdb_id ?? null,
};
} catch (err) {
log.error(`Failed to hydrate person ${personId}:`, err);
}
}
const profileThumbHash = await ensureProfileThumbHash(person);
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"),
profileThumbHash,
knownForDepartment: person.knownForDepartment,
imdbId: person.imdbId,
};
}
export async function getOrFetchPersonByTmdbId(tmdbId: number): Promise<ResolvedPerson | null> {
const existing = getPersonByTmdbId(tmdbId);
if (existing) {
return getOrFetchPerson(existing.id);
}
// Create from TMDB
try {
const details = await getPersonDetails(tmdbId);
const row = insertPersonReturning({
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(),
});
const person = row ?? getPersonByTmdbId(tmdbId);
if (!person) return null;
const profileThumbHash = await ensureProfileThumbHash(person);
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"),
profileThumbHash,
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 = getPersonFilmographyJoined(personId);
return rows.map((r) => ({
titleId: r.titleId,
tmdbId: r.tmdbId,
type: r.type as "movie" | "tv",
title: r.title,
posterPath: tmdbImageUrl(r.posterPath, "posters"),
posterThumbHash: r.posterThumbHash,
releaseDate: r.releaseDate,
firstAirDate: r.firstAirDate,
voteAverage: r.voteAverage,
character: r.character,
department: r.department,
job: r.job,
}));
}
async function syncPersonFilmography(
person: Pick<typeof import("@sofa/db/schema").persons.$inferSelect, "id" | "tmdbId">,
) {
const credits = await getPersonCombinedCredits(person.tmdbId);
// Schema types combined credits 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;
type CrewEntry = (typeof credits)["crew"] 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",
);
const validCrew = ((credits.crew ?? []) as CrewEntry[]).filter(
(c) => c.media_type === "movie" || c.media_type === "tv",
);
const allEntries = [...validCast, ...validCrew];
const tmdbIds = [...new Set(allEntries.map((c) => c.id))];
const existingTitles = getExistingTitlesByTmdbIds(tmdbIds);
// Key by (tmdbId, type) composite since the same tmdbId can be a movie and TV show
const titleIdMap = new Map<string, string>(
existingTitles.map((title) => [`${title.tmdbId}-${title.type}`, title.id]),
);
const newEntries = allEntries.filter(
(entry) => !titleIdMap.has(`${entry.id}-${entry.media_type}`),
);
if (newEntries.length > 0) {
const shellEntries = newEntries
.filter(
(entry, index, arr) =>
arr.findIndex((e) => e.id === entry.id && e.media_type === entry.media_type) === index,
)
.map((entry) => ({
tmdbId: entry.id,
mediaType: entry.media_type as "movie" | "tv",
title: entry.title ?? entry.name ?? "Unknown",
overview: entry.overview,
releaseDate: entry.release_date,
firstAirDate: entry.first_air_date,
posterPath: entry.poster_path,
backdropPath: entry.backdrop_path,
popularity: entry.popularity,
voteAverage: entry.vote_average,
voteCount: entry.vote_count,
}));
const updatedMap = batchInsertShellTitlesTransaction(shellEntries, titleIdMap);
for (const [key, id] of updatedMap) {
titleIdMap.set(key, id);
}
}
const nextRows: (typeof personFilmography.$inferInsert)[] = [];
let displayOrder = 0;
for (const credit of validCast) {
const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
if (!titleId) continue;
nextRows.push({
personId: person.id,
titleId,
character: credit.character ?? null,
department: "Acting",
job: null,
displayOrder,
});
displayOrder++;
}
for (const credit of validCrew) {
const titleId = titleIdMap.get(`${credit.id}-${credit.media_type}`);
if (!titleId) continue;
nextRows.push({
personId: person.id,
titleId,
character: null,
department: credit.department ?? "Crew",
job: credit.job ?? null,
displayOrder,
});
displayOrder++;
}
const now = new Date();
replaceFilmographyTransaction(person.id, nextRows, now);
}
export async function fetchFullFilmography(personId: string): Promise<PersonCredit[]> {
const person = getPersonById(personId);
if (!person) return [];
const localFilmography = getLocalFilmography(personId);
const isFresh =
person.filmographyLastFetchedAt &&
person.filmographyLastFetchedAt.getTime() > Date.now() - FILMOGRAPHY_STALE_MS;
if (isFresh) {
return localFilmography;
}
try {
await syncPersonFilmography(person);
return getLocalFilmography(personId);
} catch (err) {
if (person.filmographyLastFetchedAt) {
log.warn(
`Failed to refresh filmography for person ${personId}, falling back to cached DB rows:`,
err,
);
return localFilmography;
}
throw err;
}
}
// ─── Browse batch upsert ─────────────────────────────────────
interface BrowsePersonInput {
tmdbId: number;
name: string;
profilePath: string | null;
knownForDepartment?: string | null;
popularity?: number | null;
}
/**
* Ensure every person search result has a local person row.
* Inserts shell persons (`lastFetchedAt = null`) for new tmdbIds.
* Returns a map of tmdbId → internal UUID.
*/
export function ensureBrowsePersonsExist(items: BrowsePersonInput[]): Map<number, string> {
return ensureBrowsePersonsTransaction(items);
}