// TMDb-Client (KONZEPT § 4.2) — Regeln aus clients/tmdb.py: Der Key kann // ein v3-Key (32 Hex, api_key-Query) ODER ein v4-Token ('eyJ…', // Bearer-Header) sein — der Befund vom 24.07.: der alte Client konnte nur // eine Form, und die andere sah aus wie „falscher Key". Sprache de-DE. // // 5.4.0 — Kino-Banner v2: Details holen in DERSELBEN Anfrage per // `append_to_response` Logo, Besetzung und Freigabe mit. Die Feldnamen // sind GEMESSEN (01.09.2026 mit dem hinterlegten v4-Token, Regel D): // movie/149?append_to_response=images,credits,release_dates // &include_image_language=de,en,null // → tagline „Neo-Tokyo ist am EXPLODIEREN", runtime 124, // vote_average 7.94, images.logos[{iso_639_1:'de'|'en'|null, // file_path:'/….png'|'.svg', width, height, vote_average}], // credits.cast[{name, character, profile_path, order}], // release_dates.results[{iso_3166_1:'DE', release_dates:[{certification:'16', type:3|5|6}]}] // tv/46296?append_to_response=images,credits,content_ratings // → episode_run_time [53], content_ratings.results[{iso_3166_1:'DE', rating:'18'}], // seasons[{season_number, name, episode_count}], number_of_episodes // configuration.images: logo_sizes w45…w500, profile_sizes w45,w185,h632 // Und der Stub-Fund: „Spartacus: Gods of the Arena" gibt es als drei // leere Serien-Einträge (popularity 1, kein Backdrop, 1 Folge) — der // echte Inhalt ist die Extras-Staffel von „Spartacus" (46296). export interface FilmTreffer { id: number title?: string name?: string /** 5.7.0 (Durchsicht F14): TMDb antwortet mit language=de-DE auf Deutsch * („Der Patriot"), die Disc trägt meist den Originaltitel („The * Patriot") — der wörtliche Vergleich braucht beide. */ original_title?: string original_name?: string poster_path?: string | null backdrop_path?: string | null release_date?: string first_air_date?: string overview?: string popularity?: number } export interface TmdbLogo { iso_639_1: string | null file_path: string width?: number height?: number vote_average?: number } export interface TmdbCast { name: string character?: string profile_path?: string | null order?: number } export interface FilmDetails { id: number title?: string name?: string release_date?: string first_air_date?: string overview?: string poster_path?: string | null backdrop_path?: string | null runtime?: number genres?: Array<{ name: string }> tagline?: string vote_average?: number vote_count?: number popularity?: number episode_run_time?: number[] number_of_episodes?: number number_of_seasons?: number seasons?: Array<{ season_number: number; name?: string; episode_count?: number }> images?: { logos?: TmdbLogo[] } credits?: { cast?: TmdbCast[] } release_dates?: { results?: Array<{ iso_3166_1: string; release_dates?: Array<{ certification?: string; type?: number }> }> } content_ratings?: { results?: Array<{ iso_3166_1: string; rating?: string }> } } export function istV4Token(key: string): boolean { return key.startsWith('eyJ') } const BASIS = 'https://api.themoviedb.org/3' /** Das beste Logo (pur): deutsch vor englisch vor sprachlos, darin das * bestbewertete. '' wenn keins. */ export function logoAus(logos: readonly TmdbLogo[] | undefined): string { if (logos === undefined || logos.length === 0) return '' const rang = (l: TmdbLogo): number => (l.iso_639_1 === 'de' ? 0 : l.iso_639_1 === 'en' ? 1 : l.iso_639_1 === null ? 2 : 3) const sortiert = [...logos] .filter((l) => typeof l.file_path === 'string' && l.file_path.length > 0) .sort((a, b) => rang(a) - rang(b) || (b.vote_average ?? 0) - (a.vote_average ?? 0)) return sortiert[0]?.file_path ?? '' } /** Die deutsche Freigabe (pur): Film aus release_dates (Kino vor * Heimvideo vor TV), Serie aus content_ratings. '' wenn unbekannt. */ export function fskAus(details: FilmDetails, typ: 'movie' | 'tv'): string { if (typ === 'tv') { const de = details.content_ratings?.results?.find((r) => r.iso_3166_1 === 'DE') return (de?.rating ?? '').trim() } const de = details.release_dates?.results?.find((r) => r.iso_3166_1 === 'DE') const eintraege = (de?.release_dates ?? []).filter((e) => (e.certification ?? '').trim().length > 0) if (eintraege.length === 0) return '' const rang = (typ2: number | undefined): number => (typ2 === 3 ? 0 : typ2 === 5 ? 1 : typ2 === 4 ? 2 : typ2 === 6 ? 3 : 4) return [...eintraege].sort((a, b) => rang(a.type) - rang(b.type))[0].certification!.trim() } /** Die ersten Darsteller mit Bild (pur). */ export function besetzungAus( cast: readonly TmdbCast[] | undefined, hoechstens = 6, ): Array<{ name: string; rolle: string; bildPfad: string }> { if (cast === undefined) return [] return [...cast] .filter((c) => typeof c.name === 'string' && c.name.length > 0) .sort((a, b) => (a.order ?? 999) - (b.order ?? 999)) .slice(0, hoechstens) .map((c) => ({ name: c.name, rolle: (c.character ?? '').trim(), bildPfad: c.profile_path ?? '' })) } export class TmdbClient { constructor( private readonly apiKey: string, private readonly holen: typeof fetch = fetch, ) {} get verfuegbar(): boolean { return this.apiKey.length > 0 } private async anfrage(weg: string, params: Record): Promise { if (!this.verfuegbar) return null const url = new URL(`${BASIS}/${weg}`) for (const [name, wert] of Object.entries(params)) url.searchParams.set(name, wert) const kopf: Record = { Accept: 'application/json' } if (istV4Token(this.apiKey)) { kopf['Authorization'] = `Bearer ${this.apiKey}` } else { url.searchParams.set('api_key', this.apiKey) } try { const antwort = await this.holen(url.toString(), { headers: kopf, signal: AbortSignal.timeout(15_000), }) if (antwort.status >= 300) return null return await antwort.json() } catch { // R2: Ein verpasster Abruf ist keine Nachricht über die Welt — der // Aufrufer bekommt null („weiß nicht"), nie eine leere Behauptung. return null } } async searchMovie(titel: string): Promise { const daten = (await this.anfrage('search/movie', { query: titel, language: 'de-DE', include_adult: 'false', })) as { results?: FilmTreffer[] } | null return daten?.results ?? null } async searchTv(titel: string): Promise { const daten = (await this.anfrage('search/tv', { query: titel, language: 'de-DE', include_adult: 'false', })) as { results?: FilmTreffer[] } | null return daten?.results ?? null } async movieDetails(id: number): Promise { return (await this.anfrage(`movie/${id}`, { language: 'de-DE', append_to_response: 'images,credits,release_dates', include_image_language: 'de,en,null', })) as FilmDetails | null } async tvDetails(id: number): Promise { return (await this.anfrage(`tv/${id}`, { language: 'de-DE', append_to_response: 'images,credits,content_ratings', include_image_language: 'de,en,null', })) as FilmDetails | null } /** Poster beliebter Filme (5.5.0, Hintergrund der Erst-Einrichtung): * /movie/popular liefert results[].poster_path — dieselbe Form wie die * Suche (FilmTreffer). Zwei Seiten à 20 reichen für ein volles Raster. */ async beliebtePoster(hoechstens = 40): Promise { const pfade: string[] = [] for (let seite = 1; seite <= 2 && pfade.length < hoechstens; seite++) { const daten = (await this.anfrage('movie/popular', { language: 'de-DE', page: String(seite) })) as { results?: FilmTreffer[] } | null for (const f of daten?.results ?? []) { if (typeof f.poster_path === 'string' && f.poster_path.length > 0) pfade.push(f.poster_path) } } return pfade.slice(0, hoechstens) } /** Staffel-Details mit Episoden-Laufzeiten — für die Serien-Zuordnung. */ async tvStaffel(id: number, staffel: number): Promise<{ episodes?: Array<{ episode_number: number; runtime?: number }> } | null> { return (await this.anfrage(`tv/${id}/season/${staffel}`, { language: 'de-DE' })) as { episodes?: Array<{ episode_number: number; runtime?: number }> } | null } /** IMDb-ID → deutscher Datensatz (für OMDb-Treffer, die nur Englisch * können — Commander-Wunsch 24.07.). */ async findByImdb(imdbId: string): Promise | null> { const daten = (await this.anfrage(`find/${imdbId}`, { external_source: 'imdb_id', language: 'de-DE', })) as { movie_results?: FilmDetails[]; tv_results?: FilmDetails[] } | null const film = daten?.movie_results?.[0] if (film !== undefined) { return { title: film.title, overview: film.overview, poster_path: film.poster_path, year: film.release_date !== undefined && film.release_date.length >= 4 ? Number(film.release_date.slice(0, 4)) : null, type: 'movie', } } const serie = daten?.tv_results?.[0] if (serie !== undefined) { return { title: serie.name, overview: serie.overview, poster_path: serie.poster_path, year: serie.first_air_date !== undefined && serie.first_air_date.length >= 4 ? Number(serie.first_air_date.slice(0, 4)) : null, type: 'tv', } } return null } }