perf(library): send the track list as a dictionary-encoded column table

GetTracks answered with one JSON object per track: 20.5 MB at 26k
tracks, ~350 bytes a row of repeated key names, four cover URLs that
are identical across an album, and the album, artist and genre strings
repeated on every track. Encoding it was ~170 MB of transient Go
allocation, and parsing it the WebView's memory peak.

GetTrackTable replaces it: one array per column, every repeated
string stored once and sent as an index, genre lists interned, and only
the fields the Tracks view reads. LastPlayed and the three larger cover
tiers are left to the details dialog, which now fetches whole tracks by
path. Each row still goes through trackFromRow, so this is an encoding
of the one projection, not a second one. 167 bytes a track against 929
in the test library; a Go test holds it under a quarter of the object
encoding, and a Vitest test reads the Go columns and the generated
Track interface and fails if they drift.

An empty library is now an empty table rather than the "no tracks in
library" error the old binding returned.

Closes #281
This commit is contained in:
yonlu committed 2026-10-05 23:30:37 -04:00
1 parent 4a9fe1d207
commit 7a1412383f
42 files changed
+1011 -275

No files matched your search

+86
View File
@@ -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;
}