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

81 KiB

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.pySimilarArtist dataclass + SimilaritySource Protocol
  • worker/lyra_worker/similarity/_listenbrainz.pyListenBrainzSource (real adapter)
  • worker/lyra_worker/release_filter.pyis_core_release() (Python mirror of the web predicate)
  • worker/lyra_worker/discovery.pyDiscoveryConfig, 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.pyDiscoverySuggestion cleanup, insert_discovery_suggestion(), lastDiscoveredAt param

Web (new):

  • web/src/lib/listenbrainz.tssimilarArtists() 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.prismaDiscoverySuggestion, enums, WatchedArtist.lastDiscoveredAt
  • web/src/test/setup.tsdiscoverySuggestion.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:

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:

  lastDiscoveredAt  DateTime?
  • Step 2: Generate + apply the migration to dev and test DBs

Run (from web/):

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();:

  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:

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:

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:

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
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:

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:

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):

from lyra_worker.similarity.base import SimilarArtist

Append this class at the end of worker/lyra_worker/adapters/fakes.py:

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
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:

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:

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
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:

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:

_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:

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):

from lyra_worker.release_filter import is_core_release

Replace the stub def _derive_albums(...): return 0 with:

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
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:

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:

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
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:

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):

from lyra_worker.similarity._listenbrainz import ListenBrainzSource
from lyra_worker.similarity.base import SimilaritySource

Append this function to worker/lyra_worker/registry.py:

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:

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:

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:

DISCOVER_TICK_SECONDS = 300.0  # check the discovery interval every 5 min (interval itself is hours)

Add this function next to _run_scan:

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:

    similarity_sources = build_similarity_sources(get_config(conn))

Initialize a second tick timer next to last_tick = 0.0:

    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:

            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:

            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
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:

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:

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
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:

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:

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
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:

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:

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
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>"

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:

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:

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
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" }. dismissstatus="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:

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:

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
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:

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:

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
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>"

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:

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:

"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:

      <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
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:

  const [discoverCfg, setDiscoverCfg] = useState<Record<string, string>>({});
  const [discoverSaved, setDiscoverSaved] = useState(false);

Add a load effect alongside the existing useEffects:

  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):

  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):

      <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
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

  • Full worker suite: cd worker && python -m pytest -q153 passed, 7 skipped.
  • Full web suite: cd web && npm test62 passed (19 files).
  • Web build: cd web && npm run build → succeeds, /discover route present; tsc --noEmit clean.
  • Whole-branch review (opus) → Ready to merge: YES, no Critical/Important; all cross-cutting contracts verified.
  • 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 transactionalPOST /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.