feat(discover): also seed discovery from owned-album artists

Discovery seeded only from followed artists. Now it also seeds from artists
you own albums by (LibraryItem.artistMbid, captured since the MBID own-state
work) but don't follow — so your library shapes suggestions, not just your
follows.

- New DiscoverySeed throttle table (migration add_discovery_seed) mirroring
  WatchedArtist.lastDiscoveredAt, so each library-derived seed is swept once
  per interval (WatchedArtist seeds keep their own throttle).
- _seed_artists merges both seed sources least-recently-swept first, capped at
  chunk_size; run_discovery stamps the right throttle per seed and evicts
  contributions from seeds that are neither followed nor owned.
- Artists you already have (followed OR owned) are excluded from the suggestion
  candidates — discovery surfaces only genuinely new artists.
- discover.seedFromLibrary config (default true) + a Discovery settings toggle.

NOTE: existing LibraryItem rows have null artistMbid (populated on new
import/scan), so a re-scan is needed before old albums seed discovery.
worker 236, web 187 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 22:39:52 +02:00
parent a47d37a787
commit e04c1c9795
8 changed files with 132 additions and 18 deletions
+1
View File
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
"discover.similarPerSeed": "20",
"discover.albumsPerArtist": "1",
"discover.albumsOnly": "true",
"discover.seedFromLibrary": "true",
"discover.minScore": "0",
"discover.chunkSize": "5",
"discover.listenBrainzUrl": "",
+10
View File
@@ -395,6 +395,8 @@ export function SettingsForm() {
<b> Similar per seed</b> candidates pulled per artist. <b>Albums per artist</b>
releases surfaced per suggested artist. <b>Min score</b> drops weak suggestions.{" "}
<b>Albums only</b> suggest full-length albums only, hiding singles and EPs.{" "}
<b>Seed from library</b> also find artists similar to ones you own albums by, not
just the artists you follow.{" "}
<b>ListenBrainz base URL</b> leave blank for the default endpoint.
</HelpButton>
</div>
@@ -414,6 +416,14 @@ export function SettingsForm() {
/>
Albums only (hide singles &amp; EPs)
</label>
<label className="toggle" style={{ margin: "6px 0 0" }}>
<input
type="checkbox"
checked={discoverCfg["discover.seedFromLibrary"] !== "false"}
onChange={(e) => setCfg("discover.seedFromLibrary", e.target.checked ? "true" : "false")}
/>
Seed from library (similar to albums you own)
</label>
<label className="field">
<span>ListenBrainz base URL (blank = default)</span>
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
+1
View File
@@ -17,6 +17,7 @@ beforeEach(async () => {
await prisma.watchedArtist.deleteMany();
await prisma.discoverySuggestion.deleteMany();
await prisma.discoverySeedContribution.deleteMany();
await prisma.discoverySeed.deleteMany();
await prisma.config.deleteMany();
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
await prisma.unmatchedAlbum.deleteMany();