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; }