diff --git a/src/lib/tmdb.ts b/src/lib/tmdb.ts new file mode 100644 index 0000000..4d54653 --- /dev/null +++ b/src/lib/tmdb.ts @@ -0,0 +1,309 @@ +import "server-only"; +import { env } from "./env"; + +/** + * Zugriff auf die TMDB-API. + * + * Nur die Endpunkte, die Flimmer wirklich braucht — samt der Typen, die uns + * interessieren (TMDB liefert weit mehr Felder, als hier stehen). Antworten + * werden kurz zwischengespeichert, damit Tippen im Suchfeld nicht jedes + * Zeichen einzeln nach draußen trägt. + */ + +const BASE = "https://api.themoviedb.org/3"; +export const IMAGE_BASE = "https://image.tmdb.org/t/p"; + +export class TmdbNotConfiguredError extends Error { + constructor() { + super( + "Es ist kein TMDB-Schlüssel hinterlegt. Bitte TMDB_API_KEY oder TMDB_ACCESS_TOKEN setzen.", + ); + this.name = "TmdbNotConfiguredError"; + } +} + +export class TmdbError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "TmdbError"; + } +} + +// --- kleiner Antwort-Zwischenspeicher --------------------------------------- + +type CacheEntry = { at: number; value: unknown }; +const cache = new Map(); +const CACHE_TTL_MS = 10 * 60 * 1000; +const CACHE_MAX = 500; + +function readCache(key: string): T | null { + const hit = cache.get(key); + if (!hit) return null; + if (Date.now() - hit.at > CACHE_TTL_MS) { + cache.delete(key); + return null; + } + return hit.value as T; +} + +function writeCache(key: string, value: unknown) { + if (cache.size >= CACHE_MAX) { + // Ältesten Eintrag verwerfen — Map bewahrt die Einfügereihenfolge. + const oldest = cache.keys().next().value; + if (oldest) cache.delete(oldest); + } + cache.set(key, { at: Date.now(), value }); +} + +export function clearTmdbCache() { + cache.clear(); +} + +// --- Anfragen --------------------------------------------------------------- + +async function request( + path: string, + params: Record = {}, +): Promise { + if (!env.tmdb.isConfigured) throw new TmdbNotConfiguredError(); + + const url = new URL(`${BASE}${path}`); + url.searchParams.set("language", env.tmdb.language); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== "") url.searchParams.set(key, String(value)); + } + + const headers: Record = { accept: "application/json" }; + if (env.tmdb.accessToken) { + headers.authorization = `Bearer ${env.tmdb.accessToken}`; + } else { + url.searchParams.set("api_key", env.tmdb.apiKey); + } + + const cacheKey = url.toString(); + const cached = readCache(cacheKey); + if (cached) return cached; + + const response = await fetch(url, { headers, cache: "no-store" }); + + if (!response.ok) { + let detail = response.statusText; + try { + const body = (await response.json()) as { status_message?: string }; + if (body.status_message) detail = body.status_message; + } catch { + // Antwort war kein JSON — dann bleibt es beim Statustext. + } + if (response.status === 401) { + throw new TmdbError("TMDB weist den Schlüssel zurück (401).", 401); + } + if (response.status === 404) { + throw new TmdbError("Bei TMDB gibt es dazu keinen Eintrag (404).", 404); + } + throw new TmdbError(`TMDB-Anfrage fehlgeschlagen: ${detail}`, response.status); + } + + const data = (await response.json()) as T; + writeCache(cacheKey, data); + return data; +} + +// --- Typen (nur die genutzten Felder) --------------------------------------- + +export type TmdbSearchResult = { + id: number; + media_type?: "tv" | "movie" | "person"; + name?: string; + title?: string; + original_name?: string; + original_title?: string; + overview?: string; + poster_path?: string | null; + backdrop_path?: string | null; + first_air_date?: string; + release_date?: string; + vote_average?: number; + genre_ids?: number[]; + popularity?: number; +}; + +export type TmdbPaged = { + page: number; + results: T[]; + total_pages: number; + total_results: number; +}; + +export type TmdbGenre = { id: number; name: string }; + +export type TmdbProvider = { + provider_id: number; + provider_name: string; + logo_path?: string | null; + display_priority?: number; +}; + +export type TmdbWatchProviders = { + results?: Record< + string, + { + link?: string; + flatrate?: TmdbProvider[]; + rent?: TmdbProvider[]; + buy?: TmdbProvider[]; + ads?: TmdbProvider[]; + free?: TmdbProvider[]; + } + >; +}; + +export type TmdbMovieDetails = { + id: number; + imdb_id?: string | null; + title: string; + original_title?: string; + overview?: string | null; + tagline?: string | null; + poster_path?: string | null; + backdrop_path?: string | null; + release_date?: string; + runtime?: number | null; + status?: string; + genres?: TmdbGenre[]; + original_language?: string; + homepage?: string | null; + vote_average?: number; + belongs_to_collection?: { id: number; name: string; poster_path?: string | null } | null; + "watch/providers"?: TmdbWatchProviders; +}; + +export type TmdbSeasonSummary = { + id: number; + season_number: number; + name: string; + overview?: string | null; + poster_path?: string | null; + air_date?: string | null; + episode_count?: number; +}; + +export type TmdbSeriesDetails = { + id: number; + name: string; + original_name?: string; + overview?: string | null; + tagline?: string | null; + poster_path?: string | null; + backdrop_path?: string | null; + first_air_date?: string; + last_air_date?: string | null; + episode_run_time?: number[]; + status?: string; + in_production?: boolean; + genres?: TmdbGenre[]; + original_language?: string; + homepage?: string | null; + vote_average?: number; + number_of_seasons?: number; + number_of_episodes?: number; + seasons?: TmdbSeasonSummary[]; + external_ids?: { imdb_id?: string | null }; + "watch/providers"?: TmdbWatchProviders; +}; + +export type TmdbEpisode = { + id: number; + season_number: number; + episode_number: number; + name: string; + overview?: string | null; + still_path?: string | null; + air_date?: string | null; + runtime?: number | null; +}; + +export type TmdbSeasonDetails = TmdbSeasonSummary & { episodes?: TmdbEpisode[] }; + +export type TmdbCollection = { + id: number; + name: string; + overview?: string | null; + poster_path?: string | null; + backdrop_path?: string | null; + parts?: (TmdbSearchResult & { media_type?: "movie" })[]; +}; + +// --- Endpunkte -------------------------------------------------------------- + +/** Gemeinsame Suche über Serien und Filme (Personen werden herausgefiltert). */ +export async function searchMulti(query: string, page = 1) { + const data = await request>("/search/multi", { + query, + page, + include_adult: "false", + }); + return { + ...data, + results: data.results.filter((item) => item.media_type === "tv" || item.media_type === "movie"), + }; +} + +export function searchSeries(query: string, page = 1) { + return request>("/search/tv", { query, page, include_adult: "false" }); +} + +export function searchMovies(query: string, page = 1) { + return request>("/search/movie", { + query, + page, + include_adult: "false", + }); +} + +export function getSeries(tmdbId: number) { + return request(`/tv/${tmdbId}`, { + append_to_response: "watch/providers,external_ids", + }); +} + +export function getSeason(tmdbId: number, seasonNumber: number) { + return request(`/tv/${tmdbId}/season/${seasonNumber}`); +} + +export function getMovie(tmdbId: number) { + return request(`/movie/${tmdbId}`, { + append_to_response: "watch/providers,external_ids", + }); +} + +export function getCollection(tmdbId: number) { + return request(`/collection/${tmdbId}`); +} + +export function getTrending(window: "day" | "week" = "week") { + return request>(`/trending/all/${window}`); +} + +/** Alle Anbieter einer Region — für Filterlisten und Akzentfarben. */ +export function getProviderList(kind: "tv" | "movie", region = env.tmdb.region) { + return request<{ results: TmdbProvider[] }>(`/watch/providers/${kind}`, { + watch_region: region, + }); +} + +// --- Hilfen ----------------------------------------------------------------- + +/** Bildpfad zu einer vollständigen TMDB-URL. */ +export function imageUrl(path: string, size = "w500"): string { + return `${IMAGE_BASE}/${size}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** TMDB-Datum ("2019-04-14" oder "") in ein Date oder null. */ +export function parseDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(`${value}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +}