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
14 changed files with 746 additions and 269 deletions
Showing only changes of commit 4a9fe1d207 - Show all commits

No files matched your search

@@ -117,6 +117,9 @@ WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), li
-- name: GetTrackByPath :one -- name: GetTrackByPath :one
SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1; SELECT * FROM track_metadata WHERE file_path = ? LIMIT 1;
-- name: GetTracksByPaths :many
SELECT * FROM track_metadata WHERE file_path IN (sqlc.slice('paths'));
-- name: GetTracksByAlbum :many -- name: GetTracksByAlbum :many
SELECT * FROM track_metadata SELECT * FROM track_metadata
WHERE album_id = sqlc.arg(album_id) WHERE album_id = sqlc.arg(album_id)
@@ -830,6 +830,71 @@ func (q *Queries) GetTracksByGenre(ctx context.Context, arg GetTracksByGenrePara
return items, nil return items, nil
} }
const getTracksByPaths = `-- name: GetTracksByPaths :many
SELECT id, file_path, length_milliseconds, title, artist_name, track_number, disc_number, album, genre, year, release_year, composer, file_type, sample_rate, bit_depth, channels, bitrate, file_size, library_id, play_count, last_played, cover_art_path, artist_mbid, release_group_mbid, recording_mbid, album_id, artist_id FROM track_metadata WHERE file_path IN (/*SLICE:paths*/?)
`
func (q *Queries) GetTracksByPaths(ctx context.Context, paths []string) ([]TrackMetadatum, error) {
query := getTracksByPaths
var queryParams []interface{}
if len(paths) > 0 {
for _, v := range paths {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []TrackMetadatum
for rows.Next() {
var i TrackMetadatum
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.ReleaseYear,
&i.Composer,
&i.FileType,
&i.SampleRate,
&i.BitDepth,
&i.Channels,
&i.Bitrate,
&i.FileSize,
&i.LibraryID,
&i.PlayCount,
&i.LastPlayed,
&i.CoverArtPath,
&i.ArtistMbid,
&i.ReleaseGroupMbid,
&i.RecordingMbid,
&i.AlbumID,
&i.ArtistID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many const lookupTrackMetaByPaths = `-- name: LookupTrackMetaByPaths :many
SELECT id, file_path, title, artist_name, album, cover_art_path, SELECT id, file_path, title, artist_name, album, cover_art_path,
artist_mbid, release_group_mbid, recording_mbid artist_mbid, release_group_mbid, recording_mbid
+42
View File
@@ -193,6 +193,48 @@ func (l *Library) GetTracks(libraryID int64) ([]Track, error) {
return tracksFromRows(rows), nil return tracksFromRows(rows), nil
} }
// pathLookupChunk bounds the paths bound into one IN (...) query, well
// under SQLite's bind-variable limit.
const pathLookupChunk = 500
// GetTracksByPaths returns whole tracks for the given file paths, in
// the order asked, dropping any path that is not in the library.
//
// It is how the frontend resolves the tracks a surface is actually
// showing (#279). Track details from the queue, a playlist or a smart
// playlist used to look the path up in the whole library's track
// array, which had to be loaded first — so it was fetched eagerly at
// startup, 20.5 MB at 26k tracks, to answer questions about a handful
// of rows.
func (l *Library) GetTracksByPaths(paths []string) ([]Track, error) {
byPath := make(map[string]Track, len(paths))
for start := 0; start < len(paths); start += pathLookupChunk {
chunk := paths[start:min(start+pathLookupChunk, len(paths))]
rows, err := l.db.ReadQueries.GetTracksByPaths(l.ctx, chunk)
if err != nil {
return nil, fmt.Errorf("could not get tracks by path: %w", err)
}
for _, row := range rows {
byPath[row.FilePath] = trackFromRow(row)
}
}
tracks := make([]Track, 0, len(byPath))
for _, path := range paths {
if t, ok := byPath[path]; ok {
tracks = append(tracks, t)
// A path asked twice is answered once.
delete(byPath, path)
}
}
return tracks, nil
}
// SearchTracks runs the library's FTS index and returns whole tracks. // SearchTracks runs the library's FTS index and returns whole tracks.
func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) { func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) {
rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit) rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit)
+64
View File
@@ -0,0 +1,64 @@
package library
import (
"fmt"
"testing"
)
// #279: the track-details openers resolve the rows a surface is showing
// by path, instead of finding them in the whole library's array. The
// answer must keep the caller's order (a batch dialog lists them as
// selected), drop what is not in the library rather than invent it, and
// survive more paths than one IN (...) can bind.
func TestGetTracksByPaths(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
seedAlbumsAndGenres(t, lib)
got, err := lib.GetTracksByPaths([]string{
"/other/b1.mp3", "/music/missing.mp3", "/music/a1.mp3", "/other/b1.mp3",
})
if err != nil {
t.Fatalf("GetTracksByPaths: %v", err)
}
paths := make([]string, 0, len(got))
for _, tr := range got {
paths = append(paths, tr.FilePath)
}
if want := "[/other/b1.mp3 /music/a1.mp3]"; fmt.Sprint(paths) != want {
t.Fatalf("paths = %v, want %s (caller order, missing dropped, duplicate once)", paths, want)
}
// Whole tracks, not just the key: details render every field.
if got[1].TrackName != "A1" || len(got[1].Genre) != 2 {
t.Errorf("a1 = %+v, want title A1 with two genres", got[1])
}
}
func TestGetTracksByPathsSpansChunks(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
seedAlbumsAndGenres(t, lib)
// The real path last, behind more misses than one chunk binds, so a
// loop that only ran the first chunk would return nothing.
paths := make([]string, 0, pathLookupChunk+2)
for i := range pathLookupChunk + 1 {
paths = append(paths, fmt.Sprintf("/nowhere/%d.mp3", i))
}
paths = append(paths, "/music/a2.mp3")
got, err := lib.GetTracksByPaths(paths)
if err != nil {
t.Fatalf("GetTracksByPaths: %v", err)
}
if len(got) != 1 || got[0].FilePath != "/music/a2.mp3" {
t.Fatalf("got %d tracks (%v), want only /music/a2.mp3", len(got), got)
}
}
@@ -222,6 +222,21 @@ export function GetTracksByGenre(genre: string, libraryID: number): $Cancellable
return $Call.ByID(1674220245, genre, libraryID); return $Call.ByID(1674220245, genre, libraryID);
} }
/**
* GetTracksByPaths returns whole tracks for the given file paths, in
* the order asked, dropping any path that is not in the library.
*
* It is how the frontend resolves the tracks a surface is actually
* showing (#279). Track details from the queue, a playlist or a smart
* playlist used to look the path up in the whole library's track
* array, which had to be loaded first — so it was fetched eagerly at
* startup, 20.5 MB at 26k tracks, to answer questions about a handful
* of rows.
*/
export function GetTracksByPaths(paths: string[] | null): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(3966945290, paths);
}
/** /**
* IsScanActive returns whether a scan is currently running. * IsScanActive returns whether a scan is currently running.
*/ */
@@ -56,12 +56,9 @@ import {
createTrackCardDragImage, createTrackCardDragImage,
removeDragImage, removeDragImage,
} from '@utils/drag-image'; } from '@utils/drag-image';
import { libraryStore } from '@store/library-store';
import '@components/playlist-picker/playlist-picker.js'; import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js'; import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import type { TrackDetails } from '@components/track-details/track-details.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 '@components/phantom-resolver/phantom-resolver.js';
import type { PhantomResolver } from '@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'; import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
@@ -758,76 +755,21 @@ export class PlaylistDetails
} }
private async openTrackDetails(filePath: string) { private async openTrackDetails(filePath: string) {
const tracks = libraryStore.getCachedTracks(); await showTrackDetailsForPath(
const track = tracks () => this.trackDetailsDialog,
? tracksByFilePath(tracks).get(filePath) filePath,
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
() => void this.openTrackDetails(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( private async openBatchTrackDetails(
filePaths: string[], filePaths: string[],
) { ) {
const cachedTracks = await showBatchTrackDetailsForPaths(
libraryStore.getCachedTracks(); () => this.trackDetailsDialog,
if (!cachedTracks) return;
const tracks = tracksForPaths(
cachedTracks,
filePaths, filePaths,
);
if (tracks.length === 0) return;
const ready = await loadTrackDetails(
() => void this.openBatchTrackDetails(filePaths), () => 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, createTrackCardDragImage,
removeDragImage, removeDragImage,
} from '@utils/drag-image'; } from '@utils/drag-image';
import { libraryStore } from '@store/library-store'; import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
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 type { TrackDetails } from '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { import {
creditLink, creditLink,
trackLink, trackLink,
@@ -1542,90 +1538,30 @@ export class QueuePanel
} }
private async openTrackDetails(index: number) { private async openTrackDetails(index: number) {
const queueTrack = const queueTrack = this.queue.tracks[index];
this.queue.tracks[index];
if (!queueTrack) return; if (!queueTrack) return;
const tracks = await showTrackDetailsForPath(
libraryStore.getCachedTracks(); () => this.trackDetailsDialog,
const track = tracks
? tracksByFilePath(tracks).get(
queueTrack.filePath, queueTrack.filePath,
)
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
() => void this.openTrackDetails(index), () => 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( private async openBatchTrackDetails(
indices: number[], indices: number[],
) { ) {
const queueTracks = this.queue.tracks; const queueTracks = this.queue.tracks;
const cachedTracks = const filePaths = indices
libraryStore.getCachedTracks(); .map((i) => queueTracks[i]?.filePath)
.filter((fp): fp is string => fp != null);
if (!cachedTracks) return; await showBatchTrackDetailsForPaths(
() => this.trackDetailsDialog,
const byPath = tracksByFilePath(cachedTracks); filePaths,
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(
() => void this.openBatchTrackDetails(indices), () => 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 = () => { private onContextPlaylistActionComplete = () => {
@@ -52,11 +52,8 @@ import '@lit-labs/virtualizer';
import type { LitVirtualizer } from '@lit-labs/virtualizer'; import type { LitVirtualizer } from '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js'; import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import '@components/playlist-picker/playlist-picker.js'; import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js'; import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import type { TrackDetails } from '@components/track-details/track-details.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 { formatMilliseconds } from '@utils/time';
import { import {
creditLink, creditLink,
@@ -1145,76 +1142,21 @@ export class SmartPlaylistDetails
} }
private async openTrackDetails(filePath: string) { private async openTrackDetails(filePath: string) {
const tracks = libraryStore.getCachedTracks(); await showTrackDetailsForPath(
const track = tracks () => this.trackDetailsDialog,
? tracksByFilePath(tracks).get(filePath) filePath,
: undefined;
if (!track) return;
const ready = await loadTrackDetails(
() => void this.openTrackDetails(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( private async openBatchTrackDetails(
filePaths: string[], filePaths: string[],
) { ) {
const cachedTracks = await showBatchTrackDetailsForPaths(
libraryStore.getCachedTracks(); () => this.trackDetailsDialog,
if (!cachedTracks) return;
const tracks = tracksForPaths(
cachedTracks,
filePaths, filePaths,
);
if (tracks.length === 0) return;
const ready = await loadTrackDetails(
() => void this.openBatchTrackDetails(filePaths), () => 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'; import { GetTrackMBIDs } from '@go/library/library.js';
type TrackMBIDs = library.TrackMBIDs; type TrackMBIDs = library.TrackMBIDs;
import { ImageFilePicker, ReadFile } from '@go/frontendutil/frontendutil.js'; 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 { EventsOn } from '@runtime/runtime';
import { Events } from '../../events'; import { Events } from '../../events';
@@ -1317,10 +1318,13 @@ export class TrackDetails extends LitElement {
const container = img.parentElement; const container = img.parentElement;
if (container) { if (container) {
container.innerHTML = const placeholder = document.createElement('div');
'<div class="cover-placeholder">' + const icon = document.createElement('wa-icon');
'<wa-icon name="music"></wa-icon>' +
'</div>'; 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. // invalidation, which refreshes all other views.
this.exitEditMode(); this.exitEditMode();
// Re-fetch track and album data so the dialog // Re-read this one track so the dialog shows the
// shows updated values and cover art. The store // values and cover art just written. Not the whole
// invalidation is already in-flight from the event; // library (#279): the views that hold it refetch on
// these calls await the pending fetch or start one. // the event, and only if something is showing them.
// Awaited for the side effect of refreshing the const [updated] = await trackCache.refresh([filePath]);
// store; the album list is consumed elsewhere.
const [tracks] = await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
]);
const updated = tracks.find(
(t) => t.FilePath === filePath,
);
if (updated) { if (updated) {
this.track = updated; this.track = updated;
@@ -1828,39 +1823,18 @@ export class TrackDetails extends LitElement {
this.errorMessage = ''; this.errorMessage = '';
this.cleanupPendingCoverArt(); this.cleanupPendingCoverArt();
// Refresh data from library store; album list is // Re-read the tracks just written, in the order the batch
// refreshed for side effects only. // holds them (#279) — not the whole library to filter.
const [tracks] = await Promise.all([ // `refresh`, because the write's event may not have reached
libraryStore.getTracks(), // the cache before its own result did.
libraryStore.getAlbums(), 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; this.batchTracks = refreshed;
// Re-resolve cover art state. const { coverArt, mixed } = batchCoverArt(refreshed);
const first = refreshed[0];
const albumNames = new Set(refreshed.map((t) => t.Album));
if (albumNames.size === 1 && first?.CoverArtPath) { this.coverArt = coverArt;
this.coverArt = { this.batchCoverArtMixed = mixed;
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;
}
}; };
// -- Shared edit logic -- // -- Shared edit logic --
@@ -2103,6 +2077,8 @@ export class TrackDetails extends LitElement {
// as a base64-encoded string (standard encoding/json // as a base64-encoded string (standard encoding/json
// behaviour for []byte). // behaviour for []byte).
const result = await ReadFile(filePath); 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 b64 = result as unknown as string;
const binary = atob(b64); const binary = atob(b64);
const bytes = new Uint8Array(binary.length); 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 * one key both sides share, and turning it back into a track is the
* work this does. * work this does.
* *
* `libraryStore.getTracks()` is awaited rather than * The queue, playlists and smart playlists are in the same position:
* `getCachedTracks()`-and-bail (which is what `queue-panel` does): * they render their own row type, not a `library.Track`. All of them
* Explore is reachable without ever opening the library views, so a * used to find the track in `libraryStore`'s whole-library array, which
* cold cache is ordinary here rather than a symptom, and silently doing * meant that array had to be loaded for details to open — and the ones
* nothing on a menu item the user just clicked is not an option. The * that read it synchronously silently did nothing when it was not
* fetch is the store's own, shared with every other reader. * (#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'; import type * as library from '@go/library/models.js';
@@ -21,9 +22,8 @@ import type {
CoverArtUrls, CoverArtUrls,
TrackDetails, TrackDetails,
} from '@components/track-details/track-details.js'; } 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 { 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. */ /** The cover art the dialog shows, or nothing when the track has none. */
function coverArtOf(track: library.Track): CoverArtUrls | undefined { function coverArtOf(track: library.Track): CoverArtUrls | undefined {
@@ -37,6 +37,22 @@ function coverArtOf(track: library.Track): CoverArtUrls | undefined {
: 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. * What became of the attempt.
* *
@@ -62,8 +78,7 @@ export async function showTrackDetailsForPath(
filePath: string, filePath: string,
retry: () => void, retry: () => void,
): Promise<TrackDetailsOutcome> { ): Promise<TrackDetailsOutcome> {
const tracks = await libraryStore.getTracks(); const track = await trackCache.getOne(filePath);
const track = tracksByFilePath(tracks).get(filePath);
if (!track) return 'not-in-library'; if (!track) return 'not-in-library';
@@ -75,3 +90,28 @@ export async function showTrackDetailsForPath(
return 'shown'; 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';
}
@@ -28,7 +28,7 @@ import type { TrackDetails } from '@components/track-details/track-details';
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks'; const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
const COMPLETENESS = 'library.Library.GetAlbumCompleteness'; const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs'; const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
const ALL_TRACKS = 'library.Library.GetTracks'; const TRACKS_BY_PATH = 'library.Library.GetTracksByPaths';
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup'; const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
const BROWSE_RELEASES = 'explore.Service.BrowseReleases'; const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
@@ -127,14 +127,13 @@ describe('Explore track details', () => {
stub(ALBUM_TRACKS, [albumTrack]); stub(ALBUM_TRACKS, [albumTrack]);
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 }); stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
stub(FILE_PATHS, { [MBID]: [PATH] }); stub(FILE_PATHS, { [MBID]: [PATH] });
stub(ALL_TRACKS, [libraryTrack]); stub(TRACKS_BY_PATH, [libraryTrack]);
}); });
/** /**
* `libraryStore` fetches at import and caches the empty list the * `trackCache` keeps what it was answered for the life of the browser
* shared setup stubs, for the life of the browser session — so a test * session, so a test that changes the answer has to drop it. A
* that wants tracks in it has to say so. A scan-complete event is how * scan-complete event is how the app itself invalidates that cache.
* the app itself invalidates that cache.
*/ */
async function primeLibrary() { async function primeLibrary() {
emit(Events.LibraryScanComplete); emit(Events.LibraryScanComplete);
@@ -204,7 +203,7 @@ describe('Explore track details', () => {
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
// Polled rather than counted: the opener is three awaits deep — the // Polled rather than counted: the opener is three awaits deep — the
// path lookup, the store's tracks, and the dynamic `import()` of // path lookup, the track by path, and the dynamic `import()` of
// the dialog chunk — and a chunk fetch is the one of the three // the dialog chunk — and a chunk fetch is the one of the three
// whose cost depends on what else the suite is doing. // whose cost depends on what else the suite is doing.
const shown = await dialogTrack(el); const shown = await dialogTrack(el);
@@ -230,7 +229,7 @@ describe('Explore track details', () => {
}); });
it('reports a path with no library track rather than opening empty', async () => { it('reports a path with no library track rather than opening empty', async () => {
stub(ALL_TRACKS, []); stub(TRACKS_BY_PATH, []);
await primeLibrary(); await primeLibrary();
const outcome = await showTrackDetailsForPath( const outcome = await showTrackDetailsForPath(
@@ -51,8 +51,12 @@ describe('track-details stays out of the startup chunk', () => {
}, },
); );
// Directly, or through `utils/track-details-opener`, which awaits
// it and is what the path-keyed openers share (#279).
it.each(OPENERS)('%s loads it at the point of use', (_name, source) => { it.each(OPENERS)('%s loads it at the point of use', (_name, source) => {
expect(source).toContain('loadTrackDetails'); expect(source).toMatch(
/loadTrackDetails|showTrackDetailsForPath|showBatchTrackDetailsForPaths/,
);
}); });
}); });
+209
View File
@@ -0,0 +1,209 @@
/**
* `trackCache` answers "which track is at this path" for the rows a
* surface is showing (#279), instead of every surface reaching into the
* whole library's array — which had to be fetched eagerly at startup
* for that to work, and silently did nothing when it had not been.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { trackCache } from '@store/track-cache';
import { libraryStore } from '@store/library-store';
import { showBatchTrackDetailsForPaths } from '@utils/track-details-opener';
import type { TrackDetails } from '@components/track-details/track-details';
import { Events } from '../../src/events';
import {
calls,
emit,
flush,
resetHarness,
stub,
stubFailure,
} from '@test/support/harness';
const BY_PATHS = 'library.Library.GetTracksByPaths';
function track(path: string, album = 'Album') {
return {
FilePath: path,
TrackName: path,
ArtistName: 'Artist',
Album: album,
PlayCount: 0,
LastPlayed: '',
CoverArtPath: '',
};
}
/** The backend: answers every path that starts with /lib/. */
function stubLibrary(): void {
stub(BY_PATHS, (paths: string[]) =>
paths.filter((p) => p.startsWith('/lib/')).map((p) => track(p)),
);
}
describe('track cache', () => {
beforeEach(() => {
// The cache is a singleton; a scan completing is how the app empties it.
emit(Events.LibraryScanComplete);
resetHarness();
stubLibrary();
});
it('answers in the order asked and leaves out paths not in the library', async () => {
const got = await trackCache.get(['/lib/b', '/gone', '/lib/a']);
expect(got.map((t) => t.FilePath)).toEqual(['/lib/b', '/lib/a']);
});
it('coalesces lookups made together into one call', async () => {
await Promise.all([
trackCache.get(['/lib/a']),
trackCache.get(['/lib/b', '/lib/a']),
trackCache.getOne('/lib/c'),
]);
expect(calls(BY_PATHS)).toHaveLength(1);
expect((calls(BY_PATHS)[0]!.args[0] as string[]).sort()).toEqual([
'/lib/a',
'/lib/b',
'/lib/c',
]);
});
it('answers a repeat from cache, and asks again after a retag of that file', async () => {
await trackCache.get(['/lib/a', '/lib/b']);
await trackCache.get(['/lib/a', '/lib/b']);
expect(calls(BY_PATHS)).toHaveLength(1);
emit(Events.TrackMetadataChanged, { filePath: '/lib/a' });
await trackCache.get(['/lib/b']);
expect(calls(BY_PATHS), 'an unchanged file is still cached').toHaveLength(1);
await trackCache.get(['/lib/a']);
expect(calls(BY_PATHS)).toHaveLength(2);
});
it('patches a play count in place without a call', async () => {
await trackCache.getOne('/lib/a');
emit(Events.TrackPlayCountChanged, {
filePath: '/lib/a',
playCount: 7,
lastPlayed: '2026-10-05 10:00:00',
});
const got = await trackCache.getOne('/lib/a');
expect([got?.PlayCount, got?.LastPlayed, calls(BY_PATHS).length]).toEqual([
7,
'2026-10-05 10:00:00',
1,
]);
});
it('does not cache an answer that crossed an invalidation', async () => {
let answer: (v: unknown) => void = () => undefined;
stub(BY_PATHS, () => new Promise((resolve) => {
answer = resolve;
}));
const pending = trackCache.getOne('/lib/a');
// Sent, not yet answered: the invalidation lands in between.
await flush();
expect(calls(BY_PATHS)).toHaveLength(1);
emit(Events.TrackMetadataChanged, { batch: true });
answer([track('/lib/a')]);
expect((await pending)?.FilePath, 'the caller still gets it').toBe('/lib/a');
stubLibrary();
await trackCache.getOne('/lib/a');
expect(calls(BY_PATHS)).toHaveLength(2);
});
it('rejects every waiter in a batch when the call fails', async () => {
stubFailure(BY_PATHS);
const results = await Promise.allSettled([
trackCache.getOne('/lib/a'),
trackCache.getOne('/lib/b'),
]);
expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']);
});
});
/*
* The bug half of #279: the queue, playlist and smart-playlist openers
* read `libraryStore.getCachedTracks()` and returned silently when it
* was null. The batch opener is what all three call now, so it is
* asserted against a library store that has nothing loaded.
*/
describe('opening details with no library list loaded', () => {
beforeEach(() => {
emit(Events.LibraryScanComplete);
resetHarness();
stubLibrary();
stub('library.Library.GetTracks', []);
});
it('shows the dialog with the tracks asked for', async () => {
await flush();
expect(libraryStore.getCachedTracks()?.length ?? 0).toBe(0);
let shown: unknown[] | null = null;
const dialog = {
showBatch: (tracks: unknown[]) => {
shown = tracks;
},
} as unknown as TrackDetails;
const outcome = await showBatchTrackDetailsForPaths(
() => dialog,
['/lib/a', '/lib/b'],
() => undefined,
);
expect(outcome).toBe('shown');
expect((shown ?? []).map((t) => (t as { FilePath: string }).FilePath)).toEqual([
'/lib/a',
'/lib/b',
]);
});
});
/** Every source file, as text. */
const SOURCES = import.meta.glob<string>('../../src/**/*.ts', {
eager: true,
query: '?raw',
import: 'default',
});
/**
* Who may read the whole library's track array. The store owns it and
* the Tracks view draws it; anything else that needs a track by path
* asks `trackCache`, or the array becomes a startup dependency again.
*/
const MAY_READ_ALL_TRACKS = [
'store/library-store.ts',
'store/controllers/library-controller.ts',
'components/track-list/track-list.ts',
];
const ALL_TRACKS_READ = /getCachedTracks\(|libraryStore\.getTracks\(|\bcachedTracks\b/;
describe('the whole-library track array has one reader', () => {
it('reads the sources it sweeps', () => {
expect(Object.keys(SOURCES).length).toBeGreaterThan(100);
});
it('is read only by the store and the Tracks view', () => {
const readers = Object.entries(SOURCES)
.filter(([, src]) => ALL_TRACKS_READ.test(src))
.map(([path]) => path.replace('../../src/', ''))
.sort();
expect(readers).toEqual([...MAY_READ_ALL_TRACKS].sort());
});
});