stremio/apps/admin-ui/src/app/pending/page.tsx
Jos Vooges | STH db8e3e572f 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.
2026-09-16 23:33:01 +02:00

356 lines
11 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { Nav, useAuth } from "@/components/Nav";
interface PendingFolder {
key: string;
nodeId: string;
nodeName: string;
folder: string;
parsedTitle: string | null;
fileCount: number;
seasons: number[];
samplePaths: string[];
}
interface MetaHit {
tmdbId: number | null;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
source?: "tmdb" | "omdb";
}
export default function PendingSeriesPage() {
useAuth();
const [pending, setPending] = useState<PendingFolder[]>([]);
const [totalFiles, setTotalFiles] = useState(0);
const [msg, setMsg] = useState("");
const [link, setLink] = useState<PendingFolder | null>(null);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<MetaHit[]>([]);
const [busy, setBusy] = useState(false);
const [linkMsg, setLinkMsg] = useState("");
function load() {
fetch("/api/v1/admin/library/pending-series", { credentials: "include" })
.then((r) => r.json())
.then((d) => {
setPending(d.pending ?? []);
setTotalFiles(d.totalFiles ?? 0);
});
}
useEffect(() => {
load();
}, []);
function openLink(row: PendingFolder) {
setLink(row);
setQuery(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || "");
setHits([]);
setLinkMsg("");
void searchMeta(row.parsedTitle || row.folder.split("/").filter(Boolean).pop() || "");
}
async function searchMeta(q = query) {
const term = q.trim();
if (!term) return;
setBusy(true);
setLinkMsg("");
try {
const res = await fetch(
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(term)}`,
{ credentials: "include" }
);
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
setHits([]);
return;
}
setHits(data.results ?? []);
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
} finally {
setBusy(false);
}
}
async function applyHit(hit: MetaHit) {
if (!link) return;
if (
!confirm(
`${link.fileCount} bestanden in\n${link.folder}\n(${link.nodeName})\nkoppelen aan “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?`
)
) {
return;
}
setBusy(true);
setLinkMsg("Koppelen…");
try {
const res = await fetch("/api/v1/admin/library/relink-folder", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Koppelen mislukt");
return;
}
setMsg(
`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}` +
(data.failed ? ` (${data.failed} mislukt)` : "")
);
setLink(null);
load();
} finally {
setBusy(false);
}
}
async function applyImdb() {
const raw = query.trim();
if (!link) return;
const tmdbFromUrl = raw.match(/themoviedb\.org\/(?:movie|tv)\/(\d+)/i)?.[1];
const tmdbId = tmdbFromUrl
? parseInt(tmdbFromUrl, 10)
: /^\d{1,8}$/.test(raw)
? parseInt(raw, 10)
: null;
if (tmdbId != null && Number.isFinite(tmdbId)) {
setBusy(true);
setLinkMsg("Koppelen via TMDB…");
try {
const res = await fetch("/api/v1/admin/library/relink-folder", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
tmdbId,
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Koppelen mislukt");
return;
}
setMsg(`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}`);
setLink(null);
load();
} finally {
setBusy(false);
}
return;
}
if (!/^tt\d+$/i.test(raw)) {
setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in.");
return;
}
setBusy(true);
setLinkMsg("Koppelen via IMDb…");
try {
const res = await fetch("/api/v1/admin/library/relink-folder", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nodeId: link.nodeId,
folder: link.folder,
imdbId: raw,
}),
});
const data = await res.json();
if (!res.ok) {
setLinkMsg(data.error?.message ?? "Koppelen mislukt");
return;
}
setMsg(`Gekoppeld → ${data.seriesTitle}: ${data.moved}/${data.total}`);
setLink(null);
load();
} finally {
setBusy(false);
}
}
return (
<>
<Nav />
<div className="container">
<div className="page-header">
<div>
<div className="page-kicker">Library</div>
<h1>Nog te koppelen</h1>
</div>
<button type="button" onClick={() => load()}>
Vernieuwen
</button>
</div>
<p className="muted page-lead">
Series-mappen zonder betrouwbare TMDB-match. Ze staan niet in de catalogus tot jij ze
handmatig koppelt. {pending.length} mappen · {totalFiles} bestanden.
</p>
{msg && (
<p className="muted" style={{ marginBottom: "1rem" }}>
{msg}
</p>
)}
{pending.map((row) => (
<div key={row.key} className="card">
<div style={{ display: "flex", justifyContent: "space-between", gap: "1rem", flexWrap: "wrap" }}>
<div>
<strong>{row.parsedTitle || "Onbekende serie"}</strong>
<div className="muted" style={{ fontSize: "0.875rem", marginTop: 4 }}>
{row.nodeName} · {row.fileCount} bestanden
{row.seasons.length ? ` · seizoen ${row.seasons.join(", ")}` : ""}
</div>
<div className="mono tiny" style={{ marginTop: 8, color: "#a1a1aa" }}>
{row.folder}
</div>
{row.samplePaths.length > 0 && (
<div className="muted tiny" style={{ marginTop: 6 }}>
{row.samplePaths.map((p) => (
<div key={p}>{p}</div>
))}
</div>
)}
</div>
<button type="button" onClick={() => openLink(row)}>
Koppelen…
</button>
</div>
</div>
))}
{pending.length === 0 && (
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
Geen openstaande series-mappen. Alles is gekoppeld of nog niet gescand.
</div>
)}
</div>
{link && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 50,
padding: "1rem",
}}
onClick={() => !busy && setLink(null)}
>
<div
className="card"
style={{ width: "min(640px, 100%)", maxHeight: "90vh", overflow: "auto", margin: 0 }}
onClick={(e) => e.stopPropagation()}
>
<h2 style={{ fontSize: "1.1rem", color: "#fafafa", textTransform: "none", marginBottom: "0.5rem" }}>
Serie koppelen
</h2>
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
{link.nodeName}
<br />
<span className="mono">{link.folder}</span>
<br />
{link.fileCount} bestanden
</p>
<form
onSubmit={(e) => {
e.preventDefault();
void searchMeta();
}}
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Titel, TMDB-id of IMDb-id (tt…)"
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
autoFocus
/>
<button type="submit" disabled={busy}>
Zoek
</button>
<button type="button" disabled={busy} onClick={() => void applyImdb()}>
Koppel id
</button>
</form>
{linkMsg && (
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{linkMsg}</p>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{hits.map((hit) => (
<button
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
type="button"
disabled={busy}
onClick={() => void applyHit(hit)}
style={{
display: "flex",
gap: "0.75rem",
textAlign: "left",
background: "#0f1117",
border: "1px solid #2a2d3a",
borderRadius: 8,
padding: "0.75rem",
cursor: "pointer",
color: "#fafafa",
}}
>
{hit.posterUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hit.posterUrl}
alt=""
width={48}
height={72}
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
/>
) : (
<div style={{ width: 48, height: 72, background: "#1f2937", borderRadius: 4 }} />
)}
<div>
<div>
<strong>{hit.title}</strong>
{hit.year ? ` (${hit.year})` : ""}
</div>
<div style={{ color: "#71717a", fontSize: "0.8rem" }}>
{hit.source === "omdb" ? "IMDb/OMDb" : "TMDB"}
{hit.tmdbId != null ? ` ${hit.tmdbId}` : ""}
{hit.imdbId ? ` · ${hit.imdbId}` : ""}
</div>
</div>
</button>
))}
</div>
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
<button type="button" className="danger" disabled={busy} onClick={() => setLink(null)}>
Annuleren
</button>
</div>
</div>
</div>
)}
</>
);
}