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
This commit is contained in:
yonlu committed 2026-10-05 23:01:37 -04:00
1 parent 3cddf70c4d
commit 4a9fe1d207
14 files changed
+747 -270

No files matched your search

@@ -56,12 +56,9 @@ import {
createTrackCardDragImage,
removeDragImage,
} from '@utils/drag-image';
import { libraryStore } from '@store/library-store';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import '@components/phantom-resolver/phantom-resolver.js';
import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
@@ -758,76 +755,21 @@ export class PlaylistDetails
}
private async openTrackDetails(filePath: string) {
const tracks = libraryStore.getCachedTracks();
const track = tracks
? tracksByFilePath(tracks).get(filePath)
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
await showTrackDetailsForPath(
() => this.trackDetailsDialog,
filePath,
() => void this.openTrackDetails(filePath),
);
if (!ready) return;
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt,
);
}
private async openBatchTrackDetails(
filePaths: string[],
) {
const cachedTracks =
libraryStore.getCachedTracks();
if (!cachedTracks) return;
const tracks = tracksForPaths(
cachedTracks,
await showBatchTrackDetailsForPaths(
() => this.trackDetailsDialog,
filePaths,
);
if (tracks.length === 0) return;
const ready = await loadTrackDetails(
() => void this.openBatchTrackDetails(filePaths),
);
if (!ready) return;
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
this.trackDetailsDialog?.showBatch(
tracks,
coverArt,
coverArtMixed,
);
}
/**
@@ -51,12 +51,8 @@ import {
createTrackCardDragImage,
removeDragImage,
} from '@utils/drag-image';
import { libraryStore } from '@store/library-store';
import type * as library from '@go/library/models.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath } from '@utils/track-index.js';
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import {
creditLink,
trackLink,
@@ -1542,90 +1538,30 @@ export class QueuePanel
}
private async openTrackDetails(index: number) {
const queueTrack =
this.queue.tracks[index];
const queueTrack = this.queue.tracks[index];
if (!queueTrack) return;
const tracks =
libraryStore.getCachedTracks();
const track = tracks
? tracksByFilePath(tracks).get(
queueTrack.filePath,
)
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
await showTrackDetailsForPath(
() => this.trackDetailsDialog,
queueTrack.filePath,
() => void this.openTrackDetails(index),
);
if (!ready) return;
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt,
);
}
private async openBatchTrackDetails(
indices: number[],
) {
const queueTracks = this.queue.tracks;
const cachedTracks =
libraryStore.getCachedTracks();
const filePaths = indices
.map((i) => queueTracks[i]?.filePath)
.filter((fp): fp is string => fp != null);
if (!cachedTracks) return;
const byPath = tracksByFilePath(cachedTracks);
const tracks = indices
.map((i) => queueTracks[i])
.filter((qt) => qt != null)
.map((qt) => byPath.get(qt.filePath))
.filter(
(t): t is library.Track =>
t != null,
);
if (tracks.length === 0) return;
const ready = await loadTrackDetails(
await showBatchTrackDetailsForPaths(
() => this.trackDetailsDialog,
filePaths,
() => void this.openBatchTrackDetails(indices),
);
if (!ready) return;
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
this.trackDetailsDialog?.showBatch(
tracks,
coverArt,
coverArtMixed,
);
}
private onContextPlaylistActionComplete = () => {
@@ -52,11 +52,8 @@ import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { libraryStore } from '@store/library-store';
import { formatMilliseconds } from '@utils/time';
import {
creditLink,
@@ -1145,76 +1142,21 @@ export class SmartPlaylistDetails
}
private async openTrackDetails(filePath: string) {
const tracks = libraryStore.getCachedTracks();
const track = tracks
? tracksByFilePath(tracks).get(filePath)
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
await showTrackDetailsForPath(
() => this.trackDetailsDialog,
filePath,
() => void this.openTrackDetails(filePath),
);
if (!ready) return;
const coverArt = track.CoverArtPath
? {
coverArtPath: track.CoverArtPath,
coverArtSmall: track.CoverArtSmall,
coverArtMedium: track.CoverArtMedium,
coverArtLarge: track.CoverArtLarge,
}
: undefined;
this.trackDetailsDialog?.show(
track,
coverArt,
);
}
private async openBatchTrackDetails(
filePaths: string[],
) {
const cachedTracks =
libraryStore.getCachedTracks();
if (!cachedTracks) return;
const tracks = tracksForPaths(
cachedTracks,
await showBatchTrackDetailsForPaths(
() => this.trackDetailsDialog,
filePaths,
);
if (tracks.length === 0) return;
const ready = await loadTrackDetails(
() => void this.openBatchTrackDetails(filePaths),
);
if (!ready) return;
const first = tracks[0]!;
const albumNames = new Set(tracks.map((t) => t.Album));
let coverArt: CoverArtUrls | null = null;
let coverArtMixed = false;
if (albumNames.size === 1 && first.CoverArtPath) {
coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
} else if (albumNames.size > 1) {
coverArtMixed = true;
}
this.trackDetailsDialog?.showBatch(
tracks,
coverArt,
coverArtMixed,
);
}
// =================================================================
@@ -22,7 +22,8 @@ import {
import { GetTrackMBIDs } from '@go/library/library.js';
type TrackMBIDs = library.TrackMBIDs;
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js';
import { libraryStore } from '../../store/library-store';
import { trackCache } from '../../store/track-cache';
import { batchCoverArt } from '@utils/track-details-opener.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
@@ -1317,10 +1318,13 @@ export class TrackDetails extends LitElement {
const container = img.parentElement;
if (container) {
container.innerHTML =
'<div class="cover-placeholder">' +
'<wa-icon name="music"></wa-icon>' +
'</div>';
const placeholder = document.createElement('div');
const icon = document.createElement('wa-icon');
placeholder.className = 'cover-placeholder';
icon.setAttribute('name', 'music');
placeholder.append(icon);
container.replaceChildren(placeholder);
}
};
@@ -1690,20 +1694,11 @@ export class TrackDetails extends LitElement {
// invalidation, which refreshes all other views.
this.exitEditMode();
// Re-fetch track and album data so the dialog
// shows updated values and cover art. The store
// invalidation is already in-flight from the event;
// these calls await the pending fetch or start one.
// Awaited for the side effect of refreshing the
// store; the album list is consumed elsewhere.
const [tracks] = await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
]);
const updated = tracks.find(
(t) => t.FilePath === filePath,
);
// Re-read this one track so the dialog shows the
// values and cover art just written. Not the whole
// library (#279): the views that hold it refetch on
// the event, and only if something is showing them.
const [updated] = await trackCache.refresh([filePath]);
if (updated) {
this.track = updated;
@@ -1828,39 +1823,18 @@ export class TrackDetails extends LitElement {
this.errorMessage = '';
this.cleanupPendingCoverArt();
// Refresh data from library store; album list is
// refreshed for side effects only.
const [tracks] = await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
]);
// Re-read the tracks just written, in the order the batch
// holds them (#279) — not the whole library to filter.
// `refresh`, because the write's event may not have reached
// the cache before its own result did.
const refreshed = await trackCache.refresh(this.batchFilePaths);
// Re-resolve batch tracks.
const pathSet = new Set(this.batchFilePaths);
const refreshed = tracks.filter((t) =>
pathSet.has(t.FilePath),
);
this.batchTracks = refreshed;
// Re-resolve cover art state.
const first = refreshed[0];
const albumNames = new Set(refreshed.map((t) => t.Album));
const { coverArt, mixed } = batchCoverArt(refreshed);
if (albumNames.size === 1 && first?.CoverArtPath) {
this.coverArt = {
coverArtPath: first.CoverArtPath,
coverArtSmall: first.CoverArtSmall,
coverArtMedium: first.CoverArtMedium,
coverArtLarge: first.CoverArtLarge,
};
this.batchCoverArtMixed = false;
} else if (albumNames.size > 1) {
this.coverArt = null;
this.batchCoverArtMixed = true;
} else {
this.coverArt = null;
this.batchCoverArtMixed = false;
}
this.coverArt = coverArt;
this.batchCoverArtMixed = mixed;
};
// -- Shared edit logic --
@@ -2103,6 +2077,8 @@ export class TrackDetails extends LitElement {
// as a base64-encoded string (standard encoding/json
// behaviour for []byte).
const result = await ReadFile(filePath);
// SAFETY: the binding is typed as the Go []byte, but the
// wire value is the base64 string encoding/json made of it.
const b64 = result as unknown as string;
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
+240
View File
@@ -0,0 +1,240 @@
/**
* 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();
+50 -10
View File
@@ -8,12 +8,13 @@
* one key both sides share, and turning it back into a track is the
* work this does.
*
* `libraryStore.getTracks()` is awaited rather than
* `getCachedTracks()`-and-bail (which is what `queue-panel` does):
* Explore is reachable without ever opening the library views, so a
* cold cache is ordinary here rather than a symptom, and silently doing
* nothing on a menu item the user just clicked is not an option. The
* fetch is the store's own, shared with every other reader.
* The queue, playlists and smart playlists are in the same position:
* they render their own row type, not a `library.Track`. All of them
* used to find the track in `libraryStore`'s whole-library array, which
* meant that array had to be loaded for details to open — and the ones
* that read it synchronously silently did nothing when it was not
* (#279). They ask `trackCache` for the paths in hand instead: one
* small call, coalesced, answered from cache the second time.
*/
import type * as library from '@go/library/models.js';
@@ -21,9 +22,8 @@ import type {
CoverArtUrls,
TrackDetails,
} from '@components/track-details/track-details.js';
import { libraryStore } from '@store/library-store.js';
import { trackCache } from '@store/track-cache.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath } from '@utils/track-index.js';
/** The cover art the dialog shows, or nothing when the track has none. */
function coverArtOf(track: library.Track): CoverArtUrls | undefined {
@@ -37,6 +37,22 @@ function coverArtOf(track: library.Track): CoverArtUrls | undefined {
: undefined;
}
/**
* The cover a batch dialog shows: the album's, when every track is on
* one album; none and `mixed` when they span several.
*/
export function batchCoverArt(
tracks: readonly library.Track[],
): { coverArt: CoverArtUrls | null; mixed: boolean } {
const albums = new Set(tracks.map((t) => t.Album));
if (albums.size > 1) return { coverArt: null, mixed: true };
const first = tracks[0];
return { coverArt: first ? coverArtOf(first) ?? null : null, mixed: false };
}
/**
* What became of the attempt.
*
@@ -62,8 +78,7 @@ export async function showTrackDetailsForPath(
filePath: string,
retry: () => void,
): Promise<TrackDetailsOutcome> {
const tracks = await libraryStore.getTracks();
const track = tracksByFilePath(tracks).get(filePath);
const track = await trackCache.getOne(filePath);
if (!track) return 'not-in-library';
@@ -75,3 +90,28 @@ export async function showTrackDetailsForPath(
return 'shown';
}
/**
* Show the batch details dialog for the library tracks at `filePaths`,
* in that order. Paths not in the library are left out; when none are,
* nothing is shown.
*/
export async function showBatchTrackDetailsForPaths(
dialog: () => TrackDetails | undefined,
filePaths: readonly string[],
retry: () => void,
): Promise<TrackDetailsOutcome> {
const tracks = await trackCache.get(filePaths);
if (tracks.length === 0) return 'not-in-library';
const ready = await loadTrackDetails(retry);
if (!ready) return 'chunk-failed';
const { coverArt, mixed } = batchCoverArt(tracks);
dialog()?.showBatch(tracks, coverArt, mixed);
return 'shown';
}