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 )} - @@ -1070,7 +1132,7 @@ export default function DownloadsPage() { Sub kiezen )} - 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" },