Allow canceling active downloads and changing Films/4K target mid-job.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
c75bfc4a97
commit
7b7ca96d91
4 changed files with 148 additions and 8 deletions
|
|
@ -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 <div className="danger-text">{sub.text}</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") && (
|
||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||
Opnieuw importeren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-logout"
|
||||
onClick={() => void deleteJob(j.id, true)}
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -918,7 +957,23 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
)}
|
||||
</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>
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
|
|
@ -933,12 +988,19 @@ export default function DownloadsPage() {
|
|||
</td>
|
||||
<td className="muted">{j.sourceTitle}</td>
|
||||
<td>{j.nodeName}</td>
|
||||
<td>
|
||||
<td className="downloads-job-actions">
|
||||
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
||||
<button type="button" onClick={() => void retryImport(j.id)}>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-logout"
|
||||
onClick={() => void deleteJob(j.id, true)}
|
||||
>
|
||||
Annuleren
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -1023,7 +1085,7 @@ export default function DownloadsPage() {
|
|||
Ondertitel kiezen
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1070,7 +1132,7 @@ export default function DownloadsPage() {
|
|||
Sub kiezen
|
||||
</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
|
||||
</button>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue