From a4bb2c591a8e99de7717ee3fe0c71c32d89e6a76 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 8 Sep 2026 01:42:34 +0200 Subject: [PATCH] Add Films/4K/Series download targets and completed jobs panel. Co-authored-by: Cursor --- apps/admin-ui/src/app/downloads/page.tsx | 217 ++++++++++++++++-- apps/admin-ui/src/app/globals.css | 66 +++++- apps/admin-ui/src/app/settings/page.tsx | 28 ++- .../migration.sql | 11 + apps/master-api/prisma/schema.prisma | 16 +- apps/master-api/src/app.ts | 14 ++ apps/master-api/src/downloads/routes.ts | 84 ++++--- apps/master-api/src/downloads/service.ts | 124 +++++++++- 8 files changed, 484 insertions(+), 76 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260908020000_download_library_targets/migration.sql diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index 8f57f2b..41f5def 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -50,6 +50,7 @@ interface JobRow { nodeId: string; nodeName: string; status: string; + libraryTarget?: LibraryTarget; sourceTitle: string; movieTitle: string; movieYear: number | null; @@ -64,6 +65,18 @@ interface JobRow { importedPath?: string | null; } +type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; + +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 formatBytes(n: number): string { if (!n || n <= 0) return "—"; const u = ["B", "KB", "MB", "GB", "TB"]; @@ -115,6 +128,7 @@ export default function DownloadsPage() { const [nodeId, setNodeId] = useState(""); const [query, setQuery] = useState(""); + const [libraryTarget, setLibraryTarget] = useState("FOUR_K"); const [searching, setSearching] = useState(false); const [hits, setHits] = useState([]); const [searchErr, setSearchErr] = useState(null); @@ -176,6 +190,15 @@ export default function DownloadsPage() { 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); @@ -186,7 +209,7 @@ export default function DownloadsPage() { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ nodeId, q: query.trim() }), + body: JSON.stringify({ nodeId, q: query.trim(), libraryTarget }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { @@ -293,6 +316,7 @@ export default function DownloadsPage() { nodeId, uri: pick.uri, sourceTitle: pick.title, + libraryTarget, tmdbId: selectedMeta.tmdbId, imdbId: selectedMeta.imdbId ?? undefined, movieTitle: selectedMeta.title, @@ -329,6 +353,35 @@ export default function DownloadsPage() { 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) { setSubPickJob(job); setSubs([]); @@ -414,7 +467,7 @@ export default function DownloadsPage() {
-
Synology · 4K
+
Synology · bibliotheek

Downloads

@@ -432,6 +485,25 @@ export default function DownloadsPage() {
+
+ {TARGET_OPTIONS.map((opt) => ( + + ))} +
@@ -532,12 +610,14 @@ export default function DownloadsPage() {
- {jobs.length === 0 &&

Nog geen downloads.

} - {jobs.map((j) => ( + {activeJobs.length === 0 &&

Geen actieve downloads.

} + {activeJobs.map((j) => (
{statusLabel(j.status)} - {j.nodeName} + + {targetLabel(j.libraryTarget)} · {j.nodeName} +
{j.movieTitle} @@ -559,11 +639,6 @@ export default function DownloadsPage() { Opnieuw importeren )} - {j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && ( - - )}
))} @@ -573,26 +648,23 @@ export default function DownloadsPage() { Status - Film + Doel + Titel Bron Node - {jobs.map((j) => ( + {activeJobs.map((j) => ( {statusLabel(j.status)} + {targetLabel(j.libraryTarget)} {j.movieTitle} {j.movieYear ? ` (${j.movieYear})` : ""} - {j.subtitlePath && ( -
- Sub: {j.subtitlePath.split(/[/\\]/).pop()} -
- )} {j.hasSubtitle && !j.subtitlePath && (
{j.subtitleError || j.error || "Ondertitel ontbreekt"} @@ -608,18 +680,119 @@ export default function DownloadsPage() { Retry )} - {j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && ( + + + ))} + {activeJobs.length === 0 && ( + + + Geen actieve downloads. + + + )} + + +
+
+
+ +
+
+

Voltooid

+ {completedJobs.length > 0 && ( + + )} +
+
+
+ {completedJobs.length === 0 &&

Nog geen voltooide jobs.

} + {completedJobs.map((j) => ( +
+
+ {statusLabel(j.status)} + + {targetLabel(j.libraryTarget)} · {j.nodeName} + +
+
+ {j.movieTitle} + {j.movieYear ? ` (${j.movieYear})` : ""} +
+
{j.sourceTitle}
+ {j.subtitlePath && ( +
Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}
+ )} + {j.hasSubtitle && !j.subtitlePath && ( +
+ {j.subtitleError || j.error || "Ondertitel ontbreekt in map"} +
+ )} +
+ {j.importedPath && !j.subtitlePath && ( + + )} + +
+
+ ))} +
+
+ + + + + + + + + + + + + {completedJobs.map((j) => ( + + + + + + + ))} - {jobs.length === 0 && ( + {completedJobs.length === 0 && ( - )} diff --git a/apps/admin-ui/src/app/globals.css b/apps/admin-ui/src/app/globals.css index be93522..b25437d 100644 --- a/apps/admin-ui/src/app/globals.css +++ b/apps/admin-ui/src/app/globals.css @@ -205,7 +205,7 @@ a:hover { text-shadow: 0 0 24px rgba(34, 211, 238, 0.25); } -/* ——— Nav ——— */ +/* ??? Nav ??? */ .topnav { position: sticky; top: 0; @@ -371,7 +371,7 @@ a:hover { display: none; } -/* ——— Tables ——— */ +/* ??? Tables ??? */ .table-wrap, .card > table { width: 100%; @@ -412,7 +412,7 @@ td { font-size: 0.875rem; } -/* ——— Status ——— */ +/* ??? Status ??? */ .status { display: inline-flex; align-items: center; @@ -454,7 +454,7 @@ td { border-color: rgba(244, 63, 94, 0.3); } -/* ——— Buttons ——— */ +/* ??? Buttons ??? */ button, .btn { background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08)); @@ -504,7 +504,7 @@ button.ghost { color: var(--text-muted); } -/* ——— Forms ——— */ +/* ??? Forms ??? */ input, select, textarea { @@ -547,7 +547,7 @@ input::placeholder { text-transform: uppercase; } -/* ——— Login ——— */ +/* ??? Login ??? */ .login-container { display: flex; align-items: center; @@ -686,7 +686,7 @@ pre.token-display { box-shadow: 0 0 10px rgba(34, 211, 238, 0.35); } -/* ——— Layout sections & mobile cards ——— */ +/* ??? Layout sections & mobile cards ??? */ .stack-section { margin-bottom: 1.75rem; } @@ -1329,7 +1329,7 @@ tr.row-selected { object-fit: cover; } -/* ——— Settings tabs ——— */ +/* ??? Settings tabs ??? */ .settings-tabs { display: flex; gap: 0.4rem; @@ -1370,11 +1370,54 @@ tr.row-selected { } } -/* ——— Downloads (mobile-first) ——— */ +/* ??? Downloads (mobile-first) ??? */ .downloads-banner p { margin: 0; font-size: 0.9rem; } +.downloads-target-row { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + margin-bottom: 0.85rem; +} +.downloads-target { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + text-align: left; + padding: 0.65rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--border-dim); + background: var(--bg-elevated); + color: var(--text); + cursor: pointer; + min-height: 52px; +} +.downloads-target strong { + font-size: 0.9rem; +} +.downloads-target .muted { + font-size: 0.72rem; +} +.downloads-target.on { + border-color: var(--accent, #3b82f6); + box-shadow: inset 0 0 0 1px var(--accent, #3b82f6); + background: var(--bg); +} +.downloads-completed-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} +.downloads-job-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + justify-content: flex-end; +} .downloads-toolbar { display: flex; flex-wrap: wrap; @@ -1453,6 +1496,9 @@ tr.row-selected { } } @media (max-width: 720px) { + .downloads-target-row { + grid-template-columns: 1fr; + } .downloads-toolbar { flex-direction: column; align-items: stretch; @@ -1484,7 +1530,7 @@ tr.row-selected { } } -/* —— Public legal pages (privacy, etc.) —— */ +/* ?? Public legal pages (privacy, etc.) ?? */ .legal-page { min-height: 100dvh; padding: 2rem 1rem 3rem; diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 50c56bb..472f03c 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -46,7 +46,9 @@ interface DsConfig { username: string; downloadDestination: string; downloadHostPath: string; + filmsHostPath: string; fourKHostPath: string; + seriesHostPath: string; enabled: boolean; lastOkAt?: string | null; lastError?: string | null; @@ -66,7 +68,9 @@ const emptyConfig = (nodeId = ""): DsConfig => ({ username: "", downloadDestination: "Downloads", downloadHostPath: "/volume1/Downloads", + filmsHostPath: "/volume1/Media/Films/Films", fourKHostPath: "/volume1/Media/Films/4K", + seriesHostPath: "/volume1/Media/Series/Series", enabled: true, }); @@ -211,7 +215,7 @@ export default function SettingsPage() { useEffect(() => { if (!nodeId) return; const existing = configs.find((c) => c.nodeId === nodeId); - setCfg(existing ? { ...existing } : emptyConfig(nodeId)); + setCfg(existing ? { ...emptyConfig(nodeId), ...existing } : emptyConfig(nodeId)); setPassword(""); setCfgMsg(null); setCfgErr(null); @@ -651,8 +655,8 @@ export default function SettingsPage() {

Download Station

- Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "} - Downloads. + Synology DS-login, staging-pad en bibliotheekdoelen (Films / 4K / Series). Zoeken + doe je onder Downloads.

@@ -702,13 +706,21 @@ export default function SettingsPage() { /> + +
StatusDoelTitelBronNode
+ {statusLabel(j.status)} + {targetLabel(j.libraryTarget)} + {j.movieTitle} + {j.movieYear ? ` (${j.movieYear})` : ""} + {j.subtitlePath && ( +
+ Sub: {j.subtitlePath.split(/[/\\]/).pop()} +
+ )} + {j.hasSubtitle && !j.subtitlePath && ( +
+ {j.subtitleError || j.error || "Ondertitel ontbreekt"} +
+ )} +
{j.sourceTitle}{j.nodeName} + {j.importedPath && !j.subtitlePath && ( )} +
- Nog geen downloads. + + Nog geen voltooide jobs.