stremio/apps/master-api/src/metadata/service.ts

1722 lines
55 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";
/** 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 METAHUB_LOGO = (imdbId: string) =>
`https://images.metahub.space/logo/medium/${imdbId}/img`;
type TmdbMeta = {
id: number;
title: string;
year?: number;
overview?: string;
posterUrl?: string;
backdropUrl?: string;
logoUrl?: string;
imdbId?: string;
};
/** 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> {
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 } });
if (!seed) return { merged: 0, canonicalId: seriesId };
const all = await prisma.series.findMany({
select: {
id: true,
title: true,
tmdbId: true,
imdbId: true,
createdAt: true,
_count: { select: { episodes: true } },
},
});
const norm = normalizeSeriesKey(seed.title);
const group = all.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;
if (s.tmdbId != null && seed.tmdbId != null && s.tmdbId === seed.tmdbId) return true;
return normalizeSeriesKey(s.title) === norm;
});
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 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,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
update: {
title: tmdb.title,
overview: tmdb.overview,
posterUrl: tmdb.posterUrl,
backdropUrl: tmdb.backdropUrl,
year: tmdb.year,
...(tmdb.imdbId ? { imdbId: tmdb.imdbId } : {}),
},
});
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,
},
});
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) {
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;
}
/** 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 } });
if (!seed) return { merged: 0, canonicalId: movieId };
const all = await prisma.movie.findMany({
select: {
id: true,
title: true,
year: true,
tmdbId: true,
imdbId: true,
createdAt: true,
_count: { select: { mediaFiles: true } },
},
});
const norm = normalizeSeriesKey(seed.title);
const group = all.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;
});
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, then backfill missing IMDb ids.
*/
async repairLibraryAfterPathMigration(): Promise<{
moviesMerged: number;
seriesMerged: number;
staleFilesRemoved: number;
imdb: Awaited<ReturnType<MetadataService["backfillAllImdbIds"]>>;
}> {
let moviesMerged = 0;
let seriesMerged = 0;
const movies = await prisma.movie.findMany({
select: { id: true },
orderBy: { createdAt: "asc" },
});
const seenMovies = new Set<string>();
for (const m of movies) {
if (seenMovies.has(m.id)) continue;
if (!(await prisma.movie.findUnique({ where: { id: m.id }, select: { id: true } }))) {
continue;
}
const { merged, canonicalId } = await this.consolidateMovies(m.id);
seenMovies.add(canonicalId);
moviesMerged += merged;
}
const seriesList = await prisma.series.findMany({
select: { id: true },
orderBy: { createdAt: "asc" },
});
const seenSeries = new Set<string>();
for (const s of seriesList) {
if (seenSeries.has(s.id)) continue;
if (!(await prisma.series.findUnique({ where: { id: s.id }, select: { id: true } }))) {
continue;
}
const { merged, canonicalId } = await this.consolidateSeries(s.id);
seenSeries.add(canonicalId);
seriesMerged += merged;
}
// Unavailable rows left after the /media/movies → /mnt/user migration
const staleFilesRemoved = (
await prisma.mediaFile.deleteMany({
where: { available: false },
})
).count;
const imdb = await this.backfillAllImdbIds({ delayMs: 80 });
return { moviesMerged, seriesMerged, staleFilesRemoved, imdb };
}
/** 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 params = new URLSearchParams({
api_key: this.tmdbApiKey,
query: q,
language: TMDB_LANG,
include_adult: "false",
});
const path = type === "movie" ? "search/movie" : "search/tv";
const res = await fetch(`https://api.themoviedb.org/3/${path}?${params}`);
if (!res.ok) return [];
const data = (await res.json()) as TmdbSearchResult;
return (data.results ?? []).slice(0, 12).map((item) => {
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: (type === "movie" ? item.title : item.name) || "Onbekend",
year: Number.isFinite(year) ? year : null,
overview: item.overview || null,
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 };
}
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 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: (type === "movie" ? item.title : item.name) || "Onbekend",
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) 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) 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 }): 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 movies = await this.backfillMovieImdbIds({ delayMs });
const series = await this.backfillSeriesImdbIds({ delayMs });
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);
}
}
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,
shelfId: file.shelfId || null,
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,
...(file.shelfId !== undefined ? { shelfId: file.shelfId || null } : {}),
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: [
{ 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" },
],
});
}
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 imdbId = await this.fetchTmdbMovieImdbId(tmdbId);
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,
imdbId,
};
this.movieCache.set(tmdbId, meta);
return meta;
}
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey });
const res = await fetch(
`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;
overview?: string;
first_air_date?: string;
poster_path?: string;
backdrop_path?: string;
};
const logoUrl = await this.fetchTmdbSeriesLogo(tmdbId);
const imdbId = await this.fetchTmdbSeriesImdbId(tmdbId);
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,
logoUrl,
imdbId,
};
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 fetch(
`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,
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;
});
}
}