Files
Lyra/docs/superpowers/plans/2026-07-11-lyra-discovery.md
T
Jonathan decb6302d4 docs: mark slice 3 (discovery) done + record deferred todos
README: discovery status → done (ListenBrainz-native, no Spotify); off-by-default
note for the discovery sweep; architecture mentions MusicBrainz+ListenBrainz.
Plan: check off final verification (suites green, whole-branch review passed,
live-API algorithm fix), append the review's deferred items to Notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:06:56 +02:00

2164 lines
81 KiB
Markdown

# 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) <noreply@anthropic.com>`
---
## 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/<generated>_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/<timestamp>_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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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 = <default>, algorithm: str = <default>, 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<SimilarArtist[]>` (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<SimilarArtist[]> {
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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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=<name>``{ 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) <noreply@anthropic.com>"
```
---
## 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) <noreply@anthropic.com>"
```
---
## 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<string, string> = {
"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<string, unknown>).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) <noreply@anthropic.com>"
```
---
## 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 (
<main>
<h1>Discover</h1>
<p>
<a href="/">Home</a> · <a href="/artists">Artists</a> · <a href="/wanted">Wanted</a> ·{" "}
<a href="/settings">Settings</a>
</p>
<DiscoverClient />
</main>
);
}
```
- [ ] **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<Feed>({ artists: [], albums: [] });
const [result, setResult] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [query, setQuery] = useState("");
const [search, setSearch] = useState<SearchResult | null>(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 (
<div>
<section>
<button onClick={discoverNow} disabled={running}>
{running ? "Discovering…" : "Discover now"}
</button>
{result && <span> Last run: {result}</span>}
</section>
<section>
<h2>Seed search</h2>
<form onSubmit={runSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Artist name…"
aria-label="Seed artist"
/>
<button type="submit" disabled={busy}>Find similar</button>
</form>
{search && !search.seed && <p>No MusicBrainz match.</p>}
{search?.seed && (
<ul>
{search.similar.map((s) => (
<li key={s.mbid}>
{s.name} <small>({s.score.toFixed(1)})</small>
</li>
))}
</ul>
)}
</section>
<section>
<h2>Suggested artists ({feed.artists.length})</h2>
<ul>
{feed.artists.map((s) => (
<li key={s.id}>
<strong>{s.artistName}</strong>{" "}
<small>score {s.score.toFixed(2)} · {s.seedCount} seed(s) · {s.sources.join(", ")}</small>{" "}
<button onClick={() => act(s.id, "follow")}>Follow</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
</li>
))}
</ul>
</section>
<section>
<h2>Suggested albums ({feed.albums.length})</h2>
<ul>
{feed.albums.map((s) => (
<li key={s.id}>
<strong>{s.album}</strong> {s.artistName}{" "}
<small>score {s.score.toFixed(2)}</small>{" "}
<button onClick={() => act(s.id, "want")}>Want</button>{" "}
<button onClick={() => act(s.id, "dismiss")}>Dismiss</button>
</li>
))}
</ul>
</section>
</div>
);
}
```
- [ ] **Step 3: Add the nav link on the home page**
In `web/src/app/page.tsx`, change the nav paragraph to include Discover:
```tsx
<p><a href="/artists">Artists</a> · <a href="/discover">Discover</a> · <a href="/wanted">Wanted</a> · <a href="/settings">Settings</a></p>
```
- [ ] **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) <noreply@anthropic.com>"
```
---
## 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<Record<string, string>>({});
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<string, string>) => 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 `<form>` after the existing settings/scan markup (before the closing wrapper element):
```tsx
<form onSubmit={saveDiscovery}>
<h2>Discovery</h2>
<label>
<input
type="checkbox"
checked={discoverCfg["discover.enabled"] === "true"}
onChange={(e) => setCfg("discover.enabled", e.target.checked ? "true" : "false")}
/>{" "}
Enable scheduled discovery sweep
</label>
{[
["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]) => (
<p key={key}>
<label>
{label}:{" "}
<input
type="number"
step="any"
value={discoverCfg[key] ?? ""}
onChange={(e) => setCfg(key, e.target.value)}
/>
</label>
</p>
))}
<button type="submit">Save discovery settings</button>
{discoverSaved && <span> Saved.</span>}
</form>
```
- [ ] **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) <noreply@anthropic.com>"
```
---
## Final verification
- [x] **Full worker suite:** `cd worker && python -m pytest -q`**153 passed, 7 skipped**.
- [x] **Full web suite:** `cd web && npm test`**62 passed** (19 files).
- [x] **Web build:** `cd web && npm run build` → succeeds, `/discover` route present; `tsc --noEmit` clean.
- [x] **Whole-branch review** (opus) → Ready to merge: YES, no Critical/Important; all cross-cutting contracts verified.
- [x] **ListenBrainz algorithm verified against the live API (2026-07-12).** The planned string
(`…contribution_5_threshold_15_limit_50…`) was **not a permitted endpoint enum** — every lookup
400'd, making discovery inert. Corrected to
`session_based_days_7500_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30`
in both `_listenbrainz.py` and `listenbrainz.ts` (commit `d2c581d`); confirmed end-to-end (real
`ListenBrainzSource` returns 100 similar artists for Radiohead, parser shape correct). The response
fields the parser reads (`artist_mbid`, `name`, `comment`, `score`) match the live shape.
- [ ] **Manual smoke (real app), post-deploy:** `docker compose up -d --build web worker` (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. (Reset `WatchedArtist.lastDiscoveredAt = NULL` to re-run against the same seeds — the 168h throttle otherwise reports `artists 0`.)
## 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.
- **Non-cumulative score on partial re-sweep** (from whole-branch review): `_upsert_artist` sets `score = EXCLUDED.score` (replace, not accumulate). Because seeds are throttled by `lastDiscoveredAt`, a sweep where only some seeds are due recomputes a candidate's aggregate from just those seeds, so a pending suggestion's `score`/`seedCount` can shrink until all seeds refresh in the same window. Benign for a feed; revisit if scores look jumpy.
- **Trigger + tick coincidence** (from review): a pending `discover.requested` firing in the same loop iteration as the scheduled tick can run discovery twice. Idempotent upserts make this wasteful, not incorrect.
- **`discover.listenBrainzUrl`** config key (base-URL override read in `registry.py`) has no Settings UI — a hidden/undocumented override, not dead code.
- **Follow is not transactional** — `POST /api/discover/[id]` follow does `WatchedArtist.create` + `MonitoredRelease.createMany` as separate awaits (identical to the pre-existing `/api/artists` POST); single-user home server, low risk.
- **`_upsert_artist` per-`(seed, source)` similarPerSeed slice** — the top-K cut is applied per (seed, source) pair, not per seed overall; only matters once a second live `SimilaritySource` is added.