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
This commit is contained in:
2026-03-08 14:07:32 -04:00
parent ad8ec62317
commit 0838e5fb74
15 changed files with 23373 additions and 491 deletions
+2 -2
View File
@@ -30,8 +30,8 @@ export async function refreshAvailability(titleId: string) {
titleId,
region: "US",
providerId: p.provider_id,
providerName: p.provider_name,
logoPath: p.logo_path,
providerName: p.provider_name ?? "",
logoPath: p.logo_path ?? "",
offerType,
link: us.link ?? null,
lastFetchedAt: now,
+20 -18
View File
@@ -95,13 +95,13 @@ export async function refreshCredits(
if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20);
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;
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);
@@ -112,14 +112,14 @@ export async function refreshCredits(
const allPeople: PersonData[] = [
...castSlice.map((c) => ({
tmdbId: c.id,
name: c.name,
profilePath: c.profile_path,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
...notableCrew.map((c) => ({
tmdbId: c.id,
name: c.name,
profilePath: c.profile_path,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
];
@@ -180,24 +180,26 @@ export async function refreshCredits(
}
} else {
const credits = await getTvAggregateCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20);
const tvCast = credits.cast ?? [];
const tvCrew = credits.crew ?? [];
const castSlice = tvCast.slice(0, 20);
// Collect notable crew
const seenCrew = new Set<string>();
const notableCrew: Array<{
person: (typeof credits.crew)[0];
person: (typeof tvCrew)[number];
job: string;
episodeCount: number;
}> = [];
for (const c of credits.crew) {
for (const j of c.jobs) {
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue;
for (const c of tvCrew) {
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,
job: j.job ?? "",
episodeCount: j.episode_count,
});
}
@@ -207,14 +209,14 @@ export async function refreshCredits(
const allPeople: PersonData[] = [
...castSlice.map((c) => ({
tmdbId: c.id,
name: c.name,
profilePath: c.profile_path,
name: c.name ?? "",
profilePath: c.profile_path ?? null,
popularity: c.popularity,
})),
...notableCrew.map((c) => ({
tmdbId: c.person.id,
name: c.person.name,
profilePath: c.person.profile_path,
name: c.person.name ?? "",
profilePath: c.person.profile_path ?? null,
popularity: c.person.popularity,
})),
];
@@ -247,7 +249,7 @@ export async function refreshCredits(
titleId,
personId,
character: null,
department: c.person.department,
department: c.person.department ?? "",
job: c.job,
displayOrder: crewOrder,
episodeCount: c.episodeCount,
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/types";
} from "@/lib/tmdb/client";
import {
extractMovieContentRating,
extractTvContentRating,
+42 -35
View File
@@ -12,6 +12,12 @@ import {
} from "@/lib/db/schema";
import { createLogger } from "@/lib/logger";
import { generateProviderUrl } from "@/lib/providers";
import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/client";
import {
getMovieDetails,
getRecommendations,
@@ -21,12 +27,6 @@ import {
getVideos,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/types";
import type {
AvailabilityOffer,
CastMember,
@@ -66,16 +66,19 @@ function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
}
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) {
if (tmdbGenres.length === 0) return;
const validGenres = tmdbGenres.filter(
(g): g is TmdbGenre & { name: string } => !!g.name,
);
if (validGenres.length === 0) return;
db.transaction((tx) => {
for (const g of tmdbGenres) {
for (const g of validGenres) {
tx.insert(genres)
.values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run();
}
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of tmdbGenres) {
for (const g of validGenres) {
tx.insert(titleGenres)
.values({ titleId, genreId: g.id })
.onConflictDoNothing()
@@ -90,7 +93,7 @@ export function extractMovieContentRating(
): string | null {
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
if (!us) return null;
for (const rd of us.release_dates) {
for (const rd of us.release_dates ?? []) {
if (rd.certification) return rd.certification;
}
return null;
@@ -159,7 +162,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
.where(eq(titles.id, existing.id))
.run();
}
upsertGenres(existing.id, show.genres);
upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
@@ -167,8 +170,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(existing.id, show.poster_path).catch((err) =>
log.debug("Color extraction failed:", err),
extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
(err) => log.debug("Color extraction failed:", err),
);
refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err),
@@ -198,7 +201,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
{
tmdbId: movie.id,
type: "movie",
title: movie.title,
title: movie.title ?? "",
originalTitle: movie.original_title,
overview: movie.overview,
releaseDate: movie.release_date || null,
@@ -214,14 +217,14 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, movie.genres);
upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err),
);
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, movie.poster_path).catch((err) =>
extractAndStoreColors(row.id, movie.poster_path ?? null).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshCredits(row.id).catch((err) =>
@@ -245,7 +248,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null,
type: "tv",
title: show.name,
title: show.name ?? "",
originalTitle: show.original_name,
overview: show.overview,
firstAirDate: show.first_air_date || null,
@@ -261,7 +264,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId,
);
if (!row) return undefined;
upsertGenres(row.id, show.genres);
upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) =>
@@ -270,7 +273,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err),
);
extractAndStoreColors(row.id, show.poster_path).catch((err) =>
extractAndStoreColors(row.id, show.poster_path ?? null).catch((err) =>
log.debug("Color extraction failed:", err),
);
refreshCredits(row.id).catch((err) =>
@@ -316,7 +319,7 @@ export async function refreshTitle(titleId: string) {
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, movie.genres);
upsertGenres(titleId, movie.genres ?? []);
} else {
const show = await getTvDetails(title.tmdbId);
db.update(titles)
@@ -337,7 +340,7 @@ export async function refreshTitle(titleId: string) {
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, show.genres);
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
}
@@ -405,9 +408,10 @@ export async function refreshTvChildren(
.get();
// Batch all episode upserts in a single transaction per season
if (seasonData.episodes.length > 0) {
const eps = seasonData.episodes ?? [];
if (eps.length > 0) {
db.transaction((tx) => {
for (const ep of seasonData.episodes) {
for (const ep of eps) {
tx.insert(episodes)
.values({
seasonId: seasonRow.id,
@@ -455,20 +459,23 @@ export async function refreshRecommendations(
getSimilar(title.tmdbId, title.type),
]);
const recsResults = recs.results ?? [];
const similarResults = similar.results ?? [];
log.debug(
`Fetched ${recs.results.length} recommendations and ${similar.results.length} similar for title ${titleId}`,
`Fetched ${recsResults.length} recommendations and ${similarResults.length} similar for title ${titleId}`,
);
// Collect all valid results with their source/rank
interface RecItem {
result: (typeof recs.results)[0];
result: (typeof recsResults)[number];
type: "movie" | "tv";
source: "tmdb_recommendations" | "tmdb_similar";
rank: number;
}
const allItems: RecItem[] = [];
for (let i = 0; i < recs.results.length && i < 20; i++) {
const r = recs.results[i];
for (let i = 0; i < recsResults.length && i < 20; i++) {
const r = recsResults[i];
const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") {
allItems.push({
@@ -479,8 +486,8 @@ export async function refreshRecommendations(
});
}
}
for (let i = 0; i < similar.results.length && i < 20; i++) {
const r = similar.results[i];
for (let i = 0; i < similarResults.length && i < 20; i++) {
const r = similarResults[i];
const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") {
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
@@ -651,7 +658,7 @@ export async function ensureTvHydrated(
})
.where(eq(titles.id, titleId))
.run();
upsertGenres(titleId, show.genres);
upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) {
log.debug(`Failed to hydrate shell TV title ${titleId}:`, err);
@@ -807,7 +814,7 @@ export async function getOrFetchTitle(id: string): Promise<{
})
.where(eq(titles.id, id))
.run();
upsertGenres(id, movie.genres);
upsertGenres(id, movie.genres ?? []);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch (err) {
log.debug(`Failed to hydrate shell movie title ${id}:`, err);
@@ -888,15 +895,15 @@ export function pickBestTrailer(videos: TmdbVideo[]): string | null {
const officialTrailers = candidates
.filter((v) => v.official && v.type === "Trailer")
.sort(byDate);
if (officialTrailers.length > 0) return officialTrailers[0].key;
if (officialTrailers.length > 0) return officialTrailers[0].key ?? null;
// Tier 2: any trailer
const trailers = candidates.filter((v) => v.type === "Trailer").sort(byDate);
if (trailers.length > 0) return trailers[0].key;
if (trailers.length > 0) return trailers[0].key ?? null;
// Tier 3: teasers
const teasers = candidates.filter((v) => v.type === "Teaser").sort(byDate);
if (teasers.length > 0) return teasers[0].key;
if (teasers.length > 0) return teasers[0].key ?? null;
return null;
}
@@ -907,7 +914,7 @@ export async function refreshTrailer(titleId: string) {
try {
const response = await getVideos(title.tmdbId, title.type);
const key = pickBestTrailer(response.results);
const key = pickBestTrailer(response.results ?? []);
db.update(titles)
.set({ trailerVideoKey: key })
.where(eq(titles.id, titleId))
+22 -17
View File
@@ -24,7 +24,7 @@ export async function getOrFetchPerson(
const details = await getPersonDetails(person.tmdbId);
db.update(persons)
.set({
name: details.name,
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday,
deathday: details.deathday,
@@ -41,14 +41,14 @@ export async function getOrFetchPerson(
return {
id: person.id,
tmdbId: person.tmdbId,
name: details.name,
name: details.name || person.name,
biography: details.biography || null,
birthday: details.birthday,
deathday: details.deathday,
placeOfBirth: details.place_of_birth,
profilePath: tmdbImageUrl(details.profile_path, "profiles"),
knownForDepartment: details.known_for_department,
imdbId: details.imdb_id,
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);
@@ -89,15 +89,15 @@ export async function getOrFetchPersonByTmdbId(
.insert(persons)
.values({
tmdbId,
name: details.name,
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,
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,
imdbId: details.imdb_id ?? null,
lastFetchedAt: new Date(),
})
.onConflictDoNothing()
@@ -174,7 +174,12 @@ export async function fetchFullFilmography(
const credits = await getPersonCombinedCredits(person.tmdbId);
// Filter to valid cast entries
const validCast = credits.cast.filter(
// 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 [];
@@ -245,7 +250,7 @@ export async function fetchFullFilmography(
tmdbId: c.id,
type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path, "posters"),
posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average,
+19 -32
View File
@@ -162,12 +162,14 @@ async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
if (result.movie_results.length > 0) return result.movie_results[0].id;
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
if (result.movie_results.length > 0) return result.movie_results[0].id;
const movie = result.movie_results?.[0];
if (movie) return movie.id;
}
return null;
@@ -184,39 +186,27 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
// Strategy 1: Use IMDB ID to find the episode and get show_id
if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id");
if (result.tv_episode_results.length > 0) {
return {
showTmdbId: result.tv_episode_results[0].show_id,
seasonNumber,
episodeNumber,
};
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
// IMDB ID might reference the show itself
if (result.tv_results.length > 0) {
return {
showTmdbId: result.tv_results[0].id,
seasonNumber,
episodeNumber,
};
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
// Strategy 2: Use TVDB ID
if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id");
if (result.tv_episode_results.length > 0) {
return {
showTmdbId: result.tv_episode_results[0].show_id,
seasonNumber,
episodeNumber,
};
const ep = result.tv_episode_results?.[0];
if (ep) {
return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
}
if (result.tv_results.length > 0) {
return {
showTmdbId: result.tv_results[0].id,
seasonNumber,
episodeNumber,
};
const show = result.tv_results?.[0];
if (show) {
return { showTmdbId: show.id, seasonNumber, episodeNumber };
}
}
@@ -228,12 +218,9 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
// Strategy 4: Search by show title
if (event.showTitle) {
const searchResult = await searchTv(event.showTitle);
if (searchResult.results.length > 0) {
return {
showTmdbId: searchResult.results[0].id,
seasonNumber,
episodeNumber,
};
const tvMatch = searchResult.results?.[0];
if (tvMatch) {
return { showTmdbId: tvMatch.id, seasonNumber, episodeNumber };
}
}