项目文件夹

文件
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

648 行
19 KiB
TypeScript

/**
* HTTP route handlers for music playback streams, queue inspection, now-playing
* metadata, and control commands.
*
* Streaming routes support both direct web clients and Shoutcast/Icecast-style
* metadata injection for players that expect interleaved track titles.
*/
import { Transform, type TransformCallback } from "node:stream";
import type {
IAgentRuntime,
Route,
RouteRequest,
RouteResponse,
} from "@elizaos/core";
import type { MusicService } from "./service";
import { musicDebug } from "./utils/musicDebug";
type StreamingRouteRequest = RouteRequest & {
get?: (name: string) => string | undefined;
on?: (event: "close", listener: () => void) => unknown;
protocol?: string;
};
type StreamingRouteResponse = RouteResponse & NodeJS.WritableStream;
function firstRouteValue(
value: string | string[] | undefined,
): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function routeGuildId(req: RouteRequest): string | undefined {
return firstRouteValue(req.query?.guildId) || req.params?.guildId;
}
function setRouteHeader(res: RouteResponse, name: string, value: string): void {
res.setHeader?.(name, value);
}
/**
* Encode metadata for Shoutcast/Icecast format
* Metadata is injected every 8192 bytes (or as specified)
*/
function encodeShoutcastMetadata(title: string): Buffer {
// Format: StreamTitle='title';
const metadata = `StreamTitle='${title.replace(/'/g, "''")}';`;
const metadataLength = Math.ceil(metadata.length / 16) * 16; // Must be multiple of 16
const buffer = Buffer.alloc(metadataLength + 1);
buffer[0] = metadataLength / 16; // Length in 16-byte blocks
buffer.write(metadata, 1, "utf8");
return buffer;
}
/**
* Create a transform stream that injects Shoutcast metadata
*/
function createShoutcastStream(
trackTitle: string,
metadataInterval: number = 8192,
): Transform {
let bytesSinceMetadata = 0;
return new Transform({
transform(
chunk: Buffer,
_encoding: BufferEncoding,
callback: TransformCallback,
) {
const chunks: Buffer[] = [];
let offset = 0;
while (offset < chunk.length) {
const remaining = chunk.length - offset;
const needed = metadataInterval - bytesSinceMetadata;
const toTake = Math.min(remaining, needed);
chunks.push(chunk.slice(offset, offset + toTake));
bytesSinceMetadata += toTake;
offset += toTake;
// Inject metadata when we reach the interval
if (bytesSinceMetadata >= metadataInterval) {
chunks.push(encodeShoutcastMetadata(trackTitle));
bytesSinceMetadata = 0;
}
}
callback(null, Buffer.concat(chunks));
},
});
}
/**
* Route handler to stream the currently playing audio
* GET /music-player/stream?guildId=<guildId>&format=<format>
* Supports both 'webm' (default) and 'shoutcast' formats
*/
async function streamAudioHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const guildId = routeGuildId(req);
const format = firstRouteValue(req.query?.format) || "ogg";
const supportsShoutcast =
format.toLowerCase() === "shoutcast" || format.toLowerCase() === "icecast";
const streamingReq = req as StreamingRouteRequest;
const streamingRes = res as StreamingRouteResponse;
if (!guildId) {
res.status(400).json({ error: "guildId is required" });
return;
}
runtime.logger.info(
`[WebStream] /stream request received for guildId=${guildId}`,
);
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
runtime.logger.warn(
"[WebStream] Music service unavailable — returning 503",
);
res.status(503).json({ error: "Music service is not available" });
return;
}
const currentTrack = musicService.getCurrentTrack(guildId);
if (!currentTrack) {
runtime.logger.info(
`[WebStream] No current track for guildId=${guildId} — returning 404`,
);
musicDebug("stream 404 no current track", { guildId });
res.status(404).json({ error: "No track is currently playing" });
return;
}
musicDebug("stream open", {
guildId,
format,
track: currentTrack.title,
});
try {
// NEW ARCHITECTURE: Subscribe to broadcast
//
// WHY SUBSCRIPTION MODEL:
// Old approach tried to pipe the same stream to multiple HTTP responses.
// This caused "Premature close" errors - Node streams don't naturally support
// multiple readers. When one web client disconnected, it affected others.
//
// NEW APPROACH:
// Each web client gets their own independent stream from the broadcast.
// - Client connects → subscribe() gives them a fresh PassThrough stream
// - Client disconnects → unsubscribe() cleans up just their stream
// - Other clients are completely unaffected
//
// WHY UNIQUE CLIENT ID:
// The broadcast tracks subscribers by ID. Each web connection needs a unique
// identifier so it can be independently managed (subscribe/unsubscribe).
//
// DISCORD IMPACT:
// None. Discord is subscribed as `discord-${guildId}`. Web clients are
// `web-${timestamp}-${random}`. They're independent subscribers to the same
// broadcast, like different radios tuned to the same station.
const broadcast = musicService.getBroadcast(guildId);
const clientId = `web-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const subscription = broadcast.subscribe(clientId);
const listenerStream = subscription.stream;
musicDebug("stream subscribed to broadcast", {
guildId,
clientId,
subscribers: broadcast.getSubscriberCount(),
});
runtime.logger.debug(
`[WebStream] Client ${clientId} subscribed to broadcast for guild ${guildId}`,
);
if (supportsShoutcast) {
// Shoutcast/Icecast format with metadata injection
setRouteHeader(res, "Content-Type", "audio/mpeg");
setRouteHeader(res, "icy-name", runtime.character.name || "Music Player");
setRouteHeader(res, "icy-genre", "Various");
setRouteHeader(
res,
"icy-url",
`${streamingReq.protocol ?? "http"}://${streamingReq.get?.("host") ?? "localhost"}`,
);
setRouteHeader(res, "icy-pub", "1");
setRouteHeader(res, "icy-br", "128"); // Bitrate (approximate)
setRouteHeader(res, "icy-metaint", "8192"); // Metadata interval in bytes
setRouteHeader(res, "Cache-Control", "no-cache");
setRouteHeader(res, "Connection", "keep-alive");
setRouteHeader(res, "X-Content-Type-Options", "nosniff");
// Create stream with metadata injection
const shoutcastStream = createShoutcastStream(currentTrack.title, 8192);
// Pipe broadcast stream through metadata injector to response
listenerStream.pipe(shoutcastStream).pipe(streamingRes);
// Clean up on client disconnect
streamingReq.on?.("close", () => {
subscription.unsubscribe();
shoutcastStream.destroy();
runtime.logger.debug(
`[WebStream] Client ${clientId} disconnected (Shoutcast)`,
);
});
} else {
// Ogg Opus: matches StreamCore output after ELIZA_MUSIC_BROADCAST_NORMALIZE
// (yt-dlp cache/temp files are Ogg Opus; `audio/webm` mislabeling = silent playback).
setRouteHeader(res, "Content-Type", "audio/ogg; codecs=opus");
setRouteHeader(res, "Cache-Control", "no-cache");
setRouteHeader(res, "Connection", "keep-alive");
setRouteHeader(res, "X-Content-Type-Options", "nosniff");
// Pipe broadcast stream to response
listenerStream.pipe(streamingRes);
// Clean up on client disconnect
streamingReq.on?.("close", () => {
subscription.unsubscribe();
runtime.logger.debug(
`[WebStream] Client ${clientId} disconnected (Ogg Opus)`,
);
});
}
// Handle stream errors
listenerStream.on("error", (error) => {
if (!res.headersSent) {
res.status(500).json({ error: "Stream error occurred" });
}
runtime.logger.error(`Web listener stream error: ${error}`);
});
} catch (error) {
runtime.logger.error(`Error creating audio stream: ${error}`);
if (!res.headersSent) {
res.status(500).json({ error: "Failed to create audio stream" });
}
}
}
/**
* Route handler to get current track information
* GET /music-player/now-playing?guildId=<guildId>
*/
async function nowPlayingHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const guildId = routeGuildId(req);
if (!guildId) {
res.status(400).json({ error: "guildId is required" });
return;
}
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const currentTrack = musicService.getCurrentTrack(guildId);
if (!currentTrack) {
res.status(404).json({ error: "No track is currently playing" });
return;
}
res.status(200).json({
track: {
id: currentTrack.id,
title: currentTrack.title,
url: currentTrack.url,
duration: currentTrack.duration,
requestedBy: currentTrack.requestedBy,
addedAt: currentTrack.addedAt,
},
streamUrl: `/music-player/stream?guildId=${guildId}`,
});
}
/**
* Route handler to get queue information
* GET /music-player/queue?guildId=<guildId>
*/
async function queueHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const guildId = routeGuildId(req);
if (!guildId) {
res.status(400).json({ error: "guildId is required" });
return;
}
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const currentTrack = musicService.getCurrentTrack(guildId);
const queue = musicService.getQueueList(guildId);
res.status(200).json({
currentTrack: currentTrack
? {
id: currentTrack.id,
title: currentTrack.title,
url: currentTrack.url,
duration: currentTrack.duration,
requestedBy: currentTrack.requestedBy,
addedAt: currentTrack.addedAt,
}
: null,
queue: queue.map((track) => ({
id: track.id,
title: track.title,
url: track.url,
duration: track.duration,
requestedBy: track.requestedBy,
addedAt: track.addedAt,
})),
queueLength: queue.length,
});
}
/**
* Find the first guild with an actively playing track.
* Returns { guildId, track } or null if nothing is playing anywhere.
*/
function findActiveGuild(musicService: MusicService): {
guildId: string;
track: ReturnType<MusicService["getCurrentTrack"]>;
} | null {
const queues = musicService.getQueues();
for (const [guildId] of queues) {
const track = musicService.getCurrentTrack(guildId);
if (track) return { guildId, track };
}
return null;
}
/**
* Route handler for global playback status (no guildId required).
* GET /music-player/status
* Returns the first active guild + its current track, or 404 if nothing is playing.
*/
async function statusHandler(
_req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const active = findActiveGuild(musicService);
if (!active) {
res.status(200).json({ error: "No track is currently playing" });
return;
}
res.status(200).json({
guildId: active.guildId,
track: {
id: active.track?.id,
title: active.track?.title,
url: active.track?.url,
duration: active.track?.duration,
requestedBy: active.track?.requestedBy,
addedAt: active.track?.addedAt,
},
isPaused: musicService.getIsPaused(active.guildId),
streamUrl: `/music-player/stream?guildId=${encodeURIComponent(active.guildId)}`,
});
}
// ── Authenticated control (DJ booth) — NOT public radio ─────────────────
/**
* Parse optional JSON body for POST /control/* (no express.json on plugin routes).
*/
async function readControlJsonBody(
req: RouteRequest,
): Promise<Record<string, unknown>> {
if (req.body) {
return req.body;
}
const stream = req as RouteRequest &
AsyncIterable<Buffer | string | Uint8Array>;
if (typeof stream[Symbol.asyncIterator] !== "function") {
return {};
}
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
return typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
/**
* Resolve guildId from JSON body, query string, or first active guild.
*/
function resolveControlGuildId(
req: RouteRequest,
body: Record<string, unknown>,
musicService: MusicService,
): string | null {
const fromBody =
typeof body.guildId === "string" && body.guildId.trim()
? body.guildId.trim()
: undefined;
const q = req.query?.guildId;
const fromQuery =
typeof q === "string" && q.trim()
? q.trim()
: Array.isArray(q) && typeof q[0] === "string"
? q[0].trim()
: undefined;
if (fromBody) return fromBody;
if (fromQuery) return fromQuery;
const active = findActiveGuild(musicService);
return active?.guildId ?? null;
}
async function controlPauseHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const body = await readControlJsonBody(req);
const guildId = resolveControlGuildId(req, body, musicService);
if (!guildId) {
res.status(404).json({ error: "No active playback to pause" });
return;
}
await musicService.pause(guildId);
runtime.logger.info(`[MusicControl] Paused playback for guild ${guildId}`);
res.status(200).json({ ok: true, guildId, state: "paused" });
}
async function controlResumeHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const body = await readControlJsonBody(req);
const guildId = resolveControlGuildId(req, body, musicService);
if (!guildId) {
res.status(404).json({ error: "No active playback to resume" });
return;
}
await musicService.resume(guildId);
runtime.logger.info(`[MusicControl] Resumed playback for guild ${guildId}`);
res.status(200).json({ ok: true, guildId, state: "playing" });
}
async function controlStopHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const body = await readControlJsonBody(req);
const guildId = resolveControlGuildId(req, body, musicService);
if (!guildId) {
res.status(404).json({ error: "No active playback to stop" });
return;
}
await musicService.stopPlayback(guildId);
musicService.clear(guildId);
runtime.logger.info(`[MusicControl] Stopped playback for guild ${guildId}`);
res.status(200).json({ ok: true, guildId, state: "stopped" });
}
async function controlSkipHandler(
req: RouteRequest,
res: RouteResponse,
runtime: IAgentRuntime,
): Promise<void> {
const musicService = runtime.getService("music") as MusicService;
if (!musicService) {
res.status(503).json({ error: "Music service is not available" });
return;
}
const body = await readControlJsonBody(req);
const guildId = resolveControlGuildId(req, body, musicService);
if (!guildId) {
res.status(404).json({ error: "No active playback to skip" });
return;
}
const skipped = await musicService.skip(guildId);
if (!skipped) {
res.status(404).json({ error: "No track to skip" });
return;
}
const nextTrack = musicService.getCurrentTrack(guildId);
runtime.logger.info(
`[MusicControl] Skipped track for guild ${guildId}${nextTrack ? ` → ${nextTrack.title}` : ""}`,
);
res.status(200).json({
ok: true,
guildId,
nextTrack: nextTrack
? { id: nextTrack.id, title: nextTrack.title, url: nextTrack.url }
: null,
});
}
/**
* Routes for the music player plugin.
*
* Two tiers:
* - **Public radio** (`public: true`): GET stream, status, queue — any listener,
* no auth. `<audio>` cannot send Bearer tokens; keep streams here.
* - **DJ booth** (`public: false`): POST /control/* — same API token as the
* dashboard (`Authorization: Bearer`, `X-Eliza-Token`, or `X-Api-Key`).
* Operators and automation use this; it is not mixed with public GET paths.
*
* **Agentic path:** the chat action PLAYBACK_OP (op=pause|resume|skip|stop|queue)
* still routes control through the agent for natural language.
*/
const PUBLIC_RADIO_ROUTE_REASON =
"Public radio endpoints must be reachable by audio clients that cannot send bearer tokens.";
export const musicPlayerRoutes: Route[] = [
{
type: "GET",
path: "/stream",
public: true,
name: "Stream Audio",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: streamAudioHandler,
},
{
type: "GET",
path: "/stream/:guildId",
public: true,
name: "Stream Audio (with guildId param)",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: streamAudioHandler,
},
{
type: "GET",
path: "/now-playing",
public: true,
name: "Now Playing",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: nowPlayingHandler,
},
{
type: "GET",
path: "/now-playing/:guildId",
public: true,
name: "Now Playing (with guildId param)",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: nowPlayingHandler,
},
{
type: "GET",
path: "/queue",
public: true,
name: "Queue",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: queueHandler,
},
{
type: "GET",
path: "/queue/:guildId",
public: true,
name: "Queue (with guildId param)",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: queueHandler,
},
{
type: "GET",
path: "/status",
public: true,
name: "Playback Status",
publicReason: PUBLIC_RADIO_ROUTE_REASON,
handler: statusHandler,
},
{
type: "POST",
path: "/control/pause",
public: false,
name: "Pause playback (authenticated)",
handler: controlPauseHandler,
},
{
type: "POST",
path: "/control/resume",
public: false,
name: "Resume playback (authenticated)",
handler: controlResumeHandler,
},
{
type: "POST",
path: "/control/stop",
public: false,
name: "Stop playback (authenticated)",
handler: controlStopHandler,
},
{
type: "POST",
path: "/control/skip",
public: false,
name: "Skip track (authenticated)",
handler: controlSkipHandler,
},
];