diff --git a/apps/admin-ui/src/app/dashboard/page.tsx b/apps/admin-ui/src/app/dashboard/page.tsx index 08ae0e5..12283e8 100644 --- a/apps/admin-ui/src/app/dashboard/page.tsx +++ b/apps/admin-ui/src/app/dashboard/page.tsx @@ -17,6 +17,7 @@ interface Dashboard { availableFiles?: number; moviesMissingImdb?: number; moviesWithImdbStreams?: number; + moviesMissingTmdb?: number; seriesMissingImdb?: number; seriesWithImdb?: number; scannedFiles?: number; @@ -53,6 +54,8 @@ export default function DashboardPage() { const [repairBusy, setRepairBusy] = useState(false); const [dedupeBusy, setDedupeBusy] = useState(false); const [imdbBusy, setImdbBusy] = useState(false); + const [rematchBusy, setRematchBusy] = useState(false); + const [rematchMsg, setRematchMsg] = useState(""); const load = useCallback(() => { fetch("/api/v1/admin/dashboard", { credentials: "include" }) @@ -76,7 +79,7 @@ export default function DashboardPage() { async function repairLibrary() { if ( !confirm( - "Bibliotheek herstellen na pad-migratie?\n\n• Duplicaten samenvoegen naar IMDb/TMDB-records\n• Dode (unavailable) bestanden verwijderen\n• Dubbele file-rijen opruimen\n• Ontbrekende IMDb-IDs backfillen (max ~400 per run)\n\nDuurt meestal 1–3 minuten. Herhaal indien nodig." + "Bibliotheek herstellen?\n\n• Duplicaten samenvoegen\n• Dode bestanden verwijderen\n• TMDB opnieuw matchen voor gescande titels\n• IMDb-IDs backfillen\n\nDuurt 1–3 minuten." ) ) { return; @@ -94,10 +97,13 @@ export default function DashboardPage() { return; } const imdb = json.imdb ?? {}; + const rematch = json.rematch ?? {}; const imdbRemaining = (imdb.movies?.remaining ?? 0) + (imdb.series?.remaining ?? 0); + const rematchRemaining = + (rematch.movies?.remaining ?? 0) + (rematch.series?.remaining ?? 0); setRepairMsg( - `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${imdbRemaining > 0 ? ` · nog ${imdbRemaining} IMDb open (herhaal of gebruik backfill-knop)` : ""}. Herlaad daarna de Stremio-addon.` + `Klaar: ${json.moviesMerged ?? 0} film-merges, ${json.seriesMerged ?? 0} serie-merges, ${json.staleFilesRemoved ?? 0} dode files, ${json.duplicateFilesRemoved ?? 0} dubbele files · TMDB +${rematch.movies?.matched ?? 0} films, +${rematch.series?.matched ?? 0} series · IMDb +${imdb.movies?.updated ?? 0} films, +${imdb.series?.updated ?? 0} series${rematchRemaining > 0 ? ` · nog ${rematchRemaining} zonder TMDB` : ""}${imdbRemaining > 0 ? ` · nog ${imdbRemaining} zonder IMDb` : ""}.` ); load(); } catch (err) { @@ -133,7 +139,7 @@ export default function DashboardPage() { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ limit: 100 }), + body: JSON.stringify({ limit: 50 }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { @@ -163,8 +169,11 @@ export default function DashboardPage() { return; } if (remaining >= prevRemaining && updated === 0) { + const failed = (movies.failed ?? 0) + (series.failed ?? 0); setImdbMsg( - `Gestopt na batch ${batch}: films +${totalMoviesUpdated}, series +${totalSeriesUpdated} · nog ${remaining} zonder IMDb (niet beschikbaar op TMDB)` + failed > 0 + ? `TMDB rate limit of tijdelijke fout (${failed} mislukt in batch ${batch}). Wacht 1 minuut en probeer opnieuw. Nog ${remaining} open.` + : `Gestopt na batch ${batch}: nog ${remaining} zonder IMDb op TMDB (vaak nieuwe 2026-titels). Films +${totalMoviesUpdated}, series +${totalSeriesUpdated}.` ); load(); return; @@ -185,6 +194,61 @@ export default function DashboardPage() { } } + async function rematchMetadata() { + if ( + !confirm( + "Gescande titels zonder TMDB-metadata opnieuw matchen? Dit haalt posters/metadata op en helpt nieuwe films op de planken te tonen." + ) + ) { + return; + } + setRematchBusy(true); + setRematchMsg("Batch 1 bezig…"); + let batch = 0; + let totalMovies = 0; + let totalSeries = 0; + try { + while (batch < 100) { + batch += 1; + setRematchMsg(`Batch ${batch} bezig…`); + const res = await fetch("/api/v1/admin/library/rematch-metadata", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ limit: 50 }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + setRematchMsg(json.error?.message ?? "Mislukt"); + return; + } + const checked = (json.movies?.checked ?? 0) + (json.series?.checked ?? 0); + totalMovies += json.movies?.matched ?? 0; + totalSeries += json.series?.matched ?? 0; + const remaining = (json.movies?.remaining ?? 0) + (json.series?.remaining ?? 0); + if (remaining === 0) { + setRematchMsg(`Klaar: TMDB +${totalMovies} films, +${totalSeries} series`); + load(); + return; + } + if (checked === 0) { + setRematchMsg(`Klaar: +${totalMovies} films, +${totalSeries} series · nog ${remaining} niet gevonden op TMDB`); + load(); + return; + } + setRematchMsg( + `Batch ${batch}: +${json.movies?.matched ?? 0} films, +${json.series?.matched ?? 0} series · nog ${remaining} open…` + ); + } + setRematchMsg(`Batchlimiet bereikt: +${totalMovies} films, +${totalSeries} series — druk opnieuw om verder te gaan`); + load(); + } catch (err) { + setRematchMsg(err instanceof Error ? `Mislukt: ${err.message}` : "Mislukt"); + } finally { + setRematchBusy(false); + } + } + async function dedupe() { if ( !confirm( @@ -318,10 +382,21 @@ export default function DashboardPage() { {dedupeMsg &&

{dedupeMsg}

} +
+

TMDB-metadata (gescande titels)

+

+ Films met stream maar zonder TMDB: {data.moviesMissingTmdb ?? "?"} — nodig voor posters en + planken. +

+ + {rematchMsg &&

{rematchMsg}

} +

IMDb-backfill

- Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "} + Alleen voor titels die al TMDB hebben. Films IMDb: {data.moviesWithImdbStreams ?? "?"} · zonder:{" "} {data.moviesMissingImdb ?? "?"} · Series: {data.seriesWithImdb ?? "?"} / zonder{" "} {data.seriesMissingImdb ?? "?"}

diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts index a018f0f..3f9d8ca 100644 --- a/apps/master-api/src/admin/routes.ts +++ b/apps/master-api/src/admin/routes.ts @@ -29,6 +29,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) availableFileCount, moviesMissingImdb, moviesWithImdbStreams, + moviesMissingTmdb, seriesMissingImdb, seriesWithImdb, ] = await Promise.all([ @@ -71,6 +72,9 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) prisma.movie.count({ where: { imdbId: { not: null }, mediaFiles: { some: { available: true } } }, }), + prisma.movie.count({ + where: { tmdbId: null, mediaFiles: { some: { available: true } } }, + }), prisma.series.count({ where: { imdbId: null, tmdbId: { not: null } }, }), @@ -111,6 +115,7 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) availableFiles: availableFileCount, moviesMissingImdb, moviesWithImdbStreams, + moviesMissingTmdb, seriesMissingImdb, seriesWithImdb, scannedFiles, @@ -519,8 +524,18 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config) const limit = typeof body.limit === "number" && body.limit > 0 ? Math.min(Math.floor(body.limit), 500) + : 50; + const result = await metadata.backfillAllImdbIds({ delayMs: 200, limit }); + return result; + }); + + app.post("/api/v1/admin/library/rematch-metadata", { preHandler: requireAdmin }, async (request) => { + const body = (request.body ?? {}) as { limit?: number }; + const limit = + typeof body.limit === "number" && body.limit > 0 + ? Math.min(Math.floor(body.limit), 300) : 100; - const result = await metadata.backfillAllImdbIds({ delayMs: 80, limit }); + const result = await metadata.rematchLibraryMetadata({ delayMs: 120, limit }); return result; }); diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts index b2f72aa..bcf2563 100644 --- a/apps/master-api/src/metadata/service.ts +++ b/apps/master-api/src/metadata/service.ts @@ -21,6 +21,22 @@ const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_LOGO_BASE = "https://image.tmdb.org/t/p/w300"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_LANG = "nl-NL"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function fetchTmdbJson(url: string, retries = 4): Promise { + for (let attempt = 0; attempt < retries; attempt++) { + const res = await fetch(url); + if (res.status === 429) { + await sleep(1000 * (attempt + 1)); + continue; + } + return res; + } + return null; +} const METAHUB_LOGO = (imdbId: string) => `https://images.metahub.space/logo/medium/${imdbId}/img`; @@ -885,6 +901,7 @@ export class MetadataService { seriesMerged: number; staleFilesRemoved: number; duplicateFilesRemoved: number; + rematch: Awaited>; imdb: Awaited>; }> { const moviesMerged = await this.mergeAllDuplicateMovies(); @@ -895,10 +912,142 @@ export class MetadataService { const duplicateFilesRemoved = await this.removeDuplicateMediaFiles(); - // Cap IMDb backfill so repair finishes in reasonable time; run again if needed. - const imdb = await this.backfillAllImdbIds({ delayMs: 80, limit: 400 }); + const rematch = await this.rematchLibraryMetadata({ delayMs: 100, limit: 200 }); - return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, imdb }; + // Cap IMDb backfill so repair finishes in reasonable time; run again if needed. + const imdb = await this.backfillAllImdbIds({ delayMs: 200, limit: 200 }); + + return { moviesMerged, seriesMerged, staleFilesRemoved, duplicateFilesRemoved, rematch, imdb }; + } + + /** + * Re-match library titles that have streams but no TMDB id (common after scan-only adds). + */ + async rematchLibraryMetadata(options?: { + delayMs?: number; + limit?: number; + }): Promise<{ + movies: { checked: number; matched: number; remaining: number }; + series: { checked: number; matched: number; remaining: number }; + }> { + const delayMs = options?.delayMs ?? 120; + const limit = options?.limit && options.limit > 0 ? options.limit : 150; + if (!this.tmdbApiKey) { + const [movieRemaining, seriesRemaining] = await Promise.all([ + prisma.movie.count({ + where: { tmdbId: null, mediaFiles: { some: { available: true } } }, + }), + prisma.series.count({ + where: { + tmdbId: null, + episodes: { some: { mediaFiles: { some: { available: true } } } }, + }, + }), + ]); + return { + movies: { checked: 0, matched: 0, remaining: movieRemaining }, + series: { checked: 0, matched: 0, remaining: seriesRemaining }, + }; + } + + const pendingMovies = await prisma.movie.findMany({ + where: { tmdbId: null, mediaFiles: { some: { available: true } } }, + orderBy: { updatedAt: "asc" }, + take: limit, + select: { id: true, title: true, year: true }, + }); + + let moviesMatched = 0; + for (const movie of pendingMovies) { + const tmdb = await this.searchTmdbMovie(movie.title, movie.year ?? undefined); + if (tmdb) { + const canonical = await this.upsertMovieFromTmdb(tmdb); + if (canonical.id !== movie.id) { + await prisma.mediaFile.updateMany({ + where: { movieId: movie.id }, + data: { movieId: canonical.id }, + }); + await prisma.movie.delete({ where: { id: movie.id } }).catch(() => undefined); + } + moviesMatched += 1; + } else { + await prisma.movie + .update({ where: { id: movie.id }, data: { updatedAt: new Date() } }) + .catch(() => undefined); + } + if (delayMs > 0) await sleep(delayMs); + } + + const pendingSeries = await prisma.series.findMany({ + where: { + tmdbId: null, + episodes: { some: { mediaFiles: { some: { available: true } } } }, + }, + orderBy: { updatedAt: "asc" }, + take: limit, + select: { id: true, title: true }, + }); + + let seriesMatched = 0; + for (const series of pendingSeries) { + const tmdb = await this.searchTmdbSeries(series.title); + if (tmdb) { + const canonical = await prisma.series.upsert({ + where: { tmdbId: tmdb.id }, + create: { + title: tmdb.title, + year: tmdb.year, + overview: tmdb.overview, + posterUrl: tmdb.posterUrl, + backdropUrl: tmdb.backdropUrl, + tmdbId: tmdb.id, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), + }, + update: { + title: tmdb.title, + overview: tmdb.overview, + posterUrl: tmdb.posterUrl, + backdropUrl: tmdb.backdropUrl, + year: tmdb.year, + ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), + }, + }); + if (canonical.id !== series.id) { + await this.mergeSeriesInto(canonical.id, series.id); + } + seriesMatched += 1; + } else { + await prisma.series + .update({ where: { id: series.id }, data: { updatedAt: new Date() } }) + .catch(() => undefined); + } + if (delayMs > 0) await sleep(delayMs); + } + + const [movieRemaining, seriesRemaining] = await Promise.all([ + prisma.movie.count({ + where: { tmdbId: null, mediaFiles: { some: { available: true } } }, + }), + prisma.series.count({ + where: { + tmdbId: null, + episodes: { some: { mediaFiles: { some: { available: true } } } }, + }, + }), + ]); + + return { + movies: { + checked: pendingMovies.length, + matched: moviesMatched, + remaining: movieRemaining, + }, + series: { + checked: pendingSeries.length, + matched: seriesMatched, + remaining: seriesRemaining, + }, + }; } private movieGroupKey(m: { imdbId: string | null; tmdbId: number | null; title: string; year: number | null }): string { @@ -1822,10 +1971,10 @@ export class MetadataService { private async fetchTmdbMovieImdbId(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey }); - const res = await fetch( + const res = await fetchTmdbJson( `https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}` ); - if (!res.ok) return undefined; + if (!res?.ok) return undefined; const data = (await res.json()) as { imdb_id?: string | null }; const id = data.imdb_id?.trim(); return id && /^tt\d+$/i.test(id) ? id : undefined; @@ -1873,10 +2022,10 @@ export class MetadataService { private async fetchTmdbSeriesImdbId(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey }); - const res = await fetch( + const res = await fetchTmdbJson( `https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}` ); - if (!res.ok) return undefined; + if (!res?.ok) return undefined; const data = (await res.json()) as { imdb_id?: string | null }; const id = data.imdb_id?.trim(); return id && /^tt\d+$/i.test(id) ? id : undefined; diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts index 04afa44..0c95099 100644 --- a/apps/master-api/src/viewer/service.ts +++ b/apps/master-api/src/viewer/service.ts @@ -565,45 +565,45 @@ export class ViewerService { } async fourKMovies(take: number) { - const movies = await prisma.movie.findMany({ + const files = await prisma.mediaFile.findMany({ where: { - mediaFiles: { - some: { - available: true, - OR: [ - { resolution: { contains: "2160" } }, - { resolution: { contains: "4K", mode: "insensitive" } }, - { resolution: { contains: "UHD", mode: "insensitive" } }, - ], - }, - }, + available: true, + movieId: { not: null }, + OR: [ + { resolution: { contains: "2160" } }, + { resolution: { contains: "4K", mode: "insensitive" } }, + { resolution: { contains: "UHD", mode: "insensitive" } }, + ], }, orderBy: { createdAt: "desc" }, - take, - include: { - mediaFiles: { - where: { - available: true, - OR: [ - { resolution: { contains: "2160" } }, - { resolution: { contains: "4K", mode: "insensitive" } }, - { resolution: { contains: "UHD", mode: "insensitive" } }, - ], - }, - orderBy: { createdAt: "desc" }, - take: 1, - }, - }, + take: Math.max(take * 3, take), + include: { movie: true }, }); - return movies.map((m) => ({ - type: "movie" as const, - id: m.id, - title: m.title, - year: m.year, - posterUrl: m.posterUrl, - backdropUrl: m.backdropUrl, - mediaFileId: m.mediaFiles[0]?.id ?? null, - })); + const seen = new Set(); + const out: Array<{ + type: "movie"; + id: string; + title: string; + year: number | null; + posterUrl: string | null; + backdropUrl: string | null; + mediaFileId: string | null; + }> = []; + for (const f of files) { + if (!f.movieId || !f.movie || seen.has(f.movieId)) continue; + seen.add(f.movieId); + out.push({ + type: "movie", + id: f.movie.id, + title: f.movie.title, + year: f.movie.year, + posterUrl: f.movie.posterUrl, + backdropUrl: f.movie.backdropUrl, + mediaFileId: f.id, + }); + if (out.length >= take) break; + } + return out; } async shelfItems(shelfId: string, kind: "MOVIE" | "SERIES", take: number) { @@ -619,66 +619,96 @@ export class ViewerService { take: number ) { if (kind === "MOVIE") { - const where = { - mediaFiles: { some: { shelfId, available: true } }, - }; - const [total, movies] = await Promise.all([ - prisma.movie.count({ where }), - prisma.movie.findMany({ - where, - orderBy: [{ title: "asc" }, { year: "asc" }], - skip, - take, - include: { - mediaFiles: { - where: { shelfId, available: true }, - orderBy: { createdAt: "desc" }, - take: 1, - }, + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT mf.movie_id)::bigint AS count + FROM media_files mf + WHERE mf.shelf_id = ${shelfId} + AND mf.available = true + AND mf.movie_id IS NOT NULL + `; + const total = Number(countRow?.count ?? 0); + const idRows = await prisma.$queryRaw>` + SELECT mf.movie_id + FROM media_files mf + WHERE mf.shelf_id = ${shelfId} + AND mf.available = true + AND mf.movie_id IS NOT NULL + GROUP BY mf.movie_id + ORDER BY MAX(mf.created_at) DESC + OFFSET ${skip} + LIMIT ${take} + `; + const ids = idRows.map((r) => r.movie_id); + if (!ids.length) return { total, items: [] }; + + const movies = await prisma.movie.findMany({ + where: { id: { in: ids } }, + include: { + mediaFiles: { + where: { shelfId, available: true }, + orderBy: { createdAt: "desc" }, + take: 1, }, - }), - ]); + }, + }); + const byId = new Map(movies.map((m) => [m.id, m])); return { total, - items: movies.map((m) => ({ - type: "movie" as const, - id: m.id, - title: m.title, - year: m.year, - posterUrl: m.posterUrl, - backdropUrl: m.backdropUrl, - mediaFileId: m.mediaFiles[0]?.id ?? null, - })), + items: ids + .map((id) => byId.get(id)) + .filter((m): m is NonNullable => !!m) + .map((m) => ({ + type: "movie" as const, + id: m.id, + title: m.title, + year: m.year, + posterUrl: m.posterUrl, + backdropUrl: m.backdropUrl, + mediaFileId: m.mediaFiles[0]?.id ?? null, + })), }; } - const where = { - episodes: { - some: { - mediaFiles: { some: { shelfId, available: true } }, - }, - }, - }; - const [total, series] = await Promise.all([ - prisma.series.count({ where }), - prisma.series.findMany({ - where, - orderBy: [{ title: "asc" }, { year: "asc" }], - skip, - take, - }), - ]); + const [countRow] = await prisma.$queryRaw>` + SELECT COUNT(DISTINCT e.series_id)::bigint AS count + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.shelf_id = ${shelfId} + AND mf.available = true + AND mf.episode_id IS NOT NULL + `; + const total = Number(countRow?.count ?? 0); + const idRows = await prisma.$queryRaw>` + SELECT e.series_id + FROM media_files mf + INNER JOIN episodes e ON e.id = mf.episode_id + WHERE mf.shelf_id = ${shelfId} + AND mf.available = true + AND mf.episode_id IS NOT NULL + GROUP BY e.series_id + ORDER BY MAX(mf.created_at) DESC + OFFSET ${skip} + LIMIT ${take} + `; + const ids = idRows.map((r) => r.series_id); + if (!ids.length) return { total, items: [] }; + + const series = await prisma.series.findMany({ where: { id: { in: ids } } }); + const byId = new Map(series.map((s) => [s.id, s])); return { total, - items: series.map((s) => ({ - type: "series" as const, - id: s.id, - title: s.title, - year: s.year, - posterUrl: s.posterUrl, - backdropUrl: s.backdropUrl, - mediaFileId: null as string | null, - })), + items: ids + .map((id) => byId.get(id)) + .filter((s): s is NonNullable => !!s) + .map((s) => ({ + type: "series" as const, + id: s.id, + title: s.title, + year: s.year, + posterUrl: s.posterUrl, + backdropUrl: s.backdropUrl, + mediaFileId: null as string | null, + })), }; }