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.
IMDb-IDs backfillen
{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;
}