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
+61 -18
View File
@@ -18,6 +18,7 @@ class DiscoveryConfig:
albums_per_artist: int = 1
min_score: float = 0.0
albums_only: bool = True
seed_from_library: bool = True
@classmethod
def from_config(cls, config: dict) -> "DiscoveryConfig":
@@ -47,6 +48,7 @@ class DiscoveryConfig:
albums_per_artist=_int("discover.albumsPerArtist", 1),
min_score=_float("discover.minScore", 0.0),
albums_only=_bool("discover.albumsOnly", True),
seed_from_library=_bool("discover.seedFromLibrary", True),
)
@@ -71,15 +73,37 @@ def _healthy_sources(sources):
def _seed_artists(conn: psycopg.Connection, cfg: DiscoveryConfig):
"""Return up to chunk_size due seeds as (mbid, name, source) where source is 'watched'
(a followed artist, throttled on WatchedArtist.lastDiscoveredAt) or 'library' (an artist
you own albums by but don't follow, throttled on DiscoverySeed.lastDiscoveredAt). Both are
ordered least-recently-swept first so a sweep drains all eligible seeds across chunks."""
interval = cfg.interval_hours
with conn.cursor() as cur:
cur.execute(
'SELECT mbid, name FROM "WatchedArtist" '
'WHERE "lastDiscoveredAt" IS NULL '
' OR "lastDiscoveredAt" < now() - make_interval(hours => %s) '
'ORDER BY "lastDiscoveredAt" ASC NULLS FIRST LIMIT %s',
(cfg.interval_hours, cfg.chunk_size),
'SELECT mbid, name, \'watched\' AS source, "lastDiscoveredAt" FROM "WatchedArtist" '
'WHERE "lastDiscoveredAt" IS NULL OR "lastDiscoveredAt" < now() - make_interval(hours => %s) ',
(interval,),
)
return cur.fetchall()
rows = cur.fetchall()
if cfg.seed_from_library:
# Distinct owned-album artists (with a captured MBID) that we don't already follow,
# left-joined to their throttle row. A brand-new library seed (no DiscoverySeed row)
# is immediately eligible via NULLS FIRST.
cur.execute(
'SELECT DISTINCT ON (li."artistMbid") li."artistMbid", li.artist, \'library\', ds."lastDiscoveredAt" '
'FROM "LibraryItem" li '
'LEFT JOIN "DiscoverySeed" ds ON ds.mbid = li."artistMbid" '
'WHERE li."artistMbid" IS NOT NULL '
' AND li."artistMbid" NOT IN (SELECT mbid FROM "WatchedArtist") '
' AND (ds."lastDiscoveredAt" IS NULL OR ds."lastDiscoveredAt" < now() - make_interval(hours => %s))',
(interval,),
)
rows += cur.fetchall()
# Merge both sources, least-recently-swept first (NULL = never), cap at chunk_size.
rows.sort(key=lambda r: (r[3] is not None, r[3]))
return [(mbid, name, source) for mbid, name, source, _last in rows[: cfg.chunk_size]]
def _followed_mbids(conn: psycopg.Connection) -> set[str]:
@@ -88,6 +112,14 @@ def _followed_mbids(conn: psycopg.Connection) -> set[str]:
return {r[0] for r in cur.fetchall()}
def _owned_artist_mbids(conn: psycopg.Connection) -> set[str]:
"""MBIDs of artists you own albums by (captured on LibraryItem). Used both as extra
discovery seeds and to keep already-owned artists out of the suggestion candidates."""
with conn.cursor() as cur:
cur.execute('SELECT DISTINCT "artistMbid" FROM "LibraryItem" WHERE "artistMbid" IS NOT NULL')
return {r[0] for r in cur.fetchall()}
def _upsert_artist(conn: psycopg.Connection, cand: dict) -> int:
dedupe = f"artist:{cand['mbid']}:"
with conn.cursor() as cur:
@@ -250,28 +282,39 @@ def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
every seed's latest contribution, independent of chunk and sweep boundaries."""
seeds = _seed_artists(conn, cfg)
followed = _followed_mbids(conn)
owned = _owned_artist_mbids(conn) if cfg.seed_from_library else set()
# Artists you already have (followed or owned) are seeds/inputs, never suggested as
# candidates — discovery only surfaces genuinely new artists.
known = followed | owned
live = _healthy_sources(sources)
# Evict contributions from seeds no longer followed — unfollow/removal has no
# FK cascade, so without this a removed seed's score would linger and inflate
# the aggregate forever. Seed `affected` with the touched candidates so their
# suggestion scores are recomputed from the remaining live seeds this sweep.
# Evict contributions whose seed is no longer valid — an unfollowed artist AND (when
# library seeding is on) an artist whose last owned album was deleted. Neither has an FK
# cascade, so without this a stale seed's score would linger and inflate the aggregate.
# Seed `affected` with the touched candidates so their scores recompute from live seeds.
valid_seeds = list(known)
with conn.cursor() as cur:
cur.execute('SELECT DISTINCT "candidateMbid" FROM "DiscoverySeedContribution" '
'WHERE "seedMbid" NOT IN (SELECT mbid FROM "WatchedArtist")')
'WHERE NOT ("seedMbid" = ANY(%s))', (valid_seeds,))
affected = {r[0] for r in cur.fetchall()}
cur.execute('DELETE FROM "DiscoverySeedContribution" '
'WHERE "seedMbid" NOT IN (SELECT mbid FROM "WatchedArtist")')
cur.execute('DELETE FROM "DiscoverySeedContribution" WHERE NOT ("seedMbid" = ANY(%s))',
(valid_seeds,))
for seed_mbid, _seed_name in seeds:
affected |= _record_contributions(conn, seed_mbid, live, followed, cfg)
for seed_mbid, _seed_name, _source in seeds:
affected |= _record_contributions(conn, seed_mbid, live, known, cfg)
surfaced = _recompute_suggestions(conn, affected, cfg)
albums = _derive_albums(conn, browser, surfaced, cfg)
with conn.cursor() as cur:
for seed_mbid, _seed_name in seeds:
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s',
(seed_mbid,))
for seed_mbid, _seed_name, source in seeds:
if source == "library":
cur.execute(
'INSERT INTO "DiscoverySeed" (mbid, "lastDiscoveredAt") VALUES (%s, now()) '
'ON CONFLICT (mbid) DO UPDATE SET "lastDiscoveredAt" = now()',
(seed_mbid,))
else:
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s',
(seed_mbid,))
conn.commit()
return DiscoveryResult(artists=len(surfaced), albums=albums, seeds=len(seeds))