diff --git a/apps/master-api/.env.example b/apps/master-api/.env.example
index 2597d9b..a946e73 100644
--- a/apps/master-api/.env.example
+++ b/apps/master-api/.env.example
@@ -13,6 +13,8 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=http://localhost:3000
TMDB_API_KEY=
+# OMDb / IMDb — optionele env-fallback; liever Admin → Instellingen → OMDb (versleuteld in DB)
+OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
diff --git a/apps/master-api/src/admin/routes.ts b/apps/master-api/src/admin/routes.ts
index 122d49b..10317ae 100644
--- a/apps/master-api/src/admin/routes.ts
+++ b/apps/master-api/src/admin/routes.ts
@@ -8,7 +8,7 @@ import {
} from "../security/crypto";
import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager";
-import { MetadataService } from "../metadata/service";
+import { createMetadataService } from "../settings/metadata-factory";
import type { Config } from "../config";
import { listActiveIptvWatches } from "../iptv/active-watches";
import {
@@ -19,7 +19,7 @@ import {
} from "../metadata/filename-parser";
export async function registerAdminRoutes(app: FastifyInstance, config: Config) {
- const metadata = new MetadataService(config.TMDB_API_KEY);
+ const metadata = createMetadataService(config);
app.get("/api/v1/admin/dashboard", { preHandler: requireAdmin }, async () => {
const [
@@ -1084,7 +1084,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const q = (query.q ?? "").trim();
if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400);
if (!config.TMDB_API_KEY) {
- throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
+ const { resolveOmdbApiKey } = await import("../settings/omdb");
+ const omdbKey = await resolveOmdbApiKey(config.SESSION_SECRET, config.OMDB_API_KEY);
+ if (!omdbKey) {
+ throw new AppError("CONFIG", "TMDB_API_KEY of OMDB_API_KEY ontbreekt", 503);
+ }
}
const results = await metadata.searchCatalog(type, q);
return { results };
diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts
index 43bad90..e31450f 100644
--- a/apps/master-api/src/app.ts
+++ b/apps/master-api/src/app.ts
@@ -17,7 +17,7 @@ import { registerViewerRoutes } from "./viewer/routes";
import { registerGooglePlayRoutes } from "./google-play/routes";
import { startGooglePlayReconciliationPoller } from "./google-play/service";
import { nodeConnectionManager } from "./websocket/manager";
-import { MetadataService } from "./metadata/service";
+import { createMetadataService } from "./settings/metadata-factory";
import { toErrorResponse } from "./security/errors";
import { startIptvEpgWarmup } from "./viewer/iptv-client";
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
@@ -80,7 +80,7 @@ async function main() {
await registerStremioRoutes(app, config);
await registerAdminRoutes(app, config);
await registerInstallRoutes(app, config);
- registerSiteRoutes(app);
+ registerSiteRoutes(app, config);
const downloads = registerDownloadRoutes(app, config);
registerViewerRoutes(app, config, downloads);
const googlePlay = registerGooglePlayRoutes(app, config);
@@ -613,7 +613,7 @@ async function main() {
// Background: fill ALL missing movie + series imdbIds so Cinemeta can resolve local streams.
// Series catalog/meta stay on mcseries-* (tt only used for streams).
if (config.TMDB_API_KEY) {
- const metadata = new MetadataService(config.TMDB_API_KEY);
+ const metadata = createMetadataService(config);
setTimeout(() => {
void metadata
.backfillAllImdbIds({ delayMs: 120 })
diff --git a/apps/master-api/src/config.ts b/apps/master-api/src/config.ts
index dc74b66..ac0b592 100644
--- a/apps/master-api/src/config.ts
+++ b/apps/master-api/src/config.ts
@@ -10,6 +10,8 @@ const configSchema = z.object({
ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(),
TMDB_API_KEY: z.string().optional(),
+ /** OMDb (IMDb metadata) — used as admin search/link fallback when TMDB has no match */
+ OMDB_API_KEY: z.string().optional(),
PUBLIC_URL: z.string().url().default("http://localhost:3000"),
/** Base URL shown on TV for /link (often admin UI). Falls back to PUBLIC_URL. */
CLIENT_LINK_BASE_URL: z.preprocess(
diff --git a/apps/master-api/src/downloads/routes.ts b/apps/master-api/src/downloads/routes.ts
index f7b7a3f..60cd24e 100644
--- a/apps/master-api/src/downloads/routes.ts
+++ b/apps/master-api/src/downloads/routes.ts
@@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify";
import { requireAdmin } from "../auth/routes";
import type { Config } from "../config";
-import { MetadataService } from "../metadata/service";
+import { createMetadataService } from "../settings/metadata-factory";
import { AppError } from "../security/errors";
import {
DownloadService,
@@ -46,7 +46,7 @@ function mapJob(j: Awaited>[number]) {
}
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
- const metadata = new MetadataService(config.TMDB_API_KEY);
+ const metadata = createMetadataService(config);
const downloads = new DownloadService(config.SESSION_SECRET, metadata);
startDownloadPoller(downloads);
diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts
index c78058b..070c61c 100644
--- a/apps/master-api/src/downloads/service.ts
+++ b/apps/master-api/src/downloads/service.ts
@@ -631,7 +631,7 @@ export class DownloadService {
const hits = await this.metadata.searchCatalog(isSeries ? "series" : "movie", imdbId);
const hit = hits[0];
if (hit) {
- tmdbId = hit.tmdbId;
+ if (hit.tmdbId != null) tmdbId = hit.tmdbId;
if (hit.imdbId) imdbId = hit.imdbId;
title = hit.title;
year = hit.year;
diff --git a/apps/master-api/src/metadata/omdb.ts b/apps/master-api/src/metadata/omdb.ts
new file mode 100644
index 0000000..a5aa827
--- /dev/null
+++ b/apps/master-api/src/metadata/omdb.ts
@@ -0,0 +1,161 @@
+export type OmdbSearchHit = {
+ imdbId: string;
+ title: string;
+ year: number | null;
+ type: "movie" | "series" | "episode" | "game" | "other";
+ posterUrl: string | null;
+};
+
+export type OmdbTitle = {
+ imdbId: string;
+ title: string;
+ year: number | null;
+ overview: string | null;
+ posterUrl: string | null;
+ type: "movie" | "series" | "episode" | "game" | "other";
+ genres: string[];
+};
+
+type OmdbSearchResponse = {
+ Response?: string;
+ Error?: string;
+ Search?: Array<{
+ Title?: string;
+ Year?: string;
+ imdbID?: string;
+ Type?: string;
+ Poster?: string;
+ }>;
+};
+
+type OmdbTitleResponse = {
+ Response?: string;
+ Error?: string;
+ Title?: string;
+ Year?: string;
+ Plot?: string;
+ Poster?: string;
+ imdbID?: string;
+ Type?: string;
+ Genre?: string;
+};
+
+const OMDB_BASE = "http://www.omdbapi.com/";
+const OMDB_POSTER_BASE = "http://img.omdbapi.com/";
+
+function parseYear(raw?: string | null): number | null {
+ if (!raw) return null;
+ const m = raw.match(/(\d{4})/);
+ if (!m) return null;
+ const y = parseInt(m[1], 10);
+ return Number.isFinite(y) ? y : null;
+}
+
+function mapType(raw?: string | null): OmdbTitle["type"] {
+ const t = (raw || "").toLowerCase();
+ if (t === "movie") return "movie";
+ if (t === "series") return "series";
+ if (t === "episode") return "episode";
+ if (t === "game") return "game";
+ return "other";
+}
+
+function cleanPoster(raw?: string | null): string | null {
+ const p = raw?.trim();
+ if (!p || p === "N/A") return null;
+ return p;
+}
+
+export class OmdbClient {
+ constructor(
+ private readonly apiKey: string,
+ /** Master public URL — posters worden via onze proxy geserveerd (geen key in clients). */
+ private readonly publicBase?: string
+ ) {}
+
+ private async get(params: Record): Promise {
+ const qs = new URLSearchParams({ apikey: this.apiKey, ...params });
+ const res = await fetch(`${OMDB_BASE}?${qs}`);
+ if (!res.ok) return null;
+ return res.json();
+ }
+
+ /** Direct OMDb Poster API URL (server-side only — bevat apikey). */
+ posterApiUrl(imdbId: string, height = 600): string {
+ return `${OMDB_POSTER_BASE}?${new URLSearchParams({
+ i: imdbId,
+ h: String(height),
+ apikey: this.apiKey,
+ })}`;
+ }
+
+ /**
+ * Poster URL om op te slaan/te tonen.
+ * Heeft voorkeur voor onze proxy (OMDb Poster API achter de schermen).
+ */
+ resolvePosterUrl(imdbId: string, jsonPoster?: string | null): string | null {
+ const base = this.publicBase?.replace(/\/$/, "");
+ if (base && /^tt\d+$/i.test(imdbId)) {
+ return `${base}/api/v1/public/omdb-poster/${encodeURIComponent(imdbId)}?h=600`;
+ }
+ return cleanPoster(jsonPoster);
+ }
+
+ async search(
+ query: string,
+ type: "movie" | "series"
+ ): Promise {
+ const q = query.trim();
+ if (!q) return [];
+ const data = (await this.get({
+ s: q,
+ type: type === "series" ? "series" : "movie",
+ })) as OmdbSearchResponse | null;
+ if (!data || data.Response === "False" || !data.Search?.length) return [];
+ return data.Search.map((item) => {
+ const imdbId = item.imdbID?.trim();
+ if (!imdbId || !/^tt\d+$/i.test(imdbId)) return null;
+ return {
+ imdbId,
+ title: item.Title?.trim() || "Onbekend",
+ year: parseYear(item.Year),
+ type: mapType(item.Type),
+ posterUrl: this.resolvePosterUrl(imdbId, item.Poster),
+ } satisfies OmdbSearchHit;
+ }).filter((x): x is OmdbSearchHit => !!x);
+ }
+
+ async getByImdbId(imdbId: string): Promise {
+ const id = imdbId.trim();
+ if (!/^tt\d+$/i.test(id)) return null;
+ const data = (await this.get({ i: id, plot: "short" })) as OmdbTitleResponse | null;
+ if (!data || data.Response === "False" || !data.imdbID) return null;
+ const genres =
+ data.Genre && data.Genre !== "N/A"
+ ? data.Genre.split(",").map((g) => g.trim()).filter(Boolean)
+ : [];
+ return {
+ imdbId: data.imdbID,
+ title: data.Title?.trim() || "Onbekend",
+ year: parseYear(data.Year),
+ overview: data.Plot && data.Plot !== "N/A" ? data.Plot.trim() : null,
+ posterUrl: this.resolvePosterUrl(data.imdbID, data.Poster),
+ type: mapType(data.Type),
+ genres,
+ };
+ }
+
+ /** Fetch poster bytes via OMDb Poster API (for our public proxy). */
+ async fetchPosterBytes(
+ imdbId: string,
+ height = 600
+ ): Promise<{ body: ArrayBuffer; contentType: string } | null> {
+ const id = imdbId.trim();
+ if (!/^tt\d+$/i.test(id)) return null;
+ const res = await fetch(this.posterApiUrl(id, height));
+ if (!res.ok) return null;
+ const contentType = res.headers.get("content-type") || "image/jpeg";
+ if (!contentType.startsWith("image/")) return null;
+ return { body: await res.arrayBuffer(), contentType };
+ }
+}
diff --git a/apps/master-api/src/metadata/service.ts b/apps/master-api/src/metadata/service.ts
index 3946702..799f5f3 100644
--- a/apps/master-api/src/metadata/service.ts
+++ b/apps/master-api/src/metadata/service.ts
@@ -1,6 +1,7 @@
import { prisma } from "../database/client";
import type { MediaFileInfo } from "@media-cluster/shared-types";
import { parseSeriesFromPath } from "./filename-parser";
+import { OmdbClient } from "./omdb";
function normalizeFolderPrefix(folder: string): string {
return folder.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@@ -303,8 +304,18 @@ export class MetadataService {
private refreshedMovieIds = new Set();
private refreshedSeriesIds = new Set();
private enrichedSeriesSeasons = new Set();
+ private readonly resolveOmdbClient: () => Promise;
- constructor(private readonly tmdbApiKey?: string) {}
+ constructor(
+ private readonly tmdbApiKey?: string,
+ resolveOmdbClient?: () => Promise
+ ) {
+ this.resolveOmdbClient = resolveOmdbClient ?? (async () => null);
+ }
+
+ private async omdbClient(): Promise {
+ return this.resolveOmdbClient();
+ }
/**
* Cast, directors, rating, trailers, sized logo for Stremio detail pages.
@@ -1434,34 +1445,69 @@ export class MetadataService {
return this.deleteMediaFilesSafely(stale.map((f) => f.id));
}
- /** Admin: search TMDB by title, TMDB id, or resolve a single IMDb id. */
+ /** Admin: search TMDB (+ OMDb/IMDb fallback) by title, TMDB id, or IMDb id. */
async searchCatalog(
type: "movie" | "series",
query: string
): Promise<
Array<{
- tmdbId: number;
+ tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
+ source: "tmdb" | "omdb";
}>
> {
- if (!this.tmdbApiKey) return [];
const q = query.trim();
if (!q) return [];
+ if (!this.tmdbApiKey && !(await this.omdbClient())) return [];
- // Direct IMDb id → resolve via TMDB find
+ type CatalogHit = {
+ tmdbId: number | null;
+ title: string;
+ year: number | null;
+ overview: string | null;
+ posterUrl: string | null;
+ imdbId: string | null;
+ source: "tmdb" | "omdb";
+ };
+
+ // Direct IMDb id → TMDB find, else OMDb title
if (/^tt\d+$/i.test(q)) {
- const found = await this.findTmdbByImdb(type, q);
- return found ? [found] : [];
+ const fromTmdb = this.tmdbApiKey ? await this.findTmdbByImdb(type, q) : null;
+ if (fromTmdb) {
+ return [{ ...fromTmdb, source: "tmdb" as const }];
+ }
+ const omdbCli = await this.omdbClient();
+ const fromOmdb = omdbCli ? await omdbCli.getByImdbId(q) : null;
+ if (!fromOmdb) return [];
+ const typeOk =
+ type === "movie"
+ ? fromOmdb.type === "movie"
+ : fromOmdb.type === "series" || fromOmdb.type === "episode";
+ if (!typeOk && fromOmdb.type !== "other") {
+ // Still show it — admin may want to link a mini-series listed oddly
+ }
+ return [
+ {
+ tmdbId: null,
+ title: fromOmdb.title,
+ year: fromOmdb.year,
+ overview: fromOmdb.overview,
+ posterUrl: fromOmdb.posterUrl,
+ imdbId: fromOmdb.imdbId,
+ source: "omdb",
+ },
+ ];
}
// Direct TMDB id or themoviedb.org URL
const tmdbIdFromUrl = q.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
const tmdbIdRaw = tmdbIdFromUrl || (/^\d{1,8}$/.test(q) ? q : null);
if (tmdbIdRaw) {
+ if (!this.tmdbApiKey) return [];
const tmdbId = parseInt(tmdbIdRaw, 10);
const meta =
type === "movie"
@@ -1476,75 +1522,114 @@ export class MetadataService {
overview: meta.overview || null,
posterUrl: meta.posterUrl || null,
imdbId: meta.imdbId || null,
+ source: "tmdb",
},
];
}
- 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,
- })}`
- ),
- ]);
- const nlData = nlRes.ok ? ((await nlRes.json()) as TmdbSearchResult) : { results: [] };
- const enData = enRes.ok ? ((await enRes.json()) as TmdbSearchResult) : { results: [] };
- const nlList = nlData.results ?? [];
- const enList = enData.results ?? [];
- if (!nlList.length && !enList.length) return [];
-
- const enById = new Map(enList.map((hit) => [hit.id, hit]));
- const nlById = new Map(nlList.map((hit) => [hit.id, hit]));
- const orderedIds: number[] = [];
- const seen = new Set();
- for (const hit of [...nlList, ...enList]) {
- if (seen.has(hit.id)) continue;
- seen.add(hit.id);
- orderedIds.push(hit.id);
+ const tmdbHits: CatalogHit[] = [];
+ if (this.tmdbApiKey) {
+ 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,
+ })}`
+ ),
+ ]);
+ const nlData = nlRes.ok ? ((await nlRes.json()) as TmdbSearchResult) : { results: [] };
+ const enData = enRes.ok ? ((await enRes.json()) as TmdbSearchResult) : { results: [] };
+ const nlList = nlData.results ?? [];
+ const enList = enData.results ?? [];
+ const enById = new Map(enList.map((hit) => [hit.id, hit]));
+ const nlById = new Map(nlList.map((hit) => [hit.id, hit]));
+ const orderedIds: number[] = [];
+ const seen = new Set();
+ for (const hit of [...nlList, ...enList]) {
+ if (seen.has(hit.id)) continue;
+ seen.add(hit.id);
+ orderedIds.push(hit.id);
+ }
+ for (const id of orderedIds.slice(0, 12)) {
+ const nl = nlById.get(id);
+ const en = enById.get(id);
+ const item = nl ?? en!;
+ 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" ? nl?.title ?? item.title : nl?.name ?? item.name;
+ const original =
+ type === "movie"
+ ? item.original_title ?? en?.original_title
+ : item.original_name ?? en?.original_name;
+ const english = type === "movie" ? en?.title : en?.name;
+ const overview =
+ (nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null;
+ const posterPath = item.poster_path || en?.poster_path || nl?.poster_path;
+ tmdbHits.push({
+ tmdbId: id,
+ title: preferNlOrEnglishTitle({
+ localizedTitle: localized,
+ originalTitle: original,
+ originalLanguage: item.original_language ?? en?.original_language,
+ englishTitle: english,
+ }),
+ year: Number.isFinite(year) ? year : null,
+ overview,
+ posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : null,
+ imdbId: null,
+ source: "tmdb",
+ });
+ }
}
- return orderedIds.slice(0, 12).map((id) => {
- const nl = nlById.get(id);
- const en = enById.get(id);
- const item = nl ?? en!;
- 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" ? nl?.title ?? item.title : nl?.name ?? item.name;
- const original =
- type === "movie"
- ? item.original_title ?? en?.original_title
- : item.original_name ?? en?.original_name;
- const english = type === "movie" ? en?.title : en?.name;
- const overview =
- (nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null;
- const posterPath = item.poster_path || en?.poster_path || nl?.poster_path;
- return {
- tmdbId: id,
- title: preferNlOrEnglishTitle({
- localizedTitle: localized,
- originalTitle: original,
- originalLanguage: item.original_language ?? en?.original_language,
- englishTitle: english,
- }),
- year: Number.isFinite(year) ? year : null,
- overview,
- posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : null,
- imdbId: null,
- };
- });
+ const omdbHits: CatalogHit[] = [];
+ const omdbCliForSearch = await this.omdbClient();
+ if (omdbCliForSearch && tmdbHits.length < 8) {
+ const hits = await omdbCliForSearch.search(q, type);
+ for (const hit of hits.slice(0, 10)) {
+ omdbHits.push({
+ tmdbId: null,
+ title: hit.title,
+ year: hit.year,
+ overview: null,
+ posterUrl: hit.posterUrl,
+ imdbId: hit.imdbId,
+ source: "omdb",
+ });
+ }
+ }
+
+ // Prefer TMDB; append OMDb titles that aren't already covered by the same imdb/title+year
+ const out: CatalogHit[] = [...tmdbHits];
+ const seenImdb = new Set(
+ tmdbHits.map((h) => h.imdbId?.toLowerCase()).filter((x): x is string => !!x)
+ );
+ const seenTitleYear = new Set(
+ tmdbHits.map((h) => `${normalizeMatchTitle(h.title)}::${h.year ?? ""}`)
+ );
+ for (const hit of omdbHits) {
+ const imdbKey = hit.imdbId?.toLowerCase();
+ const ty = `${normalizeMatchTitle(hit.title)}::${hit.year ?? ""}`;
+ if (imdbKey && seenImdb.has(imdbKey)) continue;
+ if (seenTitleYear.has(ty)) continue;
+ if (imdbKey) seenImdb.add(imdbKey);
+ seenTitleYear.add(ty);
+ out.push(hit);
+ if (out.length >= 15) break;
+ }
+ return out;
}
/**
@@ -1555,16 +1640,18 @@ export class MetadataService {
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) {
+ if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
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");
+ if (!tmdbId) {
+ return this.linkMovieFromOmdb(movieId, opts.imdbId);
+ }
+ if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd");
const meta = await this.getTmdbMovieDetails(tmdbId);
if (!meta) throw new Error("TMDB-film niet gevonden");
@@ -1632,16 +1719,18 @@ export class MetadataService {
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) {
+ if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
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");
+ if (!tmdbId) {
+ return this.linkSeriesFromOmdb(seriesId, opts.imdbId);
+ }
+ if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd");
const meta = await this.getTmdbSeriesDetails(tmdbId);
if (!meta) throw new Error("TMDB-serie niet gevonden");
@@ -1722,37 +1811,52 @@ export class MetadataService {
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) {
+ if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
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");
+ let series: SeriesRow;
+ if (tmdbId != null && this.tmdbApiKey) {
+ 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
- );
+ 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({
+ 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,
@@ -1764,22 +1868,39 @@ export class MetadataService {
...(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 } : {}),
- },
- });
+ });
+ } else {
+ const imdbId = opts.imdbId?.trim();
+ const omdbCli = await this.omdbClient();
+ if (!imdbId || !omdbCli) {
+ throw new Error("Geef een TMDB-id of IMDb-id (tt…) op (OMDb vereist voor IMDb-only)");
+ }
+ const omdb = await omdbCli.getByImdbId(imdbId);
+ if (!omdb) throw new Error("IMDb-serie niet gevonden via OMDb");
+ series =
+ (await prisma.series.findFirst({ where: { imdbId: omdb.imdbId } })) ??
+ (await prisma.series.create({
+ data: {
+ title: omdb.title,
+ year: omdb.year,
+ overview: omdb.overview,
+ posterUrl: omdb.posterUrl,
+ imdbId: omdb.imdbId,
+ ...(omdb.genres.length ? { genres: omdb.genres } : {}),
+ },
+ }));
+ series = await prisma.series.update({
+ where: { id: series.id },
+ data: {
+ title: omdb.title,
+ year: omdb.year,
+ overview: omdb.overview,
+ posterUrl: omdb.posterUrl,
+ imdbId: omdb.imdbId,
+ ...(omdb.genres.length ? { genres: omdb.genres } : {}),
+ },
+ });
+ }
const files = await prisma.mediaFile.findMany({
where: {
@@ -1871,9 +1992,11 @@ export class MetadataService {
}
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);
+ if (series.tmdbId) {
+ await this.refreshSeriesNl(series.id, series.tmdbId).catch(() => undefined);
+ await this.ensureSeriesArtwork(series.id).catch(() => undefined);
+ await this.enrichSeriesEpisodes(series.id).catch(() => undefined);
+ }
return {
seriesId: series.id,
@@ -1884,6 +2007,78 @@ export class MetadataService {
};
}
+ private async linkMovieFromOmdb(
+ movieId: string,
+ imdbIdRaw?: string
+ ): Promise<{ movieId: string; merged: boolean }> {
+ const imdbId = imdbIdRaw?.trim();
+ const omdbCli = await this.omdbClient();
+ if (!imdbId || !omdbCli) {
+ throw new Error("Geef een TMDB-id of IMDb-id (tt…) op (OMDb vereist voor IMDb-only)");
+ }
+ const omdb = await omdbCli.getByImdbId(imdbId);
+ if (!omdb) throw new Error("IMDb-film niet gevonden via OMDb");
+
+ let target = await prisma.movie.findFirst({ where: { imdbId: omdb.imdbId } });
+ let merged = false;
+ const data = {
+ title: omdb.title,
+ year: omdb.year,
+ overview: omdb.overview,
+ posterUrl: omdb.posterUrl,
+ imdbId: omdb.imdbId,
+ ...(omdb.genres.length ? { genres: omdb.genres } : {}),
+ };
+ if (!target) {
+ target = await prisma.movie.update({ where: { id: movieId }, data });
+ } else {
+ target = await prisma.movie.update({ where: { id: target.id }, data });
+ 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;
+ }
+ }
+ return { movieId: target.id, merged };
+ }
+
+ private async linkSeriesFromOmdb(
+ seriesId: string,
+ imdbIdRaw?: string
+ ): Promise<{ seriesId: string; merged: boolean }> {
+ const imdbId = imdbIdRaw?.trim();
+ const omdbCli = await this.omdbClient();
+ if (!imdbId || !omdbCli) {
+ throw new Error("Geef een TMDB-id of IMDb-id (tt…) op (OMDb vereist voor IMDb-only)");
+ }
+ const omdb = await omdbCli.getByImdbId(imdbId);
+ if (!omdb) throw new Error("IMDb-serie niet gevonden via OMDb");
+
+ let target = await prisma.series.findFirst({ where: { imdbId: omdb.imdbId } });
+ let merged = false;
+ const data = {
+ title: omdb.title,
+ year: omdb.year,
+ overview: omdb.overview,
+ posterUrl: omdb.posterUrl,
+ imdbId: omdb.imdbId,
+ ...(omdb.genres.length ? { genres: omdb.genres } : {}),
+ };
+ if (!target) {
+ target = await prisma.series.update({ where: { id: seriesId }, data });
+ } else {
+ target = await prisma.series.update({ where: { id: target.id }, data });
+ if (target.id !== seriesId) {
+ await this.mergeSeriesInto(target.id, seriesId);
+ merged = true;
+ }
+ }
+ return { seriesId: target.id, merged };
+ }
+
private async findTmdbByImdb(
type: "movie" | "series",
imdbId: string
diff --git a/apps/master-api/src/settings/metadata-factory.ts b/apps/master-api/src/settings/metadata-factory.ts
new file mode 100644
index 0000000..654a2cb
--- /dev/null
+++ b/apps/master-api/src/settings/metadata-factory.ts
@@ -0,0 +1,13 @@
+import type { Config } from "../config";
+import { MetadataService } from "../metadata/service";
+import { createOmdbClientFromDb } from "./omdb";
+
+/** Shared MetadataService factory: TMDB env + OMDb key from admin DB (env fallback). */
+export function createMetadataService(config: Config): MetadataService {
+ return new MetadataService(config.TMDB_API_KEY, () =>
+ createOmdbClientFromDb(config.SESSION_SECRET, {
+ envFallback: config.OMDB_API_KEY,
+ publicBase: config.PUBLIC_URL,
+ })
+ );
+}
diff --git a/apps/master-api/src/settings/omdb.ts b/apps/master-api/src/settings/omdb.ts
new file mode 100644
index 0000000..221adb1
--- /dev/null
+++ b/apps/master-api/src/settings/omdb.ts
@@ -0,0 +1,93 @@
+import { prisma } from "../database/client";
+import { decryptSecret, encryptSecret } from "../security/crypto";
+import { AppError } from "../security/errors";
+import { OmdbClient } from "../metadata/omdb";
+
+const OMDB_ID = "omdb";
+
+export type OmdbSettingsPublic = {
+ enabled: boolean;
+ hasApiKey: boolean;
+ warning: string | null;
+};
+
+export async function getOmdbSettingsPublic(): Promise {
+ const row = await prisma.integrationSetting.findUnique({ where: { id: OMDB_ID } });
+ return {
+ enabled: row?.enabled ?? true,
+ hasApiKey: !!row?.apiKeyEnc,
+ warning: null,
+ };
+}
+
+export async function resolveOmdbApiKey(
+ sessionSecret: string,
+ envFallback?: string | null
+): Promise {
+ const row = await prisma.integrationSetting.findUnique({ where: { id: OMDB_ID } });
+ if (row && row.enabled === false) return undefined;
+ if (row?.apiKeyEnc) {
+ try {
+ const key = decryptSecret(row.apiKeyEnc, sessionSecret).trim();
+ if (key) return key;
+ } catch {
+ // fall through to env
+ }
+ }
+ const env = envFallback?.trim();
+ return env || undefined;
+}
+
+export async function createOmdbClientFromDb(
+ sessionSecret: string,
+ opts?: { envFallback?: string | null; publicBase?: string | null }
+): Promise {
+ const key = await resolveOmdbApiKey(sessionSecret, opts?.envFallback);
+ if (!key) return null;
+ return new OmdbClient(key, opts?.publicBase?.trim() || undefined);
+}
+
+export async function upsertOmdbSettings(
+ sessionSecret: string,
+ input: { apiKey?: string; enabled?: boolean },
+ publicBase?: string
+): Promise {
+ const existing = await prisma.integrationSetting.findUnique({ where: { id: OMDB_ID } });
+ const apiKeyTrim = input.apiKey?.trim();
+ const apiKeyEnc = apiKeyTrim
+ ? encryptSecret(apiKeyTrim, sessionSecret)
+ : existing?.apiKeyEnc ?? null;
+ const enabled = input.enabled ?? existing?.enabled ?? true;
+
+ if (!apiKeyEnc && enabled) {
+ throw new AppError("OMDB", "OMDb API-key is verplicht", 400);
+ }
+
+ await prisma.integrationSetting.upsert({
+ where: { id: OMDB_ID },
+ create: {
+ id: OMDB_ID,
+ apiKeyEnc,
+ enabled,
+ },
+ update: {
+ apiKeyEnc,
+ enabled,
+ },
+ });
+
+ if (enabled && apiKeyEnc) {
+ const key = decryptSecret(apiKeyEnc, sessionSecret);
+ const client = new OmdbClient(key, publicBase);
+ const hit = await client.getByImdbId("tt22184994");
+ if (!hit) {
+ const pub = await getOmdbSettingsPublic();
+ return {
+ ...pub,
+ warning: "Key opgeslagen, maar test-titel (Baymax & Mochi) gaf geen resultaat",
+ };
+ }
+ }
+
+ return getOmdbSettingsPublic();
+}
diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts
index 8ef2540..fc4afce 100644
--- a/apps/master-api/src/settings/routes.ts
+++ b/apps/master-api/src/settings/routes.ts
@@ -6,6 +6,10 @@ import {
getOpenSubtitlesSettingsPublic,
upsertOpenSubtitlesSettings,
} from "./opensubtitles";
+import {
+ getOmdbSettingsPublic,
+ upsertOmdbSettings,
+} from "./omdb";
import {
getGooglePlaySettingsPublic,
upsertGooglePlaySettings,
@@ -47,6 +51,16 @@ export function registerSettingsRoutes(
}
);
+ app.get("/api/v1/admin/settings/omdb", { preHandler: requireAdmin }, async () => {
+ return { settings: await getOmdbSettingsPublic() };
+ });
+
+ app.put("/api/v1/admin/settings/omdb", { preHandler: requireAdmin }, async (request) => {
+ const body = request.body as { apiKey?: string; enabled?: boolean };
+ const settings = await upsertOmdbSettings(config.SESSION_SECRET, body, config.PUBLIC_URL);
+ return { settings };
+ });
+
app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
return { settings: await getGooglePlaySettingsPublic(config) };
});
diff --git a/apps/master-api/src/site/routes.ts b/apps/master-api/src/site/routes.ts
index 0ff926a..776da6e 100644
--- a/apps/master-api/src/site/routes.ts
+++ b/apps/master-api/src/site/routes.ts
@@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { requireAdmin } from "../auth/routes";
import { AppError } from "../security/errors";
+import type { Config } from "../config";
+import { createOmdbClientFromDb } from "../settings/omdb";
const SITE_SETTINGS_ID = "public_site";
@@ -44,7 +46,7 @@ async function writeSiteConfig(cfg: PublicSiteConfig) {
});
}
-export function registerSiteRoutes(app: FastifyInstance) {
+export function registerSiteRoutes(app: FastifyInstance, config?: Config) {
app.get("/api/v1/public/site", async () => {
const cfg = await readSiteConfig();
return {
@@ -55,6 +57,37 @@ export function registerSiteRoutes(app: FastifyInstance) {
};
});
+ /** OMDb Poster API proxy — key blijft server-side. */
+ app.get("/api/v1/public/omdb-poster/:imdbId", async (request, reply) => {
+ if (!config) {
+ throw new AppError("CONFIG", "OMDb poster proxy niet beschikbaar", 503);
+ }
+ const { imdbId } = request.params as { imdbId: string };
+ const query = request.query as { h?: string };
+ const heightRaw = Number.parseInt(query.h ?? "600", 10);
+ const height = Number.isFinite(heightRaw)
+ ? Math.min(Math.max(heightRaw, 100), 1000)
+ : 600;
+ if (!/^tt\d+$/i.test(imdbId)) {
+ throw new AppError("INVALID_REQUEST", "Ongeldige IMDb-id", 400);
+ }
+ const client = await createOmdbClientFromDb(config.SESSION_SECRET, {
+ envFallback: config.OMDB_API_KEY,
+ publicBase: config.PUBLIC_URL,
+ });
+ if (!client) {
+ throw new AppError("NOT_CONFIGURED", "OMDb niet geconfigureerd", 404);
+ }
+ const poster = await client.fetchPosterBytes(imdbId, height);
+ if (!poster) {
+ throw new AppError("NOT_FOUND", "Poster niet gevonden", 404);
+ }
+ return reply
+ .header("Content-Type", poster.contentType)
+ .header("Cache-Control", "public, max-age=86400")
+ .send(Buffer.from(poster.body));
+ });
+
app.get("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async () => {
return readSiteConfig();
});
diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts
index a46cc97..df6e37d 100644
--- a/apps/master-api/src/stremio/routes.ts
+++ b/apps/master-api/src/stremio/routes.ts
@@ -2,7 +2,8 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client";
import { resolveAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service";
-import { MetadataService } from "../metadata/service";
+import { createMetadataService } from "../settings/metadata-factory";
+import type { MetadataService } from "../metadata/service";
import { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config";
import {
@@ -174,7 +175,7 @@ function videoReleased(
export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
const playback = new PlaybackService(config);
- const metadata = new MetadataService(config.TMDB_API_KEY);
+ const metadata = createMetadataService(config);
app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/stremio/")) return;
diff --git a/apps/master-api/src/viewer/service.ts b/apps/master-api/src/viewer/service.ts
index b93c1be..635e56b 100644
--- a/apps/master-api/src/viewer/service.ts
+++ b/apps/master-api/src/viewer/service.ts
@@ -15,6 +15,7 @@ import { cleanMeta, formatMediaFileLabel } from "./media-label";
import type { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client";
import { MetadataService } from "../metadata/service";
+import { createMetadataService } from "../settings/metadata-factory";
import { ensureOwnerProfile } from "./profiles";
import {
kidsContentWhere,
@@ -84,7 +85,7 @@ export class ViewerService {
private readonly playback: PlaybackService,
private readonly downloads?: DownloadService
) {
- this.metadata = new MetadataService(config.TMDB_API_KEY);
+ this.metadata = createMetadataService(config);
}
async createViewer(input: {
diff --git a/apps/master-api/src/websocket/manager.ts b/apps/master-api/src/websocket/manager.ts
index 0a5b6b0..fde8a73 100644
--- a/apps/master-api/src/websocket/manager.ts
+++ b/apps/master-api/src/websocket/manager.ts
@@ -5,7 +5,7 @@ import type { CreatePlaybackSessionPayload } from "@media-cluster/shared-types";
import { prisma } from "../database/client";
import { authenticateNode } from "../nodes/routes";
import { LibrarySyncService } from "../media/sync";
-import { MetadataService } from "../metadata/service";
+import { createMetadataService } from "../settings/metadata-factory";
import type { Config } from "../config";
import type {
FullLibrarySyncPayload,
@@ -77,7 +77,7 @@ class NodeConnectionManager {
init(config: Config): void {
this.config = config;
- const metadata = new MetadataService(config.TMDB_API_KEY);
+ const metadata = createMetadataService(config);
this.librarySync = new LibrarySyncService(metadata);
this.offlineCheckInterval = setInterval(() => {
diff --git a/deploy/docker/docker-compose.dokploy.yml b/deploy/docker/docker-compose.dokploy.yml
index 10a6b33..5784424 100644
--- a/deploy/docker/docker-compose.dokploy.yml
+++ b/deploy/docker/docker-compose.dokploy.yml
@@ -54,6 +54,7 @@ services:
# Koppel-URL in apps (/link). Zonder dit valt de API terug op PUBLIC_URL (master).
CLIENT_LINK_BASE_URL: ${CLIENT_LINK_BASE_URL:-https://admin.vonas.nl}
TMDB_API_KEY: ${TMDB_API_KEY:-}
+ OMDB_API_KEY: ${OMDB_API_KEY:-}
PLAYBACK_IDLE_TIMEOUT_SECONDS: ${PLAYBACK_IDLE_TIMEOUT_SECONDS:-1200}
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30}
diff --git a/deploy/dokploy/.env.example b/deploy/dokploy/.env.example
index 9ce00fc..79a56db 100644
--- a/deploy/dokploy/.env.example
+++ b/deploy/dokploy/.env.example
@@ -23,8 +23,9 @@ PUBLIC_URL=https://master.jouwdomein.nl
# Koppelpagina voor apps (admin-UI host met /link)
CLIENT_LINK_BASE_URL=https://admin.jouwdomein.nl
-# Optioneel – betere metadata matching
+# Optioneel – betere metadata matching (OMDb liever via Admin → Instellingen → OMDb)
TMDB_API_KEY=
+OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200
diff --git a/deploy/master/.env.example b/deploy/master/.env.example
index 73a24ec..3735244 100644
--- a/deploy/master/.env.example
+++ b/deploy/master/.env.example
@@ -13,8 +13,9 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=https://master.media.example.com
-# Optional TMDB API key for metadata matching
+# Optional TMDB / OMDb (OMDb preferred via Admin → Settings → OMDb)
TMDB_API_KEY=
+OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200