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:
parent
e8d1c4bfee
commit
db8e3e572f
22 changed files with 790 additions and 153 deletions
|
|
@ -53,12 +53,13 @@ interface NodeOption {
|
|||
}
|
||||
|
||||
interface MetaHit {
|
||||
tmdbId: number;
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
imdbId: string | null;
|
||||
source?: "tmdb" | "omdb";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: string | number | null | undefined): string {
|
||||
|
|
@ -257,7 +258,7 @@ export default function MoviesPage() {
|
|||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tmdbId: hit.tmdbId,
|
||||
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
||||
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -360,7 +361,7 @@ export default function MoviesPage() {
|
|||
body: JSON.stringify({
|
||||
nodeId: folderRelink.nodeId,
|
||||
folder: folderRelink.folder,
|
||||
tmdbId: hit.tmdbId,
|
||||
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
||||
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -742,7 +743,7 @@ export default function MoviesPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{linkHits.map((hit) => (
|
||||
<button
|
||||
key={hit.tmdbId}
|
||||
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
||||
type="button"
|
||||
disabled={linkBusy}
|
||||
onClick={() => void applyLink(hit)}
|
||||
|
|
@ -784,7 +785,8 @@ export default function MoviesPage() {
|
|||
{hit.year ? ` (${hit.year})` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
|
||||
TMDB {hit.tmdbId}
|
||||
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
|
||||
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
|
||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -857,7 +859,7 @@ export default function MoviesPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{folderHits.map((hit) => (
|
||||
<button
|
||||
key={hit.tmdbId}
|
||||
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
||||
type="button"
|
||||
disabled={folderBusy}
|
||||
onClick={() => void applyFolderAsSeries(hit)}
|
||||
|
|
@ -899,7 +901,8 @@ export default function MoviesPage() {
|
|||
{hit.year ? ` (${hit.year})` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
|
||||
TMDB {hit.tmdbId}
|
||||
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
|
||||
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
|
||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ interface PendingFolder {
|
|||
}
|
||||
|
||||
interface MetaHit {
|
||||
tmdbId: number;
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
imdbId: string | null;
|
||||
source?: "tmdb" | "omdb";
|
||||
}
|
||||
|
||||
export default function PendingSeriesPage() {
|
||||
|
|
@ -97,7 +98,7 @@ export default function PendingSeriesPage() {
|
|||
body: JSON.stringify({
|
||||
nodeId: link.nodeId,
|
||||
folder: link.folder,
|
||||
tmdbId: hit.tmdbId,
|
||||
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
||||
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -300,7 +301,7 @@ export default function PendingSeriesPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{hits.map((hit) => (
|
||||
<button
|
||||
key={hit.tmdbId}
|
||||
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void applyHit(hit)}
|
||||
|
|
@ -334,7 +335,8 @@ export default function PendingSeriesPage() {
|
|||
{hit.year ? ` (${hit.year})` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
|
||||
TMDB {hit.tmdbId}
|
||||
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
|
||||
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
|
||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,12 +71,13 @@ interface PathFile {
|
|||
}
|
||||
|
||||
interface MetaHit {
|
||||
tmdbId: number;
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
imdbId: string | null;
|
||||
source?: "tmdb" | "omdb";
|
||||
}
|
||||
|
||||
export default function SeriesPage() {
|
||||
|
|
@ -261,7 +262,7 @@ export default function SeriesPage() {
|
|||
body: JSON.stringify({
|
||||
nodeId: folderRelink.nodeId,
|
||||
folder: folderRelink.folder,
|
||||
tmdbId: hit.tmdbId,
|
||||
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
||||
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -369,7 +370,7 @@ export default function SeriesPage() {
|
|||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tmdbId: hit.tmdbId,
|
||||
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
||||
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
||||
}),
|
||||
});
|
||||
|
|
@ -807,7 +808,7 @@ export default function SeriesPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{linkHits.map((hit) => (
|
||||
<button
|
||||
key={hit.tmdbId}
|
||||
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
||||
type="button"
|
||||
disabled={linkBusy}
|
||||
onClick={() => void applyLink(hit)}
|
||||
|
|
@ -849,7 +850,8 @@ export default function SeriesPage() {
|
|||
{hit.year ? ` (${hit.year})` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
|
||||
TMDB {hit.tmdbId}
|
||||
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
|
||||
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
|
||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||
</div>
|
||||
{hit.overview && (
|
||||
|
|
@ -937,7 +939,7 @@ export default function SeriesPage() {
|
|||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
{folderHits.map((hit) => (
|
||||
<button
|
||||
key={hit.tmdbId}
|
||||
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
||||
type="button"
|
||||
disabled={folderBusy}
|
||||
onClick={() => void applyFolderRelink(hit)}
|
||||
|
|
@ -979,7 +981,8 @@ export default function SeriesPage() {
|
|||
{hit.year ? ` (${hit.year})` : ""}
|
||||
</div>
|
||||
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
|
||||
TMDB {hit.tmdbId}
|
||||
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
|
||||
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
|
||||
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
|
||||
</div>
|
||||
{hit.overview && (
|
||||
|
|
|
|||
|
|
@ -4,13 +4,21 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||
import Link from "next/link";
|
||||
import { Nav, useAuth } from "@/components/Nav";
|
||||
|
||||
type SettingsTab = "admin" | "shelves" | "download-station" | "opensubtitles" | "google-play" | "tv";
|
||||
type SettingsTab =
|
||||
| "admin"
|
||||
| "shelves"
|
||||
| "download-station"
|
||||
| "opensubtitles"
|
||||
| "omdb"
|
||||
| "google-play"
|
||||
| "tv";
|
||||
|
||||
function parseTab(v: string | null): SettingsTab {
|
||||
if (
|
||||
v === "admin" ||
|
||||
v === "download-station" ||
|
||||
v === "opensubtitles" ||
|
||||
v === "omdb" ||
|
||||
v === "shelves" ||
|
||||
v === "google-play" ||
|
||||
v === "tv"
|
||||
|
|
@ -61,6 +69,7 @@ const TABS: Array<{ id: SettingsTab; label: string; short: string }> = [
|
|||
{ id: "tv", label: "TV", short: "TV" },
|
||||
{ id: "download-station", label: "Download Station", short: "DS" },
|
||||
{ id: "opensubtitles", label: "OpenSubtitles", short: "Subs" },
|
||||
{ id: "omdb", label: "OMDb", short: "OMDb" },
|
||||
{ id: "google-play", label: "Google Play", short: "Play" },
|
||||
];
|
||||
|
||||
|
|
@ -116,6 +125,14 @@ export default function SettingsPage() {
|
|||
const [osErr, setOsErr] = useState<string | null>(null);
|
||||
const [osWarn, setOsWarn] = useState<string | null>(null);
|
||||
|
||||
const [omdbApiKey, setOmdbApiKey] = useState("");
|
||||
const [omdbEnabled, setOmdbEnabled] = useState(true);
|
||||
const [omdbHasKey, setOmdbHasKey] = useState(false);
|
||||
const [omdbBusy, setOmdbBusy] = useState(false);
|
||||
const [omdbMsg, setOmdbMsg] = useState<string | null>(null);
|
||||
const [omdbErr, setOmdbErr] = useState<string | null>(null);
|
||||
const [omdbWarn, setOmdbWarn] = useState<string | null>(null);
|
||||
|
||||
const [adminEmail, setAdminEmail] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
|
|
@ -195,6 +212,19 @@ export default function SettingsPage() {
|
|||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const loadOmdb = useCallback(() => {
|
||||
fetch("/api/v1/admin/settings/omdb", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const s = d.settings;
|
||||
if (!s) return;
|
||||
setOmdbEnabled(s.enabled !== false);
|
||||
setOmdbHasKey(!!s.hasApiKey);
|
||||
setOmdbWarn(s.warning || null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const loadGooglePlay = useCallback(() => {
|
||||
fetch("/api/v1/admin/settings/google-play", { credentials: "include" })
|
||||
.then((r) => r.json())
|
||||
|
|
@ -234,6 +264,7 @@ export default function SettingsPage() {
|
|||
loadShelves();
|
||||
loadDs();
|
||||
loadOpenSubtitles();
|
||||
loadOmdb();
|
||||
loadGooglePlay();
|
||||
loadTvSettings();
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
|
|
@ -248,7 +279,7 @@ export default function SettingsPage() {
|
|||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadGooglePlay, loadTvSettings]);
|
||||
}, [loadShelves, loadDs, loadOpenSubtitles, loadOmdb, loadGooglePlay, loadTvSettings]);
|
||||
|
||||
async function saveInstallPageEnabled(enabled: boolean) {
|
||||
setInstallPageBusy(true);
|
||||
|
|
@ -432,6 +463,35 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveOmdb() {
|
||||
setOmdbBusy(true);
|
||||
setOmdbMsg(null);
|
||||
setOmdbErr(null);
|
||||
setOmdbWarn(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/admin/settings/omdb", {
|
||||
method: "PUT",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
apiKey: omdbApiKey || undefined,
|
||||
enabled: omdbEnabled,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setOmdbErr(data.error?.message ?? "Opslaan mislukt");
|
||||
return;
|
||||
}
|
||||
setOmdbMsg(data.settings?.hasApiKey ? "Opgeslagen — OMDb OK" : "Opgeslagen");
|
||||
setOmdbWarn(data.settings?.warning || null);
|
||||
setOmdbApiKey("");
|
||||
loadOmdb();
|
||||
} finally {
|
||||
setOmdbBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGooglePlay() {
|
||||
setGpBusy(true);
|
||||
setGpMsg(null);
|
||||
|
|
@ -1102,6 +1162,53 @@ export default function SettingsPage() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{tab === "omdb" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
<h2 className="section-title">OMDb</h2>
|
||||
<p className="muted">
|
||||
IMDb-metadata en posters via{" "}
|
||||
<a href="https://www.omdbapi.com/" target="_blank" rel="noreferrer">
|
||||
OMDb
|
||||
</a>
|
||||
. Key wordt versleuteld in de database opgeslagen (niet in git). Posters gaan via
|
||||
de OMDb Poster API en onze proxy — de key komt nooit in client-URL's.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
API-key {omdbHasKey ? "(leeg = behouden)" : ""}
|
||||
<input
|
||||
type="password"
|
||||
value={omdbApiKey}
|
||||
onChange={(e) => setOmdbApiKey(e.target.value)}
|
||||
placeholder={omdbHasKey ? "••••••••" : "OMDb API-key"}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
<label className="check-label" style={{ alignSelf: "end" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={omdbEnabled}
|
||||
onChange={(e) => setOmdbEnabled(e.target.checked)}
|
||||
/>
|
||||
Ingeschakeld
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={omdbBusy} onClick={() => void saveOmdb()}>
|
||||
{omdbBusy ? "Bezig…" : "Opslaan + testen"}
|
||||
</button>
|
||||
<span className="muted">Status: {omdbHasKey && omdbEnabled ? "aan" : "uit"}</span>
|
||||
{omdbMsg && <span className="ok-text">{omdbMsg}</span>}
|
||||
{omdbWarn && <span className="danger-text">{omdbWarn}</span>}
|
||||
{omdbErr && <span className="danger-text">{omdbErr}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "google-play" && (
|
||||
<section className="stack-section">
|
||||
<div className="section-head">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { FastifyInstance } from "fastify";
|
||||
import { requireAdmin } from "../auth/routes";
|
||||
import type { Config } from "../config";
|
||||
import { MetadataService } from "../metadata/service";
|
||||
import { createMetadataService } from "../settings/metadata-factory";
|
||||
import { AppError } from "../security/errors";
|
||||
import {
|
||||
DownloadService,
|
||||
|
|
@ -46,7 +46,7 @@ function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
|
|||
}
|
||||
|
||||
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
|
||||
const metadata = new MetadataService(config.TMDB_API_KEY);
|
||||
const metadata = createMetadataService(config);
|
||||
const downloads = new DownloadService(config.SESSION_SECRET, metadata);
|
||||
startDownloadPoller(downloads);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
161
apps/master-api/src/metadata/omdb.ts
Normal file
161
apps/master-api/src/metadata/omdb.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { prisma } from "../database/client";
|
||||
import type { MediaFileInfo } from "@media-cluster/shared-types";
|
||||
import { parseSeriesFromPath } from "./filename-parser";
|
||||
import { OmdbClient } from "./omdb";
|
||||
|
||||
function normalizeFolderPrefix(folder: string): string {
|
||||
return folder.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
|
|
@ -303,8 +304,18 @@ export class MetadataService {
|
|||
private refreshedMovieIds = new Set<string>();
|
||||
private refreshedSeriesIds = new Set<string>();
|
||||
private enrichedSeriesSeasons = new Set<string>();
|
||||
private readonly resolveOmdbClient: () => Promise<OmdbClient | null>;
|
||||
|
||||
constructor(private readonly tmdbApiKey?: string) {}
|
||||
constructor(
|
||||
private readonly tmdbApiKey?: string,
|
||||
resolveOmdbClient?: () => Promise<OmdbClient | null>
|
||||
) {
|
||||
this.resolveOmdbClient = resolveOmdbClient ?? (async () => null);
|
||||
}
|
||||
|
||||
private async omdbClient(): Promise<OmdbClient | null> {
|
||||
return this.resolveOmdbClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast, directors, rating, trailers, sized logo for Stremio detail pages.
|
||||
|
|
@ -1434,34 +1445,69 @@ export class MetadataService {
|
|||
return this.deleteMediaFilesSafely(stale.map((f) => f.id));
|
||||
}
|
||||
|
||||
/** Admin: search TMDB by title, TMDB id, or resolve a single IMDb id. */
|
||||
/** Admin: search TMDB (+ OMDb/IMDb fallback) by title, TMDB id, or IMDb id. */
|
||||
async searchCatalog(
|
||||
type: "movie" | "series",
|
||||
query: string
|
||||
): Promise<
|
||||
Array<{
|
||||
tmdbId: number;
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
imdbId: string | null;
|
||||
source: "tmdb" | "omdb";
|
||||
}>
|
||||
> {
|
||||
if (!this.tmdbApiKey) return [];
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
if (!this.tmdbApiKey && !(await this.omdbClient())) return [];
|
||||
|
||||
// Direct IMDb id → resolve via TMDB find
|
||||
type CatalogHit = {
|
||||
tmdbId: number | null;
|
||||
title: string;
|
||||
year: number | null;
|
||||
overview: string | null;
|
||||
posterUrl: string | null;
|
||||
imdbId: string | null;
|
||||
source: "tmdb" | "omdb";
|
||||
};
|
||||
|
||||
// Direct IMDb id → TMDB find, else OMDb title
|
||||
if (/^tt\d+$/i.test(q)) {
|
||||
const found = await this.findTmdbByImdb(type, q);
|
||||
return found ? [found] : [];
|
||||
const fromTmdb = this.tmdbApiKey ? await this.findTmdbByImdb(type, q) : null;
|
||||
if (fromTmdb) {
|
||||
return [{ ...fromTmdb, source: "tmdb" as const }];
|
||||
}
|
||||
const omdbCli = await this.omdbClient();
|
||||
const fromOmdb = omdbCli ? await omdbCli.getByImdbId(q) : null;
|
||||
if (!fromOmdb) return [];
|
||||
const typeOk =
|
||||
type === "movie"
|
||||
? fromOmdb.type === "movie"
|
||||
: fromOmdb.type === "series" || fromOmdb.type === "episode";
|
||||
if (!typeOk && fromOmdb.type !== "other") {
|
||||
// Still show it — admin may want to link a mini-series listed oddly
|
||||
}
|
||||
return [
|
||||
{
|
||||
tmdbId: null,
|
||||
title: fromOmdb.title,
|
||||
year: fromOmdb.year,
|
||||
overview: fromOmdb.overview,
|
||||
posterUrl: fromOmdb.posterUrl,
|
||||
imdbId: fromOmdb.imdbId,
|
||||
source: "omdb",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Direct TMDB id or themoviedb.org URL
|
||||
const tmdbIdFromUrl = q.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
|
||||
const tmdbIdRaw = tmdbIdFromUrl || (/^\d{1,8}$/.test(q) ? q : null);
|
||||
if (tmdbIdRaw) {
|
||||
if (!this.tmdbApiKey) return [];
|
||||
const tmdbId = parseInt(tmdbIdRaw, 10);
|
||||
const meta =
|
||||
type === "movie"
|
||||
|
|
@ -1476,10 +1522,13 @@ export class MetadataService {
|
|||
overview: meta.overview || null,
|
||||
posterUrl: meta.posterUrl || null,
|
||||
imdbId: meta.imdbId || null,
|
||||
source: "tmdb",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const tmdbHits: CatalogHit[] = [];
|
||||
if (this.tmdbApiKey) {
|
||||
const path = type === "movie" ? "search/movie" : "search/tv";
|
||||
const base = {
|
||||
api_key: this.tmdbApiKey,
|
||||
|
|
@ -1504,8 +1553,6 @@ export class MetadataService {
|
|||
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[] = [];
|
||||
|
|
@ -1515,8 +1562,7 @@ export class MetadataService {
|
|||
seen.add(hit.id);
|
||||
orderedIds.push(hit.id);
|
||||
}
|
||||
|
||||
return orderedIds.slice(0, 12).map((id) => {
|
||||
for (const id of orderedIds.slice(0, 12)) {
|
||||
const nl = nlById.get(id);
|
||||
const en = enById.get(id);
|
||||
const item = nl ?? en!;
|
||||
|
|
@ -1531,7 +1577,7 @@ export class MetadataService {
|
|||
const overview =
|
||||
(nl?.overview || item.overview)?.trim() || en?.overview?.trim() || null;
|
||||
const posterPath = item.poster_path || en?.poster_path || nl?.poster_path;
|
||||
return {
|
||||
tmdbHits.push({
|
||||
tmdbId: id,
|
||||
title: preferNlOrEnglishTitle({
|
||||
localizedTitle: localized,
|
||||
|
|
@ -1543,9 +1589,48 @@ export class MetadataService {
|
|||
overview,
|
||||
posterUrl: posterPath ? `${TMDB_IMAGE_BASE}${posterPath}` : 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.
|
||||
|
|
@ -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,17 +1811,17 @@ 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");
|
||||
|
||||
let series: SeriesRow;
|
||||
if (tmdbId != null && this.tmdbApiKey) {
|
||||
const meta = await this.getTmdbSeriesDetails(tmdbId);
|
||||
if (!meta) throw new Error("TMDB-serie niet gevonden");
|
||||
|
||||
|
|
@ -1743,7 +1832,7 @@ export class MetadataService {
|
|||
meta.logoUrl
|
||||
);
|
||||
|
||||
let series =
|
||||
series =
|
||||
(await prisma.series.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
|
|
@ -1780,6 +1869,38 @@ export class MetadataService {
|
|||
...(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);
|
||||
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
|
||||
|
|
|
|||
13
apps/master-api/src/settings/metadata-factory.ts
Normal file
13
apps/master-api/src/settings/metadata-factory.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
}
|
||||
93
apps/master-api/src/settings/omdb.ts
Normal file
93
apps/master-api/src/settings/omdb.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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) };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue