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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MonitoredRelease" ADD COLUMN "ignored" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -126,6 +126,10 @@ model MonitoredRelease {
|
|||||||
secondaryTypes String[]
|
secondaryTypes String[]
|
||||||
firstReleaseDate String?
|
firstReleaseDate String?
|
||||||
monitored Boolean @default(false)
|
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)
|
state MonitoredReleaseState @default(wanted)
|
||||||
currentQualityClass Int?
|
currentQualityClass Int?
|
||||||
firstGrabbedAt DateTime?
|
firstGrabbedAt DateTime?
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||||||
secondaryTypes: r.secondaryTypes,
|
secondaryTypes: r.secondaryTypes,
|
||||||
firstReleaseDate: r.firstReleaseDate,
|
firstReleaseDate: r.firstReleaseDate,
|
||||||
monitored: r.monitored,
|
monitored: r.monitored,
|
||||||
|
ignored: r.ignored,
|
||||||
state: r.state,
|
state: r.state,
|
||||||
currentQualityClass: r.currentQualityClass,
|
currentQualityClass: r.currentQualityClass,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -22,9 +22,18 @@ describe("PATCH /api/releases/[id]", () => {
|
|||||||
expect((await prisma.monitoredRelease.findUnique({ where: { id: r.id } }))!.monitored).toBe(true);
|
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();
|
const r = await seedRelease();
|
||||||
expect((await PATCH(patchReq({ monitored: 1 }), ctx(r.id))).status).toBe(400);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
|
|||||||
} catch {
|
} catch {
|
||||||
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
return Response.json({ error: "invalid JSON" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const { monitored } = (body ?? {}) as { monitored?: unknown };
|
const { monitored, ignored } = (body ?? {}) as { monitored?: unknown; ignored?: unknown };
|
||||||
if (typeof monitored !== "boolean") {
|
if (typeof monitored !== "boolean" && typeof ignored !== "boolean") {
|
||||||
return Response.json({ error: "monitored (boolean) is required" }, { status: 400 });
|
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 {
|
try {
|
||||||
const updated = await prisma.monitoredRelease.update({ where: { id }, data: { monitored } });
|
const updated = await prisma.monitoredRelease.update({ where: { id }, data });
|
||||||
return Response.json({ id: updated.id, monitored: updated.monitored });
|
return Response.json({ id: updated.id, monitored: updated.monitored, ignored: updated.ignored });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2025") {
|
||||||
return Response.json({ error: "not found" }, { status: 404 });
|
return Response.json({ error: "not found" }, { status: 404 });
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { searchReleaseGroup } from "@/lib/musicbrainz";
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const items = await prisma.monitoredRelease.findMany({
|
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" }],
|
orderBy: [{ lastSearchedAt: { sort: "asc", nulls: "first" } }, { createdAt: "asc" }],
|
||||||
});
|
});
|
||||||
return Response.json({
|
return Response.json({
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ type Release = {
|
|||||||
secondaryTypes: string[];
|
secondaryTypes: string[];
|
||||||
firstReleaseDate: string | null;
|
firstReleaseDate: string | null;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
|
ignored: boolean;
|
||||||
state: string;
|
state: string;
|
||||||
currentQualityClass: number | null;
|
currentQualityClass: number | null;
|
||||||
};
|
};
|
||||||
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
type Detail = { id: string; mbid: string; name: string; autoMonitorFuture: boolean; showAllTypes: boolean; releases: Release[] };
|
||||||
|
|
||||||
function badge(r: Release): { label: string; cls: string } {
|
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.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" };
|
if (r.monitored) return { label: r.state === "wanted" ? "Wanted" : r.state, cls: "chip working" };
|
||||||
return { label: "Dormant", cls: "chip" };
|
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}`);
|
toast(res?.ok ? `Searching for ${r.album}` : `Couldn't search for ${r.album}`);
|
||||||
refresh();
|
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 counts = useMemo(() => {
|
||||||
const c: Record<string, number> = {};
|
const c: Record<string, number> = {};
|
||||||
@@ -108,12 +119,15 @@ export function DiscographyClient({ id }: { id: string }) {
|
|||||||
<div className="actions">
|
<div className="actions">
|
||||||
<span className={b.cls}>{b.label}</span>
|
<span className={b.cls}>{b.label}</span>
|
||||||
<label className="toggle">
|
<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
|
monitor
|
||||||
</label>
|
</label>
|
||||||
<button className="btn sm ghost" onClick={() => searchNow(r)}>
|
<button className="btn sm ghost" onClick={() => searchNow(r)} disabled={r.ignored}>
|
||||||
Search now
|
Search now
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn sm ghost" onClick={() => toggleIgnore(r)}>
|
||||||
|
{r.ignored ? "Un-ignore" : "Ignore"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,6 +65,20 @@ export function WantedClient() {
|
|||||||
refresh();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
|
<PageHead title="Wanted" eyebrow="Cutting list · acquire & upgrade" />
|
||||||
@@ -109,6 +123,9 @@ export function WantedClient() {
|
|||||||
<button className="btn sm ghost" onClick={() => searchNow(w)}>
|
<button className="btn sm ghost" onClick={() => searchNow(w)}>
|
||||||
Search now
|
Search now
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn sm ghost" onClick={() => ignore(w)}>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ def enqueue_due(conn: psycopg.Connection, cfg: MonitorConfig) -> int:
|
|||||||
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired '
|
' AND mr."firstGrabbedAt" < now() - make_interval(days => %s)) AS window_expired '
|
||||||
'FROM "MonitoredRelease" mr '
|
'FROM "MonitoredRelease" mr '
|
||||||
'WHERE mr.monitored = true '
|
'WHERE mr.monitored = true '
|
||||||
|
' AND NOT mr.ignored ' # user-ignored releases are never enqueued
|
||||||
" AND mr.state <> 'fulfilled' "
|
" AND mr.state <> 'fulfilled' "
|
||||||
' AND (mr."lastSearchedAt" IS NULL '
|
' AND (mr."lastSearchedAt" IS NULL '
|
||||||
' OR mr."lastSearchedAt" < now() - make_interval(hours => %s)) '
|
' OR mr."lastSearchedAt" < now() - make_interval(hours => %s)) '
|
||||||
|
|||||||
@@ -38,6 +38,15 @@ def test_skips_unmonitored_and_fulfilled(conn):
|
|||||||
assert enqueue_due(conn, CFG) == 0
|
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):
|
def test_skips_when_recently_searched(conn):
|
||||||
insert_monitored_release(conn, rg_mbid="rg-1", monitored=True, state="wanted",
|
insert_monitored_release(conn, rg_mbid="rg-1", monitored=True, state="wanted",
|
||||||
last_searched_sql="now() - interval '1 hour'") # < 6h retry
|
last_searched_sql="now() - interval '1 hour'") # < 6h retry
|
||||||
|
|||||||
Reference in New Issue
Block a user