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.
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
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;
|
|
}
|