From 40a5ecd435c9dc0fb82c0e1efe3ae13ec37a9502 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 16 Sep 2026 20:40:43 +0200 Subject: [PATCH] Fall back to English TMDB titles when Dutch is missing. Prevents German (and other) originals from being stored as the display title when no nl-NL translation exists. --- apps/master-api/package.json | 2 +- apps/master-api/src/metadata/service.ts | 223 +++++++++++++++--- .../src/metadata/title-locale.test.ts | 51 ++++ 3 files changed, 244 insertions(+), 32 deletions(-) create mode 100644 apps/master-api/src/metadata/title-locale.test.ts diff --git a/apps/master-api/package.json b/apps/master-api/package.json index 4bdf642..efa673a 100644 --- a/apps/master-api/package.json +++ b/apps/master-api/package.json @@ -6,7 +6,7 @@ "dev": "tsx watch src/app.ts", "build": "tsc", "start": "node dist/app.js", - "test": "tsx src/metadata/filename-parser.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts", + "test": "tsx src/metadata/filename-parser.test.ts && tsx src/metadata/title-locale.test.ts && tsx src/google-play/service.test.ts && tsx src/viewer/iptv-categories.test.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate deploy", "db:migrate:dev": "prisma migrate dev", diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index ab6a424..1de9dc3 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -18,6 +18,9 @@ interface TmdbSearchResult { id: number; title?: string; name?: string; + original_title?: string; + original_name?: string; + original_language?: string; release_date?: string; first_air_date?: string; overview?: string; @@ -33,6 +36,59 @@ const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_LANG = "nl-NL"; +const TMDB_LANG_EN = "en-US"; + +function isDutchLanguage(lang?: string | null): boolean { + const l = (lang || "").toLowerCase(); + return l === "nl" || l.startsWith("nl-"); +} + +/** + * TMDB met language=nl-NL zet `title` op `original_title` als er geen NL-vertaling is. + * Dan liever Engels (of original als EN ontbreekt). + */ +export function preferNlOrEnglishTitle(opts: { + localizedTitle?: string | null; + originalTitle?: string | null; + originalLanguage?: string | null; + englishTitle?: string | null; +}): string { + const localized = opts.localizedTitle?.trim() || ""; + const original = opts.originalTitle?.trim() || ""; + const english = opts.englishTitle?.trim() || ""; + if (isDutchLanguage(opts.originalLanguage)) { + return localized || original || english || "Onbekend"; + } + if ( + localized && + original && + normalizeMatchTitle(localized) !== normalizeMatchTitle(original) + ) { + return localized; + } + if (english) return english; + return localized || original || "Onbekend"; +} + +function isEnglishLanguage(lang?: string | null): boolean { + const l = (lang || "").toLowerCase(); + return l === "en" || l.startsWith("en-"); +} + +function needsEnglishTitleFallback( + localizedTitle?: string | null, + originalTitle?: string | null, + originalLanguage?: string | null +): boolean { + if (isDutchLanguage(originalLanguage)) return false; + if (isEnglishLanguage(originalLanguage)) return false; + const localized = localizedTitle?.trim() || ""; + const original = originalTitle?.trim() || ""; + if (!localized) return true; + if (!original) return false; + // Geen NL-vertaling → TMDB herhaalt de original (vaak DE/FR/…) + return normalizeMatchTitle(localized) === normalizeMatchTitle(original); +} function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -79,6 +135,7 @@ const METAHUB_LOGO = (imdbId: string) => type TmdbMeta = { id: number; title: string; + originalTitle?: string; year?: number; overview?: string; posterUrl?: string; @@ -1379,24 +1436,51 @@ export class MetadataService { return found ? [found] : []; } - const params = new URLSearchParams({ + const path = type === "movie" ? "search/movie" : "search/tv"; + const base = { api_key: this.tmdbApiKey, query: q, - language: TMDB_LANG, include_adult: "false", - }); - const path = type === "movie" ? "search/movie" : "search/tv"; - const res = await fetch(`https://api.themoviedb.org/3/${path}?${params}`); - if (!res.ok) return []; - const data = (await res.json()) as TmdbSearchResult; + }; + const [nlRes, enRes] = await Promise.all([ + fetch( + `https://api.themoviedb.org/3/${path}?${new URLSearchParams({ + ...base, + language: TMDB_LANG, + })}` + ), + fetch( + `https://api.themoviedb.org/3/${path}?${new URLSearchParams({ + ...base, + language: TMDB_LANG_EN, + })}` + ), + ]); + if (!nlRes.ok) return []; + const data = (await nlRes.json()) as TmdbSearchResult; + const enById = new Map(); + if (enRes.ok) { + const enData = (await enRes.json()) as TmdbSearchResult; + for (const hit of enData.results ?? []) enById.set(hit.id, hit); + } return (data.results ?? []).slice(0, 12).map((item) => { + const en = enById.get(item.id); const date = type === "movie" ? item.release_date : item.first_air_date; const year = date ? parseInt(date.slice(0, 4), 10) : null; + const localized = type === "movie" ? item.title : item.name; + const original = type === "movie" ? item.original_title : item.original_name; + const english = type === "movie" ? en?.title : en?.name; + const overview = item.overview?.trim() || en?.overview?.trim() || null; return { tmdbId: item.id, - title: (type === "movie" ? item.title : item.name) || "Onbekend", + title: preferNlOrEnglishTitle({ + localizedTitle: localized, + originalTitle: original, + originalLanguage: item.original_language, + englishTitle: english, + }), year: Number.isFinite(year) ? year : null, - overview: item.overview || null, + overview, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, imdbId: null, }; @@ -1768,11 +1852,29 @@ export class MetadataService { const item = type === "movie" ? data.movie_results?.[0] : data.tv_results?.[0]; if (!item) return null; + const meta = + type === "movie" + ? await this.getTmdbMovieDetails(item.id) + : await this.getTmdbSeriesDetails(item.id); + if (meta) { + return { + tmdbId: meta.id, + title: meta.title, + year: meta.year ?? null, + overview: meta.overview || null, + posterUrl: meta.posterUrl || null, + imdbId: meta.imdbId || imdbId, + }; + } const date = type === "movie" ? item.release_date : item.first_air_date; const year = date ? parseInt(date.slice(0, 4), 10) : null; return { tmdbId: item.id, - title: (type === "movie" ? item.title : item.name) || "Onbekend", + title: preferNlOrEnglishTitle({ + localizedTitle: type === "movie" ? item.title : item.name, + originalTitle: type === "movie" ? item.original_title : item.original_name, + originalLanguage: item.original_language, + }), year: Number.isFinite(year) ? year : null, overview: item.overview || null, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, @@ -2004,6 +2106,7 @@ export class MetadataService { const releaseDate = parseTmdbDate(tmdb.releaseDate); const data = { title: tmdb.title, + ...(tmdb.originalTitle ? { originalTitle: tmdb.originalTitle } : {}), year: tmdb.year, overview: tmdb.overview, posterUrl: tmdb.posterUrl, @@ -2274,20 +2377,50 @@ export class MetadataService { const item = (await res.json()) as { id: number; title?: string; + original_title?: string; + original_language?: string; overview?: string; release_date?: string; poster_path?: string; backdrop_path?: string; adult?: boolean; }; + + let englishTitle: string | undefined; + let englishOverview: string | undefined; + const wantEnTitle = needsEnglishTitleFallback( + item.title, + item.original_title, + item.original_language + ); + const wantEnOverview = !item.overview?.trim(); + if (wantEnTitle || wantEnOverview) { + const enParams = new URLSearchParams({ + api_key: this.tmdbApiKey, + language: TMDB_LANG_EN, + }); + const enRes = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}?${enParams}`); + if (enRes.ok) { + const en = (await enRes.json()) as { title?: string; overview?: string }; + englishTitle = en.title; + englishOverview = en.overview; + } + } + const imdbId = await this.fetchTmdbMovieImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, - title: item.title || "Onbekend", + title: preferNlOrEnglishTitle({ + localizedTitle: item.title, + originalTitle: item.original_title, + originalLanguage: item.original_language, + englishTitle, + }), + originalTitle: item.original_title?.trim() || undefined, year: item.release_date ? parseInt(item.release_date.slice(0, 4), 10) : fallbackYear, - overview: item.overview || 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}` @@ -2322,29 +2455,26 @@ export class MetadataService { numberOfSeasons: number; } | null> { if (!this.tmdbApiKey || !Number.isFinite(tmdbId)) return null; + const meta = await this.getTmdbSeriesDetails(tmdbId); + if (!meta) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${params}`); - if (!res.ok) return null; - const item = (await res.json()) as { - id: number; - name?: string; - overview?: string; - first_air_date?: string; - poster_path?: string; - number_of_seasons?: number; - }; - const imdbId = (await this.fetchTmdbSeriesImdbId(tmdbId)) ?? null; + let numberOfSeasons = 0; + if (res.ok) { + const item = (await res.json()) as { number_of_seasons?: number }; + numberOfSeasons = Math.max(0, item.number_of_seasons ?? 0); + } return { - tmdbId: item.id, - title: item.name || "Onbekend", - year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : null, - overview: item.overview || null, - posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, - imdbId, - numberOfSeasons: Math.max(0, item.number_of_seasons ?? 0), + tmdbId: meta.id, + title: meta.title, + year: meta.year ?? null, + overview: meta.overview || null, + posterUrl: meta.posterUrl || null, + imdbId: meta.imdbId ?? null, + numberOfSeasons, }; } @@ -2423,21 +2553,51 @@ export class MetadataService { const item = (await res.json()) as { id: number; name?: string; + original_name?: string; + original_language?: string; overview?: string; first_air_date?: string; poster_path?: string; backdrop_path?: string; adult?: boolean; }; + + let englishTitle: string | undefined; + let englishOverview: string | undefined; + const wantEnTitle = needsEnglishTitleFallback( + item.name, + item.original_name, + item.original_language + ); + const wantEnOverview = !item.overview?.trim(); + if (wantEnTitle || wantEnOverview) { + const enParams = new URLSearchParams({ + api_key: this.tmdbApiKey, + language: TMDB_LANG_EN, + }); + const enRes = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${enParams}`); + if (enRes.ok) { + const en = (await enRes.json()) as { name?: string; overview?: string }; + englishTitle = en.name; + englishOverview = en.overview; + } + } + const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, - title: item.name || "Onbekend", + title: preferNlOrEnglishTitle({ + localizedTitle: item.name, + originalTitle: item.original_name, + originalLanguage: item.original_language, + englishTitle, + }), + originalTitle: item.original_name?.trim() || undefined, year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined, - overview: item.overview || 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}` @@ -2524,6 +2684,7 @@ export class MetadataService { where: { id: movieId }, data: { title: tmdb.title, + ...(tmdb.originalTitle ? { originalTitle: tmdb.originalTitle } : {}), overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, diff --git a/apps/master-api/src/metadata/title-locale.test.ts b/apps/master-api/src/metadata/title-locale.test.ts new file mode 100644 index 0000000..07e40e6 --- /dev/null +++ b/apps/master-api/src/metadata/title-locale.test.ts @@ -0,0 +1,51 @@ +import { preferNlOrEnglishTitle } from "./service"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function run() { + assert( + preferNlOrEnglishTitle({ + localizedTitle: "Het Neverending Story", + originalTitle: "Die unendliche Geschichte II", + originalLanguage: "de", + englishTitle: "The NeverEnding Story II: The Next Chapter", + }) === "Het Neverending Story", + "should keep real NL translation" + ); + + assert( + preferNlOrEnglishTitle({ + localizedTitle: "Die unendliche Geschichte II – Auf der Suche nach Phantásien", + originalTitle: "Die unendliche Geschichte II – Auf der Suche nach Phantásien", + originalLanguage: "de", + englishTitle: "The NeverEnding Story II: The Next Chapter", + }) === "The NeverEnding Story II: The Next Chapter", + "should fall back to English when NL equals original" + ); + + assert( + preferNlOrEnglishTitle({ + localizedTitle: "Turks fruit", + originalTitle: "Turks fruit", + originalLanguage: "nl", + englishTitle: "Turkish Delight", + }) === "Turks fruit", + "should keep Dutch originals" + ); + + assert( + preferNlOrEnglishTitle({ + localizedTitle: "The Matrix", + originalTitle: "The Matrix", + originalLanguage: "en", + englishTitle: "The Matrix", + }) === "The Matrix", + "should keep English originals" + ); + + console.log("title-locale tests passed"); +} + +run();