diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 17a93b15..f197da01 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -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, + }, ], }; diff --git a/src/servers/api/music/router.ts b/src/servers/api/music/router.ts new file mode 100644 index 00000000..752a1974 --- /dev/null +++ b/src/servers/api/music/router.ts @@ -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) }); +}); diff --git a/src/servers/api/music/sidecar-server.ts b/src/servers/api/music/sidecar-server.ts new file mode 100644 index 00000000..2b92611a --- /dev/null +++ b/src/servers/api/music/sidecar-server.ts @@ -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; +} diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 92dab120..16ac3b57 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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); diff --git a/src/servers/sidecar/music/index.ts b/src/servers/sidecar/music/index.ts new file mode 100644 index 00000000..b93d1f8e --- /dev/null +++ b/src/servers/sidecar/music/index.ts @@ -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).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')); diff --git a/src/servers/sidecar/music/stream-audio.ts b/src/servers/sidecar/music/stream-audio.ts new file mode 100644 index 00000000..f0eeaa8d --- /dev/null +++ b/src/servers/sidecar/music/stream-audio.ts @@ -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 = { + 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(); + +async function probeDuration(absPath: string, mtimeMs: number): Promise { + 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 { + 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 = { + '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) } }); +} diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 22bac0e6..d4aa9124 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -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 };