diff --git a/web/src/app/api/discover/reset/route.test.ts b/web/src/app/api/discover/reset/route.test.ts new file mode 100644 index 0000000..e8404b4 --- /dev/null +++ b/web/src/app/api/discover/reset/route.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from "vitest"; +import { prisma } from "@/lib/db"; +import { POST } from "./route"; + +describe("POST /api/discover/reset", () => { + it("sets the reset + requested flags so the worker rebuilds on the next sweep", async () => { + const res = await POST(); + expect(res.status).toBe(202); + expect((await res.json()).reset).toBe(true); + const cfg = Object.fromEntries( + (await prisma.config.findMany({ where: { key: { in: ["discover.resetRequested", "discover.requested"] } } })) + .map((c) => [c.key, c.value]), + ); + expect(cfg["discover.resetRequested"]).toBe("true"); + expect(cfg["discover.requested"]).toBe("true"); + }); +}); diff --git a/web/src/app/api/discover/reset/route.ts b/web/src/app/api/discover/reset/route.ts new file mode 100644 index 0000000..7d9e73c --- /dev/null +++ b/web/src/app/api/discover/reset/route.ts @@ -0,0 +1,17 @@ +import { prisma } from "@/lib/db"; + +// POST /api/discover/reset — request a full rebuild: the worker clears the pending +// suggestions, contributions, and seed throttles at the start of the next sweep, then +// regenerates everything with the current logic. Kicks a sweep immediately. +export async function POST() { + await Promise.all( + ["discover.resetRequested", "discover.requested"].map((key) => + prisma.config.upsert({ + where: { key }, + create: { key, value: "true", secret: false }, + update: { value: "true" }, + }), + ), + ); + return Response.json({ reset: true }, { status: 202 }); +} diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 2a97086..9aef1dd 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -69,6 +69,7 @@ export function DiscoverClient() { const [query, setQuery] = useState(""); const [search, setSearch] = useState(null); const [busy, setBusy] = useState(false); + const [confirmReset, setConfirmReset] = useState(false); const [followedSimilar, setFollowedSimilar] = useState>(new Set()); const [albumModal, setAlbumModal] = useState<{ a: Album; artistName: string; artistMbid: string } | null>(null); @@ -106,6 +107,18 @@ export function DiscoverClient() { setRunning(true); } + async function resetDiscover() { + setConfirmReset(false); + const res = await fetch("/api/discover/reset", { method: "POST" }).catch(() => null); + if (res && (res.ok || res.status === 202)) { + setRows([]); // clears immediately; the rebuild repopulates + setRunning(true); + toast("Rebuilding suggestions from scratch…"); + } else { + toast("Couldn't reset"); + } + } + async function runSearch(e: React.FormEvent) { e.preventDefault(); if (!query.trim()) return; @@ -169,13 +182,26 @@ export function DiscoverClient() { - - {running - ? "Sweeping your followed artists for new suggestions…" - : lastRunAt - ? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}` - : "Not yet run — press Discover now to sweep your followed artists"} - + {confirmReset ? ( + <> + Clear all suggestions and rebuild from scratch? + + + + ) : ( + <> + + + {running + ? "Sweeping your followed artists for new suggestions…" + : lastRunAt + ? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}` + : "Not yet run — press Discover now to sweep your followed artists"} + + + )} diff --git a/worker/lyra_worker/discovery.py b/worker/lyra_worker/discovery.py index 901c52f..0f1397e 100644 --- a/worker/lyra_worker/discovery.py +++ b/worker/lyra_worker/discovery.py @@ -315,6 +315,18 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str], return surfaced +def reset_pending(conn: psycopg.Connection) -> None: + """Clear discovery state so the next sweep rebuilds from scratch with current logic. Drops + all PENDING suggestions (followed/wanted/dismissed user decisions are kept), every seed + contribution + library-seed throttle, and marks each watched artist due again.""" + with conn.cursor() as cur: + cur.execute("DELETE FROM \"DiscoverySuggestion\" WHERE status = 'pending'") + cur.execute('DELETE FROM "DiscoverySeedContribution"') + cur.execute('DELETE FROM "DiscoverySeed"') + cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = NULL') + conn.commit() + + def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult: """Record each seed's similar-artist contributions, then recompute affected diff --git a/worker/lyra_worker/main.py b/worker/lyra_worker/main.py index 165135c..95b99fe 100644 --- a/worker/lyra_worker/main.py +++ b/worker/lyra_worker/main.py @@ -10,7 +10,7 @@ from lyra_worker.claim import claim_next, reclaim_stuck_jobs from lyra_worker.config import get_config from lyra_worker.crypto import secret_key_problem from lyra_worker.db import wait_for_db -from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, run_discovery +from lyra_worker.discovery import DiscoveryConfig, DiscoveryResult, reset_pending, run_discovery from lyra_worker.library import clear_staging_root from lyra_worker.monitor import MonitorConfig, fulfill_owned_releases, reconcile, sweep from lyra_worker.pipeline import run_pipeline @@ -147,6 +147,10 @@ def maybe_run_discovery(conn, sources, browser, config, dcfg, now, last_discover starting = not in_progress if starting: # begin a fresh sweep + if str(config.get("discover.resetRequested", "")).strip().lower() in _TRUE: + reset_pending(conn) # wipe stale pending suggestions before rebuilding + _set_config(conn, "discover.resetRequested", "false") + print("worker: discovery reset — cleared pending suggestions, rebuilding", flush=True) _set_config(conn, "discover.inProgress", "true") _set_config(conn, "discover.requested", "false") diff --git a/worker/tests/test_discovery.py b/worker/tests/test_discovery.py index c473ceb..cd9e01e 100644 --- a/worker/tests/test_discovery.py +++ b/worker/tests/test_discovery.py @@ -277,3 +277,32 @@ def test_run_discovery_processes_at_most_chunk_size_seeds(conn): with conn.cursor() as cur: cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL') assert cur.fetchone()[0] == 2 + + +def test_reset_pending_clears_state_and_marks_seeds_due(conn): + from lyra_worker.discovery import reset_pending + from tests.conftest import insert_discovery_suggestion + insert_watched_artist(conn, mbid="s1", name="Seed", last_discovered_sql="now()") + insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="pending") + insert_discovery_suggestion(conn, kind="artist", artist_mbid="c2", status="dismissed") + insert_discovery_suggestion(conn, kind="artist", artist_mbid="c3", status="wanted") + with conn.cursor() as cur: + cur.execute('INSERT INTO "DiscoverySeedContribution" (id, "candidateMbid", "candidateName", ' + '"seedMbid", score, sources, "updatedAt") VALUES ' + "(gen_random_uuid()::text, 'c1', 'C1', 's1', 1.0, '{}', now())") + cur.execute('INSERT INTO "DiscoverySeed" (mbid, "lastDiscoveredAt") VALUES (%s, now())', ("libseed",)) + conn.commit() + + reset_pending(conn) + + with conn.cursor() as cur: + cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status='pending'") + assert cur.fetchone()[0] == 0 # pending cleared + cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status IN ('dismissed','wanted')") + assert cur.fetchone()[0] == 2 # user decisions kept + cur.execute('SELECT count(*) FROM "DiscoverySeedContribution"') + assert cur.fetchone()[0] == 0 + cur.execute('SELECT count(*) FROM "DiscoverySeed"') + assert cur.fetchone()[0] == 0 + cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NULL') + assert cur.fetchone()[0] == 1 # seed due again