mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
TmdbMovieDetails,
|
||||
TmdbTvDetails,
|
||||
TmdbVideo,
|
||||
} from "@/lib/tmdb/types";
|
||||
} from "@/lib/tmdb/client";
|
||||
import {
|
||||
extractMovieContentRating,
|
||||
extractTvContentRating,
|
||||
|
||||
+42
-35
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
Vendored
+22810
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user