diff --git a/src/audio/player.ts b/src/audio/player.ts index 8d492ed..b34b819 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -47,7 +47,7 @@ const resolvedFfmpeg: string = (() => { return "ffmpeg"; })(); -function getFfmpegCommand(): string { +export function getFfmpegCommand(): string { return resolvedFfmpeg; } diff --git a/src/music/spotify/go-librespot.test.ts b/src/music/spotify/go-librespot.test.ts new file mode 100644 index 0000000..aaa6ae1 --- /dev/null +++ b/src/music/spotify/go-librespot.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import pino from "pino"; +import { GoLibrespotBackend } from "./go-librespot.js"; + +const log = pino({ level: "silent" }); + +/** A minimal stand-in for a spawned ChildProcess with real Readable stdout/stderr. */ +function makeFakeChild() { + const child: any = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + return child; +} + +function makeHarness() { + const calls: string[] = []; + const ffmpegChild = makeFakeChild(); + const gliChild = makeFakeChild(); + + const spawn = vi.fn((cmd: string, ..._rest: any[]) => { + const isGli = cmd.includes("go-librespot"); + calls.push(`spawn:${isGli ? "go-librespot" : cmd}`); + return isGli ? gliChild : ffmpegChild; + }); + const execFileSync = vi.fn((cmd: string) => { + calls.push(`exec:${cmd}`); + return Buffer.from(""); + }); + const writeFileSync = vi.fn(() => calls.push("write:config")); + const mkdirSync = vi.fn(); + const unlinkSync = vi.fn(() => calls.push("unlink:fifo")); + const existsSync = vi.fn(() => false); + + const rest = { + ping: vi.fn(async () => true), + playTrack: vi.fn(async () => {}), + pause: vi.fn(async () => {}), + resume: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + getStatus: vi.fn(async () => null), + }; + const events: any = new EventEmitter(); + events.start = vi.fn(() => calls.push("ws:start")); + events.stop = vi.fn(); + + const backend = new GoLibrespotBackend({ + deviceName: "Test Bot", + bitrate: 320, + workDir: "/tmp/work", + configDir: "/tmp/cfg", + apiPort: 3678, + logger: log, + deps: { + spawn, + execFileSync, + writeFileSync, + mkdirSync, + unlinkSync, + existsSync, + // C1: pin the ffmpeg command so the arg-array/order assertions below stay + // stable while production resolves ffmpeg via getFfmpegCommand() (which + // falls back to bundled ffmpeg-static when `ffmpeg` isn't on PATH). + ffmpegCommand: "ffmpeg", + findBinary: () => "/bin/go-librespot", + makeRest: () => rest, + makeEvents: () => events, + sleep: async () => {}, + pollIntervalMs: 1, + pollTimeoutMs: 100, + } as any, + }); + + return { backend, calls, spawn, execFileSync, writeFileSync, existsSync, unlinkSync, rest, events, ffmpegChild, gliChild }; +} + +describe("GoLibrespotBackend.start", () => { + it("creates the FIFO with mkfifo before spawning ffmpeg, and spawns ffmpeg BEFORE go-librespot", async () => { + const h = makeHarness(); + await h.backend.start(); + + expect(h.execFileSync).toHaveBeenCalledWith("mkfifo", ["/tmp/work/go-librespot.fifo"]); + expect(h.writeFileSync).toHaveBeenCalled(); + + const mkfifoIdx = h.calls.indexOf("exec:mkfifo"); + const ffmpegIdx = h.calls.indexOf("spawn:ffmpeg"); + const gliIdx = h.calls.indexOf("spawn:go-librespot"); + expect(mkfifoIdx).toBeGreaterThanOrEqual(0); + expect(ffmpegIdx).toBeGreaterThan(mkfifoIdx); // ffmpeg attaches to the FIFO first + expect(gliIdx).toBeGreaterThan(ffmpegIdx); // then the writer (go-librespot) + expect(h.calls.indexOf("ws:start")).toBeGreaterThan(gliIdx); // WS connects last + }); + + it("passes --config_dir to go-librespot using the resolved binary path", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.spawn).toHaveBeenCalledWith( + "/bin/go-librespot", + ["--config_dir", "/tmp/cfg"], + expect.anything(), + ); + }); + + it("uses the 44100->48000 s16le ffmpeg command reading the FIFO", async () => { + const h = makeHarness(); + await h.backend.start(); + const ffmpegArgs = h.spawn.mock.calls.find((c) => c[0] === "ffmpeg")![1] as string[]; + expect(ffmpegArgs).toEqual([ + "-hide_banner", "-loglevel", "error", + "-f", "s16le", "-ar", "44100", "-ac", "2", "-i", "/tmp/work/go-librespot.fifo", + "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "pipe:1", + ]); + }); + + it("emits 'ready' and reports isReady() true once the REST ping succeeds", async () => { + const h = makeHarness(); + const ready = vi.fn(); + h.backend.on("ready", ready); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalled(); + expect(ready).toHaveBeenCalledTimes(1); + expect(h.backend.isReady()).toBe(true); + }); + + it("keeps polling ping() until it returns true", async () => { + const h = makeHarness(); + h.rest.ping.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + await h.backend.start(); + expect(h.rest.ping).toHaveBeenCalledTimes(3); + expect(h.backend.isReady()).toBe(true); + }); +}); + +describe("GoLibrespotBackend WebSocket event mapping", () => { + it("maps a not_playing event to trackEnded{reason:'ended'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("not_playing", { uri: "spotify:track:abc" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:abc", reason: "ended" }); + }); + + it("maps a stopped event to trackEnded{reason:'stopped'}", async () => { + const h = makeHarness(); + await h.backend.start(); + const ended = vi.fn(); + h.backend.on("trackEnded", ended); + h.events.emit("stopped", { uri: "spotify:track:xyz" }); + expect(ended).toHaveBeenCalledWith({ uri: "spotify:track:xyz", reason: "stopped" }); + }); + + it("maps a metadata event to a SpotifyNowPlaying and updates getPositionMs()", async () => { + const h = makeHarness(); + await h.backend.start(); + const meta = vi.fn(); + h.backend.on("metadata", meta); + h.events.emit("metadata", { + uri: "spotify:track:abc", + name: "Song", + artist_names: ["A", "B"], + album_name: "Alb", + album_cover_url: "http://x/y.jpg", + position: 1234, + duration: 200000, + }); + expect(meta).toHaveBeenCalledWith({ + uri: "spotify:track:abc", + name: "Song", + artist: "A, B", + album: "Alb", + coverUrl: "http://x/y.jpg", + durationMs: 200000, + }); + expect(h.backend.getPositionMs()).toBe(1234); + }); +}); + +describe("GoLibrespotBackend transport delegation + PCM", () => { + it("playTrack delegates to the REST client", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.playTrack("spotify:track:go"); + expect(h.rest.playTrack).toHaveBeenCalledWith("spotify:track:go"); + }); + + it("pause/resume/seek delegate to the REST client and seek updates position", async () => { + const h = makeHarness(); + await h.backend.start(); + await h.backend.pause(); + await h.backend.resume(); + await h.backend.seek(5000); + expect(h.rest.pause).toHaveBeenCalled(); + expect(h.rest.resume).toHaveBeenCalled(); + expect(h.rest.seek).toHaveBeenCalledWith(5000); + expect(h.backend.getPositionMs()).toBe(5000); + }); + + it("getPcmStream() returns the ffmpeg stdout Readable", async () => { + const h = makeHarness(); + await h.backend.start(); + expect(h.backend.getPcmStream()).toBe(h.ffmpegChild.stdout); + }); +}); + +describe("GoLibrespotBackend.stop", () => { + it("kills ffmpeg + go-librespot, stops the WS, removes the FIFO, and clears ready", async () => { + const h = makeHarness(); + await h.backend.start(); + h.existsSync.mockReturnValue(true); // FIFO now present, so stop() unlinks it + h.backend.stop(); + expect(h.ffmpegChild.kill).toHaveBeenCalled(); + expect(h.gliChild.kill).toHaveBeenCalled(); + expect(h.events.stop).toHaveBeenCalled(); + expect(h.unlinkSync).toHaveBeenCalledWith("/tmp/work/go-librespot.fifo"); + expect(h.backend.isReady()).toBe(false); + }); +}); diff --git a/src/music/spotify/go-librespot.ts b/src/music/spotify/go-librespot.ts new file mode 100644 index 0000000..c2f35df --- /dev/null +++ b/src/music/spotify/go-librespot.ts @@ -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; + 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 { + 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 { + const sleep = this.deps.sleep ?? ((ms: number) => new Promise((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 { + if (!this.rest) throw new Error("go-librespot backend not started"); + await this.rest.playTrack(uri); + } + + async pause(): Promise { + if (this.rest) await this.rest.pause(); + } + + async resume(): Promise { + if (this.rest) await this.rest.resume(); + } + + async seek(ms: number): Promise { + 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 */ + } + } +}