diff --git a/web/src/app/api/artists/route.test.ts b/web/src/app/api/artists/route.test.ts index 0f80619..2189e54 100644 --- a/web/src/app/api/artists/route.test.ts +++ b/web/src/app/api/artists/route.test.ts @@ -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); diff --git a/web/src/app/api/artists/route.ts b/web/src/app/api/artists/route.ts index da7979a..57c7758 100644 --- a/web/src/app/api/artists/route.ts +++ b/web/src/app/api/artists/route.ts @@ -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, }); } diff --git a/web/src/app/artists/artists-client.tsx b/web/src/app/artists/artists-client.tsx index d926f7e..e4b187e 100644 --- a/web/src/app/artists/artists-client.tsx +++ b/web/src/app/artists/artists-client.tsx @@ -19,6 +19,8 @@ type Hit = { mbid: string; name: string; disambiguation: string }; export function ArtistsClient() { const [artists, setArtists] = useState([]); + const [ownedNotFollowed, setOwnedNotFollowed] = useState([]); + const [justFollowed, setJustFollowed] = useState>(new Set()); const [filter, setFilter] = useState(""); const [showAdd, setShowAdd] = useState(false); const [query, setQuery] = useState(""); @@ -28,7 +30,9 @@ export function ArtistsClient() { async function refresh() { const res = await fetch("/api/artists"); - setArtists((await res.json()).artists); + const data = await res.json(); + setArtists(data.artists); + setOwnedNotFollowed(data.ownedNotFollowed ?? []); } useEffect(() => { refresh(); @@ -67,6 +71,29 @@ export function ArtistsClient() { refresh(); // keep the modal open so several can be added; the hit flips to "Following" } + // Follow an owned-but-not-followed artist: resolve their name → MBID (the library only + // stores names), then follow with the canonical MB name. Mirrors the Last.fm/discover flow. + async function followOwned(name: string) { + const res = await fetch(`/api/mb/artists?q=${encodeURIComponent(name)}`); + const hit = res.ok ? (await res.json()).artists?.[0] : null; + if (!hit?.mbid) { + toast(`No MusicBrainz match for ${name}`); + return; + } + const r = await fetch("/api/artists", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ mbid: hit.mbid, name: hit.name }), + }); + if (r.ok || r.status === 409) { + setJustFollowed((s) => new Set(s).add(name)); // hide it immediately, even if names differ + toast(`Following ${hit.name}`); + refresh(); + } else { + toast(`Couldn't follow ${name}`); + } + } + async function toggleAuto(a: Artist) { await fetch(`/api/artists/${a.id}`, { method: "PATCH", @@ -86,6 +113,10 @@ export function ArtistsClient() { const n = filter.trim().toLowerCase(); return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists; }, [artists, filter]); + const ownedShown = useMemo( + () => ownedNotFollowed.filter((name) => !justFollowed.has(name)), + [ownedNotFollowed, justFollowed], + ); return (
@@ -148,6 +179,33 @@ export function ArtistsClient() { )} + {ownedShown.length > 0 ? ( + <> + +

+ Artists you already own music by but aren’t watching. Follow to monitor them for new + releases and upgrades. +

+
    + {ownedShown.map((name) => ( +
  • +
    +
    {name}
    +
    +
    + +
    +
  • + ))} +
+ + ) : null} + {showAdd ? (