"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { Nav, useAuth } from "@/components/Nav"; interface NodeRow { id: string; name: string; status: string; } interface DsConfig { nodeId: string; nodeName?: string; } 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 SubtitleHit { fileId: number; language: string; release: string; fileName: string; downloadCount: number; hearingImpaired: boolean; uploader: string | null; source?: "com" | "org"; } interface JobProgress { percent: number | null; bytesDownloaded: number | null; bytesTotal: number | null; speedBps: number | null; dsStatus: string | null; } interface EpisodeTrack { episodeNumber: number; episodeTitle: string; importedPath: string | null; subtitlePath: string | null; subtitleError: string | null; } interface JobRow { id: string; nodeId: string; nodeName: string; status: string; libraryTarget?: LibraryTarget; seriesMode?: "EPISODE" | "SEASON" | null; seasonNumber?: number | null; episodeNumber?: number | null; episodeTitle?: string | null; episodes?: EpisodeTrack[]; sourceTitle: string; movieTitle: string; movieYear: number | null; posterUrl: string | null; tmdbId?: number | null; imdbId?: string | null; error: string | null; hasSubtitle?: boolean; subtitlePath?: string | null; subtitleError?: string | null; subtitleSource?: string | null; importedPath?: string | null; progress?: JobProgress | null; } type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; type SeriesMode = "EPISODE" | "SEASON"; interface SeasonEpisode { episodeNumber: number; title: string; } const TARGET_OPTIONS: Array<{ id: LibraryTarget; label: string; hint: string }> = [ { id: "FILMS", label: "Films 1080", hint: "1080p → Films-map" }, { id: "FOUR_K", label: "4K", hint: "2160p → 4K-map" }, { id: "SERIES", label: "Series", hint: "→ Series-map" }, ]; function targetLabel(t?: string): string { return TARGET_OPTIONS.find((o) => o.id === t)?.label || t || "4K"; } function jobDetailLabel(j: JobRow): string { if (j.libraryTarget !== "SERIES") return targetLabel(j.libraryTarget); const s = j.seasonNumber != null ? `S${String(j.seasonNumber).padStart(2, "0")}` : ""; if (j.seriesMode === "EPISODE" && j.episodeNumber != null) { return `Series · ${s}E${String(j.episodeNumber).padStart(2, "0")}`; } return `Series · ${s || "seizoen"}`; } function formatSpeed(bps: number | null | undefined): string { if (bps == null || bps < 0) return "—"; if (bps === 0) return "0 MB/s"; const mbs = bps / 1_000_000; if (mbs >= 10) return `${mbs.toFixed(1)} MB/s`; if (mbs >= 1) return `${mbs.toFixed(2)} MB/s`; const kbs = bps / 1000; if (kbs >= 1) return `${kbs.toFixed(0)} KB/s`; return `${bps.toFixed(0)} B/s`; } function progressLabel(job: JobRow): string { if (job.status === "IMPORTING" || job.status === "FINISHED") return "Importeren…"; if (job.status !== "DOWNLOADING") return "—"; const p = job.progress; if (!p) return "…"; const parts: string[] = []; if (p.percent != null) parts.push(`${p.percent % 1 === 0 ? p.percent.toFixed(0) : p.percent.toFixed(1)}%`); parts.push(formatSpeed(p.speedBps)); return parts.join(" · "); } function subtitleStatus(job: JobRow): { kind: "ok" | "pending" | "missing"; text: string } | null { if (job.subtitlePath) { return { kind: "ok", text: `Ondertitel: ${job.subtitlePath.split(/[/\\]/).pop()}`, }; } if (!job.hasSubtitle && !job.subtitleError) return null; if (job.status === "IMPORTED") { return { kind: "missing", text: job.subtitleError || "Ondertitel ontbreekt in map", }; } return { kind: "pending", text: job.subtitleError || "Ondertitel gekozen — wordt bij import geschreven", }; } 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"; } } function statusLabel(s: string): string { switch (s) { case "DOWNLOADING": return "Downloaden"; case "FINISHED": return "Klaar"; case "IMPORTING": return "Importeren"; case "IMPORTED": return "Geïmporteerd"; case "FAILED": return "Mislukt"; default: return s; } } export default function DownloadsPage() { useAuth(); const [nodes, setNodes] = useState([]); const [configs, setConfigs] = useState([]); const [nodeId, setNodeId] = useState(""); const [query, setQuery] = useState(""); const [libraryTarget, setLibraryTarget] = useState("FOUR_K"); const [seriesMode, setSeriesMode] = useState("EPISODE"); 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 [seasonCount, setSeasonCount] = useState(0); const [seasonNumber, setSeasonNumber] = useState(1); const [seasonEpisodes, setSeasonEpisodes] = useState([]); const [episodeNumber, setEpisodeNumber] = useState(1); const [seasonBusy, setSeasonBusy] = useState(false); const [jobBusy, setJobBusy] = useState(false); const [jobErr, setJobErr] = useState(null); const [subs, setSubs] = useState([]); const [subsBusy, setSubsBusy] = useState(false); const [subsConfigured, setSubsConfigured] = useState(true); const [selectedSubId, setSelectedSubId] = useState(null); const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null); const [subsMsg, setSubsMsg] = useState(null); const [subPickJob, setSubPickJob] = useState(null); const [subPickEpisode, setSubPickEpisode] = useState(null); const [subPickBusy, setSubPickBusy] = useState(false); 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 loadBootstrap = 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); setNodeId((prev) => prev || list[0]?.nodeId || nodeList[0]?.id || ""); }); loadJobs(); }, [loadJobs]); useEffect(() => { loadBootstrap(); }, [loadBootstrap]); const hasDownloading = useMemo( () => jobs.some((j) => j.status === "DOWNLOADING" || j.status === "IMPORTING" || j.status === "FINISHED"), [jobs] ); useEffect(() => { const ms = hasDownloading ? 4_000 : 15_000; const t = setInterval(loadJobs, ms); return () => clearInterval(t); }, [loadJobs, hasDownloading]); const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]); const configuredNodes = useMemo(() => { const ids = new Set(configs.map((c) => c.nodeId)); return nodes.filter((n) => ids.has(n.id)); }, [nodes, configs]); const activeJobs = useMemo( () => jobs.filter((j) => j.status !== "IMPORTED"), [jobs] ); const completedJobs = useMemo( () => jobs.filter((j) => j.status === "IMPORTED"), [jobs] ); 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(), libraryTarget }), }); 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); setSeasonCount(0); setSeasonEpisodes([]); setSeasonNumber(1); setEpisodeNumber(1); setJobErr(null); setSubs([]); setSelectedSubId(null); setSelectedSubSource(null); setSubsMsg(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 type = libraryTarget === "SERIES" ? "series" : "movie"; const res = await fetch( `/api/v1/admin/metadata/search?type=${type}&q=${encodeURIComponent(term)}`, { credentials: "include" } ); const data = await res.json().catch(() => ({})); setMetaHits(data.results ?? []); } finally { setMetaBusy(false); } } async function loadSeason(tmdbId: number, season: number) { setSeasonBusy(true); try { const res = await fetch(`/api/v1/admin/metadata/tv/${tmdbId}/season/${season}`, { credentials: "include", }); const data = await res.json().catch(() => ({})); const eps: SeasonEpisode[] = data.season?.episodes ?? []; setSeasonEpisodes(eps); if (eps[0]) setEpisodeNumber(eps[0].episodeNumber); } finally { setSeasonBusy(false); } } async function loadSubtitles( meta: MetaHit, releaseHint: string, opts?: { seasonNumber?: number; episodeNumber?: number } ) { setSubsBusy(true); setSubsMsg(null); setSubs([]); setSelectedSubId(null); setSelectedSubSource(null); try { const params = new URLSearchParams({ languages: "nl", releaseHint, movieTitle: meta.title, }); if (meta.year) params.set("movieYear", String(meta.year)); if (meta.imdbId) params.set("imdbId", meta.imdbId); else params.set("tmdbId", String(meta.tmdbId)); if (opts?.seasonNumber != null && opts?.episodeNumber != null) { params.set("mediaKind", "episode"); params.set("seasonNumber", String(opts.seasonNumber)); params.set("episodeNumber", String(opts.episodeNumber)); params.set("tmdbId", String(meta.tmdbId)); } const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, { credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt"); return; } setSubsConfigured(data.configured !== false); if (data.configured === false) { setSubsMsg("OpenSubtitles staat uit — zet aan onder Instellingen"); return; } const results: SubtitleHit[] = data.results ?? []; setSubs(results); if (results[0]) { const preferred = results.find((r) => r.source === "org") || results[0]; setSelectedSubId(preferred.fileId); setSelectedSubSource(preferred.source === "org" ? "org" : "com"); } else { setSubsMsg("Geen NL-ondertitels gevonden"); } } finally { setSubsBusy(false); } } async function selectMeta(m: MetaHit) { setSelectedMeta(m); setSubs([]); setSelectedSubId(null); if (libraryTarget !== "SERIES") { if (pick) await loadSubtitles(m, pick.title); return; } setSeasonBusy(true); try { const res = await fetch(`/api/v1/admin/metadata/tv/${m.tmdbId}`, { credentials: "include", }); const data = await res.json().catch(() => ({})); const n = Math.max(1, data.series?.numberOfSeasons || 1); setSeasonCount(n); const season = Math.min(seasonNumber, n) || 1; setSeasonNumber(season); await loadSeason(m.tmdbId, season); } finally { setSeasonBusy(false); } } async function onSeasonChange(season: number) { setSeasonNumber(season); if (!selectedMeta) return; await loadSeason(selectedMeta.tmdbId, season); setSubs([]); setSelectedSubId(null); } async function onEpisodeChange(ep: number) { setEpisodeNumber(ep); if (!selectedMeta || !pick || seriesMode !== "EPISODE") return; await loadSubtitles(selectedMeta, pick.title, { seasonNumber, episodeNumber: ep, }); } useEffect(() => { if ( libraryTarget === "SERIES" && seriesMode === "EPISODE" && selectedMeta && pick && seasonEpisodes.length > 0 ) { void loadSubtitles(selectedMeta, pick.title, { seasonNumber, episodeNumber, }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [seriesMode, seasonEpisodes, episodeNumber, seasonNumber]); async function confirmDownload() { if (!pick || !selectedMeta || !pick.uri) { setJobErr("Kies een match en zorg dat er een download-URI is"); return; } if (libraryTarget === "SERIES") { if (!seasonNumber) { setJobErr("Kies een seizoen"); return; } if (seriesMode === "EPISODE" && !episodeNumber) { setJobErr("Kies een aflevering"); return; } } setJobBusy(true); setJobErr(null); try { const epTitle = seasonEpisodes.find((e) => e.episodeNumber === episodeNumber)?.title || undefined; 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, libraryTarget, seriesMode: libraryTarget === "SERIES" ? seriesMode : undefined, seasonNumber: libraryTarget === "SERIES" ? seasonNumber : undefined, episodeNumber: libraryTarget === "SERIES" && seriesMode === "EPISODE" ? episodeNumber : undefined, episodeTitle: libraryTarget === "SERIES" && seriesMode === "EPISODE" ? epTitle : undefined, episodes: libraryTarget === "SERIES" && seriesMode === "SEASON" ? seasonEpisodes.map((e) => ({ episodeNumber: e.episodeNumber, episodeTitle: e.title, })) : undefined, tmdbId: selectedMeta.tmdbId, imdbId: selectedMeta.imdbId ?? undefined, movieTitle: selectedMeta.title, movieYear: selectedMeta.year, posterUrl: selectedMeta.posterUrl, subtitleFileId: libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId, subtitleLang: libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId ? "nl" : null, subtitleSource: libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId ? selectedSubSource || "com" : null, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { setJobErr(data.error?.message ?? "Download starten mislukt"); return; } setPick(null); setHits([]); setQuery(""); setSearchErr(null); setSubs([]); setSelectedSubId(null); setSelectedSubSource(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(); } async function deleteCompletedJob(id: string) { const res = await fetch(`/api/v1/admin/downloads/jobs/${id}`, { method: "DELETE", credentials: "include", }); if (!res.ok) { const data = await res.json().catch(() => ({})); alert(data.error?.message ?? "Verwijderen mislukt"); return; } loadJobs(); } async function clearAllCompleted() { if (!confirm("Alle voltooide jobs uit de lijst verwijderen? (bestanden blijven staan)")) { return; } const res = await fetch("/api/v1/admin/downloads/jobs/completed", { method: "DELETE", credentials: "include", }); if (!res.ok) { const data = await res.json().catch(() => ({})); alert(data.error?.message ?? "Legen mislukt"); return; } loadJobs(); } async function openSubtitlePicker(job: JobRow, episode?: number) { setSubPickJob(job); setSubPickEpisode(episode ?? job.episodeNumber ?? null); setSubs([]); setSelectedSubId(null); setSelectedSubSource(null); setSubsMsg(null); setSubsBusy(true); setSubsConfigured(true); try { const ep = episode ?? job.episodeNumber ?? null; const params = new URLSearchParams({ languages: "nl", releaseHint: job.sourceTitle || job.movieTitle, movieTitle: job.movieTitle, }); if (job.movieYear) params.set("movieYear", String(job.movieYear)); if (job.imdbId) params.set("imdbId", job.imdbId); if (job.tmdbId) params.set("tmdbId", String(job.tmdbId)); if (job.libraryTarget === "SERIES" && job.seasonNumber != null && ep != null) { params.set("mediaKind", "episode"); params.set("seasonNumber", String(job.seasonNumber)); params.set("episodeNumber", String(ep)); } else if (!job.imdbId && !job.tmdbId) { setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken"); return; } const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, { credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt"); return; } setSubsConfigured(data.configured !== false); const results: SubtitleHit[] = data.results ?? []; results.sort((a, b) => { if (a.source === "org" && b.source !== "org") return -1; if (b.source === "org" && a.source !== "org") return 1; return (b.downloadCount || 0) - (a.downloadCount || 0); }); setSubs(results); if (results[0]) { setSelectedSubId(results[0].fileId); setSelectedSubSource(results[0].source === "org" ? "org" : "com"); } else { setSubsMsg("Geen ondertitels gevonden"); } } finally { setSubsBusy(false); } } async function confirmSubtitlePick() { if (!subPickJob || selectedSubId == null) { alert("Kies een ondertitel (bij voorkeur OS.org)"); return; } setSubPickBusy(true); try { const res = await fetch(`/api/v1/admin/downloads/jobs/${subPickJob.id}/retry-subtitle`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subtitleFileId: selectedSubId, subtitleSource: selectedSubSource || "org", subtitleLang: "nl", episodeNumber: subPickEpisode ?? undefined, }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { alert(data.error?.message ?? "Ondertitel schrijven mislukt"); return; } setSubPickJob(null); setSubPickEpisode(null); loadJobs(); } finally { setSubPickBusy(false); } } return ( <>