From 4a9fe1d20702d256745a41ba2b49a2e8ba18b072 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 5 Oct 2026 23:01:37 -0400 Subject: [PATCH] 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 --- backend/database/sql/queries/audio_files.sql | 3 + .../database/sql/sqlcgen/audio_files.sql.go | 65 +++++ backend/library/query.go | 42 +++ backend/library/trackpaths_test.go | 64 +++++ .../yellowjacket/backend/library/library.ts | 15 ++ .../playlist-details/playlist-details.ts | 70 +---- .../src/components/queue-panel/queue-panel.ts | 86 +------ .../smart-playlist-details.ts | 70 +---- .../components/track-details/track-details.ts | 72 ++---- frontend/src/store/track-cache.ts | 240 ++++++++++++++++++ frontend/src/utils/track-details-opener.ts | 60 ++++- .../components/explore-track-details.test.ts | 15 +- .../components/lazy-track-details.test.ts | 6 +- frontend/test/stores/track-cache.test.ts | 209 +++++++++++++++ 14 files changed, 747 insertions(+), 270 deletions(-) create mode 100644 backend/library/trackpaths_test.go create mode 100644 frontend/src/store/track-cache.ts create mode 100644 frontend/test/stores/track-cache.test.ts diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index de88294..4dc7a95 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -117,6 +117,9 @@ WHERE library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), li -- name: GetTrackByPath :one 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 SELECT * FROM track_metadata WHERE album_id = sqlc.arg(album_id) diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index df3dabf..846e3ee 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -830,6 +830,71 @@ func (q *Queries) GetTracksByGenre(ctx context.Context, arg GetTracksByGenrePara 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 SELECT id, file_path, title, artist_name, album, cover_art_path, artist_mbid, release_group_mbid, recording_mbid diff --git a/backend/library/query.go b/backend/library/query.go index efc05e0..b1574da 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -193,6 +193,48 @@ func (l *Library) GetTracks(libraryID int64) ([]Track, error) { 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. func (l *Library) SearchTracks(query string, libraryID int64) ([]Track, error) { rows, err := l.db.SearchFTSTracks(query, libraryID, searchTrackLimit) diff --git a/backend/library/trackpaths_test.go b/backend/library/trackpaths_test.go new file mode 100644 index 0000000..69226e1 --- /dev/null +++ b/backend/library/trackpaths_test.go @@ -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) + } +} diff --git a/frontend/bindings/yellowjacket/backend/library/library.ts b/frontend/bindings/yellowjacket/backend/library/library.ts index 0591049..349c325 100644 --- a/frontend/bindings/yellowjacket/backend/library/library.ts +++ b/frontend/bindings/yellowjacket/backend/library/library.ts @@ -222,6 +222,21 @@ export function GetTracksByGenre(genre: string, libraryID: number): $Cancellable 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. */ diff --git a/frontend/src/components/playlist-details/playlist-details.ts b/frontend/src/components/playlist-details/playlist-details.ts index f38f359..4614f6b 100644 --- a/frontend/src/components/playlist-details/playlist-details.ts +++ b/frontend/src/components/playlist-details/playlist-details.ts @@ -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, - ); } /** diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index 990ce2e..1bb1c2f 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -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 = () => { diff --git a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts index 5dd008b..049bc7c 100644 --- a/frontend/src/components/smart-playlist-details/smart-playlist-details.ts +++ b/frontend/src/components/smart-playlist-details/smart-playlist-details.ts @@ -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, - ); } // ================================================================= diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts index f63d6b2..d229aa5 100644 --- a/frontend/src/components/track-details/track-details.ts +++ b/frontend/src/components/track-details/track-details.ts @@ -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 = - '
' + - '' + - '
'; + 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); diff --git a/frontend/src/store/track-cache.ts b/frontend/src/store/track-cache.ts new file mode 100644 index 0000000..6e496ce --- /dev/null +++ b/frontend/src/store/track-cache.ts @@ -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(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(); diff --git a/frontend/src/utils/track-details-opener.ts b/frontend/src/utils/track-details-opener.ts index bb21e5a..af02150 100644 --- a/frontend/src/utils/track-details-opener.ts +++ b/frontend/src/utils/track-details-opener.ts @@ -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 { - 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 { + 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'; +} diff --git a/frontend/test/components/explore-track-details.test.ts b/frontend/test/components/explore-track-details.test.ts index aab73a9..c051ee2 100644 --- a/frontend/test/components/explore-track-details.test.ts +++ b/frontend/test/components/explore-track-details.test.ts @@ -28,7 +28,7 @@ import type { TrackDetails } from '@components/track-details/track-details'; const ALBUM_TRACKS = 'library.Library.GetAlbumTracks'; const COMPLETENESS = 'library.Library.GetAlbumCompleteness'; 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 BROWSE_RELEASES = 'explore.Service.BrowseReleases'; @@ -127,14 +127,13 @@ describe('Explore track details', () => { stub(ALBUM_TRACKS, [albumTrack]); stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 }); stub(FILE_PATHS, { [MBID]: [PATH] }); - stub(ALL_TRACKS, [libraryTrack]); + stub(TRACKS_BY_PATH, [libraryTrack]); }); /** - * `libraryStore` fetches at import and caches the empty list the - * shared setup stubs, for the life of the browser session — so a test - * that wants tracks in it has to say so. A scan-complete event is how - * the app itself invalidates that cache. + * `trackCache` keeps what it was answered for the life of the browser + * session, so a test that changes the answer has to drop it. A + * scan-complete event is how the app itself invalidates that cache. */ async function primeLibrary() { emit(Events.LibraryScanComplete); @@ -204,7 +203,7 @@ describe('Explore track details', () => { items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true })); // 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 // whose cost depends on what else the suite is doing. 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 () => { - stub(ALL_TRACKS, []); + stub(TRACKS_BY_PATH, []); await primeLibrary(); const outcome = await showTrackDetailsForPath( diff --git a/frontend/test/components/lazy-track-details.test.ts b/frontend/test/components/lazy-track-details.test.ts index 1c8de60..78cd979 100644 --- a/frontend/test/components/lazy-track-details.test.ts +++ b/frontend/test/components/lazy-track-details.test.ts @@ -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) => { - expect(source).toContain('loadTrackDetails'); + expect(source).toMatch( + /loadTrackDetails|showTrackDetailsForPath|showBatchTrackDetailsForPaths/, + ); }); }); diff --git a/frontend/test/stores/track-cache.test.ts b/frontend/test/stores/track-cache.test.ts new file mode 100644 index 0000000..8804e19 --- /dev/null +++ b/frontend/test/stores/track-cache.test.ts @@ -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('../../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()); + }); +});