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; media_type?: string;
title?: string; title?: string;
name?: string; name?: string;
poster_path: string | null; poster_path?: string | null;
release_date?: string; release_date?: string;
first_air_date?: string; first_air_date?: string;
vote_average: number; vote_average: number;
@@ -31,7 +31,7 @@ function mapResults(
? r.media_type ? r.media_type
: fallbackType) as "movie" | "tv", : fallbackType) as "movie" | "tv",
title: r.title ?? r.name ?? "", 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, releaseDate: r.release_date ?? r.first_air_date ?? null,
voteAverage: r.vote_average, voteAverage: r.vote_average,
})); }));
@@ -49,11 +49,17 @@ async function getExploreTmdbData() {
return { return {
trending, trending,
trendingItems: mapResults(trending.results, "movie"), trendingItems: mapResults(trending.results ?? [], "movie"),
popularMovieItems: mapResults(popularMovies.results, "movie"), popularMovieItems: mapResults(popularMovies.results ?? [], "movie"),
popularTvItems: mapResults(popularTv.results, "tv"), popularTvItems: mapResults(popularTv.results ?? [], "tv"),
movieGenres: movieGenres.genres, movieGenres: (movieGenres.genres ?? []).map((g) => ({
tvGenres: tvGenres.genres, 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); episodeProgress = getEpisodeProgressByTmdbIds(session.user.id, tmdbLookups);
} }
const heroTitle = trending.results.find( const heroTitle = (trending.results ?? []).find(
(r) => (r) =>
r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"), r.backdrop_path && (r.media_type === "movie" || r.media_type === "tv"),
); );
@@ -100,9 +106,16 @@ export default async function ExplorePage() {
<HeroBanner <HeroBanner
tmdbId={heroTitle.id} tmdbId={heroTitle.id}
type={heroTitle.media_type as "movie" | "tv"} type={heroTitle.media_type as "movie" | "tv"}
title={heroTitle.title ?? heroTitle.name ?? ""} title={
overview={heroTitle.overview} ("title" in heroTitle ? heroTitle.title : undefined) ??
backdropPath={tmdbImageUrl(heroTitle.backdrop_path, "backdrops")} ("name" in heroTitle ? heroTitle.name : undefined) ??
""
}
overview={heroTitle.overview ?? ""}
backdropPath={tmdbImageUrl(
heroTitle.backdrop_path ?? null,
"backdrops",
)}
voteAverage={heroTitle.vote_average} voteAverage={heroTitle.vote_average}
/> />
)} )}
+26 -14
View File
@@ -8,7 +8,6 @@ import {
searchTv, searchTv,
} from "@/lib/tmdb/client"; } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import type { TmdbSearchResponse } from "@/lib/tmdb/types";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const session = await getSession(); const session = await getSession();
@@ -51,30 +50,43 @@ export async function GET(req: NextRequest) {
if (type === "person") { if (type === "person") {
const personResults = await searchPerson(query); const personResults = await searchPerson(query);
return NextResponse.json({ return NextResponse.json({
results: personResults.results.map((r) => ({ results: (personResults.results ?? []).map((r) => ({
tmdbId: r.id, tmdbId: r.id,
type: "person" as const, type: "person" as const,
title: r.name, title: r.name,
profilePath: tmdbImageUrl(r.profile_path, "profiles"), profilePath: tmdbImageUrl(r.profile_path ?? null, "profiles"),
knownForDepartment: r.known_for_department, knownForDepartment: r.known_for_department,
knownFor: r.known_for knownFor: r.known_for
?.slice(0, 3) ?.slice(0, 3)
.map((k) => k.title ?? k.name) .map((k) => k.title ?? (k as { name?: string }).name)
.filter(Boolean), .filter(Boolean),
})), })),
}); });
} }
let results: TmdbSearchResponse; const raw =
if (type === "movie") { type === "movie"
results = await searchMovies(query); ? await searchMovies(query)
} else if (type === "tv") { : type === "tv"
results = await searchTv(query); ? await searchTv(query)
} else { : await searchMulti(query);
results = 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) => { .map((r) => {
// Include person results from multi search // Include person results from multi search
if (r.media_type === "person") { if (r.media_type === "person") {
@@ -103,7 +115,7 @@ export async function GET(req: NextRequest) {
title: r.title ?? r.name, title: r.title ?? r.name,
overview: r.overview, overview: r.overview,
releaseDate: r.release_date ?? r.first_air_date, releaseDate: r.release_date ?? r.first_air_date,
posterPath: tmdbImageUrl(r.poster_path, "posters"), posterPath: tmdbImageUrl(r.poster_path ?? null, "posters"),
popularity: r.popularity, popularity: r.popularity,
voteAverage: r.vote_average, voteAverage: r.vote_average,
}; };
+9 -1
View File
@@ -7,7 +7,15 @@
}, },
"files": { "files": {
"ignoreUnknown": true, "ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build", "!drizzle"] "includes": [
"**",
"!node_modules",
"!.next",
"!dist",
"!build",
"!drizzle",
"!lib/tmdb/schema.d.ts"
]
}, },
"css": { "css": {
"parser": { "parser": {
+5
View File
@@ -22,6 +22,7 @@
"motion": "12.35.1", "motion": "12.35.1",
"next": "16.1.6", "next": "16.1.6",
"node-vibrant": "4.0.4", "node-vibrant": "4.0.4",
"openapi-fetch": "0.17.0",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.4", "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=="], "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=="], "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=="], "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", "vote_count.gte": "50",
with_genres: String(genreId), 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) .filter((r) => r.poster_path)
.map((r) => ({ .map((r) => ({
tmdbId: r.id, tmdbId: r.id,
type: mediaType, type: mediaType,
title: (r.title ?? r.name) as string, 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) as releaseDate: r.release_date ?? r.first_air_date ?? null,
| string
| null,
voteAverage: r.vote_average, voteAverage: r.vote_average,
})); }));
} }
+2 -2
View File
@@ -30,8 +30,8 @@ export async function refreshAvailability(titleId: string) {
titleId, titleId,
region: "US", region: "US",
providerId: p.provider_id, providerId: p.provider_id,
providerName: p.provider_name, providerName: p.provider_name ?? "",
logoPath: p.logo_path, logoPath: p.logo_path ?? "",
offerType, offerType,
link: us.link ?? null, link: us.link ?? null,
lastFetchedAt: now, lastFetchedAt: now,
+20 -18
View File
@@ -95,13 +95,13 @@ export async function refreshCredits(
if (title.type === "movie") { if (title.type === "movie") {
const credits = await getMovieCredits(title.tmdbId); const credits = await getMovieCredits(title.tmdbId);
const castSlice = credits.cast.slice(0, 20); const castSlice = (credits.cast ?? []).slice(0, 20);
// Collect notable crew // Collect notable crew
const seenCrew = new Set<string>(); const seenCrew = new Set<string>();
const notableCrew: typeof credits.crew = []; const notableCrew: typeof credits.crew = [];
for (const c of credits.crew) { for (const c of credits.crew ?? []) {
if (!NOTABLE_DEPARTMENTS.has(c.job)) continue; if (!NOTABLE_DEPARTMENTS.has(c.job ?? "")) continue;
const key = `${c.id}-${c.job}`; const key = `${c.id}-${c.job}`;
if (seenCrew.has(key)) continue; if (seenCrew.has(key)) continue;
seenCrew.add(key); seenCrew.add(key);
@@ -112,14 +112,14 @@ export async function refreshCredits(
const allPeople: PersonData[] = [ const allPeople: PersonData[] = [
...castSlice.map((c) => ({ ...castSlice.map((c) => ({
tmdbId: c.id, tmdbId: c.id,
name: c.name, name: c.name ?? "",
profilePath: c.profile_path, profilePath: c.profile_path ?? null,
popularity: c.popularity, popularity: c.popularity,
})), })),
...notableCrew.map((c) => ({ ...notableCrew.map((c) => ({
tmdbId: c.id, tmdbId: c.id,
name: c.name, name: c.name ?? "",
profilePath: c.profile_path, profilePath: c.profile_path ?? null,
popularity: c.popularity, popularity: c.popularity,
})), })),
]; ];
@@ -180,24 +180,26 @@ export async function refreshCredits(
} }
} else { } else {
const credits = await getTvAggregateCredits(title.tmdbId); 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 // Collect notable crew
const seenCrew = new Set<string>(); const seenCrew = new Set<string>();
const notableCrew: Array<{ const notableCrew: Array<{
person: (typeof credits.crew)[0]; person: (typeof tvCrew)[number];
job: string; job: string;
episodeCount: number; episodeCount: number;
}> = []; }> = [];
for (const c of credits.crew) { for (const c of tvCrew) {
for (const j of c.jobs) { for (const j of c.jobs ?? []) {
if (!NOTABLE_DEPARTMENTS.has(j.job)) continue; if (!NOTABLE_DEPARTMENTS.has(j.job ?? "")) continue;
const key = `${c.id}-${j.job}`; const key = `${c.id}-${j.job}`;
if (seenCrew.has(key)) continue; if (seenCrew.has(key)) continue;
seenCrew.add(key); seenCrew.add(key);
notableCrew.push({ notableCrew.push({
person: c, person: c,
job: j.job, job: j.job ?? "",
episodeCount: j.episode_count, episodeCount: j.episode_count,
}); });
} }
@@ -207,14 +209,14 @@ export async function refreshCredits(
const allPeople: PersonData[] = [ const allPeople: PersonData[] = [
...castSlice.map((c) => ({ ...castSlice.map((c) => ({
tmdbId: c.id, tmdbId: c.id,
name: c.name, name: c.name ?? "",
profilePath: c.profile_path, profilePath: c.profile_path ?? null,
popularity: c.popularity, popularity: c.popularity,
})), })),
...notableCrew.map((c) => ({ ...notableCrew.map((c) => ({
tmdbId: c.person.id, tmdbId: c.person.id,
name: c.person.name, name: c.person.name ?? "",
profilePath: c.person.profile_path, profilePath: c.person.profile_path ?? null,
popularity: c.person.popularity, popularity: c.person.popularity,
})), })),
]; ];
@@ -247,7 +249,7 @@ export async function refreshCredits(
titleId, titleId,
personId, personId,
character: null, character: null,
department: c.person.department, department: c.person.department ?? "",
job: c.job, job: c.job,
displayOrder: crewOrder, displayOrder: crewOrder,
episodeCount: c.episodeCount, episodeCount: c.episodeCount,
+1 -1
View File
@@ -3,7 +3,7 @@ import type {
TmdbMovieDetails, TmdbMovieDetails,
TmdbTvDetails, TmdbTvDetails,
TmdbVideo, TmdbVideo,
} from "@/lib/tmdb/types"; } from "@/lib/tmdb/client";
import { import {
extractMovieContentRating, extractMovieContentRating,
extractTvContentRating, extractTvContentRating,
+42 -35
View File
@@ -12,6 +12,12 @@ import {
} from "@/lib/db/schema"; } from "@/lib/db/schema";
import { createLogger } from "@/lib/logger"; import { createLogger } from "@/lib/logger";
import { generateProviderUrl } from "@/lib/providers"; import { generateProviderUrl } from "@/lib/providers";
import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/client";
import { import {
getMovieDetails, getMovieDetails,
getRecommendations, getRecommendations,
@@ -21,12 +27,6 @@ import {
getVideos, getVideos,
} from "@/lib/tmdb/client"; } from "@/lib/tmdb/client";
import { tmdbImageUrl } from "@/lib/tmdb/image"; import { tmdbImageUrl } from "@/lib/tmdb/image";
import type {
TmdbGenre,
TmdbMovieDetails,
TmdbTvDetails,
TmdbVideo,
} from "@/lib/tmdb/types";
import type { import type {
AvailabilityOffer, AvailabilityOffer,
CastMember, CastMember,
@@ -66,16 +66,19 @@ function upsertTitle(values: typeof titles.$inferInsert, tmdbId: number) {
} }
function upsertGenres(titleId: string, tmdbGenres: TmdbGenre[]) { 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) => { db.transaction((tx) => {
for (const g of tmdbGenres) { for (const g of validGenres) {
tx.insert(genres) tx.insert(genres)
.values({ id: g.id, name: g.name }) .values({ id: g.id, name: g.name })
.onConflictDoUpdate({ target: genres.id, set: { name: g.name } }) .onConflictDoUpdate({ target: genres.id, set: { name: g.name } })
.run(); .run();
} }
tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run(); tx.delete(titleGenres).where(eq(titleGenres.titleId, titleId)).run();
for (const g of tmdbGenres) { for (const g of validGenres) {
tx.insert(titleGenres) tx.insert(titleGenres)
.values({ titleId, genreId: g.id }) .values({ titleId, genreId: g.id })
.onConflictDoNothing() .onConflictDoNothing()
@@ -90,7 +93,7 @@ export function extractMovieContentRating(
): string | null { ): string | null {
const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US"); const us = movie.release_dates?.results?.find((r) => r.iso_3166_1 === "US");
if (!us) return null; if (!us) return null;
for (const rd of us.release_dates) { for (const rd of us.release_dates ?? []) {
if (rd.certification) return rd.certification; if (rd.certification) return rd.certification;
} }
return null; return null;
@@ -159,7 +162,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
.where(eq(titles.id, existing.id)) .where(eq(titles.id, existing.id))
.run(); .run();
} }
upsertGenres(existing.id, show.genres); upsertGenres(existing.id, show.genres ?? []);
await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons); await refreshTvChildren(existing.id, tmdbId, show.number_of_seasons);
refreshAvailability(existing.id).catch((err) => refreshAvailability(existing.id).catch((err) =>
log.debug("Availability enrichment failed:", err), log.debug("Availability enrichment failed:", err),
@@ -167,8 +170,8 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(existing.id).catch((err) => refreshRecommendations(existing.id).catch((err) =>
log.debug("Recommendations enrichment failed:", err), log.debug("Recommendations enrichment failed:", err),
); );
extractAndStoreColors(existing.id, show.poster_path).catch((err) => extractAndStoreColors(existing.id, show.poster_path ?? null).catch(
log.debug("Color extraction failed:", err), (err) => log.debug("Color extraction failed:", err),
); );
refreshCredits(existing.id).catch((err) => refreshCredits(existing.id).catch((err) =>
log.debug("Credits enrichment failed:", err), log.debug("Credits enrichment failed:", err),
@@ -198,7 +201,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
{ {
tmdbId: movie.id, tmdbId: movie.id,
type: "movie", type: "movie",
title: movie.title, title: movie.title ?? "",
originalTitle: movie.original_title, originalTitle: movie.original_title,
overview: movie.overview, overview: movie.overview,
releaseDate: movie.release_date || null, releaseDate: movie.release_date || null,
@@ -214,14 +217,14 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId, tmdbId,
); );
if (!row) return undefined; if (!row) return undefined;
upsertGenres(row.id, movie.genres); upsertGenres(row.id, movie.genres ?? []);
refreshAvailability(row.id).catch((err) => refreshAvailability(row.id).catch((err) =>
log.debug("Availability enrichment failed:", err), log.debug("Availability enrichment failed:", err),
); );
refreshRecommendations(row.id).catch((err) => refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", 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), log.debug("Color extraction failed:", err),
); );
refreshCredits(row.id).catch((err) => refreshCredits(row.id).catch((err) =>
@@ -245,7 +248,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId: show.id, tmdbId: show.id,
tvdbId: show.external_ids?.tvdb_id ?? null, tvdbId: show.external_ids?.tvdb_id ?? null,
type: "tv", type: "tv",
title: show.name, title: show.name ?? "",
originalTitle: show.original_name, originalTitle: show.original_name,
overview: show.overview, overview: show.overview,
firstAirDate: show.first_air_date || null, firstAirDate: show.first_air_date || null,
@@ -261,7 +264,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
tmdbId, tmdbId,
); );
if (!row) return undefined; if (!row) return undefined;
upsertGenres(row.id, show.genres); upsertGenres(row.id, show.genres ?? []);
await refreshTvChildren(row.id, tmdbId, show.number_of_seasons); await refreshTvChildren(row.id, tmdbId, show.number_of_seasons);
refreshAvailability(row.id).catch((err) => refreshAvailability(row.id).catch((err) =>
@@ -270,7 +273,7 @@ async function _getOrFetchTitleByTmdbId(tmdbId: number, type: "movie" | "tv") {
refreshRecommendations(row.id).catch((err) => refreshRecommendations(row.id).catch((err) =>
log.debug("Recommendations enrichment failed:", 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), log.debug("Color extraction failed:", err),
); );
refreshCredits(row.id).catch((err) => refreshCredits(row.id).catch((err) =>
@@ -316,7 +319,7 @@ export async function refreshTitle(titleId: string) {
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, movie.genres); upsertGenres(titleId, movie.genres ?? []);
} else { } else {
const show = await getTvDetails(title.tmdbId); const show = await getTvDetails(title.tmdbId);
db.update(titles) db.update(titles)
@@ -337,7 +340,7 @@ export async function refreshTitle(titleId: string) {
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, show.genres); upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons); await refreshTvChildren(titleId, title.tmdbId, show.number_of_seasons);
} }
@@ -405,9 +408,10 @@ export async function refreshTvChildren(
.get(); .get();
// Batch all episode upserts in a single transaction per season // 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) => { db.transaction((tx) => {
for (const ep of seasonData.episodes) { for (const ep of eps) {
tx.insert(episodes) tx.insert(episodes)
.values({ .values({
seasonId: seasonRow.id, seasonId: seasonRow.id,
@@ -455,20 +459,23 @@ export async function refreshRecommendations(
getSimilar(title.tmdbId, title.type), getSimilar(title.tmdbId, title.type),
]); ]);
const recsResults = recs.results ?? [];
const similarResults = similar.results ?? [];
log.debug( 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 // Collect all valid results with their source/rank
interface RecItem { interface RecItem {
result: (typeof recs.results)[0]; result: (typeof recsResults)[number];
type: "movie" | "tv"; type: "movie" | "tv";
source: "tmdb_recommendations" | "tmdb_similar"; source: "tmdb_recommendations" | "tmdb_similar";
rank: number; rank: number;
} }
const allItems: RecItem[] = []; const allItems: RecItem[] = [];
for (let i = 0; i < recs.results.length && i < 20; i++) { for (let i = 0; i < recsResults.length && i < 20; i++) {
const r = recs.results[i]; const r = recsResults[i];
const type = r.media_type ?? title.type; const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") { if (type === "movie" || type === "tv") {
allItems.push({ allItems.push({
@@ -479,8 +486,8 @@ export async function refreshRecommendations(
}); });
} }
} }
for (let i = 0; i < similar.results.length && i < 20; i++) { for (let i = 0; i < similarResults.length && i < 20; i++) {
const r = similar.results[i]; const r = similarResults[i];
const type = r.media_type ?? title.type; const type = r.media_type ?? title.type;
if (type === "movie" || type === "tv") { if (type === "movie" || type === "tv") {
allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 }); allItems.push({ result: r, type, source: "tmdb_similar", rank: i + 1 });
@@ -651,7 +658,7 @@ export async function ensureTvHydrated(
}) })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
.run(); .run();
upsertGenres(titleId, show.genres); upsertGenres(titleId, show.genres ?? []);
await refreshTvChildren(titleId, tmdbId, show.number_of_seasons); await refreshTvChildren(titleId, tmdbId, show.number_of_seasons);
} catch (err) { } catch (err) {
log.debug(`Failed to hydrate shell TV title ${titleId}:`, 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)) .where(eq(titles.id, id))
.run(); .run();
upsertGenres(id, movie.genres); upsertGenres(id, movie.genres ?? []);
title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title; title = db.select().from(titles).where(eq(titles.id, id)).get() ?? title;
} catch (err) { } catch (err) {
log.debug(`Failed to hydrate shell movie title ${id}:`, 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 const officialTrailers = candidates
.filter((v) => v.official && v.type === "Trailer") .filter((v) => v.official && v.type === "Trailer")
.sort(byDate); .sort(byDate);
if (officialTrailers.length > 0) return officialTrailers[0].key; if (officialTrailers.length > 0) return officialTrailers[0].key ?? null;
// Tier 2: any trailer // Tier 2: any trailer
const trailers = candidates.filter((v) => v.type === "Trailer").sort(byDate); 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 // Tier 3: teasers
const teasers = candidates.filter((v) => v.type === "Teaser").sort(byDate); 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; return null;
} }
@@ -907,7 +914,7 @@ export async function refreshTrailer(titleId: string) {
try { try {
const response = await getVideos(title.tmdbId, title.type); const response = await getVideos(title.tmdbId, title.type);
const key = pickBestTrailer(response.results); const key = pickBestTrailer(response.results ?? []);
db.update(titles) db.update(titles)
.set({ trailerVideoKey: key }) .set({ trailerVideoKey: key })
.where(eq(titles.id, titleId)) .where(eq(titles.id, titleId))
+22 -17
View File
@@ -24,7 +24,7 @@ export async function getOrFetchPerson(
const details = await getPersonDetails(person.tmdbId); const details = await getPersonDetails(person.tmdbId);
db.update(persons) db.update(persons)
.set({ .set({
name: details.name, name: details.name || person.name,
biography: details.biography || null, biography: details.biography || null,
birthday: details.birthday, birthday: details.birthday,
deathday: details.deathday, deathday: details.deathday,
@@ -41,14 +41,14 @@ export async function getOrFetchPerson(
return { return {
id: person.id, id: person.id,
tmdbId: person.tmdbId, tmdbId: person.tmdbId,
name: details.name, name: details.name || person.name,
biography: details.biography || null, biography: details.biography || null,
birthday: details.birthday, birthday: details.birthday ?? null,
deathday: details.deathday, deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth, placeOfBirth: details.place_of_birth ?? null,
profilePath: tmdbImageUrl(details.profile_path, "profiles"), profilePath: tmdbImageUrl(details.profile_path ?? null, "profiles"),
knownForDepartment: details.known_for_department, knownForDepartment: details.known_for_department ?? null,
imdbId: details.imdb_id, imdbId: details.imdb_id ?? null,
}; };
} catch (err) { } catch (err) {
log.error(`Failed to hydrate person ${personId}:`, err); log.error(`Failed to hydrate person ${personId}:`, err);
@@ -89,15 +89,15 @@ export async function getOrFetchPersonByTmdbId(
.insert(persons) .insert(persons)
.values({ .values({
tmdbId, tmdbId,
name: details.name, name: details.name ?? "",
biography: details.biography || null, biography: details.biography || null,
birthday: details.birthday, birthday: details.birthday ?? null,
deathday: details.deathday, deathday: details.deathday ?? null,
placeOfBirth: details.place_of_birth, placeOfBirth: details.place_of_birth ?? null,
profilePath: details.profile_path, profilePath: details.profile_path ?? null,
knownForDepartment: details.known_for_department, knownForDepartment: details.known_for_department ?? null,
popularity: details.popularity, popularity: details.popularity,
imdbId: details.imdb_id, imdbId: details.imdb_id ?? null,
lastFetchedAt: new Date(), lastFetchedAt: new Date(),
}) })
.onConflictDoNothing() .onConflictDoNothing()
@@ -174,7 +174,12 @@ export async function fetchFullFilmography(
const credits = await getPersonCombinedCredits(person.tmdbId); const credits = await getPersonCombinedCredits(person.tmdbId);
// Filter to valid cast entries // 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", (c) => c.media_type === "movie" || c.media_type === "tv",
); );
if (validCast.length === 0) return []; if (validCast.length === 0) return [];
@@ -245,7 +250,7 @@ export async function fetchFullFilmography(
tmdbId: c.id, tmdbId: c.id,
type: c.media_type as "movie" | "tv", type: c.media_type as "movie" | "tv",
title: c.title ?? c.name ?? "Unknown", title: c.title ?? c.name ?? "Unknown",
posterPath: tmdbImageUrl(c.poster_path, "posters"), posterPath: tmdbImageUrl(c.poster_path ?? null, "posters"),
releaseDate: c.release_date ?? null, releaseDate: c.release_date ?? null,
firstAirDate: c.first_air_date ?? null, firstAirDate: c.first_air_date ?? null,
voteAverage: c.vote_average, voteAverage: c.vote_average,
+19 -32
View File
@@ -162,12 +162,14 @@ async function resolveMovieTmdbId(event: WebhookEvent): Promise<number | null> {
if (event.imdbId) { if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id"); 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) { if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id"); 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; 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 // Strategy 1: Use IMDB ID to find the episode and get show_id
if (event.imdbId) { if (event.imdbId) {
const result = await findByExternalId(event.imdbId, "imdb_id"); const result = await findByExternalId(event.imdbId, "imdb_id");
if (result.tv_episode_results.length > 0) { const ep = result.tv_episode_results?.[0];
return { if (ep) {
showTmdbId: result.tv_episode_results[0].show_id, return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
seasonNumber,
episodeNumber,
};
} }
// IMDB ID might reference the show itself // IMDB ID might reference the show itself
if (result.tv_results.length > 0) { const show = result.tv_results?.[0];
return { if (show) {
showTmdbId: result.tv_results[0].id, return { showTmdbId: show.id, seasonNumber, episodeNumber };
seasonNumber,
episodeNumber,
};
} }
} }
// Strategy 2: Use TVDB ID // Strategy 2: Use TVDB ID
if (event.tvdbId) { if (event.tvdbId) {
const result = await findByExternalId(event.tvdbId, "tvdb_id"); const result = await findByExternalId(event.tvdbId, "tvdb_id");
if (result.tv_episode_results.length > 0) { const ep = result.tv_episode_results?.[0];
return { if (ep) {
showTmdbId: result.tv_episode_results[0].show_id, return { showTmdbId: ep.show_id, seasonNumber, episodeNumber };
seasonNumber,
episodeNumber,
};
} }
if (result.tv_results.length > 0) { const show = result.tv_results?.[0];
return { if (show) {
showTmdbId: result.tv_results[0].id, return { showTmdbId: show.id, seasonNumber, episodeNumber };
seasonNumber,
episodeNumber,
};
} }
} }
@@ -228,12 +218,9 @@ async function resolveEpisode(event: WebhookEvent): Promise<{
// Strategy 4: Search by show title // Strategy 4: Search by show title
if (event.showTitle) { if (event.showTitle) {
const searchResult = await searchTv(event.showTitle); const searchResult = await searchTv(event.showTitle);
if (searchResult.results.length > 0) { const tvMatch = searchResult.results?.[0];
return { if (tvMatch) {
showTmdbId: searchResult.results[0].id, return { showTmdbId: tvMatch.id, seasonNumber, episodeNumber };
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 { createLogger } from "@/lib/logger";
import type { import type { operations, paths } from "./schema";
TmdbExternalIds,
TmdbFindResult,
TmdbGenreListResponse,
TmdbMovieCreditsResponse,
TmdbMovieDetails,
TmdbPersonCombinedCredits,
TmdbPersonDetails,
TmdbPersonSearchResponse,
TmdbRecommendationResponse,
TmdbSearchResponse,
TmdbSeasonDetails,
TmdbTvAggregateCreditsResponse,
TmdbTvDetails,
TmdbVideosResponse,
TmdbWatchProviderResponse,
} from "./types";
const log = createLogger("tmdb"); 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() { function getApiKey() {
const key = process.env.TMDB_API_READ_ACCESS_TOKEN; const key = process.env.TMDB_API_READ_ACCESS_TOKEN;
if (!key) throw new Error("TMDB_API_READ_ACCESS_TOKEN is not set"); if (!key) throw new Error("TMDB_API_READ_ACCESS_TOKEN is not set");
return key; return key;
} }
async function tmdbFetch<T>( // The default TMDB base URL ends with /3, matching the schema's /3/… paths.
path: string, // Custom proxy URLs (e.g. https://tmdb.internal) may omit /3 — those worked
params?: Record<string, string>, // before because the old client built paths like /search/multi against the
fetchOptions?: RequestInit, // custom base. To preserve that, we always strip /3 from the configured URL
): Promise<T> { // and use the schema paths as-is (they already include /3/).
const url = new URL(`${TMDB_API_BASE_URL}${path}`); const DEFAULT_TMDB_BASE = "https://api.themoviedb.org/3";
if (params) { const configuredBase = process.env.TMDB_API_BASE_URL || DEFAULT_TMDB_BASE;
for (const [k, v] of Object.entries(params)) { const isCustomBase = configuredBase !== DEFAULT_TMDB_BASE;
url.searchParams.set(k, v); 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 authMiddleware: Middleware = {
const res = await fetch(url.toString(), { async onRequest({ request }) {
...fetchOptions, request.headers.set("Authorization", `Bearer ${getApiKey()}`);
headers: { request.headers.set("Accept", "application/json");
Authorization: `Bearer ${getApiKey()}`, return request;
Accept: "application/json", },
...fetchOptions?.headers, };
},
});
const elapsed = Math.round(performance.now() - start);
if (!res.ok) { const requestTimings = new WeakMap<Request, number>();
log.warn(
`${url.toString()} -> ${res.status} ${res.statusText} (${elapsed}ms)`,
);
throw new Error(`TMDB API error: ${res.status} ${res.statusText}`);
}
log.debug(`${url.toString()} -> ${res.status} (${elapsed}ms)`); const loggingMiddleware: Middleware = {
return res.json() as Promise<T>; 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) { export async function searchMulti(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/multi", { const { data, error } = await client.GET("/3/search/multi", {
query, params: { query: { query, page, include_adult: false } },
page: String(page),
include_adult: "false",
}); });
if (error) throw new Error("TMDB API error: search/multi");
return data;
} }
export async function searchMovies(query: string, page = 1) { export async function searchMovies(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/movie", { const { data, error } = await client.GET("/3/search/movie", {
query, params: { query: { query, page } },
page: String(page),
}); });
if (error) throw new Error("TMDB API error: search/movie");
return data;
} }
export async function searchTv(query: string, page = 1) { export async function searchTv(query: string, page = 1) {
return tmdbFetch<TmdbSearchResponse>("/search/tv", { const { data, error } = await client.GET("/3/search/tv", {
query, params: { query: { query, page } },
page: String(page),
}); });
if (error) throw new Error("TMDB API error: search/tv");
return data;
} }
// ─── Details ────────────────────────────────────────────────────────
export async function getMovieDetails(tmdbId: number) { export async function getMovieDetails(tmdbId: number) {
return tmdbFetch<TmdbMovieDetails>(`/movie/${tmdbId}`, { const { data, error } = await client.GET("/3/movie/{movie_id}", {
append_to_response: "release_dates", 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) { export async function getTvDetails(tmdbId: number) {
return tmdbFetch<TmdbTvDetails>(`/tv/${tmdbId}`, { const { data, error } = await client.GET("/3/tv/{series_id}", {
append_to_response: "content_ratings,external_ids", 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) { 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) { 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") { export async function getWatchProviders(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbWatchProviderResponse>( if (type === "movie") {
`/${type}/${tmdbId}/watch/providers`, 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") { export async function getRecommendations(tmdbId: number, type: "movie" | "tv") {
return tmdbFetch<TmdbRecommendationResponse>( if (type === "movie") {
`/${type}/${tmdbId}/recommendations`, 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") { 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( export async function getTrending(
mediaType: "all" | "movie" | "tv", mediaType: "all" | "movie" | "tv",
timeWindow: "day" | "week" = "day", timeWindow: "day" | "week" = "day",
) { ) {
return tmdbFetch<TmdbSearchResponse>( const opts = {
`/trending/${mediaType}/${timeWindow}`, params: { path: { time_window: timeWindow } },
undefined, fetch: revalidateFetch(3600),
{ next: { revalidate: 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) { export async function getPopular(type: "movie" | "tv", page = 1) {
return tmdbFetch<TmdbSearchResponse>( if (type === "movie") {
`/${type}/popular`, const { data, error } = await client.GET("/3/movie/popular", {
{ page: String(page) }, params: { query: { page } },
{ next: { revalidate: 3600 } }, 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") { export async function getGenres(type: "movie" | "tv") {
return tmdbFetch<TmdbGenreListResponse>(`/genre/${type}/list`, undefined, { if (type === "movie") {
next: { revalidate: 86400 }, 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( export async function discover(
type: "movie" | "tv", type: "movie" | "tv",
params: Record<string, string>, params: Record<string, string>,
page = 1, page = 1,
) { ) {
return tmdbFetch<TmdbSearchResponse>( // Discover accepts many dynamic filter params (with_genres, vote_count.gte, etc.)
`/discover/${type}`, // that aren't individually typed in the schema, so widen to Record<string, unknown>.
{ ...params, page: String(page) }, if (type === "movie") {
{ next: { revalidate: 3600 } }, 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") { 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( export async function findByExternalId(
externalId: string, externalId: string,
source: "imdb_id" | "tvdb_id", source: "imdb_id" | "tvdb_id",
) { ) {
return tmdbFetch<TmdbFindResult>(`/find/${externalId}`, { const { data, error } = await client.GET("/3/find/{external_id}", {
external_source: source, 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) { 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) { export async function getTvAggregateCredits(tmdbId: number) {
return tmdbFetch<TmdbTvAggregateCreditsResponse>( const { data, error } = await client.GET(
`/tv/${tmdbId}/aggregate_credits`, "/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) { 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) { export async function getPersonCombinedCredits(tmdbId: number) {
return tmdbFetch<TmdbPersonCombinedCredits>( const { data, error } = await client.GET(
`/person/${tmdbId}/combined_credits`, "/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) { export async function searchPerson(query: string, page = 1) {
return tmdbFetch<TmdbPersonSearchResponse>("/search/person", { const { data, error } = await client.GET("/3/search/person", {
query, params: { query: { query, page } },
page: String(page),
}); });
if (error) throw new Error("TMDB API error: search/person");
return data;
} }
export { tmdbImageUrl } from "./image"; 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:migrate": "bun --bun drizzle-kit migrate",
"db:push": "bun --bun drizzle-kit push", "db:push": "bun --bun drizzle-kit push",
"db:studio": "bun --bun drizzle-kit studio", "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": { "dependencies": {
"@base-ui/react": "1.2.0", "@base-ui/react": "1.2.0",
@@ -35,6 +36,7 @@
"motion": "12.35.1", "motion": "12.35.1",
"next": "16.1.6", "next": "16.1.6",
"node-vibrant": "4.0.4", "node-vibrant": "4.0.4",
"openapi-fetch": "0.17.0",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.14.0",
"react-dom": "19.2.4", "react-dom": "19.2.4",