diff --git a/src/music/spotify/controller.test.ts b/src/music/spotify/controller.test.ts new file mode 100644 index 0000000..901fc1c --- /dev/null +++ b/src/music/spotify/controller.test.ts @@ -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 { + 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 { + this.playCalls.push(uri); + if (this.playShouldReject) throw new Error("play boom"); + } + async pause(): Promise { + this.pauseCalls++; + } + async resume(): Promise { + this.resumeCalls++; + } + async seek(ms: number): Promise { + 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 { + return { + enabled: true, + backend: "auto", + clientId: "", + clientSecret: "", + deviceName: "TSMusicBot", + bitrate: 320, + ...over, + }; +} + +function makeCtrl(over: { + config?: Partial; + 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); + }); +}); diff --git a/src/music/spotify/controller.ts b/src/music/spotify/controller.ts new file mode 100644 index 0000000..3005244 --- /dev/null +++ b/src/music/spotify/controller.ts @@ -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 | 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 { + 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 { + 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 { + if (this.backend) await this.backend.pause(); + } + + async resume(): Promise { + if (this.backend) await this.backend.resume(); + } + + async seek(ms: number): Promise { + 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; + } +}