From 470a62129a8218593ad5730978e3e196b96ca339 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 20:40:15 +0800 Subject: [PATCH] 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) --- src/music/spotify/backend.ts | 41 ++++++ src/music/spotify/go-librespot-config.test.ts | 119 ++++++++++++++++++ src/music/spotify/go-librespot-config.ts | 42 +++++++ 3 files changed, 202 insertions(+) create mode 100644 src/music/spotify/backend.ts create mode 100644 src/music/spotify/go-librespot-config.test.ts create mode 100644 src/music/spotify/go-librespot-config.ts diff --git a/src/music/spotify/backend.ts b/src/music/spotify/backend.ts new file mode 100644 index 0000000..e038638 --- /dev/null +++ b/src/music/spotify/backend.ts @@ -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; + stop(): void; + isReady(): boolean; + playTrack(uri: string): Promise; + pause(): Promise; + resume(): Promise; + seek(ms: number): Promise; + 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; +} diff --git a/src/music/spotify/go-librespot-config.test.ts b/src/music/spotify/go-librespot-config.test.ts new file mode 100644 index 0000000..e9d9ae7 --- /dev/null +++ b/src/music/spotify/go-librespot-config.test.ts @@ -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 { + const root: Record = {}; + const stack: Array<{ indent: number; obj: Record }> = [ + { 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 = {}; + 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'); + }); +}); diff --git a/src/music/spotify/go-librespot-config.ts b/src/music/spotify/go-librespot-config.ts new file mode 100644 index 0000000..c5605b0 --- /dev/null +++ b/src/music/spotify/go-librespot-config.ts @@ -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 + * /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" + ); +}