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 88b7574..1b80cab 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` 显式保存的偏好)。
+
## 项目架构
```
@@ -789,10 +803,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` 并重启即可。
@@ -892,6 +909,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 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 f66cf5e..08d263d 100644
--- a/src/data/config.test.ts
+++ b/src/data/config.test.ts
@@ -147,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 43ed41f..6dc5cc2 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
@@ -111,6 +127,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
@@ -179,6 +197,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"],
defaultPlatform: null,
};
@@ -347,6 +374,20 @@ export function loadConfig(path: string): BotConfig {
? (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,
@@ -354,6 +395,7 @@ export function loadConfig(path: string): BotConfig {
guestMode: gm,
spotify,
jellyfin,
+ audioQuality,
enabledProviders,
defaultPlatform: defaultPlatformPref,
};
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/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/music.test.ts b/src/web/api/music.test.ts
index c0df896..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: [] };
@@ -166,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 35d99ff..2fbc548 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" });
});
@@ -151,7 +165,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 @@
+
+