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
241 lines
7.4 KiB
TypeScript
241 lines
7.4 KiB
TypeScript
/**
|
|
* 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<string, library.Track>(TRACK_CACHE_LIMIT);
|
|
|
|
/** Requests collected in this task, answered by one binding call. */
|
|
private waiting: Waiter[] = [];
|
|
|
|
private flushHandle: ReturnType<typeof setTimeout> | 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<library.Track[]> {
|
|
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<library.Track | undefined> {
|
|
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<library.Track[]> {
|
|
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<void> {
|
|
this.flushHandle = null;
|
|
|
|
const waiting = this.waiting;
|
|
|
|
this.waiting = [];
|
|
|
|
const missing = new Set<string>();
|
|
|
|
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<string, library.Track>();
|
|
|
|
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();
|