Allow canceling active downloads and changing Films/4K target mid-job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 03:05:03 +02:00
parent c75bfc4a97
commit 7b7ca96d91
4 changed files with 148 additions and 8 deletions

View file

@ -564,7 +564,11 @@ export default function DownloadsPage() {
loadJobs(); 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}`, { const res = await fetch(`/api/v1/admin/downloads/jobs/${id}`, {
method: "DELETE", method: "DELETE",
credentials: "include", credentials: "include",
@ -577,6 +581,21 @@ export default function DownloadsPage() {
loadJobs(); 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() { async function clearAllCompleted() {
if (!confirm("Alle voltooide jobs uit de lijst verwijderen? (bestanden blijven staan)")) { if (!confirm("Alle voltooide jobs uit de lijst verwijderen? (bestanden blijven staan)")) {
return; return;
@ -881,12 +900,32 @@ export default function DownloadsPage() {
return <div className="danger-text">{sub.text}</div>; return <div className="danger-text">{sub.text}</div>;
})()} })()}
{j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>} {j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>}
<div className="action-row"> <div className="action-row downloads-job-actions">
{j.libraryTarget !== "SERIES" && j.status !== "IMPORTING" && (
<select
className="downloads-target-select"
value={j.libraryTarget === "FILMS" ? "FILMS" : "FOUR_K"}
onChange={(e) =>
void changeJobTarget(j.id, e.target.value as LibraryTarget)
}
aria-label="Bibliotheekdoel"
>
<option value="FILMS">Films 1080</option>
<option value="FOUR_K">4K</option>
</select>
)}
{(j.status === "FAILED" || j.status === "FINISHED") && ( {(j.status === "FAILED" || j.status === "FINISHED") && (
<button type="button" onClick={() => void retryImport(j.id)}> <button type="button" onClick={() => void retryImport(j.id)}>
Opnieuw importeren Opnieuw importeren
</button> </button>
)} )}
<button
type="button"
className="btn-logout"
onClick={() => void deleteJob(j.id, true)}
>
Annuleren
</button>
</div> </div>
</article> </article>
))} ))}
@ -918,7 +957,23 @@ export default function DownloadsPage() {
</div> </div>
)} )}
</td> </td>
<td className="muted">{jobDetailLabel(j)}</td> <td>
{j.libraryTarget !== "SERIES" && j.status !== "IMPORTING" ? (
<select
className="downloads-target-select"
value={j.libraryTarget === "FILMS" ? "FILMS" : "FOUR_K"}
onChange={(e) =>
void changeJobTarget(j.id, e.target.value as LibraryTarget)
}
aria-label="Bibliotheekdoel"
>
<option value="FILMS">Films 1080</option>
<option value="FOUR_K">4K</option>
</select>
) : (
<span className="muted">{jobDetailLabel(j)}</span>
)}
</td>
<td> <td>
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
@ -933,12 +988,19 @@ export default function DownloadsPage() {
</td> </td>
<td className="muted">{j.sourceTitle}</td> <td className="muted">{j.sourceTitle}</td>
<td>{j.nodeName}</td> <td>{j.nodeName}</td>
<td> <td className="downloads-job-actions">
{(j.status === "FAILED" || j.status === "FINISHED") && ( {(j.status === "FAILED" || j.status === "FINISHED") && (
<button type="button" onClick={() => void retryImport(j.id)}> <button type="button" onClick={() => void retryImport(j.id)}>
Retry Retry
</button> </button>
)} )}
<button
type="button"
className="btn-logout"
onClick={() => void deleteJob(j.id, true)}
>
Annuleren
</button>
</td> </td>
</tr> </tr>
))} ))}
@ -1023,7 +1085,7 @@ export default function DownloadsPage() {
Ondertitel kiezen Ondertitel kiezen
</button> </button>
)} )}
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}> <button type="button" className="btn-logout" onClick={() => void deleteJob(j.id, false)}>
Verwijderen Verwijderen
</button> </button>
</div> </div>
@ -1070,7 +1132,7 @@ export default function DownloadsPage() {
Sub kiezen Sub kiezen
</button> </button>
)} )}
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}> <button type="button" className="btn-logout" onClick={() => void deleteJob(j.id, false)}>
Verwijderen Verwijderen
</button> </button>
</td> </td>

View file

@ -1418,6 +1418,11 @@ tr.row-selected {
gap: 0.35rem; gap: 0.35rem;
justify-content: flex-end; justify-content: flex-end;
} }
.downloads-target-select {
min-height: 36px;
font-size: 0.8rem;
max-width: 9rem;
}
.downloads-mode-row { .downloads-mode-row {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }

View file

@ -159,6 +159,23 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
return downloads.deleteJob(id); 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) => { app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
const body = request.body as { const body = request.body as {
nodeId?: string; nodeId?: string;

View file

@ -838,13 +838,69 @@ export class DownloadService {
async deleteJob(jobId: string) { async deleteJob(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);
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 } }); await prisma.downloadJob.delete({ where: { id: jobId } });
return { ok: true }; 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() { async clearCompletedJobs() {
const result = await prisma.downloadJob.deleteMany({ const result = await prisma.downloadJob.deleteMany({
where: { status: "IMPORTED" }, where: { status: "IMPORTED" },