fix(discover): hide stale singles + replace album set on re-derive
- Client filters the row's album thumbnails to full albums only (hides singles/EPs left over from pre-albums-only sweeps). - Worker: re-deriving an artist deletes its pending album suggestions that are no longer picks (stale singles / now-owned), keeping wanted/dismissed rows, so the suggestion set self-cleans instead of accumulating. worker 246, web 189 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
@@ -36,6 +36,12 @@ function similarTo(seeds: string[]): string | null {
|
|||||||
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
|
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full-length albums only — hide singles/EPs and secondary-type releases (live/comp) that
|
||||||
|
* pre-date the albums-only derivation and still linger in the DB. */
|
||||||
|
function isFullAlbum(a: Album): boolean {
|
||||||
|
return (a.primaryType ?? "Album") === "Album" && (a.secondaryTypes?.length ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** Human badge for why an album is surfaced. */
|
/** Human badge for why an album is surfaced. */
|
||||||
function badgeFor(reason: string | null): string | null {
|
function badgeFor(reason: string | null): string | null {
|
||||||
if (reason === "newest") return "Newest";
|
if (reason === "newest") return "Newest";
|
||||||
@@ -214,7 +220,9 @@ export function DiscoverClient() {
|
|||||||
<p className="muted-note">No suggestions yet — run Discover, or follow a few artists to seed it.</p>
|
<p className="muted-note">No suggestions yet — run Discover, or follow a few artists to seed it.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="list">
|
<ul className="list">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => {
|
||||||
|
const albums = row.albums.filter(isFullAlbum);
|
||||||
|
return (
|
||||||
<li key={row.artistMbid} className="list-row disco-row">
|
<li key={row.artistMbid} className="list-row disco-row">
|
||||||
<div className="main">
|
<div className="main">
|
||||||
<div className="rtitle">
|
<div className="rtitle">
|
||||||
@@ -230,9 +238,9 @@ export function DiscoverClient() {
|
|||||||
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
|
{similarTo(row.seeds) ? <div className="rmeta why">{similarTo(row.seeds)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{row.albums.length > 0 ? (
|
{albums.length > 0 ? (
|
||||||
<div className="disco-albums">
|
<div className="disco-albums">
|
||||||
{row.albums.map((a) => (
|
{albums.map((a) => (
|
||||||
<div key={a.id} className="disco-album">
|
<div key={a.id} className="disco-album">
|
||||||
<button
|
<button
|
||||||
className="disco-thumb"
|
className="disco-thumb"
|
||||||
@@ -264,7 +272,8 @@ export function DiscoverClient() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,16 @@ def _derive_albums(conn: psycopg.Connection, browser: MbBrowser, surfaced: list[
|
|||||||
if popular is not None:
|
if popular is not None:
|
||||||
reasons[popular] = "newest,popular" if reasons.get(popular) == "newest" else "popular"
|
reasons[popular] = "newest,popular" if reasons.get(popular) == "newest" else "popular"
|
||||||
|
|
||||||
|
# Re-deriving replaces this artist's album set: drop any pending album suggestion of
|
||||||
|
# theirs that is no longer a pick (stale singles/EPs from older sweeps, or an album now
|
||||||
|
# owned), keeping wanted/dismissed rows untouched. Then upsert the current picks.
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM \"DiscoverySuggestion\" WHERE kind = 'album' AND status = 'pending' "
|
||||||
|
'AND "artistMbid" = %s AND NOT ("rgMbid" = ANY(%s))',
|
||||||
|
(artist["mbid"], list(reasons.keys())),
|
||||||
|
)
|
||||||
|
|
||||||
for rg in core: # preserve newest-first order among the chosen
|
for rg in core: # preserve newest-first order among the chosen
|
||||||
reason = reasons.get(rg.rg_mbid)
|
reason = reasons.get(rg.rg_mbid)
|
||||||
if reason is not None:
|
if reason is not None:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from lyra_worker.adapters.fakes import FakeMbBrowser, FakeSimilaritySource
|
|||||||
from lyra_worker.browser import ReleaseGroupInfo
|
from lyra_worker.browser import ReleaseGroupInfo
|
||||||
from lyra_worker.discovery import DiscoveryConfig, run_discovery
|
from lyra_worker.discovery import DiscoveryConfig, run_discovery
|
||||||
from lyra_worker.similarity.base import SimilarArtist
|
from lyra_worker.similarity.base import SimilarArtist
|
||||||
from tests.conftest import insert_monitored_release, insert_watched_artist
|
from tests.conftest import insert_discovery_suggestion, insert_monitored_release, insert_watched_artist
|
||||||
|
|
||||||
|
|
||||||
def _album_rows(conn):
|
def _album_rows(conn):
|
||||||
@@ -92,6 +92,34 @@ def test_popular_matches_by_title_when_lastfm_mbid_missing_and_skips_owned(conn)
|
|||||||
assert _reasons(conn) == {"rg-new": "newest", "rg-match": "popular"}
|
assert _reasons(conn) == {"rg-new": "newest", "rg-match": "popular"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rederiving_replaces_stale_pending_album_suggestions(conn):
|
||||||
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
|
# a stale pending album suggestion for c1 that is no longer a pick (e.g. an old single)
|
||||||
|
insert_discovery_suggestion(conn, kind="album", artist_mbid="c1", rg_mbid="rg-old-single",
|
||||||
|
album="Old Single", status="pending")
|
||||||
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
browser = FakeMbBrowser(releases={"c1": [
|
||||||
|
ReleaseGroupInfo("rg-new", "New Album", "Album", (), "2022-01-01"),
|
||||||
|
]})
|
||||||
|
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
|
||||||
|
# the stale single is gone; only the freshly-derived album remains
|
||||||
|
assert [r[1] for r in _album_rows(conn)] == ["rg-new"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rederiving_keeps_wanted_album_suggestions(conn):
|
||||||
|
insert_watched_artist(conn, mbid="s1", name="Seed")
|
||||||
|
insert_discovery_suggestion(conn, kind="album", artist_mbid="c1", rg_mbid="rg-wanted",
|
||||||
|
album="Wanted One", status="wanted")
|
||||||
|
src = FakeSimilaritySource(similar={"s1": [SimilarArtist("c1", "Cand", 0.9)]})
|
||||||
|
browser = FakeMbBrowser(releases={"c1": [
|
||||||
|
ReleaseGroupInfo("rg-new", "New Album", "Album", (), "2022-01-01"),
|
||||||
|
]})
|
||||||
|
run_discovery(conn, [src], browser, DiscoveryConfig(albums_per_artist=1))
|
||||||
|
with conn.cursor() as cur: # wanted row untouched (only pending stale rows are replaced)
|
||||||
|
cur.execute('SELECT count(*) FROM "DiscoverySuggestion" WHERE "rgMbid" = %s AND status = \'wanted\'', ("rg-wanted",))
|
||||||
|
assert cur.fetchone()[0] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_no_popularity_provider_yields_newest_only(conn):
|
def test_no_popularity_provider_yields_newest_only(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