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
merge into: yonlu:main
yonlu:main
yonlu:batch/274-276-slskd
yonlu:batch/258-272
yonlu:fix/263-slskd-transfer-lifecycle
yonlu:fix/271-slskd-search-recall
yonlu:fix/270-slskd-candidate-shape
yonlu:build/265-go-1.26
yonlu:feat/264-explore-cards
yonlu:fix/258-artifact-blob-cursor
yonlu:docs/256-claude-md-split
yonlu:batch/248-249
yonlu:fix/248-artist-metadata-sweep
yonlu:fix/247-cover-art-orphans
yonlu:fix/249-unbounded-growth
yonlu:fix/246-incremental-scan-phantoms
yonlu:fix/183-phantom-across-retire
yonlu:fix/243-model-fallback
yonlu:feat/31-play-all-shuffle-all
yonlu:fix/240-loop-operational-fixes
yonlu:docs/220-skill-check-scope
yonlu:fix/231-setter-rollback
yonlu:fix/197-duplicate-column-label
yonlu:fix/175-wizard-follows-the-library
yonlu:fix/146-stub-etxtbsy
yonlu:docs/225-fixtures-wav-tags
yonlu:feat/238-merge-leg-refresh-watch
yonlu:feat/236-autonomous-backlog-loop
yonlu:test/217-fixture-names-in-queue-selection
yonlu:fix/216-riff-parse-allocation
yonlu:fix/170-queue-header-action-names
yonlu:fix/210-nav-sheet-scroll-affordance
yonlu:docs/50-readme-landing-page
yonlu:feat/65-art-prefetch-ahead
yonlu:feat/71-more-as-a-bottom-sheet
yonlu:feat/54-native-touch-feel
yonlu:feat/67-entity-links-into-menus
yonlu:test/196-visual-tier-gates
yonlu:fix/138-ui-test-storage-leak
yonlu:fix/104-wav-tags-read
yonlu:fix/207-sheet-scroll-affordance
yonlu:fix/204-ui-visual-update-filter
yonlu:pi-agent-backlog-automation
yonlu:63-touch-model-phase-2
yonlu:63-android-touch-model
yonlu:186-touch-targets-settings
yonlu:186-touch-targets-page-header
yonlu:187-seek-bar-hit-area
yonlu:189-190-explore-correctness
yonlu:135-android-underrun-instrumentation
yonlu:51-android-small-screens
yonlu:fix/171-phone-queue-scrim
yonlu:fix/137-touch-only-affordances
yonlu:fix/154-nested-css-check
yonlu:feat/58-mini-player-progress-line
yonlu:fix/66-album-page-scrolls-as-one
yonlu:60-context-menu-action-sheet
yonlu:64-android-system-volume
yonlu:59-slim-the-mini-player
yonlu:55-queue-as-a-screen
yonlu:feat/57-drop-the-android-top-bar
yonlu:feat/62-jobs-as-a-notification
yonlu:fix/53-seek-bar-never-moves
yonlu:fix/159-android-task-app-id
yonlu:fix/52-android-activity-recreation-restarts-the-process
yonlu:fix/150-expand-button-under-the-art
yonlu:feat/42-inline-volume-and-centred-transport
yonlu:fix/156-queue-selection-fixture-order
yonlu:fix/151-fuse-the-scroll-guard-and-the-write
yonlu:fix/43-queue-panel-selection
yonlu:fix/143-top-bar-fits-its-window
yonlu:feat/27-jobs-into-settings
yonlu:feat/25-configurable-sidebar-tabs
yonlu:feat/6-global-back-forward
yonlu:fix/72-active-view-broadcast
yonlu:fix/69-page-header-action-overflow
yonlu:fix/quick-wins-batch
yonlu:fix/118-in-library-clear
yonlu:fix/61-mini-player-plain-text
yonlu:fix/68-hover-affordances-pointer
yonlu:fix/119-dev-headless-port
yonlu:fix/130-issue-claim-user
yonlu:fix/131-codegen-check-scope
yonlu:feat/28-autotag-match-on-album
yonlu:feat/17-demote-version-selector
yonlu:feat/38-ownership-visibility
yonlu:ci/115-manual-release
yonlu:feat/34-icon-language
yonlu:feat/7-full-tracklist-toggle
yonlu:fix/16-tagwriter-totals
yonlu:fix/unclaim-ca-certs
yonlu:fix/unclaim-shell
yonlu:ci/unclaim-on-close
yonlu:docs/closing-keyword
yonlu:docs/retire-stale-planning-docs
yonlu:docs/issue-driven-workflow
yonlu:integration/small-fixes
yonlu:fix/small-issue-batch
yonlu:fix/queue-toggle-state
yonlu:fix/drag-count-badge
yonlu:fix/album-card-year
yonlu:fix/album-tracklist-heading
yonlu:fix/seek-bar-clock-width
yonlu:fix/explore-art-scanner-requests
yonlu:chore/workflow-guardrails
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
655434dcaa |
test(e2e): sweep the binding names a spec calls
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. |
||
|
|
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). |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |