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

View file

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

View file

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

View file

@ -4,13 +4,21 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Nav, useAuth } from "@/components/Nav"; 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 { function parseTab(v: string | null): SettingsTab {
if ( if (
v === "admin" || v === "admin" ||
v === "download-station" || v === "download-station" ||
v === "opensubtitles" || v === "opensubtitles" ||
v === "omdb" ||
v === "shelves" || v === "shelves" ||
v === "google-play" || v === "google-play" ||
v === "tv" v === "tv"
@ -61,6 +69,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
{ id: "tv", label: "TV", short: "TV" }, { id: "tv", label: "TV", short: "TV" },
{ id: "download-station", label: "Download Station", short: "DS" }, { id: "download-station", label: "Download Station", short: "DS" },
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" }, { id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
{ id: "omdb", label: "OMDb", short: "OMDb" },
{ id: "google-play", label: "Google Play", short: "Play" }, { id: "google-play", label: "Google Play", short: "Play" },
]; ];
@ -116,6 +125,14 @@ export default function SettingsPage() {
const [osErr, setOsErr] = useState<string | null>(null); const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = 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 [adminEmail, setAdminEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState(""); const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState(""); const [newPassword, setNewPassword] = useState("");
@ -195,6 +212,19 @@ export default function SettingsPage() {
.catch(() => undefined); .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(() => { const loadGooglePlay = useCallback(() => {
fetch("/api/v1/admin/settings/google-play", { credentials: "include" }) fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
.then((r) => r.json()) .then((r) => r.json())
@ -234,6 +264,7 @@ export default function SettingsPage() {
loadShelves(); loadShelves();
loadDs(); loadDs();
loadOpenSubtitles(); loadOpenSubtitles();
loadOmdb();
loadGooglePlay(); loadGooglePlay();
loadTvSettings(); loadTvSettings();
fetch("/api/v1/auth/me", { credentials: "include" }) fetch("/api/v1/auth/me", { credentials: "include" })
@ -248,7 +279,7 @@ export default function SettingsPage() {
} }
}) })
.catch(() => undefined); .catch(() => undefined);
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay, loadTvSettings]); }, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]);
async function saveInstallPageEnabled(enabled: boolean) { async function saveInstallPageEnabled(enabled: boolean) {
setInstallPageBusy(true); 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() { async function saveGooglePlay() {
setGpBusy(true); setGpBusy(true);
setGpMsg(null); setGpMsg(null);
@ -1102,6 +1162,53 @@ export default function SettingsPage() {
</section> </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" && ( {tab === "google-play" && (
<section className="stack-section"> <section className="stack-section">
<div className="section-head"> <div className="section-head">

View file

@ -13,6 +13,8 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=http://localhost:3000 PUBLIC_URL=http://localhost:3000
TMDB_API_KEY= TMDB_API_KEY=
# OMDb / IMDb — optionele env-fallback; liever Admin → Instellingen → OMDb (versleuteld in DB)
OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200

View file

@ -8,7 +8,7 @@ import {
} from "../security/crypto"; } from "../security/crypto";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { nodeConnectionManager } from "../websocket/manager"; import { nodeConnectionManager } from "../websocket/manager";
import { MetadataService } from "../metadata/service"; import { createMetadataService } from "../settings/metadata-factory";
import type { Config } from "../config"; import type { Config } from "../config";
import { listActiveIptvWatches } from "../iptv/active-watches"; import { listActiveIptvWatches } from "../iptv/active-watches";
import { import {
@ -19,7 +19,7 @@ import {
} from "../metadata/filename-parser"; } from "../metadata/filename-parser";
export async function registerAdminRoutes(app: FastifyInstance, config: Config) { 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 () => { app.get("/api/v1/admin/dashboard", { preHandler: requireAdmin }, async () => {
const [ const [
@ -1084,7 +1084,11 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
const q = (query.q ?? "").trim(); const q = (query.q ?? "").trim();
if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400); if (!q) throw new AppError("INVALID_REQUEST", "q is verplicht", 400);
if (!config.TMDB_API_KEY) { 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); const results = await metadata.searchCatalog(type, q);
return { results }; return { results };

View file

@ -17,7 +17,7 @@ import { registerViewerRoutes } from "./viewer/routes";
import { registerGooglePlayRoutes } from "./google-play/routes"; import { registerGooglePlayRoutes } from "./google-play/routes";
import { startGooglePlayReconciliationPoller } from "./google-play/service"; import { startGooglePlayReconciliationPoller } from "./google-play/service";
import { nodeConnectionManager } from "./websocket/manager"; import { nodeConnectionManager } from "./websocket/manager";
import { MetadataService } from "./metadata/service"; import { createMetadataService } from "./settings/metadata-factory";
import { toErrorResponse } from "./security/errors"; import { toErrorResponse } from "./security/errors";
import { startIptvEpgWarmup } from "./viewer/iptv-client"; import { startIptvEpgWarmup } from "./viewer/iptv-client";
import { applyIptvEpgUrlsToRuntime } from "./settings/iptv"; import { applyIptvEpgUrlsToRuntime } from "./settings/iptv";
@ -80,7 +80,7 @@ async function main() {
await registerStremioRoutes(app, config); await registerStremioRoutes(app, config);
await registerAdminRoutes(app, config); await registerAdminRoutes(app, config);
await registerInstallRoutes(app, config); await registerInstallRoutes(app, config);
registerSiteRoutes(app); registerSiteRoutes(app, config);
const downloads = registerDownloadRoutes(app, config); const downloads = registerDownloadRoutes(app, config);
registerViewerRoutes(app, config, downloads); registerViewerRoutes(app, config, downloads);
const googlePlay = registerGooglePlayRoutes(app, config); 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. // Background: fill ALL missing movie + series imdbIds so Cinemeta can resolve local streams.
// Series catalog/meta stay on mcseries-* (tt only used for streams). // Series catalog/meta stay on mcseries-* (tt only used for streams).
if (config.TMDB_API_KEY) { if (config.TMDB_API_KEY) {
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = createMetadataService(config);
setTimeout(() => { setTimeout(() => {
void metadata void metadata
.backfillAllImdbIds({ delayMs: 120 }) .backfillAllImdbIds({ delayMs: 120 })

View file

@ -10,6 +10,8 @@ const configSchema = z.object({
ADMIN_EMAIL: z.string().email().optional(), ADMIN_EMAIL: z.string().email().optional(),
ADMIN_PASSWORD: z.string().min(8).optional(), ADMIN_PASSWORD: z.string().min(8).optional(),
TMDB_API_KEY: z.string().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"), PUBLIC_URL: z.string().url().default("http://localhost:3000"),
/** Base URL shown on TV for /link (often admin UI). Falls back to PUBLIC_URL. */ /** Base URL shown on TV for /link (often admin UI). Falls back to PUBLIC_URL. */
CLIENT_LINK_BASE_URL: z.preprocess( CLIENT_LINK_BASE_URL: z.preprocess(

View file

@ -1,7 +1,7 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { requireAdmin } from "../auth/routes"; import { requireAdmin } from "../auth/routes";
import type { Config } from "../config"; import type { Config } from "../config";
import { MetadataService } from "../metadata/service"; import { createMetadataService } from "../settings/metadata-factory";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { import {
DownloadService, DownloadService,
@ -46,7 +46,7 @@ function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
} }
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService { 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); const downloads = new DownloadService(config.SESSION_SECRET, metadata);
startDownloadPoller(downloads); startDownloadPoller(downloads);

View file

@ -631,7 +631,7 @@ export class DownloadService {
const hits = await this.metadata.searchCatalog(isSeries ? "series" : "movie", imdbId); const hits = await this.metadata.searchCatalog(isSeries ? "series" : "movie", imdbId);
const hit = hits[0]; const hit = hits[0];
if (hit) { if (hit) {
tmdbId = hit.tmdbId; if (hit.tmdbId != null) tmdbId = hit.tmdbId;
if (hit.imdbId) imdbId = hit.imdbId; if (hit.imdbId) imdbId = hit.imdbId;
title = hit.title; title = hit.title;
year = hit.year; 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 { prisma } from "../database/client";
import type { MediaFileInfo } from "@media-cluster/shared-types"; import type { MediaFileInfo } from "@media-cluster/shared-types";
import { parseSeriesFromPath } from "./filename-parser"; import { parseSeriesFromPath } from "./filename-parser";
import { OmdbClient } from "./omdb";
function normalizeFolderPrefix(folder: string): string { function normalizeFolderPrefix(folder: string): string {
return folder.trim().replace(/\\/g, "/").replace(/\/+$/, ""); return folder.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@ -303,8 +304,18 @@ export class MetadataService {
private refreshedMovieIds = new Set<string>(); private refreshedMovieIds = new Set<string>();
private refreshedSeriesIds = new Set<string>(); private refreshedSeriesIds = new Set<string>();
private enrichedSeriesSeasons = 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. * 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)); 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( async searchCatalog(
type: "movie" | "series", type: "movie" | "series",
query: string query: string
): Promise< ): Promise<
Array<{ Array<{
tmdbId: number; tmdbId: number | null;
title: string; title: string;
year: number | null; year: number | null;
overview: string | null; overview: string | null;
posterUrl: string | null; posterUrl: string | null;
imdbId: string | null; imdbId: string | null;
source: "tmdb" | "omdb";
}> }>
> { > {
if (!this.tmdbApiKey) return [];
const q = query.trim(); const q = query.trim();
if (!q) return []; 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)) { if (/^tt\d+$/i.test(q)) {
const found = await this.findTmdbByImdb(type, q); const fromTmdb = this.tmdbApiKey ? await this.findTmdbByImdb(type, q) : null;
return found ? [found] : []; 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 // Direct TMDB id or themoviedb.org URL
const tmdbIdFromUrl = q.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1]; const tmdbIdFromUrl = q.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
const tmdbIdRaw = tmdbIdFromUrl || (/^\d{1,8}$/.test(q) ? q : null); const tmdbIdRaw = tmdbIdFromUrl || (/^\d{1,8}$/.test(q) ? q : null);
if (tmdbIdRaw) { if (tmdbIdRaw) {
if (!this.tmdbApiKey) return [];
const tmdbId = parseInt(tmdbIdRaw, 10); const tmdbId = parseInt(tmdbIdRaw, 10);
const meta = const meta =
type === "movie" type === "movie"
@ -1476,10 +1522,13 @@ export class MetadataService {
overview: meta.overview || null, overview: meta.overview || null,
posterUrl: meta.posterUrl || null, posterUrl: meta.posterUrl || null,
imdbId: meta.imdbId || null, imdbId: meta.imdbId || null,
source: "tmdb",
}, },
]; ];
} }
const tmdbHits: CatalogHit[] = [];
if (this.tmdbApiKey) {
const path = type === "movie" ? "search/movie" : "search/tv"; const path = type === "movie" ? "search/movie" : "search/tv";
const base = { const base = {
api_key: this.tmdbApiKey, api_key: this.tmdbApiKey,
@ -1504,8 +1553,6 @@ export class MetadataService {
const enData = enRes.ok ? ((await enRes.json()) as TmdbSearchResult) : { results: [] }; const enData = enRes.ok ? ((await enRes.json()) as TmdbSearchResult) : { results: [] };
const nlList = nlData.results ?? []; const nlList = nlData.results ?? [];
const enList = enData.results ?? []; const enList = enData.results ?? [];
if (!nlList.length && !enList.length) return [];
const enById = new Map(enList.map((hit) => [hit.id, hit])); const enById = new Map(enList.map((hit) => [hit.id, hit]));
const nlById = new Map(nlList.map((hit) => [hit.id, hit])); const nlById = new Map(nlList.map((hit) => [hit.id, hit]));
const orderedIds: number[] = []; const orderedIds: number[] = [];
@ -1515,8 +1562,7 @@ export class MetadataService {
seen.add(hit.id); seen.add(hit.id);
orderedIds.push(hit.id); orderedIds.push(hit.id);
} }
for (const id of orderedIds.slice(0, 12)) {
return orderedIds.slice(0, 12).map((id) => {
const nl = nlById.get(id); const nl = nlById.get(id);
const en = enById.get(id); const en = enById.get(id);
const item = nl ?? en!; const item = nl ?? en!;
@ -1531,7 +1577,7 @@ export class MetadataService {
const overview = const overview =
(nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null; (nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null;
const posterPath = item.poster_path || en?.poster_path || nl?.poster_path; const posterPath = item.poster_path || en?.poster_path || nl?.poster_path;
return { tmdbHits.push({
tmdbId: id, tmdbId: id,
title: preferNlOrEnglishTitle({ title: preferNlOrEnglishTitle({
localizedTitle: localized, localizedTitle: localized,
@ -1543,9 +1589,48 @@ export class MetadataService {
overview, overview,
posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : null, posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : null,
imdbId: null, imdbId: null,
}; source: "tmdb",
}); });
} }
}
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;
}
/** /**
* Admin: bind a library movie to a TMDB/IMDb title and refresh metadata. * Admin: bind a library movie to a TMDB/IMDb title and refresh metadata.
@ -1555,16 +1640,18 @@ export class MetadataService {
movieId: string, movieId: string,
opts: { tmdbId?: number; imdbId?: string } opts: { tmdbId?: number; imdbId?: string }
): Promise<{ movieId: string; merged: boolean }> { ): 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 } }); const source = await prisma.movie.findUnique({ where: { id: movieId } });
if (!source) throw new Error("Film niet gevonden"); if (!source) throw new Error("Film niet gevonden");
let tmdbId = opts.tmdbId; let tmdbId = opts.tmdbId;
if (!tmdbId && opts.imdbId) { if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
const hit = await this.findTmdbByImdb("movie", opts.imdbId.trim()); const hit = await this.findTmdbByImdb("movie", opts.imdbId.trim());
tmdbId = hit?.tmdbId; 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); const meta = await this.getTmdbMovieDetails(tmdbId);
if (!meta) throw new Error("TMDB-film niet gevonden"); if (!meta) throw new Error("TMDB-film niet gevonden");
@ -1632,16 +1719,18 @@ export class MetadataService {
seriesId: string, seriesId: string,
opts: { tmdbId?: number; imdbId?: string } opts: { tmdbId?: number; imdbId?: string }
): Promise<{ seriesId: string; merged: boolean }> { ): 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 } }); const source = await prisma.series.findUnique({ where: { id: seriesId } });
if (!source) throw new Error("Serie niet gevonden"); if (!source) throw new Error("Serie niet gevonden");
let tmdbId = opts.tmdbId; let tmdbId = opts.tmdbId;
if (!tmdbId && opts.imdbId) { if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
const hit = await this.findTmdbByImdb("series", opts.imdbId.trim()); const hit = await this.findTmdbByImdb("series", opts.imdbId.trim());
tmdbId = hit?.tmdbId; 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); const meta = await this.getTmdbSeriesDetails(tmdbId);
if (!meta) throw new Error("TMDB-serie niet gevonden"); if (!meta) throw new Error("TMDB-serie niet gevonden");
@ -1722,17 +1811,17 @@ export class MetadataService {
failed: number; failed: number;
total: number; total: number;
}> { }> {
if (!this.tmdbApiKey) throw new Error("TMDB_API_KEY niet geconfigureerd");
const folder = normalizeFolderPrefix(opts.folder); const folder = normalizeFolderPrefix(opts.folder);
if (!folder) throw new Error("Map-pad is verplicht"); if (!folder) throw new Error("Map-pad is verplicht");
let tmdbId = opts.tmdbId; let tmdbId = opts.tmdbId;
if (!tmdbId && opts.imdbId) { if (!tmdbId && opts.imdbId && this.tmdbApiKey) {
const hit = await this.findTmdbByImdb("series", opts.imdbId.trim()); const hit = await this.findTmdbByImdb("series", opts.imdbId.trim());
tmdbId = hit?.tmdbId; tmdbId = hit?.tmdbId;
} }
if (!tmdbId) throw new Error("Geef een TMDB-id of IMDb-id (tt…) op");
let series: SeriesRow;
if (tmdbId != null && this.tmdbApiKey) {
const meta = await this.getTmdbSeriesDetails(tmdbId); const meta = await this.getTmdbSeriesDetails(tmdbId);
if (!meta) throw new Error("TMDB-serie niet gevonden"); if (!meta) throw new Error("TMDB-serie niet gevonden");
@ -1743,7 +1832,7 @@ export class MetadataService {
meta.logoUrl meta.logoUrl
); );
let series = series =
(await prisma.series.findFirst({ (await prisma.series.findFirst({
where: { where: {
OR: [ OR: [
@ -1780,6 +1869,38 @@ export class MetadataService {
...(extras.genres?.length ? { genres: extras.genres } : {}), ...(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({ const files = await prisma.mediaFile.findMany({
where: { where: {
@ -1871,9 +1992,11 @@ export class MetadataService {
} }
this.refreshedSeriesIds.delete(series.id); this.refreshedSeriesIds.delete(series.id);
await this.refreshSeriesNl(series.id, meta.id).catch(() => undefined); if (series.tmdbId) {
await this.refreshSeriesNl(series.id, series.tmdbId).catch(() => undefined);
await this.ensureSeriesArtwork(series.id).catch(() => undefined); await this.ensureSeriesArtwork(series.id).catch(() => undefined);
await this.enrichSeriesEpisodes(series.id).catch(() => undefined); await this.enrichSeriesEpisodes(series.id).catch(() => undefined);
}
return { return {
seriesId: series.id, 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( private async findTmdbByImdb(
type: "movie" | "series", type: "movie" | "series",
imdbId: string 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, getOpenSubtitlesSettingsPublic,
upsertOpenSubtitlesSettings, upsertOpenSubtitlesSettings,
} from "./opensubtitles"; } from "./opensubtitles";
import {
getOmdbSettingsPublic,
upsertOmdbSettings,
} from "./omdb";
import { import {
getGooglePlaySettingsPublic, getGooglePlaySettingsPublic,
upsertGooglePlaySettings, 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 () => { app.get("/api/v1/admin/settings/google-play", { preHandler: requireAdmin }, async () => {
return { settings: await getGooglePlaySettingsPublic(config) }; return { settings: await getGooglePlaySettingsPublic(config) };
}); });

View file

@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { requireAdmin } from "../auth/routes"; import { requireAdmin } from "../auth/routes";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import type { Config } from "../config";
import { createOmdbClientFromDb } from "../settings/omdb";
const SITE_SETTINGS_ID = "public_site"; 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 () => { app.get("/api/v1/public/site", async () => {
const cfg = await readSiteConfig(); const cfg = await readSiteConfig();
return { 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 () => { app.get("/api/v1/admin/site/install-page", { preHandler: requireAdmin }, async () => {
return readSiteConfig(); return readSiteConfig();
}); });

View file

@ -2,7 +2,8 @@ import type { FastifyInstance } from "fastify";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { resolveAddonToken } from "../auth/routes"; import { resolveAddonToken } from "../auth/routes";
import { PlaybackService } from "../playback/service"; 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 { nodeConnectionManager } from "../websocket/manager";
import type { Config } from "../config"; import type { Config } from "../config";
import { import {
@ -174,7 +175,7 @@ function videoReleased(
export async function registerStremioRoutes(app: FastifyInstance, config: Config) { export async function registerStremioRoutes(app: FastifyInstance, config: Config) {
const playback = new PlaybackService(config); const playback = new PlaybackService(config);
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = createMetadataService(config);
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/stremio/")) return; 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 { DownloadService } from "../downloads/service";
import type { SubtitleSource } from "../opensubtitles/client"; import type { SubtitleSource } from "../opensubtitles/client";
import { MetadataService } from "../metadata/service"; import { MetadataService } from "../metadata/service";
import { createMetadataService } from "../settings/metadata-factory";
import { ensureOwnerProfile } from "./profiles"; import { ensureOwnerProfile } from "./profiles";
import { import {
kidsContentWhere, kidsContentWhere,
@ -84,7 +85,7 @@ export class ViewerService {
private readonly playback: PlaybackService, private readonly playback: PlaybackService,
private readonly downloads?: DownloadService private readonly downloads?: DownloadService
) { ) {
this.metadata = new MetadataService(config.TMDB_API_KEY); this.metadata = createMetadataService(config);
} }
async createViewer(input: { async createViewer(input: {

View file

@ -5,7 +5,7 @@ import type { CreatePlaybackSessionPayload } from "@media-cluster/shared-types";
import { prisma } from "../database/client"; import { prisma } from "../database/client";
import { authenticateNode } from "../nodes/routes"; import { authenticateNode } from "../nodes/routes";
import { LibrarySyncService } from "../media/sync"; import { LibrarySyncService } from "../media/sync";
import { MetadataService } from "../metadata/service"; import { createMetadataService } from "../settings/metadata-factory";
import type { Config } from "../config"; import type { Config } from "../config";
import type { import type {
FullLibrarySyncPayload, FullLibrarySyncPayload,
@ -77,7 +77,7 @@ class NodeConnectionManager {
init(config: Config): void { init(config: Config): void {
this.config = config; this.config = config;
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = createMetadataService(config);
this.librarySync = new LibrarySyncService(metadata); this.librarySync = new LibrarySyncService(metadata);
this.offlineCheckInterval = setInterval(() => { 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). # 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} CLIENT_LINK_BASE_URL: ${CLIENT_LINK_BASE_URL:-https://admin.vonas.nl}
TMDB_API_KEY: ${TMDB_API_KEY:-} TMDB_API_KEY: ${TMDB_API_KEY:-}
OMDB_API_KEY: ${OMDB_API_KEY:-}
PLAYBACK_IDLE_TIMEOUT_SECONDS: ${PLAYBACK_IDLE_TIMEOUT_SECONDS:-1200} PLAYBACK_IDLE_TIMEOUT_SECONDS: ${PLAYBACK_IDLE_TIMEOUT_SECONDS:-1200}
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200} PLAYBACK_ABSOLUTE_LIFETIME_SECONDS: ${PLAYBACK_ABSOLUTE_LIFETIME_SECONDS:-43200}
NODE_HEARTBEAT_INTERVAL_SECONDS: ${NODE_HEARTBEAT_INTERVAL_SECONDS:-30} 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) # Koppelpagina voor apps (admin-UI host met /link)
CLIENT_LINK_BASE_URL=https://admin.jouwdomein.nl 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= TMDB_API_KEY=
OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200

View file

@ -13,8 +13,9 @@ ADMIN_PASSWORD=changeme123
PUBLIC_URL=https://master.media.example.com 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= TMDB_API_KEY=
OMDB_API_KEY=
PLAYBACK_IDLE_TIMEOUT_SECONDS=1200 PLAYBACK_IDLE_TIMEOUT_SECONDS=1200
PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200 PLAYBACK_ABSOLUTE_LIFETIME_SECONDS=43200