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 type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio'; import { streamAudioFile } from './stream-audio';
import { randomSongs } from './random';
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws'; import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
import { ensurePulseAudio } from '../cliamp/pulse-audio'; import { ensurePulseAudio } from '../cliamp/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex'; 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) // GET /stream?path=<home-relative> audio with Range→206 (Content-Range/Length/Accept-Ranges)
// + `X-Audio-Duration` (seconds, ffprobe). 400/404/416. // + `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) — // GET /manifest pure read of the last completed index (NO build triggered) —
// { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } } // { version, generatedAt, albums: { "<rel>": { v, cover, tracks, videos?, disco? } } }
// GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304. // GET /meta?path=<rel> album meta.json (IndexMeta). ETag: <v>; If-None-Match → 304.
@@ -251,6 +254,23 @@ const server = Bun.serve({
} }
// ── Sync surface ── // ── 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') { if (url.pathname === '/manifest') {
// Pure read — returns the last completed index. It does NOT trigger a build (that could kick off a // 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. // long/full rebuild on a plain app refresh); use POST /reindex explicitly to pick up disk changes.
+142
View File
@@ -0,0 +1,142 @@
import { getManifest, metaFilePath, type IndexMeta, type IndexTrack } from './indexer';
/**
* A random handful of tracks, chosen by the SERVER.
*
* ## Why this is not the app's job
*
* The app used to shuffle by building a song index for the whole scope — every album's meta.json,
* flattened into one array — and then picking from it. For a library of a hundred thousand tracks
* that meant holding a hundred thousand objects in the JS heap permanently, copying the array on
* every draw, and paying for all of it in garbage collection on a phone. Measured on device: the JS
* thread never went idle during playback, and the device reached "serious" thermal state.
*
* Picking twenty songs never needed any of that. The manifest already knows how many tracks each
* album holds, so the choice can be made from COUNTS and only the chosen albums read.
*
* ## Uniform over tracks, not albums
*
* Picking an album at random and then a track from it over-represents short albums: a two-track EP
* would come up as often as a sixty-track box set. So albums are weighted by their track count — a
* running total, then a random offset into it, binary-searched back to the album that contains it.
* That is the same distribution as shuffling every track and taking the first n, which is what a
* shuffle is understood to mean.
*/
export type RandomSong = {
/** Home-relative path, the id the player streams by. */
homePath: string;
albumRel: string;
file: string;
title: string;
artist: string;
album: string;
durationSec?: number;
/** Whether this album has a cover the app can request. Saves it guessing and 404ing. */
cover: boolean;
};
const readMeta = async (rel: string): Promise<IndexMeta | null> => {
const path = metaFilePath(rel);
if (!path) return null;
try {
const f = Bun.file(path);
return (await f.exists()) ? ((await f.json()) as IndexMeta) : null;
} catch {
return null;
}
};
const titleOf = (t: IndexTrack): string => t.title || t.file.replace(/\.[^.]+$/, '');
export async function randomSongs(scopeRel: string, n: number): Promise<RandomSong[]> {
const manifest = await getManifest();
/*
In scope, and actually holding tracks.
An empty scope means the whole library. Otherwise it is the folder itself or anything beneath it —
`startsWith(scope + '/')` rather than `startsWith(scope)`, so "Albums" does not also match a
sibling called "Albums Live".
*/
const rels = Object.keys(manifest.albums).filter((rel) => {
const entry = manifest.albums[rel];
if (!entry || !entry.tracks) return false;
return scopeRel === '' || rel === scopeRel || rel.startsWith(`${scopeRel}/`);
});
if (!rels.length) return [];
// Running total of track counts — the weighting described above. Numbers only; no track is read yet.
const cumulative: number[] = [];
let total = 0;
for (const rel of rels) {
total += manifest.albums[rel]!.tracks;
cumulative.push(total);
}
if (total === 0) return [];
/** Which album owns global track offset `x`. */
const albumFor = (x: number): number => {
let lo = 0;
let hi = cumulative.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (cumulative[mid]! <= x) lo = mid + 1;
else hi = mid;
}
return lo;
};
/*
Draw distinct offsets, then group by album so each album's meta.json is read ONCE however many of
its tracks were picked. `want` is capped at the library size, and the attempt limit stops a tiny
library spinning forever trying to find offsets it does not have.
*/
const want = Math.min(n, total);
const offsets = new Set<number>();
for (let attempts = 0; offsets.size < want && attempts < want * 20; attempts++) {
offsets.add(Math.floor(Math.random() * total));
}
const byAlbum = new Map<number, number[]>();
for (const off of offsets) {
const idx = albumFor(off);
const base = idx === 0 ? 0 : cumulative[idx - 1]!;
const within = off - base;
const list = byAlbum.get(idx) ?? [];
list.push(within);
byAlbum.set(idx, list);
}
const songs: RandomSong[] = [];
await Promise.all(
[...byAlbum.entries()].map(async ([idx, withins]) => {
const rel = rels[idx]!;
const meta = await readMeta(rel);
if (!meta?.tracks?.length) return;
for (const within of withins) {
// The manifest's count and the meta file can disagree if the index is mid-rebuild; modulo
// keeps that a slightly different song rather than an empty slot.
const t = meta.tracks[within % meta.tracks.length];
if (!t) continue;
songs.push({
homePath: `Music/${rel}/${t.file}`,
albumRel: rel,
file: t.file,
title: titleOf(t),
artist: t.artist || t.albumArtist || '',
album: t.album || '',
durationSec: t.durationSec,
cover: !!manifest.albums[rel]?.cover,
});
}
}),
);
// Grouping by album clustered them; a shuffle must not come back sorted by album.
for (let i = songs.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[songs[i], songs[j]] = [songs[j]!, songs[i]!];
}
return songs;
}