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:
saopig1andClaude Opus 4.8 committed 2026-07-02 20:40:15 +08:00
1 parent 32718f0118
commit 470a62129a
3 files changed
+202

No files matched your search

+41
View File
@@ -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');
});
});
+42
View File
@@ -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"
);
}