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:
@@ -36,5 +36,11 @@ module.exports = {
|
||||
args: 'run src/servers/sidecar/vnc/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'officer-music',
|
||||
script: 'bun',
|
||||
args: 'run src/servers/sidecar/music/index.ts',
|
||||
watch: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { settingsRouter } from './api/settings/settings';
|
||||
import { dashboardsRouter } from './api/dashboards';
|
||||
import { taskLogsRouter } from './api/task-logs/task-logs';
|
||||
import { router as fileBrowserRouter } from './api/file-browser/router';
|
||||
import { musicRouter } from './api/music/router';
|
||||
import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port
|
||||
import { devServerRouter, devServerProxyRouter } from './api/dev-server/router';
|
||||
import { dockRouter } from './api/dock/dock';
|
||||
import { integrationsRouter, googleCallbackHandler } from './api/integrations/integrations';
|
||||
@@ -86,6 +88,7 @@ protectedRouter.route('/user', settingsRouter);
|
||||
protectedRouter.route('/dashboards', dashboardsRouter);
|
||||
protectedRouter.route('/task-logs', taskLogsRouter);
|
||||
protectedRouter.route('/file-browser', fileBrowserRouter);
|
||||
protectedRouter.route('/music', musicRouter);
|
||||
protectedRouter.route('/dev-server', devServerRouter);
|
||||
protectedRouter.route('/dock', dockRouter);
|
||||
protectedRouter.route('/integrations', integrationsRouter);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { streamAudioFile } from './stream-audio';
|
||||
|
||||
// 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
|
||||
// resolution + streaming + ffprobe duration happens here); the platform API is just a thin proxy that
|
||||
// authenticates and forwards to us. The server listens on a random loopback port, reported to the API
|
||||
// on connect so it can route `/api/music/*` here.
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
// ── Audio-streaming HTTP server ──
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const port = probe.port;
|
||||
probe.stop(true);
|
||||
if (port == null) throw new Error('failed to acquire a free port');
|
||||
return port;
|
||||
}
|
||||
|
||||
const port = getFreePort();
|
||||
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname === '/health') return new Response('ok');
|
||||
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'));
|
||||
}
|
||||
return new Response('Not found', { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[music] audio server listening on http://127.0.0.1:${port}`);
|
||||
|
||||
// ── Command handlers ──
|
||||
|
||||
type ReplyFn = (msg: SidecarEvent) => void;
|
||||
|
||||
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
switch (cmd.type) {
|
||||
case 'ping':
|
||||
reply({ type: 'pong', id: cmd.id });
|
||||
break;
|
||||
|
||||
default:
|
||||
reply({
|
||||
type: 'error',
|
||||
id: (cmd as SidecarCommand).id,
|
||||
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connect to API server ──
|
||||
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'music',
|
||||
capabilities: ['music'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
onConnected() {
|
||||
// Tell the API where our audio server is listening, so it can proxy /api/music/* here.
|
||||
connection.send({ type: 'music:server', port });
|
||||
console.log(`[music] reported audio server port ${port} to API`);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Graceful shutdown ──
|
||||
|
||||
function shutdown(signal: string) {
|
||||
console.log(`[music] ${signal} received, shutting down...`);
|
||||
try {
|
||||
server.stop(true);
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
connection.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,97 @@
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
// 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 = process.env.HOME_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',
|
||||
};
|
||||
|
||||
// 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 + an X-Audio-Duration header (ffprobe-derived). */
|
||||
export async function streamAudioFile(relPath: string, rangeHeader: string | 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 duration = await probeDuration(absPath, s.mtimeMs);
|
||||
const file = Bun.file(absPath);
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Accept-Ranges': 'bytes',
|
||||
...(duration ? { 'X-Audio-Duration': String(duration) } : {}),
|
||||
};
|
||||
|
||||
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) } });
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export type SidecarEvent =
|
||||
| { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
|
||||
| { type: 'opencode:session'; sessionKey: string; sessionId: string }
|
||||
| { type: 'opencode:error'; id: string; error: string }
|
||||
// Music — the sidecar reports where its audio-streaming HTTP server is listening (random port) on connect
|
||||
| { type: 'music:server'; port: number }
|
||||
// Generic
|
||||
| { type: 'error'; id?: string; error: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user