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