feat(web): "In library, not followed" section on the Artists page

Adds a section at the bottom of the Artists overview listing artists you own
music by (LibraryItem) but don't follow (WatchedArtist), each with a Follow
action — so you can start monitoring artists already in your library.

- /api/artists GET now also returns `ownedNotFollowed`: distinct LibraryItem
  artist names not matching the watched roster (case-insensitive; the known
  have-via-scan name-match caveat, #18).
- Follow resolves the library name → MBID (/api/mb/artists) then POST /api/artists
  with the canonical MB name (mirrors the Last.fm/discover follow flow); a
  no-match surfaces a toast. Followed names hide immediately via a session set,
  even if the MB canonical name differs from the library name.

web 153 tests (owned-not-followed + case-insensitive match asserted), tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 12:28:29 +02:00
parent 1a612f1808
commit 4144188a28
3 changed files with 93 additions and 8 deletions
+14
View File
@@ -84,6 +84,20 @@ describe("artists collection API", () => {
expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 });
});
it("GET lists owned artists that are not followed (case-insensitive)", async () => {
// one owned + followed, one owned + not followed, one owned matching a followed name by case
await prisma.watchedArtist.create({ data: { mbid: "wf", name: "Followed Artist" } });
for (const [artist, album] of [["Followed Artist", "A"], ["Unfollowed Artist", "B"], ["followed artist", "C"]] as const) {
await prisma.libraryItem.create({
data: { artist, album, path: `/${album}`, source: "scan", format: "FLAC", qualityClass: 2 },
});
}
const body = await (await GET(listReq())).json();
expect(body.ownedNotFollowed).toContain("Unfollowed Artist");
expect(body.ownedNotFollowed).not.toContain("Followed Artist");
expect(body.ownedNotFollowed).not.toContain("followed artist"); // case-insensitive match
});
it("GET ?mbid= returns the follow state for a single artist", async () => {
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
+20 -7
View File
@@ -11,14 +11,26 @@ export async function GET(request: Request) {
return Response.json({ followed });
}
const artists = await prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" },
include: {
releases: {
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
const [artists, libraryArtists] = await Promise.all([
prisma.watchedArtist.findMany({
orderBy: { createdAt: "desc" },
include: {
releases: {
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
},
},
},
});
}),
prisma.libraryItem.findMany({ select: { artist: true }, distinct: ["artist"] }),
]);
// Artists you own music by (LibraryItem) but don't follow yet — name-matched
// case-insensitively against the watched roster (the have-via-scan name-match caveat).
const followedNames = new Set(artists.map((a) => a.name.trim().toLowerCase()));
const ownedNotFollowed = libraryArtists
.map((r) => r.artist)
.filter((name) => name.trim() && !followedNames.has(name.trim().toLowerCase()))
.sort((a, b) => a.localeCompare(b));
return Response.json({
artists: artists.map((a) => {
const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease);
@@ -33,6 +45,7 @@ export async function GET(request: Request) {
haveCount: visible.filter((r) => r.currentQualityClass !== null).length,
};
}),
ownedNotFollowed,
});
}