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