diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 7011741..4d0dc3b 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -218,3 +218,322 @@ describe("BotInstance.getProviderFor — spotify routing", () => { 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; +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; + player: ReturnType; + url: string; + currentSourceIsSpotify?: boolean; +}) { + return { + connected: true, + config: {}, + id: "bot1", + voteSkipUsers: new Set(), + 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(); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index c96f895..c5b3481 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -15,7 +15,7 @@ import { import { parseSongRef, parseSelectionIndex } from "./song-ref.js"; import type { Logger } from "../logger.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 type { AvatarStore } from "../data/avatars.js"; import { @@ -24,6 +24,9 @@ import { shouldResumeOnReturn, } from "./auto-pause.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. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -43,6 +46,15 @@ export interface BotInstanceOptions { config: BotConfig; logger: Logger; 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 { @@ -66,6 +78,7 @@ export class BotInstance extends EventEmitter { private tsClient: TS3Client; private player: AudioPlayer; + private spotifyController: SpotifyController; private queue: PlayQueue; private neteaseProvider: MusicProvider; private qqProvider: MusicProvider; @@ -85,6 +98,9 @@ export class BotInstance extends EventEmitter { private idleTimer: ReturnType | null = null; private channelUserCount = 0; 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 isFmMode = false; private fmProvider: MusicProvider | null = null; @@ -114,6 +130,22 @@ export class BotInstance extends EventEmitter { this.player = new AudioPlayer(this.logger); 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); this.profileManager = new BotProfileManager( this.tsClient, @@ -155,6 +187,19 @@ export class BotInstance extends EventEmitter { 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 { @@ -205,6 +250,8 @@ export class BotInstance extends EventEmitter { // this.connected was never flipped to true. Previously this handler // short-circuited on !this.connected, leaving player stuck as "playing". this.connected = false; + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("disconnected"); @@ -288,6 +335,8 @@ export class BotInstance extends EventEmitter { disconnect(): void { this._cancelIdleTimer(); + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("disconnected"); @@ -310,6 +359,10 @@ export class BotInstance extends EventEmitter { this.config.autoPauseOnEmpty = enabled; if (!enabled && this.autoPaused && this.player.getState() === "paused") { 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.emit("stateChange"); } @@ -343,10 +396,21 @@ export class BotInstance extends EventEmitter { ); if (action === "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.emit("stateChange"); } else if (action === "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.emit("stateChange"); } @@ -587,16 +651,61 @@ export class BotInstance extends EventEmitter { ); return false; } - // Stage 1: Spotify metadata works but audio is not wired yet. getSongUrl - // returns a `spotify:` sentinel — never hand it to ffmpeg. Tell the user - // and skip so the queue keeps moving. `sendTextMessage` is the same - // channel-message helper the command handlers use elsewhere in this file. + // Stage 2: a `spotify:` sentinel URI means the go-librespot sidecar + // serves the audio, NOT ffmpeg. Start the per-bot sidecar on demand; if + // it can't run (disabled / non-Linux / binary missing) keep the Stage-1 + // fallback message + skip so the queue keeps moving. if (isSpotifyUri(result.url)) { - this.logger.info({ songId: song.id, name: song.name }, "Spotify playback not enabled yet — skipping"); - await this.tsClient.sendTextMessage( - "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" - ); - return false; + 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( + "⚠️ Spotify 播放尚未启用(需要 librespot 音频后端,将在后续版本支持)。" + ); + return false; + } + // `spotify:track:` 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; // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration @@ -767,6 +876,10 @@ export class BotInstance extends EventEmitter { private cmdPause(): string { 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. this.autoPaused = false; this.emit("stateChange"); @@ -775,6 +888,10 @@ export class BotInstance extends EventEmitter { private cmdResume(): string { 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. this.autoPaused = false; this.emit("stateChange"); @@ -782,6 +899,12 @@ export class BotInstance extends EventEmitter { } 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.autoPaused = false; this.queue.clear(); @@ -842,6 +965,8 @@ export class BotInstance extends EventEmitter { } private cmdClear(): string { + this.spotifyController.stop(); + this.currentSourceIsSpotify = false; this.player.stop(); this.queue.clear(); this.sweepLocalAudio("queue_cleared"); @@ -1231,6 +1356,20 @@ export class BotInstance extends EventEmitter { 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 { return this.queue; } diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 15daa04..682ed6e 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { EventEmitter } from "node:events"; +import path from "node:path"; import { BotInstance, type BotInstanceOptions, @@ -77,6 +78,7 @@ export class BotManager extends EventEmitter { private localProvider: MusicProvider; private kugouProvider: MusicProvider; private spotifyProvider: MusicProvider; + private spotifyDataDir: string; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -96,7 +98,8 @@ export class BotManager extends EventEmitter { configPath: string, localProvider?: MusicProvider, kugouProvider?: MusicProvider, - spotifyProvider?: MusicProvider + spotifyProvider?: MusicProvider, + spotifyDataDir?: string ) { super(); this.neteaseProvider = neteaseProvider; @@ -106,6 +109,7 @@ export class BotManager extends EventEmitter { this.localProvider = localProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? 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 // bot's queue, so it never deletes a file another queue/bot still needs. const referenceable = this.localProvider as Partial<{ @@ -149,6 +153,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(id, bot); @@ -290,6 +295,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(id, bot); this.emit("botInstance", bot); @@ -345,6 +351,7 @@ export class BotManager extends EventEmitter { config: this.config, logger: this.logger, avatarStore: this.avatarStore, + spotifyDataDir: this.spotifyDataDir, }); this.bots.set(saved.id, bot); diff --git a/src/index.ts b/src/index.ts index 5c206e8..cfcbdb1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ const LOG_DIR = path.join(DATA_DIR, "logs"); const COOKIE_DIR = path.join(DATA_DIR, "cookies"); const AVATAR_DIR = path.join(DATA_DIR, "avatars"); 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"); async function main() { @@ -94,7 +95,8 @@ async function main() { CONFIG_PATH, localProvider, kugouProvider, - spotifyProvider + spotifyProvider, + SPOTIFY_DATA_DIR ); await botManager.loadSavedBots(); diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 0da271e..673fd4a 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -198,7 +198,7 @@ export function createPlayerRouter( .json({ error: "position must be a finite non-negative number" }); return; } - bot.getPlayer().seek(position); + bot.seek(position); res.json({ message: `Seeked to ${Math.floor(position)}s`, seekOffset: position }); } catch (err) { res.status(500).json({ error: (err as Error).message });