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>
111 lines
3.6 KiB
TypeScript
111 lines
3.6 KiB
TypeScript
import type { ServerWebSocket } from 'bun';
|
|
import { getMusicServerWsUrl } from '../api/router';
|
|
|
|
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
|
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
|
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
|
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
|
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
|
|
|
export type CliampWSData = {
|
|
provider: 'cliamp' | 'cliamp-audio';
|
|
search?: string; // the browser's query string, forwarded minus the platform token
|
|
};
|
|
|
|
type UpstreamState = {
|
|
ws: WebSocket | null;
|
|
queue: (string | Uint8Array<ArrayBuffer>)[];
|
|
ready: boolean;
|
|
};
|
|
|
|
// Bun hands frames over as `string | Buffer`; a Buffer is a Uint8Array at runtime, so forward as-is
|
|
// rather than copying every PCM chunk.
|
|
const asPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
|
|
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
|
|
|
|
// The sidecar has no use for the platform JWT and should not see it.
|
|
const forwardedQuery = (search: string | undefined): string => {
|
|
const params = new URLSearchParams(search ?? '');
|
|
params.delete('token');
|
|
const qs = params.toString();
|
|
return qs ? `?${qs}` : '';
|
|
};
|
|
|
|
function createCliampRelay(path: string) {
|
|
const upstreams = new Map<ServerWebSocket<CliampWSData>, UpstreamState>();
|
|
|
|
return {
|
|
open(ws: ServerWebSocket<CliampWSData>) {
|
|
const base = getMusicServerWsUrl();
|
|
if (!base) {
|
|
try {
|
|
ws.close(1011, 'Music sidecar not available');
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
return;
|
|
}
|
|
|
|
const state: UpstreamState = { ws: null, queue: [], ready: false };
|
|
upstreams.set(ws, state);
|
|
|
|
const upstream = new WebSocket(`${base}${path}${forwardedQuery(ws.data.search)}`);
|
|
upstream.binaryType = 'arraybuffer';
|
|
state.ws = upstream;
|
|
|
|
upstream.addEventListener('open', () => {
|
|
state.ready = true;
|
|
for (const m of state.queue) upstream.send(m);
|
|
state.queue.length = 0;
|
|
});
|
|
upstream.addEventListener('message', (ev) => {
|
|
try {
|
|
ws.send(ev.data as string | ArrayBuffer);
|
|
} catch {
|
|
/* client gone */
|
|
}
|
|
});
|
|
upstream.addEventListener('close', (ev) => {
|
|
upstreams.delete(ws);
|
|
try {
|
|
ws.close(ev.code || 1000, ev.reason || '');
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
});
|
|
upstream.addEventListener('error', () => {
|
|
upstreams.delete(ws);
|
|
try {
|
|
ws.close(1011, 'upstream error');
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
});
|
|
},
|
|
|
|
message(ws: ServerWebSocket<CliampWSData>, raw: string | Buffer) {
|
|
const state = upstreams.get(ws);
|
|
if (!state) return;
|
|
const payload = asPayload(raw);
|
|
if (state.ready && state.ws) state.ws.send(payload);
|
|
else state.queue.push(payload); // buffer until the upstream socket opens
|
|
},
|
|
|
|
close(ws: ServerWebSocket<CliampWSData>) {
|
|
const state = upstreams.get(ws);
|
|
if (!state) return;
|
|
try {
|
|
state.ws?.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
upstreams.delete(ws);
|
|
},
|
|
|
|
drain() {},
|
|
};
|
|
}
|
|
|
|
export const cliampWebsocket = createCliampRelay('/cliamp/ws');
|
|
export const cliampAudioWebsocket = createCliampRelay('/cliamp/audio/ws');
|