mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
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:
1 parent
6debe23034
commit
3a9504500b
2 files changed
+497
No files matched your search
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user