feat(library): replace/upgrade an owned album on demand

Add Request.force (migration add_request_force): the pipeline's intake now
skips the "already in library" dedupe for a forced job, so an owned album is
re-downloaded. The import step still keeps the new copy only when it's higher
quality, so a forced upgrade can never downgrade what's on disk.

- Worker: _is_force_job → dedupe bypassed when Request.force.
- POST /api/library/[id]/upgrade finds the matching MonitoredRelease and
  enqueues a force request (guards against stacking on an in-flight job).
- Library route exposes monitoredReleaseId; the album modal shows a
  "Replace / upgrade" button when a release exists.

Worker + web tests added (pipeline force re-acquire; upgrade route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:28:54 +02:00
parent 7719b2a0d8
commit 8c8a34e320
9 changed files with 160 additions and 6 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Request" ADD COLUMN "force" BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -35,6 +35,10 @@ model Request {
album String
status RequestStatus @default(pending)
createdAt DateTime @default(now())
// Force re-acquisition: skip the "already in library" dedupe so an owned album is
// re-downloaded on demand. The import step still keeps the copy only if it's higher
// quality, so a forced upgrade never downgrades. Set by the Library "Replace / upgrade".
force Boolean @default(false)
job Job?
libraryItem LibraryItem?
@@ -0,0 +1,54 @@
import { describe, it, expect } from "vitest";
import { prisma } from "@/lib/db";
import { POST } from "./route";
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
async function owned(artist: string, album: string, rgMbid: string | null) {
return prisma.libraryItem.create({
data: { artist, album, path: "/m", source: "scan", format: "FLAC", qualityClass: 2, rgMbid },
});
}
describe("POST /api/library/[id]/upgrade", () => {
it("enqueues a forced re-acquire request against the matching release", async () => {
await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-25", album: "25", secondaryTypes: [],
monitored: true, state: "fulfilled", currentQualityClass: 2 },
});
const item = await owned("Adele", "25", "rg-25");
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(202);
expect((await res.json()).enqueued).toBe(true);
const reqRow = await prisma.request.findFirst({ where: { album: "25" }, include: { job: true } });
expect(reqRow?.force).toBe(true);
expect(reqRow?.monitoredReleaseId).toBeTruthy();
expect(reqRow?.job).toBeTruthy();
});
it("400s when there is no matching release", async () => {
const item = await owned("Nobody", "Untracked", null);
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(400);
});
it("does not stack a second job while one is in flight", async () => {
const mr = await prisma.monitoredRelease.create({
data: { artistMbid: "a1", artistName: "Adele", rgMbid: "rg-21", album: "21", secondaryTypes: [], monitored: true, state: "wanted" },
});
const item = await owned("Adele", "21", "rg-21");
await prisma.request.create({
data: { artist: "Adele", album: "21", monitoredReleaseId: mr.id, job: { create: { state: "downloading" } } },
});
const res = await POST(new Request("http://x", { method: "POST" }), ctx(item.id));
expect(res.status).toBe(202);
expect((await res.json()).enqueued).toBe(false);
});
it("404s an unknown library item", async () => {
const res = await POST(new Request("http://x", { method: "POST" }), ctx("nope"));
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
// POST /api/library/[id]/upgrade — force a re-acquire of an owned album: enqueue a job that
// skips the "already in library" dedupe (Request.force). The pipeline downloads the best
// available copy and the import step keeps it only if it's higher quality, so this can upgrade
// but never downgrades. Requires a matching MonitoredRelease (scanned/monitored albums have
// one) to carry the acquisition context.
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await prisma.libraryItem.findUnique({ where: { id } });
if (!item) return Response.json({ error: "not found" }, { status: 404 });
// Prefer the release-group match; fall back to an artist+album match.
const release =
(item.rgMbid ? await prisma.monitoredRelease.findUnique({ where: { rgMbid: item.rgMbid } }) : null) ??
(await prisma.monitoredRelease.findFirst({ where: { artistName: item.artist, album: item.album } }));
if (!release) {
return Response.json(
{ error: "no release info for this album — follow the artist or re-scan to enable upgrades" },
{ status: 400 },
);
}
// Don't stack a second job on a release that's already in flight.
const activeJob = await prisma.job.findFirst({
where: { request: { monitoredReleaseId: release.id }, state: { notIn: ["imported", "needs_attention"] } },
});
if (activeJob) return Response.json({ enqueued: false }, { status: 202 });
await prisma.request.create({
data: {
artist: release.artistName,
album: release.album,
monitoredReleaseId: release.id,
force: true,
job: { create: {} },
},
});
await prisma.monitoredRelease.update({ where: { id: release.id }, data: { lastSearchedAt: new Date() } });
return Response.json({ enqueued: true }, { status: 202 });
}
Binary file not shown.
+18
View File
@@ -19,6 +19,7 @@ type Album = {
year: string | null;
primaryType: string | null;
artistMbid: string | null;
monitoredReleaseId: string | null;
trackNames: string[];
};
@@ -115,6 +116,18 @@ export function LibraryClient() {
refresh();
}, []);
async function upgrade() {
if (!open) return;
const res = await fetch(`/api/library/${open.id}/upgrade`, { method: "POST" }).catch(() => null);
if (res && (res.status === 202 || res.ok)) {
const body = await res.json().catch(() => null);
toast(body?.enqueued === false ? `Already searching for ${open.album}` : `Re-acquiring ${open.album} — keeps it only if better`);
show(null);
} else {
toast((res && (await res.json().catch(() => null))?.error) || "Couldn't start the upgrade");
}
}
async function checkIntegrity() {
setChecking(true);
try {
@@ -349,6 +362,11 @@ export function LibraryClient() {
</>
) : (
<>
{open.monitoredReleaseId ? (
<button className="btn sm ghost" onClick={upgrade}>
Replace / upgrade
</button>
) : null}
<button className="btn sm ghost" onClick={startEdit}>
Edit metadata
</button>