import { prisma } from "../database/client"; import type { MediaFileInfo } from "@media-cluster/shared-types"; import { parseSeriesFromPath } from "./filename-parser"; function normalizeFolderPrefix(folder: string): string { return folder.trim().replace(/\\/g, "/").replace(/\/+$/, ""); } function pathUnderFolder(filePath: string | null | undefined, folder: string): boolean { if (!filePath) return false; const p = filePath.replace(/\\/g, "/"); const f = normalizeFolderPrefix(folder); return p === f || p.startsWith(`${f}/`); } interface TmdbSearchResult { results: Array<{ id: number; title?: string; name?: string; original_title?: string; original_name?: string; original_language?: string; release_date?: string; first_air_date?: string; overview?: string; poster_path?: string; backdrop_path?: string; genre_ids?: number[]; }>; } const TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"; const TMDB_STILL_BASE = "https://image.tmdb.org/t/p/w300"; /** Smaller logos render closer to Cinemeta/Metahub size in Stremio desktop. */ 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"; const TMDB_LANG_EN = "en-US"; function isDutchLanguage(lang?: string | null): boolean { const l = (lang || "").toLowerCase(); return l === "nl" || l.startsWith("nl-"); } /** * TMDB met language=nl-NL zet `title` op `original_title` als er geen NL-vertaling is. * Dan liever Engels (of original als EN ontbreekt). */ export function preferNlOrEnglishTitle(opts: { localizedTitle?: string | null; originalTitle?: string | null; originalLanguage?: string | null; englishTitle?: string | null; }): string { const localized = opts.localizedTitle?.trim() || ""; const original = opts.originalTitle?.trim() || ""; const english = opts.englishTitle?.trim() || ""; if (isDutchLanguage(opts.originalLanguage)) { return localized || original || english || "Onbekend"; } if ( localized && original && normalizeMatchTitle(localized) !== normalizeMatchTitle(original) ) { return localized; } if (english) return english; return localized || original || "Onbekend"; } function isEnglishLanguage(lang?: string | null): boolean { const l = (lang || "").toLowerCase(); return l === "en" || l.startsWith("en-"); } function needsEnglishTitleFallback( localizedTitle?: string | null, originalTitle?: string | null, originalLanguage?: string | null ): boolean { if (isDutchLanguage(originalLanguage)) return false; if (isEnglishLanguage(originalLanguage)) return false; const localized = localizedTitle?.trim() || ""; const original = originalTitle?.trim() || ""; if (!localized) return true; if (!original) return false; // Geen NL-vertaling → TMDB herhaalt de original (vaak DE/FR/…) return normalizeMatchTitle(localized) === normalizeMatchTitle(original); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } /** Normaliseer titel voor confident match (geen fuzzy first-hit). */ export function normalizeMatchTitle(s: string): string { return s .toLowerCase() .normalize("NFD") .replace(/\p{M}/gu, "") .replace(/&/g, " and ") .replace(/[^a-z0-9]+/g, " ") .replace(/\s+/g, " ") .trim(); } /** Alleen auto-matchen als lokale mapnaam en TMDB-titel echt overeenkomen. */ export function seriesTitlesMatchConfident(local: string, remote: string): boolean { const a = normalizeMatchTitle(local); const b = normalizeMatchTitle(remote); if (!a || !b) return false; if (a === b) return true; // optioneel: "The X" ↔ "X" if (a.startsWith("the ") && a.slice(4) === b) return true; if (b.startsWith("the ") && b.slice(4) === a) return true; return false; } 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`; type TmdbMeta = { id: number; title: string; originalTitle?: string; year?: number; overview?: string; posterUrl?: string; backdropUrl?: string; logoUrl?: string; imdbId?: string; adult?: boolean; /** YYYY-MM-DD from TMDB */ releaseDate?: string; }; /** File mtime → discoveredAt (no future dates; invalid → now). */ export function discoveredAtFromFileMtime(modifiedAt: string | Date | null | undefined): Date { const now = Date.now(); const mtime = modifiedAt ? new Date(modifiedAt) : new Date(NaN); if (Number.isNaN(mtime.getTime())) return new Date(now); // >1 day in the future → treat as now (bad clocks / copy artefacts) if (mtime.getTime() > now + 86_400_000) return new Date(now); return mtime; } function parseTmdbDate(raw: string | null | undefined): Date | null { if (!raw?.trim()) return null; const d = new Date(`${raw.trim()}T00:00:00.000Z`); return Number.isNaN(d.getTime()) ? null : d; } /** Extra fields for Stremio meta detail pages. */ export type StremioMetaExtras = { logo?: string; cast?: string[]; director?: string[]; imdbRating?: string; trailers?: Array<{ source: string; type: string }>; language?: string; country?: string; genres?: string[]; runtimeMinutes?: number; released?: string; }; type TmdbEpisodeMeta = { episodeNumber: number; title: string; overview?: string; airDate?: string; stillUrl?: string; runtime?: number; tmdbId?: number; }; type MovieRow = Awaited>; type SeriesRow = Awaited>; type EpisodeRow = Awaited>; function needsEpisodeEnrichment(ep: { title: string | null; stillUrl?: string | null; overview: string | null; }): boolean { if (!ep.overview) return true; if (!ep.title || /^episode\s+\d+$/i.test(ep.title.trim())) return true; return false; } /** Prefer Metahub (Stremio-sized) when IMDb is known; else shrink TMDB clearlogo. */ export function resolveStremioLogo( imdbId?: string | null, tmdbOrStoredLogo?: string | null ): string | undefined { if (imdbId && /^tt\d+$/i.test(imdbId)) { return METAHUB_LOGO(imdbId); } if (!tmdbOrStoredLogo) return undefined; return tmdbOrStoredLogo .replace(/\/t\/p\/(?:original|w\d+)\//, "/t/p/w300/") .replace(/\/logo\/(?:large|huge)\//, "/logo/medium/"); } function parseAirDate(raw?: string): Date | undefined { if (!raw || !/^\d{4}-\d{2}-\d{2}/.test(raw)) return undefined; const d = new Date(raw); if (Number.isNaN(d.getTime())) return undefined; return d; } /** Collapse punctuation/spacing so "13 Reasons Why" == "13_Reasons_Why". */ function normalizeSeriesKey(title: string): string { return title .toLowerCase() .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .replace(/&/g, "and") .replace(/[^a-z0-9]+/g, ""); } type MoviePick = { id: string; imdbId: string | null; tmdbId: number | null; createdAt: Date; _count: { mediaFiles: number }; }; type SeriesPick = { id: string; imdbId: string | null; tmdbId: number | null; createdAt: Date; _count: { episodes: number }; }; /** Prefer IMDb → TMDB → most files → oldest (stable Cinemeta identity). */ function pickBestMovie(group: MoviePick[]): MoviePick { return [...group].sort((a, b) => { if (!!a.imdbId !== !!b.imdbId) return a.imdbId ? -1 : 1; if ((a.tmdbId != null) !== (b.tmdbId != null)) return a.tmdbId != null ? -1 : 1; if (a._count.mediaFiles !== b._count.mediaFiles) { return b._count.mediaFiles - a._count.mediaFiles; } return a.createdAt.getTime() - b.createdAt.getTime(); })[0]; } function pickBestSeries(group: SeriesPick[]): SeriesPick { return [...group].sort((a, b) => { if (!!a.imdbId !== !!b.imdbId) return a.imdbId ? -1 : 1; if ((a.tmdbId != null) !== (b.tmdbId != null)) return a.tmdbId != null ? -1 : 1; if (a._count.episodes !== b._count.episodes) { return b._count.episodes - a._count.episodes; } return a.createdAt.getTime() - b.createdAt.getTime(); })[0]; } export class MetadataService { private movieCache = new Map(); private seriesCache = new Map(); private movieSearchCache = new Map(); private seriesSearchCache = new Map(); private seasonCache = new Map(); private richExtrasCache = new Map(); private refreshedMovieIds = new Set(); private refreshedSeriesIds = new Set(); private enrichedSeriesSeasons = new Set(); constructor(private readonly tmdbApiKey?: string) {} /** * Cast, directors, rating, trailers, sized logo for Stremio detail pages. * Cached; never throws — empty object on failure. */ async getStremioMetaExtras( kind: "movie" | "series", tmdbId: number | null | undefined, imdbId?: string | null, storedLogo?: string | null ): Promise { const logo = resolveStremioLogo(imdbId, storedLogo); if (!this.tmdbApiKey || tmdbId == null) { return logo ? { logo } : {}; } const cacheKey = `${kind}:${tmdbId}`; const cached = this.richExtrasCache.get(cacheKey); if (cached) { return { ...cached, logo: resolveStremioLogo(imdbId, cached.logo ?? storedLogo) }; } try { const path = kind === "movie" ? `movie/${tmdbId}` : `tv/${tmdbId}`; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, append_to_response: "credits,videos,external_ids", }); const res = await fetch(`https://api.themoviedb.org/3/${path}?${params}`); if (!res.ok) { return logo ? { logo } : {}; } const data = (await res.json()) as { runtime?: number; episode_run_time?: number[]; release_date?: string; first_air_date?: string; vote_average?: number; original_language?: string; origin_country?: string[]; genres?: Array<{ name?: string }>; credits?: { cast?: Array<{ name?: string; order?: number }>; crew?: Array<{ name?: string; job?: string }>; }; videos?: { results?: Array<{ key?: string; site?: string; type?: string; official?: boolean }>; }; external_ids?: { imdb_id?: string | null }; }; const cast = (data.credits?.cast ?? []) .slice() .sort((a, b) => (a.order ?? 99) - (b.order ?? 99)) .map((c) => c.name?.trim()) .filter((n): n is string => !!n) .slice(0, 8); const directorJobs = kind === "movie" ? ["Director"] : ["Director", "Series Director", "Executive Producer"]; const director = (data.credits?.crew ?? []) .filter((c) => c.job && directorJobs.includes(c.job)) .map((c) => c.name?.trim()) .filter((n): n is string => !!n) .filter((n, i, arr) => arr.indexOf(n) === i) .slice(0, 4); const trailers = (data.videos?.results ?? []) .filter((v) => v.site === "YouTube" && v.key && (v.type === "Trailer" || v.type === "Teaser")) .slice(0, 3) .map((v) => ({ source: v.key!, type: "Trailer" as const })); const imdbFromExt = data.external_ids?.imdb_id?.trim(); const resolvedImdb = imdbId && /^tt\d+$/i.test(imdbId) ? imdbId : imdbFromExt && /^tt\d+$/i.test(imdbFromExt) ? imdbFromExt : null; let tmdbLogo: string | undefined; try { tmdbLogo = kind === "movie" ? await this.fetchTmdbMovieLogo(tmdbId) : await this.fetchTmdbSeriesLogo(tmdbId); } catch { tmdbLogo = storedLogo ?? undefined; } const extras: StremioMetaExtras = { logo: resolveStremioLogo(resolvedImdb, tmdbLogo ?? storedLogo), cast: cast.length ? cast : undefined, director: director.length ? director : undefined, imdbRating: typeof data.vote_average === "number" && data.vote_average > 0 ? data.vote_average.toFixed(1) : undefined, trailers: trailers.length ? trailers : undefined, language: data.original_language || undefined, country: data.origin_country?.[0] || undefined, genres: (data.genres ?? []) .map((g) => g.name?.trim()) .filter((n): n is string => !!n), runtimeMinutes: kind === "movie" ? data.runtime || undefined : data.episode_run_time?.[0] || undefined, released: data.release_date || data.first_air_date || undefined, }; this.richExtrasCache.set(cacheKey, extras); return extras; } catch { return logo ? { logo } : {}; } } async matchMediaFile( nodeId: string, file: MediaFileInfo ): Promise { // Fast path: already matched → only refresh shelf / availability (no TMDB). const existing = await prisma.mediaFile.findUnique({ where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId }, }, select: { id: true, movieId: true, episodeId: true, shelfId: true, sizeBytes: true, path: true }, }); if (existing && (existing.movieId || existing.episodeId)) { const nextShelf = file.shelfId || null; const sizeBytes = BigInt(file.sizeBytes); const sizeChanged = existing.sizeBytes !== sizeBytes; const discoveredAt = sizeChanged ? new Date() : undefined; await prisma.mediaFile.update({ where: { id: existing.id }, data: { available: true, shelfId: nextShelf, sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, ...(file.path ? { path: file.path } : {}), lastSeen: new Date(file.modifiedAt), ...(discoveredAt ? { discoveredAt } : {}), }, }); if (discoveredAt) { await this.ensureLibraryAddedAt({ movieId: existing.movieId, episodeId: existing.episodeId, at: discoveredAt, }); } return existing.movieId ?? existing.episodeId; } if (file.mediaType === "movie" && file.parsedMovie) { return this.matchMovie(nodeId, file); } if (file.mediaType === "episode" && file.parsedEpisode) { return this.matchEpisode(nodeId, file); } return null; } /** Merge duplicate movies/series that share a title (prefer TMDB canonical). */ async dedupeLibrary(): Promise<{ moviesMerged: number; seriesMerged: number; emptied: number }> { let moviesMerged = 0; let seriesMerged = 0; let emptied = 0; const movies = await prisma.movie.findMany({ orderBy: [{ tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }], include: { _count: { select: { mediaFiles: true } } }, }); const movieGroups = new Map(); for (const m of movies) { const key = `${m.title.trim().toLowerCase()}::${m.year ?? ""}`; const list = movieGroups.get(key) ?? []; list.push(m); movieGroups.set(key, list); } for (const group of movieGroups.values()) { if (group.length < 2) continue; const canonical = group.find((m) => m.imdbId != null) ?? group.find((m) => m.tmdbId != null) ?? group.find((m) => m._count.mediaFiles > 0) ?? group[0]; for (const orphan of group) { if (orphan.id === canonical.id) continue; const moved = await prisma.mediaFile.updateMany({ where: { movieId: orphan.id }, data: { movieId: canonical.id }, }); moviesMerged += moved.count > 0 ? 1 : 0; await prisma.movie.delete({ where: { id: orphan.id } }); emptied += 1; } } const seriesList = await prisma.series.findMany({ orderBy: [{ tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }], }); const seriesGroups = new Map(); for (const s of seriesList) { const key = s.tmdbId != null ? `tmdb:${s.tmdbId}` : s.imdbId ? `imdb:${s.imdbId}` : `title:${normalizeSeriesKey(s.title)}`; const list = seriesGroups.get(key) ?? []; list.push(s); seriesGroups.set(key, list); } for (const group of seriesGroups.values()) { if (group.length < 2) continue; const canonical = group.find((s) => s.imdbId != null) ?? group.find((s) => s.tmdbId != null) ?? group[0]; for (const orphan of group) { if (orphan.id === canonical.id) continue; await this.mergeSeriesInto(canonical.id, orphan.id); seriesMerged += 1; emptied += 1; } } // Drop empty local shells (no TMDB, no files) const emptyMovies = await prisma.movie.findMany({ where: { tmdbId: null, mediaFiles: { none: {} } }, select: { id: true }, }); for (const m of emptyMovies) { await prisma.movie.delete({ where: { id: m.id } }).catch(() => undefined); emptied += 1; } return { moviesMerged, seriesMerged, emptied }; } private async mergeSeriesInto(canonicalId: string, orphanId: string): Promise { if (canonicalId === orphanId) return; const orphanSeasons = await prisma.season.findMany({ where: { seriesId: orphanId }, include: { episodes: { include: { mediaFiles: true } } }, }); for (const season of orphanSeasons) { let targetSeason = await prisma.season.findUnique({ where: { seriesId_seasonNumber: { seriesId: canonicalId, seasonNumber: season.seasonNumber, }, }, }); if (!targetSeason) { targetSeason = await prisma.season.create({ data: { seriesId: canonicalId, seasonNumber: season.seasonNumber, }, }); } for (const ep of season.episodes) { let targetEp = await prisma.episode.findUnique({ where: { seriesId_seasonNumber_episodeNumber: { seriesId: canonicalId, seasonNumber: ep.seasonNumber, episodeNumber: ep.episodeNumber, }, }, }); if (!targetEp) { targetEp = await prisma.episode.create({ data: { seriesId: canonicalId, seasonId: targetSeason.id, seasonNumber: ep.seasonNumber, episodeNumber: ep.episodeNumber, title: ep.title, overview: ep.overview, airDate: ep.airDate, }, }); } await prisma.mediaFile.updateMany({ where: { episodeId: ep.id }, data: { episodeId: targetEp.id }, }); } } await prisma.episode.deleteMany({ where: { seriesId: orphanId } }); await prisma.season.deleteMany({ where: { seriesId: orphanId } }); await prisma.series.delete({ where: { id: orphanId } }); } /** * Pull duplicate series rows into the best canonical (prefer IMDb/TMDB) * so Cinemeta tt-streams and local catalogs share one record with all files. */ async consolidateSeries(seriesId: string): Promise<{ merged: number; canonicalId: string }> { const seed = await prisma.series.findUnique({ where: { id: seriesId }, select: { id: true, title: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { episodes: true } }, }, }); if (!seed) return { merged: 0, canonicalId: seriesId }; const group = await this.findDuplicateSeriesGroup(seed); if (group.length < 2) return { merged: 0, canonicalId: seed.id }; const canonical = pickBestSeries(group); let merged = 0; for (const orphan of group) { if (orphan.id === canonical.id) continue; try { await this.mergeSeriesInto(canonical.id, orphan.id); merged += 1; } catch { // unique conflicts / race — skip } } return { merged, canonicalId: canonical.id }; } private async matchMovie(nodeId: string, file: MediaFileInfo): Promise { const parsed = file.parsedMovie!; const title = parsed.title.trim(); const year = parsed.year; let movie: MovieRow | null = null; // Prefer TMDB canonical title so NL/EN filenames collapse onto one record. if (this.tmdbApiKey) { const tmdb = await this.searchTmdbMovie(title, year); if (tmdb) { movie = await this.upsertMovieFromTmdb(tmdb); await this.absorbOrphanMovies(movie, title, year); } } if (!movie) { movie = await this.findExistingMovie(title, year); } if ( movie?.tmdbId && this.tmdbApiKey && !this.refreshedMovieIds.has(movie.id) ) { movie = await this.refreshMovieNl(movie.id, movie.tmdbId); this.refreshedMovieIds.add(movie.id); } if (!movie) { movie = await prisma.movie.create({ data: { title, year, }, }); } await this.upsertMediaFile(nodeId, file, { movieId: movie.id }); return movie.id; } private async matchEpisode(nodeId: string, file: MediaFileInfo): Promise { const parsed = file.parsedEpisode!; const title = parsed.title.trim(); // Alleen koppelen aan een al-geïdentificeerde serie (TMDB/IMDb), nooit aan orphan-titels. let series: SeriesRow | null = await this.findExistingCanonicalSeries(title); if (this.tmdbApiKey && !series) { const tmdb = await this.searchTmdbSeries(title); if (tmdb) { series = 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, adult: tmdb.adult === true, }, update: { title: tmdb.title, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, adult: tmdb.adult === true, }, }); if (tmdb.imdbId) { await prisma.series .update({ where: { id: series.id }, data: { imdbId: tmdb.imdbId } }) .catch(() => undefined); } if (tmdb.logoUrl) { await prisma.series .update({ where: { id: series.id }, data: { logoUrl: tmdb.logoUrl } }) .catch(() => undefined); } await this.absorbOrphanSeries(series, title); } } if ( series?.tmdbId && this.tmdbApiKey && !this.refreshedSeriesIds.has(series.id) ) { series = await this.refreshSeriesNl(series.id, series.tmdbId); this.refreshedSeriesIds.add(series.id); } // Geen betrouwbare match → bestand indexeren zonder serie (Admin: Nog te koppelen). if (!series) { await this.upsertMediaFile(nodeId, file, {}); return null; } let season = await prisma.season.findUnique({ where: { seriesId_seasonNumber: { seriesId: series.id, seasonNumber: parsed.season, }, }, }); if (!season) { season = await prisma.season.create({ data: { seriesId: series.id, seasonNumber: parsed.season, }, }); } let episode = await prisma.episode.findUnique({ where: { seriesId_seasonNumber_episodeNumber: { seriesId: series.id, seasonNumber: parsed.season, episodeNumber: parsed.episode, }, }, }); if (!episode) { episode = await prisma.episode.create({ data: { seriesId: series.id, seasonId: season.id, seasonNumber: parsed.season, episodeNumber: parsed.episode, title: `Episode ${parsed.episode}`, }, }); } if (series.tmdbId && this.tmdbApiKey) { episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode); } await this.upsertMediaFile(nodeId, file, { episodeId: episode.id, movieId: null }); return episode.id; } /** Ensure series has logo/poster from TMDB (clearlogo for Stremio detail page). */ async ensureSeriesArtwork(seriesId: string): Promise { if (!this.tmdbApiKey) return; try { const series = await prisma.series.findUnique({ where: { id: seriesId } }); if (!series?.tmdbId) return; // Prefer Metahub-sized logo when IMDb is known (fixes oversized TMDB clearlogos). if (series.imdbId) { const hub = resolveStremioLogo(series.imdbId, series.logoUrl); if (hub && hub !== series.logoUrl) { await prisma.series .update({ where: { id: seriesId }, data: { logoUrl: hub } }) .catch(() => undefined); } } if (series.logoUrl && series.posterUrl && series.backdropUrl && series.imdbId) return; this.seriesCache.delete(series.tmdbId); const tmdb = await this.getTmdbSeriesDetails(series.tmdbId); if (!tmdb) return; const logo = resolveStremioLogo( tmdb.imdbId ?? series.imdbId, tmdb.logoUrl ?? series.logoUrl ); await prisma.series.update({ where: { id: seriesId }, data: { posterUrl: tmdb.posterUrl ?? series.posterUrl, backdropUrl: tmdb.backdropUrl ?? series.backdropUrl, overview: tmdb.overview ?? series.overview, title: tmdb.title || series.title, year: tmdb.year ?? series.year, ...(tmdb.imdbId && !series.imdbId ? { imdbId: tmdb.imdbId } : {}), ...(logo ? { logoUrl: logo } : {}), }, }); } catch { // ignore — meta listing must not depend on this } } /** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */ async enrichSeriesEpisodes(seriesId: string): Promise { if (!this.tmdbApiKey) return; try { const series = await prisma.series.findUnique({ where: { id: seriesId }, include: { episodes: { select: { id: true, seasonNumber: true, episodeNumber: true, title: true, overview: true, }, }, }, }); if (!series?.tmdbId || series.episodes.length === 0) return; const seasons = new Set( series.episodes.filter((ep) => needsEpisodeEnrichment(ep)).map((ep) => ep.seasonNumber) ); for (const seasonNumber of seasons) { const key = `${series.id}:${seasonNumber}`; if (this.enrichedSeriesSeasons.has(key)) continue; const metas = await this.fetchTmdbSeasonEpisodes(series.tmdbId, seasonNumber); if (!metas.length) { this.enrichedSeriesSeasons.add(key); continue; } for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) { const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber); if (!meta) continue; try { await prisma.episode.update({ where: { id: ep.id }, data: { title: meta.title, overview: meta.overview ?? undefined, stillUrl: meta.stillUrl ?? undefined, runtime: meta.runtime ?? undefined, tmdbId: meta.tmdbId ?? undefined, airDate: parseAirDate(meta.airDate), }, }); } catch { // stillUrl column missing — update title/overview only try { await prisma.episode.update({ where: { id: ep.id }, data: { title: meta.title, overview: meta.overview ?? undefined, runtime: meta.runtime ?? undefined, tmdbId: meta.tmdbId ?? undefined, airDate: parseAirDate(meta.airDate), }, }); } catch { // skip } } } this.enrichedSeriesSeasons.add(key); } } catch { // Never fail the caller (Stremio meta) because of enrichment. } } private async applyTmdbEpisodeMeta( seriesTmdbId: number, episode: EpisodeRow ): Promise { if (!needsEpisodeEnrichment(episode)) return episode; try { const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber); const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber); if (!meta) return episode; return await prisma.episode.update({ where: { id: episode.id }, data: { title: meta.title, overview: meta.overview ?? undefined, stillUrl: meta.stillUrl ?? undefined, runtime: meta.runtime ?? undefined, tmdbId: meta.tmdbId ?? undefined, airDate: parseAirDate(meta.airDate), }, }); } catch { return episode; } } private async fetchTmdbSeasonEpisodes( seriesTmdbId: number, seasonNumber: number ): Promise { const cacheKey = `${seriesTmdbId}:${seasonNumber}`; const cached = this.seasonCache.get(cacheKey); if (cached) return cached; if (!this.tmdbApiKey) return []; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch( `https://api.themoviedb.org/3/tv/${seriesTmdbId}/season/${seasonNumber}?${params}` ); if (!res.ok) { this.seasonCache.set(cacheKey, []); return []; } const data = (await res.json()) as { episodes?: Array<{ id?: number; episode_number?: number; name?: string; overview?: string; air_date?: string; still_path?: string | null; runtime?: number | null; }>; }; const metas: TmdbEpisodeMeta[] = (data.episodes ?? []) .filter((ep) => ep.episode_number != null) .map((ep) => ({ episodeNumber: ep.episode_number!, title: ep.name?.trim() || `Episode ${ep.episode_number}`, overview: ep.overview || undefined, airDate: ep.air_date || undefined, stillUrl: ep.still_path ? `${TMDB_STILL_BASE}${ep.still_path}` : undefined, runtime: ep.runtime ?? undefined, tmdbId: ep.id, })); this.seasonCache.set(cacheKey, metas); return metas; } /** * Pull duplicate movie rows into the best canonical (prefer IMDb/TMDB holders) * so Cinemeta tt-lookups see the new available files after a path migration. */ async consolidateMovies(movieId: string): Promise<{ merged: number; canonicalId: string }> { const seed = await prisma.movie.findUnique({ where: { id: movieId }, select: { id: true, title: true, year: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { mediaFiles: true } }, }, }); if (!seed) return { merged: 0, canonicalId: movieId }; const group = await this.findDuplicateMovieGroup(seed); if (group.length < 2) return { merged: 0, canonicalId: seed.id }; const canonical = pickBestMovie(group); let merged = 0; for (const orphan of group) { if (orphan.id === canonical.id) continue; try { await prisma.mediaFile.updateMany({ where: { movieId: orphan.id }, data: { movieId: canonical.id }, }); // Prefer keeping IMDb/TMDB metadata on the survivor if ((!canonical.imdbId && orphan.imdbId) || (!canonical.tmdbId && orphan.tmdbId)) { await prisma.movie .update({ where: { id: canonical.id }, data: { ...(canonical.imdbId ? {} : orphan.imdbId ? { imdbId: orphan.imdbId } : {}), ...(canonical.tmdbId != null ? {} : orphan.tmdbId != null ? { tmdbId: orphan.tmdbId } : {}), }, }) .catch(() => undefined); } await prisma.movie.delete({ where: { id: orphan.id } }); merged += 1; } catch { // skip conflicts } } return { merged, canonicalId: canonical.id }; } /** * After path migrations: merge duplicates toward IMDb holders, drop dead * unavailable files, prune duplicate file rows, then backfill missing IMDb ids. */ async repairLibraryAfterPathMigration(): Promise<{ moviesMerged: number; seriesMerged: number; staleFilesRemoved: number; duplicateFilesRemoved: number; rematch: Awaited>; imdb: Awaited>; }> { const moviesMerged = await this.mergeAllDuplicateMovies(); const seriesMerged = await this.mergeAllDuplicateSeries(); // Unavailable rows left after the /media/movies → /mnt/user migration const staleFilesRemoved = await this.deleteUnavailableMediaFiles(); const duplicateFilesRemoved = await this.removeDuplicateMediaFiles(); const rematch = await this.rematchLibraryMetadata({ delayMs: 100, limit: 200 }); // 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, adult: tmdb.adult === true, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, update: { title: tmdb.title, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, adult: tmdb.adult === true, ...(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 { if (m.imdbId) return `imdb:${m.imdbId.toLowerCase()}`; if (m.tmdbId != null) return `tmdb:${m.tmdbId}`; return `title:${normalizeSeriesKey(m.title)}::${m.year ?? ""}`; } private seriesGroupKey(s: { imdbId: string | null; tmdbId: number | null; title: string }): string { if (s.imdbId) return `imdb:${s.imdbId.toLowerCase()}`; if (s.tmdbId != null) return `tmdb:${s.tmdbId}`; return `title:${normalizeSeriesKey(s.title)}`; } private async findDuplicateMovieGroup( seed: MoviePick & { title: string; year: number | null } ): Promise> { const norm = normalizeSeriesKey(seed.title); const orClauses: Array> = [ { title: { equals: seed.title, mode: "insensitive" } }, ]; if (seed.tmdbId != null) orClauses.push({ tmdbId: seed.tmdbId }); if (seed.imdbId) orClauses.push({ imdbId: seed.imdbId }); const candidates = await prisma.movie.findMany({ where: { OR: orClauses }, select: { id: true, title: true, year: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { mediaFiles: true } }, }, }); return candidates.filter((m) => { if (m.id === seed.id) return true; if (seed.tmdbId != null && m.tmdbId === seed.tmdbId) return true; if (seed.imdbId && m.imdbId === seed.imdbId) return true; if (normalizeSeriesKey(m.title) !== norm) return false; if (seed.year && m.year && seed.year !== m.year) return false; return true; }); } private async findDuplicateSeriesGroup( seed: SeriesPick & { title: string } ): Promise> { const norm = normalizeSeriesKey(seed.title); const orClauses: Array> = [ { title: { equals: seed.title, mode: "insensitive" } }, ]; if (seed.tmdbId != null) orClauses.push({ tmdbId: seed.tmdbId }); if (seed.imdbId) orClauses.push({ imdbId: seed.imdbId }); const candidates = await prisma.series.findMany({ where: { OR: orClauses }, select: { id: true, title: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { episodes: true } }, }, }); return candidates.filter((s) => { if (s.id === seed.id) return true; if (seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true; if (seed.imdbId && s.imdbId === seed.imdbId) return true; return normalizeSeriesKey(s.title) === norm; }); } private async mergeAllDuplicateMovies(): Promise { const all = await prisma.movie.findMany({ select: { id: true, title: true, year: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { mediaFiles: true } }, }, orderBy: { createdAt: "asc" }, }); const groups = new Map(); for (const m of all) { const key = this.movieGroupKey(m); const list = groups.get(key) ?? []; list.push(m); groups.set(key, list); } let merged = 0; for (const group of groups.values()) { if (group.length < 2) continue; const canonical = pickBestMovie(group); for (const orphan of group) { if (orphan.id === canonical.id) continue; try { await prisma.mediaFile.updateMany({ where: { movieId: orphan.id }, data: { movieId: canonical.id }, }); if ((!canonical.imdbId && orphan.imdbId) || (!canonical.tmdbId && orphan.tmdbId != null)) { await prisma.movie .update({ where: { id: canonical.id }, data: { ...(canonical.imdbId ? {} : orphan.imdbId ? { imdbId: orphan.imdbId } : {}), ...(canonical.tmdbId != null ? {} : orphan.tmdbId != null ? { tmdbId: orphan.tmdbId } : {}), }, }) .catch(() => undefined); } await prisma.movie.delete({ where: { id: orphan.id } }); merged += 1; } catch { // unique conflicts — skip } } } return merged; } private async mergeAllDuplicateSeries(): Promise { const all = await prisma.series.findMany({ select: { id: true, title: true, tmdbId: true, imdbId: true, createdAt: true, _count: { select: { episodes: true } }, }, orderBy: { createdAt: "asc" }, }); const groups = new Map(); for (const s of all) { const key = this.seriesGroupKey(s); const list = groups.get(key) ?? []; list.push(s); groups.set(key, list); } let merged = 0; for (const group of groups.values()) { if (group.length < 2) continue; const canonical = pickBestSeries(group); for (const orphan of group) { if (orphan.id === canonical.id) continue; try { await this.mergeSeriesInto(canonical.id, orphan.id); merged += 1; } catch { // skip conflicts } } } return merged; } /** Drop duplicate file rows after path migrations (same node + title + size). */ private async removeDuplicateMediaFiles(): Promise { const files = await prisma.mediaFile.findMany({ where: { OR: [{ movieId: { not: null } }, { episodeId: { not: null } }] }, select: { id: true, nodeId: true, movieId: true, episodeId: true, releaseName: true, sizeBytes: true, available: true, lastSeen: true, }, orderBy: { lastSeen: "desc" }, }); const groups = new Map(); for (const f of files) { const mediaKey = f.movieId ?? f.episodeId ?? ""; const release = (f.releaseName ?? "").trim().toLowerCase(); const key = `${f.nodeId}::${mediaKey}::${release}::${f.sizeBytes.toString()}`; const list = groups.get(key) ?? []; list.push(f); groups.set(key, list); } const toDelete: string[] = []; for (const group of groups.values()) { if (group.length < 2) continue; const sorted = [...group].sort((a, b) => { if (a.available !== b.available) return a.available ? -1 : 1; return b.lastSeen.getTime() - a.lastSeen.getTime(); }); for (const dup of sorted.slice(1)) { toDelete.push(dup.id); } } if (toDelete.length === 0) return 0; return this.deleteMediaFilesSafely(toDelete); } /** Remove playback sessions first — FK blocks direct media_file deletes. */ private async deleteMediaFilesSafely(ids: string[]): Promise { if (ids.length === 0) return 0; const chunkSize = 500; let deleted = 0; for (let i = 0; i < ids.length; i += chunkSize) { const chunk = ids.slice(i, i + chunkSize); await prisma.playbackSession.deleteMany({ where: { mediaFileId: { in: chunk } } }); const result = await prisma.mediaFile.deleteMany({ where: { id: { in: chunk } } }); deleted += result.count; } return deleted; } private async deleteUnavailableMediaFiles(): Promise { const stale = await prisma.mediaFile.findMany({ where: { available: false }, select: { id: true }, }); return this.deleteMediaFilesSafely(stale.map((f) => f.id)); } /** Admin: search TMDB by title (or resolve a single IMDb id). */ async searchCatalog( type: "movie" | "series", query: string ): Promise< Array<{ tmdbId: number; title: string; year: number | null; overview: string | null; posterUrl: string | null; imdbId: string | null; }> > { if (!this.tmdbApiKey) return []; const q = query.trim(); if (!q) return []; // Direct IMDb id → resolve via TMDB find if (/^tt\d+$/i.test(q)) { const found = await this.findTmdbByImdb(type, q); return found ? [found] : []; } const path = type === "movie" ? "search/movie" : "search/tv"; const base = { api_key: this.tmdbApiKey, query: q, include_adult: "false", }; const [nlRes, enRes] = await Promise.all([ fetch( `https://api.themoviedb.org/3/${path}?${new URLSearchParams({ ...base, language: TMDB_LANG, })}` ), fetch( `https://api.themoviedb.org/3/${path}?${new URLSearchParams({ ...base, language: TMDB_LANG_EN, })}` ), ]); if (!nlRes.ok) return []; const data = (await nlRes.json()) as TmdbSearchResult; const enById = new Map(); if (enRes.ok) { const enData = (await enRes.json()) as TmdbSearchResult; for (const hit of enData.results ?? []) enById.set(hit.id, hit); } return (data.results ?? []).slice(0, 12).map((item) => { const en = enById.get(item.id); const date = type === "movie" ? item.release_date : item.first_air_date; const year = date ? parseInt(date.slice(0, 4), 10) : null; const localized = type === "movie" ? item.title : item.name; const original = type === "movie" ? item.original_title : item.original_name; const english = type === "movie" ? en?.title : en?.name; const overview = item.overview?.trim() || en?.overview?.trim() || null; return { tmdbId: item.id, title: preferNlOrEnglishTitle({ localizedTitle: localized, originalTitle: original, originalLanguage: item.original_language, englishTitle: english, }), year: Number.isFinite(year) ? year : null, overview, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, imdbId: null, }; }); } /** * Admin: bind a library movie to a TMDB/IMDb title and refresh metadata. * Merges into an existing TMDB row when one already exists. */ async linkMovie( movieId: string, opts: { tmdbId?: number; imdbId?: string } ): Promise<{ movieId: string; merged: boolean }> { if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd"); const source = await prisma.movie.findUnique({ where: { id: movieId } }); if (!source) throw new Error("Film niet gevonden"); let tmdbId = opts.tmdbId; if (!tmdbId && opts.imdbId) { const hit = await this.findTmdbByImdb("movie", opts.imdbId.trim()); tmdbId = hit?.tmdbId; } if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op"); const meta = await this.getTmdbMovieDetails(tmdbId); if (!meta) throw new Error("TMDB-film niet gevonden"); const logoUrl = await this.fetchTmdbMovieLogo(tmdbId); const extras = await this.getStremioMetaExtras("movie", meta.id, meta.imdbId, logoUrl); let target = await prisma.movie.findFirst({ where: { OR: [ { tmdbId: meta.id }, ...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []), ], }, }); let merged = false; if (!target) { target = await prisma.movie.update({ where: { id: movieId }, data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, }); } else { target = await prisma.movie.update({ where: { id: target.id }, data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, }); if (target.id !== movieId) { await prisma.mediaFile.updateMany({ where: { movieId }, data: { movieId: target.id }, }); await prisma.movie.delete({ where: { id: movieId } }).catch(() => undefined); merged = true; } } this.refreshedMovieIds.delete(target.id); await this.refreshMovieNl(target.id, meta.id).catch(() => undefined); return { movieId: target.id, merged }; } /** * Admin: bind a library series to TMDB/IMDb and refresh artwork + episode titles. */ async linkSeries( seriesId: string, opts: { tmdbId?: number; imdbId?: string } ): Promise<{ seriesId: string; merged: boolean }> { if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd"); const source = await prisma.series.findUnique({ where: { id: seriesId } }); if (!source) throw new Error("Serie niet gevonden"); let tmdbId = opts.tmdbId; if (!tmdbId && opts.imdbId) { const hit = await this.findTmdbByImdb("series", opts.imdbId.trim()); tmdbId = hit?.tmdbId; } if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op"); const meta = await this.getTmdbSeriesDetails(tmdbId); if (!meta) throw new Error("TMDB-serie niet gevonden"); const extras = await this.getStremioMetaExtras( "series", meta.id, meta.imdbId, meta.logoUrl ); let target = await prisma.series.findFirst({ where: { OR: [ { tmdbId: meta.id }, ...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []), ], }, }); let merged = false; if (!target) { target = await prisma.series.update({ where: { id: seriesId }, data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, logoUrl: meta.logoUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, }); } else { target = await prisma.series.update({ where: { id: target.id }, data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, logoUrl: meta.logoUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, }); if (target.id !== seriesId) { await this.mergeSeriesInto(target.id, seriesId); merged = true; } } this.refreshedSeriesIds.delete(target.id); await this.refreshSeriesNl(target.id, meta.id).catch(() => undefined); await this.ensureSeriesArtwork(target.id).catch(() => undefined); await this.enrichSeriesEpisodes(target.id).catch(() => undefined); return { seriesId: target.id, merged }; } /** * Admin: alle media-bestanden onder een host-map op één node herkoppelen * aan een gekozen TMDB/IMDb-serie. S/E komt uit pad/bestandsnaam. */ async relinkFolderToSeries(opts: { nodeId: string; folder: string; tmdbId?: number; imdbId?: string; }): Promise<{ seriesId: string; seriesTitle: string; moved: number; failed: number; total: number; }> { if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd"); const folder = normalizeFolderPrefix(opts.folder); if (!folder) throw new Error("Map-pad is verplicht"); let tmdbId = opts.tmdbId; if (!tmdbId && opts.imdbId) { const hit = await this.findTmdbByImdb("series", opts.imdbId.trim()); tmdbId = hit?.tmdbId; } if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op"); const meta = await this.getTmdbSeriesDetails(tmdbId); if (!meta) throw new Error("TMDB-serie niet gevonden"); const extras = await this.getStremioMetaExtras( "series", meta.id, meta.imdbId, meta.logoUrl ); let series = (await prisma.series.findFirst({ where: { OR: [ { tmdbId: meta.id }, ...(meta.imdbId ? [{ imdbId: meta.imdbId }] : []), ], }, })) ?? (await prisma.series.create({ data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, logoUrl: meta.logoUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, })); series = await prisma.series.update({ where: { id: series.id }, data: { title: meta.title, year: meta.year ?? null, overview: meta.overview ?? null, posterUrl: meta.posterUrl ?? null, backdropUrl: meta.backdropUrl ?? null, logoUrl: meta.logoUrl ?? null, tmdbId: meta.id, ...(meta.imdbId ? { imdbId: meta.imdbId } : {}), ...(extras.genres?.length ? { genres: extras.genres } : {}), }, }); const files = await prisma.mediaFile.findMany({ where: { nodeId: opts.nodeId, path: { not: null }, }, select: { id: true, path: true, localFileId: true, sizeBytes: true, container: true, resolution: true, videoCodec: true, audioCodec: true, releaseName: true, lastSeen: true, shelfId: true, }, }); const underFolder = files.filter((f) => pathUnderFolder(f.path, folder)); let moved = 0; let failed = 0; for (const file of underFolder) { const sourcePath = file.path!.trim(); const parsed = parseSeriesFromPath(sourcePath); if (!parsed?.season || !parsed?.episode) { failed += 1; continue; } try { let season = await prisma.season.findUnique({ where: { seriesId_seasonNumber: { seriesId: series.id, seasonNumber: parsed.season, }, }, }); if (!season) { season = await prisma.season.create({ data: { seriesId: series.id, seasonNumber: parsed.season, }, }); } let episode = await prisma.episode.findUnique({ where: { seriesId_seasonNumber_episodeNumber: { seriesId: series.id, seasonNumber: parsed.season, episodeNumber: parsed.episode, }, }, }); if (!episode) { episode = await prisma.episode.create({ data: { seriesId: series.id, seasonId: season.id, seasonNumber: parsed.season, episodeNumber: parsed.episode, title: `Episode ${parsed.episode}`, }, }); } if (series.tmdbId && this.tmdbApiKey) { episode = await this.applyTmdbEpisodeMeta(series.tmdbId, episode); } await prisma.mediaFile.update({ where: { id: file.id }, data: { movieId: null, episodeId: episode.id, available: true, }, }); moved += 1; } catch { failed += 1; } } this.refreshedSeriesIds.delete(series.id); await this.refreshSeriesNl(series.id, meta.id).catch(() => undefined); await this.ensureSeriesArtwork(series.id).catch(() => undefined); await this.enrichSeriesEpisodes(series.id).catch(() => undefined); return { seriesId: series.id, seriesTitle: series.title, moved, failed, total: underFolder.length, }; } private async findTmdbByImdb( type: "movie" | "series", imdbId: string ): Promise<{ tmdbId: number; title: string; year: number | null; overview: string | null; posterUrl: string | null; imdbId: string | null; } | null> { if (!this.tmdbApiKey || !/^tt\d+$/i.test(imdbId)) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, external_source: "imdb_id", language: TMDB_LANG, }); const res = await fetch( `https://api.themoviedb.org/3/find/${encodeURIComponent(imdbId)}?${params}` ); if (!res.ok) return null; const data = (await res.json()) as { movie_results?: TmdbSearchResult["results"]; tv_results?: TmdbSearchResult["results"]; }; const item = type === "movie" ? data.movie_results?.[0] : data.tv_results?.[0]; if (!item) return null; const meta = type === "movie" ? await this.getTmdbMovieDetails(item.id) : await this.getTmdbSeriesDetails(item.id); if (meta) { return { tmdbId: meta.id, title: meta.title, year: meta.year ?? null, overview: meta.overview || null, posterUrl: meta.posterUrl || null, imdbId: meta.imdbId || imdbId, }; } const date = type === "movie" ? item.release_date : item.first_air_date; const year = date ? parseInt(date.slice(0, 4), 10) : null; return { tmdbId: item.id, title: preferNlOrEnglishTitle({ localizedTitle: type === "movie" ? item.title : item.name, originalTitle: type === "movie" ? item.original_title : item.original_name, originalLanguage: item.original_language, }), year: Number.isFinite(year) ? year : null, overview: item.overview || null, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null, imdbId, }; } /** Backfill IMDb id from TMDB so catalog can use tt + Cinemeta. */ async ensureMovieImdbId(movieId: string): Promise { if (!this.tmdbApiKey) return false; const movie = await prisma.movie.findUnique({ where: { id: movieId } }); if (!movie?.tmdbId || movie.imdbId) return false; const imdbId = await this.fetchTmdbMovieImdbId(movie.tmdbId); if (!imdbId) { await prisma.movie .update({ where: { id: movieId }, data: { updatedAt: new Date() } }) .catch(() => undefined); return false; } try { await prisma.movie.update({ where: { id: movieId }, data: { imdbId } }); 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 movies that have a TMDB id. * Processes in batches until none remain (or `limit` is hit). * Rate-limited to avoid TMDB bans. Safe for series (movies only). */ async backfillMovieImdbIds(options?: { delayMs?: number; /** Max movies to process this run. Omit / 0 = all pending. */ 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.movie.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.ensureMovieImdbId(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.movie.count({ where: { tmdbId: { not: null }, imdbId: null }, }); return { checked, updated, remaining, failed }; } /** Backfill IMDb id for a series from TMDB (streams via Cinemeta tt:S:E). */ async ensureSeriesImdbId(seriesId: string): Promise { 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) { await prisma.series .update({ where: { id: seriesId }, data: { updatedAt: new Date() } }) .catch(() => undefined); 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; limit?: 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 perTypeLimit = options?.limit && options.limit > 0 ? Math.ceil(options.limit / 2) : undefined; const movies = await this.backfillMovieImdbIds({ delayMs, limit: perTypeLimit }); const series = await this.backfillSeriesImdbIds({ delayMs, limit: perTypeLimit }); return { movies, series }; } private async absorbOrphanMovies( canonical: MovieRow, localTitle: string, year?: number ): Promise { const keys = new Set( [canonical.title, localTitle] .map((t) => normalizeSeriesKey(t.trim())) .filter(Boolean) ); if (keys.size === 0) return; const candidates = await prisma.movie.findMany({ where: { id: { not: canonical.id }, tmdbId: null, ...(year ? { OR: [{ year }, { year: null }] } : {}), }, select: { id: true, title: true }, }); for (const orphan of candidates) { if (!keys.has(normalizeSeriesKey(orphan.title))) continue; await prisma.mediaFile.updateMany({ where: { movieId: orphan.id }, data: { movieId: canonical.id }, }); await prisma.movie.delete({ where: { id: orphan.id } }).catch(() => undefined); } } private async absorbOrphanSeries(canonical: SeriesRow, localTitle: string): Promise { const keys = new Set( [canonical.title, localTitle] .map((t) => normalizeSeriesKey(t.trim())) .filter(Boolean) ); if (keys.size === 0) return; const candidates = await prisma.series.findMany({ where: { id: { not: canonical.id }, tmdbId: null }, select: { id: true, title: true }, }); for (const orphan of candidates) { if (!keys.has(normalizeSeriesKey(orphan.title))) continue; await this.mergeSeriesInto(canonical.id, orphan.id); } } /** TMDB upsert with safe IMDb linking (unique conflicts must not block file matching). */ private async upsertMovieFromTmdb(tmdb: TmdbMeta): Promise { const releaseDate = parseTmdbDate(tmdb.releaseDate); const data = { title: tmdb.title, ...(tmdb.originalTitle ? { originalTitle: tmdb.originalTitle } : {}), year: tmdb.year, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, adult: tmdb.adult === true, ...(releaseDate ? { releaseDate } : {}), }; try { return await prisma.movie.upsert({ where: { tmdbId: tmdb.id }, create: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, update: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, }); } catch { const owner = await prisma.movie.findFirst({ where: { OR: [ { tmdbId: tmdb.id }, ...(tmdb.imdbId ? [{ imdbId: tmdb.imdbId }] : []), ], }, }); if (owner) { return prisma.movie.update({ where: { id: owner.id }, data: { ...data, ...(tmdb.imdbId && !owner.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); } return prisma.movie.create({ data: { ...data, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}) }, }); } } /** * Zet libraryAddedAt één keer (cluster first-seen). * Extra node / tweede kopie bump’t niet. */ private async ensureLibraryAddedAt(opts: { movieId?: string | null; episodeId?: string | null; at: Date; }): Promise { if (opts.movieId) { await prisma.movie.updateMany({ where: { id: opts.movieId, libraryAddedAt: null }, data: { libraryAddedAt: opts.at }, }); } if (opts.episodeId) { await prisma.episode.updateMany({ where: { id: opts.episodeId, libraryAddedAt: null }, data: { libraryAddedAt: opts.at }, }); } } private async upsertMediaFile( nodeId: string, file: MediaFileInfo, refs: { movieId?: string | null; episodeId?: string | null } ): Promise { const mtime = new Date(file.modifiedAt); const sizeBytes = BigInt(file.sizeBytes); const existing = await prisma.mediaFile.findUnique({ where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId } }, select: { sizeBytes: true }, }); const sizeChanged = existing != null && existing.sizeBytes !== sizeBytes; const discoveredAtCreate = discoveredAtFromFileMtime(file.modifiedAt); const discoveredAtUpdate = sizeChanged ? new Date() : undefined; await prisma.mediaFile.upsert({ where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId }, }, create: { nodeId, localFileId: file.localFileId, movieId: refs.movieId ?? null, episodeId: refs.episodeId ?? null, shelfId: file.shelfId || null, sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, path: file.path || null, available: true, lastSeen: mtime, discoveredAt: discoveredAtCreate, }, update: { ...("movieId" in refs ? { movieId: refs.movieId ?? null } : {}), ...("episodeId" in refs ? { episodeId: refs.episodeId ?? null } : {}), ...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}), sizeBytes, container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, ...(file.path ? { path: file.path } : {}), available: true, lastSeen: mtime, ...(discoveredAtUpdate ? { discoveredAt: discoveredAtUpdate } : {}), }, }); const at = discoveredAtUpdate ?? (existing ? null : discoveredAtCreate); if (at) { await this.ensureLibraryAddedAt({ movieId: refs.movieId, episodeId: refs.episodeId, at, }); } else if (refs.movieId || refs.episodeId) { // Existing file re-linked / rematched: still seed libraryAddedAt if missing. await this.ensureLibraryAddedAt({ movieId: refs.movieId, episodeId: refs.episodeId, at: discoveredAtCreate, }); } } private async findExistingMovie(title: string, year?: number) { if (year) { const withYear = await prisma.movie.findFirst({ where: { title: { equals: title, mode: "insensitive" }, year, }, orderBy: [ { imdbId: { sort: "desc", nulls: "last" } }, { tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }, ], }); if (withYear) return withYear; } return prisma.movie.findFirst({ where: { title: { equals: title, mode: "insensitive" } }, orderBy: [ { imdbId: { sort: "desc", nulls: "last" } }, { tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }, ], }); } private async findExistingSeries(title: string) { return prisma.series.findFirst({ where: { title: { equals: title, mode: "insensitive" } }, orderBy: [ { imdbId: { sort: "desc", nulls: "last" } }, { tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }, ], }); } /** Alleen series met TMDB of IMDb — orphan titel-records tellen niet als match. */ private async findExistingCanonicalSeries(title: string) { return prisma.series.findFirst({ where: { title: { equals: title, mode: "insensitive" }, OR: [{ tmdbId: { not: null } }, { imdbId: { not: null } }], }, orderBy: [ { imdbId: { sort: "desc", nulls: "last" } }, { tmdbId: { sort: "desc", nulls: "last" } }, { createdAt: "asc" }, ], }); } private async searchTmdbMovie(title: string, year?: number) { if (!this.tmdbApiKey) return null; const cacheKey = `${title.toLowerCase()}::${year ?? ""}`; if (this.movieSearchCache.has(cacheKey)) { return this.movieSearchCache.get(cacheKey) ?? null; } const params = new URLSearchParams({ api_key: this.tmdbApiKey, query: title, language: TMDB_LANG, include_adult: "false", ...(year ? { year: String(year) } : {}), }); const res = await fetch(`https://api.themoviedb.org/3/search/movie?${params}`); if (!res.ok) { this.movieSearchCache.set(cacheKey, null); return null; } const data = (await res.json()) as TmdbSearchResult; const item = data.results[0]; if (!item) { this.movieSearchCache.set(cacheKey, null); return null; } const meta = await this.getTmdbMovieDetails(item.id, year); this.movieSearchCache.set(cacheKey, meta); return meta; } private async searchTmdbSeries(title: string) { if (!this.tmdbApiKey) return null; const cacheKey = title.toLowerCase(); if (this.seriesSearchCache.has(cacheKey)) { return this.seriesSearchCache.get(cacheKey) ?? null; } const params = new URLSearchParams({ api_key: this.tmdbApiKey, query: title, language: TMDB_LANG, include_adult: "false", }); const res = await fetch(`https://api.themoviedb.org/3/search/tv?${params}`); if (!res.ok) { this.seriesSearchCache.set(cacheKey, null); return null; } const data = (await res.json()) as { results?: Array<{ id: number; name?: string; original_name?: string; first_air_date?: string; overview?: string; poster_path?: string; backdrop_path?: string; }>; }; const hit = (data.results ?? []).find((item) => { const name = item.name || ""; const original = item.original_name || ""; return ( seriesTitlesMatchConfident(title, name) || (original ? seriesTitlesMatchConfident(title, original) : false) ); }); if (!hit) { this.seriesSearchCache.set(cacheKey, null); return null; } const meta = await this.getTmdbSeriesDetails(hit.id); this.seriesSearchCache.set(cacheKey, meta); return meta; } private async getTmdbMovieDetails(tmdbId: number, fallbackYear?: number): Promise { const cached = this.movieCache.get(tmdbId); if (cached) return cached; if (!this.tmdbApiKey) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}?${params}`); if (!res.ok) return null; const item = (await res.json()) as { id: number; title?: string; original_title?: string; original_language?: string; overview?: string; release_date?: string; poster_path?: string; backdrop_path?: string; adult?: boolean; }; let englishTitle: string | undefined; let englishOverview: string | undefined; const wantEnTitle = needsEnglishTitleFallback( item.title, item.original_title, item.original_language ); const wantEnOverview = !item.overview?.trim(); if (wantEnTitle || wantEnOverview) { const enParams = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG_EN, }); const enRes = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}?${enParams}`); if (enRes.ok) { const en = (await enRes.json()) as { title?: string; overview?: string }; englishTitle = en.title; englishOverview = en.overview; } } const imdbId = await this.fetchTmdbMovieImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, title: preferNlOrEnglishTitle({ localizedTitle: item.title, originalTitle: item.original_title, originalLanguage: item.original_language, englishTitle, }), originalTitle: item.original_title?.trim() || undefined, year: item.release_date ? parseInt(item.release_date.slice(0, 4), 10) : fallbackYear, overview: item.overview?.trim() || englishOverview?.trim() || undefined, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, imdbId, adult: item.adult === true, releaseDate: item.release_date || undefined, }; this.movieCache.set(tmdbId, meta); return meta; } /** Resolve IMDb id for a TMDB movie (tt…). */ async resolveMovieImdbId(tmdbId: number): Promise { const id = await this.fetchTmdbMovieImdbId(tmdbId); return id ?? null; } /** Resolve IMDb id for a TMDB series (tt…). */ async resolveSeriesImdbId(tmdbId: number): Promise { const id = await this.fetchTmdbSeriesImdbId(tmdbId); return id ?? null; } async getTvSeriesInfo(tmdbId: number): Promise<{ tmdbId: number; title: string; year: number | null; overview: string | null; posterUrl: string | null; imdbId: string | null; numberOfSeasons: number; } | null> { if (!this.tmdbApiKey || !Number.isFinite(tmdbId)) return null; const meta = await this.getTmdbSeriesDetails(tmdbId); if (!meta) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${params}`); let numberOfSeasons = 0; if (res.ok) { const item = (await res.json()) as { number_of_seasons?: number }; numberOfSeasons = Math.max(0, item.number_of_seasons ?? 0); } return { tmdbId: meta.id, title: meta.title, year: meta.year ?? null, overview: meta.overview || null, posterUrl: meta.posterUrl || null, imdbId: meta.imdbId ?? null, numberOfSeasons, }; } async getTvSeason( tmdbId: number, seasonNumber: number ): Promise<{ seasonNumber: number; name: string | null; episodes: Array<{ episodeNumber: number; title: string; overview: string | null; airDate: string | null; stillUrl: string | null; }>; } | null> { if (!this.tmdbApiKey || !Number.isFinite(tmdbId) || seasonNumber < 0) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch( `https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNumber}?${params}` ); if (!res.ok) return null; const data = (await res.json()) as { season_number?: number; name?: string; episodes?: Array<{ episode_number?: number; name?: string; overview?: string; air_date?: string; still_path?: string; }>; }; return { seasonNumber: data.season_number ?? seasonNumber, name: data.name || null, episodes: (data.episodes ?? []) .filter((e) => (e.episode_number ?? 0) > 0) .map((e) => ({ episodeNumber: e.episode_number!, title: e.name || `Aflevering ${e.episode_number}`, overview: e.overview || null, airDate: e.air_date || null, stillUrl: e.still_path ? `${TMDB_IMAGE_BASE}${e.still_path}` : null, })), }; } private async fetchTmdbMovieImdbId(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const res = await fetchTmdbJson( `https://api.themoviedb.org/3/movie/${tmdbId}/external_ids?${params}` ); 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; } private async getTmdbSeriesDetails(tmdbId: number): Promise { const cached = this.seriesCache.get(tmdbId); if (cached) return cached; if (!this.tmdbApiKey) return null; const params = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG, }); const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${params}`); if (!res.ok) return null; const item = (await res.json()) as { id: number; name?: string; original_name?: string; original_language?: string; overview?: string; first_air_date?: string; poster_path?: string; backdrop_path?: string; adult?: boolean; }; let englishTitle: string | undefined; let englishOverview: string | undefined; const wantEnTitle = needsEnglishTitleFallback( item.name, item.original_name, item.original_language ); const wantEnOverview = !item.overview?.trim(); if (wantEnTitle || wantEnOverview) { const enParams = new URLSearchParams({ api_key: this.tmdbApiKey, language: TMDB_LANG_EN, }); const enRes = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${enParams}`); if (enRes.ok) { const en = (await enRes.json()) as { name?: string; overview?: string }; englishTitle = en.name; englishOverview = en.overview; } } const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId); const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId); const meta: TmdbMeta = { id: item.id, title: preferNlOrEnglishTitle({ localizedTitle: item.name, originalTitle: item.original_name, originalLanguage: item.original_language, englishTitle, }), originalTitle: item.original_name?.trim() || undefined, year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined, overview: item.overview?.trim() || englishOverview?.trim() || undefined, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, logoUrl, imdbId, adult: item.adult === true, }; this.seriesCache.set(tmdbId, meta); return meta; } private async fetchTmdbSeriesImdbId(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const res = await fetchTmdbJson( `https://api.themoviedb.org/3/tv/${tmdbId}/external_ids?${params}` ); 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; } private async fetchTmdbSeriesLogo(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey, include_image_language: "nl,en,null", }); const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/images?${params}`); if (!res.ok) return undefined; const data = (await res.json()) as { logos?: Array<{ file_path?: string; iso_639_1?: string | null; aspect_ratio?: number; }>; }; const logos = data.logos ?? []; // Prefer wide clearlogos (aspect > 1.5) — tall/square logos blow up in Stremio desktop. const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); const pool = wide.length ? wide : logos; const preferred = pool.find((l) => l.iso_639_1 === "nl") || pool.find((l) => l.iso_639_1 === "en") || pool.find((l) => !l.iso_639_1) || pool[0]; return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; } private async fetchTmdbMovieLogo(tmdbId: number): Promise { if (!this.tmdbApiKey) return undefined; const params = new URLSearchParams({ api_key: this.tmdbApiKey, include_image_language: "nl,en,null", }); const res = await fetch(`https://api.themoviedb.org/3/movie/${tmdbId}/images?${params}`); if (!res.ok) return undefined; const data = (await res.json()) as { logos?: Array<{ file_path?: string; iso_639_1?: string | null; aspect_ratio?: number; }>; }; const logos = data.logos ?? []; const wide = logos.filter((l) => (l.aspect_ratio ?? 0) >= 1.5); const pool = wide.length ? wide : logos; const preferred = pool.find((l) => l.iso_639_1 === "nl") || pool.find((l) => l.iso_639_1 === "en") || pool.find((l) => !l.iso_639_1) || pool[0]; return preferred?.file_path ? `${TMDB_LOGO_BASE}${preferred.file_path}` : undefined; } private async refreshMovieNl(movieId: string, tmdbId: number) { const tmdb = await this.getTmdbMovieDetails(tmdbId); if (!tmdb) { return prisma.movie.findUniqueOrThrow({ where: { id: movieId } }); } return prisma.movie.update({ where: { id: movieId }, data: { title: tmdb.title, ...(tmdb.originalTitle ? { originalTitle: tmdb.originalTitle } : {}), overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }); } private async refreshSeriesNl(seriesId: string, tmdbId: number) { const tmdb = await this.getTmdbSeriesDetails(tmdbId); if (!tmdb) { return prisma.series.findUniqueOrThrow({ where: { id: seriesId } }); } return prisma.series.update({ where: { id: seriesId }, data: { title: tmdb.title, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, ...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}), }, }).then(async (row) => { if (tmdb.logoUrl) { await prisma.series .update({ where: { id: seriesId }, data: { logoUrl: tmdb.logoUrl } }) .catch(() => undefined); } return row; }); } }