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,
};
+9 -1
View File
@@ -7,7 +7,15 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!drizzle"]
"includes": [
"**",
"!node_modules",
"!.next",
"!dist",
"!build",
"!drizzle",
"!lib/tmdb/schema.d.ts"
]
},
"css": {
"parser": {
+5
View File
@@ -22,6 +22,7 @@
"motion": "12.35.1",
"next": "16.1.6",
"node-vibrant": "4.0.4",
"openapi-fetch": "0.17.0",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
@@ -1194,6 +1195,10 @@
"open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
"openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="],
"openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="],
"ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="],
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
+12 -6
View File
@@ -14,16 +14,22 @@ export async function discoverByGenre(
"vote_count.gte": "50",
with_genres: String(genreId),
});
return results.results
// Discover may return movie (title, release_date) or TV (name, first_air_date)
// fields depending on mediaType. The schema types them separately, so widen.
type DiscoverResult = NonNullable<typeof results.results>[number] & {
title?: string;
name?: string;
release_date?: string;
first_air_date?: string;
};
return ((results.results ?? []) as DiscoverResult[])
.filter((r) => r.poster_path)
.map((r) => ({
tmdbId: r.id,
type: mediaType,
title: (r.title ?? r.name) as string,
posterPath: tmdbImageUrl(r.poster_path, "posters"),
releaseDate: (r.release_date ?? r.first_air_date ?? null) as
| string
| null,
title: r.title ?? r.name ?? "",
posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average,
}));
}
+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 };
}
}
+378 -96
View File
@@ -1,200 +1,482 @@
import { TMDB_API_BASE_URL } from "@/lib/constants";
import createClient, { type Middleware } from "openapi-fetch";
import { createLogger } from "@/lib/logger";
import type {
TmdbExternalIds,
TmdbFindResult,
TmdbGenreListResponse,
TmdbMovieCreditsResponse,
TmdbMovieDetails,
TmdbPersonCombinedCredits,
TmdbPersonDetails,
TmdbPersonSearchResponse,
TmdbRecommendationResponse,
TmdbSearchResponse,
TmdbSeasonDetails,
TmdbTvAggregateCreditsResponse,
TmdbTvDetails,
TmdbVideosResponse,
TmdbWatchProviderResponse,
} from "./types";
import type { operations, paths } from "./schema";
const log = createLogger("tmdb");
// ─── Schema-derived types ───────────────────────────────────────────
/** Extract the 200 JSON response body from an operation */
type OpResponse<K extends keyof operations> = operations[K] extends {
responses: {
200: { content: { "application/json": infer T } };
};
}
? T
: never;
// Search
export type TmdbSearchResponse = OpResponse<"search-multi">;
export type TmdbSearchResult = NonNullable<
TmdbSearchResponse["results"]
>[number];
// Movie details — augmented with append_to_response data
export type TmdbMovieDetails = OpResponse<"movie-details"> & {
release_dates?: {
results: NonNullable<OpResponse<"movie-release-dates">["results"]>;
};
};
// TV details — augmented with append_to_response data
export type TmdbTvDetails = OpResponse<"tv-series-details"> & {
content_ratings?: OpResponse<"tv-series-content-ratings">;
external_ids?: OpResponse<"tv-series-external-ids">;
};
// Watch providers — manually defined because the schema types `results` as an
// object with literal country-code keys rather than Record<string, ...>, which
// makes dynamic lookups (e.g. results["US"]) impractical.
export interface TmdbProvider {
logo_path?: string;
provider_id: number;
provider_name?: string;
display_priority: number;
}
export interface TmdbWatchProviderRegion {
link?: string;
flatrate?: TmdbProvider[];
rent?: TmdbProvider[];
buy?: TmdbProvider[];
free?: TmdbProvider[];
ads?: TmdbProvider[];
}
export interface TmdbWatchProviderResponse {
id: number;
results?: Record<string, TmdbWatchProviderRegion>;
}
// Recommendations — movie-recommendations is Record<string, never> in schema,
// so we base on tv-series-recommendations and add movie fields TMDB returns.
type TvRecResult = NonNullable<
OpResponse<"tv-series-recommendations">["results"]
>[number];
export type TmdbRecommendationResponse = Omit<
OpResponse<"tv-series-recommendations">,
"results"
> & {
results?: (TvRecResult & {
title?: string;
original_title?: string;
release_date?: string;
})[];
};
// Find — schema types tv_results and tv_episode_results as unknown[]
export type TmdbFindResult = Omit<
OpResponse<"find-by-id">,
"tv_results" | "tv_episode_results"
> & {
tv_results?: TmdbSearchResult[];
tv_episode_results?: {
id: number;
episode_number: number;
name: string;
season_number: number;
show_id: number;
}[];
};
// Videos
export type TmdbVideo = NonNullable<
OpResponse<"movie-videos">["results"]
>[number];
// Genres
export type TmdbGenre = NonNullable<
OpResponse<"genre-movie-list">["genres"]
>[number];
// Person — schema types deathday as unknown
export type TmdbPersonDetails = Omit<
OpResponse<"person-details">,
"deathday"
> & {
deathday?: string | null;
};
// ─── Client setup ───────────────────────────────────────────────────
function getApiKey() {
const key = process.env.TMDB_API_READ_ACCESS_TOKEN;
if (!key) throw new Error("TMDB_API_READ_ACCESS_TOKEN is not set");
return key;
}
async function tmdbFetch<T>(
path: string,
params?: Record<string, string>,
fetchOptions?: RequestInit,
): Promise<T> {
const url = new URL(`${TMDB_API_BASE_URL}${path}`);
if (params) {
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, v);
// The default TMDB base URL ends with /3, matching the schema's /3/… paths.
// Custom proxy URLs (e.g. https://tmdb.internal) may omit /3 — those worked
// before because the old client built paths like /search/multi against the
// custom base. To preserve that, we always strip /3 from the configured URL
// and use the schema paths as-is (they already include /3/).
const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
const baseUrl = configuredBase.replace(/\/3\/?$/, "");
const baseUrlRewriteMiddleware: Middleware | null = isCustomBase
? {
async onRequest({ request }) {
// Custom proxy: strip the /3 prefix from schema paths so requests
// go to e.g. https://tmdb.internal/search/multi instead of /3/search/multi
const url = new URL(request.url);
url.pathname = url.pathname.replace(/^\/3\//, "/");
return new Request(url, request);
},
}
}
: null;
const start = performance.now();
const res = await fetch(url.toString(), {
...fetchOptions,
headers: {
Authorization: `Bearer ${getApiKey()}`,
Accept: "application/json",
...fetchOptions?.headers,
},
});
const elapsed = Math.round(performance.now() - start);
const authMiddleware: Middleware = {
async onRequest({ request }) {
request.headers.set("Authorization", `Bearer ${getApiKey()}`);
request.headers.set("Accept", "application/json");
return request;
},
};
if (!res.ok) {
log.warn(
`${url.toString()} -> ${res.status} ${res.statusText} (${elapsed}ms)`,
);
throw new Error(`TMDB API error: ${res.status} ${res.statusText}`);
}
const requestTimings = new WeakMap<Request, number>();
log.debug(`${url.toString()} -> ${res.status} (${elapsed}ms)`);
return res.json() as Promise<T>;
const loggingMiddleware: Middleware = {
async onRequest({ request }) {
requestTimings.set(request, performance.now());
return request;
},
async onResponse({ request, response }) {
const start = requestTimings.get(request);
const elapsed = start ? Math.round(performance.now() - start) : 0;
if (!response.ok) {
log.warn(
`${request.url} -> ${response.status} ${response.statusText} (${elapsed}ms)`,
);
} else {
log.debug(`${request.url} -> ${response.status} (${elapsed}ms)`);
}
return undefined;
},
};
const client = createClient<paths>({ baseUrl });
if (baseUrlRewriteMiddleware) client.use(baseUrlRewriteMiddleware);
client.use(authMiddleware, loggingMiddleware);
/** Helper: pass Next.js revalidate option through to fetch */
function revalidateFetch(seconds: number) {
return (req: Request) =>
globalThis.fetch(req, {
next: { revalidate: seconds },
} as RequestInit);
}
// ─── Search ─────────────────────────────────────────────────────────
export async function searchMulti(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/multi", {
query,
page: String(page),
include_adult: "false",
const { data, error } = await client.GET("/3/search/multi", {
params: { query: { query, page, include_adult: false } },
});
if (error) throw new Error("TMDB API error: search/multi");
return data;
}
export async function searchMovies(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/movie", {
query,
page: String(page),
const { data, error } = await client.GET("/3/search/movie", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/movie");
return data;
}
export async function searchTv(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/tv", {
query,
page: String(page),
const { data, error } = await client.GET("/3/search/tv", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/tv");
return data;
}
// ─── Details ────────────────────────────────────────────────────────
export async function getMovieDetails(tmdbId: number) {
return tmdbFetch<TmdbMovieDetails>(`/movie/${tmdbId}`, {
append_to_response: "release_dates",
const { data, error } = await client.GET("/3/movie/{movie_id}", {
params: {
path: { movie_id: tmdbId },
query: { append_to_response: "release_dates" },
},
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}`);
return data as TmdbMovieDetails;
}
export async function getTvDetails(tmdbId: number) {
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, {
append_to_response: "content_ratings,external_ids",
const { data, error } = await client.GET("/3/tv/{series_id}", {
params: {
path: { series_id: tmdbId },
query: { append_to_response: "content_ratings,external_ids" },
},
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}`);
return data as TmdbTvDetails;
}
export async function getTvExternalIds(tmdbId: number) {
return tmdbFetch<TmdbExternalIds>(`/tv/${tmdbId}/external_ids`);
const { data, error } = await client.GET("/3/tv/{series_id}/external_ids", {
params: { path: { series_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/external_ids`);
return data;
}
export async function getTvSeasonDetails(tmdbId: number, seasonNumber: number) {
return tmdbFetch<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
const { data, error } = await client.GET(
"/3/tv/{series_id}/season/{season_number}",
{
params: {
path: { series_id: tmdbId, season_number: seasonNumber },
},
},
);
if (error)
throw new Error(`TMDB API error: tv/${tmdbId}/season/${seasonNumber}`);
return data;
}
// ─── Watch Providers ────────────────────────────────────────────────
export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbWatchProviderResponse>(
`/${type}/${tmdbId}/watch/providers`,
if (type === "movie") {
const { data, error } = await client.GET(
"/3/movie/{movie_id}/watch/providers",
{ params: { path: { movie_id: tmdbId } } },
);
if (error)
throw new Error(`TMDB API error: movie/${tmdbId}/watch/providers`);
return data as TmdbWatchProviderResponse;
}
const { data, error } = await client.GET(
"/3/tv/{series_id}/watch/providers",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/watch/providers`);
return data as TmdbWatchProviderResponse;
}
// ─── Recommendations & Similar ──────────────────────────────────────
export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbRecommendationResponse>(
`/${type}/${tmdbId}/recommendations`,
if (type === "movie") {
const { data, error } = await client.GET(
"/3/movie/{movie_id}/recommendations",
{ params: { path: { movie_id: tmdbId } } },
);
if (error)
throw new Error(`TMDB API error: movie/${tmdbId}/recommendations`);
return data as TmdbRecommendationResponse;
}
const { data, error } = await client.GET(
"/3/tv/{series_id}/recommendations",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/recommendations`);
return data as TmdbRecommendationResponse;
}
export async function getSimilar(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbRecommendationResponse>(`/${type}/${tmdbId}/similar`);
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/{movie_id}/similar", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/similar`);
return data as TmdbRecommendationResponse;
}
// Schema incorrectly types series_id as string here (number everywhere else)
const { data, error } = await client.GET("/3/tv/{series_id}/similar", {
params: { path: { series_id: String(tmdbId) } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/similar`);
return data as TmdbRecommendationResponse;
}
// ─── Trending & Popular ─────────────────────────────────────────────
export async function getTrending(
mediaType: "all" | "movie" | "tv",
timeWindow: "day" | "week" = "day",
) {
return tmdbFetch<TmdbSearchResponse>(
`/trending/${mediaType}/${timeWindow}`,
undefined,
{ next: { revalidate: 3600 } },
const opts = {
params: { path: { time_window: timeWindow } },
fetch: revalidateFetch(3600),
} as const;
if (mediaType === "movie") {
const { data, error } = await client.GET(
"/3/trending/movie/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/movie");
return data;
}
if (mediaType === "tv") {
const { data, error } = await client.GET(
"/3/trending/tv/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/tv");
return data;
}
const { data, error } = await client.GET(
"/3/trending/all/{time_window}",
opts,
);
if (error) throw new Error("TMDB API error: trending/all");
return data;
}
export async function getPopular(type: "movie" | "tv", page = 1) {
return tmdbFetch<TmdbSearchResponse>(
`/${type}/popular`,
{ page: String(page) },
{ next: { revalidate: 3600 } },
);
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/popular", {
params: { query: { page } },
fetch: revalidateFetch(3600),
});
if (error) throw new Error("TMDB API error: movie/popular");
return data;
}
const { data, error } = await client.GET("/3/tv/popular", {
params: { query: { page } },
fetch: revalidateFetch(3600),
});
if (error) throw new Error("TMDB API error: tv/popular");
return data;
}
// ─── Genres ─────────────────────────────────────────────────────────
export async function getGenres(type: "movie" | "tv") {
return tmdbFetch<TmdbGenreListResponse>(`/genre/${type}/list`, undefined, {
next: { revalidate: 86400 },
if (type === "movie") {
const { data, error } = await client.GET("/3/genre/movie/list", {
fetch: revalidateFetch(86400),
});
if (error) throw new Error("TMDB API error: genre/movie/list");
return data;
}
const { data, error } = await client.GET("/3/genre/tv/list", {
fetch: revalidateFetch(86400),
});
if (error) throw new Error("TMDB API error: genre/tv/list");
return data;
}
// ─── Discover ───────────────────────────────────────────────────────
export async function discover(
type: "movie" | "tv",
params: Record<string, string>,
page = 1,
) {
return tmdbFetch<TmdbSearchResponse>(
`/discover/${type}`,
{ ...params, page: String(page) },
{ next: { revalidate: 3600 } },
);
// Discover accepts many dynamic filter params (with_genres, vote_count.gte, etc.)
// that aren't individually typed in the schema, so widen to Record<string, unknown>.
if (type === "movie") {
const { data, error } = await client.GET("/3/discover/movie", {
params: { query: { ...params, page } as Record<string, unknown> },
fetch: revalidateFetch(3600),
});
if (error) throw new Error("TMDB API error: discover/movie");
return data;
}
const { data, error } = await client.GET("/3/discover/tv", {
params: { query: { ...params, page } as Record<string, unknown> },
fetch: revalidateFetch(3600),
});
if (error) throw new Error("TMDB API error: discover/tv");
return data;
}
// ─── Videos ─────────────────────────────────────────────────────────
export async function getVideos(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbVideosResponse>(`/${type}/${tmdbId}/videos`);
if (type === "movie") {
const { data, error } = await client.GET("/3/movie/{movie_id}/videos", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/videos`);
return data;
}
const { data, error } = await client.GET("/3/tv/{series_id}/videos", {
params: { path: { series_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/videos`);
return data;
}
// ─── Find by External ID ───────────────────────────────────────────
export async function findByExternalId(
externalId: string,
source: "imdb_id" | "tvdb_id",
) {
return tmdbFetch<TmdbFindResult>(`/find/${externalId}`, {
external_source: source,
const { data, error } = await client.GET("/3/find/{external_id}", {
params: {
path: { external_id: externalId },
query: { external_source: source },
},
});
if (error) throw new Error(`TMDB API error: find/${externalId}`);
return data as TmdbFindResult;
}
// ─── Person / Credits endpoints ─────────────────────────────────────
// ─── Person / Credits ───────────────────────────────────────────────
export async function getMovieCredits(tmdbId: number) {
return tmdbFetch<TmdbMovieCreditsResponse>(`/movie/${tmdbId}/credits`);
const { data, error } = await client.GET("/3/movie/{movie_id}/credits", {
params: { path: { movie_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: movie/${tmdbId}/credits`);
return data;
}
export async function getTvAggregateCredits(tmdbId: number) {
return tmdbFetch<TmdbTvAggregateCreditsResponse>(
`/tv/${tmdbId}/aggregate_credits`,
const { data, error } = await client.GET(
"/3/tv/{series_id}/aggregate_credits",
{ params: { path: { series_id: tmdbId } } },
);
if (error) throw new Error(`TMDB API error: tv/${tmdbId}/aggregate_credits`);
return data;
}
export async function getPersonDetails(tmdbId: number) {
return tmdbFetch<TmdbPersonDetails>(`/person/${tmdbId}`);
const { data, error } = await client.GET("/3/person/{person_id}", {
params: { path: { person_id: tmdbId } },
});
if (error) throw new Error(`TMDB API error: person/${tmdbId}`);
return data as TmdbPersonDetails;
}
export async function getPersonCombinedCredits(tmdbId: number) {
return tmdbFetch<TmdbPersonCombinedCredits>(
`/person/${tmdbId}/combined_credits`,
const { data, error } = await client.GET(
"/3/person/{person_id}/combined_credits",
// Schema incorrectly types person_id as string here (number everywhere else)
{ params: { path: { person_id: String(tmdbId) } } },
);
if (error)
throw new Error(`TMDB API error: person/${tmdbId}/combined_credits`);
return data;
}
export async function searchPerson(query: string, page = 1) {
return tmdbFetch<TmdbPersonSearchResponse>("/search/person", {
query,
page: String(page),
const { data, error } = await client.GET("/3/search/person", {
params: { query: { query, page } },
});
if (error) throw new Error("TMDB API error: search/person");
return data;
}
export { tmdbImageUrl } from "./image";
+22810
View File
File diff suppressed because it is too large Load Diff
-257
View File
@@ -1,257 +0,0 @@
export interface TmdbSearchResult {
id: number;
media_type: "movie" | "tv" | "person";
title?: string;
name?: string;
original_title?: string;
original_name?: string;
overview: string;
release_date?: string;
first_air_date?: string;
poster_path: string | null;
backdrop_path: string | null;
profile_path?: string | null;
popularity: number;
vote_average: number;
vote_count: number;
}
export interface TmdbSearchResponse {
page: number;
results: TmdbSearchResult[];
total_pages: number;
total_results: number;
}
export interface TmdbMovieDetails {
id: number;
title: string;
original_title: string;
overview: string;
release_date: string;
poster_path: string | null;
backdrop_path: string | null;
popularity: number;
vote_average: number;
vote_count: number;
status: string;
genres: TmdbGenre[];
release_dates?: {
results: {
iso_3166_1: string;
release_dates: { certification: string; type: number }[];
}[];
};
}
export interface TmdbTvDetails {
id: number;
name: string;
original_name: string;
overview: string;
first_air_date: string;
poster_path: string | null;
backdrop_path: string | null;
popularity: number;
vote_average: number;
vote_count: number;
status: string;
number_of_seasons: number;
genres: TmdbGenre[];
content_ratings?: {
results: { iso_3166_1: string; rating: string }[];
};
external_ids?: TmdbExternalIds;
seasons: TmdbSeasonSummary[];
}
export interface TmdbExternalIds {
tvdb_id: number | null;
imdb_id: string | null;
}
export interface TmdbSeasonSummary {
id: number;
season_number: number;
name: string;
overview: string;
poster_path: string | null;
air_date: string | null;
episode_count: number;
}
export interface TmdbSeasonDetails {
id: number;
season_number: number;
name: string;
overview: string;
poster_path: string | null;
air_date: string | null;
episodes: TmdbEpisode[];
}
export interface TmdbEpisode {
id: number;
episode_number: number;
name: string;
overview: string;
still_path: string | null;
air_date: string | null;
runtime: number | null;
}
export interface TmdbWatchProviderResponse {
id: number;
results: Record<
string,
{
link?: string;
flatrate?: TmdbProvider[];
rent?: TmdbProvider[];
buy?: TmdbProvider[];
free?: TmdbProvider[];
ads?: TmdbProvider[];
}
>;
}
export interface TmdbProvider {
provider_id: number;
provider_name: string;
logo_path: string;
display_priority: number;
}
export interface TmdbRecommendationResponse {
page: number;
results: TmdbSearchResult[];
total_pages: number;
total_results: number;
}
export interface TmdbFindResult {
movie_results: TmdbSearchResult[];
tv_results: TmdbSearchResult[];
tv_episode_results: {
id: number;
episode_number: number;
name: string;
season_number: number;
show_id: number;
}[];
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: string;
type: string;
official: boolean;
published_at: string;
size: number;
iso_639_1: string;
iso_3166_1: string;
}
export interface TmdbVideosResponse {
id: number;
results: TmdbVideo[];
}
export interface TmdbGenre {
id: number;
name: string;
}
export interface TmdbGenreListResponse {
genres: TmdbGenre[];
}
// ─── Person / Credits types ─────────────────────────────────────────
export interface TmdbCastMember {
id: number;
name: string;
profile_path: string | null;
character: string;
order: number;
popularity: number;
}
export interface TmdbCrewMember {
id: number;
name: string;
profile_path: string | null;
department: string;
job: string;
popularity: number;
}
export interface TmdbMovieCreditsResponse {
id: number;
cast: TmdbCastMember[];
crew: TmdbCrewMember[];
}
export interface TmdbTvAggregateCastMember {
id: number;
name: string;
profile_path: string | null;
roles: { character: string; episode_count: number }[];
total_episode_count: number;
order: number;
popularity: number;
}
export interface TmdbTvAggregateCrewMember {
id: number;
name: string;
profile_path: string | null;
jobs: { job: string; episode_count: number }[];
department: string;
total_episode_count: number;
popularity: number;
}
export interface TmdbTvAggregateCreditsResponse {
id: number;
cast: TmdbTvAggregateCastMember[];
crew: TmdbTvAggregateCrewMember[];
}
export interface TmdbPersonDetails {
id: number;
name: string;
biography: string;
birthday: string | null;
deathday: string | null;
place_of_birth: string | null;
profile_path: string | null;
known_for_department: string;
imdb_id: string | null;
also_known_as: string[];
popularity: number;
}
export interface TmdbPersonCombinedCredits {
id: number;
cast: (TmdbSearchResult & { character?: string; episode_count?: number })[];
crew: (TmdbSearchResult & { department?: string; job?: string })[];
}
export interface TmdbPersonSearchResult {
id: number;
name: string;
profile_path: string | null;
known_for_department: string;
popularity: number;
known_for: TmdbSearchResult[];
}
export interface TmdbPersonSearchResponse {
page: number;
results: TmdbPersonSearchResult[];
total_pages: number;
total_results: number;
}
+3 -1
View File
@@ -15,7 +15,8 @@
"db:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push",
"db:studio": "bun --bun drizzle-kit studio",
"db:seed": "bun run scripts/seed.ts"
"db:seed": "bun run scripts/seed.ts",
"generate-tmdb-schema": "bunx openapi-typescript https://developer.themoviedb.org/openapi/tmdb-api.json -o ./lib/tmdb/schema.d.ts"
},
"dependencies": {
"@base-ui/react": "1.2.0",
@@ -35,6 +36,7 @@
"motion": "12.35.1",
"next": "16.1.6",
"node-vibrant": "4.0.4",
"openapi-fetch": "0.17.0",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",