diff --git a/web/src/app/api/discover/route.test.ts b/web/src/app/api/discover/route.test.ts index 98012a3..fe95f67 100644 --- a/web/src/app/api/discover/route.test.ts +++ b/web/src/app/api/discover/route.test.ts @@ -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 + }); }); diff --git a/web/src/app/api/discover/route.ts b/web/src/app/api/discover/route.ts index b8bd503..cffa368 100644 --- a/web/src/app/api/discover/route.ts +++ b/web/src/app/api/discover/route.ts @@ -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(); + 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), diff --git a/web/src/app/design.css b/web/src/app/design.css index 8487efc..c738932 100644 --- a/web/src/app/design.css +++ b/web/src/app/design.css @@ -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; } diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index cbc2dcc..152e6a5 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -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() { ยท {s.sources.join(", ")} + {similarTo(s.seeds) ?
{similarTo(s.seeds)}
: null}
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 71ccbb8..43e2488 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -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();