From 4493269479c1baa177160f55ac915ad503d1a924 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 16 Jul 2026 23:27:01 +0800 Subject: [PATCH 1/5] chore: add .claude/ to gitignore and untrack it The .claude/ directory holds local Claude Code settings that should not be version-controlled. Add it to .gitignore and remove the already-committed settings from the index (files kept on disk). Co-Authored-By: Claude Fable 5 --- .claude/settings.json | 22 ---------------------- .claude/settings.local.json | 26 -------------------------- .gitignore | 1 + 3 files changed, 1 insertion(+), 48 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .claude/settings.local.json 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 From ea0f7c17b5983c3b63da4328926abc08162c210e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Thu, 16 Jul 2026 23:31:31 +0800 Subject: [PATCH 2/5] feat(web): keep deployed WebUI out of search-engine indexes Searching "TsmusicBot" surfaced many deployed instances' WebUI URLs, letting strangers walk into other people's control pages (issue #128). Add defence-in-depth so crawlers stop indexing public deployments: - send `X-Robots-Tag: noindex, nofollow` on every Express response - serve `/robots.txt` with `User-agent: * / Disallow: /` - add `` to index.html, which also covers the /bot/ dedicated-link pages (same SPA shell) These layers only prevent indexing; real protection stays with WebUI auth and the reverse proxy. Document this in the README security section and warn users not to post their WebUI link on public pages. Co-Authored-By: Claude Fable 5 --- README.md | 1 + src/web/robots.test.ts | 86 ++++++++++++++++++++++++++++++++++++++++++ src/web/server.ts | 20 ++++++++-- web/index.html | 6 +++ 4 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 src/web/robots.test.ts diff --git a/README.md b/README.md index 7731821..4497000 100644 --- a/README.md +++ b/README.md @@ -891,6 +891,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/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 35d99ff..60bbfd0 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -77,12 +77,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(); }); @@ -95,6 +102,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" }); }); 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)。 +

+
+
+ + 默认音源 +
+
+ + +
+
+

{{ defaultSourceMessage }}

+
+

Spotify 播放(实验性)

@@ -1161,6 +1191,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: '', @@ -1217,6 +1292,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(); @@ -1506,6 +1586,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 */ } } @@ -2448,6 +2530,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; From fbd94c424bdf7618534e8396301ce115ee65d8d1 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 17 Jul 2026 00:09:47 +0800 Subject: [PATCH 4/5] feat: persist volume, play mode and audio quality across restarts Runtime playback settings were kept only in memory (AudioPlayer.volume, PlayQueue.mode, each provider's quality field), so restarting the bot reset them to defaults and users had to re-tune volume and quality every time (#125). Persist and restore them via the repo's existing storage: - Volume and play mode are per-bot, stored on new bot_instances columns (volume, play_mode) with a schema migration; restored when the instance is (re)built, written by cmdVol / cmdMode which every entry point (chat command, WebUI, REST) funnels through. Volume and mode are written independently so a transient !fm/!artist mode switch never overwrites the user's saved !mode. - Per-provider audio quality is global (shared providers), stored in a new config.json `audioQuality` block; applied to the providers at startup and re-snapshotted on POST /api/music/quality. Queue, current song, progress and FM/artist sessions stay ephemeral. Adds tests for config sanitize/round-trip, DB player-settings + migration, cmdVol/cmdMode persistence + construction-time restore, and quality persistence through the REST endpoint. Documents the behavior in the README. Co-Authored-By: Claude Fable 5 --- README.md | 14 ++++ src/bot/instance.test.ts | 134 ++++++++++++++++++++++++++++++++++++++ src/bot/instance.ts | 59 +++++++++++++++-- src/data/config.test.ts | 54 +++++++++++++++ src/data/config.ts | 42 ++++++++++++ src/data/database.test.ts | 86 ++++++++++++++++++++++++ src/data/database.ts | 70 ++++++++++++++++++++ src/index.ts | 9 +++ src/web/api/music.test.ts | 133 ++++++++++++++++++++++++++++++++++++- src/web/api/music.ts | 27 +++++++- src/web/server.ts | 2 +- 11 files changed, 618 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7731821..a740ce9 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,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` 显式保存的偏好)。 + ## 项目架构 ``` diff --git a/src/bot/instance.test.ts b/src/bot/instance.test.ts index bb5aa77..9f12883 100644 --- a/src/bot/instance.test.ts +++ b/src/bot/instance.test.ts @@ -6,6 +6,7 @@ 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 { createDatabase } from "../data/database.js"; import type { AvatarStore } from "../data/avatars.js"; import type { BotConfig } from "../data/config.js"; @@ -832,6 +833,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", @@ -912,6 +916,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 baaf8f1..29cfdf9 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 = @@ -188,6 +197,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. @@ -1136,10 +1158,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"; @@ -1205,15 +1253,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 7fbce19..9324348 100644 --- a/src/data/config.test.ts +++ b/src/data/config.test.ts @@ -92,6 +92,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 b5b515f..7e79c7c 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 @@ -102,6 +118,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 @@ -162,6 +180,15 @@ 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"], }; } @@ -317,6 +344,20 @@ export function loadConfig(path: string): BotConfig { ) : defaults.enabledProviders; + // 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, @@ -324,6 +365,7 @@ export function loadConfig(path: string): BotConfig { guestMode: gm, spotify, jellyfin, + audioQuality, enabledProviders, }; } diff --git a/src/data/database.test.ts b/src/data/database.test.ts index 7520a4f..8352215 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 992fc94..2bb5a0c 100644 --- a/src/data/database.ts +++ b/src/data/database.ts @@ -55,6 +55,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; @@ -75,6 +95,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; @@ -119,6 +142,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); @@ -161,6 +193,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 ); @@ -321,6 +355,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 = ?`); @@ -406,6 +446,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/web/api/music.test.ts b/src/web/api/music.test.ts index 451dca1..ea170cc 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: [] }; @@ -147,3 +157,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/server.ts b/src/web/server.ts index 35d99ff..4e9ed6e 100755 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -151,7 +151,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, From 846ee30bce4fd895263e1d839c1b1bc5c133d7d2 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Fri, 17 Jul 2026 00:24:37 +0800 Subject: [PATCH 5/5] fix(qq): pin the QQ Music API sidecar to qqMusicApiPort The embedded QQ Music API sidecar could bind a different port than the one the client base URL (getQQMusicBaseUrl) targets. The upstream @sansenjian/qq-music-api 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. When an old build listened on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login / cookie persistence silently broke. Align process.env.PORT with the configured qqMusicApiPort for the duration of the import (restoring the previous value afterwards so nothing else in the process is affected), reuse an already-listening instance instead of racing a second listen, and log the port actually bound (read from the socket) so any mismatch is visible in the logs. - src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log - src/music/api-server.test.ts: regression coverage that the sidecar follows qqMusicPort (not an injected PORT) and restores PORT afterwards - README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort and points stale-latest-image users (who saw 3300) at re-pulling the image Co-Authored-By: Claude Fable 5 --- README.md | 7 ++- src/music/api-server.test.ts | 107 ++++++++++++++++++++++++++++++++++- src/music/api-server.ts | 56 ++++++++++++++---- 3 files changed, 156 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7731821..5e413ec 100644 --- a/README.md +++ b/README.md @@ -788,10 +788,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` 并重启即可。 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"); }