Prevents German (and other) originals from being stored as the display title when no nl-NL translation exists.
2721 lines
87 KiB
TypeScript
2721 lines
87 KiB
TypeScript
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;
|
||
});
|
||
}
|
||
}
|