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

+42
View File
@@ -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)
+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)
}
}