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
+59 -1
View File
@@ -19,6 +19,8 @@ type Hit = { mbid: string; name: string; disambiguation: string };
export function ArtistsClient() {
const [artists, setArtists] = useState<Artist[]>([]);
const [ownedNotFollowed, setOwnedNotFollowed] = useState<string[]>([]);
const [justFollowed, setJustFollowed] = useState<Set<string>>(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 (
<div>
@@ -148,6 +179,33 @@ export function ArtistsClient() {
</ul>
)}
{ownedShown.length > 0 ? (
<>
<SectionHeader
title="In library, not followed"
note={`${ownedShown.length}`}
/>
<p className="muted-note">
Artists you already own music by but arent 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 ? (
<Modal open onClose={closeAdd} title="Add artist">
<form className="request-form" style={{ margin: "0 0 6px" }} onSubmit={search}>