From 1da247d2fb1d7b49d089dbc7059a05f4c73c2cbc Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 25 Aug 2026 11:48:46 +0200 Subject: [PATCH] Backfill series IMDb IDs and resolve Cinemeta tt:S:E streams locally. Catalog/meta stay on mcseries; tt is streams-only so episode lists stay intact. Co-authored-by: Cursor --- apps/admin-ui/src/app/dashboard/page.tsx | 17 +++-- apps/master-api/src/admin/routes.ts | 14 ++++- apps/master-api/src/app.ts | 9 +-- apps/master-api/src/metadata/service.ts | 79 ++++++++++++++++++++++++ apps/master-api/src/stremio/routes.ts | 56 +++++++++++++++-- 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx index fdc4254..6e41ce2 100644 --- a/apps/admin-ui/src/app/dashboard/page.tsx +++ b/apps/admin-ui/src/app/dashboard/page.tsx @@ -17,6 +17,8 @@ interface Dashboard { availableFiles?: number; moviesMissingImdb?: number; moviesWithImdbStreams?: number; + seriesMissingImdb?: number; + seriesWithImdb?: number; scannedFiles?: number; activeStreams: number; currentBandwidth: string; @@ -70,7 +72,7 @@ export default function DashboardPage() { async function backfillImdb() { if ( !confirm( - "IMDb-IDs ophalen via TMDB voor ALLE films die die nog missen? Dit kan enkele minuten duren bij een grote bibliotheek. Nodig zodat Cinemeta jouw lokale bronnen toont." + "IMDb-IDs ophalen via TMDB voor ALLE films én series die die nog missen? Dit kan enkele minuten duren. Series-afleveringenlijst blijft op privé-IDs; IMDb is alleen voor Cinemeta-bronnen." ) ) { return; @@ -85,8 +87,10 @@ export default function DashboardPage() { setImdbMsg(json.error?.message ?? "Mislukt"); return; } + const movies = json.movies ?? json; + const series = json.series ?? {}; setImdbMsg( - `Gecontroleerd: ${json.checked ?? 0}, bijgewerkt: ${json.updated ?? 0}, nog open: ${json.remaining ?? json.skipped ?? 0}, mislukt: ${json.failed ?? 0}` + `Films: gecontroleerd ${movies.checked ?? 0}, bijgewerkt ${movies.updated ?? 0}, open ${movies.remaining ?? 0} · Series: gecontroleerd ${series.checked ?? 0}, bijgewerkt ${series.updated ?? 0}, open ${series.remaining ?? 0}` ); load(); } @@ -199,9 +203,12 @@ export default function DashboardPage() { Cinemeta-koppeling (IMDb)

- Films met IMDb-ID: {data.moviesWithImdbStreams ?? "?"} · nog zonder IMDb:{" "} - {data.moviesMissingImdb ?? "?"}. Zonder IMDb tonen Cinemeta-detailpagina's - geen lokale bronnen. + Films met IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "} + {data.moviesMissingImdb ?? "?"} +
+ Series met IMDb: {data.seriesWithImdb ?? "?"} · zonder:{" "} + {data.seriesMissingImdb ?? "?"}. Zonder IMDb tonen Cinemeta-pagina's geen + lokale bronnen. Onze eigen series-catalogus blijft op privé-IDs.

{imdbMsg && ( diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index ef8e11e..13c9ced 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -29,6 +29,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) availableFileCount, moviesMissingImdb, moviesWithImdbStreams, + seriesMissingImdb, + seriesWithImdb, ] = await Promise.all([ prisma.node.count({ where: { status: "ONLINE", revoked: false } }), prisma.node.count({ where: { status: "OFFLINE", revoked: false } }), @@ -58,6 +60,12 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) prisma.movie.count({ where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } }, }), + prisma.series.count({ + where: { imdbId: null, tmdbId: { not: null } }, + }), + prisma.series.count({ + where: { imdbId: { not: null } }, + }), ]); const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n); @@ -79,6 +87,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) availableFiles: availableFileCount, moviesMissingImdb, moviesWithImdbStreams, + seriesMissingImdb, + seriesWithImdb, scannedFiles, activeStreams, currentBandwidth: bandwidth.toString(), @@ -445,8 +455,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) }); app.post("/api/v1/admin/library/backfill-imdb", { preHandler: requireAdmin }, async () => { - // Process ALL pending movies (batched). Can take a few minutes for large libraries. - const result = await metadata.backfillMovieImdbIds({ delayMs: 120 }); + // Process ALL pending movies + series (batched). Meta for series stays mcseries-*. + const result = await metadata.backfillAllImdbIds({ delayMs: 120 }); return result; }); diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index 8938fff..b2d44b0 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -126,16 +126,17 @@ async function main() { await app.listen({ port: config.PORT, host: config.HOST }); console.log(`Master API listening on ${config.HOST}:${config.PORT}`); - // Background: fill ALL missing movie imdbIds (batched) so Cinemeta can resolve local streams. + // Background: fill ALL missing movie + series imdbIds so Cinemeta can resolve local streams. + // Series catalog/meta stay on mcseries-* (tt only used for streams). if (config.TMDB_API_KEY) { const metadata = new MetadataService(config.TMDB_API_KEY); setTimeout(() => { void metadata - .backfillMovieImdbIds({ delayMs: 120 }) + .backfillAllImdbIds({ delayMs: 120 }) .then((r) => { - if (r.checked > 0) { + if (r.movies.checked > 0 || r.series.checked > 0) { console.log( - `IMDb backfill: checked=${r.checked} updated=${r.updated} remaining=${r.remaining} failed=${r.failed}` + `IMDb backfill movies: checked=${r.movies.checked} updated=${r.movies.updated} remaining=${r.movies.remaining}; series: checked=${r.series.checked} updated=${r.series.updated} remaining=${r.series.remaining}` ); } }) diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index ba6881f..aa851e2 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -720,6 +720,85 @@ export class MetadataService { return { checked, updated, remaining, failed }; } + /** Backfill IMDb id for a series from TMDB (streams via Cinemeta tt:S:E). */ + async ensureSeriesImdbId(seriesId: string): Promise { + if (!this.tmdbApiKey) return false; + const series = await prisma.series.findUnique({ where: { id: seriesId } }); + if (!series?.tmdbId || series.imdbId) return false; + const imdbId = await this.fetchTmdbSeriesImdbId(series.tmdbId); + if (!imdbId) return false; + try { + await prisma.series.update({ where: { id: seriesId }, data: { imdbId } }); + return true; + } catch { + const owner = await prisma.series.findFirst({ where: { imdbId } }); + if (owner && owner.id !== seriesId) { + await this.mergeSeriesInto(owner.id, seriesId); + } + return false; + } + } + + async backfillSeriesImdbIds(options?: { + delayMs?: number; + limit?: number; + batchSize?: number; + }): Promise<{ checked: number; updated: number; remaining: number; failed: number }> { + const delayMs = options?.delayMs ?? 150; + const batchSize = Math.min(Math.max(options?.batchSize ?? 100, 1), 500); + const maxTotal = options?.limit && options.limit > 0 ? options.limit : Number.POSITIVE_INFINITY; + if (!this.tmdbApiKey) { + return { checked: 0, updated: 0, remaining: 0, failed: 0 }; + } + + let checked = 0; + let updated = 0; + let failed = 0; + + while (checked < maxTotal) { + const take = Math.min(batchSize, maxTotal - checked); + const pending = await prisma.series.findMany({ + where: { tmdbId: { not: null }, imdbId: null }, + select: { id: true }, + take, + orderBy: { updatedAt: "asc" }, + }); + if (pending.length === 0) break; + + for (const row of pending) { + try { + const ok = await this.ensureSeriesImdbId(row.id); + if (ok) updated += 1; + else failed += 1; + } catch { + failed += 1; + } + checked += 1; + if (delayMs > 0) { + await new Promise((r) => setTimeout(r, delayMs)); + } + } + + if (pending.length < take) break; + } + + const remaining = await prisma.series.count({ + where: { tmdbId: { not: null }, imdbId: null }, + }); + return { checked, updated, remaining, failed }; + } + + /** Backfill IMDb for movies and series (catalog/meta for series stays private mcseries-). */ + async backfillAllImdbIds(options?: { delayMs?: number }): Promise<{ + movies: { checked: number; updated: number; remaining: number; failed: number }; + series: { checked: number; updated: number; remaining: number; failed: number }; + }> { + const delayMs = options?.delayMs ?? 120; + const movies = await this.backfillMovieImdbIds({ delayMs }); + const series = await this.backfillSeriesImdbIds({ delayMs }); + return { movies, series }; + } + private async absorbOrphanMovies( canonical: MovieRow, localTitle: string, diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index dcd7771..c8142a4 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.3", + version: "1.4.4", name: "Media Cluster", description: "Private distributed media library", catalogs: [ @@ -667,16 +667,43 @@ async function getStreams( const [, seriesKey, seasonStr, episodeStr] = match; const seasonNumber = parseInt(seasonStr, 10); const episodeNumber = parseInt(episodeStr, 10); + const isImdb = /^tt\d+$/i.test(seriesKey); - const series = await prisma.series.findFirst({ + let series = await prisma.series.findFirst({ where: { OR: [ + ...(isImdb ? [{ imdbId: seriesKey }] : []), { id: unwrapLocalId(seriesKey, "series") }, { id: seriesKey }, ], }, }); - if (!series) return []; + + if (!series) { + const missingImdb = await prisma.series.count({ + where: { imdbId: null, tmdbId: { not: null } }, + }); + log?.warn?.( + { id, seriesKey, isImdb, missingImdb }, + "series stream: not found (Cinemeta tt needs matching imdbId in library)" + ); + if (isImdb && missingImdb > 0) { + void metadata.backfillSeriesImdbIds({ delayMs: 120, limit: 40 }).catch(() => undefined); + } + return []; + } + + try { + await metadata.consolidateSeries(series.id); + const refreshed = await prisma.series.findUnique({ where: { id: series.id } }); + if (refreshed) series = refreshed; + } catch (err) { + log?.warn?.({ err, seriesId: series.id }, "series stream: consolidate failed"); + } + + if (!series.imdbId && series.tmdbId) { + void metadata.ensureSeriesImdbId(series.id).catch(() => undefined); + } const episode = await prisma.episode.findUnique({ where: { @@ -688,15 +715,23 @@ async function getStreams( }, include: { mediaFiles: { - where: { available: true }, include: { node: true }, }, }, }); - if (!episode) return []; + if (!episode) { + log?.warn?.( + { seriesId: series.id, imdbId: series.imdbId, seasonNumber, episodeNumber }, + "series stream: episode not in library" + ); + return []; + } + + const files = episode.mediaFiles.filter((f) => f.available); + const pool = files.length > 0 ? files : episode.mediaFiles; const streams = []; - for (const file of episode.mediaFiles) { + for (const file of pool) { if (file.node.status !== "ONLINE" || file.node.revoked) continue; try { const session = await playback.createSession(file.id, userId); @@ -716,6 +751,15 @@ async function getStreams( log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed"); } } + log?.info?.( + { + seriesId: series.id, + imdbId: series.imdbId, + requestId: id, + streamCount: streams.length, + }, + "series streams" + ); return streams; }