diff --git a/docs/superpowers/plans/2026-07-11-lyra-discovery.md b/docs/superpowers/plans/2026-07-11-lyra-discovery.md new file mode 100644 index 0000000..d6b26f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-lyra-discovery.md @@ -0,0 +1,2150 @@ +# 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.