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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user