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.
This commit is contained in:
Jos Vooges | STH 2026-09-16 20:40:43 +02:00
parent 93d96d7bb1
commit 40a5ecd435
3 changed files with 244 additions and 32 deletions

View file

@ -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",

View file

@ -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<void> {
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<number, TmdbSearchResult["results"][number]>();
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,

View file

@ -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();