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,
};
});