diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx
index dff00bb..5e3aadb 100644
--- a/apps/admin-ui/src/app/dashboard/page.tsx
+++ b/apps/admin-ui/src/app/dashboard/page.tsx
@@ -15,6 +15,8 @@ interface Dashboard {
availableSeries?: number;
mediaFiles?: number;
availableFiles?: number;
+ moviesMissingImdb?: number;
+ moviesWithImdbStreams?: number;
scannedFiles?: number;
activeStreams: number;
currentBandwidth: string;
@@ -44,6 +46,7 @@ export default function DashboardPage() {
const [data, setData] = useState(null);
const [error, setError] = useState("");
const [dedupeMsg, setDedupeMsg] = useState("");
+ const [imdbMsg, setImdbMsg] = useState("");
const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" })
@@ -64,6 +67,30 @@ export default function DashboardPage() {
load();
}, [load]);
+ async function backfillImdb() {
+ if (
+ !confirm(
+ "IMDb-IDs ophalen via TMDB voor films die die nog missen? Nodig zodat Cinemeta jouw lokale bronnen toont."
+ )
+ ) {
+ return;
+ }
+ setImdbMsg("Bezig…");
+ const res = await fetch("/api/v1/admin/library/backfill-imdb", {
+ method: "POST",
+ credentials: "include",
+ });
+ const json = await res.json();
+ if (!res.ok) {
+ setImdbMsg(json.error?.message ?? "Mislukt");
+ return;
+ }
+ setImdbMsg(
+ `Gecontroleerd: ${json.checked ?? 0}, bijgewerkt: ${json.updated ?? 0}, nog open: ${json.skipped ?? 0}`
+ );
+ load();
+ }
+
async function dedupe() {
if (
!confirm(
@@ -161,6 +188,27 @@ export default function DashboardPage() {
{dedupeMsg}
)}
+
+ Cinemeta-koppeling (IMDb)
+
+
+ Films met IMDb-ID: {data.moviesWithImdbStreams ?? "?"} · nog zonder IMDb:{" "}
+ {data.moviesMissingImdb ?? "?"}. Zonder IMDb tonen Cinemeta-detailpagina's
+ geen lokale bronnen.
+
+ IMDb-IDs backfillen
+ {imdbMsg && (
+
+ {imdbMsg}
+
+ )}
>
)}
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts
index 175dc40..c76536e 100644
--- a/apps/master-api/src/admin/routes.ts
+++ b/apps/master-api/src/admin/routes.ts
@@ -27,6 +27,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableSeries,
mediaFileCount,
availableFileCount,
+ moviesMissingImdb,
+ moviesWithImdbStreams,
] = await Promise.all([
prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
prisma.node.count({ where: { status: "OFFLINE", revoked: false } }),
@@ -50,6 +52,12 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}),
prisma.mediaFile.count(),
prisma.mediaFile.count({ where: { available: true } }),
+ prisma.movie.count({
+ where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
+ }),
+ prisma.movie.count({
+ where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
+ }),
]);
const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
@@ -69,6 +77,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableSeries,
mediaFiles: mediaFileCount,
availableFiles: availableFileCount,
+ moviesMissingImdb,
+ moviesWithImdbStreams,
scannedFiles,
activeStreams,
currentBandwidth: bandwidth.toString(),
@@ -434,6 +444,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result;
});
+ app.post("/api/v1/admin/library/backfill-imdb", { preHandler: requireAdmin }, async () => {
+ const result = await metadata.backfillMovieImdbIds({ delayMs: 150, limit: 500 });
+ return result;
+ });
+
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
title: string;
diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts
index 006c45e..7c590f4 100644
--- a/apps/master-api/src/app.ts
+++ b/apps/master-api/src/app.ts
@@ -11,6 +11,7 @@ import { registerStremioRoutes } from "./stremio/routes";
import { registerAdminRoutes } from "./admin/routes";
import { registerInstallRoutes } from "./install/routes";
import { nodeConnectionManager } from "./websocket/manager";
+import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors";
async function main() {
@@ -124,6 +125,23 @@ async function main() {
await app.listen({ port: config.PORT, host: config.HOST });
console.log(`Master API listening on ${config.HOST}:${config.PORT}`);
+
+ // Background: fill missing movie imdbIds so Cinemeta detail pages can resolve local streams.
+ if (config.TMDB_API_KEY) {
+ const metadata = new MetadataService(config.TMDB_API_KEY);
+ setTimeout(() => {
+ void metadata
+ .backfillMovieImdbIds({ delayMs: 200, limit: 200 })
+ .then((r) => {
+ if (r.checked > 0) {
+ console.log(
+ `IMDb backfill: checked=${r.checked} updated=${r.updated} remaining=${r.skipped}`
+ );
+ }
+ })
+ .catch((err) => console.warn("IMDb backfill failed:", err));
+ }, 5000);
+ }
}
main().catch((err) => {
diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts
index 680cfbf..92d7f4c 100644
--- a/apps/master-api/src/metadata/service.ts
+++ b/apps/master-api/src/metadata/service.ts
@@ -642,15 +642,74 @@ export class MetadataService {
}
/** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */
- async ensureMovieImdbId(movieId: string): Promise {
- if (!this.tmdbApiKey) return;
+ async ensureMovieImdbId(movieId: string): Promise {
+ if (!this.tmdbApiKey) return false;
const movie = await prisma.movie.findUnique({ where: { id: movieId } });
- if (!movie?.tmdbId || movie.imdbId) return;
+ if (!movie?.tmdbId || movie.imdbId) return false;
const imdbId = await this.fetchTmdbMovieImdbId(movie.tmdbId);
- if (!imdbId) return;
- await prisma.movie
- .update({ where: { id: movieId }, data: { imdbId } })
- .catch(() => undefined);
+ if (!imdbId) return false;
+ try {
+ await prisma.movie.update({ where: { id: movieId }, data: { imdbId } });
+ return true;
+ } catch {
+ // unique conflict on imdbId — another row already owns it; merge files over
+ const owner = await prisma.movie.findFirst({ where: { imdbId } });
+ if (owner && owner.id !== movieId) {
+ await prisma.mediaFile.updateMany({
+ where: { movieId },
+ data: { movieId: owner.id },
+ });
+ await prisma.movie.delete({ where: { id: movieId } }).catch(() => undefined);
+ }
+ return false;
+ }
+ }
+
+ /**
+ * Fill missing imdbId for all movies that have a TMDB id.
+ * Rate-limited to avoid TMDB bans. Safe for series (movies only).
+ */
+ async backfillMovieImdbIds(options?: {
+ delayMs?: number;
+ limit?: number;
+ }): Promise<{ checked: number; updated: number; skipped: number; failed: number }> {
+ const delayMs = options?.delayMs ?? 200;
+ const limit = options?.limit ?? 500;
+ if (!this.tmdbApiKey) {
+ return { checked: 0, updated: 0, skipped: 0, failed: 0 };
+ }
+
+ const pending = await prisma.movie.findMany({
+ where: { tmdbId: { not: null }, imdbId: null },
+ select: { id: true, title: true, tmdbId: true },
+ take: limit,
+ orderBy: { updatedAt: "asc" },
+ });
+
+ let updated = 0;
+ let failed = 0;
+ for (const row of pending) {
+ try {
+ const ok = await this.ensureMovieImdbId(row.id);
+ if (ok) updated += 1;
+ else failed += 1;
+ } catch {
+ failed += 1;
+ }
+ if (delayMs > 0) {
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+ }
+
+ const stillMissing = await prisma.movie.count({
+ where: { tmdbId: { not: null }, imdbId: null },
+ });
+ return {
+ checked: pending.length,
+ updated,
+ skipped: stillMissing,
+ failed,
+ };
}
private async absorbOrphanMovies(
diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts
index 259698f..4d59c50 100644
--- a/apps/master-api/src/stremio/routes.ts
+++ b/apps/master-api/src/stremio/routes.ts
@@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = {
id: "com.mediacluster.library",
- version: "1.4.2",
+ version: "1.4.3",
name: "Media Cluster",
description: "Private distributed media library",
catalogs: [
@@ -577,10 +577,11 @@ async function getStreams(
) {
if (type === "movie") {
const localUuid = unwrapLocalId(id, "movie");
+ const isImdb = /^tt\d+$/i.test(id);
let movie = await prisma.movie.findFirst({
where: {
OR: [
- ...(id.startsWith("tt") ? [{ imdbId: id }] : []),
+ ...(isImdb ? [{ imdbId: id }] : []),
{ id: localUuid },
{ id },
],
@@ -591,8 +592,19 @@ async function getStreams(
},
},
});
+
if (!movie) {
- log?.warn?.({ id, localUuid }, "movie stream: not found");
+ const missingImdb = await prisma.movie.count({
+ where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
+ });
+ log?.warn?.(
+ { id, localUuid, isImdb, missingImdb },
+ "movie stream: not found (Cinemeta tt needs matching imdbId in library)"
+ );
+ // Do not block this request — fill IMDb ids in background for next try.
+ if (isImdb && missingImdb > 0) {
+ void metadata.backfillMovieImdbIds({ delayMs: 150, limit: 80 }).catch(() => undefined);
+ }
return [];
}
@@ -607,6 +619,10 @@ async function getStreams(
}
if (!movie) return [];
+ if (!movie.imdbId && movie.tmdbId) {
+ void metadata.ensureMovieImdbId(movie.id).catch(() => undefined);
+ }
+
const files = movie.mediaFiles.filter((f) => f.available);
const pool = files.length > 0 ? files : movie.mediaFiles;
@@ -632,7 +648,13 @@ async function getStreams(
}
}
log?.info?.(
- { movieId: movie.id, requestId: id, streamCount: streams.length, fileCount: pool.length },
+ {
+ movieId: movie.id,
+ imdbId: movie.imdbId,
+ requestId: id,
+ streamCount: streams.length,
+ fileCount: pool.length,
+ },
"movie streams"
);
return streams;