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
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
/**
|
|
* 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`);
|
|
}
|