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(), 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") 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(), 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 0–1 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), cfg) # processes s1 (older) run_discovery(conn, [src], FakeMbBrowser(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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