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 }