diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 1de9dc3..3db30b2 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -132,6 +132,28 @@ async function fetchTmdbJson(url: string, retries = 4): Promise const METAHUB_LOGO = (imdbId: string) => `https://images.metahub.space/logo/medium/${imdbId}/img`; +type TmdbImageAsset = { + file_path?: string; + iso_639_1?: string | null; + vote_average?: number; + aspect_ratio?: number; +}; + +/** NL → EN → textless → hoogste score (voorkomt Duitse posters bij ontbrekende NL). */ +export function pickPreferredTmdbImage(images: TmdbImageAsset[]): TmdbImageAsset | undefined { + if (!images.length) return undefined; + const ranked = (lang: string | null) => + images + .filter((i) => (lang === null ? !i.iso_639_1 : i.iso_639_1 === lang)) + .sort((a, b) => (b.vote_average ?? 0) - (a.vote_average ?? 0))[0]; + return ( + ranked("nl") || + ranked("en") || + ranked(null) || + [...images].sort((a, b) => (b.vote_average ?? 0) - (a.vote_average ?? 0))[0] + ); +} + type TmdbMeta = { id: number; title: string; @@ -2408,6 +2430,7 @@ export class MetadataService { } const imdbId = await this.fetchTmdbMovieImdbId(tmdbId); + const art = await this.fetchTmdbPreferredArt("movie", tmdbId); const meta: TmdbMeta = { id: item.id, title: preferNlOrEnglishTitle({ @@ -2421,10 +2444,12 @@ export class MetadataService { ? parseInt(item.release_date.slice(0, 4), 10) : fallbackYear, overview: item.overview?.trim() || englishOverview?.trim() || undefined, - posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, - backdropUrl: item.backdrop_path - ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` - : undefined, + posterUrl: + art.posterUrl || + (item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined), + backdropUrl: + art.backdropUrl || + (item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined), imdbId, adult: item.adult === true, releaseDate: item.release_date || undefined, @@ -2583,7 +2608,8 @@ export class MetadataService { } } - const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); + const art = await this.fetchTmdbPreferredArt("tv", tmdbId); + const logoUrl = art.logoUrl || (await this.fetchTmdbSeriesLogo(tmdbId)); const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, @@ -2598,10 +2624,12 @@ export class MetadataService { ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined, overview: item.overview?.trim() || englishOverview?.trim() || undefined, - posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, - backdropUrl: item.backdrop_path - ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` - : undefined, + posterUrl: + art.posterUrl || + (item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined), + backdropUrl: + art.backdropUrl || + (item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined), logoUrl, imdbId, adult: item.adult === true, @@ -2610,6 +2638,38 @@ export class MetadataService { return meta; } + private async fetchTmdbPreferredArt( + kind: "movie" | "tv", + tmdbId: number + ): Promise<{ posterUrl?: string; backdropUrl?: string; logoUrl?: string }> { + if (!this.tmdbApiKey) return {}; + const params = new URLSearchParams({ + api_key: this.tmdbApiKey, + include_image_language: "nl,en,null", + }); + const res = await fetch( + `https://api.themoviedb.org/3/${kind}/${tmdbId}/images?${params}` + ); + if (!res.ok) return {}; + const data = (await res.json()) as { + posters?: TmdbImageAsset[]; + backdrops?: TmdbImageAsset[]; + logos?: TmdbImageAsset[]; + }; + const poster = pickPreferredTmdbImage(data.posters ?? []); + const backdrop = pickPreferredTmdbImage(data.backdrops ?? []); + const logos = data.logos ?? []; + const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); + const logo = pickPreferredTmdbImage(wide.length ? wide : logos); + return { + posterUrl: poster?.file_path ? `${TMDB_IMAGE_BASE}${poster.file_path}` : undefined, + backdropUrl: backdrop?.file_path + ? `${TMDB_BACKDROP_BASE}${backdrop.file_path}` + : undefined, + logoUrl: logo?.file_path ? `${TMDB_LOGO_BASE}${logo.file_path}` : undefined, + }; + } + private async fetchTmdbSeriesImdbId(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey }); @@ -2623,56 +2683,13 @@ export class MetadataService { } private async fetchTmdbSeriesLogo(tmdbId: number): Promise { - if (!this.tmdbApiKey) return undefined; - const params = new URLSearchParams({ - api_key: this.tmdbApiKey, - include_image_language: "nl,en,null", - }); - const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/images?${params}`); - if (!res.ok) return undefined; - const data = (await res.json()) as { - logos?: Array<{ - file_path?: string; - iso_639_1?: string | null; - aspect_ratio?: number; - }>; - }; - const logos = data.logos ?? []; - // Prefer wide clearlogos (aspect > 1.5) — tall/square logos blow up in Stremio desktop. - const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); - const pool = wide.length ? wide : logos; - const preferred = - pool.find((l) => l.iso_639_1 === "nl") || - pool.find((l) => l.iso_639_1 === "en") || - pool.find((l) => !l.iso_639_1) || - pool[0]; - return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; + const art = await this.fetchTmdbPreferredArt("tv", tmdbId); + return art.logoUrl; } private async fetchTmdbMovieLogo(tmdbId: number): Promise { - if (!this.tmdbApiKey) return undefined; - const params = new URLSearchParams({ - api_key: this.tmdbApiKey, - include_image_language: "nl,en,null", - }); - const res = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}/images?${params}`); - if (!res.ok) return undefined; - const data = (await res.json()) as { - logos?: Array<{ - file_path?: string; - iso_639_1?: string | null; - aspect_ratio?: number; - }>; - }; - const logos = data.logos ?? []; - const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); - const pool = wide.length ? wide : logos; - const preferred = - pool.find((l) => l.iso_639_1 === "nl") || - pool.find((l) => l.iso_639_1 === "en") || - pool.find((l) => !l.iso_639_1) || - pool[0]; - return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; + const art = await this.fetchTmdbPreferredArt("movie", tmdbId); + return art.logoUrl; } private async refreshMovieNl(movieId: string, tmdbId: number) { diff --git a/apps/master-api/src/metadata/title-locale.test.ts b/apps/master-api/src/metadata/title-locale.test.ts index 07e40e6..39c2ab2 100644 --- a/apps/master-api/src/metadata/title-locale.test.ts +++ b/apps/master-api/src/metadata/title-locale.test.ts @@ -1,4 +1,4 @@ -import { preferNlOrEnglishTitle } from "./service"; +import { preferNlOrEnglishTitle, pickPreferredTmdbImage } from "./service"; function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); @@ -45,6 +45,19 @@ function run() { "should keep English originals" ); + const poster = pickPreferredTmdbImage([ + { file_path: "/de.jpg", iso_639_1: "de", vote_average: 9 }, + { file_path: "/en.jpg", iso_639_1: "en", vote_average: 7 }, + { file_path: "/nl.jpg", iso_639_1: "nl", vote_average: 5 }, + ]); + assert(poster?.file_path === "/nl.jpg", "should prefer NL poster over EN/DE"); + + const posterEn = pickPreferredTmdbImage([ + { file_path: "/de.jpg", iso_639_1: "de", vote_average: 9 }, + { file_path: "/en.jpg", iso_639_1: "en", vote_average: 7 }, + ]); + assert(posterEn?.file_path === "/en.jpg", "should prefer EN poster over DE"); + console.log("title-locale tests passed"); }