diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md index 1af553a..e9bd4f5 100644 --- a/.pi/skills/yellowjacket-dev/references/android-tier.md +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -647,7 +647,7 @@ window.__yj = { call(name, args) { That turns the device into a tier that can be *driven* rather than only looked at — `__yj.call("player.Player.LoadFile", [path])` and `__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how -#53 was measured. Names are the Go ones (`GetTracks`, not +#53 was measured. Names are the Go ones (`GetTrackTable`, not `GetAllTracks`); an unknown one comes back as a plain `unknown bound method name`, so a wrong guess is loud. diff --git a/backend/library/query.go b/backend/library/query.go index b1574da..02259b3 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -2,7 +2,6 @@ package library import ( "database/sql" - "errors" "fmt" "os" "path/filepath" @@ -18,8 +17,6 @@ import ( // searchTrackLimit bounds an FTS search's result set. const searchTrackLimit = 500 -var errNoTracksInLibrary = errors.New("no tracks in library") - // Track is one audio file with everything a list needs to draw it. type Track struct { TrackName string @@ -169,30 +166,6 @@ func (l *Library) GetTrackMBIDs(filePath string) TrackMBIDs { } } -// GetTracks returns every track in a library, or in all of them when -// libraryID is 0. -// -// The library id is a parameter rather than a second method because the -// two used to be separate queries, separate bindings and a branch at -// every call site - and the scoped form costs nothing (measured: 23 ms -// against 21 ms over 26k rows). -func (l *Library) GetTracks(libraryID int64) ([]Track, error) { - rows, err := l.db.ReadQueries.GetTracks(l.ctx, libraryID) - if err != nil { - l.logger.Error("could not retrieve audio files", "error", err) - - return nil, fmt.Errorf("could not get tracks: %w", err) - } - - l.logger.Info("audio file list", "count", len(rows), "libraryID", libraryID) - - if len(rows) == 0 { - return nil, errNoTracksInLibrary - } - - return tracksFromRows(rows), nil -} - // pathLookupChunk bounds the paths bound into one IN (...) query, well // under SQLite's bind-variable limit. const pathLookupChunk = 500 diff --git a/backend/library/scan_fixtures_test.go b/backend/library/scan_fixtures_test.go index 8f43720..e1b7c51 100644 --- a/backend/library/scan_fixtures_test.go +++ b/backend/library/scan_fixtures_test.go @@ -58,15 +58,15 @@ func TestScan_FixtureLibraryLeavesNothingBehind(t *testing.T) { t.Skip("fixture library is empty; run make testdata") } - tracks, err := lib.GetTracks(0) + table, err := lib.GetTrackTable(0) if err != nil { - t.Fatalf("GetTracks: %v", err) + t.Fatalf("GetTrackTable: %v", err) } // One track per file: the projection cannot multiply rows, because // there is no join table left to multiply them. - if int64(len(tracks)) != files { - t.Errorf("GetTracks returned %d rows for %d files", len(tracks), files) + if int64(len(table.FilePath)) != files { + t.Errorf("GetTrackTable returned %d rows for %d files", len(table.FilePath), files) } // Nothing shared outlives what refers to it. diff --git a/backend/library/tracktable.go b/backend/library/tracktable.go new file mode 100644 index 0000000..bc701c7 --- /dev/null +++ b/backend/library/tracktable.go @@ -0,0 +1,190 @@ +package library + +import ( + "fmt" + "strconv" + "strings" +) + +// TrackTable is every track in a library as the Tracks view uses it: +// one array per column, and every repeated string stored once (#281). +// +// GetTracks used to answer with one object per track, which at 26 138 +// tracks was 20.5 MB of JSON — ~350 bytes a row of key names, four +// cover URLs identical across an album, and artist, album and genre +// strings repeated on every track of the album. Encoding it cost the +// backend ~170 MB of transient allocation and parsing it was the +// WebView's peak. Here the keys appear once, a repeated string is a +// small integer, and the columns the Tracks view does not read are not +// sent at all: LastPlayed and the three larger cover tiers belong to +// the details dialog, which fetches whole tracks by path. +// +// The projection is still trackFromRow's — each row goes through it — +// so this is an encoding of a Track, never a second description of +// one. frontend/src/utils/track-table.ts is the only decoder. +type TrackTable struct { + // Strings holds every distinct string value; a string column holds + // indexes into it. Index 0 is always "". + Strings []string `json:"strings"` + // GenreSets holds every distinct genre list, as indexes into + // Strings; Genre holds an index into it per track. + GenreSets [][]uint32 `json:"genreSets"` + + FilePath []string `json:"filePath"` + TrackName []uint32 `json:"trackName"` + ArtistName []uint32 `json:"artistName"` + Album []uint32 `json:"album"` + Composer []uint32 `json:"composer"` + FileType []uint32 `json:"fileType"` + Genre []uint32 `json:"genre"` + ArtistMBID []uint32 `json:"artistMbid"` + ReleaseGroupMBID []uint32 `json:"releaseGroupMbid"` + RecordingMBID []uint32 `json:"recordingMbid"` + CoverArtSmall []uint32 `json:"coverArtSmall"` + + // LengthMs is Track.TrackLength as the number it encodes. + LengthMs []int64 `json:"lengthMs"` + TrackNumber []int64 `json:"trackNumber"` + DiscNumber []int64 `json:"discNumber"` + Year []int64 `json:"year"` + SampleRate []int64 `json:"sampleRate"` + BitDepth []int64 `json:"bitDepth"` + Channels []int64 `json:"channels"` + Bitrate []int64 `json:"bitrate"` + FileSize []int64 `json:"fileSize"` + PlayCount []int64 `json:"playCount"` +} + +// trackTableBuilder interns strings and genre lists while rows are +// appended. +type trackTableBuilder struct { + table TrackTable + strings map[string]uint32 + genres map[string]uint32 +} + +func newTrackTableBuilder(capacity int) *trackTableBuilder { + b := &trackTableBuilder{ + strings: map[string]uint32{"": 0}, + genres: map[string]uint32{}, + } + + t := &b.table + t.Strings = []string{""} + t.FilePath = make([]string, 0, capacity) + + for _, col := range b.stringColumns() { + *col = make([]uint32, 0, capacity) + } + + for _, col := range b.intColumns() { + *col = make([]int64, 0, capacity) + } + + t.Genre = make([]uint32, 0, capacity) + + return b +} + +// stringColumns are the interned columns, in one place so the builder +// cannot allocate one and forget to fill it. +func (b *trackTableBuilder) stringColumns() []*[]uint32 { + t := &b.table + + return []*[]uint32{ + &t.TrackName, &t.ArtistName, &t.Album, &t.Composer, &t.FileType, + &t.ArtistMBID, &t.ReleaseGroupMBID, &t.RecordingMBID, &t.CoverArtSmall, + } +} + +func (b *trackTableBuilder) intColumns() []*[]int64 { + t := &b.table + + return []*[]int64{ + &t.LengthMs, &t.TrackNumber, &t.DiscNumber, &t.Year, &t.SampleRate, + &t.BitDepth, &t.Channels, &t.Bitrate, &t.FileSize, &t.PlayCount, + } +} + +func (b *trackTableBuilder) intern(s string) uint32 { + if i, ok := b.strings[s]; ok { + return i + } + + i := uint32(len(b.table.Strings)) //nolint:gosec // bounded by the row count + + b.table.Strings = append(b.table.Strings, s) + b.strings[s] = i + + return i +} + +func (b *trackTableBuilder) internGenres(genres []string) uint32 { + key := strings.Join(genres, genreDelimiter) + + if i, ok := b.genres[key]; ok { + return i + } + + set := make([]uint32, len(genres)) + for j, g := range genres { + set[j] = b.intern(g) + } + + i := uint32(len(b.table.GenreSets)) //nolint:gosec // bounded by the row count + + b.table.GenreSets = append(b.table.GenreSets, set) + b.genres[key] = i + + return i +} + +func (b *trackTableBuilder) add(tr Track) error { + lengthMs, err := strconv.ParseInt(tr.TrackLength, 10, 64) + if err != nil { + return fmt.Errorf("track %q length %q: %w", tr.FilePath, tr.TrackLength, err) + } + + t := &b.table + t.FilePath = append(t.FilePath, tr.FilePath) + + strs := []string{ + tr.TrackName, tr.ArtistName, tr.Album, tr.Composer, tr.FileType, + tr.ArtistMBID, tr.ReleaseGroupMBID, tr.RecordingMBID, tr.CoverArtSmall, + } + for i, col := range b.stringColumns() { + *col = append(*col, b.intern(strs[i])) + } + + ints := []int64{ + lengthMs, tr.TrackNumber, tr.DiscNumber, tr.Year, tr.SampleRate, + tr.BitDepth, tr.Channels, tr.Bitrate, tr.FileSize, tr.PlayCount, + } + for i, col := range b.intColumns() { + *col = append(*col, ints[i]) + } + + t.Genre = append(t.Genre, b.internGenres(tr.Genre)) + + return nil +} + +// GetTrackTable returns every track in a library, or in all of them +// when libraryID is 0, as a TrackTable. An empty library is an empty +// table, not an error. +func (l *Library) GetTrackTable(libraryID int64) (TrackTable, error) { + rows, err := l.db.ReadQueries.GetTracks(l.ctx, libraryID) + if err != nil { + return TrackTable{}, fmt.Errorf("could not get tracks: %w", err) + } + + b := newTrackTableBuilder(len(rows)) + + for i := range rows { + if err := b.add(trackFromRow(rows[i])); err != nil { + return TrackTable{}, err + } + } + + return b.table, nil +} diff --git a/backend/library/tracktable_test.go b/backend/library/tracktable_test.go new file mode 100644 index 0000000..864426e --- /dev/null +++ b/backend/library/tracktable_test.go @@ -0,0 +1,198 @@ +package library + +import ( + "encoding/json" + "fmt" + "strconv" + "testing" + + "yellowjacket/backend/database" +) + +// seedTableLibrary seeds n albums of perAlbum tracks each, with a cover +// on every album, so the table has the repetition it exists to remove. +func seedTableLibrary(t *testing.T, lib *Library, albums, perAlbum int) { + t.Helper() + + for a := range albums { + for n := range perAlbum { + database.InsertTestTrack(t, lib.db, database.TestTrack{ + FilePath: fmt.Sprintf( + "/music/artist-%d/album-%d/%02d - Some Track Title.flac", a%7, a, n+1, + ), + Title: fmt.Sprintf("Some Track Title %d", n+1), + Artist: fmt.Sprintf("Artist %d", a%7), + ArtistMBID: fmt.Sprintf("0b7a8d2e-0000-4000-8000-%012d", a%7), + Album: fmt.Sprintf("Album Name %d", a), + AlbumMBID: fmt.Sprintf("1c7a8d2e-0000-4000-8000-%012d", a), + RecordingMBID: fmt.Sprintf("2d7a8d2e-0000-4000-8000-%012d", a*100+n), + Genres: []string{"Ambient", fmt.Sprintf("Genre %d", a%3)}, + TrackNumber: int64(n + 1), + DiscNumber: 1, + Year: 2000 + int64(a%20), + LengthMs: 200_000 + int64(n), + PlayCount: int64(n), + }) + } + + res, err := lib.db.ExecContext( + "INSERT INTO cover_art (file_path, mime_type) VALUES (?, 'image/jpeg')", + fmt.Sprintf("/data/covers/%064d.jpg", a), + ) + if err != nil { + t.Fatalf("seed cover: %v", err) + } + + coverID, _ := res.LastInsertId() + + if _, err := lib.db.ExecContext( + "UPDATE albums SET cover_art_id = ? WHERE name = ?", + coverID, fmt.Sprintf("Album Name %d", a), + ); err != nil { + t.Fatalf("seed album cover: %v", err) + } + } +} + +// decodeTable is the Go mirror of frontend/src/utils/track-table.ts, +// for asserting the encoding loses nothing it claims to carry. +func decodeTable(t *testing.T, tbl TrackTable) []Track { + t.Helper() + + str := func(col []uint32, i int) string { return tbl.Strings[col[i]] } + tracks := make([]Track, len(tbl.FilePath)) + + for i := range tracks { + var genres []string + for _, g := range tbl.GenreSets[tbl.Genre[i]] { + genres = append(genres, tbl.Strings[g]) + } + + tracks[i] = Track{ + FilePath: tbl.FilePath[i], + TrackName: str(tbl.TrackName, i), + ArtistName: str(tbl.ArtistName, i), + Album: str(tbl.Album, i), + Composer: str(tbl.Composer, i), + FileType: str(tbl.FileType, i), + ArtistMBID: str(tbl.ArtistMBID, i), + ReleaseGroupMBID: str(tbl.ReleaseGroupMBID, i), + RecordingMBID: str(tbl.RecordingMBID, i), + CoverArtSmall: str(tbl.CoverArtSmall, i), + Genre: genres, + TrackLength: strconv.FormatInt(tbl.LengthMs[i], 10), + TrackNumber: tbl.TrackNumber[i], + DiscNumber: tbl.DiscNumber[i], + Year: tbl.Year[i], + SampleRate: tbl.SampleRate[i], + BitDepth: tbl.BitDepth[i], + Channels: tbl.Channels[i], + Bitrate: tbl.Bitrate[i], + FileSize: tbl.FileSize[i], + PlayCount: tbl.PlayCount[i], + } + } + + return tracks +} + +// #281: the table is an encoding of trackFromRow's Track, minus the +// fields the Tracks view does not read. Decoding it must give back +// exactly that, row for row. +func TestTrackTableRoundTrip(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + seedTableLibrary(t, lib, 6, 4) + + tbl, err := lib.GetTrackTable(0) + if err != nil { + t.Fatalf("GetTrackTable: %v", err) + } + + rows, err := lib.db.ReadQueries.GetTracks(lib.ctx, 0) + if err != nil { + t.Fatalf("GetTracks: %v", err) + } + + if len(rows) != 24 || len(tbl.FilePath) != len(rows) { + t.Fatalf("table has %d rows, query %d, want 24", len(tbl.FilePath), len(rows)) + } + + got := decodeTable(t, tbl) + + for i, row := range rows { + want := trackFromRow(row) + // Not carried: the details dialog fetches these by path. + want.LastPlayed, want.CoverArtPath, want.CoverArtMedium, want.CoverArtLarge = "", "", "", "" + + if fmt.Sprintf("%+v", got[i]) != fmt.Sprintf("%+v", want) { + t.Fatalf("row %d:\n got %+v\nwant %+v", i, got[i], want) + } + } + + // It was the cover, genre and name repetition that was paid for; a + // table that interned nothing would still round-trip. + if len(tbl.GenreSets) != 3 { + t.Errorf("genre sets = %d, want 3 distinct lists", len(tbl.GenreSets)) + } + + if a, b := tbl.CoverArtSmall[0], tbl.CoverArtSmall[1]; a != b || tbl.Strings[a] == "" { + t.Errorf("two tracks of one album hold cover indexes %d and %d", a, b) + } +} + +// The point of the table, pinned: the same rows encode to a fraction of +// the object-per-track JSON they replace. A new column has to fit +// under this or raise it on purpose. +func TestTrackTableSizeBudget(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + seedTableLibrary(t, lib, 40, 10) + + tbl, err := lib.GetTrackTable(0) + if err != nil { + t.Fatalf("GetTrackTable: %v", err) + } + + rows, err := lib.db.ReadQueries.GetTracks(lib.ctx, 0) + if err != nil { + t.Fatalf("GetTracks: %v", err) + } + + asTable, err := json.Marshal(tbl) + if err != nil { + t.Fatal(err) + } + + asObjects, err := json.Marshal(tracksFromRows(rows)) + if err != nil { + t.Fatal(err) + } + + perTrack := len(asTable) / len(rows) + ratio := float64(len(asTable)) / float64(len(asObjects)) + + t.Logf("table %d B (%d B/track), objects %d B, ratio %.2f", + len(asTable), perTrack, len(asObjects), ratio) + + if ratio > 0.25 { + t.Errorf("table is %.0f%% of the object encoding, budget 25%%", ratio*100) + } +} + +func TestTrackTableEmptyLibraryIsAnAnswer(t *testing.T) { + t.Parallel() + + lib, _ := setupTestLibrary(t) + + tbl, err := lib.GetTrackTable(0) + if err != nil { + t.Fatalf("an empty library is an empty table, not an error: %v", err) + } + + if len(tbl.FilePath) != 0 || len(tbl.Strings) != 1 { + t.Errorf("empty table = %+v", tbl) + } +} diff --git a/e2e/perf/measure.mjs b/e2e/perf/measure.mjs index ac4b125..0cff8d6 100644 --- a/e2e/perf/measure.mjs +++ b/e2e/perf/measure.mjs @@ -224,7 +224,13 @@ async function measureStartup(page) { scriptBytesBeforePaint, requests: res.length, crossOriginRequests: crossOrigin.length, - crossOriginHosts: [...new Set(crossOrigin.map((u) => new URL(u).host))], + crossOriginHosts: [...new Set(crossOrigin.map((u) => { + try { + return new URL(u).host; + } catch { + return u; + } + }))], }; }); @@ -337,7 +343,8 @@ async function measureTrackChange(page) { await ev.call('queue.Queue.Clear', [], 10000).catch(() => {}); - const tracks = await ev.call('library.Library.GetAllTracks', [], 60000); + // The columnar list (#281): only the paths are needed here. + const tracks = ((await ev.call('library.Library.GetTrackTable', [0], 60000))?.filePath ?? []).map((FilePath) => ({ FilePath })); const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath); if (paths.length < 2) return { error: 'library too small to measure' }; @@ -435,7 +442,8 @@ async function measureFavouriteToggle(page) { const ev = window.__yjEvents; const perf = window.__yjPerf; - const tracks = await ev.call('library.Library.GetAllTracks', [], 60000); + // The columnar list (#281): only the paths are needed here. + const tracks = ((await ev.call('library.Library.GetTrackTable', [0], 60000))?.filePath ?? []).map((FilePath) => ({ FilePath })); const paths = (tracks ?? []).map((t) => t.FilePath); if (paths.length < per * count) { return { error: `library too small: ${paths.length} tracks` }; @@ -671,7 +679,8 @@ async function measurePlaylistOpen(page, client) { let pl = (existing ?? []).find((p) => (p.Name ?? p.name) === name); if (!pl) { - const tracks = await ev.call('library.Library.GetAllTracks', [], 60000); + // The columnar list (#281): only the paths are needed here. + const tracks = ((await ev.call('library.Library.GetTrackTable', [0], 60000))?.filePath ?? []).map((FilePath) => ({ FilePath })); const paths = (tracks ?? []).map((t) => t.FilePath); if (paths.length < n) return { error: `library too small: ${paths.length}` }; @@ -1600,7 +1609,8 @@ async function measurePlayerBarPass(page) { // Stage a loaded track. Deliberately the same first tracks the // track-change measurement already played, so this warms no cover // art that a later measurement counts requests for. - const tracks = await ev.call('library.Library.GetAllTracks', [], 60000); + // The columnar list (#281): only the paths are needed here. + const tracks = ((await ev.call('library.Library.GetTrackTable', [0], 60000))?.filePath ?? []).map((FilePath) => ({ FilePath })); const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath); if (paths.length < 2) return { error: 'library too small to measure' }; @@ -2000,7 +2010,11 @@ function compare(a, b) { const p = resolve(OUT_DIR, `${l}.json`); if (!existsSync(p)) throw new Error(`no measurement labelled '${l}' at ${p}`); - return JSON.parse(readFileSync(p, 'utf8')); + try { + return JSON.parse(readFileSync(p, 'utf8')); + } catch (err) { + throw new Error(`measurement '${l}' at ${p} is not valid JSON: ${err.message}`); + } }; console.log(table([load(a), load(b)])); diff --git a/e2e/specs/phone-now-playing.spec.ts b/e2e/specs/phone-now-playing.spec.ts index 29d1595..b3cba16 100644 --- a/e2e/specs/phone-now-playing.spec.ts +++ b/e2e/specs/phone-now-playing.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../support/fixtures.js'; +import { test, expect, libraryTracks } from '../support/fixtures.js'; /** * Now Playing on a short screen (#51). @@ -57,19 +57,15 @@ const REFLOW_AT = 500; /** Put a track in the player, so the view has art and names to lay out. */ async function stageATrack(page: Page): Promise { - await page.evaluate(async () => { - const tracks = (await window.__yjEvents.call( - 'library.Library.GetTracks', - [0], - 10_000, - )) as { FilePath: string }[]; + const paths = (await libraryTracks(page)).slice(0, 4).map((t) => t.FilePath); + await page.evaluate(async (paths) => { await window.__yjEvents.call( 'queue.Queue.SetQueue', - [tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }], + [paths, 0, false, { type: '', id: 0, label: '' }], 10_000, ); - }); + }, paths); } /** Open the full-screen view and wait for the shell to say so. */ diff --git a/e2e/specs/phone-progress-line.spec.ts b/e2e/specs/phone-progress-line.spec.ts index 006e79f..47b8182 100644 --- a/e2e/specs/phone-progress-line.spec.ts +++ b/e2e/specs/phone-progress-line.spec.ts @@ -1,4 +1,5 @@ import { + libraryTracks, test, expect, callBinding, @@ -49,11 +50,7 @@ async function rectOf(app: Page, selector: string): Promise { * three rectangles. */ async function play(app: Page): Promise { - const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>( - app, - 'library.Library.GetTracks', - [0], - ); + const tracks = await libraryTracks(app); // `TrackName`, not `Title`: that is what the library model calls it. const long = tracks.find((t) => t.TrackName === LONG_TRACK); diff --git a/e2e/specs/phone-shell.spec.ts b/e2e/specs/phone-shell.spec.ts index 7653c34..5ed5721 100644 --- a/e2e/specs/phone-shell.spec.ts +++ b/e2e/specs/phone-shell.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, LONG_TRACK } from '../support/fixtures.js'; +import { test, expect, LONG_TRACK, libraryTracks } from '../support/fixtures.js'; /** * The phone shell (plan 016 B2, phase 1). @@ -235,15 +235,8 @@ test.describe('the shell on a phone', () => { // nothing in particular and picked a 2-second track, which had // finished before the assertions ran. The placeholder check below // is what actually holds the property this test needs. - const started = await app.evaluate(async (longTitle) => { - const tracks = (await window.__yjEvents.call( - 'library.Library.GetTracks', - [0], - 10_000, - )) as { FilePath: string; TrackName: string }[]; - - const bare = tracks.find((t) => t.TrackName === longTitle); - + const bare = (await libraryTracks(app)).find((t) => t.TrackName === LONG_TRACK); + const started = await app.evaluate(async (bare) => { if (!bare) return null; await window.__yjEvents.call( @@ -254,7 +247,7 @@ test.describe('the shell on a phone', () => { await window.__yjEvents.call('queue.Queue.Play', [], 5_000); return bare.TrackName; - }, LONG_TRACK); + }, bare); expect(started).toBe(LONG_TRACK); diff --git a/e2e/specs/queue-reorder.spec.ts b/e2e/specs/queue-reorder.spec.ts index cd37cf7..b8f19a5 100644 --- a/e2e/specs/queue-reorder.spec.ts +++ b/e2e/specs/queue-reorder.spec.ts @@ -1,4 +1,5 @@ import { + libraryTracks, test, expect, callBinding, @@ -30,18 +31,7 @@ async function order(app: Page): Promise { } async function queueFourAndOpen(app: Page): Promise { - const paths: string[] = await app.evaluate(async () => { - // One argument, and 0 means every library: the scoped and - // unscoped list queries collapsed into one when the schema did - // (plan 013 R3), so `GetTracks()` no longer exists to call. - const tracks = await window.__yjEvents.call( - 'library.Library.GetTracks', - [0], - 10_000, - ); - - return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath); - }); + const paths = (await libraryTracks(app)).slice(0, 4).map((t) => t.FilePath); await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]); diff --git a/e2e/specs/queue-selection.spec.ts b/e2e/specs/queue-selection.spec.ts index 0e5023a..223056f 100644 --- a/e2e/specs/queue-selection.spec.ts +++ b/e2e/specs/queue-selection.spec.ts @@ -1,4 +1,5 @@ import { + libraryTracks, test, expect, callBinding, @@ -86,15 +87,11 @@ const selected = (app: Page) => * is read back. */ async function queueSixAndOpen(app: Page): Promise { - const paths = await app.evaluate(async (longTitle) => { - // `TrackName`, not `Title`: the library model names it after the - // tag, and the *queue* is what calls it `title`. - const tracks = (await window.__yjEvents.call( - 'library.Library.GetTracks', - [0], - 10_000, - )) as { FilePath: string; TrackName: string; Album: string }[]; - + // `TrackName`, not `Title`: the library model names it after the + // tag, and the *queue* is what calls it `title`. + const tracks = await libraryTracks(app); + const longTitle = LONG_TRACK; + const paths = (() => { const long = tracks.find((t) => t.TrackName === longTitle); /** @@ -131,7 +128,7 @@ async function queueSixAndOpen(app: Page): Promise { long!.FilePath, ...rest.slice(3).map((t) => t.FilePath), ]; - }, LONG_TRACK); + })(); await callBinding(app, 'queue.Queue.SetQueue', [ paths, diff --git a/e2e/support/fixtures.ts b/e2e/support/fixtures.ts index e495687..ddd6f38 100644 --- a/e2e/support/fixtures.ts +++ b/e2e/support/fixtures.ts @@ -96,6 +96,36 @@ export async function callBinding( ) as Promise; } +/** A library track as the specs use it: the path and its names. */ +export interface LibraryTrack { + FilePath: string; + TrackName: string; + Album: string; +} + +/** + * Every track in the library, in the order the backend lists them. + * + * The list arrives as a columnar `TrackTable` since #281 (repeated + * strings sent once, as indexes into `strings`), so it cannot be read + * as an array of tracks any more. This reads the three columns the + * specs use; `frontend/src/utils/track-table.ts` is the real decoder. + */ +export async function libraryTracks(page: Page): Promise { + const t = await callBinding<{ + strings: string[]; + filePath: string[]; + trackName: number[]; + album: number[]; + }>(page, 'library.Library.GetTrackTable', [0]); + + return (t.filePath ?? []).map((FilePath, i) => ({ + FilePath, + TrackName: t.strings[t.trackName[i]!] ?? '', + Album: t.strings[t.album[i]!] ?? '', + })); +} + /** * The binding calls the *app* made, newest last, as `pkg.Type.Method`. * diff --git a/frontend/bindings/yellowjacket/backend/library/index.ts b/frontend/bindings/yellowjacket/backend/library/index.ts index d18a806..e974b53 100644 --- a/frontend/bindings/yellowjacket/backend/library/index.ts +++ b/frontend/bindings/yellowjacket/backend/library/index.ts @@ -18,5 +18,6 @@ export type { ScanMetrics, ScanWarning, Track, - TrackMBIDs + TrackMBIDs, + TrackTable } from "./models.js"; diff --git a/frontend/bindings/yellowjacket/backend/library/library.ts b/frontend/bindings/yellowjacket/backend/library/library.ts index 349c325..e7e9e53 100644 --- a/frontend/bindings/yellowjacket/backend/library/library.ts +++ b/frontend/bindings/yellowjacket/backend/library/library.ts @@ -203,16 +203,12 @@ export function GetTrackMBIDs(filePath: string): $CancellablePromise<$models.Tra } /** - * GetTracks returns every track in a library, or in all of them when - * libraryID is 0. - * - * The library id is a parameter rather than a second method because the - * two used to be separate queries, separate bindings and a branch at - * every call site - and the scoped form costs nothing (measured: 23 ms - * against 21 ms over 26k rows). + * GetTrackTable returns every track in a library, or in all of them + * when libraryID is 0, as a TrackTable. An empty library is an empty + * table, not an error. */ -export function GetTracks(libraryID: number): $CancellablePromise<$models.Track[] | null> { - return $Call.ByID(933082923, libraryID); +export function GetTrackTable(libraryID: number): $CancellablePromise<$models.TrackTable> { + return $Call.ByID(1179690926, libraryID); } /** diff --git a/frontend/bindings/yellowjacket/backend/library/models.ts b/frontend/bindings/yellowjacket/backend/library/models.ts index f2c9987..c1a14f9 100644 --- a/frontend/bindings/yellowjacket/backend/library/models.ts +++ b/frontend/bindings/yellowjacket/backend/library/models.ts @@ -239,3 +239,60 @@ export interface TrackMBIDs { "releaseGroupMbid": string; "artistMbid": string; } + +/** + * TrackTable is every track in a library as the Tracks view uses it: + * one array per column, and every repeated string stored once (#281). + * + * GetTracks used to answer with one object per track, which at 26 138 + * tracks was 20.5 MB of JSON — ~350 bytes a row of key names, four + * cover URLs identical across an album, and artist, album and genre + * strings repeated on every track of the album. Encoding it cost the + * backend ~170 MB of transient allocation and parsing it was the + * WebView's peak. Here the keys appear once, a repeated string is a + * small integer, and the columns the Tracks view does not read are not + * sent at all: LastPlayed and the three larger cover tiers belong to + * the details dialog, which fetches whole tracks by path. + * + * The projection is still trackFromRow's — each row goes through it — + * so this is an encoding of a Track, never a second description of + * one. frontend/src/utils/track-table.ts is the only decoder. + */ +export interface TrackTable { + /** + * Strings holds every distinct string value; a string column holds + * indexes into it. Index 0 is always "". + */ + "strings": string[] | null; + + /** + * GenreSets holds every distinct genre list, as indexes into + * Strings; Genre holds an index into it per track. + */ + "genreSets": (number[] | null)[] | null; + "filePath": string[] | null; + "trackName": number[] | null; + "artistName": number[] | null; + "album": number[] | null; + "composer": number[] | null; + "fileType": number[] | null; + "genre": number[] | null; + "artistMbid": number[] | null; + "releaseGroupMbid": number[] | null; + "recordingMbid": number[] | null; + "coverArtSmall": number[] | null; + + /** + * LengthMs is Track.TrackLength as the number it encodes. + */ + "lengthMs": number[] | null; + "trackNumber": number[] | null; + "discNumber": number[] | null; + "year": number[] | null; + "sampleRate": number[] | null; + "bitDepth": number[] | null; + "channels": number[] | null; + "bitrate": number[] | null; + "fileSize": number[] | null; + "playCount": number[] | null; +} diff --git a/frontend/src/components/track-list/columns.ts b/frontend/src/components/track-list/columns.ts index a0f8a38..dbcf19c 100644 --- a/frontend/src/components/track-list/columns.ts +++ b/frontend/src/components/track-list/columns.ts @@ -1,4 +1,4 @@ -import type * as library from '@go/library/models.js'; +import type { ListTrack } from '@utils/track-table'; import { formatSampleRate, formatBitDepth, @@ -45,7 +45,7 @@ export interface ColumnDef { */ configurable?: boolean; /** Extracts the display value from a track. */ - accessor: (track: library.Track) => string; + accessor: (track: ListTrack) => string; /** Default CSS width (used when no saved width exists). */ defaultWidth: string; /** Text alignment. Defaults to left. */ @@ -60,15 +60,15 @@ export interface ColumnDef { * needs it, which is why it is optional rather than a second * required parameter on all of them. */ - renderCell?: (track: library.Track, term?: string) => unknown; + renderCell?: (track: ListTrack, term?: string) => unknown; /** * Comparison function for sorting two tracks by this column. * Returns negative if a < b, positive if a > b, zero if equal. * If omitted the column is not sortable. */ comparator?: ( - a: library.Track, - b: library.Track, + a: ListTrack, + b: ListTrack, ) => number; } @@ -79,7 +79,7 @@ export const COLUMN_DEFS: Record = { label: 'Art', accessor: () => '', defaultWidth: '36px', - renderCell: (track: library.Track) => { + renderCell: (track: ListTrack) => { // `perf.M3`. This rendered `CoverArtPath` — the *original* // embedded artwork, commonly 1500×1500 and several hundred // kB — scaled by CSS into a 24 px box, while the 100 px @@ -90,10 +90,10 @@ export const COLUMN_DEFS: Record = { // `cover-grid.getCoverUrl()` has picked the right tier all // along; this is the same rule for a much smaller box, with // the two attributes that keep the decode off the scroll - // path. - const src = track.CoverArtSmall - || track.CoverArtMedium - || track.CoverArtPath; + // path. The list carries only this tier (#281): every tier + // is derived from the same file, so it is set whenever any + // of them would be. + const src = track.CoverArtSmall; if (!src) return nothing; diff --git a/frontend/src/components/track-list/search-ranking.ts b/frontend/src/components/track-list/search-ranking.ts index e295e33..20ba164 100644 --- a/frontend/src/components/track-list/search-ranking.ts +++ b/frontend/src/components/track-list/search-ranking.ts @@ -1,4 +1,4 @@ -import type * as library from '@go/library/models.js'; +import type { ListTrack } from '@utils/track-table'; import { html } from 'lit'; import type { TemplateResult } from 'lit'; @@ -99,7 +99,7 @@ function matchQuality( * fields are always included on top of these. */ function scoreTrack( - track: library.Track, + track: ListTrack, termLower: string, columns: ColumnDef[], ): number { @@ -147,7 +147,7 @@ function scoreTrack( /** A track paired with its relevance score. */ export interface RankedTrack { - track: library.Track; + track: ListTrack; score: number; } @@ -166,10 +166,10 @@ export interface RankedTrack { * `scores` (Map of FilePath → relevance score). */ export function rankTracks( - tracks: library.Track[], + tracks: ListTrack[], term: string, activeColumns: ColumnDef[], -): { tracks: library.Track[]; scores: Map } { +): { tracks: ListTrack[]; scores: Map } { const termLower = term.toLowerCase(); const ranked: RankedTrack[] = []; @@ -188,7 +188,7 @@ export function rankTracks( // Sort descending by score (highest relevance first). ranked.sort((a, b) => b.score - a.score); - const result: library.Track[] = []; + const result: ListTrack[] = []; const scores = new Map(); for (const r of ranked) { diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 11c12f0..e874619 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -1,5 +1,5 @@ -import * as library from '@go/library/models.js'; -import { LitElement, html, svg, css, nothing } from 'lit'; +import type { ListTrack } from '@utils/track-table'; +import { LitElement, html, svg, css, nothing, type TemplateResult } from 'lit'; import { designTokens } from '../../styles/tokens.css'; import { srOnly } from '../../styles/sr-only.css'; import { @@ -80,11 +80,10 @@ import { describeError } from '@utils/describe-error'; import { notificationStore } from '@store/notification-store'; import { confirmAction } from '@components/confirm-dialog/confirm-dialog'; import { RemoveFromLibrary } from '@go/library/library.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 { tracksByFilePath } from '@utils/track-index.js'; import '@components/playlist-picker/playlist-picker.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; -import type { CoverArtUrls } from '@components/track-details/track-details.js'; import { ICON_PLAY, ICON_PLAYLIST, @@ -155,7 +154,7 @@ export class TrackList * parent is responsible for reloading when data changes. */ @property({ type: Array, attribute: false }) - externalTracks?: library.Track[]; + externalTracks?: ListTrack[]; /** * What a host embedding this list (e.g. `genre-details`) should say @@ -199,7 +198,7 @@ export class TrackList private lastSearchTerm = ''; /** Tracks the store's cached array reference to detect refreshes. */ - private lastTracksRef: library.Track[] | null = + private lastTracksRef: ListTrack[] | null = null; /** @@ -242,7 +241,7 @@ export class TrackList } @state() - private tracks: library.Track[] = []; + private tracks: ListTrack[] = []; @query('#context-menu') private contextMenuPopup!: MenuSurface; @@ -269,16 +268,16 @@ export class TrackList private lastActiveTrackPath: string | null = null; // -- Memoisation caches for filtered / sorted tracks -- - private cachedFilteredTracks: library.Track[] = []; - private cachedSortedTracks: library.Track[] = []; + private cachedFilteredTracks: ListTrack[] = []; + private cachedSortedTracks: ListTrack[] = []; private cachedRelevanceScores = new Map< string, number >(); - private prevFilterTracks: library.Track[] = []; + private prevFilterTracks: ListTrack[] = []; private prevFilterTerm = ''; private prevFilterColIds = ''; - private prevSortFiltered: library.Track[] = []; + private prevSortFiltered: ListTrack[] = []; private prevSortField: string | null = null; private prevSortDir: SortDirection = 'asc'; @@ -523,7 +522,7 @@ export class TrackList } } - private computeFilteredTracks(): library.Track[] { + private computeFilteredTracks(): ListTrack[] { const term = this.searchCtrl.term; if (!term) { @@ -543,7 +542,7 @@ export class TrackList return result.tracks; } - private computeSortedTracks(): library.Track[] { + private computeSortedTracks(): ListTrack[] { const tracks = this.cachedFilteredTracks; const hasSearch = this.cachedRelevanceScores.size > 0; @@ -1734,7 +1733,7 @@ export class TrackList */ private resolveTrackFromEvent( e: Event, - ): { track: library.Track; index: number } | null { + ): { track: ListTrack; index: number } | null { const row = (e.target as HTMLElement).closest( '.track-row', ) as HTMLElement | null; @@ -1875,7 +1874,7 @@ export class TrackList private onTrackRowClick( e: MouseEvent, - track: library.Track, + track: ListTrack, index: number, ) { // Clicking is also how the keyboard's starting point is chosen: @@ -1884,7 +1883,7 @@ export class TrackList this.selection.handleItemClick(e, track.FilePath, index); } - private onTrackRowDblClick(_track: library.Track, index: number) { + private onTrackRowDblClick(_track: ListTrack, index: number) { this.selection.clear(); this.playFromRow(index); } @@ -1925,7 +1924,7 @@ export class TrackList ); } - private onTrackContextMenu(e: MouseEvent, track: library.Track) { + private onTrackContextMenu(e: MouseEvent, track: ListTrack) { e.preventDefault(); e.stopPropagation(); @@ -1939,7 +1938,7 @@ export class TrackList private onTrackDragStart = ( e: DragEvent, - track: library.Track, + track: ListTrack, ) => { // Gather file paths: all selected if this track is selected, // otherwise just the dragged track. @@ -2129,74 +2128,24 @@ export class TrackList this.ctxMenu.close(); } + // The list's rows do not carry what the dialog shows (#281), so + // the dialog gets whole tracks by path, like every other opener. private async openTrackDetails(filePath: string) { - const track = tracksByFilePath(this.tracks).get( + await showTrackDetailsForPath( + () => this.trackDetailsDialog, filePath, - ); - - if (!track) return; - - const ready = await loadTrackDetails( () => 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 tracks = tracksForPaths( - this.tracks, + await showBatchTrackDetailsForPaths( + () => this.trackDetailsDialog, filePaths, - ); - - if (tracks.length === 0) return; - - const ready = await loadTrackDetails( () => void this.openBatchTrackDetails(filePaths), ); - - if (!ready) return; - - // Use cover art from the first track. If all tracks share - // the same album, they share the same art. - const first = tracks[0]!; - let coverArt: CoverArtUrls | null = null; - let coverArtMixed = false; - - const albumNames = new Set(tracks.map((t) => t.Album)); - - 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, - ); } // ================================================================= @@ -2274,7 +2223,7 @@ export class TrackList this.saveSortPreferences(); } - private isActiveTrack(track: library.Track): boolean { + private isActiveTrack(track: ListTrack): boolean { const currentTrack = this.player.currentTrack; if (!currentTrack) return false; @@ -2283,9 +2232,9 @@ export class TrackList } private renderTrackRow = ( - track: library.Track, + track: ListTrack, index: number, - ): unknown => { + ): TemplateResult => { const active = this.isActiveTrack(track); const selected = this.selection.isSelected( track.FilePath, @@ -2568,7 +2517,7 @@ export class TrackList scroller .items=${visibleTracks} .renderItem=${this.renderTrackRow} - .keyFunction=${(track: library.Track) => track.FilePath} + .keyFunction=${(track: ListTrack) => track.FilePath} .layout=${this.rowLayout} > `} diff --git a/frontend/src/store/controllers/library-controller.ts b/frontend/src/store/controllers/library-controller.ts index dc00c3d..9458fd6 100644 --- a/frontend/src/store/controllers/library-controller.ts +++ b/frontend/src/store/controllers/library-controller.ts @@ -1,6 +1,7 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type * as library from '@go/library/models.js'; import { libraryStore } from '../library-store'; +import type { ListTrack } from '@utils/track-table'; type ViewName = 'tracks' | 'albums' | 'artists' | 'genres'; @@ -55,7 +56,7 @@ export class LibraryController implements ReactiveController { // DATA ACCESS // =================================================================== - async getTracks(): Promise { + async getTracks(): Promise { return libraryStore.getTracks(); } @@ -85,7 +86,7 @@ export class LibraryController implements ReactiveController { ); } - get cachedTracks(): library.Track[] | null { + get cachedTracks(): ListTrack[] | null { return libraryStore.getCachedTracks(); } diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts index 72ef0d0..29eea8c 100644 --- a/frontend/src/store/library-store.ts +++ b/frontend/src/store/library-store.ts @@ -1,6 +1,6 @@ import { EventsOn } from '@runtime/runtime'; import { - GetTracks, + GetTrackTable, GetAlbums, GetArtists, GetGenres, @@ -9,6 +9,7 @@ import { } from '@go/library/library.js'; import type * as library from '@go/library/models.js'; import { list } from '@utils/binding'; +import { decodeTrackTable, type ListTrack } from '@utils/track-table'; import { Events } from '../events'; type ViewName = 'tracks' | 'albums' | 'artists' | 'genres'; @@ -28,7 +29,7 @@ const COVER_SIZE_DEFAULT = 176; const COVER_SIZE_KEY = 'cover-grid-size'; class LibraryStore { - private tracks: library.Track[] | null = null; + private tracks: ListTrack[] | null = null; private albums: library.Album[] | null = null; private artists: library.Artist[] | null = null; private genres: library.GenreWithCount[] | null = null; @@ -214,18 +215,18 @@ class LibraryStore { } } - async getTracks(): Promise { + async getTracks(): Promise { if (this.tracks !== null) { return this.tracks; } - const pending = this.pending('tracks'); + const pending = this.pending('tracks'); if (pending) return pending; return this.track( 'tracks', - list(GetTracks(this.libraryFilter())), + GetTrackTable(this.libraryFilter()).then(decodeTrackTable), (tracks) => { this.tracks = tracks; }, @@ -341,7 +342,7 @@ class LibraryStore { // Synchronous access for controllers that need current cached values. // =================================================================== - getCachedTracks(): library.Track[] | null { + getCachedTracks(): ListTrack[] | null { return this.tracks; } @@ -520,10 +521,11 @@ class LibraryStore { private applyPlayCount(payload: unknown): void { if (this.tracks === null) return; + // The list does not carry LastPlayed (#281); trackCache patches + // it on the whole tracks the details dialog reads. const p = payload as { filePath?: string; playCount?: number; - lastPlayed?: string; } | null; if (!p?.filePath) return; @@ -536,14 +538,10 @@ class LibraryStore { if (existing === undefined) return; - const patched = Object.assign( - Object.create(Object.getPrototypeOf(existing) as object), - existing, - { - PlayCount: p.playCount ?? existing.PlayCount, - LastPlayed: p.lastPlayed ?? existing.LastPlayed, - }, - ) as library.Track; + const patched: ListTrack = { + ...existing, + PlayCount: p.playCount ?? existing.PlayCount, + }; this.tracks = [ ...this.tracks.slice(0, idx), diff --git a/frontend/src/utils/binding.ts b/frontend/src/utils/binding.ts index 1988e8d..55c8bc1 100644 --- a/frontend/src/utils/binding.ts +++ b/frontend/src/utils/binding.ts @@ -79,6 +79,14 @@ export function compact( return out; } +/** + * listField is list for a slice that arrived as a *field* of a struct + * rather than as a return value — a column of `TrackTable`, say. + */ +export function listField(field: T[] | null | undefined): T[] { + return field ?? []; +} + /** * value awaits a binding whose result is used as-is, dropping only the * cancellation the app never asks for. diff --git a/frontend/src/utils/track-index.ts b/frontend/src/utils/track-index.ts index 3685586..285d98c 100644 --- a/frontend/src/utils/track-index.ts +++ b/frontend/src/utils/track-index.ts @@ -20,22 +20,22 @@ * given array and never again. */ -import type * as library from '@go/library/models.js'; +/** Anything keyed by file path: a whole track, or the list's row. */ +interface HasFilePath { + FilePath: string; +} -const byArray = new WeakMap< - readonly library.Track[], - Map ->(); +const byArray = new WeakMap>(); /** The lookup for `tracks`, built once per array identity. */ -export function tracksByFilePath( - tracks: readonly library.Track[], -): Map { - let map = byArray.get(tracks); +export function tracksByFilePath( + tracks: readonly T[], +): Map { + let map = byArray.get(tracks) as Map | undefined; if (map) return map; - map = new Map(); + map = new Map(); for (const track of tracks) { // First wins: a duplicate path would be the same file, and @@ -49,12 +49,12 @@ export function tracksByFilePath( } /** Resolve file paths to tracks, dropping any that are not present. */ -export function tracksForPaths( - tracks: readonly library.Track[], +export function tracksForPaths( + tracks: readonly T[], filePaths: readonly string[], -): library.Track[] { +): T[] { const byPath = tracksByFilePath(tracks); - const result: library.Track[] = []; + const result: T[] = []; for (const filePath of filePaths) { const track = byPath.get(filePath); diff --git a/frontend/src/utils/track-table.ts b/frontend/src/utils/track-table.ts new file mode 100644 index 0000000..abc0c3f --- /dev/null +++ b/frontend/src/utils/track-table.ts @@ -0,0 +1,128 @@ +/** + * Decode the backend's `TrackTable` into the rows the Tracks view uses. + * + * The table is one array per column with every repeated string sent + * once (#281): ~167 bytes a track against the ~800 of the object-per- + * track JSON it replaced. This is its only decoder, and + * `backend/library/tracktable.go` its only encoder. + * + * Decoding builds plain objects of the same shape as before, so the + * filter, sort and selection code did not have to change — but every + * occurrence of a repeated string is now the *same* string, and every + * track with one genre list shares the array, which is part of why the + * JS heap shrinks along with the payload. Shared means read-only: no + * caller mutates a row's `Genre`. + */ + +import type * as library from '@go/library/models.js'; +import { listField } from '@utils/binding'; + +/** + * A track as the list carries it. The fields left out are the details + * dialog's, which reads whole tracks by path from `trackCache`. + */ +export type ListTrack = Omit< + library.Track, + 'LastPlayed' | 'CoverArtPath' | 'CoverArtMedium' | 'CoverArtLarge' +>; + +/** The `Track` fields a `ListTrack` does not carry. */ +export const LIST_TRACK_OMITS = [ + 'LastPlayed', + 'CoverArtPath', + 'CoverArtMedium', + 'CoverArtLarge', +] as const; + +/** A table whose columns disagree in length is a broken encoder, not data. */ +export class TrackTableError extends Error {} + +export function decodeTrackTable(table: library.TrackTable): ListTrack[] { + const strings = listField(table.strings); + const filePath = listField(table.filePath); + const n = filePath.length; + + const str = (col: number[] | null, name: string): string[] => { + const idx = listField(col); + + if (idx.length !== n) throw mismatch(name, idx.length, n); + + return idx.map((i) => { + const s = strings[i]; + + if (s === undefined) throw new TrackTableError(`${name}: string ${i} out of range`); + + return s; + }); + }; + const num = (col: number[] | null, name: string): number[] => { + const v = listField(col); + + if (v.length !== n) throw mismatch(name, v.length, n); + + return v; + }; + + const genreSets = listField(table.genreSets).map((set) => + listField(set).map((i) => strings[i] ?? ''), + ); + const genre = num(table.genre, 'genre'); + + const trackName = str(table.trackName, 'trackName'); + const artistName = str(table.artistName, 'artistName'); + const album = str(table.album, 'album'); + const composer = str(table.composer, 'composer'); + const fileType = str(table.fileType, 'fileType'); + const artistMbid = str(table.artistMbid, 'artistMbid'); + const releaseGroupMbid = str(table.releaseGroupMbid, 'releaseGroupMbid'); + const recordingMbid = str(table.recordingMbid, 'recordingMbid'); + const coverArtSmall = str(table.coverArtSmall, 'coverArtSmall'); + const lengthMs = num(table.lengthMs, 'lengthMs'); + const trackNumber = num(table.trackNumber, 'trackNumber'); + const discNumber = num(table.discNumber, 'discNumber'); + const year = num(table.year, 'year'); + const sampleRate = num(table.sampleRate, 'sampleRate'); + const bitDepth = num(table.bitDepth, 'bitDepth'); + const channels = num(table.channels, 'channels'); + const bitrate = num(table.bitrate, 'bitrate'); + const fileSize = num(table.fileSize, 'fileSize'); + const playCount = num(table.playCount, 'playCount'); + + const rows: ListTrack[] = new Array(n); + + for (let i = 0; i < n; i++) { + const g = genreSets[genre[i]!]; + + if (g === undefined) throw new TrackTableError(`genre: set ${genre[i]} out of range`); + + rows[i] = { + FilePath: filePath[i]!, + TrackName: trackName[i]!, + ArtistName: artistName[i]!, + Album: album[i]!, + Composer: composer[i]!, + FileType: fileType[i]!, + Genre: g, + ArtistMBID: artistMbid[i]!, + ReleaseGroupMBID: releaseGroupMbid[i]!, + RecordingMBID: recordingMbid[i]!, + CoverArtSmall: coverArtSmall[i]!, + TrackLength: String(lengthMs[i]!), + TrackNumber: trackNumber[i]!, + DiscNumber: discNumber[i]!, + Year: year[i]!, + SampleRate: sampleRate[i]!, + BitDepth: bitDepth[i]!, + Channels: channels[i]!, + Bitrate: bitrate[i]!, + FileSize: fileSize[i]!, + PlayCount: playCount[i]!, + }; + } + + return rows; +} + +function mismatch(name: string, got: number, want: number): TrackTableError { + return new TrackTableError(`${name}: ${got} values for ${want} tracks`); +} diff --git a/frontend/test/components/album-card-year.test.ts b/frontend/test/components/album-card-year.test.ts index ce57a48..bf4c4b4 100644 --- a/frontend/test/components/album-card-year.test.ts +++ b/frontend/test/components/album-card-year.test.ts @@ -19,6 +19,7 @@ import '@components/cover-grid/cover-grid'; import { emit, stub, flush, resetHarness } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadowAll } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; const LONG = 'The Rise and Fall of a Midwest Princess in the Key of Everything'; @@ -49,7 +50,7 @@ describe('the album card’s year', () => { beforeEach(() => { resetHarness(); stub('library.Library.GetAlbums', ALBUMS); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/album-dropdown.test.ts b/frontend/test/components/album-dropdown.test.ts index 8931287..c92adc9 100644 --- a/frontend/test/components/album-dropdown.test.ts +++ b/frontend/test/components/album-dropdown.test.ts @@ -22,6 +22,7 @@ import '@components/cover-grid/cover-grid'; import { emit, stub, flush, resetHarness } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadow, shadowAll } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; /** * Enough albums to fill more than one row. @@ -82,7 +83,7 @@ describe('the album dropdown', () => { beforeEach(() => { resetHarness(); stub('library.Library.GetAlbums', ALBUMS); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); stub('library.Library.GetAlbumTracks', TRACKS); stub('library.Library.GetAlbumTracks', TRACKS); emit(Events.LibraryScanComplete); @@ -155,7 +156,7 @@ describe('the albums grid scrolls', () => { beforeEach(() => { resetHarness(); stub('library.Library.GetAlbums', ALBUMS); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/aria-tail.test.ts b/frontend/test/components/aria-tail.test.ts index 3d14a14..5cd1f10 100644 --- a/frontend/test/components/aria-tail.test.ts +++ b/frontend/test/components/aria-tail.test.ts @@ -20,6 +20,7 @@ import { emit, stub, flush, resetHarness } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadow, shadowAll } from '@test/support/render'; import { searchStore } from '@store/search-store'; +import { trackTable } from '@test/support/track-table'; /** * The searchable columns' accessors read these fields and call @@ -76,7 +77,7 @@ describe('the track list says how it is sorted', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -132,7 +133,7 @@ describe('the track list has a voice for its own state', () => { }); it('announces the result of a search that matches nothing', async () => { - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); emit(Events.LibraryScanComplete); const el = await fixture('track-list'); @@ -163,7 +164,7 @@ describe('a selectable grid is a listbox, not a row of buttons', () => { searchStore.setTerm(''); stub('library.Library.GetArtists', ARTISTS); stub('library.Library.GetGenres', GENRES); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -197,7 +198,7 @@ describe('a clipped value is readable somewhere', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); @@ -240,7 +241,7 @@ describe('the playing row is more than a colour', () => { beforeEach(async () => { resetHarness(); searchStore.setTerm(''); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAlbums', []); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/art-prefetch.test.ts b/frontend/test/components/art-prefetch.test.ts index 80b88e0..b7922ca 100644 --- a/frontend/test/components/art-prefetch.test.ts +++ b/frontend/test/components/art-prefetch.test.ts @@ -35,6 +35,7 @@ import { imagePrefetched, resetImagePrefetch, } from '@utils/image-prefetch'; +import { trackTable } from '@test/support/track-table'; /** Enough albums that the virtualizer's own window is nowhere near the end. */ const ALBUMS = Array.from({ length: 400 }, (_, i) => { @@ -118,7 +119,7 @@ beforeEach(() => { localStorage.clear(); stub('library.Library.GetAlbums', ALBUMS); stub('library.Library.GetArtists', ARTISTS); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); stub('library.Library.GetGenres', []); emit(Events.LibraryScanComplete); }); diff --git a/frontend/test/components/card-grid-repaint.test.ts b/frontend/test/components/card-grid-repaint.test.ts index 0894daa..26033b8 100644 --- a/frontend/test/components/card-grid-repaint.test.ts +++ b/frontend/test/components/card-grid-repaint.test.ts @@ -29,6 +29,7 @@ import '@components/genres-view/genres-view'; import { emit, stub, flush, resetHarness } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadowAll } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; const ARTISTS = [ { ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 }, @@ -58,7 +59,7 @@ describe('a card grid shows its selection', () => { resetHarness(); stub('library.Library.GetArtists', ARTISTS); stub('library.Library.GetGenres', GENRES); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); stub('library.Library.GetAlbums', []); // The views read through LibraryController, whose cache is only // primed by a scan-complete; without it they render nothing and the diff --git a/frontend/test/components/chrome.test.ts b/frontend/test/components/chrome.test.ts index b2dc871..a60bf76 100644 --- a/frontend/test/components/chrome.test.ts +++ b/frontend/test/components/chrome.test.ts @@ -191,7 +191,7 @@ describe('', () => { select?.dispatchEvent(new Event('change')); await flush(); - expect(lastArgs('library.Library.GetTracks')).toEqual([8]); + expect(lastArgs('library.Library.GetTrackTable')).toEqual([8]); }); it('picks up a library added while it was on screen', async () => { diff --git a/frontend/test/components/detail-touch-targets.test.ts b/frontend/test/components/detail-touch-targets.test.ts index 9cc5120..d308836 100644 --- a/frontend/test/components/detail-touch-targets.test.ts +++ b/frontend/test/components/detail-touch-targets.test.ts @@ -72,7 +72,7 @@ function boxOf(el: Element | null | undefined): { w: number; h: number } { describe('the way out of a detail view', () => { beforeEach(() => { for (const path of [ - 'library.Library.GetTracks', + 'library.Library.GetTrackTable', 'library.Library.GetAlbums', 'library.Library.GetArtists', 'library.Library.GetGenres', diff --git a/frontend/test/components/empty-states.test.ts b/frontend/test/components/empty-states.test.ts index 46fab5b..27b2ca9 100644 --- a/frontend/test/components/empty-states.test.ts +++ b/frontend/test/components/empty-states.test.ts @@ -11,11 +11,12 @@ import '@components/track-list/track-list'; import { Events } from '../../src/events'; import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness'; import { fixture, shadow, text } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; /** Drop the library store's cache so the list has to fetch. */ async function emptyLibrary(): Promise { resetHarness(); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); stub('library.Library.GetAlbums', []); stub('library.Library.GetArtists', []); stub('library.Library.GetGenres', []); @@ -40,7 +41,7 @@ describe(' empty, loading and failed', () => { }); it('says the query failed, and offers to try again', async () => { - stubFailure('library.Library.GetTracks', 'sql: database is locked'); + stubFailure('library.Library.GetTrackTable', 'sql: database is locked'); emit(Events.LibraryScanComplete); await flush(); diff --git a/frontend/test/components/keyboard-reach.test.ts b/frontend/test/components/keyboard-reach.test.ts index 1aabdd0..62333c8 100644 --- a/frontend/test/components/keyboard-reach.test.ts +++ b/frontend/test/components/keyboard-reach.test.ts @@ -14,6 +14,7 @@ import '@components/track-list/track-list'; import { stub, emit, flush } from '@test/support/harness'; import { Events } from '../../src/events'; import { fixture, shadow, shadowAll, update } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; /** Two fixture tracks, enough to move a focus ring between. */ const TRACKS = [ @@ -78,7 +79,7 @@ describe(' when closed', () => { describe(' roving tabindex', () => { beforeEach(() => { - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); }); it('offers exactly one tab stop, however many rows there are', async () => { diff --git a/frontend/test/components/library-status.test.ts b/frontend/test/components/library-status.test.ts index 6945552..89f5b04 100644 --- a/frontend/test/components/library-status.test.ts +++ b/frontend/test/components/library-status.test.ts @@ -31,6 +31,7 @@ import { stubFailure, } from '@test/support/harness'; import { fixture, shadow, shadowAll, update } from '@test/support/render'; +import { trackTable } from '@test/support/track-table'; const SEARCH = 'explore.Service.SearchLocal'; @@ -133,7 +134,7 @@ describe(' badges', () => { stub('explore.Service.GetArtistImageURL', ''); stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' }); stub('library.Library.GetAlbums', []); - stub('library.Library.GetTracks', []); + stub('library.Library.GetTrackTable', trackTable([])); await withRequests([]); }); diff --git a/frontend/test/components/list-render-cost.test.ts b/frontend/test/components/list-render-cost.test.ts index a4566d6..18c5ca5 100644 --- a/frontend/test/components/list-render-cost.test.ts +++ b/frontend/test/components/list-render-cost.test.ts @@ -48,21 +48,12 @@ describe('the track list Art column', () => { ]).toEqual(['lazy', 'async']); }); - it('falls back through the tiers rather than rendering nothing', () => { - const onlyOriginal = cell('albumArt', { - CoverArtPath: '/covers/abc.jpg', - CoverArtSmall: '', - CoverArtMedium: '', - }).querySelector('img'); - - expect(onlyOriginal?.getAttribute('src')).toBe('/covers/abc.jpg'); - }); - + // There is no fallback through the larger tiers any more: the list + // carries only the small one (#281), and the backend derives every + // tier from the same file, so it is set whenever any of them is. it('renders nothing at all when there is no art', () => { const none = cell('albumArt', { - CoverArtPath: '', CoverArtSmall: '', - CoverArtMedium: '', }); expect(none.querySelector('img')).toBeNull(); diff --git a/frontend/test/components/smoke.test.ts b/frontend/test/components/smoke.test.ts index e4c3f16..0439b3a 100644 --- a/frontend/test/components/smoke.test.ts +++ b/frontend/test/components/smoke.test.ts @@ -117,7 +117,7 @@ const TAGS = [ */ function stubEmptyBackend(): void { const emptyLists = [ - 'library.Library.GetTracks', + 'library.Library.GetTrackTable', 'library.Library.GetAlbums', 'library.Library.GetArtists', 'library.Library.GetGenres', diff --git a/frontend/test/components/touch-selection.test.ts b/frontend/test/components/touch-selection.test.ts index a4fec7b..be7c656 100644 --- a/frontend/test/components/touch-selection.test.ts +++ b/frontend/test/components/touch-selection.test.ts @@ -24,6 +24,7 @@ import '@components/selection-bar/selection-bar'; import { calls, flush, resetHarness, stub } from '@test/support/harness'; import { fixture, shadow, shadowAll } from '@test/support/render'; import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures'; +import { trackTable } from '@test/support/track-table'; const HELD = LONG_PRESS_MS + 120; @@ -117,7 +118,7 @@ function rows(el: HTMLElement): HTMLElement[] { describe('a finger on a track row', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAllLibrariesWithTrackCounts', []); stub('config.Config.GetShortcuts', {}); stub('queue.Queue.SetQueue', null); @@ -320,7 +321,7 @@ describe('', () => { describe('a tap on a name inside a row', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAllLibrariesWithTrackCounts', []); stub('config.Config.GetShortcuts', {}); stub('queue.Queue.SetQueue', null); diff --git a/frontend/test/components/touch-swipe.test.ts b/frontend/test/components/touch-swipe.test.ts index 07f9595..e343df8 100644 --- a/frontend/test/components/touch-swipe.test.ts +++ b/frontend/test/components/touch-swipe.test.ts @@ -33,6 +33,7 @@ import '@components/track-list/track-list'; import { calls, flush, resetHarness, stub } from '@test/support/harness'; import { fixture, shadow, shadowAll } from '@test/support/render'; import { installTouchGestures } from '@utils/touch-gestures'; +import { trackTable } from '@test/support/track-table'; const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -125,7 +126,7 @@ function threshold(row: HTMLElement): number { describe('a finger swiped right across a track row', () => { beforeEach(() => { resetHarness(); - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAllLibrariesWithTrackCounts', []); stub('config.Config.GetShortcuts', {}); stub('queue.Queue.SetQueue', null); diff --git a/frontend/test/components/view-lifecycle.test.ts b/frontend/test/components/view-lifecycle.test.ts index 14b3ed8..5b6a38c 100644 --- a/frontend/test/components/view-lifecycle.test.ts +++ b/frontend/test/components/view-lifecycle.test.ts @@ -231,7 +231,7 @@ const CACHED_VIEWS = [ * binding resolves undefined, which is not what Go sends. */ function stubEmptyBackend(): void { for (const path of [ - 'library.Library.GetTracks', + 'library.Library.GetTrackTable', 'library.Library.GetAlbums', 'library.Library.GetArtists', 'library.Library.GetGenres', diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts index a2c2fef..3929699 100644 --- a/frontend/test/setup.ts +++ b/frontend/test/setup.ts @@ -35,7 +35,7 @@ const importTimeDefaults: Array<[string, unknown]> = [ // libraryStore and playlistStore fetch eagerly at import. Left // unstubbed they would cache `undefined` — not the empty list Go // sends — and every consumer would then crash on `.length`. - ['library.Library.GetTracks', []], + ['library.Library.GetTrackTable', { strings: [''] }], ['library.Library.GetAlbums', []], ['library.Library.GetArtists', []], ['library.Library.GetGenres', []], diff --git a/frontend/test/stores/library-store.test.ts b/frontend/test/stores/library-store.test.ts index 2430135..2bbf094 100644 --- a/frontend/test/stores/library-store.test.ts +++ b/frontend/test/stores/library-store.test.ts @@ -19,10 +19,11 @@ import { lastArgs, resetHarness, } from '@test/support/harness'; +import { trackTable } from '@test/support/track-table'; const TRACKS = [ - { ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' }, - { ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' }, + { TrackName: 'One', FilePath: '/a.mp3', PlayCount: 0 }, + { TrackName: 'Two', FilePath: '/b.mp3', PlayCount: 4 }, ]; const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }]; const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }]; @@ -33,15 +34,10 @@ const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }]; /** Stub every read binding the store can reach. Unstubbed bindings * resolve undefined, which the store would cache as if it were data. */ function stubReads(): void { - stub('library.Library.GetTracks', TRACKS); + stub('library.Library.GetTrackTable', trackTable(TRACKS)); stub('library.Library.GetAlbums', ALBUMS); stub('library.Library.GetArtists', ARTISTS); stub('library.Library.GetGenres', GENRES); - stub('library.Library.GetTracks', TRACKS); - stub('library.Library.GetAlbums', ALBUMS); - stub('library.Library.GetArtists', ARTISTS); - stub('library.Library.GetGenres', GENRES); - stub('library.Library.GetAlbumsByArtist', ALBUMS); stub('library.Library.GetAlbumsByArtist', ALBUMS); stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES); } @@ -69,7 +65,7 @@ describe('library store: caching', () => { it('serves a second read from cache without touching the backend', async () => { await libraryStore.getTracks(); - expect(calls('library.Library.GetTracks')).toHaveLength(0); + expect(calls('library.Library.GetTrackTable')).toHaveLength(0); }); it('deduplicates concurrent first reads into one backend call', async () => { @@ -88,11 +84,11 @@ describe('library store: caching', () => { it('exposes cached collections synchronously once loaded', () => { expect([ - libraryStore.getCachedTracks(), + libraryStore.getCachedTracks()?.map((t) => t.FilePath), libraryStore.getCachedAlbums(), libraryStore.cachedArtists, libraryStore.getCachedGenres(), - ]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]); + ]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]); }); it('refetches everything when a scan completes', async () => { @@ -103,7 +99,7 @@ describe('library store: caching', () => { 'library.Library.GetAlbums', 'library.Library.GetArtists', 'library.Library.GetGenres', - 'library.Library.GetTracks', + 'library.Library.GetTrackTable', ]); }); @@ -111,7 +107,7 @@ describe('library store: caching', () => { emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' }); await flush(); - expect(calls('library.Library.GetTracks')).toHaveLength(1); + expect(calls('library.Library.GetTrackTable')).toHaveLength(1); }); /* @@ -140,10 +136,11 @@ describe('library store: caching', () => { it('patches the one track it names', () => { const tracks = libraryStore.getCachedTracks(); + // LastPlayed is not on the list's rows (#281); trackCache + // patches it on whole tracks. expect(tracks?.[0]).toMatchObject({ FilePath: '/a.mp3', PlayCount: 9, - LastPlayed: '2026-08-11 10:00:00', }); }); @@ -192,7 +189,7 @@ describe('library store: caching', () => { }); it('does not refetch the tracks', () => { - expect(calls('library.Library.GetTracks')).toHaveLength(0); + expect(calls('library.Library.GetTrackTable')).toHaveLength(0); }); it('splices the removed track out in place', () => { @@ -258,7 +255,7 @@ describe('library store: library filter', () => { libraryStore.setSelectedLibrary(7); await flush(); - expect(lastArgs('library.Library.GetTracks')).toEqual([7]); + expect(lastArgs('library.Library.GetTrackTable')).toEqual([7]); }); it('ignores a redundant selection instead of invalidating', async () => { @@ -304,12 +301,12 @@ describe('library store: a fetch that is overtaken', () => { it('serves the library that is selected, not the one that was in flight', async () => { const pending: Array<{ id: number; resolve: (v: unknown) => void }> = []; - const byLibrary = (id: number) => [{ ID: id, Title: `Library ${id}` }]; + const byLibrary = (id: number) => trackTable([{ FilePath: `/lib-${id}.mp3` }]); // Only the track fetch is held open; the other three settle at once, // so the test is about the overtaking and nothing else. stub( - 'library.Library.GetTracks', + 'library.Library.GetTrackTable', (id: number) => new Promise((resolve) => { pending.push({ id, resolve }); @@ -327,11 +324,13 @@ describe('library store: a fetch that is overtaken', () => { pending.find((p) => p.id === 8)?.resolve(byLibrary(8)); await flush(); - expect(libraryStore.getCachedTracks()).toEqual(byLibrary(8)); + expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([ + '/lib-8.mp3', + ]); }); it('settles the waiters when the fetch they are waiting on fails', async () => { - stubFailure('library.Library.GetTracks', 'sql: database is locked'); + stubFailure('library.Library.GetTrackTable', 'sql: database is locked'); // Invalidation drops the cache and starts the fetch that fails. emit(Events.LibraryScanComplete); diff --git a/frontend/test/support/track-table.ts b/frontend/test/support/track-table.ts new file mode 100644 index 0000000..66d4a2d --- /dev/null +++ b/frontend/test/support/track-table.ts @@ -0,0 +1,86 @@ +/** + * Encode test tracks as the backend's `TrackTable` (#281), so a stub of + * `library.Library.GetTrackTable` can be written as a list of tracks. + * + * Test fixtures are partial tracks; a missing field encodes as the + * zero value Go would have sent. The encoding mirrors + * `backend/library/tracktable.go` closely enough to exercise the real + * decoder: shared string table with "" at 0, genre lists interned. + */ +import type * as library from '@go/library/models.js'; + +/** A fixture: any subset of a track's fields, loosely typed as fixtures are. */ +type PartialTrack = { FilePath: string } & Record; + +const STRING_COLS = [ + ['trackName', 'TrackName'], + ['artistName', 'ArtistName'], + ['album', 'Album'], + ['composer', 'Composer'], + ['fileType', 'FileType'], + ['artistMbid', 'ArtistMBID'], + ['releaseGroupMbid', 'ReleaseGroupMBID'], + ['recordingMbid', 'RecordingMBID'], + ['coverArtSmall', 'CoverArtSmall'], +] as const; + +const INT_COLS = [ + ['trackNumber', 'TrackNumber'], + ['discNumber', 'DiscNumber'], + ['year', 'Year'], + ['sampleRate', 'SampleRate'], + ['bitDepth', 'BitDepth'], + ['channels', 'Channels'], + ['bitrate', 'Bitrate'], + ['fileSize', 'FileSize'], + ['playCount', 'PlayCount'], +] as const; + +export function trackTable(tracks: readonly PartialTrack[]): library.TrackTable { + const strings = ['']; + const index = new Map([['', 0]]); + const intern = (s: string | undefined): number => { + const v = s ?? ''; + let i = index.get(v); + + if (i === undefined) { + i = strings.length; + strings.push(v); + index.set(v, i); + } + + return i; + }; + + const genreSets: number[][] = []; + const genreIndex = new Map(); + const table: Record = { + strings, + genreSets, + filePath: tracks.map((t) => t.FilePath), + lengthMs: tracks.map((t) => Number(t['TrackLength'] ?? 0) || 0), + genre: tracks.map((t) => { + const genres = (t['Genre'] as string[] | null | undefined) ?? []; + const key = genres.join('\u0000'); + let i = genreIndex.get(key); + + if (i === undefined) { + i = genreSets.length; + genreSets.push(genres.map(intern)); + genreIndex.set(key, i); + } + + return i; + }), + }; + + for (const [col, field] of STRING_COLS) { + table[col] = tracks.map((t) => intern(t[field] as string | undefined)); + } + + for (const [col, field] of INT_COLS) { + table[col] = tracks.map((t) => (t[field] as number | undefined) ?? 0); + } + + return table as unknown as library.TrackTable; +} diff --git a/frontend/test/utils/track-table.test.ts b/frontend/test/utils/track-table.test.ts new file mode 100644 index 0000000..47a630a --- /dev/null +++ b/frontend/test/utils/track-table.test.ts @@ -0,0 +1,134 @@ +/** + * `decodeTrackTable` is the only reader of the backend's columnar track + * list (#281). The encoder is Go and the row type is TypeScript, so the + * last block reads both sides' declarations and fails if a column is + * added on one and not the other. + */ +import { describe, expect, it } from 'vitest'; + +import { + decodeTrackTable, + LIST_TRACK_OMITS, + TrackTableError, +} from '@utils/track-table'; +import { trackTable } from '@test/support/track-table'; + +const FIXTURE = [ + { + FilePath: '/m/a/1.flac', + TrackName: 'One', + ArtistName: 'Artist', + Album: 'Album', + Genre: ['Ambient', 'Drone'], + TrackLength: '215000', + TrackNumber: 1, + CoverArtSmall: '/covers/x_sm.jpg', + PlayCount: 3, + }, + { + FilePath: '/m/a/2.flac', + TrackName: 'Two', + ArtistName: 'Artist', + Album: 'Album', + Genre: ['Ambient', 'Drone'], + TrackLength: '1000', + TrackNumber: 2, + CoverArtSmall: '/covers/x_sm.jpg', + }, +]; + +describe('decodeTrackTable', () => { + it('gives back each track it was given', () => { + const [a, b] = decodeTrackTable(trackTable(FIXTURE)); + + expect(a).toMatchObject({ + FilePath: '/m/a/1.flac', + TrackName: 'One', + Album: 'Album', + Genre: ['Ambient', 'Drone'], + TrackLength: '215000', + TrackNumber: 1, + PlayCount: 3, + Composer: '', + }); + expect(b).toMatchObject({ TrackName: 'Two', TrackLength: '1000', PlayCount: 0 }); + }); + + it('shares one genre list between tracks that have the same one', () => { + const [a, b] = decodeTrackTable(trackTable(FIXTURE)); + + expect(a!.Genre).toBe(b!.Genre); + }); + + it('decodes an empty library to no rows', () => { + expect(decodeTrackTable(trackTable([]))).toEqual([]); + }); + + it('refuses a table whose columns disagree in length', () => { + const broken = trackTable(FIXTURE); + + (broken as unknown as { album: number[] }).album = [1]; + + expect(() => decodeTrackTable(broken)).toThrow(TrackTableError); + }); + + it('refuses a string index past the table', () => { + const broken = trackTable(FIXTURE); + + (broken as unknown as { album: number[] }).album = [1, 999]; + + expect(() => decodeTrackTable(broken)).toThrow(/out of range/); + }); +}); + +/** The generated Track interface and the Go encoder, as text. */ +const MODELS = Object.values( + import.meta.glob('../../bindings/yellowjacket/backend/library/models.ts', { + eager: true, + query: '?raw', + import: 'default', + }), +)[0] ?? ''; +const ENCODER = Object.values( + import.meta.glob('../../../backend/library/tracktable.go', { + eager: true, + query: '?raw', + import: 'default', + }), +)[0] ?? ''; + +function interfaceKeys(source: string, name: string): string[] { + const body = source.split(`export interface ${name} {`)[1]?.split('\n}')[0] ?? ''; + + return [...body.matchAll(/^\s+"(\w+)"\??:/gm)].map((m) => m[1]!); +} + +describe('the list row and the Go table agree', () => { + const trackKeys = interfaceKeys(MODELS, 'Track'); + const tableKeys = [...ENCODER.matchAll(/json:"(\w+)"/g)].map((m) => m[1]!); + + it('read both declarations', () => { + expect(trackKeys.length).toBeGreaterThan(20); + expect(tableKeys.length).toBeGreaterThan(20); + }); + + it('decodes every Track field the list keeps, and only those', () => { + const decoded = Object.keys(decodeTrackTable(trackTable(FIXTURE))[0]!).sort(); + const omitted = new Set(LIST_TRACK_OMITS); + + expect(decoded).toEqual(trackKeys.filter((k) => !omitted.has(k)).sort()); + }); + + it('has a column for every field it decodes', () => { + // Columns are the field names in lower camel case, except the two + // that are not columns of a field and the length, which is sent as + // the number it encodes. + const columns = new Set(tableKeys.filter((k) => !['strings', 'genreSets'].includes(k))); + const expected = trackKeys + .filter((k) => !(LIST_TRACK_OMITS as readonly string[]).includes(k)) + .map((k) => (k === 'TrackLength' ? 'lengthMs' : k[0]!.toLowerCase() + k.slice(1))) + .map((k) => k.replace(/MBID$/, 'Mbid')); + + expect([...columns].sort()).toEqual(expected.sort()); + }); +});