diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 3f2e77c..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "permissions": { - "allow": [ - "Read", - "Edit", - "Write", - "Glob", - "Grep", - "Bash(*)", - "WebFetch(*)", - "WebSearch(*)", - "Agent(*)", - "mcp__Claude_Preview__*", - "mcp__Claude_in_Chrome__*", - "mcp__scheduled-tasks__*" - ], - "deny": [ - "Bash(git push --force *)", - "Bash(rm -rf /)" - ] - } -} \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 4f47eff..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "permissions": { - "allow": [ - "Read", - "Edit", - "Write", - "Glob", - "Grep", - "Bash(*)", - "WebFetch(*)", - "WebSearch(*)", - "Agent(*)", - "mcp__Claude_Preview__*", - "mcp__Claude_in_Chrome__*", - "mcp__scheduled-tasks__*", - "Bash(npx vitest:*)", - "Bash(cp \"C:\\\\Users\\\\saopig1\\\\.claude\\\\projects\\\\C--Users-saopig1-Music-teamspeak-music-bot\\\\b5a64d6f-051e-4b87-966c-ece97d2b879b\\\\tool-results\\\\webfetch-1776569008470-exv7qe.bin\" /tmp/design.gz)", - "Bash(gunzip -f /tmp/design.gz)", - "Read(//tmp/**)" - ], - "deny": [ - "Bash(git push --force *)", - "Bash(rm -rf /)" - ] - } -} diff --git a/.gitignore b/.gitignore index 4fef9fa..0cda58a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ config.json cookies/ .superpowers/ .worktrees/ +.claude/ setup.log /bin/ scripts/navbar_bigger.png diff --git a/README.md b/README.md index 30085f8..a92c572 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,20 @@ sudo systemctl start tsmusicbot 在设置页面选择音质,立即生效(影响后续播放的歌曲)。 +> **重启后保留(#125)**:音质选择会持久化到 `data/config.json`(每个平台各自记录),重启机器人后自动恢复,无需每次手动重设。 + +### 重启后保留的播放设置 + +以下运行时设置在改动时自动落盘,重启机器人后自动恢复,不再回到默认值: + +| 设置 | 作用范围 | 存储位置 | +|------|----------|----------| +| **播放音量**(`!vol` / WebUI 音量条 / REST `/volume`) | 每个机器人独立 | 数据库 `bot_instances.volume` | +| **播放模式**(`!mode` / WebUI / REST `/mode`:顺序 / 列表循环 / 随机 / 随机循环) | 每个机器人独立 | 数据库 `bot_instances.play_mode` | +| **音质**(各平台,WebUI 设置页 / REST `/quality`) | 全局(各平台各自记录) | `data/config.json` 的 `audioQuality` | + +聊天命令、WebUI、REST API 三种入口的改动都会被持久化。播放队列、当前歌曲、进度、`!fm` / `!artist` 等临时播放状态仍为一次性状态,重启后不保留(`!fm` / `!artist` 内部临时切换的随机 / 循环也**不会**覆盖你用 `!mode` 显式保存的偏好)。 + ## 项目架构 ``` @@ -558,9 +572,10 @@ teamspeak-music-bot/ - 可选值:`jellyfin`、`netease`、`qq`、`bilibili`、`youtube`、`kugou`(`local` 由 `localAudioEnabled` 控制,`spotify` 由 `spotify.enabled` 控制) - 未列出的音源:聊天命令返回「音源未启用」、REST 返回 400、WebUI 搜索栏 / 登录卡 / FM 卡片自动隐藏 -- 不带平台标志的命令走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云) +- 不带平台标志的命令默认走**固定优先级中第一个已启用的音源**:网易云 → QQ → 酷狗 → Jellyfin → B站 → YouTube(默认配置下即网易云) +- **自定义默认音源(`defaultPlatform`)** — 想让不带标志的 `!play 歌名` 直接用某个音源(例如常听哔哩哔哩,免去每次加 `-b`),可在 设置 → 默认音源 里选择,或在 `config.json` 中设置 `"defaultPlatform": "bilibili"`。取值须是 `enabledProviders` 里已启用的音源,否则被忽略(回退到上面的固定优先级);留空 / `null` / 删除该字段即恢复固定优先级。WebUI 保存后即时生效,无需重启 - 网易云 / QQ 停用时,其内嵌 API 服务(端口 3001 / 3200)**不会启动** -- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(此时默认音源仍为网易云,点歌用 `-j` 或停用网易云);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]` +- 示例(Jellyfin 为主、只留网易云备用):`"enabledProviders": ["jellyfin", "netease"]`(默认音源仍为网易云,点歌用 `-j`、停用网易云,或直接把 `defaultPlatform` 设为 `"jellyfin"`);示例(纯 Jellyfin):`"enabledProviders": ["jellyfin"]` - 注意:重新启用网易云 / QQ 的内嵌 API 服务需要重启机器人;其余音源改动即时生效(WebUI 的 Jellyfin 开关即改此列表) ## 可选:YouTube 音源 @@ -795,10 +810,13 @@ A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指 A:可以。在设置页面创建多个实例,分别连接不同的 TS 服务器或频道。 **Q:端口 3200 被占用?** -A:QQ 音乐 API 启动时自动监听 3200 端口。如果之前的进程还在运行,程序会自动复用。如需重启可手动结束 `node` 进程。 +A:QQ 音乐 API 启动时会监听 `config.json` 里的 `qqMusicApiPort`(默认 **3200**),客户端也用同一个端口发请求,二者始终一致。如果之前的进程还在运行,程序会自动复用。如需改端口,改 `qqMusicApiPort` 后重启即可;如需重启可手动结束 `node` 进程。 + +**Q:日志里 `baseURL` 是 3200,但 QQ API 实际监听在 3300?(二维码不弹)** +A:这是**旧版本**(或过期的 `latest` Docker 镜像)才有的问题:早期实现用的上游包默认端口是 3300,而客户端 `baseURL` 已经是 3200,两边对不上,取二维码时就 `ECONNREFUSED 127.0.0.1:3200`。当前版本已把内嵌 QQ 音乐 API **强制绑定到 `qqMusicApiPort`(默认 3200)**,并在启动前把上游包读取的 `PORT` 环境变量对齐到该端口,二者不可能再错位。修复方法:**拉取最新镜像并重启**(`docker compose pull && docker compose up -d`),或用 `npm ci && npm run build` 更新到最新代码。启动后可在日志里确认那行 `QQ Music API started`,其 `port` 字段就是实际监听端口。 **Q:QQ 音乐二维码不弹 / 扫码登录失败 / cookie 无法使用?** -A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 3200 端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: +A:通常是内置的 QQ 音乐 API 服务没起来——它一旦没监听 `qqMusicApiPort`(默认 3200)端口,机器人去取二维码就会拿到 `ECONNREFUSED 127.0.0.1:3200`,于是二维码不显示,登录和 cookie 也全失效。先看日志里 QQ API 的启动报错: - 报 `ERR_REQUIRE_ESM`:装到了不兼容的 `@sansenjian/qq-music-api` 版本。本项目把它锁在 **`~2.4.0`**(需要 **Node ≥ 20.17 / 22.9**);务必用 `npm ci` 或 `npm install` 让版本与锁文件一致,**不要**手动 `npm update` 把它升级或降级到不兼容的中间版本(2.3.0/2.3.1 是纯 ESM、会触发此错)。 - 报 Node 版本不满足:升级 Node 到 ≥ 20.17,或将该依赖降到 `~2.2.10`(无此 Node 要求)后重装。 修好版本后重新 `npm install && npm run build` 并重启即可。 @@ -907,6 +925,7 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部 - **会话存储**:服务端 SQLite 表 `sessions`,存储 sha256(token);浏览器只持有原始 token cookie。7 天 TTL,每小时滚动续期。同账号最多 10 个并发会话(超出剔除最旧)。 - **登录限流**:每 IP 每分钟 5 次 `/login` + 3 次 `/setup`,命中返回 429 + `Retry-After`。 - **CSRF & 安全头**:所有 mutating 请求强制 `Origin`/`Referer` 同源;响应携带 `X-Frame-Options: DENY` 和 `Content-Security-Policy: frame-ancestors 'none'`(防点击劫持)。 +- **搜索引擎隐身(防止实例被收录,issue #128)**:为避免部署实例的 WebUI 被搜索引擎收录、被陌生人搜到控制页,采用纵深防御——所有响应携带 `X-Robots-Tag: noindex, nofollow`,`/robots.txt` 返回 `User-agent: * / Disallow: /`,`index.html` 内置 ``(专属链接 `/bot/` 等所有页面同样覆盖)。这些只阻止「被索引」,不是访问控制——**请不要把自己的 WebUI 链接发到公开网页 / 论坛 / 聊天群**,真正的防护来自登录鉴权与反向代理。 - **配置变更**:反向代理部署务必 `"trustProxy": true`(详见 [反向代理部署注意事项](#反向代理部署注意事项))。`config.adminGroups` 现已启用,用于限制管理类聊天命令只能由指定 TeamSpeak 服务器组运行(为空 = 不限制,详见 [TeamSpeak 命令权限](#teamspeak-命令权限管理类命令限制));`config.adminPassword` 仍为旧版预留字段,保留以兼容旧 `config.json`,当前未使用。 ### v0.x — Bot Profile 自动更新与协议层升级 diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index 7f2feba..834b253 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -835,6 +835,9 @@ describe("BotInstance — spotifyOAuth threading to the controller factory (C3.1 const database = { getProfileConfig: () => ({}), getCustomAvatarPath: () => null, + getPlayerSettings: () => ({ volume: 75, playMode: "seq" }), + saveVolume: () => {}, + savePlayMode: () => {}, } as unknown as BotDatabase; const options: BotInstanceOptions = { id: "bot-oauth-test", @@ -915,6 +918,136 @@ describe("spotifyPortsForBotId — per-bot go-librespot ports (Fix 3)", () => { }); }); +// --- Persisting volume + play mode across restarts (#125) ------------------ +const cmdVol = (BotInstance.prototype as any).cmdVol as (this: unknown, cmd: any) => string; +const cmdMode = (BotInstance.prototype as any).cmdMode as (this: unknown, cmd: any) => string; + +describe("BotInstance.cmdVol — persistence (#125)", () => { + function makeVolCtx() { + let stored = 75; + return { + id: "bot1", + player: { + setVolume: vi.fn((v: number) => { stored = v; }), + getVolume: vi.fn(() => stored), + }, + database: { saveVolume: vi.fn() }, + logger: { warn: vi.fn() }, + emit: vi.fn(), + // The real private persist helper lives on the prototype; wire it so the + // test exercises the shipped persistence path end-to-end. + persistVolume: (BotInstance.prototype as any).persistVolume, + } as any; + } + + it("saves the new volume via database.saveVolume (covers chat !vol AND the REST endpoint)", () => { + const ctx = makeVolCtx(); + const res = cmdVol.call(ctx, { args: "40" }); + expect(res).toBe("Volume set to 40%"); + expect(ctx.player.setVolume).toHaveBeenCalledWith(40); + expect(ctx.database.saveVolume).toHaveBeenCalledWith("bot1", 40); + expect(ctx.emit).toHaveBeenCalledWith("stateChange"); + }); + + it("does not persist an out-of-range volume", () => { + const ctx = makeVolCtx(); + const res = cmdVol.call(ctx, { args: "999" }); + expect(res).toBe("Usage: !vol <0-100>"); + expect(ctx.player.setVolume).not.toHaveBeenCalled(); + expect(ctx.database.saveVolume).not.toHaveBeenCalled(); + }); + + it("swallows a database error so the volume change still succeeds", () => { + const ctx = makeVolCtx(); + ctx.database.saveVolume = vi.fn(() => { throw new Error("disk full"); }); + const res = cmdVol.call(ctx, { args: "50" }); + expect(res).toBe("Volume set to 50%"); + expect(ctx.player.setVolume).toHaveBeenCalledWith(50); + expect(ctx.logger.warn).toHaveBeenCalled(); + }); +}); + +describe("BotInstance.cmdMode — persistence (#125)", () => { + function makeModeCtx() { + let mode = "seq"; + return { + id: "bot1", + queue: { + setMode: vi.fn((m: string) => { mode = m; }), + getMode: vi.fn(() => mode), + }, + database: { savePlayMode: vi.fn() }, + logger: { warn: vi.fn() }, + emit: vi.fn(), + persistPlayMode: (BotInstance.prototype as any).persistPlayMode, + } as any; + } + + it("saves the new play mode via database.savePlayMode", () => { + const ctx = makeModeCtx(); + const res = cmdMode.call(ctx, { args: "rloop" }); + expect(res).toBe("Play mode set to: rloop"); + expect(ctx.queue.setMode).toHaveBeenCalledWith("rloop"); + expect(ctx.database.savePlayMode).toHaveBeenCalledWith("bot1", "rloop"); + expect(ctx.emit).toHaveBeenCalledWith("stateChange"); + }); + + it("does not persist an unknown mode", () => { + const ctx = makeModeCtx(); + const res = cmdMode.call(ctx, { args: "bogus" }); + expect(res).toBe("Usage: !mode "); + expect(ctx.queue.setMode).not.toHaveBeenCalled(); + expect(ctx.database.savePlayMode).not.toHaveBeenCalled(); + }); +}); + +describe("BotInstance — restores persisted player settings on construction (#125)", () => { + const provider = { platform: "netease" } as unknown as MusicProvider; + function makeOptions(id: string, database: BotDatabase): BotInstanceOptions { + const logger: any = { info() {}, warn() {}, error() {}, debug() {}, child() { return logger; } }; + return { + id, + name: "RestoreBot", + tsOptions: { host: "localhost", port: 9987, queryPort: 10011, nickname: "RestoreBot" } 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: () => ({ on: () => {} } as unknown as SpotifyController), + }; + } + + it("applies the saved volume + play mode from the database", () => { + const db = createDatabase(":memory:"); + db.saveBotInstance({ + id: "bot-restore", name: "B", serverAddress: "x", serverPort: 9987, nickname: "n", + defaultChannel: "", channelId: "", channelPassword: "", autoStart: false, + serverProtocol: "", ts6ApiKey: "", serverPassword: "", + }); + db.saveVolume("bot-restore", 33); + db.savePlayMode("bot-restore", "loop"); + + const bot = new BotInstance(makeOptions("bot-restore", db)); + const status = bot.getStatus(); + expect(status.volume).toBe(33); + expect(status.playMode).toBe("loop"); + db.close(); + }); + + it("falls back to defaults for a bot with no saved settings", () => { + const db = createDatabase(":memory:"); + const bot = new BotInstance(makeOptions("brand-new", db)); + const status = bot.getStatus(); + expect(status.volume).toBe(75); + expect(status.playMode).toBe("seq"); + db.close(); + }); +}); + describe("BotInstance.handleTextMessage — response chunking (#116)", () => { it("splits a long command response into multiple sends, each under the byte cap", async () => { const ctx = makeGateCtx({ adminGroups: [] }); diff --git a/src/bot/instance.ts b/src/bot/instance.ts index ebd8e41..3efdd34 100755 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -39,6 +39,15 @@ 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 = "⛔ 需要管理员权限(该命令仅限管理员服务器组)"; +/** Maps the persisted / command-line play-mode string to the PlayMode enum. + * Shared by the !mode command and the restart-restore path (#125). */ +const PLAY_MODE_BY_VALUE: Record = { + seq: PlayMode.Sequential, + loop: PlayMode.Loop, + random: PlayMode.Random, + rloop: PlayMode.RandomLoop, +}; + /** Fallback message when Spotify audio can't be served (backend unavailable * OR a per-track playTrack failure against a dead/failed sidecar). */ const SPOTIFY_UNAVAILABLE_MESSAGE = @@ -190,6 +199,19 @@ export class BotInstance extends EventEmitter { this.player = new AudioPlayer(this.logger); this.queue = new PlayQueue(); + // Restore persisted per-bot player settings (#125): volume + play mode + // survive restarts. getPlayerSettings returns validated values (the in-memory + // defaults when the row/column is absent), so this is a harmless no-op for a + // brand-new bot and reproduces the saved state for an existing one. + try { + const settings = this.database.getPlayerSettings(this.id); + this.player.setVolume(settings.volume); + const restoredMode = PLAY_MODE_BY_VALUE[settings.playMode]; + if (restoredMode) this.queue.setMode(restoredMode); + } catch (err) { + this.logger.warn({ err }, "Failed to restore player settings — using defaults"); + } + // Structural typing (like localProvider.sweepUnreferenced): only the real // JellyfinProvider exposes createPlaybackReporter, so the netease fallback // provider simply leaves reporting off. @@ -1239,10 +1261,36 @@ export class BotInstance extends EventEmitter { const vol = parseInt(cmd.args, 10); if (isNaN(vol) || vol < 0 || vol > 100) return "Usage: !vol <0-100>"; this.player.setVolume(vol); + // Persist so the volume survives a restart (#125). Both the chat !vol command + // and the WebUI/REST volume endpoint funnel through here, so one write covers + // every entry point. Only volume is written — play mode is saved independently. + this.persistVolume(); this.emit("stateChange"); return `Volume set to ${vol}%`; } + /** Persist the current volume (#125). Best-effort: a DB error must never break + * the volume change itself. */ + private persistVolume(): void { + try { + this.database.saveVolume(this.id, this.player.getVolume()); + } catch (err) { + this.logger.warn({ err }, "Failed to persist volume"); + } + } + + /** Persist the current play mode (#125). Best-effort, mirrors persistVolume. + * Called ONLY from the explicit !mode command — NOT from FM/artist mode, whose + * Random/Loop switch is a transient side effect that must not overwrite the + * user's saved preference. */ + private persistPlayMode(): void { + try { + this.database.savePlayMode(this.id, this.queue.getMode()); + } catch (err) { + this.logger.warn({ err }, "Failed to persist play mode"); + } + } + private cmdNow(): string { const song = this.queue.current(); if (!song) return "Nothing is playing"; @@ -1308,15 +1356,12 @@ export class BotInstance extends EventEmitter { } private cmdMode(cmd: ParsedCommand): string { - const modeMap: Record = { - seq: PlayMode.Sequential, - loop: PlayMode.Loop, - random: PlayMode.Random, - rloop: PlayMode.RandomLoop, - }; - const mode = modeMap[cmd.args]; + const mode = PLAY_MODE_BY_VALUE[cmd.args]; if (mode === undefined) return "Usage: !mode "; this.queue.setMode(mode); + // Persist so the play mode survives a restart (#125). The chat !mode command + // and the WebUI/REST mode endpoint both funnel through here. + this.persistPlayMode(); this.emit("stateChange"); return `Play mode set to: ${cmd.args}`; } diff --git a/src/data/config.test.ts b/src/data/config.test.ts index deb0d1c..119aca6 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -82,6 +82,61 @@ describe("config", () => { expect(defaultPlatform(config)).toBe("netease"); }); + // --- #126: an explicit operator default source --- + + it("defaultPlatform is null by default (follow the priority order)", () => { + expect(getDefaultConfig().defaultPlatform).toBeNull(); + }); + + it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => { + const config = getDefaultConfig(); + // Priority would pick netease; a Bilibili-loving server sets B站 instead (#126). + config.defaultPlatform = "bilibili"; + expect(defaultPlatform(config)).toBe("bilibili"); + }); + + it("defaultPlatform() ignores a preference whose source is not enabled", () => { + const config = getDefaultConfig(); + config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config + // Falls back to the fixed priority order (netease)… + expect(defaultPlatform(config)).toBe("netease"); + // …until the preferred source is actually enabled. + config.enabledProviders = [...config.enabledProviders, "jellyfin"]; + expect(defaultPlatform(config)).toBe("jellyfin"); + }); + + it("loadConfig keeps a valid, enabled defaultPlatform", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" })); + const config = loadConfig(path); + expect(config.defaultPlatform).toBe("bilibili"); + expect(defaultPlatform(config)).toBe("bilibili"); + }); + + it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => { + const dir = makeTmpDir(); + // Unknown provider name. + const p1 = join(dir, "c1.json"); + writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" })); + expect(loadConfig(p1).defaultPlatform).toBeNull(); + // Known provider, but not in enabledProviders. + const p2 = join(dir, "c2.json"); + writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" })); + expect(loadConfig(p2).defaultPlatform).toBeNull(); + // Wrong type. + const p3 = join(dir, "c3.json"); + writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 })); + expect(loadConfig(p3).defaultPlatform).toBeNull(); + }); + + it("round-trips defaultPlatform through save/load", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" }); + expect(loadConfig(path).defaultPlatform).toBe("qq"); + }); + it("respects an explicit jellyfin-only enabledProviders from disk", () => { const dir = makeTmpDir(); const path = join(dir, "config.json"); @@ -92,6 +147,60 @@ describe("config", () => { expect(defaultPlatform(config)).toBe("jellyfin"); }); + // ── audioQuality persistence (#125) ───────────────────────────────────── + it("defaults audioQuality to each provider's in-memory default", () => { + const config = getDefaultConfig(); + expect(config.audioQuality).toEqual({ + netease: "exhigh", + qq: "exhigh", + bilibili: "high", + kugou: "128", + jellyfin: "direct", + }); + }); + + it("fills audioQuality defaults for a legacy config without the field", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + writeFileSync(path, JSON.stringify({ webPort: 4000 })); + const config = loadConfig(path); + expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality); + }); + + it("round-trips a saved audioQuality through save/load", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + const config = getDefaultConfig(); + config.audioQuality = { + netease: "lossless", + qq: "flac", + bilibili: "high", + kugou: "flac", + jellyfin: "320", + }; + saveConfig(path, config); + const loaded = loadConfig(path); + expect(loaded.audioQuality).toEqual(config.audioQuality); + }); + + it("coerces missing / non-string audioQuality fields to defaults", () => { + const dir = makeTmpDir(); + const path = join(dir, "config.json"); + // netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid. + writeFileSync( + path, + JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }), + ); + const config = loadConfig(path); + expect(config.audioQuality).toEqual({ + netease: "lossless", + qq: "exhigh", // blank → default + bilibili: "high", // non-string → default + kugou: "128", // missing → default + jellyfin: "192", + }); + }); + it("creates config file on save", () => { const dir = makeTmpDir(); const path = join(dir, "sub", "config.json"); diff --git a/src/data/config.ts b/src/data/config.ts index 4b6aadf..baf671c 100755 --- a/src/data/config.ts +++ b/src/data/config.ts @@ -38,6 +38,22 @@ export interface JellyfinConfig { userId: string; } +/** + * Per-provider audio quality (音质), persisted so a restart keeps the user's + * choice instead of resetting each provider to its in-memory default (#125). + * The values are the same strings the WebUI/REST `POST /api/music/quality` + * endpoint sends and each provider's setQuality() accepts; on startup they are + * replayed onto the (shared, process-wide) providers. Providers ignore/normalize + * unknown values, so a stale/hand-edited entry can never break playback. + */ +export interface AudioQualityConfig { + netease: string; + qq: string; + bilibili: string; + kugou: string; + jellyfin: string; +} + /** * Providers gated by `enabledProviders`. Not listed here: * - "local" → governed by the existing `localAudioEnabled` flag @@ -61,15 +77,24 @@ export function isProviderEnabled(config: BotConfig, platform: string): boolean } /** - * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls: - * the first enabled provider in a fixed priority order (netease with the default - * config; jellyfin ranks after the online music platforms because it is an - * opt-in source, but ahead of the video sites for users who run it as their - * only music library). Falls back to "netease" when nothing is enabled so - * callers always get a provider — the enabled-gate then produces the friendly - * error. + * The default platform for !play/!add/!playlist/!album and all REST/WebUI calls. + * + * An explicit user preference (`config.defaultPlatform`) wins whenever it points + * at a source that is currently enabled — this lets e.g. a Bilibili-loving server + * set B站 as the default so `!play <歌名>` needs no `-b` flag (issue #126). The + * enabled-guard here matters at runtime too: if the operator later disables the + * preferred source, we must fall through instead of returning a dead default. + * + * With no (usable) preference we fall back to the first enabled provider in a + * fixed priority order (netease with the default config; jellyfin ranks after + * the online music platforms because it is an opt-in source, but ahead of the + * video sites for users who run it as their only music library). Falls back to + * "netease" when nothing is enabled so callers always get a provider — the + * enabled-gate then produces the friendly error. */ export function defaultPlatform(config: BotConfig): GateableProvider { + const pref = config.defaultPlatform; + if (pref && config.enabledProviders.includes(pref)) return pref; for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) { if (config.enabledProviders.includes(p)) return p; } @@ -115,6 +140,8 @@ export interface BotConfig { guestMode: GuestModeConfig; spotify: SpotifyConfig; jellyfin: JellyfinConfig; + /** Persisted per-provider audio quality (音质), restored on startup (#125). */ + audioQuality: AudioQualityConfig; /** * Which gateable providers are active (see GATEABLE_PROVIDERS). Default is * the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an @@ -123,6 +150,14 @@ export interface BotConfig { * API servers must not start (or bind ports 3001/3200) unless enabled. */ enabledProviders: GateableProvider[]; + /** + * Optional operator-chosen default source for commands/REST/WebUI calls that + * omit a platform (issue #126). When set to an enabled gateable provider it + * overrides the fixed priority order in defaultPlatform(); `null` (the default) + * keeps that priority order. loadConfig cleans stale/unknown/disabled values + * back to null. + */ + defaultPlatform: GateableProvider | null; } export function getDefaultConfig(): BotConfig { @@ -177,7 +212,17 @@ export function getDefaultConfig(): BotConfig { apiKey: "", userId: "", }, + // Mirrors each provider's own in-memory default quality; overwritten on + // startup once the user has changed a quality (persisted via #125). + audioQuality: { + netease: "exhigh", + qq: "exhigh", + bilibili: "high", + kugou: "128", + jellyfin: "direct", + }, enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"], + defaultPlatform: null, }; } @@ -338,6 +383,32 @@ export function loadConfig(path: string): BotConfig { const savedQueuesEnabled = partial.savedQueuesEnabled === true; const playKeepsQueue = partial.playKeepsQueue === true; + // defaultPlatform → an explicit operator default (issue #126). Keep it only + // when it names a KNOWN gateable provider that is ALSO currently enabled; + // anything else (unknown value, disabled source, wrong type, missing) becomes + // null so defaultPlatform() falls back to the fixed priority order. + const rawDefault = partial.defaultPlatform; + const defaultPlatformPref: GateableProvider | null = + typeof rawDefault === "string" && + (GATEABLE_PROVIDERS as readonly string[]).includes(rawDefault) && + enabledProviders.includes(rawDefault as GateableProvider) + ? (rawDefault as GateableProvider) + : null; + + // audioQuality → per-provider strings; each field falls back to its default + // when missing/blank/non-string (a hand-edited/legacy config must never smuggle + // a non-string past the gate — the value is fed straight to provider.setQuality). + const partialAq = (partial.audioQuality ?? {}) as Partial; + const coerceQuality = (v: unknown, fallback: string): string => + typeof v === "string" && v.trim() ? v : fallback; + const audioQuality: AudioQualityConfig = { + netease: coerceQuality(partialAq.netease, defaults.audioQuality.netease), + qq: coerceQuality(partialAq.qq, defaults.audioQuality.qq), + bilibili: coerceQuality(partialAq.bilibili, defaults.audioQuality.bilibili), + kugou: coerceQuality(partialAq.kugou, defaults.audioQuality.kugou), + jellyfin: coerceQuality(partialAq.jellyfin, defaults.audioQuality.jellyfin), + }; + return { ...defaults, ...partial, @@ -345,9 +416,11 @@ export function loadConfig(path: string): BotConfig { guestMode: gm, spotify, jellyfin, + audioQuality, enabledProviders, savedQueuesEnabled, playKeepsQueue, + defaultPlatform: defaultPlatformPref, }; } } diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 381b75c..b03ff1e 100644 --- a/src/data/database.test.ts +++ b/src/data/database.test.ts @@ -131,6 +131,92 @@ describe("database", () => { expect(botDb.deleteBotInstance("nonexistent")).toBe(false); }); + it("persists and restores per-bot player settings (volume + play mode) (#125)", () => { + const inst = { + id: "bot-ps", + name: "B", + serverAddress: "x", + serverPort: 9987, + nickname: "n", + defaultChannel: "", + channelId: "", + channelPassword: "", + autoStart: false, + serverProtocol: "", + ts6ApiKey: "", + serverPassword: "", + }; + botDb.saveBotInstance(inst); + + // Fresh row → in-memory defaults. + expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" }); + + // Volume and play mode persist independently. + botDb.saveVolume("bot-ps", 42); + expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" }); + botDb.savePlayMode("bot-ps", "rloop"); + expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" }); + + // A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them. + botDb.saveBotInstance({ ...inst, autoStart: true }); + expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" }); + }); + + it("defaults player settings for an unknown bot and validates inputs (#125)", () => { + // No row → defaults. + expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" }); + + botDb.saveBotInstance({ + id: "bot-v", + name: "B", + serverAddress: "x", + serverPort: 9987, + nickname: "n", + defaultChannel: "", + channelId: "", + channelPassword: "", + autoStart: false, + serverProtocol: "", + ts6ApiKey: "", + serverPassword: "", + }); + // Out-of-range volume is clamped; an unknown play mode is ignored (not stored). + botDb.saveVolume("bot-v", 250); + expect(botDb.getPlayerSettings("bot-v").volume).toBe(100); + botDb.saveVolume("bot-v", -10); + expect(botDb.getPlayerSettings("bot-v").volume).toBe(0); + botDb.savePlayMode("bot-v", "bogus"); + expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq"); + }); + + it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => { + const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-")); + const p = join(dir, "legacy.db"); + // Build a minimal pre-#125 bot_instances table (no volume/play_mode columns). + const legacy = createDatabase(p); + legacy.db.exec("DROP TABLE bot_instances"); + legacy.db.exec(`CREATE TABLE bot_instances ( + id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL, + serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL, + channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL, + autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '', + ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT + )`); + legacy.db + .prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')") + .run("legacy-bot"); + legacy.close(); + + // Reopen → migrateSchema adds the columns; the old row gets the defaults. + const reopened = createDatabase(p); + const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name); + expect(cols).toContain("volume"); + expect(cols).toContain("play_mode"); + expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" }); + reopened.close(); + rmSync(dir, { recursive: true, force: true }); + }); + it("persists and clears customAvatarPath on a bot instance", () => { const inst = { id: "bot-1", diff --git a/src/data/database.ts b/src/data/database.ts index 51cc805..595913f 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -95,6 +95,26 @@ export const DEFAULT_PROFILE_CONFIG: ProfileConfig = { nowPlayingMsgEnabled: true, }; +/** + * Per-bot player settings persisted across restarts (#125): the playback volume + * and play mode. These reset to defaults on process restart when kept only in + * memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row — + * exactly like the per-bot profile flags — and restored when the bot is (re)built. + */ +export interface PlayerSettings { + /** 0-100. */ + volume: number; + /** PlayMode string: "seq" | "loop" | "random" | "rloop". */ + playMode: string; +} + +const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]); + +export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = { + volume: 75, + playMode: "seq", +}; + export interface FavoritePlaylist { id: number; userId: string; @@ -115,6 +135,9 @@ export interface BotDatabase { deleteBotInstance(id: string): boolean; getProfileConfig(botId: string): ProfileConfig; saveProfileConfig(botId: string, config: ProfileConfig): void; + getPlayerSettings(botId: string): PlayerSettings; + saveVolume(botId: string, volume: number): void; + savePlayMode(botId: string, playMode: string): void; getCustomAvatarPath(botId: string): string | null; setCustomAvatarPath(botId: string, path: string | null): void; addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void; @@ -168,6 +191,15 @@ function migrateSchema(db: Database.Database): void { if (!names.includes("custom_avatar_path")) { db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT"); } + // Per-bot persisted player settings (#125): volume + play mode. Defaults match + // AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving + // exactly as before until the user changes them. + if (!names.includes("volume")) { + db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75"); + } + if (!names.includes("play_mode")) { + db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'"); + } const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; const userColNames = userColumns.map((c) => c.name); @@ -210,6 +242,8 @@ function initTables(db: Database.Database): void { serverProtocol TEXT NOT NULL DEFAULT '', ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', + volume INTEGER NOT NULL DEFAULT 75, + play_mode TEXT NOT NULL DEFAULT 'seq', identity TEXT ); @@ -392,6 +426,12 @@ export function createDatabase(dbPath: string): BotDatabase { WHERE id = @id `); + const selectPlayerSettings = db.prepare( + `SELECT volume, play_mode FROM bot_instances WHERE id = ?`, + ); + const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`); + const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`); + const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`); const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`); @@ -538,6 +578,36 @@ export function createDatabase(dbPath: string): BotDatabase { }); }, + getPlayerSettings(botId) { + const row = selectPlayerSettings.get(botId) as + | { volume: number | null; play_mode: string | null } + | undefined; + if (!row) return { ...DEFAULT_PLAYER_SETTINGS }; + // Coerce/validate: clamp volume to 0-100 and fall back to defaults for any + // NULL / out-of-range / unknown value (a hand-edited DB must never feed a + // bad value into AudioPlayer.setVolume / PlayQueue.setMode). + const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume; + const volume = Number.isFinite(rawVol) + ? Math.max(0, Math.min(100, Math.round(rawVol))) + : DEFAULT_PLAYER_SETTINGS.volume; + const playMode = + typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode) + ? row.play_mode + : DEFAULT_PLAYER_SETTINGS.playMode; + return { volume, playMode }; + }, + + saveVolume(botId, volume) { + const clamped = Math.max(0, Math.min(100, Math.round(volume))); + updateVolume.run(clamped, botId); + }, + + savePlayMode(botId, playMode) { + // Persist only recognized modes so a bad value can never poison the row. + if (!PLAY_MODES.has(playMode)) return; + updatePlayMode.run(playMode, botId); + }, + getCustomAvatarPath(botId) { const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined; return row?.custom_avatar_path ?? null; diff --git a/src/index.ts b/src/index.ts index 008a318..d7d14d1 100755 --- a/src/index.ts +++ b/src/index.ts @@ -100,6 +100,15 @@ async function main() { if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth); jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized)); + // Restore the persisted per-provider audio quality (#125) onto the shared, + // process-wide providers so a restart keeps the user's choice. setQuality() + // normalizes/ignores unknown values, so a stale entry can never break playback. + neteaseProvider.setQuality(config.audioQuality.netease); + qqProvider.setQuality(config.audioQuality.qq); + bilibiliProvider.setQuality(config.audioQuality.bilibili); + kugouProvider.setQuality(config.audioQuality.kugou); + jellyfinProvider.setQuality(config.audioQuality.jellyfin); + const permissions = createPermissionStore(db.db); // Single process-wide Spotify authorization (one Premium account for Stage 3). diff --git a/src/music/api-server.test.ts b/src/music/api-server.test.ts index c0309ce..6b8067d 100644 --- a/src/music/api-server.test.ts +++ b/src/music/api-server.test.ts @@ -1,5 +1,34 @@ -import { describe, it, expect } from "vitest"; -import { describeQqApiStartupError } from "./api-server.js"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createApiServerManager, describeQqApiStartupError } from "./api-server.js"; +import type { Logger } from "../logger.js"; + +// Record every listen() the QQ sidecar makes so we can assert it is always +// pinned to the configured port (regression coverage for issue #122). +const mockState = vi.hoisted(() => ({ + listenCalls: [] as Array<{ port: number; host: string }>, +})); + +vi.mock("@sansenjian/qq-music-api", () => { + const app = { + listen(port: number, host: string, cb?: () => void) { + mockState.listenCalls.push({ port, host }); + const server = { + address: () => ({ port, address: host, family: "IPv4" as const }), + on() { + return server; + }, + close(done?: () => void) { + done?.(); + }, + }; + // Real net/Koa fire the listening callback on a later tick, after the + // caller has captured the returned server handle. + if (cb) setImmediate(cb); + return server; + }, + }; + return { default: app }; +}); describe("describeQqApiStartupError", () => { it("flags ERR_REQUIRE_ESM by error code with version-pin guidance", () => { @@ -28,3 +57,77 @@ describe("describeQqApiStartupError", () => { expect(describeQqApiStartupError(null)).toBeNull(); }); }); + +// Regression coverage for issue #122: the QQ Music API sidecar must listen on +// the same port the client base URL targets (config.qqMusicApiPort). A stale +// build once bound 3300 while the client requested 3200, silently breaking the +// QQ login QR / search flow with ECONNREFUSED on 127.0.0.1:3200. +describe("createApiServerManager — QQ sidecar port binding", () => { + const noopLogger = { + info() {}, + warn() {}, + error() {}, + debug() {}, + trace() {}, + fatal() {}, + } as unknown as Logger; + + beforeEach(() => { + mockState.listenCalls = []; + }); + + it("listens on the configured qqMusicPort and exposes a matching base URL", async () => { + const port = 39217; // uncommon port to avoid clashing with a real instance + const manager = createApiServerManager( + { neteasePort: 39218, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + await manager.start(); + manager.stop(); + + expect(manager.getQQMusicBaseUrl()).toBe(`http://127.0.0.1:${port}`); + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + }); + + it("follows qqMusicPort — not an injected PORT — and restores PORT afterwards", async () => { + const port = 39219; + const previous = process.env.PORT; + // Simulate a hosting platform / compose file injecting a stray PORT that + // must NOT leak into the QQ sidecar's chosen port. + process.env.PORT = "39999"; + const manager = createApiServerManager( + { neteasePort: 39220, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // The sidecar follows qqMusicPort, never the injected PORT. + expect(mockState.listenCalls).toEqual([{ port, host: "127.0.0.1" }]); + // The injected PORT is restored so nothing else in the process is affected. + expect(process.env.PORT).toBe("39999"); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); + + it("leaves an absent PORT env unset after importing the sidecar", async () => { + const port = 39221; + const previous = process.env.PORT; + delete process.env.PORT; + const manager = createApiServerManager( + { neteasePort: 39222, qqMusicPort: port, neteaseEnabled: false, qqEnabled: true }, + noopLogger + ); + try { + await manager.start(); + // Was unset before importing — must be unset again, no leaked override. + expect(process.env.PORT).toBeUndefined(); + } finally { + manager.stop(); + if (previous === undefined) delete process.env.PORT; + else process.env.PORT = previous; + } + }); +}); diff --git a/src/music/api-server.ts b/src/music/api-server.ts index 08fe4b8..1374268 100644 --- a/src/music/api-server.ts +++ b/src/music/api-server.ts @@ -114,7 +114,25 @@ export function createApiServerManager( "QQ Music API port already in use — reusing existing instance" ); } else { - const qqModule = (await import("@sansenjian/qq-music-api")) as any; + // Pin the upstream server to the configured port before importing. + // The package derives its default port from process.env.PORT (falling + // back to 3200) and, in some historical versions, auto-started that + // server as an import side effect. Aligning PORT with qqMusicApiPort + // guarantees the sidecar can never bind a different port than the one + // the client base URL (getQQMusicBaseUrl) targets — the root cause of + // issue #122, where an old build listened on 3300 while the client + // requested 3200. Restore the previous value right after import so we + // never leak the override into the rest of the process (e.g. the web + // server or the NetEase sidecar, which also read PORT as a fallback). + const prevPortEnv = process.env.PORT; + process.env.PORT = String(options.qqMusicPort); + let qqModule: any; + try { + qqModule = (await import("@sansenjian/qq-music-api")) as any; + } finally { + if (prevPortEnv === undefined) delete process.env.PORT; + else process.env.PORT = prevPortEnv; + } // The module's export structure varies between versions: // 2.2.11+: default → Koa app (has .listen) // 2.2.10: default → wrapper object whose .default is the Koa app @@ -124,16 +142,34 @@ export function createApiServerManager( ? candidate : candidate.default ?? null; if (koaApp && typeof koaApp.listen === "function") { - qqMusicServer = await new Promise((resolve, reject) => { - const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => - resolve(srv) + // A version that auto-started on import has already bound the + // configured port (thanks to the PORT alignment above); reuse it + // rather than racing a second listen that would fail EADDRINUSE. + const stillFree = await isPortFree(options.qqMusicPort); + if (!stillFree) { + logger.info( + { port: options.qqMusicPort }, + "QQ Music API already listening on the configured port (auto-started on import) — reusing embedded instance" ); - srv.on("error", reject); - }); - logger.info( - { port: options.qqMusicPort }, - "QQ Music API started" - ); + } else { + qqMusicServer = await new Promise((resolve, reject) => { + const srv = koaApp.listen(options.qqMusicPort, "127.0.0.1", () => + resolve(srv) + ); + srv.on("error", reject); + }); + // Log the port actually bound (read from the socket) rather than + // the requested one, so operators can spot a mismatch in the logs. + const addr = qqMusicServer.address(); + const boundPort = + addr && typeof addr === "object" && addr !== null + ? addr.port + : options.qqMusicPort; + logger.info( + { port: boundPort }, + "QQ Music API started" + ); + } } else { logger.warn("QQ Music API module does not expose a Koa app"); } diff --git a/src/web/api/bot.test.ts b/src/web/api/bot.test.ts index 3f06367..bac40fe 100644 --- a/src/web/api/bot.test.ts +++ b/src/web/api/bot.test.ts @@ -679,4 +679,54 @@ describe("bot router /settings jellyfin block + enabledProviders", () => { // No jellyfin block in the request → no reconfigure call. expect(configureCalls).toHaveLength(0); }); + + // --- #126: operator-chosen default source --- + + it("GET /settings exposes defaultPlatform (null by default)", async () => { + const res = await request(mountBot()).get("/api/bot/settings"); + expect(res.status).toBe(200); + expect(res.body.defaultPlatform).toBeNull(); + }); + + it("POST /settings sets an enabled defaultPlatform and persists it", async () => { + const res = await request(mountBot()).post("/api/bot/settings").send({ + defaultPlatform: "bilibili", + }); + expect(res.status).toBe(200); + expect(res.body.defaultPlatform).toBe("bilibili"); + expect(config.defaultPlatform).toBe("bilibili"); + const onDisk = JSON.parse(readFileSync(configPath, "utf-8")); + expect(onDisk.defaultPlatform).toBe("bilibili"); + }); + + it("POST /settings ignores an unknown or disabled defaultPlatform", async () => { + const app = mountBot(); + // jellyfin is opt-in and not enabled in the default config → rejected. + await request(app).post("/api/bot/settings").send({ defaultPlatform: "jellyfin" }); + expect(config.defaultPlatform).toBeNull(); + // Unknown value → rejected. + await request(app).post("/api/bot/settings").send({ defaultPlatform: "bogus" }); + expect(config.defaultPlatform).toBeNull(); + }); + + it("POST /settings clears defaultPlatform with null", async () => { + const app = mountBot(); + await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" }); + expect(config.defaultPlatform).toBe("qq"); + const res = await request(app).post("/api/bot/settings").send({ defaultPlatform: null }); + expect(res.body.defaultPlatform).toBeNull(); + expect(config.defaultPlatform).toBeNull(); + }); + + it("POST /settings drops a default whose source gets disabled in the same request", async () => { + const app = mountBot(); + await request(app).post("/api/bot/settings").send({ defaultPlatform: "qq" }); + expect(config.defaultPlatform).toBe("qq"); + // Disabling qq via enabledProviders clears the now-invalid default. + const res = await request(app).post("/api/bot/settings").send({ + enabledProviders: ["netease", "bilibili"], + }); + expect(res.body.defaultPlatform).toBeNull(); + expect(config.defaultPlatform).toBeNull(); + }); }); diff --git a/src/web/api/bot.ts b/src/web/api/bot.ts index f055e8e..4653b4c 100755 --- a/src/web/api/bot.ts +++ b/src/web/api/bot.ts @@ -79,6 +79,7 @@ export function createBotRouter( spotify: maskedSpotify(), jellyfin: maskedJellyfin(), enabledProviders: config.enabledProviders, + defaultPlatform: config.defaultPlatform, }); }); @@ -187,6 +188,29 @@ export function createBotRouter( ); } + // defaultPlatform (issue #126): the operator-chosen default source for + // platform-less commands/REST/WebUI calls. Reconciled AFTER enabledProviders + // so both are validated against the same (possibly updated) enabled list: + // 1) Drop a stored default that the new enabledProviders no longer allows, + // keeping the persisted config consistent with loadConfig's invariant. + // 2) Apply an explicit change — `null`/`""` clears it (back to priority + // order); a known+enabled provider sets it; anything else is ignored. + if (config.defaultPlatform && !config.enabledProviders.includes(config.defaultPlatform)) { + config.defaultPlatform = null; + } + if ("defaultPlatform" in req.body) { + const dp = req.body.defaultPlatform; + if (dp === null || dp === "") { + config.defaultPlatform = null; + } else if ( + typeof dp === "string" && + (GATEABLE_PROVIDERS as readonly string[]).includes(dp) && + config.enabledProviders.includes(dp as GateableProvider) + ) { + config.defaultPlatform = dp as GateableProvider; + } + } + saveConfig(configPath, config); // Hot-apply the (possibly re-pointed) Jellyfin connection to the live @@ -233,6 +257,7 @@ export function createBotRouter( spotify: maskedSpotify(), jellyfin: maskedJellyfin(), enabledProviders: config.enabledProviders, + defaultPlatform: config.defaultPlatform, }); }); diff --git a/src/web/api/music.test.ts b/src/web/api/music.test.ts index 451dca1..8cd1297 100644 --- a/src/web/api/music.test.ts +++ b/src/web/api/music.test.ts @@ -1,9 +1,19 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import express from "express"; +import cookieParser from "cookie-parser"; import request from "supertest"; import pino from "pino"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { MusicProvider, SearchResult } from "../../music/provider.js"; -import { getDefaultConfig, type BotConfig } from "../../data/config.js"; +import { getDefaultConfig, loadConfig, type BotConfig } from "../../data/config.js"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore } from "../../data/users.js"; +import { createSessionStore } from "../../data/sessions.js"; +import { createPermissionStore } from "../../data/permissions.js"; +import { createRequireAuth } from "../middleware/requireAuth.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; import { createMusicRouter } from "./music.js"; const empty: SearchResult = { songs: [], albums: [], playlists: [] }; @@ -118,6 +128,25 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints", expect(res.body.enabled).not.toContain("spotify"); // spotify.enabled defaults off }); + it("GET /providers reports a configured defaultPlatform override (#126)", async () => { + const config = getDefaultConfig(); + config.defaultPlatform = "qq"; // operator prefers QQ over the priority order + const { app } = mount(config); + const res = await request(app).get("/api/music/providers"); + expect(res.status).toBe(200); + expect(res.body.default).toBe("qq"); + }); + + it("routes a platform-less /search to the configured defaultPlatform (#126)", async () => { + const config = getDefaultConfig(); + config.defaultPlatform = "bilibili"; + const { app, netease } = mount(config); + const res = await request(app).get("/api/music/search?q=hello"); + expect(res.status).toBe(200); + // Default is now bilibili, so the netease provider must NOT be hit. + expect(netease.search).not.toHaveBeenCalled(); + }); + /** Default config plus the opt-in jellyfin source enabled. */ function configWithJellyfin() { const config = getDefaultConfig(); @@ -147,3 +176,122 @@ describe("music router provider gating (enabledProviders) + jellyfin endpoints", expect(res.status).toBe(401); }); }); + +describe("music router POST /quality — persistence (#125)", () => { + let tmpDir: string; + let configPath: string; + let config: BotConfig; + let botDb: BotDatabase; + let app: express.Express; + let cookie: string; + let providers: Record; + + /** A provider whose in-memory quality is settable and readable, like the real + * ones. */ + function qualityProvider(platform: MusicProvider["platform"], initial: string): MusicProvider { + let q = initial; + return { + platform, + search: vi.fn().mockResolvedValue(empty), + getQuality: vi.fn(() => q), + setQuality: vi.fn((v: string) => { q = v; }), + } as unknown as MusicProvider; + } + + /** Jellyfin only accepts its own tiers (mirrors the real provider), so a + * broadcast of a foreign value is ignored — proving the snapshot captures each + * provider's ACTUAL post-apply state, not just the request value. */ + function jellyfinQualityProvider(): MusicProvider { + let q = "direct"; + const tiers = new Set(["direct", "320", "192", "128"]); + return { + platform: "jellyfin", + search: vi.fn().mockResolvedValue(empty), + getQuality: vi.fn(() => q), + setQuality: vi.fn((v: string) => { if (tiers.has(v)) q = v; }), + } as unknown as MusicProvider; + } + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const admin = await users.createUser("admin", "pw-admin", "admin"); + cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`; + + tmpDir = mkdtempSync(join(tmpdir(), "musicquality-")); + configPath = join(tmpDir, "config.json"); + config = getDefaultConfig(); + + providers = { + netease: qualityProvider("netease", "exhigh"), + qq: qualityProvider("qq", "exhigh"), + bilibili: qualityProvider("bilibili", "high"), + kugou: qualityProvider("kugou", "128"), + jellyfin: jellyfinQualityProvider(), + }; + + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode)); + app.use( + "/api/music", + createMusicRouter( + providers.netease, providers.qq, providers.bilibili, pino({ level: "silent" }), + undefined, config, providers.kugou, undefined, providers.jellyfin, configPath, + ), + ); + }); + + afterEach(() => { + botDb.close(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it("persists a platform-specific quality change to config.json", async () => { + const res = await request(app) + .post("/api/music/quality") + .set("Cookie", cookie) + .send({ platform: "netease", quality: "lossless" }); + expect(res.status).toBe(200); + expect(providers.netease.setQuality).toHaveBeenCalledWith("lossless"); + // in-memory config mutated + expect(config.audioQuality.netease).toBe("lossless"); + // written to disk + reload reflects it (survives a restart) + const onDisk = JSON.parse(readFileSync(configPath, "utf-8")); + expect(onDisk.audioQuality.netease).toBe("lossless"); + expect(loadConfig(configPath).audioQuality.netease).toBe("lossless"); + }); + + it("snapshots each provider's post-apply quality on a broadcast change", async () => { + const res = await request(app) + .post("/api/music/quality") + .set("Cookie", cookie) + .send({ quality: "320" }); + expect(res.status).toBe(200); + // Broadcast reached every provider… + expect(providers.netease.setQuality).toHaveBeenCalledWith("320"); + expect(providers.jellyfin.setQuality).toHaveBeenCalledWith("320"); + // …and the snapshot reflects what each one actually accepted. Jellyfin's + // "320" is a valid tier here, so it takes; a foreign value would be ignored. + expect(config.audioQuality).toEqual({ + netease: "320", + qq: "320", + bilibili: "320", + kugou: "320", + jellyfin: "320", + }); + }); + + it("ignores foreign broadcast values that a provider rejects (jellyfin)", async () => { + const res = await request(app) + .post("/api/music/quality") + .set("Cookie", cookie) + .send({ quality: "lossless" }); + expect(res.status).toBe(200); + // jellyfin rejects the NetEase-style value → stays at its default tier. + expect(config.audioQuality.jellyfin).toBe("direct"); + expect(config.audioQuality.netease).toBe("lossless"); + }); +}); diff --git a/src/web/api/music.ts b/src/web/api/music.ts index 2f8363a..ff1455d 100644 --- a/src/web/api/music.ts +++ b/src/web/api/music.ts @@ -2,7 +2,7 @@ import express, { Router, type Response } from "express"; import type { MusicProvider, Song, Album } from "../../music/provider.js"; import { YouTubeProvider } from "../../music/youtube.js"; import type { Logger } from "../../logger.js"; -import { isProviderEnabled, defaultPlatform, type BotConfig } from "../../data/config.js"; +import { isProviderEnabled, defaultPlatform, saveConfig, type BotConfig } from "../../data/config.js"; import { requirePermission } from "../middleware/requirePermission.js"; import { requireNotGuest } from "../middleware/requireNotGuest.js"; import { authorize } from "../middleware/authorize.js"; @@ -16,7 +16,10 @@ export function createMusicRouter( config?: BotConfig, kugouProvider?: MusicProvider, spotifyProvider?: MusicProvider, - jellyfinProvider?: MusicProvider + jellyfinProvider?: MusicProvider, + // When set (alongside config), a quality change is persisted to config.json so + // it survives a restart (#125). Omitted by unit-test routers → no persistence. + configPath?: string, ): Router { const router = Router(); const youtubeProvider: MusicProvider = new YouTubeProvider(); @@ -480,6 +483,26 @@ export function createMusicRouter( if ((!platform || platform === "jellyfin") && jellyfinProvider) { jellyfinProvider.setQuality(quality); } + + // Persist the (post-apply) per-provider quality so it survives a restart + // (#125). Snapshotting each provider's getQuality() AFTER setQuality captures + // exactly what each one accepted (jellyfin ignores foreign tiers, kugou maps + // aliases), so replaying these on startup reproduces this state faithfully. + if (config && configPath) { + config.audioQuality = { + netease: neteaseProvider.getQuality(), + qq: qqProvider.getQuality(), + bilibili: bilibiliProvider.getQuality(), + kugou: kugouProvider?.getQuality() ?? config.audioQuality.kugou, + jellyfin: jellyfinProvider?.getQuality() ?? config.audioQuality.jellyfin, + }; + try { + saveConfig(configPath, config); + } catch (err) { + logger.warn({ err }, "Failed to persist audio quality"); + } + } + logger.info({ quality, platform }, "Audio quality changed"); res.json({ success: true, quality }); }); diff --git a/src/web/robots.test.ts b/src/web/robots.test.ts new file mode 100644 index 0000000..9b56079 --- /dev/null +++ b/src/web/robots.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import express from "express"; +import request from "supertest"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Search-engine hardening for issue #128: searching "TsmusicBot" surfaced a + * large number of deployed instances' WebUI URLs, letting strangers walk into + * other people's control pages. The fix is defence in depth — none of these + * layers is authentication (that's handled elsewhere), they just keep the + * public URL out of crawler indexes: + * + * 1. `X-Robots-Tag: noindex, nofollow` on EVERY response; + * 2. `GET /robots.txt` → `User-agent: * / Disallow: /`; + * 3. `` in web/index.html. + * + * The header middleware and the /robots.txt route both live at the top of + * `createWebServer` in `server.ts`; this test asserts the exact behaviour we + * expect from them in isolation (the wiring inside server.ts is verified by + * code review / git diff, matching security-headers.test.ts). + */ +describe("search-engine hardening (issue #128 noindex)", () => { + function buildApp() { + const app = express(); + // Mirrors the security-headers middleware in server.ts. + app.use((_req, res, next) => { + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + res.setHeader("X-Robots-Tag", "noindex, nofollow"); + next(); + }); + // Mirrors the public /robots.txt route in server.ts. + app.get("/robots.txt", (_req, res) => { + res.type("text/plain").send("User-agent: *\nDisallow: /\n"); + }); + app.get("/", (_req, res) => res.json({ ok: true })); + app.post("/api/session/login", (_req, res) => res.json({ ok: true })); + return app; + } + + it("sets X-Robots-Tag: noindex, nofollow on GET responses", async () => { + const res = await request(buildApp()).get("/"); + expect(res.status).toBe(200); + expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow"); + }); + + it("sets X-Robots-Tag on POST (API) responses too", async () => { + const res = await request(buildApp()).post("/api/session/login"); + expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow"); + }); + + it("serves /robots.txt disallowing all crawlers", async () => { + const res = await request(buildApp()).get("/robots.txt"); + expect(res.status).toBe(200); + expect(res.headers["content-type"]).toMatch(/text\/plain/); + expect(res.text).toContain("User-agent: *"); + expect(res.text).toContain("Disallow: /"); + }); + + it("still tags the /robots.txt response itself as noindex", async () => { + const res = await request(buildApp()).get("/robots.txt"); + expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow"); + }); +}); + +describe("frontend robots meta tag (issue #128 noindex)", () => { + const indexHtmlPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../web/index.html" + ); + const html = fs.readFileSync(indexHtmlPath, "utf-8"); + + const robotsMeta = html.match( + //i + ); + + it("declares a robots meta tag", () => { + expect(robotsMeta).not.toBeNull(); + }); + + it("marks the SPA shell noindex, nofollow (covers /bot/ dedicated links)", () => { + expect(robotsMeta?.[1]).toBe("noindex, nofollow"); + }); +}); diff --git a/src/web/server.ts b/src/web/server.ts index 212d0e9..a94a4b5 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -78,12 +78,19 @@ export function createWebServer(options: WebServerOptions): WebServer { app.set("trust proxy", true); } - // Security headers: prevent the WebUI from being embedded in a third-party - // iframe (clickjacking defence). CSP frame-ancestors is the modern equivalent - // of X-Frame-Options; both are set for compatibility across browsers. + // Security headers: + // • X-Frame-Options / CSP frame-ancestors — prevent the WebUI from being + // embedded in a third-party iframe (clickjacking defence). CSP + // frame-ancestors is the modern equivalent of X-Frame-Options; both are + // set for compatibility across browsers. + // • X-Robots-Tag — keep deployed instances out of search-engine indexes + // (issue #128: searching "TsmusicBot" surfaced strangers' WebUI URLs). + // Set on EVERY response so JSON/API responses and the SPA shell are all + // covered; complements /robots.txt and the tag. app.use((_req, res, next) => { res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); + res.setHeader("X-Robots-Tag", "noindex, nofollow"); next(); }); @@ -96,6 +103,13 @@ export function createWebServer(options: WebServerOptions): WebServer { const permissions = createPermissionStore(options.database.db); // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── + // Disallow every crawler (issue #128). Declared before the static SPA + // fallback so this wins over index.html for /robots.txt. Belt-and-braces + // with the X-Robots-Tag header above and the tag. + app.get("/robots.txt", (_req, res) => { + res.type("text/plain").send("User-agent: *\nDisallow: /\n"); + }); + app.get("/api/health", (_req, res) => { res.json({ status: "ok", version: "0.1.0" }); }); @@ -152,7 +166,7 @@ export function createWebServer(options: WebServerOptions): WebServer { ); app.use( "/api/music", - createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider) + createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.localProvider, options.config, options.kugouProvider, options.spotifyProvider, options.jellyfinProvider, options.configPath) ); app.use("/api/player", createPlayerRouter( options.botManager, logger, options.database, diff --git a/web/index.html b/web/index.html index 5c0042c..139121e 100644 --- a/web/index.html +++ b/web/index.html @@ -3,6 +3,12 @@ + + + + 默认音源 + + 设置不带音源参数时(如聊天里的 !play 歌名 或网页搜索)默认使用的音源。 + 例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 -b。 + 选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。 + + + + + 默认音源 + + + + 自动(按优先级) + + {{ opt.label }} + + + + {{ defaultSourceSaving ? '保存中…' : '保存' }} + + + + {{ defaultSourceMessage }} + + Spotify 播放(实验性) @@ -1187,6 +1217,51 @@ function providerOn(p: string): boolean { return enabledProviders.value.length === 0 || enabledProviders.value.includes(p); } +// --- Default music source (issue #126) --- +// Chinese labels for the gateable providers, shown in the default-source select. +const PROVIDER_LABELS: Record = { + netease: '网易云音乐', + qq: 'QQ音乐', + kugou: '酷狗音乐', + bilibili: '哔哩哔哩', + youtube: 'YouTube', + jellyfin: 'Jellyfin', +}; +// Empty string = "auto" (follow the fixed priority order); persisted as null. +const defaultPlatformForm = ref(''); +const defaultSourceSaving = ref(false); +const defaultSourceMessage = ref(''); +const defaultSourceMessageTone = ref<'ok' | 'warn'>('ok'); +// Only currently-enabled sources can be picked as the default. +const defaultSourceOptions = computed(() => + enabledProviders.value + .filter((p) => p in PROVIDER_LABELS) + .map((p) => ({ value: p, label: PROVIDER_LABELS[p] })), +); + +async function saveDefaultSource() { + defaultSourceSaving.value = true; + defaultSourceMessage.value = ''; + try { + const res = await axios.post('/api/bot/settings', { + defaultPlatform: defaultPlatformForm.value || null, + }); + defaultPlatformForm.value = res.data?.defaultPlatform ?? ''; + // Push the new default across the app immediately (search bar / play calls + // read store.defaultSource, refreshed via GET /api/music/providers). + await store.fetchProviders(); + defaultSourceMessageTone.value = 'ok'; + defaultSourceMessage.value = '已保存'; + } catch (err: any) { + defaultSourceMessageTone.value = 'warn'; + defaultSourceMessage.value = err?.response?.status === 403 + ? '没有权限修改设置(需要 bot.manage)' + : '保存失败,请稍后重试'; + } finally { + defaultSourceSaving.value = false; + } +} + // --- Jellyfin connection (admin-configured; password/apiKey are write-only) --- const jellyfinForm = reactive({ serverUrl: '', @@ -1243,6 +1318,11 @@ async function saveJellyfin() { // Search bar / home sections / FM cards react without a reload. store.fetchProviders(); } + // Disabling a source can clear a default that pointed at it (backend + // reconciles enabledProviders → defaultPlatform); keep the select in sync. + if (res.data && 'defaultPlatform' in res.data) { + defaultPlatformForm.value = res.data.defaultPlatform ?? ''; + } jellyfinMessageTone.value = 'ok'; jellyfinMessage.value = '已保存'; await checkAuthStatus(); @@ -1538,6 +1618,8 @@ async function loadIdleTimeout() { jellyfinEnabledForm.value = res.data.enabledProviders.includes('jellyfin'); enabledProvidersLoaded.value = true; } + // null (unset) → "" so the select shows "自动(按优先级)". + defaultPlatformForm.value = res.data.defaultPlatform ?? ''; } catch { /* ignore */ } } @@ -2496,6 +2578,13 @@ onUnmounted(() => { .input-sm { max-width: 80px; } +// The default-source picker holds full source names ("网易云音乐"), so it needs +// more room than the 80px .input-sm cap. +.default-source-select { + max-width: none; + flex: 0 0 160px; +} + .textarea { width: 100%; padding: 10px 14px;
+ 设置不带音源参数时(如聊天里的 !play 歌名 或网页搜索)默认使用的音源。 + 例如把默认音源设为「哔哩哔哩」后,点播 B 站音乐就不用每次都加 -b。 + 选择「自动」则按内置优先级挑选第一个已启用的音源(网易云 → QQ → 酷狗 → Jellyfin → 哔哩哔哩 → YouTube)。 +
!play 歌名
-b
{{ defaultSourceMessage }}