music: the server picks the random tracks

The app shuffled by flattening every album's meta.json in scope into one
array and choosing from it. On a hundred-thousand-track library that
meant a six-figure object array living in the phone's JS heap — marked
by every garbage collection — and copied again on each draw. Measured on
device: the JS thread never went idle during playback and the phone
reached 'serious' thermal state.

Picking twenty songs never required any of that. The manifest already
holds per-album track counts, so the choice is arithmetic over numbers
and only the albums actually picked are read.

Uniform over TRACKS, not albums: choosing an album first would make a
two-track EP as likely as a sixty-track box set, so albums are weighted
by count via a running total and a binary search. Same distribution as
shuffling everything and taking the first n, which is what a shuffle
means. Results are re-shuffled before returning, because grouping the
reads by album would otherwise hand back something sorted by album.
This commit is contained in:
2026-09-23 00:14:06 +01:00
parent de8949efa7
commit 9fb948fe83
2 changed files with 162 additions and 0 deletions
+20
View File
@@ -3,6 +3,7 @@ import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio';
import { randomSongs } from './random';
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
import { ensurePulseAudio } from '../cliamp/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
@@ -73,6 +74,8 @@ const asKeys = (v: unknown): string[] | null =>
//
// GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416.
// GET /random?path=<rel>&n=<1..100> n random tracks from that scope, uniform over TRACKS (albums
// weighted by count). Reads only the albums picked — see random.ts.
// GET /manifest pure read of the last completed index (NO build triggered) —
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
@@ -251,6 +254,23 @@ const server = Bun.serve({
}
// ── Sync surface ──
/*
A random handful of tracks, chosen here rather than on the phone.
See random.ts for why: the app used to flatten every album's meta into one array to pick twenty
songs, which on a large library meant a six-figure object array living in the JS heap and being
copied on every draw. The manifest already holds per-album track counts, so the choice costs
arithmetic plus a read of only the albums actually picked.
*/
if (url.pathname === '/random') {
const scope = url.searchParams.get('path') ?? '';
const raw = Number(url.searchParams.get('n') ?? '20');
// Clamped: this reads one meta.json per distinct album picked, so an unbounded n is an
// unbounded amount of disk work from a query string.
const n = Number.isFinite(raw) ? Math.max(1, Math.min(100, Math.floor(raw))) : 20;
return json({ songs: await randomSongs(scope, n) }, { headers: NO_CACHE });
}
if (url.pathname === '/manifest') {
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a
// long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.