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 = env.tmdb.baseUrl; export const IMAGE_BASE = env.tmdb.imageBaseUrl; 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; }