perf(explore): ask the disk once, prefetch once, and menu the releases

Three things on the Explore surfaces, all about not asking twice.

A portrait already on disk costs no network call. explore-view seeded
only from the library store — owned artists, which on a catalog search
is nearly none of the results — and sent everything else to
GetArtistImageURL, the resolving entry point, one await at a time.
GetArtistImagesCachedPaths asks the disk about every unresolved artist
in one call, and only what it does not answer reaches the resolver,
in parallel.

The artist page's two sections both wanted PrefetchReleases and each
called it, so the most expensive call the app makes was issued twice
for an overlapping set on a 1 req/s limiter. They are collected and
sent once on a microtask, and prefetchRequested stops the cold-artist
refetch re-asking for what it already asked for.

The release cards — most of the artist page — had no context menu at
all. They have one now on both release shapes, normalised to a
ReleaseMenuTarget when the menu opens so the union does not reach the
action handlers. It is a discriminated union rather than one nullable
field per kind because the panel is shared with the track menu: that is
what keeps aria-label moving with the target, which is the fault
cover-grid shipped. Which items appear is three different questions —
playback is gated on a local album id, not on "owned", and the request
needs a catalog MBID, so it is absent for a library-only release.

Note on the docs: the CLAUDE.md and NOTES.md prose here was
reconstructed after a mishandled `git stash --keep-index` destroyed the
uncommitted originals. One NOTES.md section is marked as incomplete
where its text could not be recovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 13:34:15 -04:00
co-authored by Claude Opus 5
parent 20fbf28f2a
commit edb13a6f39
14 changed files with 1361 additions and 70 deletions
+28
View File
@@ -157,6 +157,34 @@ would be two copies free to drift.
Bulk loads must suspend them: measured on a real import, assembly runs at
~31 rows/s with the triggers attached and ~4,700 rows/s without.
They are now **dropped and recreated** on every open rather than created
with "already exists" tolerated, because a trigger is a definition: an
existing install would otherwise keep the first one it ever got, and
these definitions are where this table's write cost is decided.
`explore_index_au` is scoped to `UPDATE OF title, artist_name, aliases`
with a `WHEN` guard on them having changed, so the common write — an
upsert whose merge rules keep every existing value — re-indexes nothing.
## The writer stall that broke playback is only half explained (2026-08-14)
The user's report — play an album, the track changes, the transport
stays paused, nothing appears in the queue, the play button does
nothing — was diagnosed on the running app: 91% of its CPU was
`BackfillLibraryDiscographies``upsertBatch`, with four of its six
workers parked in `sql.(*DB).conn` waiting for the single write
connection, while the play path did its writes inline under `q.mu` and
`p.mu`. The locking half is fixed and tested (see CLAUDE.md, "Playing a
track does not wait for the database to hear about it").
**What is not explained is why those upserts were so expensive.**
> **Recovered note, 2026-08-14.** The rest of this section was lost to a
> mishandled `git stash --keep-index` before it was ever committed; what
> survives above is verbatim, and the two arguments that followed
> "Two things argue against the obvious answer" are gone. Re-derive them
> from a profile before acting on this — do not treat the question as
> answered.
## Explore "library only" toggle was removed (2026-08-06)
The Explore UI used to have a "library only" mode toggle
@@ -0,0 +1,146 @@
# 011 — An owned artist's discography, whole and offline
**Status:** built, **not yet verified against a real library**. Lint,
the three Go test configurations, `tsc` and the Vitest suite all pass;
what has *not* happened is a run against a seeded library with real
MusicBrainz traffic, which is the only thing that can show the pass
completing an artist end to end. Do that before moving this to
`completed/`.
**Branch:** main
**Created:** 2026-08-13
**Depends on:** nothing
**Related:** 010 (owned albums, offline) — the same rate limiter, the
next layer down. 010 warms *tracklists*; this warms the *list of
albums*. Read 010's "the rate limiter is the whole design constraint"
section before building either.
---
## The problem
`BackfillLibraryDiscographies` sounds like it does this and does not.
Per owned artist, `indexOneArtist` (`searchindex.go:1910`) fetches from
ListenBrainz:
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
and both drop anything under `indexMinPopularity` (50 listens). So what
an owned artist's page shows offline is **their fifty most-listened
release groups**, not their discography. For an artist with a long tail
— early EPs, live albums, splits, anything regional — the missing rows
are precisely the ones a user who owns that artist is most likely to be
looking for.
**It is also untyped.** LB's `top-release-groups-for-artist` returns no
secondary types, so the first view of every backfilled artist has no
EP / Live / Compilation / Soundtrack distinction — the discography
renders as one undifferentiated list.
MusicBrainz's browse-by-artist has both the full list and the types,
and `BrowseReleaseGroups` (`explore.go:589`) already knows it: on
finding no secondary types on any indexed row it fires the browse **in
a goroutine, for next time**, and `AddFromCache` writes the result into
the index. So the fix is not new machinery. It is running that call
deliberately, once per owned artist, at scan time instead of
accidentally, on view, one artist at a time.
## What to build
Extend the existing post-scan pass — it is already bounded, resumable,
idempotent and ordered by owned-track count, which is the shape this
needs and the proven one in this codebase.
Per unenriched owned artist, in addition to today's LB fetches:
1. **`BrowseReleaseGroups`, paged to exhaustion.** `musicbrainz.go:318`
issues a single `Paginator{Limit: MaxLimit}` with no offset loop, so
a prolific artist is silently truncated at 100 release groups. Page
until a short response. This is the one change that makes the word
*full* honest, and it is a change to a function the interactive path
also calls — which is a win, not a risk.
2. **`SimilarArtists`.** `similar_artist_map` is not in the shipped
artifact and is filled lazily on view (`explore.go:905`), so it is
empty for every artist nobody has opened. It is one LB labs call and
already persists; folding it in here costs a request and removes the
page's last routine network dependency.
Deliberately **not** in scope: cover art for non-owned release groups.
It is roughly *RGs per artist* fetches rather than one — an order of
magnitude more requests than everything else here combined — and a
missing thumbnail degrades to a placeholder, where a missing release
group degrades to a page that is quietly wrong. Covers stay lazy.
## Four things that bite
**`discog_fetched` is one boolean and would now cover three fetches
with different failure modes.** Today it is set only if an LB fetch
returned rows (`indexOneArtist:1962`), which is the right rule for one
call and useless for three — an MB failure would either permanently
claim the artist as done or force the LB fetches to repeat. Track the
facets separately. Prefer **a new table keyed by artist MBID** over new
`explore_index` columns: `artifactimport.go:95` enumerates the columns
the artifact merge preserves, so a flag column added there is a second
place to remember, and forgetting it silently wipes every mark on the
next artifact update. A new table is also the single-file schema case
(`CREATE TABLE IF NOT EXISTS`, no migration) and needs a `datamap`
entry — `Cache` / `Swept`, since it is re-derivable.
**The `hasSecondaryTypes` heuristic re-fires forever for an artist who
has none.** An artist whose discography is entirely plain albums writes
`secondary_types = ''` on every row, so the "we must be missing them"
test is true on every visit and browses again (cheaply — 7-day
`cacheTTLEntity` — but forever). An explicit per-artist "browsed at"
mark retires the heuristic, which is a second reason for the table
above.
**Popularity is safe, and only because of the upsert rule.**
`AddFromCache` writes `Popularity: 0` for every browsed release group;
`upsertIndexConflictSQL:2180` is "highest wins", so it cannot clobber
the LB figures. The consequence is one to state rather than fix:
`TopReleaseGroupsByArtist` orders by popularity descending, so the deep
cuts this plan adds sort below the top fifty. That is the correct
order.
**The MB limiter is shared — and the priority work this needed is
done.** ~~One `NewRateLimiter()` at 1 req/s serves this,
`PrefetchReleases`, and every interactive browse~~ — 010 says that and
it is wrong on the detail: `e.mb` runs on `mbSearchLimiter`,
`NewRateLimiterBurst(3, 1)`, while the 1/s `NewRateLimiter()` at
`explore.go:84` is the *artist image* limiter. Both were shared with
background work and both are FIFO, which was the real problem.
Shipped ahead of this plan (same session it was written):
- `RateLimiter.WithBackgroundLane(perSecond)` plus
`WithBackgroundPriority(ctx)` — a marked caller yields entirely while
any interactive wait is outstanding, and is paced at MB's own 1/s
rather than the interactive burst rate. The marker is a context value
so a backfill and a detail page can call the same
`MusicBrainzClient` method and be treated differently.
- Both existing backfills mark their context, including the artist
image resolution (`GetArtistImage` takes a `ctx` now for no reason
other than carrying that marking).
- `jobs.KindCatalogEnrich` and `startBackfillJob` — both backfills are
registered, cancellable, and show progress. No job is registered
when there is nothing to do, which is every launch once the library
is covered.
So this plan inherits the lane: mark the new fetches background and add
them to the existing job's progress. What it must **not** do is treat
"a backfill is now polite" as licence to widen it without measuring —
the yield gate protects latency, not the origin's patience.
## Done when
- An owned artist's page, opened for the first time after a scan,
renders their complete typed discography with no network call —
including release groups under the popularity floor and beyond the
first 100.
- Similar artists render offline for an owned artist nobody has opened.
- An interactive browse issued while the backfill runs is not delayed
by it.
- The backfill appears in the jobs indicator and can be paused and
cancelled.
- A second run after a completed one does approximately nothing, and an
artifact update does not undo a completed one.
@@ -110,6 +110,18 @@ Sketch:
### The rate limiter is the whole design constraint
> **Update (2026-08-13): the priority half is built, and the sentence
> below is wrong on a detail.** `e.mb` runs on `mbSearchLimiter`
> (`NewRateLimiterBurst(3, 1)`); the 1 req/s `NewRateLimiter()` cited
> here is the *artist image* limiter. Both are shared and both were
> FIFO. `RateLimiter.WithBackgroundLane` + `WithBackgroundPriority(ctx)`
> now make a marked caller yield to interactive work and pace at 1/s,
> and `jobs.KindCatalogEnrich` + `startBackfillJob` give the existing
> backfills progress and cancel. **"Do not start until the priority
> question has an answer" is satisfied** — mark this backfill's context
> and register it the way `BackfillLibraryDiscographies` now is.
> `PrefetchReleases`' cap of 8 is still unrevisited.
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
`PrefetchReleases`, and every interactive browse. A backfill over a
few thousand owned albums is *hours* of wall clock at that rate — which
@@ -0,0 +1,158 @@
# 012 — What we ask the network for, and what we already had
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
configs), `tsc` and 752 Vitest tests pass; **not driven against the
real app**, so the numbers below are read off the code, not measured.
One claim in the audit was wrong and is corrected in finding 3:
`CheckLibraryMBIDs` is *not* dead — `downloadcatalog.go:152` calls it.
It has no *frontend* caller, which is what was checked and not what was
written.
**Branch:** none yet
**Created:** 2026-08-13
**Related:** 010 (owned albums offline), 011 (owned artists' discography)
---
## Scope
Every frontend call site that can reach the network, and the backend
method behind it. The question asked of each: *is there a local answer
first, and if we do go out, do we go out once for many things or many
times for one?*
## What is already right, and is the standard the rest is measured against
- **Every catalog read is index-first.** `LookupArtist`,
`LookupReleaseGroup`, `BrowseReleaseGroups`,
`TopRecordingsForArtist`, `TopReleaseGroupsForArtist`,
`SimilarArtists` and `ResolveReleaseGroupMBIDs` all answer from
`explore_index` / `similar_artist_map` and only fall through on a
miss — several kick a background fetch and return empty rather than
blocking, with a `*Ready` event to re-read.
- **Album art has the right shape:** seed from the library, one
`GetThumbnails` batch that is *cached-only by contract*, then
per-item `GetThumbnail` calls that stream in
(`explore-view.ts:1445`). Nothing waits on a batch of network
fetches.
- **Artist art has the right shape in exactly one place:**
`seedSimilarArtistImagesFromLibrary`
(`explore-artist-details.ts:1627`) — library store, then disk-only
`GetArtistImageCachedPath`, fired in parallel, zero network calls.
It is the model for finding 1.
## Finding 1 — Explore's artist images: no disk check, and serial
`explore-view.ts:1526-1546`. `loadArtistImages` seeds from
`libraryStore.cachedArtists` — i.e. **owned artists only**, which on a
catalog search is a small minority of results — and then, for every
remaining artist:
```ts
const url = await GetArtistImageURL(a.mbid); // in a for loop
```
Two faults, both fixed by patterns already in the codebase:
- **No cached-path pass.** `GetArtistImageCachedPath` and
`GetArtistImageCached` are disk-only and free, and neither is used
here. An artist whose portrait is already on disk from a previous
search still takes the resolution path.
- **`await` in a loop.** `GetArtistImageURL` is the *resolving* entry
point: on a miss it does MB artist-rels (on the 1/s artist-image
limiter) → Wikidata → Wikipedia → a Wikimedia image download. Serial
awaits mean 8 unresolved artists are 8 of those end to end, each
blocking the next, while the equivalent album-art path fires all of
them at once.
The same "resolver used where a cache check belongs" appears at
`top-results-row.ts:218` and `artist-details.ts:207` (both fire in
parallel, so only the first fault applies, and both are small-N).
**Fix:** disk-cached pass first, then network in parallel. A
`GetArtistImagesCached(mbids []string) map[string]string` mirroring
`GetThumbnails` would make it one IPC call instead of N — see finding 4
for why that is not `GetArtistImages`.
## Finding 2 — The artist page prefetches tracklists twice, or four times
`prefetchReleases` (`explore-artist-details.ts:1531`) is called from
**both** `fetchTopReleaseGroups` (:1467) and `fetchReleaseGroups`
(:1506), and `PrefetchReleases` fires up to **8** `BrowseReleases` per
call — the most expensive request the app makes (every version of a
release group, with `recordings` and `media`).
The top release groups are a subset of the discography, so the two
calls are asking about overlapping sets; the backend's
`BrowseReleasesCached` guard stops a *literal* repeat, which means the
second call spends its 8 slots on the next 8 uncached albums rather
than doing nothing. One page view is therefore up to 16 browses — and
on a cold artist, `ArtistDiscographyReady` re-runs both fetchers
(:945, :948), taking it to 32.
Worse, some of that is now provably wasted: since tag-derived
completeness landed (`dcc40b1`), **a complete, MBID-matched album opens
with no catalog call at all**, so warming its tracklist buys nothing.
**Fix, in order of value:**
1. Prefetch once, from the union of both lists, after both resolve.
2. Skip release groups that are owned and complete —
`GetAlbumCompleteness` already answers this locally.
3. Revisit the cap of 8 with the other two in place. Plan 010 flags
the same number from the other direction.
## Finding 3 — Batch helpers with no caller (one of which was live)
`CheckLibraryMBIDs`, `GetPopularityBatch` and `GetArtistImages` are
bound to the frontend and have **no call site in `frontend/src`**.
They are the batch shapes a future N+1 would want, and their existence
is presumably why the N+1s above were not noticed.
**`CheckLibraryMBIDs` is not dead** — `downloadcatalog.go:152` calls
it from Go, one MBID at a time. Deleting it broke the build, which is
how that was found; it is kept, with a comment saying who its consumer
is. Read "no frontend caller" as exactly that, and grep both languages
before removing a bound method.
Note `GetArtistImages` is not the helper finding 1 needs: it resolves
names through `libMBID.AllArtistMBIDs()`, so it only answers for
artists **in the library** — the exact set Explore's search results are
not. Either give it an MBID-keyed sibling or replace it.
Also bound with no caller, and worth a separate decision about whether
the feature is live at all: `GetTrackLyrics`, `GenerateMix`,
`GetArtistPlayCount`, `GetLibrarySimilarArtists`,
`GetCandidateThumbnail`.
## Finding 4 — One more background pass with no job and no priority
`BackfillLibraryLyrics` (`lyrics.go:129`) is a bare `go` call: bounded
by passes and per-track (LRCLIB has no batch endpoint, so per-track is
correct), but with no `jobs` registration and no
`WithBackgroundPriority` marking. It runs on its own limiter, so it
starves nothing today — but it is invisible and uncancellable, which is
the gap 011 just closed for the other two backfills.
## Not a finding, recorded so it is not re-audited
- `GetThumbnails` returning only cached entries is deliberate and
documented; the per-item follow-up is the streaming half, not an
N+1.
- `explore-artist-details` calling both `TopReleaseGroupsForArtist`
(50) and `BrowseReleaseGroups` (200) reads overlapping rows from the
index twice, but both are local queries feeding two different
sections. Not worth merging.
- The newest components (`home-view`, `catalog-scope-notice`,
`page-header`, the notification stack, `shortcuts-overlay`) make no
network calls at all. `home-view` is `GetShelves` + `GetAlbumTracks`,
both local.
## Done when
- An Explore search with no owned artists in it makes zero artist-image
network calls for portraits already on disk, and resolves the rest
concurrently.
- Opening an artist page issues one prefetch pass, over albums that are
not already fully owned.
- The bound-but-uncalled batch helpers are either wired or removed.