feat(spotify): wire provider through manager/instance; skip playback sentinel

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-02 00:13:22 +08:00
1 parent 848b3931b8
commit 9a9f68c446
4 files changed
+44 -2

No files matched your search

+8
View File
@@ -210,3 +210,11 @@ describe("BotInstance.handleTextMessage — command permission gate", () => {
expect(ctx.executeCommand).toHaveBeenCalledTimes(1); 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);
});
});
+17
View File
@@ -23,6 +23,7 @@ import {
occupancyFromClientList, occupancyFromClientList,
shouldResumeOnReturn, shouldResumeOnReturn,
} from "./auto-pause.js"; } from "./auto-pause.js";
import { isSpotifyUri } from "../music/spotify/webapi.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 = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -37,6 +38,7 @@ export interface BotInstanceOptions {
youtubeProvider: MusicProvider; youtubeProvider: MusicProvider;
localProvider?: MusicProvider; localProvider?: MusicProvider;
kugouProvider?: MusicProvider; kugouProvider?: MusicProvider;
spotifyProvider?: MusicProvider;
database: BotDatabase; database: BotDatabase;
config: BotConfig; config: BotConfig;
logger: Logger; logger: Logger;
@@ -71,6 +73,7 @@ export class BotInstance extends EventEmitter {
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -101,6 +104,7 @@ export class BotInstance extends EventEmitter {
this.youtubeProvider = options.youtubeProvider; this.youtubeProvider = options.youtubeProvider;
this.localProvider = options.localProvider ?? options.neteaseProvider; this.localProvider = options.localProvider ?? options.neteaseProvider;
this.kugouProvider = options.kugouProvider ?? options.neteaseProvider; this.kugouProvider = options.kugouProvider ?? options.neteaseProvider;
this.spotifyProvider = options.spotifyProvider ?? options.neteaseProvider;
this.database = options.database; this.database = options.database;
this.config = options.config; this.config = options.config;
this.logger = options.logger.child({ botId: this.id }); 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 === "youtube") return this.youtubeProvider;
if (platform === "local") return this.localProvider; if (platform === "local") return this.localProvider;
if (platform === "kugou") return this.kugouProvider; if (platform === "kugou") return this.kugouProvider;
if (platform === "spotify") return this.spotifyProvider;
return platform === "qq" ? this.qqProvider : this.neteaseProvider; 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("q")) return this.qqProvider;
if (flags.has("y")) return this.youtubeProvider; if (flags.has("y")) return this.youtubeProvider;
if (flags.has("k")) return this.kugouProvider; if (flags.has("k")) return this.kugouProvider;
if (flags.has("s")) return this.spotifyProvider;
return this.neteaseProvider; return this.neteaseProvider;
} }
@@ -581,6 +587,17 @@ export class BotInstance extends EventEmitter {
); );
return false; 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; song.url = result.url;
// 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration // 试听片段用试听时长(让 player nearEnd 正确触发自动切歌);完整曲回退 song.duration
this.effectiveDuration = result.trialDuration ?? song.duration; this.effectiveDuration = result.trialDuration ?? song.duration;
+7 -1
View File
@@ -76,6 +76,7 @@ export class BotManager extends EventEmitter {
private youtubeProvider: MusicProvider; private youtubeProvider: MusicProvider;
private localProvider: MusicProvider; private localProvider: MusicProvider;
private kugouProvider: MusicProvider; private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -94,7 +95,8 @@ export class BotManager extends EventEmitter {
permissions: PermissionStore, permissions: PermissionStore,
configPath: string, configPath: string,
localProvider?: MusicProvider, localProvider?: MusicProvider,
kugouProvider?: MusicProvider kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -103,6 +105,7 @@ export class BotManager extends EventEmitter {
this.youtubeProvider = new YouTubeProvider(); this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider; this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
// 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<{
@@ -141,6 +144,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -281,6 +285,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
@@ -335,6 +340,7 @@ export class BotManager extends EventEmitter {
youtubeProvider: this.youtubeProvider, youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider, localProvider: this.localProvider,
kugouProvider: this.kugouProvider, kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
database: this.database, database: this.database,
config: this.config, config: this.config,
logger: this.logger, logger: this.logger,
+12 -1
View File
@@ -9,6 +9,7 @@ import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js"; import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js"; import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js"; import { KugouProvider } from "./music/kugou.js";
import { SpotifyProvider } from "./music/spotify/provider.js";
import { createCookieStore } from "./music/auth.js"; import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js"; import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js"; import { createPermissionStore } from "./data/permissions.js";
@@ -59,6 +60,14 @@ async function main() {
const bilibiliProvider = new BiliBiliProvider(); const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR); const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider(); 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 cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR); const avatarStore = createAvatarStore(AVATAR_DIR);
@@ -84,7 +93,8 @@ async function main() {
permissions, permissions,
CONFIG_PATH, CONFIG_PATH,
localProvider, localProvider,
kugouProvider kugouProvider,
spotifyProvider
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -96,6 +106,7 @@ async function main() {
bilibiliProvider, bilibiliProvider,
localProvider, localProvider,
kugouProvider, kugouProvider,
spotifyProvider,
database: db, database: db,
avatarStore, avatarStore,
config, config,