feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission

Per-bot orchestrator: isAvailable() gates on config.enabled + platform +
binary presence; ensureStarted() starts the backend once (idempotent, retries
on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies
the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected
for tests (fake backend, no real binary/network).

Correction C3: the controller does not re-emit a raw "error" event (Node's
EventEmitter throws on an unhandled "error"); it logs the backend error and
marks itself not-ready so the next ensureStarted() relaunches. getPcmStream()
returns the backend's single persistent stream (no per-attach PassThrough).

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 21:22:12 +08:00
1 parent 6debe23034
commit 3a9504500b
2 files changed
+497

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
// Controllable, hoisted so the vi.mock factory can close over it.
const bin = vi.hoisted(() => ({ supported: true, path: "" }));
vi.mock("./binary.js", () => ({
isGoLibrespotSupported: () => bin.supported,
findGoLibrespot: () => bin.path,
resetGoLibrespotBinaryCache: () => {},
checkGoLibrespotAvailable: async () => bin.supported && !!bin.path,
}));
// Import AFTER vi.mock so the mocked binary module is used.
const { SpotifyController } = await import("./controller.js");
const existingBin = join(tmpdir(), `tsmb-golibrespot-${process.pid}`);
const missingBin = join(tmpdir(), `tsmb-golibrespot-missing-${process.pid}`);
beforeAll(() => {
writeFileSync(existingBin, "#!/bin/sh\n");
});
afterAll(() => {
try {
rmSync(existingBin);
} catch {
/* ignore */
}
});
beforeEach(() => {
bin.supported = true;
bin.path = existingBin;
});
class FakeBackend extends EventEmitter implements SpotifyAudioBackend {
startCalls = 0;
stopCalls = 0;
playCalls: string[] = [];
pauseCalls = 0;
resumeCalls = 0;
seekCalls: number[] = [];
ready = false;
startShouldReject = false;
playShouldReject = false;
readonly pcm = Readable.from([Buffer.alloc(0)]);
async start(): Promise<void> {
this.startCalls++;
if (this.startShouldReject) throw new Error("start boom");
this.ready = true;
}
stop(): void {
this.stopCalls++;
this.ready = false;
}
isReady(): boolean {
return this.ready;
}
async playTrack(uri: string): Promise<void> {
this.playCalls.push(uri);
if (this.playShouldReject) throw new Error("play boom");
}
async pause(): Promise<void> {
this.pauseCalls++;
}
async resume(): Promise<void> {
this.resumeCalls++;
}
async seek(ms: number): Promise<void> {
this.seekCalls.push(ms);
}
getPcmStream(): Readable {
return this.pcm;
}
getPositionMs(): number {
return 0;
}
}
const silentLogger = {
info() {},
error() {},
warn() {},
debug() {},
trace() {},
fatal() {},
child() {
return silentLogger;
},
} as unknown as Logger;
function cfg(over: Partial<SpotifyConfig> = {}): SpotifyConfig {
return {
enabled: true,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
...over,
};
}
function makeCtrl(over: {
config?: Partial<SpotifyConfig>;
backendFactory?: () => SpotifyAudioBackend;
} = {}) {
const be = new FakeBackend();
const ctrl = new SpotifyController({
config: cfg(over.config),
workDir: "/tmp/work",
configDir: "/tmp/cfg",
logger: silentLogger,
backendFactory: over.backendFactory ?? (() => be),
});
return { ctrl, be };
}
describe("SpotifyController.isAvailable", () => {
it("true when enabled + supported + binary present", () => {
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(true);
});
it("false when config disabled", () => {
const { ctrl } = makeCtrl({ config: { enabled: false } });
expect(ctrl.isAvailable()).toBe(false);
});
it("false when platform unsupported", () => {
bin.supported = false;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
it("false when binary file is absent", () => {
bin.path = missingBin;
const { ctrl } = makeCtrl();
expect(ctrl.isAvailable()).toBe(false);
});
});
describe("SpotifyController.ensureStarted", () => {
it("starts the backend exactly once across repeated calls", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(true);
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("is idempotent under concurrent calls (single start)", async () => {
let built = 0;
const be = new FakeBackend();
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
const [a, b] = await Promise.all([ctrl.ensureStarted(), ctrl.ensureStarted()]);
expect(a).toBe(true);
expect(b).toBe(true);
expect(built).toBe(1);
expect(be.startCalls).toBe(1);
});
it("returns false and does not build a backend when unavailable", async () => {
let built = 0;
const { ctrl } = makeCtrl({
config: { enabled: false },
backendFactory: () => {
built++;
return new FakeBackend();
},
});
expect(await ctrl.ensureStarted()).toBe(false);
expect(built).toBe(0);
});
it("returns false when backend.start() throws, and allows a later retry", async () => {
const be = new FakeBackend();
be.startShouldReject = true;
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return be;
},
});
expect(await ctrl.ensureStarted()).toBe(false);
// start failure clears the cached promise so a subsequent call retries.
be.startShouldReject = false;
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be.startCalls).toBe(2);
});
});
describe("SpotifyController.playTrack", () => {
it("ensures started then delegates the uri, returning true", async () => {
const { ctrl, be } = makeCtrl();
expect(await ctrl.playTrack("spotify:track:abc")).toBe(true);
expect(be.startCalls).toBe(1);
expect(be.playCalls).toEqual(["spotify:track:abc"]);
});
it("returns false when the controller is unavailable", async () => {
const { ctrl, be } = makeCtrl({ config: { enabled: false } });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
expect(be.playCalls).toEqual([]);
});
it("returns false when backend.playTrack rejects", async () => {
const be = new FakeBackend();
be.playShouldReject = true;
const { ctrl } = makeCtrl({ backendFactory: () => be });
expect(await ctrl.playTrack("spotify:track:abc")).toBe(false);
});
});
describe("SpotifyController transport delegation", () => {
it("pause/resume/seek forward to the backend after start", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
await ctrl.pause();
await ctrl.resume();
await ctrl.seek(4200);
expect(be.pauseCalls).toBe(1);
expect(be.resumeCalls).toBe(1);
expect(be.seekCalls).toEqual([4200]);
});
it("pause/resume/seek are safe no-ops before start", async () => {
const { ctrl, be } = makeCtrl();
await expect(ctrl.pause()).resolves.toBeUndefined();
await expect(ctrl.resume()).resolves.toBeUndefined();
await expect(ctrl.seek(10)).resolves.toBeUndefined();
expect(be.pauseCalls).toBe(0);
});
it("getPcmStream returns the backend stream", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
expect(ctrl.getPcmStream()).toBe(be.pcm);
});
it("getPcmStream throws before the backend is started", () => {
const { ctrl } = makeCtrl();
expect(() => ctrl.getPcmStream()).toThrow();
});
});
describe("SpotifyController event re-emission", () => {
it("re-emits backend trackEnded with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyTrackEndedEvent[] = [];
ctrl.on("trackEnded", (e) => got.push(e));
const evt: SpotifyTrackEndedEvent = { uri: "spotify:track:x", reason: "ended" };
be.emit("trackEnded", evt);
expect(got).toEqual([evt]);
});
it("re-emits backend metadata with the same payload", async () => {
const { ctrl, be } = makeCtrl();
await ctrl.ensureStarted();
const got: SpotifyNowPlaying[] = [];
ctrl.on("metadata", (m) => got.push(m));
const meta: SpotifyNowPlaying = {
uri: "spotify:track:x",
name: "Song",
artist: "Artist",
album: "Album",
coverUrl: "http://img",
durationMs: 1000,
};
be.emit("metadata", meta);
expect(got).toEqual([meta]);
});
});
describe("SpotifyController backend error handling (C3)", () => {
it("does NOT throw on a backend 'error' with no controller listener, and marks not-ready", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
let built = 0;
const { ctrl } = makeCtrl({
backendFactory: () => {
built++;
return backends.shift()!;
},
});
await ctrl.ensureStarted();
expect(built).toBe(1);
// The controller itself has NO "error" listener. A raw re-emit would make
// Node throw here; the controller must swallow+log instead.
expect(() => be1.emit("error", new Error("sidecar boom"))).not.toThrow();
// Marked not-ready: a fresh ensureStarted relaunches a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(built).toBe(2);
expect(be2.startCalls).toBe(1);
});
});
describe("SpotifyController.stop", () => {
it("stops the backend and tears down state so a later start rebuilds", async () => {
const be1 = new FakeBackend();
const be2 = new FakeBackend();
const backends = [be1, be2];
const { ctrl } = makeCtrl({ backendFactory: () => backends.shift()! });
await ctrl.ensureStarted();
ctrl.stop();
expect(be1.stopCalls).toBe(1);
// After teardown getPcmStream is invalid again until re-started.
expect(() => ctrl.getPcmStream()).toThrow();
// A fresh ensureStarted builds a new backend.
expect(await ctrl.ensureStarted()).toBe(true);
expect(be2.startCalls).toBe(1);
});
it("stop before start is a safe no-op", () => {
const { ctrl, be } = makeCtrl();
expect(() => ctrl.stop()).not.toThrow();
expect(be.stopCalls).toBe(0);
});
});
+162
View File
@@ -0,0 +1,162 @@
import { EventEmitter } from "node:events";
import { existsSync } from "node:fs";
import type { Readable } from "node:stream";
import type { Logger } from "pino";
import type { SpotifyConfig } from "../../data/config.js";
import type {
SpotifyAudioBackend,
SpotifyTrackEndedEvent,
SpotifyNowPlaying,
} from "./backend.js";
import { isGoLibrespotSupported, findGoLibrespot } from "./binary.js";
import { GoLibrespotBackend } from "./go-librespot.js";
export interface SpotifyControllerOptions {
config: SpotifyConfig;
workDir: string;
configDir: string;
logger: Logger;
/** Injected for tests; defaults to constructing a real GoLibrespotBackend. */
backendFactory?: () => SpotifyAudioBackend;
}
/**
* Per-bot orchestrator for the go-librespot Spotify sidecar. Owns backend
* lifecycle, gates on availability (config + platform + binary), delegates
* transport, and re-emits the backend's "trackEnded"/"metadata" events so
* BotInstance can advance the queue exactly as it does for the ffmpeg path.
*
* Correction C3: this controller does NOT re-emit a raw "error" event (Node's
* EventEmitter throws on an unhandled "error"). It subscribes to the backend's
* "error", logs it, and marks itself not-ready so the next ensureStarted()
* relaunches the backend. Only the safe "trackEnded"/"metadata" events are
* re-emitted. getPcmStream() proxies the backend's SINGLE persistent stream
* (no per-attach PassThrough) to pair with the AudioPlayer detach-not-destroy
* behaviour and BotInstance's no-re-attach on spotify->spotify transitions.
*/
export class SpotifyController extends EventEmitter {
private readonly config: SpotifyConfig;
private readonly workDir: string;
private readonly configDir: string;
private readonly logger: Logger;
private readonly backendFactory: () => SpotifyAudioBackend;
private backend: SpotifyAudioBackend | null = null;
private started = false;
private startPromise: Promise<boolean> | null = null;
constructor(o: SpotifyControllerOptions) {
super();
this.config = o.config;
this.workDir = o.workDir;
this.configDir = o.configDir;
this.logger = o.logger;
this.backendFactory =
o.backendFactory ??
(() =>
new GoLibrespotBackend({
deviceName: this.config.deviceName,
bitrate: this.config.bitrate,
workDir: this.workDir,
configDir: this.configDir,
logger: this.logger,
}));
}
/** enabled in config AND on a supported OS AND the binary is present on disk. */
isAvailable(): boolean {
return (
this.config.enabled &&
isGoLibrespotSupported() &&
existsSync(findGoLibrespot())
);
}
/**
* Idempotently start the backend. Returns false (without building a backend)
* when unavailable, so callers fall back to the Stage-1 sentinel message.
* A failed start clears the cached promise so a later call can retry.
*/
async ensureStarted(): Promise<boolean> {
if (!this.isAvailable()) return false;
if (this.started) return true;
if (this.startPromise) return this.startPromise;
this.startPromise = (async () => {
try {
const backend = this.backendFactory();
backend.on("trackEnded", (e: SpotifyTrackEndedEvent) =>
this.emit("trackEnded", e),
);
backend.on("metadata", (m: SpotifyNowPlaying) =>
this.emit("metadata", m),
);
// C3: do NOT re-emit "error". Log and mark not-ready so the next
// ensureStarted() relaunches a fresh backend.
backend.on("error", (err?: unknown) => this.handleBackendError(err));
await backend.start();
this.backend = backend;
this.started = true;
return true;
} catch (err) {
this.logger.error({ err }, "Spotify backend failed to start");
this.startPromise = null;
return false;
}
})();
return this.startPromise;
}
/**
* C3 backend-error handler. Never re-emits "error" (an unhandled "error" on
* an EventEmitter throws). Logs and marks the controller not-ready so the
* next ensureStarted() relaunches the backend.
*/
private handleBackendError(err: unknown): void {
this.logger.error({ err }, "Spotify backend error; marking not-ready");
this.started = false;
this.startPromise = null;
}
/** Ensure started, then play the spotify: URI. False on any failure. */
async playTrack(uri: string): Promise<boolean> {
const ok = await this.ensureStarted();
if (!ok || !this.backend) return false;
try {
await this.backend.playTrack(uri);
return true;
} catch (err) {
this.logger.error({ err, uri }, "Spotify playTrack failed");
return false;
}
}
async pause(): Promise<void> {
if (this.backend) await this.backend.pause();
}
async resume(): Promise<void> {
if (this.backend) await this.backend.resume();
}
async seek(ms: number): Promise<void> {
if (this.backend) await this.backend.seek(ms);
}
getPcmStream(): Readable {
if (!this.backend) {
throw new Error("Spotify backend not started");
}
return this.backend.getPcmStream();
}
/** Tear down the backend and reset lifecycle state (safe before start). */
stop(): void {
if (this.backend) {
this.backend.stop();
this.backend = null;
}
this.started = false;
this.startPromise = null;
}
}