feat(library): ignore a release (do-not-monitor)

Add MonitoredRelease.ignored (migration add_release_ignored). The monitor's
enqueue_due skips ignored releases, and they drop off the Wanted list —
independent of `monitored`, so auto-monitor-future can't silently re-grab
an album the user chose to ignore.

- Worker enqueue_due: AND NOT mr.ignored.
- /api/releases/[id] PATCH now accepts `ignored`; /api/wanted filters it out;
  artist detail exposes `ignored`.
- UI: Ignore button on Wanted rows; discography shows an "Ignored" badge with
  an Ignore/Un-ignore toggle (monitor + search disabled while ignored).

Worker + web tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 21:03:54 +02:00
parent 6ca39859fa
commit 855a0f15fe
10 changed files with 70 additions and 10 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MonitoredRelease" ADD COLUMN "ignored" BOOLEAN NOT NULL DEFAULT false;
+4
View File
@@ -126,6 +126,10 @@ model MonitoredRelease {
secondaryTypes String[]
firstReleaseDate String?
monitored Boolean @default(false)
// User marked this release "do not monitor" — the monitor skips it (never enqueued as
// wanted/upgrade) and it drops off the Wanted list, independent of `monitored`, so
// auto-monitor-future can't silently re-grab it. Reversible from the discography page.
ignored Boolean @default(false)
state MonitoredReleaseState @default(wanted)
currentQualityClass Int?
firstGrabbedAt DateTime?
+1
View File
@@ -27,6 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
monitored: r.monitored,
ignored: r.ignored,
state: r.state,
currentQualityClass: r.currentQualityClass,
})),
+11 -2
View File
@@ -22,9 +22,18 @@ describe("PATCH /api/releases/[id]", () => {
expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true);
});
it("400s a non-boolean and 404s an unknown release", async () => {
it("toggles ignored independently of monitored", async () => {
const r = await seedRelease(true);
const res = await PATCH(patchReq({ ignored: true }), ctx(r.id));
expect(res.status).toBe(200);
const row = (await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!;
expect(row.ignored).toBe(true);
expect(row.monitored).toBe(true); // ignore doesn't clear monitored
});
it("400s when neither monitored nor ignored is a boolean, 404s an unknown release", async () => {
const r = await seedRelease();
expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400);
expect((await PATCH(patchReq({ monitored: true }), ctx("nope"))).status).toBe(404);
expect((await PATCH(patchReq({ ignored: true }), ctx("nope"))).status).toBe(404);
});
});
+8 -5
View File
@@ -9,13 +9,16 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
} catch {
return Response.json({ error: "invalid JSON" }, { status: 400 });
}
const { monitored } = (body ?? {}) as { monitored?: unknown };
if (typeof monitored !== "boolean") {
return Response.json({ error: "monitored (boolean) is required" }, { status: 400 });
const { monitored, ignored } = (body ?? {}) as { monitored?: unknown; ignored?: unknown };
if (typeof monitored !== "boolean" && typeof ignored !== "boolean") {
return Response.json({ error: "monitored or ignored (boolean) is required" }, { status: 400 });
}
const data: { monitored?: boolean; ignored?: boolean } = {};
if (typeof monitored === "boolean") data.monitored = monitored;
if (typeof ignored === "boolean") data.ignored = ignored;
try {
const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } });
return Response.json({ id: updated.id, monitored: updated.monitored });
const updated = await prisma.monitoredRelease.update({ where: { id }, data });
return Response.json({ id: updated.id, monitored: updated.monitored, ignored: updated.ignored });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
return Response.json({ error: "not found" }, { status: 404 });
+1 -1
View File
@@ -4,7 +4,7 @@ import { searchReleaseGroup } from "@/lib/musicbrainz";
export async function GET() {
const items = await prisma.monitoredRelease.findMany({
where: { monitored: true, state: { not: "fulfilled" } },
where: { monitored: true, ignored: false, state: { not: "fulfilled" } },
orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],
});
return Response.json({
@@ -15,12 +15,14 @@ type Release = {
secondaryTypes: string[];
firstReleaseDate: string | null;
monitored: boolean;
ignored: boolean;
state: string;
currentQualityClass: number | null;
};
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
function badge(r: Release): { label: string; cls: string } {
if (r.ignored) return { label: "Ignored", cls: "chip" };
if (r.currentQualityClass !== null) return { label: `Have q${r.currentQualityClass}`, cls: "chip done" };
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
return { label: "Dormant", cls: "chip" };
@@ -53,6 +55,15 @@ export function DiscographyClient({ id }: { id: string }) {
toast(res?.ok ? `Searching for ${r.album}` : `Couldn't search for ${r.album}`);
refresh();
}
async function toggleIgnore(r: Release) {
const res = await fetch(`/api/releases/${r.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ignored: !r.ignored }),
}).catch(() => null);
if (!res?.ok) toast(`Couldn't update ${r.album}`);
refresh();
}
const counts = useMemo(() => {
const c: Record<string, number> = {};
@@ -108,12 +119,15 @@ export function DiscographyClient({ id }: { id: string }) {
<div className="actions">
<span className={b.cls}>{b.label}</span>
<label className="toggle">
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} />
<input type="checkbox" aria-label={`monitor ${r.album}`} checked={r.monitored} onChange={() => toggleMonitor(r)} disabled={r.ignored} />
monitor
</label>
<button className="btn sm ghost" onClick={() => searchNow(r)}>
<button className="btn sm ghost" onClick={() => searchNow(r)} disabled={r.ignored}>
Search now
</button>
<button className="btn sm ghost" onClick={() => toggleIgnore(r)}>
{r.ignored ? "Un-ignore" : "Ignore"}
</button>
</div>
</li>
);
+17
View File
@@ -65,6 +65,20 @@ export function WantedClient() {
refresh();
}
async function ignore(w: Wanted) {
const res = await fetch(`/api/releases/${w.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ignored: true }),
}).catch(() => null);
if (res?.ok) {
setRows((list) => list.filter((x) => x.id !== w.id));
toast(`Ignoring ${w.album} — un-ignore from the artists discography`);
} else {
toast(`Couldn't ignore ${w.album}`);
}
}
return (
<div>
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
@@ -109,6 +123,9 @@ export function WantedClient() {
<button className="btn sm ghost" onClick={() => searchNow(w)}>
Search now
</button>
<button className="btn sm ghost" onClick={() => ignore(w)}>
Ignore
</button>
</div>
</li>
))}
+1
View File
@@ -121,6 +121,7 @@ def enqueue_due(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired '
'FROM "MonitoredRelease" mr '
'WHERE mr.monitored = true '
' AND NOT mr.ignored ' # user-ignored releases are never enqueued
" AND mr.state <> 'fulfilled' "
' AND (mr."lastSearchedAt" IS NULL '
' OR mr."lastSearchedAt" < now() - make_interval(hours => %s)) '
+9
View File
@@ -38,6 +38,15 @@ def test_skips_unmonitored_and_fulfilled(conn):
assert enqueue_due(conn, CFG) == 0
def test_skips_ignored_release(conn):
rel_id = insert_monitored_release(conn, rg_mbid="rg-ig", monitored=True, state="wanted")
with conn.cursor() as cur:
cur.execute('UPDATE "MonitoredRelease" SET ignored = true WHERE id = %s', (rel_id,))
conn.commit()
assert enqueue_due(conn, CFG) == 0 # ignored → never enqueued even though monitored+wanted
assert _job_count_for(conn, rel_id) == 0
def test_skips_when_recently_searched(conn):
insert_monitored_release(conn, rg_mbid="rg-1", monitored=True, state="wanted",
last_searched_sql="now() - interval '1 hour'") # < 6h retry