diff --git a/worker/lyra_worker/discovery.py b/worker/lyra_worker/discovery.py index 66b625b..3f1f10d 100644 --- a/worker/lyra_worker/discovery.py +++ b/worker/lyra_worker/discovery.py @@ -143,21 +143,21 @@ def _existing_rg_mbids(conn: psycopg.Connection) -> set[str]: return {r[0] for r in cur.fetchall()} -def _upsert_album(conn: psycopg.Connection, artist: dict, rg) -> int: +def _upsert_album(conn: psycopg.Connection, artist: dict, rg, reason: str) -> int: dedupe = f"album:{artist['mbid']}:{rg.rg_mbid}" with conn.cursor() as cur: cur.execute( 'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", ' '"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", ' - 'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") ' - "VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, " + 'score, "seedCount", sources, "albumReason", status, "dedupeKey", "createdAt", "updatedAt") ' + "VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, " "'pending', %s, now(), now()) " 'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, ' - '"updatedAt" = now() ' + '"albumReason" = EXCLUDED."albumReason", "updatedAt" = now() ' "WHERE \"DiscoverySuggestion\".status = 'pending'", (artist["mbid"], artist["name"], rg.rg_mbid, rg.title, rg.primary_type or None, list(rg.secondary_types), rg.first_release_date or None, - artist["score"], artist["seed_count"], sorted(artist["sources"]), dedupe), + artist["score"], artist["seed_count"], sorted(artist["sources"]), reason, dedupe), ) return cur.rowcount @@ -174,8 +174,26 @@ def _album_kind_ok(g, albums_only: bool) -> bool: return is_core_release(g.primary_type, g.secondary_types) +def _norm_title(s: str) -> str: + return (s or "").strip().lower() + + +def _popular_pick(conn, popularity, artist_name: str, core: list) -> str | None: + """The rg_mbid of the artist's most-played qualifying un-owned album (Last.fm), matched + into the already-browsed `core` by MBID first then normalized title. None if unavailable.""" + if popularity is None or not core: + return None + by_mbid = {g.rg_mbid: g for g in core} + by_title = {_norm_title(g.title): g for g in core} + for name, mbid, _plays in popularity.top_albums(conn, artist_name): + g = (by_mbid.get(mbid) if mbid else None) or by_title.get(_norm_title(name)) + if g is not None: + return g.rg_mbid + return None + + def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict], - cfg: DiscoveryConfig) -> int: + cfg: DiscoveryConfig, popularity=None) -> int: if cfg.albums_per_artist <= 0: return 0 have = _existing_rg_mbids(conn) @@ -189,9 +207,21 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[ core = [g for g in groups if _album_kind_ok(g, cfg.albums_only) and g.rg_mbid not in have] core.sort(key=lambda g: g.first_release_date or "", reverse=True) + + # newest picks (existing behavior) + the single most-played album (Last.fm); merged so + # an album that is both newest AND most-played is one row tagged "newest,popular". + reasons: dict[str, str] = {} for rg in core[: cfg.albums_per_artist]: - albums += _upsert_album(conn, artist, rg) - have.add(rg.rg_mbid) + reasons[rg.rg_mbid] = "newest" + popular = _popular_pick(conn, popularity, artist["name"], core) + if popular is not None: + reasons[popular] = "newest,popular" if reasons.get(popular) == "newest" else "popular" + + for rg in core: # preserve newest-first order among the chosen + reason = reasons.get(rg.rg_mbid) + if reason is not None: + albums += _upsert_album(conn, artist, rg, reason) + have.add(rg.rg_mbid) return albums @@ -276,7 +306,7 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str], def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], - browser: MbBrowser, cfg: DiscoveryConfig) -> DiscoveryResult: + browser: MbBrowser, cfg: DiscoveryConfig, popularity=None) -> DiscoveryResult: """Record each seed's similar-artist contributions, then recompute affected suggestions from the summed contributions — so a candidate's score is the sum of every seed's latest contribution, independent of chunk and sweep boundaries.""" @@ -304,7 +334,7 @@ def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], affected |= _record_contributions(conn, seed_mbid, live, known, cfg) surfaced = _recompute_suggestions(conn, affected, cfg) - albums = _derive_albums(conn, browser, surfaced, cfg) + albums = _derive_albums(conn, browser, surfaced, cfg, popularity) with conn.cursor() as cur: for seed_mbid, _seed_name, source in seeds: diff --git a/worker/tests/test_discovery_albums.py b/worker/tests/test_discovery_albums.py index e2d19fe..8d3f3e8 100644 --- a/worker/tests/test_discovery_albums.py +++ b/worker/tests/test_discovery_albums.py @@ -12,6 +12,21 @@ def _album_rows(conn): return cur.fetchall() +def _reasons(conn): + with conn.cursor() as cur: + cur.execute('SELECT "rgMbid", "albumReason" FROM "DiscoverySuggestion" ' + 'WHERE kind = \'album\' ORDER BY "rgMbid"') + return dict(cur.fetchall()) + + +class FakePopularity: + """Maps artist NAME -> [[album_name, mbid_or_None, playcount], …] (Last.fm shape).""" + def __init__(self, data): + self._d = data + def top_albums(self, conn, name): + return self._d.get(name, []) + + def test_derives_most_recent_core_album_for_surfaced_artist(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) @@ -37,6 +52,57 @@ def test_album_skips_release_groups_already_in_library(conn): assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded +def test_derives_newest_and_most_popular_albums(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-hit", "Big Hit", "Album", (), "2005-01-01"), + ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2022-01-01"), + ]}) + pop = FakePopularity({"Cand": [["Big Hit", "rg-hit", 90000], ["Latest", "rg-new", 100]]}) + result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop) + assert result.albums == 2 + assert _reasons(conn) == {"rg-new": "newest", "rg-hit": "popular"} + + +def test_newest_and_popular_the_same_album_is_one_row(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-only", "Only", "Album", (), "2022-01-01"), + ]}) + pop = FakePopularity({"Cand": [["Only", "rg-only", 90000]]}) + result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop) + assert result.albums == 1 + assert _reasons(conn) == {"rg-only": "newest,popular"} + + +def test_popular_matches_by_title_when_lastfm_mbid_missing_and_skips_owned(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + insert_monitored_release(conn, artist_mbid="c1", rg_mbid="rg-owned", album="Owned Hit") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-owned", "Owned Hit", "Album", (), "2010-01-01"), # excluded (owned) + ReleaseGroupInfo("rg-match", "Second Hit", "Album", (), "2012-01-01"), + ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2022-01-01"), + ]}) + # top album is owned (skipped), next has no mbid -> matched by title into core + pop = FakePopularity({"Cand": [["Owned Hit", "rg-owned", 99999], ["Second Hit", None, 80000]]}) + run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1), popularity=pop) + assert _reasons(conn) == {"rg-new": "newest", "rg-match": "popular"} + + +def test_no_popularity_provider_yields_newest_only(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-old", "Old", "Album", (), "2005-01-01"), + ReleaseGroupInfo("rg-new", "New", "Album", (), "2022-01-01"), + ]}) + run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) # popularity=None + assert _reasons(conn) == {"rg-new": "newest"} + + def test_albums_only_excludes_singles_and_eps_by_default(conn): insert_watched_artist(conn, mbid="s1", name="Seed") src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})