# Lyra Discovery (Slice 3) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a manual, MetaBrainz-native discovery feed that surfaces new artists and albums similar to the library, feeding accepted suggestions into the existing slice-2 follow / wanted flows. **Architecture:** A worker-side discovery run aggregates ListenBrainz similar-artists across the artists you follow (seeds = `WatchedArtist` MBIDs), persists ranked `DiscoverySuggestion` rows (artist + album kinds), and a `/discover` web UI lets you Follow / Want / Dismiss each. Similarity is fetched behind a pluggable `SimilaritySource` interface (mirrors the `adapters/` registry). A web-side seed-search box calls ListenBrainz directly for interactive lookups. Off by default, like the monitor. **Tech Stack:** Python 3.14 worker (psycopg3, `requests`, pytest); Next.js 15.5 / React 19 web (Prisma 6.1, vitest); Postgres. ListenBrainz Labs API (unauthenticated). ## Global Constraints - **Test DB only.** Worker tests connect to `lyra_test` via `worker/tests/conftest.py`; web tests set `DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test` (the `npm test` script). Both have a guard that refuses any DB whose name does not end in `_test`. Never run suites against live `lyra`. - **Worker layout:** source in `worker/lyra_worker/`, tests in `worker/tests/`, run with `cd worker && python -m pytest`. - **Web layout:** route handlers use the signature `(request: Request, { params }: { params: Promise<{ id: string }> })` and `await params`. Tests are co-located `*.test.ts` files; run with `cd web && npm test`. `src/test/setup.ts` truncates tables before each test. - **Config keys** are camelCase-suffixed under `discover.*` (e.g. `discover.intervalHours`), matching the existing `monitor.*` convention. - **DB enum types** created by Prisma are named `"DiscoveryKind"` and `"SuggestionStatus"`; raw-SQL inserts that pass enum values as *parameters* must cast (`%s::"DiscoveryKind"`), but string *literals* in SQL (`'artist'`, `'pending'`) need no cast. - **Commit after every task.** End each commit message with the repo's co-author trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) ` --- ## File structure **Worker (new):** - `worker/lyra_worker/similarity/__init__.py` — package marker - `worker/lyra_worker/similarity/base.py` — `SimilarArtist` dataclass + `SimilaritySource` Protocol - `worker/lyra_worker/similarity/_listenbrainz.py` — `ListenBrainzSource` (real adapter) - `worker/lyra_worker/release_filter.py` — `is_core_release()` (Python mirror of the web predicate) - `worker/lyra_worker/discovery.py` — `DiscoveryConfig`, `DiscoveryResult`, `run_discovery()` **Worker (modify):** - `worker/lyra_worker/adapters/fakes.py` — add `FakeSimilaritySource` - `worker/lyra_worker/registry.py` — add `build_similarity_sources()` - `worker/lyra_worker/main.py` — build sources, add discover tick + `_run_discovery()` - `worker/tests/conftest.py` — `DiscoverySuggestion` cleanup, `insert_discovery_suggestion()`, `lastDiscoveredAt` param **Web (new):** - `web/src/lib/listenbrainz.ts` — `similarArtists()` for web-side seed search - `web/src/app/api/discover/route.ts` — GET feed - `web/src/app/api/discover/run/route.ts` — POST trigger + GET status - `web/src/app/api/discover/search/route.ts` — GET seed search - `web/src/app/api/discover/[id]/route.ts` — POST action (follow/want/dismiss) - `web/src/app/api/discover/config/route.ts` — GET/PATCH discovery config - `web/src/app/discover/page.tsx` + `discover-client.tsx` — the feed UI **Web (modify):** - `web/prisma/schema.prisma` — `DiscoverySuggestion`, enums, `WatchedArtist.lastDiscoveredAt` - `web/src/test/setup.ts` — `discoverySuggestion.deleteMany()` - `web/src/app/page.tsx` — nav link to `/discover` - `web/src/app/settings/settings-form.tsx` — Discovery config section --- ## Task 1: Schema — `DiscoverySuggestion` + enums + `WatchedArtist.lastDiscoveredAt` **Files:** - Modify: `web/prisma/schema.prisma` - Create: `web/prisma/migrations/_add_discovery/migration.sql` (via prisma) - Modify: `web/src/test/setup.ts` - Modify: `worker/tests/conftest.py` - Test: `worker/tests/test_discovery_schema.py` **Interfaces:** - Produces: table `"DiscoverySuggestion"` (columns `id, kind, "artistMbid", "artistName", "rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt"`); enums `"DiscoveryKind"` (`artist|album`), `"SuggestionStatus"` (`pending|followed|wanted|dismissed`); column `"WatchedArtist"."lastDiscoveredAt"` (nullable timestamp). Conftest helper `insert_discovery_suggestion(conn, kind=, artist_mbid=, artist_name=, rg_mbid=, album=, score=, seed_count=, sources=, status=)` and the `last_discovered_sql=` param on `insert_watched_artist`. - [ ] **Step 1: Add the model, enums, and column to `schema.prisma`** Append to `web/prisma/schema.prisma`: ```prisma enum DiscoveryKind { artist album } enum SuggestionStatus { pending followed wanted dismissed } model DiscoverySuggestion { id String @id @default(cuid()) kind DiscoveryKind artistMbid String artistName String rgMbid String? album String? primaryType String? secondaryTypes String[] firstReleaseDate String? score Float seedCount Int sources String[] status SuggestionStatus @default(pending) dedupeKey String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` Add one line inside the existing `model WatchedArtist { ... }` block, after `lastPolledAt`: ```prisma lastDiscoveredAt DateTime? ``` - [ ] **Step 2: Generate + apply the migration to dev and test DBs** Run (from `web/`): ```bash cd web npx prisma migrate dev --name add_discovery DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra_test npx prisma migrate deploy npx prisma generate ``` Expected: a new folder `web/prisma/migrations/_add_discovery/` with `migration.sql` that `CREATE TYPE "DiscoveryKind"`, `CREATE TYPE "SuggestionStatus"`, `CREATE TABLE "DiscoverySuggestion"`, and `ALTER TABLE "WatchedArtist" ADD COLUMN "lastDiscoveredAt"`. Both `lyra` and `lyra_test` now have the table. - [ ] **Step 3: Add `DiscoverySuggestion` cleanup to both test harnesses** In `web/src/test/setup.ts`, add this line inside the `beforeEach`, before `await prisma.config.deleteMany();`: ```typescript await prisma.discoverySuggestion.deleteMany(); ``` In `worker/tests/conftest.py`, add `cur.execute('DELETE FROM "DiscoverySuggestion"')` to **both** the before-yield and after-yield cleanup blocks (place it before the `DELETE FROM "Config"` line in each). - [ ] **Step 4: Add the `lastDiscoveredAt` param + `insert_discovery_suggestion` helper to conftest** In `worker/tests/conftest.py`, change `insert_watched_artist`'s signature to add `last_discovered_sql="NULL"` and include the column in the INSERT: ```python def insert_watched_artist(conn, mbid="mbid", name="Artist", auto_monitor_future=False, monitor_from_sql="now()", last_polled_sql="NULL", last_discovered_sql="NULL"): """Insert a WatchedArtist; *_sql args are raw SQL time expressions.""" with conn.cursor() as cur: cur.execute( 'INSERT INTO "WatchedArtist" (id, mbid, name, "autoMonitorFuture", ' f'"monitorFrom", "lastPolledAt", "lastDiscoveredAt", "createdAt") ' f"VALUES (gen_random_uuid()::text, %s, %s, %s, {monitor_from_sql}, " f"{last_polled_sql}, {last_discovered_sql}, now()) RETURNING id", (mbid, name, auto_monitor_future), ) artist_id = cur.fetchone()[0] conn.commit() return artist_id ``` Then append this helper at the end of `worker/tests/conftest.py`: ```python def insert_discovery_suggestion(conn, kind="artist", artist_mbid="cand", artist_name="Cand", rg_mbid=None, album=None, score=1.0, seed_count=1, sources=None, status="pending"): """Insert a DiscoverySuggestion; returns its id.""" dedupe = f"{kind}:{artist_mbid}:{rg_mbid or ''}" with conn.cursor() as cur: cur.execute( 'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", ' '"rgMbid", album, score, "seedCount", sources, status, "dedupeKey", ' '"createdAt", "updatedAt") ' 'VALUES (gen_random_uuid()::text, %s::"DiscoveryKind", %s, %s, %s, %s, %s, %s, %s, ' '%s::"SuggestionStatus", %s, now(), now()) RETURNING id', (kind, artist_mbid, artist_name, rg_mbid, album, score, seed_count, sources if sources is not None else ["listenbrainz"], status, dedupe), ) sid = cur.fetchone()[0] conn.commit() return sid ``` - [ ] **Step 5: Write the failing schema round-trip test** Create `worker/tests/test_discovery_schema.py`: ```python import psycopg import pytest from tests.conftest import insert_discovery_suggestion, insert_watched_artist def test_suggestion_round_trip(conn): sid = insert_discovery_suggestion( conn, kind="album", artist_mbid="a1", artist_name="Boygenius", rg_mbid="rg1", album="the record", score=2.5, seed_count=2, sources=["listenbrainz"], status="pending", ) with conn.cursor() as cur: cur.execute( 'SELECT kind::text, "artistName", "rgMbid", album, score, "seedCount", ' 'sources, status::text FROM "DiscoverySuggestion" WHERE id = %s', (sid,)) assert cur.fetchone() == ( "album", "Boygenius", "rg1", "the record", 2.5, 2, ["listenbrainz"], "pending") def test_dedupe_key_is_unique(conn): insert_discovery_suggestion(conn, kind="artist", artist_mbid="dup", rg_mbid=None) with pytest.raises(psycopg.errors.UniqueViolation): insert_discovery_suggestion(conn, kind="artist", artist_mbid="dup", rg_mbid=None) def test_watched_artist_has_last_discovered_column(conn): insert_watched_artist(conn, mbid="a1", last_discovered_sql="now()") with conn.cursor() as cur: cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("a1",)) assert cur.fetchone()[0] is True ``` - [ ] **Step 6: Run the schema tests** Run: `cd worker && python -m pytest tests/test_discovery_schema.py -v` Expected: PASS (3 tests). If they error with "relation does not exist", re-run Step 2's `migrate deploy` against `lyra_test`. - [ ] **Step 7: Commit** ```bash git add web/prisma web/src/test/setup.ts worker/tests/conftest.py worker/tests/test_discovery_schema.py git commit -m "feat(discovery): DiscoverySuggestion schema + WatchedArtist.lastDiscoveredAt Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 2: `SimilaritySource` interface + `FakeSimilaritySource` **Files:** - Create: `worker/lyra_worker/similarity/__init__.py` - Create: `worker/lyra_worker/similarity/base.py` - Modify: `worker/lyra_worker/adapters/fakes.py` - Test: `worker/tests/test_similarity_base.py` **Interfaces:** - Produces: `SimilarArtist(mbid: str, name: str, score: float)` (frozen dataclass); `SimilaritySource` Protocol with `name: str`, `health() -> bool`, `similar_artists(mbid: str) -> list[SimilarArtist]`; `FakeSimilaritySource(similar: dict[str, list[SimilarArtist]] | None = None, healthy: bool = True)` with `name = "fake"`. - [ ] **Step 1: Write the failing test** Create `worker/tests/test_similarity_base.py`: ```python from lyra_worker.adapters.fakes import FakeSimilaritySource from lyra_worker.similarity.base import SimilarArtist, SimilaritySource def test_fake_conforms_to_protocol_and_returns_similar(): src = FakeSimilaritySource(similar={"seed": [SimilarArtist("c1", "Cand", 0.9)]}) assert isinstance(src, SimilaritySource) assert src.health() is True assert src.similar_artists("seed") == [SimilarArtist("c1", "Cand", 0.9)] assert src.similar_artists("unknown") == [] def test_fake_can_report_unhealthy(): assert FakeSimilaritySource(healthy=False).health() is False ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd worker && python -m pytest tests/test_similarity_base.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'lyra_worker.similarity'`. - [ ] **Step 3: Create the package marker** Create empty file `worker/lyra_worker/similarity/__init__.py` (no content). - [ ] **Step 4: Write `base.py`** Create `worker/lyra_worker/similarity/base.py`: ```python from dataclasses import dataclass from typing import Protocol, runtime_checkable @dataclass(frozen=True) class SimilarArtist: mbid: str name: str score: float @runtime_checkable class SimilaritySource(Protocol): name: str def health(self) -> bool: """Is this source reachable right now?""" ... def similar_artists(self, mbid: str) -> list[SimilarArtist]: """Artists similar to the given artist MBID (MBID-native).""" ... ``` - [ ] **Step 5: Add `FakeSimilaritySource` to `fakes.py`** Add this import near the top of `worker/lyra_worker/adapters/fakes.py` (with the other imports): ```python from lyra_worker.similarity.base import SimilarArtist ``` Append this class at the end of `worker/lyra_worker/adapters/fakes.py`: ```python class FakeSimilaritySource: """In-memory SimilaritySource for tests. `similar` maps seed mbid -> [SimilarArtist].""" name = "fake" def __init__(self, similar=None, healthy=True): self._similar = dict(similar or {}) self._healthy = healthy def health(self) -> bool: return self._healthy def similar_artists(self, mbid: str) -> list[SimilarArtist]: return list(self._similar.get(mbid, [])) ``` - [ ] **Step 6: Run test to verify it passes** Run: `cd worker && python -m pytest tests/test_similarity_base.py -v` Expected: PASS (2 tests). - [ ] **Step 7: Commit** ```bash git add worker/lyra_worker/similarity worker/lyra_worker/adapters/fakes.py worker/tests/test_similarity_base.py git commit -m "feat(discovery): SimilaritySource protocol + FakeSimilaritySource Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 3: `DiscoveryConfig` + `run_discovery` (artist suggestions) **Files:** - Create: `worker/lyra_worker/discovery.py` - Test: `worker/tests/test_discovery.py` **Interfaces:** - Consumes: `SimilaritySource`, `SimilarArtist` (Task 2); `FakeSimilaritySource`, `FakeMbBrowser` (fakes); conftest `insert_watched_artist`, `insert_discovery_suggestion`. - Produces: `DiscoveryConfig(enabled, interval_hours, max_seeds, similar_per_seed, albums_per_artist, min_score)` + `DiscoveryConfig.from_config(config: dict)`; `DiscoveryResult(artists: int, albums: int)`; `run_discovery(conn, sources: list[SimilaritySource], browser, cfg: DiscoveryConfig) -> DiscoveryResult`. This task ships `run_discovery` with a `_derive_albums(...)` stub returning `0`; Task 4 replaces the stub. - [ ] **Step 1: Write the failing tests** Create `worker/tests/test_discovery.py`: ```python from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource from lyra_worker.discovery import DiscoveryConfig, run_discovery from lyra_worker.similarity.base import SimilarArtist from tests.conftest import insert_discovery_suggestion, insert_watched_artist def _artist_rows(conn): with conn.cursor() as cur: cur.execute('SELECT "artistMbid", "artistName", score, "seedCount", sources, status::text ' 'FROM "DiscoverySuggestion" WHERE kind = \'artist\' ORDER BY score DESC') return cur.fetchall() def test_aggregates_scores_across_seeds(conn): insert_watched_artist(conn, mbid="s1", name="Seed One") insert_watched_artist(conn, mbid="s2", name="Seed Two") src = FakeSimilaritySource(similar={ "s1": [SimilarArtist("c1", "Shared", 0.6), SimilarArtist("c2", "Only1", 0.4)], "s2": [SimilarArtist("c1", "Shared", 0.5)], }) result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()) assert result.artists == 2 rows = _artist_rows(conn) # c1 recommended by both seeds: score 1.1, seedCount 2; c2 by one seed assert rows[0][0] == "c1" and abs(rows[0][2] - 1.1) < 1e-6 and rows[0][3] == 2 assert rows[0][4] == ["fake"] and rows[0][5] == "pending" assert rows[1][0] == "c2" and rows[1][3] == 1 def test_skips_already_followed_artists(conn): insert_watched_artist(conn, mbid="s1", name="Seed") insert_watched_artist(conn, mbid="c1", name="Already Followed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9), SimilarArtist("c2", "New", 0.8)]}) run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()) assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 filtered (followed) def test_min_score_filters_weak_candidates(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Strong", 0.9), SimilarArtist("c2", "Weak", 0.1)]}) run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig(min_score=0.5)) assert [r[0] for r in _artist_rows(conn)] == ["c1"] def test_dismissed_suggestion_is_never_revived(conn): insert_watched_artist(conn, mbid="s1", name="Seed") insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="dismissed", score=0.0) src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}) run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()) with conn.cursor() as cur: cur.execute('SELECT status::text, score FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', ("c1",)) assert cur.fetchone() == ("dismissed", 0.0) # untouched — WHERE status='pending' blocked the update def test_stamps_last_discovered_and_throttles(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}) assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 1 with conn.cursor() as cur: cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("s1",)) assert cur.fetchone()[0] is True # second run: seed not due (lastDiscoveredAt recent) -> no seeds -> nothing new assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0 def test_unhealthy_source_contributes_nothing(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}, healthy=False) assert run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig()).artists == 0 assert _artist_rows(conn) == [] def test_config_from_config_parses_and_defaults(): cfg = DiscoveryConfig.from_config({"discover.enabled": "true", "discover.maxSeeds": "10", "discover.minScore": "0.3"}) assert cfg.enabled is True and cfg.max_seeds == 10 and cfg.min_score == 0.3 d = DiscoveryConfig.from_config({}) assert (d.enabled, d.interval_hours, d.max_seeds, d.similar_per_seed, d.albums_per_artist, d.min_score) == (False, 168, 50, 20, 1, 0.0) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd worker && python -m pytest tests/test_discovery.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'lyra_worker.discovery'`. - [ ] **Step 3: Write `discovery.py`** Create `worker/lyra_worker/discovery.py`: ```python from dataclasses import dataclass import psycopg from lyra_worker.browser import MbBrowser from lyra_worker.similarity.base import SimilaritySource _TRUE = {"1", "true", "yes", "on"} @dataclass(frozen=True) class DiscoveryConfig: enabled: bool = False interval_hours: int = 168 max_seeds: int = 50 similar_per_seed: int = 20 albums_per_artist: int = 1 min_score: float = 0.0 @classmethod def from_config(cls, config: dict) -> "DiscoveryConfig": def _int(key: str, default: int) -> int: try: return int(config[key]) except (KeyError, TypeError, ValueError): return default def _float(key: str, default: float) -> float: try: return float(config[key]) except (KeyError, TypeError, ValueError): return default return cls( enabled=str(config.get("discover.enabled", "")).strip().lower() in _TRUE, interval_hours=_int("discover.intervalHours", 168), max_seeds=_int("discover.maxSeeds", 50), similar_per_seed=_int("discover.similarPerSeed", 20), albums_per_artist=_int("discover.albumsPerArtist", 1), min_score=_float("discover.minScore", 0.0), ) @dataclass(frozen=True) class DiscoveryResult: artists: int albums: int def _seed_artists(conn: psycopg.Connection, cfg: DiscoveryConfig): with conn.cursor() as cur: cur.execute( 'SELECT mbid, name FROM "WatchedArtist" ' 'WHERE "lastDiscoveredAt" IS NULL ' ' OR "lastDiscoveredAt" < now() - make_interval(hours => %s) ' 'ORDER BY "lastDiscoveredAt" ASC NULLS FIRST LIMIT %s', (cfg.interval_hours, cfg.max_seeds), ) return cur.fetchall() def _followed_mbids(conn: psycopg.Connection) -> set[str]: with conn.cursor() as cur: cur.execute('SELECT mbid FROM "WatchedArtist"') return {r[0] for r in cur.fetchall()} def _upsert_artist(conn: psycopg.Connection, cand: dict) -> int: dedupe = f"artist:{cand['mbid']}:" with conn.cursor() as cur: cur.execute( 'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", ' 'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") ' "VALUES (gen_random_uuid()::text, 'artist', %s, %s, %s, %s, %s, 'pending', %s, now(), now()) " 'ON CONFLICT ("dedupeKey") DO UPDATE SET ' 'score = EXCLUDED.score, "seedCount" = EXCLUDED."seedCount", ' 'sources = EXCLUDED.sources, "updatedAt" = now() ' "WHERE \"DiscoverySuggestion\".status = 'pending'", (cand["mbid"], cand["name"], cand["score"], cand["seed_count"], sorted(cand["sources"]), dedupe), ) return cur.rowcount def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict], cfg: DiscoveryConfig) -> int: return 0 # replaced in the album-derivation task def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], browser: MbBrowser, cfg: DiscoveryConfig) -> DiscoveryResult: """Aggregate similar artists across the library's seed artists; upsert suggestions.""" seeds = _seed_artists(conn, cfg) followed = _followed_mbids(conn) live = [s for s in sources if s.health()] agg: dict[str, dict] = {} for seed_mbid, _seed_name in seeds: for src in live: try: similar = src.similar_artists(seed_mbid) except Exception as e: # a bad source/seed must not abort the sweep print(f"worker: discovery source {src.name} failed for {seed_mbid}: {e}", flush=True) continue for sa in sorted(similar, key=lambda a: a.score, reverse=True)[: cfg.similar_per_seed]: if sa.mbid in followed: continue c = agg.setdefault(sa.mbid, {"mbid": sa.mbid, "name": sa.name, "score": 0.0, "seeds": set(), "sources": set()}) c["score"] += sa.score c["seeds"].add(seed_mbid) c["sources"].add(src.name) if sa.name and not c["name"]: c["name"] = sa.name artists = 0 surfaced: list[dict] = [] for c in sorted(agg.values(), key=lambda c: c["score"], reverse=True): if c["score"] < cfg.min_score: continue cand = {"mbid": c["mbid"], "name": c["name"], "score": c["score"], "seed_count": len(c["seeds"]), "sources": c["sources"]} inserted = _upsert_artist(conn, cand) artists += inserted # Only derive albums for candidates that produced a live pending suggestion. # A no-op upsert (rowcount 0) means the artist was already dismissed/wanted — # don't surface their albums against a dismissal. if inserted: surfaced.append(cand) albums = _derive_albums(conn, browser, surfaced, cfg) with conn.cursor() as cur: for seed_mbid, _seed_name in seeds: cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', (seed_mbid,)) conn.commit() return DiscoveryResult(artists=artists, albums=albums) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd worker && python -m pytest tests/test_discovery.py -v` Expected: PASS (7 tests). - [ ] **Step 5: Commit** ```bash git add worker/lyra_worker/discovery.py worker/tests/test_discovery.py git commit -m "feat(discovery): run_discovery aggregates similar artists into suggestions Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 4: Album derivation in `run_discovery` **Files:** - Create: `worker/lyra_worker/release_filter.py` - Modify: `worker/lyra_worker/discovery.py` (replace `_derive_albums`, add helpers) - Test: `worker/tests/test_release_filter.py` - Test: `worker/tests/test_discovery_albums.py` **Interfaces:** - Consumes: `run_discovery`, `DiscoveryConfig` (Task 3); `FakeMbBrowser`, `FakeSimilaritySource` (fakes); `ReleaseGroupInfo` from `lyra_worker.browser`; conftest `insert_watched_artist`, `insert_monitored_release`. - Produces: `is_core_release(primary_type: str, secondary_types) -> bool` in `lyra_worker.release_filter`; `_derive_albums` now upserts up to `cfg.albums_per_artist` most-recent core album suggestions per surfaced artist, skipping any `rgMbid` already in `MonitoredRelease`. - [ ] **Step 1: Write the failing release-filter test** Create `worker/tests/test_release_filter.py`: ```python from lyra_worker.release_filter import is_core_release def test_core_studio_types_pass(): assert is_core_release("Album", ()) is True assert is_core_release("Single", []) is True assert is_core_release("EP", ()) is True def test_secondary_types_and_non_core_fail(): assert is_core_release("Album", ("Live",)) is False assert is_core_release("Album", ("Compilation",)) is False assert is_core_release("Broadcast", ()) is False assert is_core_release("", ()) is False ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd worker && python -m pytest tests/test_release_filter.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'lyra_worker.release_filter'`. - [ ] **Step 3: Write `release_filter.py`** Create `worker/lyra_worker/release_filter.py`: ```python _CORE_PRIMARY = {"Album", "Single", "EP"} def is_core_release(primary_type: str, secondary_types) -> bool: """A 'core' studio release: Album/Single/EP with no secondary types (live, comp, etc.).""" return (primary_type or "") in _CORE_PRIMARY and len(secondary_types or ()) == 0 ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd worker && python -m pytest tests/test_release_filter.py -v` Expected: PASS (2 tests). - [ ] **Step 5: Write the failing album-derivation tests** Create `worker/tests/test_discovery_albums.py`: ```python from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource from lyra_worker.browser import ReleaseGroupInfo from lyra_worker.discovery import DiscoveryConfig, run_discovery from lyra_worker.similarity.base import SimilarArtist from tests.conftest import insert_monitored_release, insert_watched_artist def _album_rows(conn): with conn.cursor() as cur: cur.execute('SELECT "artistMbid", "rgMbid", album, "primaryType" ' 'FROM "DiscoverySuggestion" WHERE kind = \'album\' ORDER BY album') return cur.fetchall() def test_derives_most_recent_core_album_for_surfaced_artist(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) browser = FakeMbBrowser(releases={"c1": [ ReleaseGroupInfo("rg-old", "Debut", "Album", (), "2001-01-01"), ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2020-01-01"), ReleaseGroupInfo("rg-live", "At The Hall", "Album", ("Live",), "2021-01-01"), ]}) result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) assert result.albums == 1 assert _album_rows(conn) == [("c1", "rg-new", "Latest", "Album")] # newest core, live excluded def test_album_skips_release_groups_already_in_library(conn): insert_watched_artist(conn, mbid="s1", name="Seed") insert_monitored_release(conn, artist_mbid="c1", rg_mbid="rg-have", album="Owned") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) browser = FakeMbBrowser(releases={"c1": [ ReleaseGroupInfo("rg-have", "Owned", "Album", (), "2019-01-01"), ReleaseGroupInfo("rg-fresh", "Fresh", "Album", (), "2018-01-01"), ]}) run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded def test_albums_per_artist_zero_derives_none(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) browser = FakeMbBrowser(releases={"c1": [ReleaseGroupInfo("rg1", "A", "Album", (), "2020")]}) assert run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=0)).albums == 0 assert _album_rows(conn) == [] ``` - [ ] **Step 6: Run to verify they fail** Run: `cd worker && python -m pytest tests/test_discovery_albums.py -v` Expected: FAIL — `_derive_albums` still returns `0`, so assertions on album rows fail. - [ ] **Step 7: Replace `_derive_albums` and add helpers in `discovery.py`** In `worker/lyra_worker/discovery.py`, add this import near the top (with the other `lyra_worker` imports): ```python from lyra_worker.release_filter import is_core_release ``` Replace the stub `def _derive_albums(...): return 0` with: ```python def _existing_rg_mbids(conn: psycopg.Connection) -> set[str]: with conn.cursor() as cur: cur.execute('SELECT "rgMbid" FROM "MonitoredRelease"') return {r[0] for r in cur.fetchall()} def _upsert_album(conn: psycopg.Connection, artist: dict, rg) -> int: dedupe = f"album:{artist['mbid']}:{rg.rg_mbid}" with conn.cursor() as cur: cur.execute( 'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", ' '"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", ' 'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") ' "VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, " "'pending', %s, now(), now()) " 'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, ' '"updatedAt" = now() ' "WHERE \"DiscoverySuggestion\".status = 'pending'", (artist["mbid"], artist["name"], rg.rg_mbid, rg.title, rg.primary_type or None, list(rg.secondary_types), rg.first_release_date or None, artist["score"], artist["seed_count"], sorted(artist["sources"]), dedupe), ) return cur.rowcount def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict], cfg: DiscoveryConfig) -> int: if cfg.albums_per_artist <= 0: return 0 have = _existing_rg_mbids(conn) albums = 0 for artist in surfaced: try: groups = browser.browse_release_groups(artist["mbid"]) except Exception as e: # one artist's browse failure must not abort the sweep print(f"worker: discovery album browse failed for {artist['mbid']}: {e}", flush=True) continue core = [g for g in groups if is_core_release(g.primary_type, g.secondary_types) and g.rg_mbid not in have] core.sort(key=lambda g: g.first_release_date or "", reverse=True) for rg in core[: cfg.albums_per_artist]: albums += _upsert_album(conn, artist, rg) have.add(rg.rg_mbid) return albums ``` - [ ] **Step 8: Run to verify they pass** Run: `cd worker && python -m pytest tests/test_discovery_albums.py tests/test_discovery.py -v` Expected: PASS (all — artist tests from Task 3 still green, 3 album tests green). - [ ] **Step 9: Commit** ```bash git add worker/lyra_worker/release_filter.py worker/lyra_worker/discovery.py worker/tests/test_release_filter.py worker/tests/test_discovery_albums.py git commit -m "feat(discovery): derive core-album suggestions for surfaced artists Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 5: `ListenBrainzSource` (real similarity adapter) **Files:** - Create: `worker/lyra_worker/similarity/_listenbrainz.py` - Test: `worker/tests/test_listenbrainz.py` **Interfaces:** - Consumes: `SimilarArtist` (Task 2); `requests` (already in `worker/requirements.txt`). - Produces: `ListenBrainzSource(base_url: str = , algorithm: str = , timeout: float = 15.0)` with `name = "listenbrainz"`, `health() -> bool`, `similar_artists(mbid) -> list[SimilarArtist]`. - [ ] **Step 1: Write the failing test (mock `requests`)** Create `worker/tests/test_listenbrainz.py`: ```python from unittest.mock import MagicMock, patch import requests from lyra_worker.similarity._listenbrainz import ListenBrainzSource from lyra_worker.similarity.base import SimilarArtist def _resp(payload, status=200): r = MagicMock() r.status_code = status r.json.return_value = payload r.raise_for_status.side_effect = ( None if status < 400 else requests.HTTPError(f"{status}")) return r def test_similar_artists_parses_and_drops_self(): payload = [ {"artist_mbid": "seed", "name": "Seed", "score": 100}, {"artist_mbid": "c1", "name": "Cand One", "score": 42}, {"artist_mbid": "c2", "name": "Cand Two", "score": 7}, ] with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp(payload)): out = ListenBrainzSource().similar_artists("seed") assert out == [SimilarArtist("c1", "Cand One", 42.0), SimilarArtist("c2", "Cand Two", 7.0)] def test_similar_artists_raises_on_http_error(): with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp([], status=500)): try: ListenBrainzSource().similar_artists("seed") assert False, "expected HTTPError" except requests.HTTPError: pass def test_health_true_on_2xx_false_on_exception(): with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp([], status=200)): assert ListenBrainzSource().health() is True with patch("lyra_worker.similarity._listenbrainz.requests.get", side_effect=requests.ConnectionError("down")): assert ListenBrainzSource().health() is False ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd worker && python -m pytest tests/test_listenbrainz.py -v` Expected: FAIL with `ModuleNotFoundError: No module named 'lyra_worker.similarity._listenbrainz'`. - [ ] **Step 3: Write `_listenbrainz.py`** Create `worker/lyra_worker/similarity/_listenbrainz.py`: ```python import requests from lyra_worker.similarity.base import SimilarArtist _DEFAULT_URL = "https://labs.api.listenbrainz.org" # ListenBrainz Labs similar-artists dataset requires an algorithm parameter. # NOTE: confirm this string + response field names against the live API during # manual verification; parsing below is defensive if the shape shifts. _DEFAULT_ALGORITHM = ( "session_based_days_7500_session_300_contribution_5_threshold_15_limit_50_filter_True_skip_30" ) class ListenBrainzSource: """SimilaritySource backed by the (unauthenticated) ListenBrainz Labs API.""" name = "listenbrainz" def __init__(self, base_url: str = _DEFAULT_URL, algorithm: str = _DEFAULT_ALGORITHM, timeout: float = 15.0): self._base = (base_url or _DEFAULT_URL).rstrip("/") self._algorithm = algorithm self._timeout = timeout def health(self) -> bool: try: res = requests.get(self._base + "/", timeout=self._timeout) return res.status_code < 500 except requests.RequestException: return False def similar_artists(self, mbid: str) -> list[SimilarArtist]: res = requests.get( self._base + "/similar-artists/json", params={"artist_mbids": mbid, "algorithm": self._algorithm}, timeout=self._timeout, ) res.raise_for_status() out: list[SimilarArtist] = [] for row in res.json() or []: ambid = row.get("artist_mbid") if not ambid or ambid == mbid: continue out.append(SimilarArtist( mbid=ambid, name=row.get("name") or row.get("comment") or "", score=float(row.get("score") or 0), )) return out ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd worker && python -m pytest tests/test_listenbrainz.py -v` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add worker/lyra_worker/similarity/_listenbrainz.py worker/tests/test_listenbrainz.py git commit -m "feat(discovery): ListenBrainz similarity source Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 6: Registry + worker loop wiring **Files:** - Modify: `worker/lyra_worker/registry.py` - Modify: `worker/lyra_worker/main.py` - Test: `worker/tests/test_similarity_registry.py` - Test: `worker/tests/test_discovery_trigger.py` **Interfaces:** - Consumes: `ListenBrainzSource` (Task 5); `run_discovery`, `DiscoveryConfig` (Tasks 3-4); `FakeSimilaritySource`, `FakeMbBrowser` (fakes). - Produces: `registry.build_similarity_sources(config: dict) -> list[SimilaritySource]` (returns `[ListenBrainzSource(...)]` unconditionally — reachability is a per-run `health()` concern, not a startup gate); `main._run_discovery(conn, sources, browser, config=None)` which runs `run_discovery`, writes `discover.result`, clears `discover.requested`. - [ ] **Step 1: Write the failing registry test** Create `worker/tests/test_similarity_registry.py`: ```python from lyra_worker.registry import build_similarity_sources from lyra_worker.similarity._listenbrainz import ListenBrainzSource def test_build_returns_listenbrainz_by_default(): sources = build_similarity_sources({}) assert len(sources) == 1 assert isinstance(sources[0], ListenBrainzSource) assert sources[0].name == "listenbrainz" def test_base_url_override_from_config(): src = build_similarity_sources({"discover.listenBrainzUrl": "http://lb.local"})[0] assert src._base == "http://lb.local" ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd worker && python -m pytest tests/test_similarity_registry.py -v` Expected: FAIL with `ImportError: cannot import name 'build_similarity_sources'`. - [ ] **Step 3: Add `build_similarity_sources` to `registry.py`** Add these imports at the top of `worker/lyra_worker/registry.py` (with the other imports): ```python from lyra_worker.similarity._listenbrainz import ListenBrainzSource from lyra_worker.similarity.base import SimilaritySource ``` Append this function to `worker/lyra_worker/registry.py`: ```python def build_similarity_sources(config: dict) -> list[SimilaritySource]: """Similarity sources for discovery. ListenBrainz needs no credentials, so it is always constructed; per-run reachability is checked via each source's health().""" return [ListenBrainzSource(base_url=config.get("discover.listenBrainzUrl") or "")] ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd worker && python -m pytest tests/test_similarity_registry.py -v` Expected: PASS (2 tests). - [ ] **Step 5: Write the failing trigger test** Create `worker/tests/test_discovery_trigger.py`: ```python from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource from lyra_worker.main import _run_discovery from lyra_worker.similarity.base import SimilarArtist from tests.conftest import insert_watched_artist def _set(conn, key, value): with conn.cursor() as cur: cur.execute( 'INSERT INTO "Config"(key, value, secret, "updatedAt") VALUES (%s, %s, false, now()) ' 'ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, "updatedAt" = now()', (key, value), ) conn.commit() def _get(conn, key): with conn.cursor() as cur: cur.execute('SELECT value FROM "Config" WHERE key = %s', (key,)) row = cur.fetchone() return row[0] if row else None def test_run_discovery_writes_result_and_clears_flag(conn): insert_watched_artist(conn, mbid="s1", name="Seed") _set(conn, "discover.requested", "true") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) _run_discovery(conn, [src], FakeMbBrowser()) assert _get(conn, "discover.requested") == "false" # flag cleared assert "artists" in (_get(conn, "discover.result") or "") # summary written with conn.cursor() as cur: cur.execute('SELECT count(*) FROM "DiscoverySuggestion" WHERE kind = \'artist\'') assert cur.fetchone()[0] == 1 ``` - [ ] **Step 6: Run it to verify it fails** Run: `cd worker && python -m pytest tests/test_discovery_trigger.py -v` Expected: FAIL with `ImportError: cannot import name '_run_discovery'`. - [ ] **Step 7: Wire discovery into `main.py`** In `worker/lyra_worker/main.py`, extend the imports: ```python from lyra_worker.discovery import DiscoveryConfig, run_discovery from lyra_worker.registry import ( build_adapters, build_browser, build_probe, build_resolver, build_similarity_sources, build_tagger, ) ``` (Replace the existing `from lyra_worker.registry import ...` line with the multi-name version above.) Add a constant near `MONITOR_TICK_SECONDS`: ```python DISCOVER_TICK_SECONDS = 300.0 # check the discovery interval every 5 min (interval itself is hours) ``` Add this function next to `_run_scan`: ```python def _run_discovery(conn, sources, browser, config=None) -> None: cfg = DiscoveryConfig.from_config(config if config is not None else get_config(conn)) result = run_discovery(conn, sources, browser, cfg) _set_config(conn, "discover.result", f"artists {result.artists}, albums {result.albums}") _set_config(conn, "discover.requested", "false") print(f"worker: discovery done — {result.artists} artists, {result.albums} albums", flush=True) ``` In `run_forever`, after `probe = build_probe()` add: ```python similarity_sources = build_similarity_sources(get_config(conn)) ``` Initialize a second tick timer next to `last_tick = 0.0`: ```python last_discover_tick = 0.0 ``` Inside the `while True:` loop, after the monitor-sweep block (right after `last_tick = now`), add the scheduled discovery tick: ```python dcfg = DiscoveryConfig.from_config(config) if dcfg.enabled and now - last_discover_tick >= DISCOVER_TICK_SECONDS: try: _run_discovery(conn, similarity_sources, browser, config) except Exception as e: # a discovery error must never kill the worker print(f"worker: discovery sweep failed: {e}", flush=True) conn.rollback() last_discover_tick = now ``` Immediately after the existing `scan.requested` handling block, add the one-shot trigger: ```python if str(config.get("discover.requested", "")).strip().lower() in _TRUE: try: _run_discovery(conn, similarity_sources, browser, config) except Exception as e: # a discovery error must never kill the worker print(f"worker: discovery run failed: {e}", flush=True) conn.rollback() _set_config(conn, "discover.requested", "false") ``` - [ ] **Step 8: Run it to verify it passes** Run: `cd worker && python -m pytest tests/test_discovery_trigger.py -v` Expected: PASS (1 test). - [ ] **Step 9: Run the full worker suite (guard against regressions)** Run: `cd worker && python -m pytest -q` Expected: PASS (all tests, including the pre-existing ones). - [ ] **Step 10: Commit** ```bash git add worker/lyra_worker/registry.py worker/lyra_worker/main.py worker/tests/test_similarity_registry.py worker/tests/test_discovery_trigger.py git commit -m "feat(discovery): build_similarity_sources + worker sweep/trigger wiring Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 7: Web `lib/listenbrainz.ts` (seed-search similarity) **Files:** - Create: `web/src/lib/listenbrainz.ts` - Test: `web/src/lib/listenbrainz.test.ts` **Interfaces:** - Produces: `type SimilarArtist = { mbid: string; name: string; score: number }`; `similarArtists(artistMbid: string): Promise` (throws on non-2xx). - [ ] **Step 1: Write the failing test (mock `fetch`)** Create `web/src/lib/listenbrainz.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; import { similarArtists } from "./listenbrainz"; afterEach(() => vi.restoreAllMocks()); describe("similarArtists", () => { it("parses rows and drops the seed itself", async () => { vi.spyOn(global, "fetch").mockResolvedValue( new Response(JSON.stringify([ { artist_mbid: "seed", name: "Seed", score: 100 }, { artist_mbid: "c1", name: "Cand", score: 42 }, ]), { status: 200 }), ); const out = await similarArtists("seed"); expect(out).toEqual([{ mbid: "c1", name: "Cand", score: 42 }]); }); it("throws on a non-2xx response", async () => { vi.spyOn(global, "fetch").mockResolvedValue(new Response("nope", { status: 500 })); await expect(similarArtists("seed")).rejects.toThrow(); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- listenbrainz` Expected: FAIL — module `./listenbrainz` not found. - [ ] **Step 3: Write `listenbrainz.ts`** Create `web/src/lib/listenbrainz.ts`: ```typescript const LB_BASE = process.env.LISTENBRAINZ_URL ?? "https://labs.api.listenbrainz.org"; // Keep in sync with the worker's _listenbrainz.py default algorithm. const ALGORITHM = "session_based_days_7500_session_300_contribution_5_threshold_15_limit_50_filter_True_skip_30"; export type SimilarArtist = { mbid: string; name: string; score: number }; export async function similarArtists(artistMbid: string): Promise { const url = `${LB_BASE}/similar-artists/json?artist_mbids=${encodeURIComponent(artistMbid)}` + `&algorithm=${encodeURIComponent(ALGORITHM)}`; const res = await fetch(url, { headers: { Accept: "application/json" } }); if (!res.ok) throw new Error(`ListenBrainz request failed: ${res.status}`); const rows: any[] = (await res.json()) ?? []; return rows .filter((r) => r.artist_mbid && r.artist_mbid !== artistMbid) .map((r) => ({ mbid: r.artist_mbid, name: r.name ?? r.comment ?? "", score: Number(r.score ?? 0) })); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- listenbrainz` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add web/src/lib/listenbrainz.ts web/src/lib/listenbrainz.test.ts git commit -m "feat(discovery): web ListenBrainz similar-artists client Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 8: `GET /api/discover` (feed) **Files:** - Create: `web/src/app/api/discover/route.ts` - Test: `web/src/app/api/discover/route.test.ts` **Interfaces:** - Produces: `GET` returning `{ artists: Suggestion[], albums: Suggestion[] }` where each `Suggestion = { id, artistMbid, artistName, rgMbid, album, primaryType, secondaryTypes, firstReleaseDate, score, seedCount, sources }`; only `status = "pending"` rows, ordered by `score` desc. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/db"; import { GET } from "./route"; describe("discover feed API", () => { it("returns pending artists and albums split, highest score first, excluding non-pending", async () => { await prisma.discoverySuggestion.createMany({ data: [ { kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" }, { kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" }, { kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec", score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" }, { kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" }, ], }); const body = await (await GET()).json(); expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]); expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] }); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- api/discover/route` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/route.ts`: ```typescript import { prisma } from "@/lib/db"; export async function GET() { const rows = await prisma.discoverySuggestion.findMany({ where: { status: "pending" }, orderBy: [{ score: "desc" }, { createdAt: "asc" }], }); const map = (r: (typeof rows)[number]) => ({ id: r.id, artistMbid: r.artistMbid, artistName: r.artistName, rgMbid: r.rgMbid, album: r.album, primaryType: r.primaryType, secondaryTypes: r.secondaryTypes, firstReleaseDate: r.firstReleaseDate, score: r.score, seedCount: r.seedCount, sources: r.sources, }); return Response.json({ artists: rows.filter((r) => r.kind === "artist").map(map), albums: rows.filter((r) => r.kind === "album").map(map), }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- api/discover/route` Expected: PASS (1 test). - [ ] **Step 5: Commit** ```bash git add web/src/app/api/discover/route.ts web/src/app/api/discover/route.test.ts git commit -m "feat(discovery): GET /api/discover feed Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 9: `POST/GET /api/discover/run` (trigger + status) **Files:** - Create: `web/src/app/api/discover/run/route.ts` - Test: `web/src/app/api/discover/run/route.test.ts` **Interfaces:** - Produces: `POST` sets `Config discover.requested = "true"` (202, `{ requested: true }`); `GET` returns `{ requested: boolean, result: string | null }` from `discover.requested` / `discover.result`. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/run/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/db"; import { POST, GET } from "./route"; describe("discover run API", () => { it("POST sets discover.requested and GET reflects it", async () => { const res = await POST(new Request("http://localhost/api/discover/run", { method: "POST" })); expect(res.status).toBe(202); expect((await res.json()).requested).toBe(true); expect((await prisma.config.findUnique({ where: { key: "discover.requested" } }))?.value).toBe("true"); const body = await (await GET()).json(); expect(body.requested).toBe(true); expect(body.result).toBeNull(); }); it("GET returns discover.result when the worker has written it", async () => { await prisma.config.create({ data: { key: "discover.result", value: "artists 3, albums 1" } }); expect((await (await GET()).json()).result).toBe("artists 3, albums 1"); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- api/discover/run` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/run/route.ts`: ```typescript import { prisma } from "@/lib/db"; export async function POST(_request: Request) { await prisma.config.upsert({ where: { key: "discover.requested" }, create: { key: "discover.requested", value: "true", secret: false }, update: { value: "true" }, }); return Response.json({ requested: true }, { status: 202 }); } export async function GET() { const [requested, result] = await Promise.all([ prisma.config.findUnique({ where: { key: "discover.requested" } }), prisma.config.findUnique({ where: { key: "discover.result" } }), ]); return Response.json({ requested: requested?.value === "true", result: result?.value ?? null, }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- api/discover/run` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add web/src/app/api/discover/run git commit -m "feat(discovery): POST/GET /api/discover/run trigger + status Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 10: `GET /api/discover/search` (interactive seed search) **Files:** - Create: `web/src/app/api/discover/search/route.ts` - Test: `web/src/app/api/discover/search/route.test.ts` **Interfaces:** - Consumes: `searchArtists` from `@/lib/musicbrainz`; `similarArtists` from `@/lib/listenbrainz` (Task 7). - Produces: `GET ?artist=` → `{ seed: ArtistHit | null, similar: SimilarArtist[] }`; `400` when `artist` missing; `502` on MB or ListenBrainz failure. - [ ] **Step 1: Write the failing test (mock both libs)** Create `web/src/app/api/discover/search/route.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; vi.mock("@/lib/musicbrainz", () => ({ searchArtists: vi.fn() })); vi.mock("@/lib/listenbrainz", () => ({ similarArtists: vi.fn() })); import { searchArtists } from "@/lib/musicbrainz"; import { similarArtists } from "@/lib/listenbrainz"; import { GET } from "./route"; const mockSearch = vi.mocked(searchArtists); const mockSimilar = vi.mocked(similarArtists); afterEach(() => { mockSearch.mockReset(); mockSimilar.mockReset(); }); function req(artist?: string) { const u = new URL("http://localhost/api/discover/search"); if (artist !== undefined) u.searchParams.set("artist", artist); return new Request(u); } describe("discover search API", () => { it("400s when artist is missing/blank", async () => { expect((await GET(req())).status).toBe(400); expect((await GET(req(" "))).status).toBe(400); }); it("resolves the top MB hit then returns its similar artists", async () => { mockSearch.mockResolvedValue([{ mbid: "seed", name: "Boygenius", disambiguation: "" }]); mockSimilar.mockResolvedValue([{ mbid: "c1", name: "Cand", score: 9 }]); const body = await (await GET(req("boygenius"))).json(); expect(body.seed).toMatchObject({ mbid: "seed", name: "Boygenius" }); expect(body.similar).toEqual([{ mbid: "c1", name: "Cand", score: 9 }]); expect(mockSimilar).toHaveBeenCalledWith("seed"); }); it("returns empty when MB has no hit, 502 on failures", async () => { mockSearch.mockResolvedValue([]); expect(await (await GET(req("nobody"))).json()).toEqual({ seed: null, similar: [] }); mockSearch.mockRejectedValue(new Error("mb down")); expect((await GET(req("x"))).status).toBe(502); mockSearch.mockResolvedValue([{ mbid: "seed", name: "X", disambiguation: "" }]); mockSimilar.mockRejectedValue(new Error("lb down")); expect((await GET(req("x"))).status).toBe(502); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- api/discover/search` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/search/route.ts`: ```typescript import { searchArtists } from "@/lib/musicbrainz"; import { similarArtists } from "@/lib/listenbrainz"; export async function GET(request: Request) { const artist = new URL(request.url).searchParams.get("artist"); if (!artist || !artist.trim()) { return Response.json({ error: "artist is required" }, { status: 400 }); } let hits; try { hits = await searchArtists(artist.trim()); } catch { return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); } if (hits.length === 0) return Response.json({ seed: null, similar: [] }); const seed = hits[0]; let similar; try { similar = await similarArtists(seed.mbid); } catch { return Response.json({ error: "ListenBrainz unavailable" }, { status: 502 }); } return Response.json({ seed, similar }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- api/discover/search` Expected: PASS (3 tests). - [ ] **Step 5: Commit** ```bash git add web/src/app/api/discover/search git commit -m "feat(discovery): GET /api/discover/search interactive seed search Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 11: `POST /api/discover/[id]` (follow / want / dismiss) **Files:** - Create: `web/src/app/api/discover/[id]/route.ts` - Test: `web/src/app/api/discover/[id]/route.test.ts` **Interfaces:** - Consumes: `browseReleaseGroups` from `@/lib/musicbrainz`. - Produces: `POST` body `{ action: "follow" | "want" | "dismiss" }`. `dismiss` → `status="dismissed"`. `follow` (artist kind) → create `WatchedArtist` + unmonitored `MonitoredRelease` discography (idempotent if already following), `status="followed"`. `want` (album kind) → upsert `monitored=true` `MonitoredRelease` from the suggestion's stored fields, `status="wanted"`. `404` unknown id; `400` bad action or action/kind mismatch; `502` MB failure during follow. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/[id]/route.test.ts`: ```typescript import { describe, it, expect, vi, afterEach } from "vitest"; vi.mock("@/lib/musicbrainz", () => ({ browseReleaseGroups: vi.fn() })); import { browseReleaseGroups } from "@/lib/musicbrainz"; import { prisma } from "@/lib/db"; import { POST } from "./route"; const mockBrowse = vi.mocked(browseReleaseGroups); afterEach(() => mockBrowse.mockReset()); function post(id: string, body: unknown) { return POST( new Request(`http://localhost/api/discover/${id}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }), { params: Promise.resolve({ id }) }, ); } async function artist(mbid = "a1") { return prisma.discoverySuggestion.create({ data: { kind: "artist", artistMbid: mbid, artistName: "Cand", score: 1, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: `artist:${mbid}:` }, }); } async function album(rg = "rg1") { return prisma.discoverySuggestion.create({ data: { kind: "album", artistMbid: "a9", artistName: "Band", rgMbid: rg, album: "Rec", primaryType: "Album", score: 1, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], firstReleaseDate: "2023", dedupeKey: `album:a9:${rg}` }, }); } describe("discover action API", () => { it("dismiss marks the suggestion dismissed", async () => { const s = await artist("d1"); const res = await post(s.id, { action: "dismiss" }); expect(res.status).toBe(200); expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("dismissed"); }); it("follow creates a WatchedArtist + unmonitored discography and marks followed", async () => { mockBrowse.mockResolvedValue([ { rgMbid: "rg1", title: "Debut", primaryType: "Album", secondaryTypes: [], firstReleaseDate: "2019" }, ]); const s = await artist("f1"); const res = await post(s.id, { action: "follow" }); expect(res.status).toBe(200); expect(await prisma.watchedArtist.findUnique({ where: { mbid: "f1" } })).not.toBeNull(); const rel = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rg1" } }); expect(rel).toMatchObject({ monitored: false, artistName: "Cand" }); expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed"); }); it("follow is idempotent when already following (no MB call, still marks followed)", async () => { await prisma.watchedArtist.create({ data: { mbid: "f2", name: "Cand" } }); const s = await artist("f2"); const res = await post(s.id, { action: "follow" }); expect(res.status).toBe(200); expect(mockBrowse).not.toHaveBeenCalled(); expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("followed"); }); it("want upserts a monitored release from the suggestion and marks wanted", async () => { const s = await album("rgW"); const res = await post(s.id, { action: "want" }); expect(res.status).toBe(200); const rel = await prisma.monitoredRelease.findUnique({ where: { rgMbid: "rgW" } }); expect(rel).toMatchObject({ album: "Rec", monitored: true, artistName: "Band" }); expect((await prisma.discoverySuggestion.findUnique({ where: { id: s.id } }))!.status).toBe("wanted"); }); it("rejects bad action, mismatched kind, and unknown id", async () => { const a = await artist("m1"); expect((await post(a.id, { action: "nope" })).status).toBe(400); expect((await post(a.id, { action: "want" })).status).toBe(400); // want on artist const al = await album("rgM"); expect((await post(al.id, { action: "follow" })).status).toBe(400); // follow on album expect((await post("missing", { action: "dismiss" })).status).toBe(404); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- "api/discover/\[id\]"` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/[id]/route.ts`: ```typescript import { prisma } from "@/lib/db"; import { browseReleaseGroups } from "@/lib/musicbrainz"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; let body: unknown; try { body = await request.json(); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } const { action } = (body ?? {}) as { action?: unknown }; if (action !== "follow" && action !== "want" && action !== "dismiss") { return Response.json({ error: "action must be follow, want, or dismiss" }, { status: 400 }); } const s = await prisma.discoverySuggestion.findUnique({ where: { id } }); if (!s) return Response.json({ error: "not found" }, { status: 404 }); if (action === "dismiss") { await prisma.discoverySuggestion.update({ where: { id }, data: { status: "dismissed" } }); return Response.json({ id, status: "dismissed" }); } if (action === "follow") { if (s.kind !== "artist") { return Response.json({ error: "follow applies to artist suggestions" }, { status: 400 }); } const existing = await prisma.watchedArtist.findUnique({ where: { mbid: s.artistMbid } }); if (!existing) { let releases; try { releases = await browseReleaseGroups(s.artistMbid); } catch { return Response.json({ error: "MusicBrainz unavailable" }, { status: 502 }); } const created = await prisma.watchedArtist.create({ data: { mbid: s.artistMbid, name: s.artistName }, }); if (releases.length > 0) { await prisma.monitoredRelease.createMany({ data: releases.map((r) => ({ watchedArtistId: created.id, artistMbid: s.artistMbid, artistName: s.artistName, rgMbid: r.rgMbid, album: r.title, primaryType: r.primaryType, secondaryTypes: r.secondaryTypes, firstReleaseDate: r.firstReleaseDate, monitored: false, })), skipDuplicates: true, }); } } await prisma.discoverySuggestion.update({ where: { id }, data: { status: "followed" } }); return Response.json({ id, status: "followed" }); } // action === "want" if (s.kind !== "album" || !s.rgMbid) { return Response.json({ error: "want applies to album suggestions" }, { status: 400 }); } await prisma.monitoredRelease.upsert({ where: { rgMbid: s.rgMbid }, create: { artistMbid: s.artistMbid, artistName: s.artistName, rgMbid: s.rgMbid, album: s.album ?? "", primaryType: s.primaryType, secondaryTypes: s.secondaryTypes, firstReleaseDate: s.firstReleaseDate, monitored: true, }, update: { monitored: true }, }); await prisma.discoverySuggestion.update({ where: { id }, data: { status: "wanted" } }); return Response.json({ id, status: "wanted" }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- "api/discover/\[id\]"` Expected: PASS (5 tests). - [ ] **Step 5: Commit** ```bash git add "web/src/app/api/discover/[id]" git commit -m "feat(discovery): POST /api/discover/[id] follow/want/dismiss actions Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 12: `GET/PATCH /api/discover/config` **Files:** - Create: `web/src/app/api/discover/config/route.ts` - Test: `web/src/app/api/discover/config/route.test.ts` **Interfaces:** - Produces: `GET` → `{ "discover.enabled", "discover.intervalHours", "discover.maxSeeds", "discover.similarPerSeed", "discover.albumsPerArtist", "discover.minScore" }` (stored `Config` value or the default). `PATCH` body is a subset of those keys; upserts each into `Config`; unknown keys ignored; returns `{ updated: number }`. - [ ] **Step 1: Write the failing test** Create `web/src/app/api/discover/config/route.test.ts`: ```typescript import { describe, it, expect } from "vitest"; import { prisma } from "@/lib/db"; import { GET, PATCH } from "./route"; function patch(body: unknown) { return PATCH(new Request("http://localhost/api/discover/config", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body), })); } describe("discover config API", () => { it("GET returns defaults when nothing is stored", async () => { const body = await (await GET()).json(); expect(body).toMatchObject({ "discover.enabled": "false", "discover.intervalHours": "168", "discover.maxSeeds": "50", "discover.similarPerSeed": "20", "discover.albumsPerArtist": "1", "discover.minScore": "0", }); }); it("PATCH upserts known keys and ignores unknown ones", async () => { const res = await patch({ "discover.enabled": "true", "discover.maxSeeds": 25, "nope": "x" }); expect((await res.json()).updated).toBe(2); const body = await (await GET()).json(); expect(body["discover.enabled"]).toBe("true"); expect(body["discover.maxSeeds"]).toBe("25"); expect(await prisma.config.findUnique({ where: { key: "nope" } })).toBeNull(); }); }); ``` - [ ] **Step 2: Run it to verify it fails** Run: `cd web && npm test -- api/discover/config` Expected: FAIL — `./route` not found. - [ ] **Step 3: Write the route** Create `web/src/app/api/discover/config/route.ts`: ```typescript import { prisma } from "@/lib/db"; const DEFAULTS: Record = { "discover.enabled": "false", "discover.intervalHours": "168", "discover.maxSeeds": "50", "discover.similarPerSeed": "20", "discover.albumsPerArtist": "1", "discover.minScore": "0", }; export async function GET() { const rows = await prisma.config.findMany({ where: { key: { in: Object.keys(DEFAULTS) } } }); const stored = Object.fromEntries(rows.map((r) => [r.key, r.value])); return Response.json( Object.fromEntries(Object.entries(DEFAULTS).map(([k, d]) => [k, stored[k] ?? d])), ); } export async function PATCH(request: Request) { let body: unknown; try { body = await request.json(); } catch { return Response.json({ error: "invalid JSON" }, { status: 400 }); } const entries = Object.entries((body ?? {}) as Record).filter( ([k]) => k in DEFAULTS, ); for (const [key, value] of entries) { await prisma.config.upsert({ where: { key }, create: { key, value: String(value), secret: false }, update: { value: String(value) }, }); } return Response.json({ updated: entries.length }); } ``` - [ ] **Step 4: Run it to verify it passes** Run: `cd web && npm test -- api/discover/config` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add web/src/app/api/discover/config git commit -m "feat(discovery): GET/PATCH /api/discover/config Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 13: `/discover` page + client + nav link **Files:** - Create: `web/src/app/discover/page.tsx` - Create: `web/src/app/discover/discover-client.tsx` - Modify: `web/src/app/page.tsx` **Interfaces:** - Consumes: `GET /api/discover` (feed), `POST/GET /api/discover/run` (trigger/status), `GET /api/discover/search` (seed search), `POST /api/discover/[id]` (actions). - Produces: a route at `/discover`; a nav link `Discover` on the home page. This task is UI wiring with no unit test; it is verified by build + manual smoke. Keep the markup plain, matching the existing bare pages (e.g. `artists/artists-client.tsx`). - [ ] **Step 1: Write the server page** Create `web/src/app/discover/page.tsx`: ```tsx import { DiscoverClient } from "./discover-client"; export default function DiscoverPage() { return (

Discover

Home · Artists · Wanted ·{" "} Settings

); } ``` - [ ] **Step 2: Write the client component** Create `web/src/app/discover/discover-client.tsx`: ```tsx "use client"; import { useCallback, useEffect, useState } from "react"; type Suggestion = { id: string; artistMbid: string; artistName: string; rgMbid: string | null; album: string | null; score: number; seedCount: number; sources: string[]; }; type Feed = { artists: Suggestion[]; albums: Suggestion[] }; type SearchResult = { seed: { mbid: string; name: string } | null; similar: { mbid: string; name: string; score: number }[]; }; export function DiscoverClient() { const [feed, setFeed] = useState({ artists: [], albums: [] }); const [result, setResult] = useState(null); const [running, setRunning] = useState(false); const [query, setQuery] = useState(""); const [search, setSearch] = useState(null); const [busy, setBusy] = useState(false); const loadFeed = useCallback(async () => { setFeed(await (await fetch("/api/discover")).json()); }, []); useEffect(() => { loadFeed(); fetch("/api/discover/run") .then((r) => r.json()) .then((s: { result: string | null; requested: boolean }) => { setResult(s.result); setRunning(s.requested); }); }, [loadFeed]); useEffect(() => { if (!running) return; const id = setInterval(async () => { const s = await (await fetch("/api/discover/run")).json(); setResult(s.result); if (!s.requested) { setRunning(false); loadFeed(); } }, 2000); return () => clearInterval(id); }, [running, loadFeed]); async function discoverNow() { await fetch("/api/discover/run", { method: "POST" }); setRunning(true); } async function runSearch(e: React.FormEvent) { e.preventDefault(); if (!query.trim()) return; setBusy(true); try { setSearch(await (await fetch(`/api/discover/search?artist=${encodeURIComponent(query.trim())}`)).json()); } finally { setBusy(false); } } async function act(id: string, action: "follow" | "want" | "dismiss") { await fetch(`/api/discover/${id}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action }), }); loadFeed(); } return (
{result && Last run: {result}}

Seed search

setQuery(e.target.value)} placeholder="Artist name…" aria-label="Seed artist" />
{search && !search.seed &&

No MusicBrainz match.

} {search?.seed && (
    {search.similar.map((s) => (
  • {s.name} ({s.score.toFixed(1)})
  • ))}
)}

Suggested artists ({feed.artists.length})

    {feed.artists.map((s) => (
  • {s.artistName}{" "} score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}{" "} {" "}
  • ))}

Suggested albums ({feed.albums.length})

    {feed.albums.map((s) => (
  • {s.album} — {s.artistName}{" "} score {s.score.toFixed(2)}{" "} {" "}
  • ))}
); } ``` - [ ] **Step 3: Add the nav link on the home page** In `web/src/app/page.tsx`, change the nav paragraph to include Discover: ```tsx

Artists · Discover · Wanted · Settings

``` - [ ] **Step 4: Verify the build compiles** Run: `cd web && npx tsc --noEmit && npm run build` Expected: type-check passes and `next build` completes with a `/discover` route listed. - [ ] **Step 5: Commit** ```bash git add web/src/app/discover web/src/app/page.tsx git commit -m "feat(discovery): /discover feed page + seed search UI + nav link Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Task 14: Discovery settings section **Files:** - Modify: `web/src/app/settings/settings-form.tsx` **Interfaces:** - Consumes: `GET/PATCH /api/discover/config` (Task 12). - Produces: a "Discovery" fieldset in the settings form: an `enabled` checkbox + number inputs for `intervalHours`, `maxSeeds`, `similarPerSeed`, `albumsPerArtist`, `minScore`, saved via `PATCH /api/discover/config`. This task is UI wiring, verified by build + manual smoke. - [ ] **Step 1: Add discovery config state + load** In `web/src/app/settings/settings-form.tsx`, inside the `SettingsForm` component with the other `useState` hooks, add: ```tsx const [discoverCfg, setDiscoverCfg] = useState>({}); const [discoverSaved, setDiscoverSaved] = useState(false); ``` Add a load effect alongside the existing `useEffect`s: ```tsx useEffect(() => { fetch("/api/discover/config") .then((r) => r.json()) .then((c: Record) => setDiscoverCfg(c)); }, []); ``` - [ ] **Step 2: Add the save handler** Add this function inside the component (near the other handlers): ```tsx function setCfg(key: string, value: string) { setDiscoverCfg((c) => ({ ...c, [key]: value })); } async function saveDiscovery(e: React.FormEvent) { e.preventDefault(); await fetch("/api/discover/config", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(discoverCfg), }); setDiscoverSaved(true); setTimeout(() => setDiscoverSaved(false), 1500); } ``` - [ ] **Step 3: Render the fieldset** In the component's returned JSX, add this `
` after the existing settings/scan markup (before the closing wrapper element): ```tsx

Discovery

{[ ["discover.intervalHours", "Interval (hours)"], ["discover.maxSeeds", "Max seeds per sweep"], ["discover.similarPerSeed", "Similar per seed"], ["discover.albumsPerArtist", "Albums per artist"], ["discover.minScore", "Min score"], ].map(([key, label]) => (

))} {discoverSaved && Saved.}
``` - [ ] **Step 4: Verify the build compiles** Run: `cd web && npx tsc --noEmit && npm run build` Expected: type-check + build pass. - [ ] **Step 5: Commit** ```bash git add web/src/app/settings/settings-form.tsx git commit -m "feat(discovery): Discovery settings section (enable + tuning knobs) Co-Authored-By: Claude Opus 4.8 (1M context) " ``` --- ## Final verification - [ ] **Full worker suite:** `cd worker && python -m pytest -q` → all pass. - [ ] **Full web suite:** `cd web && npm test` → all pass. - [ ] **Web build:** `cd web && npm run build` → succeeds, `/discover` route present. - [ ] **Manual smoke (real app):** `docker compose up -d --build` (never `down -v`). Follow an artist or run a scan so `WatchedArtist` seeds exist, open `/discover`, click **Discover now**, confirm suggestions appear; verify **Follow** creates a watched artist, **Want** adds a wanted release, **Dismiss** removes a suggestion and it does not reappear on the next run. **Confirm the ListenBrainz algorithm string and response field names in `_listenbrainz.py` / `listenbrainz.ts` against the live API** — adjust if the shapes differ (see the NOTE in `_listenbrainz.py`). ## Notes / deferred - **Second similarity source (Last.fm) and tag-coherent re-weighting** drop in behind `SimilaritySource` + `build_similarity_sources` with no core change (spec §Notes). - **Album popularity ranking** — v1 picks the most-recent core release; a later refinement can rank by ListenBrainz popular-recordings listen counts. - **Discovery is synchronous in the worker loop** (like the scan): bounded by `maxSeeds`, but a large sweep briefly blocks job-claim. Chunking mirrors the scan's deferred item. - **Seed scope** is all `WatchedArtist` MBIDs; a "top artists only" knob is possible if API load becomes an issue.