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:
@@ -0,0 +1,7 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "DiscoverySeed" (
|
||||
"mbid" TEXT NOT NULL,
|
||||
"lastDiscoveredAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "DiscoverySeed_pkey" PRIMARY KEY ("mbid")
|
||||
);
|
||||
@@ -191,6 +191,14 @@ model DiscoverySeedContribution {
|
||||
@@index([seedMbid])
|
||||
}
|
||||
|
||||
// Throttle registry for discovery seeds that are NOT WatchedArtist rows — i.e. artists you own
|
||||
// albums by (LibraryItem.artistMbid) but don't follow. Mirrors WatchedArtist.lastDiscoveredAt so
|
||||
// each library-derived seed is swept once per interval, not every sweep.
|
||||
model DiscoverySeed {
|
||||
mbid String @id
|
||||
lastDiscoveredAt DateTime?
|
||||
}
|
||||
|
||||
model ScanWorkItem {
|
||||
id String @id @default(cuid())
|
||||
scanId String
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 & 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"] ?? ""}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,27 +282,38 @@ 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:
|
||||
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()
|
||||
|
||||
@@ -29,6 +29,7 @@ def conn():
|
||||
cur.execute('DELETE FROM "WatchedArtist"')
|
||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||
cur.execute('DELETE FROM "DiscoverySeed"')
|
||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||
cur.execute('DELETE FROM "ApiCache"')
|
||||
@@ -48,6 +49,7 @@ def conn():
|
||||
cur.execute('DELETE FROM "WatchedArtist"')
|
||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||
cur.execute('DELETE FROM "DiscoverySeed"')
|
||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||
cur.execute('DELETE FROM "ApiCache"')
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user