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:
@@ -21,4 +21,33 @@ describe("discover feed API", () => {
|
||||
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]);
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,30 @@ export async function GET() {
|
||||
where: { status: "pending" },
|
||||
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]) => ({
|
||||
id: r.id,
|
||||
artistMbid: r.artistMbid,
|
||||
@@ -17,6 +41,7 @@ export async function GET() {
|
||||
score: r.score,
|
||||
seedCount: r.seedCount,
|
||||
sources: r.sources,
|
||||
seeds: seedsByCandidate.get(r.artistMbid) ?? [],
|
||||
});
|
||||
return Response.json({
|
||||
artists: rows.filter((r) => r.kind === "artist").map(map),
|
||||
|
||||
@@ -285,6 +285,7 @@ nav.contents .sep { flex: 1; }
|
||||
}
|
||||
.save-row { display: flex; align-items: center; gap: 14px; margin-top: 8px; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@@ -20,8 +20,17 @@ type Suggestion = {
|
||||
score: number;
|
||||
seedCount: number;
|
||||
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";
|
||||
|
||||
/** Only full-length albums belong in Suggested albums — hide singles/EPs and
|
||||
@@ -217,6 +226,7 @@ export function DiscoverClient() {
|
||||
<span className="dot">·</span>
|
||||
<span>{s.sources.join(", ")}</span>
|
||||
</div>
|
||||
{similarTo(s.seeds) ? <div className="rmeta why">{similarTo(s.seeds)}</div> : null}
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn sm" onClick={() => act(s.id, "follow")}>
|
||||
@@ -246,6 +256,7 @@ export function DiscoverClient() {
|
||||
<span className="rmeta">
|
||||
<span className="score">score {s.score.toFixed(2)}</span>
|
||||
</span>
|
||||
{similarTo(s.seeds) ? <span className="rmeta why">{similarTo(s.seeds)}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ beforeEach(async () => {
|
||||
await prisma.monitoredRelease.deleteMany();
|
||||
await prisma.watchedArtist.deleteMany();
|
||||
await prisma.discoverySuggestion.deleteMany();
|
||||
await prisma.discoverySeedContribution.deleteMany();
|
||||
await prisma.config.deleteMany();
|
||||
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
|
||||
await prisma.unmatchedAlbum.deleteMany();
|
||||
|
||||
Reference in New Issue
Block a user