diff --git a/web/src/app/api/requests/[id]/retry/route.test.ts b/web/src/app/api/requests/[id]/retry/route.test.ts new file mode 100644 index 0000000..de974f6 --- /dev/null +++ b/web/src/app/api/requests/[id]/retry/route.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { POST } from "./route"; +import { prisma } from "@/lib/db"; +import type { Prisma } from "@prisma/client"; + +function postReq(id: string) { + return new Request(`http://localhost/api/requests/${id}/retry`, { method: "POST" }); +} + +async function seedRequest(jobData: Prisma.JobCreateWithoutRequestInput) { + const created = await prisma.request.create({ + data: { + artist: "Failed Artist", + album: "Failed Album", + status: "needs_attention", + job: { create: jobData }, + }, + include: { job: true }, + }); + return created; +} + +describe("retry API", () => { + it("resets a needs_attention job to requested and deletes candidates", async () => { + const created = await seedRequest({ state: "needs_attention", currentStage: "download", attempts: 2, error: "boom" }); + await prisma.candidate.create({ + data: { + jobId: created.job!.id, + source: "qobuz", + format: "flac", + qualityClass: 1, + trackCount: 12, + confidence: 0.9, + sourceRef: "ref-1", + chosen: true, + }, + }); + await prisma.candidate.create({ + data: { + jobId: created.job!.id, + source: "slskd", + format: "mp3", + qualityClass: 2, + trackCount: 12, + confidence: 0.5, + sourceRef: "ref-2", + chosen: false, + }, + }); + + const res = await POST(postReq(created.id), { params: Promise.resolve({ id: created.id }) }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true, status: "requested" }); + + const job = await prisma.job.findUnique({ where: { id: created.job!.id } }); + expect(job?.state).toBe("requested"); + expect(job?.currentStage).toBe("intake"); + expect(job?.error).toBeNull(); + expect(job?.claimedAt).toBeNull(); + + const candidates = await prisma.candidate.findMany({ where: { jobId: created.job!.id } }); + expect(candidates).toHaveLength(0); + + const request = await prisma.request.findUnique({ where: { id: created.id } }); + expect(request?.status).toBe("pending"); + }); + + it("rejects retrying a job that is not needs_attention", async () => { + const created = await seedRequest({ state: "downloading", currentStage: "download" }); + + const res = await POST(postReq(created.id), { params: Promise.resolve({ id: created.id }) }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("only failed jobs can be retried"); + + const job = await prisma.job.findUnique({ where: { id: created.job!.id } }); + expect(job?.state).toBe("downloading"); + }); + + it("404s for a missing request", async () => { + const res = await POST(postReq("does-not-exist"), { params: Promise.resolve({ id: "does-not-exist" }) }); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("not found"); + }); +}); diff --git a/web/src/app/api/requests/[id]/retry/route.ts b/web/src/app/api/requests/[id]/retry/route.ts new file mode 100644 index 0000000..f744cbf --- /dev/null +++ b/web/src/app/api/requests/[id]/retry/route.ts @@ -0,0 +1,24 @@ +import { prisma } from "@/lib/db"; + +export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + + const req = await prisma.request.findUnique({ where: { id }, include: { job: true } }); + if (!req || !req.job) { + return Response.json({ error: "not found" }, { status: 404 }); + } + if (req.job.state !== "needs_attention") { + return Response.json({ error: "only failed jobs can be retried" }, { status: 400 }); + } + + await prisma.$transaction([ + prisma.candidate.deleteMany({ where: { jobId: req.job.id } }), + prisma.job.update({ + where: { id: req.job.id }, + data: { state: "requested", currentStage: "intake", error: null, claimedAt: null }, + }), + prisma.request.update({ where: { id }, data: { status: "pending" } }), + ]); + + return Response.json({ ok: true, status: "requested" }); +} diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index ec7b55a..5fb2890 100644 --- a/web/src/app/queue.tsx +++ b/web/src/app/queue.tsx @@ -72,6 +72,14 @@ export function Queue() { return () => clearInterval(id); }, []); + async function retry(id: string) { + const res = await fetch(`/api/requests/${id}/retry`, { method: "POST" }); + if (res.ok) { + toast("Retrying…"); + refresh(); + } + } + async function submit(e: React.FormEvent) { e.preventDefault(); if (!artist.trim() || !album.trim()) return; @@ -149,10 +157,14 @@ export function Queue() { meta = undefined; } - const note = attention - ? j.error?.trim() || - "No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release." - : undefined; + const note = attention ? ( + <> + {j.error?.trim() || "No source met the quality cutoff, or the pipeline stalled."}{" "} + + + ) : undefined; const stageLabel = attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;