From e8d1c4bfee9276736968e02d7b2582a7ff77de2c Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 16 Sep 2026 22:41:55 +0200 Subject: [PATCH] Improve admin metadata linking with full TMDB search and ID support. Merge NL/EN TMDB hits and accept TMDB ids/URLs so titles missing on IMDb can still be linked. --- apps/admin-ui/src/app/movies/page.tsx | 70 +++++++++++++++++++----- apps/admin-ui/src/app/pending/page.tsx | 48 ++++++++++++++--- apps/admin-ui/src/app/series/page.tsx | 72 ++++++++++++++++++++----- apps/master-api/src/metadata/service.ts | 68 +++++++++++++++++------ 4 files changed, 209 insertions(+), 49 deletions(-) diff --git a/apps/admin-ui/src/app/movies/page.tsx b/apps/admin-ui/src/app/movies/page.tsx index 33e69da..eb88fca 100644 --- a/apps/admin-ui/src/app/movies/page.tsx +++ b/apps/admin-ui/src/app/movies/page.tsx @@ -280,9 +280,42 @@ export default function MoviesPage() { } async function applyImdbDirect() { - const imdb = linkQuery.trim(); - if (!/^tt\d+$/i.test(imdb) || !linkMovieId) { - setLinkMsg("Vul een IMDb-id in (bijv. tt1375666) of zoek eerst op titel."); + const raw = linkQuery.trim(); + if (!linkMovieId) return; + + const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbId = tmdbFromUrl + ? parseInt(tmdbFromUrl, 10) + : /^\d{1,8}$/.test(raw) + ? parseInt(raw, 10) + : null; + if (tmdbId != null && Number.isFinite(tmdbId)) { + setLinkBusy(true); + setLinkMsg("Koppelen via TMDB…"); + try { + const res = await fetch(`/api/v1/admin/movies/${linkMovieId}/link`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tmdbId }), + }); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Opslaan mislukt"); + return; + } + setLinkMsg("Gekoppeld via TMDB. Metadata vernieuwd."); + setLinkMovieId(null); + setExpanded(data.movieId ?? linkMovieId); + load(); + } finally { + setLinkBusy(false); + } + return; + } + + if (!/^tt\d+$/i.test(raw)) { + setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in."); return; } setLinkBusy(true); @@ -292,7 +325,7 @@ export default function MoviesPage() { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ imdbId: imdb }), + body: JSON.stringify({ imdbId: raw }), }); const data = await res.json(); if (!res.ok) { @@ -348,20 +381,29 @@ export default function MoviesPage() { } async function applyFolderImdbAsSeries() { - const imdb = folderQuery.trim(); - if (!/^tt\d+$/i.test(imdb) || !folderRelink) { - setFolderMsg("Vul een IMDb-id van de serie in (tt…) of zoek op titel."); + const raw = folderQuery.trim(); + if (!folderRelink) return; + + const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbId = tmdbFromUrl + ? parseInt(tmdbFromUrl, 10) + : /^\d{1,8}$/.test(raw) + ? parseInt(raw, 10) + : null; + const idLabel = tmdbId != null ? `TMDB ${tmdbId}` : raw; + if (tmdbId == null && !/^tt\d+$/i.test(raw)) { + setFolderMsg("Vul een serietitel, TMDB-id of IMDb-id (tt…) in."); return; } if ( !confirm( - `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nals SERIE herkoppelen via ${imdb}?` + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nals SERIE herkoppelen via ${idLabel}?` ) ) { return; } setFolderBusy(true); - setFolderMsg("Map herkoppelen via IMDb…"); + setFolderMsg(tmdbId != null ? "Map herkoppelen via TMDB…" : "Map herkoppelen via IMDb…"); try { const res = await fetch(`/api/v1/admin/library/relink-folder`, { method: "POST", @@ -370,7 +412,7 @@ export default function MoviesPage() { body: JSON.stringify({ nodeId: folderRelink.nodeId, folder: folderRelink.folder, - imdbId: imdb, + ...(tmdbId != null ? { tmdbId } : { imdbId: raw }), }), }); const data = await res.json(); @@ -683,7 +725,7 @@ export default function MoviesPage() { setLinkQuery(e.target.value)} - placeholder="Titel of IMDb-id (tt…)" + placeholder="Titel, TMDB-id of IMDb-id (tt…)" style={{ flex: 1, minWidth: 200, marginBottom: 0 }} autoFocus /> @@ -691,7 +733,7 @@ export default function MoviesPage() { Zoek {linkMsg && ( @@ -798,7 +840,7 @@ export default function MoviesPage() { setFolderQuery(e.target.value)} - placeholder="Serietitel of IMDb-id (tt…)" + placeholder="Serietitel, TMDB-id of IMDb-id (tt…)" style={{ flex: 1, minWidth: 200, marginBottom: 0 }} autoFocus /> @@ -806,7 +848,7 @@ export default function MoviesPage() { Zoek serie {folderMsg && ( diff --git a/apps/admin-ui/src/app/pending/page.tsx b/apps/admin-ui/src/app/pending/page.tsx index 167c332..6e5f44f 100644 --- a/apps/admin-ui/src/app/pending/page.tsx +++ b/apps/admin-ui/src/app/pending/page.tsx @@ -118,9 +118,45 @@ export default function PendingSeriesPage() { } async function applyImdb() { - const imdb = query.trim(); - if (!/^tt\d+$/i.test(imdb) || !link) { - setLinkMsg("Vul een IMDb-id in (tt…) of zoek op titel."); + const raw = query.trim(); + if (!link) return; + + const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbId = tmdbFromUrl + ? parseInt(tmdbFromUrl, 10) + : /^\d{1,8}$/.test(raw) + ? parseInt(raw, 10) + : null; + if (tmdbId != null && Number.isFinite(tmdbId)) { + setBusy(true); + setLinkMsg("Koppelen via TMDB…"); + try { + const res = await fetch("/api/v1/admin/library/relink-folder", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId: link.nodeId, + folder: link.folder, + tmdbId, + }), + }); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Koppelen mislukt"); + return; + } + setMsg(`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}`); + setLink(null); + load(); + } finally { + setBusy(false); + } + return; + } + + if (!/^tt\d+$/i.test(raw)) { + setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in."); return; } setBusy(true); @@ -133,7 +169,7 @@ export default function PendingSeriesPage() { body: JSON.stringify({ nodeId: link.nodeId, folder: link.folder, - imdbId: imdb, + imdbId: raw, }), }); const data = await res.json(); @@ -247,7 +283,7 @@ export default function PendingSeriesPage() { setQuery(e.target.value)} - placeholder="Titel of IMDb-id (tt…)" + placeholder="Titel, TMDB-id of IMDb-id (tt…)" style={{ flex: 1, minWidth: 200, marginBottom: 0 }} autoFocus /> @@ -255,7 +291,7 @@ export default function PendingSeriesPage() { Zoek {linkMsg && ( diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index 23ff673..2258377 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -283,20 +283,31 @@ export default function SeriesPage() { } async function applyFolderImdbDirect() { - const imdb = folderQuery.trim(); - if (!/^tt\d+$/i.test(imdb) || !folderRelink) { - setFolderMsg("Vul een IMDb-id in (bijv. tt14424422) of zoek eerst op titel."); + const raw = folderQuery.trim(); + if (!folderRelink) return; + + const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbId = tmdbFromUrl + ? parseInt(tmdbFromUrl, 10) + : /^\d{1,8}$/.test(raw) + ? parseInt(raw, 10) + : null; + const idLabel = tmdbId != null ? `TMDB ${tmdbId}` : raw; + if (tmdbId == null && !/^tt\d+$/i.test(raw)) { + setFolderMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in."); return; } if ( !confirm( - `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nherkoppelen via ${imdb}?` + `Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nherkoppelen via ${idLabel}?` ) ) { return; } setFolderBusy(true); - setFolderMsg("Hele map herkoppelen via IMDb…"); + setFolderMsg( + tmdbId != null ? "Hele map herkoppelen via TMDB…" : "Hele map herkoppelen via IMDb…" + ); try { const res = await fetch(`/api/v1/admin/library/relink-folder`, { method: "POST", @@ -305,7 +316,7 @@ export default function SeriesPage() { body: JSON.stringify({ nodeId: folderRelink.nodeId, folder: folderRelink.folder, - imdbId: imdb, + ...(tmdbId != null ? { tmdbId } : { imdbId: raw }), }), }); const data = await res.json(); @@ -381,9 +392,42 @@ export default function SeriesPage() { } async function applyImdbDirect() { - const imdb = linkQuery.trim(); - if (!/^tt\d+$/i.test(imdb) || !linkSeriesId) { - setLinkMsg("Vul een IMDb-id in (bijv. tt0903747) of zoek eerst op titel."); + const raw = linkQuery.trim(); + if (!linkSeriesId) return; + + const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbId = tmdbFromUrl + ? parseInt(tmdbFromUrl, 10) + : /^\d{1,8}$/.test(raw) + ? parseInt(raw, 10) + : null; + if (tmdbId != null && Number.isFinite(tmdbId)) { + setLinkBusy(true); + setLinkMsg("Koppelen via TMDB…"); + try { + const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tmdbId }), + }); + const data = await res.json(); + if (!res.ok) { + setLinkMsg(data.error?.message ?? "Opslaan mislukt"); + return; + } + setLinkMsg("Gekoppeld via TMDB. Metadata vernieuwd."); + setLinkSeriesId(null); + setExpanded(data.seriesId ?? linkSeriesId); + load(); + } finally { + setLinkBusy(false); + } + return; + } + + if (!/^tt\d+$/i.test(raw)) { + setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in."); return; } setLinkBusy(true); @@ -393,7 +437,7 @@ export default function SeriesPage() { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ imdbId: imdb }), + body: JSON.stringify({ imdbId: raw }), }); const data = await res.json(); if (!res.ok) { @@ -746,7 +790,7 @@ export default function SeriesPage() { setLinkQuery(e.target.value)} - placeholder="Titel of IMDb-id (tt…)" + placeholder="Titel, TMDB-id of IMDb-id (tt…)" style={{ flex: 1, minWidth: 200, marginBottom: 0 }} autoFocus /> @@ -754,7 +798,7 @@ export default function SeriesPage() { Zoek {linkMsg && ( @@ -876,7 +920,7 @@ export default function SeriesPage() { setFolderQuery(e.target.value)} - placeholder="Titel of IMDb-id (tt…)" + placeholder="Titel, TMDB-id of IMDb-id (tt…)" style={{ flex: 1, minWidth: 200, marginBottom: 0 }} autoFocus /> @@ -884,7 +928,7 @@ export default function SeriesPage() { Zoek {folderMsg && ( diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index 3db30b2..3946702 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -1434,7 +1434,7 @@ export class MetadataService { return this.deleteMediaFilesSafely(stale.map((f) => f.id)); } - /** Admin: search TMDB by title (or resolve a single IMDb id). */ + /** Admin: search TMDB by title, TMDB id, or resolve a single IMDb id. */ async searchCatalog( type: "movie" | "series", query: string @@ -1458,6 +1458,28 @@ export class MetadataService { return found ? [found] : []; } + // Direct TMDB id or themoviedb.org URL + const tmdbIdFromUrl = q.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; + const tmdbIdRaw = tmdbIdFromUrl || (/^\d{1,8}$/.test(q) ? q : null); + if (tmdbIdRaw) { + const tmdbId = parseInt(tmdbIdRaw, 10); + const meta = + type === "movie" + ? await this.getTmdbMovieDetails(tmdbId) + : await this.getTmdbSeriesDetails(tmdbId); + if (!meta) return []; + return [ + { + tmdbId: meta.id, + title: meta.title, + year: meta.year ?? null, + overview: meta.overview || null, + posterUrl: meta.posterUrl || null, + imdbId: meta.imdbId || null, + }, + ]; + } + const path = type === "movie" ? "search/movie" : "search/tv"; const base = { api_key: this.tmdbApiKey, @@ -1478,32 +1500,48 @@ export class MetadataService { })}` ), ]); - 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); + const nlData = nlRes.ok ? ((await nlRes.json()) as TmdbSearchResult) : { results: [] }; + const enData = enRes.ok ? ((await enRes.json()) as TmdbSearchResult) : { results: [] }; + const nlList = nlData.results ?? []; + const enList = enData.results ?? []; + if (!nlList.length && !enList.length) return []; + + const enById = new Map(enList.map((hit) => [hit.id, hit])); + const nlById = new Map(nlList.map((hit) => [hit.id, hit])); + const orderedIds: number[] = []; + const seen = new Set(); + for (const hit of [...nlList, ...enList]) { + if (seen.has(hit.id)) continue; + seen.add(hit.id); + orderedIds.push(hit.id); } - return (data.results ?? []).slice(0, 12).map((item) => { - const en = enById.get(item.id); + + return orderedIds.slice(0, 12).map((id) => { + const nl = nlById.get(id); + const en = enById.get(id); + const item = nl ?? en!; 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 localized = type === "movie" ? nl?.title ?? item.title : nl?.name ?? item.name; + const original = + type === "movie" + ? item.original_title ?? en?.original_title + : item.original_name ?? en?.original_name; const english = type === "movie" ? en?.title : en?.name; - const overview = item.overview?.trim() || en?.overview?.trim() || null; + const overview = + (nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null; + const posterPath = item.poster_path || en?.poster_path || nl?.poster_path; return { - tmdbId: item.id, + tmdbId: id, title: preferNlOrEnglishTitle({ localizedTitle: localized, originalTitle: original, - originalLanguage: item.original_language, + originalLanguage: item.original_language ?? en?.original_language, englishTitle: english, }), year: Number.isFinite(year) ? year : null, overview, - posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, + posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : null, imdbId: null, }; });