music, extracted from the platform into its own repository
Everything the plugin is, moved out of officerdev/platform on 2026-08-15 — 41 files, unchanged from the tree they left. manifest.ts identity, one permission, ffmpeg/ffprobe declared api/ the sidecar proxy; the prefix comes from mountPrefix() sidecar/ the whole /api/music contract — indexing, streaming, per-user state db/ music_favorites, _playlists, _playlist_items, _now_playing web/ panels, layout, and the player: engine, bar, lyrics, favourites cliamp/ the second playback path, parked — not working, kept deliberately widgets/ the dashboard widget, parked — plugins cannot contribute widgets assets/ icon.png, the dock tile scripts/ the reindex CLI PLUGIN.md is the design record: what moved, what stayed, what broke, and why. MUSIC_API.md is the contract the phone and tablet apps speak, and the reason the sidecar's HTTP shape is not free to change. ── It does not build here, and that is the point ── The platform resolves `hooks/useClient`, `officerdev`, `officerdb/db` and `@@/*` through the workspace links in its own node_modules. Measured from this directory, outside the platform checkout, every one of them fails to resolve — 7 imports in the backend, ~29 in the frontend. So this repository is the source of truth, not yet a buildable unit. Making it one means the host API becoming something a plugin can depend on rather than something it reaches into. That is the next problem, and having the code here is what makes it unavoidable rather than theoretical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { spawn, type Subprocess } from 'bun';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, normalize, resolve, sep } from 'node:path';
|
||||
import { VIRTUAL_SINK } from './pulse-audio';
|
||||
|
||||
// Local playback, both halves of it, owned by the process that owns the audio pipeline:
|
||||
//
|
||||
// /cliamp/ws — runs the `cliamp` TUI player against a file and pipes its terminal both ways
|
||||
// /cliamp/audio/ws — captures what the sink hears and streams it to the browser as raw PCM
|
||||
//
|
||||
// Officer relays these two sockets and nothing else: it authenticates the browser and forwards frames.
|
||||
// Every fact below — where the binary is, what a legal path is, which sink to play into, the ALSA config,
|
||||
// the capture format — is pipeline knowledge and stays here. The frame shapes are the browser's contract
|
||||
// ({type:'output'|'exit'} / {type:'input'} as JSON text, PCM as binary), so they are unchanged by the move.
|
||||
//
|
||||
// Both sockets are loopback-only, like the rest of this server: officer is the only client.
|
||||
|
||||
const ASOUNDRC_PATH = join(import.meta.dir, 'asoundrc');
|
||||
|
||||
// Single super user, so the owner's home is the root every path is resolved against — same convention as
|
||||
// stream-audio.ts and the indexer.
|
||||
const ROOT_DIR = homedir();
|
||||
|
||||
// parec's output format IS the contract with the browser's AudioWorklet: signed 16-bit LE, 44.1kHz, stereo.
|
||||
const CAPTURE_ARGS = ['--format=s16le', '--rate=44100', '--channels=2', '-d', `${VIRTUAL_SINK}.monitor`];
|
||||
|
||||
export type MusicWSData = { kind: 'player'; files: string } | { kind: 'capture' };
|
||||
|
||||
type Session = {
|
||||
proc: Subprocess;
|
||||
closed: boolean;
|
||||
};
|
||||
|
||||
const sessions = new Map<ServerWebSocket<MusicWSData>, Session>();
|
||||
|
||||
const sendOutput = (ws: ServerWebSocket<MusicWSData>, data: string) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
} catch {
|
||||
/* ws already closed */
|
||||
}
|
||||
};
|
||||
|
||||
const sendExit = (ws: ServerWebSocket<MusicWSData>) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'exit' }));
|
||||
} catch {
|
||||
/* ws already closed */
|
||||
}
|
||||
};
|
||||
|
||||
// Home-relative or leading-slash paths both mean "under the owner's home"; anything that escapes it after
|
||||
// normalisation is rejected. The trailing separator matters: without it a sibling directory whose name
|
||||
// merely starts with the home path would pass.
|
||||
const resolveInHome = (file: string): string | null => {
|
||||
const abs = normalize(resolve(ROOT_DIR, file.startsWith('/') ? `.${file}` : file));
|
||||
return abs === ROOT_DIR || abs.startsWith(ROOT_DIR + sep) ? abs : null;
|
||||
};
|
||||
|
||||
const findCliamp = (): string | null => {
|
||||
const which = Bun.which('cliamp');
|
||||
if (which) return which;
|
||||
const candidates = [
|
||||
process.env.GOPATH ? `${process.env.GOPATH}/bin/cliamp` : null,
|
||||
`${ROOT_DIR}/.local/go-path/bin/cliamp`,
|
||||
`${ROOT_DIR}/go/bin/cliamp`,
|
||||
];
|
||||
for (const bin of candidates) {
|
||||
if (!bin) continue;
|
||||
try {
|
||||
const stat = Bun.spawnSync({ cmd: ['test', '-x', bin], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (stat.exitCode === 0) return bin;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const shellEscape = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
|
||||
|
||||
// Pump a byte stream into the socket until it ends; `frame` decides how it lands on the wire.
|
||||
function pump(
|
||||
ws: ServerWebSocket<MusicWSData>,
|
||||
session: Session,
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
frame: (ws: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => void,
|
||||
onEnd?: () => void,
|
||||
): void {
|
||||
const reader = stream.getReader();
|
||||
void (async () => {
|
||||
try {
|
||||
while (!session.closed) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value && !session.closed) frame(ws, value);
|
||||
}
|
||||
} catch {
|
||||
/* stream ended */
|
||||
} finally {
|
||||
onEnd?.();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function openPlayer(ws: ServerWebSocket<MusicWSData>, files: string): void {
|
||||
if (!files) return sendOutput(ws, '\r\n[Error] No files specified.\r\n');
|
||||
|
||||
const cliampPath = findCliamp();
|
||||
if (!cliampPath) return sendOutput(ws, '\r\n[Error] cliamp not found on host.\r\n');
|
||||
|
||||
const target = resolveInHome(files);
|
||||
if (!target) return sendOutput(ws, '\r\n[Error] Invalid file path.\r\n');
|
||||
|
||||
// `script` fakes a PTY for cliamp, which avoids a node-pty native dependency here.
|
||||
const cliampCmd = `${shellEscape(cliampPath)} ${shellEscape(target)}`;
|
||||
console.log(`[music] cliamp spawning: ${cliampCmd}`);
|
||||
let proc: Subprocess<'pipe', 'pipe', 'pipe'>;
|
||||
try {
|
||||
proc = spawn({
|
||||
cmd: ['script', '-qfc', cliampCmd, '/dev/null'],
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
cwd: ROOT_DIR,
|
||||
env: { ...process.env, TERM: 'xterm-256color', PULSE_SINK: VIRTUAL_SINK, ALSA_CONFIG_PATH: ASOUNDRC_PATH },
|
||||
});
|
||||
} catch (err) {
|
||||
return sendOutput(ws, `\r\n[Error] ${err instanceof Error ? err.message : 'Failed to start cliamp'}\r\n`);
|
||||
}
|
||||
|
||||
const session: Session = { proc, closed: false };
|
||||
sessions.set(ws, session);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const asText = (sock: ServerWebSocket<MusicWSData>, chunk: Uint8Array) => sendOutput(sock, decoder.decode(chunk));
|
||||
const end = () => {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
sendExit(ws);
|
||||
};
|
||||
pump(ws, session, proc.stdout, asText, end);
|
||||
pump(ws, session, proc.stderr, asText); // cliamp writes some output there
|
||||
|
||||
void proc.exited.then((code) => {
|
||||
console.log(`[music] cliamp exited code=${code}`);
|
||||
end();
|
||||
sessions.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
function openCapture(ws: ServerWebSocket<MusicWSData>): void {
|
||||
const parecPath = Bun.which('parec');
|
||||
if (!parecPath) {
|
||||
ws.close(4000, 'parec not found on host');
|
||||
return;
|
||||
}
|
||||
|
||||
let proc: Subprocess<'ignore', 'pipe', 'ignore'>;
|
||||
try {
|
||||
proc = spawn({ cmd: [parecPath, ...CAPTURE_ARGS], stdin: 'ignore', stdout: 'pipe', stderr: 'ignore' });
|
||||
} catch {
|
||||
ws.close(4000, 'Failed to start audio capture');
|
||||
return;
|
||||
}
|
||||
|
||||
const session: Session = { proc, closed: false };
|
||||
sessions.set(ws, session);
|
||||
console.log('[music] parec started, streaming PCM to the relay');
|
||||
|
||||
pump(
|
||||
ws,
|
||||
session,
|
||||
proc.stdout,
|
||||
(sock, chunk) => {
|
||||
try {
|
||||
sock.sendBinary(chunk);
|
||||
} catch {
|
||||
session.closed = true;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (session.closed) return;
|
||||
session.closed = true;
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const musicWebsocket = {
|
||||
open(ws: ServerWebSocket<MusicWSData>) {
|
||||
if (ws.data.kind === 'player') openPlayer(ws, ws.data.files);
|
||||
else openCapture(ws);
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MusicWSData>, raw: string | Buffer) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session || session.closed || ws.data.kind !== 'player') return; // capture is one-way
|
||||
try {
|
||||
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
||||
if (msg.type === 'input' && msg.data) (session.proc as Subprocess<'pipe'>).stdin.write(msg.data);
|
||||
} catch {
|
||||
/* not a frame we understand */
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MusicWSData>) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session) return;
|
||||
session.closed = true;
|
||||
try {
|
||||
session.proc.kill();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
sessions.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
|
||||
/** Upgrade one of the two cliamp sockets, or return null if this request is not for them. */
|
||||
export function cliampUpgradeData(pathname: string, search: URLSearchParams): MusicWSData | null {
|
||||
if (pathname === '/cliamp/ws') return { kind: 'player', files: search.get('files') ?? '' };
|
||||
if (pathname === '/cliamp/audio/ws') return { kind: 'capture' };
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user