/** * 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(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`); }