Add OMDb admin settings and poster API for IMDb metadata.

Store the key encrypted in the DB (not git) and proxy OMDb posters so clients never see the API key.
This commit is contained in:
Jos Vooges | STH 2026-09-16 23:33:01 +02:00
parent e8d1c4bfee
commit db8e3e572f
22 changed files with 790 additions and 153 deletions

View file

@ -53,12 +53,13 @@ interface NodeOption {
}
interface MetaHit {
tmdbId: number;
tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
source?: "tmdb" | "omdb";
}
function formatBytes(bytes: string | number | null | undefined): string {
@ -257,7 +258,7 @@ export default function MoviesPage() {
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tmdbId: hit.tmdbId,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
@ -360,7 +361,7 @@ export default function MoviesPage() {
body: JSON.stringify({
nodeId: folderRelink.nodeId,
folder: folderRelink.folder,
tmdbId: hit.tmdbId,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
@ -742,7 +743,7 @@ export default function MoviesPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{linkHits.map((hit) => (
<button
key={hit.tmdbId}
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={linkBusy}
onClick={() => void applyLink(hit)}
@ -784,7 +785,8 @@ export default function MoviesPage() {
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
</div>
@ -857,7 +859,7 @@ export default function MoviesPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{folderHits.map((hit) => (
<button
key={hit.tmdbId}
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={folderBusy}
onClick={() => void applyFolderAsSeries(hit)}
@ -899,7 +901,8 @@ export default function MoviesPage() {
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
</div>

View file

@ -15,12 +15,13 @@ interface PendingFolder {
}
interface MetaHit {
tmdbId: number;
tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
source?: "tmdb" | "omdb";
}
export default function PendingSeriesPage() {
@ -97,7 +98,7 @@ export default function PendingSeriesPage() {
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
tmdbId: hit.tmdbId,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
@ -300,7 +301,7 @@ export default function PendingSeriesPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{hits.map((hit) => (
<button
key={hit.tmdbId}
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={busy}
onClick={() => void applyHit(hit)}
@ -334,7 +335,8 @@ export default function PendingSeriesPage() {
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
</div>

View file

@ -71,12 +71,13 @@ interface PathFile {
}
interface MetaHit {
tmdbId: number;
tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
source?: "tmdb" | "omdb";
}
export default function SeriesPage() {
@ -261,7 +262,7 @@ export default function SeriesPage() {
body: JSON.stringify({
nodeId: folderRelink.nodeId,
folder: folderRelink.folder,
tmdbId: hit.tmdbId,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
@ -369,7 +370,7 @@ export default function SeriesPage() {
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tmdbId: hit.tmdbId,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
@ -807,7 +808,7 @@ export default function SeriesPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{linkHits.map((hit) => (
<button
key={hit.tmdbId}
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={linkBusy}
onClick={() => void applyLink(hit)}
@ -849,7 +850,8 @@ export default function SeriesPage() {
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
{hit.overview && (
@ -937,7 +939,7 @@ export default function SeriesPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{folderHits.map((hit) => (
<button
key={hit.tmdbId}
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={folderBusy}
onClick={() => void applyFolderRelink(hit)}
@ -979,7 +981,8 @@ export default function SeriesPage() {
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
TMDB {hit.tmdbId}
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
{hit.overview && (

View file

@ -4,13 +4,21 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav";
type SettingsTab = "admin" | "shelves" | "download-station" | "opensubtitles" | "google-play" | "tv";
type SettingsTab =
| "admin"
| "shelves"
| "download-station"
| "opensubtitles"
| "omdb"
| "google-play"
| "tv";
function parseTab(v: string | null): SettingsTab {
if (
v === "admin" ||
v === "download-station" ||
v === "opensubtitles" ||
v === "omdb" ||
v === "shelves" ||
v === "google-play" ||
v === "tv"
@ -61,6 +69,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "tv", label: "TV", short: "TV" },
{ id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "omdb", label: "OMDb", short: "OMDb" },
{ id: "google-play", label: "Google Play", short: "Play" },
];
@ -116,6 +125,14 @@ export default function SettingsPage() {
const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = useState<string | null>(null);
const [omdbApiKey, setOmdbApiKey] = useState("");
const [omdbEnabled, setOmdbEnabled] = useState(true);
const [omdbHasKey, setOmdbHasKey] = useState(false);
const [omdbBusy, setOmdbBusy] = useState(false);
const [omdbMsg, setOmdbMsg] = useState<string | null>(null);
const [omdbErr, setOmdbErr] = useState<string | null>(null);
const [omdbWarn, setOmdbWarn] = useState<string | null>(null);
const [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
@ -195,6 +212,19 @@ export default function SettingsPage() {
.catch(() => undefined);
}, []);
const loadOmdb = useCallback(() => {
fetch("/api/v1/admin/settings/omdb", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
const s = d.settings;
if (!s) return;
setOmdbEnabled(s.enabled !== false);
setOmdbHasKey(!!s.hasApiKey);
setOmdbWarn(s.warning || null);
})
.catch(() => undefined);
}, []);
const loadGooglePlay = useCallback(() => {
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
.then((r) => r.json())
@ -234,6 +264,7 @@ export default function SettingsPage() {
loadShelves();
loadDs();
loadOpenSubtitles();
loadOmdb();
loadGooglePlay();
loadTvSettings();
fetch("/api/v1/auth/me", { credentials: "include" })
@ -248,7 +279,7 @@ export default function SettingsPage() {
}
})
.catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay, loadTvSettings]);
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]);
async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true);
@ -432,6 +463,35 @@ export default function SettingsPage() {
}
}
async function saveOmdb() {
setOmdbBusy(true);
setOmdbMsg(null);
setOmdbErr(null);
setOmdbWarn(null);
try {
const res = await fetch("/api/v1/admin/settings/omdb", {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKey: omdbApiKey || undefined,
enabled: omdbEnabled,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setOmdbErr(data.error?.message ?? "Opslaan mislukt");
return;
}
setOmdbMsg(data.settings?.hasApiKey ? "Opgeslagen — OMDb OK" : "Opgeslagen");
setOmdbWarn(data.settings?.warning || null);
setOmdbApiKey("");
loadOmdb();
} finally {
setOmdbBusy(false);
}
}
async function saveGooglePlay() {
setGpBusy(true);
setGpMsg(null);
@ -1102,6 +1162,53 @@ export default function SettingsPage() {
</section>
)}
{tab === "omdb" && (
<section className="stack-section">
<div className="section-head">
<h2 className="section-title">OMDb</h2>
<p className="muted">
IMDb-metadata en posters via{" "}
<a href="https://www.omdbapi.com/" target="_blank" rel="noreferrer">
OMDb
</a>
. Key wordt versleuteld in de database opgeslagen (niet in git). Posters gaan via
de OMDb Poster API en onze proxy — de key komt nooit in client-URL&apos;s.
</p>
</div>
<div className="card">
<div className="form-grid">
<label>
API-key {omdbHasKey ? "(leeg = behouden)" : ""}
<input
type="password"
value={omdbApiKey}
onChange={(e) => setOmdbApiKey(e.target.value)}
placeholder={omdbHasKey ? "••••••••" : "OMDb API-key"}
autoComplete="off"
/>
</label>
<label className="check-label" style={{ alignSelf: "end" }}>
<input
type="checkbox"
checked={omdbEnabled}
onChange={(e) => setOmdbEnabled(e.target.checked)}
/>
Ingeschakeld
</label>
</div>
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
<button type="button" disabled={omdbBusy} onClick={() => void saveOmdb()}>
{omdbBusy ? "Bezig…" : "Opslaan + testen"}
</button>
<span className="muted">Status: {omdbHasKey && omdbEnabled ? "aan" : "uit"}</span>
{omdbMsg && <span className="ok-text">{omdbMsg}</span>}
{omdbWarn && <span className="danger-text">{omdbWarn}</span>}
{omdbErr && <span className="danger-text">{omdbErr}</span>}
</div>
</div>
</section>
)}
{tab === "google-play" && (
<section className="stack-section">
<div className="section-head">

View file

@ -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

View file

@ -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 };

View file

@ -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 })

View file

@ -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(

View file

@ -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<ReturnType<DownloadService["listJobs"]>>[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);

View file

@ -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;

View file

@ -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<string, string>): Promise<unknown> {
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<OmdbSearchHit[]> {
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<OmdbTitle | null> {
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 };
}
}

View file

@ -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<string>();
private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = new Set<string>();
private readonly resolveOmdbClient: () => Promise<OmdbClient | null>;
constructor(private readonly tmdbApiKey?: string) {}
constructor(
private readonly tmdbApiKey?: string,
resolveOmdbClient?: () => Promise<OmdbClient | null>
) {
this.resolveOmdbClient = resolveOmdbClient ?? (async () => null);
}
private async omdbClient(): Promise<OmdbClient | null> {
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<number>();
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<number>();
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

View file

@ -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,
})
);
}

View file

@ -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<OmdbSettingsPublic> {
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<string | undefined> {
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<OmdbClient | null> {
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<OmdbSettingsPublic> {
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();
}

View file

@ -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) };
});

View file

@ -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();
});

View file

@ -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;

View file

@ -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: {

View file

@ -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(() => {

View file

@ -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}

View file

@ -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

View file

@ -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