Files
rippy/rippy-windows/src/kern/metadaten/tmdb.ts
T
HitonabiandClaude Opus 5 3dacd7cb4f fix(v5): Erkennung vergleicht auch den Originaltitel und ignoriert Akzente
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>
2026-09-12 21:41:11 +02:00

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
}
}