stremio/apps/master-api/src/metadata/service.ts
Jos Vooges | STH f113a1ad8c Watch-first library updates with nightly 03:30 catch-up, plus TMDB episode titles and thumbnails.
Realtime fsnotify handles add/remove; optional interval scans default off; Stremio series meta now enriches episodes from TMDB (name, still, air date).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 04:21:35 +02:00

751 lines
23 KiB
TypeScript

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<ReturnType<typeof prisma.movie.create>>;
type SeriesRow = Awaited<ReturnType<typeof prisma.series.create>>;
type EpisodeRow = Awaited<ReturnType<typeof prisma.episode.create>>;
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<number, TmdbMeta>();
private seriesCache = new Map<number, TmdbMeta>();
private movieSearchCache = new Map<string, TmdbMeta | null>();
private seriesSearchCache = new Map<string, TmdbMeta | null>();
private seasonCache = new Map<string, TmdbEpisodeMeta[]>();
private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = new Set<string>();
constructor(private readonly tmdbApiKey?: string) {}
async matchMediaFile(
nodeId: string,
file: MediaFileInfo
): Promise<string | null> {
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<string, typeof movies>();
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<string, typeof seriesList>();
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<void> {
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<string | null> {
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<string | null> {
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<void> {
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<EpisodeRow> {
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<TmdbEpisodeMeta[]> {
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<void> {
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<void> {
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<void> {
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<TmdbMeta | null> {
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<TmdbMeta | null> {
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,
},
});
}
}