feat(discover): show why each item is suggested

Each suggestion now carries the followed artists (seeds) that surfaced it,
from DiscoverySeedContribution joined to the current watched roster (an
unfollowed seed drops out), highest-scoring seed first. /discover renders
"Similar to Arctic Monkeys, Coldplay +2" under each artist and album row —
both kinds key off the candidate's artistMbid, so an album inherits its
artist's seeds. web 187 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 22:07:05 +02:00
parent 18a22db7fb
commit a47d37a787
5 changed files with 67 additions and 0 deletions
+29
View File
@@ -21,4 +21,33 @@ describe("discover feed API", () => {
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]); expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]);
expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] }); expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] });
}); });
it("annotates each suggestion with the followed-artist seeds that surfaced it", async () => {
await prisma.watchedArtist.createMany({
data: [
{ mbid: "seed-am", name: "Arctic Monkeys" },
{ mbid: "seed-cp", name: "Coldplay" },
{ mbid: "seed-gone", name: "Unfollowed" },
],
});
await prisma.discoverySuggestion.createMany({
data: [
{ kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
{ kind: "album", artistMbid: "cand1", artistName: "The Strokes", rgMbid: "rgX", album: "Room on Fire",
score: 0.7, seedCount: 2, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:cand1:rgX" },
],
});
await prisma.discoverySeedContribution.createMany({
data: [
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-am", score: 0.6, sources: ["listenbrainz"] },
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-cp", score: 0.3, sources: ["listenbrainz"] },
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "no-such-seed", score: 0.9, sources: ["listenbrainz"] },
],
});
const body = await (await GET()).json();
// highest-scoring seed first; an unresolved/unfollowed seed is dropped
expect(body.artists[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]);
expect(body.albums[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // album inherits its artist's seeds
});
}); });
+25
View File
@@ -5,6 +5,30 @@ export async function GET() {
where: { status: "pending" }, where: { status: "pending" },
orderBy: [{ score: "desc" }, { createdAt: "asc" }], orderBy: [{ score: "desc" }, { createdAt: "asc" }],
}); });
// "Why suggested": the followed artists (seeds) that surfaced each candidate. A suggestion's
// candidate is its artistMbid (for both artist and album kinds — the album's artist is what
// was found similar). Join the per-seed contributions to the current watched roster so an
// unfollowed seed drops out. Highest-scoring seed first.
const candidateMbids = [...new Set(rows.map((r) => r.artistMbid))];
const contribs = candidateMbids.length
? await prisma.discoverySeedContribution.findMany({
where: { candidateMbid: { in: candidateMbids } },
orderBy: { score: "desc" },
})
: [];
const seedNames = new Map(
(await prisma.watchedArtist.findMany({ select: { mbid: true, name: true } })).map((w) => [w.mbid, w.name]),
);
const seedsByCandidate = new Map<string, string[]>();
for (const c of contribs) {
const name = seedNames.get(c.seedMbid);
if (!name) continue; // seed no longer followed
const list = seedsByCandidate.get(c.candidateMbid) ?? [];
if (!list.includes(name)) list.push(name);
seedsByCandidate.set(c.candidateMbid, list);
}
const map = (r: (typeof rows)[number]) => ({ const map = (r: (typeof rows)[number]) => ({
id: r.id, id: r.id,
artistMbid: r.artistMbid, artistMbid: r.artistMbid,
@@ -17,6 +41,7 @@ export async function GET() {
score: r.score, score: r.score,
seedCount: r.seedCount, seedCount: r.seedCount,
sources: r.sources, sources: r.sources,
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
}); });
return Response.json({ return Response.json({
artists: rows.filter((r) => r.kind === "artist").map(map), artists: rows.filter((r) => r.kind === "artist").map(map),
+1
View File
@@ -285,6 +285,7 @@ nav.contents .sep { flex: 1; }
} }
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; } .save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
.edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; } .edit-meta { display: flex; flex-direction: column; gap: 8px; width: 100%; }
.rmeta.why { font-style: italic; color: var(--graphite); margin-top: 2px; }
.sel-tools { display: inline-flex; align-items: center; gap: 10px; } .sel-tools { display: inline-flex; align-items: center; gap: 10px; }
.bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); } .bulk-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 4px 0 12px; padding: 8px 12px; border: 1px solid var(--rule); background: var(--paper-2); }
.album-card { position: relative; } .album-card { position: relative; }
+11
View File
@@ -20,8 +20,17 @@ type Suggestion = {
score: number; score: number;
seedCount: number; seedCount: number;
sources: string[]; sources: string[];
seeds: string[];
}; };
/** "Similar to Radiohead, Coldplay +2" — the followed artists that surfaced this suggestion. */
function similarTo(seeds: string[]): string | null {
if (!seeds.length) return null;
const shown = seeds.slice(0, 2).join(", ");
const extra = seeds.length - 2;
return `Similar to ${shown}${extra > 0 ? ` +${extra} more` : ""}`;
}
type Tab = "Artists" | "Albums"; type Tab = "Artists" | "Albums";
/** Only full-length albums belong in Suggested albums — hide singles/EPs and /** Only full-length albums belong in Suggested albums — hide singles/EPs and
@@ -217,6 +226,7 @@ export function DiscoverClient() {
<span className="dot">·</span> <span className="dot">·</span>
<span>{s.sources.join(", ")}</span> <span>{s.sources.join(", ")}</span>
</div> </div>
{similarTo(s.seeds) ? <div className="rmeta why">{similarTo(s.seeds)}</div> : null}
</div> </div>
<div className="actions"> <div className="actions">
<button className="btn sm" onClick={() => act(s.id, "follow")}> <button className="btn sm" onClick={() => act(s.id, "follow")}>
@@ -246,6 +256,7 @@ export function DiscoverClient() {
<span className="rmeta"> <span className="rmeta">
<span className="score">score {s.score.toFixed(2)}</span> <span className="score">score {s.score.toFixed(2)}</span>
</span> </span>
{similarTo(s.seeds) ? <span className="rmeta why">{similarTo(s.seeds)}</span> : null}
</span> </span>
</button> </button>
</div> </div>
+1
View File
@@ -16,6 +16,7 @@ beforeEach(async () => {
await prisma.monitoredRelease.deleteMany(); await prisma.monitoredRelease.deleteMany();
await prisma.watchedArtist.deleteMany(); await prisma.watchedArtist.deleteMany();
await prisma.discoverySuggestion.deleteMany(); await prisma.discoverySuggestion.deleteMany();
await prisma.discoverySeedContribution.deleteMany();
await prisma.config.deleteMany(); await prisma.config.deleteMany();
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
await prisma.unmatchedAlbum.deleteMany(); await prisma.unmatchedAlbum.deleteMany();