mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer
- backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs - go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le, server enabled, interactive OAuth), no yaml dependency - tests assert exact keys/values and round-trip via a tiny structural parser Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
32718f0118
commit
470a62129a
3 files changed
+202
No files matched your search
@@ -0,0 +1,41 @@
|
|||||||
|
// src/music/spotify/backend.ts
|
||||||
|
// Type contract for the Spotify audio backend (go-librespot sidecar).
|
||||||
|
// Interface-only: this module intentionally contains NO runtime code so it
|
||||||
|
// can be imported for types by go-librespot.ts and controller.ts without
|
||||||
|
// pulling in child_process/ws/ffmpeg at type-check time.
|
||||||
|
|
||||||
|
/** Emitted when the currently playing Spotify track finishes or is stopped. */
|
||||||
|
export interface SpotifyTrackEndedEvent {
|
||||||
|
uri: string;
|
||||||
|
reason: "ended" | "stopped" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Now-playing metadata surfaced from the go-librespot "metadata" event. */
|
||||||
|
export interface SpotifyNowPlaying {
|
||||||
|
uri: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
coverUrl: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-lived Spotify audio source: owns the go-librespot sidecar + FIFO->ffmpeg
|
||||||
|
* PCM pipe and exposes transport control plus a continuous 48kHz s16le stereo
|
||||||
|
* PCM stream to feed AudioPlayer.playPcmStream().
|
||||||
|
*/
|
||||||
|
export interface SpotifyAudioBackend {
|
||||||
|
start(): Promise<void>;
|
||||||
|
stop(): void;
|
||||||
|
isReady(): boolean;
|
||||||
|
playTrack(uri: string): Promise<void>;
|
||||||
|
pause(): Promise<void>;
|
||||||
|
resume(): Promise<void>;
|
||||||
|
seek(ms: number): Promise<void>;
|
||||||
|
getPcmStream(): import("node:stream").Readable;
|
||||||
|
getPositionMs(): number;
|
||||||
|
on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void;
|
||||||
|
on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void;
|
||||||
|
on(event: "ready" | "error", cb: (arg?: unknown) => void): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// src/music/spotify/go-librespot-config.test.ts
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderConfigYml, type GoLibrespotConfigOptions } from "./go-librespot-config.js";
|
||||||
|
|
||||||
|
const OPTS: GoLibrespotConfigOptions = {
|
||||||
|
deviceName: "TeamSpeak Music Bot",
|
||||||
|
bitrate: 320,
|
||||||
|
fifoPath: "/tmp/go-librespot.fifo",
|
||||||
|
apiAddress: "0.0.0.0",
|
||||||
|
apiPort: 3678,
|
||||||
|
callbackPort: 8080,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal 2-level YAML reader for the exact shape renderConfigYml emits
|
||||||
|
* (flat scalars + one level of nesting under `server:` / `credentials:`).
|
||||||
|
* Avoids adding a yaml dependency while still proving the output round-trips.
|
||||||
|
*/
|
||||||
|
function parseTinyYaml(src: string): Record<string, unknown> {
|
||||||
|
const root: Record<string, unknown> = {};
|
||||||
|
const stack: Array<{ indent: number; obj: Record<string, unknown> }> = [
|
||||||
|
{ indent: -1, obj: root },
|
||||||
|
];
|
||||||
|
for (const rawLine of src.split("\n")) {
|
||||||
|
if (rawLine.trim() === "") continue;
|
||||||
|
const indent = rawLine.length - rawLine.trimStart().length;
|
||||||
|
const line = rawLine.trim();
|
||||||
|
const idx = line.indexOf(":");
|
||||||
|
const key = line.slice(0, idx).trim();
|
||||||
|
let valRaw = line.slice(idx + 1).trim();
|
||||||
|
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
||||||
|
stack.pop();
|
||||||
|
}
|
||||||
|
const parent = stack[stack.length - 1].obj;
|
||||||
|
if (valRaw === "") {
|
||||||
|
const child: Record<string, unknown> = {};
|
||||||
|
parent[key] = child;
|
||||||
|
stack.push({ indent, obj: child });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let val: unknown = valRaw;
|
||||||
|
if (valRaw.startsWith('"') && valRaw.endsWith('"')) val = JSON.parse(valRaw);
|
||||||
|
else if (valRaw === "true") val = true;
|
||||||
|
else if (valRaw === "false") val = false;
|
||||||
|
else if (/^-?\d+$/.test(valRaw)) val = Number(valRaw);
|
||||||
|
parent[key] = val;
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderConfigYml", () => {
|
||||||
|
it("emits the exact top-level go-librespot keys/values", () => {
|
||||||
|
const lines = renderConfigYml(OPTS).split("\n");
|
||||||
|
expect(lines).toContain('device_name: "TeamSpeak Music Bot"');
|
||||||
|
expect(lines).toContain("device_type: computer");
|
||||||
|
expect(lines).toContain("bitrate: 320");
|
||||||
|
expect(lines).toContain("audio_backend: pipe");
|
||||||
|
expect(lines).toContain("audio_output_pipe: /tmp/go-librespot.fifo");
|
||||||
|
expect(lines).toContain("audio_output_pipe_format: s16le");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a server block with enabled/address/port set explicitly", () => {
|
||||||
|
const parsed = parseTinyYaml(renderConfigYml(OPTS));
|
||||||
|
expect(parsed.server).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
address: "0.0.0.0",
|
||||||
|
port: 3678,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits interactive OAuth credentials with the callback port", () => {
|
||||||
|
const parsed = parseTinyYaml(renderConfigYml(OPTS));
|
||||||
|
expect(parsed.credentials).toEqual({
|
||||||
|
type: "interactive",
|
||||||
|
interactive: { callback_port: 8080 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("full round-trip reflects every provided option", () => {
|
||||||
|
const parsed = parseTinyYaml(renderConfigYml(OPTS));
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
device_name: "TeamSpeak Music Bot",
|
||||||
|
device_type: "computer",
|
||||||
|
bitrate: 320,
|
||||||
|
audio_backend: "pipe",
|
||||||
|
audio_output_pipe: "/tmp/go-librespot.fifo",
|
||||||
|
audio_output_pipe_format: "s16le",
|
||||||
|
server: { enabled: true, address: "0.0.0.0", port: 3678 },
|
||||||
|
credentials: { type: "interactive", interactive: { callback_port: 8080 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("threads distinct option values through unchanged (no hard-coded ports)", () => {
|
||||||
|
const parsed = parseTinyYaml(
|
||||||
|
renderConfigYml({
|
||||||
|
deviceName: "Other Bot",
|
||||||
|
bitrate: 160,
|
||||||
|
fifoPath: "/run/librespot/pipe",
|
||||||
|
apiAddress: "127.0.0.1",
|
||||||
|
apiPort: 4000,
|
||||||
|
callbackPort: 9099,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(parsed).toMatchObject({
|
||||||
|
device_name: "Other Bot",
|
||||||
|
bitrate: 160,
|
||||||
|
audio_output_pipe: "/run/librespot/pipe",
|
||||||
|
server: { address: "127.0.0.1", port: 4000 },
|
||||||
|
credentials: { interactive: { callback_port: 9099 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safely quotes device names containing special characters", () => {
|
||||||
|
const yml = renderConfigYml({ ...OPTS, deviceName: 'My "Cool" Bot' });
|
||||||
|
expect(yml.split("\n")).toContain('device_name: "My \\"Cool\\" Bot"');
|
||||||
|
// and still round-trips back to the original string
|
||||||
|
expect(parseTinyYaml(yml).device_name).toBe('My "Cool" Bot');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// src/music/spotify/go-librespot-config.ts
|
||||||
|
// Hand-built go-librespot config.yml. Keys/values verified against
|
||||||
|
// devgianlu/go-librespot cmd/daemon/cli_config.go koanf tags. No yaml
|
||||||
|
// dependency is used; the file is a small, fixed-shape document.
|
||||||
|
|
||||||
|
export interface GoLibrespotConfigOptions {
|
||||||
|
deviceName: string;
|
||||||
|
bitrate: number;
|
||||||
|
fifoPath: string;
|
||||||
|
apiAddress: string;
|
||||||
|
apiPort: number;
|
||||||
|
callbackPort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a headless go-librespot config.yml:
|
||||||
|
* - pipe audio backend writing raw 44.1kHz/s16le stereo PCM to a FIFO,
|
||||||
|
* - HTTP+WebSocket control server enabled (port has NO built-in default,
|
||||||
|
* so it is always written explicitly),
|
||||||
|
* - interactive OAuth credentials (persisted automatically to
|
||||||
|
* <config_dir>/credentials.json after first login).
|
||||||
|
*/
|
||||||
|
export function renderConfigYml(o: GoLibrespotConfigOptions): string {
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
`device_name: ${JSON.stringify(o.deviceName)}`,
|
||||||
|
`device_type: computer`,
|
||||||
|
`bitrate: ${o.bitrate}`,
|
||||||
|
`audio_backend: pipe`,
|
||||||
|
`audio_output_pipe: ${o.fifoPath}`,
|
||||||
|
`audio_output_pipe_format: s16le`,
|
||||||
|
`server:`,
|
||||||
|
` enabled: true`,
|
||||||
|
` address: ${o.apiAddress}`,
|
||||||
|
` port: ${o.apiPort}`,
|
||||||
|
`credentials:`,
|
||||||
|
` type: interactive`,
|
||||||
|
` interactive:`,
|
||||||
|
` callback_port: ${o.callbackPort}`,
|
||||||
|
].join("\n") + "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user