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:
Jonathan
2026-07-14 23:39:18 +02:00
parent efdd592761
commit 194b4911c7
2 changed files with 106 additions and 10 deletions
+40 -10
View File
@@ -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: