feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS)

Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos
the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes
config.yml, polls the REST /  until ready, then connects the WS event stream.
Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying;
play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS
seams are injectable so the lifecycle is fully unit-tested without a real binary.

Correction C1: ffmpeg is resolved via getFfmpegCommand() (now exported from
src/audio/player.ts) so the bundled ffmpeg-static fallback is honored in Docker;
the ffmpeg command is overridable via deps.ffmpegCommand for tests.

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:58:13 +08:00
1 parent f2b14b5f00
commit 9d55bea240
3 files changed
+495 -1

No files matched your search

+273
View File
@@ -0,0 +1,273 @@
import { EventEmitter } from "node:events";
// The go-librespot sidecar is Linux/Docker-gated (mkfifo + Linux-only binary),
// so the FIFO and config-dir paths are ALWAYS POSIX. Use posix.join so the
// separators are correct on the Linux target regardless of the host OS.
import { posix as posixPath } from "node:path";
import type { Readable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import {
execFileSync as realExecFileSync,
spawn as realSpawn,
} from "node:child_process";
import {
existsSync as realExistsSync,
mkdirSync as realMkdirSync,
unlinkSync as realUnlinkSync,
writeFileSync as realWriteFileSync,
} from "node:fs";
import type { Logger } from "pino";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { findGoLibrespot } from "./binary.js";
import { renderConfigYml } from "./go-librespot-config.js";
import { GoLibrespotRestClient, GoLibrespotEventClient } from "./go-librespot-api.js";
import { getFfmpegCommand } from "../../audio/player.js";
export interface GoLibrespotBackendOptions {
deviceName: string;
bitrate: number;
workDir: string;
configDir: string;
apiPort?: number;
logger: Logger;
deps?: GoLibrespotBackendDeps;
}
/** Injectable seams so the whole lifecycle is testable without a real binary/FIFO/network. */
export interface GoLibrespotBackendDeps {
spawn?: typeof realSpawn;
execFileSync?: typeof realExecFileSync;
existsSync?: typeof realExistsSync;
mkdirSync?: typeof realMkdirSync;
unlinkSync?: typeof realUnlinkSync;
writeFileSync?: typeof realWriteFileSync;
findBinary?: () => string;
/**
* C1: override the ffmpeg command. Production resolves it via
* getFfmpegCommand() (bundled ffmpeg-static fallback when `ffmpeg` isn't on
* PATH, the Docker case); tests pin it to "ffmpeg" for stable arg assertions.
*/
ffmpegCommand?: string;
makeRest?: (baseUrl: string) => GoLibrespotRestClient;
makeEvents?: (wsUrl: string) => GoLibrespotEventClient;
sleep?: (ms: number) => Promise<void>;
pollIntervalMs?: number;
pollTimeoutMs?: number;
}
const DEFAULT_API_PORT = 3678;
const DEFAULT_CALLBACK_PORT = 8080;
const FIFO_NAME = "go-librespot.fifo";
export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBackend {
private readonly opts: GoLibrespotBackendOptions;
private readonly log: Logger;
private readonly deps: GoLibrespotBackendDeps;
private readonly apiPort: number;
private readonly fifoPath: string;
private ffmpeg: ChildProcess | null = null;
private proc: ChildProcess | null = null;
private rest: GoLibrespotRestClient | null = null;
private events: GoLibrespotEventClient | null = null;
private ready = false;
private positionMs = 0;
constructor(o: GoLibrespotBackendOptions) {
super();
this.opts = o;
this.log = o.logger;
this.deps = o.deps ?? {};
this.apiPort = o.apiPort ?? DEFAULT_API_PORT;
this.fifoPath = posixPath.join(o.workDir, FIFO_NAME);
}
async start(): Promise<void> {
const spawn = this.deps.spawn ?? realSpawn;
const execFileSync = this.deps.execFileSync ?? realExecFileSync;
const existsSync = this.deps.existsSync ?? realExistsSync;
const mkdirSync = this.deps.mkdirSync ?? realMkdirSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
const writeFileSync = this.deps.writeFileSync ?? realWriteFileSync;
const findBinary = this.deps.findBinary ?? findGoLibrespot;
// C1: resolve ffmpeg via the repo's getFfmpegCommand() (ffmpeg-static
// fallback) unless a command is injected for tests.
const ffmpegCommand = this.deps.ffmpegCommand ?? getFfmpegCommand();
// 1. Ensure work + config directories exist.
mkdirSync(this.opts.workDir, { recursive: true });
mkdirSync(this.opts.configDir, { recursive: true });
// 2. (Re)create the FIFO — mkfifo fails if the path already exists.
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
execFileSync("mkfifo", [this.fifoPath]);
// 3. Spawn ffmpeg FIRST so the PCM reader is attached to the FIFO before
// go-librespot (the writer) starts pushing raw 44.1k s16le into it.
// Opening the FIFO for writing before a reader exists errors with ENXIO.
this.ffmpeg = spawn(
ffmpegCommand,
[
"-hide_banner", "-loglevel", "error",
"-f", "s16le", "-ar", "44100", "-ac", "2", "-i", this.fifoPath,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1",
],
{ stdio: ["ignore", "pipe", "pipe"] },
);
this.ffmpeg.stderr?.on("data", (b: Buffer) =>
this.log.debug({ ffmpeg: b.toString().trim() }, "ffmpeg"),
);
this.ffmpeg.on("error", (err) => this.emit("error", err));
// 4. Render + write config.yml into the config dir.
const yml = renderConfigYml({
deviceName: this.opts.deviceName,
bitrate: this.opts.bitrate,
fifoPath: this.fifoPath,
apiAddress: "0.0.0.0",
apiPort: this.apiPort,
callbackPort: DEFAULT_CALLBACK_PORT,
});
writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8");
// 5. Spawn go-librespot AFTER ffmpeg is listening on the FIFO. Its stdout/
// stderr carry the interactive OAuth URL on first run — surface via logger.
const bin = findBinary();
this.proc = spawn(bin, ["--config_dir", this.opts.configDir], {
stdio: ["ignore", "pipe", "pipe"],
});
const onLog = (b: Buffer) => this.log.info({ golibrespot: b.toString().trim() }, "go-librespot");
this.proc.stdout?.on("data", onLog);
this.proc.stderr?.on("data", onLog);
this.proc.on("error", (err) => this.emit("error", err));
this.proc.on("exit", (code, signal) => {
this.ready = false;
this.log.warn({ code, signal }, "go-librespot exited");
});
// 6. REST client, then poll GET / until the HTTP server answers.
const baseUrl = `http://127.0.0.1:${this.apiPort}`;
this.rest = this.deps.makeRest
? this.deps.makeRest(baseUrl)
: new GoLibrespotRestClient(baseUrl);
await this.waitUntilReady();
// 7. Connect the WebSocket event stream and wire event mapping.
const wsUrl = `ws://127.0.0.1:${this.apiPort}/events`;
this.events = this.deps.makeEvents
? this.deps.makeEvents(wsUrl)
: new GoLibrespotEventClient(wsUrl);
this.wireEvents(this.events);
this.events.start();
this.ready = true;
this.emit("ready");
}
private async waitUntilReady(): Promise<void> {
const sleep = this.deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const interval = this.deps.pollIntervalMs ?? 200;
const timeout = this.deps.pollTimeoutMs ?? 15_000;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (this.rest && (await this.rest.ping())) return;
await sleep(interval);
}
throw new Error("go-librespot API did not become ready within timeout");
}
private wireEvents(ev: GoLibrespotEventClient): void {
ev.on("metadata", (d: any) => {
const np: SpotifyNowPlaying = {
uri: typeof d?.uri === "string" ? d.uri : "",
name: typeof d?.name === "string" ? d.name : "",
artist: Array.isArray(d?.artist_names) ? d.artist_names.join(", ") : "",
album: typeof d?.album_name === "string" ? d.album_name : "",
coverUrl: typeof d?.album_cover_url === "string" ? d.album_cover_url : "",
durationMs: typeof d?.duration === "number" ? d.duration : 0,
};
if (typeof d?.position === "number") this.positionMs = d.position;
this.emit("metadata", np);
});
ev.on("seek", (d: any) => {
if (typeof d?.position === "number") this.positionMs = d.position;
});
ev.on("not_playing", (d: any) => {
const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "ended" };
this.emit("trackEnded", e);
});
ev.on("stopped", (d: any) => {
const e: SpotifyTrackEndedEvent = { uri: typeof d?.uri === "string" ? d.uri : "", reason: "stopped" };
this.emit("trackEnded", e);
});
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
if (!this.rest) throw new Error("go-librespot backend not started");
await this.rest.playTrack(uri);
}
async pause(): Promise<void> {
if (this.rest) await this.rest.pause();
}
async resume(): Promise<void> {
if (this.rest) await this.rest.resume();
}
async seek(ms: number): Promise<void> {
if (this.rest) await this.rest.seek(ms);
this.positionMs = ms;
}
getPcmStream(): Readable {
const out = this.ffmpeg?.stdout;
if (!out) throw new Error("PCM stream unavailable (go-librespot backend not started)");
return out;
}
getPositionMs(): number {
return this.positionMs;
}
stop(): void {
this.ready = false;
try {
this.events?.stop();
} catch {
/* ignore */
}
this.events = null;
this.rest = null;
if (this.proc) {
try {
this.proc.kill("SIGTERM");
} catch {
/* ignore */
}
this.proc = null;
}
if (this.ffmpeg) {
try {
this.ffmpeg.kill("SIGTERM");
} catch {
/* ignore */
}
this.ffmpeg = null;
}
const existsSync = this.deps.existsSync ?? realExistsSync;
const unlinkSync = this.deps.unlinkSync ?? realUnlinkSync;
try {
if (existsSync(this.fifoPath)) unlinkSync(this.fifoPath);
} catch {
/* ignore */
}
}
}