"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([]); const [pathFiles, setPathFiles] = useState(null); const [nodes, setNodes] = useState([]); 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(null); const [busyFileId, setBusyFileId] = useState(null); const [msg, setMsg] = useState(""); const [linkSeriesId, setLinkSeriesId] = useState(null); const [linkQuery, setLinkQuery] = useState(""); const [linkHits, setLinkHits] = useState([]); 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([]); 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 ( <>