From 5e11811deab1046daafb00af3cd1c3940c88dbdc Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 11:35:30 +0200 Subject: [PATCH] Backfill movie IMDb IDs so Cinemeta detail pages resolve local streams. Admin button + startup job; log tt stream misses without touching series meta. Co-authored-by: Cursor --- apps/admin-ui/src/app/dashboard/page.tsx | 48 ++++++++++++++++ apps/master-api/src/admin/routes.ts | 15 +++++ apps/master-api/src/app.ts | 18 ++++++ apps/master-api/src/metadata/service.ts | 73 +++++++++++++++++++++--- apps/master-api/src/stremio/routes.ts | 30 ++++++++-- 5 files changed, 173 insertions(+), 11 deletions(-) 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. +

+ + {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;