Compare commits

...
Author SHA1 Message Date
saopig1andClaude Opus 5 b92543f337 docs: add v1.12.0 changelog entry
也补上此前遗漏的 v1.11.2 条目。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:24:39 +08:00
TIANYAO ZHANG 990edd1bb0 Merge pull request #147 from ZHANGTIANYAO1/fix/native-module-abi-setup
fix(setup): 按 Node ABI 校验并自动修复原生模块
2026-08-09 15:22:44 +08:00
TIANYAO ZHANG e39ce590c1 Merge pull request #146 from ZHANGTIANYAO1/fix/webui-icons-and-mobile-ux
WebUI:站点图标、移动端交互与扫码文案
2026-08-09 15:22:40 +08:00
TIANYAO ZHANG e027ee3d02 Merge pull request #145 from ZHANGTIANYAO1/fix/play-id-command-syntax
feat(bot): !play id <id> 与其他命令语法保持一致
2026-08-09 15:22:36 +08:00
TIANYAO ZHANG 06d29ba306 Merge pull request #144 from ZHANGTIANYAO1/fix/playnext-in-random-mode
fix(queue): 随机模式下 !pn 插入的歌真正下一首播放
2026-08-09 15:22:32 +08:00
saopig1andClaude Opus 5 03ffd09d36 fix(setup): 按 Node ABI 校验并自动修复原生模块
换过 Node 大版本之后安装就废了,而且安装脚本还会报告成功。原生模块只能在
编译它的那个 Node ABI 上加载(Node 20 = 115、22 = 127、24 = 137),而
better-sqlite3 的 .node 放在与 ABI 无关的固定路径下,旧的 download-binaries
只检查「文件存在且大于 500KB」,于是给 Node 24 编译的 1.9MB 文件在 Node 22
下原样保留,跳过重新下载,机器人启动时死在 NODE_MODULE_VERSION 上。
(@discordjs/opus 的目录名里带 ABI,反而歪打正着没这个问题。)

download-binaries.mjs 现在不看文件大小,而是在子进程里真的把每个包 load 一遍
——子进程是必须的,Windows 上父进程加载过的 .node 会一直被映射,系统随后拒绝
删除或覆盖它。注意 better-sqlite3 的 addon 是在 Database 构造函数里惰性加载的,
所以光 require 这个包探测不出问题,得真的开一个内存库。

失败就按当前 ABI 重新安装,整个替换过程是先把旧文件挪到 node_modules/
.tsmusicbot-backup、下载解压到暂存目录、原子 rename 就位、再探测一次,任何
一步失败都把原文件还原回去——删掉不匹配的二进制却下载不下来,比原来的版本
更糟。备份特意放在包的 build/ 之外,因为源码编译回退会调 node-gyp 把 build/
清空。被中断(比如下载到一半 Ctrl+C)遗留的备份,下一次运行会自动认领回来。

其他一并修掉的问题:
- 版本号原本硬编码 12.8.0,实际锁的是 12.11.1,一旦真的触发下载就会 404;
  改为从 node_modules 里读。
- 三个模块原本用 Promise.all 并发。源码编译走的是 execSync,会把事件循环整个
  卡住几分钟,而 download() 的 120 秒超时是挂在同一个循环上的 socket 静默计时
  器——循环一恢复,还在传输中的连接就会被判超时。这不是小概率竞态:npmmirror
  上没有 ABI 137 的 opus,也没有 ABI 115 的 better-sqlite3,所以在 Node 24 和
  Node 20 上必定有一个模块在 100ms 内 404 并开始编译,而 ffmpeg 的 80MB 下载
  正在进行。ffmpeg 是可选模块,于是它被误杀后只记一条 WARN,脚本照样 exit 0,
  setup 打印「Setup Complete」,用户装完却没有 ffmpeg,放什么都放不出来。
  改成严格串行执行。
- 必需模块(opus / better-sqlite3)失败才返回非零;ffmpeg 有系统 ffmpeg 兜底,
  只警告。setup.bat 里原本形同虚设的 FAILED 标志接上了,必需模块失败会中止安装,
  不再是「装完才发现」。
- 4b 步骤原本把全部输出重定向进 setup.log,用户盯着不动的窗口以为卡死;现在
  进度走 stderr 实时显示,完整记录仍进日志。

新增 scripts/check-native.mjs:启动前预检,直接说清楚哪个模块对不上、分别是哪
个 ABI、怎么修,而不是抛一串 NODE_MODULE_VERSION 堆栈。scripts\start.bat、根目
录 start.bat(现在改为委托给前者,并且会先切到项目目录)和 npm start 的 prestart
都会跑它。Docker 运行镜像也补上这个文件,否则容器里执行 npm start 会因为找不到
脚本而失败。

Node 版本要求改为按依赖的真实下限判断(@honeybbq/teamspeak-client 要 >=20.19、
@sansenjian/qq-music-api 要 >=20.17/22.9,21 和 23 被 better-sqlite3 与 vitest
排除),package.json 补上对应的 engines;比 20/22 LTS 更新的大版本不阻止,只提
示可能要源码编译。README 相应更新,并补一条 NODE_MODULE_VERSION 的常见问题。

注意:批处理里新增的行全部保持纯 ASCII —— cmd.exe 在括号块里遇到多字节 UTF-8
会算错文件偏移,开始吃掉后续行的 echo 前缀,中文提示一律交给 Node 脚本输出。

Closes #140

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:09:15 +08:00
saopig1andClaude Opus 5 74ea8d26d4 feat(bot): !play id <id> 与其他命令语法保持一致
按 id 精确播放原本要写 `!play id:<id>`,冒号在一堆 `!<命令> <子命令> <参数>`
的命令里显得很突兀。现在空格写法 `!play id <id>` 也可以,`!add` / `!playnext`
共用同一个解析器,一起生效。

`id:<id>` 继续支持,不做废弃:用户的聊天记录、旧文档和 !search 输出里都是
这个写法。

冒号是个明确的标记,所以 `id:<任意内容>` 一律当 id。空格不是——「ID 4」和
「ID Bruno」都是真实存在的歌名,而且 `id <链接>` 原本会落到 URL 分支正常解析。
所以空格写法只认「长得像 id」的 token(纯数字 / BV 号 / 11 位以上的 id 字符),
其余照旧继续走 URL 识别,最后落到普通搜索,不会把搜索词误当成 id。

同步更新 !search 输出的提示、三条 Usage、!help 和 README。

Closes #139

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:08:19 +08:00
saopig1andClaude Opus 5 cc3684ff86 fix(queue): 随机模式下 !pn 插入的歌真正下一首播放
Random / RandomLoop 下 next() 从 shuffle bag(playedIndices)里随机挑,完全
不看数组顺序,所以 addNext() 把歌插到 currentIndex+1 之后,它只是和别的歌一
样等着被随机抽中。!pn / !playnext 和 WebUI 的「下一首播放」按钮都受影响,而
两者都回了一句「Up next: …」,等于在骗人。

addNext() 现在在随机模式下把插入位置记到 forwardStack —— next() 本来就会先
看这个栈(原本用于 prev 的回退位置),所以不用改 next() 的挑选逻辑。栈是后进
先出,正好和连续 !pn 在队列里呈现的顺序一致(每次插入都排在上一次前面),
与顺序模式表现相同。

只加这一句是不够的,另外两处会让它失效:

- addNext() 原本只把 playedIndices 和 history 中大于 currentIndex 的下标 +1,
  没管 forwardStack。连续 !pn 两次会得到两个相同的下标,第二次 pop 出来的旧
  下标恰好等于 currentIndex,被静默丢弃,先插入的那首就永远不会播。
- remove() 同样只修 playedIndices 和 history。删掉队列中靠前的歌之后,
  forwardStack 里的下标会指向挤上来的另一首歌;删得多了甚至越界,此时
  next() 返回 undefined,而 BotInstance.playNext 把假值当作队列播完直接停止
  播放。

所以一并给 forwardStack 补上和另外两个结构相同的平移/清理规则,并让 next()
像 prev() 处理失效 history 那样,循环跳过越界或指向当前曲目的条目。上限行为
也对齐 history:超出 HISTORY_LIMIT 时丢最旧的,而不是拒绝刚插入的那首。

新增测试覆盖两种随机模式、连续插入的顺序、shuffle bag 播完后插入、删除前后
的下标同步、prev 标记与插入条目共栈,以及 200 步交错操作不产生失效下标。已用
变异测试逐条回退上述四处改动确认这些用例确实会失败。

Closes #141

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:07:55 +08:00
saopig1andClaude Opus 5 f777d892db fix(web): 移动端进度条可拖动、歌曲行单击即播
移动端两个交互在触屏上是死的:

1) 迷你播放器的进度条只是展示,整行的 click 又被绑成跳转歌词页。现在这条
   进度条用 Pointer Events(pointerdown/move/up/cancel + setPointerCapture)
   支持点按和拖动 seek,一套代码同时服务触摸、手写笔和鼠标,手指滑出细条也
   不会中断。可视轨道仍是 2px,但命中区域扩到 12px 并向下伸进播放器自身的
   8px 内边距——传输按钮高 32px、在 42px 内容区里居中,上沿在 13px,正好错开。

   拖动时渲染值切到手指位置,让 60fps 的 rAF 时钟别和手指抢(与音量条 #111
   同源问题);本地覆盖在 seek 请求 resolve 之后才释放,避免先跳回旧位置再
   跳到新位置。松手后 400ms 内的 click 被整行吞掉,否则 seek 完会被顺带导航
   到歌词页。没有 transport 权限或时长未知时整条退回纯展示,并把 touch-action
   还给页面,不会白吃掉滚动手势。

2) SongCard 和队列抽屉都用 @dblclick 触发播放,而 dblclick 是鼠标专属事件,
   触屏永远不会触发。现在改为按事件判断:click 在现代浏览器里是 PointerEvent,
   pointerType 为 touch/pen 时单击播放,鼠标单击行为完全不变(双击仍然播放)。
   用按事件判断而不是 matchMedia('(pointer: coarse)'),是因为后者只反映主指针,
   在带触摸屏的笔记本上会判断错。选 click 而非 pointerup 也是有意的:浏览器
   本就会抑制滑动手势末尾的 click,滑动列表时不会误触发播放。

   队列行的移除按钮原先没有 @click.stop,加了行级 click 后会「点一下播放顺手
   删掉」,一并补上,并按 SongCard 已有的约定在 coarse 指针下常显该按钮。

Closes #143

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:07:33 +08:00
saopig1andClaude Opus 5 55695c2d1c feat(web): 给 WebUI 加站点图标与 Web App Manifest
收藏机器人控制页时浏览器只显示空白页图标,移动端加到主屏幕也没有图标。

新增 web/public/:一个蓝底白色八分音符(配色取自 --color-primary #335eea)
的 favicon.svg,以及 16/32/48 三尺寸的 favicon.ico、180px 的 apple-touch-icon、
192/512 的 PNG 和一张 maskable 图标,配 site.webmanifest 供 Android 添加到
主屏幕使用。iOS 会自己裁圆角,所以 apple-touch-icon 是满幅方形。

放在 web/public/ 是因为 Vite 会原样复制到 dist 根目录,而 Express 已经在
serve web/dist(src/index.ts STATIC_DIR),静态资源又不在 /api 鉴权范围内,
所以登录页也能显示,无需改动服务端。同时补上真实的 /favicon.ico —— 没有它
时 SPA 兜底路由会对 /favicon.ico 返回 index.html 和 200,浏览器只会静默地
继续用空白图标。

theme-color 取深色主题的 --bg-primary(#222222):前端默认深色且不跟随系统
配色(stores/player.ts)。

Closes #142

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:06:44 +08:00
saopig1andClaude Opus 5 db2e70fb11 fix(web): QQ 扫码登录提示应为「手机QQ」而非「QQ音乐APP」
QQ 的扫码登录走的是腾讯 ptlogin(getQrCode 拿到的是 qrsig + ptqrtoken,
见 src/music/qq.ts),那是 QQ 账号级别的二维码,要用手机QQ扫,用 QQ音乐
APP 扫不出来。网易云 / B站 / 酷狗 三处提示各自平台正确,未改动。

Closes #138

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 15:06:30 +08:00
TIANYAO ZHANG 4e4354282d Merge pull request #137 from ZHANGTIANYAO1/codex/voice-ducking
feat: add configurable voice ducking
2026-07-21 22:07:36 +08:00
saopig1 bc711758b7 fix: harden voice ducking bot detection 2026-07-21 22:05:15 +08:00
saopig1 97e8a87305 feat: add voice ducking 2026-07-21 15:47:03 +08:00
saopig1andClaude Fable 5 b51b5a2317 docs: add v1.11.1 changelog entry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:38:06 +08:00
TIANYAO ZHANG 7805f52151 Merge pull request #135 from EvolvedGhost/fix/drop-self-echoed-text-messages
fix(ts-protocol): drop self-echoed text messages
2026-07-18 12:24:16 +08:00
EvolvedGhost 3b2d6a7a59 fix(ts-protocol): drop self-echoed text messages
TeamSpeak echoes a bot's own channel/server messages back to itself.
Without filtering, a chunked reply re-entered the command path: !help's
output exceeds the ~1024-byte per-message cap, so splitTextIntoChunks
splits it, and the second chunk (which starts with "!artist ...") was
parsed as a new !artist command, loading 20 search results and starting
playback.

Drop messages whose invokerID matches the bot's own clientId at the
transport boundary, before they reach any consumer.
2026-07-18 01:03:25 +08:00
saopig1andClaude Opus 4.8 8b5a360d8a docs: consolidate the v1.11.0 changelog entry
Fold the six merged issue fixes (#119, #122, #125, #126, #127, #128) into a
single release section and version the previous entry as v1.10.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:13:08 +08:00
TIANYAO ZHANG 0ad00cd7f7 Merge pull request #133 from ZHANGTIANYAO1/feat/issue-119-saved-playlists
feat: save/load playlists, restart queue persistence, and playKeepsQueue (#119)
2026-07-17 11:09:23 +08:00
42 changed files with 3507 additions and 194 deletions

No files matched your search

+80 -15
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Node.js-20+-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/Node.js-20%20%7C%2022%20LTS-339933?logo=nodedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white" />
<img src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
<img src="https://img.shields.io/badge/许可证-MIT-green" />
@@ -62,20 +62,25 @@
### 方式一:Windows 一键部署(最简单)
只需电脑有网络连接,其他一切自动安装。
先装好 Node.js,其余依赖(含内置 FFmpeg)全部自动安装。
```
1. 下载或 clone 本项目
2. 双击 scripts\setup.bat (首次安装,自动安装 Node.js 和所有依赖)
3. 双击 scripts\start.bat (启动机器人)
4. 浏览器打开 http://localhost:3000
1. 安装 Node.js 20 LTS 或 22 LTS(https://nodejs.org/ 或 https://nodejs.cn/)
2. 下载或 clone 本项目
3. 双击 scripts\setup.bat (安装依赖并构建,不含 Node.js 本身)
4. 双击 scripts\start.bat (启动机器人)
5. 浏览器打开 http://localhost:3000
```
> `setup.bat` 会自动通过 winget 安装 Node.js(如果未安装),运行 `npm install` 安装所有依赖(包括内置 FFmpeg),最后构建项目。之后每次只需双击 `start.bat` 启动。
> **先装 Node.js 20 LTS 或 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
>
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
>
> 更新的 Node 大版本(如 24)也能用,但通常没有现成的 opus / better-sqlite3 预编译包,安装脚本会改用源码编译,需要 C/C++ 构建工具且耗时更久——所以推荐 20 / 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
### 方式二:手动安装(所有系统)
**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
**前置条件:** [Node.js 20 LTS 或 22 LTS](https://nodejs.org/)(推荐;更新的大版本可用但需要源码编译原生模块)和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
FFmpeg **已自动内置**,无需手动安装。
```bash
@@ -348,8 +353,8 @@ sudo systemctl start tsmusicbot
| `!play -y <关键词>` | 从 YouTube 搜索并播放(需要安装 [yt-dlp](#可选youtube-音源))|
| `!search <歌名> [-j\|-n\|-q\|-k\|-b\|-y]` | 列出前若干个匹配结果(含序号与 id),用于挑选同名歌曲;可加平台标志切换音源 |
| `!play #<序号>` | 播放上一次 `!search` 结果中的第 N 项(区分同名歌曲) |
| `!play id:<id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId) |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id:<id>` / 链接) |
| `!play id <id>` | 按歌曲 id 播放精确的某首歌(也支持直接粘贴网易云 / QQ / B站 歌曲链接;Jellyfin 曲目用 GUID ItemId)。旧写法 `!play id:<id>` 仍然可用 |
| `!add <歌名>` | 添加到播放队列(同样支持 `#序号` / `id <id>` / 链接) |
| `!pause` / `!resume` | 暂停 / 恢复播放 |
| `!next` / `!prev` | 下一首 / 上一首 |
| `!stop` | 停止播放并清空队列 |
@@ -505,7 +510,7 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
| **运行时** | Node.js 20+, TypeScript 5 |
| **运行时** | Node.js 20 / 22 LTS, TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
@@ -564,7 +569,7 @@ teamspeak-music-bot/
- **电台 / FM**(`!fm -j` 或首页「Jellyfin 电台」卡片)— 随机取一首**收藏**做种子生成 Instant Mix 歌曲流;没有收藏则回退到最近播放、再回退随机曲目
- **首页区块** — 最近添加(专辑)/ 播放最多 / Jellyfin 收藏 / 我的歌单 / 流派(点流派芯片即播放该流派)
- **播放上报** — 播放开始 / 进度(约 10s 一次)/ 停止会回报给 Jellyfin(`Sessions/Playing` 系列接口),你的 Jellyfin 播放统计(PlayCount、最近播放)保持准确;上报失败不影响播放
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id:` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
- **聊天命令** — 启用后用 `-j` 标志:`!play -j <歌名>`、`!fm -j`、`!artist -j <歌手>`;`!playlist` / `!album` / `!play id <id>` 可直接粘贴 Jellyfin GUID。若把在线音源全部停用、只保留 Jellyfin,不带标志的命令会自动以 Jellyfin 为默认音源
### enabledProviders:音源开关
@@ -797,11 +802,14 @@ A:支持。本项目内置 TS3/TS6 双协议支持,连接时会自动检测
**Q:机器人连接了但 TeamSpeak 中听不到音乐?**
A:确保机器人和你在同一个频道。检查音量(`!vol 75`)。部分 VIP 歌曲需要先登录账号。
**Q:启动报 `NODE_MODULE_VERSION 137 ... requires 127`,或提示找不到 `opus.node`?**
A:换过 Node 大版本了。原生模块(`@discordjs/opus`、`better-sqlite3`)编译时绑定了一个 Node ABI(Node 20 = 115、22 = 127、24 = 137),换版本后旧的 `.node` 就再也加载不了。**重新运行一次 `scripts\setup.bat`(Linux/macOS 是 `bash scripts/setup.sh`)即可**——安装脚本会实际加载一遍每个原生模块,发现和当前 Node 不匹配就自动重新下载/编译,替换过程中失败也会把原来的文件还原回去。`start.bat` 和 `npm start` 在启动前也会先做这个检查,直接告诉你哪个模块对不上、分别是哪个 ABI,而不是抛一串看不懂的堆栈。想彻底重来就删掉 `node_modules` 和 `web\node_modules` 再跑一次 `setup.bat`。
**Q:提示"无法获取播放链接"?**
A:在设置页面扫码登录音乐账号。许多歌曲需要登录后才能播放。
**Q:同名歌曲 `!play` 只能播到最热门的那首,怎么播放指定的版本?**
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id:<歌曲id>` 按 id 精确播放;(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
A:`!play <歌名>` 默认取最热门的匹配项。要播放同名的另一首,有三种方式:(1) 先 `!search <歌名>` 列出带序号的结果,再 `!play #序号` 选择;(2) `!play id <歌曲id>` 按 id 精确播放(`!search` 结果里每行末尾的 `[id:...]` 就是它);(3) 直接粘贴歌曲链接,如 `!play https://music.163.com/song?id=442867526`(也支持 QQ / B站 链接)。在 WebUI 中则可直接在搜索结果列表里点选任意同名歌曲。
**Q:如何更换机器人所在频道?**
A:使用 `!move <频道名>` 命令,或在设置页面创建机器人时指定默认频道。
@@ -876,7 +884,48 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
> 完整历史请查看 [git log](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/commits/main) 或 [Releases](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/releases)。这里只列出重要变更和面向用户的破坏性改动。
### 最新版本 — 保存/加载播放清单 + 重启恢复队列
### 最新版本 — v1.12.0:网站图标 / 移动端交互 / 安装脚本按 ABI 自愈
一次性处理了 6 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;`!play id:<id>` 等旧写法全部继续可用。
**安装脚本按 Node ABI 校验并自动修复([#140](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/140),[PR #147](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/147),感谢 [@zbn297427669](https://github.com/zbn297427669))**
- 换过 Node 大版本后启动报 `NODE_MODULE_VERSION 137 ... requires 127`、或提示找不到 `opus.node` 的问题已修复。原生模块只能在编译它的 Node ABI 上加载,而旧脚本只检查「文件存在且够大」,会把给另一个 Node 版本编译的二进制原样留下。
- 现在安装脚本会在子进程里真的加载一遍每个原生模块,不匹配就按当前 ABI 重新安装;替换过程先备份再原子替换,任何一步失败都会把原文件逐字节还原,不会让环境变得更糟。被中断留下的备份,下次运行自动认领回来。
- 必需模块失败会**中止安装并返回非零**,不再出现「setup 显示成功、start 才爆炸」;下载进度实时显示在控制台,不再让人以为卡死。
- `start.bat` 和 `npm start` 启动前会预检,直接说清楚哪个模块对不上、分别是哪个 ABI、怎么修。
- 顺带修掉一个会**静默丢掉 ffmpeg** 的问题:源码编译会阻塞事件循环,把同时进行的 80MB ffmpeg 下载误判为超时,而 ffmpeg 是可选模块,于是安装照样报告成功、用户却放不出任何声音。三个模块改为串行处理。
- Node 版本要求按依赖真实下限判断(20.19+ / 22.12+),推荐 20 或 22 LTS;更新的大版本不阻止,只提示可能需要源码编译。
**随机模式下 `!pn` 真正下一首播放([#141](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/141),[PR #144](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/144),感谢 [@XuVIIJay](https://github.com/XuVIIJay))**
- 随机 / 随机循环下 `!pn`(以及 WebUI 的「下一首播放」)插入的歌只是和其他歌一样等着被随机抽中,机器人却回复「Up next」。现在会真的下一首播放,连续插入多首时的顺序与队列里显示的一致。
- 同时修掉两个相关问题:插入或删除队列中的歌之后,待播位置可能指向另一首歌;删得多了甚至会让播放**静默停止**。
**WebUI 站点图标与移动端交互([#142](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/142) / [#143](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/143) / [#138](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/138),[PR #146](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/146),感谢 [@XuVIIJay](https://github.com/XuVIIJay) 与 [@hak5ya](https://github.com/hak5ya))**
- 新增站点图标:收藏网页、移动端添加到主屏幕都会显示图标(含 iOS 与 Android 适配)。
- 移动端迷你播放器的进度条现在**可以点按和拖动调节进度**,触摸区域也放大到可用尺寸,拖动时不会被自动跳转到歌词页。
- 移动端**单击歌曲行即可播放**(桌面端双击行为不变);队列抽屉里的歌曲行同样支持,其移除按钮在触屏下不再是「看不见但点得到」。
- QQ 扫码登录的提示改为「请使用手机QQ扫码」——那是 QQ 账号二维码,用 QQ音乐 APP 扫不出来。
**`!play id <id>` 与其他命令语法统一([#139](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/139),[PR #145](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/145),感谢 [@hak5ya](https://github.com/hak5ya))**
- 按 id 播放现在可以写成 `!play id <id>`,和其他命令的 `<命令> <子命令> <参数>` 形式一致;`!add` / `!playnext` 同样适用。
- **旧写法 `!play id:<id>` 继续支持**。空格写法只在参数确实像 id 时生效,普通搜索和粘贴链接的行为不受影响。
### v1.11.2 — 可配置语音闪避
- 检测频道内其他人说话时平滑降低音乐音量,停止后平滑恢复;默认关闭,可在设置页启用并调节说话时保留的音量比例([#136](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/136),[PR #137](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/137))。
### v1.11.1:修复 `!help` 触发机器人自动点歌
**丢弃自回显消息([PR #135](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/135),感谢 [@EvolvedGhost](https://github.com/EvolvedGhost))**
- 修复输入 `!help` 后机器人会自己点一首歌开始播放的问题:帮助文本超过 TeamSpeak 单条消息上限被分段发送,而 TeamSpeak 会把 bot 自己发到频道的消息回推给它自己,第二段恰好以 `!artist ...` 开头,被误当作新命令解析执行。
- 现在在协议层丢弃发送者为机器人自身的消息,机器人不再响应任何自己发出的文本,所有超长分段输出均安全。无配置变化,升级无需任何操作。
### v1.11.0 — 播放清单持久化 / 设置保留 / 自定义默认音源
**保存/加载播放清单 + 队列持久化([#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119))——三项开关均默认关闭,升级无行为变化**
@@ -885,7 +934,23 @@ A:本项目内置 `/login` 限流(每 IP 每分钟 5 次),但生产部
- **单曲直接播放不清空队列(`playKeepsQueue`,默认关闭,独立开关)**:开启后,直接播放单曲会插入到当前歌曲之后并立即播放、播完继续原队列,而不是清空整个队列。仅影响单曲的「直接播放」;歌单 / 专辑 / 电台仍会替换队列。
- 三项均在 设置 → 行为设置 中开关,保存即时生效,无需重启。
### Jellyfin 可选音源
**重启后保留播放设置([#125](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/125))**
- **音量与播放模式**现按机器人持久化到数据库(`bot_instances` 表新增 `volume` / `play_mode` 列,自动迁移),**各平台音质**持久化到 `config.json` 的 `audioQuality` 字段;重启后自动恢复,不再需要每次手动重调。
- 聊天命令、WebUI、REST 三种入口的改动都会落盘;`!fm` / `!artist` 的临时随机 / 循环切换**不会**覆盖你用 `!mode` 显式保存的偏好。播放队列、当前歌曲与播放进度仍不持久化(队列恢复见上方 `savedQueuesEnabled`)。
**自定义默认音源([#126](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/126))**
- `config.json` 新增可选字段 `defaultPlatform`,或在 **设置 → 默认音源** 下拉选择:设定后,不带平台标志的 `!play` / `!search` / `!fm` 走你指定的音源(例如设为 `bilibili` 后点播 B 站视频音乐无需每次加 `-b`)。
- 留空 / `null` 恢复原有固定优先级(网易云 → QQ → 酷狗 → Jellyfin → B 站 → YouTube);若指定音源未启用或值非法,自动回退到优先级,保存即时生效、无需重启。
**修复与加固**
- **QQ 音乐 API 端口对齐([#122](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/122))**:内嵌 QQ 音乐 API sidecar 现在保证绑定到 `qqMusicApiPort`(与客户端请求端口一致),启动日志改为打印**实际绑定端口**便于排查。若你在旧 `latest` 镜像上遇到「日志里 baseURL 是 3200、服务却在 3300」导致二维码不显示,请 `docker compose pull` 重新拉取镜像。
- **WebUI 不再被搜索引擎收录([#128](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/128))**:所有响应加 `X-Robots-Tag: noindex, nofollow`、新增 `/robots.txt`(Disallow 全站)、页面加 `robots` meta 标签。⚠️ 这只是阻止**收录**,不是访问控制——公网部署请务必依赖登录鉴权与反向代理,并且不要把自己的 WebUI 链接发到公开网页。
- **`.gitignore` 补充 `.claude/`([#127](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/127),感谢 [@ItsEricRao](https://github.com/ItsEricRao))**:本地 Claude Code 配置不再被误提交(已从版本库取消跟踪,本地文件不受影响)。
### v1.10.1 — Jellyfin 可选音源
**Jellyfin 集成([PR #123](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/123),由 [@ItsEricRao](https://github.com/ItsEricRao) 贡献;随后调整为可选音源)**
+5
View File
@@ -3,10 +3,15 @@
"version": "0.1.0",
"description": "TeamSpeak music bot with NetEase Cloud Music and QQ Music support",
"type": "module",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && npm run build:web",
"build:web": "cd web && npm run build",
"check:native": "node scripts/check-native.mjs",
"prestart": "node scripts/check-native.mjs",
"start": "node dist/index.js",
"play": "node dist/index.js",
"test": "vitest run",
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
/**
* Preflight: can THIS Node build actually load the native modules that are
* sitting in node_modules?
*
* A compiled addon is tied to one Node ABI (process.versions.modules:
* Node 20 = 115, Node 22 = 127, Node 24 = 137). Install under one Node major,
* launch under another, and the bot dies deep inside startup with a
* `NODE_MODULE_VERSION ...` stack that says nothing about how to fix it.
* This script turns that into one actionable sentence, before anything starts.
*
* Exit code:
* 0 every required native module loads (or is simply not installed yet —
* that is npm install's problem, not an ABI problem)
* 1 a required native module definitively fails to load; the bot could not
* have started anyway, so there is no false-positive risk here.
*
* Usage: node scripts/check-native.mjs
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules");
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
const NODE_ABI = process.versions.modules;
/** Only the modules the bot cannot start without. ffmpeg-static is optional
* (a system ffmpeg on PATH works too), so it is not checked here. */
const REQUIRED = ["@discordjs/opus", "better-sqlite3"];
function pkgDirOf(spec) {
return join(NODE_MODULES, ...spec.split("/"));
}
function summarizeError(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
return (interesting || lines[0] || "unknown error").slice(0, 300);
}
/**
* The snippet that actually forces each package's addon to be dlopen()ed.
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor,
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI
* binary. Opening an in-memory database is the cheapest way to really load it.
*/
const PROBE_EXPR = {
"@discordjs/opus": "require('@discordjs/opus')",
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
};
/**
* Load-probe in a throwaway child process. Child process on purpose: requiring
* an addon in this process would keep the DLL mapped, and Windows then refuses
* to let setup.bat replace the file we just told the user to replace.
*/
function probeRequire(spec) {
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
try {
execFileSync(process.execPath, ["-e", expr], {
cwd: ROOT,
stdio: "pipe",
timeout: 120000,
windowsHide: true,
});
return { ok: true };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
// requires NODE_MODULE_VERSION 127..." -> first number is the build target.
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
return {
ok: false,
abiMismatch: abis.length >= 2,
compiledAbi: abis.length >= 2 ? abis[0] : null,
error: summarizeError(text),
};
}
}
function readStamp() {
try {
return JSON.parse(readFileSync(STAMP_FILE, "utf8"));
} catch {
return null;
}
}
const setupCmd = process.platform === "win32" ? "scripts\\setup.bat" : "bash scripts/setup.sh";
const broken = [];
for (const spec of REQUIRED) {
if (!existsSync(pkgDirOf(spec))) continue; // not installed yet -> npm install's job
const probe = probeRequire(spec);
if (!probe.ok) broken.push({ spec, ...probe });
}
function report() {
const stamp = readStamp();
const mismatch = broken.find((b) => b.abiMismatch);
const out = (line) => process.stderr.write(`${line}\n`);
out("");
out("============================================================");
if (mismatch) {
out(" [ERROR] 原生模块与当前 Node 版本不匹配");
out(" Native modules do not match this Node version");
} else {
out(" [ERROR] 原生模块无法加载 / native module failed to load");
}
out("============================================================");
out(` 本机 Node / running Node : ${process.version} (ABI ${NODE_ABI})`);
if (stamp && stamp.abi) {
out(` 安装时 Node / built with : ${stamp.nodeVersion || "?"} (ABI ${stamp.abi})`);
out(` ← node_modules/.tsmusicbot-abi, ${stamp.updatedAt || "?"}`);
}
out("");
for (const b of broken) {
if (b.abiMismatch) {
out(` x ${b.spec}: 本机 Node ${process.version} (ABI ${NODE_ABI}),`);
out(` 但 node_modules 里的原生模块是给 ABI ${b.compiledAbi} 编译的。`);
out(` built for ABI ${b.compiledAbi}, this Node needs ABI ${NODE_ABI}.`);
} else {
out(` x ${b.spec}: ${b.error}`);
}
}
out("");
out(" 怎么修 / How to fix:");
out(` 1) 重新运行安装脚本 / re-run setup: ${setupCmd}`);
out(" (它会自动为当前 Node 版本重新安装原生模块)");
out(" (setup now repairs the native modules for whatever Node you run)");
out(" 2) 或者换回安装时用的 Node 版本 / or switch back to the Node version");
out(" you installed with, then start again.");
out("============================================================");
out("");
}
if (broken.length > 0) {
report();
// exitCode rather than exit(): lets the message flush when stderr is piped.
process.exitCode = 1;
}
+5
View File
@@ -46,6 +46,11 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
# package.json declares a `prestart` preflight, so `npm start` inside the
# container needs this file. The image's own CMD calls node directly and never
# goes through npm, but an interactive `docker exec ... npm start` would
# otherwise die on a missing script rather than starting the bot.
COPY --from=builder /app/scripts/check-native.mjs ./scripts/check-native.mjs
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
+654 -97
View File
@@ -1,161 +1,718 @@
#!/usr/bin/env node
/**
* Download native binaries (ffmpeg + @discordjs/opus) from npmmirror CDN.
* Called by setup.bat after npm install --ignore-scripts.
* Verify / download / repair the native binaries used by TSMusicBot
* (ffmpeg-static + @discordjs/opus + better-sqlite3), preferring the
* npmmirror CDN so China users never have to reach GitHub.
*
* Called by setup.bat / setup.sh after `npm install --ignore-scripts`.
*
* WHY THIS IS NOT JUST A DOWNLOADER
* ---------------------------------
* A compiled addon only loads into the exact Node ABI it was built for
* (process.versions.modules: Node 20 = 115, Node 22 = 127, Node 24 = 137).
* better-sqlite3 stores its addon at an ABI-agnostic path
* (build/Release/better_sqlite3.node), so a "file exists and is big enough"
* check happily keeps a binary built for a *different* Node major around and
* the bot then dies with `NODE_MODULE_VERSION 137 ... requires 127`.
* So we validate by actually LOADING each package — in a short-lived child
* process, because on Windows a loaded .node stays mapped and the OS then
* refuses to delete or overwrite it.
*
* Every repair is staged and swapped in atomically: if a download fails we put
* the previous file back, so a failed run can never leave the install in a
* worse state than it started.
*
* Usage: node scripts/download-binaries.mjs [cdn_base_url]
* Env: TSMB_BINARY_LOG_STDOUT=1 also echo progress to stdout
* (setup.bat uses this to show progress live on stderr while stdout
* is redirected into setup.log)
*/
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
import {
chmodSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { basename, dirname, join } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { createWriteStream } from "node:fs";
import { get } from "node:https";
import { Readable } from "node:stream";
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const NODE_MODULES = join(ROOT, "node_modules");
const BACKUP_DIR = join(NODE_MODULES, ".tsmusicbot-backup");
const STAMP_FILE = join(NODE_MODULES, ".tsmusicbot-abi");
const CDN = process.argv[2] || "https://cdn.npmmirror.com/binaries";
const PLATFORM = process.platform;
const ARCH = process.arch;
const NODE_ABI = process.versions.modules;
function download(url) {
/** Modules the bot cannot start without. ffmpeg-static is optional: a system
* ffmpeg on PATH is a documented fallback, so it only ever produces a WARN. */
const REQUIRED = new Set(["@discordjs/opus", "better-sqlite3"]);
/** ffmpeg-static ships ~40-90 MB depending on platform; anything under this is
* certainly a truncated download, not a real build. */
const FFMPEG_MIN_BYTES = 20 * 1024 * 1024;
// ---------------------------------------------------------------------------
// logging
// ---------------------------------------------------------------------------
// Progress goes to stderr so setup.bat can show it live while stdout is being
// appended to setup.log. TSMB_BINARY_LOG_STDOUT=1 mirrors it into stdout so the
// log keeps the full transcript too.
const ECHO_STDOUT = process.env.TSMB_BINARY_LOG_STDOUT === "1";
function log(msg) {
const line = msg === "" ? "" : ` [binary] ${msg}`;
process.stderr.write(`${line}\n`);
if (ECHO_STDOUT) process.stdout.write(`${line}\n`);
}
// ---------------------------------------------------------------------------
// small helpers
// ---------------------------------------------------------------------------
function sizeOf(filePath) {
try {
return statSync(filePath).size;
} catch {
return 0;
}
}
function humanSize(filePath) {
const bytes = sizeOf(filePath);
if (!bytes) return "unknown size";
return bytes >= 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${(bytes / 1024).toFixed(0)} KB`;
}
function ensureExecutable(filePath) {
if (PLATFORM === "win32") return;
try {
chmodSync(filePath, 0o755);
} catch {
/* best effort */
}
}
/** Read the version actually present in node_modules (never hardcode it: the
* lockfile can be far ahead of whatever version this script was written for,
* and a wrong version means a 404 on the CDN). */
function readInstalledVersion(spec) {
try {
const pkgJson = join(NODE_MODULES, ...spec.split("/"), "package.json");
const version = JSON.parse(readFileSync(pkgJson, "utf8")).version;
return typeof version === "string" && version ? version : null;
} catch {
return null;
}
}
function summarizeError(text) {
const lines = String(text || "")
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
const interesting = lines.find((l) => /NODE_MODULE_VERSION|Error:|error:/.test(l));
return (interesting || lines[0] || "unknown error").slice(0, 300);
}
// ---------------------------------------------------------------------------
// download
// ---------------------------------------------------------------------------
function download(url, redirects = 0) {
return new Promise((resolve, reject) => {
const req = get(url, { timeout: 120000 }, (res) => {
if (res.statusCode < 200 || res.statusCode >= 400) {
reject(new Error(`HTTP ${res.statusCode}: ${url}`));
const { statusCode, headers } = res;
if (statusCode >= 300 && statusCode < 400 && headers.location) {
res.resume();
if (redirects >= 5) {
reject(new Error(`too many redirects: ${url}`));
return;
}
resolve(download(new URL(headers.location, url).toString(), redirects + 1));
return;
}
if (statusCode < 200 || statusCode >= 300) {
res.resume();
reject(new Error(`HTTP ${statusCode}: ${url}`));
return;
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("error", reject);
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")); });
req.on("timeout", () => {
req.destroy();
reject(new Error(`timeout: ${url}`));
});
});
}
function log(msg) {
console.log(` [binary] ${msg}`);
let tarModule = null;
/** `tar` is not a declared dependency — it only resolves transitively through
* prebuild-install / @discordjs/node-pre-gyp. Fail with a sentence a user can
* act on instead of a raw MODULE_NOT_FOUND stack. */
function loadTar() {
if (tarModule) return tarModule;
try {
tarModule = createRequire(import.meta.url)("tar");
} catch {
throw new Error(
"'tar' module not available / 找不到 tar 模块 — run `npm install tar` in the project root and retry",
);
}
return tarModule;
}
function isValidSize(filePath, minBytes) {
try { return statSync(filePath).size >= minBytes; } catch { return false; }
async function extractTarGz(buf, cwd) {
const tar = loadTar();
const tmpFile = join(tmpdir(), `tsmb-${process.pid}-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
try {
await tar.extract({ cwd, file: tmpFile });
} finally {
try {
rmSync(tmpFile, { force: true });
} catch {
/* ignore */
}
}
}
async function downloadFfmpeg() {
const ffDir = join(ROOT, "node_modules", "ffmpeg-static");
// ---------------------------------------------------------------------------
// load probe (the whole point of this rewrite)
// ---------------------------------------------------------------------------
/**
* The snippet that actually forces each package's addon to be dlopen()ed.
* NOTE: better-sqlite3 loads its .node lazily, inside the Database constructor
* (lib/database.js: `DEFAULT_ADDON || (DEFAULT_ADDON = require('bindings')(...))`),
* so a bare `require('better-sqlite3')` succeeds even against a wrong-ABI binary.
* Opening an in-memory database is the cheapest way to really load it.
*/
const PROBE_EXPR = {
"@discordjs/opus": "require('@discordjs/opus')",
"better-sqlite3": "new (require('better-sqlite3'))(':memory:').close()",
};
/**
* Try to load a package in a throwaway child process.
* Child process on purpose: loading an addon here would keep the DLL mapped and
* Windows would then refuse to rename/delete the file we are about to replace.
*/
function probeRequire(spec) {
const expr = PROBE_EXPR[spec] || `require(${JSON.stringify(spec)})`;
try {
execFileSync(process.execPath, ["-e", expr], {
cwd: ROOT,
stdio: "pipe",
timeout: 120000,
windowsHide: true,
});
return { ok: true };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
// "...compiled against ... NODE_MODULE_VERSION 137. This version of Node.js
// requires NODE_MODULE_VERSION 127..." -> first number is what it was built for.
const abis = [...text.matchAll(/NODE_MODULE_VERSION (\d+)/g)].map((m) => m[1]);
return {
ok: false,
abiMismatch: abis.length >= 2,
compiledAbi: abis.length >= 2 ? abis[0] : null,
error: summarizeError(text),
};
}
}
function describeProbe(probe) {
if (probe.abiMismatch) {
return `built for Node ABI ${probe.compiledAbi}, but this Node needs ABI ${NODE_ABI}`;
}
return probe.error;
}
function probeFfmpegBinary(bin) {
try {
const out = execFileSync(bin, ["-version"], {
stdio: "pipe",
timeout: 30000,
windowsHide: true,
}).toString();
return { ok: true, version: (out.split(/\r?\n/)[0] || "").slice(0, 60) };
} catch (err) {
const text = [err.stderr && err.stderr.toString(), err.message].filter(Boolean).join("\n");
return { ok: false, error: summarizeError(text) };
}
}
// ---------------------------------------------------------------------------
// atomic swap helpers
// ---------------------------------------------------------------------------
let stashCounter = 0;
/**
* Move `target` (file or directory) out of the way into node_modules/.tsmusicbot-backup.
* Same volume as node_modules, so the rename is atomic, and outside the package's
* build/ tree so that `npm rebuild` / `node-gyp clean` cannot wipe the backup.
* Returns { commit, restore } — call exactly one of them.
*/
/** Windows likes to hold a brief lock on a freshly written .node (antivirus,
* indexer), and rmSync does not retry by default. */
const RM_OPTS = { recursive: true, force: true, maxRetries: 5, retryDelay: 150 };
function stash(target) {
if (!existsSync(target)) {
return { commit() {}, restore() {} };
}
mkdirSync(BACKUP_DIR, { recursive: true });
const backup = join(BACKUP_DIR, `${basename(target)}.${process.pid}.${stashCounter++}.bak`);
rmSync(backup, RM_OPTS);
renameSync(target, backup);
// The backup filename alone cannot say where the artifact came from, and a
// run that is killed (Ctrl+C during a slow download) never reaches commit or
// restore. Record the target so the next run can put it back — see
// recoverOrphanedBackups().
const manifest = `${backup}.json`;
try {
writeFileSync(manifest, `${JSON.stringify({ target })}\n`);
} catch {
/* recovery is best-effort; the swap itself still works */
}
let settled = false;
const dropManifest = () => {
try {
rmSync(manifest, RM_OPTS);
} catch {
/* ignore */
}
};
return {
commit() {
if (settled) return;
settled = true;
try {
rmSync(backup, RM_OPTS);
} catch {
/* leftover backup is harmless */
}
dropManifest();
},
restore() {
if (settled) return;
settled = true;
try {
rmSync(target, RM_OPTS);
mkdirSync(dirname(target), { recursive: true });
renameSync(backup, target);
dropManifest();
log(`restored the previous ${basename(target)} — nothing was made worse`);
} catch (err) {
// Leave the backup AND its manifest in place: recoverOrphanedBackups()
// on the next run is the second chance.
log(`WARN: could not restore ${target} from ${backup}: ${err.message}`);
log(`WARN: the previous file is still at ${backup} — the next run will try again`);
}
},
};
}
/**
* Put back anything a previous run stashed but never restored — a run killed
* mid-download, or one whose restore() itself failed. Only acts when the target
* is currently absent, so it can never clobber a good binary.
*/
function recoverOrphanedBackups() {
if (!existsSync(BACKUP_DIR)) return;
let entries;
try {
entries = readdirSync(BACKUP_DIR);
} catch {
return;
}
for (const entry of entries) {
if (!entry.endsWith(".json")) continue;
const manifest = join(BACKUP_DIR, entry);
const backup = manifest.slice(0, -".json".length);
try {
const { target } = JSON.parse(readFileSync(manifest, "utf8"));
if (!target || !existsSync(backup)) {
rmSync(manifest, RM_OPTS);
continue;
}
if (existsSync(target)) continue; // a good file is already there — leave it alone
mkdirSync(dirname(target), { recursive: true });
renameSync(backup, target);
rmSync(manifest, RM_OPTS);
log(`recovered ${basename(target)} left behind by an interrupted run`);
} catch (err) {
log(`WARN: could not process leftover backup ${entry}: ${err.message}`);
}
}
}
function cleanupBackupDir() {
try {
if (existsSync(BACKUP_DIR) && readdirSync(BACKUP_DIR).length === 0) {
rmSync(BACKUP_DIR, { recursive: true, force: true });
}
} catch {
/* ignore */
}
}
// ---------------------------------------------------------------------------
// per-module results
// ---------------------------------------------------------------------------
/** status: "ok" | "repaired" | "failed" | "missing" */
function makeResult(name, status, detail) {
return { name, required: REQUIRED.has(name), status, detail };
}
function buildFromSource(command) {
// stdout -> inherited (setup.bat sends it to the log), stderr -> inherited so
// compiler progress stays visible; npm's own output is far too noisy to buffer.
execSync(command, { cwd: ROOT, stdio: ["ignore", "inherit", "inherit"] });
}
function buildToolsHint() {
log("Install build tools first:");
log(" Windows: npm install --global windows-build-tools (或安装 Visual Studio Build Tools + Python)");
log(" Ubuntu/Debian: sudo apt install build-essential python3");
log(" CentOS/RHEL: sudo yum groupinstall 'Development Tools'");
}
// ---------------------------------------------------------------------------
// ffmpeg-static (OPTIONAL — a system ffmpeg is a documented fallback)
// ---------------------------------------------------------------------------
async function ensureFfmpeg() {
const name = "ffmpeg-static";
const ffDir = join(NODE_MODULES, name);
const ffName = PLATFORM === "win32" ? "ffmpeg.exe" : "ffmpeg";
const ffDest = join(ffDir, ffName);
if (!existsSync(ffDir)) { log("ffmpeg-static not installed, skipping"); return false; }
if (existsSync(ffDest)) {
if (isValidSize(ffDest, 50 * 1024 * 1024)) {
log("ffmpeg already exists, skipping");
return true;
if (!existsSync(ffDir)) {
log(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`);
return makeResult(name, "missing", "package not installed");
}
if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) {
ensureExecutable(ffDest);
const probe = probeFfmpegBinary(ffDest);
if (probe.ok) {
log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`);
return makeResult(name, "ok", humanSize(ffDest));
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
// Deliberately NOT re-downloading here: ffmpeg is a plain executable with no
// ABI to mismatch, and forcing an ~80 MB re-download because `-version`
// could not be spawned would hurt exactly the slow-network users this
// script exists for.
log(`${name}: present (${humanSize(ffDest)}) but could not be executed: ${probe.error}`);
return makeResult(name, "ok", "present, not verified");
}
if (existsSync(ffDest)) {
log(`${name}: existing ffmpeg looks truncated (${humanSize(ffDest)}), re-downloading...`);
} else {
log(`${name}: ffmpeg binary missing, downloading...`);
}
const url = `${CDN}/ffmpeg-static/b6.1.1/ffmpeg-${PLATFORM}-${ARCH}.gz`;
log("Downloading ffmpeg...");
const buf = await download(url);
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(ffDest));
try { execSync(`chmod +x "${ffDest}"`); } catch {}
const size = ((await statSync(ffDest)).size / 1024 / 1024).toFixed(1);
log(`ffmpeg OK (${size} MB)`);
return true;
}
async function downloadOpus() {
const opusDir = join(ROOT, "node_modules", "@discordjs", "opus");
const prebuildName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const opusDest = join(opusDir, "prebuild", prebuildName, "opus.node");
if (!existsSync(opusDir)) { log("@discordjs/opus not installed, skipping"); return false; }
if (existsSync(opusDest)) {
if (isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus already exists, skipping");
return true;
}
log("@discordjs/opus exists but seems corrupted (too small), re-downloading...");
}
const url = `${CDN}/@discordjs/opus/v0.10.0/opus-v0.10.0-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
log("Downloading @discordjs/opus...");
const backup = stash(ffDest);
const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`;
try {
log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`);
const buf = await download(url);
mkdirSync(dirname(opusDest), { recursive: true });
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `discordjs-opus-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
await tar.extract({ cwd: join(opusDir, "prebuild"), file: tmpFile });
log("@discordjs/opus OK");
return true;
await pipeline(Readable.from(buf), createGunzip(), createWriteStream(tmpDest));
ensureExecutable(tmpDest);
if (sizeOf(tmpDest) < FFMPEG_MIN_BYTES) {
throw new Error(`downloaded ffmpeg is only ${humanSize(tmpDest)} — truncated`);
}
renameSync(tmpDest, ffDest); // atomic swap, same directory
backup.commit();
log(`${name}: OK (${humanSize(ffDest)})`);
return makeResult(name, "repaired", humanSize(ffDest));
} catch (err) {
log(`CDN download failed (${err.message}), trying to build from source...`);
try {
execSync("npm rebuild @discordjs/opus", { cwd: ROOT, stdio: "inherit" });
if (existsSync(opusDest) && isValidSize(opusDest, 100 * 1024)) {
log("@discordjs/opus built from source OK");
return true;
rmSync(tmpDest, { force: true });
} catch {
/* ignore */
}
backup.restore();
log(`${name}: download failed — ${err.message}`);
log(`${name}: not fatal — install ffmpeg system-wide and put it on PATH instead`);
return makeResult(name, "failed", err.message);
}
}
// ---------------------------------------------------------------------------
// @discordjs/opus (REQUIRED)
// ---------------------------------------------------------------------------
async function ensureOpus() {
const name = "@discordjs/opus";
const pkgDir = join(NODE_MODULES, "@discordjs", "opus");
const prebuildRoot = join(pkgDir, "prebuild");
// node-pre-gyp resolves this directory from the *running* Node's ABI, so a
// stale build for another ABI simply sits at another path and is ignored.
const prebuildDirName = `node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown`;
const destDir = join(prebuildRoot, prebuildDirName);
if (!existsSync(pkgDir)) {
log(`${name}: package not installed — run 'npm install' first`);
return makeResult(name, "missing", "package not installed");
}
const before = probeRequire(name);
if (before.ok) {
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
}
log(`${name}: unusable — ${describeProbe(before)}`);
log(`${name}: installing a build for ABI ${NODE_ABI}...`);
const version = readInstalledVersion(name) || "0.10.0";
const url =
`${CDN}/@discordjs/opus/v${version}/opus-v${version}` +
`-node-v${NODE_ABI}-napi-v3-${PLATFORM}-${ARCH}-unknown-unknown.tar.gz`;
const backup = stash(destDir);
let staging = null;
try {
try {
log(`${name}: GET ${url}`);
const buf = await download(url);
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
await extractTarGz(buf, staging);
const staged = join(staging, prebuildDirName);
if (!existsSync(join(staged, "opus.node"))) {
throw new Error(`tarball did not contain ${prebuildDirName}/opus.node`);
}
mkdirSync(prebuildRoot, { recursive: true });
rmSync(destDir, { recursive: true, force: true });
renameSync(staged, destDir); // atomic swap, same volume
log(`${name}: prebuilt binary installed`);
} catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`);
log(`${name}: falling back to a source build — 'npm rebuild ${name}' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name}`);
}
const after = probeRequire(name);
if (!after.ok) throw new Error(describeProbe(after));
backup.commit();
log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI})`);
return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
} catch (err) {
backup.restore();
log(`${name}: FAILED — ${err.message}`);
buildToolsHint();
return makeResult(name, "failed", err.message);
} finally {
if (staging) {
try {
rmSync(staging, { recursive: true, force: true });
} catch {
/* ignore */
}
log("Source build completed but .node file not found");
return false;
} catch (buildErr) {
log(`Source build failed: ${buildErr.message}`);
log("Install build tools: sudo apt install build-essential (Ubuntu/Debian)");
log(" sudo yum groupinstall 'Development Tools' (CentOS/RHEL)");
return false;
}
}
}
async function downloadBetterSqlite3() {
const pkgDir = join(ROOT, "node_modules", "better-sqlite3");
// ---------------------------------------------------------------------------
// better-sqlite3 (REQUIRED) — the module the ABI bug actually bites
// ---------------------------------------------------------------------------
async function ensureBetterSqlite3() {
const name = "better-sqlite3";
const pkgDir = join(NODE_MODULES, name);
const dest = join(pkgDir, "build", "Release", "better_sqlite3.node");
if (!existsSync(pkgDir)) { log("better-sqlite3 not installed, skipping"); return false; }
if (existsSync(dest)) {
if (isValidSize(dest, 500 * 1024)) {
log("better-sqlite3 already exists, skipping");
return true;
}
log("better-sqlite3 exists but seems corrupted (too small), re-downloading...");
if (!existsSync(pkgDir)) {
log(`${name}: package not installed — run 'npm install' first`);
return makeResult(name, "missing", "package not installed");
}
const version = "12.8.0";
const url = `${CDN}/better-sqlite3/v${version}/better-sqlite3-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
log("Downloading better-sqlite3...");
const buf = await download(url);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
mkdirSync(dirname(dest), { recursive: true });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return true;
const before = probeRequire(name);
if (before.ok) {
log(`${name}: OK (loads under ${process.version}, ABI ${NODE_ABI})`);
return makeResult(name, "ok", `ABI ${NODE_ABI}`);
}
// This is the case the old size check could not see: the file is there, it is
// ~1.9 MB, and it is completely useless because it targets another ABI.
log(`${name}: unusable — ${describeProbe(before)}`);
log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`);
const version = readInstalledVersion(name) || "12.11.1";
const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
const backup = stash(dest);
let staging = null;
try {
try {
log(`${name}: GET ${url}`);
const buf = await download(url);
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
await extractTarGz(buf, staging);
const staged = join(staging, "build", "Release", "better_sqlite3.node");
if (!existsSync(staged)) {
throw new Error("tarball did not contain build/Release/better_sqlite3.node");
}
mkdirSync(dirname(dest), { recursive: true });
rmSync(dest, { force: true });
renameSync(staged, dest); // atomic swap, same volume
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
} catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`);
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name} --build-from-source`);
}
const after = probeRequire(name);
if (!after.ok) throw new Error(describeProbe(after));
backup.commit();
log(`${name}: repaired, now loads under ${process.version} (ABI ${NODE_ABI}, ${humanSize(dest)})`);
return makeResult(name, "repaired", `ABI ${NODE_ABI}`);
} catch (err) {
backup.restore();
log(`${name}: FAILED — ${err.message}`);
buildToolsHint();
return makeResult(name, "failed", err.message);
} finally {
if (staging) {
try {
rmSync(staging, { recursive: true, force: true });
} catch {
/* ignore */
}
}
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
}
// ---------------------------------------------------------------------------
// stamp
// ---------------------------------------------------------------------------
/** Record which ABI this install was built for. Lives inside node_modules so it
* dies together with the thing it describes. check-native.mjs reads it. */
function writeStamp(results) {
if (!existsSync(NODE_MODULES)) return;
const stamp = {
abi: NODE_ABI,
nodeVersion: process.version,
platform: PLATFORM,
arch: ARCH,
updatedAt: new Date().toISOString(),
modules: Object.fromEntries(results.map((r) => [r.name, r.status])),
};
try {
writeFileSync(STAMP_FILE, `${JSON.stringify(stamp, null, 2)}\n`);
log(`ABI stamp written: node_modules/.tsmusicbot-abi (Node ${process.version}, ABI ${NODE_ABI})`);
} catch (err) {
log(`WARN: could not write ABI stamp: ${err.message}`);
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
const STEPS = [
["ffmpeg-static", ensureFfmpeg],
["@discordjs/opus", ensureOpus],
["better-sqlite3", ensureBetterSqlite3],
];
try {
const results = await Promise.all([downloadFfmpeg(), downloadOpus(), downloadBetterSqlite3()]);
if (results.some(Boolean)) {
console.log(" [binary] All downloads complete");
log(`Node ${process.version} (ABI ${NODE_ABI}), ${PLATFORM}-${ARCH}, CDN ${CDN}`);
recoverOrphanedBackups();
// STRICTLY SEQUENTIAL, and it has to stay that way. The source-build fallback
// shells out through execSync, which parks the event loop for minutes; the
// 120s timeout that download() arms is a socket-INACTIVITY timer sitting on
// that same loop. Run these concurrently and the first module to fall back to
// a source build kills every download still in flight — the connection is
// healthy, the timer just never got a chance to be reset. That is not a rare
// race: npmmirror has no opus prebuild for ABI 137 (Node 24) and no
// better-sqlite3 prebuild for ABI 115 (Node 20), so on both of the Node
// versions this project supports, one module 404s within ~100ms and starts
// building while ffmpeg's ~80MB download is still going. ffmpeg is optional,
// so the spurious failure used to be swallowed as a WARN and setup still
// reported success — leaving the user with no ffmpeg and no working playback.
// Nothing here benefits from overlap anyway: every probe is execFileSync.
const results = [];
for (const [name, run] of STEPS) {
try {
results.push(await run());
} catch (err) {
results.push(makeResult(name, "failed", err?.message ?? String(err)));
}
}
cleanupBackupDir();
log("");
log(`Summary — Node ${process.version} / ABI ${NODE_ABI} / ${PLATFORM}-${ARCH}:`);
for (const r of results) {
const tag =
r.status === "ok"
? "OK"
: r.status === "repaired"
? "REPAIRED"
: r.required
? "FAILED"
: "WARN (optional)";
log(` - ${r.name.padEnd(17)} ${tag}${r.detail ? ` ${r.detail}` : ""}`);
}
const broken = results.filter(
(r) => r.required && r.status !== "ok" && r.status !== "repaired",
);
// Only stamp a build that actually succeeded. The stamp says "node_modules is
// built for ABI X"; writing it after a failed repair would have check-native
// print a reassuring "built with ABI 137" right above its own "this module is
// built for ABI 127" complaint.
if (broken.length === 0) writeStamp(results);
// process.exitCode rather than process.exit(): setup.bat redirects stdout to
// setup.log, and process.exit() can drop output that has not flushed yet.
if (broken.length > 0) {
log("");
log(`ERROR: required native module(s) unusable: ${broken.map((r) => r.name).join(", ")}`);
log("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。");
process.exitCode = 1;
} else {
log("All required native modules are ready.");
process.exitCode = 0;
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
log(`ERROR: ${e.stack || e.message}`);
process.exitCode = 1;
}
+62 -10
View File
@@ -10,8 +10,11 @@ title TSMusicBot Setup
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=20"
:: Newest Node major this project is regularly tested against. Anything above
:: still works, it just may have no prebuilt addons and fall back to a source build.
set "TESTED_NODE_MAJOR=22"
set "LOG_FILE=%~dp0..\setup.log"
set "FAILED=0"
@@ -64,13 +67,38 @@ for /f "tokens=1 delims=v." %%a in ("%NODE_VER%") do set "NODE_MAJOR=%%a"
call :log "Node.js version: %NODE_VER%"
echo [OK] Node.js found: %NODE_VER%
if %NODE_MAJOR% LSS %MIN_NODE_MAJOR% (
call :error "Node.js version too old. Need %MIN_NODE_MAJOR%+, found %NODE_VER%."
:: The supported floor is not just a major version, so let node decide:
:: @honeybbq/teamspeak-client needs >=20.19, @sansenjian/qq-music-api needs
:: >=20.17 / >=22.9, and the odd majors (21 / 23) are excluded by
:: better-sqlite3 and vitest. Keep this in sync with "engines" in package.json.
node -e "const v=process.versions.node.split('.').map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)"
if errorlevel 1 (
call :error "Node.js %NODE_VER% is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
echo Download: https://nodejs.org/ or https://nodejs.cn/
pause
exit /b 1
)
:: Not fatal: setup now rebuilds the native modules for whatever ABI you run,
:: so newer Node majors work - they are just slower to install.
:: NOTE: keep every line inside these parenthesised blocks pure ASCII.
:: cmd.exe mis-tracks its file offset when a block contains multi-byte UTF-8
:: characters and starts eating the "echo " prefix of following lines.
:: Bilingual guidance lives in the Node scripts, which print UTF-8 reliably.
if %NODE_MAJOR% GTR %TESTED_NODE_MAJOR% (
echo [WARN] Node %NODE_VER% is newer than the tested LTS line, Node 20 / Node 22.
echo Newer Node majors may have no prebuilt opus / better-sqlite3,
echo so setup falls back to a source build - slower, needs C++ build tools.
echo Recommended: Node 20 LTS or Node 22 LTS - https://nodejs.org/ or https://nodejs.cn/
echo This is only a warning; setup still builds the binaries for %NODE_VER%.
call :log "[WARN] Node major %NODE_MAJOR% is newer than tested LTS %TESTED_NODE_MAJOR%"
)
echo.
:: Native addons are tied to one Node ABI. If node_modules was built by a
:: different Node major, step 4b below detects it and repairs it.
call :log "Node ABI for this install: see node_modules\.tsmusicbot-abi after step 4b"
:: ============================================================
:: Step 2: Check npm
:: ============================================================
@@ -143,16 +171,40 @@ echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Download native binaries from CDN
:: Step 4b: Verify / download / repair native binaries (ABI aware)
:: ============================================================
call :step "4b/7" "Downloading native binaries"
call :step "4b/7" "Checking native binaries"
node scripts/download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
echo [WARN] Binary download had issues. Check %LOG_FILE% for details.
) else (
echo [OK] Native binaries installed.
echo Verifying native modules for %NODE_VER% and downloading whatever is missing.
echo Progress is shown below; the full transcript goes to the log file.
echo.
:: The .mjs writes progress to stderr and - with TSMB_BINARY_LOG_STDOUT=1 - the
:: same lines to stdout. Redirecting only stdout therefore keeps the log complete
:: while the user still sees live progress instead of a frozen window.
set "TSMB_BINARY_LOG_STDOUT=1"
node scripts\download-binaries.mjs %CDN_MIRROR% >>"%LOG_FILE%"
set "BIN_RESULT=!errorlevel!"
set "TSMB_BINARY_LOG_STDOUT="
:: ASCII only inside these blocks - see the note near the Node version check.
if not "!BIN_RESULT!"=="0" (
set "FAILED=1"
call :error "A required native module is unusable - see the [binary] lines above."
echo Required: @discordjs/opus and better-sqlite3.
echo Full log: %LOG_FILE%
)
if "!FAILED!"=="1" (
echo.
echo Setup aborted. Fix the problem above and run this script again.
call :log "Setup aborted at step 4b"
pause
exit /b 1
)
echo [OK] Native binaries ready for %NODE_VER%.
echo ABI recorded in node_modules\.tsmusicbot-abi
echo.
:: ============================================================
+41 -6
View File
@@ -27,6 +27,26 @@ if ! command -v node &>/dev/null; then
fi
echo "[OK] Node.js $(node -v)"
# Newest Node major this project is regularly tested against. Anything above
# still works, it just may have no prebuilt addons and fall back to a source build.
TESTED_NODE_MAJOR=22
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
# The floor is not just a major version, so let node decide: @honeybbq/teamspeak-client
# needs >=20.19, @sansenjian/qq-music-api needs >=20.17 / >=22.9, and the odd majors
# (21 / 23) are excluded by better-sqlite3 and vitest. Keep in sync with package.json "engines".
if ! node -e 'const v=process.versions.node.split(".").map(Number); process.exit((v[0]===20&&v[1]>=19)||(v[0]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 20.19+ LTS or Node 22.12+ LTS."
echo " https://nodejs.org/ | https://nodejs.cn/"
exit 1
fi
if [ "$NODE_MAJOR" -gt "$TESTED_NODE_MAJOR" ]; then
echo "[WARN] Node $(node -v) is newer than the tested LTS line (Node 20 / Node 22)."
echo " 新版 Node 可能没有现成的 opus / better-sqlite3 预编译包,"
echo " 安装时会自动改用源码编译,需要 C/C++ 构建工具,速度较慢。"
echo " This is only a warning - setup builds the binaries for $(node -v) either way."
fi
if ! command -v npm &>/dev/null; then
echo "[ERROR] npm not found."
exit 1
@@ -73,15 +93,30 @@ npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_F
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Download native binaries from CDN ----
echo "---- 2/5: Downloading native binaries ----"
# ---- Step 2: Verify / download / repair native binaries (ABI aware) ----
echo "---- 2/5: Checking native binaries ----"
echo ""
if node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"; then
echo "[OK] Native binaries installed."
else
echo "[WARN] Some native binaries had issues (will try source build as fallback)."
# The old `if node ... | tee ...` only printed a [WARN] and carried on, so a
# broken native module still produced a "Setup Complete!" banner. It also read
# the *pipeline's* status: `set -o pipefail` above happens to surface node's
# failure, but a failing `tee` (unwritable log) was indistinguishable from a
# failing node. PIPESTATUS[0] is exactly node's own exit code, nothing else.
set +e
node scripts/download-binaries.mjs $CDN_MIRROR 2>&1 | tee -a "$LOG_FILE"
BIN_STATUS=${PIPESTATUS[0]}
set -e
if [ "$BIN_STATUS" -ne 0 ]; then
echo ""
echo "[ERROR] A required native module (@discordjs/opus / better-sqlite3) is unusable."
echo " 必需的原生模块不可用,安装中止。原因见上面的 [binary] 输出。"
echo " Log: $LOG_FILE"
exit 1
fi
# ffmpeg-static failures are only a WARN inside the script above (a system
# ffmpeg on PATH is a supported fallback), so reaching here means we are good.
echo "[OK] Native binaries ready for $(node -v)."
echo ""
# ---- Step 3: Install web panel dependencies ----
+17 -1
View File
@@ -29,6 +29,19 @@ if not exist "dist" (
exit /b 1
)
:: Preflight: do the compiled native modules match THIS Node version?
:: Switching Node majors after setup leaves node_modules built for the old ABI;
:: without this check the bot dies mid-startup with a NODE_MODULE_VERSION stack.
:: check-native.mjs prints the bilingual explanation itself; keep the lines in
:: this block pure ASCII (cmd.exe garbles multi-byte text inside blocks).
node scripts\check-native.mjs
if errorlevel 1 (
echo.
echo Please run scripts\setup.bat to rebuild the native modules.
pause
exit /b 1
)
:: Ensure PowerShell is in PATH (fix for jdymusic CDN playback on some systems)
where powershell >nul 2>&1
if errorlevel 1 (
@@ -38,7 +51,10 @@ if errorlevel 1 (
)
:: Start the application
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist/index.js
pause
+72
View File
@@ -216,6 +216,78 @@ const silentLogger = {
},
} as unknown as Logger;
function applyPlayerVolume(player: AudioPlayer, pcm: Buffer): Buffer {
return (
player as unknown as { applyVolume(input: Buffer): Buffer }
).applyVolume(pcm);
}
function stereoPcm(sample: number, frames = 2): Buffer {
const pcm = Buffer.alloc(frames * 4);
for (let offset = 0; offset < pcm.length; offset += 2) {
pcm.writeInt16LE(sample, offset);
}
return pcm;
}
describe("AudioPlayer transient ducking gain", () => {
it("layers ducking on the PCM path without changing the user's base volume", () => {
const player = new AudioPlayer(silentLogger);
player.setVolume(100);
player.setDuckingGain(0.3);
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
expect(adjusted.readInt16LE(0)).toBe(3_000);
expect(adjusted.readInt16LE(2)).toBe(3_000);
expect(player.getVolume()).toBe(100);
expect(player.getDuckingGain()).toBe(0.3);
});
it("multiplies the transient gain by the existing base-volume curve", () => {
const player = new AudioPlayer(silentLogger);
player.setVolume(50);
player.setDuckingGain(0.5);
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
expect(adjusted.readInt16LE(0)).toBe(
Math.round(10_000 * volumeToFactor(50) * 0.5),
);
});
it("interpolates ramps smoothly across each stereo PCM frame", () => {
let now = 100;
const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
try {
const player = new AudioPlayer(silentLogger);
player.setVolume(100);
player.setDuckingGain(0.2, 100);
now = 150;
expect(player.getDuckingGain()).toBeCloseTo(0.6, 8);
const adjusted = applyPlayerVolume(player, stereoPcm(10_000));
// At t=150 the ramp is 0.6; at the end of this 20 ms frame it is 0.44.
expect(adjusted.readInt16LE(0)).toBe(6_000);
expect(adjusted.readInt16LE(2)).toBe(6_000);
expect(adjusted.readInt16LE(4)).toBe(4_400);
expect(adjusted.readInt16LE(6)).toBe(4_400);
} finally {
nowSpy.mockRestore();
}
});
it("clamps transient gain and ignores a non-finite update", () => {
const player = new AudioPlayer(silentLogger);
player.setDuckingGain(-1);
expect(player.getDuckingGain()).toBe(0);
player.setDuckingGain(2);
expect(player.getDuckingGain()).toBe(1);
player.setDuckingGain(Number.NaN);
expect(player.getDuckingGain()).toBe(1);
});
});
// A readable we fully control: no underlying source; we push PCM manually and
// keep it open (never push(null)) to model the long-lived go-librespot sidecar.
function openPcmReadable(): Readable {
+67 -5
View File
@@ -161,6 +161,15 @@ export class AudioPlayer extends EventEmitter {
private encoder: Encoder;
private state: PlayerState = "idle";
private volume = 75;
/**
* A transient gain envelope layered on top of the persisted user volume.
* Voice ducking drives this value; keeping it separate means a temporary
* attenuation can never leak into the saved volume setting.
*/
private duckingRampStartGain = 1;
private duckingTargetGain = 1;
private duckingRampStartedAt = 0;
private duckingRampDurationMs = 0;
private pcmBuffer: Buffer = Buffer.alloc(0);
private logger: Logger;
private frameLoopRunning = false;
@@ -732,17 +741,54 @@ export class AudioPlayer extends EventEmitter {
}
private applyVolume(pcm: Buffer): Buffer {
const factor = volumeToFactor(this.volume);
// factor === 1 only at volume 100; skip the per-sample loop at full loudness.
if (factor >= 1) return Buffer.from(pcm);
const baseFactor = volumeToFactor(this.volume);
const now = performance.now();
const startDuckingGain = this.duckingGainAt(now);
const endDuckingGain = this.duckingGainAt(now + FRAME_DURATION_MS);
const startFactor = baseFactor * startDuckingGain;
const endFactor = baseFactor * endDuckingGain;
if (startFactor >= 1 && endFactor >= 1) {
return Buffer.from(pcm);
}
const out = Buffer.alloc(pcm.length);
// Most frames are outside the short attack/release windows. Preserve the
// old constant-factor hot path instead of doing interpolation per sample.
if (startFactor === endFactor) {
for (let i = 0; i < pcm.length; i += 2) {
const sample = Math.round(pcm.readInt16LE(i) * startFactor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
// PCM is fixed at stereo s16le. Use one gain for each L/R pair so a ramp
// never creates a tiny channel imbalance, and span the whole 20 ms frame.
const stereoFrames = Math.max(1, Math.ceil(pcm.length / 4));
for (let i = 0; i < pcm.length; i += 2) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
const frameIndex = Math.floor(i / 4);
const progress = stereoFrames === 1 ? 0 : frameIndex / (stereoFrames - 1);
const factor = startFactor + (endFactor - startFactor) * progress;
const sample = Math.round(pcm.readInt16LE(i) * factor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
private duckingGainAt(at: number): number {
if (this.duckingRampDurationMs <= 0) return this.duckingTargetGain;
const progress = Math.max(
0,
Math.min(1, (at - this.duckingRampStartedAt) / this.duckingRampDurationMs),
);
return (
this.duckingRampStartGain +
(this.duckingTargetGain - this.duckingRampStartGain) * progress
);
}
// NOTE: in external (Spotify sidecar) mode getElapsed() is frame-count based
// (framesPlayed includes silence frames emitted on underrun) and therefore
// only APPROXIMATE — the authoritative position is the controller's live
@@ -762,9 +808,25 @@ export class AudioPlayer extends EventEmitter {
resetFailures(): void { this.consecutiveFailures = 0; }
setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); }
getVolume(): number { return this.volume; }
/** Set the temporary voice-ducking gain (0=silent, 1=unchanged). */
setDuckingGain(gain: number, rampMs = 0): void {
if (!Number.isFinite(gain)) return;
const now = performance.now();
const currentGain = this.duckingGainAt(now);
const targetGain = Math.max(0, Math.min(1, gain));
const duration = Number.isFinite(rampMs) ? Math.max(0, rampMs) : 0;
this.duckingRampStartGain = currentGain;
this.duckingTargetGain = targetGain;
this.duckingRampStartedAt = now;
this.duckingRampDurationMs =
duration > 0 && currentGain !== targetGain ? duration : 0;
}
getDuckingGain(): number { return this.duckingGainAt(performance.now()); }
getState(): PlayerState { return this.state; }
// True only while attached to an external (Spotify sidecar) PCM stream. Used
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
// externalMode=false) so this is false after any player.stop().
isExternalActive(): boolean { return this.externalMode; }
}
}
+137
View File
@@ -605,4 +605,141 @@ describe("PlayQueue", () => {
expect(q.list().map((s) => s.id)).toEqual(["A"]);
});
});
// Issue #141: in Random/RandomLoop, next() picks from the shuffle bag and
// ignores array order, so a song spliced in by addNext (!pn) was NOT played
// next — it just waited for its random turn like any other song. addNext now
// records the insert slot on the forward stack, which next() honours first.
describe("addNext in random modes (issue #141)", () => {
for (const mode of [PlayMode.Random, PlayMode.RandomLoop]) {
it(`plays the inserted song next in ${mode} mode`, () => {
queue.setMode(mode);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.play(); // current = 0 (a)
queue.addNext(makeSong("x"));
expect(queue.next()?.id).toBe("x");
});
}
it("plays consecutive inserts in the order the queue displays them", () => {
queue.setMode(PlayMode.RandomLoop);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.play(); // current = 0 (a)
queue.addNext(makeSong("x"));
queue.addNext(makeSong("y")); // splices in front of x, as in sequential
expect(queue.list().map((s) => s.id)).toEqual(["a", "y", "x", "b", "c", "d"]);
expect(queue.next()?.id).toBe("y");
expect(queue.next()?.id).toBe("x");
});
it("honours the insert even after the shuffle bag is exhausted", () => {
// Random (non-loop) returns null once every song has played. Songs added
// afterwards must still be reachable via !pn — and with TWO of them the
// order can only come from the forward stack, not from the bag having a
// single remaining candidate.
queue.setMode(PlayMode.Random);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.play();
for (let i = 0; i < 3; i++) queue.next();
expect(queue.next()).toBeNull(); // bag exhausted
queue.addNext(makeSong("x"));
queue.addNext(makeSong("y"));
queue.addNext(makeSong("z"));
expect(queue.next()?.id).toBe("z");
expect(queue.next()?.id).toBe("y");
expect(queue.next()?.id).toBe("x");
});
it("pops past a prev() marker to reach the pending insert", () => {
// prev() shares the forward stack, and in random mode with no history it
// pushes the current index and then returns null. next() must walk past
// those self-referencing markers instead of consuming one and giving up
// to the shuffle bag.
queue.setMode(PlayMode.Random);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.play(); // a
queue.addNext(makeSong("x"));
expect(queue.prev()).toBeNull();
expect(queue.prev()).toBeNull();
expect(queue.next()?.id).toBe("x");
});
it("plays each song exactly once — the insert is not replayed later", () => {
queue.setMode(PlayMode.Random);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.play(); // a
queue.addNext(makeSong("x"));
queue.addNext(makeSong("y"));
const played = [queue.current()!.id];
for (let i = 0; i < 5; i++) played.push(queue.next()!.id);
expect(queue.next()).toBeNull(); // bag exhausted
expect(played.slice(0, 3)).toEqual(["a", "y", "x"]);
expect(new Set(played).size).toBe(6);
});
it("keeps the insert reachable after an earlier song is removed", () => {
queue.setMode(PlayMode.RandomLoop);
for (const id of ["a", "b", "c", "d"]) queue.add(makeSong(id));
queue.playAt(2); // current = 2 (c)
queue.addNext(makeSong("x")); // [a, b, c, x, d]
queue.remove(0); // [b, c, x, d] — x slides from 3 to 2
expect(queue.next()?.id).toBe("x");
});
it("drops the entry when the inserted song is itself removed", () => {
// Leaving the stale entry behind would not throw — index 2 still exists
// after the removal, it just points at a different song. So the queue is
// arranged with exactly one song the shuffle bag can legally return:
// anything else means the dead forward entry was honoured.
queue.setMode(PlayMode.RandomLoop);
for (const id of ["a", "b", "c"]) queue.add(makeSong(id));
queue.playAt(0); // current = 0 (a), played = {0}
queue.next(); // b or c — two of the three are now played
const remaining = queue.list().find((s) => s.id !== "a" && s.id !== queue.current()!.id)!;
queue.addNext(makeSong("x")); // spliced at currentIndex+1
queue.remove(queue.getCurrentIndex() + 1); // …and removed again
expect(queue.list().map((s) => s.id)).not.toContain("x");
expect(queue.next()?.id).toBe(remaining.id);
});
it("never yields a stale index under interleaved inserts and removals", () => {
// The forward stack holds array indices, so every splice has to shift
// them. next() returning `undefined` here (an out-of-range index) reads
// as end-of-queue to BotInstance.playNext and silently stops playback.
queue.setMode(PlayMode.RandomLoop);
for (let i = 0; i < 6; i++) queue.add(makeSong(`s${i}`));
queue.play();
for (let step = 0; step < 200; step++) {
const roll = step % 4;
if (roll === 0) queue.addNext(makeSong(`x${step}`));
else if (roll === 1 && queue.size() > 1) queue.remove(step % queue.size());
else {
const song = queue.next();
expect(song === null || song === queue.current()).toBe(true);
if (song !== null) expect(song).toBeDefined();
}
}
});
it("leaves sequential/loop behaviour untouched", () => {
queue.setMode(PlayMode.Sequential);
for (const id of ["a", "b", "c"]) queue.add(makeSong(id));
queue.play(); // a
queue.addNext(makeSong("x"));
expect(queue.next()?.id).toBe("x");
expect(queue.next()?.id).toBe("b");
expect(queue.next()?.id).toBe("c");
expect(queue.next()).toBeNull();
});
it("still appends (no forward entry) when nothing is playing", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.addNext(makeSong("x")); // currentIndex is still -1 → plain push
expect(queue.list().map((s) => s.id)).toEqual(["a", "x"]);
queue.play(); // a — a stray forward entry would have hijacked this
expect(queue.current()?.id).toBe("a");
});
});
});
+45 -10
View File
@@ -60,8 +60,12 @@ export class PlayQueue {
* or queue empty), so the existing "add → idle bot starts playing"
* flow continues to work.
*
* Shifts playedIndices and history entries > currentIndex by +1 so
* their references stay valid after the splice.
* Shifts playedIndices, history and forwardStack entries > currentIndex
* by +1 so their references stay valid after the splice.
*
* In the random modes the array position alone means nothing — next()
* picks from the shuffle bag — so the insert slot is also recorded on
* the forward stack, which next() consults first (issue #141).
*/
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
@@ -80,6 +84,23 @@ export class PlayQueue {
this.history = this.history.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
this.forwardStack = this.forwardStack.map((i) =>
i > this.currentIndex ? i + 1 : i,
);
// Push AFTER the shift, or the slot we just claimed would be shifted
// too. Stacking makes repeated !pn play in the order the queue shows
// them (each insert lands in front of the previous one), matching what
// sequential mode does with the same array. Bounded like history: drop the
// OLDEST pending entry rather than refusing the newest, so the song the
// user just asked for is always the one that gets honoured.
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
this.forwardStack.push(insertAt);
if (this.forwardStack.length > PlayQueue.HISTORY_LIMIT) {
this.forwardStack.shift();
}
}
}
remove(index: number): QueuedSong | null {
@@ -106,6 +127,13 @@ export class PlayQueue {
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
// …and for the forward stack, which now also carries !pn insert slots
// (issue #141). Left unshifted, a removal elsewhere in the queue would
// silently repoint the entry at whatever song slid into that slot.
this.forwardStack = this.forwardStack
.filter((idx) => idx !== index)
.map((idx) => (idx > index ? idx - 1 : idx));
return removed;
}
@@ -158,15 +186,22 @@ export class PlayQueue {
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
// 优先回到前进栈记录的位置(prev 退回的歌)
if (this.forwardStack.length > 0) {
// 优先回到前进栈记录的位置(prev 退回的歌,或 !pn 插入的歌)。
// Keep popping past entries that no longer point anywhere useful,
// the way prev() walks past stale history entries. Without the loop a
// prev() that pushed the current index would swallow the pending !pn
// entry behind it. The range check is belt-and-braces — addNext and
// remove keep the stack in sync — but an out-of-range index here would
// set currentIndex out of bounds and hand back `undefined`, which
// BotInstance.playNext reads as end-of-queue and stops playback.
while (this.forwardStack.length > 0) {
const target = this.forwardStack.pop()!;
if (target !== this.currentIndex) {
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
if (target < 0 || target >= this.songs.length) continue;
if (target === this.currentIndex) continue;
this.pushHistory(this.currentIndex);
this.currentIndex = target;
this.playedIndices.add(target);
return this.songs[target];
}
// Shuffle bag: pick uniformly from the songs not yet played this
+151
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { EventEmitter } from "node:events";
import { BotInstance, COMMAND_DENIED_MESSAGE, spotifyPortsForBotId } from "./instance.js";
import type { BotInstanceOptions } from "./instance.js";
import { PlayQueue, PlayMode } from "../audio/queue.js";
@@ -11,6 +12,7 @@ import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js";
import type { AvatarStore } from "../data/avatars.js";
import type { BotConfig } from "../data/config.js";
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
// Constructing a real BotInstance is heavy (spawns a TS3Client, AudioPlayer,
// reads avatars, etc.), and runExclusive only touches a single private field
@@ -122,6 +124,155 @@ describe("BotInstance.runExclusive — serialization", () => {
});
});
describe("BotInstance voice-ducking lifecycle integration", () => {
const connect = BotInstance.prototype.connect as unknown as (
this: Record<string, any>,
) => Promise<void>;
function makeConnectContext(connectPromise: Promise<void>) {
return {
disconnectEmitted: false,
connected: false,
tsClient: {
connect: vi.fn(() => connectPromise),
getResolvedVoiceEndpoint: vi.fn(() => ({ host: "203.0.113.20", port: 12000 })),
},
configuredVoiceServerScope: {
host: "voice-alias.example.com",
voicePort: 9987,
},
voiceServerScope: { host: "voice-alias.example.com", voicePort: 9987 },
voiceDucking: { reset: vi.fn() },
registerManagedVoiceClient: vi.fn(),
profileManager: { onConnect: vi.fn() },
emit: vi.fn(),
restoreQueueFromSnapshot: vi.fn(async () => {}),
};
}
it("registers its managed client only after a successful outer connect", async () => {
const ctx = makeConnectContext(Promise.resolve());
await connect.call(ctx);
expect(ctx.connected).toBe(true);
expect(ctx.voiceServerScope).toEqual({ host: "203.0.113.20", voicePort: 12000 });
expect(ctx.voiceDucking.reset).toHaveBeenCalledWith(true);
expect(ctx.registerManagedVoiceClient).toHaveBeenCalledOnce();
expect(ctx.profileManager.onConnect).toHaveBeenCalledOnce();
});
it("does not register a late handshake after disconnect aborted it", async () => {
const handshake = deferred();
const ctx = makeConnectContext(handshake.promise);
const result = connect.call(ctx);
ctx.disconnectEmitted = true;
handshake.resolve();
await expect(result).rejects.toThrow("Connect aborted by concurrent disconnect");
expect(ctx.connected).toBe(false);
expect(ctx.registerManagedVoiceClient).not.toHaveBeenCalled();
expect(ctx.voiceDucking.reset).not.toHaveBeenCalled();
});
it("falls back to the configured endpoint when identity discovery is unavailable", async () => {
const ctx = makeConnectContext(Promise.resolve());
ctx.tsClient.getResolvedVoiceEndpoint.mockReturnValue(null as any);
await connect.call(ctx);
expect(ctx.voiceServerScope).toEqual({
host: "voice-alias.example.com",
voicePort: 9987,
});
});
it("routes human voice activity but filters another managed bot", () => {
const tsClient = new EventEmitter() as EventEmitter & {
getClientId(): number;
};
tsClient.getClientId = () => 10;
const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
managedVoiceClients.register(
{ host: "192.168.1.10", voicePort: 20_000 },
20,
{},
"managed-bot-uid=",
);
managedVoiceClients.register(voiceServerScope, 22, {}, "fallback-bot-uid=");
const handleVoiceActivity = vi.fn();
const ctx = {
tsClient,
connected: true,
managedVoiceClients,
voiceServerScope,
voiceDucking: {
handleVoiceActivity,
removeSpeaker: vi.fn(),
reset: vi.fn(),
},
} as Record<string, any>;
(BotInstance.prototype as any).setupTsEvents.call(ctx);
tsClient.emit("voiceActivity", {
clientId: 20,
codec: 5,
clientUid: "managed-bot-uid=",
});
// If a UID is momentarily unavailable, the scoped client-id registry is
// retained as a fallback for the common same-endpoint case.
tsClient.emit("voiceActivity", { clientId: 22, codec: 5 });
tsClient.emit("voiceActivity", {
clientId: 21,
codec: 5,
clientUid: "human-uid=",
});
expect(handleVoiceActivity).toHaveBeenCalledOnce();
expect(handleVoiceActivity).toHaveBeenCalledWith(21);
});
it("keeps a disconnecting bot registered during the in-flight packet grace", () => {
vi.useFakeTimers();
try {
const managedVoiceClients = new ManagedVoiceClientRegistry();
const voiceServerScope = { host: "voice.example.com", voicePort: 9987 };
const owner = {};
managedVoiceClients.register(
voiceServerScope,
20,
owner,
"managed-bot-uid=",
);
const ctx = {
managedVoiceClients,
voiceServerScope,
registeredVoiceClientId: 20,
registeredVoiceClientOwner: owner,
registeredVoiceClientScope: voiceServerScope,
registeredVoiceClientUid: "managed-bot-uid=",
};
(BotInstance.prototype as any).unregisterManagedVoiceClient.call(ctx, 1_000);
// A reconnect may resolve to a new endpoint before the grace expires;
// cleanup must still target the scope that owned the old client id.
ctx.voiceServerScope = { host: "other.example.com", voicePort: 9987 };
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(true);
vi.advanceTimersByTime(999);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(true);
vi.advanceTimersByTime(1);
expect(managedVoiceClients.has(voiceServerScope, 20)).toBe(false);
expect(managedVoiceClients.hasClientUid("managed-bot-uid=")).toBe(false);
} finally {
vi.useRealTimers();
}
});
});
/** Minimal `this` carrying only what handleTextMessage's gate path touches.
* The gate methods live on the prototype and are attached here so calls like
* `this.isCommandAllowed(...)` resolve against this same object. */
+134 -12
View File
@@ -3,6 +3,7 @@ import {
TS3Client,
type TS3ClientOptions,
type TS3TextMessage,
type TS3VoiceActivity,
} from "../ts-protocol/client.js";
import { AudioPlayer } from "../audio/player.js";
import { PlayQueue, PlayMode, type QueuedSong } from "../audio/queue.js";
@@ -21,6 +22,7 @@ import {
defaultPlatform,
type BotConfig,
type SpotifyConfig,
type VoiceDuckingConfig,
} from "../data/config.js";
import type { JellyfinPlaybackReporter } from "../music/jellyfin.js";
import { BotProfileManager } from "./profile.js";
@@ -35,6 +37,12 @@ import path from "node:path";
import { SpotifyController } from "../music/spotify/controller.js";
import type { SpotifyTrackEndedEvent } from "../music/spotify/backend.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import { VoiceDuckingController } from "./voice-ducking.js";
import {
ManagedVoiceClientRegistry,
type ManagedVoiceClientOwnerToken,
type ManagedVoiceClientScope,
} from "./managed-voice-clients.js";
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
@@ -48,6 +56,10 @@ const PLAY_MODE_BY_VALUE: Record<string, PlayMode> = {
rloop: PlayMode.RandomLoop,
};
// Keep a disconnected bot id classified as managed briefly so UDP packets
// already in flight cannot make another local bot duck during teardown.
const MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS = 1_000;
/** 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 =
@@ -100,6 +112,8 @@ export interface BotInstanceOptions {
config: BotConfig;
logger: Logger;
avatarStore: AvatarStore;
/** Shared across one manager so its bots do not trigger one another. */
managedVoiceClients?: ManagedVoiceClientRegistry;
/** Base dir (under DATA_DIR) for per-bot go-librespot work/config trees. */
spotifyDataDir?: string;
/** Process-wide shared Spotify OAuth (single account); injected into the
@@ -139,6 +153,14 @@ export class BotInstance extends EventEmitter {
private tsClient: TS3Client;
private player: AudioPlayer;
private voiceDucking: VoiceDuckingController;
private managedVoiceClients: ManagedVoiceClientRegistry;
private readonly configuredVoiceServerScope: ManagedVoiceClientScope;
private voiceServerScope: ManagedVoiceClientScope;
private registeredVoiceClientId = 0;
private registeredVoiceClientOwner: ManagedVoiceClientOwnerToken | null = null;
private registeredVoiceClientScope: ManagedVoiceClientScope | null = null;
private registeredVoiceClientUid: string | null = null;
private spotifyController: SpotifyController;
private queue: PlayQueue;
private neteaseProvider: MusicProvider;
@@ -197,6 +219,17 @@ export class BotInstance extends EventEmitter {
this.tsClient = new TS3Client(options.tsOptions, this.logger);
this.player = new AudioPlayer(this.logger);
this.voiceDucking = new VoiceDuckingController(
this.player,
this.config.voiceDucking ?? { enabled: false, volumePercent: 30 },
);
this.managedVoiceClients =
options.managedVoiceClients ?? new ManagedVoiceClientRegistry();
this.configuredVoiceServerScope = {
host: options.tsOptions.host,
voicePort: options.tsOptions.port,
};
this.voiceServerScope = { ...this.configuredVoiceServerScope };
this.queue = new PlayQueue();
// Restore persisted per-bot player settings (#125): volume + play mode
@@ -358,6 +391,8 @@ export class BotInstance extends EventEmitter {
// this.connected was never flipped to true. Previously this handler
// short-circuited on !this.connected, leaving player stuck as "playing".
this.connected = false;
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
this.voiceDucking.reset(true);
// Cancel any pending live-queue snapshot BEFORE clearing the queue: a
// debounced snapshot firing after clear() would persist an empty queue
// (clearQueueState), wiping the state we want to restore on reconnect —
@@ -389,6 +424,17 @@ export class BotInstance extends EventEmitter {
this._startJellyfinReportPoller();
});
this.tsClient.on("voiceActivity", (activity: TS3VoiceActivity) => {
if (!this.connected) return;
if (
this.managedVoiceClients.hasClientUid(activity.clientUid) ||
this.managedVoiceClients.has(this.voiceServerScope, activity.clientId)
) {
return;
}
this.voiceDucking.handleVoiceActivity(activity.clientId);
});
// React near-instantly to channel membership changes. The 30s idle
// poller remains the fallback if any of these events are missed.
//
@@ -400,8 +446,60 @@ export class BotInstance extends EventEmitter {
this._resumeIfReturning();
void this.refreshOccupancy();
});
this.tsClient.on("clientLeave", () => void this.refreshOccupancy());
this.tsClient.on("clientMoved", () => void this.refreshOccupancy());
this.tsClient.on("clientLeave", (event: { id: number }) => {
this.voiceDucking.removeSpeaker(event.id);
void this.refreshOccupancy();
});
this.tsClient.on("clientMoved", (event: { id: number }) => {
if (event.id === this.tsClient.getClientId()) {
// Moving the bot invalidates every activity deadline from its old
// channel even if no individual leave events arrive.
this.voiceDucking.reset(false);
} else {
this.voiceDucking.removeSpeaker(event.id);
}
void this.refreshOccupancy();
});
}
private registerManagedVoiceClient(): void {
this.unregisterManagedVoiceClient();
const clientId = this.tsClient.getClientId();
if (!Number.isSafeInteger(clientId) || clientId <= 0) return;
const owner = {};
const scope = { ...this.voiceServerScope };
const clientUid = this.tsClient.getClientUid();
if (this.managedVoiceClients.register(scope, clientId, owner, clientUid)) {
this.registeredVoiceClientId = clientId;
this.registeredVoiceClientOwner = owner;
this.registeredVoiceClientScope = scope;
this.registeredVoiceClientUid = clientUid;
}
}
private unregisterManagedVoiceClient(graceMs = 0): void {
const clientId = this.registeredVoiceClientId;
const owner = this.registeredVoiceClientOwner;
const clientUid = this.registeredVoiceClientUid ?? undefined;
const scope = this.registeredVoiceClientScope
? { ...this.registeredVoiceClientScope }
: { ...this.voiceServerScope };
this.registeredVoiceClientId = 0;
this.registeredVoiceClientOwner = null;
this.registeredVoiceClientScope = null;
this.registeredVoiceClientUid = null;
if (clientId <= 0 || owner === null) return;
const unregister = () => {
this.managedVoiceClients.unregister(scope, clientId, owner, clientUid);
};
if (graceMs > 0) {
const timer = setTimeout(unregister, graceMs);
timer.unref?.();
} else {
unregister();
}
}
/**
@@ -440,6 +538,13 @@ export class BotInstance extends EventEmitter {
async connect(): Promise<void> {
this.disconnectEmitted = false;
await this.tsClient.connect();
const resolvedEndpoint = this.tsClient.getResolvedVoiceEndpoint();
this.voiceServerScope = {
host:
resolvedEndpoint?.host ?? this.configuredVoiceServerScope.host,
voicePort:
resolvedEndpoint?.port ?? this.configuredVoiceServerScope.voicePort,
};
// Race guard: if disconnect() was called while the handshake was
// awaiting, don't flip connected back to true — that would leave the
// bot in an inconsistent state (externally "connected" but the tsClient
@@ -448,6 +553,12 @@ export class BotInstance extends EventEmitter {
throw new Error("Connect aborted by concurrent disconnect");
}
this.connected = true;
// Register only after the outer lifecycle race guard succeeds. The TS
// wrapper emits its own "connected" event before connect() resolves, so
// registering in that callback could let a cancelled, late handshake
// overwrite a newer instance that reused the same client id.
this.voiceDucking.reset(true);
this.registerManagedVoiceClient();
this.profileManager.onConnect();
this.emit("connected");
// Feature 2 (#119): restore + resume the live queue persisted before the
@@ -458,6 +569,7 @@ export class BotInstance extends EventEmitter {
disconnect(): void {
this._cancelIdleTimer();
this.voiceDucking.reset(true);
// Cancel any pending live-queue snapshot before clearing so it can't fire
// afterwards and persist an empty queue over the state we keep for restore
// (#119). The disconnected handler cancels too, but do it here as well for
@@ -478,6 +590,10 @@ export class BotInstance extends EventEmitter {
this.emit("disconnected");
}
this.tsClient.disconnect();
// Stop outbound PCM and initiate the TeamSpeak disconnect before removing
// our id from the shared registry, minimizing the window in which another
// managed bot could mistake our final packet for a human speaker.
this.unregisterManagedVoiceClient(MANAGED_VOICE_CLIENT_RELEASE_GRACE_MS);
}
/** 外部更新 idleTimeoutMinutes(由 API 保存时调用) */
@@ -500,6 +616,12 @@ export class BotInstance extends EventEmitter {
}
}
/** Hot-apply voice ducking without mutating the user's base player volume. */
updateVoiceDucking(settings: VoiceDuckingConfig): void {
this.config.voiceDucking = { ...settings };
this.voiceDucking.updateSettings(settings);
}
private _startIdlePoller(): void {
// 每 30 秒检查一次频道人数
const poll = async () => {
@@ -991,9 +1113,9 @@ export class BotInstance extends EventEmitter {
/**
* Resolve a !play/!add/!playnext argument into a single Song, supporting three
* forms (issue #90):
* 1) "#N" — the Nth result of the previous !search
* 2) id:<id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
* 1) "#N" — the Nth result of the previous !search
* 2) id <id> / URL — an exact song (disambiguates same-name songs)
* 3) plain text — search, returning the single most-popular hit (legacy)
*/
private async resolvePlayQuery(cmd: ParsedCommand): Promise<{ song?: Song; error?: string }> {
const args = (cmd.args ?? "").trim();
@@ -1009,7 +1131,7 @@ export class BotInstance extends EventEmitter {
return { song: this.lastSearchResults[sel - 1] };
}
// 2) id:/URL — fetch that exact song.
// 2) id/URL — fetch that exact song.
const ref = parseSongRef(args);
if (ref) {
if (ref.platform) this.assertProviderEnabled(ref.platform);
@@ -1037,13 +1159,13 @@ export class BotInstance extends EventEmitter {
(s, i) => `${i + 1}. ${s.name} - ${s.artist}${s.album ? ` 《${s.album}》` : ""} [id:${s.id}]`,
);
return [
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id:<id>):`,
`搜索结果(用 ${p}play #序号 播放,或 ${p}play id <id>):`,
...lines,
].join("\n");
}
private async cmdPlay(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}play <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const song0 = song!;
@@ -1137,7 +1259,7 @@ export class BotInstance extends EventEmitter {
}
private async cmdAdd(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}add <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
@@ -1161,7 +1283,7 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlayNext(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id:<id> | URL>`;
if (!cmd.args) return `Usage: ${this.config.commandPrefix}playnext <song name | #N | id <id> | URL>`;
const { song, error } = await this.resolvePlayQuery(cmd);
if (error) return error;
const s = song!;
@@ -1718,8 +1840,8 @@ export class BotInstance extends EventEmitter {
...(flagHelp ? [` Source flags: ${flagHelp}`] : []),
`${p}search <name> — List top matches to pick a specific (same-name) song`,
`${p}play #N — Play the Nth result of the last ${p}search`,
`${p}play id:<id> — Play an exact song by id / URL`,
`${p}add <song> — Add to queue (also accepts #N / id: / URL)`,
`${p}play id <id> — Play an exact song by id / URL`,
`${p}add <song> — Add to queue (also accepts #N / id <id> / URL)`,
`${p}playnext <song> — Insert as next song (alias: ${p}pn)`,
`${p}pause/resume — Pause/resume`,
`${p}next/prev — Next/previous`,
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, it } from "vitest";
import {
ManagedVoiceClientRegistry,
normalizeManagedVoiceClientScope,
normalizeManagedVoiceHost,
} from "./managed-voice-clients.js";
describe("managed voice client scope normalization", () => {
it("normalizes DNS host casing, whitespace, and trailing root dots", () => {
expect(normalizeManagedVoiceHost(" Voice.Example.COM... ")).toBe(
"voice.example.com",
);
expect(
normalizeManagedVoiceClientScope({
host: "VOICE.EXAMPLE.COM.",
voicePort: 9987,
}),
).toEqual({ host: "voice.example.com", voicePort: 9987 });
});
it("treats bracketed and equivalent expanded IPv6 literals as one host", () => {
expect(normalizeManagedVoiceHost("[2001:0DB8:0:0:0:0:0:1]")).toBe(
"2001:db8::1",
);
expect(normalizeManagedVoiceHost("2001:db8::1")).toBe("2001:db8::1");
});
it("rejects empty hosts and invalid voice ports", () => {
expect(
normalizeManagedVoiceClientScope({ host: " . ", voicePort: 9987 }),
).toBeNull();
expect(
normalizeManagedVoiceClientScope({ host: "example.com", voicePort: 0 }),
).toBeNull();
expect(
normalizeManagedVoiceClientScope({
host: "example.com",
voicePort: 65_536,
}),
).toBeNull();
});
});
describe("ManagedVoiceClientRegistry", () => {
it("finds clients through normalized forms of the same scope", () => {
const registry = new ManagedVoiceClientRegistry();
const owner = Symbol("connection");
expect(
registry.register(
{ host: " Voice.Example.COM. ", voicePort: 9987 },
42,
owner,
),
).toBe(true);
expect(
registry.has({ host: "voice.example.com", voicePort: 9987 }, 42),
).toBe(true);
});
it("keeps different voice ports and hosts in separate scopes", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "voice.example.com", voicePort: 9987 },
7,
Symbol("connection"),
);
expect(
registry.has({ host: "voice.example.com", voicePort: 9988 }, 7),
).toBe(false);
expect(
registry.has({ host: "other.example.com", voicePort: 9987 }, 7),
).toBe(false);
});
it("finds a managed bot by stable client UID across network endpoints", () => {
const registry = new ManagedVoiceClientRegistry();
const owner = Symbol("connection");
registry.register(
{ host: "127.0.0.1", voicePort: 9987 },
17,
owner,
" managed-client-uid= ",
);
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.has({ host: "192.168.1.10", voicePort: 20_000 }, 17),
).toBe(false);
});
it("keeps a shared managed UID until its last owner unregisters", () => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "203.0.113.4", voicePort: 9987 };
const first = Symbol("first connection");
const second = Symbol("second connection");
registry.register(scope, 18, first, "shared-client-uid=");
registry.register(scope, 19, second, "shared-client-uid=");
expect(registry.unregister(scope, 18, first, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(true);
expect(registry.unregister(scope, 19, second, "shared-client-uid=")).toBe(true);
expect(registry.hasClientUid("shared-client-uid=")).toBe(false);
});
it("ignores missing or empty client UIDs", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "203.0.113.4", voicePort: 9987 },
19,
Symbol("connection"),
" ",
);
expect(registry.hasClientUid(undefined)).toBe(false);
expect(registry.hasClientUid(" ")).toBe(false);
});
it("uses an IPv6-safe scope key", () => {
const registry = new ManagedVoiceClientRegistry();
registry.register(
{ host: "[2001:0db8:0:0:0:0:0:1]", voicePort: 9987 },
9,
Symbol("connection"),
);
expect(
registry.has({ host: "2001:db8::1", voicePort: 9987 }, 9),
).toBe(true);
});
it("does not let a delayed old disconnect remove a replacement", () => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
const oldConnection = Symbol("old connection");
const newConnection = Symbol("new connection");
registry.register(scope, 12, oldConnection, "managed-client-uid=");
registry.register(scope, 12, newConnection, "managed-client-uid=");
// The old UID owner is removed, but the replacement still owns both the
// scoped id and the shared stable UID.
expect(
registry.unregister(scope, 12, oldConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(true);
expect(registry.hasClientUid("managed-client-uid=")).toBe(true);
expect(
registry.unregister(scope, 12, newConnection, "managed-client-uid="),
).toBe(true);
expect(registry.has(scope, 12)).toBe(false);
expect(registry.hasClientUid("managed-client-uid=")).toBe(false);
});
it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
"ignores invalid client id %s",
(clientId) => {
const registry = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
const owner = Symbol("connection");
expect(registry.register(scope, clientId, owner)).toBe(false);
expect(registry.has(scope, clientId)).toBe(false);
expect(registry.unregister(scope, clientId, owner)).toBe(false);
},
);
it("has no shared module-level state between registry instances", () => {
const first = new ManagedVoiceClientRegistry();
const second = new ManagedVoiceClientRegistry();
const scope = { host: "voice.example.com", voicePort: 9987 };
first.register(scope, 3, Symbol("connection"));
expect(first.has(scope, 3)).toBe(true);
expect(second.has(scope, 3)).toBe(false);
});
});
+187
View File
@@ -0,0 +1,187 @@
import { isIP } from "node:net";
/** Identifies one TeamSpeak voice server. */
export interface ManagedVoiceClientScope {
host: string;
voicePort: number;
}
export interface NormalizedManagedVoiceClientScope {
readonly host: string;
readonly voicePort: number;
}
/**
* An opaque value identifying the connection that owns a client id.
*
* A fresh object or Symbol per connection is recommended. Value tokens are
* also supported for callers that already have a unique connection id.
*/
export type ManagedVoiceClientOwnerToken = object | string | number | symbol;
/**
* Normalize a TeamSpeak host for comparisons.
*
* DNS names are case-insensitive and may include a trailing root dot. IPv6
* literals may be supplied either bare or in URL-style brackets; valid IPv6
* addresses are also put into the canonical form produced by the URL parser.
*/
export function normalizeManagedVoiceHost(host: string): string {
let normalized = host.trim().toLowerCase().replace(/\.+$/, "");
if (normalized.startsWith("[") && normalized.endsWith("]")) {
normalized = normalized.slice(1, -1);
}
if (isIP(normalized) === 6) {
// URL's host serializer compresses equivalent IPv6 spellings. `isIP`
// ensures interpolation cannot be interpreted as another URL component.
const serialized = new URL(`http://[${normalized}]/`).hostname;
return serialized.slice(1, -1);
}
return normalized;
}
/** Return a comparable scope, or null when the runtime input is unusable. */
export function normalizeManagedVoiceClientScope(
scope: ManagedVoiceClientScope,
): NormalizedManagedVoiceClientScope | null {
if (
!scope ||
typeof scope.host !== "string" ||
typeof scope.voicePort !== "number"
) {
return null;
}
const host = normalizeManagedVoiceHost(scope.host);
if (
host.length === 0 ||
!Number.isInteger(scope.voicePort) ||
scope.voicePort < 1 ||
scope.voicePort > 65_535
) {
return null;
}
return { host, voicePort: scope.voicePort };
}
function scopeKey(scope: ManagedVoiceClientScope): string | null {
const normalized = normalizeManagedVoiceClientScope(scope);
if (!normalized) return null;
// A serialized tuple stays unambiguous when host itself contains colons.
return JSON.stringify([normalized.host, normalized.voicePort]);
}
function validClientId(clientId: number): boolean {
return Number.isSafeInteger(clientId) && clientId > 0;
}
function normalizeClientUid(clientUid: string | undefined): string | null {
if (typeof clientUid !== "string") return null;
const normalized = clientUid.trim();
return normalized.length > 0 ? normalized : null;
}
/**
* Tracks voice client ids and stable TeamSpeak identities owned by bot
* connections in this process. The UID path survives DNS aliases, NAT,
* multiple NICs, and dual-stack endpoints; scoped ids remain a fallback when
* a sender has not yet appeared in the receiving client's view cache.
*
* This class intentionally has no module-level singleton. BotManager owns one
* instance and injects it into its BotInstances so separate managers remain
* isolated in tests and in the same process.
*/
export class ManagedVoiceClientRegistry {
private readonly clientsByScope = new Map<
string,
Map<number, ManagedVoiceClientOwnerToken>
>();
private readonly ownersByClientUid = new Map<
string,
Set<ManagedVoiceClientOwnerToken>
>();
/**
* Register (or replace) the connection that owns a client id and, when
* available, add its stable UID to the managed set.
* Returns false when the scope or client id is invalid.
*/
register(
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
let clients = this.clientsByScope.get(key);
if (!clients) {
clients = new Map();
this.clientsByScope.set(key, clients);
}
clients.set(clientId, ownerToken);
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
let owners = this.ownersByClientUid.get(normalizedUid);
if (!owners) {
owners = new Set();
this.ownersByClientUid.set(normalizedUid, owners);
}
owners.add(ownerToken);
}
return true;
}
/**
* Remove a client only if it is still owned by this connection.
*
* The ownership check prevents a delayed disconnect from an old connection
* deleting a newer connection that reused the same TeamSpeak client id.
*/
unregister(
scope: ManagedVoiceClientScope,
clientId: number,
ownerToken: ManagedVoiceClientOwnerToken,
clientUid?: string,
): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
let removed = false;
const clients = this.clientsByScope.get(key);
if (clients?.get(clientId) === ownerToken) {
clients.delete(clientId);
if (clients.size === 0) this.clientsByScope.delete(key);
removed = true;
}
const normalizedUid = normalizeClientUid(clientUid);
if (normalizedUid) {
const owners = this.ownersByClientUid.get(normalizedUid);
if (owners?.delete(ownerToken)) removed = true;
if (owners?.size === 0) this.ownersByClientUid.delete(normalizedUid);
}
return removed;
}
has(scope: ManagedVoiceClientScope, clientId: number): boolean {
const key = scopeKey(scope);
if (!key || !validClientId(clientId)) return false;
return this.clientsByScope.get(key)?.has(clientId) ?? false;
}
/** TeamSpeak client UIDs are stable across endpoint aliases and NAT paths. */
hasClientUid(clientUid: string | undefined): boolean {
const normalizedUid = normalizeClientUid(clientUid);
return normalizedUid
? (this.ownersByClientUid.get(normalizedUid)?.size ?? 0) > 0
: false;
}
}
+5
View File
@@ -15,6 +15,7 @@ import type { ServerProtocol } from "../ts-protocol/client.js";
import type { AvatarStore } from "../data/avatars.js";
import type { PermissionStore } from "../data/permissions.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import { ManagedVoiceClientRegistry } from "./managed-voice-clients.js";
/**
* Run bot.connect() with a hard deadline. If the handshake hangs (e.g. the
@@ -72,6 +73,7 @@ export interface CreateBotParams {
export class BotManager extends EventEmitter {
private bots = new Map<string, BotInstance>();
private readonly managedVoiceClients = new ManagedVoiceClientRegistry();
private neteaseProvider: MusicProvider;
private qqProvider: MusicProvider;
private bilibiliProvider: MusicProvider;
@@ -161,6 +163,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
@@ -305,6 +308,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
@@ -363,6 +367,7 @@ export class BotManager extends EventEmitter {
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
+53
View File
@@ -21,6 +21,59 @@ describe("parseSongRef (#90 exact-song selection)", () => {
expect(parseSongRef("id:185868,")).toEqual({ id: "185868", platform: null });
});
// Issue #139: `!play id <id>` matches the "<command> <subcommand> <arg>"
// shape of every other command. The colon form stays supported — users have
// it in their chat scrollback and in older docs.
it("parses the space-separated id form", () => {
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
expect(parseSongRef("id 185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id 185868.")).toEqual({ id: "185868", platform: null });
});
it("does not mistake a word merely starting with 'id' for an id reference", () => {
expect(parseSongRef("idol")).toBeNull();
expect(parseSongRef("identity 185868")).toBeNull();
expect(parseSongRef("id")).toBeNull();
expect(parseSongRef("id:")).toBeNull();
// Two remaining tokens are a search phrase, not an id.
expect(parseSongRef("id die for you")).toBeNull();
});
// Without a colon, "id" is just a word — "ID 4" and "ID Bruno" are real track
// titles. The space form therefore only claims tokens that could actually be
// an id; everything else stays a search term.
it("only treats the space form as an id when the token looks like one", () => {
expect(parseSongRef("id Bruno")).toBeNull();
expect(parseSongRef("id Marshmello")).toBeNull();
expect(parseSongRef("id 4ever")).toBeNull();
// …while every real id shape is still accepted.
expect(parseSongRef("id 4")).toEqual({ id: "4", platform: null }); // numeric
expect(parseSongRef("id BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: null });
expect(parseSongRef("id 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null }); // QQ mid
expect(parseSongRef("id a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6")).toEqual({
id: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
platform: null,
}); // Jellyfin GUID / Kugou hash
});
it("keeps the colon form unrestricted, so a short or odd id still works", () => {
expect(parseSongRef("id:Bruno")).toEqual({ id: "Bruno", platform: null });
expect(parseSongRef("id: 4ever")).toEqual({ id: "4ever", platform: null });
});
it("does not let the space form swallow a pasted URL", () => {
// `id <url>` used to fall through to the URL branches; it still must.
expect(parseSongRef("id https://music.163.com/song?id=185868")).toEqual({
id: "185868",
platform: "netease",
});
expect(parseSongRef("id https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({
id: "004Z8Ihr0JIu5s",
platform: "qq",
});
});
it("does NOT treat NetEase collection (playlist/album/artist) URLs as a song id", () => {
// These reuse ?id= but are not songs — they should fall through to search,
// not misresolve to getSongDetail(collectionId) and error "no song".
+33 -6
View File
@@ -18,9 +18,22 @@ export interface SongRef {
platform: "netease" | "qq" | "bilibili" | null;
}
/**
* Could this token plausibly BE an id on a supported platform?
* - NetEase / Kugou numeric ids → all digits
* - BiliBili → BV + 8-12 alphanumerics
* - QQ mid (14), YouTube (11), Spotify (22), Jellyfin GUID / Kugou hash (32)
* → 11+ chars from the id alphabet
* Deliberately conservative: anything rejected here just stays an ordinary
* search term, which is what it almost certainly was.
*/
function looksLikeSongId(token: string): boolean {
return /^(?:\d+|BV[0-9A-Za-z]{8,12}|[0-9A-Za-z_-]{11,})$/i.test(token);
}
/**
* Detect an explicit song reference in a query. Recognizes:
* - `id:<id>` → platform from flags/default
* - `id <id>` / `id:<id>` → platform from flags/default
* - NetEase song URL → music.163.com/song?id=N (also /#/song?id=N, /song/N)
* - QQ song URL → y.qq.com/.../songDetail/MID (or ?songmid=MID)
* - BiliBili BVID (bare or in a URL) → bilibili.com/video/BVxxxx, b23.tv, or BVxxxx
@@ -30,11 +43,25 @@ export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return null;
// Explicit "id:<id>" — platform decided by the command's flags/default.
// Strip trailing punctuation that tags along from a chat paste ("id:12345."
// / "id:12345)") — no supported id (numeric / BVID / mid) ends in those.
const idPrefix = /^id:\s*(\S+)$/i.exec(q);
if (idPrefix) return { id: idPrefix[1].replace(/[.,;)\]]+$/, ""), platform: null };
// Explicit id — platform decided by the command's flags/default. The
// separator is a colon or plain whitespace, so `id <id>` matches the
// `!<cmd> <sub> <arg>` shape of every other command (issue #139) while the
// older `id:<id>` keeps working. Strip trailing punctuation that tags along
// from a chat paste ("id:12345." / "id:12345)") — no supported id
// (numeric / BVID / mid) ends in those.
//
// The colon is an unambiguous sigil, so `id:<anything>` is always an id. A
// space is not: "ID 4" and "ID Bruno" are real track titles, and `id <url>`
// has to keep resolving as a URL. So the space form only claims tokens that
// could actually be an id; anything else falls through to the URL branches
// below and ultimately to a plain search.
const idPrefix = /^id(:\s*|\s+)(\S+)$/i.exec(q);
if (idPrefix) {
const id = idPrefix[2].replace(/[.,;)\]]+$/, "");
if (idPrefix[1].startsWith(":") || looksLikeSongId(id)) {
return { id, platform: null };
}
}
// BiliBili BV id, bare or inside a bilibili URL (NetEase ids are numeric, so
// a "BV..." token never collides with them).
+152
View File
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { VoiceDuckingController } from "./voice-ducking.js";
function makeHarness(
enabled = true,
volumePercent = 30,
timing = { attackMs: 50, holdMs: 100, releaseMs: 200 },
) {
let now = 0;
const setDuckingGain = vi.fn<(gain: number, rampMs?: number) => void>();
const controller = new VoiceDuckingController(
{ setDuckingGain },
{ enabled, volumePercent },
{ timing, now: () => now },
);
const advance = (milliseconds: number) => {
now += milliseconds;
vi.advanceTimersByTime(milliseconds);
};
return { controller, setDuckingGain, advance };
}
describe("VoiceDuckingController", () => {
afterEach(() => {
vi.useRealTimers();
});
it("is inert while disabled", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness(false);
controller.handleVoiceActivity(12);
advance(1_000);
expect(setDuckingGain).not.toHaveBeenCalled();
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
});
it("attacks once, refreshes the packet deadline, then releases", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(12);
expect(setDuckingGain).toHaveBeenCalledWith(0.3, 50);
advance(60);
controller.handleVoiceActivity(12);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
// The original t=100 sweep observes the refreshed t=160 deadline.
advance(40);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
advance(60);
expect(controller.isDucking()).toBe(false);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("stays ducked until the last overlapping speaker expires", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(1);
advance(50);
controller.handleVoiceActivity(2);
advance(50);
expect(controller.activeSpeakerCount()).toBe(1);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenCalledTimes(1);
advance(50);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("removes a client immediately on leave without disturbing other speakers", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
controller.handleVoiceActivity(1);
controller.handleVoiceActivity(2);
controller.removeSpeaker(1);
expect(controller.isDucking()).toBe(true);
expect(controller.activeSpeakerCount()).toBe(1);
controller.removeSpeaker(2);
expect(controller.isDucking()).toBe(false);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("retargets a live duck and smoothly restores when disabled", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
controller.handleVoiceActivity(7);
controller.updateSettings({ enabled: true, volumePercent: 45 });
expect(setDuckingGain).toHaveBeenLastCalledWith(0.45, 50);
controller.updateSettings({ enabled: false, volumePercent: 45 });
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
});
it("attacks again when speech resumes during the release window", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(7);
advance(100);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 200);
advance(50);
controller.handleVoiceActivity(7);
expect(controller.isDucking()).toBe(true);
expect(setDuckingGain).toHaveBeenLastCalledWith(0.3, 50);
});
it("invalidates an old expiry callback after reset", () => {
vi.useFakeTimers();
const { controller, setDuckingGain, advance } = makeHarness();
controller.handleVoiceActivity(8);
controller.reset(true);
const callsAfterReset = setDuckingGain.mock.calls.length;
advance(1_000);
expect(setDuckingGain).toHaveBeenCalledTimes(callsAfterReset);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
});
it("rejects invalid client ids and supports an immediate lifecycle reset", () => {
vi.useFakeTimers();
const { controller, setDuckingGain } = makeHarness();
for (const id of [0, -1, 1.5, Number.NaN]) {
controller.handleVoiceActivity(id);
}
expect(setDuckingGain).not.toHaveBeenCalled();
controller.handleVoiceActivity(8);
controller.reset(true);
expect(controller.isDucking()).toBe(false);
expect(controller.activeSpeakerCount()).toBe(0);
expect(setDuckingGain).toHaveBeenLastCalledWith(1, 0);
});
});
+183
View File
@@ -0,0 +1,183 @@
export interface VoiceDuckingSettings {
enabled: boolean;
volumePercent: number;
}
export interface VoiceDuckingGainTarget {
setDuckingGain(gain: number, rampMs?: number): void;
}
export interface VoiceDuckingTiming {
attackMs: number;
holdMs: number;
releaseMs: number;
}
export const DEFAULT_VOICE_DUCKING_TIMING: Readonly<VoiceDuckingTiming> = {
attackMs: 50,
holdMs: 700,
releaseMs: 500,
};
interface VoiceDuckingControllerOptions {
timing?: Partial<VoiceDuckingTiming>;
now?: () => number;
}
function nonNegativeFinite(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, value)
: fallback;
}
function normalizeSettings(settings: VoiceDuckingSettings): VoiceDuckingSettings {
return {
enabled: settings.enabled === true,
volumePercent:
typeof settings.volumePercent === "number" && Number.isFinite(settings.volumePercent)
? Math.max(0, Math.min(100, settings.volumePercent))
: 30,
};
}
/**
* Converts the stream of incoming TeamSpeak voice packets into a stable
* ducking envelope. TeamSpeak's full-client protocol exposes voice packets,
* but not an explicit "stopped talking" event, so a speaker remains active
* for a short hold period after their most recent packet.
*
* Only one timeout is live at a time. Repeated ~20 ms voice packets update a
* deadline in the map instead of constantly destroying/recreating timers.
*/
export class VoiceDuckingController {
private settings: VoiceDuckingSettings;
private readonly timing: VoiceDuckingTiming;
private readonly now: () => number;
private readonly activeUntil = new Map<number, number>();
private expiryTimer: ReturnType<typeof setTimeout> | null = null;
private timerDueAt = Number.POSITIVE_INFINITY;
private timerGeneration = 0;
private ducking = false;
constructor(
private readonly target: VoiceDuckingGainTarget,
initialSettings: VoiceDuckingSettings,
options: VoiceDuckingControllerOptions = {},
) {
this.settings = normalizeSettings(initialSettings);
this.timing = {
attackMs: nonNegativeFinite(options.timing?.attackMs, DEFAULT_VOICE_DUCKING_TIMING.attackMs),
holdMs: nonNegativeFinite(options.timing?.holdMs, DEFAULT_VOICE_DUCKING_TIMING.holdMs),
releaseMs: nonNegativeFinite(options.timing?.releaseMs, DEFAULT_VOICE_DUCKING_TIMING.releaseMs),
};
this.now = options.now ?? (() => performance.now());
}
handleVoiceActivity(clientId: number): void {
if (!this.settings.enabled || !Number.isInteger(clientId) || clientId <= 0) return;
const now = this.now();
this.activeUntil.set(clientId, now + this.timing.holdMs);
if (!this.ducking) {
this.ducking = true;
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
}
this.scheduleNextSweep(now);
}
removeSpeaker(clientId: number): void {
if (!this.activeUntil.delete(clientId)) return;
if (this.activeUntil.size === 0) {
this.cancelTimer();
this.release();
}
}
updateSettings(settings: VoiceDuckingSettings): void {
const previous = this.settings;
this.settings = normalizeSettings(settings);
if (!this.settings.enabled) {
this.activeUntil.clear();
this.cancelTimer();
this.release();
return;
}
if (
previous.volumePercent !== this.settings.volumePercent &&
this.ducking
) {
this.target.setDuckingGain(this.settings.volumePercent / 100, this.timing.attackMs);
}
}
/** Clear all activity. Disconnects use an immediate reset; disabling the
* feature uses updateSettings(), which returns smoothly over releaseMs. */
reset(immediate = true): void {
this.activeUntil.clear();
this.cancelTimer();
this.ducking = false;
this.target.setDuckingGain(1, immediate ? 0 : this.timing.releaseMs);
}
isDucking(): boolean {
return this.ducking;
}
activeSpeakerCount(): number {
return this.activeUntil.size;
}
private scheduleNextSweep(now = this.now()): void {
if (this.activeUntil.size === 0) return;
let nextDueAt = Number.POSITIVE_INFINITY;
for (const deadline of this.activeUntil.values()) {
if (deadline < nextDueAt) nextDueAt = deadline;
}
// Keeping an earlier timer is intentional. When it fires it will observe
// the refreshed deadline and schedule the remaining delay, avoiding timer
// churn on every incoming packet.
if (this.expiryTimer && this.timerDueAt <= nextDueAt) return;
this.cancelTimer();
const generation = ++this.timerGeneration;
this.timerDueAt = nextDueAt;
this.expiryTimer = setTimeout(() => {
if (generation !== this.timerGeneration) return;
this.expiryTimer = null;
this.timerDueAt = Number.POSITIVE_INFINITY;
this.sweepExpiredSpeakers();
}, Math.max(0, nextDueAt - now));
}
private sweepExpiredSpeakers(): void {
const now = this.now();
for (const [clientId, deadline] of this.activeUntil) {
if (deadline <= now) this.activeUntil.delete(clientId);
}
if (this.activeUntil.size > 0) {
this.scheduleNextSweep(now);
} else {
this.release();
}
}
private release(): void {
if (!this.ducking) return;
this.ducking = false;
this.target.setDuckingGain(1, this.timing.releaseMs);
}
private cancelTimer(): void {
this.timerGeneration++;
if (this.expiryTimer) clearTimeout(this.expiryTimer);
this.expiryTimer = null;
this.timerDueAt = Number.POSITIVE_INFINITY;
}
}
+73
View File
@@ -48,6 +48,79 @@ describe("config", () => {
expect(config).toEqual(getDefaultConfig());
});
it("defaults voice ducking to disabled at 30 percent", () => {
expect(getDefaultConfig().voiceDucking).toEqual({
enabled: false,
volumePercent: 30,
});
});
it("fills voiceDucking defaults for legacy and partial configs", () => {
const dir = makeTmpDir();
const legacyPath = join(dir, "legacy.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4000 }));
expect(loadConfig(legacyPath).voiceDucking).toEqual({
enabled: false,
volumePercent: 30,
});
const partialPath = join(dir, "partial.json");
writeFileSync(partialPath, JSON.stringify({ voiceDucking: { enabled: true } }));
expect(loadConfig(partialPath).voiceDucking).toEqual({
enabled: true,
volumePercent: 30,
});
});
it("loadConfig preserves valid voiceDucking values including range endpoints", () => {
const dir = makeTmpDir();
for (const volumePercent of [0, 37.5, 100]) {
const path = join(dir, `voice-ducking-${volumePercent}.json`);
writeFileSync(
path,
JSON.stringify({ voiceDucking: { enabled: true, volumePercent } }),
);
expect(loadConfig(path).voiceDucking).toEqual({ enabled: true, volumePercent });
}
});
it("loadConfig strictly sanitizes malformed voiceDucking values", () => {
const dir = makeTmpDir();
const malformed: Array<{ name: string; json: string }> = [
{ name: "null-block", json: JSON.stringify({ voiceDucking: null }) },
{ name: "array-block", json: JSON.stringify({ voiceDucking: [true, 10] }) },
{ name: "string-block", json: JSON.stringify({ voiceDucking: "on" }) },
{
name: "wrong-types",
json: JSON.stringify({ voiceDucking: { enabled: "yes", volumePercent: "25" } }),
},
{
name: "below-range",
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: -1 } }),
},
{
name: "above-range",
json: JSON.stringify({ voiceDucking: { enabled: true, volumePercent: 101 } }),
},
// JSON.parse("1e309") produces Infinity, exercising the finite-number guard.
{
name: "non-finite",
json: '{"voiceDucking":{"enabled":true,"volumePercent":1e309}}',
},
];
for (const testCase of malformed) {
const path = join(dir, `${testCase.name}.json`);
writeFileSync(path, testCase.json);
const loaded = loadConfig(path).voiceDucking;
if (testCase.name === "below-range" || testCase.name === "above-range" || testCase.name === "non-finite") {
expect(loaded).toEqual({ enabled: true, volumePercent: 30 });
} else {
expect(loaded).toEqual({ enabled: false, volumePercent: 30 });
}
}
});
it("defaults to the online sources with jellyfin as opt-in (disabled)", () => {
const config = getDefaultConfig();
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
+38
View File
@@ -54,6 +54,12 @@ export interface AudioQualityConfig {
jellyfin: string;
}
export interface VoiceDuckingConfig {
enabled: boolean;
/** Percentage of the normal playback volume retained while someone speaks. */
volumePercent: number;
}
/**
* Providers gated by `enabledProviders`. Not listed here:
* - "local" → governed by the existing `localAudioEnabled` flag
@@ -113,6 +119,8 @@ export interface BotConfig {
adminGroups: number[];
autoReturnDelay: number;
autoPauseOnEmpty: boolean;
/** Lower music volume while voice from another client is being received. */
voiceDucking: VoiceDuckingConfig;
idleTimeoutMinutes: number;
/** Enable uploading and playback of server-stored local audio files. */
localAudioEnabled: boolean;
@@ -176,6 +184,10 @@ export function getDefaultConfig(): BotConfig {
// command, which is unreliable on some servers (it can time out when other
// clients are present). Users can opt in from the web UI.
autoPauseOnEmpty: false,
voiceDucking: {
enabled: false,
volumePercent: 30,
},
idleTimeoutMinutes: 0,
localAudioEnabled: true,
savedQueuesEnabled: false,
@@ -383,6 +395,31 @@ export function loadConfig(path: string): BotConfig {
const savedQueuesEnabled = partial.savedQueuesEnabled === true;
const playKeepsQueue = partial.playKeepsQueue === true;
// Voice ducking is opt-in and the retained-volume percentage is consumed
// directly by the audio path. Only a plain-object block with correctly
// typed, finite and in-range fields may override the safe defaults.
const rawVoiceDucking = partial.voiceDucking;
const partialVoiceDucking =
rawVoiceDucking !== null &&
typeof rawVoiceDucking === "object" &&
!Array.isArray(rawVoiceDucking)
? (rawVoiceDucking as Partial<VoiceDuckingConfig>)
: {};
const rawVolumePercent = partialVoiceDucking.volumePercent;
const voiceDucking: VoiceDuckingConfig = {
enabled:
typeof partialVoiceDucking.enabled === "boolean"
? partialVoiceDucking.enabled
: defaults.voiceDucking.enabled,
volumePercent:
typeof rawVolumePercent === "number" &&
Number.isFinite(rawVolumePercent) &&
rawVolumePercent >= 0 &&
rawVolumePercent <= 100
? rawVolumePercent
: defaults.voiceDucking.volumePercent,
};
// defaultPlatform → an explicit operator default (issue #126). Keep it only
// when it names a KNOWN gateable provider that is ALSO currently enabled;
// anything else (unknown value, disabled source, wrong type, missing) becomes
@@ -420,6 +457,7 @@ export function loadConfig(path: string): BotConfig {
enabledProviders,
savedQueuesEnabled,
playKeepsQueue,
voiceDucking,
defaultPlatform: defaultPlatformPref,
};
}
+57 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
import pino from "pino";
import { TS3Client } from "./client.js";
@@ -83,3 +83,59 @@ describe("TS3Client.getClientServerGroups — live query + parse smoke test", ()
expect(await ts.getClientServerGroups(5)).toEqual([]);
});
});
describe("TS3Client stable identity UID", () => {
it("derives the same client UID after exporting and restoring an identity", () => {
const first = makeClient();
const restored = new TS3Client(
{
host: "localhost",
port: 9987,
queryPort: 10011,
nickname: "RestoredBot",
identity: first.getIdentityExport(),
},
pino({ level: "silent" }),
);
expect(first.getClientUid()).toBeTruthy();
expect(restored.getClientUid()).toBe(first.getClientUid());
});
});
type VisibleUidHarness = {
visibleClientUids: Map<number, string>;
rememberVisibleClientUid(clientId: number, clientUid: string): void;
releaseVisibleClientUid(clientId: number): void;
clearVisibleClientUids(): void;
};
describe("TS3Client visible client UID grace", () => {
afterEach(() => vi.useRealTimers());
it("retains a leaving client's UID for final reordered voice packets", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "managed-bot-uid=");
cache.releaseVisibleClientUid(7);
vi.advanceTimersByTime(999);
expect(cache.visibleClientUids.get(7)).toBe("managed-bot-uid=");
vi.advanceTimersByTime(1);
expect(cache.visibleClientUids.has(7)).toBe(false);
});
it("lets a new clientEnter overwrite a reused id and cancel stale cleanup", () => {
vi.useFakeTimers();
const cache = makeClient() as unknown as VisibleUidHarness;
cache.rememberVisibleClientUid(7, "old-managed-bot-uid=");
cache.releaseVisibleClientUid(7);
cache.rememberVisibleClientUid(7, "new-human-uid=");
vi.advanceTimersByTime(1_000);
expect(cache.visibleClientUids.get(7)).toBe("new-human-uid=");
cache.clearVisibleClientUids();
});
});
+95
View File
@@ -3,6 +3,7 @@ import { Readable } from "node:stream";
import {
Client as TS3FullClient,
generateIdentity as genTS3Identity,
getUidFromPublicKey,
identityFromString,
sendTextMessage,
listChannels,
@@ -15,6 +16,7 @@ import {
type ClientInfo,
type ClientLeftViewEvent,
type ClientMovedEvent,
type VoiceData,
type FileUploadInfo,
} from "@honeybbq/teamspeak-client";
import type { Logger } from "../logger.js";
@@ -23,6 +25,10 @@ import {
type ServerProtocol,
} from "./protocol-detect.js";
import { TS6HttpQuery } from "./http-query.js";
import {
TrackingVoiceEndpointResolver,
type ResolvedVoiceEndpoint,
} from "./voice-endpoint.js";
export { CODEC_OPUS_MUSIC } from "./voice.js";
export type { ServerProtocol } from "./protocol-detect.js";
@@ -65,6 +71,20 @@ export interface TS3TextMessage {
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
/** Lightweight voice-packet signal used for activity detection. The encoded
* payload is intentionally not forwarded beyond this protocol wrapper. */
export interface TS3VoiceActivity {
clientId: number;
codec: number;
/** Stable TeamSpeak identity when the sender is present in the client view. */
clientUid?: string;
}
// Command notifications and UDP voice packets can be reordered in flight.
// Retain a leaving client's UID briefly so its final packet is still
// attributable; a new clientEnter for the same id cancels and overwrites it.
const VISIBLE_CLIENT_UID_RELEASE_GRACE_MS = 1_000;
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
@@ -85,12 +105,19 @@ export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
export class TS3Client extends EventEmitter {
private client: TS3FullClient | null = null;
private identity: Identity;
private readonly clientUid: string;
private clientId = 0;
private readonly visibleClientUids = new Map<number, string>();
private readonly visibleClientUidReleaseTimers = new Map<
number,
ReturnType<typeof setTimeout>
>();
private logger: Logger;
private disconnecting = false;
private detectedProtocol: ServerProtocol = "unknown";
private httpQuery: TS6HttpQuery | null = null;
private udpErrorTimer: ReturnType<typeof setTimeout> | null = null;
private readonly voiceEndpointResolver = new TrackingVoiceEndpointResolver();
constructor(private options: TS3ClientOptions, logger: Logger) {
super();
@@ -101,6 +128,7 @@ export class TS3Client extends EventEmitter {
} else {
this.identity = genTS3Identity(8);
}
this.clientUid = getUidFromPublicKey(this.identity.publicKeyBase64());
}
/** The detected (or forced) server protocol after connect(). */
@@ -114,6 +142,8 @@ export class TS3Client extends EventEmitter {
}
async connect(): Promise<void> {
this.voiceEndpointResolver.reset();
this.clearVisibleClientUids();
// Clean up any existing connection before creating a new one
if (this.client) {
this.logger.info("Cleaning up previous connection before reconnecting");
@@ -213,6 +243,7 @@ export class TS3Client extends EventEmitter {
// Forward server password to the protocol library so it can be
// included in clientinit for password-protected servers
serverPassword: this.options.serverPassword,
resolver: this.voiceEndpointResolver,
logger: {
debug: (msg) => this.logger.debug(msg),
info: (msg) => this.logger.info(msg),
@@ -222,16 +253,32 @@ export class TS3Client extends EventEmitter {
});
this.client.on("textMessage", (msg: TextMessage) => {
if (msg.invokerID === this.clientId) return;
this.emit("textMessage", toTS3TextMessage(msg));
});
this.client.on("voiceData", (voice: VoiceData) => {
// The library normally suppresses our own packets; retain the explicit
// guard so a future protocol change cannot make a bot duck itself.
if (voice.clientId === this.clientId) return;
const clientUid = this.visibleClientUids.get(voice.clientId);
const activity: TS3VoiceActivity = {
clientId: voice.clientId,
codec: voice.codec,
...(clientUid ? { clientUid } : {}),
};
this.emit("voiceActivity", activity);
});
this.client.on("disconnected", (err) => {
this.logger.warn({ err: err?.message }, "Connection closed");
this.clientId = 0;
this.clearVisibleClientUids();
this.emit("disconnected");
});
this.client.on("clientEnter", (info: ClientInfo) => {
this.rememberVisibleClientUid(info.id, info.uid);
this.logger.debug(
{ nickname: info.nickname, id: info.id },
"Client entered"
@@ -240,6 +287,7 @@ export class TS3Client extends EventEmitter {
});
this.client.on("clientLeave", (ev: ClientLeftViewEvent) => {
this.releaseVisibleClientUid(ev.id);
this.logger.debug({ id: ev.id }, "Client left");
this.emit("clientLeave", ev);
});
@@ -429,6 +477,52 @@ export class TS3Client extends EventEmitter {
return this.clientId;
}
/** Actual endpoint selected by the SDK's SRV/TSDNS discovery and DNS lookup. */
getResolvedVoiceEndpoint(): ResolvedVoiceEndpoint | null {
return this.voiceEndpointResolver.getEndpoint();
}
/** Stable identity of this managed TeamSpeak client. */
getClientUid(): string {
return this.clientUid;
}
private rememberVisibleClientUid(clientId: number, clientUid: string): void {
const pendingRelease = this.visibleClientUidReleaseTimers.get(clientId);
if (pendingRelease) clearTimeout(pendingRelease);
this.visibleClientUidReleaseTimers.delete(clientId);
if (clientId > 0 && clientUid) {
this.visibleClientUids.set(clientId, clientUid);
} else {
this.visibleClientUids.delete(clientId);
}
}
private releaseVisibleClientUid(clientId: number): void {
const clientUid = this.visibleClientUids.get(clientId);
if (!clientUid) return;
const previous = this.visibleClientUidReleaseTimers.get(clientId);
if (previous) clearTimeout(previous);
const timer = setTimeout(() => {
if (this.visibleClientUids.get(clientId) === clientUid) {
this.visibleClientUids.delete(clientId);
}
this.visibleClientUidReleaseTimers.delete(clientId);
}, VISIBLE_CLIENT_UID_RELEASE_GRACE_MS);
timer.unref?.();
this.visibleClientUidReleaseTimers.set(clientId, timer);
}
private clearVisibleClientUids(): void {
for (const timer of this.visibleClientUidReleaseTimers.values()) {
clearTimeout(timer);
}
this.visibleClientUidReleaseTimers.clear();
this.visibleClientUids.clear();
}
disconnect(): void {
if (this.client && !this.disconnecting) {
this.disconnecting = true;
@@ -441,6 +535,7 @@ export class TS3Client extends EventEmitter {
});
}
this.clientId = 0;
this.clearVisibleClientUids();
this.httpQuery = null;
this.detectedProtocol = "unknown";
if (this.udpErrorTimer) {
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
import type { AddrResolver, ResolvedAddr } from "@honeybbq/teamspeak-client";
import { TrackingVoiceEndpointResolver } from "./voice-endpoint.js";
function result(addr: string): ResolvedAddr {
return { addr, source: "test", expiry: new Date(0) };
}
function delegate(...addresses: string[]): AddrResolver {
return {
resolve: vi.fn(async () => addresses.map(result)),
};
}
describe("TrackingVoiceEndpointResolver", () => {
it("pins a DNS alias to the IPv4 endpoint used by the UDP connection", async () => {
const resolveHost = vi.fn(async () => "203.0.113.20");
const resolver = new TrackingVoiceEndpointResolver(
delegate("voice-alias.example.com:9987"),
resolveHost,
);
const resolved = await resolver.resolve("voice.example.com:9987");
expect(resolveHost).toHaveBeenCalledWith("voice-alias.example.com");
expect(resolved[0]?.addr).toBe("203.0.113.20:9987");
expect(resolver.getEndpoint()).toEqual({ host: "203.0.113.20", port: 9987 });
});
it("preserves the port chosen by SRV/TSDNS discovery", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("srv-target.example.com:12000"),
async () => "198.51.100.8",
);
expect((await resolver.resolve("voice.example.com:9987"))[0]?.addr).toBe(
"198.51.100.8:12000",
);
expect(resolver.getEndpoint()?.port).toBe(12000);
});
it("keeps the SDK target as a safe fallback when A-record lookup fails", async () => {
const original = "voice.example.com:9987";
const resolver = new TrackingVoiceEndpointResolver(
delegate(original),
async () => {
throw new Error("dns unavailable");
},
);
expect((await resolver.resolve(original))[0]?.addr).toBe(original);
expect(resolver.getEndpoint()).toEqual({ host: "voice.example.com", port: 9987 });
});
it("does not mutate secondary SDK candidates", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("first.example.com:9987", "second.example.com:9988"),
async () => "192.0.2.4",
);
const resolved = await resolver.resolve("voice.example.com:9987");
expect(resolved.map((candidate) => candidate.addr)).toEqual([
"192.0.2.4:9987",
"second.example.com:9988",
]);
});
it("clears the observed endpoint before a reconnect", async () => {
const resolver = new TrackingVoiceEndpointResolver(
delegate("voice.example.com:9987"),
async () => "192.0.2.5",
);
await resolver.resolve("voice.example.com:9987");
resolver.reset();
expect(resolver.getEndpoint()).toBeNull();
});
});
+104
View File
@@ -0,0 +1,104 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { Resolver } from "@honeybbq/teamspeak-client/discovery";
import type {
AddrResolver,
ResolvedAddr,
} from "@honeybbq/teamspeak-client";
export interface ResolvedVoiceEndpoint {
host: string;
port: number;
}
type ResolveIpv4 = (host: string) => Promise<string>;
function parseVoiceAddress(address: string): ResolvedVoiceEndpoint | null {
let host: string;
let rawPort: string;
if (address.startsWith("[")) {
const closingBracket = address.indexOf("]");
if (closingBracket < 0 || address[closingBracket + 1] !== ":") return null;
host = address.slice(1, closingBracket);
rawPort = address.slice(closingBracket + 2);
} else {
const separator = address.lastIndexOf(":");
if (separator <= 0) return null;
host = address.slice(0, separator);
rawPort = address.slice(separator + 1);
}
const port = Number(rawPort);
if (
host.length === 0 ||
!Number.isInteger(port) ||
port < 1 ||
port > 65_535
) {
return null;
}
return { host, port };
}
function formatVoiceAddress(endpoint: ResolvedVoiceEndpoint): string {
return endpoint.host.includes(":")
? `[${endpoint.host}]:${endpoint.port}`
: `${endpoint.host}:${endpoint.port}`;
}
async function resolveIpv4(host: string): Promise<string> {
if (isIP(host) === 4) return host;
return (await lookup(host, { family: 4 })).address;
}
/**
* Uses the SDK's normal SRV/TSDNS discovery, then pins its selected hostname
* to the IPv4 address that the UDP connection will use. Besides making the
* connection target observable, this gives all bots a common registry scope
* when one is configured with a DNS alias and another with the underlying IP.
*/
export class TrackingVoiceEndpointResolver implements AddrResolver {
private endpoint: ResolvedVoiceEndpoint | null = null;
constructor(
private readonly delegate: AddrResolver = new Resolver(),
private readonly resolveHost: ResolveIpv4 = resolveIpv4,
) {}
async resolve(input: string, signal?: AbortSignal): Promise<ResolvedAddr[]> {
this.endpoint = null;
const candidates = await this.delegate.resolve(input, signal);
const selected = candidates[0];
if (!selected) return candidates;
const parsed = parseVoiceAddress(selected.addr);
if (!parsed) return candidates;
try {
const pinned = {
host: await this.resolveHost(parsed.host),
port: parsed.port,
};
this.endpoint = pinned;
return [
{ ...selected, addr: formatVoiceAddress(pinned) },
...candidates.slice(1),
];
} catch {
// Preserve the SDK's original target if local A-record resolution fails.
// The connection may still succeed through platform-specific resolution;
// the registry then falls back to the logical host + resolved port.
this.endpoint = parsed;
return candidates;
}
}
reset(): void {
this.endpoint = null;
}
getEndpoint(): ResolvedVoiceEndpoint | null {
return this.endpoint ? { ...this.endpoint } : null;
}
}
+65 -2
View File
@@ -13,21 +13,30 @@ import { createAvatarStore } from "../../data/avatars.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createBotRouter } from "./bot.js";
import { getDefaultConfig, type BotConfig, type JellyfinConfig } from "../../data/config.js";
import {
getDefaultConfig,
type BotConfig,
type JellyfinConfig,
type VoiceDuckingConfig,
} from "../../data/config.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import type { BotManager } from "../../bot/manager.js";
/** Records every updateIdleTimeout / updateAutoPause call so the test can assert propagation. */
/** Records live settings updates so the tests can assert per-bot propagation. */
function makeFakeBot() {
return {
idleTimeoutCalls: [] as number[],
autoPauseCalls: [] as boolean[],
voiceDuckingCalls: [] as VoiceDuckingConfig[],
updateIdleTimeout(minutes: number) {
this.idleTimeoutCalls.push(minutes);
},
updateAutoPause(enabled: boolean) {
this.autoPauseCalls.push(enabled);
},
updateVoiceDucking(settings: VoiceDuckingConfig) {
this.voiceDuckingCalls.push({ ...settings });
},
};
}
@@ -84,6 +93,60 @@ describe("bot router /settings", () => {
expect(res.body.autoPauseOnEmpty).toBe(true);
});
it("GET /settings includes voiceDucking with safe defaults", async () => {
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.voiceDucking).toEqual({ enabled: false, volumePercent: 30 });
});
it("POST /settings safely partial-merges, persists and hot-applies voiceDucking", async () => {
const enable = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: true } });
expect(enable.status).toBe(200);
expect(enable.body.voiceDucking).toEqual({ enabled: true, volumePercent: 30 });
const setVolume = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { volumePercent: 42.5 } });
expect(setVolume.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([
{ enabled: true, volumePercent: 30 },
{ enabled: true, volumePercent: 42.5 },
]);
}
const persisted = JSON.parse(readFileSync(configPath, "utf-8"));
expect(persisted.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.voiceDucking).toEqual({ enabled: true, volumePercent: 42.5 });
});
it("POST /settings ignores malformed voiceDucking fields and non-object blocks", async () => {
config.voiceDucking = { enabled: true, volumePercent: 25 };
const invalidFields = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: { enabled: "yes", volumePercent: 101 } });
expect(invalidFields.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
const arrayBlock = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ voiceDucking: [{ enabled: false, volumePercent: 0 }] });
expect(arrayBlock.status).toBe(200);
expect(config.voiceDucking).toEqual({ enabled: true, volumePercent: 25 });
for (const bot of fakeBots) {
expect(bot.voiceDuckingCalls).toEqual([{ enabled: true, volumePercent: 25 }]);
}
});
it("POST /settings with autoPauseOnEmpty:false persists and propagates to bots", async () => {
const res = await request(app)
.post("/api/bot/settings")
+32 -1
View File
@@ -71,6 +71,7 @@ export function createBotRouter(
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
@@ -86,7 +87,14 @@ export function createBotRouter(
// POST /api/bot/settings — 保存全局 bot 行为设置 (gated: changing global bot
// behavior is a bot.manage operation, consistent with PR #80's permission model)
router.post("/settings", requirePermission("bot.manage"), (req, res) => {
const { idleTimeoutMinutes, autoPauseOnEmpty, localAudioEnabled, guestMode, adminGroups } = req.body;
const {
idleTimeoutMinutes,
autoPauseOnEmpty,
localAudioEnabled,
voiceDucking,
guestMode,
adminGroups,
} = req.body;
const hasIdle = idleTimeoutMinutes !== undefined;
if (hasIdle && (typeof idleTimeoutMinutes !== "number" || idleTimeoutMinutes < 0)) {
@@ -101,6 +109,27 @@ export function createBotRouter(
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (hasLocalAudioEnabled) config.localAudioEnabled = localAudioEnabled;
// Voice ducking is a partial settings block. Merge only known, strictly
// valid fields so malformed JSON cannot replace the object or inject NaN /
// out-of-range gain values into the live audio path.
const hasVoiceDucking =
voiceDucking !== null &&
typeof voiceDucking === "object" &&
!Array.isArray(voiceDucking);
if (hasVoiceDucking) {
if (typeof voiceDucking.enabled === "boolean") {
config.voiceDucking.enabled = voiceDucking.enabled;
}
if (
typeof voiceDucking.volumePercent === "number" &&
Number.isFinite(voiceDucking.volumePercent) &&
voiceDucking.volumePercent >= 0 &&
voiceDucking.volumePercent <= 100
) {
config.voiceDucking.volumePercent = voiceDucking.volumePercent;
}
}
// Saved-queues + play-keeps-queue toggles (default off). Both read live from
// config by BotInstance / the saved-queues router, so no per-bot push needed;
// only a literal boolean mutates the stored value (junk is ignored).
@@ -244,11 +273,13 @@ export function createBotRouter(
for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
if (hasAutoPause) bot.updateAutoPause(config.autoPauseOnEmpty);
if (hasVoiceDucking) bot.updateVoiceDucking(config.voiceDucking);
}
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
voiceDucking: config.voiceDucking,
localAudioEnabled: config.localAudioEnabled,
savedQueuesEnabled: config.savedQueuesEnabled,
playKeepsQueue: config.playKeepsQueue,
+17 -12
View File
@@ -1,19 +1,24 @@
@echo off
title TSMusicBot
:: ============================================================
:: TSMusicBot - convenience launcher at the repo root.
:: Everything real lives in scripts\start.bat; this file only makes sure we
:: run from the project directory and then delegates, so both entry points
:: behave identically (same node/dist checks, same native-module preflight).
:: ============================================================
:: Check node
where node >nul 2>&1
if errorlevel 1 (
echo Node.js not found. Run scripts\setup.bat first.
cd /d "%~dp0" || (
echo [FATAL] Cannot change to the project directory.
pause
exit /b 1
)
echo Starting TSMusicBot...
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
:: Keep lines inside parenthesised blocks pure ASCII: cmd.exe mis-tracks its
:: file offset when a block contains multi-byte UTF-8 and eats the "echo " prefix.
if not exist "scripts\start.bat" (
echo scripts\start.bat not found - is this the TSMusicBot project folder?
pause
exit /b 1
)
node dist\index.js
pause
call "scripts\start.bat"
exit /b %errorlevel%
+16
View File
@@ -22,6 +22,22 @@
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
<meta name="referrer" content="same-origin">
<title>TSMusicBot</title>
<!-- Icons live in web/public/ so Vite copies them to the dist root, which is
what Express serves (src/index.ts STATIC_DIR). Both an .ico and an .svg
are declared: browsers that understand the vector one prefer it and stay
sharp on hi-dpi tabs, the rest fall back to the .ico. The .ico also
answers the implicit /favicon.ico request — without a real file there the
SPA catch-all hands back index.html with a 200, so the tab silently keeps
the blank-page icon (issue #142). -->
<link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="any">
<!-- iOS home-screen icon; iOS masks the corners itself, so this one is a
full square. Android reads the manifest instead. -->
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="manifest" href="/site.webmanifest">
<!-- Matches --bg-primary of the dark theme, which is what the app starts in
(stores/player.ts defaults to 'dark' and never follows the OS scheme). -->
<meta name="theme-color" content="#222222">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#335eea"/>
<g fill="#ffffff" transform="translate(48.46 51.1) scale(0.88) translate(-50 -50)">
<path d="M43.5 24 L86.5 15 L86.5 26 L43.5 35 Z"/>
<rect x="43.5" y="24" width="6" height="47"/>
<rect x="80.5" y="15" width="6" height="47"/>
<ellipse cx="32" cy="71" rx="15" ry="11.5" transform="rotate(-20 32 71)"/>
<ellipse cx="69" cy="62" rx="15" ry="11.5" transform="rotate(-20 69 62)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+26
View File
@@ -0,0 +1,26 @@
{
"name": "TSMusicBot",
"short_name": "TSMusicBot",
"start_url": "/",
"display": "standalone",
"background_color": "#222222",
"theme_color": "#222222",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+235 -11
View File
@@ -9,9 +9,20 @@
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<!-- Mobile mini player -->
<div v-if="currentSong" class="m-player" @click="router.push('/lyrics')">
<div class="m-player-progress">
<div class="m-player-progress-fill" :style="{ width: mobileProgressPct + '%' }" />
<div v-if="currentSong" class="m-player" @click="onPlayerRowClick">
<div
ref="seekBarRef"
class="m-player-progress"
:class="{ 'no-seek': !canSeek, dragging: seeking }"
@pointerdown="onSeekDown"
@pointermove="onSeekMove"
@pointerup="onSeekUp"
@pointercancel="onSeekCancel"
>
<div class="m-player-progress-track">
<div class="m-player-progress-fill" :style="{ width: seekBarPct + '%' }" />
<div class="m-player-progress-thumb" :style="{ left: seekBarPct + '%' }" />
</div>
</div>
<CoverArt :url="currentSong.coverUrl" :size="40" :radius="8" />
<div class="m-player-info">
@@ -79,7 +90,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from './stores/player.js';
@@ -132,15 +143,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null;
function updateMobileProgress() {
const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
// While the finger owns the bar, the clock must keep its hands off it — see
// seekBarPct below. Skipping the write (rather than letting it be overridden)
// also avoids 60 pointless reactive re-renders per second mid-drag.
if (!seeking.value) {
const duration = currentSong.value?.duration ?? 0;
// liveElapsed() recomputes each frame; the cached `elapsed` getter would
// leave the mobile bar frozen between server pushes (#107).
mobileProgressPct.value = duration > 0
? Math.min((playerStore.liveElapsed() / duration) * 100, 100)
: 0;
}
mobileRaf = requestAnimationFrame(updateMobileProgress);
}
// --- Mini-player seek (#143) -------------------------------------------------
// The mobile progress bar used to be display-only. It now supports tap-to-seek
// and drag-to-seek via Pointer Events (one code path for touch, pen and mouse —
// no mouse/touch handler pairs) with setPointerCapture, so the drag survives the
// finger sliding off the 12px strip.
//
// Decoupling, exactly the reasoning of composables/useDecoupledSlider.ts (#111):
// updateMobileProgress() rewrites the rendered percentage every animation frame
// from the *server* clock, which is still the pre-seek position while the user
// drags. Binding the bar straight to it would snap the fill back under the
// finger ~60 times a second. So the rendered value is a computed that switches
// its source: the finger while `seeking`, the clock otherwise.
const seekBarRef = ref<HTMLElement | null>(null);
const seeking = ref(false);
const seekPct = ref(0);
let seekPointerId: number | null = null;
// The song the gesture started on. currentSong can advance mid-drag (the track
// ends), and the ratio the finger picked means nothing against a different
// song's duration.
let seekSongId: string | null = null;
// Bumped per gesture so a slow seek POST can't clear the override belonging to a
// newer drag that started while it was still in flight.
let seekGeneration = 0;
// Timestamp of the last seek gesture end, used to swallow the trailing click
// (see onPlayerRowClick).
let seekEndedAt = 0;
const seekBarPct = computed(() => (seeking.value ? seekPct.value : mobileProgressPct.value));
/** Pointer x → 0..1 along the strip, or null when the element isn't measurable. */
function seekRatio(e: PointerEvent): number | null {
const el = seekBarRef.value;
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width <= 0) return null;
return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
}
/** Duration guard: live streams report 0/undefined and ratio*0 would seek to 0,
* while a missing duration would produce NaN — which the API rejects with 400. */
function seekableDuration(): number {
const duration = currentSong.value?.duration ?? 0;
return Number.isFinite(duration) && duration > 0 ? duration : 0;
}
// Drives the `no-seek` class as well as the gesture guard, so a bar that cannot
// be seeked also gives `touch-action` back to the page — otherwise the strip
// would be a 12px band that neither seeks nor scrolls.
const canSeek = computed(() => canTransport.value && seekableDuration() > 0);
function endSeekGesture() {
const el = seekBarRef.value;
if (el && seekPointerId !== null && el.hasPointerCapture?.(seekPointerId)) {
el.releasePointerCapture(seekPointerId);
}
seekPointerId = null;
seekEndedAt = Date.now();
}
function onSeekDown(e: PointerEvent) {
// Seeking is gated on transport, like the desktop player's `no-seek` state:
// without it the bar stays purely visual and taps fall through to the row.
if (!canSeek.value) return;
// One gesture at a time: a second finger landing on the strip would otherwise
// steal seekPointerId, leaving the first pointer captured forever and
// committing whichever finger happened to lift first.
if (seekPointerId !== null) return;
const ratio = seekRatio(e);
if (ratio === null) return;
// Never let the row's router.push('/lyrics') fire while the user is seeking.
e.stopPropagation();
e.preventDefault(); // suppress text selection / compat mouse events during the drag
seekPointerId = e.pointerId;
seekSongId = currentSong.value?.id ?? null;
seekGeneration += 1;
seekBarRef.value?.setPointerCapture?.(e.pointerId);
seeking.value = true;
seekPct.value = ratio * 100;
}
function onSeekMove(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
const ratio = seekRatio(e);
if (ratio === null) return;
e.stopPropagation();
seekPct.value = ratio * 100;
}
async function onSeekUp(e: PointerEvent) {
if (!seeking.value || e.pointerId !== seekPointerId) return;
e.stopPropagation();
// A tap never moves, so pointerup is also the commit point for tap-to-seek.
const ratio = seekRatio(e) ?? seekPct.value / 100;
seekPct.value = ratio * 100;
const duration = seekableDuration();
const generation = seekGeneration;
// If the track advanced while the finger was down, the ratio belongs to a
// song that is no longer playing — drop the seek rather than applying it to
// whatever started next.
const sameSong = currentSong.value?.id === seekSongId;
endSeekGesture(); // must run synchronously, before the awaited POST
try {
if (duration > 0 && sameSong) await playerStore.seek(ratio * duration);
} catch {
// Seek rejected (403/400/offline) — fall back to the server clock below.
} finally {
// Release the local override only once seek() has resolved. store.seek()
// moves its timing anchor to the requested position in the same tick, so
// liveElapsed() already reports the new spot and the bar simply carries on
// from where the finger left it. Releasing at pointerup instead would show
// the *old* position for one round-trip and then jump a second time.
// (_syncAfterAction re-polls 500ms later, but that only nudges the bar by
// the network delta — not worth freezing the clock for.)
if (generation === seekGeneration) seeking.value = false;
}
}
function onSeekCancel(e: PointerEvent) {
if (e.pointerId !== seekPointerId) return;
// Gesture stolen (system gesture, call, …): abandon without seeking and hand
// the bar straight back to the clock.
endSeekGesture();
seeking.value = false;
}
// The whole mini player lives inside `v-if="currentSong"`, so when playback
// stops mid-drag the strip is destroyed and no pointerup/pointercancel can ever
// reach it — element removal is not a pointercancel trigger. Without this the
// `seeking` override would stay true and the progress bar would sit frozen for
// the rest of the session. Bumping the generation also neuters the finally of
// any seek still in flight.
watch(currentSong, () => {
if (!seeking.value) return;
seekGeneration += 1;
seekPointerId = null;
seekSongId = null;
seeking.value = false;
});
function onPlayerRowClick() {
// Both a tap and a drag on the strip emit a trailing `click`, which would
// otherwise navigate to /lyrics the moment the user finishes seeking. A
// `@click.stop` on the strip is not enough: after a drag the click's target is
// the nearest common ancestor of the pointerdown/pointerup hit-tests, i.e.
// `.m-player` itself once the finger has left the 12px strip. So the row
// swallows any click arriving right after a seek gesture. A timestamp rather
// than a flag, so a gesture that produces no click at all (preventDefault,
// pointercancel) can't leave the row permanently unclickable — and so an inert
// strip (no transport permission, unknown duration) still falls through here
// and navigates, exactly as it did before.
if (Date.now() - seekEndedAt < 400) return;
router.push('/lyrics');
}
function toggleMobileVolume() {
mobileVolumeOpen.value = !mobileVolumeOpen.value;
if (mobileVolumeOpen.value) mobileQueueOpen.value = false;
@@ -242,15 +412,69 @@ onUnmounted(() => {
top: 0;
left: 10px;
right: 10px;
// The visible track stays 2px, but 2px is not a touch target (#143), so the
// hit area is 12px and grows DOWNWARD into the mini player's own 8px top
// padding. It must not reach the transport buttons: they are 32px tall and
// centred in the 58px row's 42px content box, i.e. their top edge sits at
// 8 + (42 - 32) / 2 = 13px. 12px clears them by 1px. Growing upward is not an
// option — that is outside the player's rounded top edge.
height: 12px;
// Without this the browser claims the gesture for page scrolling partway
// through the drag and the pointermove stream stops.
touch-action: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
// No transport permission → purely decorative (mirrors Player.vue's .no-seek).
// Handing touch-action back matters: an inert strip must not eat gestures.
&.no-seek {
touch-action: auto;
}
}
.m-player-progress-track {
position: relative;
// Nudged down inside the 12px hit area so the drag thumb, which is centred on
// the track, stays within the card instead of poking out above its top edge.
// The 8px thumb's box is (3 + 1 - 4) = 0 to 8, i.e. exactly flush with the
// card. (The container is absolutely positioned, so it forms a BFC and this
// margin cannot collapse through it.)
margin-top: 3px;
height: 2px;
border-radius: 1px;
background: var(--border-color);
}
.m-player-progress-fill {
height: 2px;
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-primary);
border-radius: 1px;
}
.m-player-progress-thumb {
position: absolute;
top: 1px;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
background: var(--color-primary);
border-radius: var(--radius-full);
opacity: 0;
transform: scale(0);
transition: opacity var(--transition-fast), transform var(--transition-fast);
pointer-events: none;
}
.m-player-progress.dragging .m-player-progress-thumb {
opacity: 1;
transform: scale(1);
}
.m-player-info {
flex: 1;
min-width: 0;
+28 -1
View File
@@ -26,6 +26,7 @@
:key="`${song.id}-${i}`"
class="queue-item"
:class="{ active: store.currentSong?.id === song.id }"
@click="onRowClick($event, i)"
@dblclick="playAtIndex(i)"
>
<CoverArt :url="song.coverUrl" :size="32" :radius="4" />
@@ -33,7 +34,7 @@
<div class="queue-song-name">{{ song.name }}</div>
<div class="queue-song-artist">{{ song.artist }}</div>
</div>
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click="removeSong(i)" title="移除">
<button v-if="can('player.queue') || guestCan('removeClear')" class="remove-btn" @click.stop="removeSong(i)" title="移除">
<Icon icon="mdi:close" />
</button>
</div>
@@ -72,6 +73,21 @@ async function playAtIndex(index: number) {
await store.fetchQueue();
}
// Touch has no `dblclick`, so double-click-to-play was dead in the mobile queue
// drawer (#143). Same per-event touch rule as SongCard.vue — see the full
// rationale in the `isTouchClick` comment there. Permission gating stays in
// playAtIndex(); the remove button uses @click.stop so it can't double-fire.
function isTouchClick(e: MouseEvent): boolean {
const pointerType = (e as PointerEvent).pointerType;
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
}
function onRowClick(e: MouseEvent, index: number) {
if (!isTouchClick(e)) return;
void playAtIndex(index);
}
async function removeSong(index: number) {
if (!store.activeBotId) return;
try {
@@ -205,4 +221,15 @@ async function clearAndStop() {
color: var(--text-tertiary);
&:hover { color: var(--text-primary); }
}
// Touch devices have no :hover, so the parent-hover-reveals-the-button pattern
// leaves an *invisible but still tappable* remove button on the right edge of
// every row. Now that a single tap on the row plays (#143), that invisible
// target reads as "I tapped to play and it deleted the song" — show it, same as
// .song-actions in SongCard.vue.
@media (pointer: coarse) {
.remove-btn {
opacity: 1;
}
}
</style>
+31 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="song-card" :class="{ active }" @dblclick="showPlay && $emit('play')">
<div class="song-card" :class="{ active }" @click="onRowClick" @dblclick="showPlay && $emit('play')">
<div class="song-index">{{ index }}</div>
<CoverArt :url="song.coverUrl" :size="36" :radius="6" />
<div class="song-info">
@@ -51,12 +51,41 @@ const showPlay = computed(() => can('player.control') || guestCan('playNow'));
const showPlayNext = computed(() => can('player.control') || guestCan('playNext'));
const showAdd = computed(() => can('player.queue') || guestCan('addToQueue'));
defineEmits<{
const emit = defineEmits<{
play: [];
playNext: [];
add: [];
}>();
/**
* Was this click made with a finger/stylus rather than a mouse? (#143)
*
* `dblclick` is mouse-only and never fires on touch, so double-tap-to-play was
* simply dead on phones. A single *tap* plays instead, while a single mouse
* click must keep doing nothing — otherwise desktop behaviour changes and the
* surviving dblclick would fire play twice.
*
* The check is per-EVENT, not per-device: a `click` is a PointerEvent in modern
* browsers, so pointerType describes how *this* click was made. A global
* matchMedia('(pointer: coarse)') check reports only the *primary* pointer and
* is therefore wrong on hybrid laptops (touchscreen + trackpad); it is used only
* as a fallback for browsers that give us no pointerType.
*/
function isTouchClick(e: MouseEvent): boolean {
const pointerType = (e as PointerEvent).pointerType;
if (pointerType) return pointerType === 'touch' || pointerType === 'pen';
return window.matchMedia?.('(pointer: coarse)').matches ?? false;
}
// Listening on `click` rather than `pointerup` on purpose: the browser already
// suppresses the click that ended a scroll gesture, so a tap that was really the
// start of a flick can't hijack playback for everyone in the channel.
// (The action buttons stop propagation, so they never double-fire this.)
function onRowClick(e: MouseEvent) {
if (!showPlay.value || !isTouchClick(e)) return;
emit('play');
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
+187 -2
View File
@@ -387,7 +387,7 @@
<img :src="qqQr.dataUrl" class="qr-image" alt="QR Code" />
<div class="qr-status" :class="qqQr.status">
<template v-if="qqQr.status === 'waiting'">
<Icon icon="mdi:cellphone" /> 请使用QQ音乐APP扫码
<Icon icon="mdi:cellphone" /> 请使用手机QQ扫码
</template>
<template v-else-if="qqQr.status === 'scanned'">
<Icon icon="mdi:check" /> 已扫码,请在手机上确认
@@ -778,6 +778,70 @@
/>
</label>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">语音闪避</div>
<div class="profile-toggle-hint">检测到其他客户端说话时自动压低音乐音量,说话结束后恢复。默认关闭。</div>
</div>
<input
v-model="voiceDuckingEnabled"
type="checkbox"
class="profile-toggle-switch"
:disabled="voiceDuckingControlsDisabled"
@change="saveVoiceDucking"
/>
</label>
<div class="setting-row voice-ducking-volume">
<div class="setting-label">
<Icon icon="mdi:volume-minus" class="setting-icon" />
<div>
<div>说话时保留原音量</div>
<div class="voice-ducking-hint">例如设为 30%,有人说话时音乐将降至原音量的 30%。</div>
</div>
</div>
<div class="voice-ducking-controls">
<input
v-model.number="voiceDuckingVolumePercent"
type="range"
min="0"
max="100"
step="0.1"
class="voice-ducking-range"
:disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比"
/>
<div class="prefix-input-wrap">
<input
v-model.number="voiceDuckingVolumePercent"
type="number"
min="0"
max="100"
step="0.1"
class="input input-sm"
style="max-width:80px"
:disabled="voiceDuckingControlsDisabled"
aria-label="说话时保留原音量百分比"
@blur="normalizeVoiceDuckingVolume"
/>
<span class="voice-ducking-unit">%</span>
<button class="btn-primary" :disabled="voiceDuckingControlsDisabled" @click="saveVoiceDucking">
{{ !voiceDuckingLoaded ? '加载设置…' : voiceDuckingSaving ? '保存中…' : '保存比例' }}
</button>
</div>
</div>
<p
v-if="voiceDuckingMessage"
class="voice-ducking-message"
:class="`tone-${voiceDuckingMessageTone}`"
:role="voiceDuckingMessageTone === 'warn' ? 'alert' : 'status'"
:aria-live="voiceDuckingMessageTone === 'warn' ? 'assertive' : 'polite'"
aria-atomic="true"
>
{{ voiceDuckingMessage }}
</p>
</div>
<label class="profile-toggle behavior-toggle">
<div class="profile-toggle-text">
<div class="profile-toggle-label">本地音频播放</div>
@@ -1595,16 +1659,59 @@ async function savePrefix() {
const idleTimeout = ref(0);
// Defaults OFF to match the backend default (config.ts getDefaultConfig).
const autoPauseOnEmpty = ref(false);
// Voice ducking defaults OFF and retains 30% of the configured player volume.
const voiceDuckingEnabled = ref(false);
const voiceDuckingVolumePercent = ref(30);
const voiceDuckingLoaded = ref(false);
const voiceDuckingSaving = ref(false);
const voiceDuckingControlsDisabled = computed(
() => !voiceDuckingLoaded.value || voiceDuckingSaving.value,
);
const voiceDuckingMessage = ref('');
const voiceDuckingMessageTone = ref<'ok' | 'warn'>('ok');
let savedVoiceDucking = { enabled: false, volumePercent: 30 };
let voiceDuckingRequestRevision = 0;
const localAudioEnabled = ref(true);
// Saved-queues + play-keeps-queue toggles (#119), both default OFF.
const savedQueuesEnabled = ref(false);
const playKeepsQueue = ref(false);
function normalizeVoiceDuckingVolume(): number {
const raw = voiceDuckingVolumePercent.value as number | string;
const value = raw === '' ? Number.NaN : Number(raw);
voiceDuckingVolumePercent.value = Number.isFinite(value)
? Math.min(100, Math.max(0, value))
: savedVoiceDucking.volumePercent;
return voiceDuckingVolumePercent.value;
}
function applyVoiceDuckingConfig(config: unknown) {
if (!config || typeof config !== 'object') return;
const value = config as { enabled?: unknown; volumePercent?: unknown };
voiceDuckingEnabled.value = typeof value.enabled === 'boolean' ? value.enabled : false;
const percent = value.volumePercent;
voiceDuckingVolumePercent.value = typeof percent === 'number' && Number.isFinite(percent)
? Math.min(100, Math.max(0, percent))
: 30;
savedVoiceDucking = {
enabled: voiceDuckingEnabled.value,
volumePercent: voiceDuckingVolumePercent.value,
};
}
async function loadIdleTimeout() {
const voiceDuckingLoadRevision = voiceDuckingRequestRevision;
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
// A later save owns the state. Do not let an older GET response overwrite
// it if this loader is ever re-entered while a POST is in flight.
if (voiceDuckingLoadRevision === voiceDuckingRequestRevision) {
applyVoiceDuckingConfig(res.data.voiceDucking ?? { enabled: false, volumePercent: 30 });
voiceDuckingLoaded.value = true;
voiceDuckingMessage.value = '';
}
localAudioEnabled.value = res.data.localAudioEnabled ?? true;
savedQueuesEnabled.value = res.data.savedQueuesEnabled ?? false;
playKeepsQueue.value = res.data.playKeepsQueue ?? false;
@@ -1620,7 +1727,12 @@ async function loadIdleTimeout() {
}
// null (unset) → "" so the select shows "自动(按优先级)".
defaultPlatformForm.value = res.data.defaultPlatform ?? '';
} catch { /* ignore */ }
} catch {
if (!voiceDuckingLoaded.value) {
voiceDuckingMessageTone.value = 'warn';
voiceDuckingMessage.value = '语音闪避设置加载失败,请刷新页面重试';
}
}
}
async function saveIdleTimeout() {
@@ -1635,6 +1747,29 @@ async function saveAutoPause() {
} catch { /* ignore */ }
}
async function saveVoiceDucking() {
if (!voiceDuckingLoaded.value || voiceDuckingSaving.value) return;
voiceDuckingSaving.value = true;
voiceDuckingRequestRevision++;
voiceDuckingMessage.value = '';
const submitted = {
enabled: voiceDuckingEnabled.value,
volumePercent: normalizeVoiceDuckingVolume(),
};
try {
const res = await axios.post('/api/bot/settings', { voiceDucking: submitted });
applyVoiceDuckingConfig(res.data?.voiceDucking ?? submitted);
voiceDuckingMessageTone.value = 'ok';
voiceDuckingMessage.value = '已保存';
} catch {
applyVoiceDuckingConfig(savedVoiceDucking);
voiceDuckingMessageTone.value = 'warn';
voiceDuckingMessage.value = '保存失败,请稍后重试';
} finally {
voiceDuckingSaving.value = false;
}
}
async function saveLocalAudioEnabled() {
try {
const res = await axios.post('/api/bot/settings', { localAudioEnabled: localAudioEnabled.value });
@@ -2912,6 +3047,45 @@ onUnmounted(() => {
padding-top: 4px;
}
.voice-ducking-volume {
padding: 4px 0 14px;
}
.voice-ducking-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
line-height: 1.4;
font-weight: 400;
}
.voice-ducking-controls {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.voice-ducking-range {
flex: 1 1 240px;
min-width: 160px;
accent-color: var(--color-primary);
cursor: pointer;
}
.voice-ducking-unit {
font-size: 13px;
opacity: 0.7;
}
.voice-ducking-message {
margin: 8px 0 0;
font-size: 12px;
&.tone-ok { color: var(--color-online); }
&.tone-warn { color: #e26a6a; }
}
@media (max-width: 768px) {
.profile-bot-header {
padding: 14px 12px;
@@ -2939,6 +3113,17 @@ onUnmounted(() => {
transform: translateX(20px);
}
}
.voice-ducking-controls {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.voice-ducking-range {
flex-basis: auto;
width: 100%;
}
}
// --- User Management ---