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) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-07-03 00:09:12 +08:00
1 parent 322335dfc5
commit 8998b9623f
8 files changed
+363 -2

No files matched your search

+68
View File
@@ -1,6 +1,13 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js"; 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 { 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, // Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field // 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<BotInstanceOptions> = {}): {
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)", () => { describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => {
it("yields the SAME ports for the same bot id (stable across restarts)", () => { it("yields the SAME ports for the same bot id (stable across restarts)", () => {
const a = spotifyPortsForBotId("bot-alpha"); const a = spotifyPortsForBotId("bot-alpha");
+13
View File
@@ -27,6 +27,7 @@ import { isSpotifyUri } from "../music/spotify/webapi.js";
import path from "node:path"; import path from "node:path";
import { SpotifyController } from "../music/spotify/controller.js"; import { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.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. */ /** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -77,6 +78,9 @@ export interface BotInstanceOptions {
avatarStore: AvatarStore; avatarStore: AvatarStore;
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */ /** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
spotifyDataDir?: string; 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. */ /** Test seam: build a fake controller instead of a real go-librespot one. */
spotifyControllerFactory?: (o: { spotifyControllerFactory?: (o: {
config: SpotifyConfig; config: SpotifyConfig;
@@ -85,6 +89,7 @@ export interface BotInstanceOptions {
logger: Logger; logger: Logger;
apiPort: number; apiPort: number;
callbackPort: number; callbackPort: number;
oauth?: SpotifyOAuth;
}) => SpotifyController; }) => SpotifyController;
} }
@@ -181,6 +186,7 @@ export class BotInstance extends EventEmitter {
logger: this.logger, logger: this.logger,
apiPort: spotifyApiPort, apiPort: spotifyApiPort,
callbackPort: spotifyCallbackPort, callbackPort: spotifyCallbackPort,
oauth: options.spotifyOAuth,
}); });
const profileConfig = this.database.getProfileConfig(this.id); const profileConfig = this.database.getProfileConfig(this.id);
@@ -1412,6 +1418,13 @@ export class BotInstance extends EventEmitter {
return this.player; 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 * 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 * external — AudioPlayer.seek would respawn ffmpeg on the `spotify:` sentinel
+66
View File
@@ -9,6 +9,7 @@ import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../dat
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import type { AvatarStore } from "../data/avatars.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. // removeBot only calls logger.info; provide the full shape it could touch.
const stubLogger = { const stubLogger = {
@@ -85,3 +86,68 @@ describe("BotManager.removeBot — guest scope pruning", () => {
expect(loadConfig(configPath).guestMode.bots).toBe("all"); 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();
});
});
+8 -1
View File
@@ -14,6 +14,7 @@ import type { Logger } from "../logger.js";
import type { ServerProtocol } from "../ts-protocol/client.js"; import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js"; import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.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 * 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 kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider; private spotifyProvider: MusicProvider;
private spotifyDataDir: string; private spotifyDataDir: string;
private readonly spotifyOAuth?: SpotifyOAuth;
private database: BotDatabase; private database: BotDatabase;
private config: BotConfig; private config: BotConfig;
private logger: Logger; private logger: Logger;
@@ -99,7 +101,8 @@ export class BotManager extends EventEmitter {
localProvider?: MusicProvider, localProvider?: MusicProvider,
kugouProvider?: MusicProvider, kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider, spotifyProvider?: MusicProvider,
spotifyDataDir?: string spotifyDataDir?: string,
spotifyOAuth?: SpotifyOAuth
) { ) {
super(); super();
this.neteaseProvider = neteaseProvider; this.neteaseProvider = neteaseProvider;
@@ -110,6 +113,7 @@ export class BotManager extends EventEmitter {
this.kugouProvider = kugouProvider ?? neteaseProvider; this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider; this.spotifyProvider = spotifyProvider ?? neteaseProvider;
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify"); this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
this.spotifyOAuth = spotifyOAuth;
// 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<{
@@ -154,6 +158,7 @@ export class BotManager extends EventEmitter {
logger: this.logger, logger: this.logger,
avatarStore: this.avatarStore, avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir, spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
}); });
this.bots.set(id, bot); this.bots.set(id, bot);
@@ -296,6 +301,7 @@ export class BotManager extends EventEmitter {
logger: this.logger, logger: this.logger,
avatarStore: this.avatarStore, avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir, spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
}); });
this.bots.set(id, bot); this.bots.set(id, bot);
this.emit("botInstance", bot); this.emit("botInstance", bot);
@@ -352,6 +358,7 @@ export class BotManager extends EventEmitter {
logger: this.logger, logger: this.logger,
avatarStore: this.avatarStore, avatarStore: this.avatarStore,
spotifyDataDir: this.spotifyDataDir, spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
}); });
this.bots.set(saved.id, bot); this.bots.set(saved.id, bot);
+20 -1
View File
@@ -10,6 +10,7 @@ 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 { SpotifyProvider } from "./music/spotify/provider.js";
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.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";
@@ -83,6 +84,22 @@ async function main() {
const permissions = createPermissionStore(db.db); 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( const botManager = new BotManager(
neteaseProvider, neteaseProvider,
qqProvider, qqProvider,
@@ -96,7 +113,8 @@ async function main() {
localProvider, localProvider,
kugouProvider, kugouProvider,
spotifyProvider, spotifyProvider,
SPOTIFY_DATA_DIR SPOTIFY_DATA_DIR,
spotifyOAuth
); );
await botManager.loadSavedBots(); await botManager.loadSavedBots();
@@ -116,6 +134,7 @@ async function main() {
logger, logger,
cookieStore, cookieStore,
staticDir: STATIC_DIR, staticDir: STATIC_DIR,
spotifyOAuth,
}); });
await webServer.start(); await webServer.start();
+95
View File
@@ -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> = {}): 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);
});
});
+74
View File
@@ -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<boolean>;
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;
}
+19
View File
@@ -19,6 +19,8 @@ import { createUsersRouter } from "./api/users.js";
import { createAuditStore } from "../data/audit.js"; import { createAuditStore } from "../data/audit.js";
import { createAuditRouter } from "./api/audit.js"; import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.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 { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js"; import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js"; import { createSessionStore } from "../data/sessions.js";
@@ -48,6 +50,9 @@ export interface WebServerOptions {
cookieStore?: CookieStore; cookieStore?: CookieStore;
avatarStore: AvatarStore; avatarStore: AvatarStore;
staticDir?: string; 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 { export interface WebServer {
@@ -136,6 +141,20 @@ export function createWebServer(options: WebServerOptions): WebServer {
"/api/auth", "/api/auth",
createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore, options.kugouProvider, options.spotifyProvider) 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)); app.use("/api/favorites", requireNotGuest, createFavoritesRouter(options.database, logger));
// admin-only routes // admin-only routes