mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
f2b14b5f00
commit
9d55bea240
3 files changed
+495
-1
No files matched your search
+1
-1
@@ -47,7 +47,7 @@ const resolvedFfmpeg: string = (() => {
|
|||||||
return "ffmpeg";
|
return "ffmpeg";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
function getFfmpegCommand(): string {
|
export function getFfmpegCommand(): string {
|
||||||
return resolvedFfmpeg;
|
return resolvedFfmpeg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user