feat(discover): group suggestions into artist rows

/api/discover now returns rows[] grouped by artist, each carrying its album
suggestions (with albumReason) + the seed names, ordered by score. Album
suggestions whose artist has no pending artist suggestion still yield a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 23:41:56 +02:00
parent e8fd4bb0a9
commit 202fc0db3c
2 changed files with 108 additions and 49 deletions
+36 -26
View File
@@ -3,51 +3,61 @@ import { prisma } from "@/lib/db";
import { GET } from "./route"; import { GET } from "./route";
describe("discover feed API", () => { describe("discover feed API", () => {
it("returns pending artists and albums split, highest score first, excluding non-pending", async () => { it("groups pending suggestions into artist rows with their albums, highest score first", async () => {
await prisma.discoverySuggestion.createMany({ await prisma.discoverySuggestion.createMany({
data: [ data: [
{ kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1, { kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" }, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" },
{ kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2, { kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" }, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" },
{ kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec", { kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-new", album: "Latest",
score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" }, firstReleaseDate: "2022-01-01", albumReason: "newest", score: 0.9, seedCount: 2,
{ kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-new" },
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" }, { kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-hit", album: "Big Hit",
firstReleaseDate: "2018-01-01", albumReason: "popular", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-hit" },
{ kind: "artist", artistMbid: "a3", artistName: "Gone", score: 5, seedCount: 1,
sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a3:" },
], ],
}); });
const body = await (await GET()).json(); const body = await (await GET()).json();
expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded expect(body.rows.map((r: { artistName: string }) => r.artistName)).toEqual(["High", "Low"]); // dismissed excluded, score desc
expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]); const high = body.rows[0];
expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] }); expect(high).toMatchObject({ id: expect.any(String), artistMbid: "a2", seedCount: 2 });
// albums sorted newest-first, each carrying its reason
expect(high.albums.map((a: { album: string; albumReason: string }) => [a.album, a.albumReason]))
.toEqual([["Latest", "newest"], ["Big Hit", "popular"]]);
expect(body.rows[1].albums).toEqual([]); // "Low" has no album suggestions
}); });
it("annotates each suggestion with the followed-artist seeds that surfaced it", async () => { it("yields a row for an album whose artist has no pending artist suggestion", async () => {
await prisma.watchedArtist.createMany({ await prisma.discoverySuggestion.create({
data: [ data: { kind: "album", artistMbid: "orphan", artistName: "Orphan Band", rgMbid: "rgX",
{ mbid: "seed-am", name: "Arctic Monkeys" }, album: "Stray", albumReason: "newest", score: 0.4, seedCount: 1, sources: ["lastfm"],
{ mbid: "seed-cp", name: "Coldplay" }, secondaryTypes: [], dedupeKey: "album:orphan:rgX" },
{ mbid: "seed-gone", name: "Unfollowed" },
],
}); });
await prisma.discoverySuggestion.createMany({ const body = await (await GET()).json();
data: [ expect(body.rows).toHaveLength(1);
{ kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2, expect(body.rows[0]).toMatchObject({ id: null, artistName: "Orphan Band" });
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" }, expect(body.rows[0].albums[0].album).toBe("Stray");
{ 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" },
], it("annotates rows with the followed-artist seeds that surfaced them", async () => {
await prisma.watchedArtist.createMany({
data: [{ mbid: "seed-am", name: "Arctic Monkeys" }, { mbid: "seed-cp", name: "Coldplay" }],
});
await prisma.discoverySuggestion.create({
data: { kind: "artist", artistMbid: "cand1", artistName: "The Strokes", score: 0.9, seedCount: 2,
sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:cand1:" },
}); });
await prisma.discoverySeedContribution.createMany({ await prisma.discoverySeedContribution.createMany({
data: [ data: [
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "seed-am", score: 0.6, sources: ["listenbrainz"] }, { 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: "seed-cp", score: 0.3, sources: ["listenbrainz"] },
{ candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "no-such-seed", score: 0.9, sources: ["listenbrainz"] }, { candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "gone", score: 0.9, sources: ["listenbrainz"] },
], ],
}); });
const body = await (await GET()).json(); const body = await (await GET()).json();
// highest-scoring seed first; an unresolved/unfollowed seed is dropped expect(body.rows[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // score desc, unfollowed 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
}); });
}); });
+72 -23
View File
@@ -1,15 +1,35 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
// One Discover row = a suggested artist plus that artist's suggested albums (newest /
// most-played), since album suggestions are always derived from a surfaced artist. Pending
// suggestions are grouped by artistMbid so the UI shows a single artist-centric row.
type Row = {
id: string | null; // the artist suggestion id (Follow/Dismiss); null if only albums exist
artistMbid: string;
artistName: string;
score: number;
seedCount: number;
sources: string[];
seeds: string[];
albums: {
id: string;
rgMbid: string | null;
album: string | null;
primaryType: string | null;
secondaryTypes: string[];
firstReleaseDate: string | null;
albumReason: string | null;
}[];
};
export async function GET() { export async function GET() {
const rows = await prisma.discoverySuggestion.findMany({ const rows = await prisma.discoverySuggestion.findMany({
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 // "Why suggested": the followed artists (seeds) that surfaced each candidate, from the
// candidate is its artistMbid (for both artist and album kinds — the album's artist is what // per-seed contributions joined to the current watched roster (an unfollowed seed drops out).
// 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 candidateMbids = [...new Set(rows.map((r) => r.artistMbid))];
const contribs = candidateMbids.length const contribs = candidateMbids.length
? await prisma.discoverySeedContribution.findMany({ ? await prisma.discoverySeedContribution.findMany({
@@ -23,28 +43,57 @@ export async function GET() {
const seedsByCandidate = new Map<string, string[]>(); const seedsByCandidate = new Map<string, string[]>();
for (const c of contribs) { for (const c of contribs) {
const name = seedNames.get(c.seedMbid); const name = seedNames.get(c.seedMbid);
if (!name) continue; // seed no longer followed if (!name) continue;
const list = seedsByCandidate.get(c.candidateMbid) ?? []; const list = seedsByCandidate.get(c.candidateMbid) ?? [];
if (!list.includes(name)) list.push(name); if (!list.includes(name)) list.push(name);
seedsByCandidate.set(c.candidateMbid, list); seedsByCandidate.set(c.candidateMbid, list);
} }
const map = (r: (typeof rows)[number]) => ({ // Group by artist. An artist suggestion sets the row header; album suggestions fill albums[].
id: r.id, // An album whose artist has no pending artist suggestion still yields a header (never orphaned).
artistMbid: r.artistMbid, const byArtist = new Map<string, Row>();
artistName: r.artistName, const ensure = (r: (typeof rows)[number]): Row => {
rgMbid: r.rgMbid, let row = byArtist.get(r.artistMbid);
album: r.album, if (!row) {
primaryType: r.primaryType, row = {
secondaryTypes: r.secondaryTypes, id: null,
firstReleaseDate: r.firstReleaseDate, artistMbid: r.artistMbid,
score: r.score, artistName: r.artistName,
seedCount: r.seedCount, score: r.score,
sources: r.sources, seedCount: r.seedCount,
seeds: seedsByCandidate.get(r.artistMbid) ?? [], sources: r.sources,
}); seeds: seedsByCandidate.get(r.artistMbid) ?? [],
return Response.json({ albums: [],
artists: rows.filter((r) => r.kind === "artist").map(map), };
albums: rows.filter((r) => r.kind === "album").map(map), byArtist.set(r.artistMbid, row);
}); }
return row;
};
for (const r of rows) {
const row = ensure(r);
if (r.kind === "artist") {
row.id = r.id;
row.score = r.score;
row.seedCount = r.seedCount;
row.sources = r.sources;
} else {
row.albums.push({
id: r.id,
rgMbid: r.rgMbid,
album: r.album,
primaryType: r.primaryType,
secondaryTypes: r.secondaryTypes,
firstReleaseDate: r.firstReleaseDate,
albumReason: r.albumReason,
});
row.score = Math.max(row.score, r.score); // album-only row scores by its best album
}
}
for (const row of byArtist.values()) {
row.albums.sort((a, b) => (b.firstReleaseDate ?? "").localeCompare(a.firstReleaseDate ?? ""));
}
const out = [...byArtist.values()].sort((a, b) => b.score - a.score);
return Response.json({ rows: out });
} }