From 202fc0db3c6c62615a4e44749c00b96170735a18 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 23:41:56 +0200 Subject: [PATCH] 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) --- web/src/app/api/discover/route.test.ts | 62 ++++++++++------- web/src/app/api/discover/route.ts | 95 +++++++++++++++++++------- 2 files changed, 108 insertions(+), 49 deletions(-) diff --git a/web/src/app/api/discover/route.test.ts b/web/src/app/api/discover/route.test.ts index fe95f67..3cb5f8e 100644 --- a/web/src/app/api/discover/route.test.ts +++ b/web/src/app/api/discover/route.test.ts @@ -3,51 +3,61 @@ import { prisma } from "@/lib/db"; import { GET } from "./route"; 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({ data: [ { kind: "artist", artistMbid: "a1", artistName: "Low", score: 0.2, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a1:" }, { kind: "artist", artistMbid: "a2", artistName: "High", score: 0.9, seedCount: 2, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "artist:a2:" }, - { kind: "album", artistMbid: "a3", artistName: "Band", rgMbid: "rg1", album: "Rec", - score: 0.5, seedCount: 1, sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a3:rg1" }, - { kind: "artist", artistMbid: "a4", artistName: "Gone", score: 5, seedCount: 1, - sources: ["listenbrainz"], secondaryTypes: [], status: "dismissed", dedupeKey: "artist:a4:" }, + { kind: "album", artistMbid: "a2", artistName: "High", rgMbid: "rg-new", album: "Latest", + firstReleaseDate: "2022-01-01", albumReason: "newest", score: 0.9, seedCount: 2, + sources: ["listenbrainz"], secondaryTypes: [], dedupeKey: "album:a2:rg-new" }, + { 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(); - expect(body.artists.map((a: any) => a.artistName)).toEqual(["High", "Low"]); // dismissed excluded - expect(body.albums.map((a: any) => a.album)).toEqual(["Rec"]); - expect(body.artists[0]).toMatchObject({ artistMbid: "a2", seedCount: 2, sources: ["listenbrainz"] }); + expect(body.rows.map((r: { artistName: string }) => r.artistName)).toEqual(["High", "Low"]); // dismissed excluded, score desc + const high = body.rows[0]; + 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 () => { - await prisma.watchedArtist.createMany({ - data: [ - { mbid: "seed-am", name: "Arctic Monkeys" }, - { mbid: "seed-cp", name: "Coldplay" }, - { mbid: "seed-gone", name: "Unfollowed" }, - ], + it("yields a row for an album whose artist has no pending artist suggestion", async () => { + await prisma.discoverySuggestion.create({ + data: { kind: "album", artistMbid: "orphan", artistName: "Orphan Band", rgMbid: "rgX", + album: "Stray", albumReason: "newest", score: 0.4, seedCount: 1, sources: ["lastfm"], + secondaryTypes: [], dedupeKey: "album:orphan:rgX" }, }); - 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" }, - ], + const body = await (await GET()).json(); + expect(body.rows).toHaveLength(1); + expect(body.rows[0]).toMatchObject({ id: null, artistName: "Orphan Band" }); + expect(body.rows[0].albums[0].album).toBe("Stray"); + }); + + 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({ 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"] }, + { candidateMbid: "cand1", candidateName: "The Strokes", seedMbid: "gone", 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 + expect(body.rows[0].seeds).toEqual(["Arctic Monkeys", "Coldplay"]); // score desc, unfollowed dropped }); }); diff --git a/web/src/app/api/discover/route.ts b/web/src/app/api/discover/route.ts index cffa368..eba1682 100644 --- a/web/src/app/api/discover/route.ts +++ b/web/src/app/api/discover/route.ts @@ -1,15 +1,35 @@ 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() { const rows = await prisma.discoverySuggestion.findMany({ 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. + // "Why suggested": the followed artists (seeds) that surfaced each candidate, from the + // per-seed contributions joined to the current watched roster (an unfollowed seed drops out). const candidateMbids = [...new Set(rows.map((r) => r.artistMbid))]; const contribs = candidateMbids.length ? await prisma.discoverySeedContribution.findMany({ @@ -23,28 +43,57 @@ export async function GET() { const seedsByCandidate = new Map(); for (const c of contribs) { const name = seedNames.get(c.seedMbid); - if (!name) continue; // seed no longer followed + if (!name) continue; 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, - artistName: r.artistName, - rgMbid: r.rgMbid, - album: r.album, - primaryType: r.primaryType, - secondaryTypes: r.secondaryTypes, - firstReleaseDate: r.firstReleaseDate, - 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), - albums: rows.filter((r) => r.kind === "album").map(map), - }); + // Group by artist. An artist suggestion sets the row header; album suggestions fill albums[]. + // An album whose artist has no pending artist suggestion still yields a header (never orphaned). + const byArtist = new Map(); + const ensure = (r: (typeof rows)[number]): Row => { + let row = byArtist.get(r.artistMbid); + if (!row) { + row = { + id: null, + artistMbid: r.artistMbid, + artistName: r.artistName, + score: r.score, + seedCount: r.seedCount, + sources: r.sources, + seeds: seedsByCandidate.get(r.artistMbid) ?? [], + albums: [], + }; + 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 }); }