feat(discover): derive newest + most-popular album per artist
_derive_albums now also picks the artist's most-played album (Last.fm, matched into the browsed discography by MBID then normalized title), alongside the newest. Same release -> one row tagged newest,popular. albumReason persisted; run_discovery threads an optional popularity provider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,21 +143,21 @@ def _existing_rg_mbids(conn: psycopg.Connection) -> set[str]:
|
|||||||
return {r[0] for r in cur.fetchall()}
|
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}"
|
dedupe = f"album:{artist['mbid']}:{rg.rg_mbid}"
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", '
|
'INSERT INTO "DiscoverySuggestion" (id, kind, "artistMbid", "artistName", '
|
||||||
'"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", '
|
'"rgMbid", album, "primaryType", "secondaryTypes", "firstReleaseDate", '
|
||||||
'score, "seedCount", sources, status, "dedupeKey", "createdAt", "updatedAt") '
|
'score, "seedCount", sources, "albumReason", status, "dedupeKey", "createdAt", "updatedAt") '
|
||||||
"VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
|
"VALUES (gen_random_uuid()::text, 'album', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, "
|
||||||
"'pending', %s, now(), now()) "
|
"'pending', %s, now(), now()) "
|
||||||
'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, '
|
'ON CONFLICT ("dedupeKey") DO UPDATE SET score = EXCLUDED.score, '
|
||||||
'"updatedAt" = now() '
|
'"albumReason" = EXCLUDED."albumReason", "updatedAt" = now() '
|
||||||
"WHERE \"DiscoverySuggestion\".status = 'pending'",
|
"WHERE \"DiscoverySuggestion\".status = 'pending'",
|
||||||
(artist["mbid"], artist["name"], rg.rg_mbid, rg.title,
|
(artist["mbid"], artist["name"], rg.rg_mbid, rg.title,
|
||||||
rg.primary_type or None, list(rg.secondary_types), rg.first_release_date or None,
|
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
|
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)
|
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],
|
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:
|
if cfg.albums_per_artist <= 0:
|
||||||
return 0
|
return 0
|
||||||
have = _existing_rg_mbids(conn)
|
have = _existing_rg_mbids(conn)
|
||||||
@@ -189,8 +207,20 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
|
|||||||
core = [g for g in groups
|
core = [g for g in groups
|
||||||
if _album_kind_ok(g, cfg.albums_only) and g.rg_mbid not in have]
|
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)
|
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]:
|
for rg in core[: cfg.albums_per_artist]:
|
||||||
albums += _upsert_album(conn, artist, rg)
|
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)
|
have.add(rg.rg_mbid)
|
||||||
return albums
|
return albums
|
||||||
|
|
||||||
@@ -276,7 +306,7 @@ def _recompute_suggestions(conn: psycopg.Connection, affected: set[str],
|
|||||||
|
|
||||||
|
|
||||||
def run_discovery(conn: psycopg.Connection, sources: list[SimilaritySource],
|
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
|
"""Record each seed's similar-artist contributions, then recompute affected
|
||||||
suggestions from the summed contributions — so a candidate's score is the sum of
|
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."""
|
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)
|
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, popularity)
|
||||||
|
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
for seed_mbid, _seed_name, source in seeds:
|
for seed_mbid, _seed_name, source in seeds:
|
||||||
|
|||||||
@@ -12,6 +12,21 @@ def _album_rows(conn):
|
|||||||
return cur.fetchall()
|
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):
|
def test_derives_most_recent_core_album_for_surfaced_artist(conn):
|
||||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
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
|
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):
|
def test_albums_only_excludes_singles_and_eps_by_default(conn):
|
||||||
insert_watched_artist(conn, mbid="s1", name="Seed")
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
|||||||
Reference in New Issue
Block a user