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
+42
View File
@@ -11,6 +11,48 @@ def _artist_rows(conn):
return cur.fetchall()
def _insert_library_item(conn, artist, artist_mbid, album="Owned Album"):
with conn.cursor() as cur:
cur.execute(
'INSERT INTO "LibraryItem" (id, artist, album, path, source, format, "qualityClass", '
'"artistMbid", "importedAt") '
"VALUES (gen_random_uuid()::text, %s, %s, %s, 'scan', 'FLAC', 2, %s, now())",
(artist, album, f"/music/{artist}/{album}", artist_mbid),
)
conn.commit()
def test_seeds_discovery_from_owned_album_artists(conn):
# An artist you own an album by but don't follow becomes a discovery seed.
_insert_library_item(conn, "Owned Artist", "lib-seed")
src = FakeSimilaritySource(similar={"lib-seed": [SimilarArtist("c1", "New Find", 0.9)]})
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
assert result.seeds == 1
assert [r[0] for r in _artist_rows(conn)] == ["c1"] # surfaced from the library seed
with conn.cursor() as cur: # library seed throttled via DiscoverySeed, not WatchedArtist
cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "DiscoverySeed" WHERE mbid = %s', ("lib-seed",))
assert cur.fetchone()[0] is True
def test_library_seeding_can_be_disabled(conn):
_insert_library_item(conn, "Owned Artist", "lib-seed")
src = FakeSimilaritySource(similar={"lib-seed": [SimilarArtist("c1", "New Find", 0.9)]})
result = run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig(seed_from_library=False))
assert result.seeds == 0 and _artist_rows(conn) == [] # library not seeded
def test_owned_artist_is_not_suggested_as_a_candidate(conn):
# You own an album by "c1"; a followed seed finds c1 similar. c1 must not be suggested back.
insert_watched_artist(conn, mbid="s1", name="Seed")
_insert_library_item(conn, "Owned", "c1")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Owned", 0.9), SimilarArtist("c2", "New", 0.8)],
"c1": [], # the library seed itself finds nothing new here
})
run_discovery(conn, [src], FakeMbBrowser(), DiscoveryConfig())
assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 excluded (already owned)
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")