feat(discover): full reset — clear + rebuild suggestions
POST /api/discover/reset sets discover.resetRequested; the worker, at the start of the next sweep, clears all PENDING suggestions + contributions + seed throttles and nulls WatchedArtist.lastDiscoveredAt (keeping followed/wanted/dismissed decisions), then regenerates everything with current logic. A 'Reset' button on /discover (2-step confirm) triggers it. worker 247, web 190 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export function DiscoverClient() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [search, setSearch] = useState<SearchResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [followedSimilar, setFollowedSimilar] = useState<Set<string>>(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() {
|
||||
<button className="btn accent" onClick={discoverNow} disabled={running}>
|
||||
{running ? "Discovering…" : "Discover now"}
|
||||
</button>
|
||||
<span className="rmeta">
|
||||
{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"}
|
||||
</span>
|
||||
{confirmReset ? (
|
||||
<>
|
||||
<span className="rmeta">Clear all suggestions and rebuild from scratch?</span>
|
||||
<button className="btn sm accent" onClick={resetDiscover}>Reset</button>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmReset(false)}>Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn sm ghost" onClick={() => setConfirmReset(true)} disabled={running}>
|
||||
Reset
|
||||
</button>
|
||||
<span className="rmeta">
|
||||
{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"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title="Find similar" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user