music: officer-music sidecar + /api/music streaming proxy
Adds an officer-music sidecar that owns an audio-streaming HTTP server, and a
thin authenticating proxy on the platform. All processing (path resolution,
byte-range streaming, ffprobe duration) is in the sidecar; the platform only
authenticates and forwards.
App-facing contract (handoff):
GET /api/music/stream?path=<home-relative path>&token=<jwt>
- auth via userMiddleware (Bearer or ?token= for media elements)
- 200 full / 206 on Range, with Accept-Ranges, Content-Length,
Content-Range, Content-Type, and X-Audio-Duration (seconds, ffprobe)
- path resolved within HOME_DIR, traversal-guarded (400); 404 if missing
Purpose: stream + seek without pre-downloading the whole file — the app can
read X-Audio-Duration instead of scanning for VBR duration.
Pieces:
- sidecar/music/{index.ts,stream-audio.ts}: Bun.serve on a random port, /stream
+ /health, duration cached by path+mtime; reports its port via a new
music:server sidecar event on connect.
- api/music/{sidecar-server.ts,router.ts}: capture the port; reverse-proxy
/api/music/* → sidecar, streaming status + headers through.
- protocol.ts music:server event; hono.ts mounts /api/music; ecosystem adds
officer-music.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getMusicServerUrl } from './sidecar-server';
|
||||
|
||||
// Thin reverse-proxy for /api/music/*. Auth is handled upstream by userMiddleware (this router mounts
|
||||
// under the protected /api tree, so the media `?token=` path works). Everything else — path resolution,
|
||||
// byte-range streaming, ffprobe duration — is done by the officer-music sidecar's audio server. We only
|
||||
// forward the subpath + query + Range and stream the response back.
|
||||
|
||||
export const musicRouter = createRouter();
|
||||
|
||||
const PREFIX = '/api/music';
|
||||
|
||||
musicRouter.all('/*', async (ctx) => {
|
||||
const baseUrl = getMusicServerUrl();
|
||||
if (!baseUrl) return ctx.text('Music sidecar not available', 503);
|
||||
|
||||
const url = new URL(ctx.req.url);
|
||||
const subpath = url.pathname.slice(PREFIX.length) || '/';
|
||||
const target = `${baseUrl}${subpath}${url.search}`;
|
||||
|
||||
const range = ctx.req.header('range');
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(target, {
|
||||
method: ctx.req.method,
|
||||
headers: range ? { Range: range } : {},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[music] proxy fetch failed', { target, error: String(err) });
|
||||
return ctx.text('Music sidecar unreachable', 502);
|
||||
}
|
||||
|
||||
// Pass status + headers through and stream the body (206/Content-Range/X-Audio-Duration included).
|
||||
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
|
||||
// The officer-music sidecar starts its audio-streaming HTTP server on a random port and reports it here
|
||||
// on connect. We remember it so `/api/music/*` always proxies to the current server.
|
||||
|
||||
let serverPort: number | null = null;
|
||||
|
||||
sidecar.on('music:server', (msg) => {
|
||||
const port = (msg as { port?: number }).port;
|
||||
if (typeof port !== 'number') return;
|
||||
serverPort = port;
|
||||
console.log(`[music] sidecar audio server registered on port ${port}`);
|
||||
});
|
||||
|
||||
/** Base URL of the sidecar's audio server, or null if the sidecar hasn't reported in yet. */
|
||||
export function getMusicServerUrl(): string | null {
|
||||
return serverPort ? `http://127.0.0.1:${serverPort}` : null;
|
||||
}
|
||||
Reference in New Issue
Block a user