the previous commit read the duration from meta.json instead of spawning ffprobe. correct for an indexed album and wrong everywhere else: /stream serves any home-relative path and never required an index, so a library that has not been indexed got no header at all. alpha is exactly that case — its music cache is empty — which is how this was caught rather than shipped. "always present" replaced with "present if indexed" is the same regression as removing the header, one layer down. so both, in order: meta.json when it can answer, ffprobe when it cannot, each cached. verified on an indexed album (247 from meta, 0.3ms warm) and on the same file with its index deleted (7.03 from ffprobe, 83ms cold, 0.1ms warm).
190 lines
7.4 KiB
TypeScript
190 lines
7.4 KiB
TypeScript
import { stat } from 'node:fs/promises';
|
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
import type { IndexTrack } from './indexer';
|
|
import { metaFilePath } from './indexer';
|
|
|
|
// All processing lives here (the platform is just a proxy). Files live under the owner's home — single
|
|
// super user, so HOME_DIR. Paths from the app are home-relative (e.g. "Music/Artist/Album/track.mp3"),
|
|
// exactly like file-browser /raw.
|
|
const ROOT_DIR = homedir();
|
|
|
|
const CONTENT_TYPES: Record<string, string> = {
|
|
mp3: 'audio/mpeg',
|
|
m4a: 'audio/mp4',
|
|
mp4: 'audio/mp4',
|
|
aac: 'audio/aac',
|
|
flac: 'audio/flac',
|
|
wav: 'audio/wav',
|
|
ogg: 'audio/ogg',
|
|
opus: 'audio/opus',
|
|
wma: 'audio/x-ms-wma',
|
|
};
|
|
|
|
// `X-Audio-Duration`: meta.json first, ffprobe only if that has no answer.
|
|
//
|
|
// ffprobe alone cost 50ms warm and 315ms cold, once per file per restart, paid BEFORE THE FIRST BYTE.
|
|
//
|
|
// It was removed outright on the reasoning that nothing in the web read the header and the indexer had
|
|
// already written `durationSec` into the album's `meta.json`. The mobile player stopped playing, which
|
|
// settles it: the header is part of the contract and something depends on it.
|
|
//
|
|
// Reading it from meta.json instead is the same number — the indexer got it from ffprobe at index time —
|
|
// for a file read rather than a process spawn. But it is only the same number FOR AN INDEXED ALBUM, and
|
|
// streaming does not require indexing: /stream serves any home-relative path, so a library that has never
|
|
// been indexed (alpha's, at the time of writing) would get no header at all. Replacing "always present"
|
|
// with "present if indexed" is the same regression again, one layer down.
|
|
//
|
|
// Hence both. The fast path covers the normal case and the spawn stays as the floor, so the header is
|
|
// present whenever it used to be, and costs a process only where meta.json cannot answer.
|
|
|
|
/**
|
|
* `durationSec` for one track, from its album's `meta.json`. Undefined if the album is not indexed.
|
|
*
|
|
* Cached per album rather than per track: one read answers every track on the record, which is the access
|
|
* pattern — a queue plays an album in order. Keyed on the meta file's own mtime so a reindex invalidates
|
|
* it without anything having to remember to.
|
|
*/
|
|
const albumDurations = new Map<string, Map<string, number>>();
|
|
|
|
async function durationFromIndex(relPath: string): Promise<number | undefined> {
|
|
// meta.json does NOT sit beside the audio — it lives under the sidecar's own cache root, mirroring the
|
|
// album's relative path. `metaFilePath` is the one place that knows that; do not re-derive it here.
|
|
const metaPath = metaFilePath(dirname(relPath.replace(/^\/+/, '')));
|
|
if (!metaPath) return undefined;
|
|
|
|
let key: string;
|
|
try {
|
|
key = `${metaPath}:${(await stat(metaPath)).mtimeMs}`;
|
|
} catch {
|
|
return undefined; // unindexed album — caller falls back to ffprobe
|
|
}
|
|
|
|
let byFile = albumDurations.get(key);
|
|
if (!byFile) {
|
|
try {
|
|
const meta = (await Bun.file(metaPath).json()) as { tracks?: IndexTrack[] };
|
|
byFile = new Map(
|
|
(meta.tracks ?? []).flatMap((t) => (t.durationSec ? [[t.file, t.durationSec] as [string, number]] : [])),
|
|
);
|
|
albumDurations.set(key, byFile);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
return byFile.get(basename(relPath));
|
|
}
|
|
|
|
// Probe duration once per file (keyed by absolute path + mtime) — the player makes many range requests
|
|
// per track, and we don't want to shell out to ffprobe on each one.
|
|
const durationCache = new Map<string, number>();
|
|
|
|
async function probeDuration(absPath: string, mtimeMs: number): Promise<number | undefined> {
|
|
const key = `${absPath}:${mtimeMs}`;
|
|
const cached = durationCache.get(key);
|
|
if (cached !== undefined) return cached;
|
|
try {
|
|
const proc = Bun.spawn(
|
|
[
|
|
'ffprobe',
|
|
'-v',
|
|
'error',
|
|
'-show_entries',
|
|
'format=duration',
|
|
'-of',
|
|
'default=noprint_wrappers=1:nokey=1',
|
|
absPath,
|
|
],
|
|
{ stdout: 'pipe', stderr: 'ignore' },
|
|
);
|
|
const out = (await new Response(proc.stdout).text()).trim();
|
|
await proc.exited;
|
|
const d = parseFloat(out);
|
|
if (Number.isFinite(d) && d > 0) {
|
|
durationCache.set(key, d);
|
|
return d;
|
|
}
|
|
} catch {
|
|
/* ffprobe missing or failed — no duration header */
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** Resolve a home-relative path within ROOT_DIR; null if it escapes (traversal). */
|
|
function resolveWithinRoot(relPath: string): string | null {
|
|
const clean = relPath.replace(/^\/+/, '');
|
|
const abs = resolve(ROOT_DIR, clean);
|
|
if (abs !== ROOT_DIR && !abs.startsWith(ROOT_DIR + sep)) return null;
|
|
return abs;
|
|
}
|
|
|
|
/** Serve an audio file with byte-range support and an mtime+size ETag. */
|
|
export async function streamAudioFile(
|
|
relPath: string,
|
|
rangeHeader: string | null,
|
|
ifNoneMatch: string | null = null,
|
|
): Promise<Response> {
|
|
const absPath = resolveWithinRoot(relPath);
|
|
if (!absPath) return new Response('Invalid path', { status: 400 });
|
|
|
|
let s;
|
|
try {
|
|
s = await stat(absPath);
|
|
} catch {
|
|
return new Response('Not found', { status: 404 });
|
|
}
|
|
if (!s.isFile()) return new Response('Not a file', { status: 404 });
|
|
|
|
const total = s.size;
|
|
const ext = absPath.slice(absPath.lastIndexOf('.') + 1).toLowerCase();
|
|
const contentType = CONTENT_TYPES[ext] ?? 'application/octet-stream';
|
|
const file = Bun.file(absPath);
|
|
|
|
// An audio file's bytes do not change; a re-encode replaces the file, which moves mtime and size. So
|
|
// the pair identifies the content, and a client that has played this track once can seek in it without
|
|
// fetching anything again.
|
|
//
|
|
// This was the ONLY route with no validator at all — cover, meta, lyrics and poster have had one all
|
|
// along — so every seek and every replay re-read the file over the network.
|
|
const etag = `"${s.mtimeMs.toString(36)}-${total.toString(36)}"`;
|
|
|
|
const duration = (await durationFromIndex(relPath)) ?? (await probeDuration(absPath, s.mtimeMs));
|
|
|
|
const baseHeaders: Record<string, string> = {
|
|
'Content-Type': contentType,
|
|
'Accept-Ranges': 'bytes',
|
|
...(duration ? { 'X-Audio-Duration': String(duration) } : {}),
|
|
ETag: etag,
|
|
// `no-cache` means "you may keep it, but ask first" — a conditional request rather than a blind
|
|
// refetch. `private` because a member's library is not a proxy's to hold.
|
|
'Cache-Control': 'private, no-cache',
|
|
};
|
|
|
|
// Whole-file revalidation. Deliberately NOT applied to a Range request: a 304 there would answer a
|
|
// question about part of a file with a statement about all of it, and players handle that badly.
|
|
if (!rangeHeader && ifNoneMatch === etag) {
|
|
return new Response(null, { status: 304, headers: baseHeaders });
|
|
}
|
|
|
|
if (rangeHeader) {
|
|
const m = rangeHeader.match(/bytes=(\d*)-(\d*)/);
|
|
if (m) {
|
|
const start = m[1] ? parseInt(m[1], 10) : 0;
|
|
const end = m[2] ? parseInt(m[2], 10) : total - 1;
|
|
if (Number.isNaN(start) || start < 0 || end >= total || start > end) {
|
|
return new Response('Invalid range', { status: 416, headers: { 'Content-Range': `bytes */${total}` } });
|
|
}
|
|
return new Response(file.slice(start, end + 1), {
|
|
status: 206,
|
|
headers: {
|
|
...baseHeaders,
|
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
|
'Content-Length': String(end - start + 1),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
return new Response(file, { status: 200, headers: { ...baseHeaders, 'Content-Length': String(total) } });
|
|
}
|