docs: slice 3 discovery design — MetaBrainz-native (ListenBrainz), suggestion feed

Spotify similarity/new-release APIs are dead for new apps (Nov 2024 + Feb 2026),
so discovery is built on ListenBrainz similar-artists (MBID-native) behind a
pluggable SimilaritySource interface. Manual review feed of similar artists +
albums; Follow/Want reuse slice-2 operations, Dismiss is permanent. Off by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-11 22:35:49 +02:00
parent 48260c0fba
commit 0cf464aad0
@@ -0,0 +1,319 @@
# Lyra — Discovery (Design)
**Date:** 2026-07-11
**Status:** Approved design, ready for implementation planning
**Slice:** 3 of 3 (Discovery)
## Overview
Slice 3 makes Lyra surface music you **don't already follow**. Slices 12
obtain and monitor artists/releases you already know about; discovery closes the
loop by recommending *new* artists and albums similar to what's in your library,
then hands each accepted suggestion straight back into the slice-2 machinery.
Discovery is deliberately **suggestion-only and manual**: a background sweep (and
an on-demand button) populates a review feed of candidate artists and albums; you
**Follow** an artist (creating a `WatchedArtist` with its discography tracked but
**unmonitored**, exactly like the slice-2 follow) or **Want** an album (creating a
`monitored` wanted `MonitoredRelease`, exactly like the slice-2 wanted list), or
**Dismiss** it. Discovery never grabs anything itself — its only outputs are the
exact rows the existing follow / wanted flows already create, and actual grabbing
still happens only through the slice-2 monitor (off by default). The entire
acquisition + monitoring pipeline is reused unchanged.
### The source reality that shapes this slice
The original acquisition-engine spec reserved **Spotify** for "discovery/metadata
only." That premise no longer holds for a newly-registered app:
- **Nov 2024** — Spotify deprecated **Related Artists**, **Recommendations**,
audio-features, audio-analysis and featured-playlists for any *new* app (new
apps receive 403/404; no waitlist, no path back).
- **Feb 2026** — Spotify additionally removed **Get New Releases**, Get Artist's
Top Tracks and Get Several Albums.
So Spotify can supply **none** of discovery's signals to a fresh app. Lyra is
already **MusicBrainz-native** (every artist/release is an MBID), so discovery is
instead built on **MetaBrainz**: **ListenBrainz similar-artists** (same family as
MusicBrainz, returns MBIDs directly — zero fuzzy matching) as the engine, with
MusicBrainz for the album sweep. Spotify is dropped entirely from this slice.
### Goals
- Recommend new **artists** (and standout **albums**) similar to the library, at
MBID granularity, with no personal-account / listening-history dependency.
- Reuse slice-2 verbatim: **Follow** creates the same `WatchedArtist` +
unmonitored discography the follow flow does; **Want** creates the same
`monitored` wanted `MonitoredRelease` the wanted list does. No new grab logic.
- Manual review only — nothing is followed or grabbed without explicit approval;
**dismissed suggestions never resurface**.
- Keep the design source-agnostic behind a pluggable `SimilaritySource` interface
(mirroring the slice-1 `adapters/` registry), so a second source (Last.fm) or
tag-weighting can drop in later without touching the core.
- Fully developable and testable with **zero credentials** (ListenBrainz labs API
is unauthenticated) via a fake `SimilaritySource` and seeded Postgres.
### Non-goals (this slice)
- **No Spotify** anywhere (its similarity/new-release APIs are dead for new apps).
- **No auto-follow / auto-grab** — manual review only.
- **No personal listening data / OAuth** (no ListenBrainz user account required).
- **No second similarity source (Last.fm) and no genre-tag re-weighting** in this
slice — both are left as drop-in extensions behind `SimilaritySource`.
- No notifications; no multi-user. Downloading, tagging, and the six-stage
pipeline are untouched.
## Scope
**In scope:** a `SimilaritySource` engine (ListenBrainz), a worker discovery run
that aggregates similar artists across your library's artists and derives album
suggestions, a `DiscoverySuggestion` review feed, per-suggestion Follow / Want /
Dismiss actions that reuse slice-2 operations, an interactive seed-search box, a
scheduled sweep + "Discover now" trigger (off by default), and Discovery settings.
**Out of scope:** everything under Non-goals. Deferred hardening from slices 12
is unaffected and remains deferred.
## Key decisions
- **Web reads, worker persists** — the same split slice 2 established. The
interactive **seed-search box** calls ListenBrainz directly from the web
(unauthenticated, read-only, results not persisted), exactly as `/api/mb/artists`
does live MusicBrainz search today. The **scheduled sweep** and **"Discover
now"** run in the worker and persist `DiscoverySuggestion` rows.
- **Suggestion-only, manual.** Discovery emits suggestions; the *user* turns a
suggestion into a `WatchedArtist` (Follow) or a wanted `MonitoredRelease`
(Want). Discovery has no path that grabs audio.
- **Dismissed is permanent.** A `dedupeKey` unique column plus keeping
dismissed/followed/wanted rows means the sweep's conditional upsert can never
revert them to `pending` — dismissed suggestions never come back.
- **Off by default**, like the monitor: `discover.enabled` unset ⇒ no background
sweep. The on-demand button and seed-search still work regardless.
- **Pluggable engine.** One source (ListenBrainz) ships now behind a
`SimilaritySource` Protocol + `build_similarity_sources()` registry, matching
the existing `SourceAdapter` / `build_adapters` pattern.
## Architecture
```
WEB (read-only, no secrets) WORKER (persists)
/discover page ─┬─ GET /api/discover (feed) ───▶ DiscoverySuggestion (read)
├─ GET /api/discover/search ──────────▶ MB search + ListenBrainz
│ (interactive seed, not stored) (direct, unauthenticated)
├─ POST /api/discover/run ────────────▶ Config discover.requested=true
└─ POST /api/discover/[id] (action) ───▶ Follow → WatchedArtist
Want → MonitoredRelease
Dismiss→ status=dismissed
worker loop tick (discover.enabled, every discover.intervalHours; or on
discover.requested):
run_discovery(conn, sources, browser, cfg):
seeds = WatchedArtist.mbid (throttled by lastDiscoveredAt, capped maxSeeds)
for seed × source: similar_artists(mbid) → aggregate
filter out already-followed / dismissed
upsert ARTIST suggestions (conditional on status='pending')
for top artists: MbBrowser release-groups → core-release filter →
drop library dupes → upsert ALBUM suggestions
```
## Data model
One new table + two enums, plus one column on `WatchedArtist`.
```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 kind only; null for artist
album String?
primaryType String?
secondaryTypes String[]
firstReleaseDate String?
score Float // aggregated similarity across seeds
seedCount Int // how many of your artists pointed here
sources String[] // e.g. ["listenbrainz"] (pluggable)
status SuggestionStatus @default(pending)
dedupeKey String @unique // kind + ':' + artistMbid + ':' + (rgMbid ?? "")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
Plus on `WatchedArtist`:
```prisma
lastDiscoveredAt DateTime? // per-seed throttle, mirrors lastPolledAt
```
**Why `dedupeKey`:** Postgres treats `NULL`s as distinct in a composite unique
index, so a `@@unique([kind, artistMbid, rgMbid])` would let duplicate
`kind=artist` rows through (their `rgMbid` is null). A single non-null
`dedupeKey` string gives a clean `ON CONFLICT` target for the upsert.
**Conditional upsert (the dismissed-is-permanent guarantee):**
```sql
INSERT INTO "DiscoverySuggestion" (... , status, "dedupeKey", ...)
VALUES (... , 'pending', %s, ...)
ON CONFLICT ("dedupeKey") DO UPDATE
SET score = EXCLUDED.score,
"seedCount" = EXCLUDED."seedCount",
sources = EXCLUDED.sources,
"updatedAt" = now()
WHERE "DiscoverySuggestion".status = 'pending';
```
A row already `followed` / `wanted` / `dismissed` fails the `WHERE` and is left
untouched — it never returns to the feed.
## Engine — `worker/lyra_worker/similarity/`
Mirrors `worker/lyra_worker/adapters/`.
- **`base.py`** — Protocol:
```python
@dataclass(frozen=True)
class SimilarArtist:
mbid: str
name: str
score: float
@runtime_checkable
class SimilaritySource(Protocol):
name: str
def health(self) -> bool: ...
def similar_artists(self, mbid: str) -> list[SimilarArtist]: ...
```
- **`_listenbrainz.py`** — `ListenBrainzSource`: GETs the ListenBrainz Labs
similar-artists dataset (`labs.api.listenbrainz.org`, unauthenticated), maps the
response to `SimilarArtist` (MBIDs come back native). `health()` is a
reachability check. A base-URL override reads from config
(`discover.listenBrainzUrl`, default the public host).
- **`registry.build_similarity_sources(config)`** — health-filtered list, exactly
like `build_adapters`. `build_similarity_sources` is added to `registry.py`.
## Discovery run — `worker/lyra_worker/discovery.py`
`DiscoveryConfig.from_config(config)` parses the `discover.*` keys (same shape as
`MonitorConfig`). `run_discovery(conn, sources, browser, cfg) -> DiscoveryResult`:
1. **Seeds** = `WatchedArtist.mbid` where `lastDiscoveredAt IS NULL OR <
now() - intervalHours`, capped at `maxSeeds`.
2. For each seed × source → `similar_artists(mbid)`, keep top `similarPerSeed`.
3. **Aggregate** per candidate MBID: `score = Σ similarity`, `seedCount =`
distinct seeds pointing here, `sources =` union of contributing source names.
4. **Filter** candidates already in `WatchedArtist` (already followed) and any
existing dismissed row; drop below `minScore`.
5. **Upsert artist suggestions** (conditional-on-pending, above).
6. **Album derivation:** for the top surfaced artists, use the existing
**`MbBrowser`** to list their release-groups, keep only **core releases** via
the existing `release-filter` predicate (reused from slice 2), drop any already
in `LibraryItem` or `MonitoredRelease`, and upsert up to `albumsPerArtist`
(default 1, most-recent core) **album** suggestions.
7. Stamp `lastDiscoveredAt = now()` on each processed seed. Return counts.
Errors from a single source or seed are logged and skipped — a discovery failure
must never kill the worker loop (same discipline as monitor/scan).
## Worker loop integration — `worker/lyra_worker/main.py`
- Build similarity sources at startup: `sources = build_similarity_sources(config)`.
- Add a **discover tick** beside the monitor tick: when `discover.enabled` and the
interval has elapsed, call `run_discovery(...)` inside a try/except that rolls
back and logs on failure. Off by default.
- Handle the one-shot **`discover.requested`** flag exactly like `scan.requested`:
run `run_discovery`, write `discover.result` (`"artists N, albums M"`), clear
`discover.requested`.
## Config keys (`discover.*`, mirroring `monitor.*`)
| Key | Default | Meaning |
|---|---|---|
| `discover.enabled` | `false` | master switch for the scheduled sweep |
| `discover.intervalHours` | `168` | sweep cadence (weekly) + per-seed throttle window |
| `discover.maxSeeds` | `50` | seed artists processed per sweep |
| `discover.similarPerSeed` | `20` | top-K similar artists pulled per seed |
| `discover.albumsPerArtist` | `1` | album suggestions per surfaced artist |
| `discover.minScore` | `0` | drop candidates below this aggregated score |
| `discover.listenBrainzUrl` | public host | ListenBrainz Labs base URL override |
| `discover.requested` | — | one-shot "Discover now" trigger (worker clears) |
| `discover.result` | — | last-run status string surfaced in the UI |
No credential key — the ListenBrainz Labs API is unauthenticated.
## Web
New page **`/discover`** (`page.tsx` server component + `discover-client.tsx`),
linked in the nav in `layout.tsx`:
- **"Discover now"** → `POST /api/discover/run` (sets `discover.requested`);
shows `discover.result`.
- **Seed-search box** → `GET /api/discover/search?artist=<name>`: resolve the name
to an MBID (MB search), fetch ListenBrainz similar, return **unpersisted**
results inline; each is actionable via the same Follow/Want endpoints.
- **Feed** → `GET /api/discover`: pending suggestions, artists and albums in two
sections, sorted by `score` desc, each showing *why* (`seedCount`, `sources`,
`score`) and its actions.
Actions — **reuse existing slice-2 DB operations, no new grab logic:**
- **Follow** (artist) → create `WatchedArtist` + browse its discography into
`monitored=false` `MonitoredRelease` rows (the same operation as the
`/api/artists` follow POST), then set the suggestion `status='followed'`.
- **Want** (album) → create a `monitored=true` `MonitoredRelease` **directly from
the suggestion's already-stored fields** (`rgMbid`, `album`, types,
`firstReleaseDate`) — no redundant MusicBrainz search, since the sweep already
resolved them — equivalent to what the wanted list stores; set
`status='wanted'`.
- **Dismiss** → set `status='dismissed'`.
Endpoints (each with a co-located `.test.ts`, matching convention):
`api/discover/route.ts` (feed), `api/discover/run/route.ts`,
`api/discover/search/route.ts`, `api/discover/[id]/route.ts` (action).
**Settings** — a "Discovery" section in `settings-form.tsx` mirroring the monitor
fields: `enabled` + the tuning knobs above.
## Error handling
| Failure | Behavior |
|---|---|
| ListenBrainz unreachable during sweep | source `health()` false ⇒ that source contributes nothing this tick; logged; retried next sweep. Web seed-search returns an error surfaced in the UI. |
| A single seed errors | logged and skipped; the rest of the sweep proceeds. |
| Duplicate suggestion | `dedupeKey` unique + `ON CONFLICT`; re-sweeping never duplicates rows. |
| Candidate already followed / in library | filtered out before insert (checked against `WatchedArtist` / `LibraryItem` / `MonitoredRelease`). |
| Dismissed candidate reappears from source | conditional upsert leaves the dismissed row untouched; it stays out of the feed. |
| Follow/Want race with existing row | reuse slice-2 operations, which already upsert idempotently. |
| Discovery run throws | caught in the loop, `conn.rollback()`, logged; worker keeps running. |
## Testing (against `lyra_test`, per the DB guard)
- **Worker — `discovery.py`:** fake `SimilaritySource` + seeded Postgres. Assert
aggregation (score sum, seedCount, sources union), filtering vs `WatchedArtist`
/ `LibraryItem` / `MonitoredRelease`, `minScore` cutoff, the **conditional
upsert never reviving a dismissed row**, album derivation through the
core-release predicate, and `lastDiscoveredAt` throttling.
- **Worker — `_listenbrainz.py`:** thin adapter tested with faked HTTP against
recorded response shapes; `health()` true/false paths.
- **Web:** route tests for the feed, each action (Follow/Want/Dismiss with mocked
DB), and seed-search (mocked MB + ListenBrainz). Reuse the `release-filter`
tests for the core-release predicate.
## Notes / deferred
- **Second similarity source (Last.fm) and tag-coherent re-weighting** are the
planned extensions behind `SimilaritySource`; adding either is a new source in
the registry + a `sources`-array boost, no core change.
- **Album popularity ranking.** v1 picks the most-recent core release per artist.
A later refinement can rank an artist's release-groups by ListenBrainz
popular-recordings listen counts.
- **Seed scope** is currently all `WatchedArtist` MBIDs. A "top artists only"
scoping (by library album count) is a possible knob if API load becomes an issue.
- **Discovery is synchronous in the worker loop**, like the scan — fine for the
weekly cadence and bounded by `maxSeeds`, but it briefly blocks job-claim during
a sweep. Chunking mirrors the scan's deferred item.