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:
@@ -84,6 +84,20 @@ describe("artists collection API", () => {
|
|||||||
expect(after).toMatchObject({ showAllTypes: true, releaseCount: 2, monitoredCount: 2, haveCount: 2 });
|
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 () => {
|
it("GET ?mbid= returns the follow state for a single artist", async () => {
|
||||||
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
|
await prisma.watchedArtist.create({ data: { mbid: "followed-1", name: "Followed" } });
|
||||||
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
|
expect((await (await GET(listReq("?mbid=followed-1"))).json()).followed).toBe(true);
|
||||||
|
|||||||
@@ -11,14 +11,26 @@ export async function GET(request: Request) {
|
|||||||
return Response.json({ followed });
|
return Response.json({ followed });
|
||||||
}
|
}
|
||||||
|
|
||||||
const artists = await prisma.watchedArtist.findMany({
|
const [artists, libraryArtists] = await Promise.all([
|
||||||
|
prisma.watchedArtist.findMany({
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
releases: {
|
releases: {
|
||||||
select: { monitored: true, currentQualityClass: true, primaryType: true, secondaryTypes: true },
|
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({
|
return Response.json({
|
||||||
artists: artists.map((a) => {
|
artists: artists.map((a) => {
|
||||||
const visible = a.showAllTypes ? a.releases : a.releases.filter(isCoreRelease);
|
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,
|
haveCount: visible.filter((r) => r.currentQualityClass !== null).length,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
ownedNotFollowed,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type Hit = { mbid: string; name: string; disambiguation: string };
|
|||||||
|
|
||||||
export function ArtistsClient() {
|
export function ArtistsClient() {
|
||||||
const [artists, setArtists] = useState<Artist[]>([]);
|
const [artists, setArtists] = useState<Artist[]>([]);
|
||||||
|
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
|
||||||
|
const [justFollowed, setJustFollowed] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -28,7 +30,9 @@ export function ArtistsClient() {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
const res = await fetch("/api/artists");
|
const res = await fetch("/api/artists");
|
||||||
setArtists((await res.json()).artists);
|
const data = await res.json();
|
||||||
|
setArtists(data.artists);
|
||||||
|
setOwnedNotFollowed(data.ownedNotFollowed ?? []);
|
||||||
}
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
@@ -67,6 +71,29 @@ export function ArtistsClient() {
|
|||||||
refresh(); // keep the modal open so several can be added; the hit flips to "Following"
|
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) {
|
async function toggleAuto(a: Artist) {
|
||||||
await fetch(`/api/artists/${a.id}`, {
|
await fetch(`/api/artists/${a.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -86,6 +113,10 @@ export function ArtistsClient() {
|
|||||||
const n = filter.trim().toLowerCase();
|
const n = filter.trim().toLowerCase();
|
||||||
return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
|
return n ? artists.filter((a) => a.name.toLowerCase().includes(n)) : artists;
|
||||||
}, [artists, filter]);
|
}, [artists, filter]);
|
||||||
|
const ownedShown = useMemo(
|
||||||
|
() => ownedNotFollowed.filter((name) => !justFollowed.has(name)),
|
||||||
|
[ownedNotFollowed, justFollowed],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -148,6 +179,33 @@ export function ArtistsClient() {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{ownedShown.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="In library, not followed"
|
||||||
|
note={`${ownedShown.length}`}
|
||||||
|
/>
|
||||||
|
<p className="muted-note">
|
||||||
|
Artists you already own music by but aren’t watching. Follow to monitor them for new
|
||||||
|
releases and upgrades.
|
||||||
|
</p>
|
||||||
|
<ul className="list">
|
||||||
|
{ownedShown.map((name) => (
|
||||||
|
<li key={name} className="list-row">
|
||||||
|
<div className="main">
|
||||||
|
<div className="rtitle">{name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button className="btn sm" onClick={() => followOwned(name)}>
|
||||||
|
Follow
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{showAdd ? (
|
{showAdd ? (
|
||||||
<Modal open onClose={closeAdd} title="Add artist">
|
<Modal open onClose={closeAdd} title="Add artist">
|
||||||
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>
|
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>
|
||||||
|
|||||||
Reference in New Issue
Block a user