Add Films/4K/Series download targets and completed jobs panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 01:42:34 +02:00
parent 19aa18e964
commit a4bb2c591a
8 changed files with 484 additions and 76 deletions

View file

@ -50,6 +50,7 @@ interface JobRow {
nodeId: string; nodeId: string;
nodeName: string; nodeName: string;
status: string; status: string;
libraryTarget?: LibraryTarget;
sourceTitle: string; sourceTitle: string;
movieTitle: string; movieTitle: string;
movieYear: number | null; movieYear: number | null;
@ -64,6 +65,18 @@ interface JobRow {
importedPath?: string | null; 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 { function formatBytes(n: number): string {
if (!n || n <= 0) return "—"; if (!n || n <= 0) return "—";
const u = ["B", "KB", "MB", "GB", "TB"]; const u = ["B", "KB", "MB", "GB", "TB"];
@ -115,6 +128,7 @@ export default function DownloadsPage() {
const [nodeId, setNodeId] = useState(""); const [nodeId, setNodeId] = useState("");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [hits, setHits] = useState<SearchHit[]>([]); const [hits, setHits] = useState<SearchHit[]>([]);
const [searchErr, setSearchErr] = useState<string | null>(null); const [searchErr, setSearchErr] = useState<string | null>(null);
@ -176,6 +190,15 @@ export default function DownloadsPage() {
return nodes.filter((n) => ids.has(n.id)); return nodes.filter((n) => ids.has(n.id));
}, [nodes, configs]); }, [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() { async function runSearch() {
if (!query.trim() || !nodeId) return; if (!query.trim() || !nodeId) return;
setSearching(true); setSearching(true);
@ -186,7 +209,7 @@ export default function DownloadsPage() {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, 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(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
@ -293,6 +316,7 @@ export default function DownloadsPage() {
nodeId, nodeId,
uri: pick.uri, uri: pick.uri,
sourceTitle: pick.title, sourceTitle: pick.title,
libraryTarget,
tmdbId: selectedMeta.tmdbId, tmdbId: selectedMeta.tmdbId,
imdbId: selectedMeta.imdbId ?? undefined, imdbId: selectedMeta.imdbId ?? undefined,
movieTitle: selectedMeta.title, movieTitle: selectedMeta.title,
@ -329,6 +353,35 @@ export default function DownloadsPage() {
loadJobs(); 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) { async function openSubtitlePicker(job: JobRow) {
setSubPickJob(job); setSubPickJob(job);
setSubs([]); setSubs([]);
@ -414,7 +467,7 @@ export default function DownloadsPage() {
<div className="container downloads-page"> <div className="container downloads-page">
<div className="page-header"> <div className="page-header">
<div> <div>
<div className="page-kicker">Synology · 4K</div> <div className="page-kicker">Synology · bibliotheek</div>
<h1>Downloads</h1> <h1>Downloads</h1>
</div> </div>
</div> </div>
@ -432,6 +485,25 @@ export default function DownloadsPage() {
<section className="stack-section"> <section className="stack-section">
<div className="card downloads-search-card"> <div className="card downloads-search-card">
<div className="downloads-target-row" role="radiogroup" aria-label="Bibliotheekdoel">
{TARGET_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={libraryTarget === opt.id}
className={`downloads-target ${libraryTarget === opt.id ? "on" : ""}`}
onClick={() => {
setLibraryTarget(opt.id);
setHits([]);
setSearchErr(null);
}}
>
<strong>{opt.label}</strong>
<span className="muted">{opt.hint}</span>
</button>
))}
</div>
<div className="downloads-toolbar"> <div className="downloads-toolbar">
<label className="downloads-field"> <label className="downloads-field">
<span>Node</span> <span>Node</span>
@ -453,7 +525,13 @@ export default function DownloadsPage() {
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Film + 2160p / UHD…" placeholder={
libraryTarget === "FOUR_K"
? "Filmnaam…"
: libraryTarget === "FILMS"
? "Filmnaam…"
: "Serienaam…"
}
onKeyDown={(e) => e.key === "Enter" && void runSearch()} onKeyDown={(e) => e.key === "Enter" && void runSearch()}
/> />
</label> </label>
@ -532,12 +610,14 @@ export default function DownloadsPage() {
</div> </div>
<div className="card"> <div className="card">
<div className="list-cards"> <div className="list-cards">
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>} {activeJobs.length === 0 && <p className="muted">Geen actieve downloads.</p>}
{jobs.map((j) => ( {activeJobs.map((j) => (
<article key={j.id} className="list-card"> <article key={j.id} className="list-card">
<div className="list-card-top"> <div className="list-card-top">
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
<span className="muted">{j.nodeName}</span> <span className="muted">
{targetLabel(j.libraryTarget)} · {j.nodeName}
</span>
</div> </div>
<div className="list-card-title"> <div className="list-card-title">
{j.movieTitle} {j.movieTitle}
@ -559,11 +639,6 @@ export default function DownloadsPage() {
Opnieuw importeren Opnieuw importeren
</button> </button>
)} )}
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void openSubtitlePicker(j)}>
Ondertitel kiezen
</button>
)}
</div> </div>
</article> </article>
))} ))}
@ -573,26 +648,23 @@ export default function DownloadsPage() {
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>Film</th> <th>Doel</th>
<th>Titel</th>
<th>Bron</th> <th>Bron</th>
<th>Node</th> <th>Node</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{jobs.map((j) => ( {activeJobs.map((j) => (
<tr key={j.id}> <tr key={j.id}>
<td> <td>
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
</td> </td>
<td className="muted">{targetLabel(j.libraryTarget)}</td>
<td> <td>
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
{j.subtitlePath && (
<div className="ok-text">
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
</div>
)}
{j.hasSubtitle && !j.subtitlePath && ( {j.hasSubtitle && !j.subtitlePath && (
<div className="danger-text"> <div className="danger-text">
{j.subtitleError || j.error || "Ondertitel ontbreekt"} {j.subtitleError || j.error || "Ondertitel ontbreekt"}
@ -608,18 +680,119 @@ export default function DownloadsPage() {
Retry Retry
</button> </button>
)} )}
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && ( </td>
</tr>
))}
{activeJobs.length === 0 && (
<tr>
<td colSpan={6} className="muted">
Geen actieve downloads.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
<section className="stack-section">
<div className="section-head downloads-completed-head">
<h2 className="section-title">Voltooid</h2>
{completedJobs.length > 0 && (
<button type="button" className="btn-logout" onClick={() => void clearAllCompleted()}>
Alles legen
</button>
)}
</div>
<div className="card">
<div className="list-cards">
{completedJobs.length === 0 && <p className="muted">Nog geen voltooide jobs.</p>}
{completedJobs.map((j) => (
<article key={j.id} className="list-card">
<div className="list-card-top">
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
<span className="muted">
{targetLabel(j.libraryTarget)} · {j.nodeName}
</span>
</div>
<div className="list-card-title">
{j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""}
</div>
<div className="muted downloads-source">{j.sourceTitle}</div>
{j.subtitlePath && (
<div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div>
)}
{j.hasSubtitle && !j.subtitlePath && (
<div className="danger-text">
{j.subtitleError || j.error || "Ondertitel ontbreekt in map"}
</div>
)}
<div className="action-row">
{j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void openSubtitlePicker(j)}>
Ondertitel kiezen
</button>
)}
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
Verwijderen
</button>
</div>
</article>
))}
</div>
<div className="desktop-table">
<table>
<thead>
<tr>
<th>Status</th>
<th>Doel</th>
<th>Titel</th>
<th>Bron</th>
<th>Node</th>
<th></th>
</tr>
</thead>
<tbody>
{completedJobs.map((j) => (
<tr key={j.id}>
<td>
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
</td>
<td className="muted">{targetLabel(j.libraryTarget)}</td>
<td>
{j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""}
{j.subtitlePath && (
<div className="ok-text">
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
</div>
)}
{j.hasSubtitle && !j.subtitlePath && (
<div className="danger-text">
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
</div>
)}
</td>
<td className="muted">{j.sourceTitle}</td>
<td>{j.nodeName}</td>
<td className="downloads-job-actions">
{j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void openSubtitlePicker(j)}> <button type="button" onClick={() => void openSubtitlePicker(j)}>
Sub kiezen Sub kiezen
</button> </button>
)} )}
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
Verwijderen
</button>
</td> </td>
</tr> </tr>
))} ))}
{jobs.length === 0 && ( {completedJobs.length === 0 && (
<tr> <tr>
<td colSpan={5} className="muted"> <td colSpan={6} className="muted">
Nog geen downloads. Nog geen voltooide jobs.
</td> </td>
</tr> </tr>
)} )}

View file

@ -205,7 +205,7 @@ a:hover {
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25); text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
} }
/* ——— Nav ——— */ /* ??? Nav ??? */
.topnav { .topnav {
position: sticky; position: sticky;
top: 0; top: 0;
@ -371,7 +371,7 @@ a:hover {
display: none; display: none;
} }
/* ——— Tables ——— */ /* ??? Tables ??? */
.table-wrap, .table-wrap,
.card > table { .card > table {
width: 100%; width: 100%;
@ -412,7 +412,7 @@ td {
font-size: 0.875rem; font-size: 0.875rem;
} }
/* ——— Status ——— */ /* ??? Status ??? */
.status { .status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -454,7 +454,7 @@ td {
border-color: rgba(244, 63, 94, 0.3); border-color: rgba(244, 63, 94, 0.3);
} }
/* ——— Buttons ——— */ /* ??? Buttons ??? */
button, button,
.btn { .btn {
background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08)); 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); color: var(--text-muted);
} }
/* ——— Forms ——— */ /* ??? Forms ??? */
input, input,
select, select,
textarea { textarea {
@ -547,7 +547,7 @@ input::placeholder {
text-transform: uppercase; text-transform: uppercase;
} }
/* ——— Login ——— */ /* ??? Login ??? */
.login-container { .login-container {
display: flex; display: flex;
align-items: center; align-items: center;
@ -686,7 +686,7 @@ pre.token-display {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35); box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
} }
/* ——— Layout sections & mobile cards ——— */ /* ??? Layout sections & mobile cards ??? */
.stack-section { .stack-section {
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
} }
@ -1329,7 +1329,7 @@ tr.row-selected {
object-fit: cover; object-fit: cover;
} }
/* ——— Settings tabs ——— */ /* ??? Settings tabs ??? */
.settings-tabs { .settings-tabs {
display: flex; display: flex;
gap: 0.4rem; gap: 0.4rem;
@ -1370,11 +1370,54 @@ tr.row-selected {
} }
} }
/* ——— Downloads (mobile-first) ——— */ /* ??? Downloads (mobile-first) ??? */
.downloads-banner p { .downloads-banner p {
margin: 0; margin: 0;
font-size: 0.9rem; 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 { .downloads-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1453,6 +1496,9 @@ tr.row-selected {
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.downloads-target-row {
grid-template-columns: 1fr;
}
.downloads-toolbar { .downloads-toolbar {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
@ -1484,7 +1530,7 @@ tr.row-selected {
} }
} }
/* <EFBFBD><EFBFBD> Public legal pages (privacy, etc.) <20><> */ /* ?? Public legal pages (privacy, etc.) ?? */
.legal-page { .legal-page {
min-height: 100dvh; min-height: 100dvh;
padding: 2rem 1rem 3rem; padding: 2rem 1rem 3rem;

View file

@ -46,7 +46,9 @@ interface DsConfig {
username: string; username: string;
downloadDestination: string; downloadDestination: string;
downloadHostPath: string; downloadHostPath: string;
filmsHostPath: string;
fourKHostPath: string; fourKHostPath: string;
seriesHostPath: string;
enabled: boolean; enabled: boolean;
lastOkAt?: string | null; lastOkAt?: string | null;
lastError?: string | null; lastError?: string | null;
@ -66,7 +68,9 @@ const emptyConfig = (nodeId = ""): DsConfig => ({
username: "", username: "",
downloadDestination: "Downloads", downloadDestination: "Downloads",
downloadHostPath: "/volume1/Downloads", downloadHostPath: "/volume1/Downloads",
filmsHostPath: "/volume1/Media/Films/Films",
fourKHostPath: "/volume1/Media/Films/4K", fourKHostPath: "/volume1/Media/Films/4K",
seriesHostPath: "/volume1/Media/Series/Series",
enabled: true, enabled: true,
}); });
@ -211,7 +215,7 @@ export default function SettingsPage() {
useEffect(() => { useEffect(() => {
if (!nodeId) return; if (!nodeId) return;
const existing = configs.find((c) => c.nodeId === nodeId); const existing = configs.find((c) => c.nodeId === nodeId);
setCfg(existing ? { ...existing } : emptyConfig(nodeId)); setCfg(existing ? { ...emptyConfig(nodeId), ...existing } : emptyConfig(nodeId));
setPassword(""); setPassword("");
setCfgMsg(null); setCfgMsg(null);
setCfgErr(null); setCfgErr(null);
@ -651,8 +655,8 @@ export default function SettingsPage() {
<div className="section-head"> <div className="section-head">
<h2 className="section-title">Download Station</h2> <h2 className="section-title">Download Station</h2>
<p className="muted"> <p className="muted">
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "} Synology DS-login, staging-pad en bibliotheekdoelen (Films / 4K / Series). Zoeken
<Link href="/downloads">Downloads</Link>. doe je onder <Link href="/downloads">Downloads</Link>.
</p> </p>
</div> </div>
<div className="card"> <div className="card">
@ -702,13 +706,21 @@ export default function SettingsPage() {
/> />
</label> </label>
<label> <label>
Download host-pad Download host-pad (staging)
<input <input
value={cfg.downloadHostPath} value={cfg.downloadHostPath}
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })} onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
placeholder="/volume1/Downloads" placeholder="/volume1/Downloads"
/> />
</label> </label>
<label>
Films 1080 host-pad
<input
value={cfg.filmsHostPath}
onChange={(e) => setCfg({ ...cfg, filmsHostPath: e.target.value })}
placeholder="/volume1/Media/Films/Films"
/>
</label>
<label> <label>
4K bibliotheek host-pad 4K bibliotheek host-pad
<input <input
@ -717,6 +729,14 @@ export default function SettingsPage() {
placeholder="/volume1/Media/Films/4K" placeholder="/volume1/Media/Films/4K"
/> />
</label> </label>
<label>
Series host-pad
<input
value={cfg.seriesHostPath}
onChange={(e) => setCfg({ ...cfg, seriesHostPath: e.target.value })}
placeholder="/volume1/Media/Series/Series"
/>
</label>
</div> </div>
<div className="inline-form" style={{ marginTop: "0.75rem" }}> <div className="inline-form" style={{ marginTop: "0.75rem" }}>
<button type="button" disabled={cfgBusy || !nodeId} onClick={() => void saveConfig()}> <button type="button" disabled={cfgBusy || !nodeId} onClick={() => void saveConfig()}>

View file

@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "films_host_path" TEXT NOT NULL DEFAULT '';
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "series_host_path" TEXT NOT NULL DEFAULT '';
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "DownloadLibraryTarget" AS ENUM ('FILMS', 'FOUR_K', 'SERIES');
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- AlterTable
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K';

View file

@ -564,7 +564,7 @@ model PlaybackSession {
@@map("playback_sessions") @@map("playback_sessions")
} }
/** Synology Download Station credentials + fixed 4K import paths (per node). */ /** Synology Download Station credentials + library import paths (per node). */
model SynologyDsConfig { model SynologyDsConfig {
id String @id @default(uuid()) id String @id @default(uuid())
nodeId String @unique @map("node_id") nodeId String @unique @map("node_id")
@ -576,8 +576,12 @@ model SynologyDsConfig {
downloadDestination String @map("download_destination") downloadDestination String @map("download_destination")
/** Absolute host path of that destination on the media-node */ /** Absolute host path of that destination on the media-node */
downloadHostPath String @map("download_host_path") downloadHostPath String @map("download_host_path")
/** Absolute host path for 4K movies library folder */ /** Absolute host path for 1080p movies (e.g. …/Media/Films/Films) */
filmsHostPath String @default("") @map("films_host_path")
/** Absolute host path for 4K movies (e.g. …/Media/Films/4K) */
fourKHostPath String @map("four_k_host_path") fourKHostPath String @map("four_k_host_path")
/** Absolute host path for series (e.g. …/Media/Series/Series) */
seriesHostPath String @default("") @map("series_host_path")
enabled Boolean @default(true) enabled Boolean @default(true)
lastOkAt DateTime? @map("last_ok_at") lastOkAt DateTime? @map("last_ok_at")
lastError String? @map("last_error") lastError String? @map("last_error")
@ -589,10 +593,18 @@ model SynologyDsConfig {
@@map("synology_ds_configs") @@map("synology_ds_configs")
} }
enum DownloadLibraryTarget {
FILMS
FOUR_K
SERIES
}
model DownloadJob { model DownloadJob {
id String @id @default(uuid()) id String @id @default(uuid())
nodeId String @map("node_id") nodeId String @map("node_id")
status DownloadJobStatus @default(DOWNLOADING) status DownloadJobStatus @default(DOWNLOADING)
/** Where to import after DS finishes */
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
sourceUri String @map("source_uri") sourceUri String @map("source_uri")
sourceTitle String @map("source_title") sourceTitle String @map("source_title")
dsTaskId String? @map("ds_task_id") dsTaskId String? @map("ds_task_id")

View file

@ -303,6 +303,20 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT` `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT`
); );
await prisma.$executeRawUnsafe(`
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "films_host_path" TEXT NOT NULL DEFAULT ''
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "series_host_path" TEXT NOT NULL DEFAULT ''
`);
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "DownloadLibraryTarget" AS ENUM ('FILMS', 'FOUR_K', 'SERIES');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K'
`);
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "integration_settings" ( CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,

View file

@ -3,7 +3,34 @@ import { requireAdmin } from "../auth/routes";
import type { Config } from "../config"; import type { Config } from "../config";
import { MetadataService } from "../metadata/service"; import { MetadataService } from "../metadata/service";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { DownloadService, startDownloadPoller } from "./service"; import { DownloadService, isLibraryTarget, startDownloadPoller, type LibraryTarget } from "./service";
function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
return {
id: j.id,
nodeId: j.nodeId,
nodeName: j.node.name,
status: j.status,
libraryTarget: j.libraryTarget,
sourceTitle: j.sourceTitle,
movieTitle: j.movieTitle,
movieYear: j.movieYear,
posterUrl: j.posterUrl,
tmdbId: j.tmdbId,
imdbId: j.imdbId,
destFolderName: j.destFolderName,
releaseTags: j.releaseTags,
subtitleLang: j.subtitleLang,
subtitleSource: j.subtitleSource,
subtitlePath: j.subtitlePath,
subtitleError: j.subtitleError,
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
importedPath: j.importedPath,
error: j.error,
createdAt: j.createdAt,
updatedAt: j.updatedAt,
};
}
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService { export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
const metadata = new MetadataService(config.TMDB_API_KEY); const metadata = new MetadataService(config.TMDB_API_KEY);
@ -23,7 +50,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
password?: string; password?: string;
downloadDestination?: string; downloadDestination?: string;
downloadHostPath?: string; downloadHostPath?: string;
filmsHostPath?: string;
fourKHostPath?: string; fourKHostPath?: string;
seriesHostPath?: string;
enabled?: boolean; enabled?: boolean;
}; };
if (!body.nodeId || !body.baseUrl || !body.username) { if (!body.nodeId || !body.baseUrl || !body.username) {
@ -36,7 +65,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
password: body.password, password: body.password,
downloadDestination: body.downloadDestination ?? "", downloadDestination: body.downloadDestination ?? "",
downloadHostPath: body.downloadHostPath ?? "", downloadHostPath: body.downloadHostPath ?? "",
filmsHostPath: body.filmsHostPath ?? "",
fourKHostPath: body.fourKHostPath ?? "", fourKHostPath: body.fourKHostPath ?? "",
seriesHostPath: body.seriesHostPath ?? "",
enabled: body.enabled, enabled: body.enabled,
}); });
return { config: row }; return { config: row };
@ -49,12 +80,15 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
}); });
app.post("/api/v1/admin/downloads/search", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/downloads/search", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { nodeId?: string; q?: string }; const body = request.body as { nodeId?: string; q?: string; libraryTarget?: string };
if (!body.nodeId || !body.q?.trim()) { if (!body.nodeId || !body.q?.trim()) {
throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400); throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400);
} }
const results = await downloads.search(body.nodeId, body.q); const target: LibraryTarget = isLibraryTarget(body.libraryTarget)
return { results }; ? body.libraryTarget
: "FOUR_K";
const results = await downloads.search(body.nodeId, body.q, target);
return { results, libraryTarget: target };
}); });
app.get("/api/v1/admin/downloads/subtitles", { preHandler: requireAdmin }, async (request) => { app.get("/api/v1/admin/downloads/subtitles", { preHandler: requireAdmin }, async (request) => {
@ -84,33 +118,18 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => { app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
const q = request.query as { limit?: string }; const q = request.query as { limit?: string };
const limit = q.limit ? parseInt(q.limit, 10) : 50; const limit = q.limit ? parseInt(q.limit, 10) : 80;
const jobs = await downloads.listJobs(limit); const jobs = await downloads.listJobs(limit);
return { return { jobs: jobs.map(mapJob) };
jobs: jobs.map((j) => ({ });
id: j.id,
nodeId: j.nodeId, app.delete("/api/v1/admin/downloads/jobs/completed", { preHandler: requireAdmin }, async () => {
nodeName: j.node.name, return downloads.clearCompletedJobs();
status: j.status, });
sourceTitle: j.sourceTitle,
movieTitle: j.movieTitle, app.delete("/api/v1/admin/downloads/jobs/:id", { preHandler: requireAdmin }, async (request) => {
movieYear: j.movieYear, const { id } = request.params as { id: string };
posterUrl: j.posterUrl, return downloads.deleteJob(id);
tmdbId: j.tmdbId,
imdbId: j.imdbId,
destFolderName: j.destFolderName,
releaseTags: j.releaseTags,
subtitleLang: j.subtitleLang,
subtitleSource: j.subtitleSource,
subtitlePath: j.subtitlePath,
subtitleError: j.subtitleError,
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
importedPath: j.importedPath,
error: j.error,
createdAt: j.createdAt,
updatedAt: j.updatedAt,
})),
};
}); });
app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
@ -118,6 +137,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
nodeId?: string; nodeId?: string;
uri?: string; uri?: string;
sourceTitle?: string; sourceTitle?: string;
libraryTarget?: string;
tmdbId?: number; tmdbId?: number;
imdbId?: string; imdbId?: string;
movieTitle?: string; movieTitle?: string;
@ -130,10 +150,14 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
if (!body.nodeId || !body.uri || !body.movieTitle) { if (!body.nodeId || !body.uri || !body.movieTitle) {
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400); throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
} }
if (!isLibraryTarget(body.libraryTarget)) {
throw new AppError("INVALID_REQUEST", "libraryTarget verplicht (FILMS, FOUR_K of SERIES)", 400);
}
const job = await downloads.createJob({ const job = await downloads.createJob({
nodeId: body.nodeId, nodeId: body.nodeId,
uri: body.uri, uri: body.uri,
sourceTitle: body.sourceTitle || body.movieTitle, sourceTitle: body.sourceTitle || body.movieTitle,
libraryTarget: body.libraryTarget,
tmdbId: body.tmdbId, tmdbId: body.tmdbId,
imdbId: body.imdbId, imdbId: body.imdbId,
movieTitle: body.movieTitle, movieTitle: body.movieTitle,

View file

@ -19,6 +19,15 @@ import {
} from "../opensubtitles/client"; } from "../opensubtitles/client";
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client"; import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles"; import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
import type { DownloadLibraryTarget, SynologyDsConfig } from "@prisma/client";
export type LibraryTarget = DownloadLibraryTarget;
const LIBRARY_TARGETS: LibraryTarget[] = ["FILMS", "FOUR_K", "SERIES"];
export function isLibraryTarget(v: unknown): v is LibraryTarget {
return typeof v === "string" && LIBRARY_TARGETS.includes(v as LibraryTarget);
}
function normalizeBaseUrl(raw: string): string { function normalizeBaseUrl(raw: string): string {
let u = raw.trim().replace(/\/+$/, ""); let u = raw.trim().replace(/\/+$/, "");
@ -30,6 +39,42 @@ function normalizeHostPath(p: string): string {
return p.trim().replace(/\/+$/, ""); return p.trim().replace(/\/+$/, "");
} }
/** Append quality hint only when the user did not already specify one. */
export function searchQueryForTarget(query: string, target: LibraryTarget): string {
const q = query.trim();
if (!q) return q;
if (/\b(2160p|1080p|720p|480p|4k|uhd)\b/i.test(q)) return q;
if (target === "FOUR_K") return `${q} 2160p`;
if (target === "FILMS") return `${q} 1080p`;
return q;
}
function qualityHintForTarget(target: LibraryTarget): string {
if (target === "FOUR_K") return "2160p";
if (target === "FILMS") return "1080p";
return "1080p";
}
function libraryHostPath(config: SynologyDsConfig, target: LibraryTarget): string {
if (target === "FILMS") return normalizeHostPath(config.filmsHostPath || "");
if (target === "SERIES") return normalizeHostPath(config.seriesHostPath || "");
return normalizeHostPath(config.fourKHostPath);
}
function requireLibraryPath(config: SynologyDsConfig, target: LibraryTarget): string {
const path = libraryHostPath(config, target);
if (!path) {
const label =
target === "FILMS" ? "Films (1080)" : target === "SERIES" ? "Series" : "4K";
throw new AppError(
"NOT_CONFIGURED",
`Bibliotheekpad voor ${label} ontbreekt — vul dit in onder Instellingen → Download Station`,
400
);
}
return path;
}
export type DsConfigInput = { export type DsConfigInput = {
nodeId: string; nodeId: string;
baseUrl: string; baseUrl: string;
@ -37,7 +82,9 @@ export type DsConfigInput = {
password?: string; password?: string;
downloadDestination: string; downloadDestination: string;
downloadHostPath: string; downloadHostPath: string;
filmsHostPath: string;
fourKHostPath: string; fourKHostPath: string;
seriesHostPath: string;
enabled?: boolean; enabled?: boolean;
}; };
@ -269,7 +316,9 @@ export class DownloadService {
hasPassword: true, hasPassword: true,
downloadDestination: r.downloadDestination, downloadDestination: r.downloadDestination,
downloadHostPath: r.downloadHostPath, downloadHostPath: r.downloadHostPath,
filmsHostPath: r.filmsHostPath,
fourKHostPath: r.fourKHostPath, fourKHostPath: r.fourKHostPath,
seriesHostPath: r.seriesHostPath,
enabled: r.enabled, enabled: r.enabled,
lastOkAt: r.lastOkAt, lastOkAt: r.lastOkAt,
lastError: r.lastError, lastError: r.lastError,
@ -289,9 +338,22 @@ export class DownloadService {
const username = input.username.trim(); const username = input.username.trim();
const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, ""); const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, "");
const downloadHostPath = normalizeHostPath(input.downloadHostPath); const downloadHostPath = normalizeHostPath(input.downloadHostPath);
const filmsHostPath = normalizeHostPath(input.filmsHostPath);
const fourKHostPath = normalizeHostPath(input.fourKHostPath); const fourKHostPath = normalizeHostPath(input.fourKHostPath);
if (!username || !downloadDestination || !downloadHostPath || !fourKHostPath) { const seriesHostPath = normalizeHostPath(input.seriesHostPath);
throw new AppError("INVALID_REQUEST", "Vul alle verplichte velden in", 400); if (
!username ||
!downloadDestination ||
!downloadHostPath ||
!filmsHostPath ||
!fourKHostPath ||
!seriesHostPath
) {
throw new AppError(
"INVALID_REQUEST",
"Vul alle verplichte velden in (inclusief Films-, 4K- en Series-pad)",
400
);
} }
const passwordEnc = input.password?.trim() const passwordEnc = input.password?.trim()
@ -316,7 +378,9 @@ export class DownloadService {
passwordEnc, passwordEnc,
downloadDestination, downloadDestination,
downloadHostPath, downloadHostPath,
filmsHostPath,
fourKHostPath, fourKHostPath,
seriesHostPath,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
lastOkAt: new Date(), lastOkAt: new Date(),
lastError: null, lastError: null,
@ -327,7 +391,9 @@ export class DownloadService {
passwordEnc, passwordEnc,
downloadDestination, downloadDestination,
downloadHostPath, downloadHostPath,
filmsHostPath,
fourKHostPath, fourKHostPath,
seriesHostPath,
enabled: input.enabled ?? true, enabled: input.enabled ?? true,
lastOkAt: new Date(), lastOkAt: new Date(),
lastError: null, lastError: null,
@ -341,7 +407,9 @@ export class DownloadService {
username: row.username, username: row.username,
downloadDestination: row.downloadDestination, downloadDestination: row.downloadDestination,
downloadHostPath: row.downloadHostPath, downloadHostPath: row.downloadHostPath,
filmsHostPath: row.filmsHostPath,
fourKHostPath: row.fourKHostPath, fourKHostPath: row.fourKHostPath,
seriesHostPath: row.seriesHostPath,
enabled: row.enabled, enabled: row.enabled,
lastOkAt: row.lastOkAt, lastOkAt: row.lastOkAt,
}; };
@ -357,7 +425,11 @@ export class DownloadService {
return { ok: true }; return { ok: true };
} }
async search(nodeId: string, query: string): Promise< async search(
nodeId: string,
query: string,
target: LibraryTarget = "FOUR_K"
): Promise<
Array<{ Array<{
title: string; title: string;
size: number; size: number;
@ -368,10 +440,12 @@ export class DownloadService {
suggestQuery: string; suggestQuery: string;
}> }>
> { > {
const { client } = await this.clientForNode(nodeId); const { client, config } = await this.clientForNode(nodeId);
requireLibraryPath(config, target);
const searchQ = searchQueryForTarget(query, target);
let hits: DsSearchHit[]; let hits: DsSearchHit[];
try { try {
hits = await client.search(query); hits = await client.search(searchQ);
await prisma.synologyDsConfig.update({ await prisma.synologyDsConfig.update({
where: { nodeId }, where: { nodeId },
data: { lastOkAt: new Date(), lastError: null }, data: { lastOkAt: new Date(), lastError: null },
@ -403,6 +477,7 @@ export class DownloadService {
nodeId: string; nodeId: string;
uri: string; uri: string;
sourceTitle: string; sourceTitle: string;
libraryTarget: LibraryTarget;
tmdbId?: number; tmdbId?: number;
imdbId?: string; imdbId?: string;
movieTitle: string; movieTitle: string;
@ -420,8 +495,12 @@ export class DownloadService {
if (!input.tmdbId && !input.imdbId) { if (!input.tmdbId && !input.imdbId) {
throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400); throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400);
} }
if (!isLibraryTarget(input.libraryTarget)) {
throw new AppError("INVALID_REQUEST", "Ongeldig bibliotheekdoel", 400);
}
const { client, config } = await this.clientForNode(input.nodeId); const { client, config } = await this.clientForNode(input.nodeId);
requireLibraryPath(config, input.libraryTarget);
let tmdbId = input.tmdbId; let tmdbId = input.tmdbId;
let imdbId = input.imdbId?.trim() || null; let imdbId = input.imdbId?.trim() || null;
@ -535,6 +614,7 @@ export class DownloadService {
data: { data: {
nodeId: input.nodeId, nodeId: input.nodeId,
status: "DOWNLOADING", status: "DOWNLOADING",
libraryTarget: input.libraryTarget,
sourceUri: uri, sourceUri: uri,
sourceTitle: input.sourceTitle.trim() || title, sourceTitle: input.sourceTitle.trim() || title,
dsTaskId, dsTaskId,
@ -583,12 +663,29 @@ export class DownloadService {
async listJobs(limit = 50) { async listJobs(limit = 50) {
return prisma.downloadJob.findMany({ return prisma.downloadJob.findMany({
take: Math.min(100, Math.max(1, limit)), take: Math.min(200, Math.max(1, limit)),
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { node: { select: { id: true, name: true } } }, include: { node: { select: { id: true, name: true } } },
}); });
} }
async deleteJob(jobId: string) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
if (job.status !== "IMPORTED") {
throw new AppError("INVALID_REQUEST", "Alleen voltooide jobs kunnen uit de lijst", 400);
}
await prisma.downloadJob.delete({ where: { id: jobId } });
return { ok: true };
}
async clearCompletedJobs() {
const result = await prisma.downloadJob.deleteMany({
where: { status: "IMPORTED" },
});
return { deleted: result.count };
}
async retryImport(jobId: string) { async retryImport(jobId: string) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } }); const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404); if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
@ -694,7 +791,18 @@ export class DownloadService {
} }
const destFolder = job.destFolderName || folderNameForMovie(job.movieTitle, job.movieYear); const destFolder = job.destFolderName || folderNameForMovie(job.movieTitle, job.movieYear);
const destDir = `${normalizeHostPath(config.fourKHostPath)}/${destFolder}`; let destRoot: string;
try {
destRoot = requireLibraryPath(config, job.libraryTarget);
} catch (err) {
const message = err instanceof AppError ? err.message : "Bibliotheekpad ontbreekt";
await prisma.downloadJob.update({
where: { id: jobId },
data: { status: "FAILED", error: message },
});
return;
}
const destDir = `${destRoot}/${destFolder}`;
const sourcePath = const sourcePath =
job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`; job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`;
@ -760,7 +868,7 @@ export class DownloadService {
movieTitle: job.movieTitle, movieTitle: job.movieTitle,
movieYear: job.movieYear, movieYear: job.movieYear,
releaseTags, releaseTags,
qualityHint: "2160p", qualityHint: qualityHintForTarget(job.libraryTarget),
subtitle, subtitle,
}, },
120_000 120_000