Backfill movie IMDb IDs so Cinemeta detail pages resolve local streams.

Admin button + startup job; log tt stream misses without touching series meta.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 11:35:30 +02:00
parent 82bf5f5844
commit 5e11811dea
5 changed files with 173 additions and 11 deletions

View file

@ -15,6 +15,8 @@ interface Dashboard {
availableSeries?: number; availableSeries?: number;
mediaFiles?: number; mediaFiles?: number;
availableFiles?: number; availableFiles?: number;
moviesMissingImdb?: number;
moviesWithImdbStreams?: number;
scannedFiles?: number; scannedFiles?: number;
activeStreams: number; activeStreams: number;
currentBandwidth: string; currentBandwidth: string;
@ -44,6 +46,7 @@ export default function DashboardPage() {
const [data, setData] = useState<Dashboard | null>(null); const [data, setData] = useState<Dashboard | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [dedupeMsg, setDedupeMsg] = useState(""); const [dedupeMsg, setDedupeMsg] = useState("");
const [imdbMsg, setImdbMsg] = useState("");
const load = useCallback(() => { const load = useCallback(() => {
fetch("/api/v1/admin/dashboard", { credentials: "include" }) fetch("/api/v1/admin/dashboard", { credentials: "include" })
@ -64,6 +67,30 @@ export default function DashboardPage() {
load(); load();
}, [load]); }, [load]);
async function backfillImdb() {
if (
!confirm(
"IMDb-IDs ophalen via TMDB voor films die die nog missen? Nodig zodat Cinemeta jouw lokale bronnen toont."
)
) {
return;
}
setImdbMsg("Bezig…");
const res = await fetch("/api/v1/admin/library/backfill-imdb", {
method: "POST",
credentials: "include",
});
const json = await res.json();
if (!res.ok) {
setImdbMsg(json.error?.message ?? "Mislukt");
return;
}
setImdbMsg(
`Gecontroleerd: ${json.checked ?? 0}, bijgewerkt: ${json.updated ?? 0}, nog open: ${json.skipped ?? 0}`
);
load();
}
async function dedupe() { async function dedupe() {
if ( if (
!confirm( !confirm(
@ -161,6 +188,27 @@ export default function DashboardPage() {
{dedupeMsg} {dedupeMsg}
</p> </p>
)} )}
<h2
style={{
fontSize: "1rem",
color: "#fafafa",
textTransform: "none",
marginTop: "1.5rem",
}}
>
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&apos;s
geen lokale bronnen.
</p>
<button onClick={backfillImdb}>IMDb-IDs backfillen</button>
{imdbMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.875rem", marginTop: "0.75rem" }}>
{imdbMsg}
</p>
)}
</div> </div>
</> </>
)} )}

View file

@ -27,6 +27,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableSeries, availableSeries,
mediaFileCount, mediaFileCount,
availableFileCount, availableFileCount,
moviesMissingImdb,
moviesWithImdbStreams,
] = 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 } }),
@ -50,6 +52,12 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
}), }),
prisma.mediaFile.count(), prisma.mediaFile.count(),
prisma.mediaFile.count({ where: { available: true } }), prisma.mediaFile.count({ where: { available: true } }),
prisma.movie.count({
where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
}),
prisma.movie.count({
where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } },
}),
]); ]);
const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n); const totalStorage = nodes.reduce((sum, n) => sum + n.totalStorage, 0n);
@ -69,6 +77,8 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
availableSeries, availableSeries,
mediaFiles: mediaFileCount, mediaFiles: mediaFileCount,
availableFiles: availableFileCount, availableFiles: availableFileCount,
moviesMissingImdb,
moviesWithImdbStreams,
scannedFiles, scannedFiles,
activeStreams, activeStreams,
currentBandwidth: bandwidth.toString(), currentBandwidth: bandwidth.toString(),
@ -434,6 +444,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return result; return result;
}); });
app.post("/api/v1/admin/library/backfill-imdb", { preHandler: requireAdmin }, async () => {
const result = await metadata.backfillMovieImdbIds({ delayMs: 150, limit: 500 });
return result;
});
app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/movies", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { const body = request.body as {
title: string; title: string;

View file

@ -11,6 +11,7 @@ import { registerStremioRoutes } from "./stremio/routes";
import { registerAdminRoutes } from "./admin/routes"; import { registerAdminRoutes } from "./admin/routes";
import { registerInstallRoutes } from "./install/routes"; import { registerInstallRoutes } from "./install/routes";
import { nodeConnectionManager } from "./websocket/manager"; import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service";
import { toErrorResponse } from "./security/errors"; import { toErrorResponse } from "./security/errors";
async function main() { async function main() {
@ -124,6 +125,23 @@ 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 missing movie imdbIds so Cinemeta detail pages can resolve local streams.
if (config.TMDB_API_KEY) {
const metadata = new MetadataService(config.TMDB_API_KEY);
setTimeout(() => {
void metadata
.backfillMovieImdbIds({ delayMs: 200, limit: 200 })
.then((r) => {
if (r.checked > 0) {
console.log(
`IMDb backfill: checked=${r.checked} updated=${r.updated} remaining=${r.skipped}`
);
}
})
.catch((err) => console.warn("IMDb backfill failed:", err));
}, 5000);
}
} }
main().catch((err) => { main().catch((err) => {

View file

@ -642,15 +642,74 @@ export class MetadataService {
} }
/** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */ /** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */
async ensureMovieImdbId(movieId: string): Promise<void> { async ensureMovieImdbId(movieId: string): Promise<boolean> {
if (!this.tmdbApiKey) return; if (!this.tmdbApiKey) return false;
const movie = await prisma.movie.findUnique({ where: { id: movieId } }); const movie = await prisma.movie.findUnique({ where: { id: movieId } });
if (!movie?.tmdbId || movie.imdbId) return; if (!movie?.tmdbId || movie.imdbId) return false;
const imdbId = await this.fetchTmdbMovieImdbId(movie.tmdbId); const imdbId = await this.fetchTmdbMovieImdbId(movie.tmdbId);
if (!imdbId) return; if (!imdbId) return false;
await prisma.movie try {
.update({ where: { id: movieId }, data: { imdbId } }) await prisma.movie.update({ where: { id: movieId }, data: { imdbId } });
.catch(() => undefined); return true;
} catch {
// unique conflict on imdbId — another row already owns it; merge files over
const owner = await prisma.movie.findFirst({ where: { imdbId } });
if (owner && owner.id !== movieId) {
await prisma.mediaFile.updateMany({
where: { movieId },
data: { movieId: owner.id },
});
await prisma.movie.delete({ where: { id: movieId } }).catch(() => undefined);
}
return false;
}
}
/**
* Fill missing imdbId for all movies that have a TMDB id.
* Rate-limited to avoid TMDB bans. Safe for series (movies only).
*/
async backfillMovieImdbIds(options?: {
delayMs?: number;
limit?: number;
}): Promise<{ checked: number; updated: number; skipped: number; failed: number }> {
const delayMs = options?.delayMs ?? 200;
const limit = options?.limit ?? 500;
if (!this.tmdbApiKey) {
return { checked: 0, updated: 0, skipped: 0, failed: 0 };
}
const pending = await prisma.movie.findMany({
where: { tmdbId: { not: null }, imdbId: null },
select: { id: true, title: true, tmdbId: true },
take: limit,
orderBy: { updatedAt: "asc" },
});
let updated = 0;
let failed = 0;
for (const row of pending) {
try {
const ok = await this.ensureMovieImdbId(row.id);
if (ok) updated += 1;
else failed += 1;
} catch {
failed += 1;
}
if (delayMs > 0) {
await new Promise((r) => setTimeout(r, delayMs));
}
}
const stillMissing = await prisma.movie.count({
where: { tmdbId: { not: null }, imdbId: null },
});
return {
checked: pending.length,
updated,
skipped: stillMissing,
failed,
};
} }
private async absorbOrphanMovies( private async absorbOrphanMovies(

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.2", version: "1.4.3",
name: "Media Cluster", name: "Media Cluster",
description: "Private distributed media library", description: "Private distributed media library",
catalogs: [ catalogs: [
@ -577,10 +577,11 @@ async function getStreams(
) { ) {
if (type === "movie") { if (type === "movie") {
const localUuid = unwrapLocalId(id, "movie"); const localUuid = unwrapLocalId(id, "movie");
const isImdb = /^tt\d+$/i.test(id);
let movie = await prisma.movie.findFirst({ let movie = await prisma.movie.findFirst({
where: { where: {
OR: [ OR: [
...(id.startsWith("tt") ? [{ imdbId: id }] : []), ...(isImdb ? [{ imdbId: id }] : []),
{ id: localUuid }, { id: localUuid },
{ id }, { id },
], ],
@ -591,8 +592,19 @@ async function getStreams(
}, },
}, },
}); });
if (!movie) { if (!movie) {
log?.warn?.({ id, localUuid }, "movie stream: not found"); const missingImdb = await prisma.movie.count({
where: { imdbId: null, tmdbId: { not: null }, mediaFiles: { some: {} } },
});
log?.warn?.(
{ id, localUuid, isImdb, missingImdb },
"movie stream: not found (Cinemeta tt needs matching imdbId in library)"
);
// Do not block this request — fill IMDb ids in background for next try.
if (isImdb && missingImdb > 0) {
void metadata.backfillMovieImdbIds({ delayMs: 150, limit: 80 }).catch(() => undefined);
}
return []; return [];
} }
@ -607,6 +619,10 @@ async function getStreams(
} }
if (!movie) return []; if (!movie) return [];
if (!movie.imdbId && movie.tmdbId) {
void metadata.ensureMovieImdbId(movie.id).catch(() => undefined);
}
const files = movie.mediaFiles.filter((f) => f.available); const files = movie.mediaFiles.filter((f) => f.available);
const pool = files.length > 0 ? files : movie.mediaFiles; const pool = files.length > 0 ? files : movie.mediaFiles;
@ -632,7 +648,13 @@ async function getStreams(
} }
} }
log?.info?.( log?.info?.(
{ movieId: movie.id, requestId: id, streamCount: streams.length, fileCount: pool.length }, {
movieId: movie.id,
imdbId: movie.imdbId,
requestId: id,
streamCount: streams.length,
fileCount: pool.length,
},
"movie streams" "movie streams"
); );
return streams; return streams;