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;
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<LibraryTarget>("FOUR_K");
const [searching, setSearching] = useState(false);
const [hits, setHits] = useState<SearchHit[]>([]);
const [searchErr, setSearchErr] = useState<string | null>(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() {
<div className="container downloads-page">
<div className="page-header">
<div>
<div className="page-kicker">Synology · 4K</div>
<div className="page-kicker">Synology · bibliotheek</div>
<h1>Downloads</h1>
</div>
</div>
@ -432,6 +485,25 @@ export default function DownloadsPage() {
<section className="stack-section">
<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">
<label className="downloads-field">
<span>Node</span>
@ -453,7 +525,13 @@ export default function DownloadsPage() {
<input
value={query}
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()}
/>
</label>
@ -532,12 +610,14 @@ export default function DownloadsPage() {
</div>
<div className="card">
<div className="list-cards">
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>}
{jobs.map((j) => (
{activeJobs.length === 0 && <p className="muted">Geen actieve downloads.</p>}
{activeJobs.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">{j.nodeName}</span>
<span className="muted">
{targetLabel(j.libraryTarget)} · {j.nodeName}
</span>
</div>
<div className="list-card-title">
{j.movieTitle}
@ -559,11 +639,6 @@ export default function DownloadsPage() {
Opnieuw importeren
</button>
)}
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void openSubtitlePicker(j)}>
Ondertitel kiezen
</button>
)}
</div>
</article>
))}
@ -573,26 +648,23 @@ export default function DownloadsPage() {
<thead>
<tr>
<th>Status</th>
<th>Film</th>
<th>Doel</th>
<th>Titel</th>
<th>Bron</th>
<th>Node</th>
<th></th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
{activeJobs.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"}
@ -608,18 +680,119 @@ export default function DownloadsPage() {
Retry
</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)}>
Sub kiezen
</button>
)}
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
Verwijderen
</button>
</td>
</tr>
))}
{jobs.length === 0 && (
{completedJobs.length === 0 && (
<tr>
<td colSpan={5} className="muted">
Nog geen downloads.
<td colSpan={6} className="muted">
Nog geen voltooide jobs.
</td>
</tr>
)}

View file

@ -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 {
}
}
/* <EFBFBD><EFBFBD> Public legal pages (privacy, etc.) <20><> */
/* ?? Public legal pages (privacy, etc.) ?? */
.legal-page {
min-height: 100dvh;
padding: 2rem 1rem 3rem;

View file

@ -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() {
<div className="section-head">
<h2 className="section-title">Download Station</h2>
<p className="muted">
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "}
<Link href="/downloads">Downloads</Link>.
Synology DS-login, staging-pad en bibliotheekdoelen (Films / 4K / Series). Zoeken
doe je onder <Link href="/downloads">Downloads</Link>.
</p>
</div>
<div className="card">
@ -702,13 +706,21 @@ export default function SettingsPage() {
/>
</label>
<label>
Download host-pad
Download host-pad (staging)
<input
value={cfg.downloadHostPath}
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
placeholder="/volume1/Downloads"
/>
</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>
4K bibliotheek host-pad
<input
@ -717,6 +729,14 @@ export default function SettingsPage() {
placeholder="/volume1/Media/Films/4K"
/>
</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 className="inline-form" style={{ marginTop: "0.75rem" }}>
<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")
}
/** Synology Download Station credentials + fixed 4K import paths (per node). */
/** Synology Download Station credentials + library import paths (per node). */
model SynologyDsConfig {
id String @id @default(uuid())
nodeId String @unique @map("node_id")
@ -576,8 +576,12 @@ model SynologyDsConfig {
downloadDestination String @map("download_destination")
/** Absolute host path of that destination on the media-node */
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")
/** Absolute host path for series (e.g. …/Media/Series/Series) */
seriesHostPath String @default("") @map("series_host_path")
enabled Boolean @default(true)
lastOkAt DateTime? @map("last_ok_at")
lastError String? @map("last_error")
@ -589,10 +593,18 @@ model SynologyDsConfig {
@@map("synology_ds_configs")
}
enum DownloadLibraryTarget {
FILMS
FOUR_K
SERIES
}
model DownloadJob {
id String @id @default(uuid())
nodeId String @map("node_id")
status DownloadJobStatus @default(DOWNLOADING)
/** Where to import after DS finishes */
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
sourceUri String @map("source_uri")
sourceTitle String @map("source_title")
dsTaskId String? @map("ds_task_id")

View file

@ -303,6 +303,20 @@ async function main() {
await prisma.$executeRawUnsafe(
`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(`
CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL,

View file

@ -3,7 +3,34 @@ import { requireAdmin } from "../auth/routes";
import type { Config } from "../config";
import { MetadataService } from "../metadata/service";
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 {
const metadata = new MetadataService(config.TMDB_API_KEY);
@ -23,7 +50,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
password?: string;
downloadDestination?: string;
downloadHostPath?: string;
filmsHostPath?: string;
fourKHostPath?: string;
seriesHostPath?: string;
enabled?: boolean;
};
if (!body.nodeId || !body.baseUrl || !body.username) {
@ -36,7 +65,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
password: body.password,
downloadDestination: body.downloadDestination ?? "",
downloadHostPath: body.downloadHostPath ?? "",
filmsHostPath: body.filmsHostPath ?? "",
fourKHostPath: body.fourKHostPath ?? "",
seriesHostPath: body.seriesHostPath ?? "",
enabled: body.enabled,
});
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) => {
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()) {
throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400);
}
const results = await downloads.search(body.nodeId, body.q);
return { results };
const target: LibraryTarget = isLibraryTarget(body.libraryTarget)
? 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) => {
@ -84,33 +118,18 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
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);
return {
jobs: jobs.map((j) => ({
id: j.id,
nodeId: j.nodeId,
nodeName: j.node.name,
status: j.status,
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,
})),
};
return { jobs: jobs.map(mapJob) };
});
app.delete("/api/v1/admin/downloads/jobs/completed", { preHandler: requireAdmin }, async () => {
return downloads.clearCompletedJobs();
});
app.delete("/api/v1/admin/downloads/jobs/:id", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string };
return downloads.deleteJob(id);
});
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;
uri?: string;
sourceTitle?: string;
libraryTarget?: string;
tmdbId?: number;
imdbId?: string;
movieTitle?: string;
@ -130,10 +150,14 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
if (!body.nodeId || !body.uri || !body.movieTitle) {
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({
nodeId: body.nodeId,
uri: body.uri,
sourceTitle: body.sourceTitle || body.movieTitle,
libraryTarget: body.libraryTarget,
tmdbId: body.tmdbId,
imdbId: body.imdbId,
movieTitle: body.movieTitle,

View file

@ -19,6 +19,15 @@ import {
} from "../opensubtitles/client";
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
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 {
let u = raw.trim().replace(/\/+$/, "");
@ -30,6 +39,42 @@ function normalizeHostPath(p: string): string {
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 = {
nodeId: string;
baseUrl: string;
@ -37,7 +82,9 @@ export type DsConfigInput = {
password?: string;
downloadDestination: string;
downloadHostPath: string;
filmsHostPath: string;
fourKHostPath: string;
seriesHostPath: string;
enabled?: boolean;
};
@ -269,7 +316,9 @@ export class DownloadService {
hasPassword: true,
downloadDestination: r.downloadDestination,
downloadHostPath: r.downloadHostPath,
filmsHostPath: r.filmsHostPath,
fourKHostPath: r.fourKHostPath,
seriesHostPath: r.seriesHostPath,
enabled: r.enabled,
lastOkAt: r.lastOkAt,
lastError: r.lastError,
@ -289,9 +338,22 @@ export class DownloadService {
const username = input.username.trim();
const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, "");
const downloadHostPath = normalizeHostPath(input.downloadHostPath);
const filmsHostPath = normalizeHostPath(input.filmsHostPath);
const fourKHostPath = normalizeHostPath(input.fourKHostPath);
if (!username || !downloadDestination || !downloadHostPath || !fourKHostPath) {
throw new AppError("INVALID_REQUEST", "Vul alle verplichte velden in", 400);
const seriesHostPath = normalizeHostPath(input.seriesHostPath);
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()
@ -316,7 +378,9 @@ export class DownloadService {
passwordEnc,
downloadDestination,
downloadHostPath,
filmsHostPath,
fourKHostPath,
seriesHostPath,
enabled: input.enabled ?? true,
lastOkAt: new Date(),
lastError: null,
@ -327,7 +391,9 @@ export class DownloadService {
passwordEnc,
downloadDestination,
downloadHostPath,
filmsHostPath,
fourKHostPath,
seriesHostPath,
enabled: input.enabled ?? true,
lastOkAt: new Date(),
lastError: null,
@ -341,7 +407,9 @@ export class DownloadService {
username: row.username,
downloadDestination: row.downloadDestination,
downloadHostPath: row.downloadHostPath,
filmsHostPath: row.filmsHostPath,
fourKHostPath: row.fourKHostPath,
seriesHostPath: row.seriesHostPath,
enabled: row.enabled,
lastOkAt: row.lastOkAt,
};
@ -357,7 +425,11 @@ export class DownloadService {
return { ok: true };
}
async search(nodeId: string, query: string): Promise<
async search(
nodeId: string,
query: string,
target: LibraryTarget = "FOUR_K"
): Promise<
Array<{
title: string;
size: number;
@ -368,10 +440,12 @@ export class DownloadService {
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[];
try {
hits = await client.search(query);
hits = await client.search(searchQ);
await prisma.synologyDsConfig.update({
where: { nodeId },
data: { lastOkAt: new Date(), lastError: null },
@ -403,6 +477,7 @@ export class DownloadService {
nodeId: string;
uri: string;
sourceTitle: string;
libraryTarget: LibraryTarget;
tmdbId?: number;
imdbId?: string;
movieTitle: string;
@ -420,8 +495,12 @@ export class DownloadService {
if (!input.tmdbId && !input.imdbId) {
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);
requireLibraryPath(config, input.libraryTarget);
let tmdbId = input.tmdbId;
let imdbId = input.imdbId?.trim() || null;
@ -535,6 +614,7 @@ export class DownloadService {
data: {
nodeId: input.nodeId,
status: "DOWNLOADING",
libraryTarget: input.libraryTarget,
sourceUri: uri,
sourceTitle: input.sourceTitle.trim() || title,
dsTaskId,
@ -583,12 +663,29 @@ export class DownloadService {
async listJobs(limit = 50) {
return prisma.downloadJob.findMany({
take: Math.min(100, Math.max(1, limit)),
take: Math.min(200, Math.max(1, limit)),
orderBy: { createdAt: "desc" },
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) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
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 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 =
job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`;
@ -760,7 +868,7 @@ export class DownloadService {
movieTitle: job.movieTitle,
movieYear: job.movieYear,
releaseTags,
qualityHint: "2160p",
qualityHint: qualityHintForTarget(job.libraryTarget),
subtitle,
},
120_000