WAS: TMDb antwortet mit language=de-DE auf Deutsch („Der Patriot", „Déjà Vu – Wettlauf gegen die Zeit"), die Disc trägt den Originaltitel (THE_PATRIOT, DEJA_VU). Der wörtliche Vergleich prüft jetzt title UND original_title (name/original_name bei Serien); Akzente fallen vor dem Vergleich (NFD, ohneAkzente) — auch bei Wort-Überlappung und Abdeckung. WARUM: Durchsicht 12.09.2026, Fund F14 (Sonde 1): „Déjà Vu" ≠ „Deja Vu", „Der Patriot" ≠ „The Patriot" — nur 60 % statt 95 %, die Automatik wartete. Tests mit genau diesen Titeln. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
9.4 KiB
TypeScript
246 lines
9.4 KiB
TypeScript
// 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<string, string>): Promise<unknown> {
|
|
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<string, string> = { 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<FilmTreffer[] | null> {
|
|
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<FilmTreffer[] | null> {
|
|
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<FilmDetails | null> {
|
|
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<FilmDetails | null> {
|
|
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<string[]> {
|
|
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<Record<string, unknown> | 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
|
|
}
|
|
}
|