Compare commits

...
Author SHA1 Message Date
TIANYAO ZHANGandClaude Opus 5.5 07ad861ecf fix(bilibili): keep long videos playing when the CDN drops the stream (#161)
Long B站 videos (2-3 h) still stopped ~15-20 min in, the same symptom as
#89. Reconnecting to the same URL is not enough once the CDN session is
gone, so:

- Prefer an upos/cos mirror from baseUrl + backupUrl over PCDN hosts
  (*.mcdn.bilivideo.cn, *.szbdyd.com), which are the ones that cut off.
- When a B站 track ends more than 30 s before its known duration, fetch
  a fresh URL and resume at the current position instead of advancing.
  Up to 3 attempts without real progress, then advance as before; a
  track the user started meanwhile is never clobbered.
- Seek B站 URLs input-side (-ss before -i). Their CDN serves Range, so a
  resume jumps to the byte offset instead of re-downloading everything
  before it (measured locally: 0.2 s vs a full-file download at 1.5 h
  into a 2 h fMP4), which would otherwise trip the 60 s stall watchdog.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:13:08 +08:00
TIANYAO ZHANG 0ad00cd7f7 Merge pull request #133 from ZHANGTIANYAO1/feat/issue-119-saved-playlists
feat: save/load playlists, restart queue persistence, and playKeepsQueue (#119)
2026-07-17 11:09:23 +08:00
saopig1 c8dacebc45 Merge remote-tracking branch 'origin/main' into feat/issue-119-saved-playlists
# Conflicts:
#	src/data/config.ts
2026-07-17 11:09:04 +08:00
TIANYAO ZHANG 880a9c084e Merge pull request #131 from ZHANGTIANYAO1/feat/issue-126-default-source
feat: add configurable default music source (#126)
2026-07-17 11:05:16 +08:00
saopig1 75497cf0f7 Merge remote-tracking branch 'origin/main' into feat/issue-126-default-source
# Conflicts:
#	src/data/config.ts
2026-07-17 11:05:05 +08:00
TIANYAO ZHANG 72682f4308 Merge pull request #130 from ZHANGTIANYAO1/feat/issue-125-persist-settings
feat: persist volume, play mode and audio quality across restarts
2026-07-17 11:03:22 +08:00
TIANYAO ZHANG f1585b010d Merge pull request #134 from ZHANGTIANYAO1/fix/issue-128-noindex-webui
feat(web): keep deployed WebUI out of search-engine indexes
2026-07-17 11:02:49 +08:00
TIANYAO ZHANG 1f88220d01 Merge pull request #129 from ZHANGTIANYAO1/fix/issue-122-qq-api-port
fix(qq): pin QQ Music API sidecar to configured qqMusicApiPort
2026-07-17 11:02:46 +08:00
TIANYAO ZHANG 75e3b1c722 Merge pull request #132 from ZHANGTIANYAO1/chore/issue-127-gitignore-claude
chore: add .claude/ to gitignore and untrack committed settings
2026-07-17 11:02:43 +08:00
saopig1andClaude Fable 5 0c7eb677b8 docs(readme): document save/load queues + restart resume + playKeepsQueue
- Commands table: !save / !load [-a] / !queues (with the feature-disabled note).
- Features list + WebUI pages + 行为设置 mention the two toggles and 已存队列 page.
- Changelog entry with the honest caveats: restart resumes the current track
  from its start (no seek memory); Spotify auto-resume is best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:29:06 +08:00
saopig1andClaude Fable 5 0b10f9553c feat(#119): saved queues web UI + behavior-settings toggles
- Settings → 行为设置: two new toggles (保存/加载播放清单, 单曲直接播放不清空队列)
  that round-trip savedQueuesEnabled / playKeepsQueue and keep the nav gate in sync.
- New "已存队列" page (/saved-queues): save the current queue (with a 共享 option),
  load (replace) / append / delete saved queues; renders a "feature disabled"
  state on 403 so it degrades gracefully when the flag is off.
- Nav entry gated on the savedQueuesEnabled store flag (hidden for guests).
- useSavedQueues API composable + a pure, unit-tested list/ownership helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:29:00 +08:00
saopig1andClaude Fable 5 69a2e8c264 feat(#119): save/load queues + live-queue persistence + playKeepsQueue (backend)
Add three default-off capabilities that stop the play queue from being lost,
all gated behind admin/independent toggles so existing behavior is unchanged
until an operator opts in:

- Named save/load of queues (Feature 1): new saved_queues table (per-user +
  reserved __shared__ owner, capped at 50 queues / 1000 songs, JSON song blob
  that degrades to empty on corruption); /api/saved-queues router (list/save/
  load/delete with ownership 404s, inert 403 when disabled); chat commands
  !save / !load [-a] / !queues; BotInstance.loadSavedQueue (replace/append).
- Auto-restore live queue across restart (Feature 2): PlayQueue.snapshot/restore,
  queue_state table (one row per bot), a debounced snapshot writer driven off
  stateChange, and restore+resume on connect. Cancels the pending snapshot on
  disconnect so a stale write can't wipe the row a restart must restore.
- playKeepsQueue (Feature 3): BotInstance.playSingleSong funnels chat !play and
  the web /play-song route through one place; when enabled a single-song play
  inserts-after-current and jumps instead of clearing the queue.

Config gains savedQueuesEnabled + playKeepsQueue (both default false, strict-
coerced on load like spotify.enabled); the settings API round-trips them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 01:28:48 +08:00
saopig1andClaude Opus 4.8 b8ec50c7f7 docs(plan): implementation plan for save/load playlists + queue persistence (#119)
13-task TDD plan across 5 stages: config gates, playKeepsQueue seam,
named save/load (DB + API + chat + web), live-queue snapshot/restore,
and docs. Each task independently testable; all behaviors default off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:58:19 +08:00
saopig1andClaude Opus 4.8 f74b55ddbc docs(spec): design for save/load playlists + queue persistence (#119)
Design doc for issue #119: named per-user/shared save/load (chat + web),
auto-restore-and-resume of the live queue across restart (both behind an
admin-controlled savedQueuesEnabled flag, default off), and an independent
playKeepsQueue toggle so single-song !play inserts-and-plays instead of
clearing the queue. All toggles default off — no behavior change until opted in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:58:19 +08:00
saopig1andClaude Fable 5 846ee30bce fix(qq): pin the QQ Music API sidecar to qqMusicApiPort
The embedded QQ Music API sidecar could bind a different port than the one
the client base URL (getQQMusicBaseUrl) targets. The upstream
@sansenjian/qq-music-api package derives its default port from
process.env.PORT (falling back to 3200) and, in some historical versions,
auto-started that server as an import side effect. When an old build listened
on 3300 while the client requested 3200 (issue #122), fetching the QQ login QR
failed with ECONNREFUSED on 127.0.0.1:3200, so the QR never showed and login /
cookie persistence silently broke.

Align process.env.PORT with the configured qqMusicApiPort for the duration of
the import (restoring the previous value afterwards so nothing else in the
process is affected), reuse an already-listening instance instead of racing a
second listen, and log the port actually bound (read from the socket) so any
mismatch is visible in the logs.

- src/music/api-server.ts: PORT alignment + reuse-on-auto-start + bound-port log
- src/music/api-server.test.ts: regression coverage that the sidecar follows
  qqMusicPort (not an injected PORT) and restores PORT afterwards
- README.md: QQ login FAQ clarifies the sidecar and client share qqMusicApiPort
  and points stale-latest-image users (who saw 3300) at re-pulling the image

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:24:37 +08:00
saopig1andClaude Fable 5 fbd94c424b feat: persist volume, play mode and audio quality across restarts
Runtime playback settings were kept only in memory (AudioPlayer.volume,
PlayQueue.mode, each provider's quality field), so restarting the bot reset
them to defaults and users had to re-tune volume and quality every time (#125).

Persist and restore them via the repo's existing storage:
- Volume and play mode are per-bot, stored on new bot_instances columns
  (volume, play_mode) with a schema migration; restored when the instance is
  (re)built, written by cmdVol / cmdMode which every entry point (chat command,
  WebUI, REST) funnels through. Volume and mode are written independently so a
  transient !fm/!artist mode switch never overwrites the user's saved !mode.
- Per-provider audio quality is global (shared providers), stored in a new
  config.json `audioQuality` block; applied to the providers at startup and
  re-snapshotted on POST /api/music/quality.

Queue, current song, progress and FM/artist sessions stay ephemeral.

Adds tests for config sanitize/round-trip, DB player-settings + migration,
cmdVol/cmdMode persistence + construction-time restore, and quality persistence
through the REST endpoint. Documents the behavior in the README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:09:47 +08:00
saopig1andClaude Fable 5 987513a5f6 feat: add configurable default music source (#126)
Make the default playback source a user-configurable setting so servers
that mostly play e.g. Bilibili no longer need to type `-b` on every
`!play`. Previously defaultPlatform() always picked the first enabled
provider by a fixed priority order, with no way to override it.

- config: add optional `defaultPlatform: GateableProvider | null`.
  loadConfig sanitizes it — kept only when it names a known provider that
  is also currently enabled, else null. defaultPlatform() returns the
  preference when enabled, otherwise falls back to the fixed priority order.
- POST /api/bot/settings accepts `defaultPlatform` (validated against the
  possibly-updated enabledProviders; null/"" clears it), and reconciles a
  stored default that a new enabledProviders list no longer allows. GET and
  POST responses expose the field.
- WebUI: new "默认音源" section with a source picker; saving refreshes the
  store's default source so it takes effect immediately without a restart.
- Tests: extend config defaultPlatform priority tests and add coverage for
  the settings endpoint and /providers routing.
- README: document `defaultPlatform` in the enabledProviders section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:43:39 +08:00
saopig1andClaude Fable 5 ea0f7c17b5 feat(web): keep deployed WebUI out of search-engine indexes
Searching "TsmusicBot" surfaced many deployed instances' WebUI URLs,
letting strangers walk into other people's control pages (issue #128).
Add defence-in-depth so crawlers stop indexing public deployments:

- send `X-Robots-Tag: noindex, nofollow` on every Express response
- serve `/robots.txt` with `User-agent: * / Disallow: /`
- add `<meta name="robots" content="noindex, nofollow">` to index.html,
  which also covers the /bot/<id> dedicated-link pages (same SPA shell)

These layers only prevent indexing; real protection stays with WebUI
auth and the reverse proxy. Document this in the README security section
and warn users not to post their WebUI link on public pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:31:31 +08:00
saopig1andClaude Fable 5 4493269479 chore: add .claude/ to gitignore and untrack it
The .claude/ directory holds local Claude Code settings that should
not be version-controlled. Add it to .gitignore and remove the
already-committed settings from the index (files kept on disk).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:27:01 +08:00
saopig1andClaude Fable 5 051171b019 chore(claude): allow pushes to main/master, keep force-push deny
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:57:33 +08:00
saopig1andClaude Fable 5 750ad9b1cc feat: make Jellyfin an optional source instead of the default
Revert the jellyfin-only default introduced by PR #123 so upgrading
users keep their online sources; Jellyfin becomes opt-in:

- default enabledProviders is now the online set (netease/qq/bilibili/
  youtube/kugou); defaultPlatform() uses a fixed priority order
  (netease -> qq -> kugou -> jellyfin -> bilibili -> youtube) instead
  of jellyfin-first, so chat/REST/WebUI default to netease again
- Settings: Jellyfin card is always visible with a new enable toggle
  (its enabled bit is enabledProviders membership); guards against
  clobbering other providers before the list loads
- Setup wizard: saving the Jellyfin step auto-enables the source when
  a server URL was entered
- Search/player store fallbacks flip from jellyfin to netease; !help
  no longer hardcodes Jellyfin lines
- tests: update default-platform assertions, add coverage for the new
  default set, legacy configs without enabledProviders, priority
  order, and explicit jellyfin-only configs
- README: reframe Jellyfin as optional (badges, command table, quality
  tiers, dedicated section, changelog), document the enabledProviders
  default and the v1.10.0 jellyfin-only window fix, credit @ItsEricRao

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:54:34 +08:00
TIANYAO ZHANG 3841fa80d3 Update README.md 2026-07-14 00:20:48 +08:00
TIANYAO ZHANG 04ceac5a97 Merge pull request #123 from ItsEricRao/main
Jellyfin Integration. Assisted by Claude Fable 5.
2026-07-14 00:18:29 +08:00
TIANYAO ZHANG ada292574f Merge pull request #124 from Slldyd2077/fix/qq-song-detail-empty-name
fix(qq): 按 ID 播放时回填歌曲元数据,修复 TS 显示空歌名
2026-07-14 00:17:08 +08:00
Claude CodeandClaude Opus 4.8 15190413b2 fix(qq): 按 ID 播放时回填歌曲元数据,修复 TS 显示空歌名
用 id: / play-by-id 播放 QQ 音乐时,getSongDetail 依赖的 /getSongInfo
端点已被上游废弃(返回 code 500001),会 fall through 到一个 name 为空
的兜底 stub。空歌名一路传到 currentSong,导致 TS 昵称 / 正在播放消息
显示成「♪ 正在播放:  -  []」。

修复:在返回空 stub 前新增 fetchSongDetailViaMusicu(),改用与搜索同源
且可用的 u.y.qq.com/cgi-bin/musicu.fcg(music.pf_song_detail_svr /
get_song_detail_yqq)按 mid 拉取真实 track_info(歌名/歌手/专辑/时长/
封面),无需登录;仅当它也失败时才退回空 stub。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:35:54 +08:00
itsericrao f637ba0191 Jellyfin Integration. Assisted by Claude Fable 5. 2026-07-06 23:07:20 +08:00
b9767a7471 Merge PR #121: show requester names in play history
Merges feature/play-history-requester (@Fa1nttt) into main.

The PR records the WebUI/TeamSpeak requester on queued songs and persists it
to play history (schema migration for requestedBy), rendering it as a badge in
SongCard (gray for 游客/guest).

Conflicts (frontend platform union) resolved to keep both 'spotify' (from #118)
and the new requestedBy/playedAt fields.

Integration fix: the Spotify playback branch in resolveAndPlay (added by #118,
which did not exist on the PR's base) also records play history — added
`requestedBy: song.requestedBy` there so Spotify tracks carry attribution too,
matching the non-Spotify path.

Verified on the merged tree: tsc --noEmit clean, full suite 1309/1309, web build clean.

Co-Authored-By: Fa1nttt <noreply@github.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:17:16 +08:00
saopig1andClaude Opus 4.8 47b29358ae docs(readme): add Spotify badge to the badge bar, marked 可选 like YouTube
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:08:16 +08:00
Fa1nttt cb66d77e9c feat: show requester names in play history 2026-07-04 22:32:21 +08:00
saopig1andClaude Opus 4.8 9f5e27a277 docs(readme): document Spotify source, search pagination, and full !lyrics
Reflect the two merged PRs in the README:
- Multi-source bullet + changelog: Spotify (#112, experimental/opt-in) and
  per-source search pagination "加载更多" (#115).
- !lyrics command now shows full lyrics chunked into multiple messages (#116).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 15:18:34 +08:00
saopig1 486c3a0a69 Merge PR #120: full !lyrics output (#116) + web search pagination (#115)
# Conflicts:
#	src/bot/instance.test.ts
2026-07-04 15:12:26 +08:00
saopig1 8359c56dc7 Merge PR #118: optional Spotify audio source (hybrid go-librespot/Rust librespot) (#112) 2026-07-04 15:08:50 +08:00
saopig1andClaude Opus 4.8 a97e72ef30 feat(search): per-source load-more pagination in Search.vue (#115 frontend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:42:07 +08:00
saopig1andClaude Opus 4.8 a1cc0b8574 feat(search): server-side offset pagination through providers + /search route (#115 backend)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:36:54 +08:00
saopig1andClaude Opus 4.8 81b8953d52 fix(lyrics): send full lyrics chunked under TeamSpeak message cap (#116)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:26:51 +08:00
saopig1andClaude Opus 4.8 1a3ccd0e38 fix(spotify): device-scope Rust Connect control + ignore foreign-device poll state (multi-bot) [corner-case R4-4]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:50:28 +08:00
saopig1andClaude Opus 4.8 ec0a027a1e fix(spotify): round seek position to integer ms + one-poll seek grace before near-end skip [corner-case R4-1,R4-6]
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 13:40:21 +08:00
saopig1andClaude Opus 4.8 19306002e3 fix(spotify): go-librespot recover on sidecar death + WS-reconnect status re-sync + stable-connection backoff [corner-case R4-2,R4-3,R4-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:08:29 +08:00
saopig1andClaude Opus 4.8 8e89a078a7 fix(spotify): atomic OAuth token store write so a crash during rotating refresh can't corrupt/lose it [corner-case R3-5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:31:00 +08:00
saopig1andClaude Opus 4.8 4cd269d852 fix(player): don't run stall/EOF end-detection while paused [corner-case R3-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:25:23 +08:00
saopig1andClaude Opus 4.8 49a47f5e2b fix(spotify): reconcile sidecar/player state on remove-current, skip-while-paused, and queue-exhaust [corner-case R3-2,R3-3,R3-6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:17:59 +08:00
saopig1andClaude Opus 4.8 952bd26d2d fix(spotify): confirm transient null-item over two polls before ending Rust track [corner-case R3-1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:08:28 +08:00
saopig1andClaude Opus 4.8 7e58e8a611 fix: bound album-tracks pagination + treat non-object config as corrupt (backup, not crash) [corner-case R2 minors]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:44:33 +08:00
saopig1andClaude Opus 4.8 2e05d276bf fix(spotify): per-bot Connect device name to avoid multi-bot device collision/misroute [corner-case R2-5]
config.spotify is a single process-wide object shared by every BotInstance, so
config.spotify.deviceName was identical for all bots. On the Rust (librespot)
backend each bot spawned `librespot --name <deviceName>` with no per-bot
uniqueness, registering two Connect devices with the same name under the one
shared account. findDeviceByName()/waitForDevice() match by name, so bot A's
transfer()+play() could drive bot B's librespot (misroute) and a bot could
report ready on seeing the OTHER bot's same-named device (false readiness).

Fix: derive a per-bot-unique Connect identity from the shared base name.
- controller.ts: new exported pure helper perBotDeviceName(base, instanceId?)
  (`${base}-${instanceId}` when an id is given, else base). Add optional
  instanceId to SpotifyControllerOptions; buildBackend() computes the effective
  name once and passes the SAME value to both the Rust and go backends so
  --name, findDeviceByName, and waitForDevice all key on one identity.
- instance.ts: pass instanceId: this.id into buildController(); add instanceId
  to the spotifyControllerFactory param type (test seam).

The user-configured config.spotify.deviceName base is left untouched; the suffix
applies only to the backend/Connect identity. Behavior-preserving for callers
that pass no instanceId (base name used unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:32:02 +08:00
saopig1andClaude Opus 4.8 9a32f3c602 fix(spotify): refresh Web API search provider creds on Settings save (no restart) [corner-case R2-4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:25:45 +08:00
saopig1andClaude Opus 4.8 2413a9a3a1 fix(spotify): paginate playlist/album tracks (bounded) + null-filter search tracks [corner-case R2-3,R2-6,R2-7]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:20:47 +08:00
saopig1andClaude Opus 4.8 03690952cb fix(config): atomic saveConfig + never overwrite a real config on transient/corrupt read [corner-case R2-1,R2-2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:03:01 +08:00
saopig1andClaude Opus 4.8 c8227faf31 fix(spotify): never skip a self-paused Rust track (gate near-end + null-state on !paused) [corner-case residual]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:37:55 +08:00
saopig1andClaude Opus 4.8 beda8d626c docs(spotify): document per-bot port-collision limitation + correct misleading comment [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:24:06 +08:00
saopig1andClaude Opus 4.8 08fe350e02 fix(spotify): guard non-numeric 429 Retry-After (no immediate retry) [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:22:34 +08:00
saopig1andClaude Opus 4.8 6dc99d88e2 fix(spotify): don't skip paused Rust track; handle ffmpeg stdin EPIPE; guard sub-window end-detection [corner-case]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:18:47 +08:00
saopig1andClaude Opus 4.8 11c0948330 docs(spotify): document known limitations (token CLI arg, gapless elapsed) [whole-branch d1,d2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:55:34 +08:00
saopig1andClaude Opus 4.8 0914cfeb2f test(spotify): guard 429 retry bound, disclaimer copy, deviceName blank-ignore, catalog mappers [whole-branch I5,m2,m3,m4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:54:02 +08:00
saopig1andClaude Opus 4.8 d796dd48ff fix(spotify): apply UI-entered Client ID to live SpotifyOAuth without restart [whole-branch I2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:49:33 +08:00
saopig1andClaude Opus 4.8 399cf0cf41 fix(spotify): PATH-aware binary presence detection (bin/ or PATH) [whole-branch I3,m1]
rustPresent/goPresent and getBackendInfo used existsSync(findX()), which for a
bare PATH command name resolves against cwd, not $PATH — so a scoop/choco/cargo/
apt install was invisible and Spotify was gated off. Add a sync PATH-aware
resolveExecutable() + isLibrespotPresent()/isGoLibrespotPresent() in binary.ts
and route controller.ts and web/server.ts through them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:45:48 +08:00
saopig1andClaude Opus 4.8 b4c3cc0539 fix(spotify): tear down in-flight backend on stop + degrade-to-skip on persistent play failure [whole-branch I1,I4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:37:49 +08:00
saopig1andClaude Opus 4.8 dc763ec689 fix(spotify): resume re-attached PCM stream so mixed-queue Spotify tracks aren't silent [whole-branch C1]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:27:55 +08:00
saopig1andClaude Opus 4.8 b9fe770ab1 feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:09:00 +08:00
saopig1andClaude Opus 4.8 657c198a37 docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 01:02:52 +08:00
saopig1andClaude Opus 4.8 488734c0db feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:57:30 +08:00
saopig1 6e0a0f7392 fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3] 2026-07-03 00:50:40 +08:00
saopig1andClaude Opus 4.8 6a72833c2a feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:45:55 +08:00
saopig1 b672d76634 feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1] 2026-07-03 00:41:29 +08:00
saopig1 eb3523f374 docs(spotify): stage 4 plan — config UI, OAuth hardening, Connect watchdog, docs (#112) 2026-07-03 00:38:11 +08:00
saopig1 6bde51c991 chore(spotify): stage 3 verification pass 2026-07-03 00:17:20 +08:00
saopig1andClaude Opus 4.8 8998b9623f feat(spotify): web OAuth endpoints + thread single shared SpotifyOAuth to web + controllers (Stage 3, Task 6)
Add the /api/spotify {login,callback,status} router behind the SpotifyOAuthLike
seam (DI-tested with supertest, no network). Build ONE process-wide SpotifyOAuth
in index.ts (clientId/redirectUri from config; store via the already-exported
createFileOAuthTokenStore) and thread that same instance into BOTH createWebServer
AND BotManager -> BotInstance -> SpotifyController, so a web login authorizes
playback (C3.1). Reuses the existing file token store (no token-store.ts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 00:09:12 +08:00
saopig1andClaude Opus 4.8 322335dfc5 feat(spotify): backend selection + Rust librespot wiring in SpotifyController
Add chooseBackend() honoring config.spotify.backend (go-librespot|librespot|
auto) against platform + binary availability (auto: linux+go binary -> go;
else librespot present -> Rust; else null). Controller now owns a shared
SpotifyOAuth + SpotifyConnectApi passed to the Rust backend; isAvailable() =
enabled && a backend is selectable; the Rust path additionally gates
ensureStarted() on oauth.isAuthorized(). go-librespot path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:50:03 +08:00
saopig1andClaude Opus 4.8 efe47c5bbc fix(spotify): arm RustLibrespot track-end detection only after playTrack (no spurious startup advance)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:43:27 +08:00
saopig1andClaude Opus 4.8 35bdd2a168 feat(spotify): add RustLibrespotBackend (stdout-pipe -> ffmpeg, Connect-API control)
Implements the Stage-2 SpotifyAudioBackend over Rust librespot: spawns
librespot with --backend pipe (no --device => s16le/44100/2 on stdout, no
--passthrough), pipes stdout -> ffmpeg (44100->48000 s16le), waits for the
Connect device to register before emitting "ready", and controls playback
(transfer/play/pause/resume/seek) plus track-end/position/metadata via a
polled SpotifyConnectApi. child_process/connect/oauth/ffmpeg injected for
fully mocked, no-network unit tests (Windows-targeted; not e2e without Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:34:58 +08:00
saopig1andClaude Opus 4.8 540bf8c032 feat(spotify): add SpotifyConnectApi Web API Connect control client
Wraps an injected axios instance with a live user Bearer token from
getToken(): getDevices/findDeviceByName, transfer/play/pause/resume/seek,
and getPlaybackState (null on 204). Read-only calls degrade gracefully;
mutating calls no-op when unauthorized. Fully unit-tested with a mocked
AxiosInstance (no network) — Windows-targeted, not e2e-testable (no Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:22:54 +08:00
saopig1andClaude Opus 4.8 333f7606e4 feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow
Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with
rotated-refresh-token persistence + invalid_grant store-clear. axios/http and
token store injected for fully mocked, network-free unit tests.

Corrections C3.2 (require the operator's own client_id; no librespot public
client / :5588 default; buildAuthorizeUrl throws + isAuthorized false without
it) and C3.7 (delete the state->verifier map entry in a finally on every
terminal path) applied.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:13:05 +08:00
saopig1andClaude Opus 4.8 8c84090b63 feat(spotify): add Rust librespot binary resolver (Stage 3 Task 1)
Append isRustLibrespotSupported/pickLibrespotPath/findLibrespot/
checkLibrespotAvailable/resetLibrespotBinaryCache to binary.ts, mirroring
the go-librespot resolver. Supported on all platforms (pipe->stdout),
resolves librespot.exe on win32, caches only positive --version probes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:04:16 +08:00
saopig1andClaude Opus 4.8 3277736f5d docs(spotify): stage 3 plan — Rust librespot Windows backend (#112)
Drafted from a locked contract + research map, adversarially verified (3 critics).
REQUIRED CORRECTIONS: single shared OAuth threaded to web+controller; auth via the
user's own Developer app + web callback (drop ToS-gray librespot-client + :5588);
resolved-backend status; poll hasPlayed-gating + 204 track-end; Connect error
guarding; verifier-map cleanup. Cross-platform, gated, not e2e-testable (Premium).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 23:00:08 +08:00
saopig1andClaude Opus 4.8 8bd0aae7c3 fix(spotify): loopback-bind sidecar API, recover on sidecar death, per-bot go-librespot ports
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:24:51 +08:00
saopig1andClaude Opus 4.8 9c796ec05c fix(spotify): correct Spotify seek units (s→ms) + gate re-attach on player external state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 22:00:56 +08:00
saopig1andClaude Opus 4.8 b5b3585e77 feat(spotify): orchestrate go-librespot backend from BotInstance (Stage 2 Task 7)
Construct one SpotifyController per bot (config.spotify + per-bot work/config
dirs under DATA_DIR, threaded via BotManager + index). resolveAndPlay now
routes spotify: sentinels through controller.ensureStarted/playTrack +
player.playPcmStream (falling back to the Stage-1 message when unavailable),
fences/pauses the sidecar on source transitions, advances via controller
"trackEnded", and delegates pause/resume/stop transport. Correction C4:
no re-attach on a spotify->spotify handoff (playPcmStream once across tracks,
no player.stop() — playPcmStream fences the prior ffmpeg internally); occupancy
auto-pause/resume + updateAutoPause + a new BotInstance.seek() (web seek route)
also delegate to the sidecar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:44:39 +08:00
saopig1andClaude Opus 4.8 179e7c248a fix(spotify): tear down errored backend in SpotifyController (no leak/cross-talk)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:30:13 +08:00
saopig1andClaude Opus 4.8 3a9504500b feat(spotify): SpotifyController backend lifecycle, gating, and event re-emission
Per-bot orchestrator: isAvailable() gates on config.enabled + platform +
binary presence; ensureStarted() starts the backend once (idempotent, retries
on failure); playTrack/pause/resume/seek/stop delegate; getPcmStream() proxies
the backend PCM; re-emits backend trackEnded/metadata. backendFactory injected
for tests (fake backend, no real binary/network).

Correction C3: the controller does not re-emit a raw "error" event (Node's
EventEmitter throws on an unhandled "error"); it logs the backend error and
marks itself not-ready so the next ensureStarted() relaunches. getPcmStream()
returns the backend's single persistent stream (no per-attach PassThrough).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:22:12 +08:00
saopig1andClaude Opus 4.8 6debe23034 feat(audio): add external-PCM mode (playPcmStream) for Spotify sidecar
Adds AudioPlayer.playPcmStream(readable, {onExternalEnd}) that feeds a
long-lived external 48kHz/s16le/stereo Readable into the existing pcmBuffer +
20ms frame loop + Opus encoder without spawning a per-URL ffmpeg. Reuses the
same high/low-water backpressure (pausing/resuming the Readable), suppresses the
underrun trackEnd drain/stall branches while external (emitting a silence frame
to keep the 20ms timeline), tears down externalMode in stop() by DETACHING the
shared readable (remove our data/end/error listeners + pause, never destroy the
sidecar stream) and clearing onExternalEnd, and makes seek() a local no-op in
external mode. The url play() path and all exported pure functions are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:14:15 +08:00
saopig1andClaude Opus 4.8 641da086e5 fix(spotify): GoLibrespotBackend start() cleanup on failure + unhandled-error guard
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:04:42 +08:00
saopig1andClaude Opus 4.8 9d55bea240 feat(spotify): GoLibrespotBackend sidecar (FIFO + ffmpeg PCM + REST/WS)
Implements SpotifyAudioBackend over a go-librespot sidecar: start() mkfifos
the pipe, spawns the FIFO->48k s16le ffmpeg reader BEFORE go-librespot, writes
config.yml, polls the REST /  until ready, then connects the WS event stream.
Maps not_playing/stopped -> trackEnded and metadata -> SpotifyNowPlaying;
play/pause/resume/seek delegate to the REST client. All child_process/fs/REST/WS
seams are injectable so the lifecycle is fully unit-tested without a real binary.

Correction C1: ffmpeg is resolved via getFfmpegCommand() (now exported from
src/audio/player.ts) so the bundled ffmpeg-static fallback is honored in Docker;
the ffmpeg command is overridable via deps.ffmpegCommand for tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:58:13 +08:00
saopig1andClaude Opus 4.8 f2b14b5f00 feat(spotify): add go-librespot REST client + WS event client (Stage 2)
GoLibrespotRestClient wraps axios (injectable via deps.http) for
/player/play|pause|resume|stop|seek, GET /status, GET / ping; ping/getStatus
swallow errors to false/null, mutating ops reject. GoLibrespotEventClient
(EventEmitter) parses {type,data} /events frames and re-emits type with data,
reconnects on close with capped backoff, stop() tears down. TDD with a mock
AxiosInstance and a fake WebSocket (no real binary/network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:47:44 +08:00
saopig1andClaude Opus 4.8 470a62129a feat(spotify): add SpotifyAudioBackend interface + go-librespot config.yml renderer
- backend.ts: type-only SpotifyAudioBackend contract + track/metadata DTOs
- go-librespot-config.ts: renderConfigYml() hand-built config (pipe/s16le,
  server enabled, interactive OAuth), no yaml dependency
- tests assert exact keys/values and round-trip via a tiny structural parser

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:40:15 +08:00
saopig1andClaude Opus 4.8 32718f0118 feat(spotify): add go-librespot binary resolver + Linux support gate
Mirror youtube.ts findYtDlp/checkYtDlpAvailable (bin/ then PATH,
cache-positive-only availability, reset test hook) and add
isGoLibrespotSupported() Linux gate for the Stage 2 audio backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:33:42 +08:00
saopig1andClaude Opus 4.8 978e6ee7f2 docs(spotify): stage 2 plan — go-librespot audio backend (#112)
Drafted from a locked interface contract + integration map, then adversarially
verified (3 critics). Includes REQUIRED CORRECTIONS fixing the gapless-handoff
blocker (no stream re-attach on spotify->spotify), detach-not-destroy teardown,
ffmpeg-static resolution, occupancy/seek transport delegation, and unhandled-error
guarding. Linux/Docker-only + gated; not e2e-testable without Premium.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:30:45 +08:00
saopig1andClaude Opus 4.8 77d71fe418 docs(spotify): note intentional Spotify omission from unified /search/all (stage 1)
Spotify tracks are metadata-only until the librespot audio backend lands, so
they are surfaced only from the dedicated Spotify tab, not the default
all-sources search. Conscious decision from the whole-branch review (#112).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:32:08 +08:00
saopig1andClaude Opus 4.8 50bdaae26a feat(spotify): frontend plumbing (source tab, badge, auth status)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:22:45 +08:00
saopig1andClaude Opus 4.8 e1171dbcfe feat(spotify): expose provider through web music/auth routers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:19:22 +08:00
saopig1andClaude Opus 4.8 9a9f68c446 feat(spotify): wire provider through manager/instance; skip playback sentinel
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:13:22 +08:00
saopig1andClaude Opus 4.8 848b3931b8 feat(spotify): config block (disabled by default) + sanitize
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:04:31 +08:00
saopig1 828fd21e29 feat(spotify): SpotifyProvider (search/browse; playback sentinel) 2026-07-01 23:59:41 +08:00
saopig1andClaude Opus 4.8 6c16e2d966 feat(spotify): Web API client + catalog mappers, add spotify platform
Adds src/music/spotify/webapi.ts (client-credentials token, catalog
mappers, 429 retry) + tests, and threads the new "spotify" platform id
through the type unions in provider.ts and database.ts. Also widens the
downstream QueuedSong.platform union (audio/queue.ts) and the
getProviderFor parameter (bot/instance.ts) so tsc --noEmit stays clean;
these two are the necessary call-site fixes for the new union member.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:52:22 +08:00
saopig1andClaude Opus 4.8 b352d71528 docs(spotify): stage 1 implementation plan (metadata + provider + wiring) (#112)
Bite-sized TDD plan for the first shippable increment: Spotify becomes a
searchable/browsable source (Web API), with playback cleanly reporting
"not playable yet". Adversarially verified against the codebase (3 critics)
and fixed: getProviderFor signature, pre-existing config.test.ts imports,
all three BotInstance sites, enabled-gate safety, 429 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:04:35 +08:00
saopig1andClaude Opus 4.8 92bdfcb294 docs(spotify): design spec for optional hybrid librespot audio source (#112)
Adds the approved design for a new optional `spotify` MusicProvider that
streams real Spotify audio via a librespot-family sidecar behind one
SpotifyAudioBackend interface (go-librespot on Linux/Docker, Rust librespot
on Windows), with metadata via the Spotify Web API. Disabled by default,
opt-in, Premium-only, ToS-risk warned. Includes verified research appendix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:26:07 +08:00
TIANYAO ZHANG f8743a904f Merge pull request #114 from ZHANGTIANYAO1/fix/qq-api-version-pin
fix(qq): 锁定 @sansenjian/qq-music-api 到 ~2.4.0 并加固启动报错
2026-06-30 23:26:28 +08:00
saopig1andClaude Opus 4.8 f6e42811a5 chore(deps): update NeteaseCloudMusicApi (pinned) + safe patch/minor bumps
Dependency audit follow-up to the QQ pin:
- NeteaseCloudMusicApi ^4.30.0 → ~4.32.0. Same rationale as @sansenjian/
  qq-music-api: it's an embedded API server loaded via dynamic import, so a loose
  `^` could drift into a breaking minor and silently kill the netease server
  (ECONNREFUSED). Tighten to ~4.32.x. Verified at runtime: server starts on 3001
  and /banner returns 200.
- Lockfile bumps within existing ^ ranges (no API-server risk, so ranges kept):
  better-sqlite3 12.8.0→12.11.1, koa 3.2.0→3.2.1, ws 8.20.0→8.21.0,
  typescript 6.0.2→6.0.3, ts3-nodejs-library 3.5.1→3.5.3, vitest 4.1.4→4.1.9,
  tsx 4.21.0→4.22.4.
- Deliberately NOT bumped (major / needs a migration): bcryptjs 2→3 (password
  hashing), @types/node 25→26, @types/supertest 6→7.

tsc clean; full suite 913 passing under vitest 4.1.9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:16:25 +08:00
saopig1andClaude Opus 4.8 08260182a9 fix(qq): pin @sansenjian/qq-music-api to ~2.4.0 + guard startup failures
A loose `^2.2.10` range let npm pull a newer build of the QQ Music API. The
library went ESM in 2.3.x: an ESM-only 2.3.0/2.3.1 throws ERR_REQUIRE_ESM on
load, so the embedded server never binds port 3200 and every QQ request
(including /getQQLoginQr) fails downstream with ECONNREFUSED — the QR code never
appears and cookies look broken.

- Pin to `~2.4.0` (verified: loads via the bot's native ESM import, exposes the
  Koa app, and every endpoint qq.ts calls returns the exact shapes it parses —
  QR, recommend, lyric, play, playlist detail). Blocks the broken 2.3.0/2.3.1
  and any future 2.5 migration. NOTE: 2.4.x requires Node >=20.17 (or >=22.9).
- Add describeQqApiStartupError(): on startup failure, log an actionable error
  (ERR_REQUIRE_ESM → version-pin hint; engine mismatch → Node-upgrade hint)
  instead of a generic warning, so this is obvious from the logs next time.
- README: troubleshooting entry for "QQ 二维码不弹 / 登录失败 / cookie 无法使用"
  and the version/Node note in the dependency table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 23:05:47 +08:00
TIANYAO ZHANG de4343cfbb Merge pull request #113 from ZHANGTIANYAO1/docs/kugou-readme
docs: 在 README 中补充酷狗音乐音源与登录功能
2026-06-30 21:36:34 +08:00
saopig1andClaude Opus 4.8 af50d69b00 docs: document Kugou (酷狗音乐) source + login features in README
Kugou shipped in #110 but the README still listed only netease/qq/bilibili/
youtube. Add Kugou throughout:
- tagline, badge, 多平台音源, QR 登录, 歌单管理 (酷狗私人电台 !fm -k + the
  login-gated daily/recommend/user playlists)
- quick-start account login, WebUI page table (FM sources, 三→四平台 search,
  multi-platform login), architecture tree (kugou.ts), dependency table,
  milestones, and a credit to the MIT MakcRe/KuGouMusicApi reference
- command table: !play -k / !search [-k] / !artist -k / !fm -k (the flags that
  actually route to Kugou; not !playlist -k — Kugou search returns no playlists)

Also fix the in-bot `!search` usage string to include -k so it matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:36:00 +08:00
TIANYAO ZHANG e1439a5899 Merge pull request #110 from ZHANGTIANYAO1/feat/kugou-provider
feat: 增加酷狗音乐 (Kugou) 音源支持 (closes #69)
2026-06-30 21:15:12 +08:00
saopig1andClaude Opus 4.8 5ae5168b6b fix(web): keep the volume slider draggable under the per-frame progress loop (#111)
The 60fps requestAnimationFrame progress clock (#107) re-renders the player
every ~16ms, and Vue re-applied `el.value = storeVolume` on a range input each
time — snapping the thumb back to the stale store value mid-drag (un-draggable
on desktop, janky on mobile).

Extract the decoupling into a useDecoupledSlider composable used by both the
desktop (Player.vue) and mobile (App.vue) sliders: a local display ref tracks
the native drag via @input (so the bound value always matches the element), the
store is committed only on @change (release), and an onRelease safety-net
(pointerup/pointercancel/blur) clears the dragging guard even when the browser
skips `change` (value released at its start point). External/store changes still
flow into the display except while dragging. Adds a regression test for the
no-snap-back invariant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:12:05 +08:00
saopig1andClaude Opus 4.8 4cb1da29d4 fix(web): render login QR codes dark-on-light so scanners can read them
The QR images used theme-aware colours, so in the default dark theme they were
rendered light-on-dark (inverted). Many in-app scanners — notably the Kugou
music app — cannot decode an inverted QR, so the code looked fine on screen but
silently failed to scan. Force standard dark-on-light regardless of theme; the
white quiet-zone frames it cleanly in dark mode anyway. Affects all platforms'
QR login (netease/qq/bilibili/kugou).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:53 +08:00
saopig1andClaude Opus 4.8 f9caea6c79 feat(kugou): add login-gated discovery (daily/recommend/user playlists, FM) + covers
Implements the NetEase-parity login features for the Kugou provider now that
QR login works:
- getDailyRecommendSongs (每日推荐), getUserPlaylists (我的歌单), and a real
  getRecommendPlaylists (推荐歌单, was a stub) ported from the reference API.
- mapKugouSong now extracts cover art per endpoint (sizable_cover / cover /
  trans_param.union_cover, resolving the {size} template) — Kugou songs had no
  artwork before.
- Fix the playlist-song shape (combined "歌手 - 歌名" in `name`, mixsongid as the
  audio id) so opened playlists show real titles instead of 未知歌曲.
- New defensive playlist mappers keyed on global_collection_id (the only id
  getPlaylistSongs can open); dedup user playlists in case the list endpoint
  ignores pagination; firstStr() so an empty-string field can't mask a real one.

Frontend wires Kugou as a third home-discovery source (Source type, store
caches/auth, availableSources, fetchHomeData, Home FM card + source tabs,
SourceTabs label, persisted-tab whitelist). SourceTabs now highlights the
fallback-corrected source so the active tab shows when a logged-out source was
persisted (newly possible with 3 sources).

Adds kugou.test.ts coverage for the new mappers, the cover/empty-string and
playlist-shape handling, and id openability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:11:45 +08:00
saopig1andClaude Opus 4.8 a12c419dd2 feat(music): add Kugou (酷狗音乐) as a music source (#69)
Adds a self-contained Kugou provider (bilibili-style: direct API calls, no
embedded API server, no new npm dependency) plus full backend + WebUI wiring.

Provider (src/music/kugou.ts): search, song-url (with device registration),
lyrics (KRC decode), song detail, playlist, album, personal FM, QR login +
cookie persistence, and quality. Request signing / crypto / KRC decoding are
ported from the MIT-licensed MakcRe/KuGouMusicApi using Node's built-in
crypto and zlib (no third-party crypto packages).

Wiring: the "kugou" platform is threaded through the provider contract, queue,
play-history, bot instance/manager dispatch (getProviderFor + the -k command
flag), index/server composition, the music/player/auth routers (unified
/search/all, /quality, the platform coercions, QR login), the cookie store,
and the WebUI (search source tab + badge, SongCard badge, brand token, and a
Kugou QR/cookie login card in Settings).

Verified live during development: search, lyrics, and album playback resolve
correctly. NOT verifiable in CI (Kugou anti-bot blocks the build host's IP):
play-URL resolution, QR login, and VIP audio — these are built faithfully to
the reference and need end-to-end testing on a non-flagged IP / a Kugou
account. See the header comment in kugou.ts.

Includes src/music/kugou.test.ts (mappers + KRC→LRC). An adversarial review
pass fixed: pagination truncating on filtered counts, an ms/seconds duration
heuristic, dfid soft-fail caching, the /v5/url random-dfid fallback, the FM
body identity, and an unguarded nickname decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 18:55:41 +08:00
TIANYAO ZHANG af326a37b7 Merge pull request #109 from ZHANGTIANYAO1/fix/player-elapsed-reactivity
fix(web): 播放器时间每帧更新、歌词同步 (closes #107)
2026-06-30 17:26:46 +08:00
saopig1andClaude Opus 4.8 45f5d236c1 fix(web): tick player time every frame and keep lyrics in sync (#107)
`store.elapsed` is a Pinia getter (a cached Vue computed) that interpolates
with `Date.now()`. Because `Date.now()` is not a reactive dependency, the
computed only re-ran on WebSocket pushes / the 3s server poll, so the bottom
progress bar jumped ~3s at a time and lyric highlighting lagged ~half a line —
even though the consumers read it from a 60fps requestAnimationFrame loop.

Add a pure `interpolateElapsed()` helper and a non-cached `liveElapsed()` store
action. The per-frame consumers now call `liveElapsed()` so the value advances
every frame instead of returning a frozen cache:
- web/src/components/Player.vue  (desktop progress bar, rAF)
- web/src/App.vue                (mobile progress bar, rAF)
- web/src/views/Lyrics.vue       (lyric highlight, 500ms interval)

pause() now freezes at the live value rather than a possibly-stale cached one.
The `elapsed` getter is refactored onto the same helper (behaviour unchanged).

Adds web/src/stores/elapsed.test.ts covering the time-advancing interpolation,
paused freeze, no-anchor, and duration-clamp cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:17:53 +08:00
TIANYAO ZHANG ea6820204d Merge pull request #108 from Fa1nttt/feature/local-audio-upload
feat: add local audio upload playback 增加本地音频上传播放功能
2026-06-30 16:03:32 +08:00
saopig1andClaude Opus 4.8 e849db2286 fix(local-audio): reference-aware cleanup, upload quota, stricter validation
Uploaded local files were deleted whenever a track left the current slot,
with no check on whether the file was still needed — causing data loss in
several flows. Replace with reference-aware cleanup: a file is deleted only
once it has been played AND is no longer referenced by ANY bot's queue
(BotManager.getReferencedLocalSongIds wired into the provider via
setInUseResolver), with the sweep run AFTER each queue mutation.

Fixes:
- play-song replay no longer deletes the file it is about to play
- loop / repeat-all / prev no longer destroy uploads mid-cycle
- a shared upload queued on multiple bots is not deleted while still in use
- !play / play-playlist / play-album clean the whole replaced queue, and an
  empty/failed playlist/album load keeps the previous queue + files intact
- bound disk use with an upload quota (evict oldest UNREFERENCED files)
- validate uploads by extension against the audio whitelist (never trust the
  client Content-Type); the stored extension is always a known audio type

Deletion now unlinks the file FIRST and drops the record only on success,
with a bounded non-blocking retry for briefly-locked files (Windows/ffmpeg),
so a failed unlink never orphans a file or diverges index.json. The quota
never evicts the just-uploaded file, and long filenames keep their extension.

Adds src/music/local.test.ts covering the cleanup lifecycle, quota eviction,
and upload validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:58:22 +08:00
Fa1nttt e12cbf8863 feat: add local audio upload playback 2026-06-30 14:08:42 +08:00
TIANYAO ZHANG 4e148302fc Merge pull request #106 from ZHANGTIANYAO1/fix/guest-play-collection
fix(guest): allow Play All for guests via a dedicated playCollection permission (#103)
2026-06-29 17:39:50 +08:00
saopig1 9c861f487d docs: add playCollection to the guest-permission table (#103) 2026-06-29 12:16:17 +08:00
saopig1 70c0273ae7 fix(guest): add playCollection permission so guests can Play All playlist/album (#103)
- New guest flag playCollection (default OFF), gates play-playlist/play-album
- Keeps playNow's non-destructive semantics intact (Play All clears the queue)
- Admin-toggleable in Settings → 游客模式; default-off, backward-compatible
- Frontend: gate the 播放全部 button on the flag + surface 403 as a toast
  instead of failing silently (the silent-failure half of the issue)
2026-06-29 12:12:47 +08:00
TIANYAO ZHANG e2fa288f48 Merge pull request #105 from Slldyd2077/feat/song-vip-flag
feat: expose vip flag & trial duration on Song for trial-only playback
2026-06-29 11:59:08 +08:00
TIANYAO ZHANG 59a9e742c8 Merge pull request #104 from ZHANGTIANYAO1/feat/ts-command-permissions
feat: TeamSpeak chat-command permission control (adminGroups)
2026-06-28 23:41:34 +08:00
saopig1 31d3830791 test(ts-protocol): smoke-test getClientServerGroups query string + client_servergroups parse 2026-06-28 23:40:30 +08:00
Slldyd2077 d1bd010260 Merge remote-tracking branch 'upstream/main' into feat/song-vip-flag
# Conflicts:
#	src/bot/instance.ts
2026-06-28 21:31:17 +08:00
Slldyd2077 fbb127a86d feat: resolve trial-only playback via trialDuration/effectiveDuration
VIP songs for non-VIP accounts return a ~30s trial fragment. The player used the full duration for isNearEnd, so the trial end didn't trigger auto-advance (~60s stall), and currentSong.duration stayed full, leaving the UI progress stuck.

- provider.ts: SongUrlResult {url, trialDuration?}; getSongUrl signature
- netease.ts: parseNeteaseTrial (freeTrialInfo start/end in seconds) + getSongUrl
- qq.ts: parseQqTrial (isTryout/tryEnd) + getSongUrl
- bilibili/youtube: getSongUrl returns {url}
- instance.ts: resolveAndPlay uses effectiveDuration = trialDuration ?? duration -> nearEnd at trial end -> native auto-advance; BotStatus.effectiveDuration
- VIP account: freeTrialInfo absent -> full duration -> full playback (no toggle)

Backward compatible (optional fields; getSongUrl has a single caller, updated).
Tests: parseTrial assertions (seconds/alias/ms-fallback). 14 pass.
2026-06-28 21:06:52 +08:00
Slldyd2077 7b2bd0ea6a feat: expose vip flag on Song for trial-only detection
Add optional vip?: boolean to the Song interface so downstream clients
(e.g. PowerfulTS) can mark copyright-restricted songs that non-VIP users
can only play as a trial fragment, before playback starts.

- provider.ts: add optional vip?: boolean (backward compatible)
- netease.ts: extract mapNeteaseSongs() pure fn; map fee to vip
  (1=VIP, 4=album-only). fee=8 (free low-quality) is excluded because
  it plays in full, just at lower quality.
- qq.ts: mapQqSongs() maps pay.payplay/paytrackprice to vip (one fix
  covers all callers); getDailyRecommendSongs inline mapping too.
- tests: vip mapping assertions for netease fee (1/4=vip, 0/8=free) and
  qq pay fields.
2026-06-28 17:12:57 +08:00
saopig1andClaude Opus 4.8 8e5e9c810e fix(bot): resolve sender server groups live + server-wide for the admin-command gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 11:51:06 +08:00
saopig1 e104093614 fix: sanitize adminGroups on config load + final-review cleanups 2026-06-26 21:12:56 +08:00
saopig1 b387d6581e docs: TS chat-command permission implementation plan 2026-06-26 20:57:39 +08:00
saopig1 17ab477af6 docs: correct stale adminGroups references now that the feature ships 2026-06-26 20:53:56 +08:00
saopig1 10e29476f4 docs: document TeamSpeak chat-command permission control 2026-06-26 20:51:08 +08:00
saopig1 215e328f17 feat(web): admin-only command-permission (adminGroups) settings section 2026-06-26 20:47:34 +08:00
saopig1 3346286ffd feat(api): read/write adminGroups in bot settings endpoints 2026-06-26 20:44:29 +08:00
saopig1 72ffd44f68 feat(bot): gate admin chat commands on adminGroups with fallback + deny reply 2026-06-26 20:40:01 +08:00
saopig1 b090a8ec21 feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper 2026-06-26 20:35:31 +08:00
saopig1 f98ce47c52 feat(commands): add canRunCommand gate helper + admin-set source of truth 2026-06-26 20:32:45 +08:00
saopig1andClaude Opus 4.8 0c7f7e128b docs: TS chat-command permission control design spec
Binary admin gate keyed on TS server groups (config.adminGroups),
opt-in/backward-compatible (empty = no enforcement), gated in the
chat handler (executeCommand stays agnostic so WebUI is unaffected),
with adminGroups editable from the WebUI settings. Completes the
unused adminGroups/ADMIN_COMMANDS scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 23:04:58 +08:00
TIANYAO ZHANG 0cc77fdee0 Merge pull request #102 from ZHANGTIANYAO1/feat/guest-mode
docs: surface guest mode in feature list + reflect shipped behavior
2026-06-25 16:25:34 +08:00
saopig1andClaude Opus 4.8 3b2b2185a5 docs: surface guest mode in feature list + reflect shipped behavior
- Add a 游客模式 bullet to the top-level 功能特性 list.
- Note guests share one short-lived anonymous identity, and that
  disabling/narrowing takes effect live (incl. open WebSockets).
- Expand the always-denied list to include favorites, change-password,
  and the operator's personal platform-account data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:24:25 +08:00
TIANYAO ZHANG 253c0a46a1 Merge pull request #101 from ZHANGTIANYAO1/feat/guest-mode
Add guest mode (login-less WebUI access) (#83)
2026-06-25 16:20:55 +08:00
saopig1andClaude Opus 4.8 a1a70dea5d fix(guest): serialize concurrent queue-mutation playback per bot
The queue-mutating playback routes (play-now-song, play-next-song,
add-song, play-at) read queue position synchronously, mutate the queue,
then await resolveAndPlay() which suspends at an async URL fetch before
player.play(). With no serialization, two concurrent requests (normal in
login-less guest mode) interleave: the audible song (decided by URL-fetch
latency) can disagree with queue.currentIndex (decided by sync-block
ordering), corrupting "now playing" and causing skipped/duplicate songs.

Add a per-bot async serializer (BotInstance.runExclusive) and wrap the
critical region of all four routes in it. Single-request behavior and
every response shape / validation 400 are preserved; only the critical
region moved inside runExclusive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:10:38 +08:00
saopig1andClaude Opus 4.8 43c0175334 fix(guest): tear down guest WS on guest-mode config change
An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:05:14 +08:00
saopig1andClaude Opus 4.8 c1d73b6ba8 fix(guest): cap guest session TTL on touch
The sliding-refresh branch in validateAndTouch hardcoded SESSION_TTL_MS
(7d) for all roles, so a guest session created with GUEST_SESSION_TTL_MS
(1d) was wrongly bumped to 7d on the first touch after the touch
interval. Derive the touch TTL from row.role instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:02:44 +08:00
saopig1andClaude Opus 4.8 66230e6b43 fix(guest): normalize guestMode config on load + strict-boolean authorize gate
loadConfig now sanitizes guestMode the same way the write path does: bots is
coerced to "all" | string[] (numbers/objects/missing fall back to the default
"all"), and permissions are rebuilt from defaults with each known flag
strict-coerced to a boolean so a hand-edited/legacy/corrupt config.json can no
longer crash the gate or leak garbage index keys. The authorize guest gate now
uses === true instead of a truthy check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:59:42 +08:00
saopig1andClaude Opus 4.8 952f1fbad3 fix(guest): deny operator personal-data reads to guests
GET /recommend/songs, /personal/fm, and /user/playlists read the
operator's own logged-in music account; gate them with requireNotGuest
so login-less guests cannot see the operator's recommendations, FM, or
playlists. Generic search/browse stays open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:29 +08:00
saopig1andClaude Opus 4.8 365352cdd3 fix(guest): guard reserved __guest__ principal in user mgmt
The by-id user-management handlers use findById, which has no role
filter, so supplying the synthetic GUEST_USER_ID let an admin delete,
re-role, reset-password, and read/write permissions on the shared guest
principal (privilege-escalation / DoS / credential-login holes).

- web/api/users.ts: 404-guard every :id handler against GUEST_USER_ID
  (DELETE, reset-password, role, GET/PUT permissions).
- data/users.ts: defense-in-depth — setRoleIfNotLastAdmin and
  deleteUserIfNotLastAdmin return "not_found" for any role=guest row.
- web/api/session.ts: wrap POST /guest createSession in try/catch so a
  missing guest row yields 503 instead of an unhandled 500.
- Tests: data-layer guest-protection + users-router 404 by-id guards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:54:24 +08:00
saopig1andClaude Opus 4.8 45414b3baa feat(guest): prune deleted bot from guest scope on removeBot
When a bot is deleted, prune its id from config.guestMode.bots (when an
array) and persist, mirroring the existing permissions.pruneBot(id)
member-access pruning. Thread CONFIG_PATH into BotManager so removeBot
can save the updated config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:46 +08:00
saopig1andClaude Opus 4.8 f142c514cd fix(guest): deny favorites + auth-status reads to guests; UI polish
Consolidated fix wave from the final whole-branch review of guest mode.

- FIX 1 (critical): gate /api/favorites mount with requireNotGuest — the
  router keys off req.user.id (shared __guest__ principal), so guests could
  read/write a shared favorites bucket. Added focused guest-deny tests.
- FIX 2: gate GET /api/auth/status and /api/auth/qrcode/status with
  requireNotGuest so config reads no longer leak to guests.
- FIX 3: requireAuthInline in createSessionRouter now rejects guest sessions
  with 401 once guest mode is disabled (mirrors createRequireAuth), so /me
  stops returning guest data after an admin disables the feature.
- FIX 4: Login guest button now sits BELOW the card (auth-page flex-direction
  column + guest-btn width 360px) instead of beside it.
- FIX 5: mobile mini-player transport buttons in App.vue are now per-button
  gated for guests (prev/play/next/mode/volume), mirroring Player.vue.
- FIX 6: refreshed stale "gated on player.control" seek comments in Player.vue
  and relabeled the now-stale quality-GET test.

npm test: 354/354 pass. npm run build: tsc + vue-tsc + vite all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:44:07 +08:00
saopig1andClaude Opus 4.8 e47fc76529 docs: document guest mode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 12:25:46 +08:00
saopig1 0fe0e973e6 feat(web/settings): admin-only 游客模式 section (toggles + bot scope) 2026-06-25 12:20:53 +08:00
saopig1 28cff59a6f feat(web/queue): gate remove/clear by member capability or guest removeClear 2026-06-25 12:17:53 +08:00
saopig1 b17057cc41 feat(web/player): per-button transport gating honoring guest flags 2026-06-25 12:15:09 +08:00
saopig1 15fcb11f4f feat(web/store): route guest play to non-destructive play-now-song 2026-06-25 12:12:28 +08:00
saopig1 9d8c95b2f9 feat(web/songcard): gate play/playNext/add by member capability or guest flag 2026-06-25 12:09:46 +08:00
saopig1 e36a049216 feat(web/app): hide mobile settings tab for guests 2026-06-25 12:06:51 +08:00
saopig1 3042f87199 feat(web/navbar): hide settings cog for guests + 游客 badge 2026-06-25 12:06:26 +08:00
saopig1 a21a01f0dd feat(web/login): add Continue as guest entry when guest mode is on 2026-06-25 12:03:47 +08:00
saopig1 78cf516c4c feat(web/router): block guests from settings and setup routes 2026-06-25 12:01:10 +08:00
saopig1 0c59a9f84a feat(web/session): expose isGuest, guestCan, continueAsGuest, guestAllowed 2026-06-25 11:58:58 +08:00
saopig1 d2ab888114 feat(ws): scope guest WebSocket feed to allowed bots 2026-06-25 11:56:19 +08:00
saopig1 0073d7d612 feat(music): lock quality read from guests 2026-06-25 11:51:41 +08:00
saopig1andClaude Opus 4.8 e0acbf5457 feat(bot): lock settings reads from guests + persist guestMode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:48:26 +08:00
saopig1andClaude Opus 4.8 d763043305 feat(player): unified authorize() gating + non-destructive guest play-now
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:43:59 +08:00
saopig1andClaude Opus 4.8 821fa0669d feat(mw): add unified authorize() gate and requireNotGuest
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:38:21 +08:00
saopig1 8fbc522d06 feat(session): guest login endpoint, guestAllowed, guest /me payload 2026-06-25 11:35:10 +08:00
saopig1andClaude Opus 4.8 271504eec1 feat(db): seed reserved guest principal idempotently
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:30:12 +08:00
saopig1andClaude Opus 4.8 c9a0719128 feat(auth): guest-aware requireAuth + disable invalidates guest sessions
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:25:26 +08:00
saopig1andClaude Opus 4.8 0514162824 feat(sessions): guest role + per-session TTL and cap bypass
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 11:19:21 +08:00
saopig1 60c8a5c993 feat(users): guest role + reserved guest principal, excluded from count/list 2026-06-25 11:14:47 +08:00
saopig1 fb7f187ede feat(config): add default-off guestMode block with deep-merge 2026-06-25 11:11:10 +08:00
saopig1 1fd5dbaba3 feat(permissions): guest permission types + resolve guest branch 2026-06-25 11:08:17 +08:00
saopig1andClaude Opus 4.8 3433ccb661 docs: guest-mode implementation plan (#83)
23 bite-sized TDD tasks with exact code: config + guest principal,
unified authorize() gate, route re-gating + non-destructive play-now,
settings/quality read-locks, WebSocket per-bot guest scoping, and the
full frontend (entry, gating, admin 游客模式 section).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:37:08 +08:00
saopig1andClaude Opus 4.8 694ff77712 docs: guest-mode (login-less WebUI access) design spec (#83)
Brainstorm-approved design for an optional, default-off guest mode:
- guest = anonymous, config-driven principal (no account)
- per-ability admin toggles (add-to-end default on; play-next/play-now/
  skip/transport/remove-clear/play-mode opt-in) + per-bot guest scope
- unified authorize() gate; settings always locked for guests;
  non-destructive guest "play now"

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:20:49 +08:00
TIANYAO ZHANG 06aaec4ba5 Merge pull request #100 from Dr1mH4X/feat/channelid
feat: joinChannel via channelid
2026-06-24 22:58:45 +08:00
Dr1mH4X 62b5b09857 chore: add success log for numeric channel join 2026-06-23 02:45:09 +08:00
Dr1mH4X 05f090d83a chore: format 2026-06-23 02:40:54 +08:00
Dr1mH4X 4244695075 feat: Add channelId support to bot configuration and database 2026-06-23 02:37:13 +08:00
TIANYAO ZHANG 6f21b6354a Merge pull request #98 from ZHANGTIANYAO1/fix/autopause-resume-on-return
fix(auto-pause): auto-resume when a listener returns (event-driven)
2026-06-17 23:12:27 +08:00
saopig1andClaude Opus 4.8 3a34c01abb fix(auto-pause): auto-resume on a listener's return via clientEnter event
Follow-up to the auto-pause fix: resume never fired when someone came back.

Root cause (verified live against a TS3 server): the full-client library's
command/response channel is dead whenever >=2 clients are connected anywhere on
the server — clientlist, channellist and channelclientlist ALL time out
(confirmed even with the two clients in different channels). So the moment a
listener returns is exactly the moment occupancy can no longer be queried, and
the query-based refreshOccupancy() can never observe the return -> no resume.
Event channelID is also unusable (library reads notify `cid` but enter-view
carries `ctid`, so it's always 0), so per-channel membership can't be derived
from events either.

Fix (minimal, asymmetric): keep PAUSE on the authoritative clientlist path
(reliable precisely because it only succeeds when the bot is alone on the
server — the only state pause should fire), and arm RESUME directly from the
clientEnter push event. Because the bot only auto-pauses while alone, the sole
way occupancy can return while autoPaused is set is a fresh connection, which
arrives reliably as clientEnter. New pure predicate shouldResumeOnReturn() +
_resumeIfReturning() resume iff autoPaused && paused; the resume branch routes
through handleOccupancy(1) and NEVER pauses (userCount>0), so a spurious enter
can only harmlessly resume. The bot's own enter at connect is a no-op
(autoPaused is already false).

This deliberately does NOT adopt a full event-tracked peer set: events don't
reliably seed clients already present when the bot joins, so a count-from-events
==0 would reintroduce the false-pause bug we just fixed, and reconcile can't
heal it (clientlist only works when alone). Pause must trust only the
authoritative query; resume can trust the event.

Net semantics: pause when the server is empty (bot alone), resume when someone
connects. Channel granularity is impossible with this library. UI copy updated
to say "服务器" instead of "频道", and the Settings toggle default corrected to
false to match the backend default. cmdVote intentionally left as-is.

Verified live: auto-paused bot + a real client connecting -> resume fires with
no clientlist call in the path; bot's own enter and not-auto-paused enters do
not resume. 311 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:11:43 +08:00
TIANYAO ZHANG ba11519fdb Merge pull request #97 from ZHANGTIANYAO1/fix/autopause-occupancy-unknown
fix(auto-pause): don't treat failed clientlist as empty channel; default OFF
2026-06-16 23:56:56 +08:00
saopig1andClaude Opus 4.8 d3fd547ea0 fix(auto-pause): never treat a failed clientlist as "channel empty"; default OFF
Auto-pause within the first seconds of playback (and re-pause after a manual
play) whenever a listener is actually in the channel.

Root cause (confirmed live against a TS3 server): the full-client
`clientlist -uid -away -voice -groups` command TIMES OUT when other clients are
present in the bot's channel. `getClientsInChannel()` catches the error and
returns `[]`, so the occupancy callers computed `userCount = [].length - 1 = -1`,
which `decideOccupancyAction` reads as `-1 <= 0` → "channel empty" → pause. With
the bot alone, clientlist succeeds (returns just the bot), so the bug only
surfaced when someone was listening — exactly the report.

Fix: a connected bot is always a member of its own channel, so a valid query
returns >= 1 (itself). A length of 0 therefore means the query FAILED, not that
the channel is empty. New pure helper `occupancyFromClientList()` maps a
0-length result to `null` ("occupancy unknown"); `refreshOccupancy()` and the
30s idle poller skip the auto-pause / idle-disconnect decision when the count is
unknown instead of mis-reading it as empty. This also removes a latent
false-positive idle-disconnect on the same failed query.

Also default `autoPauseOnEmpty` to OFF (occupancy detection is unreliable on
some servers); users can opt in from Settings.

Verified live with two clients in one channel: clientlist returns 0 → helper
returns null → no false pause (control: bot alone returns 1 → 0 others, normal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 23:55:44 +08:00
TIANYAO ZHANG 75f09694a3 Merge pull request #96 from ZHANGTIANYAO1/fix/song-ref-url-corner-cases
fix(song-ref): NetEase collection URLs + trailing-punct ids (#90 follow-up)
2026-06-16 22:06:41 +08:00
saopig1andClaude Opus 4.8 6d56f1f371 fix(song-ref): don't misparse NetEase collection URLs / trailing-punct ids (#90 follow-up)
Corner-case review of the #90 play-by-id parser found two reachable issues:

- A NetEase playlist/album/artist/toplist/djradio share URL (which reuses ?id=)
  was matched as a SONG id, so pasting one into !play called getSongDetail() on
  a collection id and returned a confusing 'No song found' instead of falling
  back to a normal search. Guard the id= branch to exclude collection pages.
- The id: prefix captured trailing punctuation from a chat paste ('id:12345.' ->
  '12345.'), which then failed to resolve. Strip trailing .,;)] from the id.

Both fall back to safe behavior (plain search / clean id). Tests added for
collection URLs and pasted ids with punctuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:06:17 +08:00
TIANYAO ZHANG 64328d7bdf Merge pull request #95 from ZHANGTIANYAO1/feat/play-by-id-and-search
feat(play): pick same-name songs via !search / #N / id: / URL (#90)
2026-06-16 21:52:36 +08:00
saopig1andClaude Opus 4.8 287dd240b1 feat(play): pick same-name songs via !search / #N / id: / URL (#90)
!play/!add/!playnext only ever searched with limit 1, so a same-name song could
never be reached from chat (e.g. 'Die For You' always returned the most popular
match, not The Weeknd's). Add three disambiguation paths via a shared resolver:

- !search <name> — list the top matches (numbered, with id), remembered per bot
- !play #N / !add #N — play/queue the Nth result of the last !search
- !play id:<id> and pasted NetEase/QQ/BiliBili song URLs — play an exact song

Pure parsing (parseSongRef / parseSelectionIndex) is unit-tested; plain-text
search keeps the historical top-hit behavior. WebUI search (20 results) already
allowed picking same-name songs and is unchanged.

Fixes #90

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:49:45 +08:00
TIANYAO ZHANG 22ec7328fa Merge pull request #94 from ZHANGTIANYAO1/docs/update-readme-merged-features
docs(readme): document new features (permissions/favorites/scope/auto-pause/QQ FM) + recent fixes
2026-06-16 21:26:36 +08:00
saopig1andClaude Opus 4.8 540641700d docs(readme): document permissions, favorites, scope, auto-pause, QQ FM + recent fixes
Update the README to reflect everything merged recently:
- feature list: fine-grained permissions (capabilities + per-bot allow-list),
  local favorites, dedicated-link scope, channel-empty auto-pause, QQ radar FM
- first-run + WebUI page table + !fm command (-q) + architecture tree (new modules)
- config section: config.json now lives in data/config.json (+ migration note),
  complete the example with idleTimeoutMinutes/publicUrl/trustProxy
- FAQ: fine-grained member permissions, favorites, dedicated link, auto-pause
- changelog: new entry for the feature batch + bug fixes #84/#86/#89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 21:26:08 +08:00
saopig1 3422d45eeb Merge PR #93: fix(audio) smooth, monotonic volume curve (#84)
# Conflicts:
#	src/audio/player.test.ts
#	src/audio/player.ts
2026-06-16 16:29:27 +08:00
saopig1 f7626f40b3 Merge PR #92: fix(player) recover B站 long-stream playback stalls (#89) 2026-06-16 16:26:15 +08:00
saopig1 de2c956c31 Merge PR #91: fix(config) generate config.json under the persisted data dir (#86) 2026-06-16 16:26:15 +08:00
saopig1andClaude Opus 4.8 3802c90d2d fix(audio): smooth, monotonic volume curve (#84)
applyVolume() mapped 0-100 with a two-piece, discontinuous curve: gain =
(vol/100)*0.2 for vol<100 (so the whole 0-99 range only spanned 0..0.198, making
80->99 feel flat) then a raw passthrough at vol===100 (a ~5x jump to full
loudness). That produced the reported dead zone + sudden ear-blast at 100.

Replace it with a single continuous, strictly-monotonic curve
volumeToFactor(v) = 0.2*x + 0.8*x^8 (x = v/100): 0 at 0, exactly 1.0 at 100, no
flat region and no discontinuity, so the slider feels proportional and full
loudness is still reserved at 100. Extracted as an exported pure function and
unit-tested (boundaries, strict monotonicity, dead-zone removal, no jump at 100).

Note: per the maintainer's note on #84 the >80% suppression was intentional
ear-protection; this change makes the upper range (above ~75%) audibly louder
than before in exchange for a proportional slider — applied per maintainer
decision.

Fixes #84

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:21:09 +08:00
saopig1andClaude Opus 4.8 839f777a75 fix(player): recover B站 long-stream playback stalls instead of going silent (#89)
Two issues caused a long BiliBili stream to stop partway (~16 min) and never resume:

1. FFmpeg lacked -reconnect_at_eof. B站 CDN sessions can close the connection
   mid-file (premature EOF); without this flag FFmpeg treats that EOF as
   end-of-input and stops. Added it (HTTP only) so FFmpeg re-issues a Range
   request and finishes the stream.

2. The frame loop only ended a live-but-silent FFmpeg when within 5s of the song
   end (isNearEnd). Far from the end, emptyFrameAttempts grew unbounded, no
   trackEnd was emitted, and audio went permanently silent ('无法继续播放').
   Added a far-from-end stall watchdog (MAX_STALL_ATTEMPTS ~= 60s) via a pure,
   tested shouldEndOnStall() helper, so a genuinely dead stream advances instead
   of hanging — while a transient underrun on a healthy stream is left alone.

Tests: assert -reconnect_at_eof 1 is present (before -i) for HTTP and absent for
local files; shouldEndOnStall covers near-end fast end, far-from-end no-false-skip,
and far-from-end eventual recovery.

Fixes #89

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:46:08 +08:00
saopig1andClaude Opus 4.8 dd6affca6d fix(config): store config.json under the persisted data dir (#86)
CONFIG_PATH resolved to ROOT_DIR/config.json (/app/config.json in Docker), but only
DATA_DIR (/app/data) is the mounted volume — every other artifact (DB, cookies, logs,
avatars) already lives under DATA_DIR. So on first run the default config was written
into the ephemeral image layer (never appearing in the volume), and a manually-placed
data/config.json was ignored because the bot read/wrote the root path.

- Move CONFIG_PATH to DATA_DIR/config.json so it lands in the volume and manual edits
  take effect.
- Add migrateLegacyConfig(): one-time move of an existing root-level config.json into
  the data dir, so existing local installs keep their settings (no silent reset).
- Tests for first-run persistence + the three migration cases.
- README directory tree updated to data/config.json.

Fixes #86

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:33:59 +08:00
saopig1 bea2f92508 Merge PR #80: feat(perm) fine-grained account permissions
Conflict resolution + cross-PR integration:
- player.ts: kept #88's POST /:botId/fm route AND gated it with
  requirePermission('player.control') so the new control endpoint honors #80's
  permission model (it was added without gating).
- bot.ts: kept #81's relocated /settings routes (the relocation fixes the GET
  /settings shadow bug) and dropped #80's now-duplicate bottom copy; gated
  POST /settings with requirePermission('bot.manage').
- Navbar.vue: composed #82's dedicated-link scope with #80's permission filter —
  displayedBots is now the INTERSECTION (scope ∩ controllable allow-list).
- database.ts: kept BOTH new table sets (#87 favorite_playlists + #80
  user_permissions/user_bot_access).
- bot.test.ts: updated to createRequireAuth(sessions, permissions) for #80's new
  two-arg signature.

#80 review fixes (credential exposure / IDOR, adversarially verified):
- GET /:id/config now requires bot.manage + bot access AND redacts ts6ApiKey +
  identity from the response (was readable by any authenticated member).
- GET /:id and GET /:id/avatar now require bot access (were ungated read oracles).
2026-06-16 15:05:57 +08:00
saopig1 f19f56a666 fix(favorites): error handling + state hydration + input validation [#87 review]
- addFavorite/removeFavorite now wrap axios in try/catch: a 409 (already favorited,
  common on a stale heart) or 404 resyncs instead of throwing an unhandled promise
  rejection; other errors surface a toast.
- fetchHomeData refreshes favorites BEFORE the TTL cache-return (was appended after
  the early return, so warm-cache loads never refreshed); removed the now-redundant
  trailing call. App.vue onMounted also hydrates favorites so deep-links to Search/
  Playlist show correct hearts.
- favorites API: GET /check rejects non-string (array) query params with 400 instead
  of a 500; POST defaults req.body to {} so a missing JSON body yields the intended 400.
2026-06-16 14:53:18 +08:00
saopig1 140020f63a Merge PR #87: local favorites feature
# Conflicts:
#	web/src/stores/player.ts
2026-06-16 14:50:25 +08:00
saopig1 6e10764d28 fix(qq-fm): guard FM start when offline + reset radar page on re-login [#88 review]
- startFm() now refuses with 'Bot is not connected to TeamSpeak' before mutating the
  queue, so POST /api/player/:id/fm can no longer wipe the queue and flip the bot into
  FM mode while disconnected (the !fm chat command already had this guard).
- The /fm route's success detection also treats 'not connected' as a failure so the
  toast type is correct.
- QQMusicProvider.setCookie() resets radarPage to 1 so a re-login with a different
  account no longer inherits the previous account's radar pagination cursor.
2026-06-16 14:48:01 +08:00
saopig1 9bfe831022 Merge PR #88: feat(qq) QQ Music radar / personal FM stream 2026-06-16 14:45:51 +08:00
saopig1 bbdd4cbc78 fix(autopause): decouple auto-pause toggle from idle-timeout save [#81 review]
The checkbox @change was wired to saveIdleTimeout, which POSTed BOTH idleTimeoutMinutes
and autoPauseOnEmpty: toggling silently committed an unsaved idle edit, and an empty/
non-numeric idle field made the combined POST 400 (errors swallowed), leaving the
checkbox flipped but not persisted. Give the toggle its own saveAutoPause() sending only
the boolean; 保存 now sends only idleTimeoutMinutes.
2026-06-16 14:45:01 +08:00
saopig1 c57cd35f09 Merge PR #81: feat(autopause) pause when bot channel empties 2026-06-16 14:43:54 +08:00
saopig1 1a1f365cf1 fix(scope): clear scope when the scoped bot is removed [#82 review]
removeBotStatus (botRemoved WS frame or admin deleting the scoped bot) left
scopedBotId dangling: isScoped stayed true, displayedBots went empty, and activeBot
silently fell back to bots[0], locking the UI onto a phantom bot. Clear the scope
when the scoped bot disappears.
2026-06-16 14:43:18 +08:00
saopig1 d1544bab42 Merge PR #82: feat(scope) lock UI to a bot via dedicated link 2026-06-16 14:42:32 +08:00
lTinchl e0d17cf404 feat(qq): add radar FM stream 2026-06-06 21:09:57 +08:00
Kun-ovO b2de607391 本地收藏功能 2026-05-31 23:27:02 +08:00
saopig1 355793b7e6 fix(scope): keep mounting if initial navigation errors (parity with old unconditional mount) 2026-05-30 15:25:20 +08:00
saopig1 593b42830c fix(scope): await router.isReady before mount so refreshed ?bot locks the right bot 2026-05-30 15:22:51 +08:00
saopig1andClaude Opus 4.8 463a8e2f8a feat(scope): lock Navbar selector to scoped bot + apply scope on load
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:17:01 +08:00
saopig1 88ac7d2a68 feat(scope): dedicated link seeds ?bot scope instead of bare redirect 2026-05-30 15:14:54 +08:00
saopig1 53d28de17e feat(scope): router guard syncs + preserves ?bot across navigation 2026-05-30 15:11:42 +08:00
saopig1andClaude Opus 4.8 ca07ebc3b7 feat(scope): player store scopedBotId + resolveScopedBot helper
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:09:03 +08:00
saopig1 bf1fb1fd88 docs(plan): dedicated-link bot scoping implementation plan (#79 items 2,4) 2026-05-30 15:07:26 +08:00
saopig1andClaude Opus 4.8 846fb2c28c docs(spec): dedicated-link bot scoping + refresh fix design (#79 items 2,4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:06:09 +08:00
saopig1 34655e5f50 feat(autopause): autoPauseOnEmpty toggle in Settings 2026-05-30 14:58:35 +08:00
saopig1andClaude Opus 4.8 491bc53dec feat(autopause): expose autoPauseOnEmpty via /api/bot/settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:55:18 +08:00
saopig1 8f5bb26b3a feat(autopause): re-emit client enter/leave/move for instant pause/resume 2026-05-30 14:50:52 +08:00
saopig1andClaude Opus 4.8 4ba4b013b0 feat(autopause): drive pause/resume from channel occupancy in BotInstance
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:46:50 +08:00
saopig1 484202e90d feat(autopause): pure occupancy-decision function 2026-05-30 14:44:28 +08:00
saopig1 9f0ac74fbc docs(plan): auto-pause on empty channel implementation plan (#79 item 3) 2026-05-30 14:43:38 +08:00
saopig1andClaude Opus 4.8 51c954993a docs(spec): auto-pause on empty channel design (#79 item 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:42:13 +08:00
saopig1andClaude Opus 4.8 907a6651f5 fix(perm): access-check before bot-existence (no 403/404 leak); label permissions audit action
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:12:04 +08:00
saopig1andClaude Opus 4.8 1ca1ca9d0c test(perm): assert /me capabilities+bots; dry backfill token list
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:06:59 +08:00
saopig1andClaude Opus 4.8 d70664067c feat(perm): admin permission editor in user management
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:03:54 +08:00
saopig1 5177b41951 feat(perm): gate Queue.vue remove/clear/play-at controls by capability 2026-05-30 14:00:27 +08:00
saopig1 bb86f7e9ed feat(perm): gate idle-timeout + bot-profile settings on bot.manage 2026-05-30 13:56:46 +08:00
saopig1andClaude Opus 4.8 221f7c8dcf feat(perm): hide UI a member lacks capability for
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:55:31 +08:00
saopig1 cf76e0f69a feat(perm): frontend session capabilities + can()/canControlBot() 2026-05-30 13:51:34 +08:00
saopig1andClaude Opus 4.8 abf60141d9 feat(perm): one-time backfill of existing members to full access
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:47:44 +08:00
saopig1andClaude Opus 4.8 ce15f36e5e feat(perm): admin permissions API + audit + new-member basic tier
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:44:12 +08:00
saopig1andClaude Opus 4.8 1f0f162f66 feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:38:57 +08:00
saopig1andClaude Opus 4.8 cd6f2c6078 feat(perm): enforce capabilities + bot access on action routes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:32:08 +08:00
saopig1andClaude Opus 4.8 696b224f8d feat(perm): load capabilities + bot access onto req.user; expose via /me
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:24 +08:00
saopig1 7a8666efbb feat(perm): resolvePermissionContext (admin = super-user) 2026-05-30 13:17:06 +08:00
saopig1 f0c979ce71 docs(spec): use 'capabilities' consistently for req.user field 2026-05-30 13:15:44 +08:00
saopig1 d810a2ec0f test(perm): cover requireBotAccess 401 + missing-param cases 2026-05-30 13:15:01 +08:00
saopig1andClaude Opus 4.8 554501cc74 feat(perm): requirePermission + requireBotAccess middleware
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:10:39 +08:00
saopig1andClaude Opus 4.8 aaf6ba2ab4 feat(perm): permission store + capability tokens + tables
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:05:38 +08:00
saopig1andClaude Opus 4.8 547aaa304e docs(plan): account permissions implementation plan (#79-E)
11 TDD tasks: permission store + tables, requirePermission/requireBotAccess, req.user wiring, route enforcement, bot-list filtering, admin API + audit, one-time member backfill, and frontend gating + permission editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:49:57 +08:00
saopig1andClaude Opus 4.8 f09a589940 docs(spec): fine-grained account permissions design (#79-E)
Capability flags (player.control/player.queue/bot.manage/platform.auth/quality) + per-member bot allow-list, layered under the existing member role; admin is super-user. Backend-enforced via requirePermission/requireBotAccess; existing members backfilled to full on upgrade, new members get a basic tier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:44:24 +08:00
TIANYAO ZHANG a861b41809 Merge pull request #78 from ZHANGTIANYAO1/feat/shuffle-bag-random-modes
feat(queue): 随机循环改为洗牌袋,每首歌播完一轮再重复 (优化随机循环逻辑)
2026-05-29 22:16:35 +08:00
saopig1andClaude Opus 4.8 e9b3ba0075 feat(queue): shuffle-bag random modes so every song plays before repeating
随机循环 (rloop) used true random-with-replacement, so some songs repeated constantly while others were starved (issue #70). Both random modes now draw from a shuffle bag: every song plays exactly once per cycle in random order. They differ only at cycle end — 随机 (random) stops, 随机循环 (rloop) reshuffles and continues, excluding the just-played song from the first pick of the new cycle to avoid a back-to-back repeat across the boundary. Songs added mid-cycle stay eligible within the current cycle.

随机's visible behavior is unchanged (it already avoided in-cycle repeats); the two branches now share one selection path. Adds shuffle-bag tests (per-cycle permutation, even distribution, no cross-boundary repeat, mid-cycle add).

Closes #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:08:40 +08:00
TIANYAO ZHANG 0401534b88 Merge pull request #77 from ZHANGTIANYAO1/fix/webui-referrer-policy-csrf
fix(web): referrer-policy same-origin 修复扫码登录不弹二维码 + cookie 无法保存
2026-05-29 21:30:35 +08:00
saopig1andClaude Opus 4.8 f720da49d6 fix(web): referrer-policy same-origin so same-origin POSTs keep a real Origin
no-referrer downgraded the Origin header to the literal "null" on same-origin non-GET requests (per the WHATWG Fetch "Append a request Origin header" algorithm), which the /api/* csrfOriginCheck then rejected with 403 "bad origin" — silently breaking QR login, cookie save, and every other WebUI POST/PUT/DELETE/PATCH (playback, bot management, user admin). /api/session/* was unaffected because it mounts before the CSRF gate, which is why WebUI login still worked.

same-origin keeps the real Origin on same-origin requests (CSRF passes) while still sending no Referer cross-origin, so B站/NetEase/QQ CDN cover thumbnails keep loading. Adds referrer-policy.test.ts pinning the policy and a csrf.test.ts case for the Origin: "null" rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:25:20 +08:00
TIANYAO ZHANG 3abb468cca Merge pull request #75 from ZHANGTIANYAO1/fix/ui-overflow-and-textarea-resize
fix(web): long artist + B站 card grid + B站 image referer
2026-05-27 20:10:21 +08:00
saopig1andClaude Opus 4.7 c8daa14219 fix(web): set no-referrer at document level so B站 cover thumbnails load
Bilibili's CDN (i*.hdslb.com) returns 403 with `x-error-info:
RefererWhite` for image requests whose Referer is not on their
whitelist. `CoverArt.vue` already sets `referrerpolicy="no-referrer"`
on its `<img>` tag, BUT the `.cover-shadow` div renders the same URL
as a CSS `background-image`, which ignores the img attribute and uses
the document default policy (`strict-origin-when-cross-origin` in
modern Firefox/Chrome) — that sends `Referer: http://localhost:3000/`
and triggers the block.

Setting `<meta name="referrer" content="no-referrer">` in index.html
applies no-referrer site-wide: covers <img> tags, CSS background-image
fetches, and anywhere else CDNs check referer. Doesn't affect our
/api/* CSRF middleware because that uses Origin (still sent by the
browser), not Referer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:07:18 +08:00
saopig1andClaude Opus 4.7 81cd8a2bec fix(web): revert textarea + actual culprit was B站热门 card grid
Previous commit misidentified the second bug. Reverting the
Settings.vue `resize: vertical` → `resize: none` change — that
wasn't the issue.

Real fix: `.daily-card` (used by B站热门 and 每日推荐 sections in
Home.vue) is a CSS Grid cell with default `min-width: auto`, which
refuses to shrink below its content. A long Bilibili video title
inside `.daily-name` expanded the cell past its 1fr column, breaking
the 6-column grid and creating empty/black space on the right. The
existing `text-overflow: ellipsis` on `.daily-name` couldn't engage.

Adding `min-width: 0` to `.daily-card` lets the cell shrink to the
1fr grid track size, and the ellipsis truncation now works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:20:46 +08:00
saopig1andClaude Opus 4.7 35210cf570 fix(web): long artist name overflow + textarea resize artifact
- Player.vue: wrap artist text in a span with ellipsis. The previous
  text node sat directly inside the flex `.song-artist` container with
  no overflow handling, so a long author name expanded the container
  past its 240px parent and broke the bottom Player bar layout. Also
  add `min-width: 0 + overflow: hidden` to `.song-info` and
  `.song-artist`, and a `:title` attribute for the full text on hover.

- Settings.vue: change `resize: vertical` on the cookie textareas
  to `resize: none`. The browser's resize grip rendered as a stray
  black triangle at the bottom-right corner in dark theme, and
  dragging it caused visual artifacts on the right edge. The
  textareas keep their `rows="3"` default height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:14:26 +08:00
TIANYAO ZHANG d2bad58aa8 Merge pull request #74 from ZHANGTIANYAO1/feat/webui-auth
Add WebUI authentication: multi-user, roles, audit log
2026-05-27 16:46:03 +08:00
saopig1 f73ca1f61b docs: README updates for WebUI auth feature + pre-auth upgrade guide 2026-05-27 16:40:41 +08:00
saopig1andClaude Opus 4.7 a0f290459d feat(auth): X-Frame-Options + CSP frame-ancestors clickjacking defence
Every response now carries:
  X-Frame-Options: DENY
  Content-Security-Policy: frame-ancestors 'none'

Prevents the WebUI from being embedded in a third-party iframe.
Combined with the existing CSRF Origin-host check, this closes the
last meaningful UI-redress surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:34:52 +08:00
saopig1 b6b9aa07bc feat(auth): atomic session cap + change-password UI + trustProxy docs 2026-05-27 16:29:16 +08:00
saopig1 a39fc25104 feat(auth): rate-limit /login+/setup, per-user session cap, periodic /me poll 2026-05-27 16:16:07 +08:00
saopig1 1a11489f2e fix(auth): atomic last-admin guards on role-change and delete 2026-05-27 15:55:11 +08:00
saopig1andClaude Opus 4.7 c0504d65a5 fix(web): localize user.role_changed audit label
Adds the missing case so role-change entries display in Chinese
instead of falling through to the generic key→target format.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:43:09 +08:00
saopig1 780726a4e3 feat(web): role-aware UI (badge, selector, toggle button, hide admin-only sections for members) 2026-05-27 15:38:42 +08:00
saopig1andClaude Opus 4.7 a73f797bcb feat(auth): two-role permission system (admin/member)
Adds an admin/member role to WebUI auth. /api/users and /api/audit
are now gated by a requireAdmin middleware; all other authenticated
endpoints accept both roles. Schema migration defaults all existing
users to admin to preserve access. POST /api/users defaults new users
to member; first-run setup always creates an admin. Adds PATCH
/api/users/:id/role with last-admin demotion and deletion guards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:35:15 +08:00
saopig1 b0b61f8fce fix(auth): defensive audit-record + self-reset preserves current session 2026-05-27 15:16:55 +08:00
saopig1 7be4f13774 feat(web): operation audit log section in Settings 2026-05-27 15:06:54 +08:00
saopig1andClaude Sonnet 4.6 6af0e97f51 feat(auth): user-management audit log (table + record sites + /api/audit endpoint)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 15:04:19 +08:00
saopig1andClaude Sonnet 4.6 ceb24595e6 fix(auth): race-safe first-run setup + rolling cookie max-age refresh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:54:19 +08:00
saopig1andClaude Sonnet 4.6 fb7feec5cf feat(web): user management section in Settings (list/create/delete/reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:44:42 +08:00
saopig1andClaude Sonnet 4.6 175b8e6065 feat(auth): add /api/users CRUD (list, create, delete, reset-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 14:41:58 +08:00
saopig1andClaude Opus 4.7 f46b37192f fix(web): break infinite recursion in apiFetch by capturing nativeFetch
apiFetch called window.fetch which installApiClient had reassigned to
call apiFetch — every request blew the stack. Capture the native fetch
at module load (before any wrap) and use it inside apiFetch.

Symptom: first-run / login redirect never fires because the router
guard hangs on session.refresh().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:34:56 +08:00
saopig1 a8b056d2aa fix(auth): WS Origin host check + Login next-param open-redirect guard 2026-05-27 14:02:33 +08:00
saopig1andClaude Sonnet 4.6 e148c1556e feat(web): show current user + logout button in nav
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:57:31 +08:00
saopig1 e2e888710a fix(web): exclude /api/session/* from 401 auto-refresh to prevent re-entrancy 2026-05-27 13:56:14 +08:00
saopig1andClaude Sonnet 4.6 7509814abc feat(web): install global fetch wrapper with credentials + 401 handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:54:24 +08:00
saopig1andClaude Sonnet 4.6 0dc8746914 feat(web): add /first-run + /login routes with auth guard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:52:49 +08:00
saopig1 2560fc87c2 feat(web): add Login view 2026-05-27 13:51:20 +08:00
saopig1 6db35f704d feat(web): add useSession composable 2026-05-27 13:50:14 +08:00
saopig1andClaude Sonnet 4.6 490b2d57dc test(auth): verify ws upgrade gating end-to-end
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:49:05 +08:00
saopig1andClaude Sonnet 4.6 486979841e feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:46:59 +08:00
saopig1andClaude Sonnet 4.6 ee5673a22f feat(auth): add /api/session router (setup, login, logout, me, change-password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:42:00 +08:00
saopig1andClaude Sonnet 4.6 d1c9e14bf0 feat(auth): add csrfOriginCheck middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:38:35 +08:00
saopig1andClaude Sonnet 4.6 17c11f0512 feat(auth): add requireAuth middleware
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:36:26 +08:00
saopig1 df5d125279 feat(auth): add shared validateSessionFromHeaders helper 2026-05-27 13:34:47 +08:00
saopig1andClaude Sonnet 4.6 5914f41ea1 feat(auth): add SessionStore with rolling renewal and at-rest token hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:33:22 +08:00
saopig1 68a2fb2943 refactor(users): use SQLITE_CONSTRAINT_UNIQUE error code instead of message text 2026-05-27 13:31:31 +08:00
saopig1andClaude Sonnet 4.6 34523cb00f feat(auth): add UserStore with bcryptjs password hashing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:29:39 +08:00
saopig1andClaude Sonnet 4.6 8ea1a64c59 feat(db): add users and sessions tables for WebUI auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:27:12 +08:00
saopig1 df79976933 deps: add bcryptjs + cookie-parser + supertest for WebUI auth 2026-05-27 13:25:32 +08:00
saopig1andClaude Opus 4.7 d3af918f53 docs(plan): WebUI authentication implementation plan
16 bite-sized tasks with TDD discipline:
- 10 backend (schema, users, sessions, middleware, /api/session router,
  server.ts wiring, WS upgrade gating + integration test)
- 5 frontend (useSession composable, Login + FirstRunSetup views,
  router guard, fetch wrapper, Navbar logout)
- 1 manual smoke test gate before PR

Notes /first-run as the admin-setup route since /setup is already taken
by the bot-creation wizard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:18:10 +08:00
saopig1andClaude Opus 4.7 f7c16888e7 docs(spec): WebUI authentication design
Spec for adding username+password auth to the WebUI to close the
unauthenticated-API exposure (all /api/* and /ws currently open).

Design: SQLite users + sessions tables, bcryptjs, 7-day rolling
HTTP-only cookie sessions, first-run setup wizard, Origin/Referer
CSRF check, WebSocket upgrade gated on the same session cookie.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:07:51 +08:00
TIANYAO ZHANG a2982948a7 Merge pull request #71 from EvolvedGhost/main
fix(player): 歌曲结尾后持续卡死不切换下一首歌
2026-05-25 18:56:14 +08:00
EvolvedGhost b7e1f9f30b fix(player): add force trackEnd when pcmBuffer less than PCM_FRAME_BYTES 2026-05-23 22:08:52 +08:00
TIANYAO ZHANG 7fc5186c24 Merge pull request #65 from ZHANGTIANYAO1/fix/bilibili-wbi-search
fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
2026-05-16 22:31:55 +08:00
saopig1andClaude Opus 4.7 de92c8bcd4 fix(bilibili): wbi-sign search params — legacy /search/type now anti-bot blocked
Closes #64. Bilibili moved the unsigned /x/web-interface/search/type endpoint
behind their anti-bot wall; it now returns an HTML error page (出错啦!) even
with buvid3+buvid4 cookies, causing `play -b` to report "No results found".

Switch search() to /x/web-interface/wbi/search/type with proper wbi signing:
fetch img_key/sub_key from /nav, derive the mixin key via the standard
permutation, and sign each request with wts + w_rid (md5). Keys are cached
for 6h since they rotate ~daily. Other endpoints (view, playurl, popular,
top/rcmd) still work unsigned and are left unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:29:12 +08:00
TIANYAO ZHANG 6b143e1a56 Merge pull request #63 from XuVIIJay/pr/setup-scripts
安装脚本优化(对国内网络环境安装友好)
2026-05-16 02:02:10 +08:00
XuVIIJay 5728209573 fix(setup.sh): add Node.js and npm version check before install 2026-05-15 21:03:28 +08:00
XuVIIJay 02d8b39d75 fix(setup.bat): remove hardcoded personal Node.js paths 2026-05-15 20:56:59 +08:00
XuVIIJay f87aaaf8c3 feat(scripts): optimize setup/start scripts for China network 2026-05-15 20:42:51 +08:00
TIANYAO ZHANG 8861f39ecc Merge pull request #62 from NoSetViolin/main
fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
2026-05-14 22:44:41 +08:00
NoSetViolinandClaude Opus 4.7 6d5755ced7 feat(search): source filter bar, album search, and UX polish
- Add platform source filter bar (网易云/QQ/B站) above category tabs with
  localStorage persistence to remember user preference
- Remove "全部" option, single-source view only
- Increase album/playlist card column-gap to 28px for better spacing
- Sync search query to URL via router.replace so back-navigation from
  album/playlist detail pages restores search results
- Fix !album command: add name-based album search (matching !playlist
  behavior) so "!album APT." searches by name instead of treating it as ID

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:14 +08:00
NoSetViolinandClaude Opus 4.7 997bb17ceb feat(search): add tabbed category navigation + align NetEase result counts
- Replace single-page layout with pill-slider tabs (单曲/专辑/歌单) under
  the search box, showing only one category at a time with result counts
- NetEase album/playlist search limit raised from 5 to 10 to match QQ

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:56:18 +08:00
NoSetViolinandClaude Opus 4.7 ea6501ea81 feat(web): add platform badges to album and playlist cards in search
Album and playlist results now show source tags (网易云, QQ, B站, YouTube)
matching the existing SongCard platform badge style.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:34:26 +08:00
NoSetViolinandClaude Opus 4.7 1d2da33d3c fix(qq): apply musicu.fcg upstream fixes + add playlist search
Per https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/61:
- Remove searchid param (its presence now causes empty results)
- Enforce num_per_page >= 10 (lower values return empty)
- Fix search_type: 2 for albums, 3 for playlists (8 was "user")

Now uses the fixed musicu.fcg as primary (supports songs + albums +
playlists), with client_search_cp as fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 16:28:12 +08:00
阿梓喵_あずにゃんandClaude Opus 4.7 0e68e287b7 fix(qq): switch search to c.y.qq.com client_search_cp with u.y.qq.com fallback
u.y.qq.com/cgi-bin/musicu.fcg started returning is_filter=-9 (empty results)
for unauthenticated requests. Primary search now uses the classic
c.y.qq.com/soso/fcgi-bin/client_search_cp endpoint, with the old musicu.fcg
path kept as a fallback in case the primary endpoint changes format or goes
down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:55:21 +08:00
TIANYAO ZHANG ecdb2d253e Merge pull request #60 from XuVIIJay/feat/forward-stack
随机播放模式双向可回溯
2026-05-12 22:23:35 +08:00
XuVIIJay 8860347bfe Update README.md 2026-05-12 00:04:20 +08:00
XuVIIJay beb99f1d8e Update README.md 2026-05-11 23:49:34 +08:00
XuVIIJay 63f1ced2bc feat(queue): add forwardStack for reversible prev/next navigation
In Random/RandomLoop modes, prev now records the current position
to a forwardStack, and next pops from it first. This ensures prev→next
navigation is fully reversible for any number of steps (up to 50).

Also rebuild playedIndices in prev() so songs reached via backward
navigation become available for random selection again.
2026-05-11 23:45:04 +08:00
saopig1andClaude Opus 4.7 fa6013d22e docs(commands): surface existing !remove <position> in help and README
Closes #59. The command was already implemented but not advertised in
!help output or the README command table, so users assumed it was missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:56:49 +08:00
saopig1andClaude Opus 4.7 6b60792277 feat(web): custom avatar field in edit-bot modal
User reported that the edit-bot dialog had no avatar option (only
create-bot did). Reuses the same CustomAvatarRow component, which
auto-loads on mount and PUT/DELETEs on change. Each bot's avatar
is bound by botId — independent across bots.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:23:21 +08:00
saopig1andClaude Opus 4.7 c562fe05b0 fix(player): start frame loop only after ffmpeg spawns in jdymusic path
PR #54's playViaPowerShellDownload called startFrameLoop() right after
spawning the PowerShell downloader, before ffmpeg existed. The loop's
"no ffmpeg + empty buffer → emit trackEnd" branch fired on the very
first tick (~25ms), skipping every jdymusic song. Visible as: each
PowerShell download sessionId logged "Track ended, advancing queue"
before the download completed, so the queue burned through every
track in a few seconds.

Move startFrameLoop() into spawnFfmpegFromFile() where ffmpeg is
known to be alive and producing PCM. state = "playing" still flips
in playViaPowerShellDownload so external observers see the right
status during download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:04:27 +08:00
TIANYAO ZHANG 9efa818bbb Merge pull request #57 from ZHANGTIANYAO1/feat/album-search
feat(search): album section + album playback
2026-05-07 20:50:02 +08:00
TIANYAO ZHANG d757e69bf4 Merge pull request #56 from ZHANGTIANYAO1/feat/custom-bot-avatar
feat(profile): custom bot avatar
2026-05-07 20:49:58 +08:00
saopig1andClaude Opus 4.7 88ff62c829 fix(profile): apply custom avatar immediately on idle setCustomAvatar / connect
Review feedback on PR #56:

1. setCustomAvatar(buf) now triggers applyIdleAvatar when the bot is idle
   (currentSong=null OR avatarEnabled=false). Spec said this; original impl
   only stored the buffer, so a fresh upload from Settings was invisible
   until next stop event. Track currentSong in BotProfileManager for the
   idle check.

2. onConnect drops the !avatarEnabled guard — on a fresh connect there's
   no song playing yet, so the spec matrix wants the custom avatar shown
   regardless of sync. Previously bots reconnected with a stale TS3
   server-side avatar.

3. CustomAvatarRow: defer the initializing=false flip to nextTick so the
   load-time data-url assignment's queued watcher sees initializing=true
   and bails. Removes the redundant PUT-on-mount that echoed the just-
   loaded bytes back to the server.

4. BotInstance avatar load wrapped in try/catch — a corrupt/locked file
   no longer crashes startup; we log and continue with no custom avatar.

Tests rewritten: 10 cases covering the full behavior matrix
(idle vs playing × sync on/off × custom set/null × stop/connect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:40:00 +08:00
saopig1andClaude Opus 4.7 8cccf2ed24 fix(web): album hero shows real album name from songs[0].album
The album detail endpoint intentionally 404s (no /api/music/album/:id/detail
route), so we fall through to the stub built from songs. The album name is
already on every song (Song.album), so use it instead of the literal "专辑".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:35:35 +08:00
saopig1andClaude Sonnet 4.6 f6b82b8e21 feat(web): show album + playlist sections in search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:26:03 +08:00
saopig1andClaude Sonnet 4.6 51d7ce61bd feat(web): /album/:id route reusing Playlist view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:24:20 +08:00
saopig1andClaude Sonnet 4.6 20f4cfacea feat(api): /search/all returns albums and playlists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:21:07 +08:00
saopig1andClaude Sonnet 4.6 7d9081efc1 feat(qq): include albums in search results
Add mapQqAlbums pure helper (exported, TDD-covered) and wire a parallel
req_album sub-request (search_type: 8) into search(), populating the
albums field of SearchResult.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:20:05 +08:00
saopig1andClaude Sonnet 4.6 a2453784c1 feat(netease): include albums in search results
Add mapNeteaseAlbums pure helper and wire cloudsearch?type=10 as the third Promise.all arm in search(), replacing the hardcoded albums:[].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:17:46 +08:00
saopig1andClaude Sonnet 4.6 0f9c7b3c4c feat(web): custom avatar in create-bot + Settings
Adds AvatarUpload to the create-bot form (PUT on new bot id after POST)
and a CustomAvatarRow per-bot in the profile-toggles section (GET on
mount, PUT/DELETE on user action with initializing guard).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:14:34 +08:00
saopig1andClaude Sonnet 4.6 914180792d feat(web): AvatarUpload component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:11:43 +08:00
saopig1andClaude Sonnet 4.6 935656dc4f feat(api): /api/bot/:id/avatar GET/PUT/DELETE
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:10:06 +08:00
saopig1andClaude Sonnet 4.6 2dd6a9e20d feat(bot): load custom avatar on instance startup
Thread AvatarStore from index.ts → BotManager → BotInstance so every
BotInstance reads the persisted custom avatar from disk at construction
time and hands it to BotProfileManager via setCustomAvatar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:07:47 +08:00
saopig1andClaude Sonnet 4.6 ffa27d7224 feat(profile): custom avatar with idle/playback precedence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:05:00 +08:00
saopig1andClaude Sonnet 4.6 edd0fc58eb feat(data): avatar file store helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:00:56 +08:00
saopig1andClaude Sonnet 4.6 366edf7843 feat(db): custom_avatar_path column + accessors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 19:59:20 +08:00
saopig1andClaude Opus 4.7 ff5502be2f docs(plan): custom avatar + album search implementation plans
Two independent plans for spec 2026-05-07; each yields its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:55:52 +08:00
saopig1andClaude Opus 4.7 4407cd0c67 docs(spec): custom bot avatar + album search design
Spec for issue #51: custom avatar with idle/playback precedence rules,
and surfacing albums in search results + album detail playback. Two
features grouped in one spec; will land as two separate PRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:49:53 +08:00
TIANYAO ZHANG b8c12e0291 Merge pull request #53 from L1uyQwQ/codex/mobile-responsive-controls
fix: improve mobile web responsive controls
2026-05-07 19:39:51 +08:00
TIANYAO ZHANG e7411ee7fb Merge pull request #55 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua
fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
2026-05-07 19:37:58 +08:00
TIANYAO ZHANG ce88d12a60 Merge pull request #54 from ZHANGTIANYAO1/fix/jdymusic-powershell-fallback
fix(player): WinHTTP (PowerShell) fallback for /jdymusic/ CDN
2026-05-07 19:18:02 +08:00
saopig1andClaude Opus 4.7 719ceae303 fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:58 +08:00
saopig1andClaude Opus 4.7 52ece9321f fix(player): WinHTTP fallback for /jdymusic/ CDN that drops Node TCP
The old jdymusic CDN (e.g. m701/m801.music.126.net/.../jdymusic/obj/...)
intermittently drops connections from Node's HTTP stack — even with a
browser UA — while the same URL fetched via WinHTTP works. Newer paths
(/jd-musicrep-ts/, /ymusic/) do not have this restriction.

On Windows, dispatch /jdymusic/ URLs through `System.Net.WebClient` in
PowerShell into a temp file, then run ffmpeg against the file. Other
URLs and non-Windows platforms keep the direct ffmpeg + browser-UA path
from the previous commit. Also fix buildFfmpegArgs to omit HTTP-only
flags (-reconnect_*, -headers) when the input is a local file path —
new ffmpeg builds reject these as "Option not found".

stop() now also kills any in-flight PowerShell downloader and removes
the temp dir, so cancellation is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:08:00 +08:00
L1uyQwQ 0bf34d8652 fix mobile web responsive controls 2026-05-07 16:58:31 +08:00
TIANYAO ZHANG 9179780afb Merge pull request #52 from ZHANGTIANYAO1/fix/netease-ffmpeg-ua 2026-05-07 16:33:25 +08:00
saopig1andClaude Opus 4.7 f76500cfb2 fix(player): set browser UA + Referer for Netease CDN to stop auto-skip
Netease's m701/m801.music.126.net CDN RST connections from FFmpeg's
default Lavf UA. The bot's frame loop treats an early ffmpeg exit as
trackEnd and advances the queue, which surfaced as songs auto-skipping
mid-playlist. Add a browser UA + music.163.com Referer for these URLs,
extract args into a tested buildFfmpegArgs(), and harden reconnect
flags (delay_max 5 -> 30, plus reconnect_on_network_error /
reconnect_on_http_error). Verified by A/B running ffmpeg against the
same fresh CDN URL: legacy args got 0 bytes + "End of file" reading
HTTP response; fixed args streamed the full track to completion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:18:23 +08:00
saopig1andClaude Opus 4.7 7eb96d9068 ci: publish multi-arch Docker image to GHCR on tag push
Add GitHub Actions workflow that builds linux/amd64 + linux/arm64
images via buildx + QEMU and pushes to ghcr.io/zhangtianyao1/teamspeak-music-bot
on v*.*.* tag pushes (or manual dispatch). Switch docker-compose.yml
to pull the prebuilt image so NAS / non-build environments can deploy
without a local toolchain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:21:37 +08:00
TIANYAO ZHANG 001fb5a451 Merge pull request #50 from ZHANGTIANYAO1/feature/design-implementation
Web UI redesign + per-platform tabs + play-next + audit fixes
2026-05-06 17:09:59 +08:00
saopig1andClaude Opus 4.7 73f3f7749a fix: cmdPrev retry-skip + show SongCard actions on touch devices
Two corner-case fixes for the prev-history + play-next feature:

1. cmdPrev only tried queue.prev() once. instance.playNext's auto-
   advance retry-skip pushes failed songs into the same history stack,
   so a single prev frequently lands on an unplayable song — returning
   "Cannot play previous song" while leaving queue.currentIndex stuck
   mid-failure (causing next() to skip past the actually-playing song).
   Retry up to 4 times so prev finds a playable history entry, matching
   the retry budget already used by playNext for auto-advance.

2. SongCard.song-actions has opacity:0 by default and is revealed via
   parent :hover. Touch devices have no hover, so all three action
   buttons (Play / Play Next / Add) were invisible to phone/tablet
   users. Add @media (pointer: coarse) → opacity:1 to always show on
   touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:06:18 +08:00
saopig1andClaude Opus 4.7 fab8c194e3 fix(player): play-next must use insertedAt, not size-1, when idle
Final review of caeef65 caught a stale-currentIndex bug in
/play-next-song and cmdPlayNext: when the player is idle but
queue.currentIndex >= 0 (natural end-of-track, or playNext gave
up after retries without queue.clear), addNext splices mid-queue
and playAt(size-1) jumps PAST the inserted song to whatever
was last. Capture the insertion slot before calling addNext and
promote that exact index instead.

Add a regression test covering the [a,b,c,d] queue with
currentIndex=1 case -- splice at 2 yields x at index 2, but
size-1 would point at d (index 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:00:05 +08:00
saopig1andClaude Opus 4.7 caeef65cdb feat(web): wire @playNext on all SongCard call sites
Library / Search / History / Playlist now route the third action to
store.playNextSong. Home is unchanged (it doesn't use SongCard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:43:10 +08:00
saopig1andClaude Opus 4.7 ad4fb5d3c7 feat(web): third 'play next' action on SongCard
Button sits between Play and Add to queue. Icon is mdi:playlist-play,
title '下一首播放'. Emits 'playNext' for callers to wire up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:41:47 +08:00
saopig1andClaude Opus 4.7 44b0b5c31c feat(web): playNextSong store action
Posts to /play-next-song, surfaces failures via the existing Toast,
and refreshes the queue panel so the inserted song appears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:40:12 +08:00
saopig1andClaude Opus 4.7 3e795d9a83 feat: !playnext command and /play-next-song endpoint
Mirror of !play / /play-song but uses queue.addNext to splice in at
currentIndex+1 instead of clearing the queue. Idle bot still starts
the song immediately. Adds 'playnext' / 'pn' to AUDIO_COMMANDS, the
command switch, and the help text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:37:52 +08:00
saopig1andClaude Opus 4.7 11c6a3f51b feat(queue): addNext inserts a song to play right after current
Splices into currentIndex+1 and shifts both playedIndices and the
history back-stack to keep references valid. Falls through to plain
push when nothing is playing so the idle-bot "add → start playing"
flow is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:34:32 +08:00
saopig1andClaude Opus 4.7 30fd8a19b8 fix(queue): restore playedIndices clear in playAt; test HISTORY_LIMIT
Code review of 390d3fa flagged that dropping playedIndices.clear() from
playAt is an unnecessary behavior change. The two operations (push
history, reset random pool) are not in tension — restoring the clear
preserves the original 'explicit pick restarts shuffle' semantic that
Random mode users rely on, while still tracking the prev-history stack.

Also add a regression test that the 50-entry HISTORY_LIMIT cap works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:32:23 +08:00
saopig1andClaude Opus 4.7 390d3fa782 feat(queue): history-aware prev that walks real play history
In random modes, prev was just doing currentIndex-1 in the array, which
has no relationship to what the user actually played before. Add a
50-entry back-stack that's pushed by next/playAt, popped by prev, reset
on play/clear/setMode, and shifted by remove. Sequential and Loop modes
keep their old fallback for the case where history is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:28:52 +08:00
saopig1andClaude Opus 4.7 c4b7cfaa2f refactor(queue): add history field and pushHistory helper
Inert in this commit — no callers yet. Sets up the back-stack used
by the upcoming history-aware prev rewrite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:24:52 +08:00
saopig1andClaude Opus 4.7 94c60ad465 docs: implementation plan for history-aware prev + play next
Eight tasks: queue history field/helper, history-aware prev with
TDD tests, addNext with TDD tests, REST + command surface, store
action, SongCard third button, view wiring, final verify + smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:10:57 +08:00
saopig1andClaude Opus 4.7 58bdfb94a9 docs: spec for history-aware prev + play-next insert
Two queue features:
- prev walks back through actual play history (50-entry stack), so
  random modes navigate predictably instead of falling back to the
  meaningless currentIndex-1 array walk.
- addNext inserts at currentIndex+1, exposed via new /play-next-song
  endpoint, !playnext command, and a third "下一首播放" button on
  SongCard. Insert path keeps playedIndices and history index refs
  valid by shifting entries > currentIndex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:06:56 +08:00
saopig1andClaude Opus 4.7 59fb3ee3bd fix: address all known follow-up issues except NetEase batch precheck
Surface failures + tighten edges:

1. Toast for /play-song & /play-playlist failures. Backend now returns
   {ok, message} (localized in Chinese to match the rest of the UI).
   Store stashes a notification on ok=false; new Toast.vue mounted in
   App.vue shows it for 3-5s then fades. Clicking the X dismisses
   immediately. Sits above the player on desktop and above the mobile
   tabbar on phones.

2. QQ collected playlists pagination. fcg_get_profile_order_asset.fcg
   returns max 30 per call; we now loop using has_more / short-page
   detection up to a 300-playlist hard cap. Single-call users (typical)
   exit the loop on the first iteration so no extra requests.

3. getPlayableSongIds chunking. 100 mids per request keeps URL well
   under 8KB; chunk-level errors are isolated so a transient blip on
   one chunk doesn't poison the whole batch. Returns null only when
   every chunk failed (caller falls back to sequential retry).

4. SourceTabs single-source mode now renders a small subdued "网易云"
   or "QQ" label instead of vanishing entirely, so the user always
   knows which platform's data they're looking at.

5. Hide the "我的歌单 N" count badge when N=0 — Home and Library no
   longer show "我的歌单 0" with an empty grid.

6. Auth state change invalidates fetchHomeData cache. Previously, a
   user who logged out as account A and into account B within 5
   minutes would see A's playlists. Now we always re-check auth at
   the top of fetchHomeData and bypass the TTL cache when authStatus
   has changed.

Out of scope:
- NetEase analogous batch precheck (per request).
- 60s TS3 UDP idle disconnect — that's the bundled @honeybbq/teamspeak-
  client UDP layer kicking when no server packet arrives in 60s. It's
  baked in (constant `v=6e4`) and not exposed as an option, and root
  cause is server-side or network-layer behavior we can't reach from
  here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:00:48 +08:00
saopig1andClaude Opus 4.7 221079b89c fix: more corner-case audit — partial-failure UX + defensive parsing
Three small reliability fixes from re-auditing:

1. Playlist.vue used Promise.all, so a flaky /detail endpoint would
   blank out the whole page even though /songs returned just fine.
   Switch to allSettled and synthesize a stub playlist header from
   the song list when only detail fails. User can still play the
   playlist; just loses the description/cover.

2. sourceTabs.readAll: typeof null === 'object' AND typeof [] ===
   'object', so a corrupted localStorage value (e.g. an array) would
   be treated as a record and its missing keys would silently fall
   back. Reject explicitly so the failure mode is "clean defaults"
   instead of "wrong shape that almost works".

3. Settings.vue loadProfileConfig: a 200 response with empty/wrong
   body would set profileConfigs[botId] to a falsy/wrong-shape value,
   leaving the row stuck on "加载中..." (because the v-if uses
   !profileConfigs[id]). Validate the shape; surface "响应格式异常"
   so the retry link is reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:52:49 +08:00
saopig1andClaude Opus 4.7 892d9f7959 fix: corner-case audit — distinguish failures, recover from blips
Three small but real correctness fixes from auditing recent commits:

1. getPlayableSongIds returned an empty Set for both "endpoint failed"
   and "succeeded but all unplayable" — the caller couldn't tell which.
   Return Set | null now: null = error (fall through to sequential
   retry), empty Set = authoritative "all unplayable" (short-circuit
   to a clear message instead of wasting 20+ retries).

2. Web store: fetchHomeData unconditionally wrote lastFetchTime even
   when every fetch rejected (network blip, server down). That cached
   the failure for 5 minutes — user had to hard-reload to recover.
   Now only commit lastFetchTime if at least one auth-status call
   succeeded.

3. Settings profile section: if GET /profile failed, profileConfigs
   stayed undefined and the row showed "加载中..." forever. Track a
   per-bot error state and render an inline "加载失败 / 重试" link
   so the user can recover without page reload.

Out of scope but documented:
- ein=29 hardcoded in fetchCollectedPlaylists (no pagination yet —
  users with 30+ collected QQ playlists get truncated).
- /play-song single-failure UX (returns "Cannot play" message but
  frontend ignores; needs a global toast/notification primitive).
- NetEase has no analogous batch precheck (could surface same
  "click and wait silent" issue if user has region-restricted NetEase
  playlists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:46:31 +08:00
saopig1andClaude Opus 4.7 3d0aca52d0 fix(qq): batch-precheck playable URLs before queueing playlist
Many users' QQ playlists (especially collected/subscribed ones) contain
a large fraction of songs that return result=104003 (region/copyright
restricted) for the current account. Sequential retry-skip wasted time
guessing — for the user's ACG古风 collected playlist, only 3 of 115
songs are actually streamable, so a 20-retry budget had < 50% chance
of finding a hit before giving up.

Add a getPlayableSongIds(ids) method to the QQ provider that calls the
upstream /getMusicPlay with a comma-separated batch of mids — the
wrapper resolves all of them in a single upstream call (~2-3s for 100+
songs). /play-playlist now duck-types this method and, when present,
filters the playlist down to playable songs before queueing.

Response message reports "Loaded N of M songs (rest are copyright/
region restricted)" so the user sees exactly what's happening instead
of guessing why the queue is short or playback didn't start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:34:16 +08:00
saopig1andClaude Opus 4.7 4301da2e37 fix(player): bigger retry budget + return value for /play-playlist
The previous fix for "播放全部 没有反应" hooked into playNext but had
two problems:

1. Used !!queue.current() to detect success, which is always true
   after queue.next() advanced — so the response message would lie
   about playback starting even when all retries failed.

2. The default 3-retry budget is sized for "next song couldn't
   resolve, skip it" cases. User-initiated playlist plays commonly
   hit long contiguous runs of QQ 104003 songs in collected playlists
   (entire ACG/古风 packs can be uniformly unstreamable in some
   regions), so 3-4 attempts wasn't enough.

Make playNext return whether a song actually started, and accept a
maxRetries parameter. /play-playlist now uses 20 retries — high
enough to clear typical unplayable runs, bounded enough that fully
unstreamable playlists still surface a clear "none were playable"
message rather than hanging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:25:59 +08:00
saopig1andClaude Opus 4.7 82655afa20 fix(player): retry-skip in /play-playlist when first song can't resolve
QQ Music sometimes refuses to issue a play URL for individual songs
(upstream returns result=104003, "no copyright/payment"). The
/play-playlist endpoint only called resolveAndPlay once on the first
song, so when that one was 104003 the bot would sit silently with no
feedback — exactly what looked like "播放全部 没有反应".

Make BotInstance.playNext() public and call it as a fallback when the
first resolveAndPlay fails. playNext already has the 3-attempt
retry-skip used by trackEnd auto-advance, so we get the same
graceful skip behavior for explicit playlist plays.

Also tighten the response message so the user can tell when the bot
loaded songs but none were playable.

Single-song /play-song still returns "Cannot play" on failure (no
queue to advance through); UX surfacing of that goes in a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:43:01 +08:00
saopig1andClaude Opus 4.7 5799891e4f feat(web): bot profile toggles in Settings
Each bot gets a collapsible card with 6 toggles for the TeamSpeak
profile features (avatar, description, nickname, away status, channel
description, now-playing chat message). Most relevant: lets the user
turn off "更新频道描述", which was triggering the "channel edited"
notification sound on every song change. Same goes for the now-playing
chat message.

The two toggles that broadcast a sound to other channel members are
flagged with an inline ⚠️ tag so users notice them.

Wires up to the existing GET/PUT /api/player/:botId/profile endpoints
(no backend changes). Optimistic update with revert on error. Mobile
breakpoint enlarges the switch to a 44x24 touch target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:15:56 +08:00
saopig1andClaude Opus 4.7 900191eca5 fix(api): add getPlaylistDetail provider method for QQ playlist detail
The /playlist/:id/detail route was hardcoded to call the NetEase API
path /playlist/detail on whatever provider was selected. For QQ this
hit a non-existent path, so clicking into any QQ playlist showed
"歌单不存在或加载失败".

Replace the platform-specific hack in the route handler with a proper
getPlaylistDetail method on MusicProvider. Implement it for NetEase
(porting the existing /playlist/detail logic) and QQ (calling
/getSongListDetail and reading from response.cdlist[0]).

Pre-existing bug exposed by the QQ source tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 14:05:48 +08:00
saopig1andClaude Opus 4.7 e8d2f1ad98 fix(qq): correct user-playlists field mapping; add collected playlists and daily recommend
Three fixes to the QQ Music provider, all surfaced by enabling the QQ
tab on Home/Library:

1. getUserPlaylists field names were wrong. The upstream
   fcg_get_profile_homepage actually returns title/picurl/subtitle,
   not dissname/imgurl/song_count, so all playlist cards rendered as
   empty placeholders. Map title->name, picurl->coverUrl, and parse
   '(\d+)首' from subtitle for songCount.

2. getUserPlaylists only returned playlists the user CREATED. Now also
   fetches COLLECTED playlists from c.y.qq.com fcg_get_profile_order_asset
   (reqtype=3, requires g_tk derived from p_skey cookie) and concatenates
   them after the created list — same order as the QQ desktop app.

3. Add getDailyRecommendSongs implementation backed by /getNewSongs
   (新歌速递, ~20 newest songs). The QQ provider previously didn't
   implement this method, so the daily-recommend QQ tab was empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:56:54 +08:00
saopig1andClaude Opus 4.7 ec02887d22 fix(web): post-review fixes for source tabs
- Reset per-platform fields on fetch failure (was leaving stale data
  for up to 5 minutes after the user logged out of NetEase).
- Extract availableSources getter on the store; deduplicate the daily/
  user availability computeds in Home.vue and Library.vue.
- Add comment explaining why recommendAvailable always seeds netease.
- Import Source type in SourceTabs.vue from the store instead of
  redeclaring locally, removing a future type-drift risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:26:46 +08:00
saopig1andClaude Opus 4.7 ac55a346be feat(web): per-platform source tabs on Home and Library
Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.

Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:19:49 +08:00
saopig1andClaude Opus 4.7 a216709227 feat(web): add SourceTabs component for platform switcher
Presentational component for switching between netease and qq music
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
breakpoint enlarges touch target to 36px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:12:55 +08:00
saopig1andClaude Opus 4.7 4a990bfde3 docs: implementation plan for music source tabs
Step-by-step plan for per-platform tabs on Home and Library: SourceTabs
component, store refactor with authStatus and per-platform data, view
migrations with localStorage persistence, build verification, and
3-scenario manual smoke test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:08:38 +08:00
saopig1andClaude Opus 4.7 e5ac3ad896 docs: spec for multi-source tabs on Home and Library
Design for adding NetEase / QQ source switcher tabs to recommend
playlists, daily songs, and user playlists on Home, plus user playlists
on Library. Tabs auto-hide when only one source is available; selection
persists per-section in localStorage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:04:45 +08:00
saopig1andClaude Opus 4.7 652424b74c fix: post-merge type and test fixes
Library.vue: use Song type from store so SongCard's strict platform
union accepts the data (was platform: string, broke after merge tightened
SongCard prop type).

database.test.ts: switch toEqual -> toMatchObject so getBotInstances()
returning extra profile_* schema columns no longer fails the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:47:19 +08:00
saopig1 877c431b13 Merge remote-tracking branch 'origin/main' into feature/design-implementation 2026-05-06 12:37:33 +08:00
TIANYAO ZHANG e8d14a695f Merge pull request #45 from FFatTiger/feature/fm-fix-random-repeat
fix: FM Random mode to prevent song repeats
2026-05-06 12:33:08 +08:00
fattiger fe78bf812c fix: use Random mode for FM instead of RandomLoop to prevent song repeats
RandomLoop randomly selects from queue, causing the same song to play
multiple times when the queue is small (~3 songs from personal_fm API).
Random mode ensures each song plays once before repeating, relying on
refillFm() to fetch fresh songs before exhaustion.

Also fix the proactive refill condition to use unplayedCount() instead
of size() - getCurrentIndex(), which was meaningless for non-sequential
play modes.
2026-04-29 20:51:21 +08:00
TIANYAO ZHANG 64ecd92d7c Merge pull request #43 from FFatTiger/feature/fm-artist-playlist
feat: 新增 !artist 歌手循环播放、!playlist 歌单模糊搜索,修复 !fm 播放中断,QQ 音乐个人歌单支持
2026-04-29 12:21:26 +08:00
TIANYAO ZHANG f41cd888f9 Merge pull request #42 from NoSetViolin/main
issue #39: web增加一键清空播放列表功能
2026-04-29 12:19:55 +08:00
fattiger 4de8ac1810 fix(qq): bypass broken c.y.qq.com search API, use u.y.qq.com directly 2026-04-27 19:04:31 +08:00
fattiger f9e2a210b7 docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:15:36 +08:00
fattiger 62fb67933d docs: update README with new !artist, !playlist, FM fix, and QQ playlist features 2026-04-27 18:14:27 +08:00
fattiger 43f4dd94aa feat(qq): add getUserPlaylists support for QQ Music provider 2026-04-27 18:08:09 +08:00
fattiger 1a5bd74357 feat: add !artist command for artist-based loop playback 2026-04-27 17:40:35 +08:00
fattiger 31e5e08def feat: support playlist name fuzzy search in !playlist command 2026-04-27 17:31:50 +08:00
fattiger 5d74979af3 docs: clarify exhausted-queue refill is defensive-only in FM RandomLoop mode 2026-04-27 17:29:00 +08:00
fattiger 24da8cfc2e fix: FM auto-refill to prevent audio dropout after initial batch 2026-04-27 17:20:34 +08:00
fattiger e3b4e1634d fix(player): reset consecutiveFailures after sustained healthy playback 2026-04-27 17:09:49 +08:00
fattiger 2f186ec002 chore: add .worktrees/ to .gitignore 2026-04-27 17:04:26 +08:00
fattiger 08170a2574 docs: add implementation plan for FM fix, !artist, and playlist search 2026-04-27 17:01:03 +08:00
fattiger 7eb8477dad docs: add design spec for FM bug fix, !artist command, and playlist fuzzy search 2026-04-27 16:55:35 +08:00
阿梓喵_あずにゃん 2fb0cd2489 issue #39: web增加一键清空播放列表功能 2026-04-26 23:07:48 +08:00
TIANYAO ZHANG 8dcdf01129 Update setup.bat 2026-04-25 14:34:45 +08:00
TIANYAO ZHANG da26435385 Merge pull request #38 from NoSetViolin/main
fix #37
2026-04-21 21:11:54 +08:00
阿梓喵_あずにゃん 364112d94f fix: ffmpeg残留 每次只留一个ffmpeg进程 现已加入闭包校验 防止歌曲帧异常串入 2026-04-21 03:53:50 +08:00
阿梓喵_あずにゃん 82a23d291e ffmpeg进程堆积优化
ffmpeg进程堆积优化(*)
减小缓冲区
降低100音量响度

*此处仍有问题  没招了  每次切歌会有几帧前面一首歌的残留 我不知道哪里出问题了
2026-04-21 02:03:20 +08:00
saopig1andClaude Opus 4.7 1552fa1a39 feat(web): mobile-responsive redesign with Library view and design tokens
Add mobile layout (mini player, bottom tab bar, responsive navbar), new
Library view, redesigned bot dropdown with action buttons, and centralize
colors/typography/shadows into CSS variables in variables.scss.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 01:12:58 +08:00
阿梓喵_あずにゃん 47514f57aa fix #37 2026-04-20 23:48:46 +08:00
TIANYAO ZHANG 1bd6ab9975 Merge pull request #36 from ZHANGTIANYAO1/claude/start-music-api-servers-CQST1
Fix TS6 HTTP Query error handling and improve profile update logging
2026-04-18 00:26:33 +08:00
Claude 314d6ec955 fix(profile): validate TS6 HTTP status and stop escaping JSON body
Silent failure: logs showed "Client properties updated" / "Description
updated" / "Avatar updated" even though the bot's nickname never
changed and the avatar stayed as "loading image" on clients.

Root causes:
- TS6HttpQuery.clientUpdate ignored non-2xx responses, so 400 (bad
  parameter) and 403 (insufficient permission) were reported as success.
- updateClientProperties built TS3-escaped strings (\\s for space) then
  split them back into JSON props, so TS6 received literal backslashes
  and rejected the nickname silently.
- handleFeatureError only matched textual "permission" errors; HTTP
  4xx statuses weren't recognised and the feature retried every song.
- doAvatarUpload had no per-step logging, making it impossible to tell
  whether a broken avatar came from init, the TCP 30033 transfer, or
  the client_flag_avatar command.

Fixes:
- Add HttpQueryError (status/body/path); clientUpdate throws on non-2xx.
- Build a raw property map in updateClientProperties; escape only on
  the TS3 wire path.
- Log HTTP status and updated prop names on success.
- handleFeatureError now treats HTTP 400/401/403 as unrecoverable.
- Debug-log each step of doAvatarUpload plus bytes/elapsedMs on success.

https://claude.ai/code/session_018NrpGWbQQTrahUVXyea5Jy
2026-04-17 16:21:52 +00:00
TIANYAO ZHANG 66ae948371 Merge pull request #35 from ZHANGTIANYAO1/claude/start-music-api-servers-ZIruv
Add shareable bot links with public URL configuration
2026-04-18 00:09:31 +08:00
Claude fecda7cce3 fix(web): make bot-link usable on public IP with HTTP
The "复制专属链接" button silently failed on public-IP HTTP deployments
because navigator.clipboard requires a secure context. Now the link is
always revealed in a modal with a read-only input (select-all on focus),
so users can copy manually even when clipboard APIs and execCommand both
fail. The dialog still tries to auto-copy when possible.

Also adds a publicUrl config option that overrides window.location.origin
for link generation (useful behind reverse proxies / with custom domains),
exposed via GET /api/config/public-url, and a trustProxy flag so Express
honors X-Forwarded-* when fronted by nginx/Caddy/Cloudflare.

https://claude.ai/code/session_019FSX3S3UUcKEYWanYmoqUv
2026-04-17 16:08:00 +00:00
saopig1andClaude Opus 4.6 b9e42d543c fix(web): make bot-link copy work over plain HTTP
navigator.clipboard requires a secure context (HTTPS or localhost). For
users hosting the bot on a remote IP and accessing via http://<ip>:3000,
clipboard.writeText is undefined and the copy silently fails. Add a
hidden-textarea + execCommand('copy') fallback, with a final prompt()
fallback so the user can always grab the URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:26:05 +08:00
saopig1andClaude Opus 4.6 4406eeacbe fix(api-server): handle QQ Music API export differences across versions
The @sansenjian/qq-music-api module's export structure varies between
versions (2.2.10 vs 2.2.11+). Add fallback chain to find the Koa app:
try candidate.listen first, then candidate.default.listen.

Also resolve leftover merge conflict marker in instance.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:52:27 +08:00
TIANYAO ZHANG 0408f6ce3c Merge pull request #33 from NeoPecos/feature/idle-timeout
添加闲时自动退出管理功能
2026-04-13 20:46:58 +08:00
TIANYAO ZHANG 47b69a1f95 Merge pull request #29 from stanoswald/bugfix/pass-pwd-in-client
Forward server password to client opetion
2026-04-13 20:44:43 +08:00
NeoPecos 0e992f2f5f merge: resolve conflict, keep idleTimer and profileManager 2026-04-13 17:43:04 +08:00
NeoPecos 7785cc972b feat: add idle timeout setting to auto-disconnect bot when channel is empty 2026-04-13 17:27:51 +08:00
StanOswald 4b412e6277 Forward server password to client opetion 2026-04-13 02:04:05 +08:00
TIANYAO ZHANG f0a0ae5f2c Merge pull request #28 from ZHANGTIANYAO1/dev
Dev
2026-04-13 00:53:12 +08:00
saopig1andClaude Opus 4.6 a56dc5bc85 docs(readme): document bot profile auto-update feature
Add profile feature to features list, architecture tree, and changelog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:52:09 +08:00
saopig1 1a663156c3 Merge branch 'main' of https://github.com/ZHANGTIANYAO1/teamspeak-music-bot into dev 2026-04-13 00:49:40 +08:00
saopig1andClaude Opus 4.6 e92628a6b4 fix(profile): add timeout to clientedit, guard clearAvatar with generation
- Wrap clientedit (description) with 5s timeout to prevent blocking
  channel description and now-playing updates if the command hangs
- Check generation counter in clearAvatar to avoid clearing a newer
  song's avatar when stop→play happens in quick succession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:48:09 +08:00
saopig1andClaude Opus 4.6 b4ad78ce84 fix(profile): harden against race conditions and edge cases
- Add generation counter to prevent stale avatar uploads from
  overwriting newer song's profile when rapidly skipping tracks
- Fix nickname truncation to use UTF-8 byte length instead of JS
  string length (TS3 counts bytes, Chinese chars are 3 bytes)
- Add timeout to clearAvatar file transfer (was missing)
- Extract withTimeout helper to deduplicate timeout logic
- Add BiliBili CDN thumbnail resize support (@200w_200h)
- Bump generation on reconnect to discard in-flight updates from
  the old connection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:44:57 +08:00
saopig1andClaude Opus 4.6 30aced6d36 fix(profile): use clientedit for description, add sendCommandNoWait
Description via clientupdate is rejected (error 1538) on TS3 full-client
protocol. Switch to clientedit on the bot's own clid, which is how
TS3AudioBot handles it. Requires b_client_modify_description permission.

Also add sendCommandNoWait to TS3Client for fire-and-forget commands
(clientupdate, channeledit) that don't return timely responses.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:39:36 +08:00
saopig1andClaude Opus 4.6 da5b34f5f4 feat(profile): auto-update bot avatar, nickname, and away status based on playing song
Add BotProfileManager that updates the bot's TeamSpeak presence when
songs change: album cover as avatar, song info in nickname, away status
toggled on stop/play. Each feature is independently configurable via
REST API and persisted to the database. Permission-safe — features that
fail due to insufficient server permissions are silently disabled until
reconnect. Description falls back to nickname display on TS3 (only
supported via TS6 HTTP Query).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:14:29 +08:00
TIANYAO ZHANG 010aa8aa8c Update README.md 2026-04-13 00:11:41 +08:00
TIANYAO ZHANG 05edca4a9a Merge pull request #27 from ZHANGTIANYAO1/claude/fix-shuffle-loop-bug-FrIbI
Fix random mode to track played songs and prevent duplicates
2026-04-12 23:09:33 +08:00
Claude e2b1a5e055 fix: prevent skipped/duplicate songs in Random mode edge cases
Three corner cases fixed:

1. Removing the currently-playing song caused the next song in the array
   to be silently marked as "played" and skipped. Root cause: playedIndices
   was updated in next() by marking currentIndex, but after remove() shifts
   currentIndex, it pointed to the wrong song. Fix: mark songs as played
   at play-time (in play/playAt/next/prev) instead of at next-request-time.

2. Using prev() in Random mode could cause a song to play twice — the
   song navigated to via prev() was not recorded in playedIndices, so
   next() could randomly select it again. Fix: prev() now marks the
   returned song as played.

3. Switching to Random mode mid-playback could cause the current song to
   repeat because setMode() cleared playedIndices without preserving the
   currently-playing song. Fix: setMode() now re-adds currentIndex after
   clearing.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 15:02:53 +00:00
Claude 4eac2e4dde fix: Random mode now stops after all songs played instead of looping forever
In Random (shuffle) mode, the queue's next() method would return the same
song indefinitely when only one song was in the playlist, and never terminate
even with multiple songs. This happened because played songs were not tracked.

Added a playedIndices Set to track which songs have already been played in
Random mode. Once all songs have been played once, next() returns null to
stop playback — matching the expected behavior where Random plays each song
once in random order, while RandomLoop is the mode for infinite shuffling.

https://claude.ai/code/session_01W3ZncxL5VfdZeB4qqYWDmY
2026-04-12 14:53:15 +00:00
TIANYAO ZHANG 78380f27d4 Merge pull request #26 from ZHANGTIANYAO1/claude/fix-docker-audio-playback-9bGfw
Prioritize system FFmpeg over bundled ffmpeg-static
2026-04-12 22:42:56 +08:00
Claude 47e0d0f288 fix: resolve Docker FFmpeg SIGSEGV crash and build failures (#24)
The ffmpeg-static npm package bundles a pre-compiled binary that passes
`ffmpeg -version` but crashes with SIGSEGV during actual audio processing
inside Docker containers (incompatible glibc/missing shared libraries).

Changes:
- Install system FFmpeg via apt-get in Docker production stage, which is
  always compatible with the container runtime
- Reverse FFmpeg resolution priority in player.ts: prefer system FFmpeg,
  fall back to ffmpeg-static (for non-Docker environments like Windows)
- Optimize Dockerfile multi-stage build: compile native modules (opus,
  better-sqlite3) in builder stage and copy to production, eliminating
  the need for build tools (python3, make, g++) in the production image
- Fix qq-music-api dependency from file:../qq-music-api (path outside
  Docker build context, breaks npm ci) to npm registry ^2.2.10

https://claude.ai/code/session_01JAV8sBokoifKh4Hc8XbJws
2026-04-12 14:41:48 +00:00
TIANYAO ZHANG dab02ede9c Merge pull request #25 from ZHANGTIANYAO1/claude/fix-docker-repo-url-C4vzW
Rename project from tsmusicbot to teamspeak-music-bot
2026-04-12 22:33:31 +08:00
Claude c950c7402f fix: correct project root directory name in README architecture section
Changed `tsmusicbot/` to `teamspeak-music-bot/` in the project
architecture directory tree to match the actual repository name.

https://claude.ai/code/session_01VTscSE9PxD6qwF56WBMunQ
2026-04-12 14:32:34 +00:00
Claude c86d70ca08 fix: correct git clone URL in README.md
The repo URL was pointing to the old `tsmusicbot.git` instead of the
actual repository name `teamspeak-music-bot.git`. Fixed both occurrences
(方式二 and 方式三 sections).

https://claude.ai/code/session_01VTscSE9PxD6qwF56WBMunQ
2026-04-12 14:31:49 +00:00
TIANYAO ZHANG d7107677c0 Merge pull request #22 from ZHANGTIANYAO1/dev
Dev
2026-04-11 23:19:27 +08:00
saopig1andClaude Opus 4.6 5647cf6d36 feat(qq): consume local @sansenjian/qq-music-api fork with VIP-aware getMusicPlay
Repoints the `@sansenjian/qq-music-api` dependency from the public npm
release to a local fork at ../qq-music-api, which ships a corrected
getMusicPlay that:
  - drops the hardcoded-sign GET path (no longer honored by QQ's vkey
    server for VIP entitlement lookups)
  - POSTs JSON directly to u.y.qq.com/cgi-bin/musicu.fcg (mirroring
    the library's own getLyric.ts pattern)
  - extracts qqmusic_key from the forwarded cookie and passes it as
    `comm.authst` — the inline auth field the jsososo/QQMusicApi
    reference implementation sets
  - uses `ct: 19` (was 24) to match the community reference

For accounts that actually have entitlement to a given track, this
now returns the real VIP URL. For accounts that don't, QQ's vkey
server still returns result=104003 with empty purl — this is correct
server-side behavior and not a bug. Verified by observing the real
QQ Music web player on y.qq.com fall back to the same 30-second
preview on a logged-in account that lacks the specific track tier.

Supporting changes:

  src/music/api-server.ts
    The fork (v2.2.11) stopped auto-starting a Koa server on import —
    it only listens when run as `require.main`. Explicitly import the
    default Koa app and call .listen() with a server handle we can
    clean up on shutdown. Without this fix, port 3200 silently fails
    to bind and every QQ endpoint 502s.

  src/music/qq.ts (getSongDetail)
    The library's /getSongInfo endpoint returns upstream code 500001
    because its param format no longer matches QQ's current API.
    resolveAndPlay only needs `id` + `platform` to fetch a play URL,
    so fall through to a minimal stub on /getSongInfo failure. This
    unblocks /play-by-id and /add-by-id for QQ — they had been
    returning "Song not found" for every QQ track regardless of
    entitlement.

  scripts/qq_browser_login.py
    Visible-browser diagnostic tool that opens Chromium at y.qq.com,
    auto-detects login via uin cookie poll, captures the full
    post-login cookie set, tests it against /getMusicPlay for 稻香,
    and writes the cookie to data/cookies/qq.json only if VIP
    actually unlocks. On failure, dumps the full cookie to
    data/cookies/qq.browser-capture.json for OAuth-vs-browser diff.

  scripts/qq_verify_entitlement.py
    Companion diagnostic: opens the real QQ Music web player at a
    specific song's detail page so the user can manually click play
    and verify whether their account has entitlement — independent
    of any code path in this project. If the browser plays the full
    song, HTTP 104003 is a request-signing issue; if the browser
    also falls back to a 30-second preview, the account lacks the
    tier/album purchase and no code fix can change that.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:52:12 +08:00
saopig1andClaude Opus 4.6 3aa06006fe fix(qq): repair QR code login flow against @sansenjian/qq-music-api 2.x
QR login against QQ Music has been silently broken: every call to
checkQrCodeStatus returned "expired", so the scan-and-confirm cycle
never completed even when the user successfully scanned the code. The
root cause was four independent bugs in our wrapper talking past the
library's actual HTTP shape.

1. getQrCode lost ptqrtoken.
   /getQQLoginQr returns { img, qrsig, ptqrtoken }, but we stored only
   one of them in the single `key` field (picking qrsig, falling back
   to ptqrtoken). The polling endpoint needs BOTH — passing only one
   fails with 400 "参数错误". Fix: pack both into the opaque `key` as
   "qrsig|ptqrtoken" and split on the receive side.

2. checkQrCodeStatus used GET.
   @sansenjian/qq-music-api 2.x registers /checkQQLoginQr as POST only
   (router.js: `router.post('/checkQQLoginQr', ...)`). GET returns 405
   Method Not Allowed, axios throws, the catch returns "expired".
   Fix: api.post(url, null, { params }).

3. checkQrCodeStatus parsed the wrong response shape.
   The endpoint uses customResponse, not successResponse, so axios sees
   the body directly (no { response: ... } wrapper). The actual shape
   for each state is:
     waiting:  { isOk: false, refresh: false, message: '未扫描二维码' }
     expired:  { isOk: false, refresh: true,  message: '二维码已失效' }
     success:  { isOk: true, message: '登录成功', session: { cookie } }
   We were looking for a numeric `code === 0/1/2` field that does not
   exist, so every state fell through to "expired". Fix: switch on
   isOk / refresh / message.

4. Cookie read from the wrong path on success.
   On isOk=true the cookie lives at res.data.session.cookie, not
   res.data.cookie — so even if everything else had worked, the cookie
   would never have been saved. Fix: read session.cookie.

Also rewrites getAuthStatus to actually validate the cookie:

5. getAuthStatus hit a non-validating endpoint.
   /getUserAvatar is not registered on the library's main router; the
   real route is /user/getUserAvatar, and even that just builds a
   static avatar URL from a uin without round-tripping through QQ
   Music with the cookie. The result: the bot happily persisted any
   user-supplied cookie to disk and sent it on every request while
   every downstream login check returned "not logged in". Fix: parse
   uin from the cookie, call /user/getUserPlaylists (which actually
   hits QQ Music with the cookie), and derive the avatar URL from the
   uin via q.qlogo.cn/headimg_dl.

This is the same getAuthStatus fix that was sitting on the
claude/bot-shutdown-disconnect-cwFvF branch, now combined with the
QR login repairs.

Verification:
- tsc --noEmit clean
- vitest: 93/93 pass
- Live: POST /api/auth/qrcode platform=qq returns both tokens packed
  into `key`; polling a freshly-issued QR returns {"status":"waiting"}
  instead of the old {"status":"expired"}; raw library response is
  {"isOk":false,"refresh":false,"message":"未扫描二维码"} as expected.
- Regression: netease and bilibili QR flows still produce non-empty
  qrUrl/key — no collateral damage to the other providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 21:07:26 +08:00
TIANYAO ZHANG ac9f447ad2 Merge pull request #21 from ZHANGTIANYAO1/dev
Dev
2026-04-11 02:16:18 +08:00
saopig1andClaude Opus 4.6 5692d043c9 docs(readme): prepare for merge to main — remove dev-branch framing, add changelog
- Delete the top-of-file "dev 分支" warning block and the whole
  "dev 分支最新变更" section. Content that was only a dev-branch
  changelog is now folded into the new "更新日志" section.
- Reword the identity migration warning in the upgrade section so it
  refers to the library version (0.1.x → 0.2.x) instead of a specific
  dev-branch commit hash, and add a "how to tell if you need to
  migrate" note for users on fresh installs.
- Remove "`dev` 分支已实现" phrasing from the TS6 FAQ entry.
- Add a new "更新日志" section before "致谢" that summarizes recent
  changes in four buckets — protocol/stability, HTTP API hardening,
  connection state consistency, and feature improvements — plus a
  brief historical milestones block pointing at git log for full
  history.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:15:08 +08:00
saopig1andClaude Opus 4.6 49879f987c docs(readme): document identity migration for @honeybbq/teamspeak-client 0.2.x upgrade
Adds a prominent warning block at the top of the "更新升级" section
explaining why identities generated by 0.1.0 are incompatible with
0.2.x's corrected P-256 DER encoding path, and how to migrate: clear
the identity column so the next start regenerates a fresh key.

Covers three scenarios:
- TS3 + old identity: mostly still works (TS3 is tolerant)
- TS6 + old identity: must clear — otherwise handshake hangs at
  `received initivexpand2`
- After clearing: server groups must be re-granted to the new UID once

Also adds a back-reference at the end of the upgrade section so
readers skimming per-platform instructions don't miss the migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:57:39 +08:00
saopig1andClaude Opus 4.6 ca4cb1790a chore(deps): bump @honeybbq/teamspeak-client to 0.2.1, drop ts6-compat shim
Version 0.2.1 ships a universal clientinit format that works natively
against both TS3 and TS6 servers:

    client_version: "3.?.? [Build: 5680278000]"
    client_version_sign: DX5NIYLvfJEUjuIbCidnoeozxIDRRkpq3I9vVMBmE9L2qnekOo
                         BzSenkzsg2lC9CMv8K5hkEzhr2TYUYSwUXCg==

The old ts6-compat.ts workaround (monkey-patching handler.sendPacket to
rewrite clientinit's client_version to "3.6.2") is now actively wrong:
it replaces the library's new correct version/signature pair with a
stale one that TS6 servers reject, which is why the first 0.2.1 TS6
handshake attempt still hung at `received initivexpand2`.

Changes:
- package.json: "@honeybbq/teamspeak-client": "^0.1.0" -> "^0.2.1"
- src/ts-protocol/client.ts: remove patchClientInitVersion import and
  the sendPacket monkey-patch block. Leave an inline comment so anyone
  reading the git blame understands why the shim is gone.
- Delete src/ts-protocol/ts6-compat.ts and ts6-compat.test.ts — no
  callers remain.

Other 0.2.x notes worth knowing (no code change here, just documenting):
- ClientOptions gained serverPassword / defaultChannel /
  defaultChannelPassword that are sent DURING clientinit. We still call
  our own joinChannel() post-connect because the existing flow works
  and switching is an orthogonal refactor.
- 0.1.1 contains the P-256 DER encoding fix (PR #5 by ZHANGTIANYAO1).
  Identities generated by 0.1.0 are cryptographically incompatible with
  0.2.x's corrected handshake path — a bot whose identity column was
  populated before this upgrade will hang at `received initivexpand2`
  and fall through the 15s connect deadline. Workaround: clear the
  identity column so the next start generates a fresh key. Server
  groups assigned to the old UID must be re-granted once against the
  new one.

Verification:
- tsc --noEmit clean
- vitest: 93/93 unit tests pass (1 test file removed with ts6-compat)
- scripts/test_full_feature.py against a local TS6 server: 51/51 pass,
  including handshake, voice playback, identity persistence, WebSocket
  stateChange broadcasts, and all corner-case regressions.
- Live: bot connected to TS6 in ~80ms after identity regeneration,
  played NetEase audio through the voice channel, clean stop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:54:43 +08:00
saopig1andClaude Opus 4.6 4643f70f4a fix: harden bot lifecycle, validate HTTP inputs, make YouTube truly optional
Major bug fixes and corner-case hardening across the backend, plus a
comprehensive feature test suite. All 94 unit tests + 51 integration
tests pass against a local TS3 server.

Lifecycle & state consistency
-----------------------------
- Bug A: startBot() now wraps connect() in a 15s deadline. A hung TS
  handshake no longer blocks the /start HTTP call forever; the failing
  instance is torn down and the caller gets a clean 500.
- Bug B: executeCommand rejects audio-dispatching commands (play, add,
  next, skip, prev, playlist, album, fm) when the bot is disconnected.
  Config-only commands (vol, mode, clear, stop, queue, now, lyrics)
  still work so the UI stays usable while offline.
- Bug C: the tsClient 'disconnected' handler always clears player state
  now, even when connect() never completed. A separate disconnectEmitted
  flag guards duplicate external event emission. Previously an orphaned
  connect attempt that idle-timed-out would leave playing=true forever.
- resolveAndPlay re-checks this.connected AFTER the URL-resolve await so
  a stop() during the network call can't spawn ffmpeg on a disconnected
  bot.
- connect() throws if disconnect() fired during the handshake await,
  preventing a concurrent stop from being overwritten by a late connected
  flag flip.
- startBot always disconnects the outgoing BotInstance before creating
  a replacement, covering the mid-handshake case where isConnected()
  still returned false but the library client was live.
- startBot now reuses the stored identity so server groups granted to
  the bot survive restarts (was regenerating a fresh UID each time).

WebSocket reliability
---------------------
- BotManager extends EventEmitter and emits 'botInstance' whenever a
  new instance is created. websocket.ts listens and re-attaches its
  stateChange / connected / disconnected listeners immediately, fixing
  the bug where player-bar UI never updated until manual refresh.
- attachedBots map now stores the BotInstance reference and detaches
  stale listeners when the instance is replaced. Safety-net interval
  (5s) also reconciles to catch anything missed.
- removeBot emits 'botInstanceRemoved' -> WS broadcasts a new
  {type:"botRemoved", botId} message. Client drops the bot from its
  local store instead of showing it as permanently offline.

HTTP input validation
---------------------
- /volume rejects non-number, NaN, Infinity, and out-of-range values
  with a proper 400 instead of a 200 OK wrapping a usage-text string.
- /mode rejects anything not in {seq, loop, random, rloop} with 400.
- /seek rejects NaN / Infinity / negative (previously NaN slipped
  through typeof==="number" and poisoned seekOffset).
- /play-at validates index < queue.size() BEFORE stopping current
  playback (was silently killing the current song on invalid input).
- /play, /add, /playlist, /play-by-id, /add-by-id, /play-playlist
  all honour platform=youtube now (previously fell through to netease
  and silently played the wrong platform).

YouTube made truly optional
---------------------------
- Lazy checkYtDlpAvailable() runs `yt-dlp --version` once, caches only
  positive results so users can install yt-dlp mid-run and have it
  picked up without a restart.
- getAuthStatus() returns loggedIn=false with nickname
  "YouTube (yt-dlp not installed)" when the binary is missing. UI can
  grey out YouTube instead of silently returning empty searches.
- findYtDlp() picks .exe on win32 and bare binary elsewhere.
- /auth/status?platform=youtube now routes to the YouTube provider
  instead of falling through to NetEase and leaking the NetEase
  user's nickname + avatar.
- /auth/cookie rejects platform=youtube with 400 instead of clobbering
  the NetEase cookie entry.
- README documents yt-dlp install paths (bin/ local vs PATH) and adds
  a dedicated "Optional: YouTube source" section.

Bot Selector UI
---------------
- New power button in each row of the dropdown with play-state-aware
  styling: disabled + wait-cursor during API call, green highlight when
  connected, greys out when the bot is offline.
- Dropdown always visible when >=1 bot exists, bigger font + padding.

Queue correctness
-----------------
- PlayQueue.remove(current) now decrements currentIndex so next() in
  sequential mode advances to the shifted song. Previously removing
  the currently-playing track silently skipped the next track because
  current() falsely reported it as active and next() then incremented
  past it.

Vote-skip hardening
-------------------
- cmdVote: needed threshold is Math.max(1, ceil(users/2)) so a single
  voter in an empty channel can't unanimously pass a vote with
  needed=0.
- resolveAndPlay clears voteSkipUsers on every new track load so votes
  can't leak across songs via cmdPlay/cmdPlaylist/cmdAlbum/cmdFm paths.

cmdAdd parity
-------------
- cmdAdd auto-plays the newly-added song if the player was idle,
  matching /api/player/:id/add-by-id behaviour. Previously add'ing to
  an empty queue on a connected+idle bot silently enqueued without
  starting playback.

Test suite
----------
- scripts/test_full_feature.py — 51 tests across 10 groups exercising
  every HTTP endpoint, WebSocket broadcasts, all music providers, bot
  lifecycle, disconnected-state corners, seek validation, input
  validation, and the main race conditions. Captures and restores the
  target bot's initial state. Resilient to TS3 anti-flood via retry
  with exponential backoff. Runs against a real local TS3 server.
- scripts/test_rapid_cycle.py — Bugs A/B/C regressions
- scripts/test_corner_cases.py — disconnect-during-connect race, config
  commands while disconnected, etc.
- scripts/test_more_corners.py — resolveAndPlay race, seek NaN
- scripts/test_power_button.py — E2E for the new power button
- scripts/test_bot_remove.py — E2E for WS botRemoved broadcast
- scripts/test_playbar.py — player bar auto-show regression (updated
  to restore bot state on exit)
- scripts/test_multibot.py — two-bot concurrent playback monitor
- src/audio/queue.test.ts — 4 new vitest cases for remove() edge cases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 01:35:14 +08:00
saopig1andClaude Opus 4.6 6e828b9c2d fix(ws): re-attach stateChange listeners when bot instance is replaced
startBot creates a fresh BotInstance but the WS layer keyed its listener
map by bot.id, so ensureAllBotsAttached skipped the new object and
stateChange events were never broadcast — the player bar only appeared
after a manual refresh. BotManager now extends EventEmitter and emits
"botInstance" whenever a new bot object is created; websocket.ts stores
the bot reference, detaches on replacement, and subscribes to the event
for immediate wiring.

Also enlarges the bot selector (padding 10×20, font 16, min-height 44,
bigger dot/chevron/state icons, wider name) and adds Playwright repro
scripts for the player bar bug and navbar sizing check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:16:29 +08:00
saopig1andClaude Sonnet 4.6 d5d6abeb1e feat: implement server password login, YouTube source, and improved bot selector UI
- **TS server password** (#7/#9): add serverPassword field across database,
  manager, bot API, and Settings UI — allows joining password-protected servers
- **YouTube audio source** (#1/#10): new YouTubeProvider using yt-dlp binary;
  adds -y flag in chat commands, /api/music supports platform=youtube,
  YouTube badge in SongCard, yt-dlp-wrap npm dependency
- **Bot selector UI** (#14): selector always visible (not just when >1 bot),
  bigger button with border and play-state indicator; per-bot URL routing at
  /bot/:id with BotRedirect view; copy-link button in dropdown

Closes #1, #7, #9, #10, #14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 11:58:07 +08:00
saopig1 b20ddf2603 feat(manager): reload bot config from database on startBot
When starting a bot, re-instantiate BotInstance from latest database
config so changes to host, nickname, channel, protocol etc. take
effect immediately without requiring a full app restart.
2026-04-09 11:31:00 +08:00
saopig1 419911b78b Merge origin/main into dev: sync bug fixes from main
Merges 4 PRs from main:
- PR#15: Fix ffmpeg spawn failure causing infinite retry loop
- PR#16: Fix Docker restart crash (respect autoStart flag, persist identity)
- PR#17: Add missing "play" script to package.json
- PR#18: Add detailed update/upgrade instructions to README

Conflict resolution in src/data/database.ts:
- Combined dev's serverProtocol/ts6ApiKey fields with main's identity field
- Extended migrateSchema() to also migrate serverProtocol and ts6ApiKey columns
- Updated SQL schema and upsert query to include all three new columns
2026-04-08 15:48:40 +08:00
TIANYAO ZHANG 954bbddf7e Merge pull request #12 from ZHANGTIANYAO1/claude/start-music-api-servers-XrUNR
Fix TS6 compatibility by patching handshake clientinit packet
2026-04-04 23:27:28 +08:00
Claude b00b6637d2 Fix corner cases in connect/disconnect lifecycle and resource cleanup
1. Move TS6 handler patch to after client.connect() — the library's
   connect() internally replaces handler via #S(), discarding any
   patch applied beforehand. Patching after connect() is safe because
   clientinit is sent in async message callbacks after Init1 round-trips.

2. Preserve detectedProtocol across reconnect — disconnect() resets it
   to "unknown", causing the TS6 patch to be skipped on reconnect.

3. Prevent double "disconnected" event in BotInstance — disconnect()
   emitted it directly AND the async TS3Client disconnect triggered
   another through the event chain.

4. Guard playNext() against running after disconnect — check connected
   flag to avoid ghost queue processing.

5. Fix UDP error timer leak — clear previous timer before setting a new
   one to prevent accumulation.

6. Fix isPortFree() FD leak — close the test server on error path.

https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD
2026-04-04 15:26:41 +00:00
Claude 7ad9559653 Fix TS6 connection: patch clientinit version at handler level
The @honeybbq/teamspeak-client library sends clientinit directly via
handler.sendPacket() during the handshake, bypassing the commandMiddleware
chain entirely. This meant the ts6VersionMiddleware never intercepted the
handshake clientinit, so TS6 servers always received version 3.5.3 and
silently rejected it (never responding with initserver), causing idle timeout.

Fix: monkey-patch handler.sendPacket() to intercept clientinit packets and
upgrade the version to 3.6.2 before they're sent over the wire.

https://claude.ai/code/session_01QzvMLUT3UkhsffShcY1qzD
2026-04-04 14:24:06 +00:00
TIANYAO ZHANG 278f892fae Update README.md 2026-04-03 22:38:36 +08:00
Claude 80e92b77b8 Fix install.sh: build from source instead of requiring pre-built dist
The script previously checked for a dist/ directory and failed with
"Please run this script from the TSMusicBot source directory after
building" — requiring users to manually build before installing.

Now the script:
- Resolves project root from script location (works from any cwd)
- Installs build tools (build-essential/gcc) for native modules
- Runs npm install + npm run build automatically
- Copies built artifacts to /opt/tsmusicbot
- Creates data directory for runtime files
- Adds journalctl command to the help output

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 14:25:08 +00:00
Claude 185f592ce9 Update README for dev branch with TS3/TS6 dual protocol changelog
- Add dev branch notice and changelog section at the top
- Add TS3/TS6 badges
- Document new protocol modules (protocol-detect, http-query, ts6-compat)
- List all bug fixes in dev branch
- Update architecture diagram with new ts-protocol files
- Add TS6 Server FAQ entry
- Credit NeteaseTSBot for TS6 protocol reference

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 14:01:01 +00:00
Claude 1379a062ba Add TS6 clientinit version middleware and fix playNext retry bug
clientinit compatibility:
- Analyzed @honeybbq/teamspeak-client's clientinit: it already sends a
  clean 14-field payload without problematic fields (no badges,
  integrations, security_hash, etc.)
- The key difference vs NeteaseTSBot is client_version: our library
  sends 3.5.3, NeteaseTSBot sends 3.6.2. TS6 servers may reject
  older versions.
- Add ts6-compat.ts with CommandMiddleware that patches clientinit
  to use version 3.6.2 + matching ECDSA signature when connecting
  to detected TS6 servers
- Middleware is automatically applied when detectedProtocol === "ts6"

Pre-existing bug fix:
- Fix playNext() in instance.ts where successful retry still fell
  through to player.stop(), killing the just-started playback

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:46:31 +00:00
Claude 6184f38330 Fix 7 corner cases found in second review pass
Critical:
- Persist serverProtocol/ts6ApiKey in database schema so TS6 config
  survives restarts (added columns + manager save/load)

Medium:
- Clear udpErrorTimer on disconnect to prevent memory leak and stale
  log messages from firing after teardown
- Guard against double connect() by disconnecting old client first
- Add settled guard in TS6HttpQuery.request() to prevent double
  reject when both res error and req error fire
- Add res.on("error") handler to TS6HttpQuery response stream

Low:
- Cap probeTS3Query banner buffer at 256 bytes to prevent memory abuse
  from non-TS3 services sending large data on port 10011
- Remove unnecessary EventEmitter inheritance from TS6HttpQuery
- Update database test fixtures with new serverProtocol/ts6ApiKey fields

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:38:49 +00:00
Claude 9e45193e2c Fix corner cases in protocol detection and connection cleanup
- Fix double-resolve race in probeTS3Query (data event vs connect timer)
  by guarding with a resolved flag
- Fix double-resolve in probeTS6HttpQuery similarly
- Support custom query ports in detectServerProtocol via DetectOptions
- Clean up httpQuery and detectedProtocol on disconnect()
- Add res.on("error") handler in HTTP Query client to avoid unhandled errors
- Improve logging: warn with actionable message when protocol is unknown

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:34:37 +00:00
Claude e5fd35da32 Update package-lock.json after dependency install
https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:04:17 +00:00
Claude 9e51ba6cd4 Add dual TS3/TS6 protocol support with auto-detection
The @honeybbq/teamspeak-client library already handles TS6 license block
type 8 (Ts5Server) in its handshake, so voice connections work with both
TS3 and TS6 servers. This commit adds the surrounding infrastructure:

- protocol-detect.ts: Auto-detect server type by probing TS3 ServerQuery
  (port 10011) and TS6 HTTP Query (port 10080) in parallel
- http-query.ts: TS6 HTTP Query client replacing the raw-TCP ServerQuery
  that TS6 servers no longer support (ports 10080/10443)
- client.ts: Protocol-aware connection with auto-detection, TS6 HTTP
  Query setup, and forced protocol override option
- manager.ts: Pass through serverProtocol and ts6ApiKey config options
- connection.ts: Mark legacy TS3 ServerQuery as deprecated for TS6

https://claude.ai/code/session_016WhH58avUD9xy2dgADJgTh
2026-04-03 13:03:47 +00:00
245 changed files with 69024 additions and 1817 deletions

No files matched your search

-24
View File
@@ -1,24 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
-25
View File
@@ -1,25 +0,0 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Glob",
"Grep",
"Bash(*)",
"WebFetch(*)",
"WebSearch(*)",
"Agent(*)",
"mcp__Claude_Preview__*",
"mcp__Claude_in_Chrome__*",
"mcp__scheduled-tasks__*",
"Bash(npx vitest:*)"
],
"deny": [
"Bash(git push * main)",
"Bash(git push * master)",
"Bash(git push --force *)",
"Bash(rm -rf /)"
]
}
}
+64
View File
@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Extra tag to publish (optional, e.g. "edge")'
required: false
default: ''
env:
REGISTRY: ghcr.io
IMAGE_NAME: zhangtianyao1/teamspeak-music-bot
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: scripts/docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+5
View File
@@ -6,3 +6,8 @@ dist/
config.json
cookies/
.superpowers/
.worktrees/
.claude/
setup.log
/bin/
scripts/navbar_bigger.png
+762 -53
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,517 @@
# FM Bug Fix + Artist Loop + Playlist Fuzzy Search — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix FM audio dropout bug, add `!artist` command for artist-based loop playback, and support playlist name fuzzy search in `!playlist`.
**Architecture:** All changes stay within existing files. The FM fix adds auto-refill logic and a success-tracking mechanism in the player. Playlist search reuses the existing `provider.search()` API that already returns playlists. The `!artist` command is a new command method following the same pattern as `cmdPlay`/`cmdFm`.
**Tech Stack:** TypeScript, Node.js, ffmpeg-static, @honeybbq/teamspeak-client
---
## File Map
| File | Change | Purpose |
|------|--------|---------|
| `src/bot/instance.ts` | Modify | Add `isFmMode`, `refillFm()`, fix `cmdFm()`, modify `cmdPlaylist()`, add `cmdArtist()`, modify `playNext()` to trigger FM refill |
| `src/bot/commands.ts` | Modify | Register `artist` in PUBLIC_COMMANDS, update help text |
| `src/audio/player.ts` | Modify | Track healthy frame count, reset `consecutiveFailures` after sustained successful playback |
---
### Task 1: Fix FM — Track healthy playback in AudioPlayer
**Files:**
- Modify: `src/audio/player.ts:62-82` (add field)
- Modify: `src/audio/player.ts:243-261` (sendNextFrame — track healthy frames)
- [ ] **Step 1: Add healthy frame counter field**
In `src/audio/player.ts`, after the `consecutiveFailures` field (line ~80), add:
```typescript
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
```
- [ ] **Step 2: Track healthy frames and reset failures in sendNextFrame**
In `src/audio/player.ts`, in the `sendNextFrame()` method, after line 257 (`this.framesPlayed++;`), add:
```typescript
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
```
- [ ] **Step 3: Reset healthyFrames in play() and stop()**
In `play()`, after `this.framesPlayed = 0;` (line ~95), add:
```typescript
this.healthyFrames = 0;
```
In `stop()`, after `this.framesPlayed = 0;` (line ~181), add:
```typescript
this.healthyFrames = 0;
```
- [ ] **Step 4: Commit**
```bash
git add src/audio/player.ts
git commit -m "fix(player): reset consecutiveFailures after sustained healthy playback"
```
---
### Task 2: Fix FM — Add auto-refill logic in BotInstance
**Files:**
- Modify: `src/bot/instance.ts:62-66` (add fields)
- Modify: `src/bot/instance.ts:557-573` (cmdFm)
- Modify: `src/bot/instance.ts:642-673` (playNext — add refill trigger)
- [ ] **Step 1: Add isFmMode field**
In `src/bot/instance.ts`, after `private profileManager: BotProfileManager;` (line ~66), add:
```typescript
private isFmMode = false;
```
- [ ] **Step 2: Add refillFm method**
In `src/bot/instance.ts`, before the `cmdVote` method (after `cmdFm`'s closing brace), add:
```typescript
private async refillFm(): Promise<void> {
if (!this.isFmMode || !this.neteaseProvider.getPersonalFm) return;
try {
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0) return;
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.logger.debug({ count: songs.length }, "FM queue refilled");
} catch (err) {
this.logger.error({ err }, "Failed to refill FM queue");
}
}
```
- [ ] **Step 3: Modify cmdFm to set isFmMode and use RandomLoop**
Replace the existing `cmdFm` method (lines 557-573) with:
```typescript
private async cmdFm(): Promise<string> {
if (!this.neteaseProvider.getPersonalFm) {
return "Personal FM is only available for NetEase Cloud Music";
}
const songs = await this.neteaseProvider.getPersonalFm();
if (songs.length === 0)
return "No FM songs available (need to login first)";
this.queue.clear();
for (const song of songs) {
this.queue.add({ ...song, platform: "netease" });
}
this.queue.setMode(PlayMode.RandomLoop);
this.isFmMode = true;
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Personal FM started: ${first?.name ?? "unknown"} - ${first?.artist ?? ""}`;
}
```
- [ ] **Step 4: Modify playNext to trigger FM refill and check isFmMode**
In `playNext()`, replace the `else` branch (lines 665-668) that handles `queue.next() === null`:
```typescript
} else {
// FM mode: try to refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
const started = await this.resolveAndPlay(refillNext);
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
this.emit("stateChange");
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
```
Also add a proactive refill after successful advance. At the end of the `if (next)` block, after `this.emit("stateChange");` is handled outside the if/else, add this right after `resolveAndPlay` succeeds (inside the `if (next)` block, after the retry loop):
After the `if (!started)` block and before the closing `}` of `if (next)`, insert:
```typescript
// Proactive FM refill when running low
if (this.isFmMode && this.queue.size() - (this.queue.getCurrentIndex()) <= 3) {
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
```
Wait — `this.emit("stateChange")` is outside the `if (next)` block. Let me re-read the original code structure...
The original `playNext()` structure is:
```
if (next) {
let started = await resolveAndPlay(next)
if (!started) { retry loop... }
if (!started) { stop }
} else {
stop
}
emit("stateChange")
```
So I need to add the proactive refill inside the `if (next)` block, right after `resolveAndPlay` succeeds. Let me write this more carefully:
```typescript
private async playNext(): Promise<void> {
if (this.isAdvancing || !this.connected) return;
this.isAdvancing = true;
try {
this.voteSkipUsers.clear();
const next = this.queue.next();
if (next) {
let started = await this.resolveAndPlay(next);
if (!started) {
for (let i = 0; i < 3 && this.connected; i++) {
const retry = this.queue.next();
if (!retry) break;
if (await this.resolveAndPlay(retry)) {
started = true;
break;
}
}
}
if (!started) {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
} else if (this.isFmMode && this.queue.size() - this.queue.getCurrentIndex() <= 3) {
// Proactive refill: when queue is running low, fetch more FM songs
this.refillFm().catch(err => this.logger.error({ err }, "Proactive FM refill failed"));
}
} else {
// Queue exhausted — in FM mode, refill instead of stopping
if (this.isFmMode) {
await this.refillFm();
const refillNext = this.queue.next();
if (refillNext) {
await this.resolveAndPlay(refillNext);
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
} else {
this.player.stop();
this.profileManager.onSongChange(null).catch(() => {});
}
}
this.emit("stateChange");
} finally {
this.isAdvancing = false;
}
}
```
OK this is getting complex. Let me simplify the plan — I'll structure it more clearly.
Also I need to clear `isFmMode` when user issues stop/clear or manually plays something else.
- [ ] **Step 5: Clear isFmMode in stop/clear/play commands**
In `cmdStop()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdClear()` (same line), add:
```typescript
this.isFmMode = false;
```
In `cmdPlay()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdPlaylist()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
In `cmdAlbum()`, after `this.queue.clear();`, add:
```typescript
this.isFmMode = false;
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/instance.ts
git commit -m "fix: FM auto-refill to prevent audio dropout after initial batch"
```
---
### Task 3: Playlist Fuzzy Search
**Files:**
- Modify: `src/bot/instance.ts:524-539` (cmdPlaylist)
- [ ] **Step 1: Modify cmdPlaylist to support name search**
Replace the `cmdPlaylist` method (lines 524-539) with:
```typescript
private async cmdPlaylist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !playlist <playlist name or ID>";
const provider = this.getProvider(cmd.flags);
// Determine if input is a numeric ID or a name search
const id = this.extractId(cmd.args);
const isNumericId = /^\d+$/.test(cmd.args.trim());
let playlistId: string;
if (isNumericId || id !== cmd.args) {
// Input is a numeric ID or URL containing an ID — use existing logic
playlistId = id;
} else {
// Name-based search
const result = await provider.search(cmd.args);
let playlists = result.playlists ?? [];
// Also search user's personal playlists if logged in
if (provider.getUserPlaylists) {
try {
const userPlaylists = await provider.getUserPlaylists();
const query = cmd.args.toLowerCase();
const matched = userPlaylists.filter(
p => p.name.toLowerCase().includes(query)
);
// Merge: public results first (API-ranked), then user matches
playlists = [...playlists, ...matched];
} catch {
// User playlists unavailable — continue with public results
}
}
if (playlists.length === 0)
return `No playlists found for: ${cmd.args}`;
playlistId = playlists[0].id;
}
const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) return "Playlist is empty or not found";
this.queue.clear();
this.isFmMode = false;
for (const song of songs) {
this.queue.add({ ...song, platform: provider.platform });
}
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Loaded ${songs.length} songs. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 2: Update help text to reflect new usage**
In `cmdHelp()` (line ~633), change the playlist line from:
```
`${p}playlist <id> — Load playlist`
```
to:
```
`${p}playlist <name or id> — Load playlist by name or ID`
```
- [ ] **Step 3: Commit**
```bash
git add src/bot/instance.ts
git commit -m "feat: support playlist name fuzzy search in !playlist command"
```
---
### Task 4: Artist Loop Command
**Files:**
- Modify: `src/bot/commands.ts:8-11` (PUBLIC_COMMANDS)
- Modify: `src/bot/commands.ts:248-260` (AUDIO_COMMANDS in instance.ts — actually in instance.ts)
- Modify: `src/bot/instance.ts:244-314` (executeCommand switch + add cmdArtist)
Wait, AUDIO_COMMANDS is in instance.ts executeCommand. Let me check...
Actually looking back at instance.ts, the AUDIO_COMMANDS set is local to executeCommand. I don't need to add artist there since it will be handled in the switch.
- [ ] **Step 1: Register `artist` in PUBLIC_COMMANDS**
In `src/bot/commands.ts`, line 9, add `"artist"` to the PUBLIC_COMMANDS set:
```typescript
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
"artist",
]);
```
- [ ] **Step 2: Add `artist` to the AUDIO_COMMANDS set in executeCommand**
In `src/bot/instance.ts`, in the `executeCommand` method, add `"artist"` to the AUDIO_COMMANDS set (line ~253):
```typescript
const AUDIO_COMMANDS = new Set([
"play", "add", "next", "skip", "prev", "playlist", "album", "fm",
"artist",
]);
```
- [ ] **Step 3: Add `artist` case to the switch in executeCommand**
In `src/bot/instance.ts`, after the `case "fm":` block (line ~300), add:
```typescript
case "artist":
return this.cmdArtist(cmd);
```
- [ ] **Step 4: Implement cmdArtist method**
Add the `cmdArtist` method in `src/bot/instance.ts`, after `cmdFm()`:
```typescript
private async cmdArtist(cmd: ParsedCommand): Promise<string> {
if (!cmd.args) return "Usage: !artist <artist name>";
const provider = this.getProvider(cmd.flags);
const result = await provider.search(cmd.args, 50);
if (result.songs.length === 0)
return `No results found for artist: ${cmd.args}`;
const query = cmd.args.toLowerCase();
let filtered = result.songs.filter(
s => s.artist.toLowerCase().includes(query)
);
// Fallback to unfiltered results if filtering drops everything
if (filtered.length === 0) {
filtered = result.songs.slice(0, 20);
}
this.queue.clear();
this.isFmMode = false;
for (const song of filtered) {
this.queue.add({ ...song, platform: provider.platform });
}
this.queue.setMode(PlayMode.Loop);
this.player.resetFailures();
const first = this.queue.play();
if (first) await this.resolveAndPlay(first);
this.emit("stateChange");
return `Artist mode: ${cmd.args} — ${filtered.length} songs loaded. Now playing: ${first?.name ?? "unknown"}`;
}
```
- [ ] **Step 5: Update help text**
In `cmdHelp()`, add the artist help line after the fm line:
```
`${p}artist <name> — Play songs by artist (loop)`
```
- [ ] **Step 6: Commit**
```bash
git add src/bot/commands.ts src/bot/instance.ts
git commit -m "feat: add !artist command for artist-based loop playback"
```
---
### Task 5: Type-check and verify
**Files:**
- All modified files
- [ ] **Step 1: Run type check**
```bash
cd /home/proxxy/project/teamspeak-music-bot && npm run typecheck
```
Expected: No errors.
- [ ] **Step 2: Verify command parsing**
```bash
cd /home/proxxy/project/teamspeak-music-bot && node --loader ts-node/esm -e "
const { parseCommand } = await import('./src/bot/commands.ts');
console.log(parseCommand('!artist 周杰伦', '!'));
console.log(parseCommand('!artist 周杰伦 -q', '!'));
console.log(parseCommand('!playlist 华语经典', '!'));
console.log(parseCommand('!playlist 123456', '!'));
"
```
Expected: All parse correctly; `artist` with args "周杰伦", `playlist` with args "华语经典" and "123456".
- [ ] **Step 3: Commit any fixes from type check**
```bash
git add -A && git commit -m "chore: type fixes from final verification"
```
(Only if there were issues)
---
### Self-Review Checklist
1. **Spec coverage:**
- FM bug fix → Tasks 1, 2 (healthy frame tracking + auto-refill)
- Playlist fuzzy search → Task 3
- Artist loop → Task 4
- Verification → Task 5
2. **No placeholders** — all steps have exact code.
3. **Type consistency:**
- `isFmMode: boolean` — used in cmdFm, cmdStop, cmdClear, cmdPlay, cmdPlaylist, cmdAlbum, playNext, refillFm ✓
- `refillFm(): Promise<void>` — called from cmdFm (indirectly via playNext trigger), playNext ✓
- `healthyFrames: number`, `HEALTHY_FRAME_RESET: 50` — used in play(), stop(), sendNextFrame() ✓
@@ -0,0 +1,911 @@
# Music Source Tabs Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add NetEase / QQ source-switcher tabs to Home (推荐歌单 / 每日推荐 / 我的歌单) and Library (我的歌单), with per-section persistence and graceful degradation when only one source is logged in.
**Architecture:** A single shared `<SourceTabs>` Vue component handles the tab UI and self-hides when fewer than 2 sources are available. The Pinia store splits the affected fields into `{ netease, qq }` objects, fetches from both platforms in `fetchHomeData()` based on `authStatus`, and consumers select with a reactive `activeSource` ref persisted to localStorage.
**Tech Stack:** Vue 3 (Composition API + `<script setup>`), Pinia, TypeScript, SCSS (CSS variables from `web/src/styles/variables.scss`).
**Spec:** `docs/superpowers/specs/2026-05-06-music-source-tabs-design.md`
---
## File Structure
**New:**
- `web/src/components/SourceTabs.vue` — shared tab UI (presentational, no store deps)
**Modified:**
- `web/src/stores/player.ts` — state shape change + `authStatus` + `fetchHomeData` rewrite
- `web/src/views/Home.vue` — 3 sections wired to SourceTabs
- `web/src/views/Library.vue` — 1 section wired; remove dead `liked` block
**Unchanged:**
- Backend (already supports `?platform=qq`)
- All other web pages
---
## Task 1: Build the SourceTabs component
**Files:**
- Create: `web/src/components/SourceTabs.vue`
This task is fully independent of store changes — the component is presentational, takes typed props, and emits an update event. It can land and be committed alone (build will pass; component is just unused until later tasks).
- [ ] **Step 1.1: Create the component file**
Write `web/src/components/SourceTabs.vue`:
```vue
<template>
<div v-if="sources.length >= 2" class="source-tabs">
<button
v-for="src in sources"
:key="src"
type="button"
class="source-tab"
:class="{ active: src === modelValue }"
@click="$emit('update:modelValue', src)"
>
{{ LABELS[src] }}
</button>
</div>
</template>
<script setup lang="ts">
type Source = 'netease' | 'qq';
const LABELS: Record<Source, string> = {
netease: '网易云',
qq: 'QQ',
};
defineProps<{
modelValue: Source;
sources: Source[];
}>();
defineEmits<{
'update:modelValue': [value: Source];
}>();
</script>
<style lang="scss" scoped>
.source-tabs {
display: inline-flex;
gap: 4px;
margin-left: 12px;
align-items: center;
}
.source-tab {
padding: 4px 10px;
min-height: 28px;
font-size: var(--fs-sm);
font-weight: var(--fw-medium);
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: color var(--transition-fast), background var(--transition-fast);
&:hover {
color: var(--text-primary);
background: var(--hover-bg);
}
&.active {
color: var(--color-primary);
background: var(--color-primary-12);
font-weight: var(--fw-semi);
}
}
@media (max-width: 768px) {
.source-tabs {
margin-left: 8px;
gap: 2px;
}
.source-tab {
padding: 6px 10px;
min-height: 36px; // larger touch target on mobile
font-size: var(--fs-xs);
}
}
</style>
```
Why these choices:
- `v-if="sources.length >= 2"` — auto-hide when only one source available; parent doesn't need wrapper logic
- Min-height 28px desktop / 36px mobile — comfortable touch on phones
- `--color-primary-12` (12% primary tint) — matches existing active-state pattern in the codebase
- No `--brand-netease/qq` in active state — keeps tab visually consistent regardless of which platform; brand colors are reserved for SongCard platform badges where they identify content origin
- [ ] **Step 1.2: Verify it imports cleanly via type check**
Run from project root:
```
npx tsc --noEmit
```
Expected: exit code 0, no output.
Then verify the web project also type-checks:
```
cd web && npx vue-tsc --noEmit && cd ..
```
Expected: exit code 0, no output.
- [ ] **Step 1.3: Commit**
```
git add web/src/components/SourceTabs.vue
git commit -m "feat(web): add SourceTabs component for platform switcher
Presentational component for switching between netease and qq music
sources. Auto-hides when fewer than 2 sources are passed in. Mobile
breakpoint enlarges touch target to 36px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Task 2: Refactor the store (state + fetchHomeData)
**Files:**
- Modify: `web/src/stores/player.ts`
This task changes types, which will break Home.vue and Library.vue at compile time. **Do not run tsc/build between Task 2 and Task 4** — they are migrated in a single coherent commit. After Task 4, type-check confirms the whole change.
- [ ] **Step 2.1: Add the `Source` type alias and update state shape**
In `web/src/stores/player.ts`, locate the `state: () => ({ ... })` block (around line 47-63).
**Find:**
```ts
// Home page cache
recommendPlaylists: [] as PlaylistItem[],
dailySongs: [] as Song[],
userPlaylists: [] as PlaylistItem[],
bilibiliPopular: [] as Song[],
lastFetchTime: 0,
```
**Replace with:**
```ts
// Home page cache, split by source
recommendPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
dailySongs: { netease: [] as Song[], qq: [] as Song[] },
userPlaylists: { netease: [] as PlaylistItem[], qq: [] as PlaylistItem[] },
bilibiliPopular: [] as Song[],
authStatus: { netease: false, qq: false },
lastFetchTime: 0,
```
Also add this exported type at the top of the file, right after the existing `Song` interface (around line 12):
```ts
export type Source = 'netease' | 'qq';
```
- [ ] **Step 2.2: Rewrite `fetchHomeData()`**
In the same file, find the `fetchHomeData` action (around line 352-378).
**Replace the entire action body with:**
```ts
async fetchHomeData() {
if (this.lastFetchTime > 0 && Date.now() - this.lastFetchTime < HOME_CACHE_TTL) {
return;
}
// 1. Fetch auth status for both platforms first.
const [neAuthRes, qqAuthRes] = await Promise.allSettled([
axios.get('/api/auth/status', { params: { platform: 'netease' } }),
axios.get('/api/auth/status', { params: { platform: 'qq' } }),
]);
this.authStatus.netease =
neAuthRes.status === 'fulfilled' && !!neAuthRes.value.data?.loggedIn;
this.authStatus.qq =
qqAuthRes.status === 'fulfilled' && !!qqAuthRes.value.data?.loggedIn;
// 2. NetEase data: recommend playlists work anonymously; daily/user
// playlists need login but Promise.allSettled isolates failures.
const neteasePromises = [
axios.get('/api/music/recommend/playlists', { params: { platform: 'netease' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'netease' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'netease' } }),
];
// 3. QQ data: only fetch when QQ is logged in. When not logged in,
// resolve to empty payloads so the same indexed handling works.
const emptyPlaylists = { data: { playlists: [] } };
const emptySongs = { data: { songs: [] } };
const qqPromises = this.authStatus.qq
? [
axios.get('/api/music/recommend/playlists', { params: { platform: 'qq' } }),
axios.get('/api/music/recommend/songs', { params: { platform: 'qq' } }),
axios.get('/api/music/user/playlists', { params: { platform: 'qq' } }),
]
: [
Promise.resolve(emptyPlaylists),
Promise.resolve(emptySongs),
Promise.resolve(emptyPlaylists),
];
const biliPromise = axios.get('/api/music/bilibili/popular?limit=12');
const results = await Promise.allSettled([
...neteasePromises,
...qqPromises,
biliPromise,
]);
const [neRecPL, neDaily, neUserPL, qqRecPL, qqDaily, qqUserPL, bili] = results;
if (neRecPL.status === 'fulfilled') {
this.recommendPlaylists.netease = neRecPL.value.data.playlists ?? [];
}
if (neDaily.status === 'fulfilled') {
this.dailySongs.netease = neDaily.value.data.songs ?? [];
}
if (neUserPL.status === 'fulfilled') {
this.userPlaylists.netease = neUserPL.value.data.playlists ?? [];
}
if (qqRecPL.status === 'fulfilled') {
this.recommendPlaylists.qq = qqRecPL.value.data.playlists ?? [];
}
if (qqDaily.status === 'fulfilled') {
this.dailySongs.qq = qqDaily.value.data.songs ?? [];
}
if (qqUserPL.status === 'fulfilled') {
this.userPlaylists.qq = qqUserPL.value.data.playlists ?? [];
}
if (bili.status === 'fulfilled') {
this.bilibiliPopular = bili.value.data.songs ?? [];
}
this.lastFetchTime = Date.now();
},
```
**Do NOT type-check yet** — Home/Library still reference the old shape. They'll be migrated in Tasks 3 and 4.
---
## Task 3: Migrate Home.vue to multi-source tabs
**Files:**
- Modify: `web/src/views/Home.vue`
- [ ] **Step 3.1: Add a localStorage helper module**
Create `web/src/stores/sourceTabs.ts`:
```ts
import type { Source } from './player.js';
const STORAGE_KEY = 'source-tabs';
export type TabKey =
| 'home.recommend'
| 'home.daily'
| 'home.user'
| 'library.user';
function readAll(): Partial<Record<TabKey, Source>> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
}
}
export function loadTabSource(key: TabKey, fallback: Source = 'netease'): Source {
const all = readAll();
const v = all[key];
return v === 'netease' || v === 'qq' ? v : fallback;
}
export function saveTabSource(key: TabKey, value: Source): void {
try {
const all = readAll();
all[key] = value;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
// localStorage may be unavailable (private browsing); silently no-op
}
}
```
This is a separate file rather than inline so Library can reuse it without duplication.
- [ ] **Step 3.2: Update Home.vue template**
Replace the three `<section>` blocks (推荐歌单 / 每日推荐 / 我的歌单) and the `<script setup>` block.
**Find** the entire `<template>` 推荐歌单 section (currently around lines 53-67):
```vue
<!-- 推荐歌单 -->
<section class="section" v-if="store.recommendPlaylists.length > 0">
<h2 class="section-title">推荐歌单</h2>
<div class="playlist-grid">
<RouterLink
v-for="playlist in store.recommendPlaylists"
:key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ playlist.name }}</div>
</RouterLink>
</div>
</section>
```
**Replace with:**
```vue
<!-- 推荐歌单 -->
<section class="section" v-if="recommendAvailable.length > 0">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="playlist in (store.recommendPlaylists[recommendSourceSafe] ?? [])"
:key="playlist.id"
:to="`/playlist/${playlist.id}?platform=${playlist.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="playlist.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ playlist.name }}</div>
</RouterLink>
</div>
</section>
```
**Find** the 每日推荐 section (currently around lines 36-51):
```vue
<!-- 每日推荐 -->
<section class="section" v-if="store.dailySongs.length > 0">
<h2 class="section-title">每日推荐</h2>
<div class="daily-grid">
<div
v-for="song in store.dailySongs.slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
```
**Replace with:**
```vue
<!-- 每日推荐 -->
<section class="section" v-if="dailyAvailable.length > 0">
<h2 class="section-title">
每日推荐
<SourceTabs v-model="dailySource" :sources="dailyAvailable" />
</h2>
<div class="daily-grid">
<div
v-for="song in (store.dailySongs[dailySourceSafe] ?? []).slice(0, 12)"
:key="song.id"
class="daily-card hover-scale"
@click="store.playSong(song)"
>
<CoverArt :url="song.coverUrl" :size="120" :radius="10" :show-shadow="true" />
<div class="daily-name">{{ song.name }}</div>
<div class="daily-artist">{{ song.artist }}</div>
</div>
</div>
</section>
```
**Find** the 我的歌单 section (currently around lines 69-95):
```vue
<!-- 我的歌单 -->
<section class="section" v-if="store.userPlaylists.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ store.userPlaylists.length }}</span>
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in visibleUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
<button
v-if="store.userPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
>
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${store.userPlaylists.length} 个歌单` }}
</button>
</section>
```
**Replace with:**
```vue
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in visibleUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
<button
v-if="currentUserPlaylists.length > USER_PLAYLIST_LIMIT"
class="expand-btn"
@click="userPlaylistsExpanded = !userPlaylistsExpanded"
>
<Icon :icon="userPlaylistsExpanded ? 'mdi:chevron-up' : 'mdi:chevron-down'" />
{{ userPlaylistsExpanded ? '收起' : `展开全部 ${currentUserPlaylists.length} 个歌单` }}
</button>
</section>
```
- [ ] **Step 3.3: Update Home.vue `<script setup>`**
**Find** the `<script setup lang="ts">` block (currently around lines 119-153):
```ts
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song } from '../stores/player.js';
import CoverArt from '../components/CoverArt.vue';
const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false);
const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value
? store.userPlaylists
: store.userPlaylists.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
}
onMounted(() => {
store.fetchHomeData();
});
</script>
```
**Replace with:**
```ts
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore();
const USER_PLAYLIST_LIMIT = 20;
const userPlaylistsExpanded = ref(false);
// Available sources per section. Recommend playlists are public for both
// platforms — netease always; qq only when logged in. Daily and user
// playlists need login on both sides.
const recommendAvailable = computed<Source[]>(() => {
const s: Source[] = ['netease'];
if (store.authStatus.qq) s.push('qq');
return s;
});
const dailyAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
// Persisted active source per section.
const recommendSource = ref<Source>(loadTabSource('home.recommend'));
const dailySource = ref<Source>(loadTabSource('home.daily'));
const userSource = ref<Source>(loadTabSource('home.user'));
watch(recommendSource, (v) => saveTabSource('home.recommend', v));
watch(dailySource, (v) => saveTabSource('home.daily', v));
watch(userSource, (v) => saveTabSource('home.user', v));
// Fallback when persisted source is no longer available (e.g. user logged
// out of QQ since last visit). We render against `*Safe` but never write
// back, so the user's preference is preserved for when they log in again.
const recommendSourceSafe = computed<Source>(() =>
recommendAvailable.value.includes(recommendSource.value)
? recommendSource.value
: recommendAvailable.value[0] ?? 'netease'
);
const dailySourceSafe = computed<Source>(() =>
dailyAvailable.value.includes(dailySource.value)
? dailySource.value
: dailyAvailable.value[0] ?? 'netease'
);
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
const visibleUserPlaylists = computed(() =>
userPlaylistsExpanded.value
? currentUserPlaylists.value
: currentUserPlaylists.value.slice(0, USER_PLAYLIST_LIMIT)
);
async function playFm() {
try {
const res = await axios.get('/api/music/personal/fm');
const songs: Song[] = res.data.songs;
if (songs.length > 0) {
await store.play(songs[0].name, songs[0].platform);
for (let i = 1; i < songs.length; i++) {
await store.addToQueue(songs[i].name, songs[i].platform);
}
}
} catch {
// Ignore
}
}
onMounted(() => {
store.fetchHomeData();
});
</script>
```
Note: The 我的歌单 template uses `userSource` (not `userSourceSafe`) on the `<SourceTabs>` v-model so the user's click maps directly to the persisted ref. The grid below the tabs uses `currentUserPlaylists` which derives from `userSourceSafe`, so even if `userSource` points at an unavailable platform momentarily, the grid still renders something sensible. Same pattern for 推荐歌单 / 每日推荐.
---
## Task 4: Migrate Library.vue and remove dead code
**Files:**
- Modify: `web/src/views/Library.vue`
- [ ] **Step 4.1: Replace the template**
**Find** the `<template>` block (currently lines 1-64) and **replace the entire template with:**
```vue
<template>
<div class="library-page">
<h1 class="page-title">音乐库</h1>
<!-- 我的歌单 -->
<section class="section" v-if="userAvailable.length > 0">
<h2 class="section-title">
我的歌单
<span class="section-count">{{ currentUserPlaylists.length }}</span>
<SourceTabs v-model="userSource" :sources="userAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in currentUserPlaylists"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
<div class="playlist-count">{{ pl.songCount }} 首</div>
</RouterLink>
</div>
</section>
<!-- 最近播放 -->
<section class="section">
<h2 class="section-title">最近播放</h2>
<div v-if="historyLoading" class="loading">加载中...</div>
<div v-else-if="history.length === 0" class="empty">暂无播放记录</div>
<div v-else class="song-list">
<SongCard
v-for="(song, i) in history.slice(0, 10)"
:key="`hist-${song.id}-${i}`"
:song="song"
:index="i + 1"
:active="store.currentSong?.id === song.id"
@play="store.play(song.name, song.platform)"
@add="store.addToQueue(song.name, song.platform)"
/>
</div>
</section>
<div v-if="!historyLoading && userAvailable.length === 0 && history.length === 0" class="empty-state">
<Icon icon="mdi:music-box-outline" class="empty-icon" />
<div>登录网易云或QQ音乐后,这里将显示你的歌单和播放记录</div>
</div>
</div>
</template>
```
Changes from the previous version:
- "我的歌单" section: same data binding pattern as Home (`userAvailable`, `currentUserPlaylists`, `<SourceTabs>`)
- "我的收藏" section: removed entirely (the `/api/music/user/liked` endpoint never existed)
- Empty state condition: replaced `liked.length === 0` with `userAvailable.length === 0`
- [ ] **Step 4.2: Replace the script block**
**Find** the `<script setup lang="ts">` block (currently lines 66-105) and **replace with:**
```ts
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { Icon } from '@iconify/vue';
import axios from 'axios';
import { usePlayerStore, type Song, type Source } from '../stores/player.js';
import { loadTabSource, saveTabSource } from '../stores/sourceTabs.js';
import CoverArt from '../components/CoverArt.vue';
import SongCard from '../components/SongCard.vue';
import SourceTabs from '../components/SourceTabs.vue';
const store = usePlayerStore();
const history = ref<Song[]>([]);
const historyLoading = ref(true);
const userAvailable = computed<Source[]>(() => {
const s: Source[] = [];
if (store.authStatus.netease) s.push('netease');
if (store.authStatus.qq) s.push('qq');
return s;
});
const userSource = ref<Source>(loadTabSource('library.user'));
watch(userSource, (v) => saveTabSource('library.user', v));
const userSourceSafe = computed<Source>(() =>
userAvailable.value.includes(userSource.value)
? userSource.value
: userAvailable.value[0] ?? 'netease'
);
const currentUserPlaylists = computed(() => store.userPlaylists[userSourceSafe.value] ?? []);
onMounted(async () => {
if (!store.activeBotId) {
await store.fetchBots();
}
store.fetchHomeData();
if (store.activeBotId) {
try {
const res = await axios.get(`/api/player/${store.activeBotId}/history`);
history.value = res.data.history ?? [];
} catch {
// API may not be ready
}
}
historyLoading.value = false;
});
</script>
```
Changes:
- Removed `liked` ref and the `/api/music/user/liked` axios call
- Added auth-driven `userAvailable`, persisted `userSource`, and `currentUserPlaylists` computed
- Imports `Source` type and `SourceTabs` component
- [ ] **Step 4.3: Style — `.section-title` already supports inline children**
The existing `.section-title` style (Library.vue and Home.vue both) already uses `display: flex; align-items: center; gap: 8px;`. SourceTabs uses `display: inline-flex` with its own `margin-left`, so it sits inline with the title and count. **No style changes are required in either Home.vue or Library.vue.**
---
## Task 5: Verify the build and types
- [ ] **Step 5.1: Run TypeScript backend type check**
```
npx tsc --noEmit
```
Expected: exit code 0, no output. (No backend files were touched.)
- [ ] **Step 5.2: Run web type check + production build**
```
npm run build:web
```
Expected: build completes with `✓ built in N.NNs` and no TypeScript errors. The script runs `vue-tsc --noEmit && vite build`, so failures here mean a type or template error in our changes.
- [ ] **Step 5.3: Run the existing test suite to confirm no regression**
```
npm test
```
Expected: same baseline as before this feature (`Test Files 2 failed | 26 passed (28)`, `Tests 2 failed | 161 passed (163)`). The 2 pre-existing failures are in `dist/` and `.claude/worktrees/` and are unrelated to our changes — they should remain at exactly 2.
If any **source-tree** test fails (anything not in `dist/` or `.claude/worktrees/`), stop and investigate.
---
## Task 6: Manual smoke test on the dev server
This task verifies behavior the type system can't catch.
- [ ] **Step 6.1: Start the dev server**
```
npm run dev
```
Wait for `Web server started` and `WebUI: http://localhost:3000` log lines.
- [ ] **Step 6.2: Test scenario A — only NetEase logged in**
Open http://localhost:3000 in a browser. Confirm:
- 推荐歌单 section displays NetEase playlists, **no tab bar visible** (single source, SourceTabs auto-hidden)
- 每日推荐 section: visible only if NetEase login provides daily songs; **no tab bar**
- 我的歌单 section: visible if NetEase has user playlists; **no tab bar**
- Navigate to `/library`: 我的歌单 section: same — no tab bar, NetEase playlists shown
Open DevTools → Application → Local Storage → `localhost:3000` → `source-tabs` should be absent or `{}` (no clicks happened).
- [ ] **Step 6.3: Test scenario B — both NetEase and QQ logged in**
If QQ is not logged in, log in via Settings → QQ Music → 扫码登录.
Hard reload the browser (Cmd/Ctrl+Shift+R) to bypass the 5-min `lastFetchTime` cache.
Confirm:
- 推荐歌单: tab bar shows `[网易云] [QQ]`, NetEase active by default
- Click `QQ` — playlist grid switches to QQ data, no flicker (data already in store)
- Click `网易云` — back to NetEase
- Same for 每日推荐 and 我的歌单
- Navigate to `/library`, confirm 我的歌单 has its own tab bar with independent state
- Reload the page — Home tabs and Library tab persist their last-selected source independently
Check `localStorage['source-tabs']`: should contain JSON with up to 4 keys (`home.recommend`, `home.daily`, `home.user`, `library.user`).
- [ ] **Step 6.4: Test scenario C — fallback when persisted source becomes unavailable**
While logged into both:
1. On Home, switch 推荐歌单 to `QQ`. Confirm `localStorage['source-tabs']['home.recommend'] === 'qq'`.
2. Go to Settings → log out of QQ.
3. Hard-reload Home.
Confirm:
- 推荐歌单 tab bar disappears (only NetEase available)
- Grid shows NetEase playlists (graceful fallback via `recommendSourceSafe`)
- `localStorage['source-tabs']['home.recommend']` is **still `'qq'`** (preference preserved)
4. Log back into QQ → reload → 推荐歌单 grid is QQ again (preference restored)
- [ ] **Step 6.5: Test mobile layout**
Open DevTools → Toggle device toolbar → set width to 375px (iPhone SE).
Confirm on Home and Library:
- Section title + tab bar fit on the same line without overflow
- Tab buttons are at least 36px tall (use Inspect → check computed `min-height`)
- Tabs are tappable (clicking still switches sources)
- [ ] **Step 6.6: Stop the dev server**
Kill the `npm run dev` process.
---
## Task 7: Final commit
- [ ] **Step 7.1: Stage and commit Task 2 + 3 + 4 changes**
```
git add web/src/stores/player.ts web/src/stores/sourceTabs.ts web/src/views/Home.vue web/src/views/Library.vue
git status
```
Expected `git status` output: 4 modified/new files staged, working tree otherwise clean (apart from pre-existing `.claude/worktrees/` and `test-ts6-version.cjs` untracked).
```
git commit -m "feat(web): per-platform source tabs on Home and Library
Recommend playlists, daily songs, and user playlists on Home now show
a [网易云][QQ] tab when both platforms are logged in. Library 我的歌单
gets the same tab. Selection persists per-section in localStorage and
falls back gracefully when the persisted source becomes unavailable
(e.g., user logged out). Removes dead 我的收藏 block from Library that
referenced a non-existent /api/music/user/liked endpoint.
Spec: docs/superpowers/specs/2026-05-06-music-source-tabs-design.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
- [ ] **Step 7.2: Verify commit landed**
```
git log --oneline -3
```
Expected:
```
<sha> feat(web): per-platform source tabs on Home and Library
<sha> feat(web): add SourceTabs component for platform switcher
<sha> docs: spec for multi-source tabs on Home and Library
```
---
## Done
The branch should now have 3 new commits on top of the merge commit, all green builds and tests, and the feature working in dev mode.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,520 @@
# Album Search & Playback Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Surface albums in search results and allow playing the whole album from the web UI. Currently `SearchResult.albums` is always `[]` and Search.vue only renders songs.
**Architecture:** Extend `search()` in netease + qq providers to populate `albums`. Aggregate them in `/search/all`. Add a new "专辑" (and "歌单") section to Search.vue. Reuse `Playlist.vue` as the album detail page by branching on `route.meta.kind` between `/playlist/:id` and `/album/:id` endpoints. The existing `getAlbumSongs(id)` and `/api/music/album/:id` endpoint already work.
**Tech Stack:** Node 20 + TS, Express 5, Vue 3 + Vue Router 4, axios. No new deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "专辑搜索".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/music/provider.ts` | Read-only | Verify `Album` and `SearchResult.albums` shape (no change expected) |
| `src/music/netease.ts` | Modify | `search()` adds a third parallel call (`type=10`) and maps albums |
| `src/music/qq.ts` | Modify | `search()` adds `req_album` section and maps albums |
| `src/music/netease.test.ts` | Modify | New tests for albums in search response |
| `src/music/qq.test.ts` | Modify (or create if absent) | Tests for albums in qq search |
| `src/web/api/music.ts` | Modify | `/search/all` returns `{songs, albums, playlists}` |
| `web/src/views/Search.vue` | Modify | Render albums + playlists sections |
| `web/src/views/Playlist.vue` | Modify | Branch endpoint by `route.meta.kind === 'album'` |
| `web/src/router/index.ts` | Modify | Add `/album/:id` route reusing Playlist component, set `meta.kind = 'album'` |
## Conventions
- TDD throughout. Each task: failing test → implement → verify → commit.
- Mock HTTP via existing fixtures pattern (look at `src/music/netease.test.ts` for setup).
- Keep all platform-specific quirks inside the provider class — no leaking into Search.vue logic.
---
### Task 1: netease.ts — fetch albums in search
**Files:**
- Modify: `src/music/netease.ts`
- Modify: `src/music/netease.test.ts`
- [ ] **Step 1: Read the existing test setup so we mock the same way**
```bash
grep -n 'cloudsearch\|MockAdapter\|axios.create\|mock\|nock\|fixture' src/music/netease.test.ts | head -20
```
- [ ] **Step 2: Write the failing test**
Append to `src/music/netease.test.ts` inside the existing `describe`:
```ts
it("populates SearchResult.albums from cloudsearch type=10", async () => {
// Adjust the fixture/mock helper to your existing test pattern.
// The test should: arrange a mock that returns a non-empty albums array
// for type=10, run search(), assert result.albums has the expected shape.
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 10 }) }).reply(200, {
result: {
albums: [
{ id: 42, name: "Album A", picUrl: "https://x/p.jpg", artists: [{ name: "Artist X" }] },
],
},
});
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1 }) }).reply(200, { result: { songs: [] } });
mockApi.onGet("/cloudsearch", { params: expect.objectContaining({ type: 1000 }) }).reply(200, { result: { playlists: [] } });
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "42", name: "Album A", artist: "Artist X", coverUrl: "https://x/p.jpg", platform: "netease" },
]);
});
```
If the existing tests use a different mock library (e.g. `msw` or manual axios stubbing), translate the fixture above to match. Do not introduce new test deps.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/netease.test.ts`
Expected: FAIL — `result.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/netease.ts` `search()` (~line 93), change the `Promise.all` from 2 to 3 calls:
```ts
const [songRes, playlistRes, albumRes] = await Promise.all([
this.api.get("/cloudsearch", { params: { keywords: query, type: 1, limit, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 1000, limit: 5, ...this.cookieParams } }),
this.api.get("/cloudsearch", { params: { keywords: query, type: 10, limit: 5, ...this.cookieParams } }),
]);
```
After the existing `playlists: Playlist[] = ...` mapping, add:
```ts
const albums: Album[] = (albumRes.data?.result?.albums ?? []).map((a: any) => ({
id: String(a.id),
name: a.name ?? "",
artist: (a.artists ?? []).map((x: any) => x.name).join(" / "),
coverUrl: a.picUrl ?? "",
platform: "netease",
}));
```
Update the `return { songs, playlists, albums: [] }` to `return { songs, playlists, albums }`.
(Make sure `Album` is imported from `./provider.js`; if not yet imported, add it to the existing import.)
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/netease.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/netease.ts src/music/netease.test.ts
git commit -m "feat(netease): include albums in search results"
```
---
### Task 2: qq.ts — fetch albums in search
**Files:**
- Modify: `src/music/qq.ts`
- Modify or Create: `src/music/qq.test.ts`
- [ ] **Step 1: Verify whether qq.test.ts exists**
```bash
ls src/music/qq.test.ts
```
If absent, create a minimal one mirroring `netease.test.ts` style: instantiate provider, mock the `qqDirectApi` axios instance, assert `r.albums.length > 0` after a `search()` call.
- [ ] **Step 2: Write the failing test**
Add to `src/music/qq.test.ts`:
```ts
it("populates SearchResult.albums from a parallel album search request", async () => {
// Mock returns an album list under req_album.data.body.album.list
mockApi.onGet("/cgi-bin/musicu.fcg").reply((cfg) => {
const data = JSON.parse(cfg.params?.data ?? "{}");
if (data.req_album) {
return [200, { req_album: { data: { body: { album: { list: [
{ albumMID: "abc", albumName: "Aero", singerName: "S", albumPic: "https://x/p.jpg" },
] } } } } }];
}
if (data.req_0) {
return [200, { req_0: { data: { body: { song: { list: [] } } } } }];
}
return [200, {}];
});
const provider = makeProvider();
const r = await provider.search("foo", 5);
expect(r.albums).toEqual([
{ id: "abc", name: "Aero", artist: "S", coverUrl: expect.stringContaining("https://"), platform: "qq" },
]);
});
```
Verify the actual QQ API response shape against a real call before finalizing the field names — `albumMID` vs `mid`, `albumPic` vs `pic`, etc. If unsure, log a real response once and freeze the shape in the fixture.
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/music/qq.test.ts`
Expected: FAIL — `r.albums` is `[]`
- [ ] **Step 4: Implement the change**
In `src/music/qq.ts` `search()` (~line 63), change `reqData` to include both `req_0` (songs) and `req_album` (albums):
```ts
const reqData = JSON.stringify({
req_0: {
module: "music.search.SearchCgiService",
method: "DoSearchForQQMusicDesktop",
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 },
},
});
```
After the existing `songs` mapping, add:
```ts
const albumList: any[] = res.data?.req_album?.data?.body?.album?.list ?? [];
const albums: Album[] = albumList.map((a: any) => ({
id: String(a.albumMID ?? a.mid ?? a.albumID ?? ""),
name: a.albumName ?? a.title ?? "",
artist: a.singerName ?? (a.singer ?? []).map((s: any) => s.name).join(" / "),
coverUrl: a.albumMID
? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${a.albumMID}.jpg`
: (a.albumPic ?? ""),
platform: "qq",
}));
```
Change `return { songs, playlists: [], albums: [] }` to `return { songs, playlists: [], albums }`.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/music/qq.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add src/music/qq.ts src/music/qq.test.ts
git commit -m "feat(qq): include albums in search results"
```
---
### Task 3: /search/all — aggregate albums + playlists
**Files:**
- Modify: `src/web/api/music.ts`
- [ ] **Step 1: Look at the current aggregation**
In `src/web/api/music.ts` near line 40 the `/search/all` handler builds only `songs`. Extend it.
- [ ] **Step 2: Write a failing integration test (if test infra allows)**
If there's already a test file for music.ts, add a test that mocks the providers and asserts `res.body.albums.length > 0`. If not, skip and rely on Task 1+2 unit coverage + manual verification in Task 4.
- [ ] **Step 3: Aggregate albums + playlists**
Replace the existing `songs = ...` block + `res.json({ songs })` at lines ~54–62 with:
```ts
const 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, albums, playlists });
```
(Bilibili intentionally skipped for albums/playlists — no album concept; playlists likewise minor.)
- [ ] **Step 4: Verify by curl**
Build + run, then:
```bash
curl -s 'http://localhost:3000/api/music/search/all?q=Beyond' \
| python3 -c 'import json,sys;d=json.load(sys.stdin);print({k: len(v) for k, v in d.items()})'
```
Expected: `{'songs': N>0, 'albums': N>0, 'playlists': N>=0}`
- [ ] **Step 5: Commit**
```bash
git add src/web/api/music.ts
git commit -m "feat(api): /search/all returns albums and playlists"
```
---
### Task 4: Album route reusing Playlist.vue
**Files:**
- Modify: `web/src/router/index.ts`
- Modify: `web/src/views/Playlist.vue`
- [ ] **Step 1: Look at the current router config and Playlist load logic**
```bash
grep -n "path:\|component:\|meta" web/src/router/index.ts
grep -n "loadPlaylist\|/api/music/playlist\|onMounted" web/src/views/Playlist.vue
```
- [ ] **Step 2: Add /album/:id route**
In `web/src/router/index.ts`, find the `/playlist/:id` route entry. Right after it, add:
```ts
{
path: '/album/:id',
component: () => import('../views/Playlist.vue'),
meta: { kind: 'album' },
},
```
(If `/playlist/:id` is `meta:`-less, also add `meta: { kind: 'playlist' }` to it for symmetry.)
- [ ] **Step 3: Branch the endpoint inside Playlist.vue**
Find the load function (probably `onMounted(async () => { axios.get('/api/music/playlist/' + id, ...) })`). Refactor:
```ts
const route = useRoute();
const kind = (route.meta.kind as string) ?? 'playlist'; // 'playlist' | 'album'
const endpoint = kind === 'album' ? '/api/music/album/' : '/api/music/playlist/';
// ... use `${endpoint}${route.params.id}` ...
```
For the hero metadata, the playlist endpoint returns `{songs}` only (no top-level cover/title) — verify what the Album endpoint currently returns. If both only return `{songs}`, the existing Playlist.vue must already derive the cover from somewhere (probably the first song's coverUrl, or an additional `/api/music/playlist/:id/detail` call). Keep the existing pattern; if a separate detail call is needed for albums, fetch the metadata from `/api/music/song/<firstSong.id>` to get the album name + cover, OR add a thin `/api/music/album/:id/detail` endpoint that returns `{ name, coverUrl, description }`.
**Decision:** if Playlist.vue currently uses ONLY `/api/music/playlist/:id` and derives metadata from songs, do the same for albums (no new endpoint). If it calls a separate detail endpoint, add a matching `/api/music/album/:id/detail` returning `{ name, coverUrl }` from the first song's `album` and `coverUrl` fields.
- [ ] **Step 4: Verify in browser**
Run `cd web && npm run dev`. Visit `/album/<some-netease-album-id>` (pick one from a search). Expect: hero header + song list + play-all button — same UX as a playlist page.
- [ ] **Step 5: Commit**
```bash
git add web/src/router/index.ts web/src/views/Playlist.vue
git commit -m "feat(web): /album/:id route reusing Playlist view"
```
---
### Task 5: Search.vue — render albums + playlists sections
**Files:**
- Modify: `web/src/views/Search.vue`
- [ ] **Step 1: Read current Search.vue**
```bash
sed -n '1,120p' web/src/views/Search.vue
```
Identify: the `results.value = res.data.songs` line and the `<div v-else-if="results.length > 0">` block.
- [ ] **Step 2: Refactor to three result lists**
Replace the script:
```ts
import type { Song } from '../stores/player.js';
interface Album { id: string; name: string; artist: string; coverUrl: string; 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);
async function doSearch() {
if (!query.value.trim()) return;
loading.value = true;
searched.value = true;
try {
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 {
songs.value = []; albums.value = []; playlists.value = [];
} finally {
loading.value = false;
}
}
```
- [ ] **Step 3: Render the sections**
Replace the existing `<div v-else-if="results.length > 0" class="results">` block:
```vue
<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>
```
(Import `CoverArt`: `import CoverArt from '../components/CoverArt.vue';`.)
- [ ] **Step 4: Add minimal styles**
Append to the `<style lang="scss" scoped>` block:
```scss
.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; }
}
```
- [ ] **Step 5: Build + visually verify**
```bash
cd web && npm run build
```
Then `npm run dev` → search "周杰伦" → see three sections; click an album card → arrives at `/album/:id` with songs + play-all.
- [ ] **Step 6: Commit**
```bash
git add web/src/views/Search.vue
git commit -m "feat(web): show album + playlist sections in search"
```
---
### Task 6: Open PR
- [ ] **Step 1: Push branch**
```bash
git checkout -b feat/album-search
git push -u origin feat/album-search
```
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(search): album section + album playback" --body "Closes part of #51 (album half).
## Summary
- netease.search() / qq.search() now populate SearchResult.albums
- /api/music/search/all returns albums + playlists alongside songs
- Search.vue renders three sections: 专辑 / 歌单 / 单曲
- /album/:id route reuses Playlist.vue with meta.kind='album'
- bilibili / youtube intentionally still return albums:[] (no album API)
## Test plan
- [x] vitest covers netease + qq search returning non-empty albums
- [x] curl /search/all?q=周杰伦 returns {songs, albums, playlists}
- [x] Manual: search → click album card → /album/:id → play all
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Spec coverage: backend album search → Tasks 1+2; aggregator → Task 3; album detail route → Task 4; UI sections → Task 5
- [x] No "TBD"/placeholder text — every step shows the actual diff or command
- [x] Type names consistent: `Album` (capital A), `albums` (lowercase plural), `SearchResult.albums`
- [x] Bilibili/YouTube explicitly out of scope per spec — confirmed in Task 3 by skipping them in albums aggregation
- [x] Routes use `meta.kind` — same key referenced in Playlist.vue (Task 4) and `/album/:id` registration (Task 4 Step 2)
@@ -0,0 +1,903 @@
# Custom Bot Avatar Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let users upload a fixed avatar per bot. When `avatarEnabled=true`, the avatar follows the song cover during playback and reverts to the custom avatar (instead of clearing) on stop. When `avatarEnabled=false` and a custom avatar exists, the bot always shows the custom avatar.
**Architecture:** New SQLite column stores a relative file path; bytes live on disk under `data/avatars/<botId>.<ext>` (mirrors `data/cookies/`). `BotProfileManager` gains a `customAvatar` Buffer; the existing `clearAvatar()` becomes "restore custom or clear"; `onConnect()` immediately applies the custom avatar when sync is off. Three new REST endpoints (GET/PUT/DELETE) under `/api/bot/:id/avatar` accept base64 JSON (avoids adding multer; bump `express.json()` limit).
**Tech Stack:** Node 20 + TS + Express 5, better-sqlite3, Vue 3 + axios. No new runtime deps.
---
## Spec Reference
`docs/superpowers/specs/2026-05-07-custom-avatar-and-album-search-design.md` — section "自定义头像".
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `src/data/database.ts` | Modify | Add `custom_avatar_path` column + migration + accessor methods |
| `src/data/avatars.ts` | **Create** | Read/write/delete avatar files under `data/avatars/` |
| `src/bot/profile.ts` | Modify | `customAvatar` field, `setCustomAvatar`, `applyIdleAvatar`, modify `clearAvatar`, modify `onConnect` |
| `src/bot/instance.ts` | Modify | Load custom avatar on start, pass to ProfileManager |
| `src/web/api/bot.ts` | Modify | Add GET/PUT/DELETE `/avatar` endpoints |
| `src/web/server.ts` | Modify | Bump `express.json()` limit to `400kb` |
| `src/index.ts` | Modify | Pass `AVATAR_DIR` to bot manager / API router |
| `src/data/database.test.ts` | Modify | Test custom avatar path persistence + migration idempotency |
| `src/data/avatars.test.ts` | **Create** | Unit tests for avatar store |
| `src/bot/profile.test.ts` | **Create** | Tests for new precedence logic with a mock TS3Client |
| `web/src/components/AvatarUpload.vue` | **Create** | Reusable avatar picker + preview + delete |
| `web/src/views/Settings.vue` | Modify | Add custom avatar row in profile features list; insert into create-bot and edit-bot forms |
## Conventions
- TDD: failing test → implement → verify → commit, every step.
- Commits use conventional format: `feat(profile):`, `feat(api):`, `feat(web):`, `test(...)`. Each task ends with one commit.
- Tests live in vitest (`npm test`).
- All paths absolute or relative to repo root.
---
### Task 1: DB migration + getter/setter for custom avatar path
**Files:**
- Modify: `src/data/database.ts`
- Modify: `src/data/database.test.ts`
- [ ] **Step 1: Write the failing test**
Add to `src/data/database.test.ts` after the existing tests (find the closing `});` of the last test case in the `describe` block, insert before it):
```ts
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/database.test.ts`
Expected: FAIL — `botDb.getCustomAvatarPath is not a function`
- [ ] **Step 3: Add the column to migration + interface + statements**
In `src/data/database.ts`:
1. Find `BotDatabase` interface (~line 54), add two methods before `close()`:
```ts
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
```
2. Find `migrateSchema()` (~line 66). After the `for (const col of profileCols)` loop, append:
```ts
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
```
3. In `createDatabase()` after the existing `prepare(...)` calls (~line 180), add:
```ts
const selectCustomAvatar = db.prepare(
`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`,
);
const updateCustomAvatar = db.prepare(
`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`,
);
```
4. Inside the returned object, add (before `close()`):
```ts
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/database.test.ts`
Expected: PASS — all tests including the new one
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/database.test.ts
git commit -m "feat(db): custom_avatar_path column + accessors"
```
---
### Task 2: Avatar storage helper
**Files:**
- Create: `src/data/avatars.ts`
- Create: `src/data/avatars.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/data/avatars.test.ts`:
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(
/unsupported/i,
);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: FAIL — module not found
- [ ] **Step 3: Implement the store**
Create `src/data/avatars.ts`:
```ts
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
// Remove any existing avatar for this bot regardless of extension.
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npx vitest run src/data/avatars.test.ts`
Expected: PASS — all 7 tests
- [ ] **Step 5: Commit**
```bash
git add src/data/avatars.ts src/data/avatars.test.ts
git commit -m "feat(data): avatar file store helper"
```
---
### Task 3: BotProfileManager — custom avatar precedence
**Files:**
- Modify: `src/bot/profile.ts`
- Create: `src/bot/profile.test.ts`
- [ ] **Step 1: Write the failing test**
Create `src/bot/profile.test.ts`:
```ts
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h, _i, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("on stop with custom avatar set + sync on, uploads custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
const custom = Buffer.from([1, 2, 3, 4]);
pm.setCustomAvatar(custom);
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(custom)).toBe(true);
expect(ts.clearCalls).toBe(0);
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with sync off + custom avatar set, applies custom immediately", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with sync off + no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
await pm.onConnect();
expect(ts.uploadCalls.length).toBe(0);
expect(ts.clearCalls).toBe(0);
});
it("setCustomAvatar(null) makes subsequent onSongChange(null) clear again", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
pm.setCustomAvatar(null);
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: FAIL — `pm.setCustomAvatar is not a function` and/or `onConnect` not exported
- [ ] **Step 3: Look at the existing profile.ts to understand `onConnect` shape**
Run: `grep -n 'onConnect\|public async\|public ' src/bot/profile.ts | head -10`
`onConnect` likely already exists; if not, locate where reconnect resets state. Add or extend it.
- [ ] **Step 4: Implement `customAvatar`, `setCustomAvatar`, `applyIdleAvatar`; modify `clearAvatar` and `onConnect`**
In `src/bot/profile.ts`:
1. Inside the class, add fields next to `defaultNickname` (around line 27):
```ts
private customAvatar: Buffer | null = null;
```
2. After the `constructor`, add:
```ts
/** Set/clear the persistent idle avatar. Pass null to remove. */
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
}
```
3. Find `clearAvatar()` (~line 173). Change the body so that if `this.customAvatar` is set, we upload it instead of clearing the flag. Replace the existing method with:
```ts
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
4. Add a new private method right below `clearAvatar`:
```ts
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
```
5. Find `onConnect` (the existing method that resets per-feature flags). At its end, immediately after the `permDenied` reset, add:
```ts
if (!this.config.avatarEnabled && this.customAvatar) {
const gen = ++this.generation;
void this.applyIdleAvatar(gen);
}
```
If `onConnect` does not exist as a method, search for where reconnect resets `permDenied` and add the block there.
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/bot/profile.test.ts`
Expected: PASS — 5/5
Run also: `npx vitest run src/audio src/data src/bot` — confirm no regressions.
- [ ] **Step 6: Commit**
```bash
git add src/bot/profile.ts src/bot/profile.test.ts
git commit -m "feat(profile): custom avatar with idle/playback precedence"
```
---
### Task 4: Wire avatar load on bot start
**Files:**
- Modify: `src/bot/instance.ts`
- Modify: `src/bot/manager.ts` (if it constructs the instance)
- Modify: `src/index.ts`
- [ ] **Step 1: Confirm where `BotProfileManager` is constructed and how `BotInstance` receives DB**
Run: `grep -n 'new BotProfileManager\|profileManager =\|database\|botDb' src/bot/instance.ts src/bot/manager.ts | head -20`
Identify the BotInstance constructor params and verify that the DB and the avatar dir can flow in.
- [ ] **Step 2: Add `AVATAR_DIR` constant + `avatarStore` to `src/index.ts`**
Find where `COOKIE_DIR` / `createCookieStore` are set up (~line 48 in src/index.ts) and add directly after:
```ts
const AVATAR_DIR = process.env.AVATAR_DIR ?? join(DATA_DIR, "avatars");
const avatarStore = createAvatarStore(AVATAR_DIR);
```
(import as needed: `import { createAvatarStore } from "./data/avatars.js";`)
Pass `avatarStore` through to whatever constructs `BotManager` (and from there to `BotInstance`).
- [ ] **Step 3: In `BotInstance`, after `profileManager` is created, load the avatar from disk if any**
In `src/bot/instance.ts`, after `this.profileManager = new BotProfileManager(...)`:
```ts
const relPath = this.botDb.getCustomAvatarPath(this.id);
if (relPath) {
const buf = this.avatarStore.read(relPath);
if (buf) this.profileManager.setCustomAvatar(buf);
}
```
(Add `private botDb: BotDatabase` and `private avatarStore: AvatarStore` constructor params; thread them down from `BotManager.createBot()` / `BotManager` constructor.)
- [ ] **Step 4: Add `getProfileManager()` accessor if not present**
If grep already shows `getProfileManager(): BotProfileManager`, skip. Otherwise add a public method that returns `this.profileManager`.
- [ ] **Step 5: Build and run the existing tests**
Run: `npx tsc --noEmit`
Expected: no TS errors
Run: `npm test`
Expected: all green
- [ ] **Step 6: Commit**
```bash
git add src/index.ts src/bot/instance.ts src/bot/manager.ts
git commit -m "feat(bot): load custom avatar on instance startup"
```
---
### Task 5: REST endpoints for avatar upload / fetch / delete
**Files:**
- Modify: `src/web/server.ts` (json size limit)
- Modify: `src/web/api/bot.ts`
- [ ] **Step 1: Bump express.json size limit**
In `src/web/server.ts`, find `app.use(express.json())` (~line 46) and change to:
```ts
app.use(express.json({ limit: "400kb" }));
```
(Avatar payload is base64-encoded ≤200 KB → ~270 KB on the wire; 400 KB gives margin.)
- [ ] **Step 2: Write a failing API test (use supertest if not present, otherwise inline fetch)**
Run: `grep -E '"supertest"|"vitest"' package.json`
If supertest is not present, write the test using `node:http` raw client or skip API integration test and rely on manual + unit tests on Task 7. Don't add new deps unless approved.
If supertest IS present, add `src/web/api/bot.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import request from "supertest";
import express from "express";
import { createBotRouter } from "./bot.js";
// ... build minimal app with mocked manager + DB + avatarStore
```
If not present: skip Step 2, jump to Step 3 and verify by manual curl in Step 5.
- [ ] **Step 3: Add the three endpoints**
In `src/web/api/bot.ts`, modify the factory signature to accept `avatarStore` and `botDb`:
```ts
export function createBotRouter(
botManager: BotManager,
config: BotConfig,
configPath: string,
logger: Logger,
botDb: BotDatabase,
avatarStore: AvatarStore,
): Router {
```
Inside the router, after the existing `/:id/config` GET, add:
```ts
router.get("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (!path) { res.status(404).end(); return; }
const buf = avatarStore.read(path);
if (!buf) { res.status(404).end(); return; }
const ext = path.split(".").pop()!;
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
res.set("Content-Type", mime);
res.set("Cache-Control", "no-cache");
res.send(buf);
});
router.put("/:id/avatar", (req, res) => {
const bot = botManager.getBot(req.params.id);
if (!bot && !botDb.getBotInstances().some((b) => b.id === req.params.id)) {
res.status(404).json({ error: "Bot not found" });
return;
}
const { dataUrl } = req.body as { dataUrl?: string };
if (typeof dataUrl !== "string") {
res.status(400).json({ error: "dataUrl required" });
return;
}
const m = /^data:(image\/(png|jpeg|webp));base64,(.+)$/.exec(dataUrl);
if (!m) {
res.status(400).json({ error: "dataUrl must be image/png|jpeg|webp base64" });
return;
}
const mime = m[1];
const buf = Buffer.from(m[3], "base64");
if (buf.length > 200 * 1024) {
res.status(413).json({ error: "avatar exceeds 200KB limit" });
return;
}
const rel = avatarStore.write(req.params.id, mime, buf);
botDb.setCustomAvatarPath(req.params.id, rel);
bot?.getProfileManager().setCustomAvatar(buf);
res.json({ path: rel });
});
router.delete("/:id/avatar", (req, res) => {
const path = botDb.getCustomAvatarPath(req.params.id);
if (path) avatarStore.remove(path);
botDb.setCustomAvatarPath(req.params.id, null);
const bot = botManager.getBot(req.params.id);
bot?.getProfileManager().setCustomAvatar(null);
res.status(204).end();
});
```
- [ ] **Step 4: Update the call site that constructs the router**
Search: `grep -n 'createBotRouter' src/`
In the call site (likely `src/web/server.ts` or `src/index.ts`), pass the new args. Fix the call signature.
- [ ] **Step 5: Manual smoke test**
Run: `npm run build && npm run start`
In another terminal:
```bash
# create a small valid PNG (1x1) base64
B64=$(node -e "console.log(Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,8,153,99,248,255,255,63,0,5,254,2,254,205,250,236,184,0,0,0,0,73,69,78,68,174,66,96,130]).toString('base64'))")
curl -X PUT http://localhost:3000/api/bot/<BOT_ID>/avatar \
-H 'Content-Type: application/json' \
-d "{\"dataUrl\":\"data:image/png;base64,$B64\"}"
curl http://localhost:3000/api/bot/<BOT_ID>/avatar -o /tmp/x.png
file /tmp/x.png
curl -X DELETE http://localhost:3000/api/bot/<BOT_ID>/avatar -i
```
Expected: PUT returns `{"path":"<id>.png"}`, GET returns the bytes, DELETE returns 204.
- [ ] **Step 6: Commit**
```bash
git add src/web/server.ts src/web/api/bot.ts src/index.ts
git commit -m "feat(api): /api/bot/:id/avatar GET/PUT/DELETE"
```
---
### Task 6: Frontend — `AvatarUpload.vue` component
**Files:**
- Create: `web/src/components/AvatarUpload.vue`
- [ ] **Step 1: Create the component**
```vue
<template>
<div class="avatar-upload">
<div class="preview" :class="{ empty: !previewUrl }">
<img v-if="previewUrl" :src="previewUrl" alt="avatar" />
<Icon v-else icon="mdi:account-circle-outline" />
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/webp"
class="hidden"
@change="onFile"
/>
<button type="button" class="btn-sm" @click="fileInput?.click()">
{{ previewUrl ? '更换' : '上传' }}
</button>
<button v-if="previewUrl" type="button" class="btn-sm btn-danger" @click="clear">
删除
</button>
</div>
<p v-if="error" class="hint error">{{ error }}</p>
<p v-else class="hint">PNG / JPG / WebP,≤200 KB</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
const props = defineProps<{ modelValue: string | null }>();
const emit = defineEmits<{ 'update:modelValue': [value: string | null] }>();
const previewUrl = ref<string | null>(props.modelValue);
const error = ref<string | null>(null);
const fileInput = ref<HTMLInputElement | null>(null);
watch(() => props.modelValue, (v) => { previewUrl.value = v; });
function onFile(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
error.value = '仅支持 PNG / JPG / WebP';
return;
}
if (file.size > 200 * 1024) {
error.value = `图片 ${(file.size / 1024).toFixed(0)} KB 超过 200 KB 上限`;
return;
}
error.value = null;
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result as string;
previewUrl.value = dataUrl;
emit('update:modelValue', dataUrl);
};
reader.readAsDataURL(file);
}
function clear() {
previewUrl.value = null;
emit('update:modelValue', null);
if (fileInput.value) fileInput.value.value = '';
}
</script>
<style lang="scss" scoped>
.avatar-upload { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
.preview {
width: 80px; height: 80px; border-radius: 50%;
background: var(--bg-card); display: flex; align-items: center; justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
&.empty :deep(svg) { font-size: 48px; opacity: 0.4; }
}
.actions { display: flex; gap: 8px; }
.hidden { display: none; }
.hint { font-size: 12px; opacity: 0.6; margin: 0; }
.hint.error { color: var(--color-danger, #e85060); opacity: 1; }
.btn-danger { color: var(--color-danger, #e85060); }
</style>
```
- [ ] **Step 2: Verify the component compiles**
Run: `cd web && npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add web/src/components/AvatarUpload.vue
git commit -m "feat(web): AvatarUpload component"
```
---
### Task 7: Wire AvatarUpload into Settings.vue (create + edit + standalone row)
**Files:**
- Modify: `web/src/views/Settings.vue`
- [ ] **Step 1: Read the relevant Settings.vue regions**
```bash
grep -n '同步头像\|openEditBot\|saveEditBot\|createBot\|create-bot\|profile-features\|features.find' web/src/views/Settings.vue | head -20
```
Identify:
- Create-bot form template region (`<div class="create-bot">` block)
- Edit-bot modal/dialog template region
- The profile features table where `avatarEnabled` row lives
- [ ] **Step 2: Add component import + reactive state for avatar dataUrl on the create-bot form**
In the script setup region, near other `newBot*` refs:
```ts
import AvatarUpload from '../components/AvatarUpload.vue';
const newBotAvatar = ref<string | null>(null);
```
- [ ] **Step 3: Insert `<AvatarUpload v-model="newBotAvatar" />` into the create-bot form template**
In the `<div class="create-bot">` block, right before `<button class="btn-primary" @click="createBot">创建</button>`, add:
```vue
<div class="form-row">
<label>自定义头像(可选)</label>
<AvatarUpload v-model="newBotAvatar" />
</div>
```
- [ ] **Step 4: After successful `createBot()`, PUT the avatar if set**
Find the `createBot` async function. After the POST resolves and the bot id is known (`res.data.id` or similar), append:
```ts
if (newBotAvatar.value) {
await axios.put(`/api/bot/${res.data.id}/avatar`, { dataUrl: newBotAvatar.value });
}
newBotAvatar.value = null;
```
- [ ] **Step 5: Add an "自定义头像" row in the per-bot profile features table**
Find the profile-features table render (look for the `features` array iteration). The cleanest path: add a custom row OUTSIDE the array (since it isn't a boolean toggle). Right before `</template>` of the bot row, add:
```vue
<div class="feature-row">
<div class="feature-label">自定义头像</div>
<div class="feature-control">
<CustomAvatarRow :bot-id="bot.id" />
</div>
</div>
```
Where `CustomAvatarRow` is an inline-defined component or a small file `web/src/components/CustomAvatarRow.vue` that:
- Mounts → `axios.get(/api/bot/<id>/avatar, { responseType: 'blob' })` → previews if 200, ignore 404
- Wraps `<AvatarUpload>` and on `update:modelValue`:
- If string → `axios.put(/avatar, { dataUrl })`
- If null → `axios.delete(/avatar)`
Create `web/src/components/CustomAvatarRow.vue` with that logic; keep its body small (~50 lines).
- [ ] **Step 6: Build and visually verify**
Run: `cd web && npm run build` → no errors. Then `npm run dev` → open create-instance, upload PNG, create — verify the avatar appears on the bot in TS3 once it connects. Check edit/Settings flow.
- [ ] **Step 7: Commit**
```bash
git add web/src/views/Settings.vue web/src/components/CustomAvatarRow.vue
git commit -m "feat(web): custom avatar in create-bot + Settings"
```
---
### Task 8: Open PR
- [ ] **Step 1: Push the branch**
```bash
git checkout -b feat/custom-bot-avatar
git push -u origin feat/custom-bot-avatar
```
(If commits were already on `main`, instead create the branch from the first relevant commit and reset main: `git branch feat/custom-bot-avatar HEAD && git reset --hard origin/main && git checkout feat/custom-bot-avatar`. The exact sequence depends on the working state when starting.)
- [ ] **Step 2: Create the PR**
```bash
gh pr create --title "feat(profile): custom bot avatar" --body "Closes part of #51 (avatar half).
## Summary
- New /api/bot/:id/avatar GET/PUT/DELETE
- BotProfileManager: custom avatar acts as idle image; cover sync still wins during playback when avatarEnabled=true
- AvatarUpload component used in create-bot form and Settings per-bot row
- Bump express.json limit to 400kb to allow base64 payload
## Behavior matrix
| avatarEnabled | custom set | playing | stopped |
|---|---|---|---|
| ✓ | ✓ | cover | restore custom |
| ✓ | ✗ | cover | clear |
| ✗ | ✓ | custom | custom |
| ✗ | ✗ | no-op | no-op |
## Test plan
- [x] vitest covers DB, avatar store, ProfileManager precedence
- [x] Manual: upload PNG → bot avatar shows; play song → cover; stop → custom; delete → cleared
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
```
---
## Self-Review Checklist
- [x] Each spec section has at least one task: precedence matrix → Task 3; storage → Task 2; DB → Task 1; API → Task 5; UI → Task 6+7
- [x] No "TBD" / "fill in" / "implement later" text in any step
- [x] Type names consistent: `AvatarStore` / `createAvatarStore` / `getCustomAvatarPath` / `setCustomAvatarPath` / `setCustomAvatar` (singular per call site)
- [x] All code blocks compile under existing TS/Vue config (express 5, vitest, vue 3 + iconify already in use)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,811 @@
# Account Permissions Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an admin grant each member account a set of capabilities and a list of bots they may control, enforced on the backend.
**Architecture:** Capability tokens + per-member bot allow-list stored in two new SQLite tables, loaded onto `req.user` per request (live, no re-login), enforced by `requirePermission` / `requireBotAccess` middleware mirroring the existing `requireAdmin`. Admin stays a super-user. Existing members are backfilled to full access on upgrade; new members get a basic tier. The Vue UI hides what a member can't do and gives admins a permission editor.
**Tech Stack:** Node ESM + TypeScript, Express, better-sqlite3, Vitest + supertest, Vue 3 + Pinia.
**Spec:** `docs/superpowers/specs/2026-05-30-account-permissions-design.md`
**Conventions:** All file paths are repo-relative. Tests run with `npx vitest run <path>`. Backend is TDD (test first, watch fail, implement, watch pass, commit). Commit after each task.
---
## File Structure
**Create:**
- `src/data/permissions.ts` — capability constants + `PermissionStore` (tables accessed here)
- `src/data/permissions.test.ts` — store + constants tests
- `src/web/middleware/requirePermission.ts` — `requirePermission(cap)` + `requireBotAccess(param)`
- `src/web/middleware/requirePermission.test.ts` — middleware tests
**Modify:**
- `src/data/database.ts` — `initTables`: add the two tables + index; migration backfill of existing members
- `src/data/audit.ts` — add `"user.permissions_changed"` to `AuditAction`
- `src/web/middleware/requireAuth.ts` — widen `req.user`; load capabilities + bot access
- `src/web/auth/validateSession.ts` — (no change; just confirm) — actually unchanged
- `src/web/api/session.ts` — `/me` returns capabilities + bots; inline auth attaches them
- `src/web/server.ts` — construct `PermissionStore`, pass into routers/middleware
- `src/web/api/player.ts` — `requireBotAccess` on `/:botId`; per-route `requirePermission`
- `src/web/api/bot.ts` — `requirePermission("bot.manage")` + `requireBotAccess("id")`
- `src/web/api/auth.ts` — `requirePermission("platform.auth")`
- `src/web/api/music.ts` — `requirePermission("quality")` on the quality POST; filter `GET /api/bot`? no — bot list is in bot.ts
- `src/web/api/bot.ts` — filter `GET /` to allowed bots for members
- `src/web/api/users.ts` — `GET/PUT /api/users/:id/permissions`
- `src/bot/manager.ts` — `removeBot` calls `permissions.pruneBot(botId)`
- Frontend: `web/src/composables/useSession.ts`, `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
---
## Task 1: Capability constants + PermissionStore + tables
**Files:**
- Create: `src/data/permissions.ts`
- Create: `src/data/permissions.test.ts`
- Modify: `src/data/database.ts` (initTables)
- [ ] **Step 1: Write the failing test**
`src/data/permissions.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
// a user row is required for FK; insert directly
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `createPermissionStore` / `CAPABILITIES` not found (module missing).
- [ ] **Step 3: Create `src/data/permissions.ts`**
```typescript
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
```
- [ ] **Step 4: Add tables in `src/data/database.ts` initTables**
Find `initTables` (creates users/sessions/user_audit). Add, after the `user_audit` CREATE:
```typescript
db.exec(`
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
`);
```
(If `initTables` uses individual `db.exec` calls, match that style. The `BotDatabase` type already exposes `.db` and `.close()` — confirm by reading the file; the test uses `db.db` and `db.close()`.)
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS (7 tests).
- [ ] **Step 6: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts src/data/database.ts
git commit -m "feat(perm): permission store + capability tokens + tables"
```
---
## Task 2: requirePermission + requireBotAccess middleware
**Files:**
- Create: `src/web/middleware/requirePermission.ts`
- Create: `src/web/middleware/requirePermission.test.ts`
- Modify: `src/web/middleware/requireAuth.ts` (widen `req.user`)
- [ ] **Step 1: Widen the `req.user` augmentation in `src/web/middleware/requireAuth.ts`**
Change the `declare module` block so `req.user` carries capabilities + bot access:
```typescript
declare module "express-serve-static-core" {
interface Request {
user?: {
id: string;
username: string;
role: "admin" | "member";
capabilities: Set<string>;
bots: "all" | Set<string>;
};
}
}
```
(The loading of these fields is done in Task 4 — for now this only widens the type. Existing assignments to `req.user` will fail to typecheck until Task 4; that is expected and Task 4 fixes them. If you need the build green between tasks, do Task 2 + Task 4 back-to-back before running `tsc`.)
- [ ] **Step 2: Write the failing middleware test**
`src/web/middleware/requirePermission.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import express from "express";
import request from "supertest";
import { requirePermission, requireBotAccess } from "./requirePermission.js";
function appWith(user: any) {
const app = express();
app.use((req, _res, next) => { (req as any).user = user; next(); });
app.post("/cap", requirePermission("quality"), (_req, res) => res.json({ ok: true }));
app.post("/bot/:botId", requireBotAccess("botId"), (_req, res) => res.json({ ok: true }));
return app;
}
const member = (caps: string[], bots: "all" | string[]) => ({
id: "u1", username: "a", role: "member",
capabilities: new Set(caps), bots: bots === "all" ? "all" : new Set(bots),
});
const admin = { id: "a", username: "admin", role: "admin", capabilities: new Set(), bots: "all" };
describe("requirePermission", () => {
it("401 when unauthenticated", async () => {
const app = express();
app.post("/cap", requirePermission("quality"), (_r, res) => res.json({ ok: true }));
expect((await request(app).post("/cap")).status).toBe(401);
});
it("403 when member lacks the capability", async () => {
expect((await request(appWith(member([], "all"))).post("/cap")).status).toBe(403);
});
it("200 when member has the capability", async () => {
expect((await request(appWith(member(["quality"], "all"))).post("/cap")).status).toBe(200);
});
it("200 for admin regardless of capabilities", async () => {
expect((await request(appWith(admin)).post("/cap")).status).toBe(200);
});
});
describe("requireBotAccess", () => {
it("200 when bots = all", async () => {
expect((await request(appWith(member([], "all"))).post("/bot/b1")).status).toBe(200);
});
it("200 when botId in allow-list", async () => {
expect((await request(appWith(member([], ["b1"]))).post("/bot/b1")).status).toBe(200);
});
it("403 when botId not in allow-list", async () => {
expect((await request(appWith(member([], ["b2"]))).post("/bot/b1")).status).toBe(403);
});
it("200 for admin", async () => {
expect((await request(appWith(admin)).post("/bot/b1")).status).toBe(200);
});
});
```
- [ ] **Step 3: Run test to verify it fails**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: FAIL — module `./requirePermission.js` not found.
- [ ] **Step 4: Create `src/web/middleware/requirePermission.ts`**
```typescript
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function requirePermission(capability: string): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.capabilities.has(capability)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
export function requireBotAccess(paramName = "botId"): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) { res.status(401).json({ error: "unauthenticated" }); return; }
if (req.user.role === "admin" || req.user.bots === "all") { next(); return; }
const botId = req.params[paramName];
if (botId && req.user.bots.has(botId)) { next(); return; }
res.status(403).json({ error: "forbidden" });
};
}
```
- [ ] **Step 5: Run test to verify it passes**
Run: `npx vitest run src/web/middleware/requirePermission.test.ts`
Expected: PASS (8 tests).
- [ ] **Step 6: Commit**
```bash
git add src/web/middleware/requirePermission.ts src/web/middleware/requirePermission.test.ts src/web/middleware/requireAuth.ts
git commit -m "feat(perm): requirePermission + requireBotAccess middleware"
```
---
## Task 3: Effective-permissions resolver (admin = all)
**Files:**
- Modify: `src/data/permissions.ts` (add `resolveContext` helper)
- Modify: `src/data/permissions.test.ts` (add tests)
- [ ] **Step 1: Add failing tests** to `src/data/permissions.test.ts`:
```typescript
import { resolvePermissionContext } from "./permissions.js";
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
```
- [ ] **Step 2: Run to verify fail**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: FAIL — `resolvePermissionContext` not exported.
- [ ] **Step 3: Add to `src/data/permissions.ts`**
```typescript
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
}
export function resolvePermissionContext(
role: "admin" | "member",
userId: string,
store: PermissionStore
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
```
- [ ] **Step 4: Run to verify pass**
Run: `npx vitest run src/data/permissions.test.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/data/permissions.ts src/data/permissions.test.ts
git commit -m "feat(perm): resolvePermissionContext (admin = super-user)"
```
---
## Task 4: Load permissions onto req.user (requireAuth + session inline + /me)
**Files:**
- Modify: `src/web/middleware/requireAuth.ts`
- Modify: `src/web/api/session.ts`
- Modify: `src/web/server.ts`
- [ ] **Step 1: Thread `PermissionStore` into `createRequireAuth`**
`src/web/middleware/requireAuth.ts` — change the factory signature and set the new fields:
```typescript
import { resolvePermissionContext, type PermissionStore } from "../../data/permissions.js";
export function createRequireAuth(sessions: SessionStore, permissions: PermissionStore): RequestHandler {
return function requireAuth(req, res, next) {
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
res.status(401).json({ error: "unauthenticated" });
return;
}
const ctx = resolvePermissionContext(result.role, result.userId, permissions);
req.user = {
id: result.userId, username: result.username, role: result.role,
capabilities: ctx.capabilities, bots: ctx.bots,
};
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
httpOnly: true, sameSite: "lax", secure: req.secure, path: "/", maxAge: SESSION_TTL_MS,
});
}
next();
};
}
```
- [ ] **Step 2: Update `src/web/server.ts`**
Construct the store next to the others and pass it in:
```typescript
import { createPermissionStore } from "../data/permissions.js";
// ...
const permissions = createPermissionStore(options.database.db);
// ...
const requireAuth = createRequireAuth(sessions, permissions);
```
Keep `permissions` in scope — it's passed to routers in Tasks 5–7.
- [ ] **Step 3: Update session inline auth + `/me` in `src/web/api/session.ts`**
`createSessionRouter` must accept `permissions` and (a) attach capabilities in `requireAuthInline`, (b) include them in `/me`. Pass `permissions` from `server.ts` into `createSessionRouter(users, sessions, audit, logger, permissions)`. In the `/me` handler, return:
```typescript
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
res.json({
id: validation.userId, username: validation.username, role: validation.role,
capabilities: [...ctx.capabilities],
bots: ctx.bots === "all" ? "all" : [...ctx.bots],
});
```
(Match the existing `/me` shape; just add `capabilities` + `bots`. Read the file to find the exact response object.)
- [ ] **Step 4: Verify build + existing tests**
Run: `npx tsc --noEmit`
Expected: exit 0 (the widened `req.user` is now populated everywhere it's read).
Run: `npx vitest run src/web`
Expected: PASS (existing auth/session/csrf tests still green; if a test constructs `createRequireAuth(sessions)` it must be updated to pass a `createPermissionStore(db)`).
- [ ] **Step 5: Commit**
```bash
git add src/web/middleware/requireAuth.ts src/web/server.ts src/web/api/session.ts
git commit -m "feat(perm): load capabilities + bot access onto req.user; expose via /me"
```
---
## Task 5: Enforce capabilities on the action routes
**Files:**
- Modify: `src/web/api/player.ts`, `src/web/api/bot.ts`, `src/web/api/auth.ts`, `src/web/api/music.ts`
- Modify: `src/web/api/player.test.ts` (or create `src/web/api/permissions-enforcement.test.ts`)
- [ ] **Step 1: Write a failing integration test** at `src/web/api/permissions-enforcement.test.ts` that builds the real app (or the relevant router) with a stubbed `req.user` and asserts:
- member without `player.control` → `POST /api/player/:botId/pause` → 403
- member with `player.control` + bot in allow-list → 200 (bot resolves)
- member with `player.control` but bot NOT in allow-list → 403
- member without `player.queue` → `POST /api/player/:botId/clear` → 403
- member without `bot.manage` → `POST /api/bot` → 403
- member without `platform.auth` → `POST /api/auth/cookie` → 403
- member without `quality` → `POST /api/music/quality` → 403
- admin → all 200/allowed
Use the same `appWith(user)` injection pattern as Task 2 (insert a middleware that sets `req.user` before the router) and a fake `BotManager`/providers so routes resolve. Model it on the existing `src/web/api/*.test.ts` setup (read one first for the harness).
- [ ] **Step 2: Run to verify fail** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → FAIL (routes currently allow everyone).
- [ ] **Step 3: Apply gates.**
`src/web/api/player.ts` — the shared `/:botId` middleware already resolves the bot. Add bot-access there, and add per-action capability guards. Define the queue-capability routes vs control routes:
```typescript
import { requirePermission, requireBotAccess } from "../middleware/requirePermission.js";
// after the existing router.use("/:botId", resolveBot):
router.use("/:botId", requireBotAccess("botId"));
const control = requirePermission("player.control");
const queue = requirePermission("player.queue");
// control: play, pause, resume, next, prev, stop, seek, volume, mode, play-song, play-at, play-by-id, play-playlist, play-album, play-next-song
// queue: add, add-song, add-by-id, clear, playlist, /queue/:index (DELETE)
// Apply per route, e.g.:
router.post("/:botId/pause", control, async (req, res) => { /* existing */ });
router.post("/:botId/add", queue, async (req, res) => { /* existing */ });
router.delete("/:botId/queue/:index", queue, async (req, res) => { /* existing */ });
```
(Insert the `control`/`queue` middleware as the 2nd arg of each existing `router.post/delete`. Do not change handler bodies. `PUT /:botId/profile` → `requirePermission("bot.manage")`.)
`src/web/api/bot.ts` — gate management + per-bot:
```typescript
const manage = requirePermission("bot.manage");
router.post("/", manage, ...); // create (no botId)
router.put("/:id", manage, requireBotAccess("id"), ...);
router.delete("/:id", manage, requireBotAccess("id"), ...);
router.post("/:id/start", manage, requireBotAccess("id"), ...);
router.post("/:id/stop", manage, requireBotAccess("id"), ...);
router.put("/:id/avatar", manage, requireBotAccess("id"), ...);
router.delete("/:id/avatar", manage, requireBotAccess("id"), ...);
router.post("/settings", manage, ...); // global idle timeout
```
`src/web/api/auth.ts` — gate every mutating route with `requirePermission("platform.auth")`:
`POST /qrcode`, `POST /sms/send`, `POST /sms/verify`, `POST /cookie`. (Leave `GET /status`, `GET /qrcode/status` open — read-only.)
`src/web/api/music.ts` — gate the one mutating route:
`router.post("/quality", requirePermission("quality"), ...)`.
- [ ] **Step 4: Run to verify pass** — `npx vitest run src/web/api/permissions-enforcement.test.ts` → PASS. Then `npx vitest run src/web` → all green.
- [ ] **Step 5: Commit**
```bash
git add src/web/api/player.ts src/web/api/bot.ts src/web/api/auth.ts src/web/api/music.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): enforce capabilities + bot access on action routes"
```
---
## Task 6: Filter the bot list for members
**Files:**
- Modify: `src/web/api/bot.ts` (`GET /`)
- Modify: `src/bot/manager.ts` (`removeBot` → `permissions.pruneBot`)
- Modify: test from Task 5
- [ ] **Step 1: Add failing test** — member with `bots: ["b1"]` calling `GET /api/bot` sees only `b1`; admin sees all.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement.** In `GET /` of `bot.ts`:
```typescript
const all = getAllBots().map((b) => b.getStatus());
const u = req.user!;
const bots = u.role === "admin" || u.bots === "all"
? all
: all.filter((b) => (u.bots as Set<string>).has(b.id));
res.json({ bots });
```
In `src/bot/manager.ts`, give `BotManager` access to the `PermissionStore` (constructor param) and call `this.permissions.pruneBot(id)` inside `removeBot(id)` after deletion, so deleted bots drop out of allow-lists. Thread `permissions` from `index.ts`/`server.ts` into `BotManager`.
- [ ] **Step 4: Run → pass; `npx vitest run src/web src/bot` green.**
- [ ] **Step 5: Commit**
```bash
git add src/web/api/bot.ts src/bot/manager.ts src/web/api/permissions-enforcement.test.ts
git commit -m "feat(perm): filter GET /api/bot to allowed bots; prune access on bot delete"
```
---
## Task 7: Management API (GET/PUT permissions) + audit
**Files:**
- Modify: `src/data/audit.ts` (add action)
- Modify: `src/web/api/users.ts` (+ permissions endpoints; new-member default)
- Modify: `src/web/server.ts` (pass `permissions` into `createUsersRouter`)
- Create/extend: `src/web/api/users.test.ts`
- [ ] **Step 1: Add `"user.permissions_changed"`** to the `AuditAction` union in `src/data/audit.ts`.
- [ ] **Step 2: Write failing tests** for the users router (admin-only):
- `GET /api/users/:id/permissions` → `{ capabilities: [], bots: [] }` for a fresh member.
- `PUT /api/users/:id/permissions` with `{capabilities:["player.control"], bots:"all"}` → 200; subsequent GET reflects it; an audit row `user.permissions_changed` exists.
- `PUT` with an unknown capability token → it is dropped (not stored).
- New member created via `POST /api/users` → GET permissions returns basic tier (`["player.control","player.queue"]`, bots `"all"`).
- [ ] **Step 3: Run → fail.**
- [ ] **Step 4: Implement** in `src/web/api/users.ts` (router already admin-gated at mount). Accept `permissions: PermissionStore` param. Add:
```typescript
import { CAPABILITIES, isCapability, BASIC_TIER_CAPABILITIES } from "../../data/permissions.js";
router.get("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
res.json({ capabilities: permissions.getCapabilities(user.id), bots: permissions.getBotAccess(user.id) });
});
router.put("/:id/permissions", (req, res) => {
const user = users.findById(req.params.id);
if (!user) { res.status(404).json({ error: "not_found" }); return; }
const body = req.body ?? {};
const caps = Array.isArray(body.capabilities) ? body.capabilities.filter(isCapability) : [];
const bots = body.bots === "all" ? "all" : (Array.isArray(body.bots) ? body.bots.map(String) : []);
permissions.setPermissions(user.id, { capabilities: caps, bots });
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
targetUserId: user.id, targetUsername: user.username,
action: "user.permissions_changed",
});
res.json({ success: true });
});
```
In the existing `POST /api/users` handler, after creating a member, seed the basic tier:
```typescript
if (created.role === "member") {
permissions.setPermissions(created.id, { capabilities: BASIC_TIER_CAPABILITIES, bots: "all" });
}
```
- [ ] **Step 5: Run → pass; `npx vitest run src/web` green.**
- [ ] **Step 6: Commit**
```bash
git add src/data/audit.ts src/web/api/users.ts src/web/server.ts src/web/api/users.test.ts
git commit -m "feat(perm): admin permissions API + audit + new-member basic tier"
```
---
## Task 8: One-time migration backfill (existing members → full)
**Files:**
- Modify: `src/data/database.ts` (`migrateSchema` or a dedicated backfill)
- Create: `src/data/permissions-migration.test.ts`
- [ ] **Step 1: Write failing test** — given a fresh db with an existing `member` user and NO permission rows, after `createDatabase()` runs the backfill, that member has all 5 capabilities + `bots.all`; an `admin` user gets nothing (bypasses). Backfill is idempotent (running twice does not duplicate / does not re-grant a member who was later restricted to empty).
Idempotency approach: store a one-shot marker. Use a `meta` row or check: only backfill members who currently have ZERO permission rows AND only on first introduction. Simplest robust marker: a row in a tiny `schema_meta(key TEXT PK, value TEXT)` table, key `perm_backfill_done`. If present, skip.
- [ ] **Step 2: Run → fail.**
- [ ] **Step 3: Implement** a `backfillMemberPermissions(db)` run once inside `createDatabase` after `initTables`:
```typescript
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (!done) {
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tx = db.transaction(() => {
for (const m of members) {
for (const c of ["player.control","player.queue","bot.manage","platform.auth","quality","bots.all"]) {
insCap.run(m.id, c);
}
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(Date.now()));
});
tx();
}
```
- [ ] **Step 4: Run → pass.**
- [ ] **Step 5: Commit**
```bash
git add src/data/database.ts src/data/permissions-migration.test.ts
git commit -m "feat(perm): one-time backfill of existing members to full access"
```
---
## Task 9: Frontend — session capabilities + helpers
**Files:**
- Modify: `web/src/composables/useSession.ts`
- [ ] **Step 1:** Extend the `User` type with `capabilities: string[]` and `bots: 'all' | string[]`; populate from `/api/session/me`, `/login`, `/setup` responses (the backend now returns them).
- [ ] **Step 2:** Add computed helpers:
```typescript
function can(cap: string): boolean {
const u = currentUser.value;
return !!u && (u.role === 'admin' || (u.capabilities ?? []).includes(cap));
}
function canControlBot(botId: string): boolean {
const u = currentUser.value;
if (!u) return false;
if (u.role === 'admin' || u.bots === 'all') return true;
return Array.isArray(u.bots) && u.bots.includes(botId);
}
```
Export `can` and `canControlBot` from the composable.
- [ ] **Step 3:** Manual check: log in as admin → `can('quality')` true; (after backend done) a restricted member → false. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/composables/useSession.ts && git commit -m "feat(perm): frontend session capabilities + can()/canControlBot()"`
---
## Task 10: Frontend — gate UI by capability + filter bots
**Files:**
- Modify: `web/src/components/Navbar.vue`, `web/src/components/Player.vue`, `web/src/views/Settings.vue`, `web/src/stores/player.ts`
- [ ] **Step 1:** Navbar bot selector: render only controllable bots — `v-for="bot in store.bots"` becomes a filtered computed `controllableBots = store.bots.filter(b => session.canControlBot(b.id))`. (The backend already filters `GET /api/bot`, so this is belt-and-suspenders + correctness if both lists diverge.) Ensure `store.activeBot` fallback never lands on a bot the user can't control.
- [ ] **Step 2:** Player.vue: wrap control buttons with `v-if="session.can('player.control')"` and queue actions with `v-if="session.can('player.queue')"`.
- [ ] **Step 3:** Settings.vue: wrap the platform login cards with `v-if="session.can('platform.auth')"`, the audio-quality control with `v-if="session.can('quality')"`, and bot create/edit/delete with `v-if="session.can('bot.manage')"`.
- [ ] **Step 4:** Manual verification (see Verification section). Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 5: Commit** `git add web/src/components/Navbar.vue web/src/components/Player.vue web/src/views/Settings.vue web/src/stores/player.ts && git commit -m "feat(perm): hide UI a member lacks capability for"`
---
## Task 11: Frontend — admin permission editor
**Files:**
- Modify: `web/src/views/Settings.vue` (User Management section)
- [ ] **Step 1:** In each member row of the admin User-Management list, add a "权限" button opening an editor (inline panel or dialog) with: 5 capability checkboxes (labels: 播放控制 / 队列管理 / 机器人管理 / 平台登录凭据 / 音质设置), and a bot allow-list — an "全部机器人" toggle plus, when off, a checkbox per bot from `store.bots`.
- [ ] **Step 2:** On open, `GET /api/users/:id/permissions`; on save, `PUT /api/users/:id/permissions` with `{capabilities, bots}` then re-fetch. Admin rows show "全部权限(管理员)" and no editor.
- [ ] **Step 3:** Manual verification. Build: `cd web && npx vue-tsc --noEmit`.
- [ ] **Step 4: Commit** `git add web/src/views/Settings.vue && git commit -m "feat(perm): admin permission editor in user management"`
---
## Final verification
- [ ] `npx tsc --noEmit` → exit 0
- [ ] `npx vitest run src/` → all green (clean-checkout-equivalent; ignore stale `dist/` twins — see note)
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npm run build` → succeeds
- [ ] Manual (run the bot, log in): admin sees everything; create a member, restrict to `player.control` on one bot → member sees only that bot, can play/pause but cannot add to queue, cannot open platform login / quality / bot management; backend returns 403 on a forged request to a disallowed action (verify with curl + the member's session cookie).
> **Note (pre-existing):** `tsconfig.json` compiles `*.test.ts` into `dist/`, and vitest also runs the `dist/` twins after a build — so `npx vitest run` (no path) double-runs and can fail on stale artifacts. Scope verification to `npx vitest run src/`. (A separate cleanup PR could add `exclude: ['**/dist/**']` to a vitest config.)
## Out of scope (separate PRs, per spec)
#1 guest mode · #2 dedicated-link bot hiding UX · #3 auto-pause on empty channel · #4 dedicated-link refresh bug.
@@ -0,0 +1,196 @@
# Auto-pause on Empty Channel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Auto-pause playback when the bot's channel empties (no disconnect) and auto-resume when someone returns — only resuming tracks we auto-paused — gated by the existing global `autoPauseOnEmpty` flag.
**Architecture:** A pure decision function decides pause/resume from (player state, autoPaused, flag, userCount). `BotInstance` owns an `autoPaused` flag and a `checkChannelOccupancy()` that the existing 30s idle poll AND new TS enter/leave/move events both call. The toggle is wired into `/api/bot/settings` + the Settings UI.
**Tech:** Node ESM + TS, Vitest, Express, Vue 3.
**Spec:** `docs/superpowers/specs/2026-05-30-autopause-empty-channel-design.md`
---
## Task 1: Pure occupancy-decision function
**Files:** Create `src/bot/auto-pause.ts`, `src/bot/auto-pause.test.ts`.
- [ ] **Step 1 — failing test** `src/bot/auto-pause.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { decideOccupancyAction } from "./auto-pause.js";
describe("decideOccupancyAction", () => {
// (playerState, autoPaused, enabled, userCount) => "pause" | "resume" | "none"
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run src/bot/auto-pause.test.ts` → module missing.
- [ ] **Step 3 — implement** `src/bot/auto-pause.ts`:
```typescript
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Decide what auto-pause should do given the channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
```
- [ ] **Step 4 — run, expect pass:** `npx vitest run src/bot/auto-pause.test.ts` → 8 pass.
- [ ] **Step 5 — commit:** `git add src/bot/auto-pause.ts src/bot/auto-pause.test.ts && git commit -m "feat(autopause): pure occupancy-decision function"`
---
## Task 2: Wire decision into BotInstance (autoPaused flag + checkChannelOccupancy)
**Files:** Modify `src/bot/instance.ts`.
Context: `_startIdlePoller` (~lines 190-206) polls every 30s, computes `userCount = (await getClientsInChannel()).length - 1`, and calls `_scheduleIdleCheck()` (empty) / `_cancelIdleTimer()` (occupied). `cmdPause`/`cmdResume` (~484-494), `cmdStop` (~496-505), and the playback start (`cmdPlay`/resolveAndPlay) wrap `player`. There's an unused `channelUserCount` field (~line 68). The instance has `this.config` (BotConfig) and `this.player`.
- [ ] **Step 1 — add state + helper.** Add a private field `private autoPaused = false;`. Create a method that centralizes occupancy handling and is called with a freshly-computed userCount:
```typescript
import { decideOccupancyAction } from "./auto-pause.js";
private handleOccupancy(userCount: number): void {
// idle-disconnect (unchanged behavior)
if (userCount <= 0) this._scheduleIdleCheck();
else this._cancelIdleTimer();
// auto-pause
const action = decideOccupancyAction(
this.player.getState() as "idle" | "playing" | "paused",
this.autoPaused,
this.config.autoPauseOnEmpty,
userCount,
);
if (action === "pause") {
this.player.pause();
this.autoPaused = true;
this.emit("stateChange");
} else if (action === "resume") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 2 — route the idle poller through it.** In `_startIdlePoller`, replace the inline `userCount`→schedule/cancel logic with: compute `userCount` then `this.handleOccupancy(userCount)`. (Keep the 30s interval + the same getClientsInChannel call + error handling.) Remove the now-redundant inline schedule/cancel branch (it lives in `handleOccupancy`).
- [ ] **Step 3 — clear autoPaused on user actions + lifecycle.** In `cmdPause`, `cmdResume`, `cmdStop`, and the play-start path (`cmdPlay`/wherever playback (re)starts), set `this.autoPaused = false`. In the `disconnected` handler and on (re)connect, set `this.autoPaused = false`. (These ensure a user pause is never auto-resumed and the flag resets across connections.)
- [ ] **Step 4 — `updateAutoPause`.** Add (mirrors `updateIdleTimeout`):
```typescript
updateAutoPause(enabled: boolean): void {
this.config.autoPauseOnEmpty = enabled;
// if turning off, leave current playback as-is; if a track was auto-paused, optionally resume:
if (!enabled && this.autoPaused && this.player.getState() === "paused") {
this.player.resume();
this.autoPaused = false;
this.emit("stateChange");
}
}
```
- [ ] **Step 5 — verify:** `npx tsc --noEmit` → exit 0. `npx vitest run src/bot src/audio` → pass (existing tests unaffected).
- [ ] **Step 6 — commit:** `git add src/bot/instance.ts && git commit -m "feat(autopause): drive pause/resume from channel occupancy in BotInstance"`
---
## Task 3: Re-emit TS member events for instant reaction
**Files:** Modify `src/ts-protocol/client.ts`, `src/bot/instance.ts`.
Context: `client.ts` forwards `textMessage`/`disconnected`/`connected` and only debug-logs `clientEnter` (~lines 219-224); `clientLeave`/`clientMoved` are not handled. `BotInstance.setupTsEvents()` (~lines 132-156) wires tsClient events.
- [ ] **Step 1 — re-emit in client.ts.** Where `clientEnter` is logged, also `this.emit("clientEnter", info)`. Add subscriptions for `clientLeave` and `clientMoved` that `this.emit(...)` them upward (match the existing forwarding style; just propagate, no payload transformation needed since the instance re-queries).
- [ ] **Step 2 — react in instance.ts.** In `setupTsEvents()`, add handlers: on `clientEnter` / `clientLeave` / `clientMoved`, call a small `async refreshOccupancy()` that does `const clients = await this.getClientsInChannel(); this.handleOccupancy(clients.length - 1);` (guarded with try/catch + only when connected). This gives near-instant pause/resume; the 30s poll remains the fallback.
- [ ] **Step 3 — verify:** `npx tsc --noEmit` → 0. `npx vitest run src/bot` → pass.
- [ ] **Step 4 — commit:** `git add src/ts-protocol/client.ts src/bot/instance.ts && git commit -m "feat(autopause): re-emit client enter/leave/move for instant pause/resume"`
---
## Task 4: API wiring for the toggle
**Files:** Modify `src/web/api/bot.ts`; add/extend a test.
Context: `GET /api/bot/settings` returns `{ idleTimeoutMinutes }`; `POST /api/bot/settings` validates `idleTimeoutMinutes`, sets `config.idleTimeoutMinutes`, `saveConfig`, then loops `botManager.getAllBots()` → `bot.updateIdleTimeout(...)`. This route is `requirePermission("bot.manage")`-gated.
- [ ] **Step 1 — failing API test** (extend the existing bot settings test or add one): `GET /api/bot/settings` returns `autoPauseOnEmpty` (boolean); `POST /api/bot/settings` with `{ autoPauseOnEmpty: false }` persists it (a follow-up GET reflects false) and calls `updateAutoPause` on bots. Model the harness on the existing settings test.
- [ ] **Step 2 — run, expect fail.**
- [ ] **Step 3 — implement.** In `GET /settings`, add `autoPauseOnEmpty: options.config.autoPauseOnEmpty` to the response. In `POST /settings`, if `typeof req.body.autoPauseOnEmpty === "boolean"`, set `config.autoPauseOnEmpty`, include it in the `saveConfig`, and loop bots calling `bot.updateAutoPause(config.autoPauseOnEmpty)`. Keep the existing `idleTimeoutMinutes` handling intact (handle both fields in one save).
- [ ] **Step 4 — verify:** `npx vitest run src/web` → pass; `npx tsc --noEmit` → 0.
- [ ] **Step 5 — commit:** `git add src/web/api/bot.ts <test> && git commit -m "feat(autopause): expose autoPauseOnEmpty via /api/bot/settings"`
---
## Task 5: Frontend toggle in Settings
**Files:** Modify `web/src/views/Settings.vue` (and the settings load/save it uses).
Context: The **行为设置** section (already `v-if="can('bot.manage')"`) holds the idle-timeout control, loaded via `loadIdleTimeout()` (GET /api/bot/settings) and saved via `saveIdleTimeout()` (POST). Read these first.
- [ ] **Step 1 — implement.** Add an `autoPauseOnEmpty` ref. In the settings load, populate it from the GET response. Add a checkbox/toggle in the 行为设置 section labelled e.g. "频道无人时自动暂停" bound to it, and include `autoPauseOnEmpty` in the POST payload of the save function (alongside `idleTimeoutMinutes`, or via its own save — match the existing pattern). Use existing form/toggle CSS classes.
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0; read template back for correctness.
- [ ] **Step 3 — commit:** `git add web/src/views/Settings.vue && git commit -m "feat(autopause): autoPauseOnEmpty toggle in Settings"`
---
## Final verification
- [ ] `npx tsc --noEmit` → 0
- [ ] `npx vitest run src/` → all pass
- [ ] `cd web && npx vue-tsc --noEmit` → 0
- [ ] `npm run build` → succeeds
- [ ] Manual: with a bot playing, leave its channel → music auto-pauses (no disconnect); rejoin → resumes. Manually pause, leave, rejoin → stays paused. Toggle off in Settings → no auto-pause.
@@ -0,0 +1,156 @@
# Dedicated-link Bot Scoping (+ refresh fix) — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Opening a dedicated link locks the WebUI to that one bot (selector shows only it, switching disabled, with an explicit exit); the lock is carried in the URL (`?bot=<id>`) so it survives refresh — fixing item 4 too.
**Architecture:** A `scopedBotId` in the Pinia player store is the runtime lock; the URL query `?bot=<id>` is the durable source of truth. A router `beforeEach` syncs scope from the query and re-attaches `?bot` across in-app navigation while scoped. `BotRedirect` seeds it; Navbar renders the lock; graceful clear if the bot doesn't exist.
**Tech:** Vue 3 + Pinia + vue-router, TypeScript. (Frontend isn't unit-tested in this repo → verify via `vue-tsc` + manual; extract one pure helper to unit-test.)
**Spec:** `docs/superpowers/specs/2026-05-30-dedicated-link-scope-design.md`
---
## Task 1: Store scope state + pure resolve helper (with test)
**Files:** Modify `web/src/stores/player.ts`; create `web/src/stores/scope.ts` + `web/src/stores/scope.test.ts`.
READ `web/src/stores/player.ts` first: `activeBotId` state (~line 52), `setActiveBotId` action (~127-133), `fetchBots` (~196-198 default to bots[0]), `activeBot` getter (~73-75), and the localStorage pattern used by `theme` (~175-183) for reference (we are NOT using localStorage, but match code style).
- [ ] **Step 1 — pure helper + failing test.** Create `web/src/stores/scope.ts`:
```typescript
/** Given the desired scoped id (from ?bot) and the known bot ids, decide the
* effective scope. Returns the id if it exists, else null (graceful clear:
* a stale/forbidden id never locks the UI). */
export function resolveScopedBot(
requestedId: string | null | undefined,
knownBotIds: readonly string[],
): string | null {
if (!requestedId) return null;
return knownBotIds.includes(requestedId) ? requestedId : null;
}
```
`web/src/stores/scope.test.ts`:
```typescript
import { describe, it, expect } from "vitest";
import { resolveScopedBot } from "./scope.js";
describe("resolveScopedBot", () => {
it("returns null when no id requested", () => {
expect(resolveScopedBot(null, ["a", "b"])).toBeNull();
expect(resolveScopedBot(undefined, ["a"])).toBeNull();
expect(resolveScopedBot("", ["a"])).toBeNull();
});
it("returns the id when it exists in the bot list", () => {
expect(resolveScopedBot("b", ["a", "b"])).toBe("b");
});
it("clears (null) when the requested id is not a known bot", () => {
expect(resolveScopedBot("ghost", ["a", "b"])).toBeNull();
});
});
```
- [ ] **Step 2 — run, expect fail:** `npx vitest run web/src/stores/scope.test.ts` → module missing.
(Note: the repo's vitest runs from root; this test lives under web/. If the root vitest config doesn't include web/src, run it via the web workspace: `cd web && npx vitest run src/stores/scope.test.ts`. Use whichever picks it up; confirm it FAILS first.)
- [ ] **Step 3 — implement the helper** (code above).
- [ ] **Step 4 — add scope state to `web/src/stores/player.ts`:**
- state: `scopedBotId: null as string | null`.
- getter: `isScoped: (state) => state.scopedBotId !== null`.
- actions:
- `setScope(id: string)` → `this.scopedBotId = id;` and also set `this.activeBotId = id` (scoped == active), then ensure that bot's queue is loaded like `setActiveBotId` does.
- `clearScope()` → `this.scopedBotId = null;`.
- `applyScopeFromQuery(requestedId: string | null)` → uses `resolveScopedBot(requestedId, this.bots.map(b => b.id))`; if result non-null → `setScope(result)`; if null and a scope was requested → `clearScope()`. (Called after bots are loaded.)
- Guard `setActiveBotId(id)`: at the top, `if (this.scopedBotId !== null && id !== this.scopedBotId) return;` so switching is blocked while scoped.
- [ ] **Step 5 — run helper test, expect pass:** `cd web && npx vitest run src/stores/scope.test.ts` → 3 pass. `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 6 — commit:** `git add web/src/stores/scope.ts web/src/stores/scope.test.ts web/src/stores/player.ts && git commit -m "feat(scope): player store scopedBotId + resolveScopedBot helper"`
---
## Task 2: Router guard — sync scope from `?bot` + preserve across navigation
**Files:** Modify `web/src/router/index.ts`.
READ the file: the existing `beforeEach` (~lines 36-60) handles needsSetup/auth. Add scope handling AFTER auth resolves (so we don't fight the login redirect). Import the player store (use it inside the guard via `usePlayerStore()` — Pinia is active by the time navigation runs).
- [ ] **Step 1 — implement.** In `beforeEach`, after the existing auth/needsSetup logic decides the navigation is allowed to proceed to `to` (i.e., not redirecting to /login or /first-run), add:
```typescript
const store = usePlayerStore();
const qBot = typeof to.query.bot === "string" ? to.query.bot : null;
if (qBot) {
// entering/with a scope in the URL — store will validate against bots later
store.scopedBotId = qBot; // tentative; applyScopeFromQuery (after fetchBots) confirms/clears
return next();
}
if (store.scopedBotId) {
// scoped but this navigation dropped ?bot → re-attach so the lock survives in-app nav + refresh
if (to.query.bot !== store.scopedBotId) {
return next({ ...to, query: { ...to.query, bot: store.scopedBotId } });
}
}
return next();
```
(Adapt to the file's existing `next()` style — it may use `next(...)`/return. Ensure this runs only for allowed navigations, not when redirecting to /login. The exit action in Task 4 calls `store.clearScope()` BEFORE navigating to `/`, so `store.scopedBotId` is null and the re-attach branch is skipped — that's how exit works.)
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Re-read the guard to ensure no redirect loop (when `to.query.bot === store.scopedBotId`, it does NOT redirect again).
- [ ] **Step 3 — commit:** `git add web/src/router/index.ts && git commit -m "feat(scope): router guard syncs + preserves ?bot across navigation"`
---
## Task 3: BotRedirect seeds the URL scope
**Files:** Modify `web/src/views/BotRedirect.vue`.
READ it: onMounted reads `route.params.id`, ensures `store.fetchBots()`, finds the bot; if found `store.setActiveBotId(id)` + `router.replace('/')`; else shows not-found.
- [ ] **Step 1 — implement.** Change the found-branch to seed scope via the URL instead of bouncing to a bare `/`:
- keep the fetchBots + existence check,
- if found: `router.replace({ path: '/', query: { bot: botId } })` (the router guard + store will set the scope). Optionally also call `store.setScope(botId)` directly for immediacy.
- if not found: unchanged (show "机器人不存在或未加载").
- [ ] **Step 2 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0.
- [ ] **Step 3 — commit:** `git add web/src/views/BotRedirect.vue && git commit -m "feat(scope): dedicated link seeds ?bot scope instead of bare redirect"`
---
## Task 4: Navbar lock UI + apply-scope-on-load
**Files:** Modify `web/src/components/Navbar.vue`, `web/src/App.vue`.
READ both: Navbar has `controllableBots` (computed) + the dropdown selector + `selectBot`; App.vue onMounted calls `playerStore.fetchBots()` (+ loadTheme/connect).
- [ ] **Step 1 — Navbar lock.** When `store.isScoped`:
- render only the scoped bot (a `displayedBots` computed → if scoped, `controllableBots.filter(b => b.id === store.scopedBotId)`, else `controllableBots`),
- disable the dropdown open / switching (no chevron, or make the trigger non-interactive) so the user can't switch,
- hide other bots' "copy link" affordances (only the scoped bot remains anyway),
- show a small "专属模式" badge and an "退出" button → `store.clearScope(); router.push('/')` (clear BEFORE navigating so the guard doesn't re-attach `?bot`). Import `useRouter` if not present.
When not scoped: behavior unchanged.
- [ ] **Step 2 — apply scope on load (App.vue).** After `fetchBots()` resolves in onMounted, call `playerStore.applyScopeFromQuery(routeBot)` where `routeBot` is the current `?bot` query (via `useRoute().query.bot` as string|null). This confirms a refreshed `?bot` against the loaded bots and sets activeBotId (or gracefully clears if the bot is gone). (If Task 2's guard already set `scopedBotId` tentatively, this validates it against the now-loaded bot list.)
- [ ] **Step 3 — verify:** `cd web && npx vue-tsc --noEmit` → exit 0. Read templates back for valid syntax; confirm read-only displays aren't broken and the non-scoped path is unchanged.
- [ ] **Step 4 — commit:** `git add web/src/components/Navbar.vue web/src/App.vue && git commit -m "feat(scope): lock Navbar selector to scoped bot + apply scope on load"`
---
## Final verification
- [ ] `cd web && npx vue-tsc --noEmit` → exit 0
- [ ] `npx tsc --noEmit` → exit 0 (backend unaffected)
- [ ] `cd web && npx vitest run src/stores/scope.test.ts` (or root vitest if it includes web) → pass
- [ ] `npm run build` → succeeds
- [ ] Manual: open `/bot/<id>` → URL becomes `/?bot=<id>`, selector shows only that bot, switching disabled; **refresh → still locked** (item 4 fixed); navigate to Search → URL keeps `?bot`; refresh on Search → still locked; click 退出 → back to all bots (`/`, no `?bot`); open `/` directly → full multi-bot control; open `/?bot=<nonexistent>` → gracefully shows all bots (no lock).
## Notes
- Backend per-bot authorization (PR #80) is the real security boundary; this is a UX lock.
- No localStorage — URL is the source of truth, so the lock is shareable and self-clearing.
- Item 4 is fixed as a consequence of carrying `?bot` in the URL across refresh/navigation.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,840 @@
# TeamSpeak chat-command permission control — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gate a fixed set of "admin" TeamSpeak chat commands (`stop`, `clear`, `remove`, `move`, `vol`, `mode`) behind configured TS server-group IDs, opt-in and backward-compatible, configurable from the WebUI and `config.json`.
**Architecture:** A pure helper `canRunCommand(name, invokerGroups, adminGroups)` decides allow/deny. The chat handler `handleTextMessage` (NOT the WebUI-shared `executeCommand`) consults it before executing, performs a best-effort group lookup when the sender's groups weren't delivered with the event, fails closed, and replies on deny. The privileged groups live in the already-declared `config.adminGroups`, surfaced through the existing `GET/POST /api/bot/settings` endpoints and an admin-only Settings.vue section.
**Tech Stack:** Node 20, TypeScript (ESM), Express 5, Vitest + supertest (backend), Vue 3 + `vue-tsc` (frontend), `@honeybbq/teamspeak-client`.
## Global Constraints
- **ESM import specifiers:** every relative import ends in `.js` even in `.ts` files (e.g. `import { canRunCommand } from "./commands.js"`).
- **Admin command set (exact, single source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. (Note: `follow` is intentionally NOT admin — it becomes public.)
- **Enforcement is opt-in / backward-compatible:** `config.adminGroups === []` (the default) ⇒ no enforcement; admin commands stay open to everyone exactly as today.
- **Fail closed:** an admin command, with enforcement on, whose sender groups cannot be determined (even after fallback) is **denied**.
- **Group-id normalization:** `invokerGroups` are strings, `adminGroups` are numbers — compare as the same type so `"6"` matches `6`.
- **Denial reply text (exact):** `⛔ 需要管理员权限(该命令仅限管理员服务器组)`.
- **`adminGroups` validation:** array of non-negative integers; filter out everything else; ignore a non-array value entirely.
- **Live config:** `BotInstance` shares the same `config` object the router mutates; the gate reads `this.config.adminGroups` live (no restart, no propagation call).
- **Per-task tests:** run `npx vitest run <file>` (targets `.ts` directly). Before any full `npm test`, run `rm -rf dist` first — a stale untracked `dist/` makes vitest double-run compiled `.test.js` copies (known environment quirk). The repo path contains spaces (`/c/Users/saopig1/Music/teamspeak music bot`) — quote it.
- **Frontend type-check:** `cd web && npx vue-tsc --noEmit` (must be clean).
- **TDD + frequent commits:** every task is red→green→commit. Keep project `tsc`/`vitest` green after each task.
---
### Task 1: `canRunCommand` helper + admin-set as single source of truth
**Files:**
- Modify: `src/bot/commands.ts` (lines 8-16 sets; line 59-61 `isAdminCommand`)
- Test: `src/bot/commands.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `export const ADMIN_COMMANDS: Set<string>` = `{stop, clear, remove, move, vol, mode}`
- `export function isAdminCommand(commandName: string): boolean` (unchanged signature)
- `export function canRunCommand(commandName: string, invokerGroups: readonly (string | number)[], adminGroups: readonly number[]): boolean` — consumed by Task 3.
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/commands.test.ts`:
```ts
import { canRunCommand, isAdminCommand } from "./commands.js";
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: FAIL — `canRunCommand` is not exported / not a function.
- [ ] **Step 3: Implement the helper and tighten the admin set**
In `src/bot/commands.ts`, delete the dead `PUBLIC_COMMANDS` export (nothing imports it; the admin set is the sole source of truth), set `ADMIN_COMMANDS` to the exact spec set (drop `follow`), and add `canRunCommand`. The file becomes:
```ts
export interface ParsedCommand {
name: string;
args: string;
rawArgs: string[];
flags: Set<string>;
}
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
message: string,
prefix: string,
aliases: Record<string, string> = {},
): ParsedCommand | null {
const trimmed = message.trim();
if (!trimmed.startsWith(prefix)) return null;
const withoutPrefix = trimmed.slice(prefix.length);
if (!withoutPrefix) return null;
const parts = withoutPrefix.split(/\s+/);
let name = parts[0].toLowerCase();
if (aliases[name]) {
name = aliases[name];
}
const flags = new Set<string>();
const argParts: string[] = [];
for (let i = 1; i < parts.length; i++) {
if (
parts[i].startsWith("-") &&
parts[i].length === 2 &&
/[a-zA-Z]/.test(parts[i][1])
) {
flags.add(parts[i][1].toLowerCase());
} else {
argParts.push(parts[i]);
}
}
return {
name,
args: argParts.join(" "),
rawArgs: argParts,
flags,
};
}
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npx vitest run "src/bot/commands.test.ts"`
Expected: PASS (parser tests + the new classification/canRunCommand tests).
- [ ] **Step 5: Verify nothing else imported the deleted symbol**
Run: `grep -rn "PUBLIC_COMMANDS" src/`
Expected: no matches (confirms the deletion is safe).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/commands.ts" "src/bot/commands.test.ts"
git commit -m "feat(commands): add canRunCommand gate helper + admin-set source of truth"
```
---
### Task 2: Surface `invokerGroups` on `TS3TextMessage`
**Files:**
- Modify: `src/ts-protocol/client.ts` (interface lines 58-64; mapping lines 205-214)
- Test: `src/ts-protocol/text-message.test.ts` (new)
**Interfaces:**
- Consumes: nothing from other tasks.
- Produces:
- `TS3TextMessage` gains `invokerGroups: string[]`.
- `export function toTS3TextMessage(msg: TextMessage): TS3TextMessage` — a pure mapper, used by the `textMessage` event handler and unit-testable. Consumed (the field) by Task 3.
- [ ] **Step 1: Write the failing test**
Create `src/ts-protocol/text-message.test.ts`:
```ts
import { describe, it, expect } from "vitest";
import { toTS3TextMessage } from "./client.js";
import type { TextMessage } from "@honeybbq/teamspeak-client";
function makeMsg(over: Partial<TextMessage> = {}): TextMessage {
return {
invokerName: "Alice",
invokerUID: "uid-abc",
message: "!stop",
invokerGroups: ["6", "8"],
targetMode: 2,
targetID: 0n,
invokerID: 5,
...over,
};
}
describe("toTS3TextMessage", () => {
it("maps core fields and stringifies invokerID", () => {
const r = toTS3TextMessage(makeMsg());
expect(r.invokerName).toBe("Alice");
expect(r.invokerId).toBe("5");
expect(r.invokerUid).toBe("uid-abc");
expect(r.message).toBe("!stop");
expect(r.targetMode).toBe(2);
});
it("preserves the sender's server groups", () => {
expect(toTS3TextMessage(makeMsg({ invokerGroups: ["6"] })).invokerGroups).toEqual(["6"]);
});
it("defaults missing invokerGroups to an empty array", () => {
const partial = {
invokerName: "Bob",
invokerUID: "u",
message: "!stop",
targetMode: 1,
targetID: 0n,
invokerID: 7,
} as unknown as TextMessage;
expect(toTS3TextMessage(partial).invokerGroups).toEqual([]);
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: FAIL — `toTS3TextMessage` is not exported.
- [ ] **Step 3: Add the field and the pure mapper, and use it in the handler**
In `src/ts-protocol/client.ts`, extend the interface (add `invokerGroups`):
```ts
export interface TS3TextMessage {
invokerName: string;
invokerId: string;
invokerUid: string;
message: string;
targetMode: number; // 1=private, 2=channel, 3=server
invokerGroups: string[]; // sender's TS server-group ids; [] when not in view cache
}
```
Add the pure mapper just below the interface (still above the `TS3Client` class):
```ts
/**
* Map the library's TextMessage to our wrapper. Preserves invokerGroups (the
* sender's TS server groups), which the library populates only when the sender
* is in the bot's client-view cache; otherwise it is []. Used by the chat
* command permission gate.
*/
export function toTS3TextMessage(msg: TextMessage): TS3TextMessage {
return {
invokerName: msg.invokerName,
invokerId: String(msg.invokerID),
invokerUid: msg.invokerUID,
message: msg.message,
targetMode: msg.targetMode,
invokerGroups: msg.invokerGroups ?? [],
};
}
```
Replace the inline mapping inside `this.client.on("textMessage", ...)` (currently lines 205-214) with a call to the mapper:
```ts
this.client.on("textMessage", (msg: TextMessage) => {
this.emit("textMessage", toTS3TextMessage(msg));
});
```
(`TextMessage` is already imported at the top of the file.)
- [ ] **Step 4: Run the test to verify it passes**
Run: `npx vitest run "src/ts-protocol/text-message.test.ts"`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add "src/ts-protocol/client.ts" "src/ts-protocol/text-message.test.ts"
git commit -m "feat(ts-protocol): surface invokerGroups on TS3TextMessage via pure mapper"
```
---
### Task 3: Permission gate in `handleTextMessage` (fallback lookup + fail-closed + denial reply)
**Files:**
- Modify: `src/bot/instance.ts` (imports lines 10-14; add a module constant; `handleTextMessage` lines 317-349; add two private methods)
- Test: `src/bot/instance.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes:
- `canRunCommand(commandName, invokerGroups, adminGroups)` from `./commands.js` (Task 1).
- `TS3TextMessage.invokerGroups: string[]` (Task 2).
- Existing `this.tsClient.getClientsInChannel(): Promise<ClientInfo[]>` where each `ClientInfo` has `id: number` and `serverGroups: string[]` (library already parses these).
- Existing `this.tsClient.sendTextMessage(message: string, targetMode?: number): Promise<void>`.
- Produces:
- `export const COMMAND_DENIED_MESSAGE: string` (exported so the test can assert it).
- Private `isCommandAllowed(commandName, msg)` and `lookupInvokerGroups(invokerId)` (exercised via prototype in the test).
- [ ] **Step 1: Write the failing tests**
Append to `src/bot/instance.test.ts`:
```ts
import { vi } from "vitest";
import { COMMAND_DENIED_MESSAGE } from "./instance.js";
import type { TS3TextMessage } from "../ts-protocol/client.js";
/** 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. */
function makeGateCtx(opts: {
adminGroups?: number[];
clients?: Array<{ id: number; serverGroups: string[] }>;
}) {
const ctx: any = {
config: { commandPrefix: "!", commandAliases: {}, adminGroups: opts.adminGroups ?? [] },
logger: { info: vi.fn(), error: vi.fn() },
tsClient: {
sendTextMessage: vi.fn(async () => {}),
getClientsInChannel: vi.fn(async () => opts.clients ?? []),
},
executeCommand: vi.fn(async () => null),
isCommandAllowed: (BotInstance.prototype as any).isCommandAllowed,
lookupInvokerGroups: (BotInstance.prototype as any).lookupInvokerGroups,
};
return ctx;
}
function makeMsg(message: string, invokerGroups: string[] = [], invokerId = "5"): TS3TextMessage {
return { invokerName: "Tester", invokerId, invokerUid: "uid", message, targetMode: 2, invokerGroups };
}
const handleTextMessage = (BotInstance.prototype as any).handleTextMessage as (
this: unknown,
msg: TS3TextMessage,
) => Promise<void>;
describe("BotInstance.handleTextMessage — command permission gate", () => {
it("runs a public command even with enforcement on", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!play 晴天"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.sendTextMessage).not.toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("runs an admin command when enforcement is off (empty adminGroups)", async () => {
const ctx = makeGateCtx({ adminGroups: [] });
await handleTextMessage.call(ctx, makeMsg("!stop"));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("runs an admin command when the event carried a matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["6"]));
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled(); // no fallback needed
});
it("denies an admin command when known groups do not match (no fallback, with reply)", async () => {
const ctx = makeGateCtx({ adminGroups: [6] });
await handleTextMessage.call(ctx, makeMsg("!stop", ["8"]));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.getClientsInChannel).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("falls back to a group lookup when the event carried no groups, and allows on match", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["6"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.tsClient.getClientsInChannel).toHaveBeenCalledTimes(1);
expect(ctx.executeCommand).toHaveBeenCalledTimes(1);
});
it("fails closed when the fallback finds the client but no matching group", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [{ id: 5, serverGroups: ["8"] }] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
it("fails closed when the fallback cannot find the client at all", async () => {
const ctx = makeGateCtx({ adminGroups: [6], clients: [] });
await handleTextMessage.call(ctx, makeMsg("!stop", [], "5"));
expect(ctx.executeCommand).not.toHaveBeenCalled();
expect(ctx.tsClient.sendTextMessage).toHaveBeenCalledWith(COMMAND_DENIED_MESSAGE);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: FAIL — `COMMAND_DENIED_MESSAGE` is not exported; `isCommandAllowed`/`lookupInvokerGroups` are undefined.
- [ ] **Step 3: Implement the gate**
In `src/bot/instance.ts`, change the commands import (lines 10-14) from `isAdminCommand` to `canRunCommand`:
```ts
import {
parseCommand,
canRunCommand,
type ParsedCommand,
} from "./commands.js";
```
Add a module-level constant just after the imports (above `export interface BotInstanceOptions`):
```ts
/** Reply sent when a non-admin invokes an admin-only chat command. */
export const COMMAND_DENIED_MESSAGE = "⛔ 需要管理员权限(该命令仅限管理员服务器组)";
```
Replace `handleTextMessage` (lines 317-349) so the dead stub becomes the real gate:
```ts
private async handleTextMessage(msg: TS3TextMessage): Promise<void> {
const parsed = parseCommand(
msg.message,
this.config.commandPrefix,
this.config.commandAliases
);
if (!parsed) return;
if (!(await this.isCommandAllowed(parsed.name, msg))) {
this.logger.info(
{ command: parsed.name, invoker: msg.invokerName },
"Command denied: invoker not in adminGroups"
);
try {
await this.tsClient.sendTextMessage(COMMAND_DENIED_MESSAGE);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send permission-denied message to chat");
}
return;
}
this.logger.info(
{ command: parsed.name, args: parsed.args, invoker: msg.invokerName },
"Command received"
);
try {
const response = await this.executeCommand(parsed, msg);
if (response) {
await this.tsClient.sendTextMessage(response);
}
} catch (err) {
this.logger.error({ err, command: parsed.name }, "Command execution error");
try {
await this.tsClient.sendTextMessage(
`Error: ${(err as Error).message}`
);
} catch (sendErr) {
this.logger.error({ err: sendErr }, "Failed to send error message to chat");
}
}
}
/**
* Decide whether a chat command may run for this sender. Reads adminGroups
* live from this.config (the router mutates the same object). Only performs
* the async group lookup when the synchronous decision is "deny because the
* event carried no groups" — i.e. an admin command, enforcement on, and
* empty invokerGroups. Fails closed if groups remain undeterminable.
*/
private async isCommandAllowed(commandName: string, msg: TS3TextMessage): Promise<boolean> {
const adminGroups = this.config.adminGroups;
if (canRunCommand(commandName, msg.invokerGroups, adminGroups)) return true;
// Here: admin command, enforcement on, and the provided groups did not match.
// If the event actually carried groups, this is a genuine deny — no lookup.
if (msg.invokerGroups.length > 0) return false;
// Groups unknown (sender not in the view cache): one targeted lookup, then
// re-decide. canRunCommand([], …) is false ⇒ fail-closed when still unknown.
const groups = await this.lookupInvokerGroups(msg.invokerId);
return canRunCommand(commandName, groups, adminGroups);
}
/**
* Best-effort lookup of a sender's server groups by client id, via the
* channel client list (whose entries already carry parsed serverGroups).
* Returns [] when the client can't be found or the query fails (→ deny).
*/
private async lookupInvokerGroups(invokerId: string): Promise<string[]> {
const clid = Number(invokerId);
if (!Number.isFinite(clid) || clid <= 0) return [];
try {
const clients = await this.tsClient.getClientsInChannel();
const match = clients.find((c) => c.id === clid);
return match?.serverGroups ?? [];
} catch {
return [];
}
}
```
- [ ] **Step 4: Run the gate tests to verify they pass**
Run: `npx vitest run "src/bot/instance.test.ts"`
Expected: PASS (existing `runExclusive` tests + the 7 new gate tests).
- [ ] **Step 5: Confirm the live-config invariant**
Confirm `BotInstance` reads `adminGroups` from the shared, mutable config — not a copy. The constructor stores `this.config = options.config` (line 91 region) and the router (`src/web/api/bot.ts`) mutates that same object; no propagation call is needed. Quick check:
Run: `grep -n "this.config = options.config\|this.config.adminGroups" "src/bot/instance.ts"`
Expected: shows the assignment and the gate read (proves the gate uses the live reference).
- [ ] **Step 6: Commit**
```bash
git add "src/bot/instance.ts" "src/bot/instance.test.ts"
git commit -m "feat(bot): gate admin chat commands on adminGroups with fallback + deny reply"
```
---
### Task 4: Read/write `adminGroups` in the settings endpoints
**Files:**
- Modify: `src/web/api/bot.ts` (GET `/settings` lines 35-41; POST `/settings` lines 45-97)
- Test: `src/web/api/bot.test.ts` (append `it` cases to the first `describe("bot router /settings", …)` block)
**Interfaces:**
- Consumes: existing `config.adminGroups: number[]` (already declared in `src/data/config.ts`, default `[]`).
- Produces: `GET /api/bot/settings` returns `adminGroups: number[]`; `POST /api/bot/settings` accepts, validates, persists, and echoes `adminGroups`.
- [ ] **Step 1: Write the failing tests**
Append these `it` cases inside the existing first `describe("bot router /settings", …)` block in `src/web/api/bot.test.ts` (it already wires `app`, `config`, and an admin `cookie`):
```ts
it("GET /settings includes adminGroups reflecting config", async () => {
config.adminGroups = [6, 8];
const res = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings persists a validated adminGroups and GET returns it", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, 8] });
expect(res.status).toBe(200);
expect(res.body.adminGroups).toEqual([6, 8]);
expect(config.adminGroups).toEqual([6, 8]);
const followUp = await request(app).get("/api/bot/settings").set("Cookie", cookie);
expect(followUp.body.adminGroups).toEqual([6, 8]);
});
it("POST /settings filters invalid adminGroups entries (negative, non-integer, non-number)", async () => {
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: [6, -1, 2.5, "x", 8] });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6, 8]);
});
it("POST /settings ignores a non-array adminGroups (leaves config unchanged)", async () => {
config.adminGroups = [6];
const res = await request(app)
.post("/api/bot/settings")
.set("Cookie", cookie)
.send({ adminGroups: "6" });
expect(res.status).toBe(200);
expect(config.adminGroups).toEqual([6]);
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: FAIL — `res.body.adminGroups` is `undefined`; the POST does not persist `adminGroups`.
- [ ] **Step 3: Extend the GET handler**
In `src/web/api/bot.ts`, add `adminGroups` to the GET `/settings` response (the handler at lines 35-41):
```ts
router.get("/settings", requireNotGuest, (_req, res) => {
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
});
```
- [ ] **Step 4: Extend the POST handler**
In the POST `/settings` handler: (a) pull `adminGroups` out of `req.body`; (b) validate + assign before `saveConfig`; (c) echo it in the response. Change the destructuring line (46):
```ts
const { idleTimeoutMinutes, autoPauseOnEmpty, guestMode, adminGroups } = req.body;
```
Add this block just before `saveConfig(configPath, config);` (line 77):
```ts
if (Array.isArray(adminGroups)) {
config.adminGroups = adminGroups.filter(
(g: unknown): g is number =>
typeof g === "number" && Number.isInteger(g) && g >= 0,
);
}
```
Add `adminGroups` to BOTH `res.json({ … })` bodies in this handler (the success response near line 92, and — if present — keep them consistent):
```ts
res.json({
idleTimeoutMinutes: config.idleTimeoutMinutes ?? 0,
autoPauseOnEmpty: config.autoPauseOnEmpty,
adminGroups: config.adminGroups ?? [],
guestMode: config.guestMode,
});
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `npx vitest run "src/web/api/bot.test.ts"`
Expected: PASS (existing settings/guest-mode tests + the 4 new adminGroups tests).
- [ ] **Step 6: Commit**
```bash
git add "src/web/api/bot.ts" "src/web/api/bot.test.ts"
git commit -m "feat(api): read/write adminGroups in bot settings endpoints"
```
---
### Task 5: Admin-only "命令权限" section in Settings.vue
**Files:**
- Modify: `web/src/views/Settings.vue` (template: add a section after the Guest Mode section, before the Bot Profile section ~line 506; script: add state + handlers near the guest-mode block ~line 1093; hydrate in `loadIdleTimeout` ~line 1024)
**Interfaces:**
- Consumes: `GET /api/bot/settings` → `adminGroups: number[]`; `POST /api/bot/settings` with `{ adminGroups: number[] }` (Task 4). Existing `session.isAdmin.value`.
- Produces: UI only.
- [ ] **Step 1: Add the template section**
In `web/src/views/Settings.vue`, insert this `<section>` immediately AFTER the closing `</section>` of the Guest Mode block (the one whose title is `游客模式`, ends ~line 505) and BEFORE the `<!-- Bot Profile … -->` section:
```html
<!-- Command Permissions (admin only) -->
<section v-if="session.isAdmin.value" class="settings-section">
<h2 class="section-title">命令权限</h2>
<p class="profile-section-hint">
限制谁能在 TeamSpeak 聊天里运行管理类命令(stop / clear / remove / move / vol / mode)。
填写允许的服务器组 ID(逗号分隔)。留空 = 不限制,所有人可用。如何查看服务器组 ID 见 README。
</p>
<div class="setting-row">
<div class="prefix-input-wrap">
<input v-model="adminGroupsText" class="input input-sm" placeholder="如 6, 8" />
<button class="btn-primary" :disabled="adminGroupsSaving" @click="saveAdminGroups">
{{ adminGroupsSaving ? '保存中…' : '保存' }}
</button>
</div>
</div>
</section>
```
- [ ] **Step 2: Add the script state + handlers**
In the `<script setup>` block, add this just after the guest-mode block (after `saveGuestMode` closes, ~line 1093):
```ts
// --- Command permissions (admin only) ---
const adminGroupsText = ref('');
const adminGroupsSaving = ref(false);
function applyAdminGroupsFromServer(groups: unknown) {
if (Array.isArray(groups)) {
adminGroupsText.value = groups.filter((g) => typeof g === 'number').join(', ');
}
}
function parseAdminGroups(text: string): number[] {
return text
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => Number(s))
.filter((n) => Number.isInteger(n) && n >= 0);
}
async function saveAdminGroups() {
adminGroupsSaving.value = true;
try {
const res = await axios.post('/api/bot/settings', { adminGroups: parseAdminGroups(adminGroupsText.value) });
applyAdminGroupsFromServer(res.data?.adminGroups);
} catch { /* ignore */ } finally {
adminGroupsSaving.value = false;
}
}
```
- [ ] **Step 3: Hydrate on load**
In `loadIdleTimeout` (the existing function ~lines 1024-1031), add the hydrate call alongside `applyGuestModeFromServer`:
```ts
async function loadIdleTimeout() {
try {
const res = await axios.get('/api/bot/settings');
idleTimeout.value = res.data.idleTimeoutMinutes ?? 0;
autoPauseOnEmpty.value = res.data.autoPauseOnEmpty ?? false;
applyGuestModeFromServer(res.data.guestMode);
applyAdminGroupsFromServer(res.data.adminGroups);
} catch { /* ignore */ }
}
```
- [ ] **Step 4: Type-check the frontend**
Run: `cd "web" && npx vue-tsc --noEmit`
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add "web/src/views/Settings.vue"
git commit -m "feat(web): admin-only command-permission (adminGroups) settings section"
```
---
### Task 6: Document the feature in the README
**Files:**
- Modify: `README.md`
**Interfaces:**
- Consumes: nothing (docs).
- Produces: user-facing documentation of the feature + how to find TS server-group IDs.
- [ ] **Step 1: Locate the insertion point**
Run: `grep -n "游客模式\|Guest\|权限\|adminGroups" "README.md"`
Expected: shows the guest-mode / permissions area. Insert the new subsection immediately after the guest-mode documentation block (or, if there is a dedicated permissions/features section, at its end).
- [ ] **Step 2: Add the documentation block**
Insert this markdown at the chosen point:
```markdown
### TeamSpeak 命令权限(管理类命令限制)
默认情况下,频道里任何人都能运行所有聊天命令。你可以把一组「管理类」命令限制为只有特定 TeamSpeak 服务器组的成员才能运行:
- 受限命令:`stop`、`clear`、`remove`、`move`、`vol`、`mode`
- 其余命令(点歌、队列、跳过、歌词等)始终对所有人开放
- **默认不限制**:管理服务器组列表为空时,所有命令对所有人开放(向后兼容)
**配置方式**
- 网页端:设置 → 命令权限,填写允许的服务器组 ID(逗号分隔),保存即时生效。
- 或编辑 `config.json` 的 `adminGroups`(数字数组),例如 `"adminGroups": [6, 8]`。
填入任意服务器组 ID 后,限制立即开启:只有属于这些组之一的用户才能运行受限命令,其他人会收到「⛔ 需要管理员权限」的提示。
> 提示(fail-closed):当受限命令来自一个机器人当前看不到其服务器组的发送者(例如不在机器人所在频道的私聊),机器人会尝试查询其分组;若仍无法确定,则拒绝执行。
**如何查看服务器组 ID**
在 TeamSpeak 客户端中打开「权限 → 服务器组」(Permissions → Server Groups)对话框,选中某个组后,其 ID 会显示在标题栏/状态栏;或在服务器组管理界面中查看每个组对应的数字 ID。把需要授权的组 ID 填入上面的设置即可。
```
- [ ] **Step 3: Sanity-check the docs render**
Run: `grep -n "命令权限\|adminGroups" "README.md"`
Expected: shows the newly added section.
- [ ] **Step 4: Commit**
```bash
git add "README.md"
git commit -m "docs: document TeamSpeak chat-command permission control"
```
---
## Final verification (after all tasks)
- [ ] Remove stale compiled output, then run the full suite:
```bash
rm -rf dist
npm test
```
Expected: all tests pass (the new `canRunCommand`, `toTS3TextMessage`, gate, and `adminGroups` settings tests included).
- [ ] Full build (backend `tsc` + frontend `vue-tsc` + vite):
```bash
npm run build
```
Expected: SUCCESS (no type errors).
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,520 @@
# Spotify Source — Stage 4 (Polish, Config UI, Docs) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Complete the Spotify source's last mile — make it user-configurable and user-authorizable from the web UI, document it (with the required safety warnings), and land the deferred OAuth-robustness hardening.
**Architecture:** The Stage-3 OAuth endpoints (`/api/spotify/{login,callback,status}`) and the single shared `SpotifyOAuth` already exist but are unreachable from the UI. Stage 4 adds: (1) a `spotify` block to the `/api/bot/settings` config API (secret masked); (2) a resolved-backend indicator on `/status`; (3) OAuth refresh/verifier hardening; (4) the approved Settings "Connect Spotify" card (spec §8); (5) the README Spotify section (spec §11/§12). Deferred (documented, NOT built this stage): runtime binary auto-download, connect play-not-reflected diagnostics, extra watchdog.
**Tech Stack:** TypeScript (ESM, `.js` specifiers), Express, Vitest (root config also runs `web/src/**/*.test.ts` — stores/composables only; there is NO `.vue` component-test harness), Vue 3 + `<script setup>` + Pinia, `vue-tsc` (web build gate).
## Global Constraints
- **Safe-by-default (spec §2/§7):** Spotify stays inert unless `enabled` AND authorized AND a resolvable binary. Nothing in this stage may change behavior when `spotify.enabled === false`.
- **Never expose secrets (spec §2/§7):** `clientSecret` is the operator's own value. The config GET API MUST NOT return the raw `clientSecret` — return only a boolean `hasClientSecret`. POST overwrites `clientSecret` only when a non-empty string is supplied (blank/omitted = unchanged). No bundled credentials, no shared Developer app.
- **Permissions:** config writes gated by `requirePermission("bot.manage")`; reads by `requireNotGuest`; the OAuth login card is additionally shown only to `can('platform.auth')` / admins — mirror the existing Settings gating.
- **Validation mirrors `src/data/config.ts`:** `backend` ∈ `{"auto","go-librespot","librespot"}`; `bitrate` ∈ `{96,160,320}`; `deviceName` non-empty trimmed string (else keep prior); strings coerced/guarded. Reuse the exact value sets.
- **Language:** README additions are in Chinese (repo is Chinese-only), matching existing heading style (`## 功能特性` etc.).
- **Licensing (spec §9):** go-librespot is GPL-3.0 — the README must include its license note + a source offer; the bot ships NO binary (source-only Rust librespot; Linux-only go-librespot assets).
- **Honesty:** live audio / Connect control / real OAuth round-trip remain NOT verifiable here (need Premium + real binaries + a real account). "Done" = unit-tested (mocked process/HTTP/FS), `tsc --noEmit` clean, `cd web && npm run build` clean, full suite green, reviewed. Never claim audio works.
- **Branch:** all work on `feat/spotify-audio`. Do NOT create per-task branches.
- **Full suite command:** `npx vitest run --no-file-parallelism` (avoids the users.test.ts bcrypt LOAD flake). Typecheck: `npx tsc --noEmit`. Web build: `cd web && npm run build`.
---
### Task 1: `/api/bot/settings` reads & writes the `spotify` block (secret masked)
**Files:**
- Modify: `src/web/api/bot.ts` (GET `/settings` response + POST `/settings` destructure/validate/echo)
- Test: `src/web/api/bot.test.ts`
**Interfaces:**
- Consumes: `config.spotify: SpotifyConfig` (`{ enabled, backend, clientId, clientSecret, deviceName, bitrate }`), `saveConfig(configPath, config)`, `requirePermission("bot.manage")`, `requireNotGuest` — all already imported/used in this file.
- Produces (new response shape on GET+POST, both add a `spotify` key):
```ts
// masked view — NEVER includes clientSecret
spotify: {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
deviceName: string;
bitrate: number;
hasClientSecret: boolean; // whether a non-empty secret is stored
}
```
- [ ] **Step 1.1: Write failing tests.** Add to `src/web/api/bot.test.ts` (mirror the existing settings tests — reuse their app/harness + auth stubs). Cover:
1. GET `/api/bot/settings` includes a `spotify` object with `enabled/backend/clientId/deviceName/bitrate/hasClientSecret` and does NOT include a `clientSecret` key. With a stored secret, `hasClientSecret === true`; with `clientSecret: ""`, `false`.
2. POST `/api/bot/settings` with `{ spotify: { enabled: true, backend: "librespot", clientId: "cid", deviceName: "Dev", bitrate: 160 } }` updates all those fields and echoes the masked view; `saveConfig` called.
3. POST with `{ spotify: { backend: "bogus" } }` leaves `backend` unchanged (invalid rejected, not 400 for the whole request — partial-merge semantics like the other fields). Same for `bitrate: 999`.
4. POST with `{ spotify: { clientSecret: "newsecret" } }` sets the secret (assert via `hasClientSecret === true` in the echo AND that `config.spotify.clientSecret === "newsecret"`). POST with `{ spotify: { clientSecret: "" } }` does NOT overwrite an existing secret.
5. POST `/api/bot/settings` spotify write requires `bot.manage` (a member without it → 403; reuse the existing 403 test pattern).
6. An existing settings POST that omits `spotify` still works and does not touch `config.spotify` (no regression).
- [ ] **Step 1.2: Run the tests — expect failure** (`npx vitest run src/web/api/bot.test.ts`): the `spotify` key is absent from responses.
- [ ] **Step 1.3: Extend GET `/settings`.** Add the masked spotify view to the response object (both the GET at ~line 36 and the POST echo at ~line 103 — extract a local helper to avoid duplication):
```ts
// near the top of createBotRouter, after other helpers:
const maskedSpotify = () => ({
enabled: config.spotify.enabled,
backend: config.spotify.backend,
clientId: config.spotify.clientId,
deviceName: config.spotify.deviceName,
bitrate: config.spotify.bitrate,
hasClientSecret: config.spotify.clientSecret.length > 0,
});
```
Add `spotify: maskedSpotify(),` to BOTH the GET response and the POST echo object.
- [ ] **Step 1.4: Handle `spotify` in POST `/settings`.** Add `spotify` to the destructure and a partial-merge block (mirror config.ts validation). Place before `saveConfig(...)`:
```ts
const VALID_BACKENDS = ["auto", "go-librespot", "librespot"] as const;
const VALID_BITRATES = [96, 160, 320];
const sp = req.body?.spotify;
if (sp && typeof sp === "object") {
const t = config.spotify;
if (typeof sp.enabled === "boolean") t.enabled = sp.enabled;
if (typeof sp.backend === "string" && (VALID_BACKENDS as readonly string[]).includes(sp.backend)) {
t.backend = sp.backend as SpotifyConfig["backend"];
}
if (typeof sp.clientId === "string") t.clientId = sp.clientId;
// Secret is write-only + set-on-non-empty so a blank field never wipes it.
if (typeof sp.clientSecret === "string" && sp.clientSecret.length > 0) {
t.clientSecret = sp.clientSecret;
}
if (typeof sp.deviceName === "string" && sp.deviceName.trim().length > 0) {
t.deviceName = sp.deviceName.trim();
}
if (typeof sp.bitrate === "number" && VALID_BITRATES.includes(sp.bitrate)) {
t.bitrate = sp.bitrate;
}
}
```
Add the type import if not present: `import type { SpotifyConfig } from "../../data/config.js";`.
- [ ] **Step 1.5: Run tests — expect pass** (`npx vitest run src/web/api/bot.test.ts`). Then `npx tsc --noEmit` clean.
- [ ] **Step 1.6: Commit.**
```bash
git add src/web/api/bot.ts src/web/api/bot.test.ts
git commit -m "feat(spotify): expose spotify config on /api/bot/settings (secret masked) [S4.1]"
```
---
### Task 2: `/api/spotify/status` reports the RESOLVED backend + binary availability (D11)
**Files:**
- Create: `src/music/spotify/backend-select.ts` (pure resolver) + `src/music/spotify/backend-select.test.ts`
- Modify: `src/music/spotify/controller.ts` (delegate `chooseBackend` to the resolver)
- Modify: `src/web/api/spotify.ts` (`/status` shape + `getBackendInfo` type)
- Modify: `src/web/server.ts` (`getBackendInfo` computes the resolved kind)
- Modify: `src/web/api/spotify.test.ts` (update the `/status` shape assertion)
**Interfaces:**
- Produces:
```ts
// backend-select.ts
export type SpotifyBackendKind = "go-librespot" | "librespot";
export function resolveSpotifyBackendKind(
backend: "auto" | "go-librespot" | "librespot",
goPresent: boolean,
rustPresent: boolean,
): SpotifyBackendKind | null;
// spotify.ts getBackendInfo now returns:
{ backend: string; deviceName: string; binaryAvailable: boolean }
// /status response now:
{ authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean }
```
- Note: `SpotifyBackendKind` currently lives in `controller.ts`. Move the canonical definition to `backend-select.ts` and re-export it from `controller.ts` (`export type { SpotifyBackendKind } from "./backend-select.js";`) so existing importers are unaffected.
- [ ] **Step 2.1: Write failing resolver tests** `src/music/spotify/backend-select.test.ts` — the same 8-case matrix S3.5 used, but against the pure function:
```ts
import { describe, it, expect } from "vitest";
import { resolveSpotifyBackendKind as pick } from "./backend-select.js";
describe("resolveSpotifyBackendKind", () => {
it("auto: go present -> go-librespot", () => expect(pick("auto", true, true)).toBe("go-librespot"));
it("auto: go absent, rust present -> librespot", () => expect(pick("auto", false, true)).toBe("librespot"));
it("auto: neither -> null", () => expect(pick("auto", false, false)).toBeNull());
it("go-librespot: present -> go-librespot", () => expect(pick("go-librespot", true, true)).toBe("go-librespot"));
it("go-librespot: absent -> null even if rust present", () => expect(pick("go-librespot", false, true)).toBeNull());
it("librespot: present -> librespot", () => expect(pick("librespot", true, true)).toBe("librespot"));
it("librespot: absent -> null even if go present", () => expect(pick("librespot", true, false)).toBeNull());
it("auto default fallthrough matches auto", () => expect(pick("auto", true, false)).toBe("go-librespot"));
});
```
- [ ] **Step 2.2: Run — expect failure** (module missing).
- [ ] **Step 2.3: Create the resolver** `src/music/spotify/backend-select.ts`:
```ts
/** Which concrete backend runs for a given config + host binary availability. */
export type SpotifyBackendKind = "go-librespot" | "librespot";
/**
* Pure backend selection shared by SpotifyController.chooseBackend() (per-bot)
* and the web /status endpoint (process-wide). Booleans in, no IO — the caller
* supplies platform+binary presence.
*/
export function resolveSpotifyBackendKind(
backend: "auto" | "go-librespot" | "librespot",
goPresent: boolean,
rustPresent: boolean,
): SpotifyBackendKind | null {
switch (backend) {
case "go-librespot":
return goPresent ? "go-librespot" : null;
case "librespot":
return rustPresent ? "librespot" : null;
case "auto":
default:
if (goPresent) return "go-librespot";
if (rustPresent) return "librespot";
return null;
}
}
```
- [ ] **Step 2.4: Run resolver tests — expect pass.**
- [ ] **Step 2.5: Delegate from the controller.** In `controller.ts`, import the type+resolver **with a local binding** (a bare `export … from` re-export does NOT create a local name, and `SpotifyBackendKind` is still referenced locally at `chooseBackend()`'s return type and `buildBackend(kind: SpotifyBackendKind)` → would fail TS2304). Use:
```ts
import { resolveSpotifyBackendKind, type SpotifyBackendKind } from "./backend-select.js";
export type { SpotifyBackendKind }; // keep the name exported for existing importers
// ...
chooseBackend(): SpotifyBackendKind | null {
return resolveSpotifyBackendKind(this.config.backend, this.goPresent(), this.rustPresent());
}
```
Remove the old inline `switch` body and the standalone `export type SpotifyBackendKind = "go-librespot" | "librespot";` line. Keep `goPresent()/rustPresent()` as-is. Run `npx vitest run src/music/spotify/controller.test.ts` — the S3.5 matrix + auth-gate specs MUST still pass unchanged.
- [ ] **Step 2.6: Update `/status`** in `src/web/api/spotify.ts` — extend the `getBackendInfo` type and the response:
```ts
getBackendInfo: () => { backend: string; deviceName: string; binaryAvailable: boolean };
// ...
router.get("/status", requireNotGuest, (_req, res) => {
const info = opts.getBackendInfo();
res.json({
authorized: oauth.isAuthorized(),
backend: info.backend,
deviceName: info.deviceName,
binaryAvailable: info.binaryAvailable,
});
});
```
- [ ] **Step 2.7: Compute the resolved kind in `server.ts`.** Replace the `getBackendInfo` closure (currently returns raw `config.spotify.backend`) with a resolver call using live probes. Add imports `import { resolveSpotifyBackendKind } from "../music/spotify/backend-select.js";` and `import { isGoLibrespotSupported, findGoLibrespot, isRustLibrespotSupported, findLibrespot } from "../music/spotify/binary.js";` and `import { existsSync } from "node:fs";` (verify existsSync isn't already imported):
```ts
getBackendInfo: () => {
const goPresent = isGoLibrespotSupported() && existsSync(findGoLibrespot());
const rustPresent = isRustLibrespotSupported() && existsSync(findLibrespot());
const resolved = resolveSpotifyBackendKind(options.config.spotify.backend, goPresent, rustPresent);
return {
backend: resolved ?? "none",
deviceName: options.config.spotify.deviceName,
binaryAvailable: resolved !== null,
};
},
```
- [ ] **Step 2.8: Update the `/status` test** in `src/web/api/spotify.test.ts` — the existing `toEqual({ authorized, backend, deviceName })` must become `toEqual({ authorized, backend, deviceName, binaryAvailable })`; extend the fake `getBackendInfo` in that test to return `binaryAvailable`. This is THIS task's contract change; update only the status test.
- [ ] **Step 2.9: Verify.** `npx vitest run src/music/spotify/backend-select.test.ts src/music/spotify/controller.test.ts src/web/api/spotify.test.ts` all pass; `npx tsc --noEmit` clean.
- [ ] **Step 2.10: Commit.**
```bash
git add src/music/spotify/backend-select.ts src/music/spotify/backend-select.test.ts src/music/spotify/controller.ts src/web/api/spotify.ts src/web/server.ts src/web/api/spotify.test.ts
git commit -m "feat(spotify): report resolved backend + binaryAvailable on /status; share backend resolver [S4.2]"
```
---
### Task 3: OAuth robustness — in-flight refresh cache + verifier TTL/cap (D9)
**Files:**
- Modify: `src/music/spotify/spotify-oauth.ts`
- Test: `src/music/spotify/spotify-oauth.test.ts`
**Interfaces:**
- Consumes: existing `SpotifyOAuthOptions.deps?: { http?: AxiosInstance }`. Extend deps with an optional clock for testability: `deps?: { http?: AxiosInstance; now?: () => number }`.
- Produces: no public API change. Internals: `refreshInFlight: Promise<string|null> | null`; `pendingVerifiers: Map<string, { verifier: string; expiresAt: number }>`.
- [ ] **Step 3.1: Write failing tests.** Add to `src/music/spotify/spotify-oauth.test.ts`:
1. **Concurrent refresh collapses to one POST.** Build with a fake `http` whose `post("/api/token")` returns a promise you resolve manually (a `Deferred`) or counts calls, and a MUTABLE fake store (`save()` persists, `load()` returns the last saved value) seeded with an expired token. Fire two `getAccessToken()` calls before the POST resolves; assert `http.post` called exactly ONCE and both awaited results equal the new access token.
2. **In-flight clears after settle.** Using the same mutable store + a mutable `now` (`let t=…; now=()=>t`), after test 1 resolves, advance `t` past the newly-saved `expiresAt` and call `getAccessToken()` again → a NEW POST fires (count → 2). (Requires `toTokens` on `this.now()` per Step 3.3.)
3. **Verifier TTL.** With injected mutable `now`, `buildAuthorizeUrl()` at t=0 (capture its `state`), advance `now` to TTL+1, then `handleCallback(code, state)` → returns false (expired) and the entry is gone.
4. **Verifier cap.** Call `buildAuthorizeUrl()` `VERIFIER_MAX + 1` times (capturing the FIRST `state`); assert **behaviorally** that `handleCallback(code, <first state>)` now returns false (evicted as oldest). Prefer this behavioral assertion over inspecting the private `pendingVerifiers` map (no `as any` cast). Use the injected `now` for all timing.
- [ ] **Step 3.2: Run — expect failure.**
- [ ] **Step 3.3: Add the clock + in-flight refresh.** In the constructor: `this.now = o.deps?.now ?? (() => Date.now());` (add `private now: () => number;`). Rewrite `getAccessToken()` + add the in-flight field:
```ts
private refreshInFlight: Promise<string | null> | null = null;
async getAccessToken(): Promise<string | null> {
if (!this.clientId) return null;
const tokens = this.store.load();
if (!tokens?.refreshToken) return null;
if (tokens.accessToken && this.now() < tokens.expiresAt) return tokens.accessToken;
// Collapse concurrent refreshes: rotation makes a second in-flight refresh
// use a refresh token the first one already invalidated.
if (this.refreshInFlight) return this.refreshInFlight;
this.refreshInFlight = this.refresh(tokens).finally(() => {
this.refreshInFlight = null;
});
return this.refreshInFlight;
}
```
Replace the `Date.now()` in `getAccessToken` (spotify-oauth.ts:188) with `this.now()`. **Also change `toTokens` (spotify-oauth.ts:~221) to compute `expiresAt` from `this.now()` instead of `Date.now()`** — this is REQUIRED for testability: if `toTokens` kept real `Date.now()` while `getAccessToken` used an injected fixed clock, a freshly-refreshed token's `expiresAt` would sit far in the injected past/future and the "subsequent call → new POST" test would be non-deterministic. With both on `this.now()`, the test drives a mutable `now` (e.g. `let t = 0; const now = () => t;`) and advances it past the new `expiresAt` to force the second refresh.
- [ ] **Step 3.4: Add verifier TTL + cap.** Change the map type and the two touch points:
```ts
private pendingVerifiers = new Map<string, { verifier: string; expiresAt: number }>();
private static readonly VERIFIER_TTL_MS = 10 * 60 * 1000;
private static readonly VERIFIER_MAX = 32;
private evictStaleVerifiers(): void {
const t = this.now();
for (const [state, e] of this.pendingVerifiers) {
if (e.expiresAt < t) this.pendingVerifiers.delete(state);
}
// Bound memory even if all are unexpired: drop oldest (insertion order).
while (this.pendingVerifiers.size >= SpotifyOAuth.VERIFIER_MAX) {
const oldest = this.pendingVerifiers.keys().next().value;
if (oldest === undefined) break;
this.pendingVerifiers.delete(oldest);
}
}
```
In `buildAuthorizeUrl()`: call `this.evictStaleVerifiers();` before the set, then `this.pendingVerifiers.set(state, { verifier, expiresAt: this.now() + SpotifyOAuth.VERIFIER_TTL_MS });`.
In `handleCallback()`: replace the `get`:
```ts
const entry = this.pendingVerifiers.get(state);
if (!entry || entry.expiresAt < this.now()) {
this.pendingVerifiers.delete(state);
return false;
}
const verifier = entry.verifier;
```
(Keep the existing `finally { this.pendingVerifiers.delete(state); }`.)
- [ ] **Step 3.5: Run tests — expect pass.** Then `npx vitest run src/music/spotify/spotify-oauth.test.ts` (all, incl. prior S3.2 tests) + `npx tsc --noEmit` clean.
- [ ] **Step 3.6: Commit.**
```bash
git add src/music/spotify/spotify-oauth.ts src/music/spotify/spotify-oauth.test.ts
git commit -m "fix(spotify): collapse concurrent OAuth refresh + TTL/cap PKCE verifiers [S4.3]"
```
---
### Task 4: Settings "Connect Spotify" card (spec §8)
**Files:**
- Create: `web/src/composables/useSpotifySettings.ts` (pure, testable logic) + `web/src/composables/useSpotifySettings.test.ts`
- Modify: `web/src/views/Settings.vue` (add the card + wiring)
**Interfaces (composable — the unit-tested surface):**
```ts
export interface SpotifyConfigForm {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string; // blank means "unchanged"
deviceName: string;
bitrate: number;
}
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
export const SPOTIFY_DISCLAIMER: string; // Chinese risk copy
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> }; // omits clientSecret when blank
export function parseSpotifyRedirect(search: string): "success" | "error" | null; // from ?spotify=...
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" };
```
- [ ] **Step 4.1: Write failing composable tests** `web/src/composables/useSpotifySettings.test.ts` (root vitest picks it up; import from `./useSpotifySettings.js` per the repo's ESM `.js` convention):
- `buildSpotifyPayload` includes `clientSecret` only when non-blank; always includes enabled/backend/clientId/deviceName/bitrate under a `spotify` key.
- `parseSpotifyRedirect("?spotify=success")==="success"`, `"?spotify=error"==="error"`, `"?x=1"===null`.
- `statusSummary(null, false)` → tone `"off"`; `statusSummary({authorized:false,binaryAvailable:false,...}, true)` → tone `"warn"`; `statusSummary({authorized:true,binaryAvailable:true,...}, true)` → tone `"ok"`.
- [ ] **Step 4.2: Run — expect failure** (module missing).
- [ ] **Step 4.3: Implement the composable** `web/src/composables/useSpotifySettings.ts`:
```ts
export interface SpotifyConfigForm { enabled: boolean; backend: "auto" | "go-librespot" | "librespot"; clientId: string; clientSecret: string; deviceName: string; bitrate: number; }
export interface SpotifyStatus { authorized: boolean; backend: string; deviceName: string; binaryAvailable: boolean; }
// 实验性 · 灰色地带 · 需要 Premium · 使用你自己的开发者应用凭据
export const SPOTIFY_DISCLAIMER =
"实验性功能:通过 librespot 播放 Spotify 需要 Spotify Premium 账号,并使用你自己注册的 Spotify 开发者应用凭据。" +
"该方式处于 Spotify 服务条款的灰色地带,风险自负;默认关闭,不会内置任何共享凭据。";
export function buildSpotifyPayload(f: SpotifyConfigForm): { spotify: Record<string, unknown> } {
const spotify: Record<string, unknown> = {
enabled: f.enabled,
backend: f.backend,
clientId: f.clientId,
deviceName: f.deviceName,
bitrate: f.bitrate,
};
if (f.clientSecret && f.clientSecret.length > 0) spotify.clientSecret = f.clientSecret;
return { spotify };
}
export function parseSpotifyRedirect(search: string): "success" | "error" | null {
const v = new URLSearchParams(search).get("spotify");
return v === "success" || v === "error" ? v : null;
}
export function statusSummary(s: SpotifyStatus | null, enabled: boolean): { label: string; tone: "ok" | "warn" | "off" } {
if (!enabled) return { label: "已关闭", tone: "off" };
if (!s) return { label: "未知", tone: "warn" };
if (!s.binaryAvailable) return { label: "未检测到 librespot 可执行文件", tone: "warn" };
if (!s.authorized) return { label: "未授权(点击“连接 Spotify”登录)", tone: "warn" };
return { label: `已就绪 · 后端 ${s.backend}`, tone: "ok" };
}
```
- [ ] **Step 4.4: Run composable tests — expect pass.**
- [ ] **Step 4.5: Add the card to `Settings.vue`.** Insert a new `.account-card`-style section in the settings surface, gated `v-if="can('platform.auth')"` (mirror the platform-login section). Follow the EXISTING patterns in this file:
- typed input + Save → mirror the idle-timeout input/saver (`Settings.vue` idle-timeout block + `saveIdleTimeout()` → `POST /api/bot/settings`);
- select group → mirror the quality button-group for `backend` and `bitrate`;
- toggle → mirror `localAudioEnabled` for `enabled`.
**Fields to render (all six):** the `enabled` toggle, `backend` group, `bitrate` group, a `clientId` text input, a `deviceName` text input, and a **Client Secret** field — a write-only password input (`type="password"`, `autocomplete="off"`), left BLANK on load with a "已设置 / 未设置" hint from `hasClientSecret`; a blank secret on Save means "unchanged" (never wipes). The Secret is §8-mandated — do not omit it.
Wiring (use `axios`, matching the file's other calls):
- **Load** on mount: `GET /api/bot/settings` → populate the form from `res.data.spotify` (leave `clientSecret` blank; show “已设置/未设置” from `hasClientSecret`); `GET /api/spotify/status` → status indicator via `statusSummary`.
- **Save**: `POST /api/bot/settings` with `buildSpotifyPayload(form)`; on success re-load status.
- **Connect**: `const { data } = await axios.get('/api/spotify/login'); window.location.href = data.url;` (guard errors → show message; a 403 means missing `platform.auth`).
- **Redirect handling**: on mount, `parseSpotifyRedirect(window.location.search)`; if `success`/`error`, show a toast/message and strip the param (e.g. `history.replaceState`). Reuse the file's existing notification/toast mechanism if present; otherwise a simple reactive message line.
- **Disclaimer**: render `SPOTIFY_DISCLAIMER` prominently in the card.
Keep the `<script setup>` logic thin — delegate payload/summary/redirect parsing to the composable (already tested). Do not add a `.vue` test (no harness).
- **Keep the two "spotify auth" concepts distinct:** the card's status comes ONLY from `/api/spotify/status` (playback OAuth `authorized`). Do NOT wire it to the player store's `authStatus.spotify`, which reflects `/api/auth/status?platform=spotify` (metadata Web-API `loggedIn`) — a different thing. Leave the player store untouched.
- **`vue-tsc` typing:** annotate the form as `reactive<SpotifyConfigForm>({...})` and the status as `ref<SpotifyStatus | null>(null)`; otherwise a button-group assignment (`form.backend = 'librespot'`) widens `backend` to `string` and the `null` init breaks the union passed into `buildSpotifyPayload`/`statusSummary` under `vue-tsc --noEmit`.
- **Hard order dependency:** `binaryAvailable` on `/api/spotify/status` exists only after Task 2 — execute this task AFTER Task 2.
- [ ] **Step 4.6: Build gate.** `cd web && npm run build` → `vue-tsc --noEmit` clean + vite build succeeds. Then from root `npx vitest run web/src/composables/useSpotifySettings.test.ts` green.
- [ ] **Step 4.7: Commit.**
```bash
git add web/src/composables/useSpotifySettings.ts web/src/composables/useSpotifySettings.test.ts web/src/views/Settings.vue
git commit -m "feat(spotify): Connect-Spotify settings card (config + OAuth login + status) [S4.4]"
```
**Notes:** NOT e2e-verifiable (needs a real account/binary). Verified = composable unit tests + `vue-tsc` typecheck + build. The card only exposes config + a login trigger; it never displays the stored `clientSecret` (GET returns `hasClientSecret`, not the value).
---
### Task 5: README Spotify section (Chinese) — spec §9/§11/§12
**Files:**
- Modify: `README.md` (add a `## Spotify 音源(实验性)` section; add "Spotify" to the feature bullet if appropriate)
**Content (required — write real prose, not placeholders):**
- [ ] **Step 5.1:** Add a top-level section `## Spotify 音源(实验性)` covering, in Chinese:
1. **醒目警告框:** 实验性;需要 **Spotify Premium**;使用**你自己注册的 Spotify 开发者应用**(不内置任何共享凭据);处于 Spotify 服务条款灰色地带,风险自负;**默认关闭**。
2. **工作原理(简述):** 通过 librespot(Rust)/ go-librespot(Linux)作为独立进程解码 → PCM → ffmpeg 重采样到 48k → 走现有 Opus 发送管线。元数据来自 Spotify Web API。
3. **平台矩阵:** Windows → `librespot`(Rust);Linux/Docker → `go-librespot`(可回退 `librespot`);`auto` 自动选择(表格)。
4. **获取二进制:** Rust librespot 无预编译包 → `cargo install librespot` 或 scoop/choco,或将可执行文件放入项目 `bin/`(`bin/librespot.exe` / `bin/librespot`)。go-librespot 仅提供 Linux 资产(`github.com/devgianlu/go-librespot/releases`),放入 `bin/go-librespot` 或加入 PATH。
5. **注册开发者应用 + 回调:** 在 Spotify Developer Dashboard 建应用,取 Client ID,回调地址填 `http://127.0.0.1:<webPort>/api/spotify/callback`(与设置里的 Web 端口一致);PKCE 不需要 Client Secret。
6. **启用步骤:** 设置页「连接 Spotify」卡片 → 填 Client ID、选后端、开启开关 → 保存 → 点「连接 Spotify」完成 OAuth 授权。
7. **许可与来源:** go-librespot 为 **GPL-3.0**,作为独立子进程调用(mere aggregation,不影响本项目许可);附上其源码地址与许可说明(source offer)。
8. **故障排查:** 「未检测到 librespot」→ 检查 `bin/` 或 PATH;「未授权」→ 完成 OAuth;Windows 不支持 go-librespot(FIFO 仅限 POSIX)。
- [ ] **Step 5.2:** Sanity-check the doc renders (headings consistent with existing `##` style; links valid). No code test.
- [ ] **Step 5.3: Commit.**
```bash
git add README.md
git commit -m "docs(spotify): README Spotify source section — setup, binaries, warnings, license [S4.5]"
```
---
### Task 6: Rust Connect command retry/backoff (recovery/watchdog — spec §4.3/§13)
**Rationale:** Spec §4.3 mandates "a watchdog + retry/backoff for device-visibility latency and command 202/404 flakiness"; §13 lists Rust Connect as the **top risk** (mitigation: retry/backoff + degrade-to-skipped). Stage 3 already landed the device-visibility watchdog (`waitForDevice()` bounded poll in `rust-librespot.ts`) and per-track device-absence → skip (`findDeviceByName`→null → `playTrack` false → BotInstance skips). The remaining, un-built piece is transient-failure retry/backoff on the Connect **mutating commands** — this task adds it while preserving C3.6 (never reject up the queue path; swallow on exhaustion).
**Files:**
- Modify: `src/music/spotify/connect-api.ts` (retry/backoff around the mutating PUTs; optional injected `sleep`/`logger`)
- Modify: `src/music/spotify/controller.ts` (pass `this.logger` into the default `SpotifyConnectApi`)
- Test: `src/music/spotify/connect-api.test.ts`
**Interfaces:**
- Consumes: existing `constructor(getToken: () => Promise<string|null>, deps?: { http?: AxiosInstance })`. **Extend deps** to `{ http?: AxiosInstance; sleep?: (ms: number) => Promise<void>; logger?: import("pino").Logger }` (all optional → source-compatible).
- No public method signature change: `transfer/play/pause/resume/seek` stay `Promise<void>` and still swallow on final failure.
- [ ] **Step 6.1: Write failing tests** in `src/music/spotify/connect-api.test.ts` (reuse the existing `getToken`/`http` injection pattern; inject a no-op `sleep: async () => {}` so no real timers run):
1. `play()` retries a transient 404 then succeeds: `http.put` rejects once with `{ response: { status: 404 } }` then resolves → assert `http.put` called TWICE, no throw.
2. `play()` exhausts on persistent 500: `http.put` always rejects `{ response: { status: 500 } }` → assert exactly `MAX_ATTEMPTS` calls, no throw (swallowed), and `logger.warn` called once (logger provided).
3. Non-transient (403) is NOT retried: reject `{ response: { status: 403 } }` → exactly ONE call, no throw.
4. `429` honors a **capped** `Retry-After`: reject once `{ response: { status: 429, headers: { "retry-after": "1" } } }` then resolve → `sleep` called with a bounded delay (≤ the cap) and 2 calls total.
5. `transfer()` uses the same retry path (one representative non-`play` mutator) — 404-then-success → 2 calls.
- [ ] **Step 6.2: Confirm Red** (`npx vitest run src/music/spotify/connect-api.test.ts`).
- [ ] **Step 6.3: Implement retry/backoff.** Add module constants + a private helper and route each mutating PUT through it:
```ts
const TRANSIENT = new Set([404, 429, 500, 502, 503]);
const MAX_ATTEMPTS = 3;
const BASE_DELAY_MS = 150;
const MAX_DELAY_MS = 2_000;
// ...
private async mutateWithRetry(put: () => Promise<unknown>): Promise<void> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
await put();
return;
} catch (err: any) {
const status = err?.response?.status;
if (!TRANSIENT.has(status) || attempt === MAX_ATTEMPTS) {
// C3.6: never reject up the queue path — swallow, but surface once.
this.logger?.warn({ status }, "Spotify Connect command failed (exhausted/non-retryable)");
return;
}
let delay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), MAX_DELAY_MS);
if (status === 429) {
const ra = Number(err?.response?.headers?.["retry-after"]);
if (Number.isFinite(ra) && ra > 0) delay = Math.min(ra * 1000, MAX_DELAY_MS);
}
await this.sleep(delay);
}
}
}
```
In the constructor: `this.sleep = deps?.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));` and `this.logger = deps?.logger;`. Rewrite `transfer/play/pause/resume/seek` so their body becomes: guard `authHeaders()` as today (unauth → `return;`, no retry), then `await this.mutateWithRetry(() => this.http.put(<url>, <body>, { headers, ... }));`. Keep the exact URLs/bodies/params from the current methods.
- [ ] **Step 6.4: Thread the logger from the controller.** In `controller.ts` where the default connect is built:
```ts
this.connect = o.connect ?? new SpotifyConnectApi(() => this.oauth.getAccessToken(), { logger: this.logger });
```
(`Logger` is already imported in controller.ts.) Injected-connect tests are unaffected.
- [ ] **Step 6.5: Update any conflicting existing connect test.** The S3.3 (C3.6) tests assert mutating calls swallow errors. If any asserts a single `http.put` call on a *transient*-status rejection, it now expects `MAX_ATTEMPTS` — update ONLY that count (this task's contract change). Do NOT weaken the swallow/no-throw guarantees or the non-transient single-call behavior.
- [ ] **Step 6.6: Verify.** `npx vitest run src/music/spotify/connect-api.test.ts src/music/spotify/controller.test.ts` all pass; `npx tsc --noEmit` clean.
- [ ] **Step 6.7: Commit.**
```bash
git add src/music/spotify/connect-api.ts src/music/spotify/connect-api.test.ts src/music/spotify/controller.ts
git commit -m "feat(spotify): retry/backoff on Connect commands (device-latency/flakiness watchdog) [S4.6]"
```
**Notes:** Bounded retry adds latency only on the (off-audio-path) control commands and only on transient failure; the injected `sleep` makes tests instant. Not e2e-verifiable (no real account). The `202`-accepted-but-not-effective case from §4.3 is NOT handled here (it needs post-command state verification, which the existing poll-based track-end already tolerates) — noted as a follow-up.
---
### Task 7: Stage 4 verification + whole-branch final review + finish
**Files:** none (verification + review + branch finish).
- [ ] **Step 7.1: Full verification.** `npx vitest run --no-file-parallelism` (all green), `npx tsc --noEmit` (clean), `cd web && npm run build` (clean). Record counts.
- [ ] **Step 7.2: Whole-branch adversarial review.** Review the ENTIRE `feat/spotify-audio` branch (`git merge-base main HEAD`..HEAD) — Stages 2+3+4 — with a fan-out of finders across dimensions (lifecycle/teardown correctness, OAuth/token security + no-secret-leak, backend selection + gating, async races, error handling, test hygiene) and adversarial verification of each finding. Dispatch ONE fix subagent with the consolidated Critical/Important findings; roll up Minors.
- [ ] **Step 7.3: Address findings**, re-verify, then use superpowers:finishing-a-development-branch to complete the branch (tests green → present options → execute choice).
---
## Self-Review (author)
- **Spec coverage:** §8 Settings card → Task 4; §9 binaries/GPL note + §11 README → Task 5; §12 "docs, README" → Task 5; §12/§4.3/§13 "recovery/watchdog" (Connect retry/backoff) → Task 6 (device-visibility watchdog + per-track skip already landed in Stage 3); D9 → Task 3; D11 → Task 2; config editability (prereq for the card) → Task 1.
- **Deferred (documented, NOT built this stage), with rationale:** runtime binary auto-download (spec §9 calls it "optional"; README documents manual install + Docker instead — avoids a tar.gz/checksum/platform-detection downloader on the critical path); the §4.3 `202`-accepted-but-not-effective verification (needs post-command playback-state confirmation the poll-based track-end already tolerates); D10's separate play-not-reflected diagnostic beyond the retry-exhaustion `logger.warn` Task 6 adds. These are listed in the ledger as follow-ups.
- **Placeholder scan:** backend tasks (1–3) carry complete code; frontend/docs (4–5) carry the tested composable in full + explicit wiring contracts + named existing patterns to mirror (Settings.vue has no component-test harness, so exact `.vue` template text is intentionally pattern-referenced, not dictated line-by-line).
- **Type consistency:** `SpotifyBackendKind` canonicalized in `backend-select.ts`, re-exported from `controller.ts`; `getBackendInfo` return type updated in both `spotify.ts` and its `server.ts` supplier and the `/status` test; `buildSpotifyPayload`/`SpotifyStatus` shapes match Task 1's masked view (`hasClientSecret`) and Task 2's `/status` (`binaryAvailable`).
@@ -0,0 +1,53 @@
# Jellyfin 音源(主音源化)实施计划
目标:自建 Jellyfin 服务器成为默认且主要的音源;现有 NetEase / QQ / Bilibili / YouTube / Kugou
继续编译但默认停用(`enabledProviders` 配置门控,本次不删除)。约束:`src/audio/*`、
`src/ts-protocol/*` 不改(唯一例外:`queue.ts` 中 `QueuedSong.platform` 联合类型加一个成员,
纯类型加宽,无任何逻辑改动,否则无法编译)。
## 触点地图
后端:
- `src/music/provider.ts` — platform 联合类型加 `"jellyfin"`(Song/Playlist/Album/MusicProvider)
- `src/audio/queue.ts` — 同上(仅类型,一个词)
- `src/data/database.ts` — PlayHistoryRecord.platform 加 `"jellyfin"`
- `src/music/auth.ts` — CookieStore 平台加 `"jellyfin"`(token JSON 与 cookie 同路径持久化)
- `src/data/config.ts` — 新增 `JellyfinConfig`(serverUrl / authMode / username / password /
apiKey / userId)+ `enabledProviders`(默认 `["jellyfin"]`)+ 载入清洗 + `isProviderEnabled` /
`defaultPlatform` 助手。spotify 仍由 `spotify.enabled` 门控、local 仍由 `localAudioEnabled` 门控
- `src/music/jellyfin.ts` — 新 Provider:认证(userpass=AuthenticateByName + MediaBrowser 头,
401 重登一次;apikey=X-Emby-Token)、搜索(Audio/MusicAlbum/Playlist, StartIndex 翻页)、
懒解析播放 URL(direct=`/Audio/{id}/stream?static=true`;320/192/128=`/Audio/{id}/universal`
转码)、歌单/专辑/歌词(ticks→秒,404=无歌词)、收藏→InstantMix 电台链(收藏→最近播放→随机曲目)、
最近添加/最多播放/流派、播放上报(Sessions/Playing[/Progress|/Stopped],全部吞错)、
音质档(direct 原始直传 / 320k / 192k / 128k)
- `src/music/api-server.ts` — 按 provider 开关决定是否绑定 3001/3200
- `src/index.ts` — 构造 JellyfinProvider、加载持久化 token、穿线
- `src/bot/manager.ts` — 穿线 jellyfinProvider(与 spotifyProvider 同模式)
- `src/bot/instance.ts` — jellyfin 分支(getProviderFor / getProvider,新 flag `-j`/`-n`)、
默认平台=jellyfin、停用音源友好报错、歌单/专辑命令识别 GUID、播放上报挂钩、帮助文本
- `src/web/api/music.ts` — provider 选择 + 门控、`/providers`、jellyfin 首页数据端点、音质路由
- `src/web/api/auth.ts` — jellyfin 状态 + 测试连接(/System/Info);无 QR
- `src/web/api/player.ts` — 平台白名单 + platformFlag(netease 显式 `-n`)
- `src/web/api/bot.ts` — settings 读写 jellyfin 配置块(密码/APIKey 写入不回显)+ 热更新 provider
前端(Vue 3,保持 YesPlayMusic 风格;新增文案 zh+en):
- `stores/player.ts` — Source 加 jellyfin、enabledProviders 状态、jellyfin 首页数据
- `views/Search.vue` — 音源条只显示启用的 provider、Jellyfin 徽标
- `views/Home.vue` — 最近添加 / 播放最多 / 我的歌单 / 收藏 / 流派 区块 + jellyfin FM 卡片
- `views/Settings.vue` — Jellyfin 连接卡(地址/认证模式/凭据/测试连接)、隐藏停用 provider 的
登录卡、jellyfin 音质档
- `views/Setup.vue` — 向导第 3 步换成 Jellyfin 连接卡
- `views/Playlist.vue` 等 — ID 均按 string 处理,审计通过(GUID 无数字假设)
文档:README 增加「Jellyfin 音源」章节 + fork 出处与 MIT 归属(上游
ZHANGTIANYAO1/teamspeak-music-bot)。
## 阶段与验证
1. 类型 + 配置 → `npx tsc --noEmit`
2. JellyfinProvider + 单测 → tsc + vitest
3. 门控 + bot 穿线 → tsc + vitest
4. REST → tsc + vitest
5. WebUI → `cd web && npm run build`
6. README + 全量构建/测试
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
# Bot Profile Manager — Design Spec
## Goal
When the bot plays a song, automatically update its TeamSpeak presence (avatar, description, nickname, away status, channel description) and send a "now playing" chat message. When playback stops, restore all values to defaults. Each feature is independently configurable and permission-safe — if the bot lacks a required server permission, that feature silently disables itself until the next reconnect.
## Features
| # | Feature | Update on song | Restore on stop | TS3 mechanism | TS6 mechanism |
|---|---------|---------------|-----------------|---------------|---------------|
| 1 | Avatar | Album cover art | Delete avatar | File transfer upload + `clientupdate client_flag_avatar=<md5>` | Same (file transfer is protocol-level) |
| 2 | Description | `歌名 - 歌手 [专辑]` | Clear (empty string) | `clientupdate client_description=...` | `httpQuery.clientUpdate(...)` |
| 3 | Nickname | `♪ 歌名 - 歌手 \| 原昵称` (max 30 chars) | Restore `defaultNickname` | `clientupdate client_nickname=...` | `httpQuery.clientUpdate(...)` |
| 4 | Away status | `client_away=1`, message = `正在播放: 歌名 - 歌手` | `client_away=0` | `clientupdate client_away=...` | `httpQuery.clientUpdate(...)` |
| 5 | Channel description | `正在播放: 歌名 - 歌手\n专辑: xxx\n平台: xxx` | Clear | `channeledit cid=... channel_description=...` | `httpQuery.request("POST", "/1/channeledit", ...)` |
| 6 | Now-playing message | `♪ 正在播放: 歌名 - 歌手 [专辑]` | (not sent on stop) | `sendTextMessage` (existing) | `sendTextMessage` (existing) |
## Architecture
```
resolveAndPlay() success / stop / clear / playNext exhausted
↓
BotInstance → BotProfileManager.onSongChange(song | null)
├─ updateAvatar(coverUrl | null) [fire-and-forget]
├─ updateDescription(song | null)
├─ updateNickname(song | null)
├─ updateAwayStatus(song | null)
├─ updateChannelDescription(song | null)
└─ sendNowPlayingMessage(song) [only when song != null]
BotInstance.connect() → profileManager.onConnect() // reset perm flags, restore defaults
BotInstance.disconnect() → (no action needed, server cleans up)
```
## File Changes
| File | Change |
|------|--------|
| `src/bot/profile.ts` | **New** — `BotProfileManager` class |
| `src/ts-protocol/client.ts` | Add `execCommand`, `execCommandWithResponse`, file transfer methods, `escapeTS3()` |
| `src/bot/instance.ts` | Create & hold `BotProfileManager`, call at lifecycle points |
| `src/data/database.ts` | Add 6 profile config columns via ALTER TABLE migration |
| `src/web/api/player.ts` | Add `GET/PUT /api/player/:botId/profile` endpoints |
## TS3Client Layer Extensions
New methods on `TS3Client` (all delegate to underlying `@honeybbq/teamspeak-client` Client):
```typescript
execCommand(cmd: string): Promise<void>
execCommandWithResponse(cmd: string): Promise<Record<string, string>[]>
fileTransferInitUpload(channelID: bigint, path: string, password: string,
size: bigint, overwrite?: boolean): Promise<FileUploadInfo>
uploadFileData(host: string, info: FileUploadInfo, data: Readable): Promise<void>
fileTransferDeleteFile(channelID: bigint, paths: string[]): Promise<void>
```
Utility: `escapeTS3(str: string): string` — escapes spaces (`\s`), backslashes (`\\`), pipes (`\p`), slashes (`\/`).
## BotProfileManager Detail
```typescript
interface ProfileConfig {
avatarEnabled: boolean; // default true
descriptionEnabled: boolean; // default true
nicknameEnabled: boolean; // default true
awayStatusEnabled: boolean; // default true
channelDescEnabled: boolean; // default true
nowPlayingMsgEnabled: boolean; // default true
}
```
### Permission Handling
- Each feature has an independent `permDenied: boolean` flag.
- On first failure where error message contains "permission" or "insufficient" → set flag, skip subsequent calls.
- On `onConnect()` → reset all flags (new connection may have different permissions).
- Non-permission errors (network timeout, etc.) do NOT set the flag — next song change will retry.
### Nickname Truncation
- Format: `♪ {songInfo} | {defaultNickname}`
- TS3 max nickname: 30 characters
- If total > 30: truncate songInfo, keep defaultNickname
- If `♪ | {defaultNickname}` alone > 30: skip nickname update entirely
### Avatar Upload Flow (TS3)
1. Download cover image via axios (HTTP GET coverUrl) → Buffer
2. `fileTransferInitUpload(0n, "/avatar", "", BigInt(buffer.length), true)`
3. `uploadFileData(host, info, Readable.from(buffer))`
4. Compute MD5: `crypto.createHash('md5').update(buffer).digest('hex')`
5. `execCommand("clientupdate client_flag_avatar=" + md5)`
To clear: `fileTransferDeleteFile(0n, ["/avatar"])` + `execCommand("clientupdate client_flag_avatar=")`
### Fire-and-Forget
Avatar download/upload is slow. `onSongChange()` launches all updates concurrently via `Promise.allSettled()` — failures are logged but never block playback.
## Database Migration
```sql
ALTER TABLE bot_instances ADD COLUMN profile_avatar_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_description_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_nickname_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_away_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_channel_desc_enabled INTEGER DEFAULT 1;
ALTER TABLE bot_instances ADD COLUMN profile_now_playing_enabled INTEGER DEFAULT 1;
```
## Web API
```
GET /api/player/:botId/profile → { avatarEnabled, descriptionEnabled, ... }
PUT /api/player/:botId/profile → body: Partial<ProfileConfig> → 200 OK
```
## Constraints
- All profile operations are async, never block playback
- Uses existing `axios` dependency for image download
- MD5 via Node.js built-in `crypto`
- No new npm dependencies required
@@ -0,0 +1,125 @@
# Design: FM Bug Fix + Artist Loop + Playlist Fuzzy Search
Date: 2026-04-27
## Overview
Three features for the TeamSpeak Music Bot:
1. New `!artist <name>` command — loop playback filtered by artist
2. Fuzzy playlist name search in existing `!playlist` command
3. Fix `!fm` audio dropout bug (no sound after a few songs but status shows playing)
---
## Feature 1: `!artist` Command
### Behavior
`!artist <歌手名> [-q|-b|-y]` searches for songs by the artist, loads them into the queue, sets the queue mode to `Loop`, and starts playing.
### Flow
1. Parse command with optional platform flags (`-q`, `-b`, `-y`)
2. Call `provider.search(歌手名, 50)` to get up to 50 results
3. Filter results: only keep songs where `song.artist` contains the search query (case-insensitive)
4. If filtered list is empty, fall back to unfiltered search results (up to 20)
5. Clear current queue, add filtered songs, set mode to `Loop`
6. Play first song via `resolveAndPlay`
### Key Decisions
- **Why Loop mode?** The user said "循环播放" (loop playback). After the artist's songs are exhausted, they should restart.
- **Why filter client-side?** The search API doesn't support artist-only filtering. We search broadly then narrow down.
- **Why 50 results?** The default limit is 20, but for prolific artists we want more coverage. 50 balances API response size with coverage.
### Files Changed
- `src/bot/commands.ts`: Register `artist` in PUBLIC_COMMANDS, update help text
- `src/bot/instance.ts`: New `cmdArtist()` method
---
## Feature 2: Playlist Fuzzy Search
### Behavior
`!playlist <name or ID>` now accepts both playlist IDs and playlist names. When the input is not a pure numeric ID, it searches for matching playlists and uses the top result.
### Flow
1. Parse input — if it's a pure numeric ID or contains a URL with an ID, use existing logic
2. Otherwise, call `provider.search(input)` which already returns `playlists[]` in the result
3. Also call `provider.getUserPlaylists()` if the provider supports it (logged-in state)
4. Client-side fuzzy match user playlists: `playlist.name` contains input (case-insensitive)
5. Merge results: public search results first (sorted by API relevance), then user matches
6. Take the first playlist, load its songs, play
### Key Decisions
- **Why public search first?** It's already sorted by relevance from the API. User playlists are a secondary source.
- **Why client-side matching for user playlists?** The `getUserPlaylists()` API returns all user playlists without a search parameter, so we must filter locally.
- **Backward compatibility:** Numeric IDs and URL parsing are unchanged.
### Files Changed
- `src/bot/instance.ts`: Modify `cmdPlaylist()` to add search fallback
- `src/bot/commands.ts`: Update help text
---
## Feature 3: FM Bug Fix
### Root Cause Analysis
The `!fm` bug manifests as: audio stops after a few songs, but `!now` shows a playing song and the song name keeps changing.
`getPersonalFm()` returns only ~3 songs per API call. After those are consumed:
- In `Sequential` mode: `queue.next()` returns null → `player.stop()` is called → playback stops entirely. This does NOT match "歌还在轮播" (songs still rotating).
- In `Loop` mode (if user changed mode): the same 3 songs loop, but URLs may expire, causing silent playback failures.
The most likely scenario for "no audio but status shows playing + song names changing":
1. FM songs have URLs that resolve but don't produce playable audio (copyright/region restrictions)
2. ffmpeg spawns, connects to the URL, gets an HTTP error or silent stream
3. ffmpeg exits quickly (clean exit or error)
4. The frame loop detects ffmpeg gone + buffer empty → emits `trackEnd`
5. `playNext()` advances to the next song
6. This rapid cycle (spawn → fail → advance) makes it appear that songs are "playing and rotating" but with no audio
7. After 3 consecutive ffmpeg spawn failures, `consecutiveFailures >= MAX_CONSECUTIVE_FAILURES` → player refuses to spawn new ffmpeg processes
8. After that, `resolveAndPlay` still sets state via `player.play()` which immediately emits "error" → `playNext()` skips to next → cycle continues with no ffmpeg at all
### Fix Strategy
**Fix 1 — FM auto-refill (primary fix):**
- In `cmdFm()`, set queue mode to `RandomLoop` so the queue never "runs out"
- Add a `refillFm()` method that fetches more FM songs and appends to queue
- Hook into the `trackEnd` flow: when queue has ≤ 2 songs remaining and we're in FM mode, trigger a refill
- Track FM state with a boolean flag `isFmMode` on the instance
**Fix 2 — Reset consecutive failures on successful playback (safety net):**
- Reset `consecutiveFailures` when a track plays successfully for at least N frames (e.g., 50 frames = 1 second)
- This prevents transient URL failures from accumulating toward the hard limit
**Fix 3 — FM refill before queue exhaustion:**
- After `playNext()` successfully starts a song, check if `isFmMode` and `queue.size() - currentIndex <= 2`
- If so, fire an async refill (don't block playback)
### Files Changed
- `src/bot/instance.ts`: Modify `cmdFm()`, add `refillFm()`, add FM state tracking, modify `playNext()` to check for FM refill
- `src/audio/player.ts`: Add `framesPlayed` threshold check to reset `consecutiveFailures`
---
## Implementation Order
1. **FM bug fix** first — it's a bug fix affecting current users
2. **Playlist fuzzy search** — small change, quick win
3. **Artist loop** — new feature, depends on queue/player being stable
---
## Testing
- FM: Verify songs keep playing beyond the initial 3-song batch, verify auto-refill works
- Playlist: Test with numeric ID (backward compat), test with playlist name (fuzzy search)
- Artist: Test with known artist names, test edge case (no results), test with platform flags
@@ -0,0 +1,189 @@
# Multi-Source Tabs for Recommend / User Playlists / Daily Songs
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Home 和 Library 页面的"推荐歌单 / 每日推荐 / 我的歌单"这三类内容当前硬编码只走网易云。当用户同时登录了网易云和 QQ 音乐时,无法在 Web UI 上看到 QQ 侧的对应内容、也无法切换查看。
## Goal
在以下 4 个 section 上提供"网易云 / QQ"来源切换 tab,桌面端和移动端均可用:
- `Home.vue` — 推荐歌单
- `Home.vue` — 每日推荐
- `Home.vue` — 我的歌单
- `Library.vue` — 我的歌单
切换为纯前端动作(数据已预先 fetch),无加载闪烁。各 section 的选择独立持久化。
## Out of Scope
- 私人 FM(QQ 无对应概念)
- B 站热门(独立第三来源,不属于网易/QQ 切换语义)
- 最近播放(bot 维度的播放历史,与音乐源无关)
- Library 现有的"我的收藏"段落 —— 当前调用的 `/api/music/user/liked` 端点不存在,是死代码,本次顺手移除
- 登录状态实时同步(用户在 Settings 登录后需手动刷新 Home/Library 才能看到 QQ tab)
- Tab 排序、隐藏、拖动等高级配置
## Non-functional Constraints
- 桌面端(>768px)和移动端(≤768px)布局均可用,tab 与 section title 同行排布;空间不足时允许 flex-wrap
- Tab 触控区域有效高度 ≥36px
- 现有 5 分钟 home data cache 行为保留
- 不引入新的后端端点(后端已通过 `?platform=` 参数支持多源)
## Architecture
### 数据层(`web/src/stores/player.ts`)
字段从单平台改为按 platform 切分:
```ts
// 前
recommendPlaylists: PlaylistItem[]
userPlaylists: PlaylistItem[]
dailySongs: Song[]
// 后
recommendPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
userPlaylists: { netease: PlaylistItem[]; qq: PlaylistItem[] }
dailySongs: { netease: Song[]; qq: Song[] }
// 新增
authStatus: { netease: boolean; qq: boolean }
```
`fetchHomeData()` 改写:
1. 并发调用 `/api/auth/status?platform=netease` 与 `?platform=qq`,写入 `authStatus`
2. 网易云的三类数据照常 fetch(推荐歌单匿名可访问;每日推荐和我的歌单需登录,未登录时 API 自然返回空或失败,`Promise.allSettled` 已隔离)
3. QQ 的三类数据**仅在 QQ 登录时** fetch,未登录则为空数组
4. B 站热门保持原样
5. 5 分钟缓存 TTL 不变
### UI 组件
新增 `web/src/components/SourceTabs.vue`:
```vue
<SourceTabs v-model="activeSource" :sources="availableSources" />
```
Props:
- `sources: ('netease' | 'qq')[]` — 由父组件根据 auth 状态过滤后传入
- `modelValue: 'netease' | 'qq'` — v-model 绑定
行为:
- `sources.length < 2` 时组件**自身不渲染**(返回空),父组件无需 v-if 包装
- 文字标签:`{ netease: '网易云', qq: 'QQ' }`
- 视觉:水平排列,激活态用主色(`var(--color-primary)`)下划线 + 加粗,未激活态使用次要文字色
- 紧贴 section-title 右侧,使用 `display: inline-flex`,移动端 padding/font-size 缩小
### 各 section 接入模板
```vue
<section v-if="recommendAvailable.length > 0" class="section">
<h2 class="section-title">
推荐歌单
<SourceTabs v-model="recommendSource" :sources="recommendAvailable" />
</h2>
<div class="playlist-grid">
<RouterLink
v-for="pl in store.recommendPlaylists[recommendSource]"
:key="pl.id"
:to="`/playlist/${pl.id}?platform=${pl.platform}`"
class="playlist-card hover-scale"
>
<CoverArt :url="pl.coverUrl" :size="160" :radius="10" :show-shadow="true" />
<div class="playlist-name">{{ pl.name }}</div>
</RouterLink>
</div>
</section>
```
每个 section 在 `<script setup>` 维护两个值:
- `recommendSource: Ref<'netease' | 'qq'>` — 当前选中
- `recommendAvailable: ComputedRef<('netease' | 'qq')[]>` — 该 section 在当前登录状态下有哪些 source 可选
`recommendAvailable` 计算规则:
| Section | netease 加入条件 | qq 加入条件 |
|---|---|---|
| Home 推荐歌单 | 总是(公开数据) | `authStatus.qq` |
| Home 每日推荐 | `authStatus.netease` | `authStatus.qq` |
| Home 我的歌单 | `authStatus.netease` | `authStatus.qq` |
| Library 我的歌单 | `authStatus.netease` | `authStatus.qq` |
#### "我的歌单"展开按钮兼容
Home 的"我的歌单"现有 `USER_PLAYLIST_LIMIT = 20` 折叠/展开。改造后:
```ts
const visibleUserPlaylists = computed(() => {
const all = store.userPlaylists[userSource.value] ?? [];
return userPlaylistsExpanded.value ? all : all.slice(0, USER_PLAYLIST_LIMIT);
});
```
切换 source 时折叠态保留(不需 reset)。
### 持久化
localStorage 键统一为一个 JSON:
```
key: "source-tabs"
value: {
"home.recommend": "qq",
"home.daily": "netease",
"home.user": "qq",
"library.user": "netease"
}
```
读:组件 mount 时一次性读 + 解析。
写:在 v-model 的 setter 里 `watch` 一次写回。
不存在的键 / 解析失败 / 老用户没这个 key —— 默认值 `"netease"`。
### 边界与回退
| 情况 | 行为 |
|---|---|
| 选的 source 不在 `available` 里(例:选了 QQ,登出后回到页面) | 渲染时 fallback 到 `available[0]`,不修改 localStorage(保留用户偏好,下次登回来仍生效) |
| `recommendPlaylists.qq` 为空数组(API 失败 or 无数据) | tab 仍可切,切过去显示空 grid(无错误提示,符合现有"空数据隐藏 section"语义;section 顶层 v-if 检查的是 `available.length > 0`,单 platform 数据为空不影响 section 显隐) |
| QQ provider 不支持 `getDailyRecommendSongs`(501) | `Promise.allSettled` 已捕获,`dailySongs.qq` 保持 `[]`,等同上一行 |
| 用户在 Settings 登录 QQ 后切回 Home | 不自动 refetch / 不自动出 tab —— 用户需手动刷新页面(保留 5 分钟缓存语义) |
| 网易云和 QQ 都没登录 | `available` 为空时 section 隐藏(沿用现有逻辑) |
| Library "我的收藏" 段落 | 整段移除(含 template、script 中的 `liked` ref、对应 axios 调用) |
## 修改文件清单
新增:
- `web/src/components/SourceTabs.vue` — 共享 tab 组件
修改:
- `web/src/stores/player.ts` — 多平台 state、`authStatus`、`fetchHomeData` 重写
- `web/src/views/Home.vue` — 三个 section 接入 SourceTabs,对应 ref + computed
- `web/src/views/Library.vue` — 我的歌单接入 SourceTabs;移除我的收藏死代码
不改:
- 后端(API 已支持 `?platform=`)
- `Search.vue` / `Playlist.vue` / `Settings.vue` 等其他页面
## 测试方案
- 手动:在两种登录组合下访问 Home 和 Library,验证 tab 显隐、切换、刷新后持久化
- 仅网易登录 → 不显示 tab
- 两边登录 → 显示 tab,切换后刷新页面来源不变
- QQ 登录 → 网易登出 → 网易 tab 消失,若上次选的是网易则 fallback 到 QQ
- 移动端(DevTools 768px 以下):tab 与 section-title 同行不溢出,触控区域可点
- TypeScript 类型检查通过:`npx tsc --noEmit` + `npm run build:web`
- 单元测试:现有 vitest 套件不应回归(store 改动不破坏其他用法)
## 风险
- store 字段类型变更(数组 → 对象)会影响所有读取这三个字段的地方。需 grep 确认没有遗漏的消费者。
- localStorage 解析失败的容错必须周全,避免一次脏数据导致整个页面白屏。
@@ -0,0 +1,226 @@
# History-aware `prev` + "Play Next" Insert
**Date:** 2026-05-06
**Status:** Spec — pending implementation
## Problem
Two queue/playback gaps surfaced in real use:
1. In `PlayMode.Random` and `PlayMode.RandomLoop`, `!prev` does not play the
actually-previously-played song. It just walks `currentIndex - 1` in the
underlying array — but in random modes `currentIndex` jumps non-sequentially,
so the "previous" array slot has no relationship to play history.
2. `!add` / web "添加到队列" appends to the queue tail. There is no way to
say "play this song right after the current one." Users want a "下一首
播放" affordance comparable to Spotify "Add to Queue (next up)" or Apple
Music "Play Next".
## Goals
- `prev` walks back through the actual play history regardless of mode.
- A new "Play Next" path inserts a song at `currentIndex + 1`, available
via web UI button and TS3 chat command.
- Both features are usable on desktop and mobile web.
## Out of Scope
- Forward/redo through prev'd songs (user would need to push next manually,
which picks a fresh random in random modes — acceptable simplification).
- Reordering songs already in the queue ("move to next" inside Queue.vue).
- Persisting play history across bot restarts (in-memory only).
## Non-functional Constraints
- History capped at 50 entries to bound memory.
- `addNext` must keep `playedIndices` and `history` index references valid
after insertion (shift all indices > current by +1).
- New Toast UX from the previous round still applies (failures surface).
- TypeScript and existing test suite must not regress.
## Architecture
### A. History-aware `prev`
**`src/audio/queue.ts`** — `PlayQueue` gains a back-stack:
```ts
private history: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
```
Mutators call `pushHistory(this.currentIndex)` **before** changing `currentIndex`:
| Method | History action |
|---|---|
| `play()` | `this.history = []` (fresh playback) |
| `playAt(idx)` | `pushHistory(currentIndex)`, then set `currentIndex = idx` |
| `next()` | `pushHistory(currentIndex)`, then advance per mode |
| `prev()` | **Pop** from history → `currentIndex = popped`. If empty, fall back to existing `currentIndex - 1` (which keeps Sequential's wrap behavior; Random returns null). `prev` itself does NOT push to history. |
| `clear()` | `this.history = []` |
| `setMode(m)` | `this.history = []` (mode change resets context) |
| `remove(idx)` | Drop matching entries from history; shift any entry `> idx` by `-1`. Same logic as the existing `playedIndices` rebuild. |
**`prev()` rewrite:**
```ts
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// History-driven path (preferred when we have one)
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices.add(idx);
return this.songs[idx];
}
// popped index is stale (song removed) — keep popping
}
// Fallback: old index-based prev
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
```
### B. Play Next (insert after current)
**`PlayQueue.addNext(song)`:**
```ts
addNext(song: QueuedSong): void {
if (this.currentIndex < 0 || this.songs.length === 0) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
// Shift any tracked index > currentIndex by +1
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
this.history = this.history.map((i) => (i > this.currentIndex ? i + 1 : i));
}
```
**Backend endpoint** — `src/web/api/player.ts`:
```
POST /api/player/:botId/play-next-song
body: { song: Song }
```
Behavior:
- If queue is empty or `currentIndex < 0`: `queue.addNext(song)` (which falls
through to plain push), then `queue.play()`, then `resolveAndPlay`. Same
semantics as a successful `/play-song` — message: "正在播放:…"
- Otherwise: `queue.addNext(song)`, no resolveAndPlay. Message: "已加入下一首:…"
- Returns `{ ok: boolean, message: string }` matching the convention
established in the previous round.
**Bot command** — `src/bot/instance.ts`:
Register `!playnext <query>` (alias `!pn`):
- Mirror of `cmdPlay`'s search step
- On match: `queue.addNext(song)`. If no current playback, fall through to
`resolveAndPlay`.
- Reply with `已加入下一首:<name>` or `正在播放:<name>` accordingly
**Frontend store action** — `web/src/stores/player.ts`:
```ts
async playNextSong(song: Song) {
if (!this.activeBotId) 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');
}
}
```
**Frontend SongCard** — `web/src/components/SongCard.vue`:
Add a third action button between the existing "play" and "add to queue":
```vue
<button class="action-btn" @click.stop="$emit('playNext')" title="下一首播放">
<Icon icon="mdi:playlist-play" />
</button>
```
Add `playNext: []` to `defineEmits`.
**Caller updates** — `Home.vue`, `Library.vue`, `Search.vue`, `History.vue`,
`Playlist.vue`: each `<SongCard>` usage adds
`@playNext="store.playNextSong(song)"`.
**Queue.vue** is intentionally **not** updated — clicking "play next" on a
song already in the queue would create a confusing duplicate.
## Edge Cases
| Case | Behavior |
|---|---|
| `prev` with empty history in Sequential mode | Walks `currentIndex - 1`; returns null at index 0 (existing) |
| `prev` with empty history in Random/RandomLoop | Returns null (no past to recover) |
| Repeated `prev` past start of history | Pops what's there, then falls back to index walk; eventually null |
| `addNext` while `currentIndex == -1` (nothing played yet) | Falls through to push; queue.play() will pick it as first |
| `addNext` while playing and queue size = 1 | Inserts at index 1; current index unchanged; next() will advance to it |
| `remove` removes a song whose index is in history | Entry dropped; shifted accordingly |
| Mode switched mid-playback | History cleared (intentional — mode change is a context boundary) |
| `addNext` then `prev` | Inserted song was never played → not in history; prev pops the previously-played song, NOT the just-inserted one |
## Files Touched
- `src/audio/queue.ts` — history field, `pushHistory`, `addNext`, rewritten `prev`, mutator updates
- `src/audio/queue.test.ts` (or add if missing) — unit tests for history behavior + addNext shift logic
- `src/bot/instance.ts` — register `!playnext` / `!pn` command handler
- `src/web/api/player.ts` — new `/play-next-song` route
- `web/src/stores/player.ts` — `playNextSong` action
- `web/src/components/SongCard.vue` — third action button + emit
- `web/src/views/Home.vue` — wire `@playNext`
- `web/src/views/Library.vue` — wire `@playNext`
- `web/src/views/Search.vue` — wire `@playNext`
- `web/src/views/History.vue` — wire `@playNext`
- `web/src/views/Playlist.vue` — wire `@playNext`
## Test Plan
**Unit (vitest, `queue.test.ts`):**
- prev with empty history in Sequential: walks back, null at index 0
- prev with empty history in Random: returns null
- next → next → next → prev pops correctly; prev again pops earlier
- prev after `clear()` returns null (history reset)
- prev after `setMode()` returns null (history reset)
- `remove(idx)` drops from history and shifts entries > idx
- `addNext` while empty: appends
- `addNext` while playing index 2 in a 5-song queue: ends up at index 3, currentIndex still 2, queue size 6
- `addNext` then `next()`: plays the inserted song
- `addNext` shifts existing playedIndices and history correctly
**Integration (manual smoke):**
- Random mode: play 4 songs, hit `prev` 3 times → walks back through history
- Click "下一首播放" on a search result → next song after current is the chosen one
- `!playnext 七里香` → bot replies "已加入下一首:..."; current keeps playing; next song is 七里香
- `!playnext` while idle → starts playing immediately
**Regression:**
- Existing 161 source-tree tests still pass.
- TypeScript `tsc --noEmit` and `npm run build:web` clean.
@@ -0,0 +1,149 @@
# 自定义机器人头像 + 专辑搜索/播放
**Date:** 2026-05-07
**Status:** Spec — pending implementation
**Issue:** [#51](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/51)
## Problem
Issue #51 的两个独立但同源的反馈:
1. **机器人头像无法固定** — 当前 `ProfileConfig.avatarEnabled` 控制是否同步专辑封面,但没有任何"上传一张固定头像"的入口。多 bot 房间里用户依赖头像辨识具体 bot,封面跟着歌变会让识别成本变高。
2. **网页端搜索不能播放整张专辑** — `SearchResult.albums` 类型字段存在但所有 provider 都返回 `[]`,搜索 API `/search/all` 只聚合 `songs`;Search.vue 里也只渲染 SongCard。Netease 后端 `getAlbumSongs` 已实现,唯独缺把搜索/UI 连起来。
两件事独立,分两个 PR;本 spec 同时覆盖两块以保持 #51 的单一 issue 关系。
## Goal
### 自定义头像
- "创建新实例"弹窗里有一个"自定义头像"上传/预览控件(PNG/JPG/WebP,≤200 KB,与 TS3 头像上限一致)
- Settings 已有的"同步头像"那行下面增加同等的"自定义头像"卡片,可以在已存在的 bot 上随时改/删
- 行为矩阵:
| `avatarEnabled` | 有自定义 | 播放时 | 停播时 |
|---|---|---|---|
| true | 是 | 跟当前歌曲封面 | **回到自定义** |
| true | 否 | 跟当前歌曲封面 | 清空(保持现状) |
| false | 是 | 一直显示自定义 | 一直显示自定义 |
| false | 否 | 不主动改 | 不主动改 |
### 专辑搜索
- 搜索结果里能看到"专辑"分区(先支持 Netease + QQ,bilibili/youtube 仍返回 `[]`)
- 点专辑卡片进入详情页 → 看到曲目列表 + 顶部"播放全部 / 加入队列"
## Out of Scope
- 头像格式自动转换(用户传 GIF/BMP 不接受,前端校验拒掉)
- 头像服务端自动 resize(本期保持"上传时校验大小",后期可加 sharp/jimp 但不在本期)
- 专辑搜索的多平台聚合排序(按 `netease → qq` 简单拼接,与现有 `songs` 聚合一致)
- 专辑详情页的"喜欢/收藏"按钮(playlist 详情页本身也没有)
- 专辑作为推荐位(Home 不出现"推荐专辑"这一栏)
- bilibili / youtube 的专辑概念(这两个平台无对应 API)
## Architecture
### 自定义头像
#### 存储
- 文件落地 `data/avatars/<botId>.<ext>`(仿 `data/cookies/<platform>.json`,Docker volume 友好)
- DB schema:`bot_instances` 表新增 `custom_avatar_path` TEXT NULL(存相对路径,如 `avatars/<botId>.png`),通过 `migrateSchema()` 迁移
- 加载时机:`BotProfileManager` 构造时把文件读到内存 `Buffer`,避免每次 stop 都读盘
#### 后端 API
新增 `src/web/api/bot.ts` 里(如不存在则在 `instance.ts` 同源处):
- `POST /api/bot/:id/avatar` (multipart) — 校验大小 ≤200 KB、MIME ∈ {png,jpeg,webp};写盘 + 更新 DB;广播给运行中实例(重新加载 buffer + 立即 `applyIdleAvatar()`)
- `DELETE /api/bot/:id/avatar` — 删盘 + 清 DB;运行中实例切回原 clear 语义
- `GET /api/bot/:id/avatar` — 直接 `res.sendFile`(带强 ETag)供前端预览
#### `BotProfileManager` 改动
新增字段 + 方法:
```ts
private customAvatar: Buffer | null = null;
setCustomAvatar(buf: Buffer | null): void;
private async applyIdleAvatar(gen: number): Promise<void>; // 上传 customAvatar
```
修改:
- `clearAvatar(gen)` → `if (this.customAvatar) { applyIdleAvatar(gen) } else { 当前逻辑 }`
- `onConnect()` 新增:`if (!avatarEnabled && customAvatar) applyIdleAvatar(gen)`
- `setCustomAvatar(buf)`:更新内存 buffer,并触发 `applyIdleAvatar` 一次(仅当当前应该显示 idle avatar 时,即没在播放或 avatarEnabled=false)
#### 前端
新组件 `web/src/components/AvatarUpload.vue`:
- props: `botId?` (上传时空表示走临时 base64 缓存)、`v-model:value`
- 拖拽 / 文件选择 / 预览圆框 / 删除按钮
- 内部 `axios.post('/api/bot/<id>/avatar', formData)` 或在创建表单里把 base64 与表单一同提交
接入点:
- 创建实例弹窗(搜索 `BotEditor.vue` 或类似)—— 表单提交后用返回的 botId 再 POST 头像;或者表单本身保存 base64 等创建完成后由后端解码落盘
- Settings.vue:在 features 列表中插入一行"自定义头像",右侧渲染 `<AvatarUpload :bot-id="botId" />`
### 专辑搜索 / 详情
#### 后端
`src/music/netease.ts` `search()`:
- 多发一个 `cloudsearch?type=10` 请求,把返回的 `result.albums[]` 映射成 `Album[]` 填进 `SearchResult.albums`
- 字段 `id` / `name` / `coverUrl` (`picUrl`) / `artist` (`artists[].name.join(' / ')`)
`src/music/qq.ts` `search()`:
- 在现有 `req_0` 旁增加 `req_album: { module: "music.search.SearchCgiService", method: "DoSearchForQQMusicDesktop", param: { searchid, query, search_type: 8 } }`,映射 `body.album.list[]`
`src/web/api/music.ts` `/search/all`:
- 在响应里增加 `albums` 和 `playlists`,与 `songs` 一同合并
`/album/:id` 已存在,无需改动。
#### 前端
- `web/src/views/Search.vue`:响应 schema 升级为 `{songs, albums, playlists}`;模板加入两个新分区("专辑"、"歌单"),各自一个简单的卡片网格(参考 Home.vue 的 `playlist-grid`)
- 新路由 `/album/:id` → 复用 `Playlist.vue`,把它的 `loadPlaylist()` 重构为根据 `route.path` 决定调 `/playlist/:id` 还是 `/album/:id`,或者新建 `Album.vue` 内部 import 同一个 `<PlaylistDetail />` 子组件
- **方案选择**:拆出 `<PlaylistDetail :endpoint="...">` 组件 + `Album.vue` / `Playlist.vue` 两个薄壳。当前 `Playlist.vue` 内部仅 ~60 行模板,单文件改造比新建 PlaylistDetail 子组件更小,先用最小改动:在 `Playlist.vue` 内根据 `route.meta.kind === 'album'` 切换 endpoint
- 路由:`router/index.ts` 加 `{ path: '/album/:id', component: Playlist, meta: { kind: 'album' } }`
### 拆分
**两个 PR:**
1. `feat(profile): custom bot avatar with idle/playback precedence`
- DB migration + ProfileManager 改动 + 上传 API + AvatarUpload.vue + 接入两个表单
2. `feat(search): album section in search results + album detail playback`
- netease/qq search 扩展 + /search/all + Search.vue 分区 + Playlist.vue 复用为 album
## Testing
### 自定义头像
- 单元:DB 迁移加 `custom_avatar_path` 列幂等;上传 API 校验大小/MIME;ProfileManager.applyIdleAvatar 在 onSongChange(null) 后被调用
- 集成:mock TS3Client 验证 fileTransferInitUpload 收到的 buffer 是 customAvatar
- 手动:本地起 bot 上传一张 png → 检查头像;播一首歌 → 头像切封面;停止 → 头像回到 png;关掉 avatarEnabled 重启 → 头像直接是 png
### 专辑搜索
- 单元:netease/qq `search()` 测试:响应包含 albums 字段,长度 > 0 (mock fixture 必须含 album 段)
- 集成:`/search/all` 响应 schema 包含 `albums`/`playlists`
- 手动:搜"周杰伦" → 看到歌曲 + 专辑 + 歌单三个分区;点专辑 → 详情页 → 播放全部 → 队列加上整张专辑
## Migration
DB 迁移:`bot_instances.custom_avatar_path` TEXT NULL,默认 NULL。已存在 bot 不受影响。
## Open Questions
- TS6 协议路径下 `fileTransferInitUpload` 是否一致?(既有 avatar 流程已经覆盖 TS3 + TS6,本期沿用同一路径,不单独验证)
- 头像超过 200 KB 时前端用 Canvas 自动 resize 还是直接拒?— **决定:拒,错误提示"请压缩到 200KB 以内"**,简单可控
@@ -0,0 +1,360 @@
# WebUI Authentication
**Date:** 2026-05-27
**Status:** Spec — pending implementation
**Branch:** `feat/webui-auth`
## Problem
WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权:
- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。
- 静态前端通过 `express.static()` 直接对外提供。
后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。
## Goal
为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。
## Out of Scope(明确不做)
- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做)
- 角色与权限(admin / viewer)—— 全员同权
- 密码重置流程(不挂邮件;仅提供登录后 `change-password`)
- 双因素认证(2FA)
- "记住我" / 绝对过期 vs 滑动过期的可配置
- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权
- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json`
## Non-functional Constraints
- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。
- Cookie 行为必须兼容现有 `trustProxy` 反代部署。
- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。
- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。
## Architecture
### 数据层(`src/data/`)
扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表:
```sql
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- uuid v4
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, -- sha256(rawToken) hex
userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL, -- ms epoch
lastSeenAt INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
```
**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。
新模块:
`src/data/users.ts`
- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError`
- `findByUsername(username): User | null`
- `verifyPassword(plain, hash): Promise<boolean>` — bcryptjs compare
- `countUsers(): number` — 用于 `/needs-setup`
- `changePassword(userId, newPassword): void`
`src/data/sessions.ts`
- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256
- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d`
- `deleteSession(rawToken): void` — 退出
- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话
- `cleanupExpired(): void` — 定时任务
### HTTP 层(`src/web/`)
#### 新增中间件
`src/web/middleware/requireAuth.ts`
```
读取 req.cookies.tsmb_session
→ 缺失 → 401 { error: "unauthenticated" }
→ 调 sessions.validateAndTouch
→ null → 清 cookie + 401
→ 有效 → req.user = { id, username }; next()
```
`src/web/middleware/csrf.ts`
```
若 method ∈ {GET, HEAD, OPTIONS} → next()
否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致
→ 不一致或两者都缺失 → 403 { error: "bad origin" }
```
#### 新路由:`src/web/api/session.ts`
挂在 `/api/session`,全部公共(不挂 requireAuth):
| Method | Path | 行为 |
|---|---|---|
| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` |
| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` |
| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) |
| POST | `/logout` | 删 session,清 cookie,204 |
| GET | `/me` | 走 requireAuth;返回 `{ id, username }` |
| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 |
> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。
#### Cookie 规范
- 名称:`tsmb_session`
- 值:32 字节 random → base64url
- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天)
- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃
#### 装配顺序(`src/web/server.ts`)
```ts
app.use(express.json({ limit: "400kb" }));
app.use(cookieParser()); // 新增
// 公共
app.get("/api/health", …);
app.get("/api/config/public-url", …);
app.use("/api/session", createSessionRouter(...));
// 闸门(仅作用于下方注册的 /api/* 路由)
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
// 受保护
app.use("/api/bot", createBotRouter(...));
app.use("/api/music", createMusicRouter(...));
app.use("/api/player", createPlayerRouter(...));
app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR
// 静态 SPA(公共,前端自行判定登录态后跳转)
app.use(express.static(staticDir));
app.get(/^(?!\/api|\/ws)/, sendIndex);
```
> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。
#### 定时清理
`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。
### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`)
改造为手动 upgrade:
```ts
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
if (req.url !== "/ws") { socket.destroy(); return; }
const session = validateCookieFromHeaders(req.headers.cookie);
if (!session) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
(ws as any).userId = session.userId;
wss.emit("connection", ws, req);
});
});
```
`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。
不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。
### 前端层(`web/`)
#### 新视图
- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/`
- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/`
- 名称避免与既有 `Setup.vue`(bot 创建向导)冲突
#### Session 状态
新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref<User|null>`、`refresh()`、`logout()`、`needsSetup: Ref<boolean>`。在 `App.vue` mount 时调用 `refresh()`。
#### 路由守卫(`web/src/router/index.ts`)
- 公共路由:`/login`、`/setup`
- 全局 `beforeEach`:
1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存)
2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')`
3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=<path>')`
#### API 客户端
- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出)
- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')`
#### UI
- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`)
- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section)
### 依赖
新增到 `package.json`:
```
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.7"
```
不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。
## Data Flow
### 首次启动
```
Browser → GET / → static SPA
SPA mounted → GET /api/session/needs-setup → { needsSetup: true }
SPA → router.replace('/setup')
User submits form → POST /api/session/setup
Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200
SPA → currentUser refresh → router.replace('/')
```
### 已部署用户升级
旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。
### 后续登录
```
SPA → GET /api/session/me → 401
SPA → router.replace('/login?next=/queue')
User submits → POST /api/session/login → Set-Cookie + 200
SPA → currentUser refresh → router.replace('/queue')
```
### 受保护请求
```
SPA → fetch('/api/bot', { credentials: 'same-origin' })
Server requireAuth: validateAndTouch(cookie)
→ ok → req.user 注入 → 业务路由处理
→ 不 ok → 401 → SPA 拦截器跳 /login
```
### WebSocket
```
SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie
Server upgrade handler: validateCookieFromHeaders
→ ok → handleUpgrade → connection event
→ 不 ok → HTTP 401 写回原始 socket → destroy
```
## Error Handling
| 场景 | HTTP 响应 | 备注 |
|---|---|---|
| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth |
| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 |
| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 |
| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 |
| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 |
| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | |
| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | |
| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | |
| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 |
所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。
## Testing Strategy
### 单元(vitest)
`src/data/users.test.ts`
- createUser 成功后 findByUsername 命中(大小写不敏感)
- 重复 username 抛 UsernameTakenError
- verifyPassword 正反例
- changePassword 之后旧哈希不再验证通过
`src/data/sessions.test.ts`
- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token))
- validateAndTouch 过期记录返回 null 且记录被删
- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证)
- deleteAllForUser(exceptToken) 保留指定会话
### 集成(vitest + supertest,真 SQLite in-memory)
`src/web/api/session.test.ts`
- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409
- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401
- /logout 之后同一 cookie 调受保护路由 401
- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用
`src/web/middleware/csrf.test.ts`
- 带匹配 Origin 的 POST 通过
- Origin 与 host 不匹配 → 403
- 同样规则适用 Referer
- GET 永远通过
`src/web/websocket.test.ts`(新增或扩展)
- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭
- 带有效 cookie → 握手成功,收到 init 消息
- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs)
### 前端
不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列):
- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面
- 退出 → 自动跳 /login
- 关闭浏览器 7 天内再开 → 仍登录
- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login
## Files Changed
```
src/data/database.ts (schema migration)
src/data/users.ts (new)
src/data/users.test.ts (new)
src/data/sessions.ts (new)
src/data/sessions.test.ts (new)
src/web/auth/validateSession.ts (new, shared by HTTP + WS)
src/web/middleware/requireAuth.ts (new)
src/web/middleware/csrf.ts (new)
src/web/middleware/csrf.test.ts (new)
src/web/api/session.ts (new)
src/web/api/session.test.ts (new)
src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用)
src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式)
src/web/websocket.test.ts (新增 / 扩展)
package.json (deps)
web/src/views/Login.vue (new)
web/src/views/FirstRunSetup.vue (new)
web/src/composables/useSession.ts (new)
web/src/router/index.ts (公共路由 + beforeEach 守卫)
web/src/api/*.ts (credentials: 'same-origin' + 401 拦截)
web/src/App.vue (顶栏 logout + 当前用户名)
```
## Trade-offs / 已知妥协
1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。
2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。
3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。
4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。
5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。
@@ -0,0 +1,167 @@
# Fine-grained account permissions — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) (item E — the maintainer's permission-management idea)
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Issue #79 bundles five things. This spec covers **only item E**: allow an admin to
grant each non-admin (member) account a set of capabilities and a list of bots they
may control. The other items are handled in separate PRs and are **out of scope**
here:
- #1 Guest mode (login-less playback)
- #2 Dedicated-link hides other bots (subsumed conceptually by E's bot allow-list, but the link-specific UX is separate)
- #3 Auto-pause when channel empty
- #4 Dedicated link loses bot binding on refresh (a bug)
## Problem
Today the bot has a coarse two-role system: `admin | member` (single `role` column,
read live per request). `requireAdmin` gates only `/api/users` and `/api/audit`.
**Every other action — create/edit/delete bots, start/stop, all playback & queue
control, set platform login cookies, set audio quality — is open to any logged-in
member, on every bot.** Admins want to delegate limited control to members without
handing them full power.
## Decisions (from brainstorm)
1. **Model = capability flags + per-member bot allow-list** (not a per-bot×per-action
matrix, not role templates).
2. **Defaults:** on upgrade, existing members are backfilled with full capabilities +
all bots (no behavior change); newly-created members get a **basic tier**.
3. **Bot allow-list semantics:** an explicit "all bots" toggle OR a specific list;
empty list = no bots controllable. Members **cannot see** bots outside their
allow-list (hidden, not merely disabled).
4. **Capability set (5 toggles)** — see below; basic tier = playback + queue + all bots.
5. **Admin is a super-user** (bypasses all checks). The last admin cannot be demoted
(existing invariant preserved). Permission grants/revokes are written to the
existing audit log.
## Capability taxonomy
| Capability token | Covers | Scope |
|---|---|---|
| `player.control` | play/pause/resume/next/prev/stop/seek/volume/mode | per-bot (allow-list) |
| `player.queue` | search-add / clear / remove / play-at / playlist / album / play-song | per-bot (allow-list) |
| `bot.manage` | create / edit / delete / start / stop / avatar / profile / idle settings | global (create) + per-bot (operate a specific bot) |
| `platform.auth` | set NetEase/QQ/Bilibili cookie, QR, SMS | **global** (shared credentials) |
| `quality` | set audio quality per platform | **global** |
Bot scope is independent of capabilities: a member with `player.control` can only
exercise it on bots in their allow-list (or all, if the "all bots" flag is set).
`platform.auth` and `quality` are global capabilities with no bot scope.
**Basic tier** (new members): `{ player.control, player.queue }` + `bots.all = true`.
A new member can play/queue on every bot but cannot manage bots, change credentials,
or change quality.
## Data model (SQLite, additive — follows existing `CREATE TABLE IF NOT EXISTS` pattern)
```sql
-- capability tokens + the "all bots" flag (stored as token 'bots.all')
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
-- specific bot allow-list (only consulted when 'bots.all' is NOT present)
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
```
- Admins have no rows (they bypass). Only members are constrained.
- `bots.all` present ⇒ all bots (incl. future ones). Absent ⇒ only `user_bot_access`
rows; empty ⇒ none.
- `foreign_keys = ON` and WAL are already enabled; cascade-on-user-delete works.
- `user_bot_access.botId` references bot instance ids; when a bot is deleted, its
access rows should be cleaned up (either an FK to the bot table if one exists, or an
explicit cleanup in `BotManager.removeBot` / `PermissionStore.pruneBot(botId)`).
New `PermissionStore` in `src/data/permissions.ts` (mirrors `createUserStore` /
`createSessionStore`: prepared statements + an interface). Methods:
`getCapabilities(userId)`, `getBotAccess(userId)` → `'all' | string[]`,
`setPermissions(userId, { capabilities, bots })`, `pruneBot(botId)`.
## Backend enforcement (real 403 — not just hidden UI)
- **`req.user` widened** to carry `capabilities: Set<string>` and bot access. Loaded in
`requireAuth` (one extra lookup, or a JOIN in the session query). The same
`{id,username,role,capabilities,bots}` shape must be kept in sync in the three places
it is built today: `requireAuth.ts`, `session.ts` `requireAuthInline`, and the WS
upgrade handler in `server.ts` (WS only needs it if a push action becomes gated).
Because it's read live, permission changes take effect immediately (no re-login).
- **`requirePermission(cap)`** middleware (new, mirrors `requireAdmin.ts`): 401 if no
user; allow if `role === 'admin'` or `capabilities.has(cap)`; else 403.
- **`requireBotAccess`** helper: allow if admin or `bots.all` or botId ∈ access list;
else 403. Mounted on the player router's existing `/:botId` choke-point
(`src/web/api/player.ts`) and on each `:id` route in `src/web/api/bot.ts`
(start/stop/edit/delete/avatar/profile).
- **Route → capability mapping:**
- `/api/player/:botId/*` playback actions → `player.control` (+ `requireBotAccess`)
- `/api/player/:botId/*` queue actions → `player.queue` (+ `requireBotAccess`)
- `/api/bot` create, `/api/bot/:id` edit/delete, `/api/bot/:id/start|stop|avatar|profile`, `/api/bot/settings` → `bot.manage` (+ `requireBotAccess` for the `:id` ones)
- `/api/auth/*` (cookie/QR/SMS) → `platform.auth`
- `/api/music/quality` POST → `quality`
- **`GET /api/bot`** filters its result to the caller's allowed bots for members
(admins see all). This is what "hides" disallowed bots in the UI.
## Management API (admin-only, added to the existing users router)
- `GET /api/users/:id/permissions` → `{ capabilities: string[], bots: 'all' | string[] }`
- `PUT /api/users/:id/permissions` → body `{ capabilities, bots }`; validates tokens
against the known set and botIds against existing bots; writes audit
`user.permissions_changed`.
- `GET /api/session/me` is extended to include the **current** user's
`{ capabilities, bots }` so the frontend can gate UI. (admins report effectively-all.)
## Frontend
- `useSession` extends `User` with `capabilities` + bot scope and exposes
`can(cap)` and `canControlBot(botId)` helpers.
- **Navbar bot selector** filters `store.bots` to controllable bots (others hidden);
`activeBot` fallback and `fetchBots` default only ever land on an allowed bot.
- **Player / Settings** hide controls and whole sections a member lacks: platform
login, audio quality, and bot create/edit/delete are hidden without the matching
capability; playback/queue buttons hidden without `player.control` / `player.queue`.
- **Admin permission editor:** in the Settings → User Management list, each member row
gets a "权限" editor — capability checkboxes + a bot allow-list with an "全部机器人"
toggle. Saving calls `PUT /api/users/:id/permissions`.
## Defaults & migration
- New tables created idempotently in `initTables`.
- **One-time backfill** (guarded so it runs once): every existing `member` gets all
five capabilities + `bots.all`. Admins are skipped (they bypass). This preserves
current behavior for existing members on upgrade.
- **New member default** (`POST /api/users` with role member): capabilities
`{ player.control, player.queue }` + `bots.all` (basic tier).
- Pre-existing accounts default to `role = 'admin'` per the current schema — those are
super-users and unaffected.
## Testing (TDD)
- `PermissionStore` unit tests (set/get capabilities + bot access; `'all'` vs list vs
empty; `pruneBot`).
- `requirePermission` / `requireBotAccess` middleware tests (admin bypass; has/lacks
cap → 200/403; bot in/out of allow-list; `bots.all`).
- API tests: member without cap → 403; with cap → 200; bot not allowed → 403/hidden;
`GET /api/bot` filtered for members, full for admin; `PUT .../permissions` validates
+ audits.
- Migration test: existing members backfilled to full + `bots.all`; new member gets
basic tier.
## Non-goals
- No per-bot×per-capability matrix, no custom role templates (YAGNI).
- Guest mode, dedicated-link UX, auto-pause, and the refresh bug (#1–#4) are separate.
- No change to the admin/member role concept itself; this layers capabilities under
the existing `member` role.
@@ -0,0 +1,138 @@
# Auto-pause on empty channel — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) item 3
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
When everyone leaves the bot's voice channel, music keeps playing to an empty room.
The maintainer wants an option to **auto-pause when the channel is empty** (no disconnect)
and resume when someone returns.
## Decisions (from brainstorm)
- **Global toggle**, reusing the **already-declared but currently dead** `config.autoPauseOnEmpty`
(`src/data/config.ts`, default `true`). No per-bot granularity (YAGNI).
- **Event-driven, near-instant** reaction (not the 30s poll alone) — subscribe to TS client
enter/leave/move events; keep the existing 30s idle poll as a fallback.
- **Auto-resume only what we auto-paused** — a user-paused track is never auto-resumed.
- Independent of the existing **idle-disconnect** (`idleTimeoutMinutes`): both share the same
emptiness signal but act independently (pause immediately; disconnect after N minutes).
## Current state (verified)
- `client.ts` `getClientsInChannel()` returns all clients in the bot's channel *including the
bot*; callers compute "others" as `length - 1`. No persistent roster.
- The library emits `clientEnter` / `clientLeave` / `clientMoved`; `client.ts` currently only
*logs* `clientEnter` and does not re-emit leave/moved.
- The idle poller in `instance.ts` (`_startIdlePoller`, every 30s) already computes
`userCount = getClientsInChannel().length - 1` and, when `<= 0`, schedules an
idle-disconnect after `idleTimeoutMinutes`.
- `player.pause()` / `player.resume()` already pause/resume **without disconnecting** (ffmpeg
stays alive, no voice sent). The player only knows `idle|playing|paused` — there is **no**
auto-vs-user-pause distinction today.
- `BotConfig.autoPauseOnEmpty` exists (default true) but is **read nowhere**.
## Design
### Occupancy signal (shared)
Extract the idle poller's count into one method on `BotInstance`:
`checkChannelOccupancy()` → queries `getClientsInChannel()`, computes `userCount = length - 1`,
and drives **both** the existing idle-disconnect timer (unchanged behavior) **and** the new
auto-pause logic below. It is called by:
1. the existing 30s poll (fallback), and
2. new TS event handlers.
### Event subscription
`client.ts`: subscribe to and **re-emit** `clientEnter`, `clientLeave`, `clientMoved` up to
`BotInstance`. `BotInstance.setupTsEvents()` calls `checkChannelOccupancy()` on each (a re-query
is simplest, since `clientLeave` carries no channel id). This gives near-instant pause/resume;
the poll remains as a safety net.
### Auto-pause logic (inside `checkChannelOccupancy`)
Add a private `autoPaused = false` flag to `BotInstance`.
- **Empty** (`userCount <= 0`): if `config.autoPauseOnEmpty` **and** `player.getState() === "playing"`
→ `player.pause()`, `autoPaused = true`, emit `stateChange`. (Idle-disconnect timer still
scheduled as today.)
- **Re-populated** (`userCount > 0`): if `autoPaused` **and** `player.getState() === "paused"`
→ `player.resume()`, `autoPaused = false`, emit `stateChange`. (Idle timer cancelled as today.)
### `autoPaused` bookkeeping (so user pauses are respected)
Clear `autoPaused = false` in `cmdPause`, `cmdResume`, `cmdStop`, `cmdPlay`, and on
connect/disconnect (the `disconnected` handler calls `player.stop()` → idle). Net effect: only a
track *we* auto-paused gets auto-resumed; a user-paused track stays paused when someone returns.
### Config wiring
- `GET /api/bot/settings`: include `autoPauseOnEmpty` in the payload (alongside `idleTimeoutMinutes`).
- `POST /api/bot/settings`: accept + validate a boolean `autoPauseOnEmpty`, `saveConfig`, and
propagate to live bots via a new `BotInstance.updateAutoPause(enabled)` (mirrors
`updateIdleTimeout`). Since the instance reads `this.config.autoPauseOnEmpty` live, propagation
can be as simple as updating the stored config reference / a field the check reads.
- Frontend `Settings.vue` → the **行为设置** section (already `bot.manage`-gated): add a toggle
for `autoPauseOnEmpty` next to the idle-timeout control; load it in the settings fetch and send
it on save.
## Components / files
- `src/ts-protocol/client.ts` — subscribe + re-emit `clientEnter`/`clientLeave`/`clientMoved`.
- `src/bot/instance.ts` — `autoPaused` field; `checkChannelOccupancy()` (refactored from the
idle poller, drives idle + auto-pause); event handlers; clear `autoPaused` in user commands +
connect/disconnect; `updateAutoPause(enabled)`.
- `src/web/api/bot.ts` — `GET`/`POST /settings` handle `autoPauseOnEmpty`.
- `web/src/views/Settings.vue` (+ player store settings load/save) — the toggle.
- `src/data/config.ts` — field already exists (no change beyond confirming default).
## Testing
- **Decision unit test (TDD):** extract the pause/resume decision into a testable method, e.g.
`applyOccupancy(userCount)` operating on an injected fake player (`getState`/`pause`/`resume`)
+ the `autoPaused` flag + the config flag. Cases: empty+playing+enabled → pause + `autoPaused`;
re-populated+`autoPaused`+paused → resume + clear; re-populated when NOT `autoPaused` (user
pause) → no resume; flag disabled → no pause; empty while idle (not playing) → no-op.
- **API test:** `GET`/`POST /api/bot/settings` round-trips `autoPauseOnEmpty` (validates boolean,
persists, propagates).
- Live TS event wiring is verified by code review + a manual run (can't unit-test a real server).
## Non-goals
- No per-bot toggle (global only). No change to idle-disconnect behavior. No new dependency.
- Reaction relies on events the bot can already see (same-channel members are always in view);
no extra channel subscription needed.
---
## Update (2026-06): occupancy is event-driven & server-wide, not channel-filtered
Live testing against a real TS3 server (with `@honeybbq/teamspeak-client` 0.2.2)
invalidated two assumptions above. Recording the corrected model here so nobody
reintroduces the old design:
- **Default is OFF**, not on. See `getDefaultConfig()` in `src/data/config.ts`
and the rationale comment there.
- **Query commands are unusable when others are present.** `clientlist`,
`channellist`, and `channelclientlist` ALL time out (~5–10s) whenever ≥2
clients are connected to the **server** (verified even when the two clients
are in *different* channels). They succeed only when the bot is the sole
client on the whole server. So `getClientsInChannel()` returns `[]` exactly
when occupancy matters, and `occupancyFromClientList(0)` returns `null`
("unknown") so callers skip the decision rather than mis-reading it as empty.
- **PAUSE** therefore only ever fires when the bot becomes alone on the server
(the one state where the query works). This is reliable and stays on the
query path (`refreshOccupancy()` + the 30s idle poller).
- **RESUME** is armed directly from the `clientEnter` push event
(`shouldResumeOnReturn()` + `_resumeIfReturning()` in `instance.ts`), NOT from
a query. Because the bot only auto-pauses while alone, the sole way occupancy
can return while `autoPaused` is set is a fresh connection — delivered as
`clientEnter`. The resume branch never pauses (userCount is always > 0).
- **Net semantics:** "pause when the server is empty (bot alone), resume when
someone connects." Channel granularity is **impossible** with this library:
`clientEnter`'s channel field is always `0` (library reads notify param `cid`
but enter-view carries `ctid`), and `clientMoved` delivery is flaky. Do NOT
attempt to layer `clientMoved.targetChannelID` channel-accuracy on top — it is
systematically wrong for direct-connect clients and reintroduces unreliability.
The correct path to true channel scoping is an upstream library fix.
- **Knock-on:** idle-disconnect shares the same signal and is likewise
server-wide. UI copy in `web/src/views/Settings.vue` was updated to say
"服务器" rather than "频道" to match. `cmdVote` was intentionally left on the
query path (out of scope; switching it would inherit the same timeout).
@@ -0,0 +1,65 @@
# Dedicated-link bot scoping (+ refresh fix) — design
**Issue:** [#79](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/79) items 2 and 4
**Date:** 2026-05-30
**Status:** Approved (brainstorm), pending implementation plan
## Problem
- **Item 2:** A dedicated link (`/bot/:id`) is meant to give someone control of *one* bot, but today it just sets the active bot and bounces to `/`; the user can still switch to any other bot from the top-right selector.
- **Item 4 (bug):** After opening a dedicated link, refreshing the page loses the bot — the UI falls back to the first bot.
Root cause (verified): `BotRedirect.vue` does `router.replace('/')` (dropping the id), and `activeBotId` is in-memory-only Pinia state with no persistence, so a reload resets it to `bots[0]`.
## Decision (from brainstorm: Q2 = URL-carried scope)
Carry the scoped bot in the **URL query** (`?bot=<id>`). One mechanism fixes **both** items: the URL is durable across refresh (item 4) and shareable/self-clearing, and the frontend locks the selector to the scoped bot (item 2). No localStorage sticky-lock; plain `/` (no `?bot`) = full control. Backend per-bot authorization (PR #80) remains the real boundary — this is a UX lock.
## Design
### Scope state (store)
Add to the player store:
- `scopedBotId: string | null` — the bot the UI is locked to.
- getter `isScoped` = `scopedBotId !== null`.
- action `setScope(id)` / `clearScope()`.
- `setActiveBotId(id)` becomes a no-op (or ignores) when `isScoped` and `id !== scopedBotId`, so stray switch attempts can't change bots.
### URL as the durable source of truth
- `BotRedirect.vue` (`/bot/:id`): instead of `router.replace('/')`, validate the bot exists, then `router.replace({ path: '/', query: { bot: id } })`. (Keeps the "clean" home URL but with `?bot=`.)
- **Router `beforeEach` guard** (the heart of it):
- If `to.query.bot` is present → `store.setScope(to.query.bot)` and continue.
- Else if `store.isScoped` (a scope is active and this navigation dropped the param) → redirect to the same route **with** `query.bot = store.scopedBotId` re-attached (so the lock survives in-app navigation to /search, /library, etc.).
- Else → no scope; continue.
This keeps `?bot=` on the URL for every route while scoped, so a refresh on *any* route re-establishes the lock → **fixes item 4**.
- On app load / after `fetchBots()`: apply `scopedBotId`/`?bot` to `activeBotId`; if the scoped bot doesn't exist or isn't in the user's allowed set, **clear the scope gracefully** (fall back to normal multi-bot view) rather than locking onto a dead id.
### Exit
- `clearScope()` sets `scopedBotId = null`; the exit affordance navigates to `/` *after* clearing, so the guard won't re-attach `?bot`. This is the only way to leave scoped mode (self-clearing, intentional).
### Navbar (the lock UI)
- When `isScoped`: the bot selector shows **only** the scoped bot, the dropdown/switching is disabled (no chevron / non-interactive), and other bots' "copy link" affordances are not shown.
- Show a small "专属模式" indicator with an "退出" control → `clearScope()` + navigate to `/`.
- When not scoped: unchanged (full selector over `controllableBots`).
### Active-bot coherence
Because every player action already routes through `activeBotId`, locking `activeBotId === scopedBotId` guarantees all controls affect only the scoped bot. The store's `activeBot` getter `bots[0]` fallback still degrades safely if the scoped id ever fails to match (combined with the graceful-clear above).
## Components / files
- `web/src/stores/player.ts` — `scopedBotId` state, `isScoped`, `setScope`/`clearScope`, guard in `setActiveBotId`, apply scope→active in `fetchBots`/init (graceful clear if missing).
- `web/src/router/index.ts` — `beforeEach` scope sync + `?bot` preservation.
- `web/src/views/BotRedirect.vue` — set scope + `replace({ path: '/', query: { bot: id } })`.
- `web/src/components/Navbar.vue` — locked selector + "专属模式/退出" affordance.
- `web/src/App.vue` — ensure scope is applied to `activeBotId` after `fetchBots` on load (if not already handled by the store/guard).
## Testing
Vue UI isn't unit-tested in this repo, so verification is `vue-tsc` + manual run. The **store scope logic is testable** if a lightweight test harness exists for Pinia stores; otherwise assert the pure pieces:
- `setActiveBotId` ignores a switch to a non-scoped bot while scoped; allows the scoped bot.
- `clearScope` resets state.
- A small helper for "resolve scope from query + bots list → {scopedBotId, activeBotId} or cleared-if-missing" can be extracted and unit-tested.
Manual: open `/bot/<id>` → locked to that bot, selector shows only it; refresh → still locked (item 4 fixed); navigate to Search then refresh → still locked; click 退出 → back to all bots; open `/` directly → full control (no lock).
## Non-goals
- No localStorage persistence (URL is the source of truth). No backend change (per-bot auth already exists in #80). No change to how dedicated links are generated (still `<base>/bot/<id>`); only what happens when one is opened.
@@ -0,0 +1,317 @@
# Guest mode (login-less WebUI access) — design
**Issue:** [#83](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/83) — "请求增加 WebUI 鉴权 guest 登录功能"
**Date:** 2026-06-24
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add an optional, **default-OFF** guest mode. When an admin enables it, anyone who can
reach the WebUI can enter **without logging in** ("以游客身份进入 / Continue as guest")
and use a restricted subset of playback/queue features. The admin chooses, per
deployment, exactly what guests may do (a set of toggles) and which bot(s) guests may
control. Guests can **never** view or change settings, manage bots, set platform
credentials, change audio quality, or see the user/audit admin panels.
This builds directly on the existing `admin | member` role + capability system
(`src/data/permissions.ts`, `requirePermission`, `useSession().can()`) and the existing
append-vs-play-next queue split (`PlayQueue.add` vs `addNext`). It does **not** rebuild
auth.
The original issue asked specifically that guest song requests go to "下一首" only.
That exact behavior is reproducible in this design by the admin turning the
"add to end" toggle **off** and the "play next" toggle **on** — it is one configuration
of a more general per-ability toggle model (chosen in brainstorm).
## Problem
Today every `/api/*` route past the session router requires a real account
(`requireAuth`). There is no anonymous/guest path: to let a friend queue a song, an
admin must create them a `member` account. The maintainer wants a low-friction,
admin-gated way to let untrusted visitors request music without an account, while
keeping all administration locked down.
## Decisions (from brainstorm)
1. **Guest = no-login.** A guest is an **anonymous, config-driven synthetic principal**
(`role: "guest"`), not a database user with a password. No favorites, no
change-password, short-lived session.
2. **Default OFF**, enforced **server-side** (the guest-session endpoint rejects when
the flag is off — never rely on hiding the button).
3. **Per-ability toggles**, not a single "mode". Every song action and control action is
its own admin switch. Default state when guest mode is first enabled: only
"add to end of queue" is ON; everything else OFF.
4. **Per-bot guest scope** (`"all"` or an explicit bot list), mirroring the existing
member bot allow-list. Guests cannot see or control out-of-scope bots — including
over WebSocket.
5. **Settings are always hidden AND server-blocked for guests** (view + change), closing
the two currently-ungated reads (`GET /api/bot/settings`, `GET /api/music/quality`).
6. **"Play now" for guests is non-destructive**: insert-next + skip to it, **never** the
existing clear-the-whole-queue `/play-song` behavior.
7. **One unified authorization gate** encapsulates admin/member/guest logic so the
existing member/admin capability system is left behavior-unchanged.
## Guest ability model
### Always allowed (baseline read-only — the point of the feature)
- Browse/search library, playlists, history, song detail, lyrics, cover art.
- See now-playing and the live queue (REST + WebSocket), **scoped to allowed bots**.
### Always denied (hard locks — not toggles)
- View **or** change any settings (idle timeout, auto-pause, theme persistence server-side, command prefix, etc.).
- Bot management (create/edit/delete/start/stop, bot config, avatar, profile).
- Music-platform login (`/api/auth/*`), audio quality (`/api/music/quality`).
- User management (`/api/users`), audit log (`/api/audit`), change-password.
### Admin-configurable toggles (`guestMode.permissions.*`, all default `false` except `addToQueue`)
| Flag | 中文 | Default | Backend route(s) gated |
|---|---|---|---|
| `addToQueue` | 添加到队列末尾 | **true** | `POST /:botId/add`, `/add-song`, `/add-by-id` |
| `playNext` | 添加到下一首 | false | `POST /:botId/play-next-song` |
| `playNow` | 立即播放(不清空队列) | false | new guest-safe play-now (insert-next + skip) |
| `skip` | 跳过当前歌曲 | false | `POST /:botId/next` |
| `transport` | 暂停/继续/进度/音量 | false | `POST /:botId/pause`, `/resume`, `/seek`, `/volume` |
| `removeClear` | 移除/清空队列 | false | `DELETE /:botId/queue/:index`, `POST /:botId/clear` |
| `playMode` | 切换播放模式 / FM | false | `POST /:botId/mode`, `/fm` |
Notes:
- Routes with **no** guest flag (e.g. `/prev`, `/stop`, `/play-song`, `/play-playlist`,
`/play-album`, `/play-at`, all of `/api/bot/*`, `/api/auth/*`, settings, users, audit)
are **never** reachable by guests — the gate denies any guest without an explicit flag.
This is the safe default: new routes are guest-denied unless deliberately opted in.
- `/play-song`, `/play-playlist`, `/play-album` call `queue.clear()` and must stay
guest-denied regardless of toggles (they would wipe everyone's queue).
## Config schema (`src/data/config.ts`)
```ts
export interface GuestPermissions {
addToQueue: boolean; // append to end
playNext: boolean; // 下一首 (insert after current)
playNow: boolean; // 立即播放: insert-next + skip-to-it (non-destructive)
skip: boolean; // skip current track
transport: boolean; // pause/resume/seek/volume
removeClear: boolean; // remove a queue item / clear the queue
playMode: boolean; // play mode (shuffle/repeat) + FM
}
export interface GuestModeConfig {
enabled: boolean; // master switch, default false
bots: "all" | string[]; // per-bot scope (botIds); default "all"
permissions: GuestPermissions;
}
// added to BotConfig:
guestMode: GuestModeConfig;
```
`getDefaultConfig()` returns:
```ts
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
},
}
```
**Merge hardening:** `loadConfig` currently does a shallow `{...defaults, ...partial}`,
which would drop `guestMode` sub-keys if a saved config only contains a partial
`guestMode`. `loadConfig` must **deep-merge `guestMode`** (and its `permissions`) over
the defaults so missing sub-keys are back-filled. Covered by a `config.test.ts` case.
**Bot deletion:** when a bot is removed, prune its id from `guestMode.bots` (if it's an
array) and persist — mirrors `PermissionStore.pruneBot(botId)` for members. Done in the
same `BotManager.removeBot` path that already prunes member access.
## Backend design
### Synthetic guest principal & session entry
- **Role union widened** to `"admin" | "member" | "guest"` (`UserRole` in
`src/data/users.ts`, the `req.user` augmentation in `requireAuth.ts`, the frontend
`User` type, and the role badge in Navbar).
- **Reserved guest user row.** A single fixed row (e.g. id `"__guest__"`, role `"guest"`,
an unusable password hash, username e.g. `"guest"`) is created idempotently by
migration. It exists only to satisfy the `sessions.userId` FK and the
`validateAndTouch` JOIN; it is excluded from user-management listings and the
last-admin guards (those count `role = 'admin'` only, so guests don't interfere).
- **Guest login endpoint:** `POST /api/session/guest`, mounted in the **public** block
(before `csrfOriginCheck`/`requireAuth`, like `/login` and `/setup`), rate-limited.
- If `config.guestMode.enabled` is false → `403 guest mode disabled`.
- Else `sessions.createSession("__guest__")` and set the same `tsmb_session` httpOnly
cookie. **Guest sessions use a short TTL** (e.g. `GUEST_SESSION_TTL_MS`, ~24h) and
**bypass `MAX_SESSIONS_PER_USER`** for the guest principal (otherwise guest #11
would evict guest #1). Expired guest sessions are already deleted on validation; an
optional periodic sweep can prune stale ones.
- **Disable = logout.** In `createRequireAuth`/`validateSession`, if a validated session
has `role === "guest"` but `config.guestMode.enabled` is now false, treat it as
unauthenticated (401). So flipping guest mode off immediately ends guest access.
- **Expose availability:** extend `GET /api/session/needs-setup` (or add a sibling
`GET /api/session/guest-config`) to return `guestAllowed: boolean` so the **public**
Login page can decide whether to show the guest button. This must not leak any other
config.
### Permission resolution
`resolvePermissionContext` gains a `guest` branch. Signature extended to receive the
live guest config:
```ts
resolvePermissionContext(role, userId, store, guestConfig?) => {
admin → { capabilities: all CAPABILITIES, bots: "all" }
member → stored caps + stored bots // unchanged
guest → {
capabilities: new Set(), // holds NO member capabilities
bots: guestConfig.bots === "all" ? "all" : new Set(guestConfig.bots),
guest: guestConfig.permissions, // resolved per-request from live config
}
}
```
`PermissionContext` and `req.user` gain an optional `guest?: GuestPermissions`. Because
`req.user` is rebuilt per request, toggling a permission or the bot scope takes effect on
the guest's next request (no re-login).
### Unified authorization gate (`src/web/middleware/authorize.ts`, new)
Replaces `requirePermission('x')` on **guest-reachable** routes:
```ts
authorize({ capability?: Capability, guestFlag?: keyof GuestPermissions })
// 401 if no req.user
// admin → next()
// guest → (req.user.guest?.[guestFlag] === true) ? next() : 403 // also 403 if no guestFlag
// member → (capability && req.user.capabilities.has(capability)) ? next() : 403
```
- Member/admin semantics are **identical** to today's `requirePermission`.
- A route with no `guestFlag` is automatically guest-denied (safe default).
- `requireBotAccess` is unchanged and already enforces the guest `bots` scope (guests
flow through `req.user.bots`).
- `requireAdmin` is unchanged (guests are non-admin → 403), so `/api/users` and
`/api/audit` stay locked.
### Route changes (`src/web/api/player.ts`, `bot.ts`, `music.ts`)
- Re-express guest-reachable player routes via `authorize({ capability, guestFlag })`:
- `/add`, `/add-song`, `/add-by-id` → `{ capability: "player.queue", guestFlag: "addToQueue" }`
- `/play-next-song` → `{ capability: "player.control", guestFlag: "playNext" }`
(members keep `player.control`; guests pass only via `playNext`)
- new guest-safe **play-now** → `{ capability: "player.control", guestFlag: "playNow" }`
- `/next` → `{ capability: "player.control", guestFlag: "skip" }`
- `/pause`,`/resume`,`/seek`,`/volume` → `{ capability: "player.control", guestFlag: "transport" }`
- `DELETE /queue/:index`, `/clear` → `{ capability: "player.queue", guestFlag: "removeClear" }`
- `/mode`, `/fm` → `{ capability: "player.control", guestFlag: "playMode" }`
- everything else stays `authorize({ capability })` (no guest flag) → guest-denied.
- **Guest-safe play-now**: a new behavior (own route, e.g. `POST /:botId/play-now`, or a
`mode:"now"` branch) that does `queue.addNext(song)` then advances to it (skip into the
inserted track) — **no `queue.clear()`**. Members/admins may also use it; the existing
destructive `/play-song` stays for the normal ▶ in non-guest UI. Exact wiring decided
in the plan.
- **Close ungated reads against guests:** `GET /api/bot/settings` and
`GET /api/music/quality` currently have no guard, so a guest could read config. Add a
small `requireNotGuest` guard (allow `admin` + `member`, deny `guest` → 403). This
**does not change member/admin behavior** — members keep their current read access; only
guests are newly denied. (Deliberately not a new member capability, to avoid touching
member semantics.)
### WebSocket (`src/web/websocket.ts`, `src/web/server.ts`)
- Guests authenticate over the WS upgrade unchanged (session cookie).
- **Add per-client bot-scope filtering** for guests: the upgrade handler already stamps
`ws.userId`; also resolve and stamp the client's bot scope (`"all"` or a Set). In
`setupWebSocket`, when sending `init` and broadcasting `stateChange` /
`botConnected/Disconnected/Removed`, **filter to bots the client may see**. For guests
with a scoped `bots` list, out-of-scope bots are omitted. Admin/member payloads are
unchanged (they resolve to `"all"` or their existing member scope — to avoid changing
member behavior, filtering may be applied **only when the client is a guest**; decided
in the plan).
### Settings write (`POST /api/bot/settings`)
Extend the existing settings writer (today only idle-timeout + auto-pause) to also accept
and persist the `guestMode` block (admin-only via `bot.manage`/`requireAdmin`), calling
`saveConfig`. Live effect: subsequent guest requests read the updated in-memory config.
## Frontend design
- **`useSession.ts`**: extend `User` with `role:'guest'` and a `guest?: GuestPermissions`
field (from `/api/session/me`). Add `isGuest` computed and `guestCan(flag)`; make `can`
guest-aware where it maps cleanly, but UI gating for guest-specific actions uses
`guestCan('addToQueue' | 'playNext' | ...)`. `canControlBot` already enforces the bot
scope and works for guests via the `bots` field.
- **Login page (`Login.vue`)**: when `guestAllowed`, show a prominent
"以游客身份进入 / Continue as guest" button calling a new `session.continueAsGuest()`
→ `POST /api/session/guest` → refresh → redirect to `?next` or home.
- **Router (`web/src/router/index.ts`)**: in the global `beforeEach`, block guests from
`/settings` and `/setup` (redirect to home). Default-off ⇒ when not a guest, behavior
is unchanged.
- **Navbar (`Navbar.vue`)**: hide the settings cog for guests; add a `游客` role badge
branch; the bot selector already filters via `canControlBot`, so scoped guests only see
allowed bots.
- **App shell (`App.vue`)**: hide the mobile `/settings` tab for guests; the mini-player
transport reduces to the guest's allowed actions.
- **SongCard / Queue / Player**: gate each action button by the matching `guestCan(flag)`
(e.g. show ▶/下一首/添加 per `playNow`/`playNext`/`addToQueue`; show skip/transport/
remove/clear/mode per their flags). Buttons a guest lacks are hidden, mirroring how
`Queue.vue` already gates on `can('player.queue')` / `can('player.control')`.
- **Settings → Guest mode admin section (`Settings.vue`)**: new admin-only panel: a
master enable switch, the 7 permission checkboxes (with 中文 labels), and a bot scope
control (an "全部机器人 / all bots" toggle + per-bot checkboxes) reusing the existing
member permission-editor bot allow-list UI. Saving calls `POST /api/bot/settings` with
the `guestMode` block.
## Defaults, migration & backward-compat
- `getDefaultConfig().guestMode.enabled = false` ⇒ **no behavior change** on upgrade;
existing installs see nothing until an admin opts in.
- Migration adds the reserved `__guest__` user row idempotently (guarded like the
existing `backfillMemberPermissions` `schema_meta` marker) and does **not** grant it
any `user_permissions` (guest authorization is config-driven, not row-driven).
- `loadConfig` deep-merges `guestMode` so older config files gain the new block with
defaults.
- Member/admin flows, capabilities, and the backfill are untouched.
## Testing (TDD)
- **Config**: `getDefaultConfig` includes `guestMode` default-off; `loadConfig`
deep-merges a partial `guestMode` (missing sub-keys back-filled); round-trips through
`saveConfig`.
- **`resolvePermissionContext` guest branch**: empty member capabilities; `bots` `"all"`
vs scoped Set; `guest` permissions object passthrough.
- **`authorize` gate**: admin bypass; member has/lacks capability → 200/403 (regression
parity with `requirePermission`); guest allowed only when the specific flag is true;
guest with no flag on a route → 403; guest on settings reads → 403.
- **Enforcement (mirror `permissions-enforcement.test.ts`)**: each toggle independently
opens exactly its route(s) for a guest and nothing else; `/play-song`/`/play-playlist`/
`/play-album` always 403 for guests; per-bot scope: guest 403 on out-of-scope `:botId`.
- **Session entry**: `POST /api/session/guest` → 403 when disabled, mints guest session
when enabled; guest session bypasses `MAX_SESSIONS_PER_USER`; disabling guest mode
invalidates existing guest sessions (401); guest TTL shorter than member TTL.
- **WS scope**: guest receives only in-scope bots' `init`/`stateChange`; reject upgrade
unchanged for no cookie.
- **Frontend** (where covered): `guestCan` gating; router blocks `/settings` for guests.
## Non-goals (YAGNI)
- No guest accounts/usernames, passwords, favorites, or persistence per guest.
- No per-guest individual identity or rate-limiting beyond the existing IP rate limits
(a basic abuse guard on `/api/session/guest` is in; richer abuse controls are future).
- No change to the `admin | member` capability semantics; guest is an additive,
config-driven third principal.
- No chat-command (TeamSpeak `!add`/`!playnext`) changes — guest mode is **WebUI-only**
(the issue is explicitly about WebUI 鉴权).
- Per-guest bot scoping beyond a single shared guest scope is out of scope (one guest
scope applies to all guests).
## Key files touched
Backend: `src/data/config.ts` (+test), `src/data/permissions.ts` (+test),
`src/data/users.ts` (role union, reserved guest row), `src/data/database.ts` (migration),
`src/data/sessions.ts` (guest TTL + cap bypass), `src/web/middleware/authorize.ts` (new,
+test), `src/web/middleware/requireNotGuest.ts` (new, small — for the config reads),
`src/web/api/session.ts` (guest endpoint, `/me`, `needs-setup`),
`src/web/api/player.ts` (re-gate + guest play-now), `src/web/api/bot.ts` (settings
read-lock + guestMode write), `src/web/api/music.ts` (quality read-lock),
`src/web/server.ts` + `src/web/websocket.ts` (WS scope), `src/web/auth/validateSession.ts`
(guest disable→401), enforcement tests.
Frontend: `web/src/composables/useSession.ts`, `web/src/views/Login.vue`,
`web/src/router/index.ts`, `web/src/components/Navbar.vue`, `web/src/App.vue`,
`web/src/components/SongCard.vue`, `web/src/components/Queue.vue`,
`web/src/components/Player.vue`, `web/src/views/Settings.vue`,
`web/src/stores/player.ts`.
@@ -0,0 +1,116 @@
# TeamSpeak chat-command permission control — design
**Origin:** User request — "给 ts 命令也加上权限控制" (give the TS chat commands permission control too, like the WebUI already has). Completes the unused `adminGroups` scaffold the original authors left behind.
**Date:** 2026-06-25
**Status:** Approved (brainstorm), pending implementation plan
## Scope
Add permission control to **TeamSpeak chat commands** (`!play`, `!add`, `!stop`, …). Today any client in a channel with the bot can run any command; only the WebUI path is permission-gated. This adds a **binary admin gate** keyed on the sender's **TS server groups**: a fixed set of "admin" commands may be restricted to members of configured admin server-groups, while all other commands stay public. Enforcement is **opt-in and backward-compatible** — it activates only once an admin lists their server-group ID(s).
The privileged server-groups are configured in `config.adminGroups` (already declared, currently unused) and become editable from the WebUI.
## Problem
`src/bot/commands.ts` already declares `PUBLIC_COMMANDS` / `ADMIN_COMMANDS` sets and an `isAdminCommand()` helper, and `src/bot/instance.ts:325` has the stub `// TODO: Check if invoker is in adminGroups` — but none of it gates anything. `config.adminGroups: number[]` (`src/data/config.ts:21,46`) is documented as a legacy placeholder and read nowhere. So chat commands are unauthenticated: anyone can `!stop`, `!clear`, `!remove`, move the bot, change volume/mode. The WebUI, by contrast, gates everything via `authorize()` at the HTTP layer.
`executeCommand` (`instance.ts:351`) is **shared** by the chat handler and the WebUI player router; the WebUI gates at the HTTP layer, so the chat gate must live in the **chat handler**, never inside `executeCommand` (else the already-gated WebUI would be double-gated).
## Decisions (from brainstorm)
1. **Binary admin gate**, not per-group capabilities and not a whole-bot allowlist. Reuses the existing `adminGroups` scaffold.
2. **Admin command set (fixed, one source of truth):** `stop`, `clear`, `remove`, `move`, `vol`, `mode`. Everything else is public. The set lives in one constant so reclassifying a command is a one-line change.
3. **Default = open / opt-in (backward-compatible):** when `config.adminGroups` is empty (the default), there is **no enforcement** — admin commands stay open to everyone, exactly as today. Enforcement turns on only when `adminGroups` is non-empty.
4. **Identity key = TS server groups**, matched against `adminGroups`.
5. **Fail-closed on undeterminable groups:** if an admin command arrives, enforcement is on, and the sender's groups cannot be determined (even after a fallback lookup), **deny**.
6. **Reply on deny:** the bot sends the sender a brief permission-denied message (silent denial is confusing; the bot already replies to commands).
7. **Config surface:** `adminGroups` becomes editable from an admin-only WebUI Settings section, live-applied via the existing `/api/bot/settings` endpoint; `config.json` continues to work.
## Permission model
Tier definitions live in `src/bot/commands.ts` (repurpose the existing dead sets; the admin set is the source of truth):
- **Admin commands:** `stop`, `clear`, `remove`, `move`, `vol`, `mode`.
- **Public commands:** all others (`play`, `add`, `playnext`/`pn`, `skip`/`next`, `prev`, `pause`, `resume`, `now`, `queue`/`list`, `lyrics`, `vote`, `help`, `search`/`find`, `playlist`, `album`, `artist`, `fm`).
**Enforcement rule** — a command is **allowed** iff:
1. it is a public command, **OR**
2. `config.adminGroups` is empty (enforcement off), **OR**
3. the sender's server groups ∩ `config.adminGroups` ≠ ∅.
Otherwise it is **denied** (no execution; a denial reply is sent).
Expressed as a pure, unit-testable helper (no TS/async dependency):
```ts
// returns true = allowed, false = denied
function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[]
): boolean
```
- not an admin command → `true`.
- admin command, `adminGroups.length === 0` → `true` (enforcement off).
- admin command, non-empty `adminGroups` → `true` iff any `invokerGroups` value (normalized to number/string consistently) is in `adminGroups`, else `false`.
> Note: `invokerGroups` from TS are strings; `adminGroups` are numbers. Normalize both sides (compare as the same type) to avoid `"6" !== 6` bugs.
## Identity resolution
The TS library already delivers the sender's server groups on each chat event (`TextMessage.invokerGroups: string[]` in `@honeybbq/teamspeak-client`), but the wrapper type `TS3TextMessage` (`src/ts-protocol/client.ts:58-64`) and its mapping (`client.ts:205-214`) **drop** it.
Changes:
1. Add `invokerGroups: string[]` to `TS3TextMessage` and populate it from `msg.invokerGroups` in the mapping.
2. **Availability caveat:** `invokerGroups` is populated only when the sender's client is in the bot's local cache (typically same channel / in view). For a private message from an unseen client, it is `[]`.
3. **Fallback lookup (only when needed):** in the gate, if the command is admin-gated **and** enforcement is on **and** `invokerGroups` is empty, perform a targeted lookup of the sender's groups keyed on `invokerId` (clid) — reuse the already-wrapped `getClientsInChannel()` (`client.ts:314-323`, whose `ClientInfo` carries `serverGroups`), or add a thin wrapper around the library's `getClientInfo(client, clid)` for a precise `clientinfo` query. This query is skipped entirely for public commands, when enforcement is off, and when the event already carried groups (the common "listener in the channel types `!stop`" case).
4. **Fail-closed:** if after the fallback the groups are still unknown, deny the admin command.
## Enforcement seam
In `handleTextMessage` (`src/bot/instance.ts:317`), replace the dead stub at `instance.ts:325-327` with the real check, placed after `parseCommand` succeeds and **before** `executeCommand` (`instance.ts:335`):
- compute `allowed` via `canRunCommand(parsed.name, msg.invokerGroups, this.config.adminGroups)`, performing the async fallback lookup only when the synchronous check is "deny due to empty groups on an admin command with enforcement on";
- if denied → send the denial reply to `msg` (respecting its `targetMode`/sender) and return without executing;
- if allowed → `executeCommand(parsed, msg)` as today.
`executeCommand` stays permission-agnostic, so the WebUI path is unaffected.
**Live config:** `BotInstance` already holds the shared `config` object by reference (passed through `BotInstanceOptions`); `POST /api/bot/settings` mutates that same object in place, so reading `this.config.adminGroups` in the gate reflects edits immediately — no restart, no re-wiring. (Implementation must confirm the instance reads `adminGroups` from the live `config` reference, not a copied-at-construction value.)
## Denied UX
The bot replies to the sender with a short bilingual-ish message, e.g. `⛔ 需要管理员权限(该命令仅限管理员服务器组)`, via the same reply mechanism the command handlers already use, honoring the message's `targetMode` (private vs channel). No execution occurs.
## Config surface
**Backend** (`src/web/api/bot.ts`): extend the existing settings endpoints (already admin-gated: `GET` behind `requireNotGuest`, `POST` behind `requirePermission("bot.manage")`):
- `GET /api/bot/settings` → also return `adminGroups: number[]`.
- `POST /api/bot/settings` → also accept `adminGroups`; validate it is an array of non-negative integers (filter/reject otherwise), assign to `config.adminGroups`, `saveConfig`. Reuses the in-place-mutation + `saveConfig` pattern already used for idle-timeout/auto-pause/guestMode, so it is live-applied.
**Frontend** (`web/src/views/Settings.vue`): a new admin-only section **"命令权限 / Command permissions"** (`v-if="session.isAdmin.value"`), mirroring the idle-timeout/guest-mode sections:
- a text input for comma-separated server-group IDs (parsed to `number[]`, ignoring blanks/non-numbers), a Save button calling `POST /api/bot/settings`, hydrated by the existing `loadIdleTimeout()` GET;
- hint: "仅这些组可运行 stop/clear/remove/move/vol/mode;留空 = 不限制(所有人可用)。如何查看服务器组 ID 见 README。"
**`config.json`**: `adminGroups` continues to work for file-based config.
## Testing
- **`canRunCommand` unit tests** (`src/bot/commands.test.ts` or a new file): public command always allowed; admin command with empty `adminGroups` allowed; admin command with a matching group allowed; admin command with no matching group denied; string-vs-number normalization (`["6"]` matches `[6]`).
- **Handler gate tests:** a denied admin command does NOT call `executeCommand` and triggers a denial reply; an allowed admin command (matching group) and any public command DO call `executeCommand`. (Use a fake `msg` + a `config` with `adminGroups` set; stub the reply + `executeCommand`.)
- **Fallback path:** admin command with empty `invokerGroups` + enforcement on triggers the group lookup; if the lookup yields a matching group → allowed; if it yields nothing → denied (fail-closed).
- **Settings round-trip** (`src/web/api/bot.test.ts`): `POST /api/bot/settings` persists a validated `adminGroups`; `GET` returns it; invalid values (non-array, negative, non-integer) are rejected/filtered.
- **Frontend:** `vue-tsc --noEmit` clean.
## Non-goals (YAGNI)
- No per-group capability map and no whole-bot allowlist (binary admin gate only).
- No per-command customization of the admin/public split in the UI (the set is a code constant; reclassifying is a one-line edit).
- No server-group picker UI (admin types IDs; a picker that lists the bot's visible groups is a possible future enhancement).
- No new chat *management* commands.
- No change to the WebUI authorization model or `executeCommand` semantics.
## Key files touched
Backend: `src/bot/commands.ts` (admin-set constant + `canRunCommand` helper, repurpose the dead sets; +test), `src/bot/instance.ts` (gate in `handleTextMessage`, denial reply, live `adminGroups`), `src/ts-protocol/client.ts` (surface `invokerGroups` on `TS3TextMessage`; possibly a `getClientInfo` wrapper for the fallback), `src/web/api/bot.ts` (settings read/write `adminGroups`; +test). Possibly `src/data/config.ts` (no schema change; `adminGroups` already exists).
Frontend: `web/src/views/Settings.vue` (admin-only 命令权限 section).
Docs: `README.md` (document the feature + how to find TS server-group IDs).
@@ -0,0 +1,202 @@
# Spotify audio source (optional, hybrid librespot) — design spec
- **Issue:** [#112 — Support for Spotify audio source](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/112)
- **Date:** 2026-07-01
- **Status:** Approved (design) — pending implementation plan
- **Chosen approach:** Hybrid — real Spotify streaming via a librespot-family sidecar, with **go-librespot on Linux/Docker** and **Rust librespot on Windows**, behind one backend interface.
---
## 1. Summary
Add `spotify` as a new **optional** `MusicProvider`. Metadata (search / track / album / playlist) is read from the official **Spotify Web API**. Audio is the *real* Spotify stream, produced by a librespot-family sidecar and piped through ffmpeg into the bot's existing voice path.
The feature is **disabled by default**, opt-in, and **requires the user's own Spotify Premium account**. librespot is an unofficial, reverse-engineered client and using it **violates Spotify's Terms of Service** (account-ban risk). This is surfaced to the user as an explicit experimental warning, and no credentials are ever bundled.
If the feature is disabled, unauthenticated, or the sidecar binary is missing, the provider disables itself gracefully — exactly like `YouTubeProvider` when `yt-dlp` is absent (empty results, greyed-out in the UI, no crash).
## 2. Goals / Non-goals
**Goals**
- First-class `spotify` source: search, play, playlist/album import, lyrics best-effort, login status.
- Real Spotify audio (Premium), not a YouTube match.
- Cross-platform: works on the project's three deployments — native Windows one-click, Linux systemd, Docker.
- Strictly optional and safe-by-default; zero impact when off.
- Mixed queues keep working (Spotify tracks interleaved with netease/qq/etc.).
**Non-goals**
- No free-tier audio (Premium is mandatory for librespot streaming).
- No bundled Spotify credentials or shared Developer app.
- No replacement of, or change to, existing sources' behavior.
- No CI e2e against live Spotify (requires Premium; manual only).
## 3. Backend selection
`spotify.backend: "auto" | "go-librespot" | "librespot"` (default `auto`).
| Platform | `auto` resolves to | Why |
|---|---|---|
| Windows | `librespot` (Rust) | go-librespot has **no** Windows binary and its FIFO capture is POSIX-only |
| Linux / Docker | `go-librespot` (fallback `librespot`) | clean REST play-by-URI + prebuilt Linux binary |
| macOS | `librespot` (or `go-librespot` if built) | no go-librespot macOS binary published |
The split is **platform-determined**, not a per-run user toggle (a user may still force one via config if they have the binary).
## 4. Architecture
### 4.1 The seam — `SpotifyAudioBackend`
New file `src/music/spotify/backend.ts`:
```ts
export interface SpotifyTrackEndedEvent { uri: string; reason: "ended" | "stopped" | "error"; }
export interface SpotifyAudioBackend {
start(): Promise<void>; // launch sidecar + resampling ffmpeg
stop(): void; // tear everything down
isReady(): boolean; // device online & streamable
playTrack(uri: string): Promise<void>; // begin ONE track (spotify:track:...)
pause(): Promise<void>;
resume(): Promise<void>;
seek(ms: number): Promise<void>;
getPcmStream(): Readable; // 48kHz s16le stereo (post-ffmpeg)
getPosition(): number; // ms into current track
on(event: "trackEnded", cb: (e: SpotifyTrackEndedEvent) => void): void;
on(event: "metadata", cb: (m: SpotifyNowPlaying) => void): void;
on(event: "ready" | "error", cb: (arg?: unknown) => void): void;
}
```
Both implementations emit the **same** 48 kHz s16le stereo PCM and the **same** events, so everything above the seam (provider, controller, player, UI) is backend-agnostic.
### 4.2 `GoLibrespotBackend` (Linux/Docker) — `src/music/spotify/go-librespot.ts`
- Writes a `config.yml` with `server: { enabled: true, address: 'localhost', port: <p> }`, `audio_backend: 'pipe'`, `audio_output_pipe: '<fifo>'`, `audio_output_pipe_format: 's16le'`, `bitrate: 320`, credentials block.
- `start()`: `mkfifo <fifo>` → **spawn the FIFO-reading ffmpeg first** → then spawn go-librespot. (FIFO open ordering is mandatory: go-librespot opens the write end with `O_WRONLY|O_NONBLOCK` and errors `ENXIO` if no reader exists yet.) ffmpeg: `-f s16le -ar 44100 -ac 2 -i <fifo> -f s16le -ar 48000 -ac 2 -`. ffmpeg stdout = `getPcmStream()`.
- Control (REST): `POST /player/play {uri}`, `POST /player/pause`, `POST /player/resume`, `POST /player/seek {position}`. `GET /status` for position.
- Events (WebSocket `/events`): `metadata` → `metadata`; `not_playing` → `trackEnded` (track boundaries can NOT be read from the FIFO — it is gapless/continuous and never EOFs between tracks).
- Metadata token: go-librespot exposes `POST /token` (first-party session token) and a `/web-api/<path>` proxy to `api.spotify.com` — so on this backend a separate Spotify Developer app is **optional**.
- Recovery: on reader death go-librespot's write returns `EPIPE` and closes the pipe output; backend restarts the ffmpeg reader + reactivates the device.
### 4.3 `RustLibrespotBackend` (Windows / cross-platform) — `src/music/spotify/rust-librespot.ts`
Rust librespot is a **passive Spotify Connect receiver**; the bot acts as the **Connect controller** via the Spotify Web API.
- `start()`: spawn `librespot --backend pipe --name "<deviceName>" --bitrate 320 --cache <dir> [--access-token <tok> | --enable-oauth]`. With **no `--device`**, librespot writes raw PCM (**s16le, 44100 Hz, stereo**) to **stdout** (cross-platform; verified against `pipe.rs` — `None => Box::new(io::stdout())`). Pipe stdout → ffmpeg `-f s16le -ar 44100 -ac 2 -i pipe:0 -f s16le -ar 48000 -ac 2 -` → `getPcmStream()`. Do **not** pass `--passthrough` (that emits raw Ogg, not PCM).
- `playTrack(uri)`: `GET /v1/me/player/devices` → find our `device_id` by `deviceName` → `PUT /v1/me/player/play?device_id={id}` body `{uris:[uri]}`. `pause/resume/seek` → `PUT /v1/me/player/{pause,play,seek}`.
- `trackEnded`: poll `GET /v1/me/player` (is_playing → false / `item` changed / `progress_ms ≈ duration_ms`) plus optional `--onevent` hook (read-only notifications). Add a watchdog + retry/backoff for device-visibility latency and command `202/404` flakiness.
- Requires a **user OAuth token** with scopes `streaming user-read-playback-state user-modify-playback-state user-read-currently-playing` (+ `playlist-read-private playlist-read-collaborative` for user playlists).
## 5. Shared subsystems
### 5.1 Metadata — `src/music/spotify/webapi.ts`
Thin `axios` client mapping Spotify catalog objects → the bot's `Song` / `Playlist` / `Album`:
- `GET /v1/search?type=track,album,playlist&q=…`
- `GET /v1/tracks/{id}`, `GET /v1/albums/{id}/tracks`, `GET /v1/playlists/{id}/tracks`
All confirmed still available with a normal token **after** Spotify's 2024-11-27 cut (that cut removed related-artists, recommendations, audio-features/analysis, featured/category playlists, and `preview_url` — none of which we use). Token source is pluggable: user Developer-app token (primary) or go-librespot `/web-api` proxy (go-librespot path). Handle `429 Retry-After` (rolling 30 s window; dev-mode quota).
### 5.2 Auth — `src/music/spotify/auth.ts`
One web-UI **Authorization-Code + PKCE** login.
- **Primary:** the user registers their own Spotify Developer app (Client ID [+ Secret] + redirect URI) — reliable, ToS-cleaner. The resulting access token drives metadata + Web-API control; the refresh token is persisted (credential store); access tokens (~1 h) auto-refresh. The same token bootstraps librespot via `--access-token`, after which librespot caches reusable credentials — **one user-facing login**.
- **Fallbacks:** librespot `--enable-oauth` (built-in client `65b708073fc0480ea92a077233ca87bd`, redirect `http://127.0.0.1:8898/login`) or go-librespot interactive login (`http://127.0.0.1:36842/login?code=…`) as a separate one-time step; go-librespot `/web-api` proxy when no Developer app is provided.
- Username/password is **dead** (removed by Spotify in 2024); do not implement it.
### 5.3 Provider — `src/music/spotify/provider.ts`
`SpotifyProvider implements MusicProvider` with `platform: "spotify"`:
- `search`, `getSongDetail`, `getPlaylistSongs`, `getAlbumSongs`, `getLyrics` (best-effort/empty), `getRecommendPlaylists` → Web API.
- `getAuthStatus()` reports login state **and** backend/binary availability (drives greying-out in UI, like YouTube).
- `getSongUrl(id)` returns a **sentinel** (`{ url: "spotify:track:<id>" }`) — actual playback is via the backend/controller, not a URL. `instance.ts` recognizes the sentinel and routes to the `SpotifyController`.
- QR-code login methods are no-ops; Spotify uses the OAuth card instead.
## 6. Queue / player / instance integration
- **`SpotifyController`** (`src/music/spotify/controller.ts`, one per bot): owns the chosen backend and the Web-API/auth clients; exposes `playTrack/pause/resume/seek/stop` and forwards `trackEnded`/`metadata`.
- **`AudioPlayer` — new external-PCM mode:** add `playPcmStream(readable, { onExternalEnd })` that feeds the existing `pcmBuffer` → 20 ms frame loop → encoder → `frame` path **without spawning a url-ffmpeg**. For Spotify, `trackEnd` is driven by the backend's `trackEnded` event (the librespot→ffmpeg pipeline is long-lived and does not exit per song). `pause/resume/seek` on a Spotify song are routed to the backend by `instance.ts` (and gate frame emission locally for crisp UI state).
- **`instance.ts`:** `getProviderFor("spotify")`, a `-s` command flag in `getProvider(flags)`. When a dequeued `song.platform === "spotify"`: ensure the controller/backend is started + device active, `playTrack(uri)`, attach the player to the backend PCM. On `trackEnded` → advance the queue. When a **non-Spotify** song is next, **pause the sidecar** (so it doesn't buffer ahead) and use the normal `player.play(url)` path. This preserves one-track-at-a-time on-demand playback and mixed-source queues.
- Real-time pacing is guaranteed by the voice consumer: TS voice pulls 20 ms frames at real time → the player reads PCM at real time → ffmpeg's read of the sidecar stalls → sidecar backpressure pauses decode. (This is why go-librespot's pull model avoids the classic librespot "plays too fast / skips" bug; Rust librespot to stdout is likewise paced by our reads.)
## 7. Config, opt-in & safety
`BotConfig.spotify` (all default-off), added to `getDefaultConfig()` and sanitized in `loadConfig()`:
```ts
spotify: {
enabled: false,
backend: "auto", // "auto" | "go-librespot" | "librespot"
clientId: "", // user's Developer app (optional on go-librespot path)
clientSecret: "", // optional — PKCE needs none; only for confidential/client-credentials flows
deviceName: "TSMusicBot",
bitrate: 320, // 96 | 160 | 320
}
```
Inert unless `enabled` **and** logged-in **and** a resolvable binary. First-run/settings shows the experimental + ToS + Premium + own-credentials warning. Never store or transmit shared secrets.
## 8. Web UI
- Add `spotify` to `platform` and `Source` unions (`web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`).
- `SourceTabs.vue`: add "Spotify" tab; `SongCard.vue`: green **#1DB954** badge; `variables.scss`: `--brand-spotify` tokens.
- `stores/player.ts`: extend `authStatus` / `recommendPlaylists` / `dailySongs` / `userPlaylists` maps + the auth/recommend fetch fan-out.
- Settings: a **Spotify login card distinct from the QR cards** — "Connect Spotify" OAuth button, optional Client ID/Secret fields, backend/binary status indicator, and the risk disclaimer.
## 9. Binary / dependency resolution — `src/music/spotify/binary.ts`
Mirror `findYtDlp()`: resolve `go-librespot` / `librespot(.exe)` from `bin/` then PATH; positive availability cached, negative retried (install-while-running).
- **No prebuilt Rust librespot exists** (source-only: `cargo install librespot`, distro/`scoop`/`choco`, or a binary dropped in `bin/`).
- **go-librespot** ships **Linux-only** assets (`go-librespot_linux_{x86_64,arm64,armv6,armv6_rpi}.tar.gz`, ~6 MB, at `github.com/devgianlu/go-librespot/releases`). Docker build downloads the correct Linux asset; optional runtime auto-download (like yt-dlp). go-librespot is **GPL-3.0** → sidecar = mere aggregation (does not infect the Node code); ship its license text + a source offer.
- Unresolved binary → source disabled with an actionable "install X / see docs" message.
## 10. Testing
Vitest, mocking child processes and HTTP:
- Web-API response → `Song/Playlist/Album` mapping.
- `chooseBackend()` per platform/config.
- Auth: PKCE challenge, token refresh, credential persistence.
- Config load/sanitize (defaults, hand-edited/corrupt input).
- `SpotifyProvider` methods (mock webapi).
- Player external-PCM path: feed a fake `Readable` → assert `frame` emission + `trackEnd` on external end.
- go-librespot `/events` → `trackEnded` translation; Rust-backend Web-API poll → `trackEnded`.
- e2e against live Spotify is **manual & documented** (Premium required), not CI.
## 11. Files touched
**New:** `src/music/spotify/{provider,backend,go-librespot,rust-librespot,webapi,auth,controller,binary}.ts` (+ `.test.ts`).
**Edited (backend):** `src/music/provider.ts` (union: `Song`/`Playlist`/`Album`/`MusicProvider`), `src/index.ts`, `src/bot/manager.ts`, `src/bot/instance.ts` (router + `-s` flag + spotify routing), `src/audio/player.ts` (external-PCM mode), `src/data/config.ts`, `src/music/auth.ts` (credential store union), `src/web/api/auth.ts`, `src/web/api/music.ts` (routers + search aggregation), `src/web/server.ts`, `src/data/database.ts` (platform).
**Edited (frontend):** `web/src/stores/player.ts`, `web/src/stores/sourceTabs.ts`, `web/src/components/SourceTabs.vue`, `web/src/components/SongCard.vue`, `web/src/styles/variables.scss`.
**Docs:** `README.md` (Spotify section + warnings + install notes).
## 12. Staged rollout
1. **Metadata + provider + config + UI plumbing** (no audio): `spotify` source searchable/browsable; playback returns "not yet playable". Fully testable without a sidecar.
2. **go-librespot backend (Linux/Docker):** real playback on Linux; REST + FIFO + WebSocket.
3. **Rust librespot backend (Windows):** stdout PCM + Web-API Connect control.
4. **Polish:** recovery/watchdog, docs, binary auto-download, README.
## 13. Risks & open questions
- **Rust librespot control is the top risk** — Connect device visibility/latency, poll-based track-end. Mitigation: retry/backoff + watchdog; degrade to "skipped" on repeated failure.
- **One-OAuth-token bootstraps librespot** (`--access-token` from the user's own app client) is *plausible but unverified* — may fall back to two one-time logins. Verify with a spike in stage 3.
- **Windows go-librespot is impossible** (FIFO) → Windows always uses Rust librespot.
- **Token scope from librespot's built-in client** may not include `user-modify-playback-state`; if so, a user Developer app is required for the Rust/Windows control path.
- Large surface area → staged rollout above; each stage independently shippable.
## Appendix A — Verified technical facts (with sources)
go-librespot API (`devgianlu/go-librespot`, v0.7.x):
- REST: `POST /player/{play,pause,resume,playpause,stop,next,prev,seek,volume,add_to_queue}`, `GET /status`, `GET /` (`{playback_ready}`), `POST /token`, `GET|POST /web-api/<path>`. `POST /player/play` body `{uri, skip_to_uri?, paused?}`. Server enabled only when `server.enabled: true`.
- WebSocket `/events` envelopes `{type, data}`; types include `metadata, will_play, playing, paused, not_playing, stopped, seek, volume, active, inactive`. Track-end = `not_playing`.
- Pipe backend: continuous raw PCM, no header, 44100 Hz stereo, format `s16le|s32le|f32le`; FIFO opened once; POSIX-only. Backpressure real (blocks when reader stalls).
- Sources: `github.com/devgianlu/go-librespot` (README, `api-spec.yml`, `daemon/api_server.go`, `output/driver-pipe.go`).
Rust librespot (`librespot-org/librespot`, v0.8.0, MIT):
- `--backend pipe` with no `--device` → raw PCM to **stdout**; default S16 / 44100 / stereo; `--format` for higher bit depth; `--passthrough` = raw Ogg (do not use). Passive Connect receiver — no play-by-URI; control via Web API Connect. `--onevent` = read-only hook. No prebuilt binaries.
- Sources: `github.com/librespot-org/librespot` wiki (Audio-Backends, Options), `librespot_playback/audio_backend/pipe.rs`, `docs/authentication.md`.
Spotify auth & Web API:
- Username/password removed (2024); use OAuth (Auth-Code+PKCE) or Zeroconf. Premium required for librespot audio.
- Client-Credentials token authorizes `/v1/search` + public track/album/playlist GETs; 2024-11-27 cut did **not** touch these. Token endpoint `POST https://accounts.spotify.com/api/token` (`grant_type=client_credentials`, Basic base64(id:secret), `expires_in=3600`, no refresh). 429 on a rolling 30 s window.
- Sources: `developer.spotify.com` (Web API docs; 2024-11-27 blog), `librespot` `docs/authentication.md`.
@@ -0,0 +1,227 @@
# Save/Load Playlists + Queue Persistence — Design
**Issue:** [#119](https://github.com/ZHANGTIANYAO1/teamspeak-music-bot/issues/119) — 加入保存和加载播放清单功能
**Date:** 2026-07-06
**Status:** Approved (design), pending implementation plan
## Problem
The play queue lives only in memory (`PlayQueue` inside each `BotInstance`). It is lost in two situations the user calls out:
1. **On restart** — the process stops, the in-memory queue is gone.
2. **On "直接播放"** — `!play <song>` (and the WebUI "play now" path) call `queue.clear()`, wiping the queue to play one song.
The user wants to stop losing the queue. Two related-but-separate capabilities were agreed:
- **Named save/load** of queues (manual), plus **auto-restore** of the live queue across restarts.
- An **independent** option to make single-song immediate-play *not* clear the queue.
Everything ships **behind admin/independent toggles that default OFF**, so existing behavior is unchanged until an operator opts in.
## Scope & agreed decisions
| Decision | Choice |
| --- | --- |
| Core behavior | **Both** — named save/load **and** auto-restore live queue across restart |
| Saved-queue ownership | **Per-user** (like `favorite_playlists`), plus a reserved `__shared__` owner for chat + opt-in sharing |
| Trigger surface | **Web + chat** commands |
| Auto-restore on restart | **Restore and resume playing** (gated by `savedQueuesEnabled`) |
| Load semantics | **Replace (default) + append option** (`-a` flag / WebUI Append button) |
| Feature gate | `savedQueuesEnabled` — **default false, admin-controlled** |
| Single-play clear | Independent `playKeepsQueue` toggle — **default false** |
### Out of scope (YAGNI)
- Renaming a saved queue (delete + re-save instead).
- Mid-track resume on restart (resume from the current track's **start**; URLs are re-resolved).
- Normalized per-song storage (songs stored as a JSON blob).
- Sharing granularity beyond "private to me" vs "shared" (one boolean).
## Storage approach
A saved queue is an ordered list of songs that is only ever saved and loaded **whole** — never queried song-by-song. So songs are stored as a **JSON `TEXT` blob**, not a normalized child table. Each stored song is a `QueuedSong` **without `url`** (URLs are resolved lazily at play time, exactly as today). This mirrors how `QueuedSong` already flows and keeps the schema to a single row per saved queue.
---
## Feature 1 — Named save/load (`savedQueuesEnabled`)
### Data model
New table:
```sql
CREATE TABLE IF NOT EXISTS saved_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ownerId TEXT NOT NULL, -- WebUI user id, or the reserved SHARED owner
name TEXT NOT NULL,
songs TEXT NOT NULL, -- JSON array of stored songs (QueuedSong minus url)
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(ownerId, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
```
- **`ownerId`** is either a real user id **or** a reserved constant `SHARED_QUEUE_OWNER = "__shared__"` (a value that can never collide with a real user id).
- **Ownership rule (reconciles per-user with chat):**
- **WebUI save** has a **"共享 (shared)" checkbox.** Off → `ownerId = req.user.id` (private to you). On → `ownerId = SHARED_QUEUE_OWNER`.
- **Chat `!save`** always writes `ownerId = SHARED_QUEUE_OWNER` (TeamSpeak users have no WebUI account).
- **WebUI list** shows **your own + shared** (labeled). **Chat `!queues`/`!load`** see **shared only**.
- **Overwrite:** `UNIQUE(ownerId, name)` → save is an **upsert** (same owner+name replaces `songs`, `songCount`, `updatedAt`).
- **Caps** (reject with a clear message): ≤ **50** saved queues per owner; ≤ **1000** songs per saved queue.
### DB methods (added to `BotDatabase`)
```ts
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue; // upsert
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[]; // meta only (no songs blob)
getSavedQueue(id: number): SavedQueue | null; // full, with songs
deleteSavedQueue(id: number): boolean;
```
- `StoredSong` = `Omit<QueuedSong, "url">`.
- `SavedQueueMeta` = row without the `songs` blob (id, ownerId, name, songCount, timestamps) — keeps list responses light.
- JSON (de)serialize at the DB boundary; parse failures on a corrupt blob degrade to an empty song list (never throw into a route).
### Web API — `src/web/api/saved-queues.ts`
All routes require auth + `player.queue` capability, and **404/403 when `savedQueuesEnabled` is false** (feature inert).
| Method / path | Behavior |
| --- | --- |
| `GET /api/saved-queues` | list current user's own + shared (meta only) |
| `POST /api/saved-queues` | body `{ botId, name, shared? }` → snapshot that bot's **current queue** songs, upsert |
| `POST /api/saved-queues/:id/load` | body `{ botId, mode: "replace"\|"append" }` → load into that bot |
| `DELETE /api/saved-queues/:id` | delete (only own or shared; not another user's private) |
Ownership check on load/delete: allow if `ownerId === req.user.id` or `ownerId === SHARED_QUEUE_OWNER`; else 404 (no existence leak, matching the favorites pattern).
### Chat commands (new, in `BotInstance.executeCommand`)
- `!save <名称>` — save current queue → shared bucket.
- `!load <名称>` — replace queue with a saved (shared) queue and play.
- `!load -a <名称>` — append a saved (shared) queue to the end.
- `!queues` — list shared saved queues (names + counts).
All four reply **"此功能未启用"** when `savedQueuesEnabled` is false. Added to help text and the command table.
### Load semantics (shared by web + chat)
- **replace** — `queue.clear()`, add all stored songs, `queue.play()` + `resolveAndPlay(first)` (same shape as `cmdPlaylist`). Exits FM mode.
- **append** — add all stored songs to the end; if idle, start the first newly-added one; never interrupts a playing track.
- Loaded songs are re-tagged with a `requestedBy` of the loader (WebUI username / `游客` / chat `invokerName`) so play-history attribution stays correct (integrates with #121).
### WebUI
A new **"已保存队列 / Saved Queues"** page (nav entry visible only when `savedQueuesEnabled`):
- **Save current queue**: name input + **共享** checkbox → `POST`.
- Per entry (reusing `SongCard`/list styles): **Load** (replace), **Append**, **Delete**, showing name / song count / shared badge / owner.
- Store/composable follows the existing `favorites` pattern.
---
## Feature 2 — Auto-restore live queue across restart (`savedQueuesEnabled`)
Gated by the **same** `savedQueuesEnabled` flag (part of the "saved queues" feature).
### Data model
One row per bot (the live snapshot, continuously overwritten):
```sql
CREATE TABLE IF NOT EXISTS queue_state (
botId TEXT PRIMARY KEY,
songs TEXT NOT NULL, -- JSON array of StoredSong
currentIndex INTEGER NOT NULL,
mode TEXT NOT NULL, -- PlayMode value
isFmMode INTEGER NOT NULL DEFAULT 0,
fmPlatform TEXT NOT NULL DEFAULT '',
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
```
DB methods: `saveQueueState(state)` (upsert), `getQueueState(botId)`, `clearQueueState(botId)`.
### Snapshot (write path)
- Add `PlayQueue.snapshot(): QueueSnapshot` and `PlayQueue.restore(snapshot)`.
- `snapshot` captures `songs` (minus url), `currentIndex`, `mode`.
- `restore` rebuilds `songs`, `currentIndex`, `mode`, and resets the derived `playedIndices`/`history`/`forwardStack` to a clean, consistent state for the restored index.
- `BotInstance` writes the snapshot **debounced (~1 s)** on `stateChange` (queue mutations, track changes, and mode changes already emit `stateChange`). FM mode + fm platform captured alongside.
- When the queue becomes empty (`clear()` with nothing re-added), the row is cleared via `clearQueueState`.
### Restore (read path — "resume and play")
When a bot reaches **connected/ready** (the same lifecycle point `autoStart` uses):
1. If `savedQueuesEnabled` and a `queue_state` row exists → `PlayQueue.restore(...)`, restore FM mode/provider.
2. If there was a current track → `resolveAndPlay(current)` (re-resolves URL, plays **from the track's start**).
**Honest caveats (documented in README + spec):**
- Resumes the **current track from its start**, not the exact millisecond (URLs are re-resolved; no persisted elapsed/seek).
- **Spotify** auto-resume is **best-effort** — it depends on the sidecar/controller being back up; non-Spotify sources are reliable.
- Resume only happens for bots that reach the connected state (auto-started, or on next manual start).
---
## Feature 3 — `playKeepsQueue` (independent single-play toggle)
**Independent** config flag, **not** gated by `savedQueuesEnabled`. Default false → today's behavior.
Affects **single-song immediate play only**: chat `!play <song | #N | id:<id> | URL>` and the WebUI play-now / play-by-id path.
| `playKeepsQueue` | `!play <song>` behavior |
| --- | --- |
| `false` (default) | `queue.clear()` → play only that song (today) |
| `true` | `addNext(song)` (insert after current) → `playAt(insertedAt)` (jump & play now) → `resolveAndPlay`; queue **kept**; on track-end, `next()` continues the queue |
- Reuses existing `PlayQueue.addNext` + `playAt` — **no new queue logic.**
- **Not** applied to collection loads — `!playlist` / `!album` / `!artist` / `!fm` still replace the queue (loading a collection is meant to replace; the request is about 单曲/single songs).
- **Empty queue** → equivalent to a normal play (nothing to preserve).
- **FM mode** → `!play` still exits FM (manual takeover), but existing queued songs are preserved and continue after the single song (auto-refill stops because FM is off). Documented.
---
## Config
Add to `BotConfig` (in `src/data/config.ts`), both **default false**, both sanitized on load exactly like `localAudioEnabled` / `autoPauseOnEmpty` (so a hand-edited / legacy / corrupt `config.json` can never silently enable them):
```ts
savedQueuesEnabled: boolean; // default false — gates Features 1 & 2 (admin-controlled)
playKeepsQueue: boolean; // default false — independent (Feature 3)
```
- Set via **Settings → 行为设置** (the existing admin behavior-settings surface, written through `POST /api/bot/settings`).
- When `savedQueuesEnabled` is false: chat save/load/queues reply "此功能未启用"; `/api/saved-queues/*` return 403/404; the WebUI page/nav is hidden; no snapshotting; no auto-restore.
## Error handling & edge cases
- Corrupt `songs` JSON blob → treated as empty list; never throws into a route or the restore path.
- Save with a duplicate name → upsert (overwrite), not an error.
- Load/delete of a non-owned private queue → 404.
- Caps exceeded → 4xx with a clear message (web) / friendly reply (chat).
- Snapshot writes are best-effort and debounced; a DB write failure logs and never interrupts playback.
- Restore of a Spotify-containing queue → best-effort per source; failures skip to next (existing `resolveAndPlay` skip behavior).
## Testing (TDD)
- **DB:** `saveQueue` upsert + caps; `listSavedQueues` own vs shared; `getSavedQueue`/`deleteSavedQueue`; ownership; `queue_state` upsert/get/clear; JSON round-trip + corrupt-blob degradation.
- **PlayQueue:** `snapshot`/`restore` round-trip (songs, index, mode; derived state consistent).
- **BotInstance:** `!save`/`!load`/`!load -a`/`!queues`; feature-disabled replies; snapshot-on-stateChange (debounced); resume-on-ready; `playKeepsQueue` insert-and-jump vs clear; collections still replace; FM interaction.
- **Web API:** auth + capability + feature-gate (403/404); save (own/shared); load replace/append; delete ownership; caps.
- **Config:** defaults false; load sanitization (legacy/corrupt/non-boolean → false).
- **Frontend:** Saved Queues page (save w/ shared toggle, load, append, delete, hidden when disabled); store/composable.
- Then: full suite (`npx vitest run --no-file-parallelism`) + `npx tsc --noEmit` + `cd web && npm run build`.
## Rollout / staging
Implement in stages (each independently valuable, all default-off):
1. **Config + gates** — `savedQueuesEnabled`, `playKeepsQueue`, sanitization, Settings UI.
2. **Feature 3** — `playKeepsQueue` single-play behavior (small, self-contained).
3. **Feature 1** — named save/load (DB → API → chat → WebUI page).
4. **Feature 2** — live-queue snapshot + resume-on-restart.
5. **Docs** — README (commands, toggles, caveats).
+621 -415
View File
File diff suppressed because it is too large. Load diff
+24 -4
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",
@@ -15,30 +20,45 @@
"license": "MIT",
"dependencies": {
"@discordjs/opus": "^0.10.0",
"@honeybbq/teamspeak-client": "^0.1.0",
"@honeybbq/teamspeak-client": "^0.2.1",
"@koa/router": "^15.4.0",
"@sansenjian/qq-music-api": "^2.2.9",
"@sansenjian/qq-music-api": "~2.4.0",
"axios": "^1.14.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"chalk": "^5.6.2",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"koa": "^3.2.0",
"koa-bodyparser": "^4.4.1",
"koa-static": "^5.0.0",
"NeteaseCloudMusicApi": "^4.30.0",
"NeteaseCloudMusicApi": "~4.32.0",
"pino": "^10.3.1",
"ts3-nodejs-library": "^3.5.1",
"tweetnacl": "^1.0.3",
"ws": "^8.20.0"
"ws": "^8.20.0",
"yt-dlp-wrap": "^2.3.12"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"@types/supertest": "^6.0.3",
"@types/ws": "^8.18.1",
"supertest": "^7.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"allowScripts": {
"@discordjs/opus@0.10.0": true,
"better-sqlite3@12.11.1": true,
"cpu-features@0.0.10": true,
"esbuild@0.28.1": true,
"ffmpeg-static@5.3.0": true,
"ssh2@1.17.0": true
}
}
+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;
}
+20 -16
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 \
@@ -25,30 +25,34 @@ RUN cd web && npm ci
COPY . .
RUN npm run build
# --- Stage 2: Production image ---
FROM node:20-slim
# Install production dependencies (with native addons compiled) in the builder
# so we don't need build tools in the production image.
RUN rm -rf node_modules && npm ci --production && npm cache clean --force
# Install only runtime native build tools (needed for native module rebuild)
# --- Stage 2: Production image ---
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).
# System-installed FFmpeg is always compatible with the container runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ && \
ffmpeg && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy built output
# Copy built output and pre-compiled production node_modules from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/web/dist ./web/dist
COPY --from=builder /app/package*.json ./
# Install production dependencies (includes ffmpeg-static, opus, sqlite3)
# These need to compile native addons inside the container
RUN npm ci --production && npm cache clean --force
# Remove build tools to reduce image size
RUN apt-get purge -y python3 make g++ && apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# FFmpeg is bundled via ffmpeg-static — no system ffmpeg needed
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
+17 -6
View File
@@ -1,14 +1,16 @@
# TSMusicBot — Docker Compose
# 一键部署:docker-compose up -d
# 一键部署:docker-compose pull && docker-compose up -d
#
# 所有依赖已内置(Node.js, FFmpeg, Opus 编码器)
# 无需安装任何额外软件
# 默认从 GitHub Container Registry 拉取预构建镜像(amd64 + arm64),
# 无需本地编译,无需 Node.js / 构建工具链。
# 镜像内置:Node.js, FFmpeg, Opus 编码器,原生模块均已交叉编译。
#
# 如需指定版本,把 :latest 换成具体 tag(例如 :1.4.0)。
# 如需本地构建(开发或 fork),见底部注释。
services:
tsmusicbot:
build:
context: ../..
dockerfile: scripts/docker/Dockerfile
image: ghcr.io/zhangtianyao1/teamspeak-music-bot:latest
container_name: tsmusicbot
# Use host network so the bot can reach TS3 server on LAN
# If your TS3 server is on the same machine, this is required
@@ -27,3 +29,12 @@ services:
volumes:
tsmusicbot-data:
driver: local
# ------------------------------------------------------------------
# 本地构建(仅开发 / fork 场景使用):
# 把上面的 `image:` 行删掉,换成下面的 build 块即可。
#
# build:
# context: ../..
# dockerfile: scripts/docker/Dockerfile
# ------------------------------------------------------------------
+751
View File
@@ -0,0 +1,751 @@
#!/usr/bin/env node
/**
* 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 {
chmodSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { createGunzip } from "node:zlib";
import { pipeline } from "node:stream/promises";
import { get } from "node:https";
import { Readable } from "node:stream";
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;
/** 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) => {
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: ${url}`));
});
});
}
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;
}
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 */
}
}
}
// ---------------------------------------------------------------------------
// 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(`${name}: package not installed, skipping (a system ffmpeg on PATH also works)`);
return makeResult(name, "missing", "package not installed");
}
if (existsSync(ffDest) && sizeOf(ffDest) >= FFMPEG_MIN_BYTES) {
ensureExecutable(ffDest);
const probe = probeFfmpegBinary(ffDest);
if (probe.ok) {
log(`${name}: OK (${humanSize(ffDest)}, ${probe.version})`);
return makeResult(name, "ok", humanSize(ffDest));
}
// 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`;
const backup = stash(ffDest);
const tmpDest = `${ffDest}.tsmb-tmp-${process.pid}`;
try {
log(`${name}: GET ${url} (~80 MB, 这一步比较慢,请耐心等待)`);
const buf = await download(url);
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) {
try {
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 */
}
}
}
}
// ---------------------------------------------------------------------------
// 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(`${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}`);
}
// This is the case the old size check could not see: the file is there, it is
// ~1.9 MB, and it is completely useless because it targets another ABI.
log(`${name}: unusable — ${describeProbe(before)}`);
log(`${name}: replacing the native binary with a build for ABI ${NODE_ABI}...`);
const version = readInstalledVersion(name) || "12.11.1";
const url = `${CDN}/${name}/v${version}/${name}-v${version}-node-v${NODE_ABI}-${PLATFORM}-${ARCH}.tar.gz`;
const backup = stash(dest);
let staging = null;
try {
try {
log(`${name}: GET ${url}`);
const buf = await download(url);
staging = mkdtempSync(join(pkgDir, ".tsmb-staging-"));
await extractTarGz(buf, staging);
const staged = join(staging, "build", "Release", "better_sqlite3.node");
if (!existsSync(staged)) {
throw new Error("tarball did not contain build/Release/better_sqlite3.node");
}
mkdirSync(dirname(dest), { recursive: true });
rmSync(dest, { force: true });
renameSync(staged, dest); // atomic swap, same volume
log(`${name}: prebuilt binary installed (${humanSize(dest)})`);
} catch (cdnErr) {
log(`${name}: CDN install failed (${cdnErr.message})`);
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`);
}
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 {
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)));
}
}
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;
}
+41 -17
View File
@@ -6,9 +6,19 @@ echo "║ TSMusicBot Installer ║"
echo "╚══════════════════════════════════════╝"
echo ""
# Resolve script location → project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
INSTALL_DIR="/opt/tsmusicbot"
SERVICE_NAME="tsmusicbot"
# Verify we're in a valid project directory
if [ ! -f "$PROJECT_DIR/package.json" ]; then
echo "Error: Cannot find package.json in $PROJECT_DIR"
echo "Please run this script from the TSMusicBot project directory."
exit 1
fi
# Detect OS
if [ -f /etc/os-release ]; then
. /etc/os-release
@@ -18,44 +28,57 @@ else
exit 1
fi
echo "[1/5] Installing system dependencies..."
echo "[1/6] Installing system dependencies..."
case $OS in
ubuntu|debian)
sudo apt-get update -qq
sudo apt-get install -y -qq curl ffmpeg
sudo apt-get install -y -qq curl build-essential python3
;;
centos|rhel|fedora)
sudo yum install -y curl ffmpeg
sudo yum install -y curl gcc gcc-c++ make python3
;;
arch|manjaro)
sudo pacman -S --noconfirm curl ffmpeg
sudo pacman -S --noconfirm curl base-devel python
;;
*)
echo "Unsupported OS: $OS. Please install Node.js 20 and FFmpeg manually."
echo "Unsupported OS: $OS. Please install Node.js 20, build tools, and FFmpeg manually."
;;
esac
echo "[2/5] Installing Node.js 20 LTS..."
echo "[2/6] Installing Node.js 20 LTS..."
if ! command -v node &> /dev/null || [[ $(node -v | cut -d. -f1 | tr -d 'v') -lt 20 ]]; then
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y -qq nodejs 2>/dev/null || sudo yum install -y nodejs 2>/dev/null
fi
echo "Node.js $(node -v) installed"
echo "[3/5] Downloading TSMusicBot..."
sudo mkdir -p "$INSTALL_DIR"
if [ -d "$(pwd)/dist" ]; then
sudo cp -r "$(pwd)"/* "$INSTALL_DIR/"
else
echo "Please run this script from the TSMusicBot source directory after building."
exit 1
echo "[3/6] Installing dependencies..."
cd "$PROJECT_DIR"
npm install
if [ -d "$PROJECT_DIR/web/package.json" ] || [ -f "$PROJECT_DIR/web/package.json" ]; then
(cd "$PROJECT_DIR/web" && npm install)
fi
echo "[4/5] Installing npm dependencies..."
cd "$INSTALL_DIR"
sudo npm install --production
echo "[4/6] Building project..."
npm run build
echo "[5/5] Creating systemd service..."
echo "[5/6] Copying to $INSTALL_DIR..."
sudo mkdir -p "$INSTALL_DIR"
sudo cp -r "$PROJECT_DIR/dist" "$INSTALL_DIR/"
sudo cp -r "$PROJECT_DIR/node_modules" "$INSTALL_DIR/"
sudo cp "$PROJECT_DIR/package.json" "$INSTALL_DIR/"
# Copy web frontend if built
if [ -d "$PROJECT_DIR/web/dist" ]; then
sudo mkdir -p "$INSTALL_DIR/web"
sudo cp -r "$PROJECT_DIR/web/dist" "$INSTALL_DIR/web/"
fi
# Copy scripts for future use
sudo mkdir -p "$INSTALL_DIR/scripts"
sudo cp -r "$PROJECT_DIR/scripts/"* "$INSTALL_DIR/scripts/" 2>/dev/null || true
# Create data directory
sudo mkdir -p "$INSTALL_DIR/data"
echo "[6/6] Creating systemd service..."
sudo tee /etc/systemd/system/${SERVICE_NAME}.service > /dev/null <<EOL
[Unit]
Description=TSMusicBot - TeamSpeak Music Bot
@@ -88,4 +111,5 @@ echo "║ Commands: ║"
echo "║ systemctl status tsmusicbot ║"
echo "║ systemctl restart tsmusicbot ║"
echo "║ systemctl stop tsmusicbot ║"
echo "║ journalctl -u tsmusicbot -f ║"
echo "╚══════════════════════════════════════╝"
+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);
});
});
+212
View File
@@ -0,0 +1,212 @@
"""Open a real Chromium browser at y.qq.com, let the user log in via any
method (password / QR / QQ connect), then extract the resulting cookie
set and save it to data/cookies/qq.json. Also tests whether the cookie
actually unlocks a known VIP track (Jay Chou 稻香) against the local
QQ Music API before declaring success.
Usage:
"C:/Users/saopig1/miniforge3/python.exe" scripts/qq_browser_login.py
Steps:
1. A visible Chromium window opens at y.qq.com/n/ryqq/player
2. Click the login button in the top right and log in with your
real QQ Music account (the one that has VIP)
3. The script POLLS cookies in the background and auto-detects
successful login by watching for the `uin` cookie to appear
4. Once detected, cookies are captured, tested against
/getMusicPlay for 稻香, and saved on success
5. If VIP still fails, cookies are NOT saved — your existing bot
cookie stays untouched
No terminal input required — the script exits on its own when login
is detected (or after the configured timeout).
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
import requests
from playwright.sync_api import sync_playwright
BOT_ROOT = Path(r"C:\Users\saopig1\Music\teamspeak music bot")
COOKIE_FILE = BOT_ROOT / "data" / "cookies" / "qq.json"
QQ_API = "http://localhost:3200"
VIP_TEST_SONGMID = "003aAYrm3GE0Ac" # 稻香 周杰伦
# How long to wait for the user to finish logging in
LOGIN_TIMEOUT_S = 300 # 5 minutes
POLL_INTERVAL_S = 1.0
# After detecting login, wait a bit for extra cookies (e.g. qqmusic_key)
SETTLE_DELAY_S = 4.0
def qq_cookies(ctx) -> list[dict]:
wanted_suffixes = (".qq.com", "y.qq.com", ".music.qq.com")
return [
c for c in ctx.cookies()
if any(c.get("domain", "").endswith(s) or c.get("domain", "") == s.lstrip(".")
for s in wanted_suffixes)
]
def cookies_to_header(cookies: list[dict]) -> str:
return "; ".join(f"{c['name']}={c['value']}" for c in cookies)
def cookie_has_uin(cookies: list[dict]) -> str | None:
for c in cookies:
if c["name"] == "uin" and c["value"]:
return c["value"]
return None
def test_vip_unlock(cookie_header: str) -> tuple[bool, dict]:
try:
r = requests.get(
f"{QQ_API}/getMusicPlay",
params={"songmid": VIP_TEST_SONGMID, "cookie": cookie_header},
timeout=10,
proxies={"http": None, "https": None},
)
body = r.json()
play = body.get("data", {}).get("playUrl", {}).get(VIP_TEST_SONGMID, {})
url = play.get("url", "")
return (
bool(url),
{
"url_length": len(url),
"url_prefix": url[:120] if url else "",
"error": play.get("error", ""),
},
)
except Exception as e:
return False, {"error": f"request failed: {e}"}
def main() -> int:
print("[setup] launching visible Chromium — look for the window on your desktop")
print("[setup] goto https://y.qq.com/n/ryqq/player")
print()
print("action required:")
print(" 1. Click the 登录 button (top-right) in the browser window")
print(" 2. Log in with your VIP QQ Music account (QR / password / WeChat)")
print(" 3. Do NOTHING in this terminal — the script detects login itself")
print()
with sync_playwright() as p:
browser = p.chromium.launch(headless=False)
ctx = browser.new_context(
viewport={"width": 1280, "height": 820},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/132.0.0.0 Safari/537.36"
),
)
page = ctx.new_page()
try:
page.goto("https://y.qq.com/n/ryqq/player", wait_until="domcontentloaded", timeout=30_000)
except Exception as e:
print(f"[warn] initial navigation slow: {e}")
print(f"[wait] polling every {POLL_INTERVAL_S}s for login (timeout {LOGIN_TIMEOUT_S}s)")
deadline = time.time() + LOGIN_TIMEOUT_S
uin_detected: str | None = None
last_report = 0.0
while time.time() < deadline:
cks = qq_cookies(ctx)
uin = cookie_has_uin(cks)
if uin:
uin_detected = uin
print(f"[detect] uin cookie appeared: {uin}")
break
now = time.time()
if now - last_report >= 15:
remaining = int(deadline - now)
n = len(cks)
print(f"[wait] still waiting... {n} qq.com cookies so far, {remaining}s left")
last_report = now
time.sleep(POLL_INTERVAL_S)
if not uin_detected:
print("[abort] login not detected within timeout")
browser.close()
return 1
print(f"[settle] waiting {SETTLE_DELAY_S}s for session cookies to populate")
time.sleep(SETTLE_DELAY_S)
cks = qq_cookies(ctx)
cookie_header = cookies_to_header(cks)
print(f"[capture] {len(cks)} cookies, {len(cookie_header)} char header")
qm_key = next((c["value"] for c in cks if c["name"] == "qqmusic_key"), "")
qm_keyst = next((c["value"] for c in cks if c["name"] == "qm_keyst"), "")
p_skey = next((c["value"] for c in cks if c["name"] == "p_skey"), "")
print(f"[capture] qqmusic_key: {'present (' + qm_key[:20] + '...)' if qm_key else '(absent)'}")
print(f"[capture] qm_keyst : {'present (' + qm_keyst[:20] + '...)' if qm_keyst else '(absent)'}")
print(f"[capture] p_skey : {'present' if p_skey else '(absent)'}")
print("\n[test] calling /getMusicPlay for 稻香 with captured cookie...")
unlocked, details = test_vip_unlock(cookie_header)
print(f"[test] unlocked: {unlocked}")
print(f"[test] details: {details}")
if not unlocked:
print(
"\n[result] VIP did NOT unlock even with browser-extracted cookies.\n"
" Existing cookie file is UNTOUCHED.\n"
" Diagnosis: the login flow is not the bottleneck — the\n"
" account likely lacks entitlement for this specific track,\n"
" OR QQ requires additional session setup (gateway handshake)\n"
" beyond what's in the cookie itself.\n"
)
# Dump the full cookie set for inspection
dump_path = BOT_ROOT / "data" / "cookies" / "qq.browser-capture.json"
dump_path.write_text(
json.dumps(
{"cookie": cookie_header, "cookieList": cks, "capturedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
print(f"[dump] full browser cookies written to {dump_path}")
print(" (for side-by-side comparison with OAuth-derived cookies)")
browser.close()
return 2
print("\n[save] VIP unlocked. Writing cookie to bot...")
COOKIE_FILE.write_text(
json.dumps(
{"cookie": cookie_header, "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ")},
ensure_ascii=False,
),
encoding="utf-8",
)
print(f"[save] wrote {COOKIE_FILE}")
try:
r = requests.post(
"http://localhost:3000/api/auth/cookie",
json={"platform": "qq", "cookie": cookie_header},
timeout=5,
proxies={"http": None, "https": None},
)
print(f"[notify] /api/auth/cookie POST: {r.status_code}")
except Exception as e:
print(f"[notify] failed to push cookie to bot: {e}")
print(" Restart the bot to pick up the new cookie from disk.")
print("\n[done] VIP should now work through the bot. Try playing 稻香!")
browser.close()
return 0
if __name__ == "__main__":
import sys
sys.exit(main())
+119
View File
@@ -0,0 +1,119 @@
"""Open a visible browser at y.qq.com so the user can manually verify
whether their VIP account can play 稻香 (Jay Chou) in the real QQ Music
web player.
If the browser plays the song → entitlement exists and our 104003 is a
request-signing issue.
If the browser refuses / shows a VIP modal / silently fails → the
account doesn't have entitlement OR QQ's web player hits the same wall.
"""
import sys
import time
from playwright.sync_api import sync_playwright
# Force line-buffered stdout so logs actually reach the output file
sys.stdout.reconfigure(line_buffering=True)
START_URL = "https://y.qq.com/n/ryqq/player"
SONG_URL = "https://y.qq.com/n/ryqq/songDetail/003aAYrm3GE0Ac"
def log(msg: str) -> None:
print(msg, flush=True)
def main() -> int:
log("[setup] launching visible Chromium")
log(f"[setup] start URL: {START_URL}")
log(f"[setup] song URL: {SONG_URL}")
log("")
log("action required:")
log(" 1. The browser opens at the player page")
log(" 2. Make sure your VIP account is logged in (top-right avatar)")
log(" — if not, log in now, the script will wait")
log(" 3. Once logged in, the browser will auto-navigate to 稻香")
log(" 4. Click the PLAY button and report what happens:")
log(" (a) song plays → account has entitlement, issue is request signing")
log(" (b) VIP modal → account needs higher tier / digital album purchase")
log(" (c) silent failure → QQ web player has same 104003 wall")
log("")
log("[wait] browser stays open for 5 minutes")
log("")
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=False)
except Exception as e:
log(f"[fatal] failed to launch Chromium: {e}")
return 1
ctx = browser.new_context(
viewport={"width": 1400, "height": 900},
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/132.0.0.0 Safari/537.36"
),
)
page = ctx.new_page()
# Log every navigation so we can see if pages fail
page.on("framenavigated", lambda f: log(f"[nav] {f.url[:120]}") if f == page.main_frame else None)
page.on("pageerror", lambda e: log(f"[js-error] {str(e)[:200]}"))
# Step 1: open the player page (known working)
log(f"[goto] {START_URL}")
try:
page.goto(START_URL, wait_until="domcontentloaded", timeout=30_000)
log(f"[ok] loaded: {page.url}")
except Exception as e:
log(f"[warn] initial goto failed: {e}")
log("[warn] browser stays open, try manual navigation")
# Wait briefly for auth state to settle
time.sleep(3)
# Check if the user is logged in via the uin cookie
cookies = ctx.cookies()
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
if uin:
log(f"[auth] logged in as uin={uin}")
else:
log("[auth] not logged in yet — please log in via the top-right avatar")
log("[auth] waiting up to 2 minutes for login...")
end = time.time() + 120
while time.time() < end:
time.sleep(1)
cookies = ctx.cookies()
uin = next((c["value"] for c in cookies if c["name"] == "uin" and c["value"]), None)
if uin:
log(f"[auth] detected login: uin={uin}")
break
if not uin:
log("[abort] no login detected within 2 minutes")
time.sleep(30) # keep browser visible
browser.close()
return 2
# Step 2: navigate to the song page
log(f"[goto] {SONG_URL}")
try:
page.goto(SONG_URL, wait_until="domcontentloaded", timeout=30_000)
log(f"[ok] loaded: {page.url}")
except Exception as e:
log(f"[warn] song navigation failed: {e}")
log(f"[info] current page: {page.url}")
log("")
log("===========================================================")
log("browser is open on the song page — click PLAY and observe.")
log("keeping browser open for 5 more minutes")
log("===========================================================")
time.sleep(300)
browser.close()
return 0
if __name__ == "__main__":
sys.exit(main())
+12
View File
@@ -0,0 +1,12 @@
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto("http://localhost:3000")
page.wait_for_load_state("networkidle")
page.wait_for_timeout(800)
page.locator(".navbar").screenshot(path="scripts/navbar_bigger.png")
rect = page.locator(".bot-selector-btn").bounding_box()
print("bot-selector-btn bbox:", rect)
browser.close()
+348 -91
View File
@@ -1,91 +1,348 @@
@echo off
title TSMusicBot Setup
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo ============================================
echo.
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: ---- Step 1: Check / install Node.js ----
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js not found. Attempting automatic installation...
echo.
:: Try winget first (available on Windows 10 1709+ and Windows 11)
where winget >nul 2>&1
if %errorlevel% equ 0 (
echo Installing Node.js via winget...
winget install OpenJS.NodeJS.LTS --accept-source-agreements --accept-package-agreements
if %errorlevel% neq 0 (
echo winget installation failed. Please install Node.js manually from https://nodejs.org
pause
exit /b 1
)
:: Refresh PATH so node is available in this session
call refreshenv >nul 2>&1
:: If refreshenv is not available, ask user to restart
where node >nul 2>&1
if %errorlevel% neq 0 (
echo.
echo Node.js was installed but is not yet available in this terminal.
echo Please close this window and run setup.bat again.
pause
exit /b 0
)
) else (
echo winget is not available on this system.
echo Please install Node.js 20 LTS manually from https://nodejs.org
echo After installing, close this window and run setup.bat again.
pause
exit /b 1
)
) else (
echo [OK] Node.js found.
node --version
)
echo.
:: ---- Step 2: Install npm dependencies ----
echo Installing dependencies (this may take a few minutes)...
call npm install
if %errorlevel% neq 0 (
echo.
echo npm install failed. Check the error messages above.
pause
exit /b 1
)
echo [OK] Dependencies installed.
echo.
:: ---- Step 3: Build the project ----
echo Building TypeScript project...
call npx tsc
if %errorlevel% neq 0 (
echo.
echo Build failed. Check the error messages above.
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ---- Step 4: Create default config if missing ----
if not exist "config.json" (
echo Creating default config.json...
echo Please edit config.json with your TeamSpeak server details before starting the bot.
) else (
echo [OK] config.json already exists.
)
echo.
:: ---- Done ----
echo ============================================
echo Setup complete!
echo ============================================
echo.
echo To start the bot, run: scripts\start.bat
echo.
pause
@echo off
setlocal enabledelayedexpansion
chcp 65001 >nul
title TSMusicBot Setup
:: ============================================================
:: TSMusicBot Setup Script (Windows)
:: - Auto-detect China network, switch to npmmirror
:: - Download native binaries from CDN (避开 GitHub)
:: - 自动修复 PowerShell 环境变量
:: ============================================================
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"
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.." || (
echo [FATAL] Cannot change to project directory.
pause
exit /b 1
)
set "PROJECT_ROOT=%cd%"
:: ---- Initialize log ----
echo. > "%LOG_FILE%"
call :log "============================================"
call :log " TSMusicBot Setup v%SCRIPT_VERSION%"
call :log " Started: %date% %time%"
call :log " Project root: %PROJECT_ROOT%"
call :log "============================================"
echo ============================================
echo TSMusicBot - First-Time Setup (Windows)
echo Version %SCRIPT_VERSION%
echo ============================================
echo.
echo Log file: %LOG_FILE%
echo.
:: ============================================================
:: Step 1: Check Node.js
:: ============================================================
call :step "1/7" "Checking Node.js"
where node >nul 2>&1
if not errorlevel 1 goto :check_node_version
call :error "Node.js not found in PATH."
echo.
echo Please install Node.js %MIN_NODE_MAJOR% LTS or newer from:
echo https://nodejs.org/ (official)
echo https://nodejs.cn/ (China mirror, recommended)
echo.
pause
exit /b 1
:check_node_version
for /f "delims=" %%v in ('node --version 2^>nul') do set "NODE_VER=%%v"
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%
:: 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
:: ============================================================
call :step "2/7" "Checking npm"
where npm >nul 2>&1
if errorlevel 1 (
call :error "npm not found."
pause
exit /b 1
)
for /f "delims=" %%v in ('npm --version 2^>nul') do set "NPM_VER=%%v"
call :log "npm version: %NPM_VER%"
echo [OK] npm found: %NPM_VER%
echo.
:: ============================================================
:: Step 3: Detect network and configure mirror
:: ============================================================
call :step "3/7" "Checking network"
set "USE_MIRROR=0"
set "MIRROR_REGISTRY=https://registry.npmjs.org"
echo Testing connection to npm registry...
call :log "Testing npm registry connectivity..."
ping -n 1 -w 4000 registry.npmjs.org >nul 2>&1
if errorlevel 1 (
echo [WARN] Cannot reach npm registry quickly, using China mirror.
call :log "npm registry unreachable via ping"
set "USE_MIRROR=1"
) else (
echo [OK] npm registry reachable.
call :log "npm registry reachable"
)
if "%USE_MIRROR%"=="1" (
echo.
echo [INFO] Using China mirror (npmmirror.com)
call :log "Switching to npmmirror.com"
set "MIRROR_REGISTRY=https://registry.npmmirror.com"
set "CDN_MIRROR=https://cdn.npmmirror.com/binaries"
) else (
set "CDN_MIRROR="
)
echo.
:: ============================================================
:: Step 4: Install backend dependencies (跳过二进制)
:: ============================================================
call :step "4/7" "Installing backend dependencies"
if exist "node_modules\.package-lock.json" (
echo Found existing node_modules. Checking integrity...
)
echo Running: npm install --ignore-scripts (跳过 GitHub 二进制下载)
echo.
call npm install --registry=%MIRROR_REGISTRY% --ignore-scripts >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Backend npm install failed."
echo Check the log: %LOG_FILE%
pause
exit /b 1
)
echo [OK] Backend dependencies installed.
echo.
:: ============================================================
:: Step 4b: Verify / download / repair native binaries (ABI aware)
:: ============================================================
call :step "4b/7" "Checking native binaries"
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.
:: ============================================================
:: Step 5: Install frontend dependencies
:: ============================================================
call :step "5/7" "Installing frontend dependencies"
if not exist "web\package.json" (
call :error "web\package.json not found."
pause
exit /b 1
)
echo Running: npm install (in web/)
echo.
pushd web >nul
call npm install --registry=%MIRROR_REGISTRY% >>"%LOG_FILE%" 2>&1
set "WEB_INSTALL_RESULT=!errorlevel!"
popd >nul
if !WEB_INSTALL_RESULT! neq 0 (
call :error "Frontend npm install failed."
pause
exit /b 1
)
echo [OK] Frontend dependencies installed.
echo.
:: ============================================================
:: Step 6: Build project
:: ============================================================
call :step "6/7" "Building project"
echo Running: npm run build
echo.
call npm run build >>"%LOG_FILE%" 2>&1
if errorlevel 1 (
call :error "Build failed. Check: %LOG_FILE%"
pause
exit /b 1
)
echo [OK] Build succeeded.
echo.
:: ============================================================
:: Step 7: Ensure PowerShell in PATH (修复 jdymusic CDN 播放)
:: ============================================================
call :step "7/7" "Checking PowerShell PATH"
where powershell >nul 2>&1
if errorlevel 1 (
echo [WARN] PowerShell not found in PATH.
echo Attempting to fix...
set "POWERSHELL_PATH=C:\Windows\System32\WindowsPowerShell\v1.0"
if exist "!POWERSHELL_PATH!\powershell.exe" (
:: 为用户添加永久 PATH 环境变量
echo [INFO] Adding PowerShell to user PATH...
call setx PATH "!POWERSHELL_PATH!;%PATH%" >nul 2>&1
echo [OK] PowerShell added to PATH. Please restart your terminal.
) else (
echo [WARN] Could not find powershell.exe on this system.
echo If you encounter playback issues with some NetEase songs,
echo run: set PATH=%%PATH%%;C:\Windows\System32\WindowsPowerShell\v1.0\
echo before running scripts\start.bat
)
) else (
echo [OK] PowerShell found in PATH.
)
echo.
:: ============================================================
:: Verify build outputs
:: ============================================================
echo Verifying build outputs...
set "BUILD_OK=1"
if not exist "dist" (
call :error "dist/ directory missing after build."
set "BUILD_OK=0"
)
if not exist "web\dist" (
call :error "web\dist/ directory missing after build."
set "BUILD_OK=0"
)
if "!BUILD_OK!"=="0" (
echo Build completed but expected output is missing.
pause
exit /b 1
)
echo [OK] Build outputs verified.
echo.
if not exist "config.json" (
echo [INFO] config.json will be auto-generated on first launch.
) else (
echo [OK] config.json already exists.
)
echo.
:: ============================================================
:: Done
:: ============================================================
call :log "Setup completed successfully at %date% %time%"
echo ============================================
echo Setup Complete!
echo ============================================
echo.
echo Next steps:
echo 1. Run: scripts\start.bat
echo 2. Open: http://localhost:3000
echo.
echo Setup log: %LOG_FILE%
echo.
pause
exit /b 0
:: ============================================================
:: Subroutines
:: ============================================================
:step
echo ---- Step %~1: %~2 ----
call :log ""
call :log "---- Step %~1: %~2 ----"
goto :eof
:error
echo.
echo [ERROR] %~1
call :log "[ERROR] %~1"
goto :eof
:log
echo [%time%] %~1 >> "%LOG_FILE%"
goto :eof
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -euo pipefail
#
# TSMusicBot Setup Script (Linux/macOS)
# - Auto-detect China network, switch to npmmirror
# - Download native binaries from CDN (避开 GitHub)
# - One-click setup, same as setup.bat for Windows
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_FILE="$PROJECT_DIR/setup.log"
echo "============================================"
echo " TSMusicBot - First-Time Setup (Linux)"
echo "============================================"
echo ""
echo "Log file: $LOG_FILE"
echo ""
# ---- Check Node.js ----
if ! command -v node &>/dev/null; then
echo "[ERROR] Node.js not found. Please install Node.js 20+ from https://nodejs.org"
echo " or https://nodejs.cn/ (China mirror)."
exit 1
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
fi
echo "[OK] npm v$(npm -v)"
echo ""
# ---- Detect China network ----
USE_MIRROR=0
MIRROR_REGISTRY="https://registry.npmjs.org"
CDN_MIRROR=""
echo "Testing connection to npm registry..."
if ping -c 1 -W 4 registry.npmjs.org &>/dev/null; then
echo "[OK] npm registry reachable."
else
echo "[WARN] Cannot reach npm registry, using China mirror."
USE_MIRROR=1
fi
if [ "$USE_MIRROR" = "1" ]; then
echo "[INFO] Using China mirror (npmmirror.com)"
MIRROR_REGISTRY="https://registry.npmmirror.com"
CDN_MIRROR="https://cdn.npmmirror.com/binaries"
export npm_config_registry="$MIRROR_REGISTRY"
fi
echo ""
# ---- Check build tools (needed for native module fallback) ----
if ! command -v gcc &>/dev/null && ! command -v clang &>/dev/null; then
echo "[INFO] No C compiler found. If CDN binaries are unavailable,"
echo " native modules may fail. Install build tools:"
echo " sudo apt install build-essential (Ubuntu/Debian)"
echo " sudo yum groupinstall 'Development Tools' (CentOS/RHEL)"
echo ""
fi
# ---- Step 1: Install dependencies (skip GitHub binaries) ----
echo "---- 1/5: Installing Node.js dependencies ----"
echo ""
cd "$PROJECT_DIR"
npm install --registry="$MIRROR_REGISTRY" --ignore-scripts 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Dependencies installed."
echo ""
# ---- Step 2: Verify / download / repair native binaries (ABI aware) ----
echo "---- 2/5: Checking native binaries ----"
echo ""
# 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 ----
echo "---- 3/5: Installing web panel dependencies ----"
echo ""
if [ -f "web/package.json" ]; then
cd "$PROJECT_DIR/web"
npm install --registry="$MIRROR_REGISTRY" 2>&1 | tee -a "$LOG_FILE"
cd "$PROJECT_DIR"
echo "[OK] Web panel dependencies installed."
else
echo "[SKIP] web/package.json not found."
fi
echo ""
# ---- Step 4: Build project ----
echo "---- 4/5: Building project ----"
echo ""
npm run build 2>&1 | tee -a "$LOG_FILE"
echo "[OK] Build succeeded."
echo ""
# ---- Step 5: Verify ----
echo "---- 5/5: Verifying build ----"
echo ""
BUILD_OK=1
if [ ! -d "dist" ]; then
echo "[ERROR] dist/ directory missing."
BUILD_OK=0
fi
if [ -d "web" ] && [ ! -d "web/dist" ]; then
echo "[ERROR] web/dist/ directory missing."
BUILD_OK=0
fi
if [ "$BUILD_OK" = "0" ]; then
echo "Build completed but expected output is missing."
exit 1
fi
echo "[OK] Build outputs verified."
echo ""
if [ ! -f "config.json" ]; then
echo "[INFO] config.json will be auto-generated on first launch."
fi
echo ""
echo "============================================"
echo " Setup Complete!"
echo "============================================"
echo ""
echo "Next steps:"
echo " 1. Run: npm start"
echo " 2. Open: http://localhost:3000"
echo ""
echo "Setup log: $LOG_FILE"
echo ""
+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 ]
+60 -47
View File
@@ -1,47 +1,60 @@
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat for automatic installation, or install Node.js 20+ from https://nodejs.org
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Install dependencies if needed
if not exist "node_modules" (
echo Installing dependencies...
call npm install --production
if %errorlevel% neq 0 (
echo Failed to install dependencies.
pause
exit /b 1
)
)
:: Build if dist/ doesn't exist
if not exist "dist" (
echo Building project...
call npx tsc
if %errorlevel% neq 0 (
echo Build failed.
pause
exit /b 1
)
)
:: FFmpeg is bundled via ffmpeg-static — no PATH check needed.
echo FFmpeg is bundled via node_modules (ffmpeg-static).
echo.
:: Start the application
node dist/index.js
pause
@echo off
title TSMusicBot
echo Starting TSMusicBot...
echo.
:: Check if node is available
where node >nul 2>&1
if %errorlevel% neq 0 (
echo Node.js is not installed.
echo Run scripts\setup.bat first.
pause
exit /b 1
)
:: Resolve project root (one level up from scripts/)
cd /d "%~dp0.."
:: Check if dependencies are installed
if not exist "node_modules" (
echo Dependencies not found. Please run scripts\setup.bat first.
pause
exit /b 1
)
:: Check if build output exists
if not exist "dist" (
echo Build not found. Please run scripts\setup.bat first.
pause
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 (
if exist "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" (
set "PATH=%PATH%;C:\Windows\System32\WindowsPowerShell\v1.0\"
)
)
:: Start the application
echo WebUI: http://localhost:3000
echo Press Ctrl+C to stop.
echo.
node dist/index.js
pause
+99
View File
@@ -0,0 +1,99 @@
"""Regression: DELETE /api/bot/:id must broadcast botRemoved so the UI drops the row.
Creates an ephemeral bot, opens the dropdown, deletes the bot via API, and
asserts the row disappears without any page reload. Does not touch any
existing user bot.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
EPHEMERAL_NAME = "rmbot_test"
EPHEMERAL_NICK = "RmBotTest"
def api(path, method="GET", **kw):
r = getattr(requests, method.lower())(f"{BASE}{path}", timeout=10, **kw)
r.raise_for_status()
return r.json() if r.text else None
def cleanup():
for b in api("/api/bot/")["bots"]:
if b["name"] == EPHEMERAL_NAME:
try:
api(f"/api/bot/{b['id']}", method="DELETE")
except Exception:
pass
def main():
cleanup()
new = api(
"/api/bot/",
method="POST",
json={
"name": EPHEMERAL_NAME,
"serverAddress": "127.0.0.1",
"serverPort": 9987,
"nickname": EPHEMERAL_NICK,
"autoStart": False,
},
)
bot_id = new["id"]
print(f"[setup] created ephemeral bot {bot_id[:8]}")
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.8)
# Open dropdown and confirm the new bot row is present
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-dropdown")
rows_before = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows before remove: {rows_before}")
# Match the ephemeral row by its name text
present = (
page.locator(".bot-dropdown-row", has_text=EPHEMERAL_NAME).count()
)
assert present == 1, f"ephemeral row not found (got {present})"
# Delete via API
api(f"/api/bot/{bot_id}", method="DELETE")
print("[api] deleted bot")
# Wait up to 4s for UI to drop the row
removed = False
for _ in range(40):
if (
page.locator(
".bot-dropdown-row", has_text=EPHEMERAL_NAME
).count()
== 0
):
removed = True
break
time.sleep(0.1)
rows_after = page.locator(".bot-dropdown-row").count()
print(f"[ui] dropdown rows after remove: {rows_after}")
assert removed, "ephemeral row did not disappear from UI after DELETE"
assert rows_after == rows_before - 1, (
f"row count mismatch: before={rows_before} after={rows_after}"
)
print("[PASS] bot removal propagates to UI via WS")
finally:
browser.close()
finally:
cleanup()
if __name__ == "__main__":
main()
+171
View File
@@ -0,0 +1,171 @@
"""Corner case regressions that go beyond Bugs A/B/C.
A. Race — disconnect() called during connect()'s awaited handshake must
NOT leave the bot reporting connected=true afterwards.
B. Config-only commands (vol, mode, clear) must work even when the bot is
disconnected (UI should stay usable while the bot is offline).
C. After a disconnect mid-playback, the player must not be able to
auto-advance to the next queued song (trackEnd → resolveAndPlay).
"""
import time
import threading
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.1)
return False
def test_config_commands_when_disconnected(bot_id):
"""B. vol/mode/clear should succeed while bot is disconnected."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# volume is the simplest config-only command
r = api(
f"/api/player/{bot_id}/volume",
method="POST",
json={"volume": 60},
)
assert r.status_code == 200, f"volume failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(
f"/api/player/{bot_id}/mode",
method="POST",
json={"mode": "seq"},
)
assert r.status_code == 200, f"mode failed while disconnected: {r.status_code} {r.text[:100]}"
r = api(f"/api/player/{bot_id}/clear", method="POST")
assert r.status_code == 200, f"clear failed while disconnected: {r.status_code} {r.text[:100]}"
print("[PASS] config commands (vol/mode/clear) work when disconnected")
def test_play_rejected_when_disconnected(bot_id):
"""B (negative). play/add/next/prev should still be rejected."""
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"play should fail while disconnected"
r = api(
f"/api/player/{bot_id}/add",
method="POST",
json={"query": "test", "platform": "netease"},
)
assert r.status_code >= 400, f"add should fail while disconnected"
r = api(f"/api/player/{bot_id}/next", method="POST")
assert r.status_code >= 400, f"next should fail while disconnected"
print("[PASS] audio commands (play/add/next) rejected when disconnected")
def test_disconnect_during_connect_race(bot_id):
"""A. disconnect() called while connect() is awaiting must win the race.
Fires a stop 200ms into a start call; after things settle the bot's
connected state must be stable (either cleanly disconnected, or cleanly
connected if the stop happened after connect completed). It must NOT
end up in a weird state where connected=true but a subsequent query
shows inconsistent data.
"""
# Ensure disconnected first
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1) # give TS server a moment to forget us
def delayed_stop():
time.sleep(0.2)
try:
api(f"/api/bot/{bot_id}/stop", method="POST")
except Exception:
pass
threading.Thread(target=delayed_stop, daemon=True).start()
try:
r = api(f"/api/bot/{bot_id}/start", method="POST")
except Exception as e:
r = None
print(f"[info] start threw: {e}")
# Wait for all state transitions to settle
time.sleep(2)
b = get_bot(bot_id)
# The key invariant: if connected is false, playing must also be false;
# if connected is true, the transport is actually up (we can issue
# another command without error).
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: connected={b['connected']} playing={b['playing']}"
)
print(
f"[PASS] disconnect-during-connect race — final state consistent "
f"(connected={b['connected']} playing={b['playing']})"
)
def test_resolve_guard(bot_id):
"""C. resolveAndPlay on a disconnected bot is a no-op.
We can't directly invoke resolveAndPlay from the API, but we can
verify by checking that after a stop, the bot stays idle even if we
wait for a trackEnd-like event to fire.
"""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1.5) # more than a frame cycle
b = get_bot(bot_id)
assert not b["playing"], (
f"player should stay stopped after disconnect: {b}"
)
print("[PASS] player stays idle after disconnect (no ghost autoplay)")
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
try:
test_config_commands_when_disconnected(bot_id)
test_play_rejected_when_disconnected(bot_id)
test_resolve_guard(bot_id)
test_disconnect_during_connect_race(bot_id)
print("ALL GREEN")
finally:
if initial:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] connected={get_bot(bot_id)['connected']}")
if __name__ == "__main__":
main()
+852
View File
@@ -0,0 +1,852 @@
"""Comprehensive feature + corner-case test for TSMusicBot against a
local TeamSpeak 3 server.
Exercises every major HTTP endpoint, the WebSocket state-broadcast path,
all music providers, bot lifecycle transitions, and a handful of races
that have burned us in the past. Designed to be safe to run against a
real installation: captures the target bot's initial connected/volume/
mode settings and restores them in `finally`.
Usage:
"C:/Users/saopig1/miniforge3/python.exe" scripts/test_full_feature.py
Exit code: 0 if every non-skipped test passed, 1 otherwise.
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from typing import Any
import requests
BASE = "http://localhost:3000"
POLL_INTERVAL = 0.15
CONNECT_TIMEOUT = 20 # tolerate occasional TS3 anti-flood grace
ANTIFLOOD_BREATHER = 1.5 # gap between rapid cycles so TS3 stays happy
# ----------------------------- HTTP helpers ---------------------------------
def api(path: str, method: str = "GET", json_body: Any = None):
"""Return (status_code, body). Never raises."""
try:
fn = getattr(requests, method.lower())
r = fn(f"{BASE}{path}", json=json_body, timeout=30)
try:
return r.status_code, r.json()
except Exception:
return r.status_code, r.text
except Exception as e:
return None, f"<{type(e).__name__}: {e}>"
def get_bot(bot_id: str) -> dict | None:
_, data = api("/api/bot/")
if not isinstance(data, dict):
return None
return next((b for b in data.get("bots", []) if b["id"] == bot_id), None)
def wait_connected(bot_id: str, want: bool, timeout: float = CONNECT_TIMEOUT) -> bool:
end = time.time() + timeout
while time.time() < end:
b = get_bot(bot_id)
if b is not None and b["connected"] is want:
return True
time.sleep(POLL_INTERVAL)
return False
def start_and_wait(bot_id: str, retries: int = 2) -> bool:
"""Start the bot, tolerating transient TS3 anti-flood by retrying with
exponential backoff. Returns True only when the bot reports connected."""
for attempt in range(retries + 1):
s, _ = api(f"/api/bot/{bot_id}/start", method="POST")
if s == 200 and wait_connected(bot_id, True):
return True
# If /start returned an error (e.g. connect timeout from our 15s
# deadline), back off and retry — TS3 server-side anti-flood
# usually clears in a few seconds.
if attempt < retries:
time.sleep(3.0 * (attempt + 1))
# Make sure we're fully stopped before the next attempt so
# oldBot.disconnect() doesn't double-fire
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False, timeout=5)
return False
def stop_and_wait(bot_id: str) -> bool:
api(f"/api/bot/{bot_id}/stop", method="POST")
return wait_connected(bot_id, False)
def assert_started(bot_id: str):
"""Helper that raises with a clear message when start fails so test
output points at 'could not connect' rather than an empty assertion."""
if not start_and_wait(bot_id):
raise AssertionError(
"could not bring bot online (TS3 server may be anti-flooding "
"or unreachable)"
)
# ----------------------------- test runner ----------------------------------
@dataclass
class TestResult:
name: str
status: str # PASS / FAIL / ERROR / SKIP
detail: str = ""
passed = 0
failed = 0
skipped = 0
results: list[TestResult] = []
def run(name: str, fn):
global passed, failed
try:
fn()
print(f" [PASS] {name}")
passed += 1
results.append(TestResult(name, "PASS"))
except AssertionError as e:
print(f" [FAIL] {name}: {e}")
failed += 1
results.append(TestResult(name, "FAIL", str(e)))
except Exception as e:
print(f" [ERROR] {name}: {type(e).__name__}: {e}")
failed += 1
results.append(TestResult(name, "ERROR", f"{type(e).__name__}: {e}"))
def skip(name: str, reason: str):
global skipped
print(f" [SKIP] {name} ({reason})")
skipped += 1
results.append(TestResult(name, "SKIP", reason))
# ----------------------------- test groups ----------------------------------
def group_infrastructure(bot_id: str):
print("\n== infrastructure ==")
def t_health():
s, d = api("/api/health")
assert s == 200, f"health returned {s}"
assert d.get("status") == "ok"
run("GET /api/health", t_health)
def t_list_bots():
s, d = api("/api/bot/")
assert s == 200
assert isinstance(d.get("bots"), list)
assert any(b["id"] == bot_id for b in d["bots"])
run("GET /api/bot/ lists target bot", t_list_bots)
def t_bot_config():
s, d = api(f"/api/bot/{bot_id}/config")
assert s == 200
assert d["id"] == bot_id
assert "identity" in d
assert "serverAddress" in d and "nickname" in d
assert "serverPassword" in d, "serverPassword field missing"
run("GET /api/bot/:id/config returns all fields", t_bot_config)
def t_404_on_unknown_bot():
s, _ = api("/api/bot/does-not-exist/config")
assert s == 404
run("404 on unknown bot id", t_404_on_unknown_bot)
def t_quality_shape():
s, d = api("/api/music/quality")
assert s == 200
for p in ("netease", "qq", "bilibili"):
assert p in d, f"{p} missing from quality response"
run("GET /api/music/quality shape", t_quality_shape)
def group_auth_status():
print("\n== auth status per platform ==")
def t_netease_ok():
s, d = api("/api/auth/status?platform=netease")
assert s == 200
assert d.get("platform") == "netease"
assert "loggedIn" in d
run("auth status netease", t_netease_ok)
def t_qq_ok():
s, d = api("/api/auth/status?platform=qq")
assert s == 200
assert d.get("platform") == "qq"
run("auth status qq", t_qq_ok)
def t_bilibili_ok():
s, d = api("/api/auth/status?platform=bilibili")
assert s == 200
assert d.get("platform") == "bilibili"
run("auth status bilibili", t_bilibili_ok)
def t_youtube_routed():
# Regression: /auth/status?platform=youtube used to fall through
# to NetEase and leak the NetEase user's nickname/avatar.
s, d = api("/api/auth/status?platform=youtube")
assert s == 200
assert d.get("platform") == "youtube", (
f"youtube auth status leaked to {d.get('platform')}"
)
run("auth status youtube routes correctly", t_youtube_routed)
def t_youtube_cookie_rejected():
s, d = api(
"/api/auth/cookie",
method="POST",
json_body={"platform": "youtube", "cookie": "fake"},
)
assert s == 400, f"youtube cookie should be rejected, got {s}: {d}"
run("POST /auth/cookie rejects youtube", t_youtube_cookie_rejected)
def group_search():
print("\n== multi-platform search ==")
def search(platform: str, query: str = "test"):
return api(f"/api/music/search?q={query}&platform={platform}&limit=1")
def t_netease():
s, d = search("netease")
assert s == 200
assert isinstance(d.get("songs"), list)
run("netease search", t_netease)
def t_qq():
s, d = search("qq")
assert s == 200
# QQ may return 0 results if no cookie, but shouldn't error
assert isinstance(d.get("songs"), list)
run("qq search (empty ok)", t_qq)
def t_bilibili():
s, d = search("bilibili")
assert s == 200
assert isinstance(d.get("songs"), list)
run("bilibili search", t_bilibili)
def t_missing_query():
s, _ = api("/api/music/search?platform=netease")
assert s == 400, "missing q should 400"
run("400 on missing query", t_missing_query)
_, auth = api("/api/auth/status?platform=youtube")
youtube_available = isinstance(auth, dict) and auth.get("loggedIn") is True
if youtube_available:
def t_youtube():
s, d = search("youtube", "lofi")
assert s == 200
songs = d.get("songs", [])
assert len(songs) >= 1, "expected at least 1 YouTube result"
assert songs[0]["platform"] == "youtube"
run("youtube search (yt-dlp installed)", t_youtube)
else:
skip("youtube search", "yt-dlp not installed")
def group_lifecycle(bot_id: str):
print("\n== connection lifecycle ==")
def t_stop_from_any_state():
api(f"/api/bot/{bot_id}/stop", method="POST")
assert wait_connected(bot_id, False), "bot did not stop"
b = get_bot(bot_id)
assert not b["playing"], "playing should be false after stop"
run("stop from any state \u2192 disconnected+idle", t_stop_from_any_state)
def t_start_completes_quickly():
t0 = time.time()
s, d = api(f"/api/bot/{bot_id}/start", method="POST")
elapsed = time.time() - t0
assert s == 200, f"start failed: {d}"
assert elapsed < 10, f"start took {elapsed:.1f}s (expected <10s)"
assert wait_connected(bot_id, True)
run("start completes well under 15s deadline", t_start_completes_quickly)
def t_identity_persists():
assert_started(bot_id)
_, cfg1 = api(f"/api/bot/{bot_id}/config")
id1 = cfg1["identity"]
assert id1, "identity empty after first start"
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
assert_started(bot_id)
_, cfg2 = api(f"/api/bot/{bot_id}/config")
assert cfg2["identity"] == id1, (
f"identity changed across restart: {id1} \u2192 {cfg2['identity']}"
)
run("identity preserved across stop/start", t_identity_persists)
def group_playback(bot_id: str):
print("\n== playback ==")
# Bring the bot online ONCE for the whole playback group, then only
# toggle player state (play/pause/stop) between tests. This keeps the
# TS3 reconnect count for this group at exactly 1.
assert_started(bot_id)
def t_play_song():
s, d = api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
assert s == 200, f"play failed: {d}"
time.sleep(1.2)
b = get_bot(bot_id)
assert b["playing"] is True, f"not playing after /play: {b}"
assert b["currentSong"] is not None
run("play netease song \u2192 playing=true", t_play_song)
def t_pause_resume():
# Previous test left a song playing
api(f"/api/player/{bot_id}/pause", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert b["paused"] is True, f"pause failed: {b}"
api(f"/api/player/{bot_id}/resume", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert b["paused"] is False and b["playing"] is True, f"resume failed: {b}"
run("pause \u2192 paused, resume \u2192 playing", t_pause_resume)
def t_volume_change():
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": 42},
)
assert s == 200
time.sleep(0.2)
b = get_bot(bot_id)
assert b["volume"] == 42, f"volume not applied: {b['volume']}"
run("volume change", t_volume_change)
def t_mode_cycle():
for m in ("seq", "loop", "random", "rloop"):
s, _ = api(
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": m}
)
assert s == 200
b = get_bot(bot_id)
assert b["playMode"] == m, f"mode {m} not applied: {b['playMode']}"
run("all four play modes apply", t_mode_cycle)
def t_queue_endpoint():
s, d = api(f"/api/player/{bot_id}/queue")
assert s == 200
assert isinstance(d.get("queue"), list)
assert "status" in d
run("GET /player/:id/queue returns queue+status", t_queue_endpoint)
def t_elapsed_endpoint():
s, d = api(f"/api/player/{bot_id}/elapsed")
assert s == 200
elapsed = d.get("elapsed")
assert isinstance(elapsed, (int, float)) and elapsed >= 0, (
f"elapsed should be non-negative number: {elapsed}"
)
run("GET /player/:id/elapsed returns finite number", t_elapsed_endpoint)
def t_add_autoplay_on_idle():
# This specific test needs an IDLE bot — stop first (but keep
# connected), then add and confirm auto-play.
api(f"/api/player/{bot_id}/stop", method="POST")
time.sleep(0.4)
b = get_bot(bot_id)
assert not b["playing"] and b["queueSize"] == 0, f"setup failed: {b}"
s, d = api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
assert s == 200
msg = d.get("message", "") if isinstance(d, dict) else ""
assert "Now playing" in msg, (
f"add on idle bot should auto-play, got: {msg!r}"
)
time.sleep(0.8)
b = get_bot(bot_id)
assert b["playing"] is True, f"not playing after add: {b}"
run("add on idle bot auto-plays", t_add_autoplay_on_idle)
# Leave the bot in a clean state for the next group
api(f"/api/player/{bot_id}/stop", method="POST")
def group_queue_ops(bot_id: str):
print("\n== queue operations ==")
assert_started(bot_id)
def t_clear():
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(0.8)
api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "lemon tree", "platform": "netease"},
)
time.sleep(0.6)
b_before = get_bot(bot_id)
assert b_before["queueSize"] >= 2, f"expected \u22652 songs: {b_before}"
api(f"/api/player/{bot_id}/clear", method="POST")
time.sleep(0.4)
b_after = get_bot(bot_id)
assert b_after["queueSize"] == 0
run("clear queue empties it", t_clear)
def t_play_at_invalid_preserves_playback():
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.2)
assert get_bot(bot_id)["playing"]
s, _ = api(
f"/api/player/{bot_id}/play-at",
method="POST",
json_body={"index": 9999},
)
assert s == 400, f"invalid index should 400, got {s}"
time.sleep(0.4)
b = get_bot(bot_id)
assert b["playing"], "invalid play-at killed the current song"
run("invalid play-at preserves current playback", t_play_at_invalid_preserves_playback)
def t_play_at_negative_rejected():
s, _ = api(
f"/api/player/{bot_id}/play-at",
method="POST",
json_body={"index": -1},
)
assert s == 400
run("play-at with negative index rejected", t_play_at_negative_rejected)
api(f"/api/player/{bot_id}/stop", method="POST")
def group_input_validation(bot_id: str):
print("\n== HTTP input validation ==")
def t_volume_out_of_range():
for bad in (150, -10, 1000, -1):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": bad},
)
assert s == 400, f"volume={bad} should 400, got {s}"
run("volume out-of-range rejected (400)", t_volume_out_of_range)
def t_volume_wrong_type():
for bad in ("50", None, [50], {"v": 50}):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": bad},
)
assert s == 400, f"volume={bad!r} should 400, got {s}"
run("volume wrong-type rejected (400)", t_volume_wrong_type)
def t_volume_missing():
s, _ = api(
f"/api/player/{bot_id}/volume", method="POST", json_body={}
)
assert s == 400
run("volume missing rejected (400)", t_volume_missing)
def t_volume_valid():
for good in (0, 1, 50, 100):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": good},
)
assert s == 200, f"volume={good} should succeed, got {s}"
b = get_bot(bot_id)
assert b["volume"] == good, f"volume not applied: {b['volume']}"
run("valid volumes apply", t_volume_valid)
def t_mode_invalid():
for bad in ("bogus", "", None, 1, "SEQ"):
s, _ = api(
f"/api/player/{bot_id}/mode",
method="POST",
json_body={"mode": bad},
)
assert s == 400, f"mode={bad!r} should 400, got {s}"
run("mode invalid rejected (400)", t_mode_invalid)
def t_mode_missing():
s, _ = api(f"/api/player/{bot_id}/mode", method="POST", json_body={})
assert s == 400
run("mode missing rejected (400)", t_mode_missing)
def group_disconnect_corners(bot_id: str):
print("\n== disconnected-bot corners ==")
def t_play_rejected():
assert stop_and_wait(bot_id)
s, d = api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "x", "platform": "netease"},
)
assert s >= 400
err = (d.get("error") or "") if isinstance(d, dict) else ""
assert "not connected" in err.lower(), f"expected 'not connected' error: {d}"
run("play rejected while disconnected", t_play_rejected)
def t_add_rejected():
s, _ = api(
f"/api/player/{bot_id}/add",
method="POST",
json_body={"query": "x", "platform": "netease"},
)
assert s >= 400
run("add rejected while disconnected", t_add_rejected)
def t_next_rejected():
s, _ = api(f"/api/player/{bot_id}/next", method="POST")
assert s >= 400
run("next rejected while disconnected", t_next_rejected)
def t_volume_allowed():
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": 60},
)
assert s == 200, "volume should work while disconnected"
run("volume allowed while disconnected", t_volume_allowed)
def t_mode_allowed():
s, _ = api(
f"/api/player/{bot_id}/mode", method="POST", json_body={"mode": "random"}
)
assert s == 200, "mode should work while disconnected"
run("mode allowed while disconnected", t_mode_allowed)
def t_clear_allowed():
s, _ = api(f"/api/player/{bot_id}/clear", method="POST")
assert s == 200, "clear should work while disconnected"
run("clear allowed while disconnected", t_clear_allowed)
def t_player_state_clean():
b = get_bot(bot_id)
assert not b["playing"] and not b["paused"], f"state leak: {b}"
run("no player state leak while disconnected", t_player_state_clean)
def group_seek(bot_id: str):
print("\n== seek validation ==")
def t_negative():
s, _ = api(
f"/api/player/{bot_id}/seek", method="POST", json_body={"position": -5}
)
assert s == 400
run("negative seek rejected", t_negative)
def t_string():
s, _ = api(
f"/api/player/{bot_id}/seek",
method="POST",
json_body={"position": "abc"},
)
assert s == 400
run("string seek rejected", t_string)
def t_nan_literal():
r = requests.post(
f"{BASE}/api/player/{bot_id}/seek",
data='{"position": NaN}',
headers={"Content-Type": "application/json"},
timeout=10,
)
assert r.status_code >= 400, f"NaN literal accepted: {r.status_code}"
run("NaN literal seek rejected", t_nan_literal)
def t_valid_seek():
# Seek needs a live connection + playing song. The disconnect-
# corners group right before this one left the bot disconnected.
assert_started(bot_id)
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.5)
s, _ = api(
f"/api/player/{bot_id}/seek",
method="POST",
json_body={"position": 25},
)
assert s == 200
time.sleep(0.5)
_, d = api(f"/api/player/{bot_id}/elapsed")
elapsed = d.get("elapsed")
assert isinstance(elapsed, (int, float)) and 24 <= elapsed < 40, (
f"elapsed after seek(25) wrong: {elapsed}"
)
api(f"/api/player/{bot_id}/stop", method="POST")
run("valid seek produces finite elapsed", t_valid_seek)
def group_races(bot_id: str):
print("\n== race conditions ==")
def t_disconnect_during_connect():
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
def delayed_stop():
time.sleep(0.2)
api(f"/api/bot/{bot_id}/stop", method="POST")
threading.Thread(target=delayed_stop, daemon=True).start()
api(f"/api/bot/{bot_id}/start", method="POST")
time.sleep(2)
b = get_bot(bot_id)
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: {b}"
)
run("disconnect during connect", t_disconnect_during_connect)
def t_stop_during_url_resolve():
assert stop_and_wait(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
assert_started(bot_id)
def delayed_stop():
time.sleep(0.15)
api(f"/api/bot/{bot_id}/stop", method="POST")
threading.Thread(target=delayed_stop, daemon=True).start()
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(3)
b = get_bot(bot_id)
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state: {b}"
)
run("stop during URL resolve", t_stop_during_url_resolve)
def t_rapid_volume_change():
# Volume is a config-only command and works while disconnected,
# so this test deliberately doesn't call assert_started — we're
# validating the API's last-write-wins behavior, not the TS
# transport. That also spares the TS3 anti-flood budget.
for v in (10, 25, 50, 75, 100, 1):
s, _ = api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": v},
)
assert s == 200, f"volume POST failed: {s}"
time.sleep(0.3)
b = get_bot(bot_id)
assert b["volume"] == 1, f"final volume wrong: {b['volume']}"
run("rapid volume changes converge", t_rapid_volume_change)
def group_websocket(bot_id: str):
print("\n== websocket broadcasts ==")
try:
from websocket import create_connection
except Exception as e:
skip("websocket state broadcasts", f"websocket lib unavailable: {e}")
return
try:
ws = create_connection("ws://localhost:3000/ws", timeout=5)
except Exception as e:
skip("websocket state broadcasts", f"connect failed: {e}")
return
ws.settimeout(0.3)
messages: list[dict] = []
stop_reader = threading.Event()
def reader():
while not stop_reader.is_set():
try:
raw = ws.recv()
if not raw:
break
try:
messages.append(json.loads(raw))
except Exception:
pass
except Exception:
# recv() timeout or closed — keep trying until stop_reader
if stop_reader.is_set():
break
continue
reader_thread = threading.Thread(target=reader, daemon=True)
reader_thread.start()
try:
def t_init():
time.sleep(0.6)
types = [m.get("type") for m in messages]
assert "init" in types, f"no init message; got: {types}"
run("init message on connect", t_init)
def t_state_change_on_play():
assert_started(bot_id)
messages.clear()
api(
f"/api/player/{bot_id}/play",
method="POST",
json_body={"query": "the mass", "platform": "netease"},
)
time.sleep(1.5)
types = [m.get("type") for m in messages]
assert "stateChange" in types, (
f"no stateChange after play; got types: {types}"
)
api(f"/api/player/{bot_id}/stop", method="POST")
run("stateChange broadcast on play", t_state_change_on_play)
def t_bot_disconnected_event():
assert_started(bot_id)
messages.clear()
api(f"/api/bot/{bot_id}/stop", method="POST")
time.sleep(1.5)
types = [m.get("type") for m in messages]
assert "botDisconnected" in types or "stateChange" in types, (
f"no disconnect event; got: {types}"
)
run("botDisconnected event on stop", t_bot_disconnected_event)
finally:
stop_reader.set()
try:
ws.close()
except Exception:
pass
# ----------------------------- main ----------------------------------------
def main() -> int:
_, data = api("/api/bot/")
if not isinstance(data, dict) or not data.get("bots"):
print("[fatal] no bots registered — create one via the WebUI first")
return 2
target = data["bots"][0]
bot_id = target["id"]
initial_connected = target["connected"]
initial_volume = target["volume"]
initial_mode = target["playMode"]
print(f"[init] target bot = {bot_id[:8]} ({target['name']})")
print(
f"[init] initial state: connected={initial_connected} "
f"volume={initial_volume} mode={initial_mode}"
)
try:
# Read-only / no-lifecycle groups first — they don't consume TS3
# anti-flood budget.
group_infrastructure(bot_id)
group_auth_status()
group_search()
# Lifecycle-heavy groups — interleave with small breathers so the
# TS3 server's per-IP reconnect limit doesn't start throttling us.
group_lifecycle(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_playback(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_queue_ops(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_input_validation(bot_id)
group_disconnect_corners(bot_id)
group_seek(bot_id)
time.sleep(ANTIFLOOD_BREATHER)
group_races(bot_id)
# Extra breather before websocket group — races is the heaviest
# consumer of TS3 reconnect budget (disconnect-during-connect and
# stop-during-url-resolve each burn one cycle), and the websocket
# group needs a clean reconnect to observe live state broadcasts.
time.sleep(ANTIFLOOD_BREATHER * 3)
group_websocket(bot_id)
finally:
# Restore initial state — this runs even if a test raised
try:
api(
f"/api/player/{bot_id}/volume",
method="POST",
json_body={"volume": initial_volume},
)
api(
f"/api/player/{bot_id}/mode",
method="POST",
json_body={"mode": initial_mode},
)
api(f"/api/player/{bot_id}/stop", method="POST")
if initial_connected:
start_and_wait(bot_id)
else:
stop_and_wait(bot_id)
except Exception as e:
print(f"[warn] restore failed: {e}")
print()
print("=" * 60)
print(f" PASSED: {passed}")
print(f" FAILED: {failed}")
print(f" SKIPPED: {skipped}")
print("=" * 60)
if failed > 0:
print("\nFailed tests:")
for r in results:
if r.status in ("FAIL", "ERROR"):
print(f" [{r.status}] {r.name}: {r.detail}")
return 0 if failed == 0 else 1
if __name__ == "__main__":
import sys
sys.exit(main())
+106
View File
@@ -0,0 +1,106 @@
// Empirically verifies the PowerShell-download workaround for jdymusic CDN
// blocks Node.js HTTP. Runs A/B against the same fresh /jdymusic/ URL:
// A) ffmpeg direct with browser UA (the previous fix in this branch)
// B) PowerShell WebClient -> temp file -> ffmpeg from file (the new fix)
// Reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { mkdtempSync, statSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_jdymusic_powershell.mjs <jdymusic_url>");
process.exit(2);
}
if (!url.includes("/jdymusic/")) {
console.error("warning: this script targets /jdymusic/ URLs specifically");
}
const FFMPEG = "ffmpeg";
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const TIMEOUT_MS = 20_000;
function runFfmpeg(label, args, stdinSource) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: [stdinSource ?? "ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => { bytes += chunk.length; });
proc.stderr.on("data", (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-1500); });
const timer = setTimeout(() => { killed = true; proc.kill("SIGTERM"); }, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
function downloadViaPowerShell(targetUrl, outFile) {
return new Promise((resolve) => {
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: targetUrl,
DL_OUT: outFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
let stderr = "";
ps.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
ps.on("exit", (code) => resolve({ code, stderr }));
});
}
console.log(`URL: ${url}\n`);
console.log("[A] ffmpeg direct (browser UA via -headers)");
const a = await runFfmpeg("A", buildFfmpegArgs(url, 0));
console.log(` code=${a.code} bytes=${a.bytes} killed=${a.killed}`);
console.log(` stderr-tail: ${a.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
console.log("[B] PowerShell WebClient -> temp file -> ffmpeg -i tempfile");
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-test-"));
const tempFile = join(tempDir, "song.audio");
const psStart = Date.now();
const dl = await downloadViaPowerShell(url, tempFile);
const psMs = Date.now() - psStart;
if (dl.code !== 0) {
console.log(` PowerShell download FAILED: code=${dl.code}`);
console.log(` stderr: ${dl.stderr.slice(-500)}`);
rmSync(tempDir, { recursive: true, force: true });
process.exit(1);
}
const dlSize = statSync(tempFile).size;
console.log(` PowerShell downloaded ${dlSize} bytes in ${psMs}ms`);
const b = await runFfmpeg("B", buildFfmpegArgs(tempFile, 0));
console.log(` ffmpeg-from-file: code=${b.code} bytes=${b.bytes} killed=${b.killed}`);
console.log(` stderr-tail: ${b.stderrTail.split("\n").slice(-3).join(" | ")}\n`);
rmSync(tempDir, { recursive: true, force: true });
const aBlocked = a.bytes === 0 && !a.killed;
const bWorked = b.bytes > 100_000;
console.log(
`Verdict: direct ${aBlocked ? "BLOCKED" : "OK"} ; ` +
`powershell-then-ffmpeg ${bWorked ? "WORKED" : "FAILED"}`,
);
+166
View File
@@ -0,0 +1,166 @@
"""More corner-case regressions.
A. resolveAndPlay disconnect-during-URL-resolve race
The bot checks !this.connected at the top of resolveAndPlay, but the
URL-resolve await can take several seconds. If stop is called during
that window, playback would previously start on a disconnected bot.
B. /seek NaN/Infinity rejection
typeof NaN === "number" and NaN < 0 is false, so a plain range check
leaks NaN through and corrupts seekOffset / getElapsed.
"""
import threading
import time
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.15)
return False
def test_resolve_play_stop_race(bot_id):
"""Fire stopBot during the /play call's URL resolve window."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
time.sleep(1)
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
# Schedule a stop 150ms into the play call — that lands inside the
# provider.getSongUrl await, which is where the race lives.
def delayed_stop():
time.sleep(0.15)
try:
api(f"/api/bot/{bot_id}/stop", method="POST")
except Exception:
pass
threading.Thread(target=delayed_stop, daemon=True).start()
try:
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
except Exception:
pass
# Give both calls time to settle fully
time.sleep(3)
b = get_bot(bot_id)
# Key invariant: we never want connected=false AND playing=true. That
# pair is the exact Bug C symptom and would indicate the resolveAndPlay
# post-await check didn't fire.
assert not (b["connected"] is False and b["playing"] is True), (
f"inconsistent state after race: {b}"
)
print(
f"[PASS] resolveAndPlay stop-race — final state consistent "
f"(connected={b['connected']} playing={b['playing']})"
)
def test_seek_nan_rejected(bot_id):
"""Verify that NaN and Infinity seek positions are rejected at the API
layer (instead of poisoning seekOffset)."""
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
# Start a real song so there is an active playback to seek against
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
time.sleep(1.2)
# JSON spec doesn't allow NaN/Infinity literals, but Python's json
# encoder emits them as bare tokens when allow_nan=True (the default).
# Express's body-parser rejects them as invalid JSON, which itself is
# a form of rejection. We additionally verify that sending a string
# "NaN" or a negative value is also rejected with a clean 400.
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": -5},
)
assert r.status_code == 400, f"negative seek should be rejected, got {r.status_code}"
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": "fifty"},
)
assert r.status_code == 400, f"string seek should be rejected, got {r.status_code}"
# Directly send NaN in raw body (body-parser will likely 400 it)
r = requests.post(
f"{BASE}/api/player/{bot_id}/seek",
data='{"position": NaN}',
headers={"Content-Type": "application/json"},
timeout=10,
)
assert r.status_code >= 400, f"NaN seek should be rejected, got {r.status_code}"
# After the junk attempts, a valid seek still works and the elapsed
# time is a finite number (not NaN).
r = api(
f"/api/player/{bot_id}/seek",
method="POST",
json={"position": 30},
)
assert r.status_code == 200, f"valid seek failed: {r.text[:120]}"
elapsed_resp = api(f"/api/player/{bot_id}/elapsed")
elapsed = elapsed_resp.json().get("elapsed")
assert elapsed is not None and isinstance(elapsed, (int, float)), (
f"elapsed should be a number, got {elapsed}"
)
# Could be exactly 30 or a tiny bit more if a frame has advanced
assert 29 <= elapsed < 40, f"elapsed after seek(30) out of range: {elapsed}"
print(f"[PASS] seek NaN/Infinity rejected; valid seek produces finite elapsed={elapsed:.2f}")
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial}")
try:
test_resolve_play_stop_race(bot_id)
test_seek_nan_rejected(bot_id)
print("ALL GREEN")
finally:
if initial:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] connected={get_bot(bot_id)['connected']}")
if __name__ == "__main__":
main()
+212
View File
@@ -0,0 +1,212 @@
"""Stress-test two bots playing music concurrently on the same TS server.
Creates two temporary bots (or reuses existing named ones), starts them,
plays music on both, and polls /api/bot/ every 2 seconds to detect when
(if) either bot disconnects or stops playing. Cleans up on exit.
Usage:
python scripts/test_multibot.py --minutes 3
python scripts/test_multibot.py --minutes 10 --host 127.0.0.1 --port 9987
"""
import argparse
import sys
import time
from dataclasses import dataclass
import requests
API = "http://localhost:3000"
POLL_INTERVAL = 2.0
TEST_BOT_NAMES = ("mbtest1", "mbtest2")
TEST_BOT_NICKS = ("MBTest1", "MBTest2")
QUERIES = ("the mass", "lofi") # one different song per bot
@dataclass
class BotSnapshot:
t: float
connected: bool
playing: bool
song: str | None
def api_get(path: str):
r = requests.get(f"{API}{path}", timeout=5)
r.raise_for_status()
return r.json()
def api_post(path: str, json=None):
r = requests.post(f"{API}{path}", json=json, timeout=15)
r.raise_for_status()
return r.json()
def api_delete(path: str):
r = requests.delete(f"{API}{path}", timeout=10)
r.raise_for_status()
return r.json()
def cleanup_existing(names: tuple[str, ...]) -> None:
bots = api_get("/api/bot/")["bots"]
for b in bots:
if b["name"] in names:
try:
api_post(f"/api/player/{b['id']}/stop")
except Exception:
pass
try:
api_delete(f"/api/bot/{b['id']}")
print(f"[cleanup] removed existing bot {b['name']} ({b['id']})")
except Exception as e:
print(f"[cleanup] failed to remove {b['name']}: {e}")
def create_bot(name: str, nickname: str, host: str, port: int) -> str:
res = api_post(
"/api/bot/",
json={
"name": name,
"serverAddress": host,
"serverPort": port,
"nickname": nickname,
"autoStart": False,
},
)
bot_id = res["id"]
print(f"[create] {name} -> {bot_id}")
return bot_id
def start_bot(bot_id: str) -> None:
api_post(f"/api/bot/{bot_id}/start")
def play(bot_id: str, query: str) -> None:
api_post(f"/api/player/{bot_id}/play", json={"query": query, "platform": "netease"})
def snapshot(bot_id: str, t0: float) -> BotSnapshot:
bots = api_get("/api/bot/")["bots"]
b = next((x for x in bots if x["id"] == bot_id), None)
if not b:
return BotSnapshot(time.time() - t0, False, False, None)
song = b["currentSong"]["name"] if b.get("currentSong") else None
return BotSnapshot(time.time() - t0, b["connected"], b["playing"], song)
def run(minutes: float, host: str, port: int) -> int:
print(f"[setup] duration={minutes}min host={host}:{port}")
cleanup_existing(TEST_BOT_NAMES)
bot_ids = [
create_bot(TEST_BOT_NAMES[0], TEST_BOT_NICKS[0], host, port),
create_bot(TEST_BOT_NAMES[1], TEST_BOT_NICKS[1], host, port),
]
# Start both, allowing a small stagger to avoid handshake collision
for i, bid in enumerate(bot_ids):
start_bot(bid)
print(f"[start] bot{i+1} started")
time.sleep(1.5)
# Wait until both are connected (or bail after 15s)
deadline = time.time() + 15
while time.time() < deadline:
bots = {b["id"]: b for b in api_get("/api/bot/")["bots"]}
if all(bots[b]["connected"] for b in bot_ids):
print("[start] both bots connected")
break
time.sleep(0.5)
else:
print("[fatal] bots did not both come online in 15s")
cleanup_existing(TEST_BOT_NAMES)
return 2
# Kick off playback on both
for i, bid in enumerate(bot_ids):
play(bid, QUERIES[i])
print(f"[play] bot{i+1} -> {QUERIES[i]!r}")
t0 = time.time()
end = t0 + minutes * 60
first_drop: dict[str, float] = {}
last_state: dict[str, BotSnapshot] = {}
print(f"[monitor] polling every {POLL_INTERVAL}s for {minutes} min...")
print(f"{'time':>7} {'bot1':<40} {'bot2':<40}")
def fmt(snap: BotSnapshot) -> str:
flag = ("C" if snap.connected else "-") + ("P" if snap.playing else "-")
song = (snap.song or "").replace("\n", " ")[:30]
return f"{flag} {song}"
try:
while time.time() < end:
snaps = [snapshot(bid, t0) for bid in bot_ids]
elapsed = int(time.time() - t0)
row = f"{elapsed:>6}s {fmt(snaps[0]):<40} {fmt(snaps[1]):<40}"
# Only print when state changes or every 10s
changed = False
for bid, s in zip(bot_ids, snaps):
prev = last_state.get(bid)
if (prev is None
or prev.connected != s.connected
or prev.playing != s.playing
or prev.song != s.song):
changed = True
last_state[bid] = s
if not s.connected and bid not in first_drop:
first_drop[bid] = s.t
if changed or elapsed % 10 == 0:
print(row)
# If both stopped playing but are still connected, re-queue the same song
for i, (bid, s) in enumerate(zip(bot_ids, snaps)):
if s.connected and not s.playing:
try:
play(bid, QUERIES[i])
except Exception as e:
print(f"[warn] re-play bot{i+1} failed: {e}")
time.sleep(POLL_INTERVAL)
except KeyboardInterrupt:
print("\n[abort] interrupted")
# Summary
total = time.time() - t0
print()
print("=" * 60)
print(f"Total observed time: {total:.1f}s")
for i, bid in enumerate(bot_ids):
drop = first_drop.get(bid)
if drop is None:
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): stayed connected the whole run")
else:
print(f" bot{i+1} ({TEST_BOT_NICKS[i]}): FIRST DISCONNECT at t+{drop:.1f}s")
print("=" * 60)
# Cleanup
cleanup_existing(TEST_BOT_NAMES)
print("[cleanup] done")
return 0 if not first_drop else 1
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--minutes", type=float, default=3.0)
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=9987)
args = p.parse_args()
try:
return run(args.minutes, args.host, args.port)
except requests.HTTPError as e:
print(f"[http-error] {e} body={e.response.text[:200] if e.response else ''}")
return 3
if __name__ == "__main__":
sys.exit(main())
+73
View File
@@ -0,0 +1,73 @@
// Empirically tests whether the browser UA + Referer headers fix the
// connection resets we saw in bot.log against m701/m801.music.126.net.
//
// Spawns ffmpeg twice against the SAME fresh Netease CDN URL:
// A) old args from before the fix (no headers, -reconnect_delay_max 5)
// B) new args from after the fix (browser UA + Referer for music.126.net)
// and reports bytes received + exit code + stderr-tail for each.
import { spawn } from "node:child_process";
import { buildFfmpegArgs } from "../dist/audio/player.js";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/test_netease_ua_fix.mjs <netease_cdn_url>");
process.exit(2);
}
const FFMPEG = "ffmpeg";
const TIMEOUT_MS = 15_000;
function legacyArgs(u) {
return [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
"-i", u,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
];
}
function runFfmpeg(label, args) {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, args, { stdio: ["ignore", "pipe", "pipe"] });
let bytes = 0;
let stderrTail = "";
let killed = false;
proc.stdout.on("data", (chunk) => {
bytes += chunk.length;
});
proc.stderr.on("data", (chunk) => {
stderrTail = (stderrTail + chunk.toString()).slice(-1500);
});
const timer = setTimeout(() => {
killed = true;
proc.kill("SIGTERM");
}, TIMEOUT_MS);
proc.on("exit", (code, signal) => {
clearTimeout(timer);
resolve({ label, bytes, code, signal, killed, stderrTail });
});
});
}
console.log(`URL: ${url}\n`);
const a = await runFfmpeg("A) legacy args (no UA)", legacyArgs(url));
console.log(`[A] code=${a.code} signal=${a.signal} killed=${a.killed} bytes=${a.bytes}`);
console.log(` stderr-tail:\n${a.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const b = await runFfmpeg("B) fixed args (browser UA + Referer)", buildFfmpegArgs(url, 0));
console.log(`[B] code=${b.code} signal=${b.signal} killed=${b.killed} bytes=${b.bytes}`);
console.log(` stderr-tail:\n${b.stderrTail.split("\n").slice(-6).map((l) => " " + l).join("\n")}\n`);
const aFailed = a.bytes === 0 && !a.killed && a.code !== 0;
const bWorked = b.bytes > 100_000; // got real audio bytes
console.log(`Verdict: legacy ${aFailed ? "FAILED (no bytes, exit code 1)" : "??"} ; fixed ${bWorked ? "WORKED (received audio)" : "??"}`);
+119
View File
@@ -0,0 +1,119 @@
"""Reproduce / regression-check the player-bar-not-appearing bug.
Captures the bot's initial playback state and restores it on exit so the
test never leaves the user with surprise music or a cleared queue.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
fn = getattr(requests, method.lower())
r = fn(f"{BASE}{path}", timeout=10, **kw)
r.raise_for_status()
return r.json() if r.text else None
def get_bot(bot_id):
return next(b for b in api("/api/bot/")["bots"] if b["id"] == bot_id)
def capture_state(bot_id):
b = get_bot(bot_id)
return {
"playing": b["playing"],
"paused": b["paused"],
"song": (b["currentSong"] or {}).get("name"),
}
def main():
bots = api("/api/bot/")["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial = capture_state(bot_id)
print(f"[init] initial state: {initial}")
try:
# Clear slate
api(f"/api/player/{bot_id}/stop", method="POST")
time.sleep(0.6)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
ctx = browser.new_context()
ctx.add_init_script(
"""
(() => {
const OrigWS = window.WebSocket;
window.__wsMessages = [];
window.WebSocket = function(...args) {
const ws = new OrigWS(...args);
ws.addEventListener('message', (ev) => {
try {
const d = JSON.parse(ev.data);
window.__wsMessages.push({type: d.type, botId: d.botId});
} catch(e) {}
});
return ws;
};
Object.assign(window.WebSocket, OrigWS);
})();
"""
)
page = ctx.new_page()
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.8)
assert page.locator(".player-wrapper").count() == 0, (
"player bar should be hidden before playback"
)
# Trigger play via API (simulates any play trigger)
api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
# Poll for up to 6s to see if player bar appears automatically
appeared_at = None
for i in range(60):
if page.locator(".player-wrapper").count() > 0:
appeared_at = i * 0.1
break
page.wait_for_timeout(100)
if appeared_at is None:
msgs = page.evaluate("() => window.__wsMessages")
print(f"[FAIL] player bar never appeared; WS msgs: {msgs}")
raise AssertionError("player bar did not auto-show on stateChange")
print(f"[PASS] player bar appeared after {appeared_at:.1f}s")
finally:
browser.close()
finally:
# Restore: stop the "test" song we triggered, then re-apply initial
# state as best we can. We can't re-queue the user's previous song,
# but we can at least stop ours and leave the bot idle if it was idle.
try:
api(f"/api/player/{bot_id}/stop", method="POST")
except Exception as e:
print(f"[warn] failed to stop test song on cleanup: {e}")
post = capture_state(bot_id)
print(f"[restore] bot now idle (was playing={initial['playing']} song={initial['song']!r})")
if initial["playing"] and initial["song"]:
print(
f"[note] initial bot was playing {initial['song']!r}; "
"this test cannot resume arbitrary tracks — you may need to restart playback"
)
if __name__ == "__main__":
main()
+100
View File
@@ -0,0 +1,100 @@
"""E2E: the new power button in the Bot Selector dropdown toggles bot connected state.
Captures the target bot's initial connected state and restores it on exit
(including on assertion failure), so running this test never pollutes the
user's current bot setup.
"""
import time
import requests
from playwright.sync_api import sync_playwright
BASE = "http://localhost:3000"
def get_bot(bot_id):
return next(b for b in requests.get(f"{BASE}/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_for_connected(bot_id, want: bool, timeout_s: float = 12.0) -> bool:
deadline = time.time() + timeout_s
while time.time() < deadline:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.2)
return False
def set_connected(bot_id, want: bool) -> None:
"""Force the bot into the given connected state via API."""
current = get_bot(bot_id)["connected"]
if current == want:
return
endpoint = "start" if want else "stop"
requests.post(f"{BASE}/api/bot/{bot_id}/{endpoint}")
wait_for_connected(bot_id, want)
def main():
bots = requests.get(f"{BASE}/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots registered, nothing to test")
return
target = bots[0]
bot_id = target["id"]
initial_connected = target["connected"]
print(f"[init] target bot {target['name']} ({bot_id[:8]}), initial connected={initial_connected}")
try:
# Force bot disconnected before the test
set_connected(bot_id, False)
assert not get_bot(bot_id)["connected"], "bot should be disconnected at start"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1440, "height": 900})
page.goto(BASE)
page.wait_for_load_state("networkidle")
time.sleep(0.6)
# Open dropdown
page.locator(".bot-selector-btn").click()
page.wait_for_selector(".bot-power-btn")
# Click the power button to start
page.locator(".bot-power-btn").first.click()
print("[ui] clicked power (start)")
assert wait_for_connected(bot_id, True), "bot should be connected after clicking start"
print("[api] bot connected = True")
# Let UI catch up via WS then re-open the dropdown to re-check class
time.sleep(1.0)
page.locator(".bot-selector-btn").click() # close
time.sleep(0.2)
page.locator(".bot-selector-btn").click() # reopen
page.wait_for_selector(".bot-power-btn.online", timeout=3000)
print("[ui] power button now shows .online class")
# Click again to stop
page.locator(".bot-power-btn.online").first.click()
print("[ui] clicked power (stop)")
assert wait_for_connected(bot_id, False), "bot should be disconnected after clicking stop"
print("[api] bot connected = False")
print("[PASS] power button toggles bot connection")
finally:
browser.close()
finally:
# Always restore the initial state so the test never leaves the bot
# in an unexpected place
set_connected(bot_id, initial_connected)
final = get_bot(bot_id)["connected"]
print(f"[restore] bot connected={final} (initial was {initial_connected})")
if final != initial_connected:
print("[warn] failed to restore initial connected state")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""Regression for the 'connected=False but playing=True' stuck-state bug.
After rapid disconnect/reconnect, the library could drop the connection
(TS3 server anti-flood or a hung handshake). The bot then ended up in an
inconsistent state: player.state='playing' but tsClient disconnected.
This test verifies three fixes:
Bug A — startBot() has a 15s timeout instead of hanging forever on a
stalled handshake. /start returns a clean 500 instead of blocking.
Bug B — play/add/etc commands are rejected when the bot is not connected.
Bug C — the tsClient 'disconnected' handler always clears player state,
even when connect() never completed (so !this.connected).
We also sanity-check that the bot recovers (can start a fresh cycle) after
a transient failure.
"""
import time
import requests
BASE = "http://localhost:3000"
def api(path, method="GET", **kw):
return getattr(requests, method.lower())(f"{BASE}{path}", timeout=30, **kw)
def get_bot(bot_id):
return next(b for b in api("/api/bot/").json()["bots"] if b["id"] == bot_id)
def wait_connected(bot_id, want, timeout=15):
end = time.time() + timeout
while time.time() < end:
if get_bot(bot_id)["connected"] is want:
return True
time.sleep(0.15)
return False
def main():
bots = api("/api/bot/").json()["bots"]
if not bots:
print("[skip] no bots")
return
bot_id = bots[0]["id"]
initial_connected = bots[0]["connected"]
print(f"[init] bot={bot_id[:8]} initial connected={initial_connected}")
try:
# Start from a clean slate
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# --- Bug B: play while disconnected must be rejected ---
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "rejection test", "platform": "netease"},
)
assert r.status_code >= 400, (
f"play while disconnected should fail, got {r.status_code} {r.text[:120]}"
)
assert "not connected" in r.text.lower(), (
f"expected 'not connected' error, got: {r.text[:200]}"
)
b = get_bot(bot_id)
assert not b["playing"], f"player shouldn't be playing after rejected /play: {b}"
print("[PASS] Bug B — /play rejected while bot disconnected; state untouched")
# --- Bug C: normal start→play→stop leaves player state clean ---
r = api(f"/api/bot/{bot_id}/start", method="POST")
assert r.status_code == 200, f"start failed {r.text[:120]}"
assert wait_connected(bot_id, True), "bot did not connect within 15s"
r = api(
f"/api/player/{bot_id}/play",
method="POST",
json={"query": "the mass", "platform": "netease"},
)
assert r.status_code == 200, f"play failed {r.text[:120]}"
time.sleep(1.2)
b = get_bot(bot_id)
assert b["connected"] and b["playing"], f"should be connected+playing: {b}"
api(f"/api/bot/{bot_id}/stop", method="POST")
assert wait_connected(bot_id, False, timeout=5), "bot did not disconnect"
b = get_bot(bot_id)
assert not b["playing"], (
f"player should have stopped after bot disconnect (Bug C): {b}"
)
print("[PASS] Bug C — stop clears both connected and playing state")
# --- Bug A: startBot has a deadline and returns a clean error if connect hangs ---
# We can't easily force a hang in-process, but we can sanity-check that
# startBot returns promptly (well under the 15s cap) on a normal run.
t0 = time.time()
r = api(f"/api/bot/{bot_id}/start", method="POST")
elapsed = time.time() - t0
assert r.status_code == 200, f"start failed {r.text[:120]}"
assert elapsed < 10, f"start should be prompt, took {elapsed:.1f}s"
assert wait_connected(bot_id, True), "bot did not connect"
print(
f"[PASS] Bug A — startBot completed in {elapsed:.2f}s "
"(deadline is 15s, would throw on hang)"
)
# --- Recovery: after any failure, another start should work ---
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
# Give TS3 server a moment to forget us (anti-flood grace)
time.sleep(2)
r = api(f"/api/bot/{bot_id}/start", method="POST")
assert r.status_code == 200, f"recovery start failed {r.text[:120]}"
assert wait_connected(bot_id, True), "bot did not recover"
print("[PASS] recovery — bot reconnects cleanly after a cycle")
print("ALL GREEN")
finally:
if initial_connected:
api(f"/api/bot/{bot_id}/start", method="POST")
wait_connected(bot_id, True)
else:
api(f"/api/bot/{bot_id}/stop", method="POST")
wait_connected(bot_id, False)
print(f"[restore] bot connected={get_bot(bot_id)['connected']} (was {initial_connected})")
if __name__ == "__main__":
main()
+668
View File
@@ -0,0 +1,668 @@
import { describe, it, expect, vi } from "vitest";
import { mkdtempSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import { buildFfmpegArgs, shouldUsePowerShellDownload, cleanupTempDir, shouldEndOnStall, volumeToFactor, AudioPlayer } from "./player.js";
import type { Logger } from "../logger.js";
function getHeadersArg(args: string[]): string {
const idx = args.indexOf("-headers");
if (idx === -1) return "";
return args[idx + 1] ?? "";
}
describe("buildFfmpegArgs", () => {
it("includes browser User-Agent and Referer for Netease CDN URLs", () => {
const url = "http://m701.music.126.net/some/path/song.mp3?vuutv=abc";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("User-Agent:");
expect(headers).toContain("Mozilla/5.0");
expect(headers).toContain("Referer: https://music.163.com/");
});
it("keeps Bilibili Referer + UA for bilibili URLs", () => {
const url = "https://upos-sz-mirrorcoso1.bilivideo.com/foo/bar.mp3";
const args = buildFfmpegArgs(url, 0);
const headers = getHeadersArg(args);
expect(headers).toContain("Referer: https://www.bilibili.com");
expect(headers).toContain("User-Agent: Mozilla/5.0");
});
it("does not set custom headers for unknown URLs", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
expect(args).not.toContain("-headers");
});
it("includes resilient reconnect flags for all URLs", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).toContain("-reconnect");
expect(args).toContain("-reconnect_streamed");
expect(args).toContain("-reconnect_delay_max");
expect(args).toContain("-reconnect_on_network_error");
expect(args).toContain("-reconnect_on_http_error");
const idx = args.indexOf("-reconnect_delay_max");
expect(Number(args[idx + 1])).toBeGreaterThanOrEqual(30);
});
it("sets -reconnect_at_eof 1 (before -i) so long B站 streams resume after premature EOF (#89)", () => {
const args = buildFfmpegArgs("https://x.bilivideo.com/audio.m4s", 0);
const idx = args.indexOf("-reconnect_at_eof");
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe("1");
expect(idx).toBeLessThan(args.indexOf("-i")); // input options must precede -i
});
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).toBeGreaterThan(iIdx);
});
it("seeks B站 streams input-side (before -i) so a resume jumps via Range instead of re-downloading (#161)", () => {
const args = buildFfmpegArgs("https://upos-sz-mirrorcos.bilivideo.com/audio.m4s", 3600);
const ssIdx = args.indexOf("-ss");
expect(args[ssIdx + 1]).toBe("3600");
expect(ssIdx).toBeLessThan(args.indexOf("-i"));
expect(args.lastIndexOf("-ss")).toBe(ssIdx); // only one -ss
});
it("does not insert -ss when seekSeconds is 0", () => {
const args = buildFfmpegArgs("https://example.com/song.mp3", 0);
expect(args).not.toContain("-ss");
});
it("omits HTTP-only flags when input is a local file path", () => {
const args = buildFfmpegArgs("C:/temp/song.mp3", 0);
expect(args).not.toContain("-reconnect");
expect(args).not.toContain("-reconnect_at_eof");
expect(args).not.toContain("-reconnect_on_network_error");
expect(args).not.toContain("-reconnect_on_http_error");
expect(args).not.toContain("-headers");
expect(args).toContain("-i");
expect(args[args.indexOf("-i") + 1]).toBe("C:/temp/song.mp3");
});
it("ends args with the input URL and PCM output spec", () => {
const url = "https://example.com/song.mp3";
const args = buildFfmpegArgs(url, 0);
const iIdx = args.indexOf("-i");
expect(args[iIdx + 1]).toBe(url);
expect(args).toContain("-f");
expect(args).toContain("s16le");
expect(args[args.length - 1]).toBe("-");
});
});
describe("volumeToFactor (#84 smooth volume curve)", () => {
it("is 0 at vol 0 and exactly 1.0 at vol 100 (full loudness still reserved at 100)", () => {
expect(volumeToFactor(0)).toBe(0);
expect(volumeToFactor(100)).toBe(1);
});
it("clamps out-of-range input", () => {
expect(volumeToFactor(-20)).toBe(0);
expect(volumeToFactor(150)).toBe(1);
});
it("is strictly monotonic across the whole range (no dead zone)", () => {
for (let v = 0; v < 100; v++) {
expect(volumeToFactor(v + 1)).toBeGreaterThan(volumeToFactor(v));
}
});
it("removes the old flat 80-99 dead zone", () => {
// Old mapping moved only 0.16 -> 0.198 across 80..99; new curve climbs clearly.
expect(volumeToFactor(99) - volumeToFactor(80)).toBeGreaterThan(0.3);
});
it("removes the discontinuity at 100 (old jump was ~0.8)", () => {
expect(volumeToFactor(100) - volumeToFactor(99)).toBeLessThan(0.1);
});
it("keeps the low range gentle", () => {
expect(volumeToFactor(50)).toBeLessThan(0.12);
});
});
describe("shouldUsePowerShellDownload", () => {
const jdymusicUrl =
"http://m801.music.126.net/20260507/abc/jdymusic/obj/xyz/song.mp3?vuutv=tok";
const newCdnUrl =
"http://m801.music.126.net/20260507/abc/jd-musicrep-ts/obj/xyz/song.mp3?vuutv=tok";
const ymusicUrl =
"http://m801.music.126.net/20260507/abc/ymusic/obj/xyz/song.mp3?vuutv=tok";
it("returns true for /jdymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "win32")).toBe(true);
});
it("returns false for /jdymusic/ URL on linux", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "linux")).toBe(false);
});
it("returns false for /jdymusic/ URL on darwin", () => {
expect(shouldUsePowerShellDownload(jdymusicUrl, "darwin")).toBe(false);
});
it("returns false for new-format /jd-musicrep-ts/ URL on win32", () => {
expect(shouldUsePowerShellDownload(newCdnUrl, "win32")).toBe(false);
});
it("returns false for /ymusic/ URL on win32", () => {
expect(shouldUsePowerShellDownload(ymusicUrl, "win32")).toBe(false);
});
it("returns false for unrelated URLs", () => {
expect(shouldUsePowerShellDownload("https://example.com/x.mp3", "win32")).toBe(false);
});
});
describe("cleanupTempDir", () => {
it("removes a directory and its contents", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
writeFileSync(join(dir, "song.mp3"), "fake-bytes");
expect(existsSync(dir)).toBe(true);
cleanupTempDir(dir);
expect(existsSync(dir)).toBe(false);
});
it("does not throw when directory does not exist", () => {
const missing = join(tmpdir(), "tsbot-test-does-not-exist-xyz");
expect(() => cleanupTempDir(missing)).not.toThrow();
});
it("does not throw when called twice", () => {
const dir = mkdtempSync(join(tmpdir(), "tsbot-test-"));
cleanupTempDir(dir);
expect(() => cleanupTempDir(dir)).not.toThrow();
});
});
describe("shouldEndOnStall (#89 mid-track stall watchdog)", () => {
const MAX_EMPTY = 250; // ~5s near-end threshold
const MAX_STALL = 3000; // ~60s far-from-end watchdog
it("ends quickly near the end once the empty threshold is reached (normal EOF)", () => {
expect(shouldEndOnStall(MAX_EMPTY, true, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_EMPTY - 1, true, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("does NOT end far from the end at the near-end threshold (avoids false skips on transient underruns)", () => {
// This is the core regression: a brief underrun mid-song must not end the track.
expect(shouldEndOnStall(MAX_EMPTY, false, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(MAX_STALL - 1, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
it("eventually ends far from the end once the long stall watchdog trips (dead stream recovers)", () => {
// The pre-fix bug: far-from-end stalls grew unbounded and never ended -> permanent silence.
expect(shouldEndOnStall(MAX_STALL, false, MAX_EMPTY, MAX_STALL)).toBe(true);
expect(shouldEndOnStall(MAX_STALL + 500, false, MAX_EMPTY, MAX_STALL)).toBe(true);
});
it("never ends before any threshold", () => {
expect(shouldEndOnStall(0, true, MAX_EMPTY, MAX_STALL)).toBe(false);
expect(shouldEndOnStall(10, false, MAX_EMPTY, MAX_STALL)).toBe(false);
});
});
// Minimal stub: AudioPlayer only calls debug/info/warn/error; child() returns self.
const silentLogger = {
debug() {},
info() {},
warn() {},
error() {},
fatal() {},
trace() {},
child() {
return 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 {
return new Readable({ read() {} });
}
const wait = (ms: number): Promise<void> => new Promise<void>((r) => setTimeout(r, ms));
const FRAME_BYTES = 3840; // PCM_FRAME_BYTES: 960 samples * 2ch * 2 bytes @48k s16le
describe("AudioPlayer external-PCM mode (playPcmStream)", () => {
it("emits Opus 'frame' events from the external PCM stream without spawning ffmpeg", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 10)); // ~10 frames of PCM
await wait(150); // ~7 frame ticks at 20ms
expect(player.getState()).toBe("playing");
expect(frames.length).toBeGreaterThan(0);
expect(Buffer.isBuffer(frames[0])).toBe(true);
player.stop();
});
it("does NOT emit 'trackEnd' on underrun while external (stream stays open)", async () => {
const player = new AudioPlayer(silentLogger);
let ended = 0;
const frames: Buffer[] = [];
player.on("trackEnd", () => ended++);
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 2)); // only 2 frames, then underrun
await wait(200); // long after those 2 frames have drained
// In the url path, ffmpeg===null + empty buffer would fire trackEnd; here it must not.
expect(ended).toBe(0);
// Silence frames keep the 20ms timeline alive -> more than the 2 fed frames emitted.
expect(frames.length).toBeGreaterThan(2);
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C2 (c): stop() DETACHES the shared readable — it must NOT be destroyed
// (destroying the sidecar's long-lived ffmpeg stdout would kill it for every future
// track). The sessionId bump + listener removal fence stale PCM out of pcmBuffer.
it("stop() detaches external mode without destroying the readable, and fences via sessionId", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
expect(stream.listenerCount("data")).toBe(1);
stream.push(Buffer.alloc(FRAME_BYTES * 5));
await wait(80);
player.stop();
expect(player.getState()).toBe("idle");
// C2: the shared sidecar stream must NOT be destroyed by teardown.
expect(stream.destroyed).toBe(false);
// Player's listeners are removed on detach (data/end/error).
expect(stream.listenerCount("data")).toBe(0);
expect(stream.listenerCount("end")).toBe(0);
expect(stream.listenerCount("error")).toBe(0);
const countAtStop = frames.length;
// sessionId fence + detached listeners: PCM pushed after stop must not
// resurrect the timeline or re-feed pcmBuffer.
stream.push(Buffer.alloc(FRAME_BYTES * 5));
await wait(80);
expect(frames.length).toBe(countAtStop);
});
// CORRECTION C2 (a): a gapless track change is driven by the sidecar pushing LATER
// PCM over the SAME already-attached stream. The player must NOT detach/re-attach
// (no second playPcmStream) — one persistent data listener serves every track.
it("(C2-a) feeds a later chunk over the SAME single attachment — gapless track change, no re-attach", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const stream = openPcmReadable();
player.playPcmStream(stream, {});
expect(stream.listenerCount("data")).toBe(1); // attached exactly once
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // "track 1" PCM
await wait(120);
const afterFirst = frames.length;
expect(afterFirst).toBeGreaterThan(0);
stream.push(Buffer.alloc(FRAME_BYTES * 4)); // sidecar seamlessly rolls into "track 2"
await wait(120);
expect(frames.length).toBeGreaterThan(afterFirst);
// Still exactly ONE listener — no detach/re-attach across the handoff.
expect(stream.listenerCount("data")).toBe(1);
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C2 (b): a second playPcmStream detaches the first (NOT destroyed, and it
// stops feeding pcmBuffer) and attaches the second.
it("(C2-b) a second playPcmStream detaches the first (not destroyed, stops feeding) and attaches the second", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
const first = openPcmReadable();
player.playPcmStream(first, {});
first.push(Buffer.alloc(FRAME_BYTES * 4));
await wait(120);
expect(frames.length).toBeGreaterThan(0);
expect(first.listenerCount("data")).toBe(1);
const second = openPcmReadable();
player.playPcmStream(second, {}); // fences + detaches `first`, attaches `second`
// C2: `first` is DETACHED, not destroyed.
expect(first.destroyed).toBe(false);
// `first` no longer feeds pcmBuffer — its data listener was removed.
expect(first.listenerCount("data")).toBe(0);
// `second` is now the attached source.
expect(second.listenerCount("data")).toBe(1);
expect(player.getState()).toBe("playing");
player.stop();
});
it("fires onExternalEnd when the readable ends (drives controller-based advance)", async () => {
const player = new AudioPlayer(silentLogger);
let endedCb = 0;
const stream = openPcmReadable();
player.playPcmStream(stream, { onExternalEnd: () => endedCb++ });
stream.push(Buffer.alloc(FRAME_BYTES));
await wait(40);
stream.push(null); // end-of-stream
await wait(40);
expect(endedCb).toBe(1);
player.stop();
});
it("seek() is a local no-op in external mode (never respawns ffmpeg on a spotify sentinel)", async () => {
const player = new AudioPlayer(silentLogger);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 3));
await wait(40);
expect(() => player.seek(30)).not.toThrow();
// Still external, still playing — no url-ffmpeg respawn, state unchanged.
expect(player.getState()).toBe("playing");
player.stop();
});
it("isExternalActive() is false initially, true after playPcmStream, false after stop()", () => {
const player = new AudioPlayer(silentLogger);
// Idle: never attached.
expect(player.isExternalActive()).toBe(false);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
// Attached to the external sidecar stream.
expect(player.isExternalActive()).toBe(true);
player.stop();
// Detached again — the orchestrator uses this to know it must re-attach.
expect(player.isExternalActive()).toBe(false);
});
it("pause()/resume() still gate local emission in external mode (unchanged semantics)", async () => {
const player = new AudioPlayer(silentLogger);
const stream = openPcmReadable();
player.playPcmStream(stream, {});
stream.push(Buffer.alloc(FRAME_BYTES * 3));
await wait(40);
player.pause();
expect(player.getState()).toBe("paused");
player.resume();
expect(player.getState()).toBe("playing");
player.stop();
});
// CORRECTION C1 (whole-branch): mixed queue [spotifyA, neteaseB, spotifyC].
// Advancing A -> B (a NON-spotify track) calls stop(), whose detachExternalStream()
// PAUSES the backend's long-lived SHARED readable (state.flowing = false). When the
// LATER spotify track C reuses the SAME backend, the orchestrator re-attaches that
// SAME readable via playPcmStream(). Node's Readable.on('data') only auto-resumes
// when flowing !== false, so without an explicit resume() the shared stream stays
// paused, onData never fires, pcmBuffer stays empty, and C plays only silence frames.
// Regression: after re-attach the shared stream MUST be flowing again and real PCM
// MUST reach the player.
it("(C1) resumes a re-attached, previously-paused SHARED stream so a later Spotify track isn't silent", async () => {
const player = new AudioPlayer(silentLogger);
const frames: Buffer[] = [];
player.on("frame", (f) => frames.push(f));
// The backend's long-lived, SHARED readable, reused across every track.
const shared = openPcmReadable();
// --- Spotify track A: first attach (auto-resumes, flowing was null !== false).
player.playPcmStream(shared, {});
shared.push(Buffer.alloc(FRAME_BYTES * 4));
await wait(120);
expect(frames.length).toBeGreaterThan(0);
// --- Advance to a NON-spotify track (neteaseB): play(url) begins with stop(),
// which detaches AND pauses the shared stream (state.flowing = false).
player.stop();
expect(shared.isPaused()).toBe(true); // shared stream is now paused
expect(player.getState()).toBe("idle");
// --- Spotify track C reuses the SAME backend: isExternalActive() is false so the
// orchestrator re-attaches the SAME (paused) shared readable.
expect(player.isExternalActive()).toBe(false);
// Spy on the shared stream to observe whether real PCM actually flows to the
// player. Adding a 'data' listener while flowing===false does NOT resume it
// (Node semantics), so this spy cannot mask the bug — pre-fix it stays at 0.
let spyBytes = 0;
shared.on("data", (c: Buffer) => {
spyBytes += c.length;
});
player.playPcmStream(shared, {}); // re-attach the SAME shared readable
// The re-attached stream must be flowing again, or track C is silent.
expect(shared.isPaused()).toBe(false);
shared.push(Buffer.alloc(FRAME_BYTES * 4)); // "track C" PCM
await wait(120);
// onData must have run (real PCM reached the player), not just silence frames.
expect(spyBytes).toBeGreaterThan(0);
expect(player.getState()).toBe("playing");
player.stop();
});
});
// R3-4: the 20ms frame loop keeps running while paused (so a live-but-silent
// stream can refill on resume). But the stall/EOF end-detection branches MUST
// only run while state==="playing" — otherwise pausing a stalled or
// unknown-duration stream still accumulates emptyFrameAttempts and auto-emits
// trackEnd (~5s later), making the controller skip the paused track.
//
// These tests drive the real url-path frame loop (this.ffmpeg !== null, NOT
// external mode), which cannot be exercised via playPcmStream (that sets
// externalMode and suppresses both branches). We inject a fake live ffmpeg +
// an empty pcmBuffer (a stream that stays alive but never yields a full PCM
// frame) and run the actual startFrameLoop() under fake timers. `performance`
// is faked in lockstep with the timer clock so each tick advances a real 20ms,
// letting us cheaply cross MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s) deterministically.
describe("AudioPlayer stall/EOF end-detection is gated on playing state (R3-4)", () => {
// Fake `performance` in lockstep with the timer clock so each advanced 20ms is
// a real frame tick (the loop computes its delay from performance.now()).
const FAKE_TIMER_OPTS: Parameters<typeof vi.useFakeTimers>[0] = {
toFake: ["setTimeout", "clearTimeout", "setInterval", "clearInterval", "Date", "performance"],
};
// A player primed as "playing" with a LIVE ffmpeg that never produces a full
// PCM frame (unknown duration -> isNearEnd forced true). startFrameLoop() runs
// the genuine loop; no real process is spawned (fake ffmpeg has no pid, so the
// end path never touches forceCleanup/process.kill).
function makeStalledPlaying(): AudioPlayer {
const player = new AudioPlayer(silentLogger);
const p = player as unknown as {
ffmpeg: unknown;
currentSongDuration: number;
pcmBuffer: Buffer;
emptyFrameAttempts: number;
framesPlayed: number;
state: string;
startFrameLoop(): void;
};
p.ffmpeg = { pid: undefined }; // live ffmpeg, but delivers no PCM
p.currentSongDuration = 0; // unknown duration -> isNearEnd === true
p.pcmBuffer = Buffer.alloc(0); // always < one PCM frame
p.emptyFrameAttempts = 0;
p.framesPlayed = 0;
p.state = "playing";
p.startFrameLoop();
return player;
}
it("does NOT emit trackEnd (and stays paused) when a stalled unknown-duration stream is paused past the stall threshold", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = makeStalledPlaying();
let ended = 0;
player.on("trackEnd", () => ended++);
player.pause();
expect(player.getState()).toBe("paused");
// Advance well past MAX_EMPTY_ATTEMPTS (250 ticks ≈ 5s): ~300 ticks.
vi.advanceTimersByTime(20 * 300);
expect(ended).toBe(0);
expect(player.getState()).toBe("paused");
player.stop();
} finally {
vi.useRealTimers();
}
});
it("STILL emits trackEnd when the SAME stalled unknown-duration stream is left playing (dead-stream recovery #89 preserved)", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = makeStalledPlaying(); // stays "playing"
let ended = 0;
player.on("trackEnd", () => ended++);
vi.advanceTimersByTime(20 * 300); // cross the 250-tick stall threshold
expect(ended).toBe(1);
expect(player.getState()).toBe("idle");
player.stop();
} finally {
vi.useRealTimers();
}
});
it("does not spuriously end after a brief pause+resume on a healthy stream", () => {
vi.useFakeTimers(FAKE_TIMER_OPTS);
try {
const player = new AudioPlayer(silentLogger);
const p = player as unknown as {
ffmpeg: unknown;
currentSongDuration: number;
pcmBuffer: Buffer;
emptyFrameAttempts: number;
framesPlayed: number;
state: string;
startFrameLoop(): void;
};
// Healthy: a live ffmpeg with a large buffered runway that never drains
// empty across the ticks below, so no underrun is ever seen.
p.ffmpeg = { pid: undefined };
p.currentSongDuration = 0;
p.pcmBuffer = Buffer.alloc(FRAME_BYTES * 400);
p.emptyFrameAttempts = 0;
p.framesPlayed = 0;
p.state = "playing";
p.startFrameLoop();
let ended = 0;
player.on("trackEnd", () => ended++);
vi.advanceTimersByTime(20 * 5); // play a few frames
player.pause();
vi.advanceTimersByTime(20 * 100); // brief pause (buffer NOT drained while paused)
player.resume();
expect(player.getState()).toBe("playing");
vi.advanceTimersByTime(20 * 100); // resume; still plenty of runway
expect(ended).toBe(0);
expect(player.getState()).toBe("playing");
player.stop();
} finally {
vi.useRealTimers();
}
});
});
+664 -183
View File
@@ -1,15 +1,19 @@
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { accessSync, chmodSync, constants } from "node:fs";
import { accessSync, chmodSync, constants, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
import type { Readable } from "node:stream";
import type { Logger } from "../logger.js";
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
const require = createRequire(import.meta.url);
const ffmpegPath: string | null = require("ffmpeg-static");
/** Ensure the given binary has execute permission. */
/** 全局 PID 追踪器,防止进程在类实例切换时沦为孤儿进程 ( */
const globalActivePids = new Set<number>();
function isExecutable(binPath: string): boolean {
try {
accessSync(binPath, constants.X_OK);
@@ -25,7 +29,6 @@ function isExecutable(binPath: string): boolean {
}
}
/** Test if an ffmpeg binary actually works by running -version. */
function ffmpegWorks(bin: string): boolean {
try {
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
@@ -35,32 +38,123 @@ function ffmpegWorks(bin: string): boolean {
}
}
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
const resolvedFfmpeg: string = (() => {
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
// it may return a Windows .exe path if node_modules was copied cross-platform.
if (ffmpegWorks("ffmpeg")) return "ffmpeg";
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
const onWindows = process.platform === "win32";
// Only try bundled binary if platform matches
if (ffmpegPath && (onWindows === isWinPath)) {
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
return ffmpegPath;
}
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) return ffmpegPath;
}
// Fall back to system ffmpeg
if (ffmpegWorks("ffmpeg")) {
return "ffmpeg";
}
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return "ffmpeg";
})();
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */
function getFfmpegCommand(): string {
export function getFfmpegCommand(): string {
return resolvedFfmpeg;
}
const BROWSER_UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
// Old jdymusic CDN paths (e.g. /jdymusic/obj/...) RST direct Node-stack
// requests on Windows; same URL works when fetched via WinHTTP. Empirically,
// /jd-musicrep-ts/ and /ymusic/ paths do not have this restriction.
export function shouldUsePowerShellDownload(
url: string,
platform: string = process.platform,
): boolean {
return platform === "win32" && url.includes("/jdymusic/");
}
export function cleanupTempDir(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// best-effort
}
}
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
const args: string[] = [];
const isHttp = /^https?:\/\//i.test(url);
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
if (isBilibili) {
args.push(
"-headers",
`Referer: https://www.bilibili.com\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
} else if (isHttp && (url.includes("music.126.net") || url.includes("music.163.com"))) {
args.push(
"-headers",
`Referer: https://music.163.com/\r\nUser-Agent: ${BROWSER_UA}\r\n`,
);
}
if (isHttp) {
args.push(
"-reconnect", "1",
// Long B站 streams sit on a CDN whose session/token can close the
// connection mid-file (premature EOF). Without this, FFmpeg treats that
// EOF as end-of-input and stops ~partway through (see #89); with it, it
// re-issues a Range request from the current offset to finish the stream.
"-reconnect_at_eof", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "30",
"-reconnect_on_network_error", "1",
"-reconnect_on_http_error", "4xx,5xx",
);
}
// B站's CDN serves Range requests, so seek input-side: FFmpeg jumps straight
// to the byte offset. Output-side seek would download and decode everything
// before the target first — minutes for a resume deep into a 3-hour video
// (#161), long enough to trip the stall watchdog.
const inputSideSeek = isBilibili;
if (seekSeconds > 0 && inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-i", url);
// Output-side seek (after -i): works on CDNs that reject Range/keyframe seeks (NetEase music.126.net).
if (seekSeconds > 0 && !inputSideSeek) args.push("-ss", String(seekSeconds));
args.push("-f", "s16le", "-ar", "48000", "-ac", "2", "-acodec", "pcm_s16le", "-");
return args;
}
/**
* Decide whether to end the current track when FFmpeg is still alive but has
* produced no decodable audio for `emptyAttempts` consecutive frame ticks.
*
* - Near the song end we end quickly (`maxEmptyAttempts`): a normal EOF.
* - Far from the end we wait much longer (`maxStallAttempts`) before giving up,
* so a transient buffer underrun on a healthy stream does NOT cause a false
* skip — but a genuinely dead stream (e.g. a long B站 stream whose CDN session
* expired mid-playback, #89) still recovers by advancing instead of going
* permanently silent.
*/
export function shouldEndOnStall(
emptyAttempts: number,
isNearEnd: boolean,
maxEmptyAttempts: number,
maxStallAttempts: number,
): boolean {
if (isNearEnd && emptyAttempts >= maxEmptyAttempts) return true;
if (emptyAttempts >= maxStallAttempts) return true;
return false;
}
/**
* Maps a 0-100 volume value to a linear PCM gain factor (#84).
*
* Continuous and strictly monotonic over [0,100]: 0 at vol 0 and exactly 1.0 at
* vol 100. The previous mapping was a two-piece step — gain = (vol/100)*0.2 for
* vol<100 (so the whole 0-99 range only spanned 0..0.198, making 80->99 feel
* flat) then a raw passthrough at vol===100 (a ~5x jump). This single curve keeps
* the low end gentle but ramps smoothly toward full loudness near the top, so the
* slider feels proportional with no dead zone and no discontinuity at 100.
*/
export function volumeToFactor(volume: number): number {
const x = Math.max(0, Math.min(100, volume)) / 100;
return 0.2 * x + 0.8 * Math.pow(x, 8);
}
export interface PlayerEvents {
frame: (opusFrame: Buffer) => void;
trackEnd: () => void;
@@ -76,20 +170,57 @@ 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;
private nextFrameTime = 0;
private currentUrl = "";
private seekOffset = 0;
private framesPlayed = 0; // ground truth: number of 20ms frames sent
private framesPlayed = 0;
private sessionId = 0;
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private static readonly BUFFER_HIGH_WATER = 640 * 1024;
private static readonly BUFFER_LOW_WATER = 256 * 1024;
private ffmpegPaused = false;
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
private spawnFailed = false;
private consecutiveFailures = 0;
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
private healthyFrames = 0;
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
private downloader: ChildProcess | null = null;
private currentTempDir: string | null = null;
private emptyFrameAttempts = 0;
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
// Far-from-end stall watchdog (#89): if FFmpeg is alive but produces no audio
// for this many consecutive frame ticks (~60s at 20ms/frame), treat the stream
// as dead and advance instead of staying silent forever. Set high so a normal
// transient underrun never trips it.
private static readonly MAX_STALL_ATTEMPTS = 3000;
private currentSongDuration = 0; // 当前歌曲总时长(秒)
// --- External PCM mode (Stage 2: go-librespot Spotify sidecar) ---
// When true, PCM arrives from a long-lived external Readable instead of a
// per-URL ffmpeg: this.ffmpeg stays null, and the underrun-driven trackEnd
// branches are suppressed (advance is driven by the controller, not EOF).
//
// CORRECTION C2: externalStream is the backend's LONG-LIVED, SHARED ffmpeg
// stdout (one stream reused across every track). Teardown must DETACH (remove
// the listeners we added + pause), never destroy it. We keep references to the
// exact handler functions so detach can removeListener precisely.
private externalMode = false;
private externalStream: Readable | null = null;
private onExternalEnd: (() => void) | null = null;
private externalDataHandler: ((chunk: Buffer) => void) | null = null;
private externalEndHandler: (() => void) | null = null;
private externalErrorHandler: ((err: Error) => void) | null = null;
constructor(logger: Logger) {
super();
@@ -97,113 +228,381 @@ export class AudioPlayer extends EventEmitter {
this.logger = logger;
}
play(url: string, seekSeconds = 0): void {
play(url: string, seekSeconds = 0, songDuration = 0): void {
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
this.stop();
this.sessionId++;
const playSessionId = this.sessionId;
const currentSessionId = this.sessionId;
this.currentUrl = url;
this.seekOffset = seekSeconds;
this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = songDuration;
// Prevent rapid-fire spawn attempts when ffmpeg is broken
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
this.logger.error(
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
);
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
this.state = "idle";
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
this.emit("error", new Error("ffmpeg unavailable"));
return;
}
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
const args: string[] = [];
// BiliBili CDN requires Referer header for audio playback
if (url.includes("bilivideo") || url.includes("bilibili")) {
args.push(
"-headers",
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
);
if (shouldUsePowerShellDownload(url)) {
this.playViaPowerShellDownload(url, seekSeconds, currentSessionId);
return;
}
args.push(
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
);
if (seekSeconds > 0) {
args.push("-ss", String(seekSeconds));
}
args.push(
"-i", url,
"-f", "s16le",
"-ar", "48000",
"-ac", "2",
"-acodec", "pcm_s16le",
"-",
);
const args = buildFfmpegArgs(url, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId: currentSessionId }, "FFmpeg spawned");
}
// Prevent stream errors from crashing the process
this.ffmpeg.stdout!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stdout error");
});
this.ffmpeg.stderr!.on("error", (err) => {
this.logger.warn({ err }, "FFmpeg stderr error");
});
let gotFirstData = false;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (!gotFirstData) {
gotFirstData = true;
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
// 2. 严格校验 sessionId,防止老进程的数据混入新播放请求 (
if (this.sessionId !== currentSessionId) {
return;
}
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
// Backpressure: pause FFmpeg stdout when buffer is too large
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("close", (code, signal) => {
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
if (this.sessionId === playSessionId) {
this.ffmpeg = null; // Signal frame loop that no more data is coming
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
// 只有当前会话的进程结束才置空变量
if (this.sessionId === currentSessionId) {
this.ffmpeg = null;
}
});
this.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) {
if (this.sessionId === currentSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Log FFmpeg stderr at info level for debugging playback issues
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
const msg = data.toString().trimEnd();
// Log important FFmpeg messages at info level
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
} else {
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
this.state = "playing";
this.startFrameLoop();
}
private playViaPowerShellDownload(url: string, seekSeconds: number, sessionId: number): void {
const tempDir = mkdtempSync(join(tmpdir(), "tsbot-jdymusic-"));
const tempFile = join(tempDir, "song.audio");
this.currentTempDir = tempDir;
const psScript = [
"$ErrorActionPreference = 'Stop'",
"$ProgressPreference = 'SilentlyContinue'",
"$wc = New-Object System.Net.WebClient",
"$wc.Headers.Add('User-Agent', $env:DL_UA)",
"$wc.Headers.Add('Referer', $env:DL_REFERER)",
"$wc.DownloadFile($env:DL_URL, $env:DL_OUT)",
].join("; ");
this.logger.debug({ sessionId, tempFile }, "Downloading via PowerShell (jdymusic CDN)");
const ps = spawn(
"powershell",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psScript],
{
env: {
...process.env,
DL_URL: url,
DL_OUT: tempFile,
DL_UA: BROWSER_UA,
DL_REFERER: "https://music.163.com/",
},
stdio: ["ignore", "pipe", "pipe"],
},
);
this.downloader = ps;
let stderrTail = "";
ps.stderr!.on("data", (chunk: Buffer) => {
stderrTail = (stderrTail + chunk.toString()).slice(-500);
});
ps.on("exit", (code, signal) => {
if (this.sessionId !== sessionId) {
cleanupTempDir(tempDir);
return;
}
this.downloader = null;
if (code !== 0) {
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
this.spawnFailed = true;
this.consecutiveFailures++;
this.state = "idle";
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", new Error(`PowerShell download exited ${code}`));
return;
}
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
});
ps.on("error", (err) => {
if (this.sessionId !== sessionId) return;
this.downloader = null;
this.spawnFailed = true;
this.consecutiveFailures++;
cleanupTempDir(tempDir);
this.currentTempDir = null;
this.emit("error", err);
});
// Mark playing but DO NOT start the frame loop here — the loop's
// "no ffmpeg + empty buffer → trackEnd" branch would fire on the very
// first tick, before the PowerShell download even completes. The
// frame loop is started inside spawnFfmpegFromFile() once ffmpeg is
// alive and producing PCM.
this.state = "playing";
}
private spawnFfmpegFromFile(tempFile: string, seekSeconds: number, sessionId: number): void {
if (this.sessionId !== sessionId) {
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
return;
}
const args = buildFfmpegArgs(tempFile, seekSeconds);
const ffmpegBin = getFfmpegCommand();
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
const currentPid = this.ffmpeg.pid;
if (currentPid) {
globalActivePids.add(currentPid);
this.logger.debug({ pid: currentPid, sessionId }, "FFmpeg spawned (from temp file)");
}
const tempDirToCleanup = this.currentTempDir;
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
if (this.sessionId !== sessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
this.ffmpegPaused = true;
}
});
this.ffmpeg.on("exit", (code, signal) => {
if (currentPid) globalActivePids.delete(currentPid);
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
if (this.sessionId === sessionId) {
this.ffmpeg = null;
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
}
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
});
this.ffmpeg.on("error", (err) => {
if (this.sessionId === sessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err);
}
});
// Now that ffmpeg is producing PCM, run the frame loop.
this.startFrameLoop();
}
/**
* External-PCM mode (Stage 2 go-librespot Spotify sidecar).
*
* Feeds an already-normalized 48kHz/s16le/stereo PCM Readable (the
* go-librespot FIFO -> ffmpeg output) straight into the existing pcmBuffer +
* 20ms frame loop + Opus encoder + "frame" emission, WITHOUT spawning a
* per-URL ffmpeg. The url play() path is left completely untouched.
*
* Track advance is NOT driven by buffer underrun here (the sidecar stream is
* continuous and never EOFs per song); the caller drives advance via the
* SpotifyController "trackEnded" WebSocket event. onExternalEnd fires only if
* the underlying readable itself ends or errors.
*
* CORRECTION C2: the readable is the backend's long-lived, SHARED ffmpeg
* stdout reused across every track — a gapless track change is just LATER PCM
* on this SAME already-attached stream (no re-attach). Teardown DETACHES
* (removes our listeners + pauses); it never destroys the shared stream.
*/
playPcmStream(readable: Readable, opts: { onExternalEnd?: () => void } = {}): void {
// 1. Fence current playback: stop() bumps sessionId, clears pcmBuffer, kills
// any ffmpeg, and DETACHES (never destroys) any prior external stream.
this.stop();
const currentSessionId = this.sessionId;
this.externalMode = true;
this.externalStream = readable;
this.onExternalEnd = opts.onExternalEnd ?? null;
// Leave this.ffmpeg = null; clear currentUrl so seek() cannot respawn ffmpeg.
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
this.healthyFrames = 0;
this.ffmpegPaused = false;
this.spawnFailed = false;
this.emptyFrameAttempts = 0;
this.currentSongDuration = 0;
// Same ingestion + high-water backpressure as the ffmpeg.stdout handler,
// but pausing the Readable instead of ffmpeg.stdout. sessionId-guarded so
// stale sidecar PCM can't leak into a new track after stop()/skip. Handler
// refs are stored so detach can remove exactly these listeners (C2).
const onData = (chunk: Buffer): void => {
if (this.sessionId !== currentSessionId) return;
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
if (
this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER &&
!this.ffmpegPaused &&
this.externalStream === readable
) {
readable.pause();
this.ffmpegPaused = true;
}
};
const onEnd = (): void => {
if (this.sessionId !== currentSessionId) return;
this.onExternalEnd?.();
};
const onError = (err: Error): void => {
if (this.sessionId !== currentSessionId) return;
this.logger.warn({ err }, "External PCM stream error");
this.onExternalEnd?.();
};
this.externalDataHandler = onData;
this.externalEndHandler = onEnd;
this.externalErrorHandler = onError;
readable.on("data", onData);
readable.on("end", onEnd);
readable.on("error", onError);
// CORRECTION C1: explicitly resume a re-attached, previously-paused Readable.
// The backend's SHARED stdout is reused across every track; a prior non-spotify
// advance ran stop() -> detachExternalStream() which pause()d it (state.flowing =
// false). Node's Readable.on('data') only auto-resumes when flowing !== false, so
// re-attaching a paused stream would leave it stuck: onData never fires, pcmBuffer
// stays empty, and a later Spotify track plays only silence. resume() is safe/
// idempotent on a first attach (never-paused/already-flowing) stream.
readable.resume();
this.state = "playing";
this.startFrameLoop();
}
/**
* CORRECTION C2: DETACH, never destroy. The external readable is the backend's
* long-lived, SHARED ffmpeg stdout reused across every track; destroying it
* would kill the sidecar pipe for all future tracks. Remove only the listeners
* WE added and pause the flow so stale PCM stops landing in pcmBuffer, then
* clear the external-mode state.
*/
private detachExternalStream(): void {
const stream = this.externalStream;
if (stream) {
if (this.externalDataHandler) stream.off("data", this.externalDataHandler);
if (this.externalEndHandler) stream.off("end", this.externalEndHandler);
if (this.externalErrorHandler) stream.off("error", this.externalErrorHandler);
try {
stream.pause();
} catch {
/* best-effort: never destroy the shared sidecar stream */
}
}
this.externalDataHandler = null;
this.externalEndHandler = null;
this.externalErrorHandler = null;
this.externalStream = null;
this.externalMode = false;
this.onExternalEnd = null;
}
stop(): void {
// 3. 递增 ID 是最有效的逻辑“隔离墙”
this.sessionId++;
this.frameLoopRunning = false;
// 立即清空缓冲区,确保切歌瞬间静音 (
this.pcmBuffer = Buffer.alloc(0);
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
if (this.downloader) {
const ps = this.downloader;
this.downloader = null;
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
}
if (this.currentTempDir) {
cleanupTempDir(this.currentTempDir);
this.currentTempDir = null;
}
// CORRECTION C2: tear down external mode by DETACHING (remove our listeners +
// pause) — never destroy the shared, long-lived sidecar stream. The
// sessionId++ above already fences the external data/end/error handlers.
this.detachExternalStream();
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
this.healthyFrames = 0;
}
private forceCleanup(proc: ChildProcess, pid: number): void {
if (!globalActivePids.has(pid)) return;
try {
proc.kill("SIGTERM");
} catch (e) { /* ignore */ }
const killTimeout = setTimeout(() => {
try {
process.kill(pid, 0);
process.kill(pid, "SIGKILL");
} catch (e) {
} finally {
globalActivePids.delete(pid);
}
}, 1500);
proc.unref();
proc.once("exit", () => {
clearTimeout(killTimeout);
globalActivePids.delete(pid);
});
}
private startFrameLoop(): void {
if (this.frameLoopRunning) return;
this.frameLoopRunning = true;
@@ -213,53 +612,116 @@ export class AudioPlayer extends EventEmitter {
private scheduleNextFrame(): void {
if (!this.frameLoopRunning) return;
const loopSessionId = this.sessionId;
this.nextFrameTime += FRAME_DURATION_MS;
const now = performance.now();
const delay = Math.max(0, this.nextFrameTime - now);
const delay = Math.max(0, this.nextFrameTime - performance.now());
setTimeout(() => {
// Discard callback from a stale play session
if (loopSessionId !== this.sessionId) return;
if (!this.frameLoopRunning) return;
// 这里的校验能防止旧的定时器回调处理新 Session 的逻辑 (
if (loopSessionId !== this.sessionId || !this.frameLoopRunning) return;
if (this.state === "playing") {
this.sendNextFrame();
} else if (this.state === "paused") {
this.nextFrameTime = performance.now();
if (this.state === "playing") this.sendNextFrame();
else if (this.state === "paused") this.nextFrameTime = performance.now();
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
const elapsed = this.getElapsed();
const isNearEnd = this.currentSongDuration > 0
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
: true; // 未知时长时保守处理
// External mode: the sidecar PCM stream is continuous and never EOFs per
// song; a transient underrun must NOT end the track (advance is driven by
// the controller). Skip BOTH drain/stall branches while externalMode.
//
// R3-4: gate BOTH end-detection branches on state==="playing". While paused
// the loop still ticks (so a resumed stream can refill), but it must NOT
// accumulate stall attempts or emit trackEnd — otherwise pausing a stalled/
// unknown-duration stream would auto-advance ~5s later. Because the if is
// now false while paused, the else resets emptyFrameAttempts to 0, so a
// resumed healthy stream starts fresh and never ends instantly.
if (this.state === "playing" && !this.externalMode && this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.emptyFrameAttempts++;
// End the track when FFmpeg has gone silent: quickly if we're near the
// end (normal EOF), or after a much longer stall window if we're not
// (a dead/expired stream — #89 — so playback recovers instead of going
// permanently silent).
if (
shouldEndOnStall(
this.emptyFrameAttempts,
isNearEnd,
AudioPlayer.MAX_EMPTY_ATTEMPTS,
AudioPlayer.MAX_STALL_ATTEMPTS,
)
) {
this.logger.info({
sessionId: this.sessionId,
emptyAttempts: this.emptyFrameAttempts,
bufferSize: this.pcmBuffer.length,
elapsed: Math.round(elapsed),
duration: this.currentSongDuration,
remaining: Math.round(this.currentSongDuration - elapsed),
nearEnd: isNearEnd,
}, "FFmpeg stopped outputting data, ending track");
this.frameLoopRunning = false;
// The outer gate guarantees state==="playing" here, so no !=="idle"
// guard is needed: end the track directly.
this.state = "idle";
// 清理FFmpeg进程
if (this.ffmpeg) {
const procToKill = this.ffmpeg;
const pidToKill = procToKill.pid;
this.ffmpeg = null;
if (pidToKill) {
this.forceCleanup(procToKill, pidToKill);
}
}
this.consecutiveFailures = 0;
this.emit("trackEnd");
return;
}
} else {
// 成功获取数据或FFmpeg已结束,重置计数器
this.emptyFrameAttempts = 0;
}
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
// R3-4: likewise gated on state==="playing" — a drained/EOF'd stream must
// not emit trackEnd while paused; end-detection resumes on resume().
if (this.state === "playing" && !this.externalMode && !this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
if (this.spawnFailed) {
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
} else {
this.consecutiveFailures = 0; // Reset on successful track completion
this.emit("trackEnd");
}
// Outer gate guarantees state==="playing"; end directly (no !=="idle" guard).
this.state = "idle";
if (!this.spawnFailed) {
this.consecutiveFailures = 0;
this.emit("trackEnd");
}
return;
}
this.scheduleNextFrame();
}, delay);
}
private sendNextFrame(): void {
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
if (this.pcmBuffer.length < PCM_FRAME_BYTES) {
// External mode: the sidecar PCM stream is long-lived and must NOT end on
// a transient underrun. Emit an encoded silence frame so the 20ms voice
// timeline stays continuous instead of returning (which would desync TS).
if (this.externalMode) this.emitSilenceFrame();
return;
}
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER) {
if (this.externalMode && this.externalStream) {
this.externalStream.resume();
this.ffmpegPaused = false;
} else if (this.ffmpeg?.stdout) {
this.ffmpeg.stdout.resume();
this.ffmpegPaused = false;
}
}
try {
@@ -267,94 +729,113 @@ export class AudioPlayer extends EventEmitter {
const opusFrame = this.encoder.encode(adjusted);
this.emit("frame", opusFrame);
this.framesPlayed++;
if (this.framesPlayed === 1) {
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
}
// Log every ~10 seconds (500 frames * 20ms = 10s)
if (this.framesPlayed % 500 === 0) {
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
this.healthyFrames++;
if (this.healthyFrames >= AudioPlayer.HEALTHY_FRAME_RESET) {
this.consecutiveFailures = 0;
this.healthyFrames = 0;
}
} catch (err) {
this.logger.error({ err }, "Error encoding/sending audio frame");
this.emit("error", err as Error);
}
}
private emitSilenceFrame(): void {
try {
const opusFrame = this.encoder.encode(Buffer.alloc(PCM_FRAME_BYTES));
this.emit("frame", opusFrame);
this.framesPlayed++;
} catch (err) {
this.emit("error", err as Error);
}
}
private applyVolume(pcm: Buffer): Buffer {
if (this.volume === 100) return Buffer.from(pcm);
const factor = this.volume / 100;
const baseFactor = volumeToFactor(this.volume);
const now = performance.now();
const startDuckingGain = this.duckingGainAt(now);
const endDuckingGain = this.duckingGainAt(now + FRAME_DURATION_MS);
const startFactor = baseFactor * startDuckingGain;
const endFactor = baseFactor * endDuckingGain;
if (startFactor >= 1 && endFactor >= 1) {
return Buffer.from(pcm);
}
const out = Buffer.alloc(pcm.length);
// Most frames are outside the short attack/release windows. Preserve the
// old constant-factor hot path instead of doing interpolation per sample.
if (startFactor === endFactor) {
for (let i = 0; i < pcm.length; i += 2) {
const sample = Math.round(pcm.readInt16LE(i) * startFactor);
out.writeInt16LE(Math.max(-32768, Math.min(32767, sample)), i);
}
return out;
}
// PCM is fixed at stereo s16le. Use one gain for each L/R pair so a ramp
// never creates a tiny channel imbalance, and span the whole 20 ms frame.
const stereoFrames = Math.max(1, Math.ceil(pcm.length / 4));
for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i);
sample = Math.round(sample * factor);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
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;
}
/** Actual elapsed time in seconds (ground truth from frame count) */
getElapsed(): number {
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
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
// status.track.position. This approximation is acceptable for Spotify.
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
seek(seconds: number): void {
if (!this.currentUrl) return;
this.logger.info({ seek: seconds }, "Seeking");
this.play(this.currentUrl, seconds);
}
getSeekOffset(): number {
return this.seekOffset;
}
pause(): void {
if (this.state === "playing") {
this.state = "paused";
this.logger.debug("Playback paused");
// External (Spotify sidecar) mode: local seek is a no-op. Respawning ffmpeg
// on the spotify: sentinel would collide with the continuous PCM source;
// transport is delegated to the SpotifyController by the caller (Task 7).
if (this.externalMode) return;
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
this.play(this.currentUrl, seconds, this.currentSongDuration);
}
}
pause(): void { if (this.state === "playing") this.state = "paused"; }
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
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;
resume(): void {
if (this.state === "paused") {
this.state = "playing";
this.nextFrameTime = performance.now();
this.logger.debug("Playback resumed");
}
}
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;
stop(): void {
this.sessionId++;
this.frameLoopRunning = false;
if (this.ffmpeg) {
this.ffmpeg.kill("SIGTERM");
this.ffmpeg = null;
}
this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle";
this.currentUrl = "";
this.seekOffset = 0;
this.framesPlayed = 0;
}
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol));
}
getVolume(): number {
return this.volume;
}
getState(): PlayerState {
return this.state;
this.duckingRampStartGain = currentGain;
this.duckingTargetGain = targetGain;
this.duckingRampStartedAt = now;
this.duckingRampDurationMs =
duration > 0 && currentGain !== targetGain ? duration : 0;
}
getDuckingGain(): number { return this.duckingGainAt(performance.now()); }
getState(): PlayerState { return this.state; }
// True only while attached to an external (Spotify sidecar) PCM stream. Used
// by the orchestrator to decide whether to re-attach: stop() detaches (sets
// externalMode=false) so this is false after any player.stop().
isExternalActive(): boolean { return this.externalMode; }
}
+619
View File
@@ -89,6 +89,49 @@ describe("PlayQueue", () => {
expect(queue.list()[1].id).toBe("3");
});
it("removing a song before current shifts current index", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.playAt(2); // playing C at index 2
queue.remove(0); // remove A (before current)
expect(queue.current()?.id).toBe("C"); // still on C
expect(queue.getCurrentIndex()).toBe(1);
});
it("removing the currently-playing song lets next() advance to the shifted song", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.playAt(2); // playing C
queue.remove(2); // remove C — D shifts into slot 2
// Before the fix this returned null (D was silently skipped)
expect(queue.next()?.id).toBe("D");
});
it("removing the only song clears the queue", () => {
queue.add(makeSong("only"));
queue.playAt(0);
queue.remove(0);
expect(queue.size()).toBe(0);
expect(queue.current()).toBeNull();
expect(queue.next()).toBeNull();
});
it("removing the last song while playing it advances to null in sequential mode", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.playAt(1); // playing B (last)
queue.remove(1);
expect(queue.size()).toBe(1);
// currentIndex moved to 0, so next() should try to advance past the end
expect(queue.next()).toBeNull();
});
it("clears all songs", () => {
queue.add(makeSong("1"));
queue.add(makeSong("2"));
@@ -107,6 +150,101 @@ describe("PlayQueue", () => {
expect(next).not.toBeNull();
});
it("random mode with single song returns null on next", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("1"));
queue.play();
expect(queue.next()).toBeNull();
});
it("random mode plays each song exactly once then stops", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
const played = new Set<string>();
played.add(queue.current()!.id);
for (let i = 0; i < 3; i++) {
const song = queue.next();
if (!song) break;
played.add(song.id);
}
// All 3 songs should have been played
expect(played).toEqual(new Set(["A", "B", "C"]));
// next() after all played should return null
expect(queue.next()).toBeNull();
});
it("random mode: removing currently-playing song does not skip others", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.add(makeSong("D"));
queue.play(); // plays A (index 0)
const second = queue.next()!; // plays some song
// Remove the currently-playing song
const curIdx = queue.getCurrentIndex();
queue.remove(curIdx);
// Remaining songs (excluding A and the removed song) should all be reachable
const played = new Set<string>();
played.add("A"); // already played via play()
played.add(second.id); // played and then removed
let song = queue.next();
while (song) {
played.add(song.id);
song = queue.next();
}
// All 4 original songs should have been played or accounted for
expect(played).toEqual(new Set(["A", "B", "C", "D"]));
});
it("random mode: prev does not cause duplicate plays", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play(); // plays A
queue.next(); // plays B or C
queue.prev(); // go back — this song is now marked as played
// Exhaust remaining songs
const ids: string[] = [];
let song = queue.next();
while (song) {
ids.push(song.id);
song = queue.next();
}
// No song ID should appear more than once across the entire session
expect(new Set(ids).size).toBe(ids.length);
});
it("random mode: adding song mid-playback includes the new song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A
queue.next(); // plays B
// Add a new song while all existing songs have been played
queue.add(makeSong("C"));
const song = queue.next();
expect(song).not.toBeNull();
expect(song!.id).toBe("C");
// After C, should stop
expect(queue.next()).toBeNull();
});
it("random mode: setMode preserves current song as played", () => {
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // plays A in sequential mode
queue.setMode(PlayMode.Random); // switch to random — A should be marked played
// next() should only return B, never A again
const song = queue.next();
expect(song?.id).toBe("B");
expect(queue.next()).toBeNull();
});
it("random-loop mode never returns null", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("1"));
@@ -123,4 +261,485 @@ describe("PlayQueue", () => {
queue.playAt(2);
expect(queue.current()?.id).toBe("3");
});
describe("history-aware prev", () => {
it("walks back through played indices in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.add(makeSong("e"));
// Force a deterministic random sequence: a → c → e
queue.playAt(0);
queue.playAt(2);
queue.playAt(4);
expect(queue.current()?.id).toBe("e");
// prev pops back through history: e → c → a
expect(queue.prev()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("returns null when history is empty in random mode", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
// No further moves → history is empty (only 'a' is current, never pushed)
expect(queue.prev()).toBeNull();
});
it("preserves sequential prev when history is empty", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.play();
queue.next(); // currentIndex = 1
// Sequential next() pushed 0 to history → prev pops back to 0
expect(queue.prev()?.id).toBe("a");
});
it("clears history on play()", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.playAt(0);
queue.playAt(1);
queue.play(); // resets to index 0 and clears history
expect(queue.prev()).toBeNull();
});
it("clears history on clear()", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
queue.clear();
queue.add(makeSong("c"));
queue.play();
// History was wiped — no prev path available beyond index 0
expect(queue.prev()).toBeNull();
});
it("clears history on setMode()", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play();
queue.next();
// Mode change resets context
queue.setMode(PlayMode.Random);
expect(queue.prev()).toBeNull();
});
it("drops history entries pointing at a removed song", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1); // history: [0]
queue.playAt(2); // history: [0, 1]
// Remove song at index 1 → history entry 1 dropped
queue.remove(1);
// queue is now [a, c], history should be [0]
// current was at 2 → after remove shifts to 1 → song "c"
expect(queue.current()?.id).toBe("c");
expect(queue.prev()?.id).toBe("a");
});
it("does not push to history on prev itself", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.playAt(0);
queue.playAt(1);
queue.playAt(2); // history: [0, 1]
queue.prev(); // pops 1, history: [0]
queue.prev(); // pops 0, history: []
expect(queue.prev()).toBeNull(); // no fallback target in random mode
});
it("caps history at HISTORY_LIMIT (50) entries, dropping oldest", () => {
queue.setMode(PlayMode.Random);
// Build a queue large enough to overflow HISTORY_LIMIT
for (let i = 0; i < 60; i++) queue.add(makeSong(`s${i}`));
// Walk through 60 explicit picks → 59 pushes to history
// (playAt pushes the previous currentIndex; first call has -1
// which pushHistory rejects). After 60 playAts, history holds
// the last 50 of those 59 entries.
for (let i = 0; i < 60; i++) queue.playAt(i);
// Walk back through history. The first prev returns whatever the
// 50th-most-recent push was (= index 9, since pushes 0..58 happened
// and the oldest 9 fell off). We can verify by counting prevs that
// succeed before history exhausts and prev returns null in random.
let count = 0;
while (queue.prev() !== null) {
count++;
if (count > 100) break; // safety
}
expect(count).toBe(50);
});
});
describe("addNext", () => {
it("appends when queue is empty (no current)", () => {
queue.addNext(makeSong("a"));
expect(queue.size()).toBe(1);
expect(queue.list()[0].id).toBe("a");
});
it("appends when nothing is currently playing (currentIndex < 0)", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
// No play() yet → currentIndex still -1
queue.addNext(makeSong("c"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c"]);
});
it("inserts at currentIndex+1 mid-queue", () => {
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.play(); // current = 0 (a)
queue.next(); // current = 1 (b)
queue.addNext(makeSong("x"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
expect(queue.current()?.id).toBe("b"); // current unchanged
});
it("makes the inserted song play next when next() is called", () => {
queue.setMode(PlayMode.Sequential);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.play(); // current = 0 (a)
queue.addNext(makeSong("x"));
expect(queue.next()?.id).toBe("x");
});
it("shifts playedIndices entries > currentIndex by +1", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.playAt(2); // current = 2 (c), played = {2}
queue.playAt(3); // current = 3 (d), played = {2, 3}
queue.playAt(2); // current = 2 (c), played = {2, 3}
// Now insert after c — d's index 3 should become 4
queue.addNext(makeSong("x"));
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "x", "d"]);
// After addNext: currentIndex still 2; played should be {2, 4}
// (the previously-played 'd' is now at index 4)
// Verify by removing 'x' (index 3) — d should remain played at index 3
queue.remove(3);
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "c", "d"]);
});
it("shifts history entries > currentIndex by +1", () => {
queue.setMode(PlayMode.Random);
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.playAt(0); // current = 0
queue.playAt(3); // current = 3 (d), history = [0]
queue.playAt(1); // current = 1 (b), history = [0, 3]
queue.addNext(makeSong("x"));
// Insert at index 2 → entries > 1 shift +1 → history becomes [0, 4]
// queue: [a, b, x, c, d]; d is now at index 4
// prev → pop 4 → song at index 4 = d
expect(queue.prev()?.id).toBe("d");
// prev again → pop 0 → song at index 0 = a
expect(queue.prev()?.id).toBe("a");
});
it("idle player + stale currentIndex: insertion target is currentIndex+1, not size-1", () => {
// Reproduces the scenario where the player has gone idle but the
// queue still has a non-negative currentIndex (e.g., after natural
// track end without queue.clear()).
queue.add(makeSong("a"));
queue.add(makeSong("b"));
queue.add(makeSong("c"));
queue.add(makeSong("d"));
queue.play(); // current = 0 (a)
queue.next(); // current = 1 (b)
// Simulate idle-with-stale-currentIndex: the player has gone idle
// but queue still points at b.
// Caller pre-captures insertedAt:
const insertedAt = queue.getCurrentIndex() + 1; // = 2
queue.addNext(makeSong("x"));
// queue is now [a, b, x, c, d]
// size-1 would be 4 (d) — WRONG.
// insertedAt is 2 (x) — RIGHT.
expect(queue.list().map((s) => s.id)).toEqual(["a", "b", "x", "c", "d"]);
expect(queue.size() - 1).toBe(4); // proves size-1 strategy would pick d
const promoted = queue.playAt(insertedAt);
expect(promoted?.id).toBe("x");
});
});
// Issue #70: 随机循环 (rloop) used true random-with-replacement, so some
// songs repeated often while others were starved. It should behave like a
// shuffle bag (NetEase/QQ style): play every song once per cycle in random
// order, then reshuffle and continue, avoiding an immediate cross-cycle repeat.
describe("random-loop shuffle bag (issue #70)", () => {
it("plays every song exactly once per cycle before repeating", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 12;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const cycle1 = [queue.current()!.id];
for (let i = 0; i < N - 1; i++) cycle1.push(queue.next()!.id);
const cycle2: string[] = [];
for (let i = 0; i < N; i++) cycle2.push(queue.next()!.id);
// Each cycle is a full permutation of all N songs — zero repeats within
// a cycle, and both cycles cover the same complete set.
expect(new Set(cycle1).size).toBe(N);
expect(new Set(cycle2).size).toBe(N);
expect(new Set(cycle1)).toEqual(new Set(cycle2));
});
it("distributes plays evenly across songs over many cycles (no starvation)", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 6;
const CYCLES = 20;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
const counts = new Map<string, number>();
counts.set(queue.current()!.id, 1);
for (let i = 0; i < CYCLES * N - 1; i++) {
const id = queue.next()!.id;
counts.set(id, (counts.get(id) ?? 0) + 1);
}
// Shuffle bag => each song plays exactly CYCLES times. True random
// would skew heavily.
for (let i = 0; i < N; i++) {
expect(counts.get(`s${i}`)).toBe(CYCLES);
}
});
it("does not replay the same song across a cycle boundary", () => {
queue.setMode(PlayMode.RandomLoop);
const N = 5;
for (let i = 0; i < N; i++) queue.add(makeSong(`s${i}`));
queue.play();
// Walk to the last song of cycle 1, then cross into cycle 2.
for (let i = 0; i < N - 1; i++) queue.next();
const lastOfCycle1 = queue.current()!.id;
const firstOfCycle2 = queue.next()!.id;
expect(firstOfCycle2).not.toBe(lastOfCycle1);
});
it("includes a song added mid-cycle within the current cycle", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.play(); // A
queue.next(); // B — both originals now played this cycle
queue.add(makeSong("C")); // added mid-cycle, still unplayed
// C is the only unplayed song, so it must come next (not a reshuffle).
expect(queue.next()?.id).toBe("C");
});
it("keeps looping forever with multiple songs (never returns null)", () => {
queue.setMode(PlayMode.RandomLoop);
queue.add(makeSong("A"));
queue.add(makeSong("B"));
queue.add(makeSong("C"));
queue.play();
for (let i = 0; i < 30; i++) {
expect(queue.next()).not.toBeNull();
}
});
});
describe("snapshot / restore (#119)", () => {
it("round-trips songs, index, and mode; strips url", () => {
const q = new PlayQueue();
q.add(makeSong("A"));
q.add(makeSong("B"));
q.setMode(PlayMode.Loop);
q.play();
q.next(); // current = index 1
const snap = q.snapshot();
expect(snap.currentIndex).toBe(1);
expect(snap.mode).toBe(PlayMode.Loop);
expect((snap.songs[0] as QueuedSong).url).toBeUndefined();
expect(snap.songs.map((s) => s.id)).toEqual(["A", "B"]);
const q2 = new PlayQueue();
q2.restore(snap);
expect(q2.list().map((s) => s.id)).toEqual(["A", "B"]);
expect(q2.getCurrentIndex()).toBe(1);
expect(q2.getMode()).toBe(PlayMode.Loop);
expect(q2.current()?.id).toBe("B");
});
it("preserves requestedBy through a snapshot", () => {
const q = new PlayQueue();
q.add({ ...makeSong("A"), requestedBy: "alice" });
q.play();
const q2 = new PlayQueue();
q2.restore(q.snapshot());
expect(q2.current()?.requestedBy).toBe("alice");
});
it("degrades an out-of-range index to -1 (nothing current)", () => {
const q = new PlayQueue();
const { url: _url, ...noUrl } = makeSong("A");
q.restore({ songs: [noUrl], currentIndex: 5, mode: PlayMode.Sequential });
expect(q.getCurrentIndex()).toBe(-1);
expect(q.current()).toBeNull();
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");
});
});
});
+230 -23
View File
@@ -10,16 +10,41 @@ export interface QueuedSong {
name: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
url?: string; // resolved lazily at play time
coverUrl: string;
duration: number; // seconds
requestedBy?: string;
}
/**
* A persistable view of a queue: its songs (minus the lazily-resolved `url`),
* the current index, and the play mode. Used to snapshot/restore the live queue
* across restarts (issue #119). Derived state (playedIndices/history/forward
* stack) is intentionally NOT captured — restore() rebuilds it consistently.
*/
export interface QueueSnapshot {
songs: Omit<QueuedSong, "url">[];
currentIndex: number;
mode: PlayMode;
}
export class PlayQueue {
private songs: QueuedSong[] = [];
private currentIndex = -1;
private mode: PlayMode = PlayMode.Sequential;
private playedIndices = new Set<number>();
private history: number[] = [];
private forwardStack: number[] = [];
private static readonly HISTORY_LIMIT = 50;
private pushHistory(idx: number): void {
if (idx < 0 || idx >= this.songs.length) return;
this.history.push(idx);
if (this.history.length > PlayQueue.HISTORY_LIMIT) {
this.history.shift();
}
}
add(song: QueuedSong): void {
this.songs.push(song);
@@ -29,6 +54,55 @@ export class PlayQueue {
this.songs.push(...songs);
}
/**
* Insert a song to play immediately after the current one. Falls
* through to plain push when nothing is playing yet (currentIndex < 0
* or queue empty), so the existing "add → idle bot starts playing"
* flow continues to work.
*
* 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) {
this.songs.push(song);
return;
}
const insertAt = this.currentIndex + 1;
this.songs.splice(insertAt, 0, song);
const shifted = new Set<number>();
for (const i of this.playedIndices) {
shifted.add(i > this.currentIndex ? i + 1 : i);
}
this.playedIndices = shifted;
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 {
if (index < 0 || index >= this.songs.length) return null;
const [removed] = this.songs.splice(index, 1);
@@ -36,28 +110,61 @@ export class PlayQueue {
if (index < this.currentIndex) {
this.currentIndex--;
} else if (index === this.currentIndex) {
if (this.currentIndex >= this.songs.length) {
this.currentIndex = this.songs.length - 1;
}
this.currentIndex--;
}
// Rebuild playedIndices to account for shifted indices
const newPlayed = new Set<number>();
for (const idx of this.playedIndices) {
if (idx === index) continue;
newPlayed.add(idx > index ? idx - 1 : idx);
}
this.playedIndices = newPlayed;
// Same shift logic for history — drop entries pointing at the
// removed song; shift entries > index down by 1.
this.history = this.history
.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;
}
clear(): void {
this.songs = [];
this.currentIndex = -1;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
}
play(): QueuedSong | null {
if (this.songs.length === 0) return null;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
this.currentIndex = 0;
this.playedIndices.add(0);
return this.songs[0];
}
playAt(index: number): QueuedSong | null {
if (index < 0 || index >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
// Reset the Random-mode "unplayed" pool — explicit picks restart
// shuffle from this point. History tracking is independent and
// unaffected by this clear.
this.playedIndices.clear();
this.forwardStack = [];
this.currentIndex = index;
this.playedIndices.add(index);
return this.songs[index];
}
@@ -68,47 +175,108 @@ export class PlayQueue {
case PlayMode.Sequential: {
const nextIndex = this.currentIndex + 1;
if (nextIndex >= this.songs.length) return null;
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Loop: {
this.pushHistory(this.currentIndex);
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
return this.songs[this.currentIndex];
}
case PlayMode.Random: {
if (this.songs.length === 1) return this.songs[0];
let nextIndex: number;
do {
nextIndex = Math.floor(Math.random() * this.songs.length);
} while (nextIndex === this.currentIndex && this.songs.length > 1);
this.currentIndex = nextIndex;
return this.songs[nextIndex];
}
case PlayMode.Random:
case PlayMode.RandomLoop: {
if (this.songs.length === 1) {
this.currentIndex = 0;
return this.songs[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 < 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];
}
let idx: number;
do {
idx = Math.floor(Math.random() * this.songs.length);
} while (idx === this.currentIndex);
this.currentIndex = idx;
return this.songs[idx];
// Shuffle bag: pick uniformly from the songs not yet played this
// cycle, so every song plays once before any repeats (NetEase/QQ
// style). Songs added mid-cycle aren't in playedIndices, so they're
// naturally eligible within the current cycle.
const unplayed: number[] = [];
for (let i = 0; i < this.songs.length; i++) {
if (!this.playedIndices.has(i)) unplayed.push(i);
}
if (unplayed.length === 0) {
// Cycle complete.
if (this.mode === PlayMode.Random) return null; // 随机:播完即停
// 随机循环:reshuffle and keep going forever.
if (this.songs.length === 1) {
this.pushHistory(this.currentIndex);
this.currentIndex = 0;
this.playedIndices = new Set([0]);
return this.songs[0];
}
// Start a fresh cycle: every song is eligible again, but exclude
// the song that just played from THIS pick only, so it doesn't
// repeat back-to-back across the boundary. It stays eligible for
// the rest of the new cycle, so every song still plays exactly once.
this.playedIndices = new Set();
for (let i = 0; i < this.songs.length; i++) {
if (i !== this.currentIndex) unplayed.push(i);
}
}
const nextIndex =
unplayed[Math.floor(Math.random() * unplayed.length)];
this.pushHistory(this.currentIndex);
this.currentIndex = nextIndex;
this.playedIndices.add(nextIndex);
return this.songs[nextIndex];
}
}
}
prev(): QueuedSong | null {
if (this.songs.length === 0) return null;
// 记录当前位置到前进栈,供 next 优先返回
if (this.currentIndex >= 0 && this.forwardStack.length < PlayQueue.HISTORY_LIMIT) {
this.forwardStack.push(this.currentIndex);
}
// Preferred: pop from the back-stack so prev means "the song I
// actually played before this one," not "the previous array slot."
while (this.history.length > 0) {
const idx = this.history.pop()!;
if (idx >= 0 && idx < this.songs.length) {
this.currentIndex = idx;
this.playedIndices = new Set([...this.history, this.currentIndex]);
return this.songs[idx];
}
// Stale entry (song removed) — keep popping.
}
// Fallback: no history to walk back through. In Sequential we
// can still meaningfully step the index backward; in random
// modes there's nothing useful to return.
if (this.mode === PlayMode.Random || this.mode === PlayMode.RandomLoop) {
return null;
}
const prevIndex = this.currentIndex - 1;
if (prevIndex < 0) {
// In Sequential mode, don't wrap around
if (this.mode === PlayMode.Sequential) return null;
this.currentIndex = this.songs.length - 1;
} else {
this.currentIndex = prevIndex;
}
this.playedIndices.add(this.currentIndex);
return this.songs[this.currentIndex];
}
@@ -136,9 +304,48 @@ export class PlayQueue {
setMode(mode: PlayMode): void {
this.mode = mode;
this.playedIndices.clear();
this.history = [];
this.forwardStack = [];
if (this.currentIndex >= 0) {
this.playedIndices.add(this.currentIndex);
}
}
getCurrentIndex(): number {
return this.currentIndex;
}
/** Number of songs not yet played in Random mode. */
unplayedCount(): number {
return this.songs.length - this.playedIndices.size;
}
/**
* Capture the queue as a persistable snapshot (songs minus `url`, current
* index, mode). Songs keep their `requestedBy` so restored play-history
* attribution stays correct. See restore().
*/
snapshot(): QueueSnapshot {
return {
songs: this.songs.map(({ url: _url, ...s }) => s),
currentIndex: this.currentIndex,
mode: this.mode,
};
}
/**
* Replace the queue contents from a snapshot. Rebuilds the derived
* playedIndices/history/forwardStack to a clean, consistent state for the
* restored index (an out-of-range index degrades to -1 = "nothing current").
*/
restore(s: QueueSnapshot): void {
this.songs = s.songs.map((song) => ({ ...song }));
this.mode = s.mode;
this.currentIndex =
s.currentIndex >= 0 && s.currentIndex < this.songs.length ? s.currentIndex : -1;
this.playedIndices = new Set(this.currentIndex >= 0 ? [this.currentIndex] : []);
this.history = [];
this.forwardStack = [];
}
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
import {
decideOccupancyAction,
occupancyFromClientList,
shouldResumeOnReturn,
} from "./auto-pause.js";
describe("decideOccupancyAction", () => {
it("pauses when empty while playing and enabled", () => {
expect(decideOccupancyAction("playing", false, true, 0)).toBe("pause");
});
it("does not pause when the feature is disabled", () => {
expect(decideOccupancyAction("playing", false, false, 0)).toBe("none");
});
it("does not pause when idle (nothing playing)", () => {
expect(decideOccupancyAction("idle", false, true, 0)).toBe("none");
});
it("does not pause when already paused", () => {
expect(decideOccupancyAction("paused", false, true, 0)).toBe("none");
});
it("resumes when re-populated and we auto-paused", () => {
expect(decideOccupancyAction("paused", true, true, 2)).toBe("resume");
});
it("does NOT resume a user-paused track on re-population", () => {
expect(decideOccupancyAction("paused", false, true, 2)).toBe("none");
});
it("does nothing when re-populated and already playing", () => {
expect(decideOccupancyAction("playing", false, true, 2)).toBe("none");
});
it("resume is independent of the enabled flag (we already auto-paused)", () => {
expect(decideOccupancyAction("paused", true, false, 1)).toBe("resume");
});
});
describe("occupancyFromClientList", () => {
it("returns null when the query failed (0 clients — bot itself is always present)", () => {
// This is the bug fix: a clientlist timeout makes getClientsInChannel()
// return [], which must be treated as "unknown", NOT as an empty channel.
expect(occupancyFromClientList(0)).toBeNull();
});
it("returns 0 other users when only the bot is in the channel", () => {
expect(occupancyFromClientList(1)).toBe(0);
});
it("excludes the bot itself from the count", () => {
expect(occupancyFromClientList(2)).toBe(1);
expect(occupancyFromClientList(5)).toBe(4);
});
it("never yields a negative count (guards the -1 that caused false pauses)", () => {
expect(occupancyFromClientList(-3)).toBeNull();
});
});
describe("shouldResumeOnReturn (event-driven auto-resume)", () => {
it("resumes when we auto-paused and are still paused", () => {
// The reported gap: someone returns after an auto-pause. clientlist can't
// confirm it (it times out while they're present), so we resume from the
// clientEnter event alone.
expect(shouldResumeOnReturn(true, "paused")).toBe(true);
});
it("does NOT resume a track the user paused by hand", () => {
expect(shouldResumeOnReturn(false, "paused")).toBe(false);
});
it("does nothing if already playing (e.g. the bot's own enter at connect)", () => {
// autoPaused is cleared to false on connect, so the bot's own clientEnter
// is a no-op; this also covers the playing/auto-paused-flag-stale case.
expect(shouldResumeOnReturn(false, "playing")).toBe(false);
expect(shouldResumeOnReturn(true, "playing")).toBe(false);
});
it("does nothing when idle (nothing to resume)", () => {
expect(shouldResumeOnReturn(true, "idle")).toBe(false);
expect(shouldResumeOnReturn(false, "idle")).toBe(false);
});
});
+67
View File
@@ -0,0 +1,67 @@
export type PlayerStateName = "idle" | "playing" | "paused";
export type OccupancyAction = "pause" | "resume" | "none";
/**
* Convert a channel client-list length into the number of *other* users, or
* `null` when occupancy can't be determined.
*
* A connected bot is always a member of its own channel, so a valid query
* returns at least 1 (the bot itself). A length of 0 therefore does NOT mean
* "empty channel" — it means the underlying `clientlist` query failed (e.g. the
* full-client `clientlist` command times out when other clients are present,
* and `getClientsInChannel()` returns `[]` on error). Treating that failure as
* "empty" is what caused playback to auto-pause within seconds whenever a
* listener was actually in the channel. When the result is indeterminate we
* return `null` so callers skip the auto-pause/idle decision entirely rather
* than mis-reading an unknown state as empty.
*/
export function occupancyFromClientList(clientCount: number): number | null {
if (clientCount <= 0) return null; // query failed → occupancy unknown
return clientCount - 1; // exclude the bot itself
}
/**
* Decide what auto-pause should do given channel occupancy.
* - empty (userCount <= 0): pause iff enabled and currently playing.
* - re-populated (userCount > 0): resume iff we previously auto-paused and are still paused.
* `autoPaused` distinguishes our auto-pause from a user pause, so user pauses are never resumed.
*/
export function decideOccupancyAction(
playerState: PlayerStateName,
autoPaused: boolean,
enabled: boolean,
userCount: number,
): OccupancyAction {
const empty = userCount <= 0;
if (empty) {
if (enabled && playerState === "playing") return "pause";
return "none";
}
if (autoPaused && playerState === "paused") return "resume";
return "none";
}
/**
* Whether a client-presence push event (a `clientEnter`) should trigger an
* auto-resume, WITHOUT consulting a clientlist query.
*
* Why event-driven: the full-client `clientlist`/`channellist` commands time
* out whenever ≥2 clients are connected to the server (a library limitation) —
* which is exactly the moment a listener returns. So occupancy cannot be
* re-queried to confirm the return; we must act on the push event itself.
* This is sound because the bot only ever auto-pauses while it is alone on the
* server (the sole state in which the occupancy query succeeds and pause
* fires). Therefore, while `autoPaused` is true, the only way occupancy can
* return is a fresh connection — delivered reliably as `clientEnter`.
*
* Gating on `autoPaused` (not merely "paused") guarantees we never revive a
* track the user paused by hand, and makes the bot's own `clientEnter` at
* connect a no-op (autoPaused is cleared to false on connect). This predicate
* NEVER pauses — pause stays on the authoritative clientlist path.
*/
export function shouldResumeOnReturn(
autoPaused: boolean,
playerState: PlayerStateName,
): boolean {
return autoPaused && playerState === "paused";
}
+34 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { parseCommand } from "./commands.js";
import { parseCommand, canRunCommand, isAdminCommand } from "./commands.js";
describe("Command Parser", () => {
it("parses simple command", () => {
@@ -60,3 +60,36 @@ describe("Command Parser", () => {
expect(result!.args).toBe("3");
});
});
describe("isAdminCommand classification", () => {
it("treats stop/clear/remove/move/vol/mode as admin", () => {
for (const c of ["stop", "clear", "remove", "move", "vol", "mode"]) {
expect(isAdminCommand(c)).toBe(true);
}
});
it("treats follow and play as NOT admin", () => {
expect(isAdminCommand("follow")).toBe(false);
expect(isAdminCommand("play")).toBe(false);
});
});
describe("canRunCommand", () => {
it("allows any public command regardless of groups", () => {
expect(canRunCommand("play", [], [6])).toBe(true);
expect(canRunCommand("follow", [], [6])).toBe(true);
});
it("allows admin command when enforcement is off (empty adminGroups)", () => {
expect(canRunCommand("stop", [], [])).toBe(true);
});
it("allows admin command when an invoker group matches (string vs number)", () => {
expect(canRunCommand("stop", ["6"], [6])).toBe(true);
expect(canRunCommand("stop", [6], [6])).toBe(true);
expect(canRunCommand("vol", ["8", "6"], [6])).toBe(true);
});
it("denies admin command when no invoker group matches", () => {
expect(canRunCommand("stop", ["8"], [6])).toBe(false);
});
it("denies admin command when invoker has no groups and enforcement is on", () => {
expect(canRunCommand("clear", [], [6])).toBe(false);
});
});
+27 -6
View File
@@ -5,13 +5,13 @@ export interface ParsedCommand {
flags: Set<string>;
}
export const PUBLIC_COMMANDS = new Set([
"play", "add", "queue", "list", "now", "lyrics", "vote", "help",
"playlist", "album", "fm", "prev", "next", "skip", "pause", "resume",
]);
/**
* The fixed set of "admin" chat commands. This is the SINGLE source of truth
* for which commands the permission gate restricts; reclassifying a command is
* a one-line edit here. Everything not in this set is public.
*/
export const ADMIN_COMMANDS = new Set([
"stop", "clear", "move", "vol", "mode", "follow", "remove",
"stop", "clear", "remove", "move", "vol", "mode",
]);
export function parseCommand(
@@ -58,3 +58,24 @@ export function parseCommand(
export function isAdminCommand(commandName: string): boolean {
return ADMIN_COMMANDS.has(commandName);
}
/**
* Decide whether a chat command may run, given the invoker's TS server groups
* and the configured admin groups. Pure + synchronous so it is trivially unit
* tested and reused by the async gate in BotInstance.
*
* Allowed iff: (1) it is a public command, OR (2) enforcement is off
* (adminGroups empty), OR (3) some invoker group is in adminGroups.
* invokerGroups (strings from TS) and adminGroups (numbers) are normalized to
* strings before comparison so "6" matches 6.
*/
export function canRunCommand(
commandName: string,
invokerGroups: readonly (string | number)[],
adminGroups: readonly number[],
): boolean {
if (!isAdminCommand(commandName)) return true;
if (adminGroups.length === 0) return true;
const admin = new Set(adminGroups.map((g) => String(g)));
return invokerGroups.some((g) => admin.has(String(g)));
}
File diff suppressed because it is too large. Load diff
Regular → Executable
+1662 -92
View File
File diff suppressed because it is too large. Load diff
+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;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect, afterEach } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { BotManager } from "./manager.js";
import { createDatabase, type BotDatabase } from "../data/database.js";
import { createPermissionStore } from "../data/permissions.js";
import { getDefaultConfig, loadConfig, saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
import type { MusicProvider } from "../music/provider.js";
import type { AvatarStore } from "../data/avatars.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
// removeBot only calls logger.info; provide the full shape it could touch.
const stubLogger = {
info() {},
warn() {},
error() {},
debug() {},
child() {
return stubLogger;
},
} as unknown as Logger;
describe("BotManager.removeBot — guest scope pruning", () => {
const dirs: string[] = [];
let db: BotDatabase;
function makeTmpConfigPath(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-manager-test-"));
dirs.push(dir);
return join(dir, "config.json");
}
function makeManager(config: BotConfig, configPath: string): BotManager {
db = createDatabase(":memory:");
const permissions = createPermissionStore(db.db);
saveConfig(configPath, config);
return new BotManager(
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
{} as unknown as MusicProvider,
db,
config,
stubLogger,
{} as unknown as AvatarStore,
permissions,
configPath
);
}
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs.length = 0;
});
it("prunes a deleted bot from guestMode.bots (array) and persists", async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = ["botA", "botB"];
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toEqual(["botB"]);
// Persisted file must also reflect the prune.
expect(loadConfig(configPath).guestMode.bots).toEqual(["botB"]);
});
it('leaves guestMode.bots === "all" unchanged (no crash, no change)', async () => {
const configPath = makeTmpConfigPath();
const config = getDefaultConfig();
config.guestMode.bots = "all";
const manager = makeManager(config, configPath);
await manager.removeBot("botA");
expect(config.guestMode.bots).toBe("all");
expect(loadConfig(configPath).guestMode.bots).toBe("all");
});
});
// --- Spotify OAuth threading (Task 6, C3.1) --------------------------------
// The single process-wide SpotifyOAuth built in index.ts must reach every bot's
// SpotifyController: index -> BotManager (trailing positional arg) -> BotInstance
// -> controller. createBot() builds a REAL (side-effect-free) SpotifyController,
// so we assert the shared instance surfaces via the controller's getOAuth().
describe("BotManager — spotifyOAuth threading to bot controllers (C3.1)", () => {
const dirs: string[] = [];
let db: BotDatabase | undefined;
afterEach(() => {
try {
db?.close();
} catch {
/* ignore */
}
db = undefined;
for (const d of dirs) {
rmSync(d, { recursive: true, force: true });
}
dirs.length = 0;
});
it("forwards its shared SpotifyOAuth into a created bot's controller", async () => {
const dir = mkdtempSync(join(tmpdir(), "tsmusicbot-oauth-thread-"));
dirs.push(dir);
const configPath = join(dir, "config.json");
const config = getDefaultConfig();
saveConfig(configPath, config);
db = createDatabase(":memory:");
const permissions = createPermissionStore(db.db);
const provider = {} as unknown as MusicProvider;
const sentinel = {} as unknown as SpotifyOAuth;
const manager = new BotManager(
provider,
provider,
provider,
db,
config,
stubLogger,
{} as unknown as AvatarStore,
permissions,
configPath,
undefined, // localProvider
undefined, // kugouProvider
undefined, // spotifyProvider
join(dir, "spotify"), // spotifyDataDir
sentinel, // spotifyOAuth (the single shared instance)
);
const bot = await manager.createBot({
name: "b1",
serverAddress: "localhost",
serverPort: 9987,
nickname: "b1",
});
// Full chain observed: the manager's single shared instance is the exact
// one the per-bot controller now owns (getOAuth() returns it unchanged).
expect(bot.getSpotifyController().getOAuth()).toBe(sentinel);
bot.disconnect();
});
});
+207 -9
View File
@@ -1,13 +1,58 @@
import crypto from "node:crypto";
import { EventEmitter } from "node:events";
import path from "node:path";
import {
BotInstance,
type BotInstanceOptions,
} from "./instance.js";
import type { MusicProvider } from "../music/provider.js";
import { YouTubeProvider } from "../music/youtube.js";
import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js";
import { saveConfig, type BotConfig } from "../data/config.js";
import type { Logger } from "../logger.js";
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
* server silently drops the connection after initivexpand2), we tear the
* instance down instead of waiting for the library's 60s idle timeout, so
* the HTTP /start call returns promptly and the UI doesn't lock up.
*/
async function connectWithTimeout(
bot: BotInstance,
ms: number,
logger: Logger
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`connect timeout after ${ms}ms`)),
ms
);
});
try {
await Promise.race([bot.connect(), timeout]);
} catch (err) {
logger.warn(
{ err, botId: bot.id },
"Connect failed or timed out — tearing down instance"
);
try {
bot.disconnect();
} catch {
// ignore teardown errors
}
throw err;
} finally {
if (timer) clearTimeout(timer);
}
}
export interface CreateBotParams {
name: string;
serverAddress: string;
@@ -15,18 +60,36 @@ export interface CreateBotParams {
queryPort?: number;
nickname: string;
defaultChannel?: string;
channelId?: string;
channelPassword?: string;
autoStart?: boolean;
/** Force TS3 or TS6 protocol; omit or "unknown" for auto-detect. */
serverProtocol?: ServerProtocol;
/** API key for TS6 HTTP Query (port 10080/10443). */
ts6ApiKey?: string;
/** Password required to join the TS server. */
serverPassword?: string;
}
export class BotManager {
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;
private youtubeProvider: MusicProvider;
private localProvider: MusicProvider;
private kugouProvider: MusicProvider;
private spotifyProvider: MusicProvider;
private jellyfinProvider: MusicProvider;
private spotifyDataDir: string;
private readonly spotifyOAuth?: SpotifyOAuth;
private database: BotDatabase;
private config: BotConfig;
private logger: Logger;
private avatarStore: AvatarStore;
private permissions: PermissionStore;
private configPath: string;
constructor(
neteaseProvider: MusicProvider,
@@ -34,14 +97,40 @@ export class BotManager {
bilibiliProvider: MusicProvider,
database: BotDatabase,
config: BotConfig,
logger: Logger
logger: Logger,
avatarStore: AvatarStore,
permissions: PermissionStore,
configPath: string,
localProvider?: MusicProvider,
kugouProvider?: MusicProvider,
spotifyProvider?: MusicProvider,
spotifyDataDir?: string,
spotifyOAuth?: SpotifyOAuth,
jellyfinProvider?: MusicProvider
) {
super();
this.neteaseProvider = neteaseProvider;
this.qqProvider = qqProvider;
this.bilibiliProvider = bilibiliProvider;
this.youtubeProvider = new YouTubeProvider();
this.localProvider = localProvider ?? neteaseProvider;
this.kugouProvider = kugouProvider ?? neteaseProvider;
this.spotifyProvider = spotifyProvider ?? neteaseProvider;
this.jellyfinProvider = jellyfinProvider ?? neteaseProvider;
this.spotifyDataDir = spotifyDataDir ?? path.join(process.cwd(), "data", "spotify");
this.spotifyOAuth = spotifyOAuth;
// Let the local provider see which uploads are still referenced by any
// bot's queue, so it never deletes a file another queue/bot still needs.
const referenceable = this.localProvider as Partial<{
setInUseResolver: (resolver: () => Set<string>) => void;
}>;
referenceable.setInUseResolver?.(() => this.getReferencedLocalSongIds());
this.database = database;
this.config = config;
this.logger = logger;
this.avatarStore = avatarStore;
this.permissions = permissions;
this.configPath = configPath;
}
async createBot(params: CreateBotParams): Promise<BotInstance> {
@@ -56,17 +145,31 @@ export class BotManager {
queryPort: params.queryPort ?? 10011,
nickname: params.nickname,
defaultChannel: params.defaultChannel,
channelId: params.channelId,
channelPassword: params.channelPassword,
serverPassword: params.serverPassword,
serverProtocol: params.serverProtocol,
ts6ApiKey: params.ts6ApiKey,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
this.database.saveBotInstance({
id,
@@ -75,8 +178,12 @@ export class BotManager {
serverPort: params.serverPort,
nickname: params.nickname,
defaultChannel: params.defaultChannel ?? "",
channelId: params.channelId ?? "",
channelPassword: params.channelPassword ?? "",
autoStart: params.autoStart ?? false,
serverProtocol: params.serverProtocol ?? "",
ts6ApiKey: params.ts6ApiKey ?? "",
serverPassword: params.serverPassword ?? "",
});
this.logger.info({ botId: id, name: params.name }, "Bot instance created");
@@ -90,6 +197,13 @@ export class BotManager {
this.bots.delete(id);
}
this.database.deleteBotInstance(id);
this.permissions.pruneBot(id);
// Prune the deleted bot from the guest scope allow-list (mirrors permissions.pruneBot).
if (Array.isArray(this.config.guestMode.bots) && this.config.guestMode.bots.includes(id)) {
this.config.guestMode.bots = this.config.guestMode.bots.filter((b) => b !== id);
saveConfig(this.configPath, this.config);
}
this.emit("botInstanceRemoved", id);
this.logger.info({ botId: id }, "Bot instance removed");
}
@@ -105,7 +219,11 @@ export class BotManager {
serverPort: params.serverPort ?? existing.serverPort,
nickname: params.nickname ?? existing.nickname,
defaultChannel: params.defaultChannel ?? existing.defaultChannel,
channelId: params.channelId ?? existing.channelId,
channelPassword: params.channelPassword ?? existing.channelPassword,
serverProtocol: params.serverProtocol ?? existing.serverProtocol,
ts6ApiKey: params.ts6ApiKey ?? existing.ts6ApiKey,
serverPassword: params.serverPassword ?? existing.serverPassword,
});
// Update in-memory name immediately (other fields need reconnect)
const bot = this.bots.get(id);
@@ -127,16 +245,81 @@ export class BotManager {
return Array.from(this.bots.values());
}
async startBot(id: string): Promise<void> {
const bot = this.bots.get(id);
if (!bot) throw new Error(`Bot ${id} not found`);
await bot.connect();
/** Local upload ids still referenced by any bot's queue. The local provider
* uses this to avoid deleting a file another queue/bot is still using. */
getReferencedLocalSongIds(): Set<string> {
const ids = new Set<string>();
for (const bot of this.bots.values()) {
for (const song of bot.getQueueManager().list()) {
if (song.platform === "local") ids.add(song.id);
}
}
return ids;
}
// Mark as autoStart so it reconnects on Docker restart, and persist identity
async startBot(id: string): Promise<void> {
const oldBot = this.bots.get(id);
if (!oldBot) throw new Error(`Bot ${id} not found`);
// Always tear down the outgoing instance before creating a replacement.
// Covers three cases:
// 1. oldBot is fully connected (manual restart)
// 2. oldBot is mid-handshake from a prior rapid start (isConnected()
// still returns false but the library client is live and will leak
// a TS session if we abandon it)
// 3. oldBot was just created by createBot but never connected — the
// disconnect call is a cheap no-op here.
// Calling disconnect() is idempotent (disconnectEmitted guards event
// emission), so this is safe in all states.
oldBot.disconnect();
// Reload config from database so updated settings (channel, nickname, etc.) take effect
const saved = this.database.getBotInstances().find((i) => i.id === id);
if (saved) {
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
// Reuse the stored identity so server groups assigned to this bot
// survive restarts — without this the TS server sees a new UID
// each connect and strips all previously granted groups.
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelId: saved.channelId || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(id, bot);
this.emit("botInstance", bot);
await connectWithTimeout(bot, 15_000, this.logger);
// Mark as autoStart so it reconnects on Docker restart, and persist identity
this.database.saveBotInstance({ ...saved, autoStart: true });
this.persistBotIdentity(saved, bot);
} else {
await connectWithTimeout(oldBot, 15_000, this.logger);
}
}
@@ -155,27 +338,42 @@ export class BotManager {
async loadSavedBots(): Promise<void> {
const savedInstances = this.database.getBotInstances();
for (const saved of savedInstances) {
const proto = saved.serverProtocol as "ts3" | "ts6" | "" | undefined;
const bot = new BotInstance({
id: saved.id,
name: saved.name,
tsOptions: {
host: saved.serverAddress,
port: saved.serverPort,
queryPort: 10011,
queryPort: proto === "ts6" ? 10080 : 10011,
nickname: saved.nickname,
identity: saved.identity || undefined,
defaultChannel: saved.defaultChannel || undefined,
channelId: saved.channelId || undefined,
channelPassword: saved.channelPassword || undefined,
serverPassword: saved.serverPassword || undefined,
serverProtocol: proto === "ts3" || proto === "ts6" ? proto : undefined,
ts6ApiKey: saved.ts6ApiKey || undefined,
},
neteaseProvider: this.neteaseProvider,
qqProvider: this.qqProvider,
bilibiliProvider: this.bilibiliProvider,
youtubeProvider: this.youtubeProvider,
localProvider: this.localProvider,
kugouProvider: this.kugouProvider,
spotifyProvider: this.spotifyProvider,
jellyfinProvider: this.jellyfinProvider,
database: this.database,
config: this.config,
logger: this.logger,
avatarStore: this.avatarStore,
managedVoiceClients: this.managedVoiceClients,
spotifyDataDir: this.spotifyDataDir,
spotifyOAuth: this.spotifyOAuth,
});
this.bots.set(saved.id, bot);
this.emit("botInstance", bot);
// Only auto-connect bots that have autoStart enabled
if (saved.autoStart) {
+204
View File
@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { BotProfileManager } from "./profile.js";
import type { TS3Client } from "../ts-protocol/client.js";
import type { QueuedSong } from "../audio/queue.js";
function makeMockTs(): TS3Client & {
uploadCalls: Buffer[];
clearCalls: number;
} {
const calls: Buffer[] = [];
let clears = 0;
const ts: any = {
uploadCalls: calls,
get clearCalls() { return clears; },
getHost: () => "127.0.0.1",
getHttpQuery: () => null,
fileTransferInitUpload: vi.fn().mockResolvedValue({}),
uploadFileData: vi.fn().mockImplementation(async (_h: any, _i: any, stream: any) => {
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
calls.push(Buffer.concat(chunks));
}),
fileTransferDeleteFile: vi.fn().mockResolvedValue(undefined),
sendCommandNoWait: vi.fn().mockImplementation(async (cmd: string) => {
if (/client_flag_avatar=$/.test(cmd)) clears++;
}),
};
return ts;
}
const noopLogger: any = { child: () => noopLogger, info: () => {}, debug: () => {}, warn: () => {}, error: () => {} };
const cfgOn = { avatarEnabled: true, descriptionEnabled: false, nicknameEnabled: false, awayStatusEnabled: false, channelDescEnabled: false, nowPlayingMsgEnabled: false };
const cfgOff = { ...cfgOn, avatarEnabled: false };
const fakeSong: QueuedSong = {
id: "1",
name: "X",
artist: "Y",
album: "Z",
platform: "netease",
url: "u",
coverUrl: "c",
duration: 100,
};
const flush = () => new Promise((r) => setImmediate(r));
describe("BotProfileManager custom avatar precedence", () => {
let ts: ReturnType<typeof makeMockTs>;
beforeEach(() => { ts = makeMockTs(); });
it("setCustomAvatar uploads immediately on a fresh idle bot (sync on)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([1, 2, 3]))).toBe(true);
});
it("setCustomAvatar uploads immediately when sync is off (always idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([7]));
await flush();
expect(ts.uploadCalls.length).toBe(1);
});
it("setCustomAvatar while playing + sync on does NOT push (cover wins)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
// Simulate the bot playing a song. We can't actually run updateAvatar's
// full HTTP fetch path, but onSongChange records currentSong before
// updateAvatar runs, which is enough for this assertion.
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore); // no new upload
});
it("setCustomAvatar while playing + sync off DOES push (sync-off is idle)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
void pm.onSongChange(fakeSong);
await flush();
const uploadsBefore = ts.uploadCalls.length;
pm.setCustomAvatar(Buffer.from([42]));
await flush();
expect(ts.uploadCalls.length).toBe(uploadsBefore + 1);
});
it("setCustomAvatar(null) while idle clears the TS3 avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1]));
await flush();
const clearsBefore = ts.clearCalls;
pm.setCustomAvatar(null);
await flush();
expect(ts.clearCalls).toBe(clearsBefore + 1);
});
it("on stop with custom avatar set + sync on, restores custom (does not clear)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([1, 2, 3, 4]));
await flush();
const clearsBefore = ts.clearCalls;
await pm.onSongChange(null);
expect(ts.uploadCalls.at(-1)?.equals(Buffer.from([1, 2, 3, 4]))).toBe(true);
expect(ts.clearCalls).toBe(clearsBefore); // no extra clear
});
it("on stop with no custom avatar, falls back to clear", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
await pm.onSongChange(null);
expect(ts.clearCalls).toBe(1);
expect(ts.uploadCalls.length).toBe(0);
});
it("on connect with custom avatar set + sync ON, applies custom (spec matrix row 1)", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOn, "Bot");
pm.setCustomAvatar(Buffer.from([5, 5]));
await flush();
ts.uploadCalls.length = 0; // reset
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([5, 5]))).toBe(true);
});
it("on connect with custom avatar set + sync OFF, applies custom", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.setCustomAvatar(Buffer.from([9, 9]));
await flush();
ts.uploadCalls.length = 0;
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(1);
expect(ts.uploadCalls[0].equals(Buffer.from([9, 9]))).toBe(true);
});
it("on connect with no custom avatar, does not touch avatar", async () => {
const pm = new BotProfileManager(ts as any, noopLogger, cfgOff, "Bot");
pm.onConnect();
await flush();
expect(ts.uploadCalls.length).toBe(0);
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);
});
});
+514
View File
@@ -0,0 +1,514 @@
import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import axios from "axios";
import { TS3Client, escapeTS3 } from "../ts-protocol/client.js";
import { HttpQueryError } from "../ts-protocol/http-query.js";
import type { ProfileConfig } from "../data/database.js";
import type { QueuedSong } from "../audio/queue.js";
import type { Logger } from "../logger.js";
const TS3_NICKNAME_MAX = 30;
/** TS3 avatar max size — server default is ~300 KB. Use 200 KB to be safe. */
const AVATAR_MAX_BYTES = 200 * 1024;
/** Timeout for file-transfer operations (upload / delete). */
const FILE_TRANSFER_TIMEOUT_MS = 6000;
/**
* Manages the bot's TeamSpeak presence (avatar, description, nickname,
* away status, channel description, now-playing messages).
*
* Every update is permission-safe: if a feature fails due to insufficient
* server permissions, it silently disables itself until the next reconnect.
*/
export class BotProfileManager {
private tsClient: TS3Client;
private logger: Logger;
private config: ProfileConfig;
private defaultNickname: string;
private customAvatar: Buffer | null = null;
/**
* Tracks the last song handed to onSongChange. null means stopped/idle.
* Used by setCustomAvatar to decide whether the new buffer should be
* pushed immediately (idle) or wait for the next stop event (playing).
*/
private currentSong: QueuedSong | null = null;
/** Per-feature permission-denied flags. Reset on reconnect. */
private permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
/**
* Monotonically increasing generation counter. Incremented on every
* onSongChange / onConnect call. Long-running operations (avatar
* download/upload) check this before committing their result — if
* the generation changed, a newer update has superseded them.
*/
private generation = 0;
constructor(
tsClient: TS3Client,
logger: Logger,
config: ProfileConfig,
defaultNickname: string,
) {
this.tsClient = tsClient;
this.logger = logger.child({ component: "profile" });
this.config = { ...config };
this.defaultNickname = defaultNickname;
}
// --- 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.
*
* If the bot is currently in an idle state (no song playing OR
* avatarEnabled is off), the new buffer is pushed to TS3 right away;
* otherwise the cover-art sync is in charge until the next stop event,
* at which point clearAvatar restores from this.customAvatar.
*/
setCustomAvatar(buffer: Buffer | null): void {
this.customAvatar = buffer;
const idle = this.currentSong === null || !this.config.avatarEnabled;
if (!idle) return;
const gen = ++this.generation;
if (buffer && buffer.length > 0) {
void this.applyIdleAvatar(gen);
} else {
void this.clearAvatar(gen);
}
}
/**
* Called when a new song starts playing (song != null) or playback
* stops (song == null).
*
* Commands are serialized to avoid overwhelming the TS3 command queue.
* Nickname + away status are merged into a single `clientupdate` call.
*
* A generation counter guards against stale updates: if a newer
* onSongChange fires while the avatar is still downloading, the old
* update is discarded.
*/
async onSongChange(song: QueuedSong | null): Promise<void> {
const gen = ++this.generation;
this.currentSong = song;
// 1. Avatar first — file transfer uses its own response tracker and
// must run before sendCommandNoWait calls whose orphaned responses
// could confuse the command matcher.
await this.updateAvatar(song?.coverUrl ?? null, gen);
if (this.generation !== gen) return; // superseded
// 2. Combined clientupdate (nickname + away in one fire-and-forget)
await this.updateClientProperties(song);
// 3. Description (clientedit on TS3, httpQuery on TS6)
await this.updateDescription(song);
// 4. Channel description (fire-and-forget channeledit)
await this.updateChannelDescription(song);
// 5. Now-playing chat message
if (song) await this.sendNowPlayingMessage(song);
}
/** Reset permission-denied flags and bump generation on new connection. */
onConnect(): void {
this.generation++;
this.currentSong = null;
this.permDenied = {
avatar: false,
description: false,
nickname: false,
awayStatus: false,
channelDesc: false,
nowPlayingMsg: false,
};
// No song is playing on a fresh connect, so the matrix says the
// custom avatar should be visible regardless of avatarEnabled.
if (this.customAvatar) {
const gen = this.generation;
void this.applyIdleAvatar(gen);
}
}
getConfig(): ProfileConfig {
return { ...this.config };
}
updateConfig(partial: Partial<ProfileConfig>): void {
Object.assign(this.config, partial);
}
// --- Internal update methods ---
private async updateAvatar(coverUrl: string | null, gen: number): Promise<void> {
if (!this.config.avatarEnabled || this.permDenied.avatar) return;
try {
if (!coverUrl) {
await this.clearAvatar(gen);
return;
}
// Request a thumbnail from the CDN to stay within TS3's avatar size limit.
const thumbUrl = this.thumbnailUrl(coverUrl);
const imageBuffer = await this.downloadImage(thumbUrl);
// Check generation after the slow download — bail if superseded.
if (this.generation !== gen) return;
if (!imageBuffer || imageBuffer.length === 0) return;
if (imageBuffer.length > AVATAR_MAX_BYTES) {
this.logger.warn(
{ bytes: imageBuffer.length, max: AVATAR_MAX_BYTES },
"Cover image still too large after resize — skipping avatar update",
);
return;
}
// Wrap the file-transfer sequence with a timeout — the TS3
// full-client file transfer can silently hang.
const start = Date.now();
await this.withTimeout(this.doAvatarUpload(imageBuffer), FILE_TRANSFER_TIMEOUT_MS);
this.logger.info(
{ bytes: imageBuffer.length, elapsedMs: Date.now() - start },
"Avatar updated",
);
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
/**
* Three-step upload. Each step is logged so the log can tell us whether
* a broken/loading avatar on the client is from:
* (a) init failing (no permission)
* (b) file transfer hanging on TCP 30033
* (c) client_flag_avatar not applying
* If (b) happens, the avatar MD5 would still be set in the past — leaving
* clients showing a placeholder. The flag is now only set after the TCP
* transfer resolves.
*/
private async doAvatarUpload(imageBuffer: Buffer): Promise<void> {
const host = this.tsClient.getHost();
this.logger.debug({ bytes: imageBuffer.length, host }, "Avatar: init file transfer");
const info = await this.tsClient.fileTransferInitUpload(
0n, "/avatar", "", BigInt(imageBuffer.length), true,
);
this.logger.debug({ bytes: imageBuffer.length }, "Avatar: uploading file data");
await this.tsClient.uploadFileData(host, info, Readable.from(imageBuffer));
const md5 = createHash("md5").update(imageBuffer).digest("hex");
this.logger.debug({ md5 }, "Avatar: setting client_flag_avatar");
await this.tsClient.sendCommandNoWait(`clientupdate client_flag_avatar=${escapeTS3(md5)}`);
}
private async clearAvatar(gen: number): Promise<void> {
if (this.customAvatar && this.customAvatar.length > 0) {
await this.applyIdleAvatar(gen);
return;
}
try {
await this.withTimeout(
this.tsClient.fileTransferDeleteFile(0n, ["/avatar"]),
FILE_TRANSFER_TIMEOUT_MS,
);
} catch {
// File may not exist or transfer timed out — that's fine
}
if (this.generation !== gen) return;
try {
await this.tsClient.sendCommandNoWait("clientupdate client_flag_avatar=");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async applyIdleAvatar(gen: number): Promise<void> {
if (!this.customAvatar || this.customAvatar.length === 0) return;
if (this.permDenied.avatar) return;
try {
await this.withTimeout(this.doAvatarUpload(this.customAvatar), FILE_TRANSFER_TIMEOUT_MS);
if (this.generation !== gen) return;
this.logger.info({ bytes: this.customAvatar.length }, "Idle (custom) avatar applied");
} catch (err) {
this.handleFeatureError("avatar", err);
}
}
private async updateDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.descriptionEnabled || this.permDenied.description) return;
try {
const text = song
? `${song.name} - ${song.artist} [${song.album}]`
: "";
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6 HTTP API: send the raw (unescaped) text. clientUpdate
// throws HttpQueryError on non-2xx so a silent 400/403 cannot
// be misreported as success.
const result = await httpQuery.clientUpdate({ client_description: text });
this.logger.info({ status: result.status }, "Description updated");
} else {
// clientupdate rejects client_description (error 1538).
// Use clientedit on our own clid instead — this is what
// TS3AudioBot does via TSLib's ChangeDescription().
const clid = this.tsClient.getClientId();
if (clid <= 0) return;
// Use a 5s timeout — if clientedit hangs, don't block the
// remaining profile updates (channeledit, now-playing msg).
await this.withTimeout(
this.tsClient.execCommand(
`clientedit clid=${clid} client_description=${escapeTS3(text)}`,
),
5000,
);
this.logger.info("Description updated");
}
} catch (err) {
this.handleFeatureError("description", err);
}
}
/**
* Build and send a single `clientupdate` command that sets nickname
* and away status together, avoiding multiple round-trips that can
* cause command-queue timeouts on the TS3 protocol.
*
* Values are collected as raw strings/numbers. The TS6 HTTP path
* forwards them as JSON (the server expects real spaces, not `\s`);
* the TS3 wire path escapes them on the fly. Previously the code
* escaped upfront and then split the escaped string to build the
* JSON body, so TS6 received literal backslashes and silently
* rejected the update.
*/
private async updateClientProperties(song: QueuedSong | null): Promise<void> {
const rawProps: Record<string, string | number> = {};
// --- Nickname ---
if (this.config.nicknameEnabled && !this.permDenied.nickname) {
if (!song) {
rawProps.client_nickname = this.defaultNickname;
} else {
const nickname = this.buildNickname(song);
if (nickname) {
rawProps.client_nickname = nickname;
}
}
}
// --- Away status ---
if (this.config.awayStatusEnabled && !this.permDenied.awayStatus) {
if (song) {
rawProps.client_away = 0;
} else {
rawProps.client_away = 1;
rawProps.client_away_message = "\u7B49\u5F85\u64AD\u653E";
}
}
if (Object.keys(rawProps).length === 0) return;
try {
const httpQuery = this.tsClient.getHttpQuery();
if (httpQuery) {
// TS6: send raw values as JSON. Throws HttpQueryError on 4xx/5xx.
const result = await httpQuery.clientUpdate(rawProps);
this.logger.info(
{ status: result.status, props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
} else {
// TS3 wire protocol: escape string values inline.
// sendCommandNoWait: the TS3 full-client protocol often
// doesn't return a timely error response for clientupdate,
// causing execCommand to time out after 10s.
const parts = Object.entries(rawProps).map(([k, v]) =>
typeof v === "string" ? `${k}=${escapeTS3(v)}` : `${k}=${v}`,
);
await this.tsClient.sendCommandNoWait(`clientupdate ${parts.join(" ")}`);
this.logger.info(
{ props: Object.keys(rawProps) },
"Client properties updated (nickname + away)",
);
}
} catch (err) {
// Flag both features on permission error
this.handleFeatureError("nickname", err);
this.handleFeatureError("awayStatus", err);
}
}
/**
* Build a nickname string that fits within TS3_NICKNAME_MAX.
* Uses UTF-8 byte length for the limit since TS3 counts bytes,
* not characters.
*/
private buildNickname(song: QueuedSong): string | null {
const songInfo = `${song.name} - ${song.artist}`;
const prefix = "\u266A "; // ♪
const sep = " - ";
const suffix = `${sep}${this.defaultNickname}`;
const overheadBytes = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
if (overheadBytes >= TS3_NICKNAME_MAX) {
// Default nickname alone is too long with decoration — skip
return null;
}
const maxSongBytes = TS3_NICKNAME_MAX - overheadBytes;
const truncated = this.truncateUtf8(songInfo, maxSongBytes);
return `${prefix}${truncated}${suffix}`;
}
/**
* Truncate a string so its UTF-8 byte length does not exceed maxBytes.
* Appends an ellipsis if truncation occurred, taking its byte cost
* into account. Never splits a multi-byte character.
*/
private truncateUtf8(str: string, maxBytes: number): string {
if (Buffer.byteLength(str, "utf8") <= maxBytes) return str;
const ellipsis = "\u2026"; // …
const ellipsisBytes = Buffer.byteLength(ellipsis, "utf8"); // 3
const target = maxBytes - ellipsisBytes;
if (target <= 0) return ellipsis;
// Walk characters, accumulating byte length
let byteLen = 0;
let end = 0;
for (const ch of str) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (byteLen + chBytes > target) break;
byteLen += chBytes;
end += ch.length; // ch.length handles surrogate pairs
}
return str.slice(0, end) + ellipsis;
}
private async updateChannelDescription(song: QueuedSong | null): Promise<void> {
if (!this.config.channelDescEnabled || this.permDenied.channelDesc) return;
try {
const channelId = this.tsClient.getChannelId();
if (channelId === 0n) return; // unknown channel
if (!song) {
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=`,
);
return;
}
const lines = [
`\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist}`, // ♪ 正在播放:
`\u4E13\u8F91: ${song.album}`, // 专辑:
`\u5E73\u53F0: ${song.platform}`, // 平台:
];
const desc = lines.join("\\n");
await this.tsClient.sendCommandNoWait(
`channeledit cid=${channelId} channel_description=${escapeTS3(desc)}`,
);
} catch (err) {
this.handleFeatureError("channelDesc", err);
}
}
private async sendNowPlayingMessage(song: QueuedSong): Promise<void> {
if (!this.config.nowPlayingMsgEnabled || this.permDenied.nowPlayingMsg) return;
try {
const text = `\u266A \u6B63\u5728\u64AD\u653E: ${song.name} - ${song.artist} [${song.album}]`;
await this.tsClient.sendTextMessage(text);
} catch (err) {
this.handleFeatureError("nowPlayingMsg", err);
}
}
// --- Helpers ---
/**
* Append CDN resize parameters to get a thumbnail suitable for TS3 avatars.
* NetEase and QQ Music CDNs support URL-based image resizing.
* BiliBili and YouTube covers fall through to the size-check guard.
*/
private thumbnailUrl(url: string): string {
if (url.includes("music.126.net") || url.includes("netease")) {
return url.includes("?") ? url : `${url}?param=200y200`;
}
if (url.includes("qqmusic") || url.includes("qq.com")) {
return url.replace(/\/\d+$/, "/200");
}
if (url.includes("bilivideo") || url.includes("hdslb")) {
// BiliBili CDN supports @<w>w_<h>h suffix
return url.includes("@") ? url : `${url}@200w_200h`;
}
return url;
}
private async downloadImage(url: string): Promise<Buffer | null> {
try {
const resp = await axios.get(url, {
responseType: "arraybuffer",
timeout: 8000,
maxContentLength: 2 * 1024 * 1024, // 2 MB cap
});
return Buffer.from(resp.data);
} catch (err) {
this.logger.warn({ err, url }, "Failed to download cover image");
return null;
}
}
/** Race a promise against a timeout. */
private withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms),
),
]);
}
private handleFeatureError(
feature: keyof typeof this.permDenied,
err: unknown,
): void {
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
const status = err instanceof HttpQueryError ? err.status : undefined;
const body = err instanceof HttpQueryError ? err.body : undefined;
// Disable the feature for this session on unrecoverable errors:
// - permission / insufficient → server denies the action
// - invalid parameter → command not supported by this protocol
// - HTTP 401/403 → TS6 server rejects the API key/role
// - HTTP 400 → bad parameter; retrying on every song change is wasteful
const isUnrecoverable =
msg.includes("permission") ||
msg.includes("insufficient") ||
msg.includes("invalid parameter") ||
status === 400 ||
status === 401 ||
status === 403;
if (isUnrecoverable) {
this.permDenied[feature] = true;
this.logger.info(
{ feature, status, body, reason: msg },
"Feature disabled for this session (will retry after reconnect)",
);
} else {
this.logger.warn({ feature, status, body, err }, "Profile update failed");
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import { parseSongRef, parseSelectionIndex } from "./song-ref.js";
describe("parseSongRef (#90 exact-song selection)", () => {
it("returns null for a plain search term", () => {
expect(parseSongRef("Die For You")).toBeNull();
expect(parseSongRef("周杰伦 晴天")).toBeNull();
expect(parseSongRef("")).toBeNull();
// A bare number is NOT treated as an id (a song may be named "2002").
expect(parseSongRef("2002")).toBeNull();
});
it("parses an explicit id: prefix with no platform (defer to flags)", () => {
expect(parseSongRef("id:185868")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("ID: 004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: null });
});
it("strips trailing punctuation from a pasted id:", () => {
expect(parseSongRef("id:185868.")).toEqual({ id: "185868", platform: null });
expect(parseSongRef("id:185868)")).toEqual({ id: "185868", platform: null });
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".
expect(parseSongRef("https://music.163.com/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/#/playlist?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/album?id=123456")).toBeNull();
expect(parseSongRef("https://music.163.com/artist?id=185858")).toBeNull();
// A genuine song URL is still parsed.
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses NetEase song URLs", () => {
expect(parseSongRef("https://music.163.com/song?id=185868")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("https://music.163.com/#/song?id=185868&userid=1")).toEqual({ id: "185868", platform: "netease" });
expect(parseSongRef("music.163.com/song/185868")).toEqual({ id: "185868", platform: "netease" });
});
it("parses QQ song URLs", () => {
expect(parseSongRef("https://y.qq.com/n/ryqq/songDetail/004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
expect(parseSongRef("https://y.qq.com/n/yqq/song/abc.html?songmid=004Z8Ihr0JIu5s")).toEqual({ id: "004Z8Ihr0JIu5s", platform: "qq" });
});
it("parses BiliBili BV ids (bare or in a URL)", () => {
expect(parseSongRef("BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://www.bilibili.com/video/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
expect(parseSongRef("https://b23.tv/BV1yxHQeYEuE")).toEqual({ id: "BV1yxHQeYEuE", platform: "bilibili" });
});
});
describe("parseSelectionIndex (#90 pick from last search)", () => {
it("parses #N tokens (1-based)", () => {
expect(parseSelectionIndex("#1")).toBe(1);
expect(parseSelectionIndex("#2")).toBe(2);
expect(parseSelectionIndex("# 3")).toBe(3);
expect(parseSelectionIndex(" #10 ")).toBe(10);
});
it("rejects non-selections", () => {
expect(parseSelectionIndex("2")).toBeNull();
expect(parseSelectionIndex("#0")).toBeNull();
expect(parseSelectionIndex("#-1")).toBeNull();
expect(parseSelectionIndex("Die For You")).toBeNull();
expect(parseSelectionIndex("#2 extra")).toBeNull();
expect(parseSelectionIndex("")).toBeNull();
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* 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
* most-popular search hit (issue #90).
*
* Two mechanisms:
* - A song reference: an explicit id / platform URL → play that exact song.
* - A selection index: "#N" → the Nth result of the previous !search.
*/
export interface SongRef {
id: string;
/**
* Platform inferred from a URL. `null` means the platform wasn't encoded in
* the reference (e.g. a bare `id:`), so the caller should fall back to the
* command's flags / default provider.
*/
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>` / `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
* Returns `null` for a plain search term (the common case).
*/
export function parseSongRef(raw: string): SongRef | null {
const q = (raw ?? "").trim();
if (!q) return 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).
const bv = /BV[0-9A-Za-z]{8,12}/.exec(q);
if (bv && (/^BV[0-9A-Za-z]{8,12}$/.test(q) || /bilibili\.com|b23\.tv/i.test(q))) {
return { id: bv[0], platform: "bilibili" };
}
// NetEase song URL. Only treat `id=N` as a SONG id when the URL is not a
// collection page (playlist/album/artist/toplist/djradio) — those reuse the
// same `id=` param but are NOT songs; getSongDetail() would 404 them into a
// confusing "no song" error instead of falling back to a normal search.
if (/music\.163\.com/i.test(q) && !/(playlist|album|artist|toplist|djradio)/i.test(q)) {
const m = /[?&#/]id=(\d+)/.exec(q) ?? /\/song\/(\d+)/.exec(q);
if (m) return { id: m[1], platform: "netease" };
}
// QQ song URL.
if (/y\.qq\.com/i.test(q)) {
const m = /songDetail\/([0-9A-Za-z]+)/.exec(q) ?? /[?&]songmid=([0-9A-Za-z]+)/i.exec(q);
if (m) return { id: m[1], platform: "qq" };
}
return null;
}
/**
* Detect a "#N" selection token (1-based) referencing the previous !search.
* Returns the positive integer, or `null` when the query isn't a selection.
*/
export function parseSelectionIndex(raw: string): number | null {
const m = /^#\s*(\d+)$/.exec((raw ?? "").trim());
if (!m) return null;
const n = parseInt(m[1], 10);
return Number.isFinite(n) && n > 0 ? n : null;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { splitTextIntoChunks } from "./text-chunk.js";
const bytes = (s: string) => Buffer.byteLength(s, "utf8");
describe("splitTextIntoChunks", () => {
it("returns a single chunk for a short string", () => {
const chunks = splitTextIntoChunks("hello world", 900);
expect(chunks).toEqual(["hello world"]);
});
it("splits a multi-line string longer than maxBytes into multiple chunks on line boundaries", () => {
const lines = Array.from({ length: 50 }, (_, i) => `line number ${i}`);
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 60);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(60);
}
// No hard-split of any line occurred, so rejoining with "\n" is lossless.
expect(chunks.join("\n")).toBe(text);
});
it("bounds by BYTES not chars: multibyte (Chinese) content stays under the cap", () => {
// Each Chinese char is 3 bytes in UTF-8. 40 chars/line = 120 bytes/line.
const lines = Array.from({ length: 10 }, () => "歌词".repeat(20));
const text = lines.join("\n");
const chunks = splitTextIntoChunks(text, 150);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(150);
}
expect(chunks.join("\n")).toBe(text);
});
it("hard-splits a single over-long line so no chunk exceeds the cap", () => {
const longLine = "a".repeat(500);
const chunks = splitTextIntoChunks(longLine, 100);
expect(chunks.length).toBeGreaterThan(1);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(100);
}
// Content is preserved (hard-split introduces split points, not \n).
expect(chunks.join("")).toBe(longLine);
});
it("never splits a multibyte character across a hard-split boundary", () => {
// 200 Chinese chars = 600 bytes on ONE line, cap 40 bytes.
const longLine = "歌".repeat(200);
const chunks = splitTextIntoChunks(longLine, 40);
for (const c of chunks) {
expect(bytes(c)).toBeLessThanOrEqual(40);
// A clean re-decode: every chunk is valid UTF-8 with no replacement char.
expect(c.includes("�")).toBe(false);
}
expect(chunks.join("")).toBe(longLine);
});
it("preserves blank lines within a single chunk", () => {
const text = "a\n\nb";
expect(splitTextIntoChunks(text, 900)).toEqual([text]);
});
});
+74
View File
@@ -0,0 +1,74 @@
/**
* Split `text` into chunks whose UTF-8 byte length never exceeds `maxBytes`.
*
* TeamSpeak enforces a per-message byte cap (~1024 bytes), and the send path
* does no chunking, so a long single reply (e.g. full song lyrics) would be
* truncated or rejected. This packs whole lines greedily, breaking BETWEEN
* lines. When a single line is itself longer than `maxBytes`, it is hard-split
* on UTF-8 character boundaries so no chunk ever exceeds the cap and no
* multibyte character is ever cut in half.
*
* Content is preserved on rejoin, modulo the split points: chunks split only on
* newline boundaries rejoin losslessly with `chunks.join("\n")`; a hard-split
* long line rejoins with `chunks.join("")`.
*
* @param text The full message text.
* @param maxBytes Max UTF-8 bytes per chunk (default 900 — under TS's ~1024 cap
* with headroom for protocol framing/escaping).
*/
export function splitTextIntoChunks(text: string, maxBytes = 900): string[] {
const chunks: string[] = [];
let current = "";
const flush = (): void => {
if (current !== "") {
chunks.push(current);
current = "";
}
};
for (const rawLine of text.split("\n")) {
const pieces =
Buffer.byteLength(rawLine, "utf8") > maxBytes
? hardSplitByBytes(rawLine, maxBytes)
: [rawLine];
for (const piece of pieces) {
const candidate = current === "" ? piece : `${current}\n${piece}`;
if (Buffer.byteLength(candidate, "utf8") <= maxBytes) {
current = candidate;
} else {
// current is guaranteed non-empty here: pieces never exceed maxBytes,
// so an empty `current` always accepts the next piece above.
flush();
current = piece;
}
}
}
flush();
return chunks;
}
/**
* Break a single line into pieces each ≤ `maxBytes` UTF-8 bytes, never cutting
* a character (iterates code points, so surrogate pairs stay intact).
*/
function hardSplitByBytes(line: string, maxBytes: number): string[] {
const pieces: string[] = [];
let current = "";
let currentBytes = 0;
for (const ch of line) {
const chBytes = Buffer.byteLength(ch, "utf8");
if (currentBytes + chBytes > maxBytes && current !== "") {
pieces.push(current);
current = "";
currentBytes = 0;
}
current += ch;
currentBytes += chBytes;
}
if (current !== "") pieces.push(current);
return pieces;
}
+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;
}
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createAuditStore, type AuditStore } from "./audit.js";
describe("AuditStore", () => {
let botDb: BotDatabase;
let audit: AuditStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
audit = createAuditStore(botDb.db);
});
afterEach(() => botDb.close());
it("records and lists entries newest-first", async () => {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.created",
});
await new Promise((r) => setTimeout(r, 5));
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: "b1", targetUsername: "bob",
action: "user.deleted",
});
const list = audit.list(10, 0);
expect(list).toHaveLength(2);
expect(list[0].action).toBe("user.deleted");
expect(list[1].action).toBe("user.created");
});
it("supports limit and offset", () => {
for (let i = 0; i < 5; i++) {
audit.record({
actorId: "a1", actorUsername: "alice",
targetUserId: null, targetUsername: null,
action: "user.password_changed",
});
}
expect(audit.list(2, 0)).toHaveLength(2);
expect(audit.list(2, 4)).toHaveLength(1);
expect(audit.list(10, 10)).toHaveLength(0);
});
it("stores nullable fields correctly", () => {
audit.record({
actorId: null, actorUsername: null,
targetUserId: "x", targetUsername: "deleted-user",
action: "admin.first_created",
});
const e = audit.list(1, 0)[0];
expect(e.actorId).toBeNull();
expect(e.actorUsername).toBeNull();
expect(e.targetUserId).toBe("x");
});
});
+58
View File
@@ -0,0 +1,58 @@
import type Database from "better-sqlite3";
export type AuditAction =
| "admin.first_created"
| "user.created"
| "user.deleted"
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
export interface AuditEntry {
id: number;
timestamp: number;
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditRecordInput {
actorId: string | null;
actorUsername: string | null;
targetUserId: string | null;
targetUsername: string | null;
action: AuditAction;
}
export interface AuditStore {
record(input: AuditRecordInput): void;
list(limit: number, offset: number): AuditEntry[];
}
export function createAuditStore(db: Database.Database): AuditStore {
const insertStmt = db.prepare(
"INSERT INTO user_audit (timestamp, actorId, actorUsername, targetUserId, targetUsername, action) VALUES (?, ?, ?, ?, ?, ?)"
);
const listStmt = db.prepare(
"SELECT id, timestamp, actorId, actorUsername, targetUserId, targetUsername, action FROM user_audit ORDER BY timestamp DESC, id DESC LIMIT ? OFFSET ?"
);
return {
record(input) {
insertStmt.run(
Date.now(),
input.actorId,
input.actorUsername,
input.targetUserId,
input.targetUsername,
input.action
);
},
list(limit, offset) {
return listStmt.all(limit, offset) as AuditEntry[];
},
};
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createAvatarStore } from "./avatars.js";
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "avatar-test-"));
});
describe("createAvatarStore", () => {
it("write returns a relative path under the store dir", () => {
const store = createAvatarStore(dir);
const buf = Buffer.from("fake-png");
const rel = store.write("bot-1", "image/png", buf);
expect(rel).toBe("bot-1.png");
expect(readFileSync(join(dir, "bot-1.png")).equals(buf)).toBe(true);
});
it("write picks correct extension for jpeg / webp", () => {
const store = createAvatarStore(dir);
expect(store.write("a", "image/jpeg", Buffer.from(""))).toBe("a.jpg");
expect(store.write("b", "image/webp", Buffer.from(""))).toBe("b.webp");
});
it("write rejects unsupported MIME types", () => {
const store = createAvatarStore(dir);
expect(() => store.write("c", "image/gif", Buffer.from(""))).toThrow(/unsupported/i);
});
it("read returns the bytes for an existing file", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("hello"));
const buf = store.read("bot-1.png");
expect(buf?.equals(Buffer.from("hello"))).toBe(true);
});
it("read returns null when path is missing", () => {
const store = createAvatarStore(dir);
expect(store.read("missing.png")).toBeNull();
});
it("remove deletes the file (idempotent)", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("x"));
store.remove("bot-1.png");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(() => store.remove("bot-1.png")).not.toThrow();
});
it("write replaces any existing file for the same botId regardless of old extension", () => {
const store = createAvatarStore(dir);
store.write("bot-1", "image/png", Buffer.from("old"));
const rel = store.write("bot-1", "image/jpeg", Buffer.from("new"));
expect(rel).toBe("bot-1.jpg");
expect(existsSync(join(dir, "bot-1.png"))).toBe(false);
expect(existsSync(join(dir, "bot-1.jpg"))).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { mkdirSync, writeFileSync, readFileSync, rmSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const MIME_TO_EXT: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
};
export interface AvatarStore {
/** Returns the relative path written (e.g. "bot-1.png"). */
write(botId: string, mime: string, buffer: Buffer): string;
read(relPath: string): Buffer | null;
remove(relPath: string): void;
getDir(): string;
}
export function createAvatarStore(dir: string): AvatarStore {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
return {
write(botId, mime, buffer) {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new Error(`unsupported avatar MIME: ${mime}`);
for (const name of readdirSync(dir)) {
if (name.startsWith(`${botId}.`)) rmSync(join(dir, name), { force: true });
}
const rel = `${botId}.${ext}`;
writeFileSync(join(dir, rel), buffer);
return rel;
},
read(relPath) {
const full = join(dir, relPath);
if (!existsSync(full)) return null;
return readFileSync(full);
},
remove(relPath) {
rmSync(join(dir, relPath), { force: true });
},
getDir() {
return dir;
},
};
}
+653 -4
View File
@@ -1,8 +1,31 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { join } from "node:path";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readFileSync,
readdirSync,
renameSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { getDefaultConfig, loadConfig, saveConfig } from "./config.js";
import { getDefaultConfig, loadConfig, saveConfig, migrateLegacyConfig, defaultPlatform } from "./config.js";
// Wrap the fs functions config.ts uses in call-through spies so the atomic-write
// and transient-read-error paths can be observed/forced. Everything else (mkdtemp,
// rmSync, existsSync, …) is the real implementation via `...actual`, so all other
// tests keep their real filesystem behavior. `vi.spyOn` can't be used here because
// the node:fs ESM namespace is non-configurable in this setup.
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
readFileSync: vi.fn(actual.readFileSync),
writeFileSync: vi.fn(actual.writeFileSync),
renameSync: vi.fn(actual.renameSync),
};
});
describe("config", () => {
const dirs: string[] = [];
@@ -25,6 +48,232 @@ 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"]);
expect(config.enabledProviders).not.toContain("jellyfin");
});
it("keeps pre-gating behavior for legacy configs without enabledProviders", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// A config written before enabledProviders existed: no such field.
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
const config = loadConfig(path);
expect(config.enabledProviders).toEqual(["netease", "qq", "bilibili", "youtube", "kugou"]);
expect(defaultPlatform(config)).toBe("netease");
});
it("defaultPlatform follows the fixed priority order", () => {
const config = getDefaultConfig();
expect(defaultPlatform(config)).toBe("netease");
// Jellyfin ranks after the online music platforms…
config.enabledProviders = ["netease", "jellyfin"];
expect(defaultPlatform(config)).toBe("netease");
// …but ahead of the video sites…
config.enabledProviders = ["bilibili", "jellyfin", "youtube"];
expect(defaultPlatform(config)).toBe("jellyfin");
// …and is the default when it is the only enabled source.
config.enabledProviders = ["jellyfin"];
expect(defaultPlatform(config)).toBe("jellyfin");
// Nothing enabled → netease fallback (the gate then reports it disabled).
config.enabledProviders = [];
expect(defaultPlatform(config)).toBe("netease");
});
// --- #126: an explicit operator default source ---
it("defaultPlatform is null by default (follow the priority order)", () => {
expect(getDefaultConfig().defaultPlatform).toBeNull();
});
it("defaultPlatform() honors an explicit, enabled preference over the priority order", () => {
const config = getDefaultConfig();
// Priority would pick netease; a Bilibili-loving server sets B站 instead (#126).
config.defaultPlatform = "bilibili";
expect(defaultPlatform(config)).toBe("bilibili");
});
it("defaultPlatform() ignores a preference whose source is not enabled", () => {
const config = getDefaultConfig();
config.defaultPlatform = "jellyfin"; // opt-in, not enabled in the default config
// Falls back to the fixed priority order (netease)…
expect(defaultPlatform(config)).toBe("netease");
// …until the preferred source is actually enabled.
config.enabledProviders = [...config.enabledProviders, "jellyfin"];
expect(defaultPlatform(config)).toBe("jellyfin");
});
it("loadConfig keeps a valid, enabled defaultPlatform", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ defaultPlatform: "bilibili" }));
const config = loadConfig(path);
expect(config.defaultPlatform).toBe("bilibili");
expect(defaultPlatform(config)).toBe("bilibili");
});
it("loadConfig nulls a defaultPlatform that is unknown, disabled, or the wrong type", () => {
const dir = makeTmpDir();
// Unknown provider name.
const p1 = join(dir, "c1.json");
writeFileSync(p1, JSON.stringify({ defaultPlatform: "bogus" }));
expect(loadConfig(p1).defaultPlatform).toBeNull();
// Known provider, but not in enabledProviders.
const p2 = join(dir, "c2.json");
writeFileSync(p2, JSON.stringify({ enabledProviders: ["netease"], defaultPlatform: "bilibili" }));
expect(loadConfig(p2).defaultPlatform).toBeNull();
// Wrong type.
const p3 = join(dir, "c3.json");
writeFileSync(p3, JSON.stringify({ defaultPlatform: 42 }));
expect(loadConfig(p3).defaultPlatform).toBeNull();
});
it("round-trips defaultPlatform through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, { ...getDefaultConfig(), defaultPlatform: "qq" });
expect(loadConfig(path).defaultPlatform).toBe("qq");
});
it("respects an explicit jellyfin-only enabledProviders from disk", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// e.g. a config persisted by the short-lived jellyfin-by-default builds.
writeFileSync(path, JSON.stringify({ enabledProviders: ["jellyfin"] }));
const config = loadConfig(path);
expect(config.enabledProviders).toEqual(["jellyfin"]);
expect(defaultPlatform(config)).toBe("jellyfin");
});
// ── audioQuality persistence (#125) ─────────────────────────────────────
it("defaults audioQuality to each provider's in-memory default", () => {
const config = getDefaultConfig();
expect(config.audioQuality).toEqual({
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
});
});
it("fills audioQuality defaults for a legacy config without the field", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ webPort: 4000 }));
const config = loadConfig(path);
expect(config.audioQuality).toEqual(getDefaultConfig().audioQuality);
});
it("round-trips a saved audioQuality through save/load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const config = getDefaultConfig();
config.audioQuality = {
netease: "lossless",
qq: "flac",
bilibili: "high",
kugou: "flac",
jellyfin: "320",
};
saveConfig(path, config);
const loaded = loadConfig(path);
expect(loaded.audioQuality).toEqual(config.audioQuality);
});
it("coerces missing / non-string audioQuality fields to defaults", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// netease valid, qq blank, bilibili wrong type, kugou missing, jellyfin valid.
writeFileSync(
path,
JSON.stringify({ audioQuality: { netease: "lossless", qq: " ", bilibili: 320, jellyfin: "192" } }),
);
const config = loadConfig(path);
expect(config.audioQuality).toEqual({
netease: "lossless",
qq: "exhigh", // blank → default
bilibili: "high", // non-string → default
kugou: "128", // missing → default
jellyfin: "192",
});
});
it("creates config file on save", () => {
const dir = makeTmpDir();
const path = join(dir, "sub", "config.json");
@@ -49,6 +298,406 @@ describe("config", () => {
// defaults should fill in the rest
expect(loaded.theme).toBe("dark");
expect(loaded.commandPrefix).toBe("!");
expect(loaded.autoPauseOnEmpty).toBe(true);
// auto-pause defaults OFF (occupancy detection is unreliable on some servers)
expect(loaded.autoPauseOnEmpty).toBe(false);
});
// --- #86: config.json must live under (and be created in) the persisted data dir ---
it("first run writes config.json into the data dir and reads it back", () => {
const root = makeTmpDir();
const dataDir = join(root, "data");
const configPath = join(dataDir, "config.json"); // mirrors index.ts CONFIG_PATH
// Boot sequence: load (missing -> defaults) then save.
const config = loadConfig(configPath);
saveConfig(configPath, config);
expect(existsSync(configPath)).toBe(true);
// A subsequent hand-edited file under the SAME persisted path is honored.
writeFileSync(configPath, JSON.stringify({ webPort: 9999 }), "utf-8");
expect(loadConfig(configPath).webPort).toBe(9999);
});
it("migrates a legacy root config into the data dir, preserving values", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 4242, publicUrl: "http://x" }), "utf-8");
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(true);
expect(existsSync(newPath)).toBe(true);
expect(existsSync(legacyPath)).toBe(false); // legacy moved, not duplicated
const loaded = loadConfig(newPath);
expect(loaded.webPort).toBe(4242);
expect(loaded.publicUrl).toBe("http://x");
});
it("does NOT overwrite an existing data-dir config during migration", () => {
const root = makeTmpDir();
const legacyPath = join(root, "config.json");
const newPath = join(root, "data", "config.json");
writeFileSync(legacyPath, JSON.stringify({ webPort: 1111 }), "utf-8");
saveConfig(newPath, { ...getDefaultConfig(), webPort: 2222 });
const migrated = migrateLegacyConfig(legacyPath, newPath);
expect(migrated).toBe(false); // new location wins, untouched
expect(loadConfig(newPath).webPort).toBe(2222);
expect(existsSync(legacyPath)).toBe(true); // legacy left intact when not migrated
});
it("migration is a no-op when there is no legacy config", () => {
const root = makeTmpDir();
const migrated = migrateLegacyConfig(join(root, "config.json"), join(root, "data", "config.json"));
expect(migrated).toBe(false);
});
});
describe("guestMode config", () => {
it("defaults to disabled, all-bots, append-only", () => {
const c = getDefaultConfig();
expect(c.guestMode.enabled).toBe(false);
expect(c.guestMode.bots).toBe("all");
expect(c.guestMode.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
});
});
it("deep-merges a partial guestMode so missing sub-keys are back-filled", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify({ guestMode: { enabled: true, permissions: { playNext: true } } }));
const c = loadConfig(p);
expect(c.guestMode.enabled).toBe(true);
expect(c.guestMode.bots).toBe("all"); // back-filled
expect(c.guestMode.permissions.playNext).toBe(true);
expect(c.guestMode.permissions.addToQueue).toBe(true); // back-filled default
expect(c.guestMode.permissions.skip).toBe(false); // back-filled default
rmSync(dir, { recursive: true, force: true });
});
// --- B1: loadConfig must sanitize a hand-edited/legacy/corrupt guestMode ---
function loadGuestMode(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).guestMode;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("bots normalization", () => {
it("a numeric bots value falls back to the default \"all\" (no crash)", () => {
const gm = loadGuestMode({ guestMode: { bots: 5 } });
expect(gm.bots).toBe("all");
});
it("an array bots value is filtered to strings only", () => {
const gm = loadGuestMode({ guestMode: { bots: ["a", 2, "b"] } });
expect(gm.bots).toEqual(["a", "b"]);
});
it("the literal \"all\" is preserved", () => {
const gm = loadGuestMode({ guestMode: { bots: "all" } });
expect(gm.bots).toBe("all");
});
});
describe("permissions coercion", () => {
it("a non-boolean truthy flag is coerced to false; a real true stays true", () => {
const gm = loadGuestMode({ guestMode: { permissions: { skip: 1, playNext: true } } });
expect(gm.permissions.skip).toBe(false);
expect(gm.permissions.playNext).toBe(true);
});
it("a string permissions value yields defaults with no numeric index keys", () => {
const gm = loadGuestMode({ guestMode: { permissions: "hacked" } });
// all known flags present at their defaults
expect(gm.permissions).toEqual({
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
});
// no garbage index keys leaked from spreading a string
expect((gm.permissions as unknown as Record<string, unknown>)["0"]).toBeUndefined();
});
});
});
describe("adminGroups normalization", () => {
function loadAdminGroups(raw: unknown) {
const dir = mkdtempSync(join(tmpdir(), "tsmb-cfg-"));
const p = join(dir, "config.json");
writeFileSync(p, JSON.stringify(raw));
try {
return loadConfig(p).adminGroups;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("defaults to [] when absent", () => {
expect(loadAdminGroups({})).toEqual([]);
});
it("keeps valid non-negative integers", () => {
expect(loadAdminGroups({ adminGroups: [6, 8] })).toEqual([6, 8]);
});
it("filters out negatives, non-integers and non-numbers", () => {
expect(loadAdminGroups({ adminGroups: [6, -1, 2.5, "8", null] })).toEqual([6]);
});
it("a non-array value falls back to the default [] (no crash)", () => {
expect(loadAdminGroups({ adminGroups: "6" })).toEqual([]);
});
});
describe("spotify config", () => {
it("defaults are present and disabled", () => {
const c = getDefaultConfig();
expect(c.spotify).toEqual({
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
});
});
it("loadConfig coerces bad spotify values back to safe defaults", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: { enabled: "yes", backend: "bogus", bitrate: 7, clientId: 5 },
})
);
const c = loadConfig(p);
expect(c.spotify.enabled).toBe(false); // non-boolean → false
expect(c.spotify.backend).toBe("auto"); // invalid enum → auto
expect(c.spotify.bitrate).toBe(320); // invalid → 320
expect(c.spotify.clientId).toBe(""); // non-string → ""
expect(c.spotify.deviceName).toBe("TSMusicBot"); // missing → default
});
it("loadConfig preserves valid spotify values", () => {
const dir = mkdtempSync(join(tmpdir(), "cfg-"));
const p = join(dir, "config.json");
writeFileSync(
p,
JSON.stringify({
spotify: {
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
},
})
);
const c = loadConfig(p);
expect(c.spotify).toEqual({
enabled: true,
backend: "librespot",
clientId: "abc",
clientSecret: "def",
deviceName: "MyBot",
bitrate: 160,
});
});
});
// --- R2-1: saveConfig must write atomically (temp file + rename), never truncate ---
describe("saveConfig atomic write", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmb-atomic-"));
dirs.push(dir);
return dir;
}
beforeEach(() => {
vi.clearAllMocks(); // reset call history, keep the call-through implementations
});
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("round-trips (save then load equals) and leaves NO .tmp file behind", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const config = { ...getDefaultConfig(), webPort: 4567, adminPassword: "pw" };
saveConfig(path, config);
expect(loadConfig(path)).toEqual(config);
// No temp remnants in the target directory.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
it("writes via a same-dir temp file then renameSync onto the final path", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, getDefaultConfig());
expect(vi.mocked(renameSync)).toHaveBeenCalled();
const [from, to] = vi.mocked(renameSync).mock.calls[0] as [string, string];
expect(to).toBe(path); // renamed ONTO the real path
expect(String(from)).not.toBe(path); // ...from a distinct temp file
expect(join(String(from), "..")).toBe(join(path, "..")); // ...in the SAME directory
});
it("does not corrupt a pre-existing valid config when saving over it", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
saveConfig(path, { ...getDefaultConfig(), adminPassword: "first", webPort: 1234 });
// Overwrite with a different, fully-formed config.
saveConfig(path, { ...getDefaultConfig(), adminPassword: "second", webPort: 9999 });
const loaded = loadConfig(path);
expect(loaded.adminPassword).toBe("second");
expect(loaded.webPort).toBe(9999);
// The on-disk file is a single complete JSON document (no partial/truncated write).
expect(() => JSON.parse(readFileSync(path, "utf-8"))).not.toThrow();
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
it("cleans up the temp file (no .tmp remnant) when the rename fails", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
vi.mocked(renameSync).mockImplementationOnce(() => {
throw new Error("rename boom");
});
expect(() => saveConfig(path, getDefaultConfig())).toThrow(/rename boom/);
// The failed write left no temp file lying around.
expect(readdirSync(dir).filter((f) => f.includes(".tmp"))).toEqual([]);
});
});
// --- R2-2: loadConfig must not treat a transient/corrupt read as "missing" ---
describe("loadConfig error handling", () => {
const dirs: string[] = [];
function makeTmpDir(): string {
const dir = mkdtempSync(join(tmpdir(), "tsmb-load-"));
dirs.push(dir);
return dir;
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
for (const d of dirs) rmSync(d, { recursive: true, force: true });
dirs.length = 0;
});
it("(a) ENOENT (missing file) returns defaults — unchanged first-run behavior", () => {
const dir = makeTmpDir();
expect(loadConfig(join(dir, "config.json"))).toEqual(getDefaultConfig());
});
it("(b) a non-ENOENT read error (EBUSY) rethrows instead of returning defaults", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
// A REAL config exists on disk; a transient lock must NOT collapse to defaults
// (the caller would otherwise overwrite this real config with defaults).
saveConfig(path, { ...getDefaultConfig(), adminPassword: "keep-me" });
vi.mocked(readFileSync).mockImplementationOnce(() => {
const err = new Error("EBUSY: resource busy or locked") as NodeJS.ErrnoException;
err.code = "EBUSY";
throw err;
});
expect(() => loadConfig(path)).toThrow(/EBUSY/);
// The on-disk config is untouched and still readable once the lock clears.
expect(loadConfig(path).adminPassword).toBe("keep-me");
});
it("(c) corrupt JSON returns defaults AND backs up the original to *.corrupt-*", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
const garbage = "{ not: valid json, ";
writeFileSync(path, garbage, "utf-8");
const loaded = loadConfig(path);
expect(loaded).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
// The corrupt original is preserved verbatim (recoverable, never deleted).
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(garbage);
});
// (d)/(e) Valid JSON that is NOT a non-null object (null / [] / 42 / "str") passes
// JSON.parse but would throw a raw TypeError in the per-field sanitize block
// (property access on a non-object), bypassing the corrupt-backup path. It must be
// treated EXACTLY like corrupt JSON: back up to *.corrupt-* (original preserved),
// return defaults — NOT a thrown TypeError, and NOT a silent defaults-with-no-backup.
it("(d) a `null` config is treated as corrupt: defaults + *.corrupt-* backup (original preserved)", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, "null", "utf-8");
let loaded: ReturnType<typeof getDefaultConfig>;
expect(() => {
loaded = loadConfig(path);
}).not.toThrow();
expect(loaded!).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe("null");
});
it("(e) a non-object config (`[]` / `42`) is backed up + defaults, not a thrown TypeError", () => {
for (const content of ["[]", "42"]) {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, content, "utf-8");
let loaded: ReturnType<typeof getDefaultConfig>;
expect(() => {
loaded = loadConfig(path);
}).not.toThrow();
expect(loaded!).toEqual(getDefaultConfig());
const backups = readdirSync(dir).filter((f) => f.includes(".corrupt-"));
expect(backups.length).toBeGreaterThan(0);
expect(readFileSync(join(dir, backups[0]), "utf-8")).toBe(content);
}
});
it("defaults savedQueuesEnabled and playKeepsQueue to false", () => {
const c = getDefaultConfig();
expect(c.savedQueuesEnabled).toBe(false);
expect(c.playKeepsQueue).toBe(false);
});
it("coerces non-boolean savedQueues/playKeepsQueue values to false on load", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: "yes", playKeepsQueue: 1 }));
const c = loadConfig(path);
expect(c.savedQueuesEnabled).toBe(false);
expect(c.playKeepsQueue).toBe(false);
});
it("preserves savedQueues/playKeepsQueue true when explicitly enabled", () => {
const dir = makeTmpDir();
const path = join(dir, "config.json");
writeFileSync(path, JSON.stringify({ savedQueuesEnabled: true, playKeepsQueue: true }));
const c = loadConfig(path);
expect(c.savedQueuesEnabled).toBe(true);
expect(c.playKeepsQueue).toBe(true);
});
});
Regular → Executable
+480 -6
View File
@@ -1,5 +1,111 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import {
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
copyFileSync,
rmSync,
renameSync,
} from "node:fs";
import { dirname } from "node:path";
import type { BotAccess, GuestPermissions } from "./permissions.js";
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
export interface GuestModeConfig {
enabled: boolean;
bots: BotAccess; // "all" | string[]
permissions: GuestPermissions;
}
export interface SpotifyConfig {
enabled: boolean;
backend: "auto" | "go-librespot" | "librespot";
clientId: string;
clientSecret: string;
deviceName: string;
bitrate: number;
}
export interface JellyfinConfig {
/** Base URL of the Jellyfin server, e.g. "https://jellyfin.example.com". */
serverUrl: string;
authMode: "userpass" | "apikey";
// userpass mode
username: string;
password: string;
// apikey mode: admin API key + the user whose library/favorites/playlists are used
apiKey: string;
userId: string;
}
/**
* Per-provider audio quality (音质), persisted so a restart keeps the user's
* choice instead of resetting each provider to its in-memory default (#125).
* The values are the same strings the WebUI/REST `POST /api/music/quality`
* endpoint sends and each provider's setQuality() accepts; on startup they are
* replayed onto the (shared, process-wide) providers. Providers ignore/normalize
* unknown values, so a stale/hand-edited entry can never break playback.
*/
export interface AudioQualityConfig {
netease: string;
qq: string;
bilibili: string;
kugou: string;
jellyfin: string;
}
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
* - "spotify" → governed by the existing `spotify.enabled` flag
*/
export const GATEABLE_PROVIDERS = [
"jellyfin",
"netease",
"qq",
"bilibili",
"youtube",
"kugou",
] as const;
export type GateableProvider = (typeof GATEABLE_PROVIDERS)[number];
/** Whether a platform may be used for search/playback under the current config. */
export function isProviderEnabled(config: BotConfig, platform: string): boolean {
if (platform === "local") return config.localAudioEnabled !== false;
if (platform === "spotify") return config.spotify.enabled;
return config.enabledProviders.includes(platform as GateableProvider);
}
/**
* The default platform for !play/!add/!playlist/!album and all REST/WebUI calls.
*
* An explicit user preference (`config.defaultPlatform`) wins whenever it points
* at a source that is currently enabled — this lets e.g. a Bilibili-loving server
* set B站 as the default so `!play <歌名>` needs no `-b` flag (issue #126). The
* enabled-guard here matters at runtime too: if the operator later disables the
* preferred source, we must fall through instead of returning a dead default.
*
* With no (usable) preference we fall back to the first enabled provider in a
* fixed priority order (netease with the default config; jellyfin ranks after
* the online music platforms because it is an opt-in source, but ahead of the
* video sites for users who run it as their only music library). Falls back to
* "netease" when nothing is enabled so callers always get a provider — the
* enabled-gate then produces the friendly error.
*/
export function defaultPlatform(config: BotConfig): GateableProvider {
const pref = config.defaultPlatform;
if (pref && config.enabledProviders.includes(pref)) return pref;
for (const p of ["netease", "qq", "kugou", "jellyfin", "bilibili", "youtube"] as const) {
if (config.enabledProviders.includes(p)) return p;
}
return "netease";
}
export interface BotConfig {
webPort: number;
@@ -13,6 +119,53 @@ 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;
/**
* Enable named save/load of queues (chat + web) AND auto-restore of the live
* queue across a restart. Admin-controlled; default false so nothing is
* persisted/restored until an operator opts in.
*/
savedQueuesEnabled: boolean;
/**
* When true, a single-song immediate !play (chat) / play-song (web) inserts
* after the current track and jumps to it instead of clearing the queue, so
* the rest of the queue survives and continues afterwards. Default false
* keeps today's clear-and-play behavior.
*/
playKeepsQueue: boolean;
// Public base URL used when generating share links (e.g. the bot专属链接).
// Leave empty to use the browser's current origin. Example:
// "https://music.example.com" or "http://1.2.3.4:3000"
publicUrl: string;
// When true, Express trusts X-Forwarded-* headers from a reverse proxy
// (nginx/Caddy/Cloudflare). Required for correct protocol/host detection
// behind HTTPS-terminating proxies.
trustProxy: boolean;
guestMode: GuestModeConfig;
spotify: SpotifyConfig;
jellyfin: JellyfinConfig;
/** Persisted per-provider audio quality (音质), restored on startup (#125). */
audioQuality: AudioQualityConfig;
/**
* Which gateable providers are active (see GATEABLE_PROVIDERS). Default is
* the online sources (NetEase/QQ/Bilibili/YouTube/Kugou); jellyfin is an
* opt-in extra that must be listed here (Settings → Jellyfin 音乐库 toggles
* it). Sources not listed stay disabled — the NetEase/QQ embedded sidecar
* API servers must not start (or bind ports 3001/3200) unless enabled.
*/
enabledProviders: GateableProvider[];
/**
* Optional operator-chosen default source for commands/REST/WebUI calls that
* omit a platform (issue #126). When set to an enabled gateable provider it
* overrides the fixed priority order in defaultPlatform(); `null` (the default)
* keeps that priority order. loadConfig cleans stale/unknown/disabled values
* back to null.
*/
defaultPlatform: GateableProvider | null;
}
export function getDefaultConfig(): BotConfig {
@@ -27,22 +180,343 @@ export function getDefaultConfig(): BotConfig {
adminPassword: "",
adminGroups: [],
autoReturnDelay: 300,
autoPauseOnEmpty: true,
// Default OFF: occupancy detection relies on the full-client `clientlist`
// 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,
playKeepsQueue: false,
publicUrl: "",
trustProxy: false,
guestMode: {
enabled: false,
bots: "all",
permissions: {
addToQueue: true,
playNext: false,
playNow: false,
skip: false,
transport: false,
removeClear: false,
playMode: false,
playCollection: false,
},
},
spotify: {
enabled: false,
backend: "auto",
clientId: "",
clientSecret: "",
deviceName: "TSMusicBot",
bitrate: 320,
},
jellyfin: {
serverUrl: "",
authMode: "userpass",
username: "",
password: "",
apiKey: "",
userId: "",
},
// Mirrors each provider's own in-memory default quality; overwritten on
// startup once the user has changed a quality (persisted via #125).
audioQuality: {
netease: "exhigh",
qq: "exhigh",
bilibili: "high",
kugou: "128",
jellyfin: "direct",
},
enabledProviders: ["netease", "qq", "bilibili", "youtube", "kugou"],
defaultPlatform: null,
};
}
/**
* Move an unusable config aside to a timestamped `*.corrupt-*` backup so the data
* stays recoverable (it is NEVER deleted), for both the corrupt-JSON case and the
* parses-but-not-an-object case. Prefer an atomic same-dir rename; if that fails,
* copy instead. If it can't be preserved at all, rethrow rather than let the caller
* overwrite unrecoverable data.
*/
function backupCorruptConfig(path: string): void {
const backup = `${path}.corrupt-${Date.now()}`;
try {
renameSync(path, backup);
} catch {
try {
copyFileSync(path, backup);
} catch (backupErr) {
throw backupErr;
}
}
}
export function loadConfig(path: string): BotConfig {
const defaults = getDefaultConfig();
// Distinguish the three failure modes so a *real* on-disk config is NEVER
// silently replaced with defaults (the caller saveConfig()s right after load,
// which would otherwise erase spotify creds / adminPassword / adminGroups /
// guestMode permanently):
// (a) file ABSENT (ENOENT) — normal first run → defaults.
// (b) any OTHER read error (EBUSY/EACCES/EPERM/EISDIR/…) on an existing file —
// rethrow (fail-fast at boot). A loud crash beats silent credential loss.
// (c) file readable but JSON.parse fails (corrupt) — back the file up first
// (never delete it), THEN return defaults so boot can proceed.
let raw: string;
try {
const raw = readFileSync(path, "utf-8");
const partial = JSON.parse(raw) as Partial<BotConfig>;
return { ...defaults, ...partial };
raw = readFileSync(path, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return defaults; // (a) missing file — first run
}
throw err; // (b) transient/permission error on an existing file — do not clobber it
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// (c) Corrupt content: move the unreadable file aside to a timestamped backup
// so the data stays recoverable, then fall back to defaults.
backupCorruptConfig(path);
return defaults;
}
// (d) Parses cleanly but is NOT a non-null object (e.g. `null`, `42`, `"str"`,
// `[]`). The per-field sanitize below assumes an object and would throw a raw
// TypeError (or silently spread junk), bypassing the corrupt-backup path. Treat
// it EXACTLY like corrupt JSON: back it up (never delete), then return defaults.
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
backupCorruptConfig(path);
return defaults;
}
const partial = parsed as Partial<BotConfig>;
{
// Normalize/sanitize guestMode on load. The WRITE path (POST /api/bot/settings)
// sanitizes too, but a hand-edited/legacy/corrupt config.json reaches the gate
// directly — so coerce it here as well, mirroring that write-path logic.
const partialGm = (partial.guestMode ?? {}) as Partial<GuestModeConfig>;
const gm: GuestModeConfig = {
...defaults.guestMode,
...partialGm,
// bots → "all" | string[]; anything else falls back to the default ("all").
bots:
partialGm.bots === "all"
? "all"
: Array.isArray(partialGm.bots)
? partialGm.bots.filter((id): id is string => typeof id === "string")
: defaults.guestMode.bots,
// permissions → defaults, then spread ONLY a plain object, then strict-coerce
// each known flag to a boolean (drops index keys + non-boolean values).
permissions: { ...defaults.guestMode.permissions },
};
const partialPerms = partialGm.permissions;
if (
partialPerms !== null &&
typeof partialPerms === "object" &&
!Array.isArray(partialPerms)
) {
Object.assign(gm.permissions, partialPerms);
}
for (const f of GUEST_PERMISSION_FLAGS) {
gm.permissions[f] = gm.permissions[f] === true;
}
// Sanitize adminGroups on load too: the WebUI write path filters it, but a
// hand-edited / legacy / corrupt config.json reaches the command gate
// directly. Keep only non-negative integers; a non-array falls back to the
// default []. Mirrors the guestMode sanitization above.
const adminGroups = Array.isArray(partial.adminGroups)
? partial.adminGroups.filter(
(g): g is number => typeof g === "number" && Number.isInteger(g) && g >= 0,
)
: defaults.adminGroups;
const partialSp = (partial.spotify ?? {}) as Partial<SpotifyConfig>;
const validBackends = ["auto", "go-librespot", "librespot"] as const;
const validBitrates = [96, 160, 320];
const spotify: SpotifyConfig = {
enabled: partialSp.enabled === true,
backend: (validBackends as readonly string[]).includes(partialSp.backend as string)
? (partialSp.backend as SpotifyConfig["backend"])
: defaults.spotify.backend,
clientId: typeof partialSp.clientId === "string" ? partialSp.clientId : defaults.spotify.clientId,
clientSecret:
typeof partialSp.clientSecret === "string" ? partialSp.clientSecret : defaults.spotify.clientSecret,
deviceName:
typeof partialSp.deviceName === "string" && partialSp.deviceName.trim()
? partialSp.deviceName
: defaults.spotify.deviceName,
bitrate: validBitrates.includes(partialSp.bitrate as number)
? (partialSp.bitrate as number)
: defaults.spotify.bitrate,
};
// Sanitize the jellyfin block on load, mirroring the spotify handling: a
// hand-edited/legacy config.json must never smuggle wrong shapes past the
// gate. Unknown/invalid sub-fields fall back to defaults.
const partialJf = (partial.jellyfin ?? {}) as Partial<JellyfinConfig>;
const jellyfin: JellyfinConfig = {
serverUrl:
typeof partialJf.serverUrl === "string"
? partialJf.serverUrl.trim().replace(/\/+$/, "")
: defaults.jellyfin.serverUrl,
authMode:
partialJf.authMode === "apikey" ? "apikey" : defaults.jellyfin.authMode,
username:
typeof partialJf.username === "string" ? partialJf.username : defaults.jellyfin.username,
password:
typeof partialJf.password === "string" ? partialJf.password : defaults.jellyfin.password,
apiKey: typeof partialJf.apiKey === "string" ? partialJf.apiKey : defaults.jellyfin.apiKey,
userId: typeof partialJf.userId === "string" ? partialJf.userId : defaults.jellyfin.userId,
};
// enabledProviders → known providers only; a non-array falls back to the
// default (online sources, jellyfin off). An explicitly-empty array is
// respected (operator chose to disable every gateable source).
const enabledProviders = Array.isArray(partial.enabledProviders)
? partial.enabledProviders.filter((p): p is GateableProvider =>
(GATEABLE_PROVIDERS as readonly string[]).includes(p as string),
)
: defaults.enabledProviders;
// Strict-coerce the two feature flags exactly like spotify.enabled so a
// hand-edited / legacy / corrupt config.json can never silently enable
// them (`"yes"`, `1`, `null` → false; only a literal `true` enables).
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
// null so defaultPlatform() falls back to the fixed priority order.
const rawDefault = partial.defaultPlatform;
const defaultPlatformPref: GateableProvider | null =
typeof rawDefault === "string" &&
(GATEABLE_PROVIDERS as readonly string[]).includes(rawDefault) &&
enabledProviders.includes(rawDefault as GateableProvider)
? (rawDefault as GateableProvider)
: null;
// audioQuality → per-provider strings; each field falls back to its default
// when missing/blank/non-string (a hand-edited/legacy config must never smuggle
// a non-string past the gate — the value is fed straight to provider.setQuality).
const partialAq = (partial.audioQuality ?? {}) as Partial<AudioQualityConfig>;
const coerceQuality = (v: unknown, fallback: string): string =>
typeof v === "string" && v.trim() ? v : fallback;
const audioQuality: AudioQualityConfig = {
netease: coerceQuality(partialAq.netease, defaults.audioQuality.netease),
qq: coerceQuality(partialAq.qq, defaults.audioQuality.qq),
bilibili: coerceQuality(partialAq.bilibili, defaults.audioQuality.bilibili),
kugou: coerceQuality(partialAq.kugou, defaults.audioQuality.kugou),
jellyfin: coerceQuality(partialAq.jellyfin, defaults.audioQuality.jellyfin),
};
return {
...defaults,
...partial,
adminGroups,
guestMode: gm,
spotify,
jellyfin,
audioQuality,
enabledProviders,
savedQueuesEnabled,
playKeepsQueue,
voiceDucking,
defaultPlatform: defaultPlatformPref,
};
}
}
export function saveConfig(path: string, config: BotConfig): void {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(config, null, 2), "utf-8");
const json = JSON.stringify(config, null, 2);
// Atomic write: serialize to a sibling temp file in the SAME directory, then
// rename it onto the final path. rename is an atomic replace on POSIX and modern
// Windows, so a crash / power loss / ENOSPC mid-write can never leave config.json
// truncated — a reader always sees either the previous file or the fully-written
// new one, never a partial. The temp lives in the same dir so the rename stays on
// one filesystem (a cross-device rename would fail); pid + timestamp keep
// concurrent writers from colliding on the temp name.
const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tmp, json, "utf-8");
renameSync(tmp, path);
} catch (err) {
// Never leave a partial temp file behind on failure.
try {
rmSync(tmp, { force: true });
} catch {
/* best-effort cleanup */
}
throw err;
}
}
/**
* One-time migration for the config location fix (#86).
*
* Older versions wrote config.json to the app/repo ROOT, which is NOT inside the
* persisted data directory (the Docker volume is mounted at data/). That meant the
* file never landed in the volume on first run and a manually-placed data/config.json
* was ignored. config.json now lives under the data dir alongside the DB/cookies/logs.
*
* If a legacy root-level config exists and the new data-dir config does not yet exist,
* move it so existing local installs keep their customized settings. Best-effort:
* any failure is swallowed and loadConfig falls back to defaults.
*
* @returns true if a legacy config was migrated, false otherwise.
*/
export function migrateLegacyConfig(legacyPath: string, newPath: string): boolean {
try {
if (legacyPath === newPath) return false;
if (existsSync(newPath)) return false; // new location already populated — leave it
if (!existsSync(legacyPath)) return false; // nothing to migrate
mkdirSync(dirname(newPath), { recursive: true });
copyFileSync(legacyPath, newPath); // copy first (works across filesystems)
try {
rmSync(legacyPath);
} catch {
/* leave the legacy file if it can't be removed; the new one wins */
}
return true;
} catch {
return false;
}
}
+254 -2
View File
@@ -1,5 +1,9 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createDatabase, SHARED_QUEUE_OWNER, type BotDatabase, type BotInstance, type PlayHistoryEntry } from "./database.js";
import { createUserStore, GUEST_USER_ID } from "./users.js";
describe("database", () => {
let botDb: BotDatabase;
@@ -23,6 +27,30 @@ describe("database", () => {
expect(names).toContain("bot_instances");
});
it("creates users and sessions tables on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
const names = tables.map((t) => t.name);
expect(names).toContain("users");
expect(names).toContain("sessions");
const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userCols.map((c) => c.name).sort();
expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "role", "updatedAt", "username"]);
const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>;
const sessionColNames = sessionCols.map((c) => c.name).sort();
expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]);
});
it("creates user_audit table on init", () => {
const tables = botDb.db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all() as Array<{ name: string }>;
expect(tables.map((t) => t.name)).toContain("user_audit");
});
it("records and retrieves play history", () => {
botDb.addPlayHistory({
botId: "bot1",
@@ -32,6 +60,7 @@ describe("database", () => {
album: "Test Album",
platform: "netease",
coverUrl: "https://example.com/cover.jpg",
requestedBy: "alice",
});
botDb.addPlayHistory({
@@ -48,6 +77,7 @@ describe("database", () => {
expect(history).toHaveLength(2);
expect(history[0].songName).toBe("Another Song");
expect(history[1].songName).toBe("Test Song");
expect(history[1].requestedBy).toBe("alice");
});
it("saves and loads bot instances", () => {
@@ -58,14 +88,18 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: true,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(instance);
const instances = botDb.getBotInstances();
expect(instances).toHaveLength(1);
expect(instances[0]).toEqual(instance);
expect(instances[0]).toMatchObject(instance);
expect(instances[0].autoStart).toBe(true);
// Test upsert
@@ -84,12 +118,230 @@ describe("database", () => {
serverPort: 9987,
nickname: "MusicBot",
defaultChannel: "Music",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
expect(botDb.deleteBotInstance("bot1")).toBe(true);
expect(botDb.getBotInstances()).toHaveLength(0);
expect(botDb.deleteBotInstance("nonexistent")).toBe(false);
});
it("persists and restores per-bot player settings (volume + play mode) (#125)", () => {
const inst = {
id: "bot-ps",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
// Fresh row → in-memory defaults.
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 75, playMode: "seq" });
// Volume and play mode persist independently.
botDb.saveVolume("bot-ps", 42);
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "seq" });
botDb.savePlayMode("bot-ps", "rloop");
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
// A later saveBotInstance upsert (e.g. autoStart toggle) must NOT reset them.
botDb.saveBotInstance({ ...inst, autoStart: true });
expect(botDb.getPlayerSettings("bot-ps")).toEqual({ volume: 42, playMode: "rloop" });
});
it("defaults player settings for an unknown bot and validates inputs (#125)", () => {
// No row → defaults.
expect(botDb.getPlayerSettings("does-not-exist")).toEqual({ volume: 75, playMode: "seq" });
botDb.saveBotInstance({
id: "bot-v",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
});
// Out-of-range volume is clamped; an unknown play mode is ignored (not stored).
botDb.saveVolume("bot-v", 250);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(100);
botDb.saveVolume("bot-v", -10);
expect(botDb.getPlayerSettings("bot-v").volume).toBe(0);
botDb.savePlayMode("bot-v", "bogus");
expect(botDb.getPlayerSettings("bot-v").playMode).toBe("seq");
});
it("migrates volume + play_mode columns onto a legacy bot_instances table (#125)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-mig-"));
const p = join(dir, "legacy.db");
// Build a minimal pre-#125 bot_instances table (no volume/play_mode columns).
const legacy = createDatabase(p);
legacy.db.exec("DROP TABLE bot_instances");
legacy.db.exec(`CREATE TABLE bot_instances (
id TEXT PRIMARY KEY, name TEXT NOT NULL, serverAddress TEXT NOT NULL,
serverPort INTEGER NOT NULL, nickname TEXT NOT NULL, defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '', channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0, serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '', serverPassword TEXT NOT NULL DEFAULT '', identity TEXT
)`);
legacy.db
.prepare("INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword) VALUES (?, 'B', 'x', 9987, 'n', '', '')")
.run("legacy-bot");
legacy.close();
// Reopen → migrateSchema adds the columns; the old row gets the defaults.
const reopened = createDatabase(p);
const cols = (reopened.db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>).map((c) => c.name);
expect(cols).toContain("volume");
expect(cols).toContain("play_mode");
expect(reopened.getPlayerSettings("legacy-bot")).toEqual({ volume: 75, playMode: "seq" });
reopened.close();
rmSync(dir, { recursive: true, force: true });
});
it("persists and clears customAvatarPath on a bot instance", () => {
const inst = {
id: "bot-1",
name: "B",
serverAddress: "x",
serverPort: 9987,
nickname: "n",
defaultChannel: "",
channelId: "",
channelPassword: "",
autoStart: false,
serverProtocol: "",
ts6ApiKey: "",
serverPassword: "",
};
botDb.saveBotInstance(inst);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
botDb.setCustomAvatarPath("bot-1", "avatars/bot-1.png");
expect(botDb.getCustomAvatarPath("bot-1")).toBe("avatars/bot-1.png");
botDb.setCustomAvatarPath("bot-1", null);
expect(botDb.getCustomAvatarPath("bot-1")).toBeNull();
});
const sq = (id: string) => ({
id,
name: id,
artist: "",
album: "",
platform: "netease" as const,
coverUrl: "",
duration: 1,
});
describe("saved_queues", () => {
it("upserts by (ownerId, name) and returns songs", () => {
botDb.saveQueue("u1", "night", [sq("a"), sq("b")]);
const again = botDb.saveQueue("u1", "night", [sq("c")]); // overwrite
expect(again.songCount).toBe(1);
expect(botDb.listSavedQueues("u1", false)).toHaveLength(1);
const full = botDb.getSavedQueue(again.id)!;
expect(full.songs.map((s) => s.id)).toEqual(["c"]);
});
it("strips url before persisting", () => {
const saved = botDb.saveQueue("u1", "x", [
{ ...sq("a"), url: "http://example.com/a.mp3" } as never,
]);
const full = botDb.getSavedQueue(saved.id)!;
expect((full.songs[0] as { url?: string }).url).toBeUndefined();
});
it("lists own + shared when includeShared, own-only otherwise", () => {
botDb.saveQueue("u1", "mine", [sq("a")]);
botDb.saveQueue(SHARED_QUEUE_OWNER, "party", [sq("b")]);
expect(botDb.listSavedQueues("u1", false).map((q) => q.name)).toEqual(["mine"]);
expect(
botDb.listSavedQueues("u1", true).map((q) => q.name).sort(),
).toEqual(["mine", "party"]);
});
it("caps songs at 1000 and queues at 50", () => {
expect(() =>
botDb.saveQueue("u1", "big", Array.from({ length: 1001 }, (_, i) => sq("s" + i))),
).toThrow(/1000/);
for (let i = 0; i < 50; i++) botDb.saveQueue("u1", "q" + i, [sq("a")]);
expect(() => botDb.saveQueue("u1", "q50", [sq("a")])).toThrow(/50/);
// Overwriting an existing name is always allowed despite the cap.
expect(() => botDb.saveQueue("u1", "q0", [sq("z")])).not.toThrow();
});
it("deletes and degrades a corrupt blob to empty", () => {
const q = botDb.saveQueue("u1", "x", [sq("a")]);
botDb.db.prepare("UPDATE saved_queues SET songs='not json' WHERE id=?").run(q.id);
expect(botDb.getSavedQueue(q.id)!.songs).toEqual([]);
expect(botDb.deleteSavedQueue(q.id)).toBe(true);
expect(botDb.getSavedQueue(q.id)).toBeNull();
expect(botDb.deleteSavedQueue(q.id)).toBe(false); // already gone
});
});
describe("queue_state", () => {
it("upserts, reads back, and clears per bot", () => {
botDb.saveQueueState({ botId: "b1", songs: [sq("a")], currentIndex: 0, mode: "loop", isFmMode: true, fmPlatform: "netease" });
botDb.saveQueueState({ botId: "b1", songs: [sq("a"), sq("b")], currentIndex: 1, mode: "seq", isFmMode: false, fmPlatform: "" });
const st = botDb.getQueueState("b1")!;
expect(st.songs.map((s) => s.id)).toEqual(["a", "b"]);
expect(st.currentIndex).toBe(1);
expect(st.mode).toBe("seq");
expect(st.isFmMode).toBe(false);
botDb.clearQueueState("b1");
expect(botDb.getQueueState("b1")).toBeNull();
});
it("round-trips FM flags and degrades a corrupt blob", () => {
botDb.saveQueueState({ botId: "b2", songs: [sq("a")], currentIndex: 0, mode: "random", isFmMode: true, fmPlatform: "qq" });
const st = botDb.getQueueState("b2")!;
expect(st.isFmMode).toBe(true);
expect(st.fmPlatform).toBe("qq");
botDb.db.prepare("UPDATE queue_state SET songs='{' WHERE botId=?").run("b2");
expect(botDb.getQueueState("b2")!.songs).toEqual([]);
});
});
});
describe("guest principal migration", () => {
it("creates exactly one reserved guest row, idempotently", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db-"));
const p = join(dir, "t.db");
const a = createDatabase(p); a.db.close();
const b = createDatabase(p); // run again — must not duplicate
const row = b.db.prepare("SELECT id, role FROM users WHERE id = ?").get(GUEST_USER_ID) as { id: string; role: string } | undefined;
expect(row?.role).toBe("guest");
const n = (b.db.prepare("SELECT COUNT(*) AS n FROM users WHERE role='guest'").get() as { n: number }).n;
expect(n).toBe(1);
b.db.close();
rmSync(dir, { recursive: true, force: true });
});
it("guest row does not break first-run detection (countUsers excludes it)", () => {
const dir = mkdtempSync(join(tmpdir(), "tsmb-db2-"));
const p = join(dir, "t.db");
const d = createDatabase(p);
const users = createUserStore(d.db);
expect(users.countUsers()).toBe(0); // guest excluded → still needs setup
d.db.close();
rmSync(dir, { recursive: true, force: true });
});
});
+580 -9
View File
@@ -1,4 +1,46 @@
import Database from "better-sqlite3";
import { CAPABILITIES, BOTS_ALL } from "./permissions.js";
import { GUEST_USER_ID, GUEST_USERNAME } from "./users.js";
import type { QueuedSong } from "../audio/queue.js";
/**
* Reserved owner id for chat-saved / opt-in-shared queues. A `__`-bracketed
* literal can never collide with a real WebUI user id (UUIDs), so it cleanly
* partitions "shared" saved queues from per-user private ones (issue #119).
*/
export const SHARED_QUEUE_OWNER = "__shared__";
/** Cap per owner (private user OR the shared bucket). */
export const MAX_SAVED_QUEUES = 50;
/** Cap per saved queue / persisted live-queue snapshot. */
export const MAX_QUEUE_SONGS = 1000;
/** A stored song is a QueuedSong minus the lazily-resolved `url`. */
export type StoredSong = Omit<QueuedSong, "url">;
/** Saved-queue row without the (potentially large) songs blob — for list views. */
export interface SavedQueueMeta {
id: number;
ownerId: string;
name: string;
songCount: number;
createdAt: string;
updatedAt: string;
}
/** Full saved queue, including its songs. */
export interface SavedQueue extends SavedQueueMeta {
songs: StoredSong[];
}
/** One-row-per-bot persisted live-queue state (Feature 2, auto-restore). */
export interface QueueStateRow {
botId: string;
songs: StoredSong[];
currentIndex: number;
mode: string;
isFmMode: boolean;
fmPlatform: string;
}
export interface PlayHistoryEntry {
botId: string;
@@ -6,8 +48,9 @@ export interface PlayHistoryEntry {
songName: string;
artist: string;
album: string;
platform: "netease" | "qq" | "bilibili";
platform: "netease" | "qq" | "bilibili" | "youtube" | "local" | "kugou" | "spotify" | "jellyfin";
coverUrl: string;
requestedBy?: string;
}
export interface PlayHistoryRecord extends PlayHistoryEntry {
@@ -22,11 +65,67 @@ export interface BotInstance {
serverPort: number;
nickname: string;
defaultChannel: string;
channelId: string;
channelPassword: string;
autoStart: boolean;
/** "ts3" | "ts6" | "" (empty = auto-detect) */
serverProtocol: string;
/** API key for TS6 HTTP Query */
ts6ApiKey: string;
/** Password to join the TS server (server password) */
serverPassword: string;
identity?: string;
}
export interface ProfileConfig {
avatarEnabled: boolean;
descriptionEnabled: boolean;
nicknameEnabled: boolean;
awayStatusEnabled: boolean;
channelDescEnabled: boolean;
nowPlayingMsgEnabled: boolean;
}
export const DEFAULT_PROFILE_CONFIG: ProfileConfig = {
avatarEnabled: true,
descriptionEnabled: true,
nicknameEnabled: true,
awayStatusEnabled: true,
channelDescEnabled: true,
nowPlayingMsgEnabled: true,
};
/**
* Per-bot player settings persisted across restarts (#125): the playback volume
* and play mode. These reset to defaults on process restart when kept only in
* memory (AudioPlayer/PlayQueue), so they are stored on the bot_instances row —
* exactly like the per-bot profile flags — and restored when the bot is (re)built.
*/
export interface PlayerSettings {
/** 0-100. */
volume: number;
/** PlayMode string: "seq" | "loop" | "random" | "rloop". */
playMode: string;
}
const PLAY_MODES = new Set(["seq", "loop", "random", "rloop"]);
export const DEFAULT_PLAYER_SETTINGS: PlayerSettings = {
volume: 75,
playMode: "seq",
};
export interface FavoritePlaylist {
id: number;
userId: string;
platform: string;
playlistId: string;
name: string;
coverUrl: string;
songCount: number;
createdAt: string;
}
export interface BotDatabase {
db: Database.Database;
addPlayHistory(entry: PlayHistoryEntry): void;
@@ -34,16 +133,85 @@ export interface BotDatabase {
saveBotInstance(instance: BotInstance): void;
getBotInstances(): BotInstance[];
deleteBotInstance(id: string): boolean;
getProfileConfig(botId: string): ProfileConfig;
saveProfileConfig(botId: string, config: ProfileConfig): void;
getPlayerSettings(botId: string): PlayerSettings;
saveVolume(botId: string, volume: number): void;
savePlayMode(botId: string, playMode: string): void;
getCustomAvatarPath(botId: string): string | null;
setCustomAvatarPath(botId: string, path: string | null): void;
addFavorite(userId: string, playlist: { platform: string; playlistId: string; name: string; coverUrl: string; songCount: number }): void;
removeFavorite(userId: string, playlistId: string, platform: string): boolean;
getFavorites(userId: string): FavoritePlaylist[];
isFavorited(userId: string, playlistId: string, platform: string): boolean;
// Saved queues (Feature 1) — upsert by (ownerId, name), capped.
saveQueue(ownerId: string, name: string, songs: StoredSong[]): SavedQueue;
listSavedQueues(ownerId: string, includeShared: boolean): SavedQueueMeta[];
getSavedQueue(id: number): SavedQueue | null;
deleteSavedQueue(id: number): boolean;
// Live-queue persistence (Feature 2) — one row per bot.
saveQueueState(state: QueueStateRow): void;
getQueueState(botId: string): QueueStateRow | null;
clearQueueState(botId: string): void;
close(): void;
}
function migrateSchema(db: Database.Database): void {
// Add identity column if it doesn't exist (migration for existing databases)
const columns = db.prepare("PRAGMA table_info(bot_instances)").all() as Array<{ name: string }>;
const hasIdentity = columns.some((c) => c.name === "identity");
if (!hasIdentity) {
const names = columns.map((c) => c.name);
if (!names.includes("identity")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN identity TEXT");
}
if (!names.includes("serverProtocol")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverProtocol TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("ts6ApiKey")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN ts6ApiKey TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("serverPassword")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN serverPassword TEXT NOT NULL DEFAULT ''");
}
if (!names.includes("channelId")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN channelId TEXT NOT NULL DEFAULT ''");
}
// Profile feature flags
const profileCols = [
"profile_avatar_enabled",
"profile_description_enabled",
"profile_nickname_enabled",
"profile_away_enabled",
"profile_channel_desc_enabled",
"profile_now_playing_enabled",
];
for (const col of profileCols) {
if (!names.includes(col)) {
db.exec(`ALTER TABLE bot_instances ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`);
}
}
if (!names.includes("custom_avatar_path")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN custom_avatar_path TEXT");
}
// Per-bot persisted player settings (#125): volume + play mode. Defaults match
// AudioPlayer/PlayQueue's in-memory defaults so pre-existing rows keep behaving
// exactly as before until the user changes them.
if (!names.includes("volume")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN volume INTEGER NOT NULL DEFAULT 75");
}
if (!names.includes("play_mode")) {
db.exec("ALTER TABLE bot_instances ADD COLUMN play_mode TEXT NOT NULL DEFAULT 'seq'");
}
const userColumns = db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>;
const userColNames = userColumns.map((c) => c.name);
if (!userColNames.includes("role")) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'");
}
const historyColumns = db.prepare("PRAGMA table_info(play_history)").all() as Array<{ name: string }>;
const historyColNames = historyColumns.map((c) => c.name);
if (!historyColNames.includes("requestedBy")) {
db.exec("ALTER TABLE play_history ADD COLUMN requestedBy TEXT NOT NULL DEFAULT ''");
}
}
function initTables(db: Database.Database): void {
@@ -57,6 +225,7 @@ function initTables(db: Database.Database): void {
album TEXT NOT NULL,
platform TEXT NOT NULL,
coverUrl TEXT NOT NULL,
requestedBy TEXT NOT NULL DEFAULT '',
playedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
@@ -67,22 +236,150 @@ function initTables(db: Database.Database): void {
serverPort INTEGER NOT NULL,
nickname TEXT NOT NULL,
defaultChannel TEXT NOT NULL,
channelId TEXT NOT NULL DEFAULT '',
channelPassword TEXT NOT NULL,
autoStart INTEGER NOT NULL DEFAULT 0,
serverProtocol TEXT NOT NULL DEFAULT '',
ts6ApiKey TEXT NOT NULL DEFAULT '',
serverPassword TEXT NOT NULL DEFAULT '',
volume INTEGER NOT NULL DEFAULT 75,
play_mode TEXT NOT NULL DEFAULT 'seq',
identity TEXT
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
passwordHash TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'admin'
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
createdAt INTEGER NOT NULL,
expiresAt INTEGER NOT NULL,
lastSeenAt INTEGER NOT NULL,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
actorId TEXT,
actorUsername TEXT,
targetUserId TEXT,
targetUsername TEXT,
action TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_audit_timestamp ON user_audit(timestamp DESC);
CREATE TABLE IF NOT EXISTS favorite_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId TEXT NOT NULL,
platform TEXT NOT NULL,
playlistId TEXT NOT NULL,
name TEXT NOT NULL,
coverUrl TEXT NOT NULL DEFAULT '',
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(userId, platform, playlistId)
);
CREATE INDEX IF NOT EXISTS idx_favorites_userId ON favorite_playlists(userId);
CREATE TABLE IF NOT EXISTS user_permissions (
userId TEXT NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (userId, permission),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_bot_access (
userId TEXT NOT NULL,
botId TEXT NOT NULL,
PRIMARY KEY (userId, botId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_bot_access_userId ON user_bot_access(userId);
CREATE TABLE IF NOT EXISTS saved_queues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ownerId TEXT NOT NULL,
name TEXT NOT NULL,
songs TEXT NOT NULL,
songCount INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(ownerId, name)
);
CREATE INDEX IF NOT EXISTS idx_saved_queues_ownerId ON saved_queues(ownerId);
CREATE TABLE IF NOT EXISTS queue_state (
botId TEXT PRIMARY KEY,
songs TEXT NOT NULL,
currentIndex INTEGER NOT NULL,
mode TEXT NOT NULL,
isFmMode INTEGER NOT NULL DEFAULT 0,
fmPlatform TEXT NOT NULL DEFAULT '',
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
/**
* One-time backfill: existing `member` users created before the
* account-permissions feature are granted full access (all 5 capabilities +
* the `bots.all` marker), exactly once per database. Admins are skipped (they
* bypass permission checks). New members created after this runs are not
* affected — they get the basic tier via POST /api/users. A marker row in
* `schema_meta` makes this idempotent.
*/
export function backfillMemberPermissions(db: Database.Database): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_meta (key TEXT PRIMARY KEY, value TEXT)`);
const done = db.prepare("SELECT value FROM schema_meta WHERE key = 'perm_backfill_done'").get();
if (done) return;
const members = db.prepare("SELECT id FROM users WHERE role = 'member'").all() as { id: string }[];
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const tokens = [...CAPABILITIES, BOTS_ALL];
const tx = db.transaction(() => {
for (const m of members) {
for (const t of tokens) insCap.run(m.id, t);
}
db.prepare("INSERT INTO schema_meta (key, value) VALUES ('perm_backfill_done', ?)").run(String(members.length));
});
tx();
}
/**
* Ensure the reserved guest principal exists. Idempotent via the PK on
* `users.id`. This row only backs login-less guest sessions; it is excluded
* from countUsers()/listUsers() so it never interferes with first-run setup
* or the user-management UI, and holds an unusable password hash.
*/
export function ensureGuestUser(db: Database.Database): void {
const now = Date.now();
db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, '!', ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, now, now);
}
export function createDatabase(dbPath: string): BotDatabase {
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initTables(db);
migrateSchema(db);
backfillMemberPermissions(db);
ensureGuestUser(db);
const insertHistory = db.prepare(`
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl)
INSERT INTO play_history (botId, songId, songName, artist, album, platform, coverUrl, requestedBy)
VALUES (@botId, @songId, @songName, @artist, @album, @platform, @coverUrl, @requestedBy)
`);
const selectHistory = db.prepare(`
@@ -90,16 +387,20 @@ export function createDatabase(dbPath: string): BotDatabase {
`);
const upsertInstance = db.prepare(`
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelPassword, autoStart, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelPassword, @autoStart, @identity)
INSERT INTO bot_instances (id, name, serverAddress, serverPort, nickname, defaultChannel, channelId, channelPassword, autoStart, serverProtocol, ts6ApiKey, serverPassword, identity)
VALUES (@id, @name, @serverAddress, @serverPort, @nickname, @defaultChannel, @channelId, @channelPassword, @autoStart, @serverProtocol, @ts6ApiKey, @serverPassword, @identity)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
serverAddress = excluded.serverAddress,
serverPort = excluded.serverPort,
nickname = excluded.nickname,
defaultChannel = excluded.defaultChannel,
channelId = excluded.channelId,
channelPassword = excluded.channelPassword,
autoStart = excluded.autoStart,
serverProtocol = excluded.serverProtocol,
ts6ApiKey = excluded.ts6ApiKey,
serverPassword = excluded.serverPassword,
identity = excluded.identity
`);
@@ -107,11 +408,117 @@ export function createDatabase(dbPath: string): BotDatabase {
const deleteInstance = db.prepare(`DELETE FROM bot_instances WHERE id = ?`);
const selectProfileConfig = db.prepare(`
SELECT profile_avatar_enabled, profile_description_enabled,
profile_nickname_enabled, profile_away_enabled,
profile_channel_desc_enabled, profile_now_playing_enabled
FROM bot_instances WHERE id = ?
`);
const updateProfileConfig = db.prepare(`
UPDATE bot_instances SET
profile_avatar_enabled = @avatar,
profile_description_enabled = @description,
profile_nickname_enabled = @nickname,
profile_away_enabled = @away,
profile_channel_desc_enabled = @channelDesc,
profile_now_playing_enabled = @nowPlaying
WHERE id = @id
`);
const selectPlayerSettings = db.prepare(
`SELECT volume, play_mode FROM bot_instances WHERE id = ?`,
);
const updateVolume = db.prepare(`UPDATE bot_instances SET volume = ? WHERE id = ?`);
const updatePlayMode = db.prepare(`UPDATE bot_instances SET play_mode = ? WHERE id = ?`);
const selectCustomAvatar = db.prepare(`SELECT custom_avatar_path FROM bot_instances WHERE id = ?`);
const updateCustomAvatar = db.prepare(`UPDATE bot_instances SET custom_avatar_path = ? WHERE id = ?`);
const insertFavorite = db.prepare(`
INSERT INTO favorite_playlists (userId, platform, playlistId, name, coverUrl, songCount)
VALUES (@userId, @platform, @playlistId, @name, @coverUrl, @songCount)
`);
const deleteFavorite = db.prepare(`
DELETE FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
const selectFavorites = db.prepare(`
SELECT id, userId, platform, playlistId, name, coverUrl, songCount, createdAt
FROM favorite_playlists WHERE userId = ? ORDER BY createdAt DESC
`);
const checkFavorited = db.prepare(`
SELECT 1 FROM favorite_playlists WHERE userId = ? AND playlistId = ? AND platform = ?
`);
// A corrupt/hand-edited songs blob must never throw into a route or the
// restore path — degrade to an empty list instead.
const parseSongs = (raw: string): StoredSong[] => {
try {
const v = JSON.parse(raw);
return Array.isArray(v) ? (v as StoredSong[]) : [];
} catch {
return [];
}
};
const rowToSavedMeta = (r: {
id: number; ownerId: string; name: string; songCount: number; createdAt: string; updatedAt: string;
}): SavedQueueMeta => ({
id: r.id,
ownerId: r.ownerId,
name: r.name,
songCount: r.songCount,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
});
const upsertSavedQueue = db.prepare(`
INSERT INTO saved_queues (ownerId, name, songs, songCount)
VALUES (@ownerId, @name, @songs, @songCount)
ON CONFLICT(ownerId, name) DO UPDATE SET
songs = excluded.songs,
songCount = excluded.songCount,
updatedAt = datetime('now')
`);
const selectSavedQueueByOwnerName = db.prepare(
"SELECT * FROM saved_queues WHERE ownerId = ? AND name = ?",
);
const selectSavedQueueIdByOwnerName = db.prepare(
"SELECT id FROM saved_queues WHERE ownerId = ? AND name = ?",
);
const countSavedQueues = db.prepare(
"SELECT COUNT(*) AS c FROM saved_queues WHERE ownerId = ?",
);
const listSavedQueuesOwn = db.prepare(
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? ORDER BY updatedAt DESC",
);
const listSavedQueuesShared = db.prepare(
"SELECT id, ownerId, name, songCount, createdAt, updatedAt FROM saved_queues WHERE ownerId = ? OR ownerId = ? ORDER BY updatedAt DESC",
);
const selectSavedQueueById = db.prepare("SELECT * FROM saved_queues WHERE id = ?");
const deleteSavedQueueById = db.prepare("DELETE FROM saved_queues WHERE id = ?");
const upsertQueueState = db.prepare(`
INSERT INTO queue_state (botId, songs, currentIndex, mode, isFmMode, fmPlatform, updatedAt)
VALUES (@botId, @songs, @currentIndex, @mode, @isFmMode, @fmPlatform, datetime('now'))
ON CONFLICT(botId) DO UPDATE SET
songs = excluded.songs,
currentIndex = excluded.currentIndex,
mode = excluded.mode,
isFmMode = excluded.isFmMode,
fmPlatform = excluded.fmPlatform,
updatedAt = datetime('now')
`);
const selectQueueState = db.prepare("SELECT * FROM queue_state WHERE botId = ?");
const deleteQueueState = db.prepare("DELETE FROM queue_state WHERE botId = ?");
return {
db,
addPlayHistory(record) {
insertHistory.run(record);
insertHistory.run({ ...record, requestedBy: record.requestedBy ?? "" });
},
getPlayHistory(botId, limit) {
@@ -133,6 +540,10 @@ export function createDatabase(dbPath: string): BotDatabase {
return rows.map((r) => ({
...r,
autoStart: r.autoStart === 1,
serverProtocol: r.serverProtocol ?? "",
ts6ApiKey: r.ts6ApiKey ?? "",
serverPassword: r.serverPassword ?? "",
channelId: r.channelId ?? "",
identity: r.identity ?? undefined,
}));
},
@@ -142,6 +553,166 @@ export function createDatabase(dbPath: string): BotDatabase {
return result.changes > 0;
},
getProfileConfig(botId) {
const row = selectProfileConfig.get(botId) as Record<string, number> | undefined;
if (!row) return { ...DEFAULT_PROFILE_CONFIG };
return {
avatarEnabled: row.profile_avatar_enabled === 1,
descriptionEnabled: row.profile_description_enabled === 1,
nicknameEnabled: row.profile_nickname_enabled === 1,
awayStatusEnabled: row.profile_away_enabled === 1,
channelDescEnabled: row.profile_channel_desc_enabled === 1,
nowPlayingMsgEnabled: row.profile_now_playing_enabled === 1,
};
},
saveProfileConfig(botId, config) {
updateProfileConfig.run({
id: botId,
avatar: config.avatarEnabled ? 1 : 0,
description: config.descriptionEnabled ? 1 : 0,
nickname: config.nicknameEnabled ? 1 : 0,
away: config.awayStatusEnabled ? 1 : 0,
channelDesc: config.channelDescEnabled ? 1 : 0,
nowPlaying: config.nowPlayingMsgEnabled ? 1 : 0,
});
},
getPlayerSettings(botId) {
const row = selectPlayerSettings.get(botId) as
| { volume: number | null; play_mode: string | null }
| undefined;
if (!row) return { ...DEFAULT_PLAYER_SETTINGS };
// Coerce/validate: clamp volume to 0-100 and fall back to defaults for any
// NULL / out-of-range / unknown value (a hand-edited DB must never feed a
// bad value into AudioPlayer.setVolume / PlayQueue.setMode).
const rawVol = typeof row.volume === "number" ? row.volume : DEFAULT_PLAYER_SETTINGS.volume;
const volume = Number.isFinite(rawVol)
? Math.max(0, Math.min(100, Math.round(rawVol)))
: DEFAULT_PLAYER_SETTINGS.volume;
const playMode =
typeof row.play_mode === "string" && PLAY_MODES.has(row.play_mode)
? row.play_mode
: DEFAULT_PLAYER_SETTINGS.playMode;
return { volume, playMode };
},
saveVolume(botId, volume) {
const clamped = Math.max(0, Math.min(100, Math.round(volume)));
updateVolume.run(clamped, botId);
},
savePlayMode(botId, playMode) {
// Persist only recognized modes so a bad value can never poison the row.
if (!PLAY_MODES.has(playMode)) return;
updatePlayMode.run(playMode, botId);
},
getCustomAvatarPath(botId) {
const row = selectCustomAvatar.get(botId) as { custom_avatar_path: string | null } | undefined;
return row?.custom_avatar_path ?? null;
},
setCustomAvatarPath(botId, path) {
updateCustomAvatar.run(path, botId);
},
addFavorite(userId, playlist) {
insertFavorite.run({ userId, ...playlist });
},
removeFavorite(userId, playlistId, platform) {
const result = deleteFavorite.run(userId, playlistId, platform);
return result.changes > 0;
},
getFavorites(userId) {
return selectFavorites.all(userId) as FavoritePlaylist[];
},
isFavorited(userId, playlistId, platform) {
const row = checkFavorited.get(userId, playlistId, platform);
return row !== undefined;
},
saveQueue(ownerId, name, songs) {
if (songs.length > MAX_QUEUE_SONGS) {
throw new Error(`保存失败:歌曲数量超过上限 ${MAX_QUEUE_SONGS}`);
}
// Strip any lazily-resolved url before persisting.
const stripped: StoredSong[] = songs.map((s) => {
const { url: _url, ...rest } = s as QueuedSong;
return rest;
});
// Enforce the per-owner cap only for a NEW name (an overwrite of an
// existing saved queue must always be allowed).
const existing = selectSavedQueueIdByOwnerName.get(ownerId, name) as
| { id: number }
| undefined;
if (!existing) {
const { c } = countSavedQueues.get(ownerId) as { c: number };
if (c >= MAX_SAVED_QUEUES) {
throw new Error(`保存失败:已保存队列数量超过上限 ${MAX_SAVED_QUEUES}`);
}
}
upsertSavedQueue.run({
ownerId,
name,
songs: JSON.stringify(stripped),
songCount: stripped.length,
});
const row = selectSavedQueueByOwnerName.get(ownerId, name) as SavedQueueMeta;
return { ...rowToSavedMeta(row), songs: stripped };
},
listSavedQueues(ownerId, includeShared) {
const rows = includeShared
? (listSavedQueuesShared.all(ownerId, SHARED_QUEUE_OWNER) as SavedQueueMeta[])
: (listSavedQueuesOwn.all(ownerId) as SavedQueueMeta[]);
return rows.map(rowToSavedMeta);
},
getSavedQueue(id) {
const row = selectSavedQueueById.get(id) as
| (SavedQueueMeta & { songs: string })
| undefined;
if (!row) return null;
return { ...rowToSavedMeta(row), songs: parseSongs(row.songs) };
},
deleteSavedQueue(id) {
return deleteSavedQueueById.run(id).changes > 0;
},
saveQueueState(state) {
upsertQueueState.run({
botId: state.botId,
songs: JSON.stringify(state.songs),
currentIndex: state.currentIndex,
mode: state.mode,
isFmMode: state.isFmMode ? 1 : 0,
fmPlatform: state.fmPlatform,
});
},
getQueueState(botId) {
const r = selectQueueState.get(botId) as
| { botId: string; songs: string; currentIndex: number; mode: string; isFmMode: number; fmPlatform: string }
| undefined;
if (!r) return null;
return {
botId: r.botId,
songs: parseSongs(r.songs),
currentIndex: r.currentIndex,
mode: r.mode,
isFmMode: r.isFmMode === 1,
fmPlatform: r.fmPlatform,
};
},
clearQueueState(botId) {
deleteQueueState.run(botId);
},
close() {
db.close();
},
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, backfillMemberPermissions, type BotDatabase } from "./database.js";
import { createPermissionStore, CAPABILITIES } from "./permissions.js";
describe("backfillMemberPermissions", () => {
let dbFile: string;
let db: BotDatabase;
function fresh() {
dbFile = path.join(os.tmpdir(), `mig-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
}
afterEach(() => {
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.rmSync(dbFile + s, { force: true });
} catch {}
}
});
it("grants existing members full access + bots.all, skips admins, once", () => {
fresh();
// simulate a pre-feature DB: clear the marker that createDatabase set, add users, no perm rows
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
const ins = db.db.prepare(
"INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)"
);
ins.run("m1", "mem", "x", now, now, "member");
ins.run("a1", "adm", "x", now, now, "admin");
backfillMemberPermissions(db.db);
const store = createPermissionStore(db.db);
expect(store.getCapabilities("m1").sort()).toEqual([...CAPABILITIES].sort());
expect(store.getBotAccess("m1")).toBe("all");
expect(store.getCapabilities("a1")).toEqual([]);
expect(store.getBotAccess("a1")).toEqual([]);
});
it("is idempotent — running again does not change or re-grant", () => {
fresh();
db.db.prepare("DELETE FROM schema_meta WHERE key = 'perm_backfill_done'").run();
const now = Date.now();
db.db
.prepare("INSERT INTO users (id,username,passwordHash,createdAt,updatedAt,role) VALUES (?,?,?,?,?,?)")
.run("m1", "mem", "x", now, now, "member");
backfillMemberPermissions(db.db);
// member restricted afterwards
createPermissionStore(db.db).setPermissions("m1", { capabilities: [], bots: [] });
// second run must NOT re-grant (marker present)
backfillMemberPermissions(db.db);
expect(createPermissionStore(db.db).getCapabilities("m1")).toEqual([]);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createDatabase, type BotDatabase } from "./database.js";
import { createPermissionStore } from "./permissions.js";
import { CAPABILITIES, BASIC_TIER_CAPABILITIES, resolvePermissionContext } from "./permissions.js";
describe("PermissionStore", () => {
let dbFile: string;
let db: BotDatabase;
beforeEach(() => {
dbFile = path.join(os.tmpdir(), `perm-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
db = createDatabase(dbFile);
db.db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?,?,?,?,?,?)"
).run("u1", "alice", "x", Date.now(), Date.now(), "member");
});
afterEach(() => {
db.close();
try { fs.rmSync(dbFile, { force: true }); } catch {}
try { fs.rmSync(dbFile + "-wal", { force: true }); } catch {}
try { fs.rmSync(dbFile + "-shm", { force: true }); } catch {}
});
it("exposes the five capability tokens and a basic tier", () => {
expect(CAPABILITIES).toEqual([
"player.control", "player.queue", "bot.manage", "platform.auth", "quality",
]);
expect(BASIC_TIER_CAPABILITIES).toEqual(["player.control", "player.queue"]);
});
it("defaults to no capabilities and no bots", () => {
const store = createPermissionStore(db.db);
expect(store.getCapabilities("u1")).toEqual([]);
expect(store.getBotAccess("u1")).toEqual([]);
});
it("round-trips capabilities and a specific bot list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "quality"], bots: ["botA", "botB"] });
expect(store.getCapabilities("u1").sort()).toEqual(["player.control", "quality"]);
expect(store.getBotAccess("u1")).toEqual(["botA", "botB"]);
});
it("stores the all-bots flag as 'all'", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: "all" });
expect(store.getBotAccess("u1")).toBe("all");
});
it("setPermissions replaces prior capabilities and bots", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["botA"] });
store.setPermissions("u1", { capabilities: ["quality"], bots: "all" });
expect(store.getCapabilities("u1")).toEqual(["quality"]);
expect(store.getBotAccess("u1")).toBe("all");
});
it("ignores unknown capability tokens", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control", "bogus" as any], bots: [] });
expect(store.getCapabilities("u1")).toEqual(["player.control"]);
});
it("pruneBot removes a bot from every user's allow-list", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: [], bots: ["botA", "botB"] });
store.pruneBot("botA");
expect(store.getBotAccess("u1")).toEqual(["botB"]);
});
describe("resolvePermissionContext", () => {
it("admin gets all capabilities and all bots regardless of stored rows", () => {
const store = createPermissionStore(db.db);
const ctx = resolvePermissionContext("admin", "u1", store);
expect([...ctx.capabilities].sort()).toEqual([...CAPABILITIES].sort());
expect(ctx.bots).toBe("all");
});
it("member reflects stored capabilities + bot access", () => {
const store = createPermissionStore(db.db);
store.setPermissions("u1", { capabilities: ["player.control"], bots: ["b1"] });
const ctx = resolvePermissionContext("member", "u1", store);
expect([...ctx.capabilities]).toEqual(["player.control"]);
expect(ctx.bots).toEqual(new Set(["b1"]));
});
});
});
import { GUEST_PERMISSION_FLAGS } from "./permissions.js";
describe("resolvePermissionContext guest branch", () => {
const noStore = {
getCapabilities: () => [],
getBotAccess: () => [] as string[],
setPermissions: () => {},
pruneBot: () => {},
};
it("guest has no member capabilities and exposes the guest permissions + bots", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: ["bot1"],
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: true, transport: false, removeClear: false, playMode: false,
playCollection: false,
},
});
expect([...ctx.capabilities]).toEqual([]);
expect(ctx.bots).toBeInstanceOf(Set);
expect((ctx.bots as Set<string>).has("bot1")).toBe(true);
expect(ctx.guest?.addToQueue).toBe(true);
expect(ctx.guest?.skip).toBe(true);
});
it("guest with bots:'all' resolves to 'all'", () => {
const ctx = resolvePermissionContext("guest", "__guest__", noStore, {
bots: "all",
permissions: {
addToQueue: true, playNext: false, playNow: false,
skip: false, transport: false, removeClear: false, playMode: false,
playCollection: false,
},
});
expect(ctx.bots).toBe("all");
});
it("exposes the 8 canonical flags", () => {
expect([...GUEST_PERMISSION_FLAGS].sort()).toEqual(
["addToQueue", "playCollection", "playMode", "playNext", "playNow", "removeClear", "skip", "transport"].sort()
);
});
});
+123
View File
@@ -0,0 +1,123 @@
import type Database from "better-sqlite3";
export const CAPABILITIES = [
"player.control",
"player.queue",
"bot.manage",
"platform.auth",
"quality",
] as const;
export type Capability = (typeof CAPABILITIES)[number];
/** Marker token stored in user_permissions meaning "all bots, incl. future". */
export const BOTS_ALL = "bots.all";
/** Capabilities granted to a newly-created member by default. */
export const BASIC_TIER_CAPABILITIES: Capability[] = ["player.control", "player.queue"];
export function isCapability(x: string): x is Capability {
return (CAPABILITIES as readonly string[]).includes(x);
}
export type BotAccess = "all" | string[];
export interface GuestPermissions {
addToQueue: boolean;
playNext: boolean;
playNow: boolean;
skip: boolean;
transport: boolean;
removeClear: boolean;
playMode: boolean;
/** Load + play an entire playlist/album (clears the queue). Issue #103. */
playCollection: boolean;
}
export const GUEST_PERMISSION_FLAGS = [
"addToQueue",
"playNext",
"playNow",
"skip",
"transport",
"removeClear",
"playMode",
"playCollection",
] as const;
export type GuestFlag = (typeof GUEST_PERMISSION_FLAGS)[number];
export interface PermissionStore {
getCapabilities(userId: string): Capability[];
getBotAccess(userId: string): BotAccess;
setPermissions(userId: string, input: { capabilities: string[]; bots: BotAccess }): void;
pruneBot(botId: string): void;
}
export function createPermissionStore(db: Database.Database): PermissionStore {
const selCaps = db.prepare("SELECT permission FROM user_permissions WHERE userId = ?");
const delCaps = db.prepare("DELETE FROM user_permissions WHERE userId = ?");
const insCap = db.prepare("INSERT OR IGNORE INTO user_permissions (userId, permission) VALUES (?, ?)");
const selBots = db.prepare("SELECT botId FROM user_bot_access WHERE userId = ?");
const delBots = db.prepare("DELETE FROM user_bot_access WHERE userId = ?");
const insBot = db.prepare("INSERT OR IGNORE INTO user_bot_access (userId, botId) VALUES (?, ?)");
const pruneBotStmt = db.prepare("DELETE FROM user_bot_access WHERE botId = ?");
return {
getCapabilities(userId) {
return (selCaps.all(userId) as { permission: string }[])
.map((r) => r.permission)
.filter((p): p is Capability => isCapability(p));
},
getBotAccess(userId) {
const all = (selCaps.all(userId) as { permission: string }[]).some((r) => r.permission === BOTS_ALL);
if (all) return "all";
return (selBots.all(userId) as { botId: string }[]).map((r) => r.botId);
},
setPermissions(userId, input) {
const caps = input.capabilities.filter(isCapability);
const tx = db.transaction(() => {
delCaps.run(userId);
delBots.run(userId);
for (const c of caps) insCap.run(userId, c);
if (input.bots === "all") {
insCap.run(userId, BOTS_ALL);
} else {
for (const b of input.bots) insBot.run(userId, b);
}
});
tx();
},
pruneBot(botId) {
pruneBotStmt.run(botId);
},
};
}
export interface PermissionContext {
capabilities: Set<string>;
bots: "all" | Set<string>;
guest?: GuestPermissions;
}
export function resolvePermissionContext(
role: "admin" | "member" | "guest",
userId: string,
store: PermissionStore,
guest?: { bots: BotAccess; permissions: GuestPermissions }
): PermissionContext {
if (role === "admin") {
return { capabilities: new Set(CAPABILITIES), bots: "all" };
}
if (role === "guest") {
const bots = guest?.bots ?? [];
return {
capabilities: new Set<string>(),
bots: bots === "all" ? "all" : new Set(bots),
guest: guest?.permissions,
};
}
const access = store.getBotAccess(userId);
return {
capabilities: new Set(store.getCapabilities(userId)),
bots: access === "all" ? "all" : new Set(access),
};
}
+199
View File
@@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS, MAX_SESSIONS_PER_USER, GUEST_SESSION_TTL_MS } from "./sessions.js";
function sha256(token: string) {
return createHash("sha256").update(token).digest("hex");
}
describe("SessionStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let sessions: SessionStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("createSession returns a raw token whose sha256 matches the DB row id", () => {
const { token } = sessions.createSession(userId);
const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string };
expect(row.id).toBe(sha256(token));
expect(row.id).not.toBe(token);
});
it("validateAndTouch returns the user for a fresh token", () => {
const { token } = sessions.createSession(userId);
const result = sessions.validateAndTouch(token);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null and deletes the row for an expired session", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
expect(sessions.validateAndTouch(token)).toBeNull();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
});
it("validateAndTouch does not write the DB if called again within the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token } = sessions.createSession(userId);
const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000);
sessions.validateAndTouch(token);
const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number };
expect(after.lastSeenAt).toBe(before.lastSeenAt);
});
it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { token, expiresAt: initialExpiry } = sessions.createSession(userId);
vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000);
sessions.validateAndTouch(token);
const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number };
expect(row.lastSeenAt).toBe(Date.now());
expect(row.expiresAt).toBeGreaterThan(initialExpiry);
});
it("deleteSession removes the row", () => {
const { token } = sessions.createSession(userId);
sessions.deleteSession(token);
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(0);
expect(sessions.validateAndTouch(token)).toBeNull();
});
it("deleteAllForUser keeps the exceptToken session", () => {
const a = sessions.createSession(userId);
const b = sessions.createSession(userId);
sessions.deleteAllForUser(userId, a.token);
expect(sessions.validateAndTouch(a.token)).not.toBeNull();
expect(sessions.validateAndTouch(b.token)).toBeNull();
});
it("cleanupExpired removes only expired rows", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
sessions.createSession(userId); // expires later
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000);
sessions.createSession(userId); // fresh
sessions.cleanupExpired();
const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(remaining).toBe(1);
});
it("createSession caps concurrent sessions per user at MAX_SESSIONS_PER_USER, evicting oldest", async () => {
// Create MAX + 2 sessions for the same user.
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 2; i++) {
tokens.push(sessions.createSession(userId).token);
await new Promise((r) => setTimeout(r, 2)); // stagger createdAt
}
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
// The first two should have been evicted, the last MAX remain
expect(sessions.validateAndTouch(tokens[0])).toBeNull();
expect(sessions.validateAndTouch(tokens[1])).toBeNull();
expect(sessions.validateAndTouch(tokens[tokens.length - 1])).not.toBeNull();
});
it("createSession respects cap under concurrent calls (no 1-over-cap race)", async () => {
// better-sqlite3 transactions are serialised at the engine level. Calling
// createSession N times sequentially via Promise.all proves atomic check+insert.
const N = MAX_SESSIONS_PER_USER + 3;
await Promise.all(Array.from({ length: N }, () => Promise.resolve(sessions.createSession(userId))));
const count = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n;
expect(count).toBe(MAX_SESSIONS_PER_USER);
});
});
describe("guest sessions", () => {
let botDb: BotDatabase;
let sessions: SessionStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
sessions = createSessionStore(botDb.db);
// Create the synthetic guest user row to satisfy the sessions FK.
botDb.db
.prepare("INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('__guest__','游客','!',?,?, 'guest')")
.run(Date.now(), Date.now());
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("skipCap lets more than MAX_SESSIONS_PER_USER coexist for one principal", () => {
const tokens: string[] = [];
for (let i = 0; i < MAX_SESSIONS_PER_USER + 3; i++) {
tokens.push(sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true }).token);
}
// The first token must STILL validate (not evicted).
expect(sessions.validateAndTouch(tokens[0])?.role).toBe("guest");
const n = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId='__guest__'").get() as { n: number }).n;
expect(n).toBe(MAX_SESSIONS_PER_USER + 3);
});
it("ttlMs sets a shorter expiry than the default", () => {
const { expiresAt } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
expect(expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 50);
});
it("validateAndTouch refreshes a guest session to GUEST_SESSION_TTL_MS (1d), not SESSION_TTL_MS (7d)", () => {
const { token } = sessions.createSession("__guest__", { ttlMs: GUEST_SESSION_TTL_MS, skipCap: true });
// Force the touch branch: backdate lastSeenAt past the touch interval.
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = '__guest__'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("guest");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = '__guest__'")
.get() as { expiresAt: number };
// Should refresh to ~now + 1 day, NOT now + 7 days.
expect(row.expiresAt).toBeGreaterThan(Date.now() + GUEST_SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + GUEST_SESSION_TTL_MS + 5000);
// Sanity: well below the 7d window.
expect(row.expiresAt).toBeLessThan(Date.now() + SESSION_TTL_MS);
});
it("validateAndTouch still refreshes a non-guest (admin) session to SESSION_TTL_MS (7d) on touch", () => {
botDb.db
.prepare("INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES ('admin1','adminuser','!',?,?, 'admin')")
.run(Date.now(), Date.now());
const { token } = sessions.createSession("admin1");
botDb.db
.prepare("UPDATE sessions SET lastSeenAt = ? WHERE userId = 'admin1'")
.run(Date.now() - (SESSION_TOUCH_INTERVAL_MS + 1000));
const result = sessions.validateAndTouch(token);
expect(result?.role).toBe("admin");
const row = botDb.db
.prepare("SELECT expiresAt FROM sessions WHERE userId = 'admin1'")
.get() as { expiresAt: number };
// Refreshes to ~now + 7 days, NOT the 1d guest window.
expect(row.expiresAt).toBeGreaterThan(Date.now() + SESSION_TTL_MS - 5000);
expect(row.expiresAt).toBeLessThanOrEqual(Date.now() + SESSION_TTL_MS + 5000);
});
});
+110
View File
@@ -0,0 +1,110 @@
import { createHash, randomBytes } from "node:crypto";
import type Database from "better-sqlite3";
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const GUEST_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 1 day — guests are short-lived
export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export const MAX_SESSIONS_PER_USER = 10;
export interface SessionValidation {
userId: string;
username: string;
role: "admin" | "member" | "guest";
}
export interface SessionStore {
createSession(userId: string, opts?: { ttlMs?: number; skipCap?: boolean }): { token: string; expiresAt: number };
validateAndTouch(rawToken: string): SessionValidation | null;
deleteSession(rawToken: string): void;
deleteAllForUser(userId: string, exceptToken?: string): void;
cleanupExpired(): void;
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function createSessionStore(db: Database.Database): SessionStore {
const insertStmt = db.prepare(
"INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)"
);
const selectStmt = db.prepare(`
SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username, u.role
FROM sessions s INNER JOIN users u ON u.id = s.userId
WHERE s.id = ?
`);
const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?");
const touchStmt = db.prepare(
"UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?"
);
const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?");
const deleteAllForUserExceptStmt = db.prepare(
"DELETE FROM sessions WHERE userId = ? AND id != ?"
);
const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE userId = ?");
const deleteOldestForUserStmt = db.prepare(
"DELETE FROM sessions WHERE id IN (SELECT id FROM sessions WHERE userId = ? ORDER BY createdAt ASC LIMIT ?)"
);
return {
createSession(userId, opts) {
// Cap concurrent sessions per user — oldest gets evicted on overflow.
// Wrap the count → delete → insert in a transaction so concurrent logins
// for the same user can't both pass the cap check and both insert,
// ending up 1 over cap (race window between count and insert).
const token = randomBytes(32).toString("base64url");
const id = hashToken(token);
const now = Date.now();
const expiresAt = now + (opts?.ttlMs ?? SESSION_TTL_MS);
const tx = db.transaction(() => {
if (!opts?.skipCap) {
const existing = (countForUserStmt.get(userId) as { n: number }).n;
if (existing >= MAX_SESSIONS_PER_USER) {
deleteOldestForUserStmt.run(userId, existing - MAX_SESSIONS_PER_USER + 1);
}
}
insertStmt.run(id, userId, now, expiresAt, now);
});
tx();
return { token, expiresAt };
},
validateAndTouch(rawToken) {
if (!rawToken) return null;
const id = hashToken(rawToken);
const row = selectStmt.get(id) as
| { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string; role: string }
| undefined;
if (!row) return null;
const now = Date.now();
if (row.expiresAt < now) {
deleteByIdStmt.run(id);
return null;
}
if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) {
// Refresh against the role's own TTL — guests are short-lived (1d) and
// must NOT be bumped to the member/admin 7d window on touch.
const ttl = row.role === "guest" ? GUEST_SESSION_TTL_MS : SESSION_TTL_MS;
touchStmt.run(now, now + ttl, id);
}
return { userId: row.userId, username: row.username, role: row.role as "admin" | "member" | "guest" };
},
deleteSession(rawToken) {
deleteByIdStmt.run(hashToken(rawToken));
},
deleteAllForUser(userId, exceptToken) {
if (exceptToken) {
deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken));
} else {
deleteAllForUserStmt.run(userId);
}
},
cleanupExpired() {
cleanupStmt.run(Date.now());
},
};
}
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, UsernameTakenError, GUEST_USER_ID, GUEST_USERNAME, type UserStore } from "./users.js";
describe("UserStore", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers is 0 on a fresh db", () => {
expect(users.countUsers()).toBe(0);
});
it("createUser stores the user and bumps countUsers", async () => {
const u = await users.createUser("alice", "pw-hunter2", "member");
expect(u.id).toMatch(/^[0-9a-f-]{36}$/);
expect(u.username).toBe("alice");
expect(users.countUsers()).toBe(1);
});
it("findByUsername is case-insensitive and returns null for missing", async () => {
await users.createUser("Alice", "pw-alice", "member");
expect(users.findByUsername("ALICE")).not.toBeNull();
expect(users.findByUsername("alice")).not.toBeNull();
expect(users.findByUsername("bob")).toBeNull();
});
it("createUser rejects duplicate usernames (case-insensitive)", async () => {
await users.createUser("Alice", "pw-alice", "member");
await expect(users.createUser("alice", "pw-alice-2", "member")).rejects.toBeInstanceOf(UsernameTakenError);
});
it("verifyPassword accepts correct password and rejects wrong one", async () => {
await users.createUser("alice", "correct-horse-battery-staple", "member");
const row = users.findByUsername("alice");
expect(row).not.toBeNull();
expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true);
expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false);
});
it("changePassword updates the hash so the old password no longer verifies", async () => {
const u = await users.createUser("alice", "old-pw-pw", "member");
await users.changePassword(u.id, "new-pw-pw");
const row = users.findByUsername("alice");
expect(await users.verifyPassword("old-pw-pw", row!.passwordHash)).toBe(false);
expect(await users.verifyPassword("new-pw-pw", row!.passwordHash)).toBe(true);
});
it("listUsers returns id+username+createdAt ascending, no password hash", async () => {
await users.createUser("alice", "pw-alice", "member");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
expect(list).toHaveLength(2);
expect(list[0].username).toBe("alice");
expect(list[1].username).toBe("bob");
expect(list[0]).not.toHaveProperty("passwordHash");
expect(list[0].id).toMatch(/^[0-9a-f-]{36}$/);
expect(typeof list[0].createdAt).toBe("number");
});
it("deleteUser removes the row and returns true; returns false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.deleteUser(u.id)).toBe(true);
expect(users.countUsers()).toBe(0);
expect(users.deleteUser("not-a-real-id")).toBe(false);
});
it("createFirstUser succeeds on empty db, returns null when a user already exists", async () => {
const a = await users.createFirstUser("alice", "pw-alice");
expect(a).not.toBeNull();
expect(a!.username).toBe("alice");
const b = await users.createFirstUser("bob", "pw-bob-bob");
expect(b).toBeNull();
expect(users.countUsers()).toBe(1);
});
it("createFirstUser is race-safe: concurrent calls produce exactly one user", async () => {
const [a, b, c] = await Promise.all([
users.createFirstUser("alice", "pw-alice"),
users.createFirstUser("bob", "pw-bob-bob"),
users.createFirstUser("charlie", "pw-charlie-pw"),
]);
const created = [a, b, c].filter((u) => u !== null);
expect(created).toHaveLength(1);
expect(users.countUsers()).toBe(1);
});
it("createFirstUser always creates an admin", async () => {
const u = await users.createFirstUser("alice", "pw-alice");
expect(u).not.toBeNull();
expect(u!.role).toBe("admin");
});
it("countAdmins reflects only role=admin", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
expect(users.countUsers()).toBe(2);
expect(users.countAdmins()).toBe(1);
});
it("setRole changes the role and returns true; false for unknown id", async () => {
const u = await users.createUser("alice", "pw-alice", "member");
expect(users.setRole(u.id, "admin")).toBe(true);
expect(users.findById(u.id)!.role).toBe("admin");
expect(users.setRole("nope", "admin")).toBe(false);
});
it("listUsers includes role", async () => {
await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "member");
const list = users.listUsers();
const alice = list.find((u) => u.username === "alice")!;
const bob = list.find((u) => u.username === "bob")!;
expect(alice.role).toBe("admin");
expect(bob.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'would_orphan' for the only admin being demoted", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("would_orphan");
expect(users.findById(alice.id)!.role).toBe("admin"); // unchanged
});
it("setRoleIfNotLastAdmin allows demotion when another admin exists", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
await users.createUser("bob", "pw-bob-bob", "admin");
expect(users.setRoleIfNotLastAdmin(alice.id, "member")).toBe("ok");
expect(users.findById(alice.id)!.role).toBe("member");
});
it("setRoleIfNotLastAdmin returns 'not_found' for unknown id", () => {
expect(users.setRoleIfNotLastAdmin("not-a-real-id", "member")).toBe("not_found");
});
it("setRoleIfNotLastAdmin: concurrent demotions of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
// Concurrent demotion of both
const [r1, r2] = await Promise.all([
Promise.resolve(users.setRoleIfNotLastAdmin(alice.id, "member")),
Promise.resolve(users.setRoleIfNotLastAdmin(bob.id, "member")),
]);
// Exactly one should succeed; the other gets "would_orphan"
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
// System retains at least one admin
expect(users.countAdmins()).toBe(1);
});
it("deleteUserIfNotLastAdmin returns 'would_orphan' for the only admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
expect(users.deleteUserIfNotLastAdmin(alice.id)).toBe("would_orphan");
expect(users.findById(alice.id)).not.toBeNull();
});
it("deleteUserIfNotLastAdmin allows deleting a member at any count", async () => {
await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "member");
expect(users.deleteUserIfNotLastAdmin(bob.id)).toBe("ok");
expect(users.findById(bob.id)).toBeNull();
});
it("deleteUserIfNotLastAdmin: concurrent deletes of two admins keep one admin", async () => {
const alice = await users.createUser("alice", "pw-alice", "admin");
const bob = await users.createUser("bob", "pw-bob-bob", "admin");
const [r1, r2] = await Promise.all([
Promise.resolve(users.deleteUserIfNotLastAdmin(alice.id)),
Promise.resolve(users.deleteUserIfNotLastAdmin(bob.id)),
]);
const oks = [r1, r2].filter((r) => r === "ok").length;
const orphans = [r1, r2].filter((r) => r === "would_orphan").length;
expect(oks).toBe(1);
expect(orphans).toBe(1);
expect(users.countAdmins()).toBe(1);
});
});
describe("guest row exclusion", () => {
let botDb: BotDatabase;
let users: UserStore;
beforeEach(() => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
});
afterEach(() => {
botDb.close();
});
it("countUsers and listUsers ignore the reserved guest row", async () => {
await users.createUser("alice", "password123", "member");
// Insert the reserved guest row directly (mirrors the migration).
botDb.db.prepare(
"INSERT OR IGNORE INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, 'guest')"
).run(GUEST_USER_ID, GUEST_USERNAME, "!", Date.now(), Date.now());
expect(users.countUsers()).toBe(1); // alice only
expect(users.listUsers().some((u) => u.id === GUEST_USER_ID)).toBe(false);
});
it("setRoleIfNotLastAdmin refuses to re-role the reserved guest principal", () => {
// The guest row is seeded by createDatabase via ensureGuestUser.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest"); // sanity
expect(users.setRoleIfNotLastAdmin(GUEST_USER_ID, "admin")).toBe("not_found");
// The guest row's role is unchanged.
expect(users.findById(GUEST_USER_ID)!.role).toBe("guest");
});
it("deleteUserIfNotLastAdmin refuses to delete the reserved guest principal", () => {
expect(users.findById(GUEST_USER_ID)).not.toBeNull(); // sanity
expect(users.deleteUserIfNotLastAdmin(GUEST_USER_ID)).toBe("not_found");
// The guest row still exists.
expect(users.findById(GUEST_USER_ID)).not.toBeNull();
});
});
+176
View File
@@ -0,0 +1,176 @@
import { randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
import bcrypt from "bcryptjs";
const BCRYPT_ROUNDS = 12;
export type UserRole = "admin" | "member" | "guest";
/** Reserved synthetic principal for login-less guest sessions. The username is
* non-ASCII so it can never collide with an API-created account (which is
* validated against ^[A-Za-z0-9_\-.]{3,32}$). */
export const GUEST_USER_ID = "__guest__";
export const GUEST_USERNAME = "游客";
export interface UserRow {
id: string;
username: string;
passwordHash: string;
createdAt: number;
updatedAt: number;
role: UserRole;
}
export interface UserStore {
countUsers(): number;
countAdmins(): number;
createUser(username: string, password: string, role: UserRole): Promise<UserRow>;
createFirstUser(username: string, password: string): Promise<UserRow | null>;
findByUsername(username: string): UserRow | null;
findById(id: string): UserRow | null;
verifyPassword(plain: string, hash: string): Promise<boolean>;
changePassword(userId: string, newPassword: string): Promise<void>;
setRole(userId: string, role: UserRole): boolean;
setRoleIfNotLastAdmin(id: string, newRole: UserRole): "ok" | "not_found" | "would_orphan";
deleteUser(id: string): boolean;
deleteUserIfNotLastAdmin(id: string): "ok" | "not_found" | "would_orphan";
listUsers(): Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
}
export class UsernameTakenError extends Error {
constructor(username: string) {
super(`username taken: ${username}`);
this.name = "UsernameTakenError";
}
}
export function createUserStore(db: Database.Database): UserStore {
const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role != 'guest'");
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'");
const insertStmt = db.prepare(
"INSERT INTO users (id, username, passwordHash, createdAt, updatedAt, role) VALUES (?, ?, ?, ?, ?, ?)"
);
const findByUsernameStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE username = ? COLLATE NOCASE"
);
const findByIdStmt = db.prepare(
"SELECT id, username, passwordHash, createdAt, updatedAt, role FROM users WHERE id = ?"
);
const updatePasswordStmt = db.prepare(
"UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?"
);
const updateRoleStmt = db.prepare(
"UPDATE users SET role = ?, updatedAt = ? WHERE id = ?"
);
const listUsersStmt = db.prepare(
"SELECT id, username, createdAt, role FROM users WHERE role != 'guest' ORDER BY createdAt ASC"
);
const deleteUserStmt = db.prepare("DELETE FROM users WHERE id = ?");
return {
countUsers() {
return (countStmt.get() as { n: number }).n;
},
countAdmins() {
return (countAdminsStmt.get() as { n: number }).n;
},
async createUser(username, password, role) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
try {
insertStmt.run(id, username, hash, now, now, role);
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
throw new UsernameTakenError(username);
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role };
},
async createFirstUser(username, password) {
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const id = randomUUID();
const now = Date.now();
const run = db.transaction(() => {
const count = (countStmt.get() as { n: number }).n;
if (count !== 0) return null;
try {
insertStmt.run(id, username, hash, now, now, "admin");
} catch (err) {
if (err && typeof err === "object" && (err as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE") {
return null;
}
throw err;
}
return { id, username, passwordHash: hash, createdAt: now, updatedAt: now, role: "admin" } as UserRow;
});
return run();
},
findByUsername(username) {
return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null;
},
findById(id) {
return (findByIdStmt.get(id) as UserRow | undefined) ?? null;
},
verifyPassword(plain, hash) {
return bcrypt.compare(plain, hash);
},
async changePassword(userId, newPassword) {
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
updatePasswordStmt.run(hash, Date.now(), userId);
},
setRole(userId, role) {
const result = updateRoleStmt.run(role, Date.now(), userId);
return result.changes > 0;
},
setRoleIfNotLastAdmin(id, newRole) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === newRole) return "ok" as const; // no-op
if (row.role === "admin" && newRole === "member") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
updateRoleStmt.run(newRole, Date.now(), id);
return "ok" as const;
});
return tx();
},
listUsers() {
return listUsersStmt.all() as Array<{ id: string; username: string; createdAt: number; role: UserRole }>;
},
deleteUser(id) {
const result = deleteUserStmt.run(id);
return result.changes > 0;
},
deleteUserIfNotLastAdmin(id) {
const tx = db.transaction(() => {
const row = findByIdStmt.get(id) as UserRow | undefined;
if (!row) return "not_found" as const;
if (row.role === "guest") return "not_found" as const; // reserved synthetic principal
if (row.role === "admin") {
const adminCount = (countAdminsStmt.get() as { n: number }).n;
if (adminCount <= 1) return "would_orphan" as const;
}
deleteUserStmt.run(id);
return "ok" as const;
});
return tx();
},
};
}
Regular → Executable
+98 -5
View File
@@ -1,26 +1,43 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConfig, saveConfig } from "./data/config.js";
import { loadConfig, saveConfig, migrateLegacyConfig, isProviderEnabled } from "./data/config.js";
import { createDatabase } from "./data/database.js";
import { createLogger } from "./logger.js";
import { createApiServerManager } from "./music/api-server.js";
import { NeteaseProvider } from "./music/netease.js";
import { QQMusicProvider } from "./music/qq.js";
import { BiliBiliProvider } from "./music/bilibili.js";
import { LocalMusicProvider } from "./music/local.js";
import { KugouProvider } from "./music/kugou.js";
import { JellyfinProvider } from "./music/jellyfin.js";
import { SpotifyProvider } from "./music/spotify/provider.js";
import { SpotifyOAuth, createFileOAuthTokenStore } from "./music/spotify/spotify-oauth.js";
import { createCookieStore } from "./music/auth.js";
import { createAvatarStore } from "./data/avatars.js";
import { createPermissionStore } from "./data/permissions.js";
import { BotManager } from "./bot/manager.js";
import { createWebServer } from "./web/server.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.resolve(__dirname, "..");
const DATA_DIR = path.join(ROOT_DIR, "data");
const CONFIG_PATH = path.join(ROOT_DIR, "config.json");
// config.json lives under the persisted data dir (the Docker volume) alongside the
// DB/cookies/logs, so it survives container restarts and manual edits take effect
// (#86). LEGACY_CONFIG_PATH is the old root-level location we migrate from once.
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
const LEGACY_CONFIG_PATH = path.join(ROOT_DIR, "config.json");
const DB_PATH = path.join(DATA_DIR, "tsmusicbot.db");
const LOG_DIR = path.join(DATA_DIR, "logs");
const COOKIE_DIR = path.join(DATA_DIR, "cookies");
const AVATAR_DIR = path.join(DATA_DIR, "avatars");
const LOCAL_AUDIO_DIR = path.join(DATA_DIR, "local-audio");
const SPOTIFY_DATA_DIR = path.join(DATA_DIR, "spotify");
const STATIC_DIR = path.join(ROOT_DIR, "web", "dist");
async function main() {
// Migrate a pre-#86 root-level config.json into the data dir so existing
// installs keep their settings; no-op if already migrated or none exists.
migrateLegacyConfig(LEGACY_CONFIG_PATH, CONFIG_PATH);
const config = loadConfig(CONFIG_PATH);
saveConfig(CONFIG_PATH, config);
@@ -36,7 +53,14 @@ async function main() {
const db = createDatabase(DB_PATH);
const apiServer = createApiServerManager(
{ neteasePort: config.neteaseApiPort, qqMusicPort: config.qqMusicApiPort },
{
neteasePort: config.neteaseApiPort,
qqMusicPort: config.qqMusicApiPort,
// Provider gating: a sidecar only starts (and binds 3001/3200) when its
// source is listed in enabledProviders — both are on in the default config.
neteaseEnabled: isProviderEnabled(config, "netease"),
qqEnabled: isProviderEnabled(config, "qq"),
},
logger
);
await apiServer.start();
@@ -44,14 +68,64 @@ async function main() {
const neteaseProvider = new NeteaseProvider(apiServer.getNeteaseBaseUrl());
const qqProvider = new QQMusicProvider(apiServer.getQQMusicBaseUrl());
const bilibiliProvider = new BiliBiliProvider();
const localProvider = new LocalMusicProvider(LOCAL_AUDIO_DIR);
const kugouProvider = new KugouProvider();
const spotifyProvider = new SpotifyProvider();
// Safety gate (spec §7): the source is inert unless EXPLICITLY enabled.
// Only feed credentials when enabled — otherwise the provider has no creds,
// hasCreds() is false, search returns empty, and getAuthStatus() is loggedIn:false,
// so setting a Client ID/Secret alone (enabled:false) never activates Spotify.
if (config.spotify.enabled && config.spotify.clientId) {
spotifyProvider.setCreds(config.spotify.clientId, config.spotify.clientSecret);
}
const cookieStore = createCookieStore(COOKIE_DIR);
const avatarStore = createAvatarStore(AVATAR_DIR);
const neteaseCookie = cookieStore.load("netease");
if (neteaseCookie) neteaseProvider.setCookie(neteaseCookie);
const qqCookie = cookieStore.load("qq");
if (qqCookie) qqProvider.setCookie(qqCookie);
const bilibiliCookie = cookieStore.load("bilibili");
if (bilibiliCookie) bilibiliProvider.setCookie(bilibiliCookie);
const kugouCookie = cookieStore.load("kugou");
if (kugouCookie) kugouProvider.setCookie(kugouCookie);
// Jellyfin: admin-configured connection (no QR). The persisted blob carries
// AccessToken + User.Id + DeviceId and lives alongside the other platform
// cookies; a Jellyfin server that is unreachable at boot must not block
// startup — the provider authenticates lazily on first use.
const jellyfinProvider = new JellyfinProvider(logger);
jellyfinProvider.configure(config.jellyfin);
const jellyfinAuth = cookieStore.load("jellyfin");
if (jellyfinAuth) jellyfinProvider.setCookie(jellyfinAuth);
jellyfinProvider.setPersist((serialized) => cookieStore.save("jellyfin", serialized));
// Restore the persisted per-provider audio quality (#125) onto the shared,
// process-wide providers so a restart keeps the user's choice. setQuality()
// normalizes/ignores unknown values, so a stale entry can never break playback.
neteaseProvider.setQuality(config.audioQuality.netease);
qqProvider.setQuality(config.audioQuality.qq);
bilibiliProvider.setQuality(config.audioQuality.bilibili);
kugouProvider.setQuality(config.audioQuality.kugou);
jellyfinProvider.setQuality(config.audioQuality.jellyfin);
const permissions = createPermissionStore(db.db);
// Single process-wide Spotify authorization (one Premium account for Stage 3).
// Threaded into BOTH the web OAuth router and every bot's SpotifyController so
// a web login immediately authorizes playback (C3.1). Own-app clientId => the
// redirect points at this bot's web callback; empty clientId leaves OAuth
// disabled (isAuthorized() stays false and the Rust backend never starts).
const spotifyOAuthClientId = config.spotify.clientId.trim();
const spotifyOAuth = new SpotifyOAuth({
clientId: spotifyOAuthClientId || undefined,
redirectUri: spotifyOAuthClientId
? `http://127.0.0.1:${config.webPort}/api/spotify/callback`
: undefined,
store: createFileOAuthTokenStore(
path.join(SPOTIFY_DATA_DIR, "oauth", "oauth-tokens.json"),
),
});
const botManager = new BotManager(
neteaseProvider,
@@ -59,7 +133,16 @@ async function main() {
bilibiliProvider,
db,
config,
logger
logger,
avatarStore,
permissions,
CONFIG_PATH,
localProvider,
kugouProvider,
spotifyProvider,
SPOTIFY_DATA_DIR,
spotifyOAuth,
jellyfinProvider
);
await botManager.loadSavedBots();
@@ -69,16 +152,26 @@ async function main() {
neteaseProvider,
qqProvider,
bilibiliProvider,
localProvider,
kugouProvider,
spotifyProvider,
jellyfinProvider,
database: db,
avatarStore,
config,
configPath: CONFIG_PATH,
logger,
cookieStore,
staticDir: STATIC_DIR,
spotifyOAuth,
});
await webServer.start();
logger.info({ webPort: config.webPort }, "TSMusicBot started");
logger.info(`WebUI: http://localhost:${config.webPort}`);
const publicUrl = (config.publicUrl ?? "").trim().replace(/\/+$/, "");
logger.info(
`WebUI: ${publicUrl || `http://localhost:${config.webPort}`}`
);
const shutdown = () => {
logger.info("Shutting down...");
Loaded 100 of 245 files, more files were not shown because too many files have changed in this diff. Show more