// 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. export interface FilmTreffer { id: number title?: string name?: string poster_path?: string release_date?: string first_air_date?: string } export interface FilmDetails { id: number title?: string name?: string release_date?: string first_air_date?: string overview?: string poster_path?: string backdrop_path?: string runtime?: number genres?: Array<{ name: string }> } export function istV4Token(key: string): boolean { return key.startsWith('eyJ') } const BASIS = 'https://api.themoviedb.org/3' 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' })) as FilmDetails | null } async tvDetails(id: number): Promise { return (await this.anfrage(`tv/${id}`, { language: 'de-DE' })) as FilmDetails | null } /** 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 } }