diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx
index 8820e2a..d5c0cb8 100644
--- a/apps/admin-ui/src/app/downloads/page.tsx
+++ b/apps/admin-ui/src/app/downloads/page.tsx
@@ -564,7 +564,11 @@ export default function DownloadsPage() {
loadJobs();
}
- async function deleteCompletedJob(id: string) {
+ async function deleteJob(id: string, active: boolean) {
+ const msg = active
+ ? "Download annuleren en uit de lijst verwijderen? (Download Station-taak wordt gestopt)"
+ : "Job uit de lijst verwijderen? (bestanden blijven staan)";
+ if (!confirm(msg)) return;
const res = await fetch(`/api/v1/admin/downloads/jobs/${id}`, {
method: "DELETE",
credentials: "include",
@@ -577,6 +581,21 @@ export default function DownloadsPage() {
loadJobs();
}
+ async function changeJobTarget(id: string, libraryTarget: LibraryTarget) {
+ const res = await fetch(`/api/v1/admin/downloads/jobs/${id}`, {
+ method: "PATCH",
+ credentials: "include",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ libraryTarget }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ alert(data.error?.message ?? "Doel wijzigen mislukt");
+ return;
+ }
+ loadJobs();
+ }
+
async function clearAllCompleted() {
if (!confirm("Alle voltooide jobs uit de lijst verwijderen? (bestanden blijven staan)")) {
return;
@@ -881,12 +900,32 @@ export default function DownloadsPage() {
return
{sub.text}
;
})()}
{j.error && !j.hasSubtitle && {j.error}
}
-
+
+ {j.libraryTarget !== "SERIES" && j.status !== "IMPORTING" && (
+
+ )}
{(j.status === "FAILED" || j.status === "FINISHED") && (
)}
+
))}
@@ -918,7 +957,23 @@ export default function DownloadsPage() {
)}
- {jobDetailLabel(j)} |
+
+ {j.libraryTarget !== "SERIES" && j.status !== "IMPORTING" ? (
+
+ ) : (
+ {jobDetailLabel(j)}
+ )}
+ |
{j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""}
@@ -933,12 +988,19 @@ export default function DownloadsPage() {
|
{j.sourceTitle} |
{j.nodeName} |
-
+ |
{(j.status === "FAILED" || j.status === "FINISHED") && (
)}
+
|
))}
@@ -1023,7 +1085,7 @@ export default function DownloadsPage() {
Ondertitel kiezen
)}
-
)}
- void deleteCompletedJob(j.id)}>
+ void deleteJob(j.id, false)}>
Verwijderen
diff --git a/apps/admin-ui/src/app/globals.css b/apps/admin-ui/src/app/globals.css
index d568658..ca3bbfd 100644
--- a/apps/admin-ui/src/app/globals.css
+++ b/apps/admin-ui/src/app/globals.css
@@ -1418,6 +1418,11 @@ tr.row-selected {
gap: 0.35rem;
justify-content: flex-end;
}
+.downloads-target-select {
+ min-height: 36px;
+ font-size: 0.8rem;
+ max-width: 9rem;
+}
.downloads-mode-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
diff --git a/apps/master-api/src/downloads/routes.ts b/apps/master-api/src/downloads/routes.ts
index 6fd1690..f7b7a3f 100644
--- a/apps/master-api/src/downloads/routes.ts
+++ b/apps/master-api/src/downloads/routes.ts
@@ -159,6 +159,23 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
return downloads.deleteJob(id);
});
+ app.patch("/api/v1/admin/downloads/jobs/:id", { preHandler: requireAdmin }, async (request) => {
+ const { id } = request.params as { id: string };
+ const body = (request.body || {}) as { libraryTarget?: string };
+ if (!isLibraryTarget(body.libraryTarget)) {
+ throw new AppError("INVALID_REQUEST", "libraryTarget verplicht (FILMS, FOUR_K of SERIES)", 400);
+ }
+ const job = await downloads.updateJobTarget(id, body.libraryTarget);
+ return {
+ job: {
+ id: job.id,
+ libraryTarget: job.libraryTarget,
+ status: job.status,
+ movieTitle: job.movieTitle,
+ },
+ };
+ });
+
app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
const body = request.body as {
nodeId?: string;
diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts
index ad20020..6db161e 100644
--- a/apps/master-api/src/downloads/service.ts
+++ b/apps/master-api/src/downloads/service.ts
@@ -838,13 +838,69 @@ export class DownloadService {
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);
+
+ // Stop Download Station task when still active
+ if (job.dsTaskId && job.status !== "IMPORTED") {
+ try {
+ const { client } = await this.clientForNode(job.nodeId);
+ await client.deleteTask(job.dsTaskId, true);
+ } catch (err) {
+ console.warn(
+ `[downloads] DS task delete job=${jobId}:`,
+ err instanceof Error ? err.message : err
+ );
+ }
}
+
await prisma.downloadJob.delete({ where: { id: jobId } });
return { ok: true };
}
+ /** Change Films 1080 / 4K / Series target before import completes. */
+ async updateJobTarget(jobId: string, libraryTarget: LibraryTarget) {
+ if (!isLibraryTarget(libraryTarget)) {
+ throw new AppError("INVALID_REQUEST", "Ongeldig bibliotheekdoel", 400);
+ }
+ 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",
+ "Job is al geïmporteerd — doel kan niet meer wijzigen",
+ 400
+ );
+ }
+ if (libraryTarget === "SERIES" && job.libraryTarget !== "SERIES") {
+ throw new AppError(
+ "INVALID_REQUEST",
+ "Kan niet naar Series wisselen op een film-job — annuleer en start opnieuw als serie",
+ 400
+ );
+ }
+
+ const config = await prisma.synologyDsConfig.findUnique({ where: { nodeId: job.nodeId } });
+ if (!config) throw new AppError("NOT_CONFIGURED", "Geen DS-config", 400);
+ requireLibraryPath(config, libraryTarget);
+
+ return prisma.downloadJob.update({
+ where: { id: jobId },
+ data: {
+ libraryTarget,
+ // Drop series-only fields when moving to a movie library
+ ...(libraryTarget !== "SERIES" && job.libraryTarget === "SERIES"
+ ? {
+ seriesMode: null,
+ seasonNumber: null,
+ episodeNumber: null,
+ episodeTitle: null,
+ episodesJson: null,
+ }
+ : {}),
+ },
+ include: { node: { select: { id: true, name: true } } },
+ });
+ }
+
async clearCompletedJobs() {
const result = await prisma.downloadJob.deleteMany({
where: { status: "IMPORTED" },