From 8998b9623f5f53d31057036152a863e05b40d2f1 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 3 Jul 2026 00:09:12 +0800 Subject: [PATCH] feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6) Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth in index.ts (clientId/redirectUri from config; store via the already-exported createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes playback (C3.1). Reuses the existing file token store (no token-store.ts). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/bot/instance.test.ts | 68 ++++++++++++++++++++++++++ src/bot/instance.ts | 13 +++++ src/bot/manager.test.ts | 66 ++++++++++++++++++++++++++ src/bot/manager.ts | 9 +++- src/index.ts | 21 +++++++- src/web/api/spotify.test.ts | 95 +++++++++++++++++++++++++++++++++++++ src/web/api/spotify.ts | 74 +++++++++++++++++++++++++++++ src/web/server.ts | 19 ++++++++ 8 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/web/api/spotify.test.ts create mode 100644 src/web/api/spotify.ts diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index cfc8eae..e92aa3f 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; +import type { BotInstanceOptions } from "./instance.js"; import type { TS3TextMessage } from "../ts-protocol/client.js"; +import type { SpotifyController } from "../music/spotify/controller.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; +import type { MusicProvider } from "../music/provider.js"; +import type { BotDatabase } from "../data/database.js"; +import type { AvatarStore } from "../data/avatars.js"; +import type { BotConfig } from "../data/config.js"; // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer, // reads avatars, etc.), and runExclusive only touches a single private field @@ -611,6 +618,67 @@ describe("BotInstance.seek — spotify routing (C4)", () => { }); }); +// --- Spotify OAuth threading (Task 6, C3.1) -------------------------------- +// The process-wide shared SpotifyOAuth must reach the SpotifyController via the +// controller factory. We drive the REAL BotInstance constructor with a fake +// controller factory that captures its param object, so the thread is observed +// end-to-end (options.spotifyOAuth -> buildController({ oauth })). +describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1)", () => { + function makeInstanceOptions(over: Partial = {}): { + options: BotInstanceOptions; + captured: { param?: { oauth?: SpotifyOAuth } }; + } { + const captured: { param?: { oauth?: SpotifyOAuth } } = {}; + const provider = { platform: "netease" } as unknown as MusicProvider; + const logger: any = { + info() {}, warn() {}, error() {}, debug() {}, + child() { return logger; }, + }; + const database = { + getProfileConfig: () => ({}), + getCustomAvatarPath: () => null, + } as unknown as BotDatabase; + const options: BotInstanceOptions = { + id: "bot-oauth-test", + name: "OAuthBot", + tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "OAuthBot" } as any, + neteaseProvider: provider, + qqProvider: provider, + bilibiliProvider: provider, + youtubeProvider: provider, + database, + config: { spotify: {} } as unknown as BotConfig, + logger, + avatarStore: { read: () => null } as unknown as AvatarStore, + spotifyControllerFactory: (o) => { + captured.param = o; + // Only `on` is touched during construction (setupPlayerEvents wires + // the "trackEnded" listener); return a minimal fake controller. + return { on: () => {} } as unknown as SpotifyController; + }, + ...over, + }; + return { options, captured }; + } + + it("forwards the injected spotifyOAuth to the controller factory as `oauth`", () => { + const sentinel = {} as unknown as SpotifyOAuth; + const { options, captured } = makeInstanceOptions({ spotifyOAuth: sentinel }); + // eslint-disable-next-line no-new + new BotInstance(options); + expect(captured.param).toBeDefined(); + expect(captured.param?.oauth).toBe(sentinel); + }); + + it("leaves the factory `oauth` undefined when no spotifyOAuth is supplied (behavior-unchanged)", () => { + const { options, captured } = makeInstanceOptions(); + // eslint-disable-next-line no-new + new BotInstance(options); + expect(captured.param).toBeDefined(); + expect(captured.param?.oauth).toBeUndefined(); + }); +}); + describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => { it("yields the SAME ports for the same bot id (stable across restarts)", () => { const a = spotifyPortsForBotId("bot-alpha"); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index c667b27..aed5988 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -27,6 +27,7 @@ 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"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; /** Reply sent when a non-admin invokes an admin-only chat command. */ export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; @@ -77,6 +78,9 @@ export interface BotInstanceOptions { avatarStore: AvatarStore; /** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */ spotifyDataDir?: string; + /** Process-wide shared Spotify OAuth (single account); injected into the + * SpotifyController so web-login authorization is visible to playback (C3.1). */ + spotifyOAuth?: SpotifyOAuth; /** Test seam: build a fake controller instead of a real go-librespot one. */ spotifyControllerFactory?: (o: { config: SpotifyConfig; @@ -85,6 +89,7 @@ export interface BotInstanceOptions { logger: Logger; apiPort: number; callbackPort: number; + oauth?: SpotifyOAuth; }) => SpotifyController; } @@ -181,6 +186,7 @@ export class BotInstance extends EventEmitter { logger: this.logger, apiPort: spotifyApiPort, callbackPort: spotifyCallbackPort, + oauth: options.spotifyOAuth, }); const profileConfig = this.database.getProfileConfig(this.id); @@ -1412,6 +1418,13 @@ export class BotInstance extends EventEmitter { return this.player; } + /** The per-bot Spotify sidecar controller. Exposed like getPlayer()/ + * getQueueManager() so the shared, process-wide OAuth threaded in at + * construction (C3.1) is observable to callers/tests via getOAuth(). */ + getSpotifyController(): SpotifyController { + return this.spotifyController; + } + /** * 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 diff --git a/src/bot/manager.test.ts b/src/bot/manager.test.ts index 65e2c35..b3b6dfa 100644 --- a/src/bot/manager.test.ts +++ b/src/bot/manager.test.ts @@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat import type { Logger } from "../logger.js"; import type { MusicProvider } from "../music/provider.js"; import type { AvatarStore } from "../data/avatars.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; // removeBot only calls logger.info; provide the full shape it could touch. const stubLogger = { @@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => { expect(loadConfig(configPath).guestMode.bots).toBe("all"); }); }); + +// --- Spotify OAuth threading (Task 6, C3.1) -------------------------------- +// The single process-wide SpotifyOAuth built in index.ts must reach every bot's +// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance +// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController, +// so we assert the shared instance surfaces via the controller's getOAuth(). +describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => { + const dirs: string[] = []; + let db: BotDatabase | undefined; + + afterEach(() => { + try { + db?.close(); + } catch { + /* ignore */ + } + db = undefined; + for (const d of dirs) { + rmSync(d, { recursive: true, force: true }); + } + dirs.length = 0; + }); + + it("forwards its shared SpotifyOAuth into a created bot's controller", async () => { + const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-")); + dirs.push(dir); + const configPath = join(dir, "config.json"); + const config = getDefaultConfig(); + saveConfig(configPath, config); + db = createDatabase(":memory:"); + const permissions = createPermissionStore(db.db); + const provider = {} as unknown as MusicProvider; + const sentinel = {} as unknown as SpotifyOAuth; + + const manager = new BotManager( + provider, + provider, + provider, + db, + config, + stubLogger, + {} as unknown as AvatarStore, + permissions, + configPath, + undefined, // localProvider + undefined, // kugouProvider + undefined, // spotifyProvider + join(dir, "spotify"), // spotifyDataDir + sentinel, // spotifyOAuth (the single shared instance) + ); + + const bot = await manager.createBot({ + name: "b1", + serverAddress: "localhost", + serverPort: 9987, + nickname: "b1", + }); + + // Full chain observed: the manager's single shared instance is the exact + // one the per-bot controller now owns (getOAuth() returns it unchanged). + expect(bot.getSpotifyController().getOAuth()).toBe(sentinel); + + bot.disconnect(); + }); +}); diff --git a/src/bot/manager.ts b/src/bot/manager.ts index 682ed6e..82ee79b 100644 --- a/src/bot/manager.ts +++ b/src/bot/manager.ts @@ -14,6 +14,7 @@ import type { Logger } from "../logger.js"; import type { ServerProtocol } from "../ts-protocol/client.js"; import type { AvatarStore } from "../data/avatars.js"; import type { PermissionStore } from "../data/permissions.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; /** * Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the @@ -79,6 +80,7 @@ export class BotManager extends EventEmitter { private kugouProvider: MusicProvider; private spotifyProvider: MusicProvider; private spotifyDataDir: string; + private readonly spotifyOAuth?: SpotifyOAuth; private database: BotDatabase; private config: BotConfig; private logger: Logger; @@ -99,7 +101,8 @@ export class BotManager extends EventEmitter { localProvider?: MusicProvider, kugouProvider?: MusicProvider, spotifyProvider?: MusicProvider, - spotifyDataDir?: string + spotifyDataDir?: string, + spotifyOAuth?: SpotifyOAuth ) { super(); this.neteaseProvider = neteaseProvider; @@ -110,6 +113,7 @@ export class BotManager extends EventEmitter { this.kugouProvider = kugouProvider ?? neteaseProvider; this.spotifyProvider = spotifyProvider ?? neteaseProvider; this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); + this.spotifyOAuth = spotifyOAuth; // 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<{ @@ -154,6 +158,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(id, bot); @@ -296,6 +301,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(id, bot); this.emit("botInstance", bot); @@ -352,6 +358,7 @@ export class BotManager extends EventEmitter { logger: this.logger, avatarStore: this.avatarStore, spotifyDataDir: this.spotifyDataDir, + spotifyOAuth: this.spotifyOAuth, }); this.bots.set(saved.id, bot); diff --git a/src/index.ts b/src/index.ts index cfcbdb1..51a5d81 100755 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ 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 { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js"; import { createCookieStore } from "./music/auth.js"; import { createAvatarStore } from "./data/avatars.js"; import { createPermissionStore } from "./data/permissions.js"; @@ -83,6 +84,22 @@ async function main() { const permissions = createPermissionStore(db.db); + // Single process-wide Spotify authorization (one Premium account for Stage 3). + // Threaded into BOTH the web OAuth router and every bot's SpotifyController so + // a web login immediately authorizes playback (C3.1). Own-app clientId => the + // redirect points at this bot's web callback; empty clientId leaves OAuth + // disabled (isAuthorized() stays false and the Rust backend never starts). + const spotifyOAuthClientId = config.spotify.clientId.trim(); + const spotifyOAuth = new SpotifyOAuth({ + clientId: spotifyOAuthClientId || undefined, + redirectUri: spotifyOAuthClientId + ? `http://127.0.0.1:${config.webPort}/api/spotify/callback` + : undefined, + store: createFileOAuthTokenStore( + path.join(SPOTIFY_DATA_DIR, "oauth", "oauth-tokens.json"), + ), + }); + const botManager = new BotManager( neteaseProvider, qqProvider, @@ -96,7 +113,8 @@ async function main() { localProvider, kugouProvider, spotifyProvider, - SPOTIFY_DATA_DIR + SPOTIFY_DATA_DIR, + spotifyOAuth ); await botManager.loadSavedBots(); @@ -116,6 +134,7 @@ async function main() { logger, cookieStore, staticDir: STATIC_DIR, + spotifyOAuth, }); await webServer.start(); diff --git a/src/web/api/spotify.test.ts b/src/web/api/spotify.test.ts new file mode 100644 index 0000000..020467e --- /dev/null +++ b/src/web/api/spotify.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import pino from "pino"; +import { createSpotifyRouter, type SpotifyOAuthLike } from "./spotify.js"; + +type Role = "admin" | "member" | "guest"; +function makeApp(oauth: SpotifyOAuthLike, role: Role = "admin", caps: string[] = []) { + const app = express(); + app.use(express.json()); + // Stand in for the global requireAuth that populates req.user. + app.use((req, _res, next) => { + (req as any).user = { role, capabilities: new Set(caps) }; + next(); + }); + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth, + logger: pino({ level: "silent" }), + getBackendInfo: () => ({ backend: "librespot", deviceName: "TS-Bot" }), + webUiRedirect: "/", + }), + ); + return app; +} + +function fakeOauth(over: Partial = {}): SpotifyOAuthLike { + return { + buildAuthorizeUrl: () => ({ url: "https://accounts.spotify.com/authorize?x=1", state: "st" }), + handleCallback: async () => true, + isAuthorized: () => false, + ...over, + }; +} + +describe("spotify OAuth router", () => { + it("GET /login returns the authorize url for a permitted user", async () => { + const app = makeApp(fakeOauth()); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(200); + expect(res.body.url).toContain("accounts.spotify.com/authorize"); + }); + + it("GET /login is 403 for a member lacking platform.auth", async () => { + const app = makeApp(fakeOauth(), "member", []); + const res = await request(app).get("/api/spotify/login"); + expect(res.status).toBe(403); + }); + + it("GET /callback with a good code+state redirects to success", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=success"); + expect(handleCallback).toHaveBeenCalledWith("abc", "st"); + }); + + it("GET /callback with a bad state (handleCallback false) redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => false })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=WRONG"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /callback with missing code does not call oauth and redirects to error", async () => { + const handleCallback = vi.fn(async () => true); + const app = makeApp(fakeOauth({ handleCallback })); + const res = await request(app).get("/api/spotify/callback?state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + expect(handleCallback).not.toHaveBeenCalled(); + }); + + it("GET /callback swallows a throwing handleCallback and redirects to error", async () => { + const app = makeApp(fakeOauth({ handleCallback: async () => { throw new Error("boom"); } })); + const res = await request(app).get("/api/spotify/callback?code=abc&state=st"); + expect(res.status).toBe(302); + expect(res.headers.location).toBe("/?spotify=error"); + }); + + it("GET /status reflects authorized + backend + deviceName", async () => { + const app = makeApp(fakeOauth({ isAuthorized: () => true })); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ authorized: true, backend: "librespot", deviceName: "TS-Bot" }); + }); + + it("GET /status is 403 for a guest", async () => { + const app = makeApp(fakeOauth(), "guest"); + const res = await request(app).get("/api/spotify/status"); + expect(res.status).toBe(403); + }); +}); diff --git a/src/web/api/spotify.ts b/src/web/api/spotify.ts new file mode 100644 index 0000000..2e4565f --- /dev/null +++ b/src/web/api/spotify.ts @@ -0,0 +1,74 @@ +import { Router } from "express"; +import type { Logger } from "pino"; +import { requirePermission } from "../middleware/requirePermission.js"; +import { requireNotGuest } from "../middleware/requireNotGuest.js"; + +/** Minimal structural seam over SpotifyOAuth so this router needs no real + * network/crypto in tests. The concrete SpotifyOAuth satisfies it verbatim. */ +export interface SpotifyOAuthLike { + buildAuthorizeUrl(): { url: string; state: string }; + handleCallback(code: string, state: string): Promise; + isAuthorized(): boolean; +} + +export interface SpotifyRouterOptions { + oauth: SpotifyOAuthLike; + logger: Logger; + /** Process-wide backend info for /status (single Premium account, Stage 3). */ + getBackendInfo: () => { backend: string; deviceName: string }; + /** Web UI page to bounce the browser back to after the OAuth callback. */ + webUiRedirect?: string; +} + +export function createSpotifyRouter(opts: SpotifyRouterOptions): Router { + const { oauth, logger } = opts; + const redirectBase = opts.webUiRedirect ?? "/"; + const sep = redirectBase.includes("?") ? "&" : "?"; + const router = Router(); + + // Start the Authorization Code + PKCE flow: hand the WebUI the accounts.spotify.com + // authorize URL (verifier is stashed by state inside SpotifyOAuth). Gated like the + // other platform logins in auth.ts. + router.get("/login", requirePermission("platform.auth"), (_req, res) => { + try { + const { url } = oauth.buildAuthorizeUrl(); + res.json({ url }); + } catch (err) { + logger.error({ err }, "Spotify authorize URL build failed"); + res.status(500).json({ error: (err as Error).message }); + } + }); + + // OAuth redirect target (own-app clientId => redirect_uri points here). This is a + // top-level browser navigation carrying the SameSite=Lax session cookie, so the + // global requireAuth passes; state is the CSRF guard for the flow itself. Always + // redirect (never JSON) so the user lands back in the UI. + router.get("/callback", async (req, res) => { + const code = typeof req.query.code === "string" ? req.query.code : ""; + const state = typeof req.query.state === "string" ? req.query.state : ""; + if (!code || !state) { + res.redirect(`${redirectBase}${sep}spotify=error`); + return; + } + try { + const ok = await oauth.handleCallback(code, state); + res.redirect(`${redirectBase}${sep}spotify=${ok ? "success" : "error"}`); + } catch (err) { + logger.error({ err }, "Spotify OAuth callback failed"); + res.redirect(`${redirectBase}${sep}spotify=error`); + } + }); + + // Whether the (single, process-wide) account is authorized, plus which backend + // + device name are configured — used by the WebUI to show login-needed state. + router.get("/status", requireNotGuest, (_req, res) => { + const info = opts.getBackendInfo(); + res.json({ + authorized: oauth.isAuthorized(), + backend: info.backend, + deviceName: info.deviceName, + }); + }); + + return router; +} diff --git a/src/web/server.ts b/src/web/server.ts index 428db8c..fd47f71 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -19,6 +19,8 @@ import { createUsersRouter } from "./api/users.js"; import { createAuditStore } from "../data/audit.js"; import { createAuditRouter } from "./api/audit.js"; import { createFavoritesRouter } from "./api/favorites.js"; +import { createSpotifyRouter } from "./api/spotify.js"; +import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js"; import { setupWebSocket } from "./websocket.js"; import { createUserStore } from "../data/users.js"; import { createSessionStore } from "../data/sessions.js"; @@ -48,6 +50,9 @@ export interface WebServerOptions { cookieStore?: CookieStore; avatarStore: AvatarStore; staticDir?: string; + /** Process-wide shared Spotify OAuth (single account, Stage 3). When set, the + * /api/spotify {login,callback,status} router is mounted. */ + spotifyOAuth?: SpotifyOAuth; } export interface WebServer { @@ -136,6 +141,20 @@ export function createWebServer(options: WebServerOptions): WebServer { "/api/auth", createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider) ); + if (options.spotifyOAuth) { + app.use( + "/api/spotify", + createSpotifyRouter({ + oauth: options.spotifyOAuth, + logger, + getBackendInfo: () => ({ + backend: options.config.spotify.backend, + deviceName: options.config.spotify.deviceName, + }), + webUiRedirect: "/", + }), + ); + } app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger)); // admin-only routes