0cf464aad0
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>
320 lines
16 KiB
Markdown
320 lines
16 KiB
Markdown
# 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 1–2
|
||
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 1–2
|
||
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.
|