import { db } from "@/db/client"; import { classics, externalIds, mediaItems } from "@/db/schema"; import { and, eq } from "drizzle-orm"; const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMAGE_BASE_URL = "https://image.tmdb.org/t/p/w342"; const TMDB_BACKDROP_BASE_URL = "https://image.tmdb.org/t/p/w780"; const TMDB_PROFILE_BASE_URL = "https://image.tmdb.org/t/p/w185"; export type CatalogKind = "television" | "movies" | "music"; export type CatalogItem = { id: string; source: "tmdb"; kind: Exclude; title: string; year: string | null; overview: string | null; posterUrl: string | null; backdropUrl: string | null; rating: number | null; popularity: number | null; releaseDate: string | null; isClassic?: boolean; }; export type CatalogPerson = { id: string; name: string; role: string | null; profileUrl: string | null; }; export type CatalogDetails = CatalogItem & { tagline: string | null; status: string | null; runtime: string | null; genres: string[]; cast: CatalogPerson[]; creators: CatalogPerson[]; networks: string[]; seasonCount: number | null; episodeCount: number | null; homepage: string | null; }; type TmdbMovie = { id: number; title?: string; release_date?: string; overview?: string; poster_path?: string | null; backdrop_path?: string | null; vote_average?: number; popularity?: number; }; type TmdbTv = { id: number; name?: string; first_air_date?: string; overview?: string; poster_path?: string | null; backdrop_path?: string | null; vote_average?: number; popularity?: number; }; type TmdbListResponse = { results?: T[]; }; type TmdbPerson = { id?: number; name?: string; character?: string; job?: string; profile_path?: string | null; }; type TmdbGenre = { name?: string; }; type TmdbNetwork = { name?: string; }; type TmdbMovieDetails = TmdbMovie & { tagline?: string; status?: string; runtime?: number; genres?: TmdbGenre[]; homepage?: string; credits?: { cast?: TmdbPerson[]; crew?: TmdbPerson[] }; }; type TmdbTvDetails = TmdbTv & { tagline?: string; status?: string; episode_run_time?: number[]; genres?: TmdbGenre[]; homepage?: string; created_by?: TmdbPerson[]; networks?: TmdbNetwork[]; number_of_seasons?: number; number_of_episodes?: number; credits?: { cast?: TmdbPerson[]; crew?: TmdbPerson[] }; }; const tmdbCredential = process.env.MOVIEDB_API; function tmdbHeaders(): HeadersInit { if (tmdbCredential?.startsWith("eyJ")) { return { Authorization: `Bearer ${tmdbCredential}` }; } return {}; } function withTmdbApiKey(url: URL) { if (!tmdbCredential || tmdbCredential.startsWith("eyJ")) { return url; } url.searchParams.set("api_key", tmdbCredential); return url; } async function fetchTmdb(path: string, searchParams: Record) { if (!tmdbCredential) { throw new Error("Missing MOVIEDB_API in .env.local"); } const url = withTmdbApiKey(new URL(`${TMDB_BASE_URL}${path}`)); for (const [key, value] of Object.entries(searchParams)) { url.searchParams.set(key, value); } const response = await fetch(url, { headers: tmdbHeaders(), next: { revalidate: 900 }, }); if (!response.ok) { throw new Error(`TMDB request failed: ${response.status} ${response.statusText}`); } return (await response.json()) as T; } function imageUrl(path?: string | null) { return path ? `${TMDB_IMAGE_BASE_URL}${path}` : null; } /** * A usable poster URL from whatever media_items.poster_path happens to hold. * * Two writers disagree about that column. The metadata refresh stores TMDB's * bare path (`/abc.jpg`, 2,802 rows) and the catalog board stores the absolute * URL it had already built (1 row). Both forms are in the table right now, so * anything rendering a stored poster has to cope with both -- and anything * handing one to ANOTHER SERVICE has to resolve it, because a bare TMDB path * means nothing on accounts.sticknife.com. */ export function posterUrlFromStored(path?: string | null) { if (!path) return null; return /^https?:\/\//.test(path) ? path : imageUrl(path); } function backdropUrl(path?: string | null) { return path ? `${TMDB_BACKDROP_BASE_URL}${path}` : null; } function profileUrl(path?: string | null) { return path ? `${TMDB_PROFILE_BASE_URL}${path}` : null; } function yearFrom(date?: string) { return date ? date.slice(0, 4) : null; } function normalizeMovie(movie: TmdbMovie): CatalogItem { const releaseDate = movie.release_date ?? null; return { id: String(movie.id), source: "tmdb", kind: "movies", title: movie.title ?? "Untitled movie", year: yearFrom(movie.release_date), overview: movie.overview || null, posterUrl: imageUrl(movie.poster_path), backdropUrl: backdropUrl(movie.backdrop_path), rating: typeof movie.vote_average === "number" ? movie.vote_average : null, popularity: typeof movie.popularity === "number" ? movie.popularity : null, releaseDate, }; } function normalizeTv(show: TmdbTv): CatalogItem { const releaseDate = show.first_air_date ?? null; return { id: String(show.id), source: "tmdb", kind: "television", title: show.name ?? "Untitled series", year: yearFrom(show.first_air_date), overview: show.overview || null, posterUrl: imageUrl(show.poster_path), backdropUrl: backdropUrl(show.backdrop_path), rating: typeof show.vote_average === "number" ? show.vote_average : null, popularity: typeof show.popularity === "number" ? show.popularity : null, releaseDate, }; } async function withClassicFlags(kind: Exclude, items: CatalogItem[]) { if (!items.length) { return items; } const mediaType = kind === "movies" ? "movie" : "tv_series"; const rows = await db .select({ title: mediaItems.title, year: mediaItems.year, externalId: externalIds.externalId, }) .from(classics) .innerJoin(mediaItems, eq(mediaItems.id, classics.mediaItemId)) .leftJoin(externalIds, and(eq(externalIds.mediaItemId, mediaItems.id), eq(externalIds.source, "tmdb"))) .where(eq(mediaItems.mediaType, mediaType)); const externalIdSet = new Set(rows.map((row) => row.externalId).filter((value): value is string => Boolean(value))); const titleYearSet = new Set(rows.map((row) => row.title.toLocaleLowerCase("en-US") + "::" + (row.year ?? ""))); return items.map((item) => { const titleYearKey = item.title.toLocaleLowerCase("en-US") + "::" + (item.year ?? ""); return externalIdSet.has(item.id) || titleYearSet.has(titleYearKey) ? { ...item, isClassic: true } : item; }); } export async function getCatalogItems(kind: CatalogKind, query: string) { const trimmedQuery = query.trim(); if (kind === "music") { return []; } if (kind === "movies") { const path = trimmedQuery ? "/search/movie" : "/movie/popular"; const response = await fetchTmdb>(path, { ...(trimmedQuery ? { query: trimmedQuery } : {}), include_adult: "false", language: "en-US", page: "1", }); return withClassicFlags(kind, (response.results ?? []).slice(0, 18).map(normalizeMovie)); } const path = trimmedQuery ? "/search/tv" : "/tv/popular"; const response = await fetchTmdb>(path, { ...(trimmedQuery ? { query: trimmedQuery } : {}), include_adult: "false", language: "en-US", page: "1", }); return withClassicFlags(kind, (response.results ?? []).slice(0, 18).map(normalizeTv)); } function normalizePerson(person: TmdbPerson): CatalogPerson | null { if (!person.name) { return null; } return { id: String(person.id ?? person.name), name: person.name, role: person.character ?? person.job ?? null, profileUrl: profileUrl(person.profile_path), }; } function peopleFrom(list: TmdbPerson[] | undefined, limit: number) { return (list ?? []) .map(normalizePerson) .filter((person): person is CatalogPerson => person !== null) .slice(0, limit); } function minutesToRuntime(minutes?: number) { if (!minutes) { return null; } const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return hours ? hours + "h " + remainingMinutes + "m" : remainingMinutes + "m"; } export async function getCatalogDetails(kind: Exclude, id: string): Promise { if (kind === "movies") { const movie = await fetchTmdb("/movie/" + encodeURIComponent(id), { append_to_response: "credits,external_ids", language: "en-US", }); const item = normalizeMovie(movie); return { ...item, tagline: movie.tagline || null, status: movie.status || null, runtime: minutesToRuntime(movie.runtime), genres: (movie.genres ?? []).map((genre) => genre.name).filter((name): name is string => Boolean(name)), cast: peopleFrom(movie.credits?.cast, 10), creators: peopleFrom(movie.credits?.crew?.filter((person) => person.job === "Director"), 4), networks: [], seasonCount: null, episodeCount: null, homepage: movie.homepage || null, }; } const show = await fetchTmdb("/tv/" + encodeURIComponent(id), { append_to_response: "credits,external_ids", language: "en-US", }); const item = normalizeTv(show); return { ...item, tagline: show.tagline || null, status: show.status || null, runtime: minutesToRuntime(show.episode_run_time?.[0]), genres: (show.genres ?? []).map((genre) => genre.name).filter((name): name is string => Boolean(name)), cast: peopleFrom(show.credits?.cast, 10), creators: peopleFrom(show.created_by, 4), networks: (show.networks ?? []).map((network) => network.name).filter((name): name is string => Boolean(name)), seasonCount: show.number_of_seasons ?? null, episodeCount: show.number_of_episodes ?? null, homepage: show.homepage || null, }; }