Store the key encrypted in the DB (not git) and proxy OMDb posters so clients never see the API key.
1022 lines
36 KiB
TypeScript
1022 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Nav, useAuth } from "@/components/Nav";
|
|
|
|
interface SeriesFolder {
|
|
folder: string;
|
|
fileCount: number;
|
|
}
|
|
|
|
interface SeriesNode {
|
|
id: string;
|
|
name: string;
|
|
fileCount: number;
|
|
folders?: SeriesFolder[];
|
|
}
|
|
|
|
interface SeriesFile {
|
|
id: string;
|
|
node: string;
|
|
resolution: string | null;
|
|
available: boolean;
|
|
releaseName?: string;
|
|
path?: string | null;
|
|
folder?: string | null;
|
|
}
|
|
|
|
interface Series {
|
|
id: string;
|
|
title: string;
|
|
year: number | null;
|
|
imdbId: string | null;
|
|
tmdbId?: number | null;
|
|
posterUrl?: string | null;
|
|
episodesWithFiles?: number;
|
|
fileCount?: number;
|
|
nodes?: SeriesNode[];
|
|
seasons: Array<{
|
|
seasonNumber: number;
|
|
episodes: Array<{
|
|
seasonNumber: number;
|
|
episodeNumber: number;
|
|
title: string | null;
|
|
files: SeriesFile[];
|
|
}>;
|
|
}>;
|
|
}
|
|
|
|
interface NodeOption {
|
|
id: string;
|
|
name: string;
|
|
status?: string;
|
|
}
|
|
|
|
interface PathFile {
|
|
id: string;
|
|
node: string;
|
|
path: string | null;
|
|
folder: string | null;
|
|
releaseName: string;
|
|
mediaType: string;
|
|
movie: { id: string; title: string; year: number | null } | null;
|
|
series: {
|
|
id: string;
|
|
title: string;
|
|
year: number | null;
|
|
seasonNumber: number;
|
|
episodeNumber: number;
|
|
} | null;
|
|
parsedHint: { title?: string; season?: number; episode?: number; year?: number } | null;
|
|
}
|
|
|
|
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 SeriesPage() {
|
|
useAuth();
|
|
const [series, setSeries] = useState<Series[]>([]);
|
|
const [pathFiles, setPathFiles] = useState<PathFile[] | null>(null);
|
|
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
|
const [nodeId, setNodeId] = useState("");
|
|
const [search, setSearch] = useState("");
|
|
const [pathSearch, setPathSearch] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [searched, setSearched] = useState(false);
|
|
const [limitHit, setLimitHit] = useState(false);
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
const [busyFileId, setBusyFileId] = useState<string | null>(null);
|
|
const [msg, setMsg] = useState("");
|
|
const [linkSeriesId, setLinkSeriesId] = useState<string | null>(null);
|
|
const [linkQuery, setLinkQuery] = useState("");
|
|
const [linkHits, setLinkHits] = useState<MetaHit[]>([]);
|
|
const [linkBusy, setLinkBusy] = useState(false);
|
|
const [linkMsg, setLinkMsg] = useState("");
|
|
const [folderRelink, setFolderRelink] = useState<{
|
|
nodeId: string;
|
|
nodeName: string;
|
|
folder: string;
|
|
fileCount: number;
|
|
} | null>(null);
|
|
const [folderQuery, setFolderQuery] = useState("");
|
|
const [folderHits, setFolderHits] = useState<MetaHit[]>([]);
|
|
const [folderBusy, setFolderBusy] = useState(false);
|
|
const [folderMsg, setFolderMsg] = useState("");
|
|
|
|
function hasSearchQuery(q = search, pathQ = pathSearch) {
|
|
return q.trim().length >= 2 || pathQ.trim().length >= 2;
|
|
}
|
|
|
|
function loadSeries(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
|
|
if (!hasSearchQuery(q, pathQ)) {
|
|
setSeries([]);
|
|
setSearched(false);
|
|
setLimitHit(false);
|
|
return;
|
|
}
|
|
const params = new URLSearchParams();
|
|
if (q.trim()) params.set("search", q.trim());
|
|
if (filterNodeId) params.set("nodeId", filterNodeId);
|
|
if (pathQ.trim()) params.set("path", pathQ.trim());
|
|
setLoading(true);
|
|
setSearched(true);
|
|
fetch(`/api/v1/admin/series?${params}`, { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
const list = d.series ?? [];
|
|
setSeries(list);
|
|
setLimitHit(Boolean(d.limit && list.length >= d.limit));
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}
|
|
|
|
function loadPathFiles(pathQ = pathSearch, filterNodeId = nodeId) {
|
|
if (!pathQ.trim() && !filterNodeId) {
|
|
setPathFiles(null);
|
|
return;
|
|
}
|
|
if (!pathQ.trim()) {
|
|
setPathFiles(null);
|
|
return;
|
|
}
|
|
const params = new URLSearchParams();
|
|
if (pathQ.trim()) params.set("path", pathQ.trim());
|
|
if (filterNodeId) params.set("nodeId", filterNodeId);
|
|
fetch(`/api/v1/admin/media-files?${params}`, { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => setPathFiles(d.files ?? []));
|
|
}
|
|
|
|
function load(q = search, filterNodeId = nodeId, pathQ = pathSearch) {
|
|
loadSeries(q, filterNodeId, pathQ);
|
|
if (pathQ.trim()) loadPathFiles(pathQ, filterNodeId);
|
|
else setPathFiles(null);
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetch("/api/v1/admin/nodes", { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
const list = (d.nodes ?? []) as NodeOption[];
|
|
setNodes(list.filter((n) => n.id).sort((a, b) => a.name.localeCompare(b.name)));
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!searched) return;
|
|
load(search, nodeId, pathSearch);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [nodeId]);
|
|
|
|
async function rematchFile(fileId: string) {
|
|
setBusyFileId(fileId);
|
|
setMsg("");
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/media-files/${fileId}/rematch`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setMsg(data.error?.message ?? "Rematch mislukt");
|
|
return;
|
|
}
|
|
const label = data.series
|
|
? `${data.series.title} S${String(data.series.seasonNumber).padStart(2, "0")}E${String(data.series.episodeNumber).padStart(2, "0")}`
|
|
: data.movie?.title ?? "onbekend";
|
|
setMsg(`Opnieuw gekoppeld → ${label}`);
|
|
load();
|
|
} finally {
|
|
setBusyFileId(null);
|
|
}
|
|
}
|
|
|
|
function openLink(s: Series) {
|
|
setLinkSeriesId(s.id);
|
|
setLinkQuery(s.title);
|
|
setLinkHits([]);
|
|
setLinkMsg("");
|
|
void searchMeta(s.title);
|
|
}
|
|
|
|
function openFolderRelink(opts: {
|
|
nodeId: string;
|
|
nodeName: string;
|
|
folder: string;
|
|
fileCount: number;
|
|
}) {
|
|
const folderName = opts.folder.split("/").filter(Boolean).pop() || opts.folder;
|
|
setFolderRelink(opts);
|
|
setFolderQuery(folderName);
|
|
setFolderHits([]);
|
|
setFolderMsg("");
|
|
void searchFolderMeta(folderName);
|
|
}
|
|
|
|
async function searchFolderMeta(q = folderQuery) {
|
|
const query = q.trim();
|
|
if (!query) return;
|
|
setFolderBusy(true);
|
|
setFolderMsg("");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`,
|
|
{ credentials: "include" }
|
|
);
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setFolderMsg(data.error?.message ?? "Zoeken mislukt");
|
|
setFolderHits([]);
|
|
return;
|
|
}
|
|
setFolderHits(data.results ?? []);
|
|
if (!(data.results ?? []).length) setFolderMsg("Geen resultaten");
|
|
} finally {
|
|
setFolderBusy(false);
|
|
}
|
|
}
|
|
|
|
async function applyFolderRelink(hit: MetaHit) {
|
|
if (!folderRelink) return;
|
|
if (
|
|
!confirm(
|
|
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nop ${folderRelink.nodeName}\nherkoppelen naar “${hit.title}${hit.year ? ` (${hit.year})` : ""}”?`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setFolderBusy(true);
|
|
setFolderMsg("Hele map herkoppelen…");
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/library/relink-folder`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
nodeId: folderRelink.nodeId,
|
|
folder: folderRelink.folder,
|
|
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
|
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
|
|
return;
|
|
}
|
|
setMsg(
|
|
`Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` +
|
|
(data.failed ? ` (${data.failed} mislukt)` : "")
|
|
);
|
|
setFolderRelink(null);
|
|
setExpanded(data.seriesId ?? expanded);
|
|
load();
|
|
} finally {
|
|
setFolderBusy(false);
|
|
}
|
|
}
|
|
|
|
async function applyFolderImdbDirect() {
|
|
const raw = folderQuery.trim();
|
|
if (!folderRelink) 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;
|
|
const idLabel = tmdbId != null ? `TMDB ${tmdbId}` : raw;
|
|
if (tmdbId == null && !/^tt\d+$/i.test(raw)) {
|
|
setFolderMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in.");
|
|
return;
|
|
}
|
|
if (
|
|
!confirm(
|
|
`Alle ${folderRelink.fileCount} bestanden in\n${folderRelink.folder}\nherkoppelen via ${idLabel}?`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
setFolderBusy(true);
|
|
setFolderMsg(
|
|
tmdbId != null ? "Hele map herkoppelen via TMDB…" : "Hele map herkoppelen 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: folderRelink.nodeId,
|
|
folder: folderRelink.folder,
|
|
...(tmdbId != null ? { tmdbId } : { imdbId: raw }),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setFolderMsg(data.error?.message ?? "Herkoppelen mislukt");
|
|
return;
|
|
}
|
|
setMsg(
|
|
`Map herkoppeld → ${data.seriesTitle}: ${data.moved}/${data.total} bestanden` +
|
|
(data.failed ? ` (${data.failed} mislukt)` : "")
|
|
);
|
|
setFolderRelink(null);
|
|
setExpanded(data.seriesId ?? expanded);
|
|
load();
|
|
} finally {
|
|
setFolderBusy(false);
|
|
}
|
|
}
|
|
|
|
async function searchMeta(q = linkQuery) {
|
|
const query = q.trim();
|
|
if (!query) return;
|
|
setLinkBusy(true);
|
|
setLinkMsg("");
|
|
try {
|
|
const res = await fetch(
|
|
`/api/v1/admin/metadata/search?type=series&q=${encodeURIComponent(query)}`,
|
|
{ credentials: "include" }
|
|
);
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setLinkMsg(data.error?.message ?? "Zoeken mislukt");
|
|
setLinkHits([]);
|
|
return;
|
|
}
|
|
setLinkHits(data.results ?? []);
|
|
if (!(data.results ?? []).length) setLinkMsg("Geen resultaten");
|
|
} finally {
|
|
setLinkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function applyLink(hit: MetaHit) {
|
|
if (!linkSeriesId) return;
|
|
setLinkBusy(true);
|
|
setLinkMsg("Koppelen…");
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...(hit.tmdbId != null ? { tmdbId: hit.tmdbId } : {}),
|
|
...(hit.imdbId ? { imdbId: hit.imdbId } : {}),
|
|
}),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
|
return;
|
|
}
|
|
setLinkMsg(
|
|
data.merged
|
|
? "Gekoppeld (samengevoegd). Metadata + afleveringen vernieuwd."
|
|
: "Gekoppeld. Metadata + afleveringen vernieuwd."
|
|
);
|
|
setLinkSeriesId(null);
|
|
setExpanded(data.seriesId ?? linkSeriesId);
|
|
load();
|
|
} finally {
|
|
setLinkBusy(false);
|
|
}
|
|
}
|
|
|
|
async function applyImdbDirect() {
|
|
const raw = linkQuery.trim();
|
|
if (!linkSeriesId) 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)) {
|
|
setLinkBusy(true);
|
|
setLinkMsg("Koppelen via TMDB…");
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ tmdbId }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
|
return;
|
|
}
|
|
setLinkMsg("Gekoppeld via TMDB. Metadata vernieuwd.");
|
|
setLinkSeriesId(null);
|
|
setExpanded(data.seriesId ?? linkSeriesId);
|
|
load();
|
|
} finally {
|
|
setLinkBusy(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!/^tt\d+$/i.test(raw)) {
|
|
setLinkMsg("Vul een titel, TMDB-id of IMDb-id (tt…) in.");
|
|
return;
|
|
}
|
|
setLinkBusy(true);
|
|
setLinkMsg("Koppelen via IMDb…");
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/series/${linkSeriesId}/link`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ imdbId: raw }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
setLinkMsg(data.error?.message ?? "Opslaan mislukt");
|
|
return;
|
|
}
|
|
setLinkMsg("Gekoppeld via IMDb. Metadata vernieuwd.");
|
|
setLinkSeriesId(null);
|
|
setExpanded(data.seriesId ?? linkSeriesId);
|
|
load();
|
|
} finally {
|
|
setLinkBusy(false);
|
|
}
|
|
}
|
|
|
|
const activeSeries = series.find((s) => s.id === linkSeriesId);
|
|
const selectedNodeName = nodes.find((n) => n.id === nodeId)?.name;
|
|
|
|
return (
|
|
<>
|
|
<Nav />
|
|
<div className="container">
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-kicker">Library</div>
|
|
<h1>Series</h1>
|
|
</div>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (!hasSearchQuery()) {
|
|
setMsg("Typ minstens 2 tekens in titel of pad.");
|
|
return;
|
|
}
|
|
setMsg("");
|
|
load();
|
|
}}
|
|
style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}
|
|
>
|
|
<select
|
|
value={nodeId}
|
|
onChange={(e) => setNodeId(e.target.value)}
|
|
style={{ marginBottom: 0, minWidth: 180 }}
|
|
aria-label="Filter op node"
|
|
>
|
|
<option value="">Alle nodes</option>
|
|
{nodes.map((n) => (
|
|
<option key={n.id} value={n.id}>
|
|
{n.name}
|
|
{n.status === "OFFLINE" ? " (offline)" : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Zoek titel / IMDb…"
|
|
style={{ width: 180, marginBottom: 0 }}
|
|
autoFocus
|
|
/>
|
|
<input
|
|
value={pathSearch}
|
|
onChange={(e) => setPathSearch(e.target.value)}
|
|
placeholder="Zoek map/pad… (bv. Breaking Bad)"
|
|
style={{ width: 260, marginBottom: 0 }}
|
|
/>
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? "Zoeken…" : "Zoek"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<p className="muted page-lead">
|
|
Zoek op titel of pad (min. 2 tekens) — de hele bibliotheek wordt niet meer geladen. Per node
|
|
zie je de show-map. Padzoeken toont ook losse bestanden om foutmatches opnieuw te koppelen.
|
|
{searched
|
|
? nodeId
|
|
? ` Filter: ${selectedNodeName ?? "node"} · ${series.length} series.`
|
|
: ` ${series.length} series.`
|
|
: ""}
|
|
{pathSearch.trim() && searched ? ` Padfilter: “${pathSearch.trim()}”.` : ""}
|
|
</p>
|
|
|
|
{msg && (
|
|
<p className="muted" style={{ marginBottom: "1rem" }}>
|
|
{msg}
|
|
</p>
|
|
)}
|
|
|
|
{pathFiles && (
|
|
<div className="card" style={{ marginBottom: "1.25rem" }}>
|
|
<h2 style={{ fontSize: "1rem", marginBottom: "0.75rem", color: "#fafafa", textTransform: "none" }}>
|
|
Bestanden op pad ({pathFiles.length})
|
|
</h2>
|
|
{pathFiles.length === 0 ? (
|
|
<p className="muted" style={{ margin: 0 }}>
|
|
Geen bestanden. Na node-upgrade 1.3.7+ eerst rescan zodat paden worden opgeslagen.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
{pathFiles.map((f) => (
|
|
<div
|
|
key={f.id}
|
|
style={{
|
|
borderTop: "1px solid #2a2d3a",
|
|
paddingTop: "0.75rem",
|
|
fontSize: "0.875rem",
|
|
}}
|
|
>
|
|
<div style={{ color: "#e4e4e7" }}>{f.path || f.releaseName}</div>
|
|
<div className="muted tiny" style={{ marginTop: 4 }}>
|
|
{f.node}
|
|
{f.folder ? ` · map ${f.folder}` : ""}
|
|
{" · "}
|
|
{f.series
|
|
? `gekoppeld: ${f.series.title} S${String(f.series.seasonNumber).padStart(2, "0")}E${String(f.series.episodeNumber).padStart(2, "0")}`
|
|
: f.movie
|
|
? `gekoppeld als film: ${f.movie.title}`
|
|
: "niet gekoppeld"}
|
|
{f.parsedHint?.title
|
|
? ` · parse: ${f.parsedHint.title}${
|
|
f.parsedHint.season != null
|
|
? ` S${f.parsedHint.season}E${f.parsedHint.episode}`
|
|
: ""
|
|
}`
|
|
: ""}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
style={{ marginTop: 8 }}
|
|
disabled={busyFileId === f.id}
|
|
onClick={() => void rematchFile(f.id)}
|
|
>
|
|
{busyFileId === f.id ? "Bezig…" : "Opnieuw matchen op pad"}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{series.map((s) => (
|
|
<div key={s.id} className="card">
|
|
<div
|
|
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", gap: "1rem" }}
|
|
onClick={() => setExpanded(expanded === s.id ? null : s.id)}
|
|
>
|
|
<div style={{ display: "flex", gap: "0.75rem", alignItems: "flex-start" }}>
|
|
{s.posterUrl && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={s.posterUrl}
|
|
alt=""
|
|
width={40}
|
|
height={60}
|
|
style={{ objectFit: "cover", borderRadius: 4, flexShrink: 0 }}
|
|
/>
|
|
)}
|
|
<div>
|
|
<strong>{s.title}</strong>
|
|
{s.year ? ` (${s.year})` : ""}
|
|
<div style={{ color: "#71717a", fontSize: "0.875rem", marginTop: 4 }}>
|
|
{s.episodesWithFiles ?? 0} afleveringen · {s.fileCount ?? 0} bestanden · {s.seasons.length}{" "}
|
|
seizoen(en)
|
|
{s.imdbId ? ` · ${s.imdbId}` : " · geen IMDb"}
|
|
{s.tmdbId ? ` · TMDB ${s.tmdbId}` : ""}
|
|
</div>
|
|
{(s.nodes?.length ?? 0) > 0 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10 }}>
|
|
{s.nodes!.map((n) => (
|
|
<div key={n.id}>
|
|
<span
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
padding: "2px 8px",
|
|
borderRadius: 999,
|
|
border: "1px solid #2a2d3a",
|
|
background: nodeId === n.id ? "#1e293b" : "#12141c",
|
|
color: nodeId === n.id ? "#e2e8f0" : "#a1a1aa",
|
|
}}
|
|
>
|
|
{n.name} · {n.fileCount} files
|
|
</span>
|
|
{(n.folders?.length ?? 0) > 0 ? (
|
|
<div className="muted mono tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
|
|
{n.folders!.map((f) => (
|
|
<div
|
|
key={f.folder}
|
|
style={{
|
|
display: "flex",
|
|
gap: 8,
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
<span>
|
|
{f.folder}{" "}
|
|
<span style={{ color: "#52525b" }}>({f.fileCount})</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
style={{ fontSize: "0.7rem", padding: "2px 8px" }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openFolderRelink({
|
|
nodeId: n.id,
|
|
nodeName: n.name,
|
|
folder: f.folder,
|
|
fileCount: f.fileCount,
|
|
});
|
|
}}
|
|
>
|
|
Hele map herkoppelen
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="muted tiny" style={{ marginTop: 4, paddingLeft: 4 }}>
|
|
Map onbekend — upgrade node naar 1.3.7+ en rescan
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<span style={{ color: "#71717a" }}>{expanded === s.id ? "▲" : "▼"}</span>
|
|
</div>
|
|
|
|
{expanded === s.id && (
|
|
<div style={{ marginTop: "1rem" }}>
|
|
<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
openLink(s);
|
|
}}
|
|
>
|
|
Juiste content zoeken
|
|
</button>
|
|
</div>
|
|
{s.seasons.map((season) => (
|
|
<div key={season.seasonNumber} style={{ marginTop: "1rem", paddingLeft: "1rem" }}>
|
|
<h3 style={{ fontSize: "0.875rem", color: "#a1a1aa", marginBottom: "0.5rem" }}>
|
|
Season {season.seasonNumber}
|
|
</h3>
|
|
{season.episodes.map((ep) => (
|
|
<div key={ep.episodeNumber} style={{ fontSize: "0.875rem", padding: "0.35rem 0" }}>
|
|
<div>
|
|
S{String(ep.seasonNumber).padStart(2, "0")}E
|
|
{String(ep.episodeNumber).padStart(2, "0")} {ep.title ?? ""}
|
|
</div>
|
|
{ep.files.map((f) => (
|
|
<div
|
|
key={f.id}
|
|
style={{
|
|
color: "#71717a",
|
|
fontSize: "0.8rem",
|
|
marginTop: 2,
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<span className="mono">
|
|
{f.node} · {f.path || f.releaseName || "?"}
|
|
{f.resolution ? ` (${f.resolution})` : ""}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="danger"
|
|
style={{ fontSize: "0.75rem", padding: "2px 8px" }}
|
|
disabled={busyFileId === f.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void rematchFile(f.id);
|
|
}}
|
|
>
|
|
{busyFileId === f.id ? "…" : "Rematch"}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{series.length === 0 && (
|
|
<div className="card" style={{ textAlign: "center", color: "#71717a" }}>
|
|
{loading
|
|
? "Zoeken…"
|
|
: searched
|
|
? nodeId || pathSearch
|
|
? "Geen series voor dit filter. Check scan-roots / pad / rescan."
|
|
: "Geen series gevonden"
|
|
: "Typ een titel of pad en klik Zoek"}
|
|
</div>
|
|
)}
|
|
{limitHit && (
|
|
<p className="muted" style={{ marginTop: "0.75rem", textAlign: "center" }}>
|
|
Max. 50 resultaten — verfijn je zoekopdracht als je titel er niet tussen staat.
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{linkSeriesId && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.65)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
zIndex: 50,
|
|
padding: "1rem",
|
|
}}
|
|
onClick={() => !linkBusy && setLinkSeriesId(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" }}>
|
|
Juiste serie zoeken
|
|
</h2>
|
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
|
|
Huidig: <strong style={{ color: "#e4e4e7" }}>{activeSeries?.title}</strong>
|
|
{activeSeries?.year ? ` (${activeSeries.year})` : ""}
|
|
{activeSeries?.imdbId ? ` · ${activeSeries.imdbId}` : " · geen IMDb"}
|
|
</p>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void searchMeta();
|
|
}}
|
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
|
|
>
|
|
<input
|
|
value={linkQuery}
|
|
onChange={(e) => setLinkQuery(e.target.value)}
|
|
placeholder="Titel, TMDB-id of IMDb-id (tt…)"
|
|
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
|
|
autoFocus
|
|
/>
|
|
<button type="submit" disabled={linkBusy}>
|
|
Zoek
|
|
</button>
|
|
<button type="button" disabled={linkBusy} onClick={() => void applyImdbDirect()}>
|
|
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" }}>
|
|
{linkHits.map((hit) => (
|
|
<button
|
|
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
|
type="button"
|
|
disabled={linkBusy}
|
|
onClick={() => void applyLink(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,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
<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>
|
|
{hit.overview && (
|
|
<div
|
|
style={{
|
|
color: "#a1a1aa",
|
|
fontSize: "0.8rem",
|
|
marginTop: 4,
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: "vertical",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{hit.overview}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
|
<button type="button" className="danger" disabled={linkBusy} onClick={() => setLinkSeriesId(null)}>
|
|
Annuleren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{folderRelink && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
background: "rgba(0,0,0,0.65)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
zIndex: 50,
|
|
padding: "1rem",
|
|
}}
|
|
onClick={() => !folderBusy && setFolderRelink(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" }}>
|
|
Hele map herkoppelen
|
|
</h2>
|
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "1rem" }}>
|
|
Node: <strong style={{ color: "#e4e4e7" }}>{folderRelink.nodeName}</strong>
|
|
<br />
|
|
Map: <span className="mono">{folderRelink.folder}</span>
|
|
<br />
|
|
{folderRelink.fileCount} bestanden — kies de juiste serie (niet de verkeerde TMDB-hit).
|
|
Seizoen/aflevering blijft uit het pad.
|
|
</p>
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
void searchFolderMeta();
|
|
}}
|
|
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem", flexWrap: "wrap" }}
|
|
>
|
|
<input
|
|
value={folderQuery}
|
|
onChange={(e) => setFolderQuery(e.target.value)}
|
|
placeholder="Titel, TMDB-id of IMDb-id (tt…)"
|
|
style={{ flex: 1, minWidth: 200, marginBottom: 0 }}
|
|
autoFocus
|
|
/>
|
|
<button type="submit" disabled={folderBusy}>
|
|
Zoek
|
|
</button>
|
|
<button type="button" disabled={folderBusy} onClick={() => void applyFolderImdbDirect()}>
|
|
Koppel id
|
|
</button>
|
|
</form>
|
|
{folderMsg && (
|
|
<p style={{ color: "#a1a1aa", fontSize: "0.85rem", marginBottom: "0.75rem" }}>{folderMsg}</p>
|
|
)}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
|
{folderHits.map((hit) => (
|
|
<button
|
|
key={`${hit.source ?? "tmdb"}-${hit.tmdbId ?? hit.imdbId}`}
|
|
type="button"
|
|
disabled={folderBusy}
|
|
onClick={() => void applyFolderRelink(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,
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
<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>
|
|
{hit.overview && (
|
|
<div
|
|
style={{
|
|
color: "#a1a1aa",
|
|
fontSize: "0.8rem",
|
|
marginTop: 4,
|
|
display: "-webkit-box",
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: "vertical",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{hit.overview}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
|
<button
|
|
type="button"
|
|
className="danger"
|
|
disabled={folderBusy}
|
|
onClick={() => setFolderRelink(null)}
|
|
>
|
|
Annuleren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|