From 9a9f68c446905a1e00f46a537f00f0acf03f13b5 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 2 Jul 2026 00:13:22 +0800 Subject: [PATCH] feat(spotify): wire provider through manager/instance; skip playback sentinel Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 8 ++++++++ src/bot/instance.ts | 17 +++++++++++++++++ src/bot/manager.ts | 8 +++++++- src/index.ts | 13 ++++++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index e745258..7011741 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => { expect(ctx.executeCommand).toHaveBeenCalledTimes(1); }); }); + +describe("BotInstance.getProviderFor — spotify routing", () => { + it("getProviderFor routes 'spotify' to the injected spotify provider", () => { + const spotify = { platform: "spotify" } as any; + const ctx = { spotifyProvider: spotify, neteaseProvider: { platform: "netease" } } as any; + expect(BotInstance.prototype.getProviderFor.call(ctx, "spotify" as any)).toBe(spotify); + }); +}); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 68197d7..c96f895 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -23,6 +23,7 @@ import { occupancyFromClientList, shouldResumeOnReturn, } from "./auto-pause.js"; +import { isSpotifyUri } from "../music/spotify/webapi.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -37,6 +38,7 @@ export interface BotInstanceOptions { youtubeProvider: MusicProvider; localProvider?: MusicProvider; kugouProvider?: MusicProvider; + spotifyProvider?: MusicProvider; database: BotDatabase; config: BotConfig; logger: Logger; @@ -71,6 +73,7 @@ export class BotInstance extends EventEmitter { private youtubeProvider: MusicProvider; private localProvider: MusicProvider; private kugouProvider: MusicProvider; + private spotifyProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -101,6 +104,7 @@ export class BotInstance extends EventEmitter { this.youtubeProvider = options.youtubeProvider; this.localProvider = options.localProvider ?? options.neteaseProvider; this.kugouProvider = options.kugouProvider ?? options.neteaseProvider; + this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider; this.database = options.database; this.config = options.config; this.logger = options.logger.child({ botId: this.id }); @@ -532,6 +536,7 @@ export class BotInstance extends EventEmitter { if (platform === "youtube") return this.youtubeProvider; if (platform === "local") return this.localProvider; if (platform === "kugou") return this.kugouProvider; + if (platform === "spotify") return this.spotifyProvider; return platform === "qq" ? this.qqProvider : this.neteaseProvider; } @@ -545,6 +550,7 @@ export class BotInstance extends EventEmitter { if (flags.has("q")) return this.qqProvider; if (flags.has("y")) return this.youtubeProvider; if (flags.has("k")) return this.kugouProvider; + if (flags.has("s")) return this.spotifyProvider; return this.neteaseProvider; } @@ -581,6 +587,17 @@ 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. + 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; + } song.url = result.url; // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration this.effectiveDuration = result.trialDuration ?? song.duration; diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 8e5310d..15daa04 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -76,6 +76,7 @@ export class BotManager extends EventEmitter { private youtubeProvider: MusicProvider; private localProvider: MusicProvider; private kugouProvider: MusicProvider; + private spotifyProvider: MusicProvider; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -94,7 +95,8 @@ export class BotManager extends EventEmitter { permissions: PermissionStore, configPath: string, localProvider?: MusicProvider, - kugouProvider?: MusicProvider + kugouProvider?: MusicProvider, + spotifyProvider?: MusicProvider ) { super(); this.neteaseProvider = neteaseProvider; @@ -103,6 +105,7 @@ export class BotManager extends EventEmitter { this.youtubeProvider = new YouTubeProvider(); this.localProvider = localProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider; + this.spotifyProvider = spotifyProvider ?? neteaseProvider; // 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<{ @@ -141,6 +144,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, @@ -281,6 +285,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, @@ -335,6 +340,7 @@ export class BotManager extends EventEmitter { youtubeProvider: this.youtubeProvider, localProvider: this.localProvider, kugouProvider: this.kugouProvider, + spotifyProvider: this.spotifyProvider, database: this.database, config: this.config, logger: this.logger, diff --git a/src/index.ts b/src/index.ts index 9eebc07..5c206e8 100755 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js"; import { BiliBiliProvider } from "./music/bilibili.js"; import { LocalMusicProvider } from "./music/local.js"; import { KugouProvider } from "./music/kugou.js"; +import { SpotifyProvider } from "./music/spotify/provider.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; import { createPermissionStore } from "./data/permissions.js"; @@ -59,6 +60,14 @@ async function main() { const bilibiliProvider = new BiliBiliProvider(); const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR); const kugouProvider = new KugouProvider(); + const spotifyProvider = new SpotifyProvider(); + // Safety gate (spec §7): the source is inert unless EXPLICITLY enabled. + // Only feed credentials when enabled — otherwise the provider has no creds, + // hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false, + // so setting a Client ID/Secret alone (enabled:false) never activates Spotify. + if (config.spotify.enabled && config.spotify.clientId) { + spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret); + } const cookieStore = createCookieStore(COOKIE_DIR); const avatarStore = createAvatarStore(AVATAR_DIR); @@ -84,7 +93,8 @@ async function main() { permissions, CONFIG_PATH, localProvider, - kugouProvider + kugouProvider, + spotifyProvider ); await botManager.loadSavedBots(); @@ -96,6 +106,7 @@ async function main() { bilibiliProvider, localProvider, kugouProvider, + spotifyProvider, database: db, avatarStore, config,