0d2b7f0ae5
Schema → SimilaritySource + ListenBrainz engine → run_discovery (artists+albums) → worker sweep/trigger wiring → web feed/run/search/action/config routes → /discover UI + settings. Each task independently testable against lyra_test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2151 lines
79 KiB
Markdown
2151 lines
79 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
|
|
|
|
- [ ] **Full worker suite:** `cd worker && python -m pytest -q` → all pass.
|
|
- [ ] **Full web suite:** `cd web && npm test` → all pass.
|
|
- [ ] **Web build:** `cd web && npm run build` → succeeds, `/discover` route present.
|
|
- [ ] **Manual smoke (real app):** `docker compose up -d --build` (never `down -v`). Follow an artist or run a scan so `WatchedArtist` seeds exist, open `/discover`, click **Discover now**, confirm suggestions appear; verify **Follow** creates a watched artist, **Want** adds a wanted release, **Dismiss** removes a suggestion and it does not reappear on the next run. **Confirm the ListenBrainz algorithm string and response field names in `_listenbrainz.py` / `listenbrainz.ts` against the live API** — adjust if the shapes differ (see the NOTE in `_listenbrainz.py`).
|
|
|
|
## Notes / deferred
|
|
|
|
- **Second similarity source (Last.fm) and tag-coherent re-weighting** drop in behind `SimilaritySource` + `build_similarity_sources` with no core change (spec §Notes).
|
|
- **Album popularity ranking** — v1 picks the most-recent core release; a later refinement can rank by ListenBrainz popular-recordings listen counts.
|
|
- **Discovery is synchronous in the worker loop** (like the scan): bounded by `maxSeeds`, but a large sweep briefly blocks job-claim. Chunking mirrors the scan's deferred item.
|
|
- **Seed scope** is all `WatchedArtist` MBIDs; a "top artists only" knob is possible if API load becomes an issue.
|