202fc0db3c
/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>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
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, 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({
|
|
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;
|
|
const list = seedsByCandidate.get(c.candidateMbid) ?? [];
|
|
if (!list.includes(name)) list.push(name);
|
|
seedsByCandidate.set(c.candidateMbid, list);
|
|
}
|
|
|
|
// 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<string, Row>();
|
|
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 });
|
|
}
|