From db8e3e572ff802389d539ff4e57f2eca24c1c3db Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Wed, 16 Sep 2026 23:33:01 +0200 Subject: [PATCH] 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. --- apps/admin-ui/src/app/movies/page.tsx | 17 +- apps/admin-ui/src/app/pending/page.tsx | 10 +- apps/admin-ui/src/app/series/page.tsx | 17 +- apps/admin-ui/src/app/settings/page.tsx | 111 ++++- apps/master-api/.env.example | 2 + apps/master-api/src/admin/routes.ts | 10 +- apps/master-api/src/app.ts | 6 +- apps/master-api/src/config.ts | 2 + apps/master-api/src/downloads/routes.ts | 4 +- apps/master-api/src/downloads/service.ts | 2 +- apps/master-api/src/metadata/omdb.ts | 161 +++++++ apps/master-api/src/metadata/service.ts | 427 +++++++++++++----- .../src/settings/metadata-factory.ts | 13 + apps/master-api/src/settings/omdb.ts | 93 ++++ apps/master-api/src/settings/routes.ts | 14 + apps/master-api/src/site/routes.ts | 35 +- apps/master-api/src/stremio/routes.ts | 5 +- apps/master-api/src/viewer/service.ts | 3 +- apps/master-api/src/websocket/manager.ts | 4 +- deploy/docker/docker-compose.dokploy.yml | 1 + deploy/dokploy/.env.example | 3 +- deploy/master/.env.example | 3 +- 22 files changed, 790 insertions(+), 153 deletions(-) create mode 100644 apps/master-api/src/metadata/omdb.ts create mode 100644 apps/master-api/src/settings/metadata-factory.ts create mode 100644 apps/master-api/src/settings/omdb.ts diff --git a/apps/admin-ui/src/app/movies/page.tsx b/apps/admin-ui/src/app/movies/page.tsx index eb88fca..b4f3b77 100644 --- a/apps/admin-ui/src/app/movies/page.tsx +++ b/apps/admin-ui/src/app/movies/page.tsx @@ -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() {
{linkHits.map((hit) => (
- TMDB {hit.tmdbId} + {hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"} + {hit.tmdbId != null ? ` ${hit.tmdbId}` : ""} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
@@ -857,7 +859,7 @@ export default function MoviesPage() {
{folderHits.map((hit) => (
- TMDB {hit.tmdbId} + {hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"} + {hit.tmdbId != null ? ` ${hit.tmdbId}` : ""} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
diff --git a/apps/admin-ui/src/app/pending/page.tsx b/apps/admin-ui/src/app/pending/page.tsx index 6e5f44f..8d86298 100644 --- a/apps/admin-ui/src/app/pending/page.tsx +++ b/apps/admin-ui/src/app/pending/page.tsx @@ -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() {
{hits.map((hit) => (
- TMDB {hit.tmdbId} + {hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"} + {hit.tmdbId != null ? ` ${hit.tmdbId}` : ""} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
diff --git a/apps/admin-ui/src/app/series/page.tsx b/apps/admin-ui/src/app/series/page.tsx index 2258377..bb00af4 100644 --- a/apps/admin-ui/src/app/series/page.tsx +++ b/apps/admin-ui/src/app/series/page.tsx @@ -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() {
{linkHits.map((hit) => (
- TMDB {hit.tmdbId} + {hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"} + {hit.tmdbId != null ? ` ${hit.tmdbId}` : ""} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
{hit.overview && ( @@ -937,7 +939,7 @@ export default function SeriesPage() {
{folderHits.map((hit) => (
- TMDB {hit.tmdbId} + {hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"} + {hit.tmdbId != null ? ` ${hit.tmdbId}` : ""} {hit.imdbId ? ` · ${hit.imdbId}` : ""}
{hit.overview && ( diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index eff7d4d..cb41d72 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -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(null); const [osWarn, setOsWarn] = useState(null); + const [omdbApiKey, setOmdbApiKey] = useState(""); + const [omdbEnabled, setOmdbEnabled] = useState(true); + const [omdbHasKey, setOmdbHasKey] = useState(false); + const [omdbBusy, setOmdbBusy] = useState(false); + const [omdbMsg, setOmdbMsg] = useState(null); + const [omdbErr, setOmdbErr] = useState(null); + const [omdbWarn, setOmdbWarn] = useState(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() { )} + {tab === "omdb" && ( +
+
+

OMDb

+

+ IMDb-metadata en posters via{" "} + + OMDb + + . 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's. +

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