music: server-side library indexer + sync surface
The officer-music sidecar now builds a cache tree mirroring the library (server
counterpart of the app's music-index.ts), and exposes an rsync-clean diff surface.
Indexer (indexer.ts): walks HOME_DIR/Music; per album computes a version `v` =
hash of the source signature (track name+size+mtime, cover size+mtime); ffprobe
→ meta.json (phone IndexMeta schema: file/title/artist/albumArtist/album/track/
year/durationSec); ffmpeg compresses the cover to <=600px q5 cover.jpg. Writes
DATA_PATH/music/cache/<rel>/. Incremental (skip albums whose `v` is unchanged),
prunes cache dirs for albums removed from the library, maintains manifest.json.
Endpoints (sidecar, auto-proxied by /api/music/*):
POST /reindex async build; GET /reindex/status polls progress
GET /manifest { version, albums: { "<rel>": { v, cover, tracks } } }
GET /meta?path=<rel> album meta.json (ETag: v, 304 on If-None-Match)
GET /cover?path=<rel> compressed cover (ETag: v, 304 on If-None-Match)
Phone resync: GET /manifest, diff `v` against last-stored → fetch only changed
albums' meta+cover; drop rels missing from the manifest. No re-download of
unchanged albums.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,14 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { streamAudioFile } from './stream-audio';
|
||||
import {
|
||||
buildMusicIndex,
|
||||
getIndexStatus,
|
||||
getManifest,
|
||||
albumVersion,
|
||||
metaFilePath,
|
||||
coverFilePath,
|
||||
} from './indexer';
|
||||
|
||||
// The music sidecar (officer-music). Same philosophy as the other officer-* sidecars: a singleton
|
||||
// process that registers with the API server. It OWNS an audio-streaming HTTP server (all path
|
||||
@@ -28,12 +36,47 @@ const server = Bun.serve({
|
||||
hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const json = (data: unknown, init?: ResponseInit) =>
|
||||
new Response(JSON.stringify(data), { ...init, headers: { 'Content-Type': 'application/json', ...init?.headers } });
|
||||
|
||||
if (url.pathname === '/health') return new Response('ok');
|
||||
|
||||
// ── Streaming ──
|
||||
if (url.pathname === '/stream') {
|
||||
const path = url.searchParams.get('path');
|
||||
if (!path) return new Response('path is required', { status: 400 });
|
||||
return streamAudioFile(path, req.headers.get('range'));
|
||||
}
|
||||
|
||||
// ── Index build ──
|
||||
if (url.pathname === '/reindex') {
|
||||
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
||||
void buildMusicIndex(); // fire-and-forget; sets running=true synchronously before the first await
|
||||
return json(getIndexStatus());
|
||||
}
|
||||
if (url.pathname === '/reindex/status') return json(getIndexStatus());
|
||||
|
||||
// ── Sync surface ──
|
||||
if (url.pathname === '/manifest') return json(await getManifest());
|
||||
|
||||
if (url.pathname === '/meta' || url.pathname === '/cover') {
|
||||
const rel = url.searchParams.get('path');
|
||||
if (rel === null) return new Response('path is required', { status: 400 });
|
||||
const isMeta = url.pathname === '/meta';
|
||||
const file = isMeta ? metaFilePath(rel) : coverFilePath(rel);
|
||||
if (!file) return new Response('Invalid path', { status: 400 });
|
||||
if (!(await Bun.file(file).exists())) return new Response('Not found', { status: 404 });
|
||||
|
||||
const v = await albumVersion(rel);
|
||||
if (v && req.headers.get('if-none-match') === v) return new Response(null, { status: 304 });
|
||||
return new Response(Bun.file(file), {
|
||||
headers: {
|
||||
'Content-Type': isMeta ? 'application/json' : 'image/jpeg',
|
||||
...(v ? { ETag: v } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user