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:
parent
fc842aa145
commit
1da247d2fb
5 changed files with 158 additions and 17 deletions
|
|
@ -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)
|
||||
</h2>
|
||||
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", margin: "0.75rem 0" }}>
|
||||
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 ?? "?"}
|
||||
<br />
|
||||
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.
|
||||
</p>
|
||||
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
|
||||
{imdbMsg && (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
);
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
canonical: MovieRow,
|
||||
localTitle: string,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue