diff --git a/MUSIC_API.md b/MUSIC_API.md index f775ea7..ea68575 100644 --- a/MUSIC_API.md +++ b/MUSIC_API.md @@ -33,14 +33,20 @@ Byte-range streaming so the player can **seek without downloading the whole file | No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `ETag`, `Cache-Control` | | With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `ETag` | -- **`X-Audio-Duration` is GONE** (2026-08-19). It cost an `ffprobe` per file on the first request — - measured at 50ms warm, 315ms cold, paid before the first byte — to report a number the indexer had - already written into `meta.json`. **Read `durationSec` from `/meta` instead**, which every client - already fetches to build its queue. +- **`X-Audio-Duration` still ships, and no longer costs an `ffprobe`** (2026-08-19). It used to spawn + one per file on the first request — measured at 50ms warm, 315ms cold, paid before the first byte. + It is now read from the album's `meta.json` and cached per album: 1.5ms cold, 0.1ms warm, and the + same number, because the indexer got it from ffprobe too. + + It was briefly removed outright, on the reasoning that nothing in the web read it and `/meta` already + carried `durationSec`. The mobile player stopped playing, so it is load-bearing for at least one + client and stays in the contract. New clients should still prefer `durationSec` from `/meta` — it + arrives with the queue, before the first stream request. + + Absent when the album has not been indexed, exactly as it was absent when ffprobe failed. - **`ETag`** is `mtime-size` in base36. A whole-file request with a matching `If-None-Match` answers `304`; a Range request never does, because a 304 would answer a question about part of a file with a statement about all of it. - VBR MP3s. No need to scan the file. - Errors: `400` invalid/missing path · `404` not found · `416` bad range. ``` diff --git a/sidecar/stream-audio.ts b/sidecar/stream-audio.ts index 7ae4034..da86cdf 100644 --- a/sidecar/stream-audio.ts +++ b/sidecar/stream-audio.ts @@ -1,6 +1,8 @@ import { stat } from 'node:fs/promises'; -import { resolve, sep } from 'node:path'; +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"), @@ -19,15 +21,56 @@ const CONTENT_TYPES: Record = { wma: 'audio/x-ms-wma', }; -// NO ffprobe HERE, and no `X-Audio-Duration`. +// `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 filled a header that nothing in -// the web read, duplicating `durationSec` which the indexer already wrote into the album's `meta.json` -// and which every client fetches anyway to build its queue. +// cold, paid before the first byte, once per file per sidecar restart. // -// Removed rather than cached harder: the fastest work is the work not done, and a second copy of a number -// is a second thing that can disagree. +// 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>(); + +async function durationFromMeta(relPath: string): Promise { + // 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 { @@ -67,9 +110,12 @@ export async function streamAudioFile( // 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 = { '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.