feat: TMDB-Client für Suche, Details und Episodendaten

Nur die Endpunkte, die Flimmer wirklich braucht, samt der Felder, die uns
interessieren. Antworten werden zehn Minuten zwischengespeichert, damit
Tippen im Suchfeld nicht jedes Zeichen einzeln nach draußen trägt.

Zugang wahlweise über den klassischen API-Schlüssel oder das Bearer-Token —
TMDB gibt beides aus, und Selbsthoster haben mal das eine, mal das andere.
This commit is contained in:
Florian Wilde
2026-09-01 19:00:11 +02:00
parent d1303d10f5
commit 4feb84c060
+309
View File
@@ -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<string, CacheEntry>();
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX = 500;
function readCache<T>(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<T>(
path: string,
params: Record<string, string | number | undefined> = {},
): Promise<T> {
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<string, string> = { 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<T>(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<T> = {
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<TmdbPaged<TmdbSearchResult>>("/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<TmdbPaged<TmdbSearchResult>>("/search/tv", { query, page, include_adult: "false" });
}
export function searchMovies(query: string, page = 1) {
return request<TmdbPaged<TmdbSearchResult>>("/search/movie", {
query,
page,
include_adult: "false",
});
}
export function getSeries(tmdbId: number) {
return request<TmdbSeriesDetails>(`/tv/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getSeason(tmdbId: number, seasonNumber: number) {
return request<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
}
export function getMovie(tmdbId: number) {
return request<TmdbMovieDetails>(`/movie/${tmdbId}`, {
append_to_response: "watch/providers,external_ids",
});
}
export function getCollection(tmdbId: number) {
return request<TmdbCollection>(`/collection/${tmdbId}`);
}
export function getTrending(window: "day" | "week" = "week") {
return request<TmdbPaged<TmdbSearchResult>>(`/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;
}