diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx new file mode 100644 index 0000000..3a7bb23 --- /dev/null +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -0,0 +1,590 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Nav, useAuth } from "@/components/Nav"; + +interface NodeRow { + id: string; + name: string; + status: string; +} + +interface DsConfig { + id?: string; + nodeId: string; + nodeName?: string; + baseUrl: string; + username: string; + downloadDestination: string; + downloadHostPath: string; + fourKHostPath: string; + enabled: boolean; + lastOkAt?: string | null; + lastError?: string | null; +} + +interface SearchHit { + title: string; + size: number; + seeds: number; + peers: number; + uri: string | null; + module: string | null; + suggestQuery: string; +} + +interface MetaHit { + tmdbId: number; + title: string; + year: number | null; + overview: string | null; + posterUrl: string | null; + imdbId: string | null; +} + +interface JobRow { + id: string; + nodeId: string; + nodeName: string; + status: string; + sourceTitle: string; + movieTitle: string; + movieYear: number | null; + posterUrl: string | null; + tmdbId: number | null; + imdbId: string | null; + destFolderName: string | null; + importedPath: string | null; + error: string | null; + createdAt: string; + updatedAt: string; +} + +function formatBytes(n: number): string { + if (!n || n <= 0) return "—"; + const u = ["B", "KB", "MB", "GB", "TB"]; + let i = 0; + let v = n; + while (v >= 1024 && i < u.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`; +} + +function statusClass(s: string): string { + switch (s) { + case "IMPORTED": + return "badge badge-ok"; + case "DOWNLOADING": + case "IMPORTING": + case "FINISHED": + return "badge badge-warn"; + case "FAILED": + return "badge badge-danger"; + default: + return "badge"; + } +} + +const emptyConfig = (nodeId = ""): DsConfig => ({ + nodeId, + baseUrl: "", + username: "", + downloadDestination: "Downloads", + downloadHostPath: "/volume1/Downloads", + fourKHostPath: "/volume1/Media/Films/4K", + enabled: true, +}); + +export default function DownloadsPage() { + useAuth(); + const [nodes, setNodes] = useState([]); + const [configs, setConfigs] = useState([]); + const [nodeId, setNodeId] = useState(""); + const [cfg, setCfg] = useState(emptyConfig()); + const [password, setPassword] = useState(""); + const [cfgMsg, setCfgMsg] = useState(null); + const [cfgErr, setCfgErr] = useState(null); + const [cfgBusy, setCfgBusy] = useState(false); + + const [query, setQuery] = useState(""); + const [searching, setSearching] = useState(false); + const [hits, setHits] = useState([]); + const [searchErr, setSearchErr] = useState(null); + + const [pick, setPick] = useState(null); + const [metaQ, setMetaQ] = useState(""); + const [metaHits, setMetaHits] = useState([]); + const [metaBusy, setMetaBusy] = useState(false); + const [selectedMeta, setSelectedMeta] = useState(null); + const [jobBusy, setJobBusy] = useState(false); + const [jobErr, setJobErr] = useState(null); + + const [jobs, setJobs] = useState([]); + + const loadJobs = useCallback(() => { + fetch("/api/v1/admin/downloads/jobs", { credentials: "include" }) + .then((r) => r.json()) + .then((d) => setJobs(d.jobs ?? [])) + .catch(() => undefined); + }, []); + + const loadAll = useCallback(() => { + Promise.all([ + fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()), + fetch("/api/v1/admin/downloads/configs", { credentials: "include" }).then((r) => r.json()), + ]).then(([n, c]) => { + const nodeList: NodeRow[] = (n.nodes ?? []).map( + (x: { id: string; name: string; status: string }) => ({ + id: x.id, + name: x.name, + status: x.status, + }) + ); + setNodes(nodeList); + const list: DsConfig[] = c.configs ?? []; + setConfigs(list); + const preferred = list[0]?.nodeId || nodeList[0]?.id || ""; + setNodeId((prev) => prev || preferred); + }); + loadJobs(); + }, [loadJobs]); + + useEffect(() => { + loadAll(); + const t = setInterval(loadJobs, 15_000); + return () => clearInterval(t); + }, [loadAll, loadJobs]); + + useEffect(() => { + if (!nodeId) return; + const existing = configs.find((c) => c.nodeId === nodeId); + setCfg(existing ? { ...existing } : emptyConfig(nodeId)); + setPassword(""); + setCfgMsg(null); + setCfgErr(null); + }, [nodeId, configs]); + + const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]); + + async function saveConfig() { + setCfgBusy(true); + setCfgMsg(null); + setCfgErr(null); + try { + const res = await fetch("/api/v1/admin/downloads/config", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...cfg, + nodeId, + password: password || undefined, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setCfgErr(data.error?.message ?? "Opslaan mislukt"); + return; + } + setCfgMsg("Opgeslagen — verbinding OK"); + setPassword(""); + loadAll(); + } finally { + setCfgBusy(false); + } + } + + async function runSearch() { + if (!query.trim() || !nodeId) return; + setSearching(true); + setSearchErr(null); + setHits([]); + try { + const res = await fetch("/api/v1/admin/downloads/search", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nodeId, q: query.trim() }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setSearchErr(data.error?.message ?? "Zoeken mislukt"); + return; + } + setHits(data.results ?? []); + } finally { + setSearching(false); + } + } + + async function openMatch(hit: SearchHit) { + setPick(hit); + setSelectedMeta(null); + setJobErr(null); + setMetaQ(hit.suggestQuery || hit.title); + setMetaHits([]); + await searchMeta(hit.suggestQuery || hit.title); + } + + async function searchMeta(q: string) { + const term = q.trim(); + if (!term) return; + setMetaBusy(true); + try { + const res = await fetch( + `/api/v1/admin/metadata/search?type=movie&q=${encodeURIComponent(term)}`, + { credentials: "include" } + ); + const data = await res.json().catch(() => ({})); + setMetaHits(data.results ?? []); + } finally { + setMetaBusy(false); + } + } + + async function confirmDownload() { + if (!pick || !selectedMeta || !pick.uri) { + setJobErr("Kies een match en zorg dat er een download-URI is"); + return; + } + setJobBusy(true); + setJobErr(null); + try { + const res = await fetch("/api/v1/admin/downloads/jobs", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nodeId, + uri: pick.uri, + sourceTitle: pick.title, + tmdbId: selectedMeta.tmdbId, + imdbId: selectedMeta.imdbId ?? undefined, + movieTitle: selectedMeta.title, + movieYear: selectedMeta.year, + posterUrl: selectedMeta.posterUrl, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setJobErr(data.error?.message ?? "Download starten mislukt"); + return; + } + setPick(null); + loadJobs(); + } finally { + setJobBusy(false); + } + } + + async function retryImport(id: string) { + await fetch(`/api/v1/admin/downloads/jobs/${id}/retry-import`, { + method: "POST", + credentials: "include", + }); + loadJobs(); + } + + return ( + <> +