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 <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 11:48:46 +02:00
parent fc842aa145
commit 1da247d2fb
5 changed files with 158 additions and 17 deletions

View file

@ -17,6 +17,8 @@ interface Dashboard {
availableFiles?: number; availableFiles?: number;
moviesMissingImdb?: number; moviesMissingImdb?: number;
moviesWithImdbStreams?: number; moviesWithImdbStreams?: number;
seriesMissingImdb?: number;
seriesWithImdb?: number;
scannedFiles?: number; scannedFiles?: number;
activeStreams: number; activeStreams: number;
currentBandwidth: string; currentBandwidth: string;
@ -70,7 +72,7 @@ export default function DashboardPage() {
async function backfillImdb() { async function backfillImdb() {
if ( if (
!confirm( !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; return;
@ -85,8 +87,10 @@ export default function DashboardPage() {
setImdbMsg(json.error?.message ?? "Mislukt"); setImdbMsg(json.error?.message ?? "Mislukt");
return; return;
} }
const movies = json.movies ?? json;
const series = json.series ?? {};
setImdbMsg( 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(); load();
} }
@ -199,9 +203,12 @@ export default function DashboardPage() {
Cinemeta-koppeling (IMDb) Cinemeta-koppeling (IMDb)
</h2> </h2>
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}> <p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
Films met IMDb-ID: {data.moviesWithImdbStreams ?? "?"} · nog zonder IMDb:{" "} Films met IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "}
{data.moviesMissingImdb ?? "?"}. Zonder IMDb tonen Cinemeta-detailpagina&apos;s {data.moviesMissingImdb ?? "?"}
geen lokale bronnen. <br />
Series met IMDb: {data.seriesWithImdb ?? "?"} · zonder:{" "}
{data.seriesMissingImdb ?? "?"}. Zonder IMDb tonen Cinemeta-pagina&apos;s geen
lokale bronnen. Onze eigen series-catalogus blijft op privé-IDs.
</p> </p>
<button onClick={backfillImdb}>IMDb-IDs backfillen</button> <button onClick={backfillImdb}>IMDb-IDs backfillen</button>
{imdbMsg && ( {imdbMsg && (

View file

@ -29,6 +29,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFileCount, availableFileCount,
moviesMissingImdb, moviesMissingImdb,
moviesWithImdbStreams, moviesWithImdbStreams,
seriesMissingImdb,
seriesWithImdb,
] = await Promise.all([ ] = await Promise.all([
prisma.node.count({ where: { status: "ONLINE", revoked: false } }), prisma.node.count({ where: { status: "ONLINE", revoked: false } }),
prisma.node.count({ where: { status: "OFFLINE", 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({ prisma.movie.count({
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } }, 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); const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
@ -79,6 +87,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableFiles: availableFileCount, availableFiles: availableFileCount,
moviesMissingImdb, moviesMissingImdb,
moviesWithImdbStreams, moviesWithImdbStreams,
seriesMissingImdb,
seriesWithImdb,
scannedFiles, scannedFiles,
activeStreams, activeStreams,
currentBandwidth: bandwidth.toString(), 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 () => { app.post("/api/v1/admin/library/backfill-imdb", { preHandler: requireAdmin }, async () => {
// Process ALL pending movies (batched). Can take a few minutes for large libraries. // Process ALL pending movies + series (batched). Meta for series stays mcseries-*.
const result = await metadata.backfillMovieImdbIds({ delayMs: 120 }); const result = await metadata.backfillAllImdbIds({ delayMs: 120 });
return result; return result;
}); });

View file

@ -126,16 +126,17 @@ async function main() {
await app.listen({ port: config.PORT, host: config.HOST }); await app.listen({ port: config.PORT, host: config.HOST });
console.log(`Master API listening on ${config.HOST}:${config.PORT}`); 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) { if (config.TMDB_API_KEY) {
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = new MetadataService(config.TMDB_API_KEY);
setTimeout(() => { setTimeout(() => {
void metadata void metadata
.backfillMovieImdbIds({ delayMs: 120 }) .backfillAllImdbIds({ delayMs: 120 })
.then((r) => { .then((r) => {
if (r.checked > 0) { if (r.movies.checked > 0 || r.series.checked > 0) {
console.log( 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}`
); );
} }
}) })

View file

@ -720,6 +720,85 @@ export class MetadataService {
return { checked, updated, remaining, failed }; return { checked, updated, remaining, failed };
} }
/** Backfill IMDb id for a series from TMDB (streams via Cinemeta tt:S:E). */
async ensureSeriesImdbId(seriesId: string): Promise<boolean> {
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( private async absorbOrphanMovies(
canonical: MovieRow, canonical: MovieRow,
localTitle: string, localTitle: string,

View file

@ -18,7 +18,7 @@ const SKIP_EXTRA = [{ name: "skip", isRequired: false }];
const MANIFEST = { const MANIFEST = {
id: "com.mediacluster.library", id: "com.mediacluster.library",
version: "1.4.3", version: "1.4.4",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -667,16 +667,43 @@ async function getStreams(
const [, seriesKey, seasonStr, episodeStr] = match; const [, seriesKey, seasonStr, episodeStr] = match;
const seasonNumber = parseInt(seasonStr, 10); const seasonNumber = parseInt(seasonStr, 10);
const episodeNumber = parseInt(episodeStr, 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: { where: {
OR: [ OR: [
...(isImdb ? [{ imdbId: seriesKey }] : []),
{ id: unwrapLocalId(seriesKey, "series") }, { id: unwrapLocalId(seriesKey, "series") },
{ id: seriesKey }, { 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({ const episode = await prisma.episode.findUnique({
where: { where: {
@ -688,15 +715,23 @@ async function getStreams(
}, },
include: { include: {
mediaFiles: { mediaFiles: {
where: { available: true },
include: { node: 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 = []; const streams = [];
for (const file of episode.mediaFiles) { for (const file of pool) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { try {
const session = await playback.createSession(file.id, userId); 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?.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; return streams;
} }