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;
|
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's
|
{data.moviesMissingImdb ?? "?"}
|
||||||
geen lokale bronnen.
|
<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>
|
</p>
|
||||||
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
|
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
|
||||||
{imdbMsg && (
|
{imdbMsg && (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue