Files
Lyra/worker/tests/test_discovery.py
T
Jonathan 4a768b7122 fix(discover): drop suggestions for non-artists (no core discography)
ListenBrainz surfaces producers/session players/band members (Max Martin,
Dominic Howard) that co-occur in listens but have no releases of their own —
they showed as bare rows with an empty page. _derive_albums now drops a
surfaced artist (deletes their pending suggestion) when their MB discography
has no core release-group (Album/Single/EP, no secondary type), catching both
empty discographies and soundtrack/production-only credits, while keeping real
singles/EP-only artists. On browse failure the suggestion is kept.

Test fake browser gains default_core so scoring tests keep surfacing candidates.
worker 249 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:38:56 +02:00

309 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
from lyra_worker.discovery import DiscoveryConfig, run_discovery
from lyra_worker.similarity.base import SimilarArtist
from tests.conftest import insert_discovery_suggestion, insert_watched_artist
def _artist_rows(conn):
with conn.cursor() as cur:
cur.execute('SELECT "artistMbid", "artistName", score, "seedCount", sources, status::text '
'FROM "DiscoverySuggestion" WHERE kind = \'artist\' ORDER BY score DESC')
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(default_core=True), 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(default_core=True), 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(default_core=True), 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")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Shared", 0.6), SimilarArtist("c2", "Only1", 0.4)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert result.artists == 2
rows = _artist_rows(conn)
# c1 recommended by both seeds; each seed max-normalizes its top score to 1.0, so
# c1 = 1.0 + 1.0 = 2.0, seedCount 2; c2 (0.4/0.6 within s1) by one seed
assert rows[0][0] == "c1" and abs(rows[0][2] - 2.0) < 1e-6 and rows[0][3] == 2
assert rows[0][4] == ["fake"] and rows[0][5] == "pending"
assert rows[1][0] == "c2" and rows[1][3] == 1
def test_normalizes_across_sources_of_different_scales(conn):
# ListenBrainz emits co-occurrence counts in the thousands; Last.fm emits 01 match
# scores. Max-normalizing each source before summing keeps the small-scale source's
# candidates from being drowned.
insert_watched_artist(conn, mbid="s1", name="Seed")
lb = FakeSimilaritySource(similar={"s1": [
SimilarArtist("c_lb", "LB Only", 5000.0), SimilarArtist("c_shared", "Shared", 4000.0)]})
lb.name = "listenbrainz"
lfm = FakeSimilaritySource(similar={"s1": [
SimilarArtist("c_lfm", "LFM Only", 0.9), SimilarArtist("c_shared", "Shared", 0.5)]})
lfm.name = "lastfm"
run_discovery(conn, [lb, lfm], FakeMbBrowser(default_core=True), DiscoveryConfig())
lb_only = _artist_score(conn, "c_lb")[0] # 5000/5000 = 1.0
lfm_only = _artist_score(conn, "c_lfm")[0] # 0.9/0.9 = 1.0
shared = _artist_score(conn, "c_shared")[0] # 4000/5000 + 0.5/0.9 = 0.8 + 0.556
assert abs(lb_only - lfm_only) < 1e-6 # small-scale source not drowned — ranks equal
assert shared > lb_only # co-sourced candidate outranks a single-source one
def test_skips_already_followed_artists(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
insert_watched_artist(conn, mbid="c1", name="Already Followed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9),
SimilarArtist("c2", "New", 0.8)]})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert [r[0] for r in _artist_rows(conn)] == ["c2"] # c1 filtered (followed)
def test_min_score_filters_weak_candidates(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Strong", 0.9),
SimilarArtist("c2", "Weak", 0.1)]})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig(min_score=0.5))
assert [r[0] for r in _artist_rows(conn)] == ["c1"]
def test_dismissed_suggestion_is_never_revived(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="dismissed", score=0.0)
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
with conn.cursor() as cur:
cur.execute('SELECT status::text, score FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', ("c1",))
assert cur.fetchone() == ("dismissed", 0.0) # untouched — WHERE status='pending' blocked the update
def test_stamps_last_discovered_and_throttles(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 1
with conn.cursor() as cur:
cur.execute('SELECT "lastDiscoveredAt" IS NOT NULL FROM "WatchedArtist" WHERE mbid = %s', ("s1",))
assert cur.fetchone()[0] is True
# second run: seed not due (lastDiscoveredAt recent) -> no seeds -> nothing new
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 0
def test_unhealthy_source_contributes_nothing(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]}, healthy=False)
assert run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()).artists == 0
assert _artist_rows(conn) == []
def test_source_with_raising_health_is_skipped_not_fatal(conn):
insert_watched_artist(conn, mbid="s1", name="Seed")
class BoomSource:
name = "boom"
def health(self):
raise RuntimeError("boom")
def similar_artists(self, mbid):
return []
good = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "X", 0.9)]})
result = run_discovery(conn, [BoomSource(), good], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert result.artists == 1 # good source still processed; boom skipped, no crash
def test_config_from_config_parses_and_defaults():
cfg = DiscoveryConfig.from_config({"discover.enabled": "true", "discover.chunkSize": "10",
"discover.minScore": "0.3"})
assert cfg.enabled is True and cfg.chunk_size == 10 and cfg.min_score == 0.3
d = DiscoveryConfig.from_config({})
assert (d.enabled, d.interval_hours, d.chunk_size, d.similar_per_seed,
d.albums_per_artist, d.min_score) == (False, 168, 5, 20, 1, 0.0)
def _artist_score(conn, mbid):
with conn.cursor() as cur:
cur.execute('SELECT score, "seedCount" FROM "DiscoverySuggestion" WHERE "artistMbid" = %s', (mbid,))
return cur.fetchone()
def test_score_sums_across_separate_chunks(conn):
# Two seeds, both eligible, forced into separate one-seed chunks by chunk_size=1.
insert_watched_artist(conn, mbid="s1", name="Seed One",
last_discovered_sql="now() - interval '200 hours'")
insert_watched_artist(conn, mbid="s2", name="Seed Two",
last_discovered_sql="now() - interval '190 hours'")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
cfg = DiscoveryConfig(chunk_size=1)
run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg) # processes s1 (older)
run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg) # processes s2
# replace-not-accumulate would leave 0.5/seedCount1; contributions give the sum.
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
def test_partial_sweep_preserves_other_seeds_contribution(conn):
# Full sweep: c1 gets s1(0.6)+s2(0.5)=1.1. Then only s1 is due again with a new
# score; s2's contribution must survive untouched.
insert_watched_artist(conn, mbid="s1", name="Seed One",
last_discovered_sql="now() - interval '200 hours'")
insert_watched_artist(conn, mbid="s2", name="Seed Two",
last_discovered_sql="now() - interval '200 hours'")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig()) # both -> 1.1/2
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# s1 due again (older than interval), s2 fresh; s1 now scores c1 at 0.7.
with conn.cursor() as cur:
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s1",))
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Shared", 0.7)]})
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # 1.0 (new s1) + 1.0 (kept s2), max-normalized
def test_seed_dropoff_lowers_seedcount(conn):
insert_watched_artist(conn, mbid="s1", name="Seed One",
last_discovered_sql="now() - interval '200 hours'")
insert_watched_artist(conn, mbid="s2", name="Seed Two",
last_discovered_sql="now() - interval '200 hours'")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# s1 re-swept but no longer finds c1 -> its contribution is removed, c1 recomputed.
with conn.cursor() as cur:
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s1",))
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s1": [SimilarArtist("c9", "Other", 0.9)]})
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (1.0, 1) # only s2 remains (max-normalized to 1.0)
def test_evicts_contributions_from_removed_seed(conn):
# Two live seeds contribute to c1 -> 1.1/2. Then s1 is unfollowed (deleted from
# WatchedArtist, simulating DELETE /api/artists/[id]) without any FK cascade on
# DiscoverySeedContribution. The next sweep must evict s1's orphaned contribution
# rather than leaving it to inflate c1's score forever.
insert_watched_artist(conn, mbid="s1", name="Seed One",
last_discovered_sql="now() - interval '200 hours'")
insert_watched_artist(conn, mbid="s2", name="Seed Two",
last_discovered_sql="now() - interval '200 hours'")
src = FakeSimilaritySource(similar={
"s1": [SimilarArtist("c1", "Shared", 0.6)],
"s2": [SimilarArtist("c1", "Shared", 0.5)],
})
run_discovery(conn, [src], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (2.0, 2) # each seed max-normalizes to 1.0
# Unfollow s1 (no FK cascade on DiscoverySeedContribution).
with conn.cursor() as cur:
cur.execute('DELETE FROM "WatchedArtist" WHERE mbid = %s', ("s1",))
conn.commit()
# Make s2 due again and sweep; s1 can no longer be a seed since it's gone.
with conn.cursor() as cur:
cur.execute('UPDATE "WatchedArtist" SET "lastDiscoveredAt" = now() - interval \'200 hours\' WHERE mbid = %s', ("s2",))
conn.commit()
src2 = FakeSimilaritySource(similar={"s2": [SimilarArtist("c1", "Shared", 0.5)]})
run_discovery(conn, [src2], FakeMbBrowser(default_core=True), DiscoveryConfig())
assert _artist_score(conn, "c1") == (1.0, 1) # s1's stale contribution evicted; s2 alone -> 1.0
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "DiscoverySeedContribution" WHERE "seedMbid" = %s', ("s1",))
assert cur.fetchone()[0] == 0
def test_run_discovery_processes_at_most_chunk_size_seeds(conn):
from lyra_worker.discovery import DiscoveryConfig, run_discovery
from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
from lyra_worker.similarity.base import SimilarArtist
from tests.conftest import insert_watched_artist
for i in range(4):
insert_watched_artist(conn, mbid=f"s{i}", name=f"Seed{i}")
src = FakeSimilaritySource(similar={f"s{i}": [SimilarArtist(f"c{i}", f"Cand{i}", 0.9)] for i in range(4)})
cfg = DiscoveryConfig.from_config({"discover.chunkSize": "2"})
result = run_discovery(conn, [src], FakeMbBrowser(default_core=True), cfg)
assert result.seeds == 2 # only chunk_size seeds this call
# exactly 2 seeds now have lastDiscoveredAt set
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NOT NULL')
assert cur.fetchone()[0] == 2
def test_reset_pending_clears_state_and_marks_seeds_due(conn):
from lyra_worker.discovery import reset_pending
from tests.conftest import insert_discovery_suggestion
insert_watched_artist(conn, mbid="s1", name="Seed", last_discovered_sql="now()")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c1", status="pending")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c2", status="dismissed")
insert_discovery_suggestion(conn, kind="artist", artist_mbid="c3", status="wanted")
with conn.cursor() as cur:
cur.execute('INSERT INTO "DiscoverySeedContribution" (id, "candidateMbid", "candidateName", '
'"seedMbid", score, sources, "updatedAt") VALUES '
"(gen_random_uuid()::text, 'c1', 'C1', 's1', 1.0, '{}', now())")
cur.execute('INSERT INTO "DiscoverySeed" (mbid, "lastDiscoveredAt") VALUES (%s, now())', ("libseed",))
conn.commit()
reset_pending(conn)
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status='pending'")
assert cur.fetchone()[0] == 0 # pending cleared
cur.execute("SELECT count(*) FROM \"DiscoverySuggestion\" WHERE status IN ('dismissed','wanted')")
assert cur.fetchone()[0] == 2 # user decisions kept
cur.execute('SELECT count(*) FROM "DiscoverySeedContribution"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "DiscoverySeed"')
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "WatchedArtist" WHERE "lastDiscoveredAt" IS NULL')
assert cur.fetchone()[0] == 1 # seed due again