From 9fb948fe83802fa85960fff34a6c4a11101e4c01 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 23 Sep 2026 00:14:06 +0100 Subject: [PATCH] music: the server picks the random tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sidecar/index.ts | 20 +++++++ sidecar/random.ts | 142 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 sidecar/random.ts diff --git a/sidecar/index.ts b/sidecar/index.ts index c6ebb55..879718b 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -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= audio with Range→206 (Content-Range/Length/Accept-Ranges) // + `X-Audio-Duration` (seconds, ffprobe). 400/404/416. +// GET /random?path=&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: { "": { v, cover, tracks, videos?, disco? } } } // GET /meta?path= album meta.json (IndexMeta). ETag: ; 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. diff --git a/sidecar/random.ts b/sidecar/random.ts new file mode 100644 index 0000000..7169baf --- /dev/null +++ b/sidecar/random.ts @@ -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 => { + 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 { + 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(); + for (let attempts = 0; offsets.size < want && attempts < want * 20; attempts++) { + offsets.add(Math.floor(Math.random() * total)); + } + + const byAlbum = new Map(); + 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; +}