removing it was wrong: the mobile player stopped playing. nothing in the web read the header and /meta already carried durationSec, so it looked dead. it was not — it is a documented part of the contract and something depended on it. the header is back and the ffprobe is not. the value is read from the album's meta.json, which is the same number ffprobe produced (the indexer got it from ffprobe at index time), cached per album and keyed on the meta file's mtime so a reindex invalidates it. 1.5ms cold, 0.1ms warm, against 50ms warm and 315ms cold for the process spawn. first pass looked for meta.json beside the audio; it lives under the sidecar's own cache root mirroring the relative path. uses metaFilePath now rather than re-deriving it. verified against a real file: header present and equal to meta, 304 on whole-file revalidation, 206 on range with a matching etag.
152 lines
6.0 KiB
TypeScript
152 lines
6.0 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` COMES FROM meta.json, NOT from ffprobe.
|
|
//
|
|
// This used to shell out to ffprobe on the first request for each file — measured at 50ms warm and 315ms
|
|
// cold, paid before the first byte, once per file per sidecar restart.
|
|
//
|
|
// It was briefly removed altogether, on the grounds that nothing in the web read the header and the
|
|
// indexer had already written `durationSec` into the album's `meta.json`. The mobile player disagreed by
|
|
// refusing to play, which is the answer that counts: it is a documented part of the contract and
|
|
// something out there depends on it.
|
|
//
|
|
// So the header stays and the ffprobe does not. The value is read from the same `meta.json` the client
|
|
// fetches anyway, cached per album, and it is the SAME number ffprobe produced — the indexer got it from
|
|
// ffprobe at index time. A file read instead of a process spawn.
|
|
|
|
/**
|
|
* `durationSec` for one track, from its album's `meta.json`.
|
|
*
|
|
* 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 durationFromMeta(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 — no duration to report, same as ffprobe failing
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
/** 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 durationFromMeta(relPath);
|
|
|
|
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) } });
|
|
}
|