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:
parent
82bf5f5844
commit
5e11811dea
5 changed files with 173 additions and 11 deletions
|
|
@ -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'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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue