diff --git a/web/prisma/migrations/20260714190047_add_release_ignored/migration.sql b/web/prisma/migrations/20260714190047_add_release_ignored/migration.sql new file mode 100644 index 0000000..4991aaf --- /dev/null +++ b/web/prisma/migrations/20260714190047_add_release_ignored/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "MonitoredRelease" ADD COLUMN "ignored" BOOLEAN NOT NULL DEFAULT false; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index c9f132c..23e6e5c 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -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? diff --git a/web/src/app/api/artists/[id]/route.ts b/web/src/app/api/artists/[id]/route.ts index 8c02205..20b3edf 100644 --- a/web/src/app/api/artists/[id]/route.ts +++ b/web/src/app/api/artists/[id]/route.ts @@ -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, })), diff --git a/web/src/app/api/releases/[id]/route.test.ts b/web/src/app/api/releases/[id]/route.test.ts index c118d21..72be7f6 100644 --- a/web/src/app/api/releases/[id]/route.test.ts +++ b/web/src/app/api/releases/[id]/route.test.ts @@ -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); }); }); diff --git a/web/src/app/api/releases/[id]/route.ts b/web/src/app/api/releases/[id]/route.ts index 6e4f9ae..c66f860 100644 --- a/web/src/app/api/releases/[id]/route.ts +++ b/web/src/app/api/releases/[id]/route.ts @@ -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 }); diff --git a/web/src/app/api/wanted/route.ts b/web/src/app/api/wanted/route.ts index a556091..744ee43 100644 --- a/web/src/app/api/wanted/route.ts +++ b/web/src/app/api/wanted/route.ts @@ -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({ diff --git a/web/src/app/artists/[id]/discography-client.tsx b/web/src/app/artists/[id]/discography-client.tsx index ce2959b..65adc94 100644 --- a/web/src/app/artists/[id]/discography-client.tsx +++ b/web/src/app/artists/[id]/discography-client.tsx @@ -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 = {}; @@ -108,12 +119,15 @@ export function DiscographyClient({ id }: { id: string }) {
{b.label} - +
); diff --git a/web/src/app/wanted/wanted-client.tsx b/web/src/app/wanted/wanted-client.tsx index a735ad9..f834147 100644 --- a/web/src/app/wanted/wanted-client.tsx +++ b/web/src/app/wanted/wanted-client.tsx @@ -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 artist’s discography`); + } else { + toast(`Couldn't ignore ${w.album}`); + } + } + return (
@@ -109,6 +123,9 @@ export function WantedClient() { +
))} diff --git a/worker/lyra_worker/monitor.py b/worker/lyra_worker/monitor.py index a4e2f59..c65c0d5 100644 --- a/worker/lyra_worker/monitor.py +++ b/worker/lyra_worker/monitor.py @@ -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)) ' diff --git a/worker/tests/test_monitor_enqueue.py b/worker/tests/test_monitor_enqueue.py index a86707f..f93dc44 100644 --- a/worker/tests/test_monitor_enqueue.py +++ b/worker/tests/test_monitor_enqueue.py @@ -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