Files
yellowjacket/frontend/src/store/library-store.ts
T
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

753 lines
24 KiB
TypeScript

import { EventsOn } from '@runtime/runtime';
import {
GetTrackTable,
GetAlbums,
GetArtists,
GetGenres,
GetAlbumsByArtist,
GetAllLibrariesWithTrackCounts,
} from '@go/library/library.js';
import type * as library from '@go/library/models.js';
import { list } from '@utils/binding';
import { decodeTrackTable, type ListTrack } from '@utils/track-table';
import { Events } from '../events';
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
type Subscriber = () => void;
/** Minimum album card width in CSS pixels. */
const COVER_SIZE_MIN = 100;
/** Maximum album card width in CSS pixels. */
const COVER_SIZE_MAX = 350;
/** Default album card width in CSS pixels. */
const COVER_SIZE_DEFAULT = 176;
/** localStorage key for persisted cover size. */
const COVER_SIZE_KEY = 'cover-grid-size';
/**
* How long the small-collection warm-up will wait for idle before
* running anyway. It is speculative, but a busy main thread must not
* mean Albums is slow to open — the timeout is the promise that it is
* only ever deferred, never skipped.
*/
const WARM_IDLE_TIMEOUT_MS = 3_000;
class LibraryStore {
private tracks: ListTrack[] | null = null;
private albums: library.Album[] | null = null;
private artists: library.Artist[] | null = null;
private genres: library.GenreWithCount[] | null = null;
private libraries: library.Info[] | null = null;
private selectedLibraryIdValue: number | null = null;
private tracksLoading = false;
private albumsLoading = false;
private artistsLoading = false;
private genresLoading = false;
private coverSizeValue: number = COVER_SIZE_DEFAULT;
private scrollPositions: Record<ViewName, number> = {
tracks: 0,
albums: 0,
artists: 0,
genres: 0,
};
private subscribers = new Set<Subscriber>();
private notifyScheduled = false;
/**
* The one in-flight request per collection.
*
* Concurrent readers used to be deduplicated by deriving a promise
* from subscriber notifications, which never settled when the fetch
* *failed* (errors.M1) — the waiter tested for "loaded and not
* loading", and a failed fetch is neither. Holding the request
* itself makes every waiter settle exactly as the fetch did.
*/
private inFlight = new Map<ViewName, Promise<unknown>>();
/**
* Monotonic counter incremented only when actual data changes
* (not loading flag transitions). Subscribers can compare against
* a saved value to skip requestUpdate when only loading state toggled.
*/
private changeGen = 0;
/**
* Incremented only by invalidation — a scan, a retag, or a library
* filter switch. A fetch captures it at request time and discards
* its answer if it no longer matches, which is what stops library
* A's tracks being cached while library B is selected (errors.C4).
* Separate from `changeGen`, which any collection landing bumps.
*/
private cacheGen = 0;
constructor() {
EventsOn(Events.LibraryScanComplete, () => {
this.invalidate();
});
EventsOn(Events.LibraryRemoved, () => {
this.libraries = null;
this.invalidate();
});
EventsOn(Events.LibraryAdded, () => {
this.libraries = null;
this.changeGen++;
this.notify();
});
EventsOn(Events.LibraryRenamed, () => {
this.libraries = null;
this.changeGen++;
this.notify();
});
EventsOn(Events.TrackMetadataChanged, () => {
this.invalidate();
});
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
this.applyPlayCount(payload);
});
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
this.applyTracksRemoved(payload);
});
this.loadCoverSize();
this.warmSmallCollectionsOnIdle();
}
/**
* Warm the three small collections once the app is idle after first
* paint, and deliberately not the tracks (#280).
*
* All four used to be fetched together at `DOMContentLoaded`,
* 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
* needs none of it. Measured on 50 000 tracks: 543 MB of backend RSS
* at rest before, 296 MB after, and 12.1 MB of binding bytes instead
* of 35.9.
*
* Albums, artists and genres are 1.6 MB together, so they are still
* fetched ahead of the click — that is what made those views
* instant. Tracks are 12 MB at 50 000 and are fetched by the view
* that draws them, or by `prefetch` from a hover.
*
* Idle rather than immediate: this is speculative, so it must not
* compete with the first paint.
*/
private warmSmallCollectionsOnIdle(): void {
const warm = () => {
const logged = this.failureReporter();
void this.getAlbums().catch(logged('albums'));
void this.getArtists().catch(logged('artists'));
void this.getGenres().catch(logged('genres'));
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(warm, { timeout: WARM_IDLE_TIMEOUT_MS });
} else {
setTimeout(warm, 0);
}
}
/**
* Start loading what a view will need, without waiting for it.
*
* A nav item calls this on hover or focus: that is the ~100 ms
* before the click, and it is what #280 trades for not paying for
* every collection at startup whether or not anyone goes there.
*
* A view this store holds nothing for is ignored rather than an
* error — it is a hint, and a hint about Home is not a mistake.
* The failure is swallowed here because the view that wanted the
* data reports it: this is the same request, already deduplicated
* by `inFlight`, and it has no caller to reject to.
*/
prefetch(view: string): void {
const started = (() => {
switch (view) {
case 'tracks':
return this.getTracks();
case 'albums':
return this.getAlbums();
case 'artists':
return this.getArtists();
case 'genres':
return this.getGenres();
default:
return null;
}
})();
void started?.catch(() => undefined);
}
private failureReporter(): (what: string) => (err: unknown) => void {
return (what) => (err) =>
console.error(`library: could not load ${what}`, err);
}
// ===================================================================
// DATA ACCESS
// Returns cached data or fetches from backend on first access.
// ===================================================================
/** Synchronous access to cached artists (null if not yet loaded). */
get cachedArtists(): library.Artist[] | null {
return this.artists;
}
/** Synchronous access to cached albums (null if not yet loaded). */
get cachedAlbums(): library.Album[] | null {
return this.albums;
}
/**
* Track a fetch: guard its answer against an invalidation that
* happened while it was in flight, hold it as *the* in-flight
* request for its collection, and clear the loading flag when it
* settles either way.
*
* A stale answer is not cached and not returned — the caller is
* chained onto whatever the current selection is fetching instead,
* so "give me the tracks" always answers with the tracks of the
* library that is selected when it answers.
*/
private track<T>(
slot: ViewName,
request: Promise<T>,
commit: (value: T) => void,
current: () => Promise<T>,
): Promise<T> {
const gen = this.cacheGen;
const guarded = request.then((value) => {
if (gen !== this.cacheGen) return current();
commit(value);
this.changeGen++;
return value;
});
const tracked: Promise<T> = guarded.finally(() => {
if (this.inFlight.get(slot) !== tracked) return;
this.inFlight.delete(slot);
this.setLoading(slot, false);
this.notify();
});
this.inFlight.set(slot, tracked);
this.setLoading(slot, true);
this.notify();
return tracked;
}
private pending<T>(slot: ViewName): Promise<T> | undefined {
return this.inFlight.get(slot) as Promise<T> | undefined;
}
private setLoading(slot: ViewName, loading: boolean): void {
switch (slot) {
case 'tracks':
this.tracksLoading = loading;
break;
case 'albums':
this.albumsLoading = loading;
break;
case 'artists':
this.artistsLoading = loading;
break;
case 'genres':
this.genresLoading = loading;
break;
}
}
async getTracks(): Promise<ListTrack[]> {
if (this.tracks !== null) {
return this.tracks;
}
const pending = this.pending<ListTrack[]>('tracks');
if (pending) return pending;
return this.track(
'tracks',
GetTrackTable(this.libraryFilter()).then(decodeTrackTable),
(tracks) => {
this.tracks = tracks;
},
() => this.getTracks(),
);
}
async getAlbums(): Promise<library.Album[]> {
if (this.albums !== null) {
return this.albums;
}
const pending = this.pending<library.Album[]>('albums');
if (pending) return pending;
return this.track(
'albums',
list(GetAlbums(this.libraryFilter())),
(albums) => {
this.albums = albums;
},
() => this.getAlbums(),
);
}
async getArtists(): Promise<library.Artist[]> {
if (this.artists !== null) {
return this.artists;
}
const pending = this.pending<library.Artist[]>('artists');
if (pending) return pending;
return this.track(
'artists',
list(GetArtists(this.libraryFilter())),
(artists) => {
this.artists = artists;
},
() => this.getArtists(),
);
}
async getGenres(): Promise<library.GenreWithCount[]> {
if (this.genres !== null) {
return this.genres;
}
const pending = this.pending<library.GenreWithCount[]>('genres');
if (pending) return pending;
return this.track(
'genres',
list(GetGenres(this.libraryFilter())),
(genres) => {
this.genres = genres;
},
() => this.getGenres(),
);
}
async getAlbumsByArtist(
artist: string,
): Promise<library.Album[]> {
return list(GetAlbumsByArtist(artist, this.libraryFilter()));
}
/**
* The library id every backend query takes, where 0 means "all of
* them".
*
* Each of these used to be two bindings and a branch here, because
* the backend had a scoped and an unscoped query for every list.
* One query answers both now, so the branch is a `?? 0`.
*/
libraryFilter(): number {
return this.selectedLibraryIdValue ?? 0;
}
/**
* Returns albums filtered by artist name from
* the in-memory cache, or null if the cache is
* not populated. This provides an instant
* result when the all-albums list has already
* been loaded (e.g. the user visited the albums
* view first).
*
* Returns null when a library filter is active
* because the cached albums are already filtered
* by library — the client-side ArtistName match
* is correct but forcing a backend query ensures
* consistency.
*/
getAlbumsByArtistNameCached(
artistName: string,
): library.Album[] | null {
if (this.selectedLibraryIdValue !== null) {
return null;
}
if (this.albums === null) return null;
return this.albums.filter(
(a) => a.ArtistName === artistName,
);
}
// ===================================================================
// STATE ACCESSORS
// Synchronous access for controllers that need current cached values.
// ===================================================================
getCachedTracks(): ListTrack[] | null {
return this.tracks;
}
getCachedAlbums(): library.Album[] | null {
return this.albums;
}
isTracksLoading(): boolean {
return this.tracksLoading;
}
isAlbumsLoading(): boolean {
return this.albumsLoading;
}
getCachedArtists(): library.Artist[] | null {
return this.artists;
}
isArtistsLoading(): boolean {
return this.artistsLoading;
}
getCachedGenres(): library.GenreWithCount[] | null {
return this.genres;
}
isGenresLoading(): boolean {
return this.genresLoading;
}
/**
* Monotonic counter that increments only when cached data
* actually changes (tracks, albums, artists, genres, coverSize,
* or invalidation). Loading flag transitions do NOT increment.
* Controllers can compare against a saved value to skip
* unnecessary requestUpdate() calls.
*/
get changeGeneration(): number {
return this.changeGen;
}
// ===================================================================
// LIBRARY FILTER
// ===================================================================
getSelectedLibraryId(): number | null {
return this.selectedLibraryIdValue;
}
setSelectedLibrary(id: number | null): void {
if (id === this.selectedLibraryIdValue) return;
this.selectedLibraryIdValue = id;
this.invalidate();
}
async getLibraries(): Promise<library.Info[]> {
if (this.libraries !== null) {
return this.libraries;
}
const libs = await list(GetAllLibrariesWithTrackCounts());
this.libraries = libs;
return libs;
}
/**
* Resolves a library id to attach a download/want to: the selected
* filter if one is set, otherwise the first known library. Callers
* that need a library id (rather than "all libraries", which is
* what `getSelectedLibraryId()` returning null means for browsing)
* should use this instead of defaulting to 0 — id 0 never exists
* and trips the library_id foreign key on download_requests /
* download_wants.
*/
async getDefaultLibraryId(): Promise<number | null> {
if (this.selectedLibraryIdValue !== null) {
return this.selectedLibraryIdValue;
}
const libraries = await this.getLibraries();
return libraries[0]?.id ?? null;
}
// ===================================================================
// SCROLL POSITION
// ===================================================================
getScrollPosition(view: ViewName): number {
return this.scrollPositions[view];
}
setScrollPosition(view: ViewName, offset: number): void {
this.scrollPositions[view] = offset;
}
// ===================================================================
// COVER SIZE
// ===================================================================
getCoverSize(): number {
return this.coverSizeValue;
}
setCoverSize(size: number): void {
const clamped = Math.round(
Math.max(COVER_SIZE_MIN, Math.min(COVER_SIZE_MAX, size)),
);
if (clamped === this.coverSizeValue) return;
this.coverSizeValue = clamped;
this.changeGen++;
this.saveCoverSize();
this.notify();
}
private loadCoverSize(): void {
try {
const stored = localStorage.getItem(COVER_SIZE_KEY);
if (stored !== null) {
const parsed = parseInt(stored, 10);
if (!Number.isNaN(parsed)) {
this.coverSizeValue = Math.max(
COVER_SIZE_MIN,
Math.min(COVER_SIZE_MAX, parsed),
);
}
}
} catch {
// localStorage may be unavailable.
}
}
private saveCoverSize(): void {
try {
localStorage.setItem(
COVER_SIZE_KEY,
String(this.coverSizeValue),
);
} catch {
// localStorage may be unavailable.
}
}
// ===================================================================
// INVALIDATION
// ===================================================================
/**
* Patch one track's play statistics in place.
*
* Finishing a track used to arrive as TrackMetadataChanged, so every
* song invalidated the whole cache and refetched tracks, albums,
* artists and genres — ~37 MB across the IPC and ~0.8 s of blocked
* main thread per song at 50 000 tracks (perf.C1), and it cleared
* the user's track-list selection while it did (perf.C2).
*
* A play count changes one integer on one row. The backend sends
* everything needed to write it, so nothing is refetched and no
* collection identity changes except the tracks array itself.
*
* The array *is* replaced rather than mutated: consumers key their
* memoized filter/sort caches on its identity (`track-list`'s
* `cachedTracks` check is the load-bearing one), so an in-place
* mutation would be invisible to every one of them. The individual
* Track objects other than the patched one are shared, which is
* what keeps this cheap.
*/
private applyPlayCount(payload: unknown): void {
if (this.tracks === null) return;
// The list does not carry LastPlayed (#281); trackCache patches
// it on the whole tracks the details dialog reads.
const p = payload as {
filePath?: string;
playCount?: number;
} | null;
if (!p?.filePath) return;
const idx = this.tracks.findIndex((t) => t.FilePath === p.filePath);
if (idx === -1) return;
const existing = this.tracks[idx];
if (existing === undefined) return;
const patched: ListTrack = {
...existing,
PlayCount: p.playCount ?? existing.PlayCount,
};
this.tracks = [
...this.tracks.slice(0, idx),
patched,
...this.tracks.slice(idx + 1),
];
this.changeGen++;
this.notify();
}
/**
* Splice removed tracks out in place, and refetch only the
* summaries whose counts changed.
*
* `invalidate()` would be correct and is the expensive answer: it
* nulls `tracks` and eagerly refetches it, which is ~37 MB across
* the IPC at 50 000 tracks for an operation that removed three
* rows. The event carries the paths precisely so this does not have
* to happen — the same bargain `TrackPlayCountChanged` makes.
*
* The album, artist and genre lists really do change (their track
* counts, and the row itself when its last track goes), so they are
* dropped and refetched. They are the small collections.
*/
private applyTracksRemoved(payload: unknown): void {
const p = payload as { filePaths?: string[] } | null;
const removed = p?.filePaths;
if (!removed || removed.length === 0) return;
// A tracks fetch already in flight would land holding the rows
// that were just deleted, and it captured the cache generation
// this patch is about to leave behind. There is no patch that
// is equivalent to that, so fall back.
if (this.inFlight.has('tracks')) {
this.invalidate();
return;
}
if (this.tracks !== null) {
const gone = new Set(removed);
const kept = this.tracks.filter((t) => !gone.has(t.FilePath));
// A new array identity even when nothing matched would
// invalidate every memoized filter/sort cache keyed on it
// for no reason.
if (kept.length !== this.tracks.length) {
this.tracks = kept;
}
}
const loaded = this.loadedCollections();
this.albums = null;
this.artists = null;
this.genres = null;
// Bumping the cache generation is what stops an album fetch
// issued before the removal from committing its pre-removal
// answer. Safe for the tracks slot precisely because the guard
// above established there is nothing in flight for it.
this.cacheGen++;
this.inFlight.delete('albums');
this.inFlight.delete('artists');
this.inFlight.delete('genres');
this.changeGen++;
this.notify();
// Only the summaries something was showing (#280): a removal
// nobody was looking at does not load a collection to correct it.
this.refetchLoaded({
albums: loaded.albums,
artists: loaded.artists,
genres: loaded.genres,
});
}
private invalidate(): void {
// Which collections something has actually loaded, before they
// are dropped. Refetching all four here would undo #280: a scan
// finishing would fetch the track list of a library nobody has
// opened the Tracks view on.
const loaded = this.loadedCollections();
this.tracks = null;
this.albums = null;
this.artists = null;
this.genres = null;
// Anything still in flight was asked for on behalf of a
// selection that no longer applies: forget it, so the refetch
// below starts a request for the current one rather than
// adopting the old one's answer.
this.inFlight.clear();
this.cacheGen++;
this.changeGen++;
this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify();
this.refetchLoaded(loaded);
}
/** The collections currently held, keyed by the view that draws them. */
private loadedCollections(): Partial<Record<ViewName, boolean>> {
return {
tracks: this.tracks !== null,
albums: this.albums !== null,
artists: this.artists !== null,
genres: this.genres !== null,
};
}
/**
* Refetch exactly the collections named, and nothing else.
*
* A failed fetch is reported by whichever view asked for the data
* (it is that panel's failure, not the app's), but this refetch has
* no caller to reject to — without a catch it is an unhandled
* rejection.
*/
private refetchLoaded(loaded: Partial<Record<ViewName, boolean>>): void {
const logged = this.failureReporter();
if (loaded.tracks) void this.getTracks().catch(logged('tracks'));
if (loaded.albums) void this.getAlbums().catch(logged('albums'));
if (loaded.artists) void this.getArtists().catch(logged('artists'));
if (loaded.genres) void this.getGenres().catch(logged('genres'));
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private notify(): void {
if (this.notifyScheduled) return;
this.notifyScheduled = true;
queueMicrotask(() => {
this.notifyScheduled = false;
this.subscribers.forEach((callback) => callback());
});
}
}
// Singleton instance.
export const libraryStore = new LibraryStore();