diff --git a/worker/lyra_worker/discovery.py b/worker/lyra_worker/discovery.py index dbff2bd..a32e2fe 100644 --- a/worker/lyra_worker/discovery.py +++ b/worker/lyra_worker/discovery.py @@ -3,6 +3,7 @@ from dataclasses import dataclass import psycopg from lyra_worker.browser import MbBrowser +from lyra_worker.release_filter import is_core_release from lyra_worker.similarity.base import SimilaritySource _TRUE = {"1", "true", "yes", "on"} @@ -82,9 +83,50 @@ def _upsert_artist(conn: psycopg.Connection, cand: dict) -> int: return cur.rowcount +def _existing_rg_mbids(conn: psycopg.Connection) -> set[str]: + with conn.cursor() as cur: + cur.execute('SELECT "rgMbid" FROM "MonitoredRelease"') + return {r[0] for r in cur.fetchall()} + + +def _upsert_album(conn: psycopg.Connection, artist: dict, rg) -> 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, " + "'pending', %s, now(), now()) " + 'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, ' + '"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), + ) + return cur.rowcount + + def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[dict], cfg: DiscoveryConfig) -> int: - return 0 # replaced in the album-derivation task + if cfg.albums_per_artist <= 0: + return 0 + have = _existing_rg_mbids(conn) + albums = 0 + for artist in surfaced: + try: + groups = browser.browse_release_groups(artist["mbid"]) + except Exception as e: # one artist's browse failure must not abort the sweep + print(f"worker: discovery album browse failed for {artist['mbid']}: {e}", flush=True) + continue + core = [g for g in groups + if is_core_release(g.primary_type, g.secondary_types) and g.rg_mbid not in have] + core.sort(key=lambda g: g.first_release_date or "", reverse=True) + for rg in core[: cfg.albums_per_artist]: + albums += _upsert_album(conn, artist, rg) + have.add(rg.rg_mbid) + return albums def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource], diff --git a/worker/lyra_worker/release_filter.py b/worker/lyra_worker/release_filter.py new file mode 100644 index 0000000..f3ccbd5 --- /dev/null +++ b/worker/lyra_worker/release_filter.py @@ -0,0 +1,6 @@ +_CORE_PRIMARY = {"Album", "Single", "EP"} + + +def is_core_release(primary_type: str, secondary_types) -> bool: + """A 'core' studio release: Album/Single/EP with no secondary types (live, comp, etc.).""" + return (primary_type or "") in _CORE_PRIMARY and len(secondary_types or ()) == 0 diff --git a/worker/tests/test_discovery_albums.py b/worker/tests/test_discovery_albums.py new file mode 100644 index 0000000..732d181 --- /dev/null +++ b/worker/tests/test_discovery_albums.py @@ -0,0 +1,45 @@ +from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource +from lyra_worker.browser import ReleaseGroupInfo +from lyra_worker.discovery import DiscoveryConfig, run_discovery +from lyra_worker.similarity.base import SimilarArtist +from tests.conftest import insert_monitored_release, insert_watched_artist + + +def _album_rows(conn): + with conn.cursor() as cur: + cur.execute('SELECT "artistMbid", "rgMbid", album, "primaryType" ' + 'FROM "DiscoverySuggestion" WHERE kind = \'album\' ORDER BY album') + return cur.fetchall() + + +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)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-old", "Debut", "Album", (), "2001-01-01"), + ReleaseGroupInfo("rg-new", "Latest", "Album", (), "2020-01-01"), + ReleaseGroupInfo("rg-live", "At The Hall", "Album", ("Live",), "2021-01-01"), + ]}) + result = run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) + assert result.albums == 1 + assert _album_rows(conn) == [("c1", "rg-new", "Latest", "Album")] # newest core, live excluded + + +def test_album_skips_release_groups_already_in_library(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + insert_monitored_release(conn, artist_mbid="c1", rg_mbid="rg-have", album="Owned") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ + ReleaseGroupInfo("rg-have", "Owned", "Album", (), "2019-01-01"), + ReleaseGroupInfo("rg-fresh", "Fresh", "Album", (), "2018-01-01"), + ]}) + run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1)) + assert _album_rows(conn) == [("c1", "rg-fresh", "Fresh", "Album")] # owned rg excluded + + +def test_albums_per_artist_zero_derives_none(conn): + insert_watched_artist(conn, mbid="s1", name="Seed") + src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]}) + browser = FakeMbBrowser(releases={"c1": [ReleaseGroupInfo("rg1", "A", "Album", (), "2020")]}) + assert run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=0)).albums == 0 + assert _album_rows(conn) == [] diff --git a/worker/tests/test_release_filter.py b/worker/tests/test_release_filter.py new file mode 100644 index 0000000..ad6ab0e --- /dev/null +++ b/worker/tests/test_release_filter.py @@ -0,0 +1,14 @@ +from lyra_worker.release_filter import is_core_release + + +def test_core_studio_types_pass(): + assert is_core_release("Album", ()) is True + assert is_core_release("Single", []) is True + assert is_core_release("EP", ()) is True + + +def test_secondary_types_and_non_core_fail(): + assert is_core_release("Album", ("Live",)) is False + assert is_core_release("Album", ("Compilation",)) is False + assert is_core_release("Broadcast", ()) is False + assert is_core_release("", ()) is False