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
+24 -11
View File
@@ -16,7 +16,7 @@ function mapResults(
media_type?: string;
title?: string;
name?: string;
poster_path: string | null;
poster_path?: string | null;
release_date?: string;
first_air_date?: string;
vote_average: number;
@@ -31,7 +31,7 @@ function mapResults(
? r.media_type
: fallbackType) as "movie" | "tv",
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path, "posters"),
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
}));
@@ -49,11 +49,17 @@ async function getExploreTmdbData() {
return {
trending,
trendingItems: mapResults(trending.results, "movie"),
popularMovieItems: mapResults(popularMovies.results, "movie"),
popularTvItems: mapResults(popularTv.results, "tv"),
movieGenres: movieGenres.genres,
tvGenres: tvGenres.genres,
trendingItems: mapResults(trending.results ?? [], "movie"),
popularMovieItems: mapResults(popularMovies.results ?? [], "movie"),
popularTvItems: mapResults(popularTv.results ?? [], "tv"),
movieGenres: (movieGenres.genres ?? []).map((g) => ({
id: g.id,
name: g.name ?? "",
})),
tvGenres: (tvGenres.genres ?? []).map((g) => ({
id: g.id,
name: g.name ?? "",
})),
};
}
@@ -89,7 +95,7 @@ export default async function ExplorePage() {
episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
}
const heroTitle = trending.results.find(
const heroTitle = (trending.results ?? []).find(
(r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
);
@@ -100,9 +106,16 @@ export default async function ExplorePage() {
<HeroBanner
tmdbId={heroTitle.id}
type={heroTitle.media_type as "movie" | "tv"}
title={heroTitle.title ?? heroTitle.name ?? ""}
overview={heroTitle.overview}
backdropPath={tmdbImageUrl(heroTitle.backdrop_path, "backdrops")}
title={
("title" in heroTitle ? heroTitle.title : undefined) ??
("name" in heroTitle ? heroTitle.name : undefined) ??
""
}
overview={heroTitle.overview ?? ""}
backdropPath={tmdbImageUrl(
heroTitle.backdrop_path ?? null,
"backdrops",
)}
voteAverage={heroTitle.vote_average}
/>
)}
+26 -14
View File
@@ -8,7 +8,6 @@ import {
searchTv,
} from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image";
import type { TmdbSearchResponse } from "@/lib/tmdb/types";
export async function GET(req: NextRequest) {
const session = await getSession();
@@ -51,30 +50,43 @@ export async function GET(req: NextRequest) {
if (type === "person") {
const personResults = await searchPerson(query);
return NextResponse.json({
results: personResults.results.map((r) => ({
results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id,
type: "person" as const,
title: r.name,
profilePath: tmdbImageUrl(r.profile_path, "profiles"),
profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
knownForDepartment: r.known_for_department,
knownFor: r.known_for
?.slice(0, 3)
.map((k) => k.title ?? k.name)
.map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean),
})),
});
}
let results: TmdbSearchResponse;
if (type === "movie") {
results = await searchMovies(query);
} else if (type === "tv") {
results = await searchTv(query);
} else {
results = await searchMulti(query);
}
const raw =
type === "movie"
? await searchMovies(query)
: type === "tv"
? await searchTv(query)
: await searchMulti(query);
const mapped = results.results
// Search endpoints return movie, TV, or multi results with slightly
// different fields. Widen to the union we actually access.
type SearchResult = {
id: number;
media_type?: string;
title?: string;
name?: string;
overview?: string;
poster_path?: string | null;
profile_path?: string | null;
release_date?: string;
first_air_date?: string;
popularity?: number;
vote_average?: number;
};
const mapped = ((raw.results ?? []) as SearchResult[])
.map((r) => {
// Include person results from multi search
if (r.media_type === "person") {
@@ -103,7 +115,7 @@ export async function GET(req: NextRequest) {
title: r.title ?? r.name,
overview: r.overview,
releaseDate: r.release_date ?? r.first_air_date,
posterPath: tmdbImageUrl(r.poster_path, "posters"),
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity,
voteAverage: r.vote_average,
};