perf(library): send the track list as a dictionary-encoded column table
GetTracks answered with one JSON object per track: 20.5 MB at 26k tracks, ~350 bytes a row of repeated key names, four cover URLs that are identical across an album, and the album, artist and genre strings repeated on every track. Encoding it was ~170 MB of transient Go allocation, and parsing it the WebView's memory peak. GetTrackTable replaces it: one array per column, every repeated string stored once and sent as an index, genre lists interned, and only the fields the Tracks view reads. LastPlayed and the three larger cover tiers are left to the details dialog, which now fetches whole tracks by path. Each row still goes through trackFromRow, so this is an encoding of the one projection, not a second one. 167 bytes a track against 929 in the test library; a Go test holds it under a quarter of the object encoding, and a Vitest test reads the Go columns and the generated Track interface and fails if they drift. An empty library is now an empty table rather than the "no tracks in library" error the old binding returned. Closes #281
This commit is contained in:
1 parent
4a9fe1d207
commit
7a1412383f
42 files changed
+1011
-275
No files matched your search
@@ -647,7 +647,7 @@ window.__yj = { call(name, args) {
|
|||||||
That turns the device into a tier that can be *driven* rather than only
|
That turns the device into a tier that can be *driven* rather than only
|
||||||
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
||||||
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
|
`__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
|
`GetAllTracks`); an unknown one comes back as a plain
|
||||||
`unknown bound method name`, so a wrong guess is loud.
|
`unknown bound method name`, so a wrong guess is loud.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package library
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,8 +17,6 @@ import (
|
|||||||
// searchTrackLimit bounds an FTS search's result set.
|
// searchTrackLimit bounds an FTS search's result set.
|
||||||
const searchTrackLimit = 500
|
const searchTrackLimit = 500
|
||||||
|
|
||||||
var errNoTracksInLibrary = errors.New("no tracks in library")
|
|
||||||
|
|
||||||
// Track is one audio file with everything a list needs to draw it.
|
// Track is one audio file with everything a list needs to draw it.
|
||||||
type Track struct {
|
type Track struct {
|
||||||
TrackName string
|
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
|
// pathLookupChunk bounds the paths bound into one IN (...) query, well
|
||||||
// under SQLite's bind-variable limit.
|
// under SQLite's bind-variable limit.
|
||||||
const pathLookupChunk = 500
|
const pathLookupChunk = 500
|
||||||
|
|||||||
@@ -58,15 +58,15 @@ func TestScan_FixtureLibraryLeavesNothingBehind(t *testing.T) {
|
|||||||
t.Skip("fixture library is empty; run make testdata")
|
t.Skip("fixture library is empty; run make testdata")
|
||||||
}
|
}
|
||||||
|
|
||||||
tracks, err := lib.GetTracks(0)
|
table, err := lib.GetTrackTable(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetTracks: %v", err)
|
t.Fatalf("GetTrackTable: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// One track per file: the projection cannot multiply rows, because
|
// One track per file: the projection cannot multiply rows, because
|
||||||
// there is no join table left to multiply them.
|
// there is no join table left to multiply them.
|
||||||
if int64(len(tracks)) != files {
|
if int64(len(table.FilePath)) != files {
|
||||||
t.Errorf("GetTracks returned %d rows for %d files", len(tracks), files)
|
t.Errorf("GetTrackTable returned %d rows for %d files", len(table.FilePath), files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing shared outlives what refers to it.
|
// Nothing shared outlives what refers to it.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -224,7 +224,13 @@ async function measureStartup(page) {
|
|||||||
scriptBytesBeforePaint,
|
scriptBytesBeforePaint,
|
||||||
requests: res.length,
|
requests: res.length,
|
||||||
crossOriginRequests: crossOrigin.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(() => {});
|
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);
|
const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath);
|
||||||
if (paths.length < 2) return { error: 'library too small to measure' };
|
if (paths.length < 2) return { error: 'library too small to measure' };
|
||||||
|
|
||||||
@@ -435,7 +442,8 @@ async function measureFavouriteToggle(page) {
|
|||||||
const ev = window.__yjEvents;
|
const ev = window.__yjEvents;
|
||||||
const perf = window.__yjPerf;
|
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);
|
const paths = (tracks ?? []).map((t) => t.FilePath);
|
||||||
if (paths.length < per * count) {
|
if (paths.length < per * count) {
|
||||||
return { error: `library too small: ${paths.length} tracks` };
|
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);
|
let pl = (existing ?? []).find((p) => (p.Name ?? p.name) === name);
|
||||||
|
|
||||||
if (!pl) {
|
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);
|
const paths = (tracks ?? []).map((t) => t.FilePath);
|
||||||
|
|
||||||
if (paths.length < n) return { error: `library too small: ${paths.length}` };
|
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
|
// Stage a loaded track. Deliberately the same first tracks the
|
||||||
// track-change measurement already played, so this warms no cover
|
// track-change measurement already played, so this warms no cover
|
||||||
// art that a later measurement counts requests for.
|
// 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);
|
const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath);
|
||||||
|
|
||||||
if (paths.length < 2) return { error: 'library too small to measure' };
|
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`);
|
const p = resolve(OUT_DIR, `${l}.json`);
|
||||||
if (!existsSync(p)) throw new Error(`no measurement labelled '${l}' at ${p}`);
|
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)]));
|
console.log(table([load(a), load(b)]));
|
||||||
|
|||||||
@@ -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).
|
* 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. */
|
/** Put a track in the player, so the view has art and names to lay out. */
|
||||||
async function stageATrack(page: Page): Promise<void> {
|
async function stageATrack(page: Page): Promise<void> {
|
||||||
await page.evaluate(async () => {
|
const paths = (await libraryTracks(page)).slice(0, 4).map((t) => t.FilePath);
|
||||||
const tracks = (await window.__yjEvents.call(
|
|
||||||
'library.Library.GetTracks',
|
|
||||||
[0],
|
|
||||||
10_000,
|
|
||||||
)) as { FilePath: string }[];
|
|
||||||
|
|
||||||
|
await page.evaluate(async (paths) => {
|
||||||
await window.__yjEvents.call(
|
await window.__yjEvents.call(
|
||||||
'queue.Queue.SetQueue',
|
'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,
|
10_000,
|
||||||
);
|
);
|
||||||
});
|
}, paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the full-screen view and wait for the shell to say so. */
|
/** Open the full-screen view and wait for the shell to say so. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
libraryTracks,
|
||||||
test,
|
test,
|
||||||
expect,
|
expect,
|
||||||
callBinding,
|
callBinding,
|
||||||
@@ -49,11 +50,7 @@ async function rectOf(app: Page, selector: string): Promise<Rect | null> {
|
|||||||
* three rectangles.
|
* three rectangles.
|
||||||
*/
|
*/
|
||||||
async function play(app: Page): Promise<void> {
|
async function play(app: Page): Promise<void> {
|
||||||
const tracks = await callBinding<{ FilePath: string; TrackName: string }[]>(
|
const tracks = await libraryTracks(app);
|
||||||
app,
|
|
||||||
'library.Library.GetTracks',
|
|
||||||
[0],
|
|
||||||
);
|
|
||||||
|
|
||||||
// `TrackName`, not `Title`: that is what the library model calls it.
|
// `TrackName`, not `Title`: that is what the library model calls it.
|
||||||
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
|
const long = tracks.find((t) => t.TrackName === LONG_TRACK);
|
||||||
|
|||||||
@@ -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).
|
* 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
|
// nothing in particular and picked a 2-second track, which had
|
||||||
// finished before the assertions ran. The placeholder check below
|
// finished before the assertions ran. The placeholder check below
|
||||||
// is what actually holds the property this test needs.
|
// is what actually holds the property this test needs.
|
||||||
const started = await app.evaluate(async (longTitle) => {
|
const bare = (await libraryTracks(app)).find((t) => t.TrackName === LONG_TRACK);
|
||||||
const tracks = (await window.__yjEvents.call(
|
const started = await app.evaluate(async (bare) => {
|
||||||
'library.Library.GetTracks',
|
|
||||||
[0],
|
|
||||||
10_000,
|
|
||||||
)) as { FilePath: string; TrackName: string }[];
|
|
||||||
|
|
||||||
const bare = tracks.find((t) => t.TrackName === longTitle);
|
|
||||||
|
|
||||||
if (!bare) return null;
|
if (!bare) return null;
|
||||||
|
|
||||||
await window.__yjEvents.call(
|
await window.__yjEvents.call(
|
||||||
@@ -254,7 +247,7 @@ test.describe('the shell on a phone', () => {
|
|||||||
await window.__yjEvents.call('queue.Queue.Play', [], 5_000);
|
await window.__yjEvents.call('queue.Queue.Play', [], 5_000);
|
||||||
|
|
||||||
return bare.TrackName;
|
return bare.TrackName;
|
||||||
}, LONG_TRACK);
|
}, bare);
|
||||||
|
|
||||||
expect(started).toBe(LONG_TRACK);
|
expect(started).toBe(LONG_TRACK);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
libraryTracks,
|
||||||
test,
|
test,
|
||||||
expect,
|
expect,
|
||||||
callBinding,
|
callBinding,
|
||||||
@@ -30,18 +31,7 @@ async function order(app: Page): Promise<string[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function queueFourAndOpen(app: Page): Promise<string[]> {
|
async function queueFourAndOpen(app: Page): Promise<string[]> {
|
||||||
const paths: string[] = await app.evaluate(async () => {
|
const paths = (await libraryTracks(app)).slice(0, 4).map((t) => t.FilePath);
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
libraryTracks,
|
||||||
test,
|
test,
|
||||||
expect,
|
expect,
|
||||||
callBinding,
|
callBinding,
|
||||||
@@ -86,15 +87,11 @@ const selected = (app: Page) =>
|
|||||||
* is read back.
|
* is read back.
|
||||||
*/
|
*/
|
||||||
async function queueSixAndOpen(app: Page): Promise<void> {
|
async function queueSixAndOpen(app: Page): Promise<void> {
|
||||||
const paths = await app.evaluate(async (longTitle) => {
|
// `TrackName`, not `Title`: the library model names it after the
|
||||||
// `TrackName`, not `Title`: the library model names it after the
|
// tag, and the *queue* is what calls it `title`.
|
||||||
// tag, and the *queue* is what calls it `title`.
|
const tracks = await libraryTracks(app);
|
||||||
const tracks = (await window.__yjEvents.call(
|
const longTitle = LONG_TRACK;
|
||||||
'library.Library.GetTracks',
|
const paths = (() => {
|
||||||
[0],
|
|
||||||
10_000,
|
|
||||||
)) as { FilePath: string; TrackName: string; Album: string }[];
|
|
||||||
|
|
||||||
const long = tracks.find((t) => t.TrackName === longTitle);
|
const long = tracks.find((t) => t.TrackName === longTitle);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,7 +128,7 @@ async function queueSixAndOpen(app: Page): Promise<void> {
|
|||||||
long!.FilePath,
|
long!.FilePath,
|
||||||
...rest.slice(3).map((t) => t.FilePath),
|
...rest.slice(3).map((t) => t.FilePath),
|
||||||
];
|
];
|
||||||
}, LONG_TRACK);
|
})();
|
||||||
|
|
||||||
await callBinding(app, 'queue.Queue.SetQueue', [
|
await callBinding(app, 'queue.Queue.SetQueue', [
|
||||||
paths,
|
paths,
|
||||||
|
|||||||
@@ -96,6 +96,36 @@ export async function callBinding<T = unknown>(
|
|||||||
) as Promise<T>;
|
) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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<LibraryTrack[]> {
|
||||||
|
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`.
|
* The binding calls the *app* made, newest last, as `pkg.Type.Method`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ export type {
|
|||||||
ScanMetrics,
|
ScanMetrics,
|
||||||
ScanWarning,
|
ScanWarning,
|
||||||
Track,
|
Track,
|
||||||
TrackMBIDs
|
TrackMBIDs,
|
||||||
|
TrackTable
|
||||||
} from "./models.js";
|
} from "./models.js";
|
||||||
@@ -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
|
* GetTrackTable returns every track in a library, or in all of them
|
||||||
* libraryID is 0.
|
* when libraryID is 0, as a TrackTable. An empty library is an empty
|
||||||
*
|
* table, not an error.
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
export function GetTracks(libraryID: number): $CancellablePromise<$models.Track[] | null> {
|
export function GetTrackTable(libraryID: number): $CancellablePromise<$models.TrackTable> {
|
||||||
return $Call.ByID(933082923, libraryID);
|
return $Call.ByID(1179690926, libraryID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -239,3 +239,60 @@ export interface TrackMBIDs {
|
|||||||
"releaseGroupMbid": string;
|
"releaseGroupMbid": string;
|
||||||
"artistMbid": 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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type * as library from '@go/library/models.js';
|
import type { ListTrack } from '@utils/track-table';
|
||||||
import {
|
import {
|
||||||
formatSampleRate,
|
formatSampleRate,
|
||||||
formatBitDepth,
|
formatBitDepth,
|
||||||
@@ -45,7 +45,7 @@ export interface ColumnDef {
|
|||||||
*/
|
*/
|
||||||
configurable?: boolean;
|
configurable?: boolean;
|
||||||
/** Extracts the display value from a track. */
|
/** 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). */
|
/** Default CSS width (used when no saved width exists). */
|
||||||
defaultWidth: string;
|
defaultWidth: string;
|
||||||
/** Text alignment. Defaults to left. */
|
/** Text alignment. Defaults to left. */
|
||||||
@@ -60,15 +60,15 @@ export interface ColumnDef {
|
|||||||
* needs it, which is why it is optional rather than a second
|
* needs it, which is why it is optional rather than a second
|
||||||
* required parameter on all of them.
|
* 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.
|
* Comparison function for sorting two tracks by this column.
|
||||||
* Returns negative if a < b, positive if a > b, zero if equal.
|
* Returns negative if a < b, positive if a > b, zero if equal.
|
||||||
* If omitted the column is not sortable.
|
* If omitted the column is not sortable.
|
||||||
*/
|
*/
|
||||||
comparator?: (
|
comparator?: (
|
||||||
a: library.Track,
|
a: ListTrack,
|
||||||
b: library.Track,
|
b: ListTrack,
|
||||||
) => number;
|
) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
label: 'Art',
|
label: 'Art',
|
||||||
accessor: () => '',
|
accessor: () => '',
|
||||||
defaultWidth: '36px',
|
defaultWidth: '36px',
|
||||||
renderCell: (track: library.Track) => {
|
renderCell: (track: ListTrack) => {
|
||||||
// `perf.M3`. This rendered `CoverArtPath` — the *original*
|
// `perf.M3`. This rendered `CoverArtPath` — the *original*
|
||||||
// embedded artwork, commonly 1500×1500 and several hundred
|
// embedded artwork, commonly 1500×1500 and several hundred
|
||||||
// kB — scaled by CSS into a 24 px box, while the 100 px
|
// kB — scaled by CSS into a 24 px box, while the 100 px
|
||||||
@@ -90,10 +90,10 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
// `cover-grid.getCoverUrl()` has picked the right tier all
|
// `cover-grid.getCoverUrl()` has picked the right tier all
|
||||||
// along; this is the same rule for a much smaller box, with
|
// along; this is the same rule for a much smaller box, with
|
||||||
// the two attributes that keep the decode off the scroll
|
// the two attributes that keep the decode off the scroll
|
||||||
// path.
|
// path. The list carries only this tier (#281): every tier
|
||||||
const src = track.CoverArtSmall
|
// is derived from the same file, so it is set whenever any
|
||||||
|| track.CoverArtMedium
|
// of them would be.
|
||||||
|| track.CoverArtPath;
|
const src = track.CoverArtSmall;
|
||||||
|
|
||||||
if (!src) return nothing;
|
if (!src) return nothing;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { html } from 'lit';
|
||||||
import type { TemplateResult } from 'lit';
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
@@ -99,7 +99,7 @@ function matchQuality(
|
|||||||
* fields are always included on top of these.
|
* fields are always included on top of these.
|
||||||
*/
|
*/
|
||||||
function scoreTrack(
|
function scoreTrack(
|
||||||
track: library.Track,
|
track: ListTrack,
|
||||||
termLower: string,
|
termLower: string,
|
||||||
columns: ColumnDef[],
|
columns: ColumnDef[],
|
||||||
): number {
|
): number {
|
||||||
@@ -147,7 +147,7 @@ function scoreTrack(
|
|||||||
|
|
||||||
/** A track paired with its relevance score. */
|
/** A track paired with its relevance score. */
|
||||||
export interface RankedTrack {
|
export interface RankedTrack {
|
||||||
track: library.Track;
|
track: ListTrack;
|
||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,10 +166,10 @@ export interface RankedTrack {
|
|||||||
* `scores` (Map of FilePath → relevance score).
|
* `scores` (Map of FilePath → relevance score).
|
||||||
*/
|
*/
|
||||||
export function rankTracks(
|
export function rankTracks(
|
||||||
tracks: library.Track[],
|
tracks: ListTrack[],
|
||||||
term: string,
|
term: string,
|
||||||
activeColumns: ColumnDef[],
|
activeColumns: ColumnDef[],
|
||||||
): { tracks: library.Track[]; scores: Map<string, number> } {
|
): { tracks: ListTrack[]; scores: Map<string, number> } {
|
||||||
const termLower = term.toLowerCase();
|
const termLower = term.toLowerCase();
|
||||||
const ranked: RankedTrack[] = [];
|
const ranked: RankedTrack[] = [];
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ export function rankTracks(
|
|||||||
// Sort descending by score (highest relevance first).
|
// Sort descending by score (highest relevance first).
|
||||||
ranked.sort((a, b) => b.score - a.score);
|
ranked.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
const result: library.Track[] = [];
|
const result: ListTrack[] = [];
|
||||||
const scores = new Map<string, number>();
|
const scores = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of ranked) {
|
for (const r of ranked) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as library from '@go/library/models.js';
|
import type { ListTrack } from '@utils/track-table';
|
||||||
import { LitElement, html, svg, css, nothing } from 'lit';
|
import { LitElement, html, svg, css, nothing, type TemplateResult } from 'lit';
|
||||||
import { designTokens } from '../../styles/tokens.css';
|
import { designTokens } from '../../styles/tokens.css';
|
||||||
import { srOnly } from '../../styles/sr-only.css';
|
import { srOnly } from '../../styles/sr-only.css';
|
||||||
import {
|
import {
|
||||||
@@ -80,11 +80,10 @@ import { describeError } from '@utils/describe-error';
|
|||||||
import { notificationStore } from '@store/notification-store';
|
import { notificationStore } from '@store/notification-store';
|
||||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||||
import { RemoveFromLibrary } from '@go/library/library.js';
|
import { RemoveFromLibrary } from '@go/library/library.js';
|
||||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
import { tracksByFilePath } from '@utils/track-index.js';
|
||||||
import '@components/playlist-picker/playlist-picker.js';
|
import '@components/playlist-picker/playlist-picker.js';
|
||||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
|
||||||
import {
|
import {
|
||||||
ICON_PLAY,
|
ICON_PLAY,
|
||||||
ICON_PLAYLIST,
|
ICON_PLAYLIST,
|
||||||
@@ -155,7 +154,7 @@ export class TrackList
|
|||||||
* parent is responsible for reloading when data changes.
|
* parent is responsible for reloading when data changes.
|
||||||
*/
|
*/
|
||||||
@property({ type: Array, attribute: false })
|
@property({ type: Array, attribute: false })
|
||||||
externalTracks?: library.Track[];
|
externalTracks?: ListTrack[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a host embedding this list (e.g. `genre-details`) should say
|
* What a host embedding this list (e.g. `genre-details`) should say
|
||||||
@@ -199,7 +198,7 @@ export class TrackList
|
|||||||
private lastSearchTerm = '';
|
private lastSearchTerm = '';
|
||||||
|
|
||||||
/** Tracks the store's cached array reference to detect refreshes. */
|
/** Tracks the store's cached array reference to detect refreshes. */
|
||||||
private lastTracksRef: library.Track[] | null =
|
private lastTracksRef: ListTrack[] | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,7 +241,7 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private tracks: library.Track[] = [];
|
private tracks: ListTrack[] = [];
|
||||||
|
|
||||||
@query('#context-menu')
|
@query('#context-menu')
|
||||||
private contextMenuPopup!: MenuSurface;
|
private contextMenuPopup!: MenuSurface;
|
||||||
@@ -269,16 +268,16 @@ export class TrackList
|
|||||||
private lastActiveTrackPath: string | null = null;
|
private lastActiveTrackPath: string | null = null;
|
||||||
|
|
||||||
// -- Memoisation caches for filtered / sorted tracks --
|
// -- Memoisation caches for filtered / sorted tracks --
|
||||||
private cachedFilteredTracks: library.Track[] = [];
|
private cachedFilteredTracks: ListTrack[] = [];
|
||||||
private cachedSortedTracks: library.Track[] = [];
|
private cachedSortedTracks: ListTrack[] = [];
|
||||||
private cachedRelevanceScores = new Map<
|
private cachedRelevanceScores = new Map<
|
||||||
string,
|
string,
|
||||||
number
|
number
|
||||||
>();
|
>();
|
||||||
private prevFilterTracks: library.Track[] = [];
|
private prevFilterTracks: ListTrack[] = [];
|
||||||
private prevFilterTerm = '';
|
private prevFilterTerm = '';
|
||||||
private prevFilterColIds = '';
|
private prevFilterColIds = '';
|
||||||
private prevSortFiltered: library.Track[] = [];
|
private prevSortFiltered: ListTrack[] = [];
|
||||||
private prevSortField: string | null = null;
|
private prevSortField: string | null = null;
|
||||||
private prevSortDir: SortDirection = 'asc';
|
private prevSortDir: SortDirection = 'asc';
|
||||||
|
|
||||||
@@ -523,7 +522,7 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeFilteredTracks(): library.Track[] {
|
private computeFilteredTracks(): ListTrack[] {
|
||||||
const term = this.searchCtrl.term;
|
const term = this.searchCtrl.term;
|
||||||
|
|
||||||
if (!term) {
|
if (!term) {
|
||||||
@@ -543,7 +542,7 @@ export class TrackList
|
|||||||
return result.tracks;
|
return result.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
private computeSortedTracks(): library.Track[] {
|
private computeSortedTracks(): ListTrack[] {
|
||||||
const tracks = this.cachedFilteredTracks;
|
const tracks = this.cachedFilteredTracks;
|
||||||
const hasSearch =
|
const hasSearch =
|
||||||
this.cachedRelevanceScores.size > 0;
|
this.cachedRelevanceScores.size > 0;
|
||||||
@@ -1734,7 +1733,7 @@ export class TrackList
|
|||||||
*/
|
*/
|
||||||
private resolveTrackFromEvent(
|
private resolveTrackFromEvent(
|
||||||
e: Event,
|
e: Event,
|
||||||
): { track: library.Track; index: number } | null {
|
): { track: ListTrack; index: number } | null {
|
||||||
const row = (e.target as HTMLElement).closest(
|
const row = (e.target as HTMLElement).closest(
|
||||||
'.track-row',
|
'.track-row',
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
@@ -1875,7 +1874,7 @@ export class TrackList
|
|||||||
|
|
||||||
private onTrackRowClick(
|
private onTrackRowClick(
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
track: library.Track,
|
track: ListTrack,
|
||||||
index: number,
|
index: number,
|
||||||
) {
|
) {
|
||||||
// Clicking is also how the keyboard's starting point is chosen:
|
// 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);
|
this.selection.handleItemClick(e, track.FilePath, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTrackRowDblClick(_track: library.Track, index: number) {
|
private onTrackRowDblClick(_track: ListTrack, index: number) {
|
||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
this.playFromRow(index);
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
@@ -1939,7 +1938,7 @@ export class TrackList
|
|||||||
|
|
||||||
private onTrackDragStart = (
|
private onTrackDragStart = (
|
||||||
e: DragEvent,
|
e: DragEvent,
|
||||||
track: library.Track,
|
track: ListTrack,
|
||||||
) => {
|
) => {
|
||||||
// Gather file paths: all selected if this track is selected,
|
// Gather file paths: all selected if this track is selected,
|
||||||
// otherwise just the dragged track.
|
// otherwise just the dragged track.
|
||||||
@@ -2129,74 +2128,24 @@ export class TrackList
|
|||||||
this.ctxMenu.close();
|
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) {
|
private async openTrackDetails(filePath: string) {
|
||||||
const track = tracksByFilePath(this.tracks).get(
|
await showTrackDetailsForPath(
|
||||||
|
() => this.trackDetailsDialog,
|
||||||
filePath,
|
filePath,
|
||||||
);
|
|
||||||
|
|
||||||
if (!track) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openTrackDetails(filePath),
|
() => void this.openTrackDetails(filePath),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!ready) return;
|
|
||||||
|
|
||||||
const coverArt = track.CoverArtPath
|
|
||||||
? {
|
|
||||||
coverArtPath: track.CoverArtPath,
|
|
||||||
coverArtSmall: track.CoverArtSmall,
|
|
||||||
coverArtMedium: track.CoverArtMedium,
|
|
||||||
coverArtLarge: track.CoverArtLarge,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
this.trackDetailsDialog?.show(
|
|
||||||
track,
|
|
||||||
coverArt,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openBatchTrackDetails(
|
private async openBatchTrackDetails(
|
||||||
filePaths: string[],
|
filePaths: string[],
|
||||||
) {
|
) {
|
||||||
const tracks = tracksForPaths(
|
await showBatchTrackDetailsForPaths(
|
||||||
this.tracks,
|
() => this.trackDetailsDialog,
|
||||||
filePaths,
|
filePaths,
|
||||||
);
|
|
||||||
|
|
||||||
if (tracks.length === 0) return;
|
|
||||||
|
|
||||||
const ready = await loadTrackDetails(
|
|
||||||
() => void this.openBatchTrackDetails(filePaths),
|
() => 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();
|
this.saveSortPreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
private isActiveTrack(track: library.Track): boolean {
|
private isActiveTrack(track: ListTrack): boolean {
|
||||||
const currentTrack = this.player.currentTrack;
|
const currentTrack = this.player.currentTrack;
|
||||||
|
|
||||||
if (!currentTrack) return false;
|
if (!currentTrack) return false;
|
||||||
@@ -2283,9 +2232,9 @@ export class TrackList
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderTrackRow = (
|
private renderTrackRow = (
|
||||||
track: library.Track,
|
track: ListTrack,
|
||||||
index: number,
|
index: number,
|
||||||
): unknown => {
|
): TemplateResult => {
|
||||||
const active = this.isActiveTrack(track);
|
const active = this.isActiveTrack(track);
|
||||||
const selected = this.selection.isSelected(
|
const selected = this.selection.isSelected(
|
||||||
track.FilePath,
|
track.FilePath,
|
||||||
@@ -2568,7 +2517,7 @@ export class TrackList
|
|||||||
scroller
|
scroller
|
||||||
.items=${visibleTracks}
|
.items=${visibleTracks}
|
||||||
.renderItem=${this.renderTrackRow}
|
.renderItem=${this.renderTrackRow}
|
||||||
.keyFunction=${(track: library.Track) => track.FilePath}
|
.keyFunction=${(track: ListTrack) => track.FilePath}
|
||||||
.layout=${this.rowLayout}
|
.layout=${this.rowLayout}
|
||||||
></lit-virtualizer>
|
></lit-virtualizer>
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
import type * as library from '@go/library/models.js';
|
import type * as library from '@go/library/models.js';
|
||||||
import { libraryStore } from '../library-store';
|
import { libraryStore } from '../library-store';
|
||||||
|
import type { ListTrack } from '@utils/track-table';
|
||||||
|
|
||||||
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ export class LibraryController implements ReactiveController {
|
|||||||
// DATA ACCESS
|
// DATA ACCESS
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
async getTracks(): Promise<library.Track[]> {
|
async getTracks(): Promise<ListTrack[]> {
|
||||||
return libraryStore.getTracks();
|
return libraryStore.getTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ export class LibraryController implements ReactiveController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get cachedTracks(): library.Track[] | null {
|
get cachedTracks(): ListTrack[] | null {
|
||||||
return libraryStore.getCachedTracks();
|
return libraryStore.getCachedTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import {
|
import {
|
||||||
GetTracks,
|
GetTrackTable,
|
||||||
GetAlbums,
|
GetAlbums,
|
||||||
GetArtists,
|
GetArtists,
|
||||||
GetGenres,
|
GetGenres,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@go/library/library.js';
|
} from '@go/library/library.js';
|
||||||
import type * as library from '@go/library/models.js';
|
import type * as library from '@go/library/models.js';
|
||||||
import { list } from '@utils/binding';
|
import { list } from '@utils/binding';
|
||||||
|
import { decodeTrackTable, type ListTrack } from '@utils/track-table';
|
||||||
import { Events } from '../events';
|
import { Events } from '../events';
|
||||||
|
|
||||||
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
||||||
@@ -28,7 +29,7 @@ const COVER_SIZE_DEFAULT = 176;
|
|||||||
const COVER_SIZE_KEY = 'cover-grid-size';
|
const COVER_SIZE_KEY = 'cover-grid-size';
|
||||||
|
|
||||||
class LibraryStore {
|
class LibraryStore {
|
||||||
private tracks: library.Track[] | null = null;
|
private tracks: ListTrack[] | null = null;
|
||||||
private albums: library.Album[] | null = null;
|
private albums: library.Album[] | null = null;
|
||||||
private artists: library.Artist[] | null = null;
|
private artists: library.Artist[] | null = null;
|
||||||
private genres: library.GenreWithCount[] | null = null;
|
private genres: library.GenreWithCount[] | null = null;
|
||||||
@@ -214,18 +215,18 @@ class LibraryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTracks(): Promise<library.Track[]> {
|
async getTracks(): Promise<ListTrack[]> {
|
||||||
if (this.tracks !== null) {
|
if (this.tracks !== null) {
|
||||||
return this.tracks;
|
return this.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = this.pending<library.Track[]>('tracks');
|
const pending = this.pending<ListTrack[]>('tracks');
|
||||||
|
|
||||||
if (pending) return pending;
|
if (pending) return pending;
|
||||||
|
|
||||||
return this.track(
|
return this.track(
|
||||||
'tracks',
|
'tracks',
|
||||||
list(GetTracks(this.libraryFilter())),
|
GetTrackTable(this.libraryFilter()).then(decodeTrackTable),
|
||||||
(tracks) => {
|
(tracks) => {
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
},
|
},
|
||||||
@@ -341,7 +342,7 @@ class LibraryStore {
|
|||||||
// Synchronous access for controllers that need current cached values.
|
// Synchronous access for controllers that need current cached values.
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
getCachedTracks(): library.Track[] | null {
|
getCachedTracks(): ListTrack[] | null {
|
||||||
return this.tracks;
|
return this.tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,10 +521,11 @@ class LibraryStore {
|
|||||||
private applyPlayCount(payload: unknown): void {
|
private applyPlayCount(payload: unknown): void {
|
||||||
if (this.tracks === null) return;
|
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 {
|
const p = payload as {
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
playCount?: number;
|
playCount?: number;
|
||||||
lastPlayed?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
if (!p?.filePath) return;
|
if (!p?.filePath) return;
|
||||||
@@ -536,14 +538,10 @@ class LibraryStore {
|
|||||||
|
|
||||||
if (existing === undefined) return;
|
if (existing === undefined) return;
|
||||||
|
|
||||||
const patched = Object.assign(
|
const patched: ListTrack = {
|
||||||
Object.create(Object.getPrototypeOf(existing) as object),
|
...existing,
|
||||||
existing,
|
PlayCount: p.playCount ?? existing.PlayCount,
|
||||||
{
|
};
|
||||||
PlayCount: p.playCount ?? existing.PlayCount,
|
|
||||||
LastPlayed: p.lastPlayed ?? existing.LastPlayed,
|
|
||||||
},
|
|
||||||
) as library.Track;
|
|
||||||
|
|
||||||
this.tracks = [
|
this.tracks = [
|
||||||
...this.tracks.slice(0, idx),
|
...this.tracks.slice(0, idx),
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ export function compact<V>(
|
|||||||
return out;
|
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<T>(field: T[] | null | undefined): T[] {
|
||||||
|
return field ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* value awaits a binding whose result is used as-is, dropping only the
|
* value awaits a binding whose result is used as-is, dropping only the
|
||||||
* cancellation the app never asks for.
|
* cancellation the app never asks for.
|
||||||
|
|||||||
@@ -20,22 +20,22 @@
|
|||||||
* given array and never again.
|
* 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<
|
const byArray = new WeakMap<readonly HasFilePath[], Map<string, HasFilePath>>();
|
||||||
readonly library.Track[],
|
|
||||||
Map<string, library.Track>
|
|
||||||
>();
|
|
||||||
|
|
||||||
/** The lookup for `tracks`, built once per array identity. */
|
/** The lookup for `tracks`, built once per array identity. */
|
||||||
export function tracksByFilePath(
|
export function tracksByFilePath<T extends HasFilePath>(
|
||||||
tracks: readonly library.Track[],
|
tracks: readonly T[],
|
||||||
): Map<string, library.Track> {
|
): Map<string, T> {
|
||||||
let map = byArray.get(tracks);
|
let map = byArray.get(tracks) as Map<string, T> | undefined;
|
||||||
|
|
||||||
if (map) return map;
|
if (map) return map;
|
||||||
|
|
||||||
map = new Map<string, library.Track>();
|
map = new Map<string, T>();
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
// First wins: a duplicate path would be the same file, and
|
// 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. */
|
/** Resolve file paths to tracks, dropping any that are not present. */
|
||||||
export function tracksForPaths(
|
export function tracksForPaths<T extends HasFilePath>(
|
||||||
tracks: readonly library.Track[],
|
tracks: readonly T[],
|
||||||
filePaths: readonly string[],
|
filePaths: readonly string[],
|
||||||
): library.Track[] {
|
): T[] {
|
||||||
const byPath = tracksByFilePath(tracks);
|
const byPath = tracksByFilePath(tracks);
|
||||||
const result: library.Track[] = [];
|
const result: T[] = [];
|
||||||
|
|
||||||
for (const filePath of filePaths) {
|
for (const filePath of filePaths) {
|
||||||
const track = byPath.get(filePath);
|
const track = byPath.get(filePath);
|
||||||
|
|||||||
@@ -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<ListTrack>(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`);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import '@components/cover-grid/cover-grid';
|
|||||||
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadowAll } from '@test/support/render';
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const LONG =
|
const LONG =
|
||||||
'The Rise and Fall of a Midwest Princess in the Key of Everything';
|
'The Rise and Fall of a Midwest Princess in the Key of Everything';
|
||||||
@@ -49,7 +50,7 @@ describe('the album card’s year', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetAlbums', ALBUMS);
|
stub('library.Library.GetAlbums', ALBUMS);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import '@components/cover-grid/cover-grid';
|
|||||||
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enough albums to fill more than one row.
|
* Enough albums to fill more than one row.
|
||||||
@@ -82,7 +83,7 @@ describe('the album dropdown', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetAlbums', ALBUMS);
|
stub('library.Library.GetAlbums', ALBUMS);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
@@ -155,7 +156,7 @@ describe('the albums grid scrolls', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetAlbums', ALBUMS);
|
stub('library.Library.GetAlbums', ALBUMS);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
|||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
import { searchStore } from '@store/search-store';
|
import { searchStore } from '@store/search-store';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The searchable columns' accessors read these fields and call
|
* The searchable columns' accessors read these fields and call
|
||||||
@@ -76,7 +77,7 @@ describe('the track list says how it is sorted', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
searchStore.setTerm('');
|
searchStore.setTerm('');
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
emit(Events.LibraryScanComplete);
|
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 () => {
|
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);
|
emit(Events.LibraryScanComplete);
|
||||||
|
|
||||||
const el = await fixture<LitElement>('track-list');
|
const el = await fixture<LitElement>('track-list');
|
||||||
@@ -163,7 +164,7 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
|
|||||||
searchStore.setTerm('');
|
searchStore.setTerm('');
|
||||||
stub('library.Library.GetArtists', ARTISTS);
|
stub('library.Library.GetArtists', ARTISTS);
|
||||||
stub('library.Library.GetGenres', GENRES);
|
stub('library.Library.GetGenres', GENRES);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
@@ -197,7 +198,7 @@ describe('a clipped value is readable somewhere', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
searchStore.setTerm('');
|
searchStore.setTerm('');
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
@@ -240,7 +241,7 @@ describe('the playing row is more than a colour', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
searchStore.setTerm('');
|
searchStore.setTerm('');
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
imagePrefetched,
|
imagePrefetched,
|
||||||
resetImagePrefetch,
|
resetImagePrefetch,
|
||||||
} from '@utils/image-prefetch';
|
} from '@utils/image-prefetch';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
/** Enough albums that the virtualizer's own window is nowhere near the end. */
|
/** Enough albums that the virtualizer's own window is nowhere near the end. */
|
||||||
const ALBUMS = Array.from({ length: 400 }, (_, i) => {
|
const ALBUMS = Array.from({ length: 400 }, (_, i) => {
|
||||||
@@ -118,7 +119,7 @@ beforeEach(() => {
|
|||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
stub('library.Library.GetAlbums', ALBUMS);
|
stub('library.Library.GetAlbums', ALBUMS);
|
||||||
stub('library.Library.GetArtists', ARTISTS);
|
stub('library.Library.GetArtists', ARTISTS);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
stub('library.Library.GetGenres', []);
|
stub('library.Library.GetGenres', []);
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import '@components/genres-view/genres-view';
|
|||||||
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadowAll } from '@test/support/render';
|
import { fixture, shadowAll } from '@test/support/render';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const ARTISTS = [
|
const ARTISTS = [
|
||||||
{ ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 },
|
{ ID: 1, Name: 'Alpha', AlbumCount: 2, TrackCount: 9 },
|
||||||
@@ -58,7 +59,7 @@ describe('a card grid shows its selection', () => {
|
|||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetArtists', ARTISTS);
|
stub('library.Library.GetArtists', ARTISTS);
|
||||||
stub('library.Library.GetGenres', GENRES);
|
stub('library.Library.GetGenres', GENRES);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
// The views read through LibraryController, whose cache is only
|
// The views read through LibraryController, whose cache is only
|
||||||
// primed by a scan-complete; without it they render nothing and the
|
// primed by a scan-complete; without it they render nothing and the
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ describe('<library-filter>', () => {
|
|||||||
select?.dispatchEvent(new Event('change'));
|
select?.dispatchEvent(new Event('change'));
|
||||||
await flush();
|
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 () => {
|
it('picks up a library added while it was on screen', async () => {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ function boxOf(el: Element | null | undefined): { w: number; h: number } {
|
|||||||
describe('the way out of a detail view', () => {
|
describe('the way out of a detail view', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
'library.Library.GetTracks',
|
'library.Library.GetTrackTable',
|
||||||
'library.Library.GetAlbums',
|
'library.Library.GetAlbums',
|
||||||
'library.Library.GetArtists',
|
'library.Library.GetArtists',
|
||||||
'library.Library.GetGenres',
|
'library.Library.GetGenres',
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import '@components/track-list/track-list';
|
|||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness';
|
import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness';
|
||||||
import { fixture, shadow, text } from '@test/support/render';
|
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. */
|
/** Drop the library store's cache so the list has to fetch. */
|
||||||
async function emptyLibrary(): Promise<void> {
|
async function emptyLibrary(): Promise<void> {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
stub('library.Library.GetArtists', []);
|
stub('library.Library.GetArtists', []);
|
||||||
stub('library.Library.GetGenres', []);
|
stub('library.Library.GetGenres', []);
|
||||||
@@ -40,7 +41,7 @@ describe('<track-list> empty, loading and failed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('says the query failed, and offers to try again', async () => {
|
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);
|
emit(Events.LibraryScanComplete);
|
||||||
await flush();
|
await flush();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '@components/track-list/track-list';
|
|||||||
import { stub, emit, flush } from '@test/support/harness';
|
import { stub, emit, flush } from '@test/support/harness';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
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. */
|
/** Two fixture tracks, enough to move a focus ring between. */
|
||||||
const TRACKS = [
|
const TRACKS = [
|
||||||
@@ -78,7 +79,7 @@ describe('<queue-panel> when closed', () => {
|
|||||||
|
|
||||||
describe('<track-list> roving tabindex', () => {
|
describe('<track-list> roving tabindex', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers exactly one tab stop, however many rows there are', async () => {
|
it('offers exactly one tab stop, however many rows there are', async () => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
stubFailure,
|
stubFailure,
|
||||||
} from '@test/support/harness';
|
} from '@test/support/harness';
|
||||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const SEARCH = 'explore.Service.SearchLocal';
|
const SEARCH = 'explore.Service.SearchLocal';
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ describe('<explore-view> badges', () => {
|
|||||||
stub('explore.Service.GetArtistImageURL', '');
|
stub('explore.Service.GetArtistImageURL', '');
|
||||||
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
|
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
|
||||||
stub('library.Library.GetAlbums', []);
|
stub('library.Library.GetAlbums', []);
|
||||||
stub('library.Library.GetTracks', []);
|
stub('library.Library.GetTrackTable', trackTable([]));
|
||||||
await withRequests([]);
|
await withRequests([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,21 +48,12 @@ describe('the track list Art column', () => {
|
|||||||
]).toEqual(['lazy', 'async']);
|
]).toEqual(['lazy', 'async']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back through the tiers rather than rendering nothing', () => {
|
// There is no fallback through the larger tiers any more: the list
|
||||||
const onlyOriginal = cell('albumArt', {
|
// carries only the small one (#281), and the backend derives every
|
||||||
CoverArtPath: '/covers/abc.jpg',
|
// tier from the same file, so it is set whenever any of them is.
|
||||||
CoverArtSmall: '',
|
|
||||||
CoverArtMedium: '',
|
|
||||||
}).querySelector('img');
|
|
||||||
|
|
||||||
expect(onlyOriginal?.getAttribute('src')).toBe('/covers/abc.jpg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders nothing at all when there is no art', () => {
|
it('renders nothing at all when there is no art', () => {
|
||||||
const none = cell('albumArt', {
|
const none = cell('albumArt', {
|
||||||
CoverArtPath: '',
|
|
||||||
CoverArtSmall: '',
|
CoverArtSmall: '',
|
||||||
CoverArtMedium: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(none.querySelector('img')).toBeNull();
|
expect(none.querySelector('img')).toBeNull();
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ const TAGS = [
|
|||||||
*/
|
*/
|
||||||
function stubEmptyBackend(): void {
|
function stubEmptyBackend(): void {
|
||||||
const emptyLists = [
|
const emptyLists = [
|
||||||
'library.Library.GetTracks',
|
'library.Library.GetTrackTable',
|
||||||
'library.Library.GetAlbums',
|
'library.Library.GetAlbums',
|
||||||
'library.Library.GetArtists',
|
'library.Library.GetArtists',
|
||||||
'library.Library.GetGenres',
|
'library.Library.GetGenres',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import '@components/selection-bar/selection-bar';
|
|||||||
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
import { installTouchGestures, LONG_PRESS_MS } from '@utils/touch-gestures';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const HELD = LONG_PRESS_MS + 120;
|
const HELD = LONG_PRESS_MS + 120;
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ function rows(el: HTMLElement): HTMLElement[] {
|
|||||||
describe('a finger on a track row', () => {
|
describe('a finger on a track row', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
stub('config.Config.GetShortcuts', {});
|
stub('config.Config.GetShortcuts', {});
|
||||||
stub('queue.Queue.SetQueue', null);
|
stub('queue.Queue.SetQueue', null);
|
||||||
@@ -320,7 +321,7 @@ describe('<selection-bar>', () => {
|
|||||||
describe('a tap on a name inside a row', () => {
|
describe('a tap on a name inside a row', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
stub('config.Config.GetShortcuts', {});
|
stub('config.Config.GetShortcuts', {});
|
||||||
stub('queue.Queue.SetQueue', null);
|
stub('queue.Queue.SetQueue', null);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import '@components/track-list/track-list';
|
|||||||
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
import { calls, flush, resetHarness, stub } from '@test/support/harness';
|
||||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
import { installTouchGestures } from '@utils/touch-gestures';
|
import { installTouchGestures } from '@utils/touch-gestures';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
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', () => {
|
describe('a finger swiped right across a track row', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetHarness();
|
resetHarness();
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||||
stub('config.Config.GetShortcuts', {});
|
stub('config.Config.GetShortcuts', {});
|
||||||
stub('queue.Queue.SetQueue', null);
|
stub('queue.Queue.SetQueue', null);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const CACHED_VIEWS = [
|
|||||||
* binding resolves undefined, which is not what Go sends. */
|
* binding resolves undefined, which is not what Go sends. */
|
||||||
function stubEmptyBackend(): void {
|
function stubEmptyBackend(): void {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
'library.Library.GetTracks',
|
'library.Library.GetTrackTable',
|
||||||
'library.Library.GetAlbums',
|
'library.Library.GetAlbums',
|
||||||
'library.Library.GetArtists',
|
'library.Library.GetArtists',
|
||||||
'library.Library.GetGenres',
|
'library.Library.GetGenres',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const importTimeDefaults: Array<[string, unknown]> = [
|
|||||||
// libraryStore and playlistStore fetch eagerly at import. Left
|
// libraryStore and playlistStore fetch eagerly at import. Left
|
||||||
// unstubbed they would cache `undefined` — not the empty list Go
|
// unstubbed they would cache `undefined` — not the empty list Go
|
||||||
// sends — and every consumer would then crash on `.length`.
|
// sends — and every consumer would then crash on `.length`.
|
||||||
['library.Library.GetTracks', []],
|
['library.Library.GetTrackTable', { strings: [''] }],
|
||||||
['library.Library.GetAlbums', []],
|
['library.Library.GetAlbums', []],
|
||||||
['library.Library.GetArtists', []],
|
['library.Library.GetArtists', []],
|
||||||
['library.Library.GetGenres', []],
|
['library.Library.GetGenres', []],
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ import {
|
|||||||
lastArgs,
|
lastArgs,
|
||||||
resetHarness,
|
resetHarness,
|
||||||
} from '@test/support/harness';
|
} from '@test/support/harness';
|
||||||
|
import { trackTable } from '@test/support/track-table';
|
||||||
|
|
||||||
const TRACKS = [
|
const TRACKS = [
|
||||||
{ ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' },
|
{ TrackName: 'One', FilePath: '/a.mp3', PlayCount: 0 },
|
||||||
{ ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' },
|
{ TrackName: 'Two', FilePath: '/b.mp3', PlayCount: 4 },
|
||||||
];
|
];
|
||||||
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
|
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
|
||||||
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other 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
|
/** Stub every read binding the store can reach. Unstubbed bindings
|
||||||
* resolve undefined, which the store would cache as if it were data. */
|
* resolve undefined, which the store would cache as if it were data. */
|
||||||
function stubReads(): void {
|
function stubReads(): void {
|
||||||
stub('library.Library.GetTracks', TRACKS);
|
stub('library.Library.GetTrackTable', trackTable(TRACKS));
|
||||||
stub('library.Library.GetAlbums', ALBUMS);
|
stub('library.Library.GetAlbums', ALBUMS);
|
||||||
stub('library.Library.GetArtists', ARTISTS);
|
stub('library.Library.GetArtists', ARTISTS);
|
||||||
stub('library.Library.GetGenres', GENRES);
|
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.GetAlbumsByArtist', ALBUMS);
|
||||||
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
|
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 () => {
|
it('serves a second read from cache without touching the backend', async () => {
|
||||||
await libraryStore.getTracks();
|
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 () => {
|
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', () => {
|
it('exposes cached collections synchronously once loaded', () => {
|
||||||
expect([
|
expect([
|
||||||
libraryStore.getCachedTracks(),
|
libraryStore.getCachedTracks()?.map((t) => t.FilePath),
|
||||||
libraryStore.getCachedAlbums(),
|
libraryStore.getCachedAlbums(),
|
||||||
libraryStore.cachedArtists,
|
libraryStore.cachedArtists,
|
||||||
libraryStore.getCachedGenres(),
|
libraryStore.getCachedGenres(),
|
||||||
]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]);
|
]).toEqual([['/a.mp3', '/b.mp3'], ALBUMS, ARTISTS, GENRES]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refetches everything when a scan completes', async () => {
|
it('refetches everything when a scan completes', async () => {
|
||||||
@@ -103,7 +99,7 @@ describe('library store: caching', () => {
|
|||||||
'library.Library.GetAlbums',
|
'library.Library.GetAlbums',
|
||||||
'library.Library.GetArtists',
|
'library.Library.GetArtists',
|
||||||
'library.Library.GetGenres',
|
'library.Library.GetGenres',
|
||||||
'library.Library.GetTracks',
|
'library.Library.GetTrackTable',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +107,7 @@ describe('library store: caching', () => {
|
|||||||
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
|
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
|
||||||
await flush();
|
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', () => {
|
it('patches the one track it names', () => {
|
||||||
const tracks = libraryStore.getCachedTracks();
|
const tracks = libraryStore.getCachedTracks();
|
||||||
|
|
||||||
|
// LastPlayed is not on the list's rows (#281); trackCache
|
||||||
|
// patches it on whole tracks.
|
||||||
expect(tracks?.[0]).toMatchObject({
|
expect(tracks?.[0]).toMatchObject({
|
||||||
FilePath: '/a.mp3',
|
FilePath: '/a.mp3',
|
||||||
PlayCount: 9,
|
PlayCount: 9,
|
||||||
LastPlayed: '2026-08-11 10:00:00',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +189,7 @@ describe('library store: caching', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not refetch the tracks', () => {
|
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', () => {
|
it('splices the removed track out in place', () => {
|
||||||
@@ -258,7 +255,7 @@ describe('library store: library filter', () => {
|
|||||||
libraryStore.setSelectedLibrary(7);
|
libraryStore.setSelectedLibrary(7);
|
||||||
await flush();
|
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 () => {
|
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 () => {
|
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 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,
|
// Only the track fetch is held open; the other three settle at once,
|
||||||
// so the test is about the overtaking and nothing else.
|
// so the test is about the overtaking and nothing else.
|
||||||
stub(
|
stub(
|
||||||
'library.Library.GetTracks',
|
'library.Library.GetTrackTable',
|
||||||
(id: number) =>
|
(id: number) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
pending.push({ id, 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));
|
pending.find((p) => p.id === 8)?.resolve(byLibrary(8));
|
||||||
await flush();
|
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 () => {
|
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.
|
// Invalidation drops the cache and starts the fetch that fails.
|
||||||
emit(Events.LibraryScanComplete);
|
emit(Events.LibraryScanComplete);
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<string, number>([['', 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<string, number>();
|
||||||
|
const table: Record<string, unknown> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string>('../../bindings/yellowjacket/backend/library/models.ts', {
|
||||||
|
eager: true,
|
||||||
|
query: '?raw',
|
||||||
|
import: 'default',
|
||||||
|
}),
|
||||||
|
)[0] ?? '';
|
||||||
|
const ENCODER = Object.values(
|
||||||
|
import.meta.glob<string>('../../../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<string>(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());
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user