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])
|
@@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 {
|
model ScanWorkItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
scanId String
|
scanId String
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const DEFAULTS: Record<string, string> = {
|
|||||||
"discover.similarPerSeed": "20",
|
"discover.similarPerSeed": "20",
|
||||||
"discover.albumsPerArtist": "1",
|
"discover.albumsPerArtist": "1",
|
||||||
"discover.albumsOnly": "true",
|
"discover.albumsOnly": "true",
|
||||||
|
"discover.seedFromLibrary": "true",
|
||||||
"discover.minScore": "0",
|
"discover.minScore": "0",
|
||||||
"discover.chunkSize": "5",
|
"discover.chunkSize": "5",
|
||||||
"discover.listenBrainzUrl": "",
|
"discover.listenBrainzUrl": "",
|
||||||
|
|||||||
@@ -395,6 +395,8 @@ export function SettingsForm() {
|
|||||||
<b> Similar per seed</b> — candidates pulled per artist. <b>Albums per artist</b> —
|
<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.{" "}
|
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>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.
|
<b>ListenBrainz base URL</b> — leave blank for the default endpoint.
|
||||||
</HelpButton>
|
</HelpButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,6 +416,14 @@ export function SettingsForm() {
|
|||||||
/>
|
/>
|
||||||
Albums only (hide singles & EPs)
|
Albums only (hide singles & EPs)
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>ListenBrainz base URL (blank = default)</span>
|
<span>ListenBrainz base URL (blank = default)</span>
|
||||||
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
<input value={discoverCfg["discover.listenBrainzUrl"] ?? ""}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ beforeEach(async () => {
|
|||||||
await prisma.watchedArtist.deleteMany();
|
await prisma.watchedArtist.deleteMany();
|
||||||
await prisma.discoverySuggestion.deleteMany();
|
await prisma.discoverySuggestion.deleteMany();
|
||||||
await prisma.discoverySeedContribution.deleteMany();
|
await prisma.discoverySeedContribution.deleteMany();
|
||||||
|
await prisma.discoverySeed.deleteMany();
|
||||||
await prisma.config.deleteMany();
|
await prisma.config.deleteMany();
|
||||||
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
|
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
|
||||||
await prisma.unmatchedAlbum.deleteMany();
|
await prisma.unmatchedAlbum.deleteMany();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class DiscoveryConfig:
|
|||||||
albums_per_artist: int = 1
|
albums_per_artist: int = 1
|
||||||
min_score: float = 0.0
|
min_score: float = 0.0
|
||||||
albums_only: bool = True
|
albums_only: bool = True
|
||||||
|
seed_from_library: bool = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, config: dict) -> "DiscoveryConfig":
|
def from_config(cls, config: dict) -> "DiscoveryConfig":
|
||||||
@@ -47,6 +48,7 @@ class DiscoveryConfig:
|
|||||||
albums_per_artist=_int("discover.albumsPerArtist", 1),
|
albums_per_artist=_int("discover.albumsPerArtist", 1),
|
||||||
min_score=_float("discover.minScore", 0.0),
|
min_score=_float("discover.minScore", 0.0),
|
||||||
albums_only=_bool("discover.albumsOnly", True),
|
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):
|
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:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'SELECT mbid, name FROM "WatchedArtist" '
|
'SELECT mbid, name, \'watched\' AS source, "lastDiscoveredAt" FROM "WatchedArtist" '
|
||||||
'WHERE "lastDiscoveredAt" IS NULL '
|
'WHERE "lastDiscoveredAt" IS NULL OR "lastDiscoveredAt" < now() - make_interval(hours => %s) ',
|
||||||
' OR "lastDiscoveredAt" < now() - make_interval(hours => %s) '
|
(interval,),
|
||||||
'ORDER BY "lastDiscoveredAt" ASC NULLS FIRST LIMIT %s',
|
|
||||||
(cfg.interval_hours, cfg.chunk_size),
|
|
||||||
)
|
)
|
||||||
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]:
|
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()}
|
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:
|
def _upsert_artist(conn: psycopg.Connection, cand: dict) -> int:
|
||||||
dedupe = f"artist:{cand['mbid']}:"
|
dedupe = f"artist:{cand['mbid']}:"
|
||||||
with conn.cursor() as cur:
|
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."""
|
every seed's latest contribution, independent of chunk and sweep boundaries."""
|
||||||
seeds = _seed_artists(conn, cfg)
|
seeds = _seed_artists(conn, cfg)
|
||||||
followed = _followed_mbids(conn)
|
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)
|
live = _healthy_sources(sources)
|
||||||
|
|
||||||
# Evict contributions from seeds no longer followed — unfollow/removal has no
|
# Evict contributions whose seed is no longer valid — an unfollowed artist AND (when
|
||||||
# FK cascade, so without this a removed seed's score would linger and inflate
|
# library seeding is on) an artist whose last owned album was deleted. Neither has an FK
|
||||||
# the aggregate forever. Seed `affected` with the touched candidates so their
|
# cascade, so without this a stale seed's score would linger and inflate the aggregate.
|
||||||
# suggestion scores are recomputed from the remaining live seeds this sweep.
|
# Seed `affected` with the touched candidates so their scores recompute from live seeds.
|
||||||
|
valid_seeds = list(known)
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute('SELECT DISTINCT "candidateMbid" FROM "DiscoverySeedContribution" '
|
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()}
|
affected = {r[0] for r in cur.fetchall()}
|
||||||
cur.execute('DELETE FROM "DiscoverySeedContribution" '
|
cur.execute('DELETE FROM "DiscoverySeedContribution" WHERE NOT ("seedMbid" = ANY(%s))',
|
||||||
'WHERE "seedMbid" NOT IN (SELECT mbid FROM "WatchedArtist")')
|
(valid_seeds,))
|
||||||
|
|
||||||
for seed_mbid, _seed_name in seeds:
|
for seed_mbid, _seed_name, _source in seeds:
|
||||||
affected |= _record_contributions(conn, seed_mbid, live, followed, cfg)
|
affected |= _record_contributions(conn, seed_mbid, live, known, cfg)
|
||||||
|
|
||||||
surfaced = _recompute_suggestions(conn, affected, cfg)
|
surfaced = _recompute_suggestions(conn, affected, cfg)
|
||||||
albums = _derive_albums(conn, browser, surfaced, cfg)
|
albums = _derive_albums(conn, browser, surfaced, cfg)
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
for seed_mbid, _seed_name in seeds:
|
for seed_mbid, _seed_name, source in seeds:
|
||||||
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s',
|
if source == "library":
|
||||||
(seed_mbid,))
|
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()
|
conn.commit()
|
||||||
return DiscoveryResult(artists=len(surfaced), albums=albums, seeds=len(seeds))
|
return DiscoveryResult(artists=len(surfaced), albums=albums, seeds=len(seeds))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def conn():
|
|||||||
cur.execute('DELETE FROM "WatchedArtist"')
|
cur.execute('DELETE FROM "WatchedArtist"')
|
||||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||||
|
cur.execute('DELETE FROM "DiscoverySeed"')
|
||||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||||
cur.execute('DELETE FROM "ApiCache"')
|
cur.execute('DELETE FROM "ApiCache"')
|
||||||
@@ -48,6 +49,7 @@ def conn():
|
|||||||
cur.execute('DELETE FROM "WatchedArtist"')
|
cur.execute('DELETE FROM "WatchedArtist"')
|
||||||
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
cur.execute('DELETE FROM "DiscoverySuggestion"')
|
||||||
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
cur.execute('DELETE FROM "DiscoverySeedContribution"')
|
||||||
|
cur.execute('DELETE FROM "DiscoverySeed"')
|
||||||
cur.execute('DELETE FROM "ScanWorkItem"')
|
cur.execute('DELETE FROM "ScanWorkItem"')
|
||||||
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
cur.execute('DELETE FROM "UnmatchedAlbum"')
|
||||||
cur.execute('DELETE FROM "ApiCache"')
|
cur.execute('DELETE FROM "ApiCache"')
|
||||||
|
|||||||
@@ -11,6 +11,48 @@ def _artist_rows(conn):
|
|||||||
return cur.fetchall()
|
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):
|
def test_aggregates_scores_across_seeds(conn):
|
||||||
insert_watched_artist(conn, mbid="s1", name="Seed One")
|
insert_watched_artist(conn, mbid="s1", name="Seed One")
|
||||||
insert_watched_artist(conn, mbid="s2", name="Seed Two")
|
insert_watched_artist(conn, mbid="s2", name="Seed Two")
|
||||||
|
|||||||
Reference in New Issue
Block a user