Compare commits

..
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 6b82df4e50 feat(playlist): load a playlist straight from its link (#160)
`!playlist` already pulled a numeric id out of a URL, but the platform
still came from flags, so a QQ link without -q was looked up on NetEase,
and a YouTube ?list= link fell through to a name search on the URL.

- Detect NetEase / QQ Music / YouTube playlist links (also inside an
  app's share text and the [URL] BBCode TeamSpeak adds) and take the
  platform from the link.
- Follow NetEase (163cn.tv) and QQ (c6.y.qq.com/base/fcgi-bin/u) share
  short links one hop. Only those hosts are fetched.
- Document it in the README command table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 21:41:05 +08:00
TIANYAO ZHANG 8ff51ea6e0 Merge pull request #166 from xxmod/main
fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
2026-09-27 21:12:25 +08:00
xxmod 3c0e8df763 fix(bilibili): 修复了B站分P视频播放时只能播放第一P,且时长显示为视频总时长
在网页端播放多P视频时弹出界面选择需要播放的P数,ts里!play播放则只播放第一P
2026-09-22 17:10:54 +08:00
TIANYAO ZHANG 2ea02f54d9 Merge pull request #155 from ZHANGTIANYAO1/fix/152-setup-console-eio
fix(setup): stop a failed console write from aborting setup, require Node 22+ (#152)
2026-08-31 16:22:36 +08:00
saopig1andClaude Opus 5 a804b2edc1 feat(setup)!: require Node 22.12+ and drop Node 20 (#152)
better-sqlite3 stopped publishing prebuilt binaries for Node 20's ABI
(115) in 12.10.0 - upstream, not a mirror gap:

    12.8.0 / 12.9.0   115 127 131 137 141
    12.10.0+              127 137 141 147

`better-sqlite3: ^12.8.0` resolves well past that, so every Node 20
install 404'd on the CDN, fell through to the source build, and demanded
Python plus a C++ toolchain before the bot could start at all. package.json
went on claiming `^20.19.0` worked, and the README went on recommending
Node 20 as one of two blessed versions. It was not a supported
configuration in any meaningful sense - it was a trap.

So say so up front: engines, both setup scripts, and the Docker images now
require Node 22.12+ (or 24+, which still needs a source build for opus).
The version gate in setup.bat / setup.sh is kept byte-identical to the
engines range, as before.

Also copy scripts/lib/console-log.mjs into the production image. The
previous commit had check-native.mjs import it, and the Dockerfile copies
check-native.mjs in on its own for `docker exec ... npm start` - without
its dependency that preflight now dies with ERR_MODULE_NOT_FOUND.

BREAKING CHANGE: Node 20 is no longer supported. Node 22.12 LTS or newer
is required; setup refuses to run on anything older.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 18:27:22 +08:00
saopig1andClaude Opus 5 5e9ae49f52 fix(setup): stop a failed console write from aborting setup (#152)
setup.bat runs `chcp 65001` and shows binary progress on stderr. On some
Windows consoles - the reporter's Windows Server 2012 R2 above all - that
code page cannot render non-ASCII text and the OS fails the write with
EIO. process.stderr is an ordinary stream, so the EIO arrived as an
'error' event, and with no listener attached Node rethrew it as an
uncaught exception:

    Error: write EIO { errno: -4070, code: 'EIO', syscall: 'write' }
        at log (scripts/download-binaries.mjs:84:18)
        at ensureFfmpeg (scripts/download-binaries.mjs:451:5)

Those two frames pin it exactly: line 84 is `process.stderr.write`, and
line 451 is the first log line of the whole run that contains Chinese.
The three lines before it are pure ASCII and printed fine. Nothing was
wrong with the download it was announcing - setup killed itself inside
its own progress logging and reported the native modules as unusable.

scripts/lib/console-log.mjs now wraps both streams: it listens for
'error' so the failure can never be fatal, then degrades that stream
rather than dying - first to an ASCII rendering that keeps the English
half of each bilingual line, then silent if the stream is really gone.
The streams degrade independently, so a console that gives up costs
setup.log nothing: that stdout is a redirected file. check-native.mjs
gets the same treatment, since the console that cannot print its Chinese
is exactly the one a user needs its English from.

Also report a 404 honestly. better-sqlite3 dropped its Node 20 (ABI 115)
prebuilds in 12.10.0 and @discordjs/opus 0.10.0 has none for Node 24, so
users on those majors fall through to the source build and are told to
install Python and a C++ toolchain - when switching Node major takes two
minutes. Nothing in the output said so, and the README recommended
Node 20 as if it still worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 17:59:31 +08:00
TIANYAO ZHANG 1407cadf7b Merge pull request #154 from XuVIIJay/fix/output-side-seek
fix(audio): 网易云拖动进度条播放中断,改用输出侧 seek 兼容不支持 Range 的 CDN
2026-08-24 11:19:37 +08:00
XuVIIJayandClaude Opus 4.7 9fd1b39092 fix(audio): seek after -i (output-side) so drag-seek works on non-seekable HTTP CDNs
Input-side fast seek (-ss before -i) requires the HTTP server to support
Range/keyframe seeking. NetEase's CDN (music.126.net signed streams) doesn't,
so dragging the progress bar hung FFmpeg and the player force-killed it
(SIGKILL) with no audio. Moving -ss after -i decodes from the start and
discards to the target, which works on any HTTP stream; FFmpeg still fast-seeks
when the CDN supports it, so QQ keeps its instant resume.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-20 21:07:21 +08:00
saopig1andClaude Opus 5 af1dac848d fix(local): remux aac into .m4a so the extracted audio is bit-exact (#149)
Extraction always used Matroska (.mka) because it takes essentially any
audio codec. That is right for most codecs but wrong for AAC: MP4 records
the AAC encoder priming (the ~1000 warm-up samples every AAC encoder emits)
in an edit list, and the edit list does not survive into Matroska. The
remuxed track then decodes ~23 ms longer than the source, with the priming
samples played at the head instead of discarded.

Measured on a 5s 640x480 fixture: source audio decodes to 962980 bytes of
PCM, the .mka to 967440 — 4460 bytes / ~23 ms extra, peaking at -66 dBFS.
Inaudible in practice, but it also puts the track fractionally out of step
with its own reported duration, for no reason.

Pick the container by codec instead: aac -> .m4a (keeps the edit list),
everything else -> .mka as before. If the preferred container refuses the
codec, retry into .mka before falling back to keeping the whole video. AAC
is worth the special case because mp4 / mov / m4v — what people actually
upload — almost always carry it.

Adds the strongest available test of the "lossless" claim: decode the audio
straight out of the source mp4, decode the stored extract, assert the PCM is
byte-for-byte equal. Forcing .mka fails it with exactly the 4460-byte delta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:50:08 +08:00
saopig1andClaude Opus 5 9fdc164f98 test(session): give the change-password case a timeout that fits its work
The bcrypt change-password case runs six bcryptjs rounds (one hash to create
the user, four verifies, one hash for the new password). bcryptjs is pure JS,
so it takes ~4.5s on an idle machine against vitest's 5s default — and tipped
over whenever the full suite saturated the CPU. It read as an intermittent
failure but the work is genuinely slow, not hung.

The new #149 tests spawn real ffmpeg processes, which added enough CPU
pressure to turn an occasional flake into a near-every-run failure, so fix it
rather than leave a suite that cries wolf.

Raise this one case to 20s. Suite is now stably green across repeated full
runs: 138 files / 2109 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:40:36 +08:00
saopig1andClaude Opus 5 19ad48c4ab fix(local): keep the original size when the source video can't be deleted (#149)
After extracting the audio track, uploadAudio assigned the record's `size`
from the new .mka BEFORE deleting the source video:

    size = statSync(extracted).size;
    rmSync(filePath, { force: true });   // can throw EBUSY/EPERM on Windows
    filePath = extracted;

rmSync with force:true only swallows ENOENT — a briefly locked file (exactly
what the existing scheduleRetry machinery in this file exists to handle)
throws. The catch then discards the extract and keeps playing the original
container, which is correct, but `size` had already been overwritten with the
much smaller extracted size while the whole video stayed on disk. That makes
totalBytes() under-count and lets the upload directory grow past its quota.

Commit filePath and size together, only once the source is actually gone.

Adds a regression test that partially mocks node:fs to make rmSync throw for
the source .mp4 and asserts the persisted record (index.json — `size` is not
exposed through search()/toSong) still describes the retained file. With the
old ordering it records 27894 bytes for a 104544-byte file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:40:23 +08:00
saopig1andClaude Opus 5 c79a9a6dee docs: add v1.13.0 changelog entry
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:26:31 +08:00
TIANYAO ZHANG ea0d7abce3 Merge pull request #151 from ZHANGTIANYAO1/feat/local-video-playback
feat(local): 支持上传并播放本地视频文件(只保留音轨)
2026-08-14 01:23:02 +08:00
TIANYAO ZHANG c7b577adba Merge pull request #150 from ZHANGTIANYAO1/fix/avatar-upload-before-connect
fix(avatar): 初始化阶段不再发起注定失败的头像上传
2026-08-14 01:22:58 +08:00
saopig1andClaude Opus 5 7c3926a2ae fix(avatar): don't fire a doomed avatar upload before TeamSpeak connects (#148)
BotInstance loads the persisted custom avatar in its constructor and handed
it to profileManager.setCustomAvatar(). On an idle bot that method
immediately starts the three-step file transfer
(fileTransferInitUpload -> uploadFileData -> clientupdate) — but the
constructor runs long before tsClient.connect(), so TS3Client.client is
still null and the very first step throws "Not connected".

Scope of the bug: setCustomAvatar stores the buffer before attempting the
upload, and profileManager.onConnect() re-applies this.customAvatar once the
handshake completes, so the avatar itself did end up on the server. What the
premature call actually cost was a guaranteed-to-fail file transfer plus a
"Profile update failed" warning on every bot start — and every restart, since
manager.startBot() tears the instance down and reconstructs it. ("Not
connected" is not in handleFeatureError's unrecoverable list, so it never
disabled the avatar feature.)

Add loadCustomAvatar(), which only stores the buffer, and use it at the
constructor call site. onConnect() was already doing the real work, so
nothing is lost. Guard on length > 0 as well: avatarStore.write() is
delete-then-write, so a crash mid-write leaves a 0-byte file, and a 0-byte
Buffer is truthy — previously that took setCustomAvatar's else branch and
fired two more doomed calls (fileTransferDeleteFile + a clear).

setCustomAvatar keeps its immediate-apply behaviour, so editing the avatar
from the WebUI on a live bot still takes effect right away.

Reported-by: @shenmu-rua
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:20:44 +08:00
saopig1andClaude Opus 5 28b3cd771f feat(local): 支持上传并播放本地视频文件,只保留音轨 (#149)
本地上传此前只接受音频。想放一段本地 mp4/mov/avi 里的音乐,四道关卡
挡着(前两道在服务端,后两道在浏览器端):

1. src/music/local.ts 的 AUDIO_EXTENSIONS 只列了 12 种音频后缀;
2. src/web/api/music.ts 里 express.raw 的 type 只匹配 audio/*、
   video/webm、application/octet-stream —— 浏览器给 .mp4 打的
   Content-Type 是 video/mp4,请求体压根不会被解析,处理函数看到
   req.body === undefined,回 400「raw audio body is required」;
3. Search.vue 的 accept 属性让文件选择框把视频文件置灰;
4. isAudioFile() 把拖进来的视频文件静默丢掉。

ffmpeg 层不是瓶颈:s16le 输出格式不接受视频,ffmpeg 的自动选流本来
就只挑音轨。实测 mp4/mov/avi/mkv/flv/wmv/ts/m4v/mpg 九种容器用现有
参数全部正常出声,多音轨、带字幕、带 timecode 的也一样,所以
buildFfmpegArgs 一个字没动。

## 改动

- **打通四道关卡**:新增 VIDEO_EXTENSIONS(mp4/mov/avi/mkv/flv/wmv/
  m4v/mpg/mpeg/3gp/ts/m2ts/ogv),express.raw 收 video/*,前端 accept
  与过滤函数同步放宽。
- **上传时抽取音轨**(extractAudioTrack):视频落盘后用
  `-vn -sn -dn -map 0:a:0 -c:a copy` 把音轨原样搬进 Matroska 音频容器
  (.mka)再删掉原视频。`-c:a copy` 不重编码,无损、快,且 Matroska
  几乎收所有音频编码,不用维护「编码→后缀」对照表。实测 720p 素材
  落盘体积降到原文件的 14%,这对 5 GiB 的上传目录配额很关键——否则
  十来个视频就把配额占满了。抽取失败(冷门编码、超时)则保留原容器
  继续播,只是占地方,绝不会因此上传失败。
- **拒绝没有音轨的视频**:上传时探测,直接回「这个视频里没有音轨,
  无法播放」,而不是等到播放时静默跳过。只在 ffmpeg 确实打开了容器
  (打印了 `Input #0,`)时才拒绝——认不出的字节一律放行,截断的 mp3
  一直是这个行为,不能因为这次改动开始被拒。
- **上限从 200mb 提到 500mb**,并把超限响应从 Express 默认的 HTML
  错误页(带堆栈和服务器绝对路径)换成和本路由一致的 JSON;前端也加
  了同样的预检,不再传完几百兆才被拒。
- **上传进度**:视频比音频大得多,原来那句静止的「正在上传 N 个文件」
  看着像卡死,现在按文件显示百分比,传完切到「服务端处理中」。

## 验证

- 全量 `npx vitest run`:136 个文件 / 2070 项,新增 24 项。
- 新增测试用 ffmpeg 现造真实容器跑端到端:mp4 上传后时长正确、原
  容器已删、剩下的 .mka 能被播放链路解码出 PCM;avi/mkv/flv 同样;
  无音轨视频被拒且不留残留文件;纯音频上传字节数不变、不被重封装。
- 变异测试(逐个改回旧实现,确认新测试真的会红):后缀白名单 4 项失败、
  express.raw 的 type 5 项失败、抽取音轨 2 项失败、无音轨拒绝 2 项失败。
- `npx tsc --noEmit` 与 `npx vue-tsc --noEmit` 均 exit 0。

Reported-by: @LadenceE
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 01:20:01 +08:00
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
60 changed files with 6540 additions and 248 deletions

No files matched your search

+77 -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" />
@@ -32,7 +32,7 @@
- **游客模式(免登录点歌,默认关闭)** — 管理员可选择允许访客**无需账号密码**进入 WebUI 点歌,并逐项配置游客权限(8 个开关,默认仅「添加到队列末尾」开启)与可控机器人白名单;游客无法查看 / 修改任何设置、管理机器人或访问用户管理。开启后登录页出现 **「以游客身份进入」**。详见下文 **「游客模式 / Guest mode」** 小节
- **本地收藏歌单** — 在首页 / 搜索 / 歌单页一键收藏,收藏内容按用户存储,登录后跨设备同步
- **保存/加载播放清单 + 重启后自动恢复队列(可选,默认关闭)** — 管理员在 设置 → 行为设置 开启后,可在网页「已存队列」页或聊天命令(`!save` / `!load` / `!queues`)把当前队列保存为清单,随时**替换**加载或**追加**到队列末尾;同时机器人重启后会自动恢复并继续播放上次的队列。网页保存可选「共享」,聊天保存进入共享清单。**说明**:重启只能从当前曲目的开头恢复(不记忆播放进度);Spotify 自动恢复为尽力而为(依赖 sidecar 可用)。详见 [使用说明](#使用说明)
- **本地音频上传播放** — 在搜索页拖拽或选择本地音频上传,上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **本地音视频上传播放** — 在搜索页拖拽或选择本地文件上传,音频(mp3 / flac / wav / m4a / ogg / opus 等)和视频(mp4 / mov / avi / mkv / flv / wmv 等)都支持,视频上传后只保留其中的音轨;上传后可直接播放 / 下一首播放 / 加入队列;管理员可在 设置 → 行为设置 开关此功能,播放结束或停止/清空/替换队列时会清理服务端接收的本地文件
- **专属链接(单机器人锁定)** — 通过 `/bot/<id>` 专属链接打开 WebUI 时锁定到单个机器人,刷新后保持,适合把某台机器人的控制页分享给特定用户
- **频道无人时自动暂停** — 机器人所在频道没有其他人时自动暂停播放,有人加入后自动恢复(**默认关闭**,可在设置中开启)
- **Jellyfin 音源(可选)** — 连接自建 [Jellyfin](https://jellyfin.org/) 服务器作为额外音源:搜索(歌曲 / 专辑 / 歌单)、懒解析直传播放、同步歌词、收藏 Instant Mix 电台(`!fm -j`)、首页「最近添加 / 播放最多 / 收藏 / 流派」,并把播放进度回报给 Jellyfin(PlayCount / 播放状态)。**默认关闭**,在 设置 → Jellyfin 音乐库 一键开启。详见 [可选:Jellyfin 音源](#可选jellyfin-音源)
@@ -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 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 22 LTS**([nodejs.org](https://nodejs.org/) / 国内镜像 [nodejs.cn](https://nodejs.cn/))。`setup.bat` 检测到没装 Node 时会给出下载地址并退出,不会替你安装。
>
> 之后 `setup.bat` 会运行 `npm install` 安装所有依赖(包括内置 FFmpeg),按当前 Node 版本准备好原生模块,最后构建项目。之后每次只需双击 `start.bat` 启动。
>
> **Node 20 已不再支持**:better-sqlite3 从 12.10.0 起不再发布它那个 ABI(115)的预编译包,装起来必须先备好 Python + C++ 构建工具([#152](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/152))。Node 24 及更新的大版本能用,但 @discordjs/opus 0.10.0 同样没有 Node 24(ABI 137)的预编译包,安装脚本会改用源码编译,需要构建工具且耗时更久——所以推荐 22 LTS。**装好之后不要再换 Node 大版本**:原生模块只能在编译它的那个版本上加载,换版本后必须重新运行 `setup.bat`(脚本会自动检测并重装,见下方常见问题)。
### 方式二:手动安装(所有系统)
**前置条件:** [Node.js 20+](https://nodejs.org/) 和一个 TeamSpeak 服务器(TS3/TS5/TS6 均可)。
**前置条件:** [Node.js 22 LTS](https://nodejs.org/)(Node 24 及更新版本也能用,但需要源码编译原生模块;Node 20 已不再支持)和一个 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` | 停止播放并清空队列 |
@@ -359,6 +364,7 @@ sudo systemctl start tsmusicbot
| `!mode <seq\|loop\|random\|rloop>` | 切换播放模式 |
| `!playlist <歌单名或ID>` | 加载歌单(支持名称模糊搜索和 ID;Jellyfin 歌单 GUID 也可直接粘贴) |
| `!playlist -q <歌单名>` | 从 QQ 音乐搜索并加载歌单 |
| `!playlist <歌单链接>` | 直接粘贴网易云 / QQ 音乐 / YouTube 歌单链接加载,平台由链接自动识别,无需加 `-q` 等标志;也可直接粘贴 App 的分享文案或短链(`163cn.tv`、`c6.y.qq.com`) |
| `!album <专辑名或ID>` | 加载专辑(支持名称搜索 / 数字 ID / Jellyfin GUID) |
| `!artist <歌手名>` | 按歌手循环播放(支持 `-j`/`-n`/`-q`/`-k`/`-b`/`-y`) |
| `!fm` | 私人 FM(默认网易云,自动续播) |
@@ -505,7 +511,7 @@ teamspeak-music-bot/
| 层级 | 技术 |
|------|------|
| **运行时** | Node.js 20+, TypeScript 5 |
| **运行时** | Node.js 22 LTS(推荐), TypeScript 5 |
| **后端框架** | Express 4, WebSocket (ws) |
| **数据库** | better-sqlite3 (SQLite) |
| **音频处理** | FFmpeg (ffmpeg-static 内置), @discordjs/opus |
@@ -564,7 +570,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 +803,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 +885,60 @@ 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.11.1:修复 `!help` 触发机器人自动点歌
### 最新版本 — v1.13.0:本地视频上传播放 / 头像上传时机
处理了 2 个社区反馈的 issue。**没有配置变化,升级无需任何操作**;原有的本地音频上传行为完全不变。
**本地视频上传播放([#149](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/149),[PR #151](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/151),感谢 [@LadenceE](https://github.com/LadenceE))**
- 搜索页的本地上传现在也收**视频文件**:mp4 / mov / avi / mkv / flv / wmv / m4v / mpg / mpeg / 3gp / ts / m2ts / ogv。上传后当普通歌曲用——直接播放、下一首播放、加入队列都一样。
- 视频**只保留音轨**:上传后立刻把音频流原样搬进一个音频容器(不重编码、无损),再删掉原视频。720p 素材实测落盘只剩原文件的 14%,不然十几个视频就把 5 GiB 的上传目录配额占满了。
- 没有音轨的视频会在**上传时**就被拒绝并说明原因,而不是排进队列后静默跳过。
- 单文件上限从 200 MB 提到 **500 MB**;超限时的报错从 Express 默认的 HTML 错误页(带堆栈和服务器绝对路径)换成正常的中文提示,浏览器端也会在开传前就拦下超大文件。
- 上传进度按文件显示百分比,传完切到「服务端处理中」——视频比音频大得多,原先那句静止的「正在上传」看着像卡死。
- 说明:这里做的是「把你本地磁盘上的文件传上来播放」。让机器人直接读取**服务器**磁盘上任意路径的文件没有做——那等于开一个全盘任意文件读取的口子,而「播放服务器上已有的媒体库」用 Jellyfin 音源即可。
**初始化阶段不再发起注定失败的头像上传([#148](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/148),[PR #150](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/pull/150),感谢 [@shenmu-rua](https://github.com/shenmu-rua))**
- 机器人构造阶段读出已保存的自定义头像后会立刻发起文件传输,但那时 TeamSpeak 还没连上,这次传输必定失败。现在构造阶段只把头像数据装入内存,实际上传交给连接成功后的 `onConnect()`。
- **影响范围说明**:头像本身一直是能正常显示的(连接成功后本来就会重新应用一次),所以这不是「头像丢了」。真正的代价是每次启动 / 重启都会多一次注定失败的请求和一条 `Profile update failed` 警告日志——现在没有了。
- 顺带修掉一个边角:头像文件写到一半崩溃会留下 0 字节文件,原先这会再触发两个同样注定失败的请求。
### 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))**
+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": "^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",
+591
View File
@@ -0,0 +1,591 @@
diff --git a/src/music/netease.test.ts b/src/music/netease.test.ts
index b2adb3d..ecc2202 100644
--- a/src/music/netease.test.ts
+++ b/src/music/netease.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { parseLyrics } from "./netease.js";
+import { parseLyrics, mapNeteaseAlbums } from "./netease.js";
describe("NetEase adapter", () => {
it("parses LRC format lyrics", () => {
@@ -28,4 +28,32 @@ describe("NetEase adapter", () => {
expect(lines[0].text).toBe("Hello world");
expect(lines[0].translation).toBe("你好世界");
});
+
+ it("mapNeteaseAlbums maps raw cloudsearch albums to Album shape", () => {
+ const raw = [
+ {
+ id: 42,
+ name: "Album A",
+ picUrl: "https://x/p.jpg",
+ artists: [{ name: "Artist X" }, { name: "Featured Y" }],
+ size: 12,
+ },
+ {
+ id: 99,
+ name: "Album B",
+ picUrl: "",
+ artists: [],
+ },
+ ];
+ expect(mapNeteaseAlbums(raw)).toEqual([
+ { id: "42", name: "Album A", artist: "Artist X / Featured Y", coverUrl: "https://x/p.jpg", songCount: 12, platform: "netease" },
+ { id: "99", name: "Album B", artist: "", coverUrl: "", songCount: 0, platform: "netease" },
+ ]);
+ });
+
+ it("mapNeteaseAlbums returns [] for empty/null input", () => {
+ expect(mapNeteaseAlbums([])).toEqual([]);
+ expect(mapNeteaseAlbums(null as any)).toEqual([]);
+ expect(mapNeteaseAlbums(undefined as any)).toEqual([]);
+ });
});
diff --git a/src/music/netease.ts b/src/music/netease.ts
index 8aaeab6..4a863db 100644
--- a/src/music/netease.ts
+++ b/src/music/netease.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
@@ -55,6 +56,18 @@ export function parseLyrics(lrc: string, tlyric?: string): LyricLine[] {
return lines.sort((a, b) => a.time - b.time);
}
+export function mapNeteaseAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => ({
+ id: String(a.id),
+ name: a.name ?? "",
+ artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
+ coverUrl: a.picUrl ?? "",
+ songCount: a.size ?? 0,
+ platform: "netease",
+ }));
+}
+
// NetEase quality levels: standard(128k) higher(192k) exhigh(320k) lossless(flac) hires(hi-res) jyeffect jymaster
export const NETEASE_QUALITY_LEVELS = [
{ value: "standard", label: "标准 (128kbps)", bitrate: 128 },
@@ -91,7 +104,7 @@ export class NeteaseProvider implements MusicProvider {
}
async search(query: string, limit = 20): Promise<SearchResult> {
- const [songRes, playlistRes] = await Promise.all([
+ const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", {
params: { keywords: query, type: 1, limit, ...this.cookieParams },
}),
@@ -103,6 +116,9 @@ export class NeteaseProvider implements MusicProvider {
...this.cookieParams,
},
}),
+ this.api.get("/cloudsearch", {
+ params: { keywords: query, type: 10, limit: 5, ...this.cookieParams },
+ }),
]);
const songs: Song[] = (songRes.data?.result?.songs ?? []).map(
@@ -127,7 +143,9 @@ export class NeteaseProvider implements MusicProvider {
platform: "netease",
}));
- return { songs, playlists, albums: [] };
+ const albums = mapNeteaseAlbums(albumRes.data?.result?.albums);
+
+ return { songs, playlists, albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/music/qq.test.ts b/src/music/qq.test.ts
new file mode 100644
index 0000000..4f606cf
--- /dev/null
+++ b/src/music/qq.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect } from "vitest";
+import { mapQqAlbums } from "./qq.js";
+
+describe("QQ adapter", () => {
+ it("mapQqAlbums maps albumMID-style raw entries", () => {
+ const raw = [
+ {
+ albumMID: "abc",
+ albumName: "Aero",
+ singerName: "Singer A",
+ },
+ {
+ albumMID: "xyz",
+ albumName: "Beta",
+ singer: [{ name: "Singer B" }, { name: "Singer C" }],
+ },
+ ];
+ const out = mapQqAlbums(raw);
+ expect(out).toHaveLength(2);
+ expect(out[0]).toMatchObject({
+ id: "abc",
+ name: "Aero",
+ artist: "Singer A",
+ platform: "qq",
+ });
+ expect(out[0].coverUrl).toContain("T002R300x300M000abc.jpg");
+ expect(out[1].artist).toBe("Singer B / Singer C");
+ expect(out[1].coverUrl).toContain("xyz");
+ });
+
+ it("mapQqAlbums returns [] for empty/null input", () => {
+ expect(mapQqAlbums([])).toEqual([]);
+ expect(mapQqAlbums(null as any)).toEqual([]);
+ expect(mapQqAlbums(undefined as any)).toEqual([]);
+ });
+
+ it("mapQqAlbums falls back to albumPic when no albumMID", () => {
+ const raw = [{ albumName: "C", albumPic: "https://x/p.jpg", singerName: "S" }];
+ const out = mapQqAlbums(raw);
+ expect(out[0].coverUrl).toBe("https://x/p.jpg");
+ expect(out[0].id).toBe("");
+ });
+});
diff --git a/src/music/qq.ts b/src/music/qq.ts
index 9c0360d..1e7a8ae 100644
--- a/src/music/qq.ts
+++ b/src/music/qq.ts
@@ -8,6 +8,7 @@ import type {
SearchResult,
QrCodeResult,
AuthStatus,
+ Album,
} from "./provider.js";
import { parseLyrics } from "./netease.js";
@@ -27,6 +28,26 @@ const qqFavApi = axios.create({
headers: { referer: "https://y.qq.com/" },
});
+export function mapQqAlbums(raw: any[] | null | undefined): Album[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((a) => {
+ const id = String(a.albumMID ?? a.mid ?? a.albumID ?? "");
+ const artist = a.singerName
+ ?? (Array.isArray(a.singer) ? a.singer.map((s: any) => s.name).join(" / ") : "");
+ const coverUrl = id
+ ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${id}.jpg`
+ : (a.albumPic ?? "");
+ return {
+ id,
+ name: a.albumName ?? a.title ?? "",
+ artist,
+ coverUrl,
+ songCount: a.song_count ?? a.songCount ?? 0,
+ platform: "qq" as const,
+ };
+ });
+}
+
function computeGtk(pSkey: string): number {
let hash = 5381;
for (let i = 0; i < pSkey.length; i++) {
@@ -65,11 +86,12 @@ export class QQMusicProvider implements MusicProvider {
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
- param: {
- searchid: "1",
- query,
- num_per_page: Math.min(limit, 50),
- },
+ param: { searchid: "1", query, num_per_page: Math.min(limit, 50), search_type: 0 },
+ },
+ req_album: {
+ module: "music.search.SearchCgiService",
+ method: "DoSearchForQQMusicDesktop",
+ param: { searchid: "1", query, num_per_page: 5, search_type: 8 },
},
});
const res = await qqDirectApi.get("/cgi-bin/musicu.fcg", {
@@ -90,7 +112,10 @@ export class QQMusicProvider implements MusicProvider {
platform: "qq",
}));
- return { songs, playlists: [], albums: [] };
+ const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
+ const albums = mapQqAlbums(albumList);
+
+ return { songs, playlists: [], albums };
}
async getSongUrl(songId: string, quality?: string): Promise<string | null> {
diff --git a/src/web/api/music.ts b/src/web/api/music.ts
index b08f9a2..edf9c04 100644
--- a/src/web/api/music.ts
+++ b/src/web/api/music.ts
@@ -52,14 +52,20 @@ export function createMusicRouter(
]);
const songs = [
- ...(neteaseResult.status === "fulfilled"
- ? neteaseResult.value.songs
- : []),
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.songs : []),
...(qqResult.status === "fulfilled" ? qqResult.value.songs : []),
...(bilibiliResult.status === "fulfilled" ? bilibiliResult.value.songs : []),
];
+ const albums = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.albums : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.albums : []),
+ ];
+ const playlists = [
+ ...(neteaseResult.status === "fulfilled" ? neteaseResult.value.playlists : []),
+ ...(qqResult.status === "fulfilled" ? qqResult.value.playlists : []),
+ ];
- res.json({ songs });
+ res.json({ songs, albums, playlists });
} catch (err) {
logger.error({ err }, "Unified search failed");
res.status(500).json({ error: (err as Error).message });
diff --git a/src/web/api/player.ts b/src/web/api/player.ts
index a9af600..4f0930b 100644
--- a/src/web/api/player.ts
+++ b/src/web/api/player.ts
@@ -313,6 +313,78 @@ export function createPlayerRouter(
}
});
+ // Play an album by ID — mirrors play-playlist but calls getAlbumSongs
+ router.post("/:botId/play-album", async (req, res) => {
+ try {
+ const bot = (req as any).bot;
+ const { albumId, platform } = req.body;
+ const provider = bot.getProviderFor(
+ platform === "bilibili" || platform === "qq" || platform === "youtube"
+ ? platform
+ : "netease"
+ );
+
+ // Stop current playback
+ bot.getPlayer().stop();
+ bot.getPlayer().resetFailures();
+
+ const songs = await provider.getAlbumSongs(albumId);
+ if (songs.length === 0) {
+ res.json({ message: "Album is empty" });
+ return;
+ }
+
+ // QQ-specific optimization: batch-resolve playable IDs to avoid
+ // wasting retries on region/copyright-restricted tracks.
+ let queueable: { id: string }[] = songs;
+ const totalCount = songs.length;
+ const qqLike = provider as { getPlayableSongIds?: (ids: string[]) => Promise<Set<string> | null> };
+ if (typeof qqLike.getPlayableSongIds === "function") {
+ const playable = await qqLike.getPlayableSongIds(songs.map((s: { id: string }) => s.id));
+ if (playable !== null) {
+ queueable = songs.filter((s: { id: string }) => playable.has(s.id));
+ }
+ }
+ if (queueable.length === 0) {
+ res.json({ ok: false, message: `专辑 ${totalCount} 首歌曲均无版权可播放(区域/版权限制)` });
+ return;
+ }
+
+ const queue = bot.getQueueManager();
+ queue.clear();
+ for (const song of queueable) {
+ queue.add({ ...song, platform: provider.platform });
+ }
+
+ const mode = queue.getMode();
+ let first;
+ if (mode === "random" || mode === "rloop") {
+ const idx = Math.floor(Math.random() * queue.size());
+ first = queue.playAt(idx);
+ } else {
+ first = queue.play();
+ }
+
+ let started = first ? await bot.resolveAndPlay(first) : false;
+ if (first && !started) {
+ started = await bot.playNext(20);
+ }
+
+ const playing = queue.current();
+ const loadedMsg = queueable.length < totalCount
+ ? `已加载 ${queueable.length}/${totalCount} 首(其余区域/版权限制)`
+ : `已加载 ${queueable.length} 首`;
+ if (started && playing) {
+ res.json({ ok: true, message: `${loadedMsg},正在播放:${playing.name}` });
+ } else {
+ res.json({ ok: false, message: `${loadedMsg},但无法开始播放。` });
+ }
+ } catch (err) {
+ logger.error({ err }, "play-album failed");
+ res.status(500).json({ error: (err as Error).message });
+ }
+ });
+
// Play a single song by ID — resolves URL on demand
router.post("/:botId/play-song", async (req, res) => {
try {
diff --git a/web/src/router/index.ts b/web/src/router/index.ts
index cc060f5..d62afcd 100644
--- a/web/src/router/index.ts
+++ b/web/src/router/index.ts
@@ -22,6 +22,13 @@ const router = createRouter({
path: '/playlist/:id',
name: 'playlist',
component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'playlist' },
+ },
+ {
+ path: '/album/:id',
+ name: 'album',
+ component: () => import('../views/Playlist.vue'),
+ meta: { kind: 'album' },
},
{
path: '/lyrics',
diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts
index 9bc817d..083262c 100644
--- a/web/src/stores/player.ts
+++ b/web/src/stores/player.ts
@@ -322,6 +322,16 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
+ async playAlbum(albumId: string, platform = 'netease') {
+ if (!this.activeBotId) return;
+ const res = await axios.post(`/api/player/${this.activeBotId}/play-album`, { albumId, platform });
+ if (res.data?.message) {
+ this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
+ }
+ this._setTiming(this.activeBotId, { serverElapsed: 0 });
+ this._syncAfterAction();
+ },
+
async pause() {
if (!this.activeBotId) return;
// Freeze elapsed at current interpolated value
diff --git a/web/src/views/Playlist.vue b/web/src/views/Playlist.vue
index d5c9f8d..d00101d 100644
--- a/web/src/views/Playlist.vue
+++ b/web/src/views/Playlist.vue
@@ -38,7 +38,7 @@
</div>
</template>
- <div v-else class="loading">歌单不存在或加载失败</div>
+ <div v-else class="loading">{{ kind === 'album' ? '专辑' : '歌单' }}不存在或加载失败</div>
</div>
</template>
@@ -64,6 +64,8 @@ interface PlaylistDetail {
songCount: number;
}
+const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
+
const playlist = ref<PlaylistDetail | null>(null);
const songs = ref<Song[]>([]);
const loading = ref(true);
@@ -71,20 +73,32 @@ const loading = ref(true);
async function playAll() {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
- await store.playPlaylist(id, platform);
+ if (kind === 'album') {
+ await store.playAlbum(id, platform);
+ } else {
+ await store.playPlaylist(id, platform);
+ }
}
onMounted(async () => {
const id = route.params.id as string;
const platform = (route.query.platform as string) || 'netease';
+ const detailUrl = kind === 'album'
+ ? `/api/music/album/${id}/detail`
+ : `/api/music/playlist/${id}/detail`;
+ const songsUrl = kind === 'album'
+ ? `/api/music/album/${id}`
+ : `/api/music/playlist/${id}`;
+
// allSettled, not Promise.all — if detail 404s but songs is fine
// (e.g., a QQ playlist whose detail endpoint flaked but the song
// list resolved), we still want to show the songs rather than
- // the "歌单不存在" empty state.
+ // the "不存在" empty state. For albums, detail always 404s — that
+ // is intentional; the fallback stub below handles it.
const [detailRes, songsRes] = await Promise.allSettled([
- axios.get(`/api/music/playlist/${id}/detail`, { params: { platform } }),
- axios.get(`/api/music/playlist/${id}`, { params: { platform } }),
+ axios.get(detailUrl, { params: { platform } }),
+ axios.get(songsUrl, { params: { platform } }),
]);
const detail = detailRes.status === 'fulfilled' ? detailRes.value.data?.playlist : null;
@@ -96,7 +110,7 @@ onMounted(async () => {
// Fall back to a stub built from the route + first song's cover.
playlist.value = {
id,
- name: '歌单',
+ name: kind === 'album' ? '专辑' : '歌单',
description: '',
coverUrl: songList[0]?.coverUrl ?? '',
songCount: songList.length,
@@ -104,7 +118,7 @@ onMounted(async () => {
} else {
playlist.value = null;
if (detailRes.status === 'rejected') {
- console.error('Failed to load playlist detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
+ console.error('Failed to load detail:', (detailRes.reason as any)?.response?.status, (detailRes.reason as any)?.message);
}
}
songs.value = songList;
diff --git a/web/src/views/Search.vue b/web/src/views/Search.vue
index 0536e36..8822277 100644
--- a/web/src/views/Search.vue
+++ b/web/src/views/Search.vue
@@ -20,22 +20,54 @@
<div v-if="loading" class="loading">搜索中...</div>
- <div v-else-if="results.length > 0" class="results">
- <SongCard
- v-for="(song, i) in results"
- :key="`${song.platform}-${song.id}`"
- :song="song"
- :index="i + 1"
- :active="store.currentSong?.id === song.id"
- @play="store.playSong(song)"
- @playNext="store.playNextSong(song)"
- @add="store.addSong(song)"
- />
- </div>
+ <template v-else-if="songs.length || albums.length || playlists.length">
+ <section v-if="albums.length" class="result-section">
+ <h2 class="section-title">专辑</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="al in albums"
+ :key="`${al.platform}-${al.id}`"
+ :to="`/album/${al.id}?platform=${al.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="al.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ al.name }}</div>
+ <div class="card-sub">{{ al.artist }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="playlists.length" class="result-section">
+ <h2 class="section-title">歌单</h2>
+ <div class="card-grid">
+ <router-link
+ v-for="pl in playlists"
+ :key="`${pl.platform}-${pl.id}`"
+ :to="`/playlist/${pl.id}?platform=${pl.platform}`"
+ class="card hover-scale"
+ >
+ <CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
+ <div class="card-name">{{ pl.name }}</div>
+ </router-link>
+ </div>
+ </section>
+
+ <section v-if="songs.length" class="result-section">
+ <h2 class="section-title">单曲</h2>
+ <SongCard
+ v-for="(song, i) in songs"
+ :key="`${song.platform}-${song.id}`"
+ :song="song"
+ :index="i + 1"
+ :active="store.currentSong?.id === song.id"
+ @play="store.playSong(song)"
+ @playNext="store.playNextSong(song)"
+ @add="store.addSong(song)"
+ />
+ </section>
+ </template>
- <div v-else-if="searched" class="empty">
- 未找到相关结果
- </div>
+ <div v-else-if="searched" class="empty">未找到相关结果</div>
</div>
</template>
@@ -45,15 +77,21 @@ import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore } from '../stores/player.js';
+import type { Song } from '../stores/player.js';
import SongCard from '../components/SongCard.vue';
+import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const route = useRoute();
const query = ref((route.query.q as string) || '');
-import { Song } from '../stores/player.js';
-const results = ref<Song[]>([]);
+interface Album { id: string; name: string; artist: string; coverUrl: string; songCount?: number; platform: string; }
+interface Playlist { id: string; name: string; coverUrl: string; songCount?: number; platform: string; }
+
+const songs = ref<Song[]>([]);
+const albums = ref<Album[]>([]);
+const playlists = ref<Playlist[]>([]);
const loading = ref(false);
const searched = ref(false);
@@ -62,12 +100,12 @@ async function doSearch() {
loading.value = true;
searched.value = true;
try {
- const res = await axios.get('/api/music/search/all', {
- params: { q: query.value },
- });
- results.value = res.data.songs;
+ const res = await axios.get('/api/music/search/all', { params: { q: query.value } });
+ songs.value = res.data.songs ?? [];
+ albums.value = res.data.albums ?? [];
+ playlists.value = res.data.playlists ?? [];
} catch {
- results.value = [];
+ songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
@@ -141,4 +179,23 @@ onMounted(() => {
flex-direction: column;
gap: 2px;
}
+
+.result-section {
+ margin-bottom: 32px;
+ .section-title { font-size: 18px; margin: 0 0 12px; opacity: 0.85; }
+}
+.card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ gap: 16px;
+}
+.card {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ text-decoration: none;
+ color: inherit;
+ .card-name { font-size: 14px; line-height: 1.3; max-height: 2.6em; overflow: hidden; }
+ .card-sub { font-size: 12px; opacity: 0.6; }
+}
</style>
+155
View File
@@ -0,0 +1,155 @@
#!/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";
import { createLineWriter } from "./lib/console-log.mjs";
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);
// Never let a failed console write become an uncaught error and replace this
// report with a stack trace - the console that cannot print the Chinese half
// of these lines is exactly the one a user needs the English half from.
// See scripts/lib/console-log.mjs and issue #152.
const out = createLineWriter(process.stderr);
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;
}
+9 -2
View File
@@ -4,7 +4,7 @@
# ==========================================
# --- Stage 1: Build backend + frontend ---
FROM node:20-slim AS builder
FROM node:22-slim AS builder
# Install build tools for native modules (opus, better-sqlite3)
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -30,7 +30,7 @@ RUN npm run build
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# --- Stage 2: Production image ---
FROM node:20-slim
FROM node:22-slim
# Install system FFmpeg — the ffmpeg-static npm package bundles a pre-compiled
# binary that can SIGSEGV inside Docker (incompatible glibc / missing libs).
@@ -46,6 +46,13 @@ 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
# ...and the module it imports for crash-proof logging.
COPY --from=builder /app/scripts/lib/console-log.mjs ./scripts/lib/console-log.mjs
# Data directory for database, cookies, logs
RUN mkdir -p /app/data
+685 -95
View File
@@ -1,161 +1,751 @@
#!/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";
import { createLineWriter } from "./lib/console-log.mjs";
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;
const NODE_MAJOR = Number(process.versions.node.split(".")[0]);
/** The newest Node major this project is regularly tested against, and the one
* every required addon currently ships a prebuild for. Keep in sync with
* TESTED_NODE_MAJOR in scripts/setup.bat. */
const TESTED_NODE_MAJOR = 22;
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";
// Both writers swallow a failed write instead of letting it become an uncaught
// 'error' event: a console that cannot print the Chinese half of a line (issue
// #152) must not be able to abort a whole setup run. The two streams degrade
// independently, so setup.log keeps the full bilingual transcript either way.
const writeErr = createLineWriter(process.stderr);
const writeOut = createLineWriter(process.stdout);
function log(msg) {
const line = msg === "" ? "" : ` [binary] ${msg}`;
writeErr(line);
if (ECHO_STDOUT) writeOut(line);
}
// ---------------------------------------------------------------------------
// 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"] });
}
/**
* A 404 from the CDN is not a mirror outage: it means this exact package
* version publishes no prebuilt binary for the running Node ABI at all.
* @discordjs/opus 0.10.0 has no build for Node 24 (ABI 137), so a user on that
* major lands in the source-build fallback below and is told to install Python
* and a C++ toolchain. Switching Node major is the far cheaper fix, and nothing
* else in this output points at it. (better-sqlite3 dropping its Node 20 / ABI
* 115 builds in 12.10.0 is why Node 20 is no longer accepted at all.)
* See issue #152.
*/
function explainMissingPrebuild(name, version, err) {
if (!/HTTP 404/.test(err.message)) return;
log(`${name}: ${name}@${version} ships no prebuilt binary for Node ${NODE_MAJOR} (ABI ${NODE_ABI})`);
log(
`${name}: Node ${TESTED_NODE_MAJOR} LTS has one — switching Node is usually much quicker than ` +
`setting up a compiler (换用 Node ${TESTED_NODE_MAJOR} LTS 通常比装编译环境快得多)`,
);
}
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");
}
log("ffmpeg exists but seems corrupted (too small), re-downloading...");
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));
}
// 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})`);
explainMissingPrebuild(name, version, cdnErr);
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 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);
const require = createRequire(import.meta.url);
const tar = require("tar");
const tmpFile = join(tmpdir(), `better-sqlite3-${Date.now()}.tar.gz`);
writeFileSync(tmpFile, buf);
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 });
await tar.extract({ cwd: pkgDir, file: tmpFile });
if (existsSync(dest)) {
log(`better-sqlite3 OK (${((await statSync(dest)).size / 1024).toFixed(0)} KB)`);
return 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})`);
explainMissingPrebuild(name, version, cdnErr);
log(`${name}: falling back to a source build — 'npm rebuild ${name} --build-from-source' (可能需要几分钟)`);
buildFromSource(`npm rebuild ${name} --build-from-source`);
}
log("better-sqlite3 extracted but .node file not found at expected path");
return false;
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 */
}
}
}
}
// ---------------------------------------------------------------------------
// 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: @discordjs/opus 0.10.0 has no prebuild for ABI 137, so on Node 24 that
// 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)));
}
} catch (e) {
console.error(` [binary] ERROR: ${e.message}`);
process.exit(1);
}
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) {
log(`ERROR: ${e.stack || e.message}`);
process.exitCode = 1;
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Crash-proof line logging for the setup scripts.
*
* WHY THIS EXISTS (issue #152)
* ----------------------------
* setup.bat runs `chcp 65001` and shows progress on stderr. Some Windows
* consoles - Windows Server 2012 R2 above all - cannot render non-ASCII text in
* that code page and the OS fails the write with EIO. `process.stderr` is an
* ordinary stream, so that EIO arrives as an 'error' event, and a stream with
* no 'error' listener rethrows it as an uncaught exception:
*
* Error: write EIO
* at afterWriteDispatched (node:internal/stream_base_commons:159:15)
* ...
* at log (scripts/download-binaries.mjs:84:18)
* at ensureFfmpeg (scripts/download-binaries.mjs:451:5)
*
* That is setup killing itself inside its own progress logging, on the first
* line of the run that happened to contain Chinese - nothing was wrong with the
* download it was about to start.
*
* So: listen for the error and degrade instead of dying.
* full -> ascii : drop the CJK the console choked on, keep the English half
* ascii -> off : the stream is simply gone (closed pipe) - stay quiet
* Each stream degrades on its own, so a console that gives up does not cost
* setup.log its full bilingual transcript: that stdout is a redirected file.
*/
const HAS_NON_ASCII = /[^\x00-\x7F]/;
/** Placeholders for a removed run: one that separated words, one that did not. */
const SPACED = "\u0000";
const TIGHT = "\u0001";
/** Punctuation the bilingual strings use that has an obvious ASCII twin. */
const PUNCTUATION = new Map(
Object.entries({
"—": "-",
"–": "-",
"…": "...",
"“": '"',
"”": '"',
"‘": "'",
"’": "'",
",": ",",
"。": ".",
"、": ",",
":": ":",
";": ";",
"(": "(",
")": ")",
"!": "!",
"?": "?",
"←": "<-",
"→": "->",
"×": "x",
}),
);
/**
* Best-effort ASCII rendering of a log line, for a console that cannot print
* anything else. Returns null when nothing worth printing survives - every
* Chinese-only line in these scripts sits directly beside an English line
* saying the same thing, so dropping it loses no information.
*/
export function toAsciiFallback(text) {
if (!HAS_NON_ASCII.test(text)) return text;
let out = "";
for (const ch of text) out += PUNCTUATION.get(ch) ?? ch;
out = out
.replace(/[\u0000\u0001]/g, "")
// Mark each removed run rather than just deleting it, so the tidy-up below
// can tell "a separator that introduced text we dropped" from "a separator
// that belongs to the English half". SPACED was holding two ASCII words
// apart; TIGHT was hugging a bracket or a comma.
.replace(/[ \t]*[^\x00-\x7F]+[ \t]*/g, (run) =>
/^[ \t]/.test(run) && /[ \t]$/.test(run) ? SPACED : TIGHT,
)
// "(可能需要几分钟)" — the parentheses held nothing else.
.replace(/[ \t]*\([ \t]*(?:[\u0000\u0001][ \t]*)+\)/g, "")
// "FAILED — 编译失败", "(~80 MB, 请耐心等待)" — drop the trailing marks along
// with the separators that were only ever there to introduce them.
.replace(/[ \t]*[-,;:]*[ \t]*(?:[\u0000\u0001][ \t,;:-]*)+(?=[)\]]|$)/gm, "")
.replace(/\u0000/g, " ")
.replace(/\u0001/g, "")
.replace(/[ \t]+$/gm, "");
return /[A-Za-z0-9]/.test(out) ? out : null;
}
/** One degradation state per stream, shared by every writer built on it. */
const guards = new WeakMap();
function guardFor(stream) {
const existing = guards.get(stream);
if (existing) return existing;
const guard = { mode: "full" };
guards.set(stream, guard);
try {
// The whole point: without this listener the next EIO/EPIPE is fatal.
stream.on("error", () => degrade(guard));
} catch {
/* not an EventEmitter - the try/catch around write() still guards us */
}
return guard;
}
function degrade(guard) {
guard.mode = guard.mode === "full" ? "ascii" : "off";
}
/**
* Build a `writeLine(text)` that appends a newline, never throws, and never
* lets a failed console write take the process down with it.
* Returns true when the line reached the stream.
*/
export function createLineWriter(stream) {
const guard = guardFor(stream);
return function writeLine(text) {
if (guard.mode === "off") return false;
let line = text;
if (guard.mode === "ascii") {
line = toAsciiFallback(text);
if (line === null) return false;
}
try {
stream.write(`${line}\n`);
return true;
} catch {
// A synchronous throw (EBADF on a closed handle) never reaches the
// 'error' listener, so degrade here too.
degrade(guard);
return false;
}
};
}
+133
View File
@@ -0,0 +1,133 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import { createLineWriter, toAsciiFallback } from "./console-log.mjs";
/** Stand-in for process.stderr: an EventEmitter with a write() we can steer. */
function fakeStream() {
const stream = new EventEmitter();
stream.written = [];
stream.throwOnWrite = false;
stream.write = (chunk) => {
if (stream.throwOnWrite) throw new Error("EBADF");
stream.written.push(chunk);
return true;
};
return stream;
}
describe("toAsciiFallback", () => {
it("leaves ASCII lines exactly as they are", () => {
const line = " [binary] better-sqlite3: OK (loads under v22.23.2, ABI 127)";
expect(toAsciiFallback(line)).toBe(line);
expect(toAsciiFallback("")).toBe("");
});
it("keeps the English half of the line that crashed setup in #152", () => {
expect(
toAsciiFallback(
" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)",
),
).toBe(" [binary] ffmpeg-static: GET https://cdn/ffmpeg.gz (~80 MB)");
});
it("drops parentheses and separators left stranded by the removed text", () => {
expect(
toAsciiFallback(" [binary] better-sqlite3: falling back — 'npm rebuild' (可能需要几分钟)"),
).toBe(" [binary] better-sqlite3: falling back - 'npm rebuild'");
expect(
toAsciiFallback(" Windows: npm install --global windows-build-tools (或安装 VS Build Tools)"),
).toBe(" Windows: npm install --global windows-build-tools (VS Build Tools)");
});
it("drops a Chinese-only line, which always has an English twin beside it", () => {
expect(toAsciiFallback("必需的原生模块不可用,机器人无法启动 —— 请查看上面的错误信息。")).toBeNull();
});
it("preserves the indentation the summary is aligned on, and drops the dangling dash", () => {
expect(toAsciiFallback(" - better-sqlite3 FAILED — 编译失败")).toBe(
" - better-sqlite3 FAILED",
);
});
it("keeps a separator that belongs to the English half", () => {
expect(toAsciiFallback("Summary — Node v22.0.0 / ABI 127 / win32-x64:")).toBe(
"Summary - Node v22.0.0 / ABI 127 / win32-x64:",
);
});
});
describe("createLineWriter", () => {
it("appends a newline and reports the write", () => {
const stream = fakeStream();
expect(createLineWriter(stream)("hello")).toBe(true);
expect(stream.written).toEqual(["hello\n"]);
});
it("survives the EIO that killed setup: an 'error' event must not throw", () => {
const stream = fakeStream();
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
expect(() => stream.emit("error", Object.assign(new Error("write EIO"), { code: "EIO" }))).not.toThrow();
});
it("falls back to ASCII once the console has refused a line", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
write(" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)");
stream.emit("error", new Error("write EIO"));
write(" [binary] GET https://cdn/opus.tar.gz (~1 MB, 这一步比较慢,请耐心等待)");
expect(stream.written).toEqual([
" [binary] GET https://cdn/ffmpeg.gz (~80 MB, 这一步比较慢,请耐心等待)\n",
" [binary] GET https://cdn/opus.tar.gz (~1 MB)\n",
]);
});
it("goes quiet after a second failure rather than retrying a dead stream", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.emit("error", new Error("write EIO"));
stream.emit("error", new Error("write EPIPE"));
expect(write("anything at all")).toBe(false);
expect(stream.written).toEqual([]);
});
it("degrades on a synchronous throw too, which never reaches the listener", () => {
const stream = fakeStream();
const write = createLineWriter(stream);
stream.throwOnWrite = true;
expect(write(" [binary] 下载中 downloading")).toBe(false);
stream.throwOnWrite = false;
write(" [binary] 下载中 downloading");
expect(stream.written).toEqual([" [binary] downloading\n"]);
});
it("degrades each stream on its own, so setup.log keeps the full transcript", () => {
const console_ = fakeStream();
const logFile = fakeStream();
const writeConsole = createLineWriter(console_);
const writeLog = createLineWriter(logFile);
console_.emit("error", new Error("write EIO"));
const line = " [binary] ffmpeg-static: 下载完成 done";
writeConsole(line);
writeLog(line);
expect(console_.written).toEqual([" [binary] ffmpeg-static: done\n"]);
expect(logFile.written).toEqual([`${line}\n`]);
});
it("never installs a second listener for a stream that already has a writer", () => {
const stream = fakeStream();
createLineWriter(stream);
createLineWriter(stream);
expect(stream.listenerCount("error")).toBe(1);
});
it("still guards a stream that is not an EventEmitter", () => {
const stream = { write: vi.fn(() => { throw new Error("EBADF"); }) };
const write = createLineWriter(stream);
expect(() => write("line")).not.toThrow();
expect(write("line")).toBe(false);
});
});
+65 -11
View File
@@ -10,8 +10,11 @@ title TSMusicBot Setup
:: - 自动修复 PowerShell 环境变量
:: ============================================================
set "SCRIPT_VERSION=2.1"
set "MIN_NODE_MAJOR=20"
set "SCRIPT_VERSION=2.2"
set "MIN_NODE_MAJOR=22"
:: 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,40 @@ 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.
:: Node 20 was dropped: better-sqlite3 ships no prebuilt binary for its ABI
:: (115) since 12.10.0, so every Node 20 install needed Python and a C++
:: toolchain just to get off the ground (issue #152). 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]===22&&v[1]>=12)||v[0]>=24?0:1)"
if errorlevel 1 (
call :error "Node.js %NODE_VER% is not supported. Use Node 22.12+ LTS or newer."
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 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 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 +173,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.
:: ============================================================
+43 -6
View File
@@ -27,6 +27,28 @@ 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. Node 20 was dropped:
# better-sqlite3 ships no prebuilt binary for its ABI (115) since 12.10.0, so every
# Node 20 install needed Python and a C++ toolchain just to get off the ground
# (issue #152). 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]===22&&v[1]>=12)||v[0]>=24?0:1)'; then
echo "[ERROR] Node.js $(node -v) is not supported. Use Node 22.12+ LTS or newer."
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 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 +95,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 ----
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Smoke test for issue #51 — run AFTER you start the bot from temp/preview-merge
# (or from main once both PRs are merged).
#
# Usage: ./scripts/smoke_issue51.sh [HOST]
# Default HOST is http://127.0.0.1:3000
set -e
HOST="${1:-http://127.0.0.1:3000}"
PASS=0
FAIL=0
note() { echo -e "\n=== $* ==="; }
ok() { echo " [PASS] $*"; PASS=$((PASS+1)); }
bad() { echo " [FAIL] $*"; FAIL=$((FAIL+1)); }
# ---- Album search ----------------------------------------------------------
note "1. /api/music/search/all returns {songs,albums,playlists}"
RES=$(curl.exe -s "$HOST/api/music/search/all?q=%E5%91%A8%E6%9D%B0%E4%BC%A6") # 周杰伦
KEYS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(','.join(sorted(d.keys())))")
if [ "$KEYS" = "albums,playlists,songs" ]; then ok "keys = $KEYS"; else bad "keys = $KEYS (expected albums,playlists,songs)"; fi
NA=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('albums',[])))")
NS=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))")
NP=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('playlists',[])))")
echo " songs=$NS, albums=$NA, playlists=$NP"
if [ "$NA" -gt 0 ]; then ok "albums populated"; else bad "albums empty (expected >0 for 周杰伦)"; fi
if [ "$NS" -gt 0 ]; then ok "songs populated"; fi
# ---- Album detail playback path -------------------------------------------
note "2. /api/music/album/:id returns songs"
if [ "$NA" -gt 0 ]; then
ALBUM_ID=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['id'])")
PLATFORM=$(echo "$RES" | python3 -c "import json,sys;d=json.load(sys.stdin);a=d['albums'][0];print(a['platform'])")
echo " testing album id=$ALBUM_ID platform=$PLATFORM"
ASONGS=$(curl.exe -s "$HOST/api/music/album/$ALBUM_ID?platform=$PLATFORM" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('songs',[])))" 2>/dev/null || echo 0)
if [ "$ASONGS" -gt 0 ]; then ok "album returned $ASONGS songs"; else bad "album endpoint returned 0 songs"; fi
else
echo " (skipped — no albums to test)"
fi
# ---- Avatar API ------------------------------------------------------------
note "3. avatar GET 404 on bot with no avatar"
BOT_ID=$(curl.exe -s "$HOST/api/bot" | python3 -c "import json,sys;d=json.load(sys.stdin);bots=d.get('bots',[]);print(bots[0]['id'] if bots else '')")
if [ -z "$BOT_ID" ]; then bad "no bot found — create a bot first"; exit 1; fi
echo " using bot $BOT_ID"
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ] || [ "$CODE" = "200" ]; then ok "GET initial state = $CODE"; else bad "unexpected GET status $CODE"; fi
note "4. avatar PUT 200 + GET 200 round-trip"
# 1×1 transparent PNG (67 bytes)
TINY_PNG_B64="iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
PUT_RES=$(curl.exe -s -X PUT "$HOST/api/bot/$BOT_ID/avatar" -H "Content-Type: application/json" \
-d "{\"dataUrl\":\"data:image/png;base64,$TINY_PNG_B64\"}")
echo " PUT response: $PUT_RES"
GOT_PATH=$(echo "$PUT_RES" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('path',''))" 2>/dev/null || echo "")
if [ "$GOT_PATH" = "$BOT_ID.png" ]; then ok "PUT returned path=$GOT_PATH"; else bad "PUT path = $GOT_PATH (expected $BOT_ID.png)"; fi
curl.exe -s -o /tmp/avatar_check.png -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
SIZE=$(wc -c < /tmp/avatar_check.png)
if [ "$CODE" = "200" ] && [ "$SIZE" -gt 60 ]; then ok "GET returned 200, $SIZE bytes"; else bad "GET status=$CODE size=$SIZE"; fi
note "5. avatar DELETE 204 + GET 404"
curl.exe -s -X DELETE "$HOST/api/bot/$BOT_ID/avatar" -o /dev/null -w "%{http_code}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "204" ]; then ok "DELETE returned 204"; else bad "DELETE status = $CODE"; fi
curl.exe -s -o /dev/null -w "%{http_code}" "$HOST/api/bot/$BOT_ID/avatar" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "404" ]; then ok "GET after DELETE returned 404"; else bad "GET after DELETE = $CODE"; fi
note "6. avatar PUT rejects oversize (>200KB)"
BIG_B64=$(node -e "console.log(Buffer.alloc(210*1024,7).toString('base64'))")
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/png;base64,$BIG_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "413" ]; then ok "oversize rejected with 413"; else bad "oversize status = $CODE (expected 413)"; fi
note "7. avatar PUT rejects bad MIME (image/gif)"
GIF_B64="R0lGODlhAQABAAAAACw=" # tiny gif
curl.exe -s -o /dev/null -w "%{http_code}" -X PUT "$HOST/api/bot/$BOT_ID/avatar" \
-H "Content-Type: application/json" -d "{\"dataUrl\":\"data:image/gif;base64,$GIF_B64\"}" > /tmp/code
CODE=$(cat /tmp/code)
if [ "$CODE" = "400" ]; then ok "bad MIME rejected with 400"; else bad "bad MIME status = $CODE (expected 400)"; fi
# ---------------------------------------------------------------------------
echo ""
echo "============================================="
echo "SMOKE RESULT: $PASS passed, $FAIL failed"
echo "============================================="
[ "$FAIL" -eq 0 ]
+16
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,6 +51,9 @@ if errorlevel 1 (
)
:: Start the application
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist/index.js
pause
+74 -2
View File
@@ -55,13 +55,13 @@ describe("buildFfmpegArgs", () => {
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
it("inserts -ss before -i when seekSeconds > 0", () => {
it("inserts -ss after -i when seekSeconds > 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 42);
const ssIdx = args.indexOf("-ss");
const iIdx = args.indexOf("-i");
expect(ssIdx).toBeGreaterThan(-1);
expect(args[ssIdx + 1]).toBe("42");
expect(ssIdx).toBeLessThan(iIdx);
expect(ssIdx).toBeGreaterThan(iIdx);
});
it("does not insert -ss when seekSeconds is 0", () => {
@@ -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 {
+69 -5
View File
@@ -103,8 +103,10 @@ export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
"-reconnect_on_http_error", "4xx,5xx",
);
}
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0) args.push("-ss", String(seekSeconds));
args.push("-i", url, "-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
@@ -161,6 +163,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 +743,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) {
let sample = Math.round(pcm.readInt16LE(i) * factor);
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) {
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,6 +810,22 @@ 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
+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");
});
});
});
+41 -6
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,16 +186,23 @@ 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) {
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
// cycle, so every song plays once before any repeats (NetEase/QQ
+270
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. */
@@ -1387,3 +1538,122 @@ describe("BotInstance live-queue persistence (#119)", () => {
}
});
});
describe("BotInstance Bilibili multi-P resolution", () => {
it("resolves multi-P search result to P1 with accurate duration and name", async () => {
const multiPSongDetail = {
id: "BV1multiP?p=1",
name: "测试视频 - P1 分P1",
artist: "UP主",
album: "",
duration: 100, // P1 duration
coverUrl: "",
platform: "bilibili" as const,
};
const mockBili = {
platform: "bilibili" as const,
search: vi.fn().mockResolvedValue({
songs: [{
id: "BV1multiP",
name: "测试视频",
artist: "UP主",
album: "",
duration: 300, // total duration in search
coverUrl: "",
platform: "bilibili",
}],
albums: [],
playlists: [],
}),
getSongDetail: vi.fn().mockResolvedValue(multiPSongDetail),
getSongUrl: vi.fn().mockResolvedValue({ url: "http://audio.test" }),
};
const ctx = {
config: { commandPrefix: "!" },
lastSearchResults: [] as any[],
getProvider: () => mockBili,
getProviderFor: () => mockBili,
};
const res = await (BotInstance.prototype as any).resolvePlayQuery.call(ctx, {
name: "play",
args: "测试视频",
rawArgs: ["测试视频"],
flags: new Set(),
});
expect(res.song).toBeDefined();
expect(res.song.id).toBe("BV1multiP?p=1");
expect(res.song.name).toBe("测试视频 - P1 分P1");
expect(res.song.duration).toBe(100);
});
});
describe("cmdPlaylist with a playlist link (#160)", () => {
const cmdPlaylist = (BotInstance.prototype as any).cmdPlaylist as (
this: unknown, cmd: { name: string; args: string; rawArgs: string[]; flags: Set<string> },
) => Promise<string>;
function makeCtx() {
const song = { id: "s1", name: "Song", artist: "A", album: "B", duration: 1, coverUrl: "" };
const makeProvider = (platform: string) => ({
platform,
search: vi.fn().mockResolvedValue({ songs: [], playlists: [] }),
getPlaylistSongs: vi.fn().mockResolvedValue([song]),
});
const providers: Record<string, any> = {
netease: makeProvider("netease"),
qq: makeProvider("qq"),
youtube: makeProvider("youtube"),
};
const queued: any[] = [];
return {
providers,
queued,
getProvider: vi.fn(() => providers.netease),
getProviderFor: vi.fn((p: string) => providers[p]),
assertProviderEnabled: vi.fn(),
extractId: (BotInstance.prototype as any).extractId,
looksLikeCollectionId: (BotInstance.prototype as any).looksLikeCollectionId,
player: { stop: vi.fn() },
queue: { clear: vi.fn(), add: (s: any) => queued.push(s), play: () => queued[0] },
disableFmMode: vi.fn(),
withRequester: (s: any) => s,
resolveAndPlay: vi.fn(async () => true),
sweepLocalAudio: vi.fn(),
emit: vi.fn(),
};
}
const cmd = (args: string, flags: string[] = []) =>
({ name: "playlist", args, rawArgs: args.split(" "), flags: new Set(flags) });
it("routes a QQ playlist link to QQ even without -q (default is NetEase)", async () => {
const ctx = makeCtx();
const reply = await cmdPlaylist.call(ctx, cmd("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]"));
expect(ctx.providers.qq.getPlaylistSongs).toHaveBeenCalledWith("8052190267");
expect(ctx.providers.netease.getPlaylistSongs).not.toHaveBeenCalled();
expect(ctx.queued[0].platform).toBe("qq");
expect(reply).toMatch(/^Loaded 1 songs/);
});
it("loads a YouTube playlist link by its list id instead of name-searching the URL", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("https://www.youtube.com/playlist?list=PLabc123"));
expect(ctx.providers.youtube.getPlaylistSongs).toHaveBeenCalledWith("PLabc123");
expect(ctx.providers.netease.search).not.toHaveBeenCalled();
});
it("checks the link's platform is enabled", async () => {
const ctx = makeCtx();
ctx.assertProviderEnabled.mockImplementation(() => { throw new Error("音源未启用:qq"); });
await expect(cmdPlaylist.call(ctx, cmd("https://y.qq.com/n/ryqq/playlist/1"))).rejects.toThrow("音源未启用");
});
it("keeps the old behavior for a bare id", async () => {
const ctx = makeCtx();
await cmdPlaylist.call(ctx, cmd("2829883282"));
expect(ctx.providers.netease.getPlaylistSongs).toHaveBeenCalledWith("2829883282");
});
});
+184 -17
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";
@@ -12,7 +13,13 @@ import {
canRunCommand,
type ParsedCommand,
} from "./commands.js";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import {
parseSongRef,
parseSelectionIndex,
parsePlaylistRef,
findShareShortLink,
resolveShareLink,
} from "./song-ref.js";
import { splitTextIntoChunks } from "./text-chunk.js";
import type { Logger } from "../logger.js";
import { SHARED_QUEUE_OWNER, type BotDatabase, type ProfileConfig, type StoredSong } from "../data/database.js";
@@ -21,6 +28,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 +43,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 +62,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 +118,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 +159,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 +225,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
@@ -262,7 +301,13 @@ export class BotInstance extends EventEmitter {
const relPath = this.database.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
// loadCustomAvatar, NOT setCustomAvatar (#148): we are still in the
// constructor, so tsClient has not connected. setCustomAvatar would
// start a file transfer right here and fail. profileManager.onConnect()
// uploads it for real once the handshake completes.
// `length > 0` because avatarStore.write is delete-then-write, so a
// crash mid-write leaves a 0-byte file that is truthy as a Buffer.
if (buf && buf.length > 0) this.profileManager.loadCustomAvatar(buf);
}
} catch (err) {
this.logger.warn({ err }, "Failed to load custom avatar — skipping");
@@ -358,6 +403,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 +436,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 +458,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 +550,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 +565,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 +581,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 +602,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 +628,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 () => {
@@ -840,6 +974,14 @@ export class BotInstance extends EventEmitter {
this.voteSkipUsers.clear();
const provider = this.getProviderFor(song.platform);
try {
if (song.platform === "bilibili" && (!song.id.includes("?p=") || song.duration === 0)) {
const detail = await provider.getSongDetail(song.id);
if (detail) {
song.duration = detail.duration;
song.name = detail.name;
song.id = detail.id;
}
}
const result = await provider.getSongUrl(song.id);
if (!result?.url) {
this.logger.warn({ songId: song.id, name: song.name }, "No URL available, skipping");
@@ -992,7 +1134,7 @@ 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)
* 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 }> {
@@ -1006,10 +1148,15 @@ export class BotInstance extends EventEmitter {
return { error: `No recent search. Use ${p}search <name> first.` };
if (sel > this.lastSearchResults.length)
return { error: `Invalid selection #${sel}. ${p}search returned ${this.lastSearchResults.length} results.` };
return { song: this.lastSearchResults[sel - 1] };
let song = this.lastSearchResults[sel - 1];
if (song.platform === "bilibili") {
const detail = await this.getProviderFor("bilibili").getSongDetail(song.id);
if (detail) song = { ...detail, platform: "bilibili" };
}
return { song };
}
// 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);
@@ -1023,7 +1170,12 @@ export class BotInstance extends EventEmitter {
const provider = this.getProvider(cmd.flags);
const result = await provider.search(args, 1);
if (result.songs.length === 0) return { error: `No results found for: ${args}` };
return { song: { ...result.songs[0], platform: provider.platform } };
let song = result.songs[0];
if (provider.platform === "bilibili") {
const detail = await provider.getSongDetail(song.id);
if (detail) song = detail;
}
return { song: { ...song, platform: provider.platform } };
}
private async cmdSearch(cmd: ParsedCommand): Promise<string> {
@@ -1037,13 +1189,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 +1289,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 +1313,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!;
@@ -1367,8 +1519,21 @@ export class BotInstance extends EventEmitter {
}
private async cmdPlaylist(cmd: ParsedCommand, requesterName?: string): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
if (!cmd.args) return "Usage: !playlist <playlist name, ID or link>";
// A playlist link (#160) names its own platform, so it wins over flags.
// App share short links are followed one hop to the real URL first.
let ref = parsePlaylistRef(cmd.args);
if (!ref) {
const shortLink = findShareShortLink(cmd.args);
if (shortLink) {
const target = await resolveShareLink(shortLink);
ref = target ? parsePlaylistRef(target) : null;
if (!ref) return "Could not open that share link — paste the full playlist link or its ID instead";
}
}
if (ref) this.assertProviderEnabled(ref.platform);
const provider = ref ? this.getProviderFor(ref.platform) : this.getProvider(cmd.flags);
// Determine if input is a direct ID (numeric / Jellyfin GUID) or a name search
const id = this.extractId(cmd.args);
@@ -1376,7 +1541,9 @@ export class BotInstance extends EventEmitter {
let playlistId: string;
if (isDirectId || id !== cmd.args) {
if (ref) {
playlistId = ref.id;
} else if (isDirectId || id !== cmd.args) {
// Input is a direct ID or URL containing an ID — use existing logic
playlistId = id;
} else {
@@ -1718,8 +1885,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,
});
+57
View File
@@ -145,3 +145,60 @@ describe("BotProfileManager custom avatar precedence", () => {
expect(ts.clearCalls).toBe(0);
});
});
// #148: the persisted avatar is loaded in the BotInstance constructor, before
// tsClient.connect() has run. Loading it must not touch the wire at all.
describe("BotProfileManager loadCustomAvatar (pre-connect load, #148)", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("does not upload or clear anything when called before connect", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
expect(ts.fileTransferInitUpload).not.toHaveBeenCalled();
});
it("the loaded avatar is uploaded once onConnect fires", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([7, 7, 7]));
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([7, 7, 7]))).toBe(true);
});
it("survives a reconnect: onConnect re-applies the loaded avatar every time", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([8]));
pm.onConnect();
await flush();
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(2);
});
it("loading null leaves the wire untouched and onConnect stays quiet", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(null);
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar still uploads immediately after connect (post-connect edit unchanged)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.loadCustomAvatar(Buffer.from([1]));
pm.onConnect();
await flush();
ts.uploadCalls.length = 0;
pm.setCustomAvatar(Buffer.from([2, 2]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([2, 2]))).toBe(true);
});
});
+14
View File
@@ -65,6 +65,20 @@ export class BotProfileManager {
// --- Public API ---
/**
* Store a persisted custom avatar WITHOUT touching TeamSpeak (#148).
*
* Used during BotInstance construction, when the TS connection does not
* exist yet: setCustomAvatar would immediately fire the three-step file
* transfer (fileTransferInitUpload → uploadFileData → clientupdate) against
* a client that has not connected, so the upload always failed and the
* saved avatar never appeared. onConnect() re-applies this.customAvatar
* once the handshake completes, so loading it silently here loses nothing.
*/
loadCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
/**
* Set/clear the persistent idle avatar. Pass null to remove.
*
+116 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
import { parseSongRef, parseSelectionIndex, parsePlaylistRef, findShareShortLink, resolveShareLink } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
@@ -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".
@@ -67,3 +120,65 @@ describe("parseSelectionIndex (#90 pick from last search)", () => {
expect(parseSelectionIndex("")).toBeNull();
});
});
describe("parsePlaylistRef (#160 play a playlist from its link)", () => {
it("returns null for a playlist name or a bare id (caller keeps its old logic)", () => {
expect(parsePlaylistRef("华语经典")).toBeNull();
expect(parsePlaylistRef("2829883282")).toBeNull();
expect(parsePlaylistRef("")).toBeNull();
});
it("parses NetEase playlist URLs (web, hash route, mobile share)", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/#/playlist?id=2829883282")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://y.music.163.com/m/playlist?id=2829883282&userid=77&creatorId=77")).toEqual({ id: "2829883282", platform: "netease" });
expect(parsePlaylistRef("https://music.163.com/playlist/2829883282")).toEqual({ id: "2829883282", platform: "netease" });
});
it("does not mistake a NetEase userid= for the playlist id", () => {
expect(parsePlaylistRef("https://music.163.com/playlist?userid=77&id=123")).toEqual({ id: "123", platform: "netease" });
});
it("parses QQ Music playlist URLs", () => {
expect(parsePlaylistRef("https://y.qq.com/n/ryqq/playlist/8052190267")).toEqual({ id: "8052190267", platform: "qq" });
expect(parsePlaylistRef("https://i.y.qq.com/n2/m/share/details/taoge.html?platform=11&appshare=android_qq&hosteuin=abc&id=8052190267&appversion=13")).toEqual({ id: "8052190267", platform: "qq" });
});
it("parses YouTube playlist URLs by their list= id", () => {
expect(parsePlaylistRef("https://www.youtube.com/playlist?list=PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG")).toEqual({ id: "PLx0sYbCqOb8TBPRdmBHs5Iftvv9TPboYG", platform: "youtube" });
expect(parsePlaylistRef("https://youtu.be/abc?list=PLabc-_1")).toEqual({ id: "PLabc-_1", platform: "youtube" });
});
it("unwraps the [URL] BBCode the TeamSpeak client adds to pasted links", () => {
expect(parsePlaylistRef("[URL]https://y.qq.com/n/ryqq/playlist/8052190267[/URL]")).toEqual({ id: "8052190267", platform: "qq" });
});
it("finds the link inside an app's share text", () => {
expect(parsePlaylistRef("分享某人创建的歌单「深夜」: https://y.music.163.com/m/playlist?id=123&userid=77 (来自@网易云音乐)")).toEqual({ id: "123", platform: "netease" });
});
});
describe("findShareShortLink (#160)", () => {
it("finds NetEase and QQ app short links, even inside share text or BBCode", () => {
expect(findShareShortLink("歌单「深夜」: https://163cn.tv/Abc123 (来自@网易云音乐)")).toBe("https://163cn.tv/Abc123");
expect(findShareShortLink("[URL]https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12[/URL]")).toBe("https://c6.y.qq.com/base/fcgi-bin/u?__=AbCd12");
});
it("ignores every other host, so we never fetch arbitrary user-supplied URLs", () => {
expect(findShareShortLink("https://evil.example/163cn.tv/Abc")).toBeNull();
expect(findShareShortLink("http://127.0.0.1:8080/x")).toBeNull();
expect(findShareShortLink("华语经典")).toBeNull();
});
});
describe("resolveShareLink (#160)", () => {
it("returns the redirect target", async () => {
const get = async () => ({ status: 302, location: "https://music.163.com/playlist?id=123" });
expect(await resolveShareLink("https://163cn.tv/Abc", get)).toBe("https://music.163.com/playlist?id=123");
});
it("returns null when there is no redirect or the request fails", async () => {
expect(await resolveShareLink("https://163cn.tv/Abc", async () => ({ status: 200, location: undefined }))).toBeNull();
expect(await resolveShareLink("https://163cn.tv/Abc", async () => { throw new Error("boom"); })).toBeNull();
});
});
+114 -6
View File
@@ -1,3 +1,5 @@
import axios from "axios";
/**
* Parsing helpers for picking an EXACT song in a !play / !add / !playnext query,
* so same-name songs can be disambiguated instead of always getting the single
@@ -18,9 +20,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 +45,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).
@@ -71,3 +100,82 @@ export function parseSelectionIndex(raw: string): number | null {
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
export interface PlaylistRef {
id: string;
platform: "netease" | "qq" | "youtube";
}
/** Drop the [URL]…[/URL] BBCode the TeamSpeak client wraps around pasted links. */
function stripUrlBBCode(text: string): string {
return text.replace(/\[\/?url(?:=[^\]]*)?\]/gi, " ");
}
/**
* Detect a playlist URL (#160) — a web link, or the full link inside an app's
* share text. The platform comes from the URL, so a QQ link works without
* `-q`. Returns `null` for anything else (a playlist name or bare id), which
* the caller handles as before.
*/
export function parsePlaylistRef(raw: string): PlaylistRef | null {
const q = stripUrlBBCode(raw ?? "").trim();
if (!q) return null;
if (/music\.163\.com/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/playlist\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
if (/y\.qq\.com/i.test(q)) {
const m = /\/playlist\/(\d+)/.exec(q) ?? /[?&](?:id|disstid)=(\d+)/.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
if (/youtube\.com|youtu\.be/i.test(q)) {
const m = /[?&]list=([\w-]+)/.exec(q);
if (m) return { id: m[1], platform: "youtube" };
}
return null;
}
/**
* Find a NetEase (163cn.tv) or QQ Music (c6.y.qq.com/base/fcgi-bin/u) share
* short link — what the phone apps copy. Only these hosts are recognized so
* the bot never fetches an arbitrary user-supplied URL.
*/
export function findShareShortLink(raw: string): string | null {
const q = stripUrlBBCode(raw ?? "");
const m =
/https?:\/\/163cn\.(?:tv|link)\/[0-9A-Za-z]+/i.exec(q) ??
/https?:\/\/c\d*\.y\.qq\.com\/base\/fcgi-bin\/u\?__=[0-9A-Za-z]+/i.exec(q);
return m ? m[0] : null;
}
type RedirectGet = (url: string) => Promise<{ status: number; location: string | undefined }>;
const redirectGet: RedirectGet = async (url) => {
const res = await axios.get(url, {
maxRedirects: 0,
timeout: 5000,
validateStatus: () => true,
responseType: "stream",
});
res.data?.destroy?.();
const location = res.headers.location;
return { status: res.status, location: typeof location === "string" ? location : undefined };
};
/** Follow a share short link one hop. Returns the target URL, or null. */
export async function resolveShareLink(
url: string,
get: RedirectGet = redirectGet,
): Promise<string | null> {
try {
const { status, location } = await get(url);
if (status < 300 || status >= 400 || !location) return null;
return new URL(location, url).toString();
} catch {
return null;
}
}
+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,
};
}
+125
View File
@@ -37,3 +37,128 @@ describe("BiliBiliProvider.search pagination", () => {
expect(searchParams(get).page).toBe("1");
});
});
describe("BiliBiliProvider multi-P support", () => {
it("parseBilibiliId extracts bvid and page correctly", async () => {
const { parseBilibiliId } = await import("./bilibili.js");
expect(parseBilibiliId("BV1yxHQeYEuE")).toEqual({ bvid: "BV1yxHQeYEuE", page: 1 });
expect(parseBilibiliId("BV1yxHQeYEuE?p=3")).toEqual({ bvid: "BV1yxHQeYEuE", page: 3 });
expect(parseBilibiliId("BV1yxHQeYEuE:p2")).toEqual({ bvid: "BV1yxHQeYEuE", page: 2 });
expect(parseBilibiliId("https://www.bilibili.com/video/BV1yxHQeYEuE?p=5")).toEqual({
bvid: "BV1yxHQeYEuE",
page: 5,
});
expect(parseBilibiliId("some-other-id")).toEqual({ bvid: "some-other-id", page: 1 });
});
function mockViewProvider(viewData: any, playUrlData?: any) {
const p = new BiliBiliProvider();
const get = vi.fn().mockImplementation((url: string, opts?: any) => {
if (url === "/x/web-interface/view") {
return Promise.resolve({ data: { data: viewData } });
}
if (url === "/x/player/playurl") {
return Promise.resolve({ data: { data: playUrlData ?? {} } });
}
return Promise.resolve({ data: {} });
});
(p as any).buvidInitialized = true;
(p as any).api = { get };
return { p, get };
}
const multiPViewData = {
bvid: "BV1multiP",
title: "测试多P教程",
pic: "http://i0.hdslb.com/bfs/archive/test.jpg",
duration: 300, // 总时长 300 秒 (120 + 180)
owner: { name: "UP主测试" },
pages: [
{ cid: 10001, page: 1, part: "第一讲 入门", duration: 120 },
{ cid: 10002, page: 2, part: "第二讲 进阶", duration: 180 },
],
};
const singlePViewData = {
bvid: "BV1singleP",
title: "测试单P视频",
pic: "http://i0.hdslb.com/bfs/archive/single.jpg",
duration: 200,
owner: { name: "UP主测试" },
pages: [
{ cid: 20001, page: 1, part: "测试单P视频", duration: 200 },
],
};
it("getSongDetail for single-P video returns total duration and clean bvid", async () => {
const { p } = mockViewProvider(singlePViewData);
const song = await p.getSongDetail("BV1singleP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1singleP");
expect(song!.name).toBe("测试单P视频");
expect(song!.duration).toBe(200);
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video without ?p defaults to P1 with P1 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=1");
expect(song!.name).toBe("测试多P教程 - P1 第一讲 入门");
expect(song!.duration).toBe(120); // P1 独立时长,而非总时长 300!
expect(song!.platform).toBe("bilibili");
});
it("getSongDetail for multi-P video with ?p=2 returns P2 with P2 duration", async () => {
const { p } = mockViewProvider(multiPViewData);
const song = await p.getSongDetail("BV1multiP?p=2");
expect(song).not.toBeNull();
expect(song!.id).toBe("BV1multiP?p=2");
expect(song!.name).toBe("测试多P教程 - P2 第二讲 进阶");
expect(song!.duration).toBe(180); // P2 独立时长
expect(song!.platform).toBe("bilibili");
});
it("getVideoParts returns all parts with duration and cid", async () => {
const { p } = mockViewProvider(multiPViewData);
const partsResult = await p.getVideoParts("BV1multiP");
expect(partsResult).not.toBeNull();
expect(partsResult!.bvid).toBe("BV1multiP");
expect(partsResult!.title).toBe("测试多P教程");
expect(partsResult!.parts).toHaveLength(2);
expect(partsResult!.parts[0]).toEqual({
part: 1,
cid: 10001,
title: "第一讲 入门",
duration: 120,
});
expect(partsResult!.parts[1]).toEqual({
part: 2,
cid: 10002,
title: "第二讲 进阶",
duration: 180,
});
});
it("getSongUrl requests playurl with correct cid for specific part", async () => {
const playUrlResponse = {
dash: {
audio: [
{ bandwidth: 64000, baseUrl: "http://audio.64k.test" },
{ bandwidth: 320000, baseUrl: "http://audio.320k.test" },
],
},
};
const { p, get } = mockViewProvider(multiPViewData, playUrlResponse);
const result = await p.getSongUrl("BV1multiP?p=2");
expect(result).not.toBeNull();
expect(result!.url).toBe("http://audio.320k.test");
const playurlCall = get.mock.calls.find((c: any[]) => c[0] === "/x/player/playurl");
expect(playurlCall).toBeTruthy();
expect(playurlCall![1].params.cid).toBe(10002); // 准确传入 P2 的 cid
expect(playurlCall![1].params.bvid).toBe("BV1multiP"); // 纯净 bvid
});
});
+106 -11
View File
@@ -27,6 +27,38 @@ const WBI_MIXIN_KEY_ENC_TAB = [
const WBI_KEY_TTL_MS = 6 * 60 * 60 * 1000; // wbi keys rotate ~daily; refresh every 6h
export interface BiliVideoPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliVideoPartsResult {
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliVideoPart[];
}
/**
* 解析带有分P信息的 B站 ID 或 URL。
* 支持形如 "BVxxxx", "BVxxxx?p=2", "BVxxxx:p2" 以及完整 URL 等格式,默认 page 为 1。
*/
export function parseBilibiliId(songId: string): { bvid: string; page: number } {
const str = (songId ?? "").trim();
const bvMatch = str.match(/BV[0-9A-Za-z]+/i);
if (!bvMatch) {
return { bvid: str, page: 1 };
}
const bvid = bvMatch[0];
const pageMatch = str.match(/[?&]p=(\d+)|:p?(\d+)/i);
const pageStr = pageMatch ? (pageMatch[1] ?? pageMatch[2]) : undefined;
const page = pageStr ? parseInt(pageStr, 10) : 1;
return { bvid, page: Math.max(1, page) };
}
export class BiliBiliProvider implements MusicProvider {
readonly platform = "bilibili" as const;
private api: AxiosInstance;
@@ -192,18 +224,41 @@ export class BiliBiliProvider implements MusicProvider {
}
async getSongDetail(songId: string): Promise<Song | null> {
const { bvid, page } = parseBilibiliId(songId);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: songId },
params: { bvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
// Cache cid for later audio URL fetching
if (data.pages?.[0]?.cid) {
this.cidCache.set(songId, data.pages[0].cid);
const pages = data.pages ?? [];
// 缓存所有分P的 cid 映射
for (const p of pages) {
this.cidCache.set(`${bvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(bvid, pages[0].cid);
}
const targetPage = pages.find((p: any) => p.page === page) ?? pages[0];
// 若为多P视频,返回对应分P的名称与独立时长
if (pages.length > 1 && targetPage) {
const partTitle = targetPage.part && targetPage.part !== data.title
? `${data.title} - P${targetPage.page} ${targetPage.part}`
: `${data.title} (P${targetPage.page})`;
return {
id: `${bvid}?p=${targetPage.page}`,
name: partTitle,
artist: data.owner?.name ?? "",
album: "",
duration: targetPage.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
}
return {
@@ -211,7 +266,7 @@ export class BiliBiliProvider implements MusicProvider {
name: data.title ?? "",
artist: data.owner?.name ?? "",
album: "",
duration: data.duration ?? 0,
duration: targetPage?.duration ?? data.duration ?? 0,
coverUrl: this.normalizeCover(data.pic ?? ""),
platform: "bilibili" as const,
};
@@ -220,9 +275,47 @@ export class BiliBiliProvider implements MusicProvider {
}
}
/** 获取视频所有分P列表 */
async getVideoParts(bvid: string): Promise<BiliVideoPartsResult | null> {
const { bvid: cleanBvid } = parseBilibiliId(bvid);
try {
const res = await this.api.get("/x/web-interface/view", {
params: { bvid: cleanBvid },
headers: this.cookieHeaders,
});
const data = res.data?.data;
if (!data) return null;
const pages = data.pages ?? [];
for (const p of pages) {
this.cidCache.set(`${cleanBvid}?p=${p.page}`, p.cid);
}
if (pages[0]?.cid) {
this.cidCache.set(cleanBvid, pages[0].cid);
}
return {
bvid: cleanBvid,
title: data.title ?? "",
coverUrl: this.normalizeCover(data.pic ?? ""),
artist: data.owner?.name ?? "",
parts: pages.map((p: any) => ({
part: p.page,
cid: p.cid,
title: p.part ?? `P${p.page}`,
duration: p.duration ?? 0,
})),
};
} catch {
return null;
}
}
/** Get CID for a bvid, using cache when available */
private async getCid(bvid: string): Promise<number | null> {
const cached = this.cidCache.get(bvid);
private async getCid(bvid: string, page = 1): Promise<number | null> {
const key = page > 1 ? `${bvid}?p=${page}` : bvid;
const cached = this.cidCache.get(key) ?? (page === 1 ? this.cidCache.get(`${bvid}?p=1`) : undefined);
if (cached) return cached;
// Limit cache size to prevent unbounded growth
@@ -231,20 +324,22 @@ export class BiliBiliProvider implements MusicProvider {
if (firstKey) this.cidCache.delete(firstKey);
}
const detail = await this.getSongDetail(bvid);
const songId = page > 1 ? `${bvid}?p=${page}` : bvid;
const detail = await this.getSongDetail(songId);
if (!detail) return null;
return this.cidCache.get(bvid) ?? null;
return this.cidCache.get(key) ?? this.cidCache.get(`${bvid}?p=${page}`) ?? this.cidCache.get(bvid) ?? null;
}
async getSongUrl(songId: string, _quality?: string): Promise<SongUrlResult | null> {
const cid = await this.getCid(songId);
const { bvid, page } = parseBilibiliId(songId);
const cid = await this.getCid(bvid, page);
if (!cid) return null;
try {
const res = await this.api.get("/x/player/playurl", {
params: {
cid,
bvid: songId,
bvid,
fnval: 16, // DASH format
},
headers: this.cookieHeaders,
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// unlinkSync/rmdirSync are NOT mocked below, so the test's own fixture
// teardown is unaffected by the simulated lock on *.mp4.
import { mkdtempSync, statSync, existsSync, readFileSync, unlinkSync, readdirSync, rmdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
/**
* #149: when the audio track is extracted successfully but the source video
* cannot be deleted (Windows keeps files locked briefly — rmSync with
* force:true still throws EBUSY/EPERM), the record must fall back to the
* ORIGINAL container completely: both the path AND the recorded size.
*
* Committing the size before the delete succeeded would leave the record
* claiming the small extracted size while still holding the whole video, so
* totalBytes() under-counts and the upload directory grows past its quota.
*
* This lives in its own file because it partially mocks node:fs, which would
* otherwise leak into every other test in local.test.ts.
*/
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
default: actual,
rmSync: (path: string, opts?: object) => {
// Simulate the lock on the source video only; every other delete
// (the discarded .mka, temp dirs, the reject path) behaves normally.
if (typeof path === "string" && path.endsWith(".mp4")) {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
}
return actual.rmSync(path, opts as never);
},
};
});
const { LocalMusicProvider } = await import("./local.js");
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "local-extract-fallback-")); });
afterEach(() => {
// Recursive teardown without rmSync (mocked above for *.mp4).
for (const f of readdirSync(dir)) {
try { unlinkSync(join(dir, f)); } catch { /* best effort */ }
}
try { rmdirSync(dir); } catch { /* best effort */ }
});
describe("LocalMusicProvider: source video cannot be deleted after extraction (#149)", () => {
it.runIf(have)("keeps the original container AND its real size, not the extracted size", async () => {
const src = join(dir, "fixture.mp4");
const r = spawnSync(ffmpeg!, [
"-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=s=320x240:r=25:d=3",
"-f", "lavfi", "-i", "sine=f=440:d=3",
"-c:v", "libx264", "-b:v", "800k", "-c:a", "aac", "-shortest", src,
], { stdio: "ignore" });
expect(r.status).toBe(0);
const bytes = readFileSync(src);
unlinkSync(src); // uploadAudio writes its own copy under a uuid name
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: bytes, originalName: "fixture.mp4", mimeType: "video/mp4",
});
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// Fell back to the original container — the extract was discarded.
expect(resolved!.url.endsWith(".mp4")).toBe(true);
expect(existsSync(resolved!.url)).toBe(true);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".m4a"))).toBe(false);
expect(existsSync(resolved!.url.replace(/\.mp4$/, ".mka"))).toBe(false);
const onDisk = statSync(resolved!.url).size;
expect(onDisk).toBe(bytes.length);
// The RECORDED size drives the quota (totalBytes()), so it must describe
// the file actually retained. It is not exposed through search()/toSong,
// but it is persisted to index.json — read it back from there.
const record = (JSON.parse(readFileSync(join(dir, "index.json"), "utf8")) as Array<{
id: string; size: number; filePath: string;
}>).find((r) => r.id === song.id);
expect(record).toBeDefined();
expect(record!.filePath.endsWith(".mp4")).toBe(true);
// Before the fix this was the (much smaller) .mka size while the whole
// .mp4 stayed on disk, so the quota under-counted the retained bytes.
expect(record!.size).toBe(bytes.length);
// Sanity: the extract really is much smaller, so a wrong commit order
// would have been clearly observable rather than a rounding error.
expect(onDisk).toBeGreaterThan(50_000);
}, 60000);
});
+257 -2
View File
@@ -1,8 +1,11 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
import { mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync, statSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import { buildFfmpegArgs } from "../audio/player.js";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { LocalMusicProvider } from "./local.js";
import { LocalMusicProvider, parseMediaProbe } from "./local.js";
let dir: string;
@@ -149,6 +152,115 @@ describe("LocalMusicProvider upload validation", () => {
p.uploadAudio({ buffer: Buffer.alloc(0), originalName: "a.mp3" }),
).rejects.toThrow();
});
// #149: video containers are accepted; only their audio track is kept.
it("still rejects a non-media extension after video was allowed", async () => {
const p = new LocalMusicProvider(dir);
for (const name of ["evil.exe", "evil.html", "evil.mp4.txt", "notes.pdf"]) {
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: name, mimeType: "video/mp4" }),
).rejects.toThrow();
}
});
it("accepts every supported video extension at the extension gate", async () => {
const p = new LocalMusicProvider(dir);
// Junk content: ffmpeg cannot open it, so it is "unrecognised" rather than
// "no audio track" and must be accepted exactly like a truncated .mp3
// always has been. The extension allowlist is what is under test here.
// .m4v is excluded on purpose — see the next test.
for (const ext of [".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".mpg", ".mpeg", ".3gp", ".ts", ".m2ts", ".ogv"]) {
const song = await p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: `clip${ext}`,
mimeType: "video/mp4",
});
expect(song.platform).toBe("local");
expect(song.name).toBe("clip");
}
});
it("refuses a .m4v raw video elementary stream, which by definition has no audio", async () => {
// .m4v is not a container — ffmpeg's rawvideo demuxer opens arbitrary
// bytes as an MPEG-4 video elementary stream, so it IS recognised and
// genuinely carries no audio track. Refusing it is the correct outcome,
// and it is the one case that distinguishes `recognized` from `probed`.
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({
buffer: Buffer.from("not really a video"),
originalName: "clip.m4v",
mimeType: "video/x-m4v",
}),
).rejects.toThrow(/音轨/);
});
it("the error message names both audio and video formats", async () => {
const p = new LocalMusicProvider(dir);
await expect(
p.uploadAudio({ buffer: Buffer.from("x"), originalName: "a.exe" }),
).rejects.toThrow(/视频/);
});
});
describe("parseMediaProbe (#149)", () => {
const mp4Banner = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':
Duration: 00:03:27.15, start: 0.000000, bitrate: 1105 kb/s
Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1280x720, 30 fps
Stream #0:1[0x2](und): Audio: aac (LC), 48000 Hz, stereo, fltp, 192 kb/s`;
it("reads duration and detects the audio stream in a video container", () => {
const r = parseMediaProbe(mp4Banner);
expect(r.durationSeconds).toBe(3 * 60 + 27);
expect(r.hasAudio).toBe(true);
});
it("reports hasAudio false for a video with only a video stream", () => {
const silent = `Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'silent.mp4':
Duration: 00:00:02.00, start: 0.000000, bitrate: 29 kb/s
Stream #0:0[0x1](und): Video: h264 (High 4:4:4 Predictive), yuv444p, 160x120, 10 fps`;
const r = parseMediaProbe(silent);
expect(r.durationSeconds).toBe(2);
expect(r.hasAudio).toBe(false);
});
it("detects a plain audio file", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'a.mp3':
Duration: 00:00:30.02, start: 0.000000, bitrate: 128 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s`);
expect(r.durationSeconds).toBe(30);
expect(r.hasAudio).toBe(true);
});
it("does not mistake an attached cover image for an audio stream", () => {
const r = parseMediaProbe(`Input #0, mp3, from 'cover.mp3':
Duration: 00:00:10.00, start: 0.000000, bitrate: 130 kb/s
Stream #0:0: Audio: mp3, 44100 Hz, stereo, fltp, 128 kb/s
Stream #0:1: Video: mjpeg (Baseline), yuvj420p(pc), 100x100 [attached pic]`);
expect(r.hasAudio).toBe(true);
});
it("returns zeros on unparseable output rather than throwing", () => {
const r = parseMediaProbe("ffmpeg: command exploded");
expect(r.durationSeconds).toBe(0);
expect(r.hasAudio).toBe(false);
expect(r.recognized).toBe(false);
});
// The distinction that decides whether an upload is refused: ffmpeg opened
// the file and found no audio (refuse) vs ffmpeg could not open it at all
// (accept, as it always has for truncated audio).
it("marks a readable container recognized and unreadable bytes not", () => {
expect(parseMediaProbe(mp4Banner).recognized).toBe(true);
expect(parseMediaProbe(`[mov,mp4,m4a,3gp,3g2,mj2 @ 0x1] moov atom not found
[in#0 @ 0x2] Error opening input: Invalid data found when processing input
Error opening input file junk.mp4.`).recognized).toBe(false);
});
it("rounds fractional durations", () => {
expect(parseMediaProbe("Duration: 00:00:03.60,").durationSeconds).toBe(4);
expect(parseMediaProbe("Duration: 01:02:03.10,").durationSeconds).toBe(3723);
});
});
describe("LocalMusicProvider quota", () => {
@@ -233,3 +345,146 @@ describe("LocalMusicProvider filename handling", () => {
expect(await p.getSongUrl(song.id)).not.toBeNull();
});
});
// #149 end-to-end: build real containers with the bundled ffmpeg and push
// them through the actual upload path. Skipped automatically if the binary is
// unavailable, so the suite still runs on a machine without it.
describe("LocalMusicProvider video upload, end to end (#149)", () => {
const ffmpeg: string | null = (() => {
try {
return createRequire(import.meta.url)("ffmpeg-static") as string;
} catch {
return null;
}
})();
const have = !!ffmpeg && spawnSync(ffmpeg, ["-version"], { stdio: "ignore" }).status === 0;
/** Render a real container into the temp dir and return its bytes. */
function render(name: string, args: string[]): Buffer {
const out = join(dir, name);
const r = spawnSync(ffmpeg!, ["-y", "-hide_banner", "-loglevel", "error", ...args, out], {
stdio: "ignore",
});
if (r.status !== 0) throw new Error(`fixture render failed: ${name}`);
const buf = readFileSync(out);
rmSync(out, { force: true }); // upload writes its own copy
return buf;
}
const withAudio = (dur: number, vcodec: string, acodec: string) => [
"-f", "lavfi", "-i", `testsrc=s=160x120:r=10:d=${dur}`,
"-f", "lavfi", "-i", `sine=f=440:d=${dur}`,
"-c:v", vcodec, "-c:a", acodec, "-shortest",
];
it.runIf(have)("accepts an mp4, reads its duration, and keeps only the audio", async () => {
const p = new LocalMusicProvider(dir);
const mp4 = render("src.mp4", withAudio(3, "libx264", "aac"));
const song = await p.uploadAudio({
buffer: mp4, originalName: "My Clip.mp4", mimeType: "video/mp4",
});
expect(song.name).toBe("My Clip");
expect(song.platform).toBe("local");
expect(song.duration).toBe(3);
const resolved = await p.getSongUrl(song.id);
expect(resolved).not.toBeNull();
// The video container is gone; what remains is the extracted audio track.
// AAC (what libx264+aac mp4s carry) goes to .m4a so the encoder-priming
// edit list survives — see extractedAudioExt.
expect(resolved!.url.endsWith(".m4a")).toBe(true);
expect(existsSync(join(dir, `${song.id}.mp4`))).toBe(false);
expect(existsSync(resolved!.url)).toBe(true);
expect(statSync(resolved!.url).size).toBeGreaterThan(0);
expect(statSync(resolved!.url).size).toBeLessThan(mp4.length);
}, 60000);
it.runIf(have)("extracted audio is still decodable by the player's ffmpeg args", async () => {
const p = new LocalMusicProvider(dir);
const song = await p.uploadAudio({
buffer: render("src2.mp4", withAudio(2, "libx264", "aac")),
originalName: "clip.mp4",
mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const decoded = spawnSync(
ffmpeg!,
[...buildFfmpegArgs(url, 0).slice(0, -1), "-"],
{ maxBuffer: 64 * 1024 * 1024 },
);
expect(decoded.status).toBe(0);
// 2s of 48 kHz stereo s16le ≈ 384000 bytes; allow codec priming slack.
expect(decoded.stdout.length).toBeGreaterThan(300000);
}, 60000);
it.runIf(have)("aac extraction decodes bit-for-bit identically to the audio inside the video", async () => {
// The strongest statement of "lossless": decode the audio track straight
// out of the source mp4, decode the stored extract, compare the PCM.
// A Matroska remux would NOT pass this — it loses the MP4 edit list that
// discards AAC encoder priming, so it decodes ~23 ms longer.
const p = new LocalMusicProvider(dir);
const bytes = render("bitexact.mp4", withAudio(4, "libx264", "aac"));
const sourceCopy = join(dir, "source-kept.mp4");
writeFileSync(sourceCopy, bytes);
const song = await p.uploadAudio({
buffer: bytes, originalName: "bitexact.mp4", mimeType: "video/mp4",
});
const url = (await p.getSongUrl(song.id))!.url;
const toPcm = (input: string, pre: string[] = []) => spawnSync(
ffmpeg!,
["-hide_banner", "-loglevel", "error", "-i", input, ...pre,
"-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-"],
{ maxBuffer: 128 * 1024 * 1024 },
);
const fromVideo = toPcm(sourceCopy, ["-vn", "-map", "0:a:0"]);
const fromExtract = toPcm(url);
expect(fromVideo.status).toBe(0);
expect(fromExtract.status).toBe(0);
expect(fromExtract.stdout.length).toBe(fromVideo.stdout.length);
expect(fromExtract.stdout.equals(fromVideo.stdout)).toBe(true);
}, 90000);
it.runIf(have)("refuses a video that genuinely has no audio track", async () => {
const p = new LocalMusicProvider(dir);
const silent = render("silent.mp4", [
"-f", "lavfi", "-i", "testsrc=s=160x120:r=10:d=2", "-an",
]);
await expect(
p.uploadAudio({ buffer: silent, originalName: "silent.mp4", mimeType: "video/mp4" }),
).rejects.toThrow(/音轨/);
// The rejected upload must not leave its bytes behind.
expect(readdirSync(dir).filter((f) => f.endsWith(".mp4"))).toEqual([]);
}, 60000);
it.runIf(have)("extracts losslessly from avi/mkv/flv too, not just mp4", async () => {
const p = new LocalMusicProvider(dir);
const cases: Array<[string, string[]]> = [
["a.avi", withAudio(2, "mpeg4", "libmp3lame")],
["a.mkv", withAudio(2, "libx264", "libopus")],
["a.flv", withAudio(2, "flv", "libmp3lame")],
];
for (const [name, args] of cases) {
const song = await p.uploadAudio({
buffer: render(`src-${name}`, args), originalName: name, mimeType: "video/x-msvideo",
});
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mka")).toBe(true);
expect(statSync(url).size).toBeGreaterThan(0);
}
}, 120000);
it.runIf(have)("a plain audio upload is untouched — no extraction, original extension kept", async () => {
const p = new LocalMusicProvider(dir);
const mp3 = render("src.mp3", ["-f", "lavfi", "-i", "sine=f=440:d=2", "-c:a", "libmp3lame"]);
const song = await p.uploadAudio({ buffer: mp3, originalName: "tune.mp3", mimeType: "audio/mpeg" });
const url = (await p.getSongUrl(song.id))!.url;
expect(url.endsWith(".mp3")).toBe(true);
expect(statSync(url).size).toBe(mp3.length); // byte-identical, not remuxed
}, 60000);
});
+223 -24
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import crypto from "node:crypto";
@@ -34,6 +34,56 @@ const AUDIO_EXTENSIONS = new Set([
".ape",
]);
/** Video containers accepted for upload (#149). Only the audio track is ever
* used — the bot has no video output. Playback would work straight from the
* container (ffmpeg selects the audio stream), but we extract the audio on
* upload so a 200 MB clip does not sit on disk for a 3 MB song; see
* extractAudioTrack. `.webm` is deliberately absent: it is already in
* AUDIO_EXTENSIONS and both audio-only and video .webm are handled there. */
const VIDEO_EXTENSIONS = new Set([
".mp4",
".mov",
".avi",
".mkv",
".flv",
".wmv",
".m4v",
".mpg",
".mpeg",
".3gp",
".ts",
".m2ts",
".ogv",
]);
/** Fallback container for an extracted audio track. Matroska takes
* essentially any audio codec, so `-c:a copy` works without knowing what the
* source used — no re-encode, no codec/extension table. */
const EXTRACTED_AUDIO_EXT = ".mka";
/**
* Container to remux an extracted track into, chosen by its codec.
*
* AAC gets .m4a rather than the Matroska fallback. MP4 stores the AAC encoder
* priming (the ~1000 warm-up samples every AAC encoder emits) in an edit list,
* and that edit list does NOT survive into Matroska — so an aac→.mka remux
* decodes ~23 ms longer than the source, with the priming samples audible at
* the head instead of discarded. Measured: −66 dBFS, i.e. inaudible, but the
* track is then fractionally out of step with its own reported duration for
* no reason. Copying aac into .m4a keeps the edit list and decodes
* byte-for-byte identical to the audio inside the original video.
*
* AAC is worth special-casing because it is what mp4 / mov / m4v — the
* formats people actually upload — almost always carry.
*/
function extractedAudioExt(codec: string | null): string {
return codec === "aac" ? ".m4a" : EXTRACTED_AUDIO_EXT;
}
function isSupportedUploadExt(ext: string): boolean {
return AUDIO_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext);
}
const DEFAULT_MAX_FILES = 200;
const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024; // 5 GiB
@@ -71,37 +121,128 @@ function titleFromFileName(name: string): string {
return safeFileName(name).replace(/\.[^.]+$/, "") || "本地音频";
}
async function probeDurationSeconds(filePath: string): Promise<number> {
export interface MediaProbe {
/** Rounded seconds, 0 when the probe failed or the container has no duration. */
durationSeconds: number;
/** True when ffmpeg reported at least one audio stream. Only meaningful
* together with `recognized` — see the comment there. */
hasAudio: boolean;
/** Lowercased codec name of the first audio stream ("aac", "mp3", "opus",
* …), or null when there is none. Picks the remux container. */
audioCodec: string | null;
/**
* True when ffmpeg actually opened the container and printed its
* `Input #0, <format>, from '...'` header.
*
* This is what separates "ffmpeg looked inside and there is genuinely no
* audio track" from "ffmpeg could not make sense of these bytes at all".
* Both produce hasAudio === false, but only the first is a file we should
* refuse. Unreadable bytes have always been accepted here (a truncated mp3
* uploads fine and simply reports duration 0), and that stays true.
*/
recognized: boolean;
/** False when ffmpeg could not be run or timed out, so nothing else in this
* object is meaningful and the caller must not reject the file on it. */
probed: boolean;
}
/** Parse `Duration: HH:MM:SS.ss`, the `Input #0,` header and
* `Stream #0:N...: Audio:` out of the banner ffmpeg prints on stderr when
* asked to open a file with no output. */
export function parseMediaProbe(stderr: string): Omit<MediaProbe, "probed"> {
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
let durationSeconds = 0;
if (match) {
const total = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3]);
durationSeconds = Number.isFinite(total) ? Math.round(total) : 0;
}
// e.g. " Stream #0:1[0x2](und): Audio: aac (LC) ..." — the stream index and
// the bracketed id/language vary, so match on the "Audio:" tag itself. An
// embedded cover image is a separate "Video: mjpeg ... [attached pic]" line
// and never matches this.
const audioMatch = stderr.match(/Stream #\d+:\d+[^\n]*:\s*Audio:\s*([A-Za-z0-9_]+)/);
const hasAudio = audioMatch !== null;
const audioCodec = audioMatch ? audioMatch[1].toLowerCase() : null;
// "Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'clip.mp4':" — absent entirely
// when ffmpeg bails with "Error opening input: Invalid data found ...".
const recognized = /^Input #\d+,/m.test(stderr);
return { durationSeconds, hasAudio, audioCodec, recognized };
}
async function probeMedia(filePath: string): Promise<MediaProbe> {
return new Promise((resolve) => {
const ffmpeg = spawn(ffmpegPath || "ffmpeg", ["-hide_banner", "-i", filePath], {
stdio: ["ignore", "ignore", "pipe"],
});
let stderr = "";
let settled = false;
const done = (probe: MediaProbe) => {
if (settled) return;
settled = true;
resolve(probe);
};
// Video containers are much larger than the audio files this used to see,
// and the probe only reads headers — but a network/USB path can still be
// slow, so allow more than the old 5s before giving up.
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
resolve(0);
}, 5000);
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
}, 20000);
ffmpeg.stderr.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
ffmpeg.on("error", () => {
clearTimeout(timeout);
resolve(0);
done({ durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false });
});
ffmpeg.on("close", () => {
clearTimeout(timeout);
const match = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (!match) {
resolve(0);
return;
done({ ...parseMediaProbe(stderr), probed: true });
});
});
}
const hours = Number(match[1]);
const minutes = Number(match[2]);
const seconds = Number(match[3]);
const total = hours * 3600 + minutes * 60 + seconds;
resolve(Number.isFinite(total) ? Math.round(total) : 0);
});
/**
* Remux the first audio stream of `source` into `target` (#149).
*
* `-c:a copy` — the audio is moved bit-for-bit into a Matroska audio
* container, so this is fast, lossless, and codec-agnostic. Nothing is
* re-encoded, so a 200 MB .mp4 becomes a few MB .mka with the original audio
* intact. Video, subtitle and data streams are dropped.
*
* Returns true only if ffmpeg exited 0 AND produced a non-empty file, so a
* partial/zero-byte result can never be mistaken for a successful extraction.
* Callers fall back to keeping the original container, which plays fine.
*/
async function extractAudioTrack(source: string, target: string): Promise<boolean> {
const ok = await new Promise<boolean>((resolve) => {
const ffmpeg = spawn(
ffmpegPath || "ffmpeg",
["-hide_banner", "-loglevel", "error", "-y", "-i", source,
"-vn", "-sn", "-dn", "-map", "0:a:0", "-c:a", "copy", target],
{ stdio: ["ignore", "ignore", "ignore"] },
);
let settled = false;
const done = (v: boolean) => {
if (settled) return;
settled = true;
resolve(v);
};
// Remuxing is I/O bound, but a multi-GB input on a slow disk still takes
// a while. Cap it so a pathological file cannot wedge the upload request.
const timeout = setTimeout(() => {
ffmpeg.kill("SIGKILL");
done(false);
}, 120000);
ffmpeg.on("error", () => { clearTimeout(timeout); done(false); });
ffmpeg.on("close", (code) => { clearTimeout(timeout); done(code === 0); });
});
if (!ok) return false;
try {
return statSync(target).size > 0;
} catch {
return false;
}
}
export class LocalMusicProvider implements MusicProvider {
@@ -171,33 +312,91 @@ export class LocalMusicProvider implements MusicProvider {
const ext = path.extname(originalName).toLowerCase();
// Validate by the (sanitised) file extension only — never trust the
// client-supplied Content-Type. This also guarantees the STORED extension
// is one of the known audio types, so a spoofed header cannot persist an
// arbitrary-extension blob on disk.
if (!AUDIO_EXTENSIONS.has(ext)) {
throw new Error("只支持常见音频文件,如 mp3、flac、wav、m4a、ogg、opus、aac、webm 等");
// is one of the known audio/video types, so a spoofed header cannot
// persist an arbitrary-extension blob on disk.
if (!isSupportedUploadExt(ext)) {
throw new Error(
"只支持常见音频文件(mp3、flac、wav、m4a、ogg、opus、aac、webm 等)" +
"和视频文件(mp4、mov、avi、mkv、flv、wmv 等,仅取其中的音轨播放)",
);
}
if (!input.buffer || input.buffer.length === 0) {
throw new Error("上传文件为空");
}
const id = crypto.randomUUID();
const storedName = `${id}${ext}`;
const filePath = path.join(this.uploadDir, storedName);
const isVideo = VIDEO_EXTENSIONS.has(ext);
let filePath = path.join(this.uploadDir, `${id}${ext}`);
writeFileSync(filePath, input.buffer);
const duration = await probeDurationSeconds(filePath);
let probe: MediaProbe;
try {
probe = await probeMedia(filePath);
} catch {
probe = { durationSeconds: 0, hasAudio: false, audioCodec: null, recognized: false, probed: false };
}
// Reject a video with no audio track up front (#149). Left to playback it
// would produce a silent, zero-byte stream that just looks like a broken
// song. Require `recognized` as well as `probed`: bytes ffmpeg cannot open
// at all report hasAudio false for a different reason, and those have
// always been accepted (a truncated upload lands with duration 0) — this
// change must not start rejecting them.
if (isVideo && probe.probed && probe.recognized && !probe.hasAudio) {
rmSync(filePath, { force: true });
throw new Error("这个视频里没有音轨,无法播放");
}
let size = input.buffer.length;
if (isVideo) {
// Keep only the audio. The video bytes are dead weight against the
// upload-directory quota and would never be used.
// Preferred container first; if that remux fails (a codec the container
// will not take), retry into Matroska, which takes almost anything.
const preferredExt = extractedAudioExt(probe.audioCodec);
let extracted = path.join(this.uploadDir, `${id}${preferredExt}`);
let ok = await extractAudioTrack(filePath, extracted);
if (!ok && preferredExt !== EXTRACTED_AUDIO_EXT) {
rmSync(extracted, { force: true });
extracted = path.join(this.uploadDir, `${id}${EXTRACTED_AUDIO_EXT}`);
ok = await extractAudioTrack(filePath, extracted);
}
if (ok) {
try {
// Commit filePath and size TOGETHER, and only after the source is
// actually gone. rmSync(force) still throws EBUSY/EPERM on Windows,
// and assigning size first would leave the record claiming the
// small extracted size while still pointing at the whole video —
// which makes totalBytes() under-count and lets the upload
// directory grow past its quota.
const extractedSize = statSync(extracted).size;
rmSync(filePath, { force: true });
filePath = extracted;
size = extractedSize;
} catch {
// Could not stat/remove (Windows lock) — keep playing the original
// container and drop the half-finished extract.
rmSync(extracted, { force: true });
}
} else {
// Extraction failed (exotic codec Matroska won't take, timeout, …).
// The original container still plays: ffmpeg picks its audio stream.
rmSync(extracted, { force: true });
}
}
const song: LocalSongRecord = {
id,
name: titleFromFileName(originalName),
artist: "本地上传",
album: "本地音乐",
duration,
duration: probe.durationSeconds,
coverUrl: "",
platform: "local",
filePath,
originalName,
uploadedAt: new Date().toISOString(),
size: input.buffer.length,
size,
mimeType: input.mimeType || "application/octet-stream",
};
+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();
});
});
+94
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),
@@ -226,13 +257,28 @@ export class TS3Client extends EventEmitter {
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"
@@ -241,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);
});
@@ -430,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;
@@ -442,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,
+209 -1
View File
@@ -14,7 +14,7 @@ import { createSessionStore } from "../../data/sessions.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
import { createMusicRouter } from "./music.js";
import { createMusicRouter, createLocalUploadBody } from "./music.js";
const empty: SearchResult = { songs: [], albums: [], playlists: [] };
@@ -295,3 +295,211 @@ describe("music router POST /quality — persistence (#125)", () => {
expect(config.audioQuality.netease).toBe("lossless");
});
});
// #149: video containers must survive the transport layer. Before this the
// express.raw type filter only matched audio/*, video/webm and
// application/octet-stream, so a browser-sent video/mp4 body was never parsed
// and the handler answered 400 "raw audio body is required".
describe("music router POST /local/upload — content types and size cap (#149)", () => {
let app: express.Express;
let botDb: BotDatabase;
let cookie: string;
let uploadAudio: ReturnType<typeof vi.fn>;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const admin = await users.createUser("admin", "pw-admin", "admin");
cookie = `${SESSION_COOKIE_NAME}=${sessions.createSession(admin.id).token}`;
uploadAudio = vi.fn(async (input: { originalName: string }) => ({
id: "local-1", name: input.originalName, artist: "本地上传", album: "本地音乐",
duration: 1, coverUrl: "", platform: "local",
}));
const local = { platform: "local", search: vi.fn().mockResolvedValue(empty), uploadAudio } as unknown as MusicProvider;
app = express();
app.use(express.json());
app.use(cookieParser());
app.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app.use("/api/music", createMusicRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }), local, getDefaultConfig(),
));
});
afterEach(() => botDb.close());
const post = (contentType: string, body: Buffer, name = "clip.mp4") =>
request(app)
.post("/api/music/local/upload")
.set("Cookie", cookie)
.set("Content-Type", contentType)
.set("X-Filename", encodeURIComponent(name))
.send(body);
it("accepts the video MIME types browsers actually send", async () => {
// These are what Chrome/Firefox put on a File for .mp4/.mov/.avi/.mkv.
for (const ct of ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska", "video/webm"]) {
uploadAudio.mockClear();
const res = await post(ct, Buffer.from("fake video bytes"));
expect(res.status, `content-type ${ct}`).toBe(200);
expect(uploadAudio).toHaveBeenCalledOnce();
expect(res.body.song.platform).toBe("local");
}
});
it("still accepts audio and octet-stream bodies", async () => {
for (const ct of ["audio/mpeg", "audio/flac", "application/octet-stream"]) {
uploadAudio.mockClear();
const res = await post(ct, Buffer.from("fake audio"), "tune.mp3");
expect(res.status, `content-type ${ct}`).toBe(200);
expect(uploadAudio).toHaveBeenCalledOnce();
}
});
it("passes the decoded filename and the content type through to the provider", async () => {
await post("video/mp4", Buffer.from("bytes"), "我的 视频.mp4");
expect(uploadAudio).toHaveBeenCalledWith(
expect.objectContaining({ originalName: "我的 视频.mp4", mimeType: "video/mp4" }),
);
});
it("surfaces a provider rejection as a 400 with its message", async () => {
uploadAudio.mockRejectedValueOnce(new Error("这个视频里没有音轨,无法播放"));
const res = await post("video/mp4", Buffer.from("bytes"));
expect(res.status).toBe(400);
expect(res.body.error).toBe("这个视频里没有音轨,无法播放");
});
it("requires authentication", async () => {
const res = await request(app)
.post("/api/music/local/upload")
.set("Content-Type", "video/mp4")
.send(Buffer.from("bytes"));
expect(res.status).toBe(401);
});
it("rejects an oversize body as JSON, not an HTML stack trace", async () => {
// Same middleware the route mounts, built with a small limit so the test
// does not have to allocate half a gigabyte to reach the cap.
const tiny = express();
const reached = vi.fn();
tiny.post("/u", createLocalUploadBody("1kb"), (_req, res) => { reached(); res.json({ ok: true }); });
const res = await request(tiny)
.post("/u")
.set("Content-Type", "video/mp4")
.send(Buffer.alloc(4096, 1));
expect(res.status).toBe(413);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body.error).toContain("文件太大");
// The HTML default handler leaked absolute server paths and a stack.
expect(res.text).not.toMatch(/node_modules|<\/pre>|at read/);
expect(reached).not.toHaveBeenCalled();
});
it("lets a body under the cap through the same middleware", async () => {
const tiny = express();
tiny.post("/u", createLocalUploadBody("1kb"), (req, res) => {
res.json({ bytes: (req.body as Buffer).length });
});
const res = await request(tiny)
.post("/u")
.set("Content-Type", "video/mp4")
.send(Buffer.alloc(512, 1));
expect(res.status).toBe(200);
expect(res.body.bytes).toBe(512);
});
it("rejects local uploads when the feature is switched off", async () => {
const off = getDefaultConfig();
off.localAudioEnabled = false;
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const a2 = await users.createUser("admin2", "pw-admin2", "admin");
const c2 = `${SESSION_COOKIE_NAME}=${sessions.createSession(a2.id).token}`;
const app2 = express();
app2.use(cookieParser());
app2.use("/api", createRequireAuth(sessions, createPermissionStore(botDb.db), () => getDefaultConfig().guestMode));
app2.use("/api/music", createMusicRouter(
fakeProvider("netease"), fakeProvider("qq"), fakeProvider("bilibili"),
pino({ level: "silent" }),
{ platform: "local", search: vi.fn(), uploadAudio } as unknown as MusicProvider, off,
));
const res = await request(app2)
.post("/api/music/local/upload")
.set("Cookie", c2)
.set("Content-Type", "video/mp4")
.send(Buffer.from("bytes"));
expect(res.status).toBe(403);
expect(uploadAudio).not.toHaveBeenCalled();
});
});
describe("music router GET /bilibili/parts", () => {
it("returns 400 when bvid is missing", async () => {
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
fakeProvider("bilibili"),
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts");
expect(res.status).toBe(400);
expect(res.body.error).toBe("bvid is required");
});
it("returns parts from bilibili provider when available", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue({
bvid: "BV1test",
title: "多P视频测试",
parts: [
{ part: 1, cid: 101, title: "P1", duration: 100 },
{ part: 2, cid: 102, title: "P2", duration: 200 },
],
}),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1test");
expect(res.status).toBe(200);
expect(res.body.bvid).toBe("BV1test");
expect(res.body.parts).toHaveLength(2);
expect(mockBilibili.getVideoParts).toHaveBeenCalledWith("BV1test");
});
it("returns 404 when getVideoParts returns null", async () => {
const mockBilibili = {
platform: "bilibili" as const,
search: vi.fn(),
getVideoParts: vi.fn().mockResolvedValue(null),
};
const router = createMusicRouter(
fakeProvider("netease"),
fakeProvider("qq"),
mockBilibili as unknown as MusicProvider,
pino({ level: "silent" })
);
const app = express();
app.use("/api/music", router);
const res = await request(app).get("/api/music/bilibili/parts?bvid=BV1notfound");
expect(res.status).toBe(404);
});
});
+76 -4
View File
@@ -7,6 +7,56 @@ import { requirePermission } from "../middleware/requirePermission.js";
import { requireNotGuest } from "../middleware/requireNotGuest.js";
import { authorize } from "../middleware/authorize.js";
/**
* Body cap for a local upload. express.raw buffers the whole body in memory,
* so this is also the peak RAM one upload can cost — raised from 200mb for
* video (#149), which is far bigger than audio for the same song, but kept
* well short of "any video file at all" for that reason. Only the audio track
* survives to disk.
*/
export const LOCAL_UPLOAD_LIMIT = "500mb";
/**
* Body parser for the local-upload route.
*
* `type` includes "video/*" (#149): the browser sends the File's own MIME
* type, so an .mp4 arrives as video/mp4 and used to be rejected by this
* filter before ever reaching the provider. Only the audio track is kept —
* uploadAudio remuxes it out on the way in.
*
* express.raw hands an oversize body to the default error handler, which
* answers with an HTML page carrying a stack trace and absolute server paths
* (unless NODE_ENV=production, which this project never sets). Video makes
* hitting the cap far more likely than audio did, so that one case is
* translated into the same JSON shape the rest of this route returns. Any
* other body-parser error is passed on untouched.
*
* Exported as a factory so tests can drive the identical path with a small
* limit instead of allocating half a gigabyte.
*/
export function createLocalUploadBody(limit: string): express.RequestHandler {
const raw = express.raw({
type: ["audio/*", "video/*", "application/octet-stream"],
limit,
});
return (req, res, next) => {
raw(req, res, (err?: unknown) => {
if (!err) {
next();
return;
}
const e = err as { type?: string; status?: number };
if (e?.type === "entity.too.large" || e?.status === 413) {
res.status(413).json({ error: `文件太大,单个文件上限 ${limit}` });
return;
}
next(err);
});
};
}
const localUploadBody = createLocalUploadBody(LOCAL_UPLOAD_LIMIT);
export function createMusicRouter(
neteaseProvider: MusicProvider,
qqProvider: MusicProvider,
@@ -65,10 +115,7 @@ export function createMusicRouter(
}
next();
},
express.raw({
type: ["audio/*", "video/webm", "application/octet-stream"],
limit: "200mb",
}),
localUploadBody,
async (req, res) => {
try {
if (!localProvider) {
@@ -335,6 +382,31 @@ export function createMusicRouter(
}
});
// B站分P列表查询
router.get("/bilibili/parts", async (req, res) => {
try {
const bvid = (req.query.bvid as string)?.trim();
if (!bvid) {
res.status(400).json({ error: "bvid is required" });
return;
}
const provider = bilibiliProvider as any;
if (typeof provider.getVideoParts === "function") {
const result = await provider.getVideoParts(bvid);
if (!result) {
res.status(404).json({ error: "Video not found" });
return;
}
res.json(result);
} else {
res.status(501).json({ error: "Not supported" });
}
} catch (err) {
logger.error({ err }, "Get bilibili parts failed");
res.status(500).json({ error: (err as Error).message });
}
});
// Enabled sources + default platform, for the web UI (source tabs, default
// search/playback source). Without a config (unit-test routers) everything
// reports enabled with the legacy netease default.
+7 -1
View File
@@ -167,7 +167,13 @@ describe("session router", () => {
expect(meB.status).toBe(401);
expect(u.id).toBe(meA.body.id);
});
// 20s, not the 5s default: this case runs SIX bcryptjs rounds (one hash to
// create the user, four verifies, one hash for the new password), and
// bcryptjs is pure JS. It takes ~4.5s on an idle machine — close enough to
// the default that it tipped over whenever the full suite saturated the
// CPU, which made it look like a real intermittent failure. The work is
// genuinely slow, not hung, so the timeout is what was wrong.
}, 20000);
});
describe("session router — guest mode", () => {
+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.
node dist\index.js
:: 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
)
call "scripts\start.bat"
exit /b %errorlevel%
+92
View File
@@ -0,0 +1,92 @@
const { Client, generateIdentity } = require('@honeybbq/teamspeak-client');
function escapeTS3(value) {
return value
.replace(/\\/g, "\\\\")
.replace(/\//g, "\\/")
.replace(/ /g, "\\s")
.replace(/\|/g, "\\p")
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
function replaceField(cmd, key, value) {
const escaped = escapeTS3(value);
const regex = new RegExp(key + "=\\S*");
if (regex.test(cmd)) return cmd.replace(regex, key + "=" + escaped);
return cmd;
}
// Version to test - passed via env
const VERSIONS = [
{
name: "6.0.0-beta2",
version: "6.0.0-beta2 [Build: 1737468425]",
platform: "Windows",
sign: "b5mySiqYAd4Lv5TZNflW+G5Gk8y7Woo9YnJfHRjmzhUyMdGfF1O7fSGJzmx2Hpe/PKaY2aDuKKD6lSxbLYlKCw==",
},
{
name: "3.?.? wildcard",
version: "3.?.? [Build: 5680278000]",
platform: "Windows",
sign: "DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOoBzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==",
},
{
name: "5.0.0-beta77",
version: "5.0.0-beta77 [Build: 1702382332]",
platform: "Windows",
sign: "Ee6DzP16MUXpdKWjiSY0NGb4thN22/Ks0hwNcaMrWoaadgkM6c5477X0IbGFWVjzTWfjFTEad5noYLUPDWSgCQ==",
},
{
name: "3.6.2 (corrected sign)",
version: "3.6.2 [Build: 1695203293]",
platform: "Windows",
sign: "4BdaZpdgUSMCuIs8qcloJPNxNlJ4o7QKnxMCRO60mSOTtJZyKjOrGLAmeAEtLIJjcjmdSpycMbQOIV92K2vXAw==",
},
];
const idx = parseInt(process.env.VERSION_IDX || "0");
const V = VERSIONS[idx];
const identity = generateIdentity(8);
const client = new Client(identity, "localhost:9987", "MusicBot", {
logger: {
debug: () => {},
info: (m) => console.log("[INFO]", m),
warn: (m) => console.log("[WARN]", m),
error: (m) => console.log("[ERROR]", m),
},
});
console.log("Testing version: " + V.name + " -> " + V.version);
client.connect().then(() => {
const handler = client.handler;
const origSendPacket = handler.sendPacket.bind(handler);
handler.sendPacket = (pType, data, flags) => {
if (pType === 2) {
let str = Buffer.from(data).toString("utf-8");
if (str.startsWith("clientinit ")) {
str = replaceField(str, "client_version", V.version);
str = replaceField(str, "client_platform", V.platform);
str = replaceField(str, "client_version_sign", V.sign);
console.log("[PATCHED] version=" + V.version);
origSendPacket(pType, Buffer.from(str), flags);
return;
}
}
origSendPacket(pType, data, flags);
};
return client.waitConnected();
}).then(() => {
console.log("SUCCESS! Connected with clientId = " + client.clientID());
client.disconnect();
process.exit(0);
}).catch((err) => {
console.log("ERROR:", err && err.message || err);
process.exit(1);
});
setTimeout(() => {
console.log("TIMEOUT - version rejected");
process.exit(2);
}, 10000);
+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"
}
]
}
+231 -5
View File
@@ -7,11 +7,23 @@
<Player />
<Toast />
<Queue class="mobile-queue" :open="mobileQueueOpen" @close="mobileQueueOpen = false" />
<BilibiliPartModal />
<!-- 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 +91,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';
@@ -91,6 +103,7 @@ import Player from './components/Player.vue';
import CoverArt from './components/CoverArt.vue';
import Toast from './components/Toast.vue';
import Queue from './components/Queue.vue';
import BilibiliPartModal from './components/BilibiliPartModal.vue';
const playerStore = usePlayerStore();
const session = useSession();
@@ -132,15 +145,174 @@ let syncTimer: ReturnType<typeof setInterval> | null = null;
let mobileRaf: number | null = null;
function updateMobileProgress() {
// 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 +414,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;
+293
View File
@@ -0,0 +1,293 @@
<template>
<div v-if="modal.open" class="edit-modal-overlay" @click.self="store.closeBilibiliPartModal">
<div class="edit-modal">
<h3 class="modal-title">选择分P</h3>
<div class="form-group">
<label>视频名称</label>
<div class="video-info-box">
<CoverArt :url="modal.coverUrl" :size="44" :radius="8" />
<div class="video-meta">
<div class="video-title" :title="modal.title">{{ modal.title }}</div>
<div class="video-hint">共 {{ modal.parts.length }} 个分P · {{ actionHint }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>分P列表</label>
<div class="parts-list">
<div
v-for="part in modal.parts"
:key="part.part"
class="part-item"
:class="{ active: selectedPart?.part === part.part }"
@click="selectedPart = part"
@dblclick="confirmSelect(part)"
>
<span class="part-badge">P{{ part.part }}</span>
<span class="part-name" :title="part.title">{{ part.title }}</span>
<span class="part-duration">{{ formatDuration(part.duration) }}</span>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" @click="store.closeBilibiliPartModal">取消</button>
<button
class="btn-primary"
:disabled="!selectedPart"
@click="selectedPart && confirmSelect(selectedPart)"
>
{{ confirmBtnText }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { usePlayerStore, type BiliPart } from '../stores/player.js';
import CoverArt from './CoverArt.vue';
const store = usePlayerStore();
const modal = computed(() => store.biliPartModal);
const selectedPart = ref<BiliPart | null>(null);
// 弹窗打开时默认选中第 1 P
watch(
() => modal.value.open,
(open) => {
if (open && modal.value.parts.length > 0) {
selectedPart.value = modal.value.parts[0];
} else {
selectedPart.value = null;
}
},
{ immediate: true },
);
const actionHint = computed(() => {
if (modal.value.action === 'playNext') return '添加到下一首播放';
if (modal.value.action === 'add') return '添加到播放队列';
return '立即播放';
});
const confirmBtnText = computed(() => {
if (modal.value.action === 'playNext') return '下一首播放';
if (modal.value.action === 'add') return '添加到队列';
return '播放';
});
function confirmSelect(part: BiliPart) {
store.selectBilibiliPart(part);
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '--:--';
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<style lang="scss" scoped>
.edit-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.edit-modal {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 28px;
width: 480px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.modal-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
min-height: 0;
label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
opacity: 0.7;
color: var(--text-primary);
}
}
.video-info-box {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
.video-meta {
flex: 1;
min-width: 0;
}
.video-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.video-hint {
font-size: 12px;
color: var(--text-secondary);
}
}
.parts-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 280px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: var(--radius-sm);
}
}
.part-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--hover-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
border-color: var(--color-primary);
background: var(--color-primary-10, rgba(0, 161, 214, 0.08));
}
&.active {
border-color: var(--color-primary);
background: var(--color-primary-15, rgba(0, 161, 214, 0.15));
.part-badge {
background: var(--color-primary);
color: white;
}
.part-name {
color: var(--color-primary);
font-weight: 600;
}
}
.part-badge {
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: var(--radius-xs);
background: var(--color-primary-10, rgba(0, 161, 214, 0.1));
color: var(--color-primary);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.part-name {
flex: 1;
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.part-duration {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 10px;
}
.btn-primary {
padding: 10px 20px;
background: var(--color-primary);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
border: none;
cursor: pointer;
transition: transform var(--transition-fast);
&:hover:not(:disabled) {
transform: scale(1.02);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-secondary {
padding: 10px 20px;
background: var(--hover-bg);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
border: none;
cursor: pointer;
transition: opacity var(--transition-fast);
&:hover {
opacity: 0.8;
}
}
</style>
+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;
+107 -3
View File
@@ -17,6 +17,24 @@ export interface Song {
export type Source = 'jellyfin' | 'netease' | 'qq' | 'kugou' | 'spotify';
export interface BiliPart {
part: number;
cid: number;
title: string;
duration: number;
}
export interface BiliPartModalState {
open: boolean;
song: Song | null;
action: 'play' | 'playNext' | 'add';
bvid: string;
title: string;
coverUrl: string;
artist: string;
parts: BiliPart[];
}
export interface AlbumItem {
id: string;
name: string;
@@ -138,6 +156,18 @@ export const usePlayerStore = defineStore('player', {
// Transient notification for surfacing failures (e.g., "song not playable")
// to a global Toast. Bumped `id` triggers re-render of the same message.
notification: null as { id: number; message: string; type: 'error' | 'info' } | null,
// Bilibili 多P分P选择弹窗状态
biliPartModal: {
open: false,
song: null,
action: 'play',
bvid: '',
title: '',
coverUrl: '',
artist: '',
parts: [] as BiliPart[],
} as BiliPartModalState,
}),
getters: {
@@ -411,8 +441,74 @@ export const usePlayerStore = defineStore('player', {
this.notification = { id: Date.now(), message, type };
},
async playSong(song: Song) {
/**
* 检查 B站视频是否为多P,若为多P则弹窗询问,单P则直接修正时长并继续
*/
async checkBilibiliMultiPart(song: Song, action: 'play' | 'playNext' | 'add'): Promise<boolean> {
try {
const cleanBvid = song.id.split('?')[0].split(':')[0];
const res = await axios.get('/api/music/bilibili/parts', { params: { bvid: cleanBvid } });
const parts: BiliPart[] = res.data?.parts ?? [];
if (parts.length > 1) {
this.biliPartModal = {
open: true,
song,
action,
bvid: cleanBvid,
title: res.data.title || song.name,
coverUrl: res.data.coverUrl || song.coverUrl,
artist: res.data.artist || song.artist,
parts,
};
return true; // 弹窗接管
}
if (parts.length === 1) {
song.duration = parts[0].duration;
}
} catch {
// 网络请求异常则降级为正常播放
}
return false;
},
selectBilibiliPart(part: BiliPart) {
if (!this.biliPartModal.open || !this.biliPartModal.song) return;
const { song, action, bvid, title, artist, coverUrl } = this.biliPartModal;
this.biliPartModal.open = false;
const partTitle = part.title && part.title !== title
? `${title} - P${part.part} ${part.title}`
: `${title} (P${part.part})`;
const partSong: Song = {
...song,
id: `${bvid}?p=${part.part}`,
name: partTitle,
artist: artist || song.artist,
coverUrl: coverUrl || song.coverUrl,
duration: part.duration,
};
if (action === 'play') {
this.playSong(partSong, true);
} else if (action === 'playNext') {
this.playNextSong(partSong, true);
} else if (action === 'add') {
this.addSong(partSong, true);
}
},
closeBilibiliPartModal() {
this.biliPartModal.open = false;
this.biliPartModal.song = null;
},
async playSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'play');
if (handled) return;
}
// Guests use the non-destructive "play now" (insert-next + skip) so they
// can't wipe everyone else's queue; members/admins keep the normal behavior.
const endpoint = useSession().isGuest.value ? 'play-now-song' : 'play-song';
@@ -424,8 +520,12 @@ export const usePlayerStore = defineStore('player', {
this._syncAfterAction();
},
async playNextSong(song: Song) {
async playNextSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'playNext');
if (handled) return;
}
const res = await axios.post(`/api/player/${this.activeBotId}/play-next-song`, { song });
if (res.data?.message) {
this.notify(res.data.message, res.data.ok === false ? 'error' : 'info');
@@ -444,8 +544,12 @@ export const usePlayerStore = defineStore('player', {
await axios.post(`/api/player/${this.activeBotId}/add-by-id`, { songId, platform });
},
async addSong(song: Song) {
async addSong(song: Song, skipPartCheck = false) {
if (!this.activeBotId) return;
if (!skipPartCheck && song.platform === 'bilibili' && !song.id.includes('?p=')) {
const handled = await this.checkBilibiliMultiPart(song, 'add');
if (handled) return;
}
await axios.post(`/api/player/${this.activeBotId}/add-song`, { song });
},
+47 -10
View File
@@ -28,18 +28,18 @@
>
<Icon icon="mdi:tray-arrow-up" class="upload-icon" />
<div class="upload-copy">
<div class="upload-title">拖拽本地音频到这里上传</div>
<div class="upload-subtitle">支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等格式,上传后可直接播放或加入队列</div>
<div class="upload-title">拖拽本地音频 / 视频到这里上传</div>
<div class="upload-subtitle">音频支持 mp3、flac、wav、m4a、ogg、opus、aac、webm 等,视频支持 mp4、mov、avi、mkv、flv、wmv 等(只取其中的音轨播放);上传后可直接播放或加入队列</div>
</div>
<button class="upload-btn" :disabled="uploading" @click="fileInput?.click()">
{{ uploading ? '上传中...' : '选择音频' }}
{{ uploading ? '上传中...' : '选择文件' }}
</button>
<input
ref="fileInput"
class="file-input"
type="file"
multiple
accept="audio/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape"
accept="audio/*,video/*,.mp3,.flac,.wav,.m4a,.aac,.ogg,.opus,.webm,.wma,.alac,.aiff,.ape,.mp4,.mov,.avi,.mkv,.flv,.wmv,.m4v,.mpg,.mpeg,.3gp,.ts,.m2ts,.ogv"
@change="handleFileSelect"
/>
</div>
@@ -404,8 +404,17 @@ async function doSearch() {
}
function isAudioFile(file: File): boolean {
return file.type.startsWith('audio/') || /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape)$/i.test(file.name);
/** Must match LOCAL_UPLOAD_LIMIT in src/web/api/music.ts. */
const UPLOAD_MAX_MB = 500;
const UPLOAD_MAX_BYTES = UPLOAD_MAX_MB * 1024 * 1024;
// Video is accepted too (#149) — the server keeps only the audio track.
// Keep the extension list in sync with AUDIO_EXTENSIONS / VIDEO_EXTENSIONS in
// src/music/local.ts; the server re-validates, this just avoids a round-trip.
function isMediaFile(file: File): boolean {
return file.type.startsWith('audio/')
|| file.type.startsWith('video/')
|| /\.(mp3|flac|wav|m4a|aac|ogg|opus|webm|wma|alac|aiff|ape|mp4|mov|avi|mkv|flv|wmv|m4v|mpg|mpeg|3gp|ts|m2ts|ogv)$/i.test(file.name);
}
async function uploadLocalFiles(fileList: File[]) {
@@ -414,10 +423,22 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = '本地音频播放已关闭';
return;
}
const files = fileList.filter(isAudioFile);
const candidates = fileList.filter(isMediaFile);
if (candidates.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频 / 视频文件';
return;
}
// Reject oversize files before spending minutes uploading them (#149).
// The server enforces the same cap (LOCAL_UPLOAD_LIMIT in
// src/web/api/music.ts) and answers 413 — this only saves the round-trip,
// which matters now that a single video can be hundreds of megabytes.
const files = candidates.filter((f) => f.size <= UPLOAD_MAX_BYTES);
const oversize = candidates.filter((f) => f.size > UPLOAD_MAX_BYTES);
if (files.length === 0) {
uploadMessageType.value = 'error';
uploadMessage.value = '没有找到可上传的音频文件';
uploadMessage.value = `文件太大,单个文件上限 ${UPLOAD_MAX_MB} MB:${oversize[0].name}`;
return;
}
@@ -426,8 +447,19 @@ async function uploadLocalFiles(fileList: File[]) {
uploadMessage.value = `正在上传 ${files.length} 个文件...`;
const uploaded: Song[] = [];
const failed: string[] = [];
for (const file of files) {
const failed: string[] = oversize.map((f) => `${f.name}: 超过 ${UPLOAD_MAX_MB} MB 上限`);
for (const [i, file] of files.entries()) {
// Videos are orders of magnitude bigger than the audio files this used to
// handle (#149), so a silent "正在上传..." can sit there for minutes and
// look hung. Report per-file percentage while the bytes are in flight, and
// switch to a processing note once the server takes over (it still has to
// probe the file and remux the audio track out).
const label = files.length > 1 ? `(${i + 1}/${files.length})` : '';
const setProgress = (text: string) => {
uploadMessageType.value = 'info';
uploadMessage.value = `${text}${label}:${file.name}`;
};
setProgress('正在上传');
try {
const res = await axios.post('/api/music/local/upload', file, {
headers: {
@@ -435,6 +467,11 @@ async function uploadLocalFiles(fileList: File[]) {
'X-Filename': encodeURIComponent(file.name),
},
maxBodyLength: Infinity,
onUploadProgress: (e) => {
if (!e.total) return;
const pct = Math.round((e.loaded / e.total) * 100);
setProgress(pct >= 100 ? '服务端处理中' : `正在上传 ${pct}%`);
},
});
if (res.data?.song) uploaded.push(res.data.song as Song);
} catch (err: any) {
+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 ---