mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
fix(spotify): loopback-bind sidecar API, recover on sidecar death, per-bot go-librespot ports
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9c796ec05c
commit
8bd0aae7c3
6 files changed
+249
-17
No files matched your search
@@ -21,6 +21,37 @@ vi.mock("./binary.js", () => ({
|
||||
checkGoLibrespotAvailable: async () => bin.supported && !!bin.path,
|
||||
}));
|
||||
|
||||
// Capture options the DEFAULT factory hands to the real GoLibrespotBackend so
|
||||
// we can assert per-bot ports (Fix 3) are threaded through. Every other test
|
||||
// injects its own backendFactory, so this mock is inert for them.
|
||||
const goLibrespotCtor = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./go-librespot.js", async () => {
|
||||
const { EventEmitter } = await import("node:events");
|
||||
return {
|
||||
GoLibrespotBackend: class extends EventEmitter {
|
||||
constructor(opts: any) {
|
||||
super();
|
||||
goLibrespotCtor(opts);
|
||||
}
|
||||
async start(): Promise<void> {}
|
||||
isReady(): boolean {
|
||||
return true;
|
||||
}
|
||||
stop(): void {}
|
||||
async playTrack(): Promise<void> {}
|
||||
async pause(): Promise<void> {}
|
||||
async resume(): Promise<void> {}
|
||||
async seek(): Promise<void> {}
|
||||
getPcmStream(): any {
|
||||
return null;
|
||||
}
|
||||
getPositionMs(): number {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import AFTER vi.mock so the mocked binary module is used.
|
||||
const { SpotifyController } = await import("./controller.js");
|
||||
|
||||
@@ -401,3 +432,22 @@ describe("SpotifyController.stop", () => {
|
||||
expect(be.stopCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SpotifyController per-bot ports (Fix 3)", () => {
|
||||
it("threads apiPort + callbackPort into the default GoLibrespotBackend", async () => {
|
||||
goLibrespotCtor.mockClear();
|
||||
// No injected backendFactory → the controller builds the (mocked) real backend.
|
||||
const ctrl = new SpotifyController({
|
||||
config: cfg(),
|
||||
workDir: "/tmp/work",
|
||||
configDir: "/tmp/cfg",
|
||||
logger: silentLogger,
|
||||
apiPort: 3712,
|
||||
callbackPort: 8712,
|
||||
});
|
||||
expect(await ctrl.ensureStarted()).toBe(true);
|
||||
expect(goLibrespotCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiPort: 3712, callbackPort: 8712 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ export interface SpotifyControllerOptions {
|
||||
workDir: string;
|
||||
configDir: string;
|
||||
logger: Logger;
|
||||
/** Per-bot go-librespot control-API port (distinct per bot to avoid binds). */
|
||||
apiPort?: number;
|
||||
/** Per-bot go-librespot OAuth callback port (distinct per bot). */
|
||||
callbackPort?: number;
|
||||
/** Injected for tests; defaults to constructing a real GoLibrespotBackend. */
|
||||
backendFactory?: () => SpotifyAudioBackend;
|
||||
}
|
||||
@@ -39,6 +43,8 @@ export class SpotifyController extends EventEmitter {
|
||||
private readonly workDir: string;
|
||||
private readonly configDir: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly apiPort?: number;
|
||||
private readonly callbackPort?: number;
|
||||
private readonly backendFactory: () => SpotifyAudioBackend;
|
||||
|
||||
private backend: SpotifyAudioBackend | null = null;
|
||||
@@ -51,6 +57,8 @@ export class SpotifyController extends EventEmitter {
|
||||
this.workDir = o.workDir;
|
||||
this.configDir = o.configDir;
|
||||
this.logger = o.logger;
|
||||
this.apiPort = o.apiPort;
|
||||
this.callbackPort = o.callbackPort;
|
||||
this.backendFactory =
|
||||
o.backendFactory ??
|
||||
(() =>
|
||||
@@ -59,6 +67,8 @@ export class SpotifyController extends EventEmitter {
|
||||
bitrate: this.config.bitrate,
|
||||
workDir: this.workDir,
|
||||
configDir: this.configDir,
|
||||
apiPort: this.apiPort,
|
||||
callbackPort: this.callbackPort,
|
||||
logger: this.logger,
|
||||
}));
|
||||
}
|
||||
@@ -79,7 +89,14 @@ export class SpotifyController extends EventEmitter {
|
||||
*/
|
||||
async ensureStarted(): Promise<boolean> {
|
||||
if (!this.isAvailable()) return false;
|
||||
if (this.started) return true;
|
||||
if (this.started) {
|
||||
// A previously-started backend still counts as ready only if its process
|
||||
// is alive. If the sidecar died (isReady()===false) — e.g. the go-librespot
|
||||
// child exited — tear it down here so the code below rebuilds a fresh one
|
||||
// instead of handing callers a dead backend.
|
||||
if (this.backend?.isReady()) return true;
|
||||
this.stop();
|
||||
}
|
||||
if (this.startPromise) return this.startPromise;
|
||||
|
||||
this.startPromise = (async () => {
|
||||
@@ -169,12 +186,22 @@ export class SpotifyController extends EventEmitter {
|
||||
return this.backend.getPcmStream();
|
||||
}
|
||||
|
||||
/** Tear down the backend and reset lifecycle state (safe before start). */
|
||||
/**
|
||||
* Tear down the backend and reset lifecycle state (safe before start).
|
||||
* Mirrors handleBackendError's teardown so the NEXT ensureStarted() rebuilds
|
||||
* a fresh backend: stop() cleans the ffmpeg/go-librespot children + FIFO,
|
||||
* removeAllListeners() detaches this controller's handlers so a late event
|
||||
* from the now-orphaned backend can't disturb a rebuilt one. Guard stop()
|
||||
* (it may throw) so teardown always completes.
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.backend) {
|
||||
this.backend.stop();
|
||||
this.backend = null;
|
||||
try {
|
||||
this.backend?.stop();
|
||||
} catch (stopErr) {
|
||||
this.logger.error({ err: stopErr }, "Spotify backend stop() threw during teardown");
|
||||
}
|
||||
(this.backend as unknown as EventEmitter | null)?.removeAllListeners();
|
||||
this.backend = null;
|
||||
this.started = false;
|
||||
this.startPromise = null;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ function makeFakeChild() {
|
||||
return child;
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
function makeHarness(portOpts: { apiPort?: number; callbackPort?: number } = {}) {
|
||||
const calls: string[] = [];
|
||||
const ffmpegChild = makeFakeChild();
|
||||
const gliChild = makeFakeChild();
|
||||
@@ -52,7 +52,8 @@ function makeHarness() {
|
||||
bitrate: 320,
|
||||
workDir: "/tmp/work",
|
||||
configDir: "/tmp/cfg",
|
||||
apiPort: 3678,
|
||||
apiPort: portOpts.apiPort ?? 3678,
|
||||
callbackPort: portOpts.callbackPort,
|
||||
logger: log,
|
||||
deps: {
|
||||
spawn,
|
||||
@@ -134,6 +135,37 @@ describe("GoLibrespotBackend.start", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoLibrespotBackend config binding (Fix 1 loopback + Fix 3 ports)", () => {
|
||||
function writtenYml(h: ReturnType<typeof makeHarness>): string {
|
||||
const calls = h.writeFileSync.mock.calls as unknown as any[][];
|
||||
const call = calls.find((c) => String(c[0]).endsWith("config.yml"));
|
||||
expect(call).toBeDefined();
|
||||
return call![1] as string;
|
||||
}
|
||||
|
||||
it("binds the control API to loopback (127.0.0.1), never 0.0.0.0", async () => {
|
||||
const h = makeHarness();
|
||||
await h.backend.start();
|
||||
const yml = writtenYml(h);
|
||||
expect(yml).toContain("address: 127.0.0.1");
|
||||
expect(yml).not.toContain("0.0.0.0");
|
||||
});
|
||||
|
||||
it("threads apiPort + callbackPort into the rendered config", async () => {
|
||||
const h = makeHarness({ apiPort: 3712, callbackPort: 8712 });
|
||||
await h.backend.start();
|
||||
const yml = writtenYml(h);
|
||||
expect(yml).toContain("port: 3712");
|
||||
expect(yml).toContain("callback_port: 8712");
|
||||
});
|
||||
|
||||
it("defaults callbackPort to 8080 when unset", async () => {
|
||||
const h = makeHarness();
|
||||
await h.backend.start();
|
||||
expect(writtenYml(h)).toContain("callback_port: 8080");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GoLibrespotBackend WebSocket event mapping", () => {
|
||||
it("maps a not_playing event to trackEnded{reason:'ended'}", async () => {
|
||||
const h = makeHarness();
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface GoLibrespotBackendOptions {
|
||||
workDir: string;
|
||||
configDir: string;
|
||||
apiPort?: number;
|
||||
callbackPort?: number;
|
||||
logger: Logger;
|
||||
deps?: GoLibrespotBackendDeps;
|
||||
}
|
||||
@@ -67,6 +68,7 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack
|
||||
private readonly log: Logger;
|
||||
private readonly deps: GoLibrespotBackendDeps;
|
||||
private readonly apiPort: number;
|
||||
private readonly callbackPort: number;
|
||||
private readonly fifoPath: string;
|
||||
|
||||
private ffmpeg: ChildProcess | null = null;
|
||||
@@ -82,6 +84,7 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack
|
||||
this.log = o.logger;
|
||||
this.deps = o.deps ?? {};
|
||||
this.apiPort = o.apiPort ?? DEFAULT_API_PORT;
|
||||
this.callbackPort = o.callbackPort ?? DEFAULT_CALLBACK_PORT;
|
||||
this.fifoPath = posixPath.join(o.workDir, FIFO_NAME);
|
||||
}
|
||||
|
||||
@@ -132,9 +135,12 @@ export class GoLibrespotBackend extends EventEmitter implements SpotifyAudioBack
|
||||
deviceName: this.opts.deviceName,
|
||||
bitrate: this.opts.bitrate,
|
||||
fifoPath: this.fifoPath,
|
||||
apiAddress: "0.0.0.0",
|
||||
// The go-librespot control API is UNAUTHENTICATED and the client only
|
||||
// ever connects via 127.0.0.1; the sidecar runs in the SAME container
|
||||
// as the bot, so bind to loopback rather than exposing it on 0.0.0.0.
|
||||
apiAddress: "127.0.0.1",
|
||||
apiPort: this.apiPort,
|
||||
callbackPort: DEFAULT_CALLBACK_PORT,
|
||||
callbackPort: this.callbackPort,
|
||||
});
|
||||
writeFileSync(posixPath.join(this.opts.configDir, "config.yml"), yml, "utf8");
|
||||
|
||||
|
||||
Reference in new issue
Block a user