import { prisma } from "../database/client"; import type { MediaFileInfo } from "@media-cluster/shared-types"; interface TmdbSearchResult { results: Array<{ id: number; title?: string; name?: 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"; const TMDB_BACKDROP_BASE = "https://image.tmdb.org/t/p/original"; const TMDB_LANG = "nl-NL"; type TmdbMeta = { id: number; title: string; year?: number; overview?: string; posterUrl?: string; backdropUrl?: 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.stillUrl) return true; if (!ep.overview) return true; if (!ep.title || /^episode\s+\d+$/i.test(ep.title.trim())) return true; return false; } export class MetadataService { private movieCache = new Map(); private seriesCache = new Map(); private movieSearchCache = new Map(); private seriesSearchCache = new Map(); private seasonCache = new Map(); private refreshedMovieIds = new Set(); private refreshedSeriesIds = new Set(); private enrichedSeriesSeasons = new Set(); constructor(private readonly tmdbApiKey?: string) {} async matchMediaFile( nodeId: string, file: MediaFileInfo ): Promise { 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.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.title.trim().toLowerCase(); 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.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 { 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 } }); } 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 prisma.movie.upsert({ where: { tmdbId: tmdb.id }, create: { title: tmdb.title, year: tmdb.year, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, tmdbId: tmdb.id, }, update: { title: tmdb.title, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, }, }); 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(); let series: SeriesRow | null = await this.findExistingSeries(title); if (this.tmdbApiKey && (!series || series.tmdbId == null)) { 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, }, update: { title: tmdb.title, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, }, }); await this.absorbOrphanSeries(series, title); } } if (!series) { series = await this.findExistingSeries(title); } if ( series?.tmdbId && this.tmdbApiKey && !this.refreshedSeriesIds.has(series.id) ) { series = await this.refreshSeriesNl(series.id, series.tmdbId); this.refreshedSeriesIds.add(series.id); } if (!series) { series = await prisma.series.create({ data: { title }, }); } 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 }); return episode.id; } /** Fill episode titles/stills from TMDB for a series (lazy, used by Stremio meta). */ async enrichSeriesEpisodes(seriesId: string): Promise { if (!this.tmdbApiKey) return; const series = await prisma.series.findUnique({ where: { id: seriesId }, include: { episodes: { select: { id: true, seasonNumber: true, episodeNumber: true, title: true, overview: true, stillUrl: 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) continue; for (const ep of series.episodes.filter((e) => e.seasonNumber === seasonNumber)) { const meta = metas.find((m) => m.episodeNumber === ep.episodeNumber); if (!meta) continue; 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: meta.airDate ? new Date(meta.airDate) : undefined, }, }); } this.enrichedSeriesSeasons.add(key); } } private async applyTmdbEpisodeMeta( seriesTmdbId: number, episode: EpisodeRow ): Promise { if (!needsEpisodeEnrichment(episode)) return episode; const metas = await this.fetchTmdbSeasonEpisodes(seriesTmdbId, episode.seasonNumber); const meta = metas.find((m) => m.episodeNumber === episode.episodeNumber); if (!meta) return episode; return 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: meta.airDate ? new Date(meta.airDate) : undefined, }, }); } 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; } private async absorbOrphanMovies( canonical: MovieRow, localTitle: string, year?: number ): Promise { const titles = Array.from( new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) ); const orphans = await prisma.movie.findMany({ where: { id: { not: canonical.id }, tmdbId: null, AND: [ { OR: titles.map((t) => ({ title: { equals: t, mode: "insensitive" as const }, })), }, ...(year ? [{ OR: [{ year }, { year: null }] }] : []), ], }, }); for (const orphan of orphans) { 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 titles = Array.from( new Set([canonical.title.trim(), localTitle.trim()].filter(Boolean)) ); const orphans = await prisma.series.findMany({ where: { id: { not: canonical.id }, tmdbId: null, OR: titles.map((t) => ({ title: { equals: t, mode: "insensitive" as const }, })), }, }); for (const orphan of orphans) { await this.mergeSeriesInto(canonical.id, orphan.id); } } private async upsertMediaFile( nodeId: string, file: MediaFileInfo, refs: { movieId?: string; episodeId?: string } ): Promise { await prisma.mediaFile.upsert({ where: { nodeId_localFileId: { nodeId, localFileId: file.localFileId }, }, create: { nodeId, localFileId: file.localFileId, movieId: refs.movieId, episodeId: refs.episodeId, sizeBytes: BigInt(file.sizeBytes), container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, available: true, lastSeen: new Date(file.modifiedAt), }, update: { movieId: refs.movieId, episodeId: refs.episodeId, sizeBytes: BigInt(file.sizeBytes), container: file.container, resolution: file.resolution, videoCodec: file.videoCodec, audioCodec: file.audioCodec, releaseName: file.releaseName, available: true, lastSeen: new Date(file.modifiedAt), }, }); } private async findExistingMovie(title: string, year?: number) { if (year) { const withYear = await prisma.movie.findFirst({ where: { title: { equals: title, mode: "insensitive" }, year, }, orderBy: { tmdbId: { sort: "desc", nulls: "last" } }, }); if (withYear) return withYear; } return prisma.movie.findFirst({ where: { title: { equals: title, mode: "insensitive" } }, orderBy: { tmdbId: { sort: "desc", nulls: "last" } }, }); } private async findExistingSeries(title: string) { return prisma.series.findFirst({ where: { title: { equals: title, mode: "insensitive" } }, orderBy: { tmdbId: { sort: "desc", nulls: "last" } }, }); } 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 TmdbSearchResult; const item = data.results[0]; if (!item) { this.seriesSearchCache.set(cacheKey, null); return null; } const meta = await this.getTmdbSeriesDetails(item.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; overview?: string; release_date?: string; poster_path?: string; backdrop_path?: string; }; const meta: TmdbMeta = { id: item.id, title: item.title || "Onbekend", year: item.release_date ? parseInt(item.release_date.slice(0, 4), 10) : fallbackYear, overview: item.overview || undefined, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, }; this.movieCache.set(tmdbId, meta); return meta; } 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; overview?: string; first_air_date?: string; poster_path?: string; backdrop_path?: string; }; const meta: TmdbMeta = { id: item.id, title: item.name || "Onbekend", year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : undefined, overview: item.overview || undefined, posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : undefined, backdropUrl: item.backdrop_path ? `${TMDB_BACKDROP_BASE}${item.backdrop_path}` : undefined, }; this.seriesCache.set(tmdbId, meta); return meta; } 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, overview: tmdb.overview, posterUrl: tmdb.posterUrl, backdropUrl: tmdb.backdropUrl, year: tmdb.year, }, }); } 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, }, }); } }