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:
1 parent
4a9fe1d207
commit
7a1412383f
42 files changed
+1011
-275
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type * as library from '@go/library/models.js';
|
||||
import type { ListTrack } from '@utils/track-table';
|
||||
import {
|
||||
formatSampleRate,
|
||||
formatBitDepth,
|
||||
@@ -45,7 +45,7 @@ export interface ColumnDef {
|
||||
*/
|
||||
configurable?: boolean;
|
||||
/** Extracts the display value from a track. */
|
||||
accessor: (track: library.Track) => string;
|
||||
accessor: (track: ListTrack) => string;
|
||||
/** Default CSS width (used when no saved width exists). */
|
||||
defaultWidth: string;
|
||||
/** Text alignment. Defaults to left. */
|
||||
@@ -60,15 +60,15 @@ export interface ColumnDef {
|
||||
* needs it, which is why it is optional rather than a second
|
||||
* required parameter on all of them.
|
||||
*/
|
||||
renderCell?: (track: library.Track, term?: string) => unknown;
|
||||
renderCell?: (track: ListTrack, term?: string) => unknown;
|
||||
/**
|
||||
* Comparison function for sorting two tracks by this column.
|
||||
* Returns negative if a < b, positive if a > b, zero if equal.
|
||||
* If omitted the column is not sortable.
|
||||
*/
|
||||
comparator?: (
|
||||
a: library.Track,
|
||||
b: library.Track,
|
||||
a: ListTrack,
|
||||
b: ListTrack,
|
||||
) => number;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||||
label: 'Art',
|
||||
accessor: () => '',
|
||||
defaultWidth: '36px',
|
||||
renderCell: (track: library.Track) => {
|
||||
renderCell: (track: ListTrack) => {
|
||||
// `perf.M3`. This rendered `CoverArtPath` — the *original*
|
||||
// embedded artwork, commonly 1500×1500 and several hundred
|
||||
// kB — scaled by CSS into a 24 px box, while the 100 px
|
||||
@@ -90,10 +90,10 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
||||
// `cover-grid.getCoverUrl()` has picked the right tier all
|
||||
// along; this is the same rule for a much smaller box, with
|
||||
// the two attributes that keep the decode off the scroll
|
||||
// path.
|
||||
const src = track.CoverArtSmall
|
||||
|| track.CoverArtMedium
|
||||
|| track.CoverArtPath;
|
||||
// path. The list carries only this tier (#281): every tier
|
||||
// is derived from the same file, so it is set whenever any
|
||||
// of them would be.
|
||||
const src = track.CoverArtSmall;
|
||||
|
||||
if (!src) return nothing;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as library from '@go/library/models.js';
|
||||
import type { ListTrack } from '@utils/track-table';
|
||||
import { html } from 'lit';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
@@ -99,7 +99,7 @@ function matchQuality(
|
||||
* fields are always included on top of these.
|
||||
*/
|
||||
function scoreTrack(
|
||||
track: library.Track,
|
||||
track: ListTrack,
|
||||
termLower: string,
|
||||
columns: ColumnDef[],
|
||||
): number {
|
||||
@@ -147,7 +147,7 @@ function scoreTrack(
|
||||
|
||||
/** A track paired with its relevance score. */
|
||||
export interface RankedTrack {
|
||||
track: library.Track;
|
||||
track: ListTrack;
|
||||
score: number;
|
||||
}
|
||||
|
||||
@@ -166,10 +166,10 @@ export interface RankedTrack {
|
||||
* `scores` (Map of FilePath → relevance score).
|
||||
*/
|
||||
export function rankTracks(
|
||||
tracks: library.Track[],
|
||||
tracks: ListTrack[],
|
||||
term: string,
|
||||
activeColumns: ColumnDef[],
|
||||
): { tracks: library.Track[]; scores: Map<string, number> } {
|
||||
): { tracks: ListTrack[]; scores: Map<string, number> } {
|
||||
const termLower = term.toLowerCase();
|
||||
const ranked: RankedTrack[] = [];
|
||||
|
||||
@@ -188,7 +188,7 @@ export function rankTracks(
|
||||
// Sort descending by score (highest relevance first).
|
||||
ranked.sort((a, b) => b.score - a.score);
|
||||
|
||||
const result: library.Track[] = [];
|
||||
const result: ListTrack[] = [];
|
||||
const scores = new Map<string, number>();
|
||||
|
||||
for (const r of ranked) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as library from '@go/library/models.js';
|
||||
import { LitElement, html, svg, css, nothing } from 'lit';
|
||||
import type { ListTrack } from '@utils/track-table';
|
||||
import { LitElement, html, svg, css, nothing, type TemplateResult } from 'lit';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { srOnly } from '../../styles/sr-only.css';
|
||||
import {
|
||||
@@ -80,11 +80,10 @@ import { describeError } from '@utils/describe-error';
|
||||
import { notificationStore } from '@store/notification-store';
|
||||
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
|
||||
import { RemoveFromLibrary } from '@go/library/library.js';
|
||||
import { loadTrackDetails } from '@utils/lazy-track-details.js';
|
||||
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
|
||||
import { showTrackDetailsForPath, showBatchTrackDetailsForPaths } from '@utils/track-details-opener.js';
|
||||
import { tracksByFilePath } from '@utils/track-index.js';
|
||||
import '@components/playlist-picker/playlist-picker.js';
|
||||
import type { TrackDetails } from '@components/track-details/track-details.js';
|
||||
import type { CoverArtUrls } from '@components/track-details/track-details.js';
|
||||
import {
|
||||
ICON_PLAY,
|
||||
ICON_PLAYLIST,
|
||||
@@ -155,7 +154,7 @@ export class TrackList
|
||||
* parent is responsible for reloading when data changes.
|
||||
*/
|
||||
@property({ type: Array, attribute: false })
|
||||
externalTracks?: library.Track[];
|
||||
externalTracks?: ListTrack[];
|
||||
|
||||
/**
|
||||
* What a host embedding this list (e.g. `genre-details`) should say
|
||||
@@ -199,7 +198,7 @@ export class TrackList
|
||||
private lastSearchTerm = '';
|
||||
|
||||
/** Tracks the store's cached array reference to detect refreshes. */
|
||||
private lastTracksRef: library.Track[] | null =
|
||||
private lastTracksRef: ListTrack[] | null =
|
||||
null;
|
||||
|
||||
/**
|
||||
@@ -242,7 +241,7 @@ export class TrackList
|
||||
}
|
||||
|
||||
@state()
|
||||
private tracks: library.Track[] = [];
|
||||
private tracks: ListTrack[] = [];
|
||||
|
||||
@query('#context-menu')
|
||||
private contextMenuPopup!: MenuSurface;
|
||||
@@ -269,16 +268,16 @@ export class TrackList
|
||||
private lastActiveTrackPath: string | null = null;
|
||||
|
||||
// -- Memoisation caches for filtered / sorted tracks --
|
||||
private cachedFilteredTracks: library.Track[] = [];
|
||||
private cachedSortedTracks: library.Track[] = [];
|
||||
private cachedFilteredTracks: ListTrack[] = [];
|
||||
private cachedSortedTracks: ListTrack[] = [];
|
||||
private cachedRelevanceScores = new Map<
|
||||
string,
|
||||
number
|
||||
>();
|
||||
private prevFilterTracks: library.Track[] = [];
|
||||
private prevFilterTracks: ListTrack[] = [];
|
||||
private prevFilterTerm = '';
|
||||
private prevFilterColIds = '';
|
||||
private prevSortFiltered: library.Track[] = [];
|
||||
private prevSortFiltered: ListTrack[] = [];
|
||||
private prevSortField: string | null = null;
|
||||
private prevSortDir: SortDirection = 'asc';
|
||||
|
||||
@@ -523,7 +522,7 @@ export class TrackList
|
||||
}
|
||||
}
|
||||
|
||||
private computeFilteredTracks(): library.Track[] {
|
||||
private computeFilteredTracks(): ListTrack[] {
|
||||
const term = this.searchCtrl.term;
|
||||
|
||||
if (!term) {
|
||||
@@ -543,7 +542,7 @@ export class TrackList
|
||||
return result.tracks;
|
||||
}
|
||||
|
||||
private computeSortedTracks(): library.Track[] {
|
||||
private computeSortedTracks(): ListTrack[] {
|
||||
const tracks = this.cachedFilteredTracks;
|
||||
const hasSearch =
|
||||
this.cachedRelevanceScores.size > 0;
|
||||
@@ -1734,7 +1733,7 @@ export class TrackList
|
||||
*/
|
||||
private resolveTrackFromEvent(
|
||||
e: Event,
|
||||
): { track: library.Track; index: number } | null {
|
||||
): { track: ListTrack; index: number } | null {
|
||||
const row = (e.target as HTMLElement).closest(
|
||||
'.track-row',
|
||||
) as HTMLElement | null;
|
||||
@@ -1875,7 +1874,7 @@ export class TrackList
|
||||
|
||||
private onTrackRowClick(
|
||||
e: MouseEvent,
|
||||
track: library.Track,
|
||||
track: ListTrack,
|
||||
index: number,
|
||||
) {
|
||||
// Clicking is also how the keyboard's starting point is chosen:
|
||||
@@ -1884,7 +1883,7 @@ export class TrackList
|
||||
this.selection.handleItemClick(e, track.FilePath, index);
|
||||
}
|
||||
|
||||
private onTrackRowDblClick(_track: library.Track, index: number) {
|
||||
private onTrackRowDblClick(_track: ListTrack, index: number) {
|
||||
this.selection.clear();
|
||||
this.playFromRow(index);
|
||||
}
|
||||
@@ -1925,7 +1924,7 @@ export class TrackList
|
||||
);
|
||||
}
|
||||
|
||||
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
|
||||
private onTrackContextMenu(e: MouseEvent, track: ListTrack) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1939,7 +1938,7 @@ export class TrackList
|
||||
|
||||
private onTrackDragStart = (
|
||||
e: DragEvent,
|
||||
track: library.Track,
|
||||
track: ListTrack,
|
||||
) => {
|
||||
// Gather file paths: all selected if this track is selected,
|
||||
// otherwise just the dragged track.
|
||||
@@ -2129,74 +2128,24 @@ export class TrackList
|
||||
this.ctxMenu.close();
|
||||
}
|
||||
|
||||
// The list's rows do not carry what the dialog shows (#281), so
|
||||
// the dialog gets whole tracks by path, like every other opener.
|
||||
private async openTrackDetails(filePath: string) {
|
||||
const track = tracksByFilePath(this.tracks).get(
|
||||
await showTrackDetailsForPath(
|
||||
() => this.trackDetailsDialog,
|
||||
filePath,
|
||||
);
|
||||
|
||||
if (!track) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
() => void this.openTrackDetails(filePath),
|
||||
);
|
||||
|
||||
if (!ready) return;
|
||||
|
||||
const coverArt = track.CoverArtPath
|
||||
? {
|
||||
coverArtPath: track.CoverArtPath,
|
||||
coverArtSmall: track.CoverArtSmall,
|
||||
coverArtMedium: track.CoverArtMedium,
|
||||
coverArtLarge: track.CoverArtLarge,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
this.trackDetailsDialog?.show(
|
||||
track,
|
||||
coverArt,
|
||||
);
|
||||
}
|
||||
|
||||
private async openBatchTrackDetails(
|
||||
filePaths: string[],
|
||||
) {
|
||||
const tracks = tracksForPaths(
|
||||
this.tracks,
|
||||
await showBatchTrackDetailsForPaths(
|
||||
() => this.trackDetailsDialog,
|
||||
filePaths,
|
||||
);
|
||||
|
||||
if (tracks.length === 0) return;
|
||||
|
||||
const ready = await loadTrackDetails(
|
||||
() => void this.openBatchTrackDetails(filePaths),
|
||||
);
|
||||
|
||||
if (!ready) return;
|
||||
|
||||
// Use cover art from the first track. If all tracks share
|
||||
// the same album, they share the same art.
|
||||
const first = tracks[0]!;
|
||||
let coverArt: CoverArtUrls | null = null;
|
||||
let coverArtMixed = false;
|
||||
|
||||
const albumNames = new Set(tracks.map((t) => t.Album));
|
||||
|
||||
if (albumNames.size === 1 && first.CoverArtPath) {
|
||||
coverArt = {
|
||||
coverArtPath: first.CoverArtPath,
|
||||
coverArtSmall: first.CoverArtSmall,
|
||||
coverArtMedium: first.CoverArtMedium,
|
||||
coverArtLarge: first.CoverArtLarge,
|
||||
};
|
||||
} else if (albumNames.size > 1) {
|
||||
coverArtMixed = true;
|
||||
}
|
||||
|
||||
this.trackDetailsDialog?.showBatch(
|
||||
tracks,
|
||||
coverArt,
|
||||
coverArtMixed,
|
||||
);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
@@ -2274,7 +2223,7 @@ export class TrackList
|
||||
this.saveSortPreferences();
|
||||
}
|
||||
|
||||
private isActiveTrack(track: library.Track): boolean {
|
||||
private isActiveTrack(track: ListTrack): boolean {
|
||||
const currentTrack = this.player.currentTrack;
|
||||
|
||||
if (!currentTrack) return false;
|
||||
@@ -2283,9 +2232,9 @@ export class TrackList
|
||||
}
|
||||
|
||||
private renderTrackRow = (
|
||||
track: library.Track,
|
||||
track: ListTrack,
|
||||
index: number,
|
||||
): unknown => {
|
||||
): TemplateResult => {
|
||||
const active = this.isActiveTrack(track);
|
||||
const selected = this.selection.isSelected(
|
||||
track.FilePath,
|
||||
@@ -2568,7 +2517,7 @@ export class TrackList
|
||||
scroller
|
||||
.items=${visibleTracks}
|
||||
.renderItem=${this.renderTrackRow}
|
||||
.keyFunction=${(track: library.Track) => track.FilePath}
|
||||
.keyFunction=${(track: ListTrack) => track.FilePath}
|
||||
.layout=${this.rowLayout}
|
||||
></lit-virtualizer>
|
||||
`}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type * as library from '@go/library/models.js';
|
||||
import { libraryStore } from '../library-store';
|
||||
import type { ListTrack } from '@utils/track-table';
|
||||
|
||||
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
||||
|
||||
@@ -55,7 +56,7 @@ export class LibraryController implements ReactiveController {
|
||||
// DATA ACCESS
|
||||
// ===================================================================
|
||||
|
||||
async getTracks(): Promise<library.Track[]> {
|
||||
async getTracks(): Promise<ListTrack[]> {
|
||||
return libraryStore.getTracks();
|
||||
}
|
||||
|
||||
@@ -85,7 +86,7 @@ export class LibraryController implements ReactiveController {
|
||||
);
|
||||
}
|
||||
|
||||
get cachedTracks(): library.Track[] | null {
|
||||
get cachedTracks(): ListTrack[] | null {
|
||||
return libraryStore.getCachedTracks();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventsOn } from '@runtime/runtime';
|
||||
import {
|
||||
GetTracks,
|
||||
GetTrackTable,
|
||||
GetAlbums,
|
||||
GetArtists,
|
||||
GetGenres,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@go/library/library.js';
|
||||
import type * as library from '@go/library/models.js';
|
||||
import { list } from '@utils/binding';
|
||||
import { decodeTrackTable, type ListTrack } from '@utils/track-table';
|
||||
import { Events } from '../events';
|
||||
|
||||
type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
|
||||
@@ -28,7 +29,7 @@ const COVER_SIZE_DEFAULT = 176;
|
||||
const COVER_SIZE_KEY = 'cover-grid-size';
|
||||
|
||||
class LibraryStore {
|
||||
private tracks: library.Track[] | null = null;
|
||||
private tracks: ListTrack[] | null = null;
|
||||
private albums: library.Album[] | null = null;
|
||||
private artists: library.Artist[] | null = null;
|
||||
private genres: library.GenreWithCount[] | null = null;
|
||||
@@ -214,18 +215,18 @@ class LibraryStore {
|
||||
}
|
||||
}
|
||||
|
||||
async getTracks(): Promise<library.Track[]> {
|
||||
async getTracks(): Promise<ListTrack[]> {
|
||||
if (this.tracks !== null) {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
const pending = this.pending<library.Track[]>('tracks');
|
||||
const pending = this.pending<ListTrack[]>('tracks');
|
||||
|
||||
if (pending) return pending;
|
||||
|
||||
return this.track(
|
||||
'tracks',
|
||||
list(GetTracks(this.libraryFilter())),
|
||||
GetTrackTable(this.libraryFilter()).then(decodeTrackTable),
|
||||
(tracks) => {
|
||||
this.tracks = tracks;
|
||||
},
|
||||
@@ -341,7 +342,7 @@ class LibraryStore {
|
||||
// Synchronous access for controllers that need current cached values.
|
||||
// ===================================================================
|
||||
|
||||
getCachedTracks(): library.Track[] | null {
|
||||
getCachedTracks(): ListTrack[] | null {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
@@ -520,10 +521,11 @@ class LibraryStore {
|
||||
private applyPlayCount(payload: unknown): void {
|
||||
if (this.tracks === null) return;
|
||||
|
||||
// The list does not carry LastPlayed (#281); trackCache patches
|
||||
// it on the whole tracks the details dialog reads.
|
||||
const p = payload as {
|
||||
filePath?: string;
|
||||
playCount?: number;
|
||||
lastPlayed?: string;
|
||||
} | null;
|
||||
|
||||
if (!p?.filePath) return;
|
||||
@@ -536,14 +538,10 @@ class LibraryStore {
|
||||
|
||||
if (existing === undefined) return;
|
||||
|
||||
const patched = Object.assign(
|
||||
Object.create(Object.getPrototypeOf(existing) as object),
|
||||
existing,
|
||||
{
|
||||
PlayCount: p.playCount ?? existing.PlayCount,
|
||||
LastPlayed: p.lastPlayed ?? existing.LastPlayed,
|
||||
},
|
||||
) as library.Track;
|
||||
const patched: ListTrack = {
|
||||
...existing,
|
||||
PlayCount: p.playCount ?? existing.PlayCount,
|
||||
};
|
||||
|
||||
this.tracks = [
|
||||
...this.tracks.slice(0, idx),
|
||||
|
||||
@@ -79,6 +79,14 @@ export function compact<V>(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* listField is list for a slice that arrived as a *field* of a struct
|
||||
* rather than as a return value — a column of `TrackTable`, say.
|
||||
*/
|
||||
export function listField<T>(field: T[] | null | undefined): T[] {
|
||||
return field ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* value awaits a binding whose result is used as-is, dropping only the
|
||||
* cancellation the app never asks for.
|
||||
|
||||
@@ -20,22 +20,22 @@
|
||||
* given array and never again.
|
||||
*/
|
||||
|
||||
import type * as library from '@go/library/models.js';
|
||||
/** Anything keyed by file path: a whole track, or the list's row. */
|
||||
interface HasFilePath {
|
||||
FilePath: string;
|
||||
}
|
||||
|
||||
const byArray = new WeakMap<
|
||||
readonly library.Track[],
|
||||
Map<string, library.Track>
|
||||
>();
|
||||
const byArray = new WeakMap<readonly HasFilePath[], Map<string, HasFilePath>>();
|
||||
|
||||
/** The lookup for `tracks`, built once per array identity. */
|
||||
export function tracksByFilePath(
|
||||
tracks: readonly library.Track[],
|
||||
): Map<string, library.Track> {
|
||||
let map = byArray.get(tracks);
|
||||
export function tracksByFilePath<T extends HasFilePath>(
|
||||
tracks: readonly T[],
|
||||
): Map<string, T> {
|
||||
let map = byArray.get(tracks) as Map<string, T> | undefined;
|
||||
|
||||
if (map) return map;
|
||||
|
||||
map = new Map<string, library.Track>();
|
||||
map = new Map<string, T>();
|
||||
|
||||
for (const track of tracks) {
|
||||
// First wins: a duplicate path would be the same file, and
|
||||
@@ -49,12 +49,12 @@ export function tracksByFilePath(
|
||||
}
|
||||
|
||||
/** Resolve file paths to tracks, dropping any that are not present. */
|
||||
export function tracksForPaths(
|
||||
tracks: readonly library.Track[],
|
||||
export function tracksForPaths<T extends HasFilePath>(
|
||||
tracks: readonly T[],
|
||||
filePaths: readonly string[],
|
||||
): library.Track[] {
|
||||
): T[] {
|
||||
const byPath = tracksByFilePath(tracks);
|
||||
const result: library.Track[] = [];
|
||||
const result: T[] = [];
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const track = byPath.get(filePath);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
Reference in new issue
Block a user