stremio/apps/master-api/src/metadata/service.ts
Jos Vooges | STH 40a5ecd435 Fall back to English TMDB titles when Dutch is missing.
Prevents German (and other) originals from being stored as the display title when no nl-NL translation exists.
2026-09-16 20:40:43 +02:00

2721 lines
87 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
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<Response | null> {
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<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.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<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 richExtrasCache = new Map<string, StremioMetaExtras>();
private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = new Set<string>();
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<StremioMetaExtras> {
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<string | null> {
// 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<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.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<string, typeof seriesList>();
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<void> {
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<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 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<string | null> {
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<void> {
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<void> {
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<EpisodeRow> {
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<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;
}
/**
* 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<ReturnType<MetadataService["rematchLibraryMetadata"]>>;
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
}> {
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<Array<MoviePick & { title: string; year: number | null }>> {
const norm = normalizeSeriesKey(seed.title);
const orClauses: Array<Record<string, unknown>> = [
{ 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<Array<SeriesPick & { title: string }>> {
const norm = normalizeSeriesKey(seed.title);
const orClauses: Array<Record<string, unknown>> = [
{ 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<number> {
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<string, typeof all>();
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<number> {
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<string, typeof all>();
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<number> {
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<string, typeof files>();
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<number> {
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<number> {
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<number, TmdbSearchResult["results"][number]>();
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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<MovieRow> {
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<void> {
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<void> {
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<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;
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<string | null> {
const id = await this.fetchTmdbMovieImdbId(tmdbId);
return id ?? null;
}
/** Resolve IMDb id for a TMDB series (tt…). */
async resolveSeriesImdbId(tmdbId: number): Promise<string | null> {
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<string | undefined> {
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<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;
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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;
});
}
}