Scoped library payloads: load the track list when a view needs it #287

Open
yonlu wants to merge 10 commits from perf/279-284-scoped-library-payloads into main
pull from: perf/279-284-scoped-library-payloads
10 Commits
Author SHA1 Message Date
yonlu 655434dcaa test(e2e): sweep the binding names a spec calls
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 7m25s
CI / e2e (pull_request) Successful in 12m52s
Four specs still called `library.Library.GetTracks` after #281 replaced
it, and the suite reported twenty failures across transport, bottom-bar
and reduced-motion specs — none of which mention the library list. A
binding call carries only a method id, so a stale name fails at runtime
in whichever spec happens to call it.

`binding-names.spec.ts` reads every spec and harness file, extracts the
names they pass as strings, and checks them against the map derived
from the generated bindings tree. It asserts first that it read
something: a sweep over an empty glob passes and proves nothing.

It immediately found a second thing: `play-count.spec.ts` asserted that
a play refetched no collection by matching a `GetAll` prefix, which
matches exactly one real name (`GetAllLibrariesWithTrackCounts`) — so
the assertion held whatever the app refetched. It names the four
collection bindings now.
2026-10-06 02:36:28 -04:00
yonlu 2ed9e725e3 docs(planning): what the library payloads cost, measured
Plan 023 (#279–#284): the before/after table on 50 000 tracks, and the
six things worth keeping — including two measurements that pointed away
from where the work had been (mmap is a bound, and the remaining second
is Wails encoding every result twice).
2026-10-06 01:59:57 -04:00
yonlu 5b236ca5af perf(library): patch what changed instead of reloading the list
A retag named the one file it rewrote and the store threw the whole
list away anyway: 20.5 MB across the IPC at 26 138 tracks to pick up one
new title. A soft rescan of an unchanged library did the same, though
its metrics say zero added, zero updated and zero removed.

- `TrackMetadataChanged` with a `filePath` re-reads that one row
  through `trackCache` and splices it in, replacing the array so the
  memoized filter and sort caches notice. The summaries are still
  refetched: a retag can move a track between albums. A failed patch
  falls back to the full reload, because the answer that cannot be
  wrong is the one to keep.
- A batch write names no paths (it can be thousands) and still reloads.
- `LibraryScanComplete` reads its own metrics and reloads only when the
  scan changed something; a payload of an unknown shape is treated as a
  change, so nothing can leave a stale list on screen.

Closes #282
2026-10-06 01:59:17 -04:00
yonlu bce8d27370 fix(explore): stop asking ListenBrainz after it refuses this client
Every popularity request answered 401 on 2026-10-05: 399 in one minute
of a real library's discography backfill, one rate-limited request per
artist to be told the same thing, each with its own log line.

A token is a property of the installation, not of the artist, so the
first 401 is the answer for the rest of that client's life. The refusal
is latched, logged once at warning level, and returned as
ErrListenBrainzUnauthorized — separate from the generic HTTP error
because it is the one failure no retry can fix. A 500 still does not
latch, so a transient failure leaves the artist unmarked and the next
run asks again.

The backfill also stops feeding artists once the client is refused: the
rest of the pass would be the same 401, and the artists stay unmarked
for a run that has a token.

Closes #284
2026-10-06 01:55:15 -04:00
yonlu c9b3048a20 perf(database): bound the read pool's mapping and cache by measurement
#283 read as "64 MB of mmap and up to 32 MB of page cache per pool",
and it is — but on a 50 000-track library neither is what the
process's memory is: the RSS of the database mapping is 46 MB whether
the bound is 64 MB or 16 MB, because a mapping costs what the working
set touches. Quartering it and halving the page cache moved no query
either: 1 172 → 1 197 ms for the whole track list, 106 → 124 ms for the
album list, 6 → 8 ms for an FTS search, all inside the run-to-run
spread.

Kept, because a bound that costs nothing measurable is worth having on
a phone: five read connections' worth of mapping is 320 MB of address
space at 64 MB each against 80 MB here, and #52 was a low-memory kill.

Closes #283
2026-10-06 01:51:56 -04:00
yonlu 620151aa41 perf(library): load a collection when a view needs it, not at startup
All four collections were fetched at DOMContentLoaded and refetched on
every invalidation, whichever view was showing. On a 26 138-track
library the track list was 20.5 MB of that, and encoding it cost the
backend ~170 MB of transient allocation — paid by someone looking at
Home, which draws none of it.

- The store warms only albums, artists and genres, on idle after first
  paint: 1.6 MB together, and what made those views instant.
- A nav item prefetches on hover and on keyboard focus, which is the
  ~100 ms before the click that a cold open would otherwise wait.
- An invalidation refetches what something had loaded, and nothing
  else — a scan no longer loads the track list of a library whose
  Tracks view nobody has opened.
- `index.html`'s first-paint `<track-list>` is `view-hidden`, and
  `index.ts` no longer activates it: it is markup, not a decision about
  which view the launch lands on, and activating it was what fetched
  the whole list for a landing on Home. The track list itself loads on
  view activation, which the shell drives.

Measured on 50 000 tracks: backend RSS at rest 543 → 296 MB, peak 571 →
296 MB, Go heap held 361 → 125 MB, JS heap 31.8 → 18 MB, binding bytes
at rest 35.9 → 12.1 MB, heap after a browse 36.5 → 22.7 MB. Tracks
first open 26 ms; slowest view open 57 ms.

Closes #280
2026-10-06 01:43:00 -04:00
yonlu 3d9828b847 perf(library): hand the batch dialog the rows the view already has
Fetching whole tracks back for a dialog that reads only fields the
rows in hand already carry cost 3 s and 700 ms of blocked main thread
on a "select all" over 50 000 tracks — the regression #281 introduced
by dropping four fields from the list.

`showBatchTrackDetails` takes the rows; the Tracks view passes its
own, and the views that hold no library rows (Explore, the queue,
playlists) keep the path form over `trackCache`. The batch view's
merged-fields extractors read only list fields already, and a cover
tier the list does not carry falls back to the one it does.

Measured on 50 000 tracks: 50.4 ms, from 87.9 ms before #281.

Refs #281
2026-10-05 23:55:54 -04:00
yonlu 7a1412383f perf(library): send the track list as a dictionary-encoded column table
GetTracks answered with one JSON object per track: 20.5 MB at 26k
tracks, ~350 bytes a row of repeated key names, four cover URLs that
are identical across an album, and the album, artist and genre strings
repeated on every track. Encoding it was ~170 MB of transient Go
allocation, and parsing it the WebView's memory peak.

GetTrackTable replaces it: one array per column, every repeated
string stored once and sent as an index, genre lists interned, and only
the fields the Tracks view reads. LastPlayed and the three larger cover
tiers are left to the details dialog, which now fetches whole tracks by
path. Each row still goes through trackFromRow, so this is an encoding
of the one projection, not a second one. 167 bytes a track against 929
in the test library; a Go test holds it under a quarter of the object
encoding, and a Vitest test reads the Go columns and the generated
Track interface and fails if they drift.

An empty library is now an empty table rather than the "no tracks in
library" error the old binding returned.

Closes #281
2026-10-05 23:30:37 -04:00
yonlu 4a9fe1d207 fix(library): open track details by path, not from the whole library
Track details from the queue, a playlist or a smart playlist looked
the track up in libraryStore's whole-library array, and returned
silently when it had not loaded, so the menu item did nothing. That
dependency is also why every track was fetched eagerly at startup
(20.5 MB at 26k tracks).

GetTracksByPaths answers for the paths in hand, and trackCache holds
the answers: bounded, coalesced into one call per task, and forgetting
what the retag, removal and scan events name. Every opener, and
track-details' own refresh after a save, go through it. A source sweep
pins the whole-library array to the store and the Tracks view.

Closes #279
2026-10-05 23:01:37 -04:00
yonlu 3cddf70c4d perf(smart-playlist): suggest rule values for what was typed
The rule editor's value box built its suggestions from libraryStore's
whole-library arrays, which made it one more reason to load every
track at startup, and left it empty when they had not loaded.

SuggestSmartPlaylistValues answers with up to 50 distinct values of a
field that contain the typed text, from the same joined row the rules
match against, so a suggestion is always something a rule can match.
yj-combobox announces its filter text so the editor can ask, debounced
and cached per field and text.

Refs #279
2026-10-05 22:59:24 -04:00