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>
16 KiB
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 samemonitoredwantedMonitoredReleasethe 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
SimilaritySourceinterface (mirroring the slice-1adapters/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
SimilaritySourceand 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/artistsdoes live MusicBrainz search today. The scheduled sweep and "Discover now" run in the worker and persistDiscoverySuggestionrows. - Suggestion-only, manual. Discovery emits suggestions; the user turns a
suggestion into a
WatchedArtist(Follow) or a wantedMonitoredRelease(Want). Discovery has no path that grabs audio. - Dismissed is permanent. A
dedupeKeyunique column plus keeping dismissed/followed/wanted rows means the sweep's conditional upsert can never revert them topending— dismissed suggestions never come back. - Off by default, like the monitor:
discover.enabledunset ⇒ no background sweep. The on-demand button and seed-search still work regardless. - Pluggable engine. One source (ListenBrainz) ships now behind a
SimilaritySourceProtocol +build_similarity_sources()registry, matching the existingSourceAdapter/build_adapterspattern.
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.
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:
lastDiscoveredAt DateTime? // per-seed throttle, mirrors lastPolledAt
Why dedupeKey: Postgres treats NULLs 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):
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:@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 toSimilarArtist(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 likebuild_adapters.build_similarity_sourcesis added toregistry.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:
- Seeds =
WatchedArtist.mbidwherelastDiscoveredAt IS NULL OR < now() - intervalHours, capped atmaxSeeds. - For each seed × source →
similar_artists(mbid), keep topsimilarPerSeed. - Aggregate per candidate MBID:
score = Σ similarity,seedCount =distinct seeds pointing here,sources =union of contributing source names. - Filter candidates already in
WatchedArtist(already followed) and any existing dismissed row; drop belowminScore. - Upsert artist suggestions (conditional-on-pending, above).
- Album derivation: for the top surfaced artists, use the existing
MbBrowserto list their release-groups, keep only core releases via the existingrelease-filterpredicate (reused from slice 2), drop any already inLibraryItemorMonitoredRelease, and upsert up toalbumsPerArtist(default 1, most-recent core) album suggestions. - 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.enabledand the interval has elapsed, callrun_discovery(...)inside a try/except that rolls back and logs on failure. Off by default. - Handle the one-shot
discover.requestedflag exactly likescan.requested: runrun_discovery, writediscover.result("artists N, albums M"), cleardiscover.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(setsdiscover.requested); showsdiscover.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 byscoredesc, 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 intomonitored=falseMonitoredReleaserows (the same operation as the/api/artistsfollow POST), then set the suggestionstatus='followed'. - Want (album) → create a
monitored=trueMonitoredReleasedirectly 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; setstatus='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: fakeSimilaritySource+ seeded Postgres. Assert aggregation (score sum, seedCount, sources union), filtering vsWatchedArtist/LibraryItem/MonitoredRelease,minScorecutoff, the conditional upsert never reviving a dismissed row, album derivation through the core-release predicate, andlastDiscoveredAtthrottling. - 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-filtertests 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 + asources-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
WatchedArtistMBIDs. 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.