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
199 lines
5.6 KiB
Go
199 lines
5.6 KiB
Go
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)
|
|
}
|
|
}
|