Store the key encrypted in the DB (not git) and proxy OMDb posters so clients never see the API key.
356 lines
11 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|