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();
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue