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:
parent
93d96d7bb1
commit
40a5ecd435
3 changed files with 244 additions and 32 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
51
apps/master-api/src/metadata/title-locale.test.ts
Normal file
51
apps/master-api/src/metadata/title-locale.test.ts
Normal 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();
|
||||
Loading…
Reference in a new issue