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);
});
});
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,
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;
+7 -1
View File
@@ -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,
+12 -1
View File
@@ -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,