/** * Whole tracks, looked up by file path, for the rows a surface is * actually showing (#279). * * Track details from the queue, a playlist or a smart playlist used to * find their track in `libraryStore`'s whole-library array. That made * the array a dependency of every surface that can open details, so it * was fetched eagerly at startup — 20.5 MB of JSON at 26 138 tracks — * and when it had *not* landed yet, the openers that read it * synchronously did nothing at all. This asks the backend for the * paths in hand instead. * * Three things are load-bearing, the same three as `credit-store`: * * **Lookups are coalesced.** Every `get()` made in the same task joins * one `GetTracksByPaths` call. A timer rather than a frame: these are * user actions, not row renders, and a frame never fires in a hidden * window, which would leave the caller waiting on nothing. * * **It is bounded.** A batch details dialog over "Select all" asks for * every track in the library; it gets every one of them back, but the * cache keeps only the most recent `TRACK_CACHE_LIMIT`. * * **It forgets what changed.** The events that make `library-store` * refetch drop the affected entries here, and a play count is patched * in place, so a cached track is never older than the last event about * it. An answer that was in flight across an invalidation is still * returned to its caller (it was correct when asked) but not cached. */ import { EventsOn } from '@runtime/runtime'; import { GetTracksByPaths } from '@go/library/library.js'; import type * as library from '@go/library/models.js'; import { list } from '@utils/binding'; import { LRUMap } from '@utils/lru-map'; import { registerCacheProbe } from '@utils/cache-stats'; import { Events } from '../events'; /** * Tracks retained. A track is ~25 short fields, so this is a couple of * megabytes at most — sized above any batch a person edits by hand, * far below a library. */ export const TRACK_CACHE_LIMIT = 2_000; interface Waiter { paths: readonly string[]; resolve: (tracks: library.Track[]) => void; reject: (err: unknown) => void; } class TrackCache { private cache = new LRUMap(TRACK_CACHE_LIMIT); /** Requests collected in this task, answered by one binding call. */ private waiting: Waiter[] = []; private flushHandle: ReturnType | null = null; /** Bumped by every invalidation; an older answer is not cached. */ private gen = 0; constructor() { EventsOn(Events.LibraryScanComplete, () => this.clear()); EventsOn(Events.LibraryRemoved, () => this.clear()); EventsOn(Events.TrackMetadataChanged, (payload: unknown) => { const p = payload as { filePath?: string } | null; // A batch write names no paths: forget everything. if (p?.filePath) this.forget([p.filePath]); else this.clear(); }); EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => { const p = payload as { filePaths?: string[] } | null; this.forget(p?.filePaths ?? []); }); EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => { this.applyPlayCount(payload); }); registerCacheProbe('tracks-by-path', () => ({ entries: this.cache.size, chars: this.retainedChars(), limit: TRACK_CACHE_LIMIT, })); } /** * The tracks at `paths`, in the order given, without the ones that * are not in the library. Cached tracks are answered without a call. */ get(paths: readonly string[]): Promise { const answered = this.fromCache(paths); if (answered) return Promise.resolve(answered); return new Promise((resolve, reject) => { this.waiting.push({ paths, resolve, reject }); this.flushHandle ??= setTimeout(() => void this.flush(), 0); }); } /** One track, or undefined when the path is not in the library. */ async getOne(path: string): Promise { return (await this.get([path]))[0]; } /** * Ask the backend again for `paths`, ignoring the cache. * * For a caller that has just written to these files and must not be * answered from before the write, whether or not the event that * invalidates them has arrived yet. */ refresh(paths: readonly string[]): Promise { this.forget(paths); return this.get(paths); } /** Every path cached, or null if any is missing. */ private fromCache(paths: readonly string[]): library.Track[] | null { const tracks: library.Track[] = []; for (const path of paths) { const track = this.cache.get(path); if (!track) return null; tracks.push(track); } return tracks; } private async flush(): Promise { this.flushHandle = null; const waiting = this.waiting; this.waiting = []; const missing = new Set(); for (const w of waiting) { for (const path of w.paths) { if (!this.cache.has(path)) missing.add(path); } } const gen = this.gen; let fetched: library.Track[]; try { fetched = missing.size > 0 ? await list(GetTracksByPaths([...missing])) : []; } catch (err) { for (const w of waiting) w.reject(err); return; } // Answer from what this call returned plus what was cached when // it was made — not from the cache afterwards, which an LRU // eviction or an invalidation may have emptied in between. const answer = new Map(); for (const w of waiting) { for (const path of w.paths) { const hit = this.cache.get(path); if (hit) answer.set(path, hit); } } for (const track of fetched) { answer.set(track.FilePath, track); if (gen === this.gen) this.cache.set(track.FilePath, track); } for (const w of waiting) { const tracks: library.Track[] = []; for (const path of w.paths) { const track = answer.get(path); if (track) tracks.push(track); } w.resolve(tracks); } } private forget(paths: readonly string[]): void { for (const path of paths) this.cache.delete(path); this.gen++; } private clear(): void { this.cache.clear(); this.gen++; } private applyPlayCount(payload: unknown): void { const p = payload as { filePath?: string; playCount?: number; lastPlayed?: string; } | null; if (!p?.filePath) return; const existing = this.cache.get(p.filePath); if (!existing) return; this.cache.set(p.filePath, { ...existing, PlayCount: p.playCount ?? existing.PlayCount, LastPlayed: p.lastPlayed ?? existing.LastPlayed, }); } private retainedChars(): number { let total = 0; for (const t of this.cache.values()) { total += t.FilePath.length + t.TrackName.length + t.ArtistName.length + t.Album.length; } return total; } } export const trackCache = new TrackCache();