mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7)
Construct one SpotifyController per bot (config.spotify + per-bot work/config dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now routes spotify: sentinels through controller.ensureStarted/playTrack + player.playPcmStream (falling back to the Stage-1 message when unavailable), fences/pauses the sidecar on source transitions, advances via controller "trackEnded", and delegates pause/resume/stop transport. Correction C4: no re-attach on a spotify->spotify handoff (playPcmStream once across tracks, no player.stop() — playPcmStream fences the prior ffmpeg internally); occupancy auto-pause/resume + updateAutoPause + a new BotInstance.seek() (web seek route) also delegate to the sidecar. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
179e7c248a
commit
b5b3585e77
5 files changed
+476
-9
No files matched your search
@@ -218,3 +218,322 @@ describe("BotInstance.getProviderFor — spotify routing", () => {
|
|||||||
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
|
expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Spotify orchestration (Task 7 + Correction C4) ------------------------
|
||||||
|
// These drive the REAL prototype methods on a hand-built ctx (the file's
|
||||||
|
// established `.call(ctx)` style) and assert the routing DECISIONS. Live audio
|
||||||
|
// is not testable here. C4 supersedes the brief where they conflict: switching
|
||||||
|
// a URL track -> spotify does NOT call player.stop() (playPcmStream fences the
|
||||||
|
// prior ffmpeg internally), and a spotify -> spotify handoff does NOT re-attach
|
||||||
|
// the persistent PCM stream (playPcmStream is called ONCE across both tracks).
|
||||||
|
|
||||||
|
const resolveAndPlay = BotInstance.prototype.resolveAndPlay as (
|
||||||
|
this: unknown,
|
||||||
|
song: any,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
const setupPlayerEvents = (BotInstance.prototype as any).setupPlayerEvents as (
|
||||||
|
this: unknown,
|
||||||
|
) => void;
|
||||||
|
const cmdPause = (BotInstance.prototype as any).cmdPause as (this: unknown) => string;
|
||||||
|
const cmdResume = (BotInstance.prototype as any).cmdResume as (this: unknown) => string;
|
||||||
|
const cmdStop = (BotInstance.prototype as any).cmdStop as (this: unknown) => string;
|
||||||
|
const handleOccupancy = (BotInstance.prototype as any).handleOccupancy as (
|
||||||
|
this: unknown,
|
||||||
|
userCount: number,
|
||||||
|
) => void;
|
||||||
|
const seek = (BotInstance.prototype as any).seek as (this: unknown, ms: number) => void;
|
||||||
|
|
||||||
|
function makeController() {
|
||||||
|
return {
|
||||||
|
ensureStarted: vi.fn(async () => true),
|
||||||
|
playTrack: vi.fn(async () => true),
|
||||||
|
getPcmStream: vi.fn(() => ({ kind: "pcm" } as any)),
|
||||||
|
pause: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
seek: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(() => {}),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePlayer() {
|
||||||
|
return {
|
||||||
|
play: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
playPcmStream: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
resume: vi.fn(),
|
||||||
|
seek: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makeResolveCtx(opts: {
|
||||||
|
controller: ReturnType<typeof makeController>;
|
||||||
|
player: ReturnType<typeof makePlayer>;
|
||||||
|
url: string;
|
||||||
|
currentSourceIsSpotify?: boolean;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
config: {},
|
||||||
|
id: "bot1",
|
||||||
|
voteSkipUsers: new Set<string>(),
|
||||||
|
autoPaused: false,
|
||||||
|
currentSourceIsSpotify: opts.currentSourceIsSpotify ?? false,
|
||||||
|
effectiveDuration: undefined,
|
||||||
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
tsClient: { sendTextMessage: vi.fn(async () => {}) },
|
||||||
|
database: { addPlayHistory: vi.fn() },
|
||||||
|
spotifyController: opts.controller,
|
||||||
|
player: opts.player,
|
||||||
|
getProviderFor: vi.fn(() => ({ getSongUrl: async () => ({ url: opts.url }) })),
|
||||||
|
syncProfileToSong: vi.fn(async () => {}),
|
||||||
|
emit: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
function spotifySong() {
|
||||||
|
return {
|
||||||
|
id: "abc",
|
||||||
|
name: "Song",
|
||||||
|
artist: "Artist",
|
||||||
|
album: "Album",
|
||||||
|
platform: "spotify",
|
||||||
|
coverUrl: "c",
|
||||||
|
duration: 200,
|
||||||
|
url: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BotInstance.resolveAndPlay — Spotify routing (C4)", () => {
|
||||||
|
it("routes a spotify song to controller.playTrack + player.playPcmStream, not player.play", async () => {
|
||||||
|
const controller = makeController();
|
||||||
|
const player = makePlayer();
|
||||||
|
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
|
||||||
|
|
||||||
|
const ok = await resolveAndPlay.call(ctx, spotifySong());
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(controller.ensureStarted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.playTrack).toHaveBeenCalledWith("spotify:track:abc");
|
||||||
|
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(player.playPcmStream.mock.calls[0][0]).toEqual({ kind: "pcm" });
|
||||||
|
expect(player.play).not.toHaveBeenCalled();
|
||||||
|
// C4: playPcmStream fences the prior url-ffmpeg internally — no player.stop().
|
||||||
|
expect(player.stop).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.currentSourceIsSpotify).toBe(true);
|
||||||
|
expect(ctx.database.addPlayHistory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.emit).toHaveBeenCalledWith("stateChange");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false + sends the Stage-1 fallback when the backend is unavailable", async () => {
|
||||||
|
const controller = makeController();
|
||||||
|
controller.ensureStarted = vi.fn(async () => false);
|
||||||
|
const player = makePlayer();
|
||||||
|
const ctx = makeResolveCtx({ controller, player, url: "spotify:track:abc" });
|
||||||
|
|
||||||
|
const ok = await resolveAndPlay.call(ctx, spotifySong());
|
||||||
|
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(controller.playTrack).not.toHaveBeenCalled();
|
||||||
|
expect(player.playPcmStream).not.toHaveBeenCalled();
|
||||||
|
expect(player.play).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches the PCM stream once (no player.stop) when switching URL -> spotify", async () => {
|
||||||
|
const controller = makeController();
|
||||||
|
const player = makePlayer();
|
||||||
|
const ctx = makeResolveCtx({
|
||||||
|
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolveAndPlay.call(ctx, spotifySong());
|
||||||
|
|
||||||
|
// C4: NO player.stop() on the URL -> spotify transition.
|
||||||
|
expect(player.stop).not.toHaveBeenCalled();
|
||||||
|
expect(player.playPcmStream).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-attach the stream on a spotify -> spotify handoff (playPcmStream called once across two tracks)", async () => {
|
||||||
|
const controller = makeController();
|
||||||
|
const player = makePlayer();
|
||||||
|
const ctx = makeResolveCtx({
|
||||||
|
controller, player, url: "spotify:track:abc", currentSourceIsSpotify: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First spotify track: coming from a URL/idle source -> attach.
|
||||||
|
await resolveAndPlay.call(ctx, spotifySong());
|
||||||
|
expect(ctx.currentSourceIsSpotify).toBe(true);
|
||||||
|
// Second spotify track: go-librespot changes tracks into the SAME FIFO.
|
||||||
|
await resolveAndPlay.call(ctx, spotifySong());
|
||||||
|
|
||||||
|
expect(player.playPcmStream).toHaveBeenCalledTimes(1); // NOT re-attached
|
||||||
|
expect(controller.playTrack).toHaveBeenCalledTimes(2); // both tracks played
|
||||||
|
expect(player.stop).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses the sidecar and clears the flag when switching to a non-spotify track", async () => {
|
||||||
|
const controller = makeController();
|
||||||
|
const player = makePlayer();
|
||||||
|
const song = { ...spotifySong(), platform: "netease" };
|
||||||
|
const ctx = makeResolveCtx({
|
||||||
|
controller, player, url: "http://cdn/x.mp3", currentSourceIsSpotify: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await resolveAndPlay.call(ctx, song);
|
||||||
|
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
expect(controller.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.currentSourceIsSpotify).toBe(false);
|
||||||
|
expect(player.play).toHaveBeenCalledWith("http://cdn/x.mp3", 0, 200);
|
||||||
|
expect(player.playPcmStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BotInstance.setupPlayerEvents — controller trackEnded wiring", () => {
|
||||||
|
function makeEventCtx(currentPlatform: string) {
|
||||||
|
return {
|
||||||
|
spotifyController: { on: vi.fn() },
|
||||||
|
player: { on: vi.fn() },
|
||||||
|
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
|
||||||
|
logger: { debug: vi.fn(), error: vi.fn() },
|
||||||
|
playNext: vi.fn(async () => true),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
function trackEndedHandler(ctx: any) {
|
||||||
|
const call = ctx.spotifyController.on.mock.calls.find(
|
||||||
|
(c: any[]) => c[0] === "trackEnded",
|
||||||
|
);
|
||||||
|
expect(call).toBeDefined();
|
||||||
|
return call[1] as (e: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("advances via playNext when the current song is spotify", () => {
|
||||||
|
const ctx = makeEventCtx("spotify");
|
||||||
|
setupPlayerEvents.call(ctx);
|
||||||
|
trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" });
|
||||||
|
expect(ctx.playNext).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores controller trackEnded when the current song is not spotify", () => {
|
||||||
|
const ctx = makeEventCtx("netease");
|
||||||
|
setupPlayerEvents.call(ctx);
|
||||||
|
trackEndedHandler(ctx)({ uri: "spotify:track:x", reason: "ended" });
|
||||||
|
expect(ctx.playNext).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BotInstance transport delegation — spotify current song", () => {
|
||||||
|
function makeCmdCtx(currentPlatform: string) {
|
||||||
|
return {
|
||||||
|
player: { pause: vi.fn(), resume: vi.fn(), stop: vi.fn() },
|
||||||
|
spotifyController: {
|
||||||
|
pause: vi.fn(async () => {}),
|
||||||
|
resume: vi.fn(async () => {}),
|
||||||
|
stop: vi.fn(() => {}),
|
||||||
|
},
|
||||||
|
queue: { current: vi.fn(() => ({ platform: currentPlatform })), clear: vi.fn() },
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
emit: vi.fn(),
|
||||||
|
autoPaused: true,
|
||||||
|
currentSourceIsSpotify: true,
|
||||||
|
sweepLocalAudio: vi.fn(),
|
||||||
|
disableFmMode: vi.fn(),
|
||||||
|
profileManager: { onSongChange: vi.fn(async () => {}) },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("cmdPause delegates to controller.pause when current is spotify", () => {
|
||||||
|
const ctx = makeCmdCtx("spotify");
|
||||||
|
cmdPause.call(ctx);
|
||||||
|
expect(ctx.player.pause).toHaveBeenCalled();
|
||||||
|
expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdResume delegates to controller.resume when current is spotify", () => {
|
||||||
|
const ctx = makeCmdCtx("spotify");
|
||||||
|
cmdResume.call(ctx);
|
||||||
|
expect(ctx.player.resume).toHaveBeenCalled();
|
||||||
|
expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cmdStop stops the sidecar + player and clears the spotify flag", () => {
|
||||||
|
const ctx = makeCmdCtx("spotify");
|
||||||
|
cmdStop.call(ctx);
|
||||||
|
expect(ctx.spotifyController.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.player.stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.queue.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.currentSourceIsSpotify).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT touch the controller when current is not spotify", () => {
|
||||||
|
const ctx = makeCmdCtx("netease");
|
||||||
|
cmdPause.call(ctx);
|
||||||
|
cmdResume.call(ctx);
|
||||||
|
expect(ctx.spotifyController.pause).not.toHaveBeenCalled();
|
||||||
|
expect(ctx.spotifyController.resume).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BotInstance.handleOccupancy — spotify auto-pause delegation (C4)", () => {
|
||||||
|
function makeOccupancyCtx(currentPlatform: string, state: string) {
|
||||||
|
return {
|
||||||
|
player: { getState: () => state, pause: vi.fn(), resume: vi.fn() },
|
||||||
|
spotifyController: { pause: vi.fn(async () => {}), resume: vi.fn(async () => {}) },
|
||||||
|
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
|
||||||
|
config: { autoPauseOnEmpty: true },
|
||||||
|
autoPaused: false,
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
emit: vi.fn(),
|
||||||
|
_scheduleIdleCheck: vi.fn(),
|
||||||
|
_cancelIdleTimer: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("delegates pause to the controller when auto-pausing a spotify track (empty channel)", () => {
|
||||||
|
const ctx = makeOccupancyCtx("spotify", "playing");
|
||||||
|
handleOccupancy.call(ctx, 0);
|
||||||
|
expect(ctx.player.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.spotifyController.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.autoPaused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates resume to the controller when a listener returns to a spotify track", () => {
|
||||||
|
const ctx = makeOccupancyCtx("spotify", "paused");
|
||||||
|
ctx.autoPaused = true;
|
||||||
|
handleOccupancy.call(ctx, 1);
|
||||||
|
expect(ctx.player.resume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.spotifyController.resume).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.autoPaused).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT touch the controller when auto-pausing a non-spotify track", () => {
|
||||||
|
const ctx = makeOccupancyCtx("netease", "playing");
|
||||||
|
handleOccupancy.call(ctx, 0);
|
||||||
|
expect(ctx.player.pause).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.spotifyController.pause).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BotInstance.seek — spotify routing (C4)", () => {
|
||||||
|
function makeSeekCtx(currentPlatform: string) {
|
||||||
|
return {
|
||||||
|
queue: { current: vi.fn(() => ({ platform: currentPlatform })) },
|
||||||
|
spotifyController: { seek: vi.fn(async () => {}) },
|
||||||
|
player: { seek: vi.fn() },
|
||||||
|
logger: { warn: vi.fn() },
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("routes seek to the controller for a spotify track", () => {
|
||||||
|
const ctx = makeSeekCtx("spotify");
|
||||||
|
seek.call(ctx, 30);
|
||||||
|
expect(ctx.spotifyController.seek).toHaveBeenCalledWith(30);
|
||||||
|
expect(ctx.player.seek).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes seek to the player for a non-spotify track", () => {
|
||||||
|
const ctx = makeSeekCtx("netease");
|
||||||
|
seek.call(ctx, 30);
|
||||||
|
expect(ctx.player.seek).toHaveBeenCalledWith(30);
|
||||||
|
expect(ctx.spotifyController.seek).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+145
-6
@@ -15,7 +15,7 @@ import {
|
|||||||
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
|
||||||
import type { Logger } from "../logger.js";
|
import type { Logger } from "../logger.js";
|
||||||
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
import type { BotDatabase, ProfileConfig } from "../data/database.js";
|
||||||
import type { BotConfig } from "../data/config.js";
|
import type { BotConfig, SpotifyConfig } from "../data/config.js";
|
||||||
import { BotProfileManager } from "./profile.js";
|
import { BotProfileManager } from "./profile.js";
|
||||||
import type { AvatarStore } from "../data/avatars.js";
|
import type { AvatarStore } from "../data/avatars.js";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,9 @@ import {
|
|||||||
shouldResumeOnReturn,
|
shouldResumeOnReturn,
|
||||||
} from "./auto-pause.js";
|
} from "./auto-pause.js";
|
||||||
import { isSpotifyUri } from "../music/spotify/webapi.js";
|
import { isSpotifyUri } from "../music/spotify/webapi.js";
|
||||||
|
import path from "node:path";
|
||||||
|
import { SpotifyController } from "../music/spotify/controller.js";
|
||||||
|
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
|
||||||
|
|
||||||
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
/** Reply sent when a non-admin invokes an admin-only chat command. */
|
||||||
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
|
||||||
@@ -43,6 +46,15 @@ export interface BotInstanceOptions {
|
|||||||
config: BotConfig;
|
config: BotConfig;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
avatarStore: AvatarStore;
|
avatarStore: AvatarStore;
|
||||||
|
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
|
||||||
|
spotifyDataDir?: string;
|
||||||
|
/** Test seam: build a fake controller instead of a real go-librespot one. */
|
||||||
|
spotifyControllerFactory?: (o: {
|
||||||
|
config: SpotifyConfig;
|
||||||
|
workDir: string;
|
||||||
|
configDir: string;
|
||||||
|
logger: Logger;
|
||||||
|
}) => SpotifyController;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotStatus {
|
export interface BotStatus {
|
||||||
@@ -66,6 +78,7 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private tsClient: TS3Client;
|
private tsClient: TS3Client;
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
|
private spotifyController: SpotifyController;
|
||||||
private queue: PlayQueue;
|
private queue: PlayQueue;
|
||||||
private neteaseProvider: MusicProvider;
|
private neteaseProvider: MusicProvider;
|
||||||
private qqProvider: MusicProvider;
|
private qqProvider: MusicProvider;
|
||||||
@@ -85,6 +98,9 @@ export class BotInstance extends EventEmitter {
|
|||||||
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
private idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private channelUserCount = 0;
|
private channelUserCount = 0;
|
||||||
private autoPaused = false;
|
private autoPaused = false;
|
||||||
|
/** True while the audible track is served by the Spotify sidecar (external
|
||||||
|
* PCM mode) — drives fence/handoff decisions in resolveAndPlay + cmdStop. */
|
||||||
|
private currentSourceIsSpotify = false;
|
||||||
private profileManager: BotProfileManager;
|
private profileManager: BotProfileManager;
|
||||||
private isFmMode = false;
|
private isFmMode = false;
|
||||||
private fmProvider: MusicProvider | null = null;
|
private fmProvider: MusicProvider | null = null;
|
||||||
@@ -114,6 +130,22 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.player = new AudioPlayer(this.logger);
|
this.player = new AudioPlayer(this.logger);
|
||||||
this.queue = new PlayQueue();
|
this.queue = new PlayQueue();
|
||||||
|
|
||||||
|
// One long-lived Spotify sidecar controller per bot. Construction is
|
||||||
|
// cheap and side-effect-free — nothing spawns until ensureStarted().
|
||||||
|
const spotifyBase =
|
||||||
|
options.spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||||
|
const spotifyWorkDir = path.join(spotifyBase, this.id, "work");
|
||||||
|
const spotifyConfigDir = path.join(spotifyBase, this.id, "config");
|
||||||
|
const buildController =
|
||||||
|
options.spotifyControllerFactory ??
|
||||||
|
((o) => new SpotifyController({ ...o }));
|
||||||
|
this.spotifyController = buildController({
|
||||||
|
config: this.config.spotify,
|
||||||
|
workDir: spotifyWorkDir,
|
||||||
|
configDir: spotifyConfigDir,
|
||||||
|
logger: this.logger,
|
||||||
|
});
|
||||||
|
|
||||||
const profileConfig = this.database.getProfileConfig(this.id);
|
const profileConfig = this.database.getProfileConfig(this.id);
|
||||||
this.profileManager = new BotProfileManager(
|
this.profileManager = new BotProfileManager(
|
||||||
this.tsClient,
|
this.tsClient,
|
||||||
@@ -155,6 +187,19 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.logger.error({ err: err2 }, "playNext failed after player error");
|
this.logger.error({ err: err2 }, "playNext failed after player error");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Spotify advances exclusively via the sidecar's WebSocket "trackEnded"
|
||||||
|
// (the continuous go-librespot→ffmpeg pipe never EOFs per track, so the
|
||||||
|
// player's own underrun "trackEnd" is suppressed in external mode). Guard
|
||||||
|
// on the current song being spotify so a stray event can't double-advance
|
||||||
|
// a URL track; playNext()'s isAdvancing guard covers any residual race.
|
||||||
|
this.spotifyController.on("trackEnded", (_e: SpotifyTrackEndedEvent) => {
|
||||||
|
if (this.queue.current()?.platform !== "spotify") return;
|
||||||
|
this.logger.debug("Spotify track ended, advancing queue");
|
||||||
|
this.playNext().catch((err) => {
|
||||||
|
this.logger.error({ err }, "playNext failed after spotify trackEnded");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isLocalAudioEnabled(): boolean {
|
isLocalAudioEnabled(): boolean {
|
||||||
@@ -205,6 +250,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
// this.connected was never flipped to true. Previously this handler
|
// this.connected was never flipped to true. Previously this handler
|
||||||
// short-circuited on !this.connected, leaving player stuck as "playing".
|
// short-circuited on !this.connected, leaving player stuck as "playing".
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
|
this.spotifyController.stop();
|
||||||
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("disconnected");
|
this.sweepLocalAudio("disconnected");
|
||||||
@@ -288,6 +335,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this._cancelIdleTimer();
|
this._cancelIdleTimer();
|
||||||
|
this.spotifyController.stop();
|
||||||
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("disconnected");
|
this.sweepLocalAudio("disconnected");
|
||||||
@@ -310,6 +359,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
this.config.autoPauseOnEmpty = enabled;
|
this.config.autoPauseOnEmpty = enabled;
|
||||||
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.resume().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify resume failed (auto-pause disabled)"));
|
||||||
|
}
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
}
|
}
|
||||||
@@ -343,10 +396,21 @@ export class BotInstance extends EventEmitter {
|
|||||||
);
|
);
|
||||||
if (action === "pause") {
|
if (action === "pause") {
|
||||||
this.player.pause();
|
this.player.pause();
|
||||||
|
// Occupancy paths drive player.pause()/resume() DIRECTLY (bypassing the
|
||||||
|
// cmd handlers), so they must ALSO stop/resume the sidecar — else it
|
||||||
|
// keeps decoding into an empty channel.
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.pause().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify pause failed (occupancy)"));
|
||||||
|
}
|
||||||
this.autoPaused = true;
|
this.autoPaused = true;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
} else if (action === "resume") {
|
} else if (action === "resume") {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.resume().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify resume failed (occupancy)"));
|
||||||
|
}
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
}
|
}
|
||||||
@@ -587,17 +651,62 @@ export class BotInstance extends EventEmitter {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl
|
// Stage 2: a `spotify:` sentinel URI means the go-librespot sidecar
|
||||||
// returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user
|
// serves the audio, NOT ffmpeg. Start the per-bot sidecar on demand; if
|
||||||
// and skip so the queue keeps moving. `sendTextMessage` is the same
|
// it can't run (disabled / non-Linux / binary missing) keep the Stage-1
|
||||||
// channel-message helper the command handlers use elsewhere in this file.
|
// fallback message + skip so the queue keeps moving.
|
||||||
if (isSpotifyUri(result.url)) {
|
if (isSpotifyUri(result.url)) {
|
||||||
this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping");
|
const ready = await this.spotifyController.ensureStarted();
|
||||||
|
if (!ready) {
|
||||||
|
this.logger.info({ songId: song.id, name: song.name }, "Spotify backend unavailable — skipping");
|
||||||
await this.tsClient.sendTextMessage(
|
await this.tsClient.sendTextMessage(
|
||||||
"⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。"
|
"⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。"
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// `spotify:track:<id>` is the URI. go-librespot decodes into a SINGLE
|
||||||
|
// continuous FIFO/PCM stream, so per-track playback is just a REST
|
||||||
|
// playTrack — the stream keeps flowing.
|
||||||
|
await this.spotifyController.playTrack(result.url);
|
||||||
|
// C4: only ATTACH the persistent PCM stream when coming from a
|
||||||
|
// non-spotify source. playPcmStream internally fences the prior
|
||||||
|
// url-ffmpeg (so NO player.stop() here). On a spotify→spotify handoff
|
||||||
|
// the sidecar changes tracks into the SAME FIFO — re-attaching would
|
||||||
|
// tear down and re-subscribe the shared stream and silence playback.
|
||||||
|
if (!this.currentSourceIsSpotify) {
|
||||||
|
this.player.playPcmStream(this.spotifyController.getPcmStream(), {
|
||||||
|
// The sidecar PCM pipe is long-lived; per-track end arrives via the
|
||||||
|
// controller "trackEnded" WS event, not stream EOF. A real EOF here
|
||||||
|
// means the sidecar died — recovery is the controller's job.
|
||||||
|
onExternalEnd: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.currentSourceIsSpotify = true;
|
||||||
|
song.url = result.url;
|
||||||
|
// No trial clip for Spotify — full-track duration only (the near-end
|
||||||
|
// stall watchdog is disabled for the external stream anyway).
|
||||||
|
this.effectiveDuration = song.duration;
|
||||||
|
this.autoPaused = false;
|
||||||
|
this.database.addPlayHistory({
|
||||||
|
botId: this.id,
|
||||||
|
songId: song.id,
|
||||||
|
songName: song.name,
|
||||||
|
artist: song.artist,
|
||||||
|
album: song.album,
|
||||||
|
platform: song.platform,
|
||||||
|
coverUrl: song.coverUrl,
|
||||||
|
});
|
||||||
|
await this.syncProfileToSong(song);
|
||||||
|
this.emit("stateChange");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Non-Spotify track: if we were on Spotify, pause the sidecar so it stops
|
||||||
|
// decoding ahead before the URL ffmpeg reclaims the PCM buffer.
|
||||||
|
if (this.currentSourceIsSpotify) {
|
||||||
|
this.spotifyController.pause().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Failed to pause Spotify sidecar on source switch"));
|
||||||
|
this.currentSourceIsSpotify = false;
|
||||||
|
}
|
||||||
song.url = result.url;
|
song.url = result.url;
|
||||||
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
|
||||||
this.effectiveDuration = result.trialDuration ?? song.duration;
|
this.effectiveDuration = result.trialDuration ?? song.duration;
|
||||||
@@ -767,6 +876,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdPause(): string {
|
private cmdPause(): string {
|
||||||
this.player.pause();
|
this.player.pause();
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.pause().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify pause failed"));
|
||||||
|
}
|
||||||
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
// User-initiated pause — clear auto-pause so occupancy won't auto-resume it.
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
@@ -775,6 +888,10 @@ export class BotInstance extends EventEmitter {
|
|||||||
|
|
||||||
private cmdResume(): string {
|
private cmdResume(): string {
|
||||||
this.player.resume();
|
this.player.resume();
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.resume().catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify resume failed"));
|
||||||
|
}
|
||||||
// User-initiated resume — drop any auto-pause flag.
|
// User-initiated resume — drop any auto-pause flag.
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.emit("stateChange");
|
this.emit("stateChange");
|
||||||
@@ -782,6 +899,12 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cmdStop(): string {
|
private cmdStop(): string {
|
||||||
|
// Read the current song BEFORE queue.clear() so we can tell whether the
|
||||||
|
// sidecar needs stopping.
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.stop();
|
||||||
|
}
|
||||||
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.autoPaused = false;
|
this.autoPaused = false;
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
@@ -842,6 +965,8 @@ export class BotInstance extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private cmdClear(): string {
|
private cmdClear(): string {
|
||||||
|
this.spotifyController.stop();
|
||||||
|
this.currentSourceIsSpotify = false;
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
this.queue.clear();
|
this.queue.clear();
|
||||||
this.sweepLocalAudio("queue_cleared");
|
this.sweepLocalAudio("queue_cleared");
|
||||||
@@ -1231,6 +1356,20 @@ export class BotInstance extends EventEmitter {
|
|||||||
return this.player;
|
return this.player;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route a seek to the Spotify sidecar for a spotify track (its PCM stream is
|
||||||
|
* external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel
|
||||||
|
* and collide with the running stream), otherwise to the URL player.
|
||||||
|
*/
|
||||||
|
seek(ms: number): void {
|
||||||
|
if (this.queue.current()?.platform === "spotify") {
|
||||||
|
this.spotifyController.seek(ms).catch((err) =>
|
||||||
|
this.logger.warn({ err }, "Spotify seek failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.player.seek(ms);
|
||||||
|
}
|
||||||
|
|
||||||
getQueueManager(): PlayQueue {
|
getQueueManager(): PlayQueue {
|
||||||
return this.queue;
|
return this.queue;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -1,5 +1,6 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
|
import path from "node:path";
|
||||||
import {
|
import {
|
||||||
BotInstance,
|
BotInstance,
|
||||||
type BotInstanceOptions,
|
type BotInstanceOptions,
|
||||||
@@ -77,6 +78,7 @@ export class BotManager extends EventEmitter {
|
|||||||
private localProvider: MusicProvider;
|
private localProvider: MusicProvider;
|
||||||
private kugouProvider: MusicProvider;
|
private kugouProvider: MusicProvider;
|
||||||
private spotifyProvider: MusicProvider;
|
private spotifyProvider: MusicProvider;
|
||||||
|
private spotifyDataDir: string;
|
||||||
private database: BotDatabase;
|
private database: BotDatabase;
|
||||||
private config: BotConfig;
|
private config: BotConfig;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
@@ -96,7 +98,8 @@ export class BotManager extends EventEmitter {
|
|||||||
configPath: string,
|
configPath: string,
|
||||||
localProvider?: MusicProvider,
|
localProvider?: MusicProvider,
|
||||||
kugouProvider?: MusicProvider,
|
kugouProvider?: MusicProvider,
|
||||||
spotifyProvider?: MusicProvider
|
spotifyProvider?: MusicProvider,
|
||||||
|
spotifyDataDir?: string
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
this.neteaseProvider = neteaseProvider;
|
this.neteaseProvider = neteaseProvider;
|
||||||
@@ -106,6 +109,7 @@ export class BotManager extends EventEmitter {
|
|||||||
this.localProvider = localProvider ?? neteaseProvider;
|
this.localProvider = localProvider ?? neteaseProvider;
|
||||||
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
this.kugouProvider = kugouProvider ?? neteaseProvider;
|
||||||
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
|
||||||
|
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
|
||||||
// Let the local provider see which uploads are still referenced by any
|
// Let the local provider see which uploads are still referenced by any
|
||||||
// bot's queue, so it never deletes a file another queue/bot still needs.
|
// bot's queue, so it never deletes a file another queue/bot still needs.
|
||||||
const referenceable = this.localProvider as Partial<{
|
const referenceable = this.localProvider as Partial<{
|
||||||
@@ -149,6 +153,7 @@ export class BotManager extends EventEmitter {
|
|||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
avatarStore: this.avatarStore,
|
avatarStore: this.avatarStore,
|
||||||
|
spotifyDataDir: this.spotifyDataDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
@@ -290,6 +295,7 @@ export class BotManager extends EventEmitter {
|
|||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
avatarStore: this.avatarStore,
|
avatarStore: this.avatarStore,
|
||||||
|
spotifyDataDir: this.spotifyDataDir,
|
||||||
});
|
});
|
||||||
this.bots.set(id, bot);
|
this.bots.set(id, bot);
|
||||||
this.emit("botInstance", bot);
|
this.emit("botInstance", bot);
|
||||||
@@ -345,6 +351,7 @@ export class BotManager extends EventEmitter {
|
|||||||
config: this.config,
|
config: this.config,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
avatarStore: this.avatarStore,
|
avatarStore: this.avatarStore,
|
||||||
|
spotifyDataDir: this.spotifyDataDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bots.set(saved.id, bot);
|
this.bots.set(saved.id, bot);
|
||||||
|
|||||||
+3
-1
@@ -29,6 +29,7 @@ const LOG_DIR = path.join(DATA_DIR, "logs");
|
|||||||
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
|
||||||
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
|
||||||
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
|
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
|
||||||
|
const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify");
|
||||||
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
@@ -94,7 +95,8 @@ async function main() {
|
|||||||
CONFIG_PATH,
|
CONFIG_PATH,
|
||||||
localProvider,
|
localProvider,
|
||||||
kugouProvider,
|
kugouProvider,
|
||||||
spotifyProvider
|
spotifyProvider,
|
||||||
|
SPOTIFY_DATA_DIR
|
||||||
);
|
);
|
||||||
await botManager.loadSavedBots();
|
await botManager.loadSavedBots();
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export function createPlayerRouter(
|
|||||||
.json({ error: "position must be a finite non-negative number" });
|
.json({ error: "position must be a finite non-negative number" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bot.getPlayer().seek(position);
|
bot.seek(position);
|
||||||
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
|
res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: (err as Error).message });
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
|||||||
Reference in new issue
Block a user